mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e19e7131d | |||
| 60236da65b | |||
| 7bdae0cbda | |||
| 3692d9c930 | |||
| 2e1a827157 | |||
| fe33871dfe | |||
| f22bd1d7c8 | |||
| 48ff0ad84b | |||
| 4f626c08c2 | |||
| 57e9abd77f | |||
| 0d7ce76c21 | |||
| c8d307c2d4 | |||
| 10c51ef08d | |||
| bb988b551d | |||
| 0e75edf7e3 | |||
| 3523ee4c35 | |||
| c0fba3913c | |||
| 597106cb48 | |||
| 02c29e06fb | |||
| 7226109989 | |||
| 85957c10b8 | |||
| 7250bd3bcb | |||
| 2ee7e0f832 | |||
| c5278a71de | |||
| e41519575f | |||
| 201ccf39a0 | |||
| ac3285a29a | |||
| fdaeb6602d | |||
| da4cd4ebcd | |||
| b6fc8fb4b1 | |||
| 4e6572d686 | |||
| 9e378899ff | |||
| 31dafc4258 | |||
| 6614b23eae | |||
| 9e54fd1bfb | |||
| f0add849f9 | |||
| b55915c257 | |||
| bdac4360b4 | |||
| 72bfbf2060 | |||
| db02b0ae6b | |||
| bb40e4079a | |||
| 198a96c78f | |||
| 1dd835bb87 | |||
| 25c504ceaf | |||
| 9680e57849 | |||
| 0f8ac54bcb | |||
| 936a8b2510 | |||
| b7b5e3edb9 | |||
| 1cea59abe2 | |||
| 8f0211057c | |||
| 2bfef05137 | |||
| d2a99b6872 | |||
| 6c9f265918 | |||
| 7a8d40b9e7 | |||
| 3ddffdda17 | |||
| 91396148ae | |||
| 1c2ea2aa92 | |||
| ba5eb60825 | |||
| a0e363799c | |||
| 3d457890cd | |||
| e857d00e3d | |||
| 98d8435b15 | |||
| b80463665b | |||
| b4ce4a2922 | |||
| 9bee54b07e | |||
| d3c8224839 | |||
| 0a1c614c55 | |||
| db4dad5e37 | |||
| 35ff70bf14 | |||
| 8b5fdba6f4 | |||
| e0a3ad92e0 | |||
| 10f4889737 | |||
| 7f66393e63 | |||
| 033b05f679 | |||
| 8356d44cae | |||
| 030c0fd40e | |||
| 1a02b0d9d7 | |||
| be5f092117 | |||
| 0ba423feb4 |
@@ -108,8 +108,7 @@ jobs:
|
||||
executor: docker-publisher
|
||||
steps:
|
||||
- checkout
|
||||
- setup_remote_docker:
|
||||
version: 20.10.6
|
||||
- setup_remote_docker
|
||||
- run:
|
||||
name: Install Docker buildx
|
||||
command: |
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
RestoreIcon,
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
StarredIcon,
|
||||
@@ -10,11 +12,13 @@ import {
|
||||
UnstarredIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Collection from "~/models/Collection";
|
||||
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
|
||||
import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
@@ -129,9 +133,20 @@ export const searchInCollection = createAction({
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).readDocument,
|
||||
visible: ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
if (!collection?.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stores.policies.abilities(activeCollectionId).readDocument;
|
||||
},
|
||||
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
|
||||
},
|
||||
@@ -190,6 +205,72 @@ export const unstarCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveCollection = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive collection",
|
||||
section: CollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).archive;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
const { dialogs, collections } = stores;
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Archive collection"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await collection.archive();
|
||||
toast.success(t("Collection archived"));
|
||||
}}
|
||||
submitText={t("Archive")}
|
||||
savingText={`${t("Archiving")}…`}
|
||||
>
|
||||
{t(
|
||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreCollection = createAction({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore collection",
|
||||
section: CollectionSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).restore;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
await collection.restore();
|
||||
toast.success(t("Collection restored"));
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
|
||||
@@ -358,8 +358,6 @@ export const shareDocument = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
const share = stores.shares.getByDocumentId(activeDocumentId);
|
||||
const sharedParent = stores.shares.getByDocumentParents(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
@@ -370,8 +368,6 @@ export const shareDocument = createAction({
|
||||
content: (
|
||||
<SharePopover
|
||||
document={document}
|
||||
share={share}
|
||||
sharedParent={sharedParent}
|
||||
onRequestClose={stores.dialogs.closeAllModals}
|
||||
visible
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { DisclosureIcon } from "outline-icons";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
@@ -189,10 +189,14 @@ const Button = <T extends React.ElementType = "button">(
|
||||
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
|
||||
{hasIcon && ic}
|
||||
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}
|
||||
{disclosure && <ExpandedIcon />}
|
||||
{disclosure && <StyledDisclosureIcon />}
|
||||
</Inner>
|
||||
</RealButton>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledDisclosureIcon = styled(DisclosureIcon)`
|
||||
opacity: 0.8;
|
||||
`;
|
||||
|
||||
export default React.forwardRef(Button);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { archivePath, collectionPath } from "~/utils/routeHelpers";
|
||||
import Breadcrumb from "./Breadcrumb";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const collectionNode: MenuInternalLink = {
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
};
|
||||
|
||||
const category: MenuInternalLink | undefined = collection.isArchived
|
||||
? {
|
||||
type: "route",
|
||||
icon: <ArchiveIcon />,
|
||||
title: t("Archive"),
|
||||
to: archivePath(),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const output = [];
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
output.push(collectionNode);
|
||||
|
||||
return output;
|
||||
}, [collection, t]);
|
||||
|
||||
return <Breadcrumb items={items} highlightFirstItem />;
|
||||
};
|
||||
@@ -77,7 +77,10 @@ const SearchInput = styled(KBarSearch)`
|
||||
border: none;
|
||||
background: ${s("menuBackground")};
|
||||
color: ${s("text")};
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
|
||||
@@ -7,6 +7,10 @@ import CommandBarItem from "./CommandBarItem";
|
||||
export default function CommandBarResults() {
|
||||
const { results, rootActionId } = useMatches();
|
||||
|
||||
if (results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<KBarResults
|
||||
|
||||
@@ -21,11 +21,13 @@ function ConfirmMoveDialog({ collection, item, ...rest }: Props) {
|
||||
const { documents, dialogs, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const prevCollection = collections.get(item.collectionId!);
|
||||
const accessMapping = {
|
||||
[CollectionPermission.ReadWrite]: t("view and edit access"),
|
||||
[CollectionPermission.Read]: t("view only access"),
|
||||
null: t("no access"),
|
||||
};
|
||||
const accessMapping: Record<Partial<CollectionPermission> | "null", string> =
|
||||
{
|
||||
[CollectionPermission.Admin]: t("manage access"),
|
||||
[CollectionPermission.ReadWrite]: t("view and edit access"),
|
||||
[CollectionPermission.Read]: t("view only access"),
|
||||
null: t("no access"),
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await documents.move({
|
||||
|
||||
@@ -35,7 +35,7 @@ function ConnectionStatus() {
|
||||
};
|
||||
|
||||
const message = ui.multiplayerErrorCode
|
||||
? codeToMessage[ui.multiplayerErrorCode]
|
||||
? codeToMessage[ui.multiplayerErrorCode as keyof typeof codeToMessage]
|
||||
: undefined;
|
||||
|
||||
return ui.multiplayerStatus === "connecting" ||
|
||||
|
||||
@@ -11,6 +11,9 @@ class DocumentContext {
|
||||
/** The editor instance for this document */
|
||||
editor?: Editor;
|
||||
|
||||
@observable
|
||||
isEditorInitialized: boolean = false;
|
||||
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
@@ -31,6 +34,11 @@ class DocumentContext {
|
||||
this.updateState();
|
||||
};
|
||||
|
||||
@action
|
||||
setEditorInitialized = (initialized: boolean) => {
|
||||
this.isEditorInitialized = initialized;
|
||||
};
|
||||
|
||||
@action
|
||||
updateState = () => {
|
||||
this.updateHeadings();
|
||||
|
||||
@@ -125,6 +125,7 @@ function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
|
||||
avatarUrl={data.avatarUrl}
|
||||
color={data.color}
|
||||
lastActive={data.lastActive}
|
||||
email={data.email}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
|
||||
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
|
||||
|
||||
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
{ avatarUrl, name, lastActive, color }: Props,
|
||||
{ avatarUrl, name, lastActive, color, email }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
return (
|
||||
@@ -25,6 +25,7 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
|
||||
/>
|
||||
<Flex column gap={2} justify="center">
|
||||
<Title>{name}</Title>
|
||||
{email && <Info>{email}</Info>}
|
||||
<Info>{lastActive}</Info>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -71,7 +71,7 @@ const GridTemplate = (
|
||||
<IconButton
|
||||
key={item.name}
|
||||
onClick={() => onIconSelect({ id: item.name, value: item.name })}
|
||||
delay={item.delay}
|
||||
style={{ "--delay": `${item.delay}ms` } as React.CSSProperties}
|
||||
>
|
||||
<Icon as={IconLibrary.getComponent(item.name)} color={item.color}>
|
||||
{item.initial}
|
||||
|
||||
@@ -7,7 +7,6 @@ export const IconButton = styled(NudeButton)<{ delay?: number }>`
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
--delay: ${({ delay }) => delay && `${delay}ms`};
|
||||
|
||||
&: ${hover} {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
|
||||
@@ -82,6 +82,7 @@ const IconPicker = ({
|
||||
modal: true,
|
||||
unstable_offset: [0, 0],
|
||||
});
|
||||
const { hide, show, visible } = popover;
|
||||
const tab = useTabState({ selectedId: defaultTab });
|
||||
const previouslyVisible = usePrevious(popover.visible);
|
||||
|
||||
@@ -96,12 +97,12 @@ const IconPicker = ({
|
||||
|
||||
const handleIconChange = React.useCallback(
|
||||
(ic: string) => {
|
||||
popover.hide();
|
||||
hide();
|
||||
const icType = determineIconType(ic);
|
||||
const finalColor = icType === IconType.SVG ? chosenColor : null;
|
||||
onChange(ic, finalColor);
|
||||
},
|
||||
[popover, onChange, chosenColor]
|
||||
[hide, onChange, chosenColor]
|
||||
);
|
||||
|
||||
const handleIconColorChange = React.useCallback(
|
||||
@@ -118,32 +119,32 @@ const IconPicker = ({
|
||||
);
|
||||
|
||||
const handleIconRemove = React.useCallback(() => {
|
||||
popover.hide();
|
||||
hide();
|
||||
onChange(null, null);
|
||||
}, [popover, onChange]);
|
||||
}, [hide, onChange]);
|
||||
|
||||
const handlePopoverButtonClick = React.useCallback(
|
||||
(ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (popover.visible) {
|
||||
popover.hide();
|
||||
if (visible) {
|
||||
hide();
|
||||
} else {
|
||||
popover.show();
|
||||
show();
|
||||
}
|
||||
},
|
||||
[popover]
|
||||
[hide, show, visible]
|
||||
);
|
||||
|
||||
// Popover open effect
|
||||
React.useEffect(() => {
|
||||
if (popover.visible && !previouslyVisible) {
|
||||
if (visible && !previouslyVisible) {
|
||||
onOpen?.();
|
||||
} else if (!popover.visible && previouslyVisible) {
|
||||
} else if (!visible && previouslyVisible) {
|
||||
onClose?.();
|
||||
setQuery("");
|
||||
resetDefaultTab();
|
||||
}
|
||||
}, [popover.visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
|
||||
}, [visible, previouslyVisible, onOpen, onClose, resetDefaultTab]);
|
||||
|
||||
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
|
||||
// prevent event bubbling.
|
||||
@@ -198,7 +199,7 @@ const IconPicker = ({
|
||||
{...tab}
|
||||
id={TAB_NAMES["Icon"]}
|
||||
aria-label={t("Icons")}
|
||||
active={tab.selectedId === TAB_NAMES["Icon"]}
|
||||
$active={tab.selectedId === TAB_NAMES["Icon"]}
|
||||
>
|
||||
{t("Icons")}
|
||||
</StyledTab>
|
||||
@@ -206,7 +207,7 @@ const IconPicker = ({
|
||||
{...tab}
|
||||
id={TAB_NAMES["Emoji"]}
|
||||
aria-label={t("Emojis")}
|
||||
active={tab.selectedId === TAB_NAMES["Emoji"]}
|
||||
$active={tab.selectedId === TAB_NAMES["Emoji"]}
|
||||
>
|
||||
{t("Emojis")}
|
||||
</StyledTab>
|
||||
@@ -273,7 +274,7 @@ const TabActionsWrapper = styled(Flex)`
|
||||
border-bottom: 1px solid ${s("inputBorder")};
|
||||
`;
|
||||
|
||||
const StyledTab = styled(Tab)<{ active: boolean }>`
|
||||
const StyledTab = styled(Tab)<{ $active: boolean }>`
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
@@ -282,15 +283,15 @@ const StyledTab = styled(Tab)<{ active: boolean }>`
|
||||
border: 0;
|
||||
padding: 8px 12px;
|
||||
user-select: none;
|
||||
color: ${({ active }) => (active ? s("textSecondary") : s("textTertiary"))};
|
||||
color: ${({ $active }) => ($active ? s("textSecondary") : s("textTertiary"))};
|
||||
transition: color 100ms ease-in-out;
|
||||
|
||||
&: ${hover} {
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
|
||||
${({ active }) =>
|
||||
active &&
|
||||
${({ $active }) =>
|
||||
$active &&
|
||||
css`
|
||||
&:after {
|
||||
content: "";
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Button, { Props as ButtonProps, Inner } from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -33,7 +33,7 @@ export type Option = {
|
||||
divider?: boolean;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
export type Props = Omit<ButtonProps<any>, "onChange"> & {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string | null;
|
||||
@@ -313,7 +313,7 @@ const StyledButton = styled(Button)<{ $nude?: boolean }>`
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
cursor: var(--pointer);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${s("buttonNeutralBackground")};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { $Diff } from "utility-types";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import InputSelect, { Props, Option, InputSelectRef } from "./InputSelect";
|
||||
@@ -19,7 +21,7 @@ function InputSelectPermission(
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
<Select
|
||||
ref={ref}
|
||||
label={t("Permission")}
|
||||
options={[
|
||||
@@ -45,4 +47,8 @@ function InputSelectPermission(
|
||||
);
|
||||
}
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default React.forwardRef(InputSelectPermission);
|
||||
|
||||
@@ -39,12 +39,15 @@ const LocaleTime: React.FC<Props> = ({
|
||||
relative,
|
||||
tooltipDelay,
|
||||
}: Props) => {
|
||||
const userLocale: string = useUserLocale() || "";
|
||||
const dateFormatLong = {
|
||||
const userLocale = useUserLocale();
|
||||
const dateFormatLong: Record<string, string> = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
|
||||
};
|
||||
const formatLocaleLong = dateFormatLong[userLocale] ?? "MMMM do, yyyy h:mm a";
|
||||
const formatLocaleLong =
|
||||
(userLocale ? dateFormatLong[userLocale] : undefined) ??
|
||||
"MMMM do, yyyy h:mm a";
|
||||
// @ts-expect-error fallback to formatLocaleLong
|
||||
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const callback = React.useRef<() => void>();
|
||||
|
||||
@@ -24,13 +24,15 @@ import NotificationListItem from "./NotificationListItem";
|
||||
type Props = {
|
||||
/** Callback when the notification panel wants to close. */
|
||||
onRequestClose: () => void;
|
||||
/** Whether the panel is open or not. */
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A panel containing a list of notifications and controls to manage them.
|
||||
*/
|
||||
function Notifications(
|
||||
{ onRequestClose }: Props,
|
||||
{ onRequestClose, isOpen }: Props,
|
||||
ref: React.RefObject<HTMLDivElement>
|
||||
) {
|
||||
const context = useActionContext();
|
||||
@@ -72,7 +74,7 @@ function Notifications(
|
||||
<PaginatedList
|
||||
fetch={notifications.fetchPage}
|
||||
options={{ archived: false }}
|
||||
items={notifications.orderedData}
|
||||
items={isOpen ? notifications.orderedData : undefined}
|
||||
renderItem={(item: Notification) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
|
||||
@@ -40,7 +40,11 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
shrink
|
||||
flex
|
||||
>
|
||||
<Notifications onRequestClose={popover.hide} ref={scrollableRef} />
|
||||
<Notifications
|
||||
onRequestClose={popover.hide}
|
||||
isOpen={popover.visible}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
</StyledPopover>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,8 @@ export interface PaginatedItem {
|
||||
}
|
||||
|
||||
type Props<T> = WithTranslation &
|
||||
RootStore & {
|
||||
RootStore &
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
fetch?: (
|
||||
options: Record<string, any> | undefined
|
||||
) => Promise<T[] | undefined> | undefined;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import invariant from "invariant";
|
||||
import debounce from "lodash/debounce";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
@@ -17,7 +16,6 @@ import Input, { NativeInput } from "~/components/Input";
|
||||
import Switch from "~/components/Switch";
|
||||
import env from "~/env";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { AvatarSize } from "../../Avatar";
|
||||
import CopyToClipboard from "../../CopyToClipboard";
|
||||
import NudeButton from "../../NudeButton";
|
||||
@@ -39,7 +37,6 @@ type Props = {
|
||||
};
|
||||
|
||||
function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
const { shares } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [validationError, setValidationError] = React.useState("");
|
||||
@@ -55,18 +52,15 @@ function PublicAccess({ document, share, sharedParent }: Props) {
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (event) => {
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
invariant(share, "Share must exist");
|
||||
|
||||
try {
|
||||
await share.save({
|
||||
await share?.save({
|
||||
published: event.currentTarget.checked,
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[document.id, shares]
|
||||
[share]
|
||||
);
|
||||
|
||||
const handleUrlChange = React.useMemo(
|
||||
|
||||
@@ -8,7 +8,6 @@ import { toast } from "sonner";
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Group from "~/models/Group";
|
||||
import Share from "~/models/Share";
|
||||
import User from "~/models/User";
|
||||
import { Avatar, GroupAvatar, AvatarSize } from "~/components/Avatar";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -32,26 +31,19 @@ import { AccessControlList } from "./AccessControlList";
|
||||
type Props = {
|
||||
/** The document to share. */
|
||||
document: Document;
|
||||
/** The existing share model, if any. */
|
||||
share: Share | null | undefined;
|
||||
/** The existing share parent model, if any. */
|
||||
sharedParent: Share | null | undefined;
|
||||
/** Callback fired when the popover requests to be closed. */
|
||||
onRequestClose: () => void;
|
||||
/** Whether the popover is visible. */
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
function SharePopover({
|
||||
document,
|
||||
share,
|
||||
sharedParent,
|
||||
onRequestClose,
|
||||
visible,
|
||||
}: Props) {
|
||||
function SharePopover({ document, onRequestClose, visible }: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(document);
|
||||
const { shares } = useStores();
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const sharedParent = shares.getByDocumentParents(document.id);
|
||||
const [hasRendered, setHasRendered] = React.useState(visible);
|
||||
const { users, userMemberships, groups, groupMemberships } = useStores();
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
@@ -133,16 +133,16 @@ function AppSidebar() {
|
||||
<Section>
|
||||
<SharedWithMe />
|
||||
</Section>
|
||||
<Section auto>
|
||||
<Section>
|
||||
<Collections />
|
||||
</Section>
|
||||
{can.createDocument && (
|
||||
<Section auto>
|
||||
<ArchiveLink />
|
||||
</Section>
|
||||
)}
|
||||
<Section>
|
||||
{can.createDocument && (
|
||||
<>
|
||||
<ArchiveLink />
|
||||
<TrashLink />
|
||||
</>
|
||||
)}
|
||||
{can.createDocument && <TrashLink />}
|
||||
<SidebarAction action={inviteUser} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
|
||||
@@ -217,15 +217,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NotificationsPopover>
|
||||
{(rest: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...rest}
|
||||
position="bottom"
|
||||
image={<NotificationIcon />}
|
||||
/>
|
||||
)}
|
||||
</NotificationsPopover>
|
||||
<Notifications />
|
||||
</SidebarButton>
|
||||
)}
|
||||
</AccountMenu>
|
||||
@@ -240,6 +232,14 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
);
|
||||
});
|
||||
|
||||
const Notifications = () => (
|
||||
<NotificationsPopover>
|
||||
{(rest: SidebarButtonProps) => (
|
||||
<SidebarButton {...rest} position="bottom" image={<NotificationIcon />} />
|
||||
)}
|
||||
</NotificationsPopover>
|
||||
);
|
||||
|
||||
const Backdrop = styled.a`
|
||||
animation: ${fadeIn} 250ms ease-in-out;
|
||||
position: fixed;
|
||||
|
||||
@@ -1,41 +1,101 @@
|
||||
import isUndefined from "lodash/isUndefined";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Collection from "~/models/Collection";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { archivePath } from "~/utils/routeHelpers";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import { useDropToArchive } from "../hooks/useDragAndDrop";
|
||||
import { ArchivedCollectionLink } from "./ArchivedCollectionLink";
|
||||
import { StyledError } from "./Collections";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
function ArchiveLink() {
|
||||
const { policies, documents } = useStores();
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [{ isDocumentDropping }, dropToArchiveDocument] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject) => {
|
||||
const document = documents.get(item.id);
|
||||
await document?.archive();
|
||||
toast.success(t("Document archived"));
|
||||
},
|
||||
canDrop: (item) => policies.abilities(item.id).archive,
|
||||
collect: (monitor) => ({
|
||||
isDocumentDropping: monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
const [disclosure, setDisclosure] = React.useState<boolean>(false);
|
||||
const [expanded, setExpanded] = React.useState<boolean | undefined>();
|
||||
|
||||
const { request, data, loading, error } = useRequest(
|
||||
collections.fetchArchived,
|
||||
true
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isUndefined(data) && !loading && isUndefined(error)) {
|
||||
setDisclosure(data.length > 0);
|
||||
}
|
||||
}, [data, loading, error]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setDisclosure(collections.archived.length > 0);
|
||||
}, [collections.archived]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (disclosure && isUndefined(expanded)) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [disclosure]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (expanded) {
|
||||
void request();
|
||||
}
|
||||
}, [expanded, request]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, []);
|
||||
|
||||
const [{ isOverArchiveSection, isDragging }, dropToArchiveRef] =
|
||||
useDropToArchive();
|
||||
|
||||
return (
|
||||
<div ref={dropToArchiveDocument}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isDocumentDropping} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
active={documents.active?.isArchived && !documents.active?.isDeleted}
|
||||
isActiveDrop={isDocumentDropping}
|
||||
/>
|
||||
</div>
|
||||
<Flex column>
|
||||
<div ref={dropToArchiveRef}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
isActiveDrop={isOverArchiveSection && isDragging}
|
||||
depth={0}
|
||||
expanded={disclosure ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</div>
|
||||
{expanded === true ? (
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
aria-label={t("Archived collections")}
|
||||
items={collections.archived}
|
||||
loading={<PlaceholderCollections />}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection) => (
|
||||
<ArchivedCollectionLink
|
||||
key={item.id}
|
||||
depth={1}
|
||||
collection={item}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Relative>
|
||||
) : null}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import Collection from "~/models/Collection";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import Relative from "./Relative";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
export function ArchivedCollectionLink({ collection, depth }: Props) {
|
||||
const { documents } = useStores();
|
||||
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollectionLink
|
||||
depth={depth ? depth : 0}
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
activeDocument={documents.active}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
/>
|
||||
</Relative>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
@@ -30,6 +29,8 @@ type Props = {
|
||||
onDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
activeDocument: Document | undefined;
|
||||
isDraggingAnyCollection?: boolean;
|
||||
depth?: number;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const CollectionLink: React.FC<Props> = ({
|
||||
@@ -37,13 +38,14 @@ const CollectionLink: React.FC<Props> = ({
|
||||
expanded,
|
||||
onDisclosureClick,
|
||||
isDraggingAnyCollection,
|
||||
depth,
|
||||
onClick,
|
||||
}: Props) => {
|
||||
const { dialogs, documents, collections } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const can = usePolicy(collection);
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const sidebarContext = useSidebarContext();
|
||||
const editableTitleRef = React.useRef<RefHandle>(null);
|
||||
|
||||
@@ -52,9 +54,8 @@ const CollectionLink: React.FC<Props> = ({
|
||||
await collection.save({
|
||||
name,
|
||||
});
|
||||
history.replace(collection.path, history.location.state);
|
||||
},
|
||||
[collection, history]
|
||||
[collection]
|
||||
);
|
||||
|
||||
// Drop to re-parent document
|
||||
@@ -111,10 +112,15 @@ const CollectionLink: React.FC<Props> = ({
|
||||
sidebarContext,
|
||||
});
|
||||
|
||||
const handleRename = React.useCallback(() => {
|
||||
editableTitleRef.current?.setIsEditing(true);
|
||||
}, [editableTitleRef]);
|
||||
|
||||
return (
|
||||
<Relative ref={drop}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
onClick={onClick}
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
@@ -140,7 +146,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={0}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
@@ -155,7 +161,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
</NudeButton>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
onRename={() => editableTitleRef.current?.setIsEditing(true)}
|
||||
onRename={handleRename}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
/>
|
||||
|
||||
@@ -55,7 +55,7 @@ function Collections() {
|
||||
<PaginatedList
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.orderedData}
|
||||
items={collections.allActive}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
@@ -84,7 +84,7 @@ function Collections() {
|
||||
);
|
||||
}
|
||||
|
||||
const StyledError = styled(Error)`
|
||||
export const StyledError = styled(Error)`
|
||||
font-size: 15px;
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
@@ -41,16 +41,6 @@ function EditableTitle(
|
||||
setIsEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsEditing(false);
|
||||
setValue(originalValue);
|
||||
}
|
||||
},
|
||||
[originalValue]
|
||||
);
|
||||
|
||||
const stopPropagation = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -63,6 +53,7 @@ function EditableTitle(
|
||||
const handleSave = React.useCallback(
|
||||
async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setIsEditing(false);
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
@@ -85,6 +76,22 @@ function EditableTitle(
|
||||
[originalValue, value, onSubmit]
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
async (ev) => {
|
||||
if (ev.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Escape") {
|
||||
setIsEditing(false);
|
||||
setValue(originalValue);
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
await handleSave(ev);
|
||||
}
|
||||
},
|
||||
[handleSave, originalValue]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
onEditing?.(isEditing);
|
||||
}, [onEditing, isEditing]);
|
||||
|
||||
@@ -2,41 +2,29 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Badge from "~/components/Badge";
|
||||
import { version } from "../../../../package.json";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { version as currentVersion } from "../../../../package.json";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
export default function Version() {
|
||||
const [releasesBehind, setReleasesBehind] = React.useState(-1);
|
||||
const [versionsBehind, setVersionsBehind] = React.useState(-1);
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function loadReleases() {
|
||||
const res = await fetch(
|
||||
"https://api.github.com/repos/outline/outline/releases"
|
||||
);
|
||||
const releases = await res.json();
|
||||
|
||||
if (Array.isArray(releases)) {
|
||||
const everyNewRelease = releases
|
||||
.map((release) => release.tag_name)
|
||||
.findIndex((tagName) => tagName === `v${version}`);
|
||||
|
||||
const onlyFullNewRelease = releases
|
||||
.filter((release) => !release.prerelease)
|
||||
.map((release) => release.tag_name)
|
||||
.findIndex((tagName) => tagName === `v${version}`);
|
||||
|
||||
const computedReleasesBehind = version.includes("pre")
|
||||
? everyNewRelease
|
||||
: onlyFullNewRelease;
|
||||
|
||||
if (computedReleasesBehind >= 0) {
|
||||
setReleasesBehind(computedReleasesBehind);
|
||||
async function loadVersionInfo() {
|
||||
try {
|
||||
// Fetch version info from the server-side proxy
|
||||
const res = await client.post("/installation.info");
|
||||
if (res.data && res.data.versionsBehind >= 0) {
|
||||
setVersionsBehind(res.data.versionsBehind);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Failed to load version info", error);
|
||||
}
|
||||
}
|
||||
|
||||
void loadReleases();
|
||||
void loadVersionInfo();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -45,16 +33,16 @@ export default function Version() {
|
||||
href="https://github.com/outline/outline/releases"
|
||||
label={
|
||||
<>
|
||||
v{version}
|
||||
{releasesBehind >= 0 && (
|
||||
v{currentVersion}
|
||||
{versionsBehind >= 0 && (
|
||||
<>
|
||||
<br />
|
||||
<LilBadge>
|
||||
{releasesBehind === 0
|
||||
{versionsBehind === 0
|
||||
? t("Up to date")
|
||||
: t(`{{ releasesBehind }} versions behind`, {
|
||||
releasesBehind,
|
||||
count: releasesBehind,
|
||||
releasesBehind: versionsBehind,
|
||||
count: versionsBehind,
|
||||
})}
|
||||
</LilBadge>
|
||||
</>
|
||||
|
||||
@@ -149,6 +149,7 @@ export function useDragDocument(
|
||||
icon: icon ? <Icon value={icon} color={color} /> : undefined,
|
||||
collectionId: document?.collectionId || "",
|
||||
} as DragObject),
|
||||
canDrag: () => !!document?.isActive,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
@@ -245,6 +246,7 @@ export function useDropToReparentDocument(
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem().id) &&
|
||||
item.id !== node.id &&
|
||||
!!document?.isActive &&
|
||||
policies.abilities(node.id).update &&
|
||||
policies.abilities(item.id).move,
|
||||
hover: (_item, monitor) => {
|
||||
@@ -297,6 +299,8 @@ export function useDropToReorderDocument(
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
|
||||
const document = documents.get(node.id);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
@@ -304,7 +308,11 @@ export function useDropToReorderDocument(
|
||||
>({
|
||||
accept: "document",
|
||||
canDrop: (item: DragObject) => {
|
||||
if (item.id === node.id || !policies.abilities(item.id)?.move) {
|
||||
if (
|
||||
item.id === node.id ||
|
||||
!policies.abilities(item.id)?.move ||
|
||||
!document?.isActive
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -427,3 +435,44 @@ export function useDropToReorderUserMembership(getIndex?: () => string) {
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents and collections onto archive section
|
||||
*/
|
||||
export function useDropToArchive() {
|
||||
const accept = ["document", "collection"];
|
||||
const { documents, collections, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOverArchiveSection: boolean; isDragging: boolean }
|
||||
>({
|
||||
accept,
|
||||
drop: async (item, monitor) => {
|
||||
const type = monitor.getItemType();
|
||||
let model;
|
||||
|
||||
if (type === "collection") {
|
||||
model = collections.get(item.id);
|
||||
} else {
|
||||
model = documents.get(item.id);
|
||||
}
|
||||
|
||||
if (model) {
|
||||
await model.archive();
|
||||
toast.success(
|
||||
type === "collection"
|
||||
? t("Collection archived")
|
||||
: t("Document archived")
|
||||
);
|
||||
}
|
||||
},
|
||||
canDrop: (item) => policies.abilities(item.id).archive,
|
||||
collect: (monitor) => ({
|
||||
isOverArchiveSection: !!monitor.isOver(),
|
||||
isDragging: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import User from "~/models/User";
|
||||
import UserMembership from "~/models/UserMembership";
|
||||
import withStores from "~/components/withStores";
|
||||
import {
|
||||
PartialWithId,
|
||||
PartialExcept,
|
||||
WebsocketCollectionUpdateIndexEvent,
|
||||
WebsocketEntitiesEvent,
|
||||
WebsocketEntityDeletedEvent,
|
||||
@@ -214,23 +214,20 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"documents.update",
|
||||
action(
|
||||
(event: PartialWithId<Document> & { title: string; url: string }) => {
|
||||
documents.add(event);
|
||||
action((event: PartialExcept<Document, "id" | "title" | "url">) => {
|
||||
documents.add(event);
|
||||
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.updateDocument(event);
|
||||
}
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.updateDocument(event);
|
||||
}
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.archive",
|
||||
action((event: PartialWithId<Document>) => {
|
||||
documents.add(event);
|
||||
policies.remove(event.id);
|
||||
action((event: PartialExcept<Document, "id">) => {
|
||||
documents.addToArchive(event as Document);
|
||||
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
@@ -241,7 +238,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"documents.delete",
|
||||
action((event: PartialWithId<Document>) => {
|
||||
action((event: PartialExcept<Document, "id">) => {
|
||||
documents.add(event);
|
||||
policies.remove(event.id);
|
||||
|
||||
@@ -265,7 +262,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"documents.add_user",
|
||||
async (event: PartialWithId<UserMembership>) => {
|
||||
async (event: PartialExcept<UserMembership, "id">) => {
|
||||
userMemberships.add(event);
|
||||
|
||||
// Any existing child policies are now invalid
|
||||
@@ -286,7 +283,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"documents.remove_user",
|
||||
(event: PartialWithId<UserMembership>) => {
|
||||
(event: PartialExcept<UserMembership, "id">) => {
|
||||
userMemberships.remove(event.id);
|
||||
|
||||
// Any existing child policies are now invalid
|
||||
@@ -308,7 +305,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"documents.add_group",
|
||||
(event: PartialWithId<GroupMembership>) => {
|
||||
(event: PartialExcept<GroupMembership, "id">) => {
|
||||
groupMemberships.add(event);
|
||||
|
||||
const group = groups.get(event.groupId!);
|
||||
@@ -330,16 +327,23 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"documents.remove_group",
|
||||
(event: PartialWithId<GroupMembership>) => {
|
||||
(event: PartialExcept<GroupMembership, "id">) => {
|
||||
groupMemberships.remove(event.id);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("comments.create", (event: PartialWithId<Comment>) => {
|
||||
this.socket.on("comments.create", (event: PartialExcept<Comment, "id">) => {
|
||||
comments.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("comments.update", (event: PartialWithId<Comment>) => {
|
||||
this.socket.on("comments.update", (event: PartialExcept<Comment, "id">) => {
|
||||
const comment = comments.get(event.id);
|
||||
|
||||
// Existing policy becomes invalid when the resolution status has changed and we don't have the latest version.
|
||||
if (comment?.resolvedAt !== event.resolvedAt) {
|
||||
policies.remove(event.id);
|
||||
}
|
||||
|
||||
comments.add(event);
|
||||
});
|
||||
|
||||
@@ -347,11 +351,11 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
comments.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
|
||||
this.socket.on("groups.create", (event: PartialExcept<Group, "id">) => {
|
||||
groups.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("groups.update", (event: PartialWithId<Group>) => {
|
||||
this.socket.on("groups.update", (event: PartialExcept<Group, "id">) => {
|
||||
groups.add(event);
|
||||
});
|
||||
|
||||
@@ -359,24 +363,36 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
groups.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("groups.add_user", (event: PartialWithId<GroupUser>) => {
|
||||
groupUsers.add(event);
|
||||
});
|
||||
this.socket.on(
|
||||
"groups.add_user",
|
||||
(event: PartialExcept<GroupUser, "id">) => {
|
||||
groupUsers.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("groups.remove_user", (event: PartialWithId<GroupUser>) => {
|
||||
groupUsers.removeAll({
|
||||
groupId: event.groupId,
|
||||
userId: event.userId,
|
||||
});
|
||||
});
|
||||
this.socket.on(
|
||||
"groups.remove_user",
|
||||
(event: PartialExcept<GroupUser, "id">) => {
|
||||
groupUsers.removeAll({
|
||||
groupId: event.groupId,
|
||||
userId: event.userId,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("collections.create", (event: PartialWithId<Collection>) => {
|
||||
collections.add(event);
|
||||
});
|
||||
this.socket.on(
|
||||
"collections.create",
|
||||
(event: PartialExcept<Collection, "id">) => {
|
||||
collections.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("collections.update", (event: PartialWithId<Collection>) => {
|
||||
collections.add(event);
|
||||
});
|
||||
this.socket.on(
|
||||
"collections.update",
|
||||
(event: PartialExcept<Collection, "id">) => {
|
||||
collections.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"collections.delete",
|
||||
@@ -398,7 +414,49 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
|
||||
this.socket.on(
|
||||
"collections.archive",
|
||||
async (event: PartialExcept<Collection, "id">) => {
|
||||
const collectionId = event.id;
|
||||
|
||||
// Fetch collection to update policies
|
||||
await collections.fetch(collectionId, { force: true });
|
||||
|
||||
documents.unarchivedInCollection(collectionId).forEach(
|
||||
action((doc) => {
|
||||
if (!doc.publishedAt) {
|
||||
// draft is to be detached from collection, not archived
|
||||
doc.collectionId = null;
|
||||
} else {
|
||||
doc.archivedAt = event.archivedAt as string;
|
||||
}
|
||||
policies.remove(doc.id);
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"collections.restore",
|
||||
async (event: PartialExcept<Collection, "id">) => {
|
||||
const collectionId = event.id;
|
||||
documents
|
||||
.archivedInCollection(collectionId, {
|
||||
archivedAt: event.archivedAt as string,
|
||||
})
|
||||
.forEach(
|
||||
action((doc) => {
|
||||
doc.archivedAt = null;
|
||||
policies.remove(doc.id);
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch collection to update policies
|
||||
await collections.fetch(collectionId, { force: true });
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("teams.update", (event: PartialExcept<Team, "id">) => {
|
||||
if ("sharing" in event && event.sharing !== auth.team?.sharing) {
|
||||
documents.all.forEach((document) => {
|
||||
policies.remove(document.id);
|
||||
@@ -410,23 +468,23 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"notifications.create",
|
||||
(event: PartialWithId<Notification>) => {
|
||||
(event: PartialExcept<Notification, "id">) => {
|
||||
notifications.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"notifications.update",
|
||||
(event: PartialWithId<Notification>) => {
|
||||
(event: PartialExcept<Notification, "id">) => {
|
||||
notifications.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
|
||||
this.socket.on("pins.create", (event: PartialExcept<Pin, "id">) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.update", (event: PartialWithId<Pin>) => {
|
||||
this.socket.on("pins.update", (event: PartialExcept<Pin, "id">) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
@@ -434,11 +492,11 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
pins.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("stars.create", (event: PartialWithId<Star>) => {
|
||||
this.socket.on("stars.create", (event: PartialExcept<Star, "id">) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("stars.update", (event: PartialWithId<Star>) => {
|
||||
this.socket.on("stars.update", (event: PartialExcept<Star, "id">) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
@@ -496,14 +554,14 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"fileOperations.create",
|
||||
(event: PartialWithId<FileOperation>) => {
|
||||
(event: PartialExcept<FileOperation, "id">) => {
|
||||
fileOperations.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"fileOperations.update",
|
||||
(event: PartialWithId<FileOperation>) => {
|
||||
(event: PartialExcept<FileOperation, "id">) => {
|
||||
fileOperations.add(event);
|
||||
|
||||
if (
|
||||
@@ -520,7 +578,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"subscriptions.create",
|
||||
(event: PartialWithId<Subscription>) => {
|
||||
(event: PartialExcept<Subscription, "id">) => {
|
||||
subscriptions.add(event);
|
||||
}
|
||||
);
|
||||
@@ -532,11 +590,11 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("users.update", (event: PartialWithId<User>) => {
|
||||
this.socket.on("users.update", (event: PartialExcept<User, "id">) => {
|
||||
users.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("users.demote", async (event: PartialWithId<User>) => {
|
||||
this.socket.on("users.demote", async (event: PartialExcept<User, "id">) => {
|
||||
if (event.id === auth.user?.id) {
|
||||
documents.all.forEach((document) => policies.remove(document.id));
|
||||
await collections.fetchAll();
|
||||
@@ -545,7 +603,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
this.socket.on(
|
||||
"userMemberships.update",
|
||||
async (event: PartialWithId<UserMembership>) => {
|
||||
async (event: PartialExcept<UserMembership, "id">) => {
|
||||
userMemberships.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
import { Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import { EditorView, Decoration } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { ThemeProvider } from "styled-components";
|
||||
import { FunctionComponent } from "react";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { ComponentProps } from "@shared/editor/types";
|
||||
import { Editor } from "~/editor";
|
||||
import { NodeViewRenderer } from "./NodeViewRenderer";
|
||||
|
||||
type Component = (props: ComponentProps) => React.ReactElement;
|
||||
type ComponentViewConstructor = {
|
||||
/** The editor instance. */
|
||||
editor: Editor;
|
||||
/** The extension the view belongs to. */
|
||||
extension: Extension;
|
||||
/** The node that the view is responsible for. */
|
||||
node: ProsemirrorNode;
|
||||
/** The editor view instance. */
|
||||
view: EditorView;
|
||||
/** A function that returns the current position of the node. */
|
||||
getPos: () => number;
|
||||
/** The decorations applied to the node. */
|
||||
decorations: Decoration[];
|
||||
};
|
||||
|
||||
export default class ComponentView {
|
||||
component: Component;
|
||||
/** The React component to render. */
|
||||
component: FunctionComponent<ComponentProps>;
|
||||
/** The editor instance. */
|
||||
editor: Editor;
|
||||
/** The extension the view belongs to. */
|
||||
extension: Extension;
|
||||
/** The node that the view is responsible for. */
|
||||
node: ProsemirrorNode;
|
||||
/** The editor view instance. */
|
||||
view: EditorView;
|
||||
/** A function that returns the current position of the node. */
|
||||
getPos: () => number;
|
||||
/** The decorations applied to the node. */
|
||||
decorations: Decoration[];
|
||||
|
||||
/** The renderer instance. */
|
||||
renderer: NodeViewRenderer<ComponentProps>;
|
||||
/** Whether the node is selected. */
|
||||
isSelected = false;
|
||||
/** The DOM element that the node is rendered into. */
|
||||
dom: HTMLElement | null;
|
||||
|
||||
// See https://prosemirror.net/docs/ref/#view.NodeView
|
||||
constructor(
|
||||
component: Component,
|
||||
component: FunctionComponent<ComponentProps>,
|
||||
{
|
||||
editor,
|
||||
extension,
|
||||
@@ -31,14 +53,7 @@ export default class ComponentView {
|
||||
view,
|
||||
getPos,
|
||||
decorations,
|
||||
}: {
|
||||
editor: Editor;
|
||||
extension: Extension;
|
||||
node: ProsemirrorNode;
|
||||
view: EditorView;
|
||||
getPos: () => number;
|
||||
decorations: Decoration[];
|
||||
}
|
||||
}: ComponentViewConstructor
|
||||
) {
|
||||
this.component = component;
|
||||
this.editor = editor;
|
||||
@@ -52,51 +67,33 @@ export default class ComponentView {
|
||||
: document.createElement("div");
|
||||
|
||||
this.dom.classList.add(`component-${node.type.name}`);
|
||||
this.renderer = new NodeViewRenderer(this.dom, this.component, this.props);
|
||||
|
||||
this.renderElement();
|
||||
window.addEventListener("theme-changed", this.renderElement);
|
||||
window.addEventListener("location-changed", this.renderElement);
|
||||
// Add the renderer to the editor's set of renderers so that it is included in the React tree.
|
||||
this.editor.renderers.add(this.renderer);
|
||||
}
|
||||
|
||||
renderElement = () => {
|
||||
const { theme } = this.editor.props;
|
||||
|
||||
const children = this.component({
|
||||
theme,
|
||||
node: this.node,
|
||||
view: this.view,
|
||||
isSelected: this.isSelected,
|
||||
isEditable: this.view.editable,
|
||||
getPos: this.getPos,
|
||||
});
|
||||
|
||||
ReactDOM.render(
|
||||
<ThemeProvider theme={theme}>{children}</ThemeProvider>,
|
||||
this.dom
|
||||
);
|
||||
};
|
||||
|
||||
update(node: ProsemirrorNode) {
|
||||
if (node.type !== this.node.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.node = node;
|
||||
this.renderElement();
|
||||
this.renderer.updateProps(this.props);
|
||||
return true;
|
||||
}
|
||||
|
||||
selectNode() {
|
||||
if (this.view.editable) {
|
||||
this.isSelected = true;
|
||||
this.renderElement();
|
||||
this.renderer.updateProps(this.props);
|
||||
}
|
||||
}
|
||||
|
||||
deselectNode() {
|
||||
if (this.view.editable) {
|
||||
this.isSelected = false;
|
||||
this.renderElement();
|
||||
this.renderer.updateProps(this.props);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,16 +102,21 @@ export default class ComponentView {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener("theme-changed", this.renderElement);
|
||||
window.removeEventListener("location-changed", this.renderElement);
|
||||
|
||||
if (this.dom) {
|
||||
ReactDOM.unmountComponentAtNode(this.dom);
|
||||
}
|
||||
this.editor.renderers.delete(this.renderer);
|
||||
this.dom = null;
|
||||
}
|
||||
|
||||
ignoreMutation() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get props() {
|
||||
return {
|
||||
node: this.node,
|
||||
view: this.view,
|
||||
isSelected: this.isSelected,
|
||||
isEditable: this.view.editable,
|
||||
getPos: this.getPos,
|
||||
} as ComponentProps;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ const EmojiMenu = (props: Props) => {
|
||||
.map((item) => {
|
||||
// We snake_case the shortcode for backwards compatability with gemoji to
|
||||
// avoid multiple formats being written into documents.
|
||||
// @ts-expect-error emojiMartToGemoji key
|
||||
const shortcode = snakeCase(emojiMartToGemoji[item.id] || item.id);
|
||||
const emoji = item.value;
|
||||
|
||||
|
||||
@@ -130,59 +130,61 @@ function usePosition({
|
||||
|
||||
// Images need their own positioning to get the toolbar in the center
|
||||
if (isImageSelection) {
|
||||
const element = view.nodeDOM(selection.from);
|
||||
const element = view.nodeDOM(selection.from) as HTMLElement;
|
||||
|
||||
// Images are wrapped which impacts positioning - need to get the element
|
||||
// specifically tagged as the handle
|
||||
const imageElement = (element as HTMLElement).getElementsByClassName(
|
||||
const imageElement = element.getElementsByClassName(
|
||||
EditorStyleHelper.imageHandle
|
||||
)[0];
|
||||
const { left, top, width } = imageElement.getBoundingClientRect();
|
||||
if (imageElement) {
|
||||
const { left, top, width } = imageElement.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
left: Math.round(left + width / 2 - menuWidth / 2 - offsetParent.left),
|
||||
top: Math.round(top - menuHeight - offsetParent.top),
|
||||
offset: 0,
|
||||
visible: true,
|
||||
};
|
||||
} else {
|
||||
// calculate the horizontal center of the selection
|
||||
const halfSelection =
|
||||
Math.abs(selectionBounds.right - selectionBounds.left) / 2;
|
||||
const centerOfSelection = selectionBounds.left + halfSelection;
|
||||
|
||||
// position the menu so that it is centered over the selection except in
|
||||
// the cases where it would extend off the edge of the screen. In these
|
||||
// instances leave a margin
|
||||
const margin = 12;
|
||||
const left = Math.min(
|
||||
Math.min(
|
||||
offsetParent.x + offsetParent.width - menuWidth - margin,
|
||||
window.innerWidth - margin
|
||||
),
|
||||
Math.max(
|
||||
Math.max(offsetParent.x, margin),
|
||||
centerOfSelection - menuWidth / 2
|
||||
)
|
||||
);
|
||||
const top = Math.min(
|
||||
window.innerHeight - menuHeight - margin,
|
||||
Math.max(margin, selectionBounds.top - menuHeight)
|
||||
);
|
||||
|
||||
// if the menu has been offset to not extend offscreen then we should adjust
|
||||
// the position of the triangle underneath to correctly point to the center
|
||||
// of the selection still
|
||||
const offset = left - (centerOfSelection - menuWidth / 2);
|
||||
return {
|
||||
left: Math.round(left - offsetParent.left),
|
||||
top: Math.round(top - offsetParent.top),
|
||||
offset: Math.round(offset),
|
||||
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
|
||||
blockSelection: codeBlock || isColSelection || isRowSelection,
|
||||
visible: true,
|
||||
};
|
||||
return {
|
||||
left: Math.round(left + width / 2 - menuWidth / 2 - offsetParent.left),
|
||||
top: Math.round(top - menuHeight - offsetParent.top),
|
||||
offset: 0,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// calculate the horizontal center of the selection
|
||||
const halfSelection =
|
||||
Math.abs(selectionBounds.right - selectionBounds.left) / 2;
|
||||
const centerOfSelection = selectionBounds.left + halfSelection;
|
||||
|
||||
// position the menu so that it is centered over the selection except in
|
||||
// the cases where it would extend off the edge of the screen. In these
|
||||
// instances leave a margin
|
||||
const margin = 12;
|
||||
const left = Math.min(
|
||||
Math.min(
|
||||
offsetParent.x + offsetParent.width - menuWidth - margin,
|
||||
window.innerWidth - margin
|
||||
),
|
||||
Math.max(
|
||||
Math.max(offsetParent.x, margin),
|
||||
centerOfSelection - menuWidth / 2
|
||||
)
|
||||
);
|
||||
const top = Math.min(
|
||||
window.innerHeight - menuHeight - margin,
|
||||
Math.max(margin, selectionBounds.top - menuHeight)
|
||||
);
|
||||
|
||||
// if the menu has been offset to not extend offscreen then we should adjust
|
||||
// the position of the triangle underneath to correctly point to the center
|
||||
// of the selection still
|
||||
const offset = left - (centerOfSelection - menuWidth / 2);
|
||||
return {
|
||||
left: Math.round(left - offsetParent.left),
|
||||
top: Math.round(top - offsetParent.top),
|
||||
offset: Math.round(offset),
|
||||
maxWidth: Math.min(window.innerWidth - margin * 2, offsetParent.width),
|
||||
blockSelection: codeBlock || isColSelection || isRowSelection,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
|
||||
@@ -92,7 +92,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
const user = users.get(item.attrs.modelId);
|
||||
toast.message(
|
||||
t(
|
||||
"{{ userName }} won't by notified as they do not have access to this document",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document",
|
||||
{
|
||||
userName: item.attrs.label,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export class NodeViewRenderer<T extends object> {
|
||||
@observable public props: T;
|
||||
|
||||
public constructor(
|
||||
public element: HTMLElement,
|
||||
private Component: FunctionComponent,
|
||||
props: T
|
||||
) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
@computed
|
||||
public get content() {
|
||||
return createPortal(<this.Component {...this.props} />, this.element);
|
||||
}
|
||||
|
||||
@action
|
||||
public updateProps(props: T) {
|
||||
if (!isEqual(props, this.props)) {
|
||||
this.props = props;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import deburr from "lodash/deburr";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import { observable } from "mobx";
|
||||
import { Node } from "prosemirror-model";
|
||||
@@ -243,11 +244,11 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
});
|
||||
|
||||
mergedTextNodes.forEach(({ text = "", pos }) => {
|
||||
const search = this.findRegExp;
|
||||
let m;
|
||||
|
||||
try {
|
||||
while ((m = search.exec(text))) {
|
||||
let m;
|
||||
const search = this.findRegExp;
|
||||
|
||||
while ((m = search.exec(deburr(text)))) {
|
||||
if (m[0] === "") {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export default class MentionMenuExtension extends Suggestion {
|
||||
get defaultOptions() {
|
||||
return {
|
||||
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
|
||||
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d]+)?$/u,
|
||||
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d\s{1}@\.]+)?$/u,
|
||||
closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s{2})|(\s+[\p{L}\p{M}\d]+))$/u,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import {
|
||||
ySyncPlugin,
|
||||
yCursorPlugin,
|
||||
yUndoPlugin,
|
||||
undo,
|
||||
redo,
|
||||
} from "@getoutline/y-prosemirror";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
} from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { Second } from "@shared/utils/time";
|
||||
|
||||
type UserAwareness = {
|
||||
user?: {
|
||||
id: string;
|
||||
};
|
||||
anchor: object;
|
||||
head: object;
|
||||
};
|
||||
|
||||
export default class Multiplayer extends Extension {
|
||||
get name() {
|
||||
@@ -18,6 +28,7 @@ export default class Multiplayer extends Extension {
|
||||
const { user, provider, document: doc } = this.options;
|
||||
const type = doc.get("default", Y.XmlFragment);
|
||||
|
||||
// Assign a user to a client ID once they've made a change and then remove the listener
|
||||
const assignUser = (tr: Y.Transaction) => {
|
||||
const clientIds = Array.from(doc.store.clients.keys());
|
||||
|
||||
@@ -32,6 +43,54 @@ export default class Multiplayer extends Extension {
|
||||
}
|
||||
};
|
||||
|
||||
const userAwarenessCache = new Map<
|
||||
string,
|
||||
{ aw: UserAwareness; changedAt: Date }
|
||||
>();
|
||||
|
||||
// The opacity of a remote user's selection.
|
||||
const selectionOpacity = 70;
|
||||
|
||||
// The time in milliseconds after which a remote user's selection will be hidden.
|
||||
const selectionTimeout = 10 * Second.ms;
|
||||
|
||||
// We're hijacking this method to store the last time a user's awareness changed as a side
|
||||
// effect, and otherwise behaving as the default.
|
||||
const awarenessStateFilter = (
|
||||
currentClientId: number,
|
||||
userClientId: number,
|
||||
aw: UserAwareness
|
||||
) => {
|
||||
if (currentClientId === userClientId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const userId = aw.user?.id;
|
||||
const cached = userId ? userAwarenessCache.get(userId) : undefined;
|
||||
if (!cached || !isEqual(cached?.aw, aw)) {
|
||||
if (userId) {
|
||||
userAwarenessCache.set(userId, { aw, changedAt: new Date() });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Override the default selection builder to add a background color to the selection
|
||||
// only if the user's awareness has changed recently – this stops selections from lingering.
|
||||
const selectionBuilder = (u: { id: string; color: string }) => {
|
||||
const cached = userAwarenessCache.get(u.id);
|
||||
const opacity =
|
||||
!cached || cached?.changedAt > new Date(Date.now() - selectionTimeout)
|
||||
? selectionOpacity
|
||||
: 0;
|
||||
|
||||
return {
|
||||
style: `background-color: ${u.color}${opacity}`,
|
||||
class: "ProseMirror-yjs-selection",
|
||||
};
|
||||
};
|
||||
|
||||
provider.setAwarenessField("user", user);
|
||||
|
||||
// only once an actual change has been made do we add the userId <> clientId
|
||||
@@ -40,7 +99,10 @@ export default class Multiplayer extends Extension {
|
||||
|
||||
return [
|
||||
ySyncPlugin(type),
|
||||
yCursorPlugin(provider.awareness),
|
||||
yCursorPlugin(provider.awareness, {
|
||||
awarenessStateFilter,
|
||||
selectionBuilder,
|
||||
}),
|
||||
yUndoPlugin(),
|
||||
keymap({
|
||||
"Mod-z": undo,
|
||||
|
||||
+28
-1
@@ -38,7 +38,7 @@ import Mark from "@shared/editor/marks/Mark";
|
||||
import { basicExtensions as extensions } from "@shared/editor/nodes";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import ReactNode from "@shared/editor/nodes/ReactNode";
|
||||
import { EventType } from "@shared/editor/types";
|
||||
import { ComponentProps, EventType } from "@shared/editor/types";
|
||||
import { ProsemirrorData, UserPreferences } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import EventEmitter from "@shared/utils/events";
|
||||
@@ -50,6 +50,7 @@ import ComponentView from "./components/ComponentView";
|
||||
import EditorContext from "./components/EditorContext";
|
||||
import { SearchResult } from "./components/LinkEditor";
|
||||
import LinkToolbar from "./components/LinkToolbar";
|
||||
import { NodeViewRenderer } from "./components/NodeViewRenderer";
|
||||
import SelectionToolbar from "./components/SelectionToolbar";
|
||||
import WithTheme from "./components/WithTheme";
|
||||
|
||||
@@ -90,6 +91,10 @@ export type Props = {
|
||||
scrollTo?: string;
|
||||
/** Callback for handling uploaded images, should return the url of uploaded file */
|
||||
uploadFile?: (file: File) => Promise<string>;
|
||||
/** Callback when prosemirror nodes are initialized on document mount. */
|
||||
onInit?: () => void;
|
||||
/** Callback when prosemirror nodes are destroyed on document unmount. */
|
||||
onDestroy?: () => void;
|
||||
/** Callback when editor is blurred, as native input */
|
||||
onBlur?: () => void;
|
||||
/** Callback when editor is focused, as native input */
|
||||
@@ -175,6 +180,7 @@ export class Editor extends React.PureComponent<
|
||||
linkToolbarOpen: false,
|
||||
};
|
||||
|
||||
isInitialized = false;
|
||||
isBlurred = true;
|
||||
extensions: ExtensionManager;
|
||||
elementRef = React.createRef<HTMLDivElement>();
|
||||
@@ -192,6 +198,7 @@ export class Editor extends React.PureComponent<
|
||||
};
|
||||
|
||||
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
|
||||
renderers: Set<NodeViewRenderer<ComponentProps>> = new Set();
|
||||
nodes: { [name: string]: NodeSpec };
|
||||
marks: { [name: string]: MarkSpec };
|
||||
commands: Record<string, CommandFactory>;
|
||||
@@ -281,6 +288,7 @@ export class Editor extends React.PureComponent<
|
||||
window.removeEventListener("theme-changed", this.dispatchThemeChanged);
|
||||
this.view?.destroy();
|
||||
this.mutationObserver?.disconnect();
|
||||
this.handleEditorDestroy();
|
||||
}
|
||||
|
||||
private init() {
|
||||
@@ -480,6 +488,8 @@ export class Editor extends React.PureComponent<
|
||||
self.handleChange();
|
||||
}
|
||||
|
||||
self.handleEditorInit();
|
||||
|
||||
self.calculateDir();
|
||||
|
||||
// Because Prosemirror and React are not linked we must tell React that
|
||||
@@ -738,6 +748,22 @@ export class Editor extends React.PureComponent<
|
||||
);
|
||||
};
|
||||
|
||||
private handleEditorInit = () => {
|
||||
if (!this.props.onInit || this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onInit();
|
||||
this.isInitialized = true;
|
||||
};
|
||||
|
||||
private handleEditorDestroy = () => {
|
||||
if (!this.props.onDestroy) {
|
||||
return;
|
||||
}
|
||||
this.props.onDestroy();
|
||||
};
|
||||
|
||||
private handleEditorBlur = () => {
|
||||
this.setState({ isEditorFocused: false });
|
||||
return false;
|
||||
@@ -838,6 +864,7 @@ export class Editor extends React.PureComponent<
|
||||
Object.values(this.widgets).map((Widget, index) => (
|
||||
<Widget key={String(index)} rtl={isRTL} readOnly={readOnly} />
|
||||
))}
|
||||
{Array.from(this.renderers).map((view) => view.content)}
|
||||
</Flex>
|
||||
</EditorContext.Provider>
|
||||
</PortalContext.Provider>
|
||||
|
||||
@@ -14,7 +14,10 @@ export default function codeMenuItems(
|
||||
): MenuItem[] {
|
||||
const node = state.selection.$from.node();
|
||||
|
||||
const allLanguages = Object.entries(LANGUAGES);
|
||||
const allLanguages = Object.entries(LANGUAGES) as [
|
||||
keyof typeof LANGUAGES,
|
||||
string
|
||||
][];
|
||||
const frequentLanguages = getFrequentCodeLanguages();
|
||||
|
||||
const frequentLangMenuItems = frequentLanguages.map((value) => {
|
||||
@@ -49,6 +52,7 @@ export default function codeMenuItems(
|
||||
visible: !readOnly,
|
||||
name: "code_block",
|
||||
icon: <ExpandedIcon />,
|
||||
// @ts-expect-error We have a fallback for incorrect mapping
|
||||
label: LANGUAGES[node.attrs.language ?? "none"],
|
||||
children: languageMenuItems,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlignFullWidthIcon, TrashIcon } from "outline-icons";
|
||||
import { AlignFullWidthIcon, DownloadIcon, TrashIcon } from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
@@ -32,5 +32,15 @@ export default function tableMenuItems(
|
||||
tooltip: dictionary.deleteTable,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "exportTable",
|
||||
tooltip: dictionary.exportAsCSV,
|
||||
label: "CSV",
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export default function useDictionary() {
|
||||
sortAsc: t("Sort ascending"),
|
||||
sortDesc: t("Sort descending"),
|
||||
table: t("Table"),
|
||||
exportAsCSV: t("Export as CSV"),
|
||||
toggleHeader: t("Toggle header"),
|
||||
mathInline: t("Math inline (LaTeX)"),
|
||||
mathBlock: t("Math block (LaTeX)"),
|
||||
|
||||
@@ -3,16 +3,9 @@ import useCurrentUser from "./useCurrentUser";
|
||||
/**
|
||||
* Returns the user's locale, or undefined if the user is not logged in.
|
||||
*
|
||||
* @param languageCode Whether to only return the language code
|
||||
* @returns The user's locale, or undefined if the user is not logged in
|
||||
*/
|
||||
export default function useUserLocale(languageCode?: boolean) {
|
||||
export default function useUserLocale() {
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
|
||||
if (!user?.language) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { language } = user;
|
||||
return languageCode ? language.split("_")[0] : language;
|
||||
return user?.language;
|
||||
}
|
||||
|
||||
@@ -32,12 +32,6 @@ void PluginManager.loadPlugins();
|
||||
initI18n(env.DEFAULT_LANGUAGE);
|
||||
const element = window.document.getElementById("root");
|
||||
|
||||
history.listen(() => {
|
||||
requestAnimationFrame(() =>
|
||||
window.dispatchEvent(new Event("location-changed"))
|
||||
);
|
||||
});
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
initSentry(history);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
unstarCollection,
|
||||
searchInCollection,
|
||||
createTemplate,
|
||||
archiveCollection,
|
||||
restoreCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
@@ -151,6 +153,7 @@ function CollectionMenu({
|
||||
const canUserInTeam = usePolicy(team);
|
||||
const items: MenuItem[] = React.useMemo(
|
||||
() => [
|
||||
actionToMenuItem(restoreCollection, context),
|
||||
actionToMenuItem(starCollection, context),
|
||||
actionToMenuItem(unstarCollection, context),
|
||||
{
|
||||
@@ -224,6 +227,7 @@ function CollectionMenu({
|
||||
onClick: handleExport,
|
||||
icon: <ExportIcon />,
|
||||
},
|
||||
actionToMenuItem(archiveCollection, context),
|
||||
actionToMenuItem(searchInCollection, context),
|
||||
{
|
||||
type: "separator",
|
||||
|
||||
@@ -75,7 +75,7 @@ function CommentMenu({
|
||||
title: `${t("Edit")}…`,
|
||||
icon: <EditIcon />,
|
||||
onClick: onEdit,
|
||||
visible: can.update,
|
||||
visible: can.update && !comment.isResolved,
|
||||
},
|
||||
actionToMenuItem(
|
||||
resolveCommentFactory({
|
||||
|
||||
@@ -215,8 +215,8 @@ const MenuContent: React.FC<MenuContentProps> = ({
|
||||
type: "button",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
((document.isWorkspaceTemplate || !!collection) && can.restore) ||
|
||||
!!can.unarchive,
|
||||
!!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive),
|
||||
onClick: (ev) => handleRestore(ev),
|
||||
icon: <RestoreIcon />,
|
||||
},
|
||||
@@ -224,9 +224,8 @@ const MenuContent: React.FC<MenuContentProps> = ({
|
||||
type: "submenu",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
!document.isWorkspaceTemplate &&
|
||||
!collection &&
|
||||
!!can.restore &&
|
||||
!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive) &&
|
||||
restoreItems.length !== 0,
|
||||
style: {
|
||||
left: -170,
|
||||
|
||||
@@ -16,10 +16,13 @@ class ApiKey extends ParanoidModel {
|
||||
@observable
|
||||
expiresAt?: string;
|
||||
|
||||
/** An optional datetime that the API key was last used at. */
|
||||
/** Timestamp that the API key was last used. */
|
||||
@observable
|
||||
lastActiveAt?: string;
|
||||
|
||||
/** The user ID that the API key belongs to. */
|
||||
userId: string;
|
||||
|
||||
/** The plain text value of the API key, only available on creation. */
|
||||
value: string;
|
||||
|
||||
|
||||
@@ -80,6 +80,18 @@ export default class Collection extends ParanoidModel {
|
||||
@observable
|
||||
urlId: string;
|
||||
|
||||
/**
|
||||
* The date and time the collection was archived.
|
||||
*/
|
||||
@observable
|
||||
archivedAt: string;
|
||||
|
||||
/**
|
||||
* User who archived the collection.
|
||||
*/
|
||||
@observable
|
||||
archivedBy?: User;
|
||||
|
||||
/** Returns whether the collection is empty, or undefined if not loaded. */
|
||||
@computed
|
||||
get isEmpty(): boolean | undefined {
|
||||
@@ -154,6 +166,21 @@ export default class Collection extends ParanoidModel {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isArchived() {
|
||||
return !!this.archivedAt;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isDeleted() {
|
||||
return !!this.deletedAt;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isActive() {
|
||||
return !this.isArchived && !this.isDeleted;
|
||||
}
|
||||
|
||||
fetchDocuments = async (options?: { force: boolean }) => {
|
||||
if (this.isFetching) {
|
||||
return;
|
||||
@@ -314,6 +341,10 @@ export default class Collection extends ParanoidModel {
|
||||
@action
|
||||
unstar = async () => this.store.unstar(this);
|
||||
|
||||
archive = () => this.store.archive(this);
|
||||
|
||||
restore = () => this.store.restore(this);
|
||||
|
||||
export = (format: FileOperationFormat, includeAttachments: boolean) =>
|
||||
client.post("/collections.export", {
|
||||
id: this.id,
|
||||
|
||||
@@ -26,7 +26,7 @@ class Comment extends Model {
|
||||
* The Prosemirror data representing the comment content
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
@observable.shallow
|
||||
data: ProsemirrorData;
|
||||
|
||||
/**
|
||||
@@ -99,8 +99,8 @@ class Comment extends Model {
|
||||
* Whether the comment is resolved
|
||||
*/
|
||||
@computed
|
||||
public get isResolved() {
|
||||
return !!this.resolvedAt;
|
||||
public get isResolved(): boolean {
|
||||
return !!this.resolvedAt || !!this.parentComment?.isResolved;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,7 +28,7 @@ import { settingsPath } from "~/utils/routeHelpers";
|
||||
import Collection from "./Collection";
|
||||
import Notification from "./Notification";
|
||||
import View from "./View";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import ArchivableModel from "./base/ArchivableModel";
|
||||
import Field from "./decorators/Field";
|
||||
import Relation from "./decorators/Relation";
|
||||
|
||||
@@ -38,7 +38,7 @@ type SaveOptions = JSONObject & {
|
||||
autosave?: boolean;
|
||||
};
|
||||
|
||||
export default class Document extends ParanoidModel {
|
||||
export default class Document extends ArchivableModel {
|
||||
static modelName = "Document";
|
||||
|
||||
constructor(fields: Record<string, any>, store: DocumentsStore) {
|
||||
@@ -176,7 +176,10 @@ export default class Document extends ParanoidModel {
|
||||
@observable
|
||||
parentDocumentId: string | undefined;
|
||||
|
||||
@Relation(() => Document)
|
||||
/**
|
||||
* Parent document that this is a child of, if any.
|
||||
*/
|
||||
@Relation(() => Document, { onArchive: "cascade" })
|
||||
parentDocument?: Document;
|
||||
|
||||
@observable
|
||||
@@ -191,9 +194,6 @@ export default class Document extends ParanoidModel {
|
||||
@observable
|
||||
publishedAt: string | undefined;
|
||||
|
||||
@observable
|
||||
archivedAt: string;
|
||||
|
||||
/**
|
||||
* @deprecated Use path instead
|
||||
*/
|
||||
@@ -643,7 +643,9 @@ export default class Document extends ParanoidModel {
|
||||
nodes: extensionManager.nodes,
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
const markdown = serializer.serialize(Node.fromJSON(schema, this.data));
|
||||
const markdown = serializer.serialize(Node.fromJSON(schema, this.data), {
|
||||
softBreak: true,
|
||||
});
|
||||
return markdown;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { computed, observable } from "mobx";
|
||||
import { FileOperationFormat, FileOperationType } from "@shared/types";
|
||||
import {
|
||||
FileOperationFormat,
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
} from "@shared/types";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
@@ -10,7 +14,7 @@ class FileOperation extends Model {
|
||||
id: string;
|
||||
|
||||
@observable
|
||||
state: string;
|
||||
state: FileOperationState;
|
||||
|
||||
name: string;
|
||||
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ import {
|
||||
UserRole,
|
||||
} from "@shared/types";
|
||||
import type { NotificationSettings } from "@shared/types";
|
||||
import { locales } from "@shared/utils/date";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Document from "./Document";
|
||||
import Group from "./Group";
|
||||
@@ -39,7 +40,7 @@ class User extends ParanoidModel {
|
||||
|
||||
@Field
|
||||
@observable
|
||||
language: string;
|
||||
language: keyof typeof locales;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { observable } from "mobx";
|
||||
import ParanoidModel from "./ParanoidModel";
|
||||
|
||||
export default abstract class ArchivableModel extends ParanoidModel {
|
||||
@observable
|
||||
archivedAt: string | null;
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export default abstract class Model {
|
||||
* @returns A promise that resolves when loading is complete.
|
||||
*/
|
||||
async loadRelations(
|
||||
this: Model,
|
||||
options: { withoutPolicies?: boolean } = {}
|
||||
): Promise<any> {
|
||||
const relations = getRelationsForModelClass(
|
||||
@@ -62,7 +63,7 @@ export default abstract class Model {
|
||||
if ("fetch" in store) {
|
||||
const id = this[properties.idKey];
|
||||
if (id) {
|
||||
promises.push(store.fetch(id));
|
||||
promises.push(store.fetch(id as string));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,6 +146,7 @@ export default abstract class Model {
|
||||
if (key === "initialized") {
|
||||
continue;
|
||||
}
|
||||
// @ts-expect-error TODO
|
||||
this[key] = data[key];
|
||||
} catch (error) {
|
||||
Logger.warn(`Error setting ${key} on model`, error);
|
||||
|
||||
@@ -3,17 +3,21 @@ import type Model from "../base/Model";
|
||||
|
||||
/** The behavior of a relationship on deletion */
|
||||
type DeleteBehavior = "cascade" | "null" | "ignore";
|
||||
/** The behavior of a relationship on archival */
|
||||
type ArchiveBehavior = "cascade" | "null" | "ignore";
|
||||
|
||||
type RelationOptions<T = Model> = {
|
||||
/** Whether this relation is required. */
|
||||
required?: boolean;
|
||||
/** Behavior of this model when relationship is deleted. */
|
||||
onDelete: DeleteBehavior | ((item: T) => DeleteBehavior);
|
||||
onDelete?: DeleteBehavior | ((item: T) => DeleteBehavior);
|
||||
/** Behavior of this model when relationship is archived. */
|
||||
onArchive?: ArchiveBehavior | ((item: T) => ArchiveBehavior);
|
||||
};
|
||||
|
||||
type RelationProperties<T = Model> = {
|
||||
/** The name of the property on the model that stores the ID of the relation. */
|
||||
idKey: string;
|
||||
idKey: keyof T;
|
||||
/** A function that returns the class of the relation. */
|
||||
relationClassResolver: () => typeof Model;
|
||||
/** Options for the relation. */
|
||||
|
||||
@@ -43,8 +43,12 @@ const MembershipPreview = ({ collection, limit = 8 }: Props) => {
|
||||
memberships.fetchPage(options),
|
||||
groupMemberships.fetchPage(options),
|
||||
]);
|
||||
setUsersCount(users[PAGINATION_SYMBOL].total);
|
||||
setGroupsCount(groups[PAGINATION_SYMBOL].total);
|
||||
if (users[PAGINATION_SYMBOL]) {
|
||||
setUsersCount(users[PAGINATION_SYMBOL].total);
|
||||
}
|
||||
if (groups[PAGINATION_SYMBOL]) {
|
||||
setGroupsCount(groups[PAGINATION_SYMBOL].total);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Collection from "~/models/Collection";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import Notice from "~/components/Notice";
|
||||
import Time from "~/components/Time";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
export default function Notices({ collection }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{collection.isArchived && !collection.isDeleted && (
|
||||
<Notice icon={<ArchiveIcon />}>
|
||||
{t("Archived by {{userName}}", {
|
||||
userName: collection.archivedBy?.name ?? t("Unknown"),
|
||||
})}
|
||||
|
||||
<Time dateTime={collection.archivedAt} addSuffix />
|
||||
</Notice>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { StatusFilter } from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Collection from "~/models/Collection";
|
||||
import Search from "~/scenes/Search";
|
||||
import { Action } from "~/components/Actions";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
|
||||
import CollectionDescription from "~/components/CollectionDescription";
|
||||
import Heading from "~/components/Heading";
|
||||
import Icon, { IconTitleWrapper } from "~/components/Icon";
|
||||
@@ -28,6 +30,7 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
||||
import PinnedDocuments from "~/components/PinnedDocuments";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Scene from "~/components/Scene";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import { editCollection } from "~/actions/definitions/collections";
|
||||
@@ -41,6 +44,7 @@ import Actions from "./components/Actions";
|
||||
import DropToImport from "./components/DropToImport";
|
||||
import Empty from "./components/Empty";
|
||||
import MembershipPreview from "./components/MembershipPreview";
|
||||
import Notices from "./components/Notices";
|
||||
import ShareButton from "./components/ShareButton";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
@@ -132,7 +136,9 @@ function CollectionScene() {
|
||||
centered={false}
|
||||
textTitle={collection.name}
|
||||
left={
|
||||
collection.isEmpty ? undefined : (
|
||||
collection.isArchived ? (
|
||||
<CollectionBreadcrumb collection={collection} />
|
||||
) : collection.isEmpty ? undefined : (
|
||||
<InputSearchPage
|
||||
source="collection"
|
||||
placeholder={`${t("Search in collection")}…`}
|
||||
@@ -163,6 +169,7 @@ function CollectionScene() {
|
||||
collectionId={collection.id}
|
||||
>
|
||||
<CenteredContent withStickyHeader>
|
||||
<Notices collection={collection} />
|
||||
<CollectionHeading>
|
||||
<IconTitleWrapper>
|
||||
{can.update ? (
|
||||
@@ -192,26 +199,28 @@ function CollectionScene() {
|
||||
<CollectionDescription collection={collection} />
|
||||
|
||||
<Documents>
|
||||
<Tabs>
|
||||
<Tab to={collectionPath(collection.path)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
{!collection.isArchived && (
|
||||
<Tabs>
|
||||
<Tab to={collectionPath(collection.path)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
)}
|
||||
{collection.isEmpty ? (
|
||||
<Empty collection={collection} />
|
||||
) : (
|
||||
) : !collection.isArchived ? (
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
@@ -279,6 +288,24 @@ function CollectionScene() {
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
) : (
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.archivedInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
heading={<Subheading sticky>{t("Documents")}</Subheading>}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: collection.sort.direction,
|
||||
statusFilter: [StatusFilter.Archived],
|
||||
}}
|
||||
showParentDocuments
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
</Documents>
|
||||
</CenteredContent>
|
||||
|
||||
@@ -172,7 +172,7 @@ function SharedDocumentScene(props: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
if (!response?.sharedTree) {
|
||||
return <Loading location={props.location} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { basicExtensions, withComments } from "@shared/editor/nodes";
|
||||
import HardBreak from "@shared/editor/nodes/HardBreak";
|
||||
@@ -36,4 +37,4 @@ const CommentEditor = (
|
||||
);
|
||||
};
|
||||
|
||||
export default React.forwardRef(CommentEditor);
|
||||
export default observer(React.forwardRef(CommentEditor));
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import queryString from "query-string";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { UserPreference } from "@shared/types";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import { CommentSortType } from "~/types";
|
||||
|
||||
const CommentSortMenu = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const user = useCurrentUser();
|
||||
const params = useQuery();
|
||||
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const value = viewingResolved
|
||||
? "resolved"
|
||||
: user.getPreference(UserPreference.SortCommentsByOrderInDocument)
|
||||
? CommentSortType.OrderInDocument
|
||||
: CommentSortType.MostRecent;
|
||||
|
||||
const handleSortTypeChange = (type: CommentSortType) => {
|
||||
user.setPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument,
|
||||
type === CommentSortType.OrderInDocument
|
||||
);
|
||||
void user.save();
|
||||
};
|
||||
|
||||
const showResolved = () => {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: "",
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
});
|
||||
};
|
||||
|
||||
const showUnresolved = () => {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: undefined,
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
style={{ margin: 0 }}
|
||||
ariaLabel={t("Sort comments")}
|
||||
value={value}
|
||||
onChange={(ev) => {
|
||||
if (ev === "resolved") {
|
||||
showResolved();
|
||||
} else {
|
||||
handleSortTypeChange(ev as CommentSortType);
|
||||
showUnresolved();
|
||||
}
|
||||
}}
|
||||
borderOnHover
|
||||
options={[
|
||||
{ value: CommentSortType.MostRecent, label: t("Most recent") },
|
||||
{ value: CommentSortType.OrderInDocument, label: t("Order in doc") },
|
||||
{
|
||||
divider: true,
|
||||
value: "resolved",
|
||||
label: t("Resolved"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
export default CommentSortMenu;
|
||||
@@ -80,6 +80,8 @@ function CommentThread({
|
||||
});
|
||||
const can = usePolicy(document);
|
||||
|
||||
const canReply = can.comment && !thread.isResolved;
|
||||
|
||||
const highlightedCommentMarks = editor
|
||||
?.getComments()
|
||||
.filter((comment) => comment.id === thread.id);
|
||||
@@ -105,7 +107,7 @@ function CommentThread({
|
||||
const handleClickThread = () => {
|
||||
history.replace({
|
||||
// Clear any commentId from the URL when explicitly focusing a thread
|
||||
search: "",
|
||||
search: thread.isResolved ? "resolved=" : "",
|
||||
pathname: location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId: thread.id },
|
||||
});
|
||||
@@ -190,8 +192,8 @@ function CommentThread({
|
||||
<CommentThreadItem
|
||||
highlightedText={index === 0 ? highlightedText : undefined}
|
||||
comment={comment}
|
||||
onDelete={() => editor?.removeComment(comment.id)}
|
||||
onUpdate={(attrs) => editor?.updateComment(comment.id, attrs)}
|
||||
onDelete={editor?.removeComment}
|
||||
onUpdate={editor?.updateComment}
|
||||
key={comment.id}
|
||||
firstOfThread={index === 0}
|
||||
lastOfThread={index === commentsInThread.length - 1 && !draft}
|
||||
@@ -214,7 +216,7 @@ function CommentThread({
|
||||
))}
|
||||
|
||||
<ResizingHeightContainer hideOverflow={false} ref={replyRef}>
|
||||
{(focused || draft || commentsInThread.length === 0) && can.comment && (
|
||||
{(focused || draft || commentsInThread.length === 0) && canReply && (
|
||||
<Fade timing={100}>
|
||||
<CommentForm
|
||||
onSaveDraft={onSaveDraft}
|
||||
@@ -232,7 +234,7 @@ function CommentThread({
|
||||
</Fade>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
{!focused && !recessed && !draft && can.comment && (
|
||||
{!focused && !recessed && !draft && canReply && (
|
||||
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}…</Reply>
|
||||
)}
|
||||
</Thread>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { differenceInMilliseconds } from "date-fns";
|
||||
import { toJS } from "mobx";
|
||||
import { action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
@@ -76,9 +76,9 @@ type Props = {
|
||||
/** Whether the user can reply in the thread */
|
||||
canReply: boolean;
|
||||
/** Callback when the comment has been deleted */
|
||||
onDelete: () => void;
|
||||
onDelete?: (id: string) => void;
|
||||
/** Callback when the comment has been updated */
|
||||
onUpdate: (attrs: { resolved: boolean }) => void;
|
||||
onUpdate?: (id: string, attrs: { resolved: boolean }) => void;
|
||||
/** Text to highlight at the top of the comment */
|
||||
highlightedText?: string;
|
||||
};
|
||||
@@ -96,8 +96,7 @@ function CommentThreadItem({
|
||||
highlightedText,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [forceRender, setForceRender] = React.useState(0);
|
||||
const [data, setData] = React.useState(toJS(comment.data));
|
||||
const [data, setData] = React.useState(comment.data);
|
||||
const showAuthor = firstOfAuthor;
|
||||
const showTime = useShowTime(comment.createdAt, previousCommentCreatedAt);
|
||||
const showEdited =
|
||||
@@ -107,41 +106,48 @@ function CommentThreadItem({
|
||||
const [isEditing, setEditing, setReadOnly] = useBoolean();
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const handleChange = (value: (asString: boolean) => ProsemirrorData) => {
|
||||
setData(value(false));
|
||||
};
|
||||
const handleUpdate = React.useCallback(
|
||||
(attrs: { resolved: boolean }) => {
|
||||
onUpdate?.(comment.id, attrs);
|
||||
},
|
||||
[comment.id, onUpdate]
|
||||
);
|
||||
|
||||
const handleSave = () => {
|
||||
const handleDelete = React.useCallback(() => {
|
||||
onDelete?.(comment.id);
|
||||
}, [comment.id, onDelete]);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(value: (asString: boolean) => ProsemirrorData) => {
|
||||
setData(value(false));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSave = React.useCallback(() => {
|
||||
formRef.current?.dispatchEvent(
|
||||
new Event("submit", { cancelable: true, bubbles: true })
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
const handleSubmit = action(async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
setReadOnly();
|
||||
await comment.save({
|
||||
data,
|
||||
});
|
||||
comment.data = data;
|
||||
await comment.save();
|
||||
} catch (error) {
|
||||
setEditing();
|
||||
toast.error(t("Error updating comment"));
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
setData(toJS(comment.data));
|
||||
setData(comment.data);
|
||||
setReadOnly();
|
||||
setForceRender((i) => ++i);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setData(toJS(comment.data));
|
||||
setForceRender((i) => ++i);
|
||||
}, [comment.data]);
|
||||
|
||||
return (
|
||||
<Flex gap={8} align="flex-start" reverse={dir === "rtl"}>
|
||||
{firstOfAuthor && (
|
||||
@@ -186,8 +192,9 @@ function CommentThreadItem({
|
||||
)}
|
||||
<Body ref={formRef} onSubmit={handleSubmit}>
|
||||
<StyledCommentEditor
|
||||
key={`${forceRender}`}
|
||||
key={String(isEditing)}
|
||||
readOnly={!isEditing}
|
||||
value={comment.data}
|
||||
defaultValue={data}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
@@ -209,8 +216,8 @@ function CommentThreadItem({
|
||||
<Menu
|
||||
comment={comment}
|
||||
onEdit={setEditing}
|
||||
onDelete={onDelete}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={handleDelete}
|
||||
onUpdate={handleUpdate}
|
||||
dir={dir}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,36 +1,34 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import { ProsemirrorData } from "@shared/types";
|
||||
import Button from "~/components/Button";
|
||||
import { useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { ProsemirrorData, UserPreference } from "@shared/types";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { bigPulse } from "~/styles/animations";
|
||||
import { CommentSortOption, CommentSortType } from "~/types";
|
||||
import CommentForm from "./CommentForm";
|
||||
import CommentSortMenu from "./CommentSortMenu";
|
||||
import CommentThread from "./CommentThread";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
|
||||
function Comments() {
|
||||
const { ui, comments, documents } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const { editor, isEditorInitialized } = useDocumentContext();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch<{ documentSlug: string }>();
|
||||
const params = useQuery();
|
||||
const [pulse, setPulse] = React.useState(false);
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const focusedComment = useFocusedComment();
|
||||
const can = usePolicy(document);
|
||||
@@ -42,71 +40,35 @@ function Comments() {
|
||||
undefined
|
||||
);
|
||||
|
||||
const sortOption: CommentSortOption = user.getPreference(
|
||||
UserPreference.SortCommentsByOrderInDocument
|
||||
)
|
||||
? {
|
||||
type: CommentSortType.OrderInDocument,
|
||||
referencedCommentIds: editor?.getComments().map((c) => c.id) ?? [],
|
||||
}
|
||||
: { type: CommentSortType.MostRecent };
|
||||
|
||||
const viewingResolved = params.get("resolved") === "";
|
||||
const resolvedThreads = document
|
||||
? comments.resolvedThreadsInDocument(document.id)
|
||||
? comments.resolvedThreadsInDocument(document.id, sortOption)
|
||||
: [];
|
||||
const resolvedThreadsCount = resolvedThreads.length;
|
||||
|
||||
React.useEffect(() => {
|
||||
setPulse(true);
|
||||
const timeout = setTimeout(() => setPulse(false), 250);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
setPulse(false);
|
||||
};
|
||||
}, [resolvedThreadsCount]);
|
||||
|
||||
if (!document) {
|
||||
if (!document || !isEditorInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const threads = viewingResolved
|
||||
? resolvedThreads
|
||||
: comments.unresolvedThreadsInDocument(document.id);
|
||||
: comments.unresolvedThreadsInDocument(document.id, sortOption);
|
||||
const hasComments = threads.length > 0;
|
||||
|
||||
const toggleViewingResolved = () => {
|
||||
history.push({
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
resolved: viewingResolved ? undefined : "",
|
||||
}),
|
||||
pathname: location.pathname,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
title={
|
||||
<Flex align="center" justify="space-between" auto>
|
||||
{viewingResolved ? (
|
||||
<React.Fragment key="resolved">
|
||||
<span>{t("Resolved comments")}</span>
|
||||
<Tooltip delay={500} content={t("View comments")}>
|
||||
<ResolvedButton
|
||||
neutral
|
||||
borderOnHover
|
||||
icon={<DoneIcon />}
|
||||
onClick={toggleViewingResolved}
|
||||
/>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<span>{t("Comments")}</span>
|
||||
<Tooltip delay={250} content={t("View resolved comments")}>
|
||||
<ResolvedButton
|
||||
neutral
|
||||
borderOnHover
|
||||
icon={<DoneIcon outline />}
|
||||
onClick={toggleViewingResolved}
|
||||
$pulse={pulse}
|
||||
/>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
)}
|
||||
<span>{t("Comments")}</span>
|
||||
<CommentSortMenu />
|
||||
</Flex>
|
||||
}
|
||||
onClose={() => ui.collapseComments(document?.id)}
|
||||
@@ -158,14 +120,6 @@ function Comments() {
|
||||
);
|
||||
}
|
||||
|
||||
const ResolvedButton = styled(Button)<{ $pulse: boolean }>`
|
||||
${(props) =>
|
||||
props.$pulse &&
|
||||
css`
|
||||
animation: ${bigPulse} 250ms 1;
|
||||
`}
|
||||
`;
|
||||
|
||||
const PositionedEmpty = styled(Empty)`
|
||||
position: absolute;
|
||||
top: calc(50vh - 30px);
|
||||
|
||||
@@ -175,7 +175,11 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
[comments]
|
||||
);
|
||||
|
||||
const { setEditor, updateState: updateDocState } = useDocumentContext();
|
||||
const {
|
||||
setEditor,
|
||||
setEditorInitialized,
|
||||
updateState: updateDocState,
|
||||
} = useDocumentContext();
|
||||
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
|
||||
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
|
||||
|
||||
@@ -189,6 +193,16 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
[childOffsetHeight]
|
||||
);
|
||||
|
||||
const handleInit = React.useCallback(
|
||||
() => setEditorInitialized(true),
|
||||
[setEditorInitialized]
|
||||
);
|
||||
|
||||
const handleDestroy = React.useCallback(
|
||||
() => setEditorInitialized(false),
|
||||
[setEditorInitialized]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex auto column>
|
||||
<DocumentTitle
|
||||
@@ -241,6 +255,8 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
? handleRemoveComment
|
||||
: undefined
|
||||
}
|
||||
onInit={handleInit}
|
||||
onDestroy={handleDestroy}
|
||||
onChange={updateDocState}
|
||||
extensions={extensions}
|
||||
editorStyle={editorStyle}
|
||||
|
||||
@@ -41,7 +41,7 @@ import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import NewChildDocumentMenu from "~/menus/NewChildDocumentMenu";
|
||||
import TableOfContentsMenu from "~/menus/TableOfContentsMenu";
|
||||
import TemplatesMenu from "~/menus/TemplatesMenu";
|
||||
import { metaDisplay } from "~/utils/keyboard";
|
||||
import { altDisplay, metaDisplay } from "~/utils/keyboard";
|
||||
import { documentEditPath } from "~/utils/routeHelpers";
|
||||
import ObservingBanner from "./ObservingBanner";
|
||||
import PublicBreadcrumb from "./PublicBreadcrumb";
|
||||
@@ -124,7 +124,7 @@ function DocumentHeader({
|
||||
? t("Show contents")
|
||||
: `${t("Show contents")} (${t("available when headings are added")})`
|
||||
}
|
||||
shortcut="ctrl+alt+h"
|
||||
shortcut={`ctrl+${altDisplay}+h`}
|
||||
delay={250}
|
||||
placement="bottom"
|
||||
>
|
||||
|
||||
@@ -53,8 +53,6 @@ function ShareButton({ document }: Props) {
|
||||
>
|
||||
<SharePopover
|
||||
document={document}
|
||||
share={share}
|
||||
sharedParent={sharedParent}
|
||||
onRequestClose={popover.hide}
|
||||
visible={popover.visible}
|
||||
/>
|
||||
|
||||
@@ -71,10 +71,11 @@ function Invite({ onSubmit }: Props) {
|
||||
[onSubmit, invites, role, t, users]
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback((ev, index) => {
|
||||
const handleChange = React.useCallback((ev, index: number) => {
|
||||
setInvites((prevInvites) => {
|
||||
const newInvites = [...prevInvites];
|
||||
newInvites[index][ev.target.name] = ev.target.value;
|
||||
newInvites[index][ev.target.name as keyof InviteRequest] =
|
||||
ev.target.value;
|
||||
return newInvites;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -57,7 +57,9 @@ function Search(props: Props) {
|
||||
const recentSearchesRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// filters
|
||||
const query = decodeURIComponentSafe(routeMatch.params.term ?? "");
|
||||
const query = decodeURIComponentSafe(
|
||||
routeMatch.params.term ?? params.get("query") ?? ""
|
||||
);
|
||||
const collectionId = params.get("collectionId") ?? undefined;
|
||||
const userId = params.get("userId") ?? undefined;
|
||||
const documentId = params.get("documentId") ?? undefined;
|
||||
@@ -117,7 +119,12 @@ function Search(props: Props) {
|
||||
const updateLocation = (query: string) => {
|
||||
history.replace({
|
||||
pathname: searchPath(query),
|
||||
search: location.search,
|
||||
search: queryString.stringify(
|
||||
{ ...queryString.parse(location.search), query: undefined },
|
||||
{
|
||||
skipEmptyString: true,
|
||||
}
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -134,7 +141,7 @@ function Search(props: Props) {
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: queryString.stringify(
|
||||
{ ...queryString.parse(location.search), ...search },
|
||||
{ ...queryString.parse(location.search), query: undefined, ...search },
|
||||
{
|
||||
skipEmptyString: true,
|
||||
}
|
||||
@@ -201,59 +208,68 @@ function Search(props: Props) {
|
||||
</div>
|
||||
)}
|
||||
<ResultsWrapper column auto>
|
||||
<SearchInput
|
||||
key={query ? "search" : "recent"}
|
||||
ref={searchInputRef}
|
||||
placeholder={`${
|
||||
documentId
|
||||
? t("Search in document")
|
||||
: collectionId
|
||||
? t("Search in collection")
|
||||
: t("Search")
|
||||
}…`}
|
||||
onKeyDown={handleKeyDown}
|
||||
defaultValue={query}
|
||||
/>
|
||||
<form
|
||||
method="GET"
|
||||
action={searchPath()}
|
||||
onSubmit={(ev) => ev.preventDefault()}
|
||||
>
|
||||
<SearchInput
|
||||
name="query"
|
||||
key={query ? "search" : "recent"}
|
||||
ref={searchInputRef}
|
||||
placeholder={`${
|
||||
documentId
|
||||
? t("Search in document")
|
||||
: collectionId
|
||||
? t("Search in collection")
|
||||
: t("Search")
|
||||
}…`}
|
||||
onKeyDown={handleKeyDown}
|
||||
defaultValue={query}
|
||||
/>
|
||||
|
||||
{(query || hasFilters) && (
|
||||
<Filters>
|
||||
{document && (
|
||||
<DocumentFilter
|
||||
document={document}
|
||||
onClick={() => {
|
||||
handleFilterChange({ documentId: undefined });
|
||||
}}
|
||||
{(query || hasFilters) && (
|
||||
<Filters>
|
||||
{document && (
|
||||
<DocumentFilter
|
||||
document={document}
|
||||
onClick={() => {
|
||||
handleFilterChange({ documentId: undefined });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DocumentTypeFilter
|
||||
statusFilter={statusFilter}
|
||||
onSelect={({ statusFilter }) =>
|
||||
handleFilterChange({ statusFilter })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<DocumentTypeFilter
|
||||
statusFilter={statusFilter}
|
||||
onSelect={({ statusFilter }) =>
|
||||
handleFilterChange({ statusFilter })
|
||||
}
|
||||
/>
|
||||
<CollectionFilter
|
||||
collectionId={collectionId}
|
||||
onSelect={(collectionId) => handleFilterChange({ collectionId })}
|
||||
/>
|
||||
<UserFilter
|
||||
userId={userId}
|
||||
onSelect={(userId) => handleFilterChange({ userId })}
|
||||
/>
|
||||
<DateFilter
|
||||
dateFilter={dateFilter}
|
||||
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
|
||||
/>
|
||||
<SearchTitlesFilter
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Search titles only")}
|
||||
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFilterChange({ titleFilter: ev.target.checked });
|
||||
}}
|
||||
checked={titleFilter}
|
||||
/>
|
||||
</Filters>
|
||||
)}
|
||||
<CollectionFilter
|
||||
collectionId={collectionId}
|
||||
onSelect={(collectionId) =>
|
||||
handleFilterChange({ collectionId })
|
||||
}
|
||||
/>
|
||||
<UserFilter
|
||||
userId={userId}
|
||||
onSelect={(userId) => handleFilterChange({ userId })}
|
||||
/>
|
||||
<DateFilter
|
||||
dateFilter={dateFilter}
|
||||
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
|
||||
/>
|
||||
<SearchTitlesFilter
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Search titles only")}
|
||||
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFilterChange({ titleFilter: ev.target.checked });
|
||||
}}
|
||||
checked={titleFilter}
|
||||
/>
|
||||
</Filters>
|
||||
)}
|
||||
</form>
|
||||
{query ? (
|
||||
<>
|
||||
{error ? (
|
||||
|
||||
@@ -4,10 +4,10 @@ import styled, { useTheme } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLInputElement> & {
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
interface Props extends React.HTMLAttributes<HTMLInputElement> {
|
||||
name: string;
|
||||
defaultValue: string;
|
||||
}
|
||||
|
||||
function SearchInput(
|
||||
{ defaultValue, ...rest }: Props,
|
||||
|
||||
@@ -13,12 +13,14 @@ import Text from "~/components/Text";
|
||||
import { createApiKey } from "~/actions/definitions/apiKeys";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ApiKeyListItem from "./components/ApiKeyListItem";
|
||||
|
||||
function ApiKeys() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const can = usePolicy(team);
|
||||
@@ -79,7 +81,8 @@ function ApiKeys() {
|
||||
</Text>
|
||||
<PaginatedList
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.orderedData}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
heading={<h2>{t("Personal keys")}</h2>}
|
||||
renderItem={(apiKey: ApiKey) => (
|
||||
<ApiKeyListItem
|
||||
|
||||
@@ -62,7 +62,9 @@ function Members() {
|
||||
filter,
|
||||
role,
|
||||
});
|
||||
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
|
||||
if (response[PAGINATION_SYMBOL]) {
|
||||
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
|
||||
}
|
||||
setUserIds(response.map((u: User) => u.id));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -47,7 +47,9 @@ function Shares() {
|
||||
sort,
|
||||
direction,
|
||||
});
|
||||
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
|
||||
if (response[PAGINATION_SYMBOL]) {
|
||||
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
|
||||
}
|
||||
setShareIds(response.map((u: Share) => u.id));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -38,7 +38,7 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
|
||||
[FileOperationState.Error]: t("Failed"),
|
||||
};
|
||||
|
||||
const iconMapping = {
|
||||
const iconMapping: Record<FileOperationState, React.JSX.Element> = {
|
||||
[FileOperationState.Creating]: <Spinner />,
|
||||
[FileOperationState.Uploading]: <Spinner />,
|
||||
[FileOperationState.Expired]: <ArchiveIcon color={theme.textTertiary} />,
|
||||
@@ -46,8 +46,9 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
|
||||
[FileOperationState.Error]: <WarningIcon color={theme.danger} />,
|
||||
};
|
||||
|
||||
const formatMapping = {
|
||||
const formatMapping: Record<FileOperationFormat, string> = {
|
||||
[FileOperationFormat.JSON]: "JSON",
|
||||
[FileOperationFormat.Notion]: "Notion",
|
||||
[FileOperationFormat.MarkdownZip]: "Markdown",
|
||||
[FileOperationFormat.HTMLZip]: "HTML",
|
||||
[FileOperationFormat.PDF]: "PDF",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { computed } from "mobx";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import RootStore from "./RootStore";
|
||||
import Store, { RPCAction } from "./base/Store";
|
||||
@@ -8,4 +9,12 @@ export default class ApiKeysStore extends Store<ApiKey> {
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, ApiKey);
|
||||
}
|
||||
|
||||
@computed
|
||||
get personalApiKeys() {
|
||||
const userId = this.rootStore.auth.user?.id;
|
||||
return userId
|
||||
? this.orderedData.filter((key) => key.userId === userId)
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import Team from "~/models/Team";
|
||||
import User from "~/models/User";
|
||||
import env from "~/env";
|
||||
import { setPostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import { PartialWithId } from "~/types";
|
||||
import { PartialExcept } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Logger from "~/utils/Logger";
|
||||
@@ -20,8 +20,8 @@ import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Store from "./base/Store";
|
||||
|
||||
type PersistedData = {
|
||||
user?: PartialWithId<User>;
|
||||
team?: PartialWithId<Team>;
|
||||
user?: PartialExcept<User, "id">;
|
||||
team?: PartialExcept<Team, "id">;
|
||||
collaborationToken?: string;
|
||||
availableTeams?: {
|
||||
id: string;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import invariant from "invariant";
|
||||
import find from "lodash/find";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { computed, action } from "mobx";
|
||||
import { CollectionPermission, FileOperationFormat } from "@shared/types";
|
||||
import { computed, action, runInAction } from "mobx";
|
||||
import {
|
||||
CollectionPermission,
|
||||
CollectionStatusFilter,
|
||||
FileOperationFormat,
|
||||
} from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import { Properties } from "~/types";
|
||||
import { PaginationParams, Properties } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import RootStore from "./RootStore";
|
||||
import Store from "./base/Store";
|
||||
@@ -27,6 +32,11 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@computed
|
||||
get allActive() {
|
||||
return this.orderedData.filter((c) => c.isActive);
|
||||
}
|
||||
|
||||
@computed
|
||||
get orderedData(): Collection[] {
|
||||
let collections = Array.from(this.data.values());
|
||||
@@ -97,6 +107,30 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
archive = async (collection: Collection) => {
|
||||
const res = await client.post("/collections.archive", {
|
||||
id: collection.id,
|
||||
});
|
||||
runInAction("Collection#archive", () => {
|
||||
invariant(res?.data, "Data should be available");
|
||||
this.add(res.data);
|
||||
this.addPolicies(res.policies);
|
||||
});
|
||||
};
|
||||
|
||||
@action
|
||||
restore = async (collection: Collection) => {
|
||||
const res = await client.post("/collections.restore", {
|
||||
id: collection.id,
|
||||
});
|
||||
runInAction("Collection#restore", () => {
|
||||
invariant(res?.data, "Data should be available");
|
||||
this.add(res.data);
|
||||
this.addPolicies(res.policies);
|
||||
});
|
||||
};
|
||||
|
||||
async update(params: Properties<Collection>): Promise<Collection> {
|
||||
const result = await super.update(params);
|
||||
|
||||
@@ -119,6 +153,52 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
return model;
|
||||
}
|
||||
|
||||
@action
|
||||
fetchNamedPage = async (
|
||||
request = "list",
|
||||
options:
|
||||
| (PaginationParams & { statusFilter: CollectionStatusFilter[] })
|
||||
| undefined
|
||||
): Promise<Collection[]> => {
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/collections.${request}`, options);
|
||||
invariant(res?.data, "Collection list not available");
|
||||
runInAction("CollectionsStore#fetchNamedPage", () => {
|
||||
res.data.forEach(this.add);
|
||||
this.addPolicies(res.policies);
|
||||
this.isLoaded = true;
|
||||
});
|
||||
return res.data;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
fetchArchived = async (options?: PaginationParams): Promise<Collection[]> =>
|
||||
this.fetchNamedPage("list", {
|
||||
...options,
|
||||
statusFilter: [CollectionStatusFilter.Archived],
|
||||
});
|
||||
|
||||
@computed
|
||||
get archived(): Collection[] {
|
||||
return orderBy(this.orderedData, "archivedAt", "desc").filter(
|
||||
(c) => c.isArchived && !c.isDeleted
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get publicCollections() {
|
||||
return this.orderedData.filter(
|
||||
(collection) =>
|
||||
collection.permission &&
|
||||
Object.values(CollectionPermission).includes(collection.permission)
|
||||
);
|
||||
}
|
||||
|
||||
star = async (collection: Collection, index?: string) => {
|
||||
await this.rootStore.stars.create({
|
||||
collectionId: collection.id,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import invariant from "invariant";
|
||||
import compact from "lodash/compact";
|
||||
import differenceBy from "lodash/differenceBy";
|
||||
import keyBy from "lodash/keyBy";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import uniq from "lodash/uniq";
|
||||
import { action, computed } from "mobx";
|
||||
import Comment from "~/models/Comment";
|
||||
import { CommentSortOption, CommentSortType } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import RootStore from "./RootStore";
|
||||
import Store from "./base/Store";
|
||||
@@ -28,14 +33,29 @@ export default class CommentsStore extends Store<Comment> {
|
||||
* @param documentId ID of the document to get comments for
|
||||
* @returns Array of comments
|
||||
*/
|
||||
threadsInDocument(documentId: string): Comment[] {
|
||||
return this.filter(
|
||||
threadsInDocument(
|
||||
documentId: string,
|
||||
options: CommentSortOption = { type: CommentSortType.MostRecent }
|
||||
) {
|
||||
const comments = this.filter(
|
||||
(comment: Comment) =>
|
||||
comment.documentId === documentId &&
|
||||
!comment.parentCommentId &&
|
||||
(!comment.isNew ||
|
||||
comment.createdById === this.rootStore.auth.currentUserId)
|
||||
);
|
||||
|
||||
if (options.type === CommentSortType.MostRecent) {
|
||||
return comments;
|
||||
}
|
||||
|
||||
const commentsById = keyBy(comments, "id");
|
||||
const referencedComments = compact(
|
||||
uniq(options.referencedCommentIds.map((id) => commentsById[id]))
|
||||
);
|
||||
const directComments = differenceBy(comments, referencedComments, "id");
|
||||
|
||||
return [...referencedComments, ...directComments];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,8 +65,11 @@ export default class CommentsStore extends Store<Comment> {
|
||||
* @param documentId ID of the document to get comments for
|
||||
* @returns Array of comments
|
||||
*/
|
||||
resolvedThreadsInDocument(documentId: string): Comment[] {
|
||||
return this.threadsInDocument(documentId).filter(
|
||||
resolvedThreadsInDocument(
|
||||
documentId: string,
|
||||
options: CommentSortOption = { type: CommentSortType.MostRecent }
|
||||
): Comment[] {
|
||||
return this.threadsInDocument(documentId, options).filter(
|
||||
(comment: Comment) => comment.isResolved === true
|
||||
);
|
||||
}
|
||||
@@ -58,8 +81,11 @@ export default class CommentsStore extends Store<Comment> {
|
||||
* @param documentId ID of the document to get comments for
|
||||
* @returns Array of comments
|
||||
*/
|
||||
unresolvedThreadsInDocument(documentId: string): Comment[] {
|
||||
return this.threadsInDocument(documentId).filter(
|
||||
unresolvedThreadsInDocument(
|
||||
documentId: string,
|
||||
options: CommentSortOption = { type: CommentSortType.MostRecent }
|
||||
): Comment[] {
|
||||
return this.threadsInDocument(documentId, options).filter(
|
||||
(comment: Comment) => comment.isResolved !== true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import env from "~/env";
|
||||
import type {
|
||||
FetchOptions,
|
||||
PaginationParams,
|
||||
PartialWithId,
|
||||
PartialExcept,
|
||||
SearchResult,
|
||||
} from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
@@ -121,6 +121,33 @@ export default class DocumentsStore extends Store<Document> {
|
||||
);
|
||||
}
|
||||
|
||||
archivedInCollection(
|
||||
collectionId: string,
|
||||
options?: { archivedAt: string }
|
||||
): Document[] {
|
||||
const filterCond = (document: Document) =>
|
||||
options
|
||||
? document.collectionId === collectionId &&
|
||||
document.isArchived &&
|
||||
document.archivedAt === options.archivedAt &&
|
||||
!document.isDeleted
|
||||
: document.collectionId === collectionId &&
|
||||
document.isArchived &&
|
||||
!document.isDeleted;
|
||||
|
||||
return filter(this.orderedData, filterCond);
|
||||
}
|
||||
|
||||
unarchivedInCollection(collectionId: string): Document[] {
|
||||
return filter(
|
||||
this.orderedData,
|
||||
(document) =>
|
||||
document.collectionId === collectionId &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted
|
||||
);
|
||||
}
|
||||
|
||||
templatesInCollection(collectionId: string): Document[] {
|
||||
return orderBy(
|
||||
filter(
|
||||
@@ -313,8 +340,18 @@ export default class DocumentsStore extends Store<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
fetchArchived = async (options?: PaginationParams): Promise<Document[]> =>
|
||||
this.fetchNamedPage("archived", options);
|
||||
fetchArchived = async (options?: PaginationParams): Promise<Document[]> => {
|
||||
const archivedInResponse = await this.fetchNamedPage("archived", options);
|
||||
const archivedInMemory = this.archived;
|
||||
|
||||
archivedInMemory.forEach((docInMemory) => {
|
||||
!archivedInResponse.find(
|
||||
(docInResponse) => docInResponse.id === docInMemory.id
|
||||
) && this.remove(docInMemory.id);
|
||||
});
|
||||
|
||||
return archivedInResponse;
|
||||
};
|
||||
|
||||
@action
|
||||
fetchDeleted = async (options?: PaginationParams): Promise<Document[]> =>
|
||||
@@ -489,7 +526,7 @@ export default class DocumentsStore extends Store<Document> {
|
||||
super.fetch(
|
||||
id,
|
||||
options,
|
||||
(res: { data: { document: PartialWithId<Document> } }) =>
|
||||
(res: { data: { document: PartialExcept<Document, "id"> } }) =>
|
||||
res.data.document
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ import GroupMembership from "~/models/GroupMembership";
|
||||
import { PaginationParams } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import RootStore from "./RootStore";
|
||||
import Store, { PAGINATION_SYMBOL, RPCAction } from "./base/Store";
|
||||
import Store, {
|
||||
PAGINATION_SYMBOL,
|
||||
PaginatedResponse,
|
||||
RPCAction,
|
||||
} from "./base/Store";
|
||||
|
||||
export default class GroupMembershipsStore extends Store<GroupMembership> {
|
||||
actions = [RPCAction.Create, RPCAction.Delete];
|
||||
@@ -24,7 +28,7 @@ export default class GroupMembershipsStore extends Store<GroupMembership> {
|
||||
documentId?: string;
|
||||
collectionId?: string;
|
||||
groupId?: string;
|
||||
}): Promise<GroupMembership[]> => {
|
||||
}): Promise<PaginatedResponse<GroupMembership>> => {
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
@@ -41,7 +45,7 @@ export default class GroupMembershipsStore extends Store<GroupMembership> {
|
||||
: await client.post(`/groupMemberships.list`, params);
|
||||
invariant(res?.data, "Data not available");
|
||||
|
||||
let response: GroupMembership[] = [];
|
||||
let response: PaginatedResponse<GroupMembership> = [];
|
||||
runInAction(`GroupMembershipsStore#fetchPage`, () => {
|
||||
res.data.groups?.forEach(this.rootStore.groups.add);
|
||||
res.data.documents?.forEach(this.rootStore.documents.add);
|
||||
|
||||
@@ -5,7 +5,11 @@ import Membership from "~/models/Membership";
|
||||
import { PaginationParams } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import RootStore from "./RootStore";
|
||||
import Store, { PAGINATION_SYMBOL, RPCAction } from "./base/Store";
|
||||
import Store, {
|
||||
PAGINATION_SYMBOL,
|
||||
PaginatedResponse,
|
||||
RPCAction,
|
||||
} from "./base/Store";
|
||||
|
||||
export default class MembershipsStore extends Store<Membership> {
|
||||
actions = [RPCAction.Create, RPCAction.Delete];
|
||||
@@ -17,14 +21,14 @@ export default class MembershipsStore extends Store<Membership> {
|
||||
@action
|
||||
fetchPage = async (
|
||||
params: (PaginationParams & { id?: string }) | undefined
|
||||
): Promise<Membership[]> => {
|
||||
): Promise<PaginatedResponse<Membership>> => {
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/collections.memberships`, params);
|
||||
invariant(res?.data, "Data not available");
|
||||
|
||||
let response: Membership[] = [];
|
||||
let response: PaginatedResponse<Membership> = [];
|
||||
runInAction(`MembershipsStore#fetchPage`, () => {
|
||||
res.data.users.forEach(this.rootStore.users.add);
|
||||
response = res.data.memberships.map(this.add);
|
||||
|
||||
+10
-9
@@ -102,14 +102,11 @@ export default class RootStore {
|
||||
*
|
||||
* @param modelName
|
||||
*/
|
||||
public getStoreForModelName<K extends keyof RootStore>(
|
||||
modelName: string
|
||||
): RootStore[K] {
|
||||
public getStoreForModelName<K extends keyof RootStore>(modelName: string) {
|
||||
const storeName = this.getStoreNameForModelName(modelName);
|
||||
const store = this[storeName];
|
||||
invariant(store, `No store found for model name "${modelName}"`);
|
||||
|
||||
return store;
|
||||
return store as RootStore[K];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,8 +115,9 @@ export default class RootStore {
|
||||
public clear() {
|
||||
Object.getOwnPropertyNames(this)
|
||||
.filter((key) => ["auth", "ui"].includes(key) === false)
|
||||
.forEach((key) => {
|
||||
this[key]?.clear?.();
|
||||
.forEach((key: keyof RootStore) => {
|
||||
// @ts-expect-error clear exists on all stores
|
||||
"clear" in this[key] && this[key].clear();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -128,7 +126,10 @@ export default class RootStore {
|
||||
*
|
||||
* @param StoreClass
|
||||
*/
|
||||
private registerStore<T = typeof Store>(StoreClass: T, name?: string) {
|
||||
private registerStore<T = typeof Store>(
|
||||
StoreClass: T,
|
||||
name?: keyof RootStore
|
||||
) {
|
||||
// @ts-expect-error TS thinks we are instantiating an abstract class.
|
||||
const store = new StoreClass(this);
|
||||
const storeName = name ?? this.getStoreNameForModelName(store.modelName);
|
||||
@@ -136,6 +137,6 @@ export default class RootStore {
|
||||
}
|
||||
|
||||
private getStoreNameForModelName(modelName: string) {
|
||||
return pluralize(lowerFirst(modelName));
|
||||
return pluralize(lowerFirst(modelName)) as keyof RootStore;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { action, autorun, computed, observable } from "mobx";
|
||||
import { flushSync } from "react-dom";
|
||||
import { light as defaultTheme } from "@shared/styles/theme";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import Document from "~/models/Document";
|
||||
import type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
|
||||
import { startViewTransition } from "~/utils/viewTransition";
|
||||
import type RootStore from "./RootStore";
|
||||
|
||||
const UI_STORE = "UI_STORE";
|
||||
@@ -140,7 +142,11 @@ class UiStore {
|
||||
|
||||
@action
|
||||
setTheme = (theme: Theme) => {
|
||||
this.theme = theme;
|
||||
startViewTransition(() => {
|
||||
flushSync(() => {
|
||||
this.theme = theme;
|
||||
});
|
||||
});
|
||||
Storage.set("theme", this.theme);
|
||||
};
|
||||
|
||||
|
||||
+64
-14
@@ -11,12 +11,12 @@ import { Pagination } from "@shared/constants";
|
||||
import { type JSONObject } from "@shared/types";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Policy from "~/models/Policy";
|
||||
import ArchivableModel from "~/models/base/ArchivableModel";
|
||||
import Model from "~/models/base/Model";
|
||||
import { LifecycleManager } from "~/models/decorators/Lifecycle";
|
||||
import { getInverseRelationsForModelClass } from "~/models/decorators/Relation";
|
||||
import type { PaginationParams, PartialWithId, Properties } from "~/types";
|
||||
import type { PaginationParams, PartialExcept, Properties } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||
|
||||
export enum RPCAction {
|
||||
@@ -28,10 +28,19 @@ export enum RPCAction {
|
||||
Count = "count",
|
||||
}
|
||||
|
||||
export type FetchPageParams = PaginationParams & Record<string, any>;
|
||||
|
||||
export const PAGINATION_SYMBOL = Symbol.for("pagination");
|
||||
|
||||
export type PaginatedResponse<T> = T[] & {
|
||||
[PAGINATION_SYMBOL]?: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
nextPath: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type FetchPageParams = PaginationParams & Record<string, any>;
|
||||
|
||||
export default abstract class Store<T extends Model> {
|
||||
@observable
|
||||
data: Map<string, T> = new Map();
|
||||
@@ -81,7 +90,7 @@ export default abstract class Store<T extends Model> {
|
||||
};
|
||||
|
||||
@action
|
||||
add = (item: PartialWithId<T> | T): T => {
|
||||
add = (item: PartialExcept<T, "id"> | T): T => {
|
||||
const ModelClass = this.model;
|
||||
|
||||
if (!(item instanceof ModelClass)) {
|
||||
@@ -128,6 +137,7 @@ export default abstract class Store<T extends Model> {
|
||||
if (deleteBehavior === "cascade") {
|
||||
store.remove(item.id);
|
||||
} else if (deleteBehavior === "null") {
|
||||
// @ts-expect-error TODO
|
||||
item[relation.idKey] = null;
|
||||
}
|
||||
});
|
||||
@@ -144,6 +154,43 @@ export default abstract class Store<T extends Model> {
|
||||
LifecycleManager.executeHooks(model.constructor, "afterRemove", model);
|
||||
}
|
||||
|
||||
@action
|
||||
addToArchive(item: ArchivableModel): void {
|
||||
const inverseRelations = getInverseRelationsForModelClass(this.model);
|
||||
|
||||
inverseRelations.forEach((relation) => {
|
||||
const store = this.rootStore.getStoreForModelName(relation.modelName);
|
||||
if ("orderedData" in store) {
|
||||
const items = (store.orderedData as ArchivableModel[]).filter(
|
||||
(data) => data[relation.idKey] === item.id
|
||||
);
|
||||
|
||||
items.forEach((item) => {
|
||||
let archiveBehavior = relation.options.onArchive;
|
||||
|
||||
if (typeof relation.options.onArchive === "function") {
|
||||
archiveBehavior = relation.options.onArchive(item);
|
||||
}
|
||||
|
||||
if (archiveBehavior === "cascade") {
|
||||
store.addToArchive(item);
|
||||
} else if (archiveBehavior === "null") {
|
||||
// @ts-expect-error TODO
|
||||
item[relation.idKey] = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Remove associated policies automatically, not defined through Relation decorator.
|
||||
if (this.modelName !== "Policy") {
|
||||
this.rootStore.policies.remove(item.id);
|
||||
}
|
||||
|
||||
item.archivedAt = new Date().toISOString();
|
||||
(this as unknown as Store<ArchivableModel>).add(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all items in the store that match the predicate.
|
||||
*
|
||||
@@ -245,7 +292,7 @@ export default abstract class Store<T extends Model> {
|
||||
async fetch(
|
||||
id: string,
|
||||
options: JSONObject = {},
|
||||
accessor = (res: unknown) => (res as { data: PartialWithId<T> }).data
|
||||
accessor = (res: unknown) => (res as { data: PartialExcept<T, "id"> }).data
|
||||
): Promise<T> {
|
||||
if (!this.actions.includes(RPCAction.Info)) {
|
||||
throw new Error(`Cannot fetch ${this.modelName}`);
|
||||
@@ -279,7 +326,9 @@ export default abstract class Store<T extends Model> {
|
||||
}
|
||||
|
||||
@action
|
||||
fetchPage = async (params?: FetchPageParams | undefined): Promise<T[]> => {
|
||||
fetchPage = async (
|
||||
params?: FetchPageParams | undefined
|
||||
): Promise<PaginatedResponse<T>> => {
|
||||
if (!this.actions.includes(RPCAction.List)) {
|
||||
throw new Error(`Cannot list ${this.modelName}`);
|
||||
}
|
||||
@@ -290,7 +339,7 @@ export default abstract class Store<T extends Model> {
|
||||
const res = await client.post(`/${this.apiEndpoint}.list`, params);
|
||||
invariant(res?.data, "Data not available");
|
||||
|
||||
let response: T[] = [];
|
||||
let response: PaginatedResponse<T> = [];
|
||||
|
||||
runInAction(`list#${this.modelName}`, () => {
|
||||
this.addPolicies(res.policies);
|
||||
@@ -306,15 +355,16 @@ export default abstract class Store<T extends Model> {
|
||||
};
|
||||
|
||||
@action
|
||||
fetchAll = async (params?: Record<string, any>): Promise<T[]> => {
|
||||
fetchAll = async (
|
||||
params?: Record<string, any>
|
||||
): Promise<PaginatedResponse<T>> => {
|
||||
const limit = params?.limit ?? Pagination.defaultLimit;
|
||||
const response = await this.fetchPage({ ...params, limit });
|
||||
|
||||
if (!response[PAGINATION_SYMBOL]) {
|
||||
Logger.warn("Pagination information not available in response", {
|
||||
params,
|
||||
});
|
||||
}
|
||||
invariant(
|
||||
response[PAGINATION_SYMBOL],
|
||||
"Pagination information not available in response"
|
||||
);
|
||||
|
||||
const pages = Math.ceil(response[PAGINATION_SYMBOL].total / limit);
|
||||
const fetchPages = [];
|
||||
|
||||
+15
-5
@@ -14,7 +14,8 @@ import Pin from "./models/Pin";
|
||||
import Star from "./models/Star";
|
||||
import UserMembership from "./models/UserMembership";
|
||||
|
||||
export type PartialWithId<T> = Partial<T> & { id: string };
|
||||
export type PartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> &
|
||||
Required<Pick<T, K>>;
|
||||
|
||||
export type MenuItemButton = {
|
||||
type: "button";
|
||||
@@ -188,10 +189,10 @@ export type WebsocketCollectionUpdateIndexEvent = {
|
||||
};
|
||||
|
||||
export type WebsocketEvent =
|
||||
| PartialWithId<Pin>
|
||||
| PartialWithId<Star>
|
||||
| PartialWithId<FileOperation>
|
||||
| PartialWithId<UserMembership>
|
||||
| PartialExcept<Pin, "id">
|
||||
| PartialExcept<Star, "id">
|
||||
| PartialExcept<FileOperation, "id">
|
||||
| PartialExcept<UserMembership, "id">
|
||||
| WebsocketCollectionUpdateIndexEvent
|
||||
| WebsocketEntityDeletedEvent
|
||||
| WebsocketEntitiesEvent;
|
||||
@@ -214,3 +215,12 @@ export type Properties<C> = {
|
||||
? Property
|
||||
: never]?: C[Property];
|
||||
};
|
||||
|
||||
export enum CommentSortType {
|
||||
MostRecent = "mostRecent",
|
||||
OrderInDocument = "orderInDocument",
|
||||
}
|
||||
|
||||
export type CommentSortOption =
|
||||
| { type: CommentSortType.MostRecent }
|
||||
| { type: CommentSortType.OrderInDocument; referencedCommentIds: string[] };
|
||||
|
||||
+3
-2
@@ -17,13 +17,14 @@ import {
|
||||
getCurrentTimeAsString,
|
||||
unicodeCLDRtoBCP47,
|
||||
dateLocale,
|
||||
locales,
|
||||
} from "@shared/utils/date";
|
||||
import User from "~/models/User";
|
||||
|
||||
export function dateToHeading(
|
||||
dateTime: string,
|
||||
t: TFunction,
|
||||
userLocale: string | null | undefined
|
||||
userLocale: keyof typeof locales | undefined
|
||||
) {
|
||||
const date = Date.parse(dateTime);
|
||||
const now = new Date();
|
||||
@@ -84,7 +85,7 @@ export function dateToHeading(
|
||||
export function dateToExpiry(
|
||||
dateTime: string,
|
||||
t: TFunction,
|
||||
userLocale: string | null | undefined
|
||||
userLocale: keyof typeof locales | null | undefined
|
||||
) {
|
||||
const date = Date.parse(dateTime);
|
||||
const now = new Date();
|
||||
|
||||
@@ -33,7 +33,9 @@ export default function download(
|
||||
// reverse arguments, allowing download.bind(true, "text/xml", "export.xml") to act as a callback
|
||||
// @ts-expect-error this is weird code
|
||||
x = [x, m];
|
||||
// @ts-expect-error this is weird code
|
||||
m = x[0];
|
||||
// @ts-expect-error this is weird code
|
||||
x = x[1];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { i18n } from "i18next";
|
||||
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
|
||||
import { locales, unicodeCLDRtoBCP47 } from "@shared/utils/date";
|
||||
import Desktop from "./Desktop";
|
||||
|
||||
/**
|
||||
@@ -25,7 +25,7 @@ export function formatNumber(number: number, locale: string) {
|
||||
export function detectLanguage() {
|
||||
const [ln, r] = navigator.language.split("-");
|
||||
const region = (r || ln).toUpperCase();
|
||||
return `${ln}_${region}`;
|
||||
return `${ln}_${region}` as keyof typeof locales;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,5 +19,5 @@ export function getVisibilityListener(): string {
|
||||
}
|
||||
|
||||
export function getPageVisible(): boolean {
|
||||
return !document[hidden];
|
||||
return !document[hidden as keyof Document];
|
||||
}
|
||||
|
||||
@@ -25,7 +25,9 @@ export function settingsPath(section?: string): string {
|
||||
}
|
||||
|
||||
export function commentPath(document: Document, comment: Comment): string {
|
||||
return `${documentPath(document)}?commentId=${comment.id}`;
|
||||
return `${documentPath(document)}?commentId=${comment.id}${
|
||||
comment.isResolved ? "&resolved=" : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
export function collectionPath(url: string, section?: string): string {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* A simple wrapper around the startViewTransition API, if it exists. Otherwise
|
||||
* it will just call the callback immediately.
|
||||
*
|
||||
* @param callback The callback to call inside the view transition.
|
||||
*/
|
||||
export const startViewTransition = (callback: UpdateCallback) => {
|
||||
if (self.document.startViewTransition) {
|
||||
self.document.startViewTransition(callback);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3"
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
|
||||
+18
-18
@@ -58,7 +58,7 @@
|
||||
"@babel/plugin-transform-class-properties": "^7.24.7",
|
||||
"@babel/plugin-transform-destructuring": "^7.24.8",
|
||||
"@babel/plugin-transform-regenerator": "^7.24.7",
|
||||
"@babel/preset-env": "^7.24.7",
|
||||
"@babel/preset-env": "^7.25.8",
|
||||
"@babel/preset-react": "^7.24.7",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^4.2.2",
|
||||
@@ -73,7 +73,6 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@getoutline/react-roving-tabindex": "^3.2.4",
|
||||
"@getoutline/y-prosemirror": "^1.0.18",
|
||||
"@hocuspocus/extension-throttle": "1.1.2",
|
||||
"@hocuspocus/provider": "1.1.2",
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
@@ -100,7 +99,7 @@
|
||||
"class-validator": "^0.14.1",
|
||||
"command-score": "^0.1.2",
|
||||
"compressorjs": "^1.2.1",
|
||||
"cookie": "^0.6.0",
|
||||
"cookie": "^0.7.0",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"core-js": "^3.37.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
@@ -111,7 +110,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"email-providers": "^1.14.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"emoji-regex": "^10.3.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"es6-error": "^4.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fetch-retry": "^5.0.6",
|
||||
@@ -159,7 +158,7 @@
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^6.9.14",
|
||||
"octokit": "^3.2.1",
|
||||
"outline-icons": "^3.8.0",
|
||||
"outline-icons": "^3.10.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth2": "^0.2.0",
|
||||
@@ -178,12 +177,12 @@
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.0",
|
||||
"prosemirror-model": "^1.22.3",
|
||||
"prosemirror-markdown": "^1.13.1",
|
||||
"prosemirror-model": "^1.23.0",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.4.0",
|
||||
"prosemirror-transform": "^1.10.0",
|
||||
"prosemirror-transform": "^1.10.2",
|
||||
"prosemirror-view": "^1.34.3",
|
||||
"query-string": "^7.1.3",
|
||||
"randomstring": "1.3.0",
|
||||
@@ -239,11 +238,12 @@
|
||||
"utility-types": "^3.10.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vite": "^5.3.1",
|
||||
"vite": "^5.4.8",
|
||||
"vite-plugin-pwa": "^0.20.3",
|
||||
"winston": "^3.13.0",
|
||||
"ws": "^7.5.10",
|
||||
"y-indexeddb": "^9.0.11",
|
||||
"y-prosemirror": "^1.2.12",
|
||||
"y-protocols": "^1.0.6",
|
||||
"yauzl": "^2.10.0",
|
||||
"yjs": "^13.6.1",
|
||||
@@ -253,7 +253,7 @@
|
||||
"@babel/cli": "^7.23.4",
|
||||
"@babel/preset-typescript": "^7.24.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.2.9",
|
||||
"@relative-ci/agent": "^4.2.12",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
@@ -281,7 +281,7 @@
|
||||
"@types/koa-send": "^4.1.6",
|
||||
"@types/koa-sslify": "^4.0.6",
|
||||
"@types/koa-useragent": "^2.1.2",
|
||||
"@types/markdown-it": "^12.2.3",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/markdown-it-container": "^2.0.9",
|
||||
"@types/markdown-it-emoji": "^2.0.4",
|
||||
"@types/mermaid": "^9.2.0",
|
||||
@@ -296,7 +296,7 @@
|
||||
"@types/quoted-printable": "^1.0.2",
|
||||
"@types/randomstring": "^1.3.0",
|
||||
"@types/react": "^17.0.34",
|
||||
"@types/react-avatar-editor": "^13.0.2",
|
||||
"@types/react-avatar-editor": "^13.0.3",
|
||||
"@types/react-color": "^3.0.12",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
@@ -307,7 +307,7 @@
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/readable-stream": "^4.0.15",
|
||||
"@types/redis-info": "^3.0.3",
|
||||
"@types/refractor": "^3.4.0",
|
||||
"@types/refractor": "^3.4.1",
|
||||
"@types/resolve-path": "^1.4.2",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/sequelize": "^4.28.20",
|
||||
@@ -316,7 +316,7 @@
|
||||
"@types/styled-components": "^5.1.32",
|
||||
"@types/throng": "^5.0.7",
|
||||
"@types/tmp": "^0.2.6",
|
||||
"@types/turndown": "^5.0.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/utf8": "^3.0.3",
|
||||
"@types/validator": "^13.12.1",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
@@ -329,7 +329,7 @@
|
||||
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
|
||||
"browserslist-to-esbuild": "^1.2.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"discord-api-types": "^0.37.101",
|
||||
"discord-api-types": "^0.37.102",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.3",
|
||||
@@ -347,14 +347,14 @@
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"lint-staged": "^13.3.0",
|
||||
"nodemon": "^3.1.4",
|
||||
"nodemon": "^3.1.7",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^2.8.8",
|
||||
"react-refresh": "^0.14.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^0.4.1",
|
||||
"terser": "^5.32.0",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
},
|
||||
@@ -362,7 +362,7 @@
|
||||
"body-scroll-lock": "^4.0.0-beta.0",
|
||||
"d3": "^7.0.0",
|
||||
"debug": "4.3.4",
|
||||
"node-fetch": "^2.6.12",
|
||||
"node-fetch": "^2.7.0",
|
||||
"js-yaml": "^3.14.1",
|
||||
"qs": "6.9.7",
|
||||
"rollup": "^4.5.1"
|
||||
|
||||
@@ -69,9 +69,9 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!organizationResponse) {
|
||||
if (!organizationResponse?.value?.length) {
|
||||
throw MicrosoftGraphError(
|
||||
"Unable to load organization info from Microsoft Graph API"
|
||||
`Unable to load organization info from Microsoft Graph API: ${organizationResponse.error?.message}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,9 @@ class AzurePluginEnvironment extends Environment {
|
||||
);
|
||||
|
||||
@IsOptional()
|
||||
@CannotUseWithout("AZURE_CLIENT_ID")
|
||||
public AZURE_RESOURCE_APP_ID = this.toOptionalString(
|
||||
environment.AZURE_RESOURCE_APP_ID
|
||||
);
|
||||
public AZURE_RESOURCE_APP_ID =
|
||||
this.toOptionalString(environment.AZURE_RESOURCE_APP_ID) ??
|
||||
"00000003-0000-0000-c000-000000000000";
|
||||
|
||||
@IsOptional()
|
||||
@CannotUseWithout("AZURE_CLIENT_ID")
|
||||
|
||||
@@ -74,6 +74,12 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
if (!domain && !team) {
|
||||
const userExists = await User.count({
|
||||
where: { email: profile.email.toLowerCase() },
|
||||
include: [
|
||||
{
|
||||
association: "team",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Users cannot create a team with personal gmail accounts
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user