Compare commits

..

14 Commits

Author SHA1 Message Date
Salihu 9b9275dff0 re-run tests 2025-12-06 12:20:17 +01:00
Salihu 39f0f78ff4 code cleanup 2025-12-06 12:07:48 +01:00
Salihu b2a0a9cf21 use actions for bulk selection menu 2025-12-06 00:31:33 +01:00
Salihu c0ee1aa3d7 code cleanup 2025-12-04 23:12:52 +01:00
Salihu b9e34e4227 code cleanup 2025-12-04 22:55:42 +01:00
Salihu b405e1e985 clean up code 2025-12-04 22:24:35 +01:00
Salihu e0e6b3f3db fetch documents when selected 2025-12-04 21:24:03 +01:00
Salihu b2b0bd8c8f fix bugs 2025-12-04 21:21:19 +01:00
Salihu 1a8d75b81b fix bugs 2025-12-04 20:38:50 +01:00
Salihu 6c3816e07c use DocumentArchive component for single and bulk archiving 2025-12-04 20:38:50 +01:00
Salihu d264848024 use DocumentDelete component for bulk and single delete 2025-12-04 20:38:50 +01:00
Salihu 65a3d1ac47 use DocumentMove component for bulk and single move 2025-12-04 20:38:50 +01:00
Salihu af98549ca7 rework bulk selection functionality 2025-12-04 20:38:48 +01:00
Salihu ce1d2a90c0 select mutiple documents 2025-12-04 19:58:39 +01:00
129 changed files with 2635 additions and 4304 deletions
-5
View File
@@ -1,10 +1,6 @@
NODE_ENV=test
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline-test
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET=test-utils-secret-key
REDIS_URL=redis://localhost:6379
URL=http://localhost:3000
COLLABORATION_URL=http://localhost:3001
SMTP_HOST=smtp.example.com
SMTP_USERNAME=test
@@ -16,7 +12,6 @@ GOOGLE_CLIENT_SECRET=123
SLACK_CLIENT_ID=123
SLACK_CLIENT_SECRET=123
SLACK_VERIFICATION_TOKEN=test-token-123
GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
+1 -2
View File
@@ -94,8 +94,7 @@
"args": "after-used",
"ignoreRestSiblings": true
}
],
"react/rules-of-hooks": "error"
]
},
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
}
+123
View File
@@ -0,0 +1,123 @@
import { ArchiveIcon, MoveIcon, TrashIcon } from "outline-icons";
import DocumentMove from "~/scenes/DocumentMove";
import { createAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentArchive from "~/scenes/DocumentArchive";
import Document from "~/models/Document";
type Props = {
documents: Document[];
};
/**
* Archive multiple documents at once.
*/
export const bulkArchiveDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Bulk archive documents",
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).archive);
},
perform: async ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Archive {{ count }} documents", { count }),
content: (
<DocumentArchive
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
/**
* Move multiple documents at once.
*/
export const bulkMoveDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Move")}`,
analyticsName: "Bulk move documents",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).move);
},
perform: ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Move {{ count }} documents", { count }),
content: (
<DocumentMove
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
/**
* Delete multiple documents at once.
*/
export const bulkDeleteDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Bulk delete documents",
section: ActiveDocumentSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).delete);
},
perform: async ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Delete {{ count }} documents", { count }),
content: (
<DocumentDelete
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
export const rootBulkDocumentActions = [
bulkArchiveDocuments,
bulkMoveDocuments,
bulkDeleteDocuments,
];
+4 -21
View File
@@ -47,7 +47,6 @@ import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -81,6 +80,7 @@ import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Action, ActionGroup, ActionSeparator } from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DocumentArchive from "~/scenes/DocumentArchive";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
@@ -1028,7 +1028,7 @@ export const moveDocumentToCollection = createAction({
title: t("Move {{ documentType }}", {
documentType: document.noun,
}),
content: <DocumentMove document={document} />,
content: <DocumentMove documents={[document]} />,
});
}
},
@@ -1094,19 +1094,7 @@ export const archiveDocument = createAction({
dialogs.openModal({
title: t("Are you sure you want to archive this document?"),
content: (
<ConfirmationDialog
onSubmit={async () => {
await document.archive();
toast.success(t("Document archived"));
}}
savingText={`${t("Archiving")}`}
>
{t(
"Archiving this document will remove it from the collection and search results."
)}
</ConfirmationDialog>
),
content: <DocumentArchive documents={[document]} />,
});
}
},
@@ -1230,12 +1218,7 @@ export const deleteDocument = createAction({
title: t("Delete {{ documentName }}", {
documentName: document.noun,
}),
content: (
<DocumentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
content: <DocumentDelete documents={[document]} />,
});
}
},
+1 -1
View File
@@ -21,7 +21,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
* Button that can be used to trigger an action definition.
*/
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
function ActionButton_(
function _ActionButton(
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) {
-1
View File
@@ -21,7 +21,6 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
font-size: 12px;
font-weight: 500;
user-select: none;
white-space: nowrap;
`;
export default Badge;
+108
View File
@@ -0,0 +1,108 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import {
MenuHeader,
MenuSeparator,
} from "~/components/primitives/components/Menu";
import { Portal } from "~/components/Portal";
import { toMobileMenuItems } from "~/components/Menu/transformer";
import { actionToMenuItem } from "~/actions";
import { useBulkDocumentMenuAction } from "~/hooks/useBulkDocumentMenuAction";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
import { ActionVariant } from "~/types";
import NudeButton from "./NudeButton";
import { CrossIcon } from "outline-icons";
function BulkSelectionToolbar() {
const { t } = useTranslation();
const { documents, ui } = useStores();
const selectedCount = documents.selectedDocumentIds.length;
const selectedDocuments = documents.selectedDocuments;
const sidebarWidth = ui.sidebarWidth;
const handleClearSelection = React.useCallback(() => {
documents.clearSelection();
}, [documents]);
const rootAction = useBulkDocumentMenuAction({
documents: selectedDocuments,
});
const actionContext = useActionContext({
isMenu: true,
});
const menuItems = React.useMemo(() => {
if (!rootAction.children || selectedCount === 0) {
return [];
}
return (rootAction.children as ActionVariant[]).map((childAction) =>
actionToMenuItem(childAction, actionContext)
);
}, [rootAction.children, selectedCount, actionContext]);
const content = toMobileMenuItems(menuItems, handleClearSelection, () => {});
if (selectedCount === 0) {
return null;
}
return (
<Portal>
<Wrapper $sidebarWidth={sidebarWidth}>
<MenuContainer>
<Header>
<MenuHeader>
{t("{{ count }} selected", { count: selectedCount })}
</MenuHeader>
<ClearButton
onClick={handleClearSelection}
tooltip={{
content: t("Clear selection"),
}}
>
<CrossIcon size={18} />
</ClearButton>
</Header>
<MenuSeparator />
{content}
</MenuContainer>
</Wrapper>
</Portal>
);
}
const ClearButton = styled(NudeButton)`
&:hover {
color: ${s("text")};
background: ${s("sidebarControlHoverBackground")};
}
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const Wrapper = styled.div<{ $sidebarWidth: number }>`
position: fixed;
bottom: 24px;
left: ${(props) => props.$sidebarWidth + 16}px;
z-index: ${depths.menu};
`;
const MenuContainer = styled.div`
min-width: 180px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
padding: 6px;
`;
export default observer(BulkSelectionToolbar);
+1 -1
View File
@@ -28,7 +28,7 @@ export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
name: collection.name,
section: ActiveCollectionSection,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection),
to: collectionPath(collection.path),
}),
],
[collection, t]
+1 -1
View File
@@ -31,7 +31,7 @@ export type RefHandle = {
* Defines a content editable component with the same interface as a native
* HTMLInputElement (or, as close as we can get).
*/
const ContentEditable = React.forwardRef(function ContentEditable_(
const ContentEditable = React.forwardRef(function _ContentEditable(
{
disabled,
onChange,
+23 -29
View File
@@ -8,7 +8,7 @@ import { AttachmentPreset } from "@shared/types";
import { getDataTransferFiles } from "@shared/utils/files";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Input, { LabelText } from "~/components/Input";
import Input from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
@@ -108,16 +108,12 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
setIsUploading(true);
try {
// Skip compression for GIFs to preserve animation
const fileToUpload =
file.type === "image/gif"
? file
: await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
const compressed = await compressImage(file, {
maxHeight: 64,
maxWidth: 64,
});
const attachment = await uploadFile(fileToUpload, {
const attachment = await uploadFile(compressed, {
name: file.name,
preset: AttachmentPreset.Emoji,
});
@@ -151,11 +147,26 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
>
<Text as="p" type="secondary">
{t(
"Square images with transparent backgrounds work best. If your image is too large, well try to resize it for you."
"The emoji name should be unique and contain only lowercase letters, numbers, and underscores."
)}
</Text>
<LabelText as="label">{t("Upload an image")}</LabelText>
<Input
label={t("Name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
error={
!isValidName
? t(
"name can only contain lowercase letters, numbers, and underscores."
)
: undefined
}
/>
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<Flex column align="center" gap={8}>
@@ -186,22 +197,6 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
</Flex>
</DropZone>
<Input
label={t("Choose a name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
error={
!isValidName
? t(
"name can only contain lowercase letters, numbers, and underscores."
)
: undefined
}
/>
{name.trim() && isValidName && (
<Text type="secondary" style={{ marginTop: "8px" }}>
{t("This emoji will be available as")} <code>:{name}:</code>
@@ -218,7 +213,6 @@ const DropZone = styled.div`
text-align: center;
cursor: var(--pointer);
transition: border-color 0.2s;
margin-bottom: 1em;
&:hover {
border-color: ${s("inputBorderFocused")};
@@ -15,7 +15,7 @@ import {
type Props = Omit<UnfurlResponse[UnfurlResourceType.Document], "type">;
const HoverPreviewDocument = React.forwardRef(function HoverPreviewDocument_(
const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
{ url, id, title, summary, lastActivityByViewer }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -17,7 +17,7 @@ import ErrorBoundary from "../ErrorBoundary";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Group], "type">;
const HoverPreviewGroup = React.forwardRef(function HoverPreviewGroup_(
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
{ name, description, memberCount, users }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -24,7 +24,7 @@ import {
type Props = Omit<UnfurlResponse[UnfurlResourceType.Issue], "type">;
const HoverPreviewIssue = React.forwardRef(function HoverPreviewIssue_(
const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
{ url, id, title, description, author, labels, state, createdAt }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -20,7 +20,7 @@ type Props = {
description: string;
};
const HoverPreviewLink = React.forwardRef(function HoverPreviewLink_(
const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
{ url, thumbnailUrl, title, description }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -6,7 +6,7 @@ import { Preview, Title, Info, Card, CardContent } from "./Components";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
const HoverPreviewMention = React.forwardRef(function HoverPreviewMention_(
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
{ avatarUrl, name, lastActive, color }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -20,7 +20,7 @@ import {
type Props = Omit<UnfurlResponse[UnfurlResourceType.PR], "type">;
const HoverPreviewPullRequest = React.forwardRef(
function HoverPreviewPullRequest_(
function _HoverPreviewPullRequest(
{ url, title, id, description, author, state, createdAt }: Props,
ref: React.Ref<HTMLDivElement>
) {
@@ -9,5 +9,4 @@ export const UserInputContainer = styled(Flex)`
export const StyledInputSearch = styled(InputSearch)`
flex-grow: 1;
min-width: 0;
`;
@@ -0,0 +1,170 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Flex from "~/components/Flex";
import useStores from "~/hooks/useStores";
import GridTemplate, { DataNode, EmojiNode } from "./GridTemplate";
import { IconType } from "@shared/types";
import { DisplayCategory } from "../utils";
import { StyledInputSearch, UserInputContainer } from "./Components";
import { useIconState } from "../useIconState";
import Emoji from "~/models/Emoji";
const GRID_HEIGHT = 410;
type Props = {
panelWidth: number;
height?: number;
query: string;
panelActive: boolean;
onEmojiChange: (emoji: string) => void;
onQueryChange: (query: string) => void;
};
const CustomEmojiPanel = ({
query,
panelActive,
panelWidth,
height = GRID_HEIGHT,
onEmojiChange,
onQueryChange,
}: Props) => {
const { t } = useTranslation();
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const [searchData, setSearchData] = useState<DataNode[]>([]);
const [freqEmojis, setFreqEmojis] = useState<EmojiNode[]>([]);
const { getFrequentIcons, incrementIconCount } = useIconState(
IconType.Custom
);
const { emojis } = useStores();
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
},
[onQueryChange]
);
useEffect(() => {
if (query.trim()) {
const initialData = emojis.findByQuery(query);
if (initialData.length) {
setSearchData([
{
category: DisplayCategory.Search,
icons: initialData?.map(toIcon),
},
]);
}
emojis
.fetchAll({
query,
})
.then((data) => {
if (data.length) {
const iconMap = new Map([
...initialData.map((emoji): [string, EmojiNode] => [
emoji.name,
toIcon(emoji),
]),
...data.map((emoji): [string, EmojiNode] => [
emoji.name,
toIcon(emoji),
]),
]);
setSearchData([
{
category: DisplayCategory.Search,
icons: Array.from(iconMap.values()),
},
]);
return;
}
setSearchData([]);
});
} else {
setSearchData([]);
}
}, [query, emojis]);
useEffect(() => {
getFrequentIcons().forEach((id) => {
emojis
.fetch(id)
.then((emoji) => {
setFreqEmojis((prev) => {
if (prev.some((item) => item.id === id)) {
return prev;
}
return [...prev, toIcon(emoji)];
});
})
.catch(() => {
// ignore
});
});
}, [getFrequentIcons, emojis]);
const handleEmojiSelection = React.useCallback(
({ id }: { id: string }) => {
onEmojiChange(id);
incrementIconCount(id);
},
[onEmojiChange, incrementIconCount]
);
const templateData: DataNode[] = React.useMemo(
() => [
{
category: DisplayCategory.Frequent,
icons: freqEmojis,
},
{
category: DisplayCategory.All,
icons: emojis.orderedData.map(toIcon),
},
],
[emojis.orderedData, freqEmojis]
);
React.useLayoutEffect(() => {
if (!panelActive) {
return;
}
scrollableRef.current?.scroll({ top: 0 });
requestAnimationFrame(() => searchRef.current?.focus());
}, [panelActive]);
return (
<Flex column>
<UserInputContainer align="center" gap={12}>
<StyledInputSearch
ref={searchRef}
value={query}
placeholder={`${t("Search")}`}
onChange={handleFilter}
/>
</UserInputContainer>
<GridTemplate
ref={scrollableRef}
width={panelWidth}
height={height - 48}
data={searchData.length ? searchData : templateData}
onIconSelect={handleEmojiSelection}
/>
</Flex>
);
};
const toIcon = (emoji: Emoji): EmojiNode => ({
type: IconType.Custom,
id: emoji.id,
value: emoji.id,
name: emoji.name,
});
export default CustomEmojiPanel;
@@ -1,23 +1,14 @@
import concat from "lodash/concat";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji";
import Flex from "~/components/Flex";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
import { DisplayCategory } from "../utils";
import GridTemplate, { DataNode, EmojiNode } from "./GridTemplate";
import GridTemplate, { DataNode } from "./GridTemplate";
import SkinTonePicker from "./SkinTonePicker";
import { StyledInputSearch, UserInputContainer } from "./Components";
import { useIconState } from "../useIconState";
import useStores from "~/hooks/useStores";
import Emoji from "~/models/Emoji";
import { useComputed } from "~/hooks/useComputed";
import { MenuButton } from "./MenuButton";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import { IconButton } from "./IconButton";
const GRID_HEIGHT = 410;
@@ -39,15 +30,9 @@ const EmojiPanel = ({
height = GRID_HEIGHT,
}: Props) => {
const { t } = useTranslation();
const { emojis, dialogs } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team);
const searchRef = React.useRef<HTMLInputElement | null>(null);
const scrollableRef = React.useRef<HTMLDivElement | null>(null);
const customEmojis = useComputed(
() => emojis.orderedData.map(toIcon),
[emojis.orderedData]
);
const {
emojiSkinTone: skinTone,
@@ -56,20 +41,11 @@ const EmojiPanel = ({
getFrequentIcons,
} = useIconState(IconType.Emoji);
const {
incrementIconCount: incrementCustomIconCount,
getFrequentIcons: getFrequentCustomIcons,
} = useIconState(IconType.Custom);
const freqEmojis = React.useMemo(
() => getFrequentIcons(),
[getFrequentIcons]
);
const [freqCustomEmojis, setFreqCustomEmojis] = React.useState<EmojiNode[]>(
[]
);
const handleFilter = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
onQueryChange(event.target.value);
@@ -84,68 +60,23 @@ const EmojiPanel = ({
[setEmojiSkinTone]
);
const handleUploadClick = React.useCallback(() => {
dialogs.openModal({
title: t("Upload emoji"),
content: <EmojiCreateDialog onSubmit={dialogs.closeAllModals} />,
});
}, [dialogs, t]);
const handleEmojiSelection = React.useCallback(
({ id, value }: { id: string; value: string }) => {
onEmojiChange(value);
// Determine if this is a custom emoji by checking if it's in the custom emoji data
const isCustomEmoji =
customEmojis.some((emoji) => emoji.id === id) ||
freqCustomEmojis.some((emoji) => emoji.id === id);
if (isCustomEmoji) {
incrementCustomIconCount(id);
} else {
incrementIconCount(id);
}
incrementIconCount(id);
},
[
onEmojiChange,
incrementIconCount,
incrementCustomIconCount,
customEmojis,
freqCustomEmojis,
]
[onEmojiChange, incrementIconCount]
);
React.useEffect(() => {
// Load frequent custom emojis
getFrequentCustomIcons().forEach((id) => {
emojis
.fetch(id)
.then((emoji) => {
setFreqCustomEmojis((prev) => {
if (prev.some((item) => item.id === id)) {
return prev;
}
return [...prev, toIcon(emoji)];
});
})
.catch(() => {
// ignore
});
});
}, [emojis, getFrequentCustomIcons]);
const isSearch = query !== "";
const templateData: DataNode[] = isSearch
? getSearchResults({
query,
skinTone,
customEmojis,
})
: getAllEmojis({
skinTone,
freqEmojis,
customEmojis,
freqCustomEmojis,
});
React.useLayoutEffect(() => {
@@ -158,7 +89,7 @@ const EmojiPanel = ({
return (
<Flex column>
<UserInputContainer align="center" gap={8}>
<UserInputContainer align="center" gap={12}>
<StyledInputSearch
ref={searchRef}
value={query}
@@ -166,14 +97,6 @@ const EmojiPanel = ({
onChange={handleFilter}
/>
<SkinTonePicker skinTone={skinTone} onChange={handleSkinChange} />
{can.update && (
<MenuButton
onClick={handleUploadClick}
aria-label={t("Upload emoji")}
>
<PlusIcon />
</MenuButton>
)}
</UserInputContainer>
<GridTemplate
ref={scrollableRef}
@@ -181,11 +104,6 @@ const EmojiPanel = ({
height={height - 48}
data={templateData}
onIconSelect={handleEmojiSelection}
empty={
<IconButton onClick={handleUploadClick}>
<PlusIcon />
</IconButton>
}
/>
</Flex>
);
@@ -194,32 +112,19 @@ const EmojiPanel = ({
const getSearchResults = ({
query,
skinTone,
customEmojis,
}: {
query: string;
skinTone: EmojiSkinTone;
customEmojis: EmojiNode[];
}): DataNode[] => {
const emojis = search({ query, skinTone });
// Search custom emojis by name
const matchingCustomEmojis = customEmojis.filter((emoji) =>
emoji.name?.toLowerCase().includes(query.toLowerCase())
);
const allResults = [
...matchingCustomEmojis,
...emojis.map((emoji) => ({
type: IconType.Emoji as const,
id: emoji.id,
value: emoji.value,
})),
];
return [
{
category: DisplayCategory.Search,
icons: allResults,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
},
];
};
@@ -227,32 +132,21 @@ const getSearchResults = ({
const getAllEmojis = ({
skinTone,
freqEmojis,
customEmojis,
freqCustomEmojis,
}: {
skinTone: EmojiSkinTone;
freqEmojis: string[];
customEmojis: EmojiNode[];
freqCustomEmojis: EmojiNode[];
}): DataNode[] => {
const emojisWithCategory = getEmojisWithCategory({ skinTone });
const getFrequentIcons = (): DataNode => {
const emojis = getEmojis({ ids: freqEmojis, skinTone });
// Combine frequent standard and custom emojis
const allFrequent = [
...emojis.map((emoji) => ({
type: IconType.Emoji as const,
return {
category: DisplayCategory.Frequent,
icons: emojis.map((emoji) => ({
type: IconType.Emoji,
id: emoji.id,
value: emoji.value,
})),
...freqCustomEmojis,
];
return {
category: DisplayCategory.Frequent,
icons: allFrequent,
};
};
@@ -268,7 +162,7 @@ const getAllEmojis = ({
};
};
const allData = concat(
return concat(
getFrequentIcons(),
getCategoryData(EmojiCategory.People),
getCategoryData(EmojiCategory.Nature),
@@ -279,22 +173,6 @@ const getAllEmojis = ({
getCategoryData(EmojiCategory.Symbols),
getCategoryData(EmojiCategory.Flags)
);
if (customEmojis.length) {
allData.push({
category: "Custom",
icons: customEmojis,
});
}
return allData;
};
const toIcon = (emoji: Emoji): EmojiNode => ({
type: IconType.Custom,
id: emoji.id,
value: emoji.id,
name: emoji.name,
});
export default EmojiPanel;
@@ -37,20 +37,14 @@ export type DataNode = {
};
type Props = {
/** Width of the grid container */
width: number;
/** Height of the grid container */
height: number;
/** Data to be displayed in the grid */
data: DataNode[];
/** Content to display when search results are empty */
empty?: React.ReactNode;
/** Callback when an icon is selected */
onIconSelect: ({ id, value }: { id: string; value: string }) => void;
};
const GridTemplate = (
{ width, height, data, empty, onIconSelect }: Props,
{ width, height, data, onIconSelect }: Props,
ref: React.Ref<HTMLDivElement>
) => {
// 24px padding for the Grid Container
@@ -58,6 +52,10 @@ const GridTemplate = (
const gridItems = compact(
data.flatMap((node) => {
if (node.icons.length === 0) {
return [];
}
const category = (
<CategoryName
key={node.category}
@@ -69,13 +67,6 @@ const GridTemplate = (
</CategoryName>
);
if (node.icons.length === 0) {
if (node.category !== "Search") {
return [];
}
return [[category], [empty]];
}
const items = node.icons.map((item) => {
if (item.type === IconType.SVG) {
return (
@@ -1,19 +0,0 @@
import { hover, s } from "@shared/styles";
import styled from "styled-components";
import NudeButton from "~/components/NudeButton";
export const MenuButton = styled(NudeButton)`
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
color: ${s("textSecondary")};
border: 1px solid ${s("inputBorder")};
padding: 4px;
&: ${hover} {
border: 1px solid ${s("inputBorderFocused")};
}
`;
@@ -1,17 +1,18 @@
import { useMemo, useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { EmojiSkinTone } from "@shared/types";
import { getEmojiVariants } from "@shared/utils/emoji";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/primitives/Popover";
import { IconButton } from "./IconButton";
import { MenuButton } from "./MenuButton";
const SkinTonePicker = ({
skinTone,
@@ -56,9 +57,9 @@ const SkinTonePicker = ({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<MenuButton aria-label={t("Choose default skin tone")}>
<StyledMenuButton aria-label={t("Choose default skin tone")}>
{handEmojiVariants[skinTone]?.value}
</MenuButton>
</StyledMenuButton>
</PopoverTrigger>
<PopoverContent
side="bottom"
@@ -78,4 +79,15 @@ const Emojis = styled(Flex)`
padding: 0 8px;
`;
const StyledMenuButton = styled(NudeButton)`
width: 32px;
height: 32px;
border: 1px solid ${s("inputBorder")};
padding: 4px;
&: ${hover} {
border: 1px solid ${s("inputBorderFocused")};
}
`;
export default SkinTonePicker;
+19 -1
View File
@@ -21,11 +21,13 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../primitives/Drawer";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
import { PopoverButton } from "./components/PopoverButton";
import CustomEmojiPanel from "./components/CustomEmojiPanel";
import useStores from "~/hooks/useStores";
const TAB_NAMES = {
Icon: "icon",
Emoji: "emoji",
Custom: "custom",
} as const;
type TabName = (typeof TAB_NAMES)[keyof typeof TAB_NAMES];
@@ -173,7 +175,7 @@ const IconPicker = ({
if (open) {
void emojis.fetchAll();
}
}, [open, emojis]);
}, [open]);
if (isMobile) {
return (
@@ -252,6 +254,13 @@ const Content = ({
>
{t("Emojis")}
</StyledTab>
<StyledTab
value={TAB_NAMES["Custom"]}
aria-label={t("Custom Emojis")}
$active={activeTab === TAB_NAMES["Custom"]}
>
{t("Custom")}
</StyledTab>
</Tabs.List>
{allowDelete && (
<RemoveButton onClick={onIconRemove}>{t("Remove")}</RemoveButton>
@@ -278,6 +287,15 @@ const Content = ({
onQueryChange={onQueryChange}
/>
</StyledTabContent>
<StyledTabContent value={TAB_NAMES["Custom"]}>
<CustomEmojiPanel
panelWidth={panelWidth}
query={query}
panelActive={open && activeTab === TAB_NAMES["Custom"]}
onEmojiChange={onIconChange}
onQueryChange={onQueryChange}
/>
</StyledTabContent>
</Tabs.Root>
);
};
+1 -1
View File
@@ -904,7 +904,7 @@ type ImageProps = {
onMaxZoom: () => void;
};
const Image = forwardRef<HTMLImageElement, ImageProps>(function Image_(
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
{
src,
alt,
+1 -1
View File
@@ -9,7 +9,7 @@ type Props = React.ComponentProps<typeof OneTimePasswordRoot> & {
};
export const OneTimePasswordInput = React.forwardRef(
function OneTimePasswordInput_(
function _OneTimePasswordInput(
{ length = 6, ...rest }: Props,
ref: React.RefObject<HTMLInputElement>
) {
+3 -22
View File
@@ -13,9 +13,6 @@ import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import { isUUID } from "validator";
import { CustomEmoji } from "@shared/components/CustomEmoji";
import useStores from "~/hooks/useStores";
type Props = {
/** Thin reaction data - contains the emoji & active user ids for this reaction. */
@@ -42,25 +39,13 @@ const useTooltipContent = ({
active: boolean;
}) => {
const { t } = useTranslation();
const { emojis } = useStores();
const customEmoji = emojis.get(emoji);
const [transformedEmoji, setTransformedEmoji] = React.useState(
customEmoji?.shortName ?? `:${getEmojiId(emoji)}:`
);
// If the emoji is a custom emoji ID, we need to get its short name for display
if (isUUID(emoji)) {
emojis.fetch(emoji).then((ce) => {
if (ce) {
setTransformedEmoji(ce.shortName);
}
});
}
if (!reactedUsers.length) {
return;
}
const transformedEmoji = `:${getEmojiId(emoji)}:`;
switch (reactedUsers.length) {
case 1: {
return t("{{ username }} reacted with {{ emoji }}", {
@@ -135,11 +120,7 @@ const Reaction: React.FC<Props> = ({
() => (
<EmojiButton disabled={disabled} $active={active} onClick={handleClick}>
<Flex gap={6} justify="center" align="center">
{isUUID(reaction.emoji) ? (
<CustomEmoji size={15} value={reaction.emoji} />
) : (
<Emoji size={15}>{reaction.emoji}</Emoji>
)}
<Emoji size={15}>{reaction.emoji}</Emoji>
<Count weight="xbold">{reaction.userIds.length}</Count>
</Flex>
</EmojiButton>
+7 -65
View File
@@ -1,43 +1,27 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled, { css } from "styled-components";
import styled from "styled-components";
import { hideScrollbars } from "@shared/styles";
import useWindowSize from "~/hooks/useWindowSize";
type Props = React.HTMLAttributes<HTMLDivElement> & {
/** Whether to show shadows at top and bottom when scrolled */
shadow?: boolean;
/** Whether to show shadow at the top when scrolled */
topShadow?: boolean;
/** Whether to show shadow at the bottom when scrolled */
bottomShadow?: boolean;
/** Whether to hide the scrollbars */
hiddenScrollbars?: boolean;
/** Color to fade to (enables fade effect) */
fadeTo?: string;
/** Whether to use flexbox layout */
flex?: boolean;
/** Custom overflow style */
overflow?: string;
children: React.ReactNode;
};
/**
* A scrollable container component with optional shadow indicators and custom scrollbar styling.
*
* @param props - component properties.
* @param ref - forwarded ref to the scrollable div element.
* @returns the scrollable container element.
*/
function Scrollable(
{
shadow,
topShadow,
bottomShadow,
hiddenScrollbars,
fadeTo,
flex,
overflow,
children,
...rest
}: Props,
ref: React.RefObject<HTMLDivElement>
@@ -52,17 +36,14 @@ function Scrollable(
return;
}
const scrollTop = c.scrollTop;
const tsv = !!((shadow || topShadow || fadeTo) && scrollTop > 0);
const tsv = !!((shadow || topShadow) && scrollTop > 0);
if (tsv !== topShadowVisible) {
setTopShadow(tsv);
}
const wrapperHeight = c.scrollHeight - c.clientHeight;
const bsv = !!(
(shadow || bottomShadow || fadeTo) &&
wrapperHeight - scrollTop !== 0
);
const bsv = !!((shadow || bottomShadow) && wrapperHeight - scrollTop !== 0);
if (bsv !== bottomShadowVisible) {
setBottomShadow(bsv);
@@ -71,7 +52,6 @@ function Scrollable(
shadow,
topShadow,
bottomShadow,
fadeTo,
ref,
topShadowVisible,
bottomShadowVisible,
@@ -87,59 +67,21 @@ function Scrollable(
onScroll={updateShadows}
$flex={flex}
$hiddenScrollbars={hiddenScrollbars}
$topShadowVisible={topShadowVisible && !fadeTo}
$bottomShadowVisible={bottomShadowVisible && !fadeTo}
$topShadowVisible={topShadowVisible}
$bottomShadowVisible={bottomShadowVisible}
$overflow={overflow}
{...rest}
>
{fadeTo && <Fade to={fadeTo} visible={topShadowVisible} top />}
{children}
{fadeTo && <Fade to={fadeTo} visible={bottomShadowVisible} bottom />}
</Wrapper>
/>
);
}
const Fade = styled.div<{
to: string;
top?: boolean;
bottom?: boolean;
visible: boolean;
}>`
--height: 1.5em;
position: sticky;
${(props) =>
props.top &&
css`
top: 0;
background: linear-gradient(to bottom, ${props.to}, transparent);
margin-bottom: calc(-1 * var(--height));
`}
${(props) =>
props.bottom &&
css`
bottom: 0;
background: linear-gradient(to top, ${props.to}, transparent);
margin-top: calc(-1 * var(--height));
`}
flex-shrink: 0;
height: var(--height);
width: calc(100% - var(--scrollbar-width, 0px));
pointer-events: none;
opacity: ${(props) => (props.visible ? 1 : 0)};
transition: opacity 100ms ease-in-out;
z-index: 1;
`;
const Wrapper = styled.div<{
$flex?: boolean;
$fadeTo?: string;
$topShadowVisible?: boolean;
$bottomShadowVisible?: boolean;
$hiddenScrollbars?: boolean;
$overflow?: string;
}>`
position: relative;
display: ${(props) => (props.$flex ? "flex" : "block")};
flex-direction: column;
height: 100%;
@@ -331,7 +331,7 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
) : (
<CopyLinkButton
key="copy-link"
url={urlify(collectionPath(collection))}
url={urlify(collectionPath(collection.path))}
onCopy={onRequestClose}
/>
);
@@ -16,7 +16,7 @@ type Props = {
action: React.ReactNode;
};
export const SearchInput = React.forwardRef(function SearchInput_(
export const SearchInput = React.forwardRef(function _SearchInput(
{ onChange, onClick, onKeyDown, query, back, action }: Props,
ref: React.Ref<HTMLInputElement>
) {
@@ -44,7 +44,7 @@ type Props = {
};
export const Suggestions = observer(
React.forwardRef(function Suggestions_(
React.forwardRef(function _Suggestions(
{
document,
collection,
+2
View File
@@ -30,6 +30,7 @@ import SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import ToggleButton from "./components/ToggleButton";
import TrashLink from "./components/TrashLink";
import BulkSelectionToolbar from "../BulkSelectionToolbar";
function AppSidebar() {
const { t } = useTranslation();
@@ -131,6 +132,7 @@ function AppSidebar() {
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
<BulkSelectionToolbar />
</DndProvider>
)}
</Sidebar>
+1 -2
View File
@@ -88,8 +88,7 @@ function SharedSidebar({ share }: Props) {
) : (
<SharedDocumentLink
index={0}
// If the root node has an icon we need some extra space for it
depth={rootNode.icon ? 1 : 0}
depth={0}
shareId={shareId}
node={rootNode}
prefetchDocument={documents.prefetchDocument}
+1 -1
View File
@@ -37,7 +37,7 @@ type Props = {
children: React.ReactNode;
};
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function Sidebar_(
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
{ children, hidden = false, canCollapse = true, className }: Props,
ref: React.RefObject<HTMLDivElement>
) {
@@ -78,6 +78,18 @@ function InnerDocumentLink(
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
// Selection state for bulk operations
const isSelected = documents.isSelected(node.id);
const hasAnySelection = documents.selectedDocumentIds.length > 0;
const handleSelectionChange = React.useCallback(() => {
if (isSelected) {
documents.deselect(node.id);
} else {
documents.select(node.id);
}
}, [documents, node.id, isSelected]);
React.useEffect(() => {
if (
isActiveDocument &&
@@ -434,6 +446,12 @@ function InnerDocumentLink(
isDraft={isDraft}
ref={ref}
menu={menuElement}
onSelectionChange={handleSelectionChange}
selectionState={{
isSelected,
hasAnySelection,
showCheckbox: true,
}}
/>
</DropToImport>
</div>
@@ -123,7 +123,7 @@ function SharedWithMe() {
.map((membership) => (
<SharedWithMeLink key={membership.id} membership={membership} />
))}
{!loading && !end && (
{!end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
@@ -18,7 +18,7 @@ export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
};
const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
function SidebarButton_(
function _SidebarButton(
{
position = "top",
showMoreMenu,
@@ -14,6 +14,7 @@ import NavLink, { Props as NavLinkProps } from "./NavLink";
import { ActionWithChildren } from "~/types";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useTranslation } from "react-i18next";
import { CheckboxIcon } from "outline-icons";
/**
* Props for the SidebarLink component.
@@ -56,6 +57,14 @@ type Props = Omit<NavLinkProps, "to"> & {
scrollIntoViewIfNeeded?: boolean;
/** Optional context menu action to display */
contextAction?: ActionWithChildren;
/** State of the selection checkbox */
selectionState?: {
isSelected: boolean;
showCheckbox: boolean;
hasAnySelection: boolean;
};
/** Callback fired when the selection checkbox is toggled */
onSelectionChange?: () => void;
};
const activeDropStyle = {
@@ -88,6 +97,12 @@ function SidebarLink(
disabled,
unreadBadge,
contextAction,
selectionState = {
isSelected: false,
showCheckbox: false,
hasAnySelection: false,
},
onSelectionChange,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -96,6 +111,7 @@ function SidebarLink(
const { t } = useTranslation();
const theme = useTheme();
const { handleMouseEnter, handleMouseLeave } = useClickIntent(onClickIntent);
const { isSelected, showCheckbox, hasAnySelection } = selectionState;
const style = React.useMemo(
() => ({
paddingLeft: `${(depth || 0) * 16 + (icon ? -8 : 12)}px`,
@@ -149,6 +165,7 @@ function SidebarLink(
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
$hasCheckbox={showCheckbox}
style={style}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
onClick={handleClick}
@@ -166,6 +183,17 @@ function SidebarLink(
{...rest}
>
<Content>
{showCheckbox && (
<CheckboxWrapper $alwaysVisible={hasAnySelection}>
<NudeButton
type="button"
onClick={onSelectionChange}
aria-label={t("Select")}
>
<CheckboxIcon checked={isSelected} />
</NudeButton>
</CheckboxWrapper>
)}
{hasDisclosure && (
<DisclosureComponent
expanded={expanded}
@@ -184,13 +212,23 @@ function SidebarLink(
);
}
// accounts for whitespace around icon
export const IconWrapper = styled.span`
margin-left: -4px;
margin-right: 4px;
height: 24px;
overflow: hidden;
flex-shrink: 0;
transition: opacity 200ms ease-in-out;
transition: opacity 150ms ease-in-out;
`;
const CheckboxWrapper = styled(EventBoundary)<{ $alwaysVisible?: boolean }>`
display: flex;
align-items: center;
justify-content: center;
margin-left: -11px;
flex-shrink: 0;
opacity: ${(props) => (props.$alwaysVisible ? 1 : 0)};
transition: opacity 150ms ease-in-out;
`;
const Content = styled.span`
@@ -239,6 +277,7 @@ const Link = styled(NavLink)<{
$isActiveDrop?: boolean;
$isDraft?: boolean;
$disabled?: boolean;
$hasCheckbox?: boolean;
}>`
&:hover,
&:active {
@@ -326,6 +365,14 @@ const Link = styled(NavLink)<{
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
${(props) =>
props.$hasCheckbox &&
css`
&:hover ${CheckboxWrapper} {
opacity: 1;
}
`}
}
& ${Actions} {
@@ -63,7 +63,7 @@ function Starred() {
.map((star) => (
<StarredLink key={star.id} star={star} />
))}
{!loading && !end && (
{!end && (
<SidebarLink
onClick={next}
label={`${t("Show more")}`}
@@ -24,12 +24,7 @@ function TrashLink() {
title: t("Delete {{ documentName }}", {
documentName: document?.noun,
}),
content: (
<DocumentDelete
document={document}
onSubmit={dialogs.closeAllModals}
/>
),
content: <DocumentDelete documents={[document]} />,
});
},
canDrop: (item) => policies.abilities(item.id).delete,
@@ -67,7 +67,8 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
!props.disabled &&
`
&[data-highlighted],
&:focus-visible {
&:focus-visible,
&:hover {
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
+3 -3
View File
@@ -302,8 +302,9 @@ export default function FindAndReplace({
const style: React.CSSProperties = React.useMemo(
() => ({
position: "fixed",
top: 0,
right: 0,
left: "initial",
top: 60,
right: 16,
zIndex: depths.popover,
}),
[]
@@ -374,7 +375,6 @@ export default function FindAndReplace({
minWidth={420}
scrollable={false}
onPointerDownOutside={() => setLocalOpen(false)}
style={{ marginRight: 16, marginTop: 60 }}
>
<Content column>
<Flex gap={4}>
+2 -4
View File
@@ -294,7 +294,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
<Portal>
<Wrapper
active={props.active && position.visible}
arrow={!!props.children && !position.blockSelection}
arrow={!position.blockSelection}
ref={menuRef}
$offset={position.offset}
style={{
@@ -304,9 +304,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
left: `${position.left}px`,
}}
>
{props.children && (
<Background align={props.align}>{props.children}</Background>
)}
<Background align={props.align}>{props.children}</Background>
</Wrapper>
</Portal>
);
+22 -50
View File
@@ -1,12 +1,7 @@
import { observer } from "mobx-react";
import {
ArrowIcon,
CloseIcon,
DocumentIcon,
OpenIcon,
ReturnIcon,
} from "outline-icons";
import { ArrowIcon, CloseIcon, DocumentIcon, OpenIcon } from "outline-icons";
import { Mark } from "prosemirror-model";
import { Selection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
@@ -33,25 +28,9 @@ type Props = {
mark?: Mark;
dictionary: Dictionary;
view: EditorView;
onLinkAdd: () => void;
onLinkUpdate: () => void;
onLinkRemove: () => void;
onEscape: () => void;
onClickOutside: (ev: MouseEvent | TouchEvent) => void;
onClickBack: () => void;
};
const LinkEditor: React.FC<Props> = ({
mark,
dictionary,
view,
onLinkAdd,
onLinkUpdate,
onLinkRemove,
onEscape,
onClickOutside,
onClickBack,
}) => {
const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
const initialValue = getHref();
const { commands } = useEditor();
@@ -79,7 +58,7 @@ const LinkEditor: React.FC<Props> = ({
}
}, [trimmedQuery, request]);
useOnClickOutside(wrapperRef, (ev) => {
useOnClickOutside(wrapperRef, () => {
// If the link is totally empty or only spaces then remove the mark
if (!trimmedQuery) {
return removeLink();
@@ -87,14 +66,9 @@ const LinkEditor: React.FC<Props> = ({
// If the link in input is non-empty and same as it was when the editor opened, nothing to do
if (trimmedQuery === initialValue) {
onClickOutside(ev);
return;
}
if (!mark) {
return addLink(trimmedQuery);
}
updateLink(trimmedQuery);
});
@@ -104,23 +78,26 @@ const LinkEditor: React.FC<Props> = ({
const removeLink = React.useCallback(() => {
commands["removeLink"]();
onLinkRemove();
}, [commands, onLinkRemove]);
}, []);
const updateLink = (link: string) => {
if (!link) {
return;
}
commands["updateLink"]({ href: sanitizeUrl(link) ?? "" });
onLinkUpdate();
};
const addLink = (link: string) => {
if (!link) {
return;
const moveSelectionToEnd = () => {
const { state, dispatch } = view;
const nextSelection = Selection.findFrom(
state.tr.doc.resolve(state.selection.to),
1,
true
);
if (nextSelection) {
dispatch(state.tr.setSelection(nextSelection));
}
commands["addLink"]({ href: sanitizeUrl(link) ?? "" });
onLinkAdd();
view.focus();
};
const handleKeyDown = (event: React.KeyboardEvent) => {
@@ -142,11 +119,9 @@ const LinkEditor: React.FC<Props> = ({
if (selectedIndex >= 0 && results[selectedIndex]) {
const selectedDoc = results[selectedIndex];
!mark ? addLink(selectedDoc.url) : updateLink(selectedDoc.url);
updateLink(selectedDoc.url);
} else if (!trimmedQuery) {
removeLink();
} else if (!mark) {
addLink(trimmedQuery);
} else {
updateLink(trimmedQuery);
}
@@ -160,7 +135,11 @@ const LinkEditor: React.FC<Props> = ({
return removeLink();
}
onEscape();
// Moving selection to end causes editor state to change,
// forcing a re-render of the top-level editor component. As
// a result, the new selection, being devoid of any link mark,
// prevents LinkEditor from re-rendering.
moveSelectionToEnd();
return;
}
}
@@ -190,13 +169,6 @@ const LinkEditor: React.FC<Props> = ({
disabled: false,
handler: removeLink,
},
{
tooltip: dictionary.formattingControls,
icon: <ReturnIcon />,
visible: view.editable,
disabled: false,
handler: onClickBack,
},
];
return (
@@ -236,7 +208,7 @@ const LinkEditor: React.FC<Props> = ({
{results.map((doc, index) => (
<SuggestionsMenuItem
onPointerDown={() => {
!mark ? addLink(doc.url) : updateLink(doc.url);
updateLink(doc.url);
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
+8 -36
View File
@@ -2,43 +2,24 @@ import { OpenIcon, TrashIcon } from "outline-icons";
import { Node } from "prosemirror-model";
import { Selection, TextSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useCallback, useRef, useState } from "react";
import { useCallback, useState } from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Tooltip from "~/components/Tooltip";
import Input from "~/editor/components/Input";
import { Dictionary } from "~/hooks/useDictionary";
import ToolbarButton from "./ToolbarButton";
import useOnClickOutside from "~/hooks/useOnClickOutside";
type Props = {
node?: Node;
node: Node;
view: EditorView;
dictionary: Dictionary;
autoFocus?: boolean;
onLinkUpdate: () => void;
onLinkRemove: () => void;
onEscape: () => void;
onClickOutside: (ev: MouseEvent | TouchEvent) => void;
};
export function MediaLinkEditor({
node,
view,
dictionary,
onLinkUpdate,
onLinkRemove,
onEscape,
onClickOutside,
}: Props) {
const url = (node?.attrs.href ?? node?.attrs.src) as string;
export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
const url = (node.attrs.href ?? node.attrs.src) as string;
const [localUrl, setLocalUrl] = useState(url);
const wrapperRef = useRef<HTMLDivElement>(null);
// If we're attempting to edit an image, autofocus the input
// Not doing for embed type because it made the editor scroll to top
// unexpectedlyleaving that out for now
const isEditingImgUrl = node?.type.name === "image";
const moveSelectionToEnd = useCallback(() => {
const { state, dispatch } = view;
@@ -60,24 +41,20 @@ export function MediaLinkEditor({
const remove = useCallback(() => {
const { state, dispatch } = view;
dispatch(state.tr.deleteSelection());
onLinkRemove();
}, [view]);
const update = useCallback(() => {
const { state } = view;
const hrefType = node?.type.name === "image" ? "src" : "href";
const hrefType = node.type.name === "image" ? "src" : "href";
const tr = state.tr.setNodeMarkup(state.selection.from, undefined, {
...node?.attrs,
...node.attrs,
[hrefType]: localUrl,
});
view.dispatch(tr);
moveSelectionToEnd();
onLinkUpdate();
}, [localUrl, node, view, moveSelectionToEnd]);
useOnClickOutside(wrapperRef, onClickOutside);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing) {
@@ -94,7 +71,6 @@ export function MediaLinkEditor({
case "Escape": {
event.preventDefault();
moveSelectionToEnd();
onEscape();
return;
}
}
@@ -102,14 +78,10 @@ export function MediaLinkEditor({
[update, moveSelectionToEnd]
);
if (!node) {
return null;
}
return (
<Wrapper ref={wrapperRef}>
<Wrapper>
<Input
autoFocus={isEditingImgUrl}
autoFocus={autoFocus}
value={localUrl}
placeholder={dictionary.pasteLink}
onChange={(e) => setLocalUrl(e.target.value)}
+34 -117
View File
@@ -1,10 +1,7 @@
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import {
getMarkRange,
getMarkRangeNodeSelection,
} from "@shared/editor/queries/getMarkRange";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
@@ -33,7 +30,6 @@ import { MediaLinkEditor } from "./MediaLinkEditor";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
import { isModKey } from "@shared/utils/keyboard";
type Props = {
/** Whether the text direction is right-to-left */
@@ -60,12 +56,6 @@ function useIsDragging() {
return isDragging;
}
enum Toolbar {
Link = "link",
Media = "media",
Menu = "menu",
}
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
const { view, commands } = useEditor();
@@ -74,41 +64,11 @@ export function SelectionToolbar(props: Props) {
const isMobile = useMobile();
const isActive = props.isActive || isMobile;
const isDragging = useIsDragging();
const { state } = view;
const { selection } = state;
const [activeToolbar, setActiveToolbar] = React.useState<Toolbar | null>(
null
);
const [isEditingImgUrl, setIsEditingImgUrl] = React.useState(false);
React.useEffect(() => {
const { selection } = state;
const linkMark =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
const isEmbedSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "embed";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
if (isEmbedSelection && !readOnly) {
setActiveToolbar(Toolbar.Media);
} else if (linkMark && !activeToolbar && !readOnly) {
setActiveToolbar(Toolbar.Link);
} else if (isCodeSelection) {
setActiveToolbar(Toolbar.Menu);
} else if (!selection.empty) {
setActiveToolbar(Toolbar.Menu);
} else if (isNoticeSelection && selection.empty) {
setActiveToolbar(Toolbar.Menu);
} else if (selection.empty) {
setActiveToolbar(null);
}
}, [readOnly, selection]);
setIsEditingImgUrl(false);
}, [isActive]);
React.useEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
@@ -131,6 +91,8 @@ export function SelectionToolbar(props: Props) {
return;
}
setIsEditingImgUrl(false);
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
@@ -144,46 +106,27 @@ export function SelectionToolbar(props: Props) {
};
}, [isActive, readOnly, view]);
useEventListener(
"keydown",
(ev: KeyboardEvent) => {
if (
isModKey(ev) &&
ev.key.toLowerCase() === "k" &&
!view.state.selection.empty
) {
ev.stopPropagation();
if (activeToolbar === Toolbar.Link) {
setActiveToolbar(Toolbar.Menu);
} else if (activeToolbar === Toolbar.Menu) {
setActiveToolbar(Toolbar.Link);
}
}
},
view.dom,
{ capture: true }
);
if (isDragging) {
return null;
}
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const link = getMarkRange(selection.$from, state.schema.marks.link);
const isImageSelection =
selection instanceof NodeSelection && selection.node.type.name === "image";
const isAttachmentSelection =
selection instanceof NodeSelection &&
selection.node.type.name === "attachment";
const isEmbedSelection =
selection instanceof NodeSelection && selection.node.type.name === "embed";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
const link =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
let items: MenuItem[] = [];
let align: "center" | "start" | "end" = "center";
@@ -235,73 +178,47 @@ export function SelectionToolbar(props: Props) {
});
items = filterExcessSeparators(items);
items = items.map((item) => {
if (item.children) {
item.children = item.children.map((child) => {
if (child.name === "editImageUrl") {
child.onClick = () => {
setActiveToolbar(Toolbar.Media);
};
}
return child;
});
}
if (!items.length) {
return null;
}
if (item.name === "linkOnImage" || item.name === "addLink") {
item.onClick = () => {
setActiveToolbar(Toolbar.Link);
};
}
return item;
});
const showLinkToolbar =
link && link.from === selection.from && link.to === selection.to;
const handleClickOutsideLinkEditor = (ev: MouseEvent | TouchEvent) => {
if (ev.target instanceof Element && ev.target.closest(".image-wrapper")) {
return;
}
setActiveToolbar(null);
};
const isEditingMedia =
isEmbedSelection || (isImageSelection && isEditingImgUrl);
return (
<FloatingToolbar
align={align}
active={isActive}
ref={menuRef}
width={
activeToolbar === Toolbar.Link || activeToolbar === Toolbar.Media
? 336
: undefined
}
width={showLinkToolbar || isEmbedSelection ? 336 : undefined}
>
{activeToolbar === Toolbar.Link ? (
{showLinkToolbar ? (
<LinkEditor
key={`${selection.from}-${selection.to}`}
key={`${link.from}-${link.to}`}
dictionary={dictionary}
view={view}
mark={link ? link.mark : undefined}
onLinkAdd={() => setActiveToolbar(null)}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
onEscape={() => setActiveToolbar(Toolbar.Menu)}
onClickOutside={handleClickOutsideLinkEditor}
onClickBack={() => setActiveToolbar(Toolbar.Menu)}
mark={link.mark}
/>
) : activeToolbar === Toolbar.Media ? (
) : isEditingMedia ? (
<MediaLinkEditor
key={`embed-${selection.from}`}
node={
"node" in selection ? (selection as NodeSelection).node : undefined
}
node={selection.node}
view={view}
dictionary={dictionary}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
onEscape={() => setActiveToolbar(Toolbar.Menu)}
onClickOutside={handleClickOutsideLinkEditor}
autoFocus={isEditingImgUrl}
/>
) : activeToolbar === Toolbar.Menu && items.length ? (
<ToolbarMenu items={items} {...rest} />
) : null}
) : (
<ToolbarMenu
items={items}
{...rest}
handlers={{
editImageUrl: () => setIsEditingImgUrl(true),
}}
/>
)}
</FloatingToolbar>
);
}
+14 -11
View File
@@ -20,15 +20,20 @@ import EventBoundary from "@shared/components/EventBoundary";
type Props = {
items: MenuItem[];
handlers?: Record<string, (...args: any[]) => void>;
};
/*
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
function ToolbarDropdown(props: {
active: boolean;
item: MenuItem;
handlers?: Record<string, Function>;
}) {
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item } = props;
const { item, handlers } = props;
const { state } = view;
const items: TMenuItem[] = useMemo(() => {
@@ -43,8 +48,12 @@ function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
? menuItem.attrs(state)
: menuItem.attrs
);
} else if (menuItem.onClick) {
menuItem.onClick();
} else if (handlers && handlers[menuItem.name]) {
handlers[menuItem.name](
typeof menuItem.attrs === "function"
? menuItem.attrs(state)
: menuItem.attrs
);
}
};
@@ -104,13 +113,6 @@ function ToolbarMenu(props: Props) {
return;
}
// if item has an associated onClick prop, run it
if (item.onClick) {
item.onClick();
return;
}
// otherwise, run the associated editor command
commands[item.name](
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
);
@@ -139,6 +141,7 @@ function ToolbarMenu(props: Props) {
<MediaDimension key={index} />
) : item.children ? (
<ToolbarDropdown
handlers={props.handlers}
active={isActive && !item.label}
item={item}
/>
+18 -12
View File
@@ -282,22 +282,28 @@ export default class PasteHandler extends Extension {
const slice = paste.slice(0);
const tr = view.state.tr;
let currentPos = view.state.selection.from;
// If the pasted content is a single paragraph then we slice
// the outer paragraph so that the text is inserted directly.
// If the pasted content is a single paragraph then we loop over
// it's content and insert each node one at a time to allow it to
// be pasted inline with surrounding content.
const singleNode = sliceSingleNode(slice);
if (singleNode?.type === this.editor.schema.nodes.paragraph) {
const slice = new Slice(singleNode.content, 0, 0);
tr.replaceSelection(slice);
} else if (singleNode) {
if (isList(singleNode, this.editor.schema)) {
this.handleList(singleNode);
return true;
} else {
tr.replaceSelectionWith(singleNode, this.shiftKey);
}
singleNode.forEach((node) => {
tr.insert(currentPos, node);
currentPos += node.nodeSize;
});
} else {
tr.replaceSelection(slice);
if (singleNode) {
if (isList(singleNode, this.editor.schema)) {
this.handleList(singleNode);
return true;
} else {
tr.replaceSelectionWith(singleNode, this.shiftKey);
}
} else {
tr.replaceSelection(slice);
}
}
view.dispatch(
+1 -1
View File
@@ -914,7 +914,7 @@ const EditorContainer = styled(Styles)<{
`;
const LazyLoadedEditor = React.forwardRef<Editor, Props>(
function LazyLoadedEditor_(props: Props, ref) {
function _LazyLoadedEditor(props: Props, ref) {
return (
<WithTheme>
{(theme) => <Editor theme={theme} {...props} ref={ref} />}
-1
View File
@@ -258,7 +258,6 @@ export default function formattingMenuItems(
shortcut: `${metaDisplay}+K`,
icon: <LinkIcon />,
attrs: { href: "" },
active: isMarkActive(schema.marks.link, undefined, { exact: true }),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{
-9
View File
@@ -8,7 +8,6 @@ import {
AlignFullWidthIcon,
EditIcon,
CommentIcon,
LinkIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
@@ -17,7 +16,6 @@ import { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "@shared/utils/keyboard";
import { ImageSource } from "@shared/editor/lib/FileHelper";
import Desktop from "~/utils/Desktop";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
export default function imageMenuItems(
state: EditorState,
@@ -125,13 +123,6 @@ export default function imageMenuItems(
{
name: "separator",
},
{
name: "linkOnImage",
tooltip: dictionary.createLink,
shortcut: `${metaDisplay}+K`,
active: isMarkActive(schema.marks.link),
icon: <LinkIcon />,
},
{
name: "commentOnImage",
tooltip: dictionary.comment,
+1 -12
View File
@@ -1,9 +1,4 @@
import {
AlignFullWidthIcon,
DownloadIcon,
TableColumnsDistributeIcon,
TrashIcon,
} from "outline-icons";
import { AlignFullWidthIcon, DownloadIcon, TrashIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem, TableLayout } from "@shared/editor/types";
@@ -18,7 +13,6 @@ export default function tableMenuItems(
return [];
}
const { schema } = state;
const isFullWidth = isNodeActive(schema.nodes.table, {
layout: TableLayout.fullWidth,
})(state);
@@ -33,11 +27,6 @@ export default function tableMenuItems(
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
active: () => isFullWidth,
},
{
name: "distributeColumns",
tooltip: dictionary.distributeColumns,
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
-9
View File
@@ -11,13 +11,11 @@ import {
TableSplitCellsIcon,
AlphabeticalSortIcon,
AlphabeticalReverseSortIcon,
TableColumnsDistributeIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
getAllSelectedColumns,
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
@@ -40,7 +38,6 @@ export default function tableColMenuItems(
const { index, rtl } = options;
const { schema, selection } = state;
const selectedCols = getAllSelectedColumns(state);
if (!(selection instanceof CellSelection)) {
return [];
@@ -150,12 +147,6 @@ export default function tableColMenuItems(
icon: <TableSplitCellsIcon />,
visible: isMergedCellSelection(state),
},
{
name: "distributeColumns",
visible: selectedCols.length > 1,
label: dictionary.distributeColumns,
icon: <TableColumnsDistributeIcon />,
},
{
name: "separator",
},
+35
View File
@@ -0,0 +1,35 @@
import { useMemo } from "react";
import {
bulkArchiveDocuments,
bulkDeleteDocuments,
bulkMoveDocuments,
} from "~/actions/definitions/bulkDocuments";
import Document from "~/models/Document";
import { useMenuAction } from "./useMenuAction";
type Props = {
/** Documents that are selected */
documents: Document[];
};
/**
* Hook that creates bulk document menu actions.
*
* @param props - documents and callbacks.
* @returns root menu action with children for bulk operations.
*/
export function useBulkDocumentMenuAction({ documents }: Props) {
const actions = useMemo(() => {
if (!documents.length) {
return [];
}
return [
bulkArchiveDocuments({ documents }),
bulkMoveDocuments({ documents }),
bulkDeleteDocuments({ documents }),
];
}, [documents]);
return useMenuAction(actions);
}
-2
View File
@@ -110,8 +110,6 @@ export default function useDictionary() {
none: t("None"),
deleteEmbed: t("Delete embed"),
uploadImage: t("Upload an image"),
formattingControls: t("Formatting controls"),
distributeColumns: t("Distribute columns"),
}),
[t]
);
+1 -1
View File
@@ -37,7 +37,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
if (href[0] !== "/") {
try {
const url = new URL(href);
navigateTo = url.pathname + url.search + url.hash;
navigateTo = url.pathname + url.hash;
} catch (_err) {
navigateTo = href;
}
+1 -6
View File
@@ -1,4 +1,4 @@
import { computed, observable } from "mobx";
import { observable } from "mobx";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
@@ -36,11 +36,6 @@ class Emoji extends Model {
return this.name;
}
@computed
get shortName(): string {
return `:${this.name}:`;
}
/**
* emoji name
*/
+1 -1
View File
@@ -177,7 +177,7 @@ class Notification extends Model {
const collection = this.collectionId
? this.store.rootStore.collections.get(this.collectionId)
: undefined;
return collection ? collectionPath(collection) : "";
return collection ? collectionPath(collection.path) : "";
}
case NotificationEventType.AddUserToDocument:
case NotificationEventType.GroupMentionedInDocument:
-197
View File
@@ -1,197 +0,0 @@
import User from "./User";
import stores from "~/stores";
describe("User model", () => {
const users = stores.users;
describe("initial", () => {
test("should return first character of name uppercased", () => {
const user = new User(
{
id: "123",
name: "alice smith",
},
users
);
expect(user.initial).toBe("A");
});
test("should return first character when name is already uppercase", () => {
const user = new User(
{
id: "124",
name: "Bob Johnson",
},
users
);
expect(user.initial).toBe("B");
});
test("should return ? when name is empty", () => {
const user = new User(
{
id: "125",
name: "",
},
users
);
expect(user.initial).toBe("?");
});
test("should return ? when name is null", () => {
const user = new User(
{
id: "126",
name: null,
},
users
);
expect(user.initial).toBe("?");
});
test("should return ? when name is undefined", () => {
const user = new User(
{
id: "127",
name: undefined,
},
users
);
expect(user.initial).toBe("?");
});
});
describe("initials", () => {
test("should return empty string when name is empty", () => {
const user = new User(
{
id: "201",
name: "",
},
users
);
expect(user.initials).toBe("");
});
test("should return empty string when name is null", () => {
const user = new User(
{
id: "202",
name: null,
},
users
);
expect(user.initials).toBe("");
});
test("should return single character uppercased for single word name", () => {
const user = new User(
{
id: "203",
name: "alice",
},
users
);
expect(user.initials).toBe("A");
});
test("should return single character uppercased for single word name already uppercase", () => {
const user = new User(
{
id: "204",
name: "BOB",
},
users
);
expect(user.initials).toBe("B");
});
test("should return first and last initials for two word name", () => {
const user = new User(
{
id: "205",
name: "alice smith",
},
users
);
expect(user.initials).toBe("AS");
});
test("should return first and last initials for three word name", () => {
const user = new User(
{
id: "206",
name: "alice marie smith",
},
users
);
expect(user.initials).toBe("AS");
});
test("should return first and last initials for many word name", () => {
const user = new User(
{
id: "207",
name: "alice marie jane doe smith",
},
users
);
expect(user.initials).toBe("AS");
});
test("should handle names with extra spaces", () => {
const user = new User(
{
id: "208",
name: " alice smith ",
},
users
);
expect(user.initials).toBe("AS");
});
test("should handle names with mixed case", () => {
const user = new User(
{
id: "209",
name: "aLiCe sMiTh",
},
users
);
expect(user.initials).toBe("AS");
});
test("should handle names with special characters", () => {
const user = new User(
{
id: "210",
name: "Jean-Pierre O'Connor",
},
users
);
expect(user.initials).toBe("JO");
});
test("should handle single letter names", () => {
const user = new User(
{
id: "211",
name: "X",
},
users
);
expect(user.initials).toBe("X");
});
test("should handle names with unicode characters", () => {
const user = new User(
{
id: "212",
name: "José García",
},
users
);
expect(user.initials).toBe("JG");
});
});
});
+1 -13
View File
@@ -65,7 +65,7 @@ class User extends ParanoidModel implements Searchable {
@computed
get searchContent(): string[] {
return [this.name, this.email, this.initials].filter(Boolean);
return [this.name, this.email].filter(Boolean);
}
@computed
@@ -78,18 +78,6 @@ class User extends ParanoidModel implements Searchable {
return (this.name ? this.name[0] : "?").toUpperCase();
}
@computed
get initials(): string {
if (!this.name) {
return "";
}
const names = this.name.trim().split(" ");
if (names.length === 1) {
return names[0][0].toUpperCase();
}
return (names[0][0] + names[names.length - 1][0]).toUpperCase();
}
/**
* Whether the user has been invited but not yet signed in.
*/
+7 -25
View File
@@ -17,8 +17,7 @@ import {
homePath,
searchPath,
settingsPath,
matchDocumentSlug as documentSlug,
matchCollectionSlug as collectionSlug,
matchDocumentSlug as slug,
trashPath,
} from "~/utils/routeHelpers";
@@ -81,39 +80,22 @@ function AuthenticatedRoutes() {
to={settingsPath("templates")}
/>
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route
exact
path={`/collection/${collectionSlug}/new`}
component={DocumentNew}
/>
<Route
exact
path={`/collection/${collectionSlug}/overview/edit`}
component={Collection}
/>
<Route
exact
path={`/collection/${collectionSlug}/:tab?`}
path="/collection/:id/:tab?"
component={Collection}
/>
<Route exact path="/doc/new" component={DocumentNew} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact
path={`/d/${documentSlug}`}
component={RedirectDocument}
/>
<Route
exact
path={`/doc/${documentSlug}/history/:revisionId?`}
path={`/doc/${slug}/history/:revisionId?`}
component={Document}
/>
<Route
exact
path={`/doc/${documentSlug}/edit`}
component={Document}
/>
<Route path={`/doc/${documentSlug}`} component={Document} />
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route
exact
path={`${searchPath()}/:query?`}
+3 -62
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { EditIcon, PlusIcon } from "outline-icons";
import { PlusIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import Collection from "~/models/Collection";
@@ -8,76 +8,18 @@ import Button from "~/components/Button";
import Tooltip from "~/components/Tooltip";
import usePolicy from "~/hooks/usePolicy";
import CollectionMenu from "~/menus/CollectionMenu";
import {
collectionEditPath,
collectionPath,
newDocumentPath,
} from "~/utils/routeHelpers";
import useCurrentUser from "~/hooks/useCurrentUser";
import { SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
import { CollectionTab } from "./Navigation";
import lazyWithRetry from "~/utils/lazyWithRetry";
import history from "~/utils/history";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import { useCallback } from "react";
const ShareButton = lazyWithRetry(() => import("./ShareButton"));
import { newDocumentPath } from "~/utils/routeHelpers";
type Props = {
/** The collection for which to render actions */
collection: Collection;
/** Whether the collection is in editing mode */
isEditing: boolean;
/** Contextual information for the sidebar */
sidebarContext: SidebarContextType;
};
function Actions({ collection, isEditing, sidebarContext }: Props) {
function Actions({ collection }: Props) {
const { t } = useTranslation();
const can = usePolicy(collection);
const user = useCurrentUser();
const goToEdit = useCallback(() => {
history.push({
pathname: collectionEditPath(collection),
state: { sidebarContext },
});
}, [collection, sidebarContext]);
const goBack = useCallback(() => {
history.push({
pathname: collectionPath(collection, CollectionTab.Overview),
state: { sidebarContext },
});
}, [collection, sidebarContext]);
return (
<>
{(!isEditing || !user?.separateEditMode) && (
<Action>
<ShareButton collection={collection} />
</Action>
)}
{!isEditing && user?.separateEditMode && (
<Action>
<RegisterKeyDown trigger="e" handler={goToEdit} />
<Tooltip
content={t("Edit collection")}
shortcut="e"
placement="bottom"
>
<Button icon={<EditIcon />} onClick={goToEdit} neutral>
{t("Edit")}
</Button>
</Tooltip>
</Action>
)}
{isEditing && user?.separateEditMode && (
<Action>
<RegisterKeyDown trigger="Escape" handler={goBack} />
<Button onClick={goBack}>{t("Done editing")}</Button>
</Action>
)}
{can.createDocument && (
<>
<Action>
@@ -91,7 +33,6 @@ function Actions({ collection, isEditing, sidebarContext }: Props) {
to={collection ? newDocumentPath(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
neutral={isEditing}
>
{t("New doc")}
</Button>
@@ -1,68 +0,0 @@
import { IconTitleWrapper } from "@shared/components/Icon";
import breakpoint from "styled-components-breakpoint";
import first from "lodash/first";
import { Suspense, useCallback } from "react";
import styled from "styled-components";
import Heading from "~/components/Heading";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import type Collection from "~/models/Collection";
import { colorPalette } from "@shared/utils/collections";
import usePolicy from "~/hooks/usePolicy";
import { observer } from "mobx-react";
import lazyWithRetry from "~/utils/lazyWithRetry";
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
type Props = {
/** The collection for which to render a header */
collection: Collection;
};
export const Header = observer(function Header_({ collection }: Props) {
const can = usePolicy(collection);
const handleIconChange = useCallback(
(icon: string | null, color: string | null) =>
collection?.save({ icon, color }),
[collection]
);
const fallbackIcon = collection ? (
<CollectionIcon collection={collection} size={40} expanded />
) : null;
return (
<StyledHeading>
<IconTitleWrapper>
{can.update ? (
<Suspense fallback={fallbackIcon}>
<IconPicker
icon={collection.icon ?? "collection"}
color={collection.color ?? (first(colorPalette) as string)}
initial={collection.initial}
size={40}
popoverPosition="bottom-start"
onChange={handleIconChange}
borderOnHover
>
{fallbackIcon}
</IconPicker>
</Suspense>
) : (
fallbackIcon
)}
</IconTitleWrapper>
{collection.name}
</StyledHeading>
);
});
const StyledHeading = styled(Heading)`
display: flex;
align-items: center;
position: relative;
margin-left: 40px;
${breakpoint("tablet")`
margin-left: 0;
`}
`;
@@ -1,78 +0,0 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import Collection from "~/models/Collection";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import { collectionPath } from "~/utils/routeHelpers";
import { type SidebarContextType } from "~/components/Sidebar/components/SidebarContext";
export enum CollectionTab {
Overview = "overview",
Recent = "recent",
Popular = "popular",
Updated = "updated",
Published = "published",
Old = "old",
Alphabetical = "alphabetical",
}
type Props = {
/** The collection for which to render navigation tabs */
collection: Collection;
/** Callback when the tab is changed */
onChangeTab: (tab: CollectionTab) => void;
/** Whether to show the overview tab */
showOverview?: boolean;
/** Contextual information for the sidebar */
sidebarContext: SidebarContextType;
};
/**
* Navigation component for collection tabs, providing navigation between
* different views of collection documents.
*/
const Navigation = observer(function Navigation({
collection,
onChangeTab,
showOverview,
sidebarContext,
}: Props) {
const { t } = useTranslation();
const tabProps = (path: CollectionTab) => ({
exact: true,
onClick: () => onChangeTab(path),
to: {
pathname: collectionPath(collection, path),
state: { sidebarContext },
},
});
return (
<Tabs>
{showOverview && (
<Tab {...tabProps(CollectionTab.Overview)} exact={false}>
{t("Overview")}
</Tab>
)}
<Tab {...tabProps(CollectionTab.Recent)}>{t("Documents")}</Tab>
{!collection.isArchived && (
<>
<Tab {...tabProps(CollectionTab.Popular)}>{t("Popular")}</Tab>
<Tab {...tabProps(CollectionTab.Updated)}>
{t("Recently updated")}
</Tab>
<Tab {...tabProps(CollectionTab.Published)}>
{t("Recently published")}
</Tab>
<Tab {...tabProps(CollectionTab.Old)}>
{t("Least recently updated")}
</Tab>
<Tab {...tabProps(CollectionTab.Alphabetical)}>{t("AZ")}</Tab>
</>
)}
</Tabs>
);
});
export default Navigation;
+4 -11
View File
@@ -1,6 +1,6 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { useMemo, useRef, useCallback, useEffect, Suspense } from "react";
import { useMemo, useRef, useCallback, Suspense } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
@@ -24,10 +24,10 @@ const extensions = withUIExtensions(richExtensions);
type Props = {
collection: Collection;
readOnly?: boolean;
shareId?: string;
};
function Overview({ collection, readOnly }: Props) {
function Overview({ collection, shareId }: Props) {
const { documents, collections } = useStores();
const { t } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
@@ -48,13 +48,6 @@ function Overview({ collection, readOnly }: Props) {
[collection, t]
);
useEffect(
() => () => {
handleSave.flush();
},
[handleSave]
);
const childRef = useRef<HTMLDivElement>(null);
const childOffsetHeight = childRef.current?.offsetHeight || 0;
const editorStyle = useMemo(
@@ -98,7 +91,7 @@ function Overview({ collection, readOnly }: Props) {
maxLength={CollectionValidation.maxDescriptionLength}
onCreateLink={onCreateLink}
canUpdate={can.update}
readOnly={!can.update || readOnly}
readOnly={!can.update || !!shareId}
userId={user?.id}
editorStyle={editorStyle}
/>
+156 -69
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { useState, useEffect } from "react";
import { useState, useCallback, useEffect, Suspense } from "react";
import { useTranslation } from "react-i18next";
import {
useParams,
@@ -11,9 +11,13 @@ import {
Redirect,
} from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { IconTitleWrapper } from "@shared/components/Icon";
import { s } from "@shared/styles";
import { StatusFilter } from "@shared/types";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
import { Action } from "~/components/Actions";
import CenteredContent from "~/components/CenteredContent";
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
import Heading from "~/components/Heading";
@@ -24,62 +28,77 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
import PinnedDocuments from "~/components/PinnedDocuments";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import { editCollection } from "~/actions/definitions/collections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import { useLastVisitedPath } from "~/hooks/useLastVisitedPath";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePersistedState from "~/hooks/usePersistedState";
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { NotFoundError } from "~/utils/errors";
import {
collectionEditPath,
collectionPath,
matchCollectionEdit,
updateCollectionPath,
} from "~/utils/routeHelpers";
import { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
import Error404 from "../Errors/Error404";
import Actions from "./components/Actions";
import DropToImport from "./components/DropToImport";
import Empty from "./components/Empty";
import MembershipPreview from "./components/MembershipPreview";
import Navigation, { CollectionTab } from "./components/Navigation";
import Notices from "./components/Notices";
import Overview from "./components/Overview";
import { Header } from "./components/Header";
import usePersistedState from "~/hooks/usePersistedState";
import useCurrentUser from "~/hooks/useCurrentUser";
import first from "lodash/first";
import lazyWithRetry from "~/utils/lazyWithRetry";
const CollectionScene = observer(function CollectionScene_() {
const params = useParams<{ collectionSlug?: string }>();
const IconPicker = lazyWithRetry(() => import("~/components/IconPicker"));
const ShareButton = lazyWithRetry(() => import("./components/ShareButton"));
enum CollectionPath {
Overview = "overview",
Recent = "recent",
Popular = "popular",
Updated = "updated",
Published = "published",
Old = "old",
Alphabetical = "alphabetical",
}
const CollectionScene = observer(function _CollectionScene() {
const params = useParams<{ id?: string }>();
const history = useHistory();
const match = useRouteMatch();
const location = useLocation();
const { t } = useTranslation();
const user = useCurrentUser();
const { documents, collections, shares, ui } = useStores();
const [error, setError] = useState<Error | undefined>();
const currentPath = location.pathname;
const [, setLastVisitedPath] = useLastVisitedPath();
const sidebarContext = useLocationSidebarContext();
const isEditRoute = match.path === matchCollectionEdit;
const id = params.collectionSlug || "";
const id = params.id || "";
const urlId = id.split("-").pop() ?? "";
const collection: Collection | null | undefined = collections.get(id);
const can = usePolicy(collection);
const { pins, count } = usePinnedDocuments(urlId, collection?.id);
const [collectionTab, setCollectionTab] = usePersistedState<CollectionTab>(
const [collectionTab, setCollectionTab] = usePersistedState<CollectionPath>(
`collection-tab:${collection?.id}`,
collection?.hasDescription ? CollectionTab.Overview : CollectionTab.Recent,
collection?.hasDescription
? CollectionPath.Overview
: CollectionPath.Recent,
{
listen: false,
}
);
const handleIconChange = useCallback(
(icon: string | null, color: string | null) =>
collection?.save({ icon, color }),
[collection]
);
useEffect(() => {
setLastVisitedPath(currentPath);
}, [currentPath, setLastVisitedPath]);
@@ -130,13 +149,23 @@ const CollectionScene = observer(function CollectionScene_() {
if (!collection && error) {
return <Error404 />;
}
if (!collection) {
return <Loading />;
}
const showOverview = can.update || collection?.hasDescription;
const hasOverview = can.update || collection?.hasDescription;
return (
const fallbackIcon = collection ? (
<CollectionIcon collection={collection} size={40} expanded />
) : null;
const tabProps = (path: CollectionPath) => ({
exact: true,
onClick: () => setCollectionTab(path),
to: {
pathname: collectionPath(collection!.path, path),
state: { sidebarContext },
},
});
return collection ? (
<Scene
centered={false}
textTitle={collection.name}
@@ -161,11 +190,10 @@ const CollectionScene = observer(function CollectionScene_() {
actions={
<>
<MembershipPreview collection={collection} />
<Actions
collection={collection}
isEditing={isEditRoute}
sidebarContext={sidebarContext}
/>
<Action>
{can.update && <ShareButton collection={collection} />}
</Action>
<Actions collection={collection} />
</>
}
>
@@ -176,7 +204,28 @@ const CollectionScene = observer(function CollectionScene_() {
>
<CenteredContent withStickyHeader>
<Notices collection={collection} />
<Header collection={collection} />
<CollectionHeading>
<IconTitleWrapper>
{can.update ? (
<Suspense fallback={fallbackIcon}>
<IconPicker
icon={collection.icon ?? "collection"}
color={collection.color ?? (first(colorPalette) as string)}
initial={collection.initial}
size={40}
popoverPosition="bottom-start"
onChange={handleIconChange}
borderOnHover
>
{fallbackIcon}
</IconPicker>
</Suspense>
) : (
fallbackIcon
)}
</IconTitleWrapper>
{collection.name}
</CollectionHeading>
<PinnedDocuments
pins={pins}
@@ -184,39 +233,54 @@ const CollectionScene = observer(function CollectionScene_() {
placeholderCount={count}
/>
<Content>
<Navigation
collection={collection}
onChangeTab={setCollectionTab}
showOverview={showOverview}
sidebarContext={sidebarContext}
/>
<Documents>
<Tabs>
{hasOverview && (
<Tab {...tabProps(CollectionPath.Overview)}>
{t("Overview")}
</Tab>
)}
<Tab {...tabProps(CollectionPath.Recent)}>{t("Documents")}</Tab>
{!collection.isArchived && (
<>
<Tab {...tabProps(CollectionPath.Popular)}>
{t("Popular")}
</Tab>
<Tab {...tabProps(CollectionPath.Updated)}>
{t("Recently updated")}
</Tab>
<Tab {...tabProps(CollectionPath.Published)}>
{t("Recently published")}
</Tab>
<Tab {...tabProps(CollectionPath.Old)}>
{t("Least recently updated")}
</Tab>
<Tab {...tabProps(CollectionPath.Alphabetical)}>
{t("AZ")}
</Tab>
</>
)}
</Tabs>
<Switch>
<Route path={collectionPath(collection)} exact>
<Route path={collectionPath(collection.path)} exact>
<Redirect
to={{
pathname: collectionPath(collection!, collectionTab),
pathname: collectionPath(collection!.path, collectionTab),
state: { sidebarContext },
}}
/>
</Route>
<Route
path={[
collectionPath(collection, CollectionTab.Overview),
collectionEditPath(collection),
]}
path={collectionPath(collection.path, CollectionPath.Overview)}
>
{showOverview ? (
<Overview
collection={collection}
readOnly={!isEditRoute && !!user?.separateEditMode}
/>
{hasOverview ? (
<Overview collection={collection} />
) : (
<Redirect
to={{
pathname: collectionPath(
collection,
CollectionTab.Recent
collection.path,
CollectionPath.Recent
),
state: { sidebarContext },
}}
@@ -229,8 +293,8 @@ const CollectionScene = observer(function CollectionScene_() {
<>
<Route
path={collectionPath(
collection,
CollectionTab.Alphabetical
collection.path,
CollectionPath.Alphabetical
)}
>
<PaginatedDocumentList
@@ -244,7 +308,9 @@ const CollectionScene = observer(function CollectionScene_() {
}}
/>
</Route>
<Route path={collectionPath(collection, CollectionTab.Old)}>
<Route
path={collectionPath(collection.path, CollectionPath.Old)}
>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
@@ -257,7 +323,10 @@ const CollectionScene = observer(function CollectionScene_() {
/>
</Route>
<Route
path={collectionPath(collection, CollectionTab.Published)}
path={collectionPath(
collection.path,
CollectionPath.Published
)}
>
<PaginatedDocumentList
key="published"
@@ -272,7 +341,10 @@ const CollectionScene = observer(function CollectionScene_() {
/>
</Route>
<Route
path={collectionPath(collection, CollectionTab.Updated)}
path={collectionPath(
collection.path,
CollectionPath.Updated
)}
>
<PaginatedDocumentList
key="updated"
@@ -286,7 +358,10 @@ const CollectionScene = observer(function CollectionScene_() {
/>
</Route>
<Route
path={collectionPath(collection, CollectionTab.Popular)}
path={collectionPath(
collection.path,
CollectionPath.Popular
)}
>
<PaginatedDocumentList
key="popular"
@@ -298,7 +373,10 @@ const CollectionScene = observer(function CollectionScene_() {
/>
</Route>
<Route
path={collectionPath(collection, CollectionTab.Recent)}
path={collectionPath(
collection.path,
CollectionPath.Recent
)}
exact
>
<PaginatedDocumentList
@@ -316,7 +394,7 @@ const CollectionScene = observer(function CollectionScene_() {
</>
) : (
<Route
path={collectionPath(collection, CollectionTab.Recent)}
path={collectionPath(collection.path, CollectionPath.Recent)}
exact
>
<PaginatedDocumentList
@@ -334,22 +412,20 @@ const CollectionScene = observer(function CollectionScene_() {
</Route>
)}
</Switch>
</Content>
</Documents>
</CenteredContent>
</DropToImport>
</Scene>
) : (
<CenteredContent>
<Heading>
<PlaceholderText height={35} />
</Heading>
<PlaceholderList count={5} />
</CenteredContent>
);
});
const Loading = () => (
<CenteredContent>
<Heading>
<PlaceholderText height={35} />
</Heading>
<PlaceholderList count={5} />
</CenteredContent>
);
const KeyedCollection = () => {
const params = useParams<{ id?: string }>();
@@ -358,9 +434,20 @@ const KeyedCollection = () => {
return <CollectionScene key={params.id} />;
};
const Content = styled.div`
const Documents = styled.div`
position: relative;
background: ${s("background")};
`;
const CollectionHeading = styled(Heading)`
display: flex;
align-items: center;
position: relative;
margin-left: 40px;
${breakpoint("tablet")`
margin-left: 0;
`}
`;
export default KeyedCollection;
+9 -11
View File
@@ -4,7 +4,7 @@ import isEqual from "lodash/isEqual";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import { Node } from "prosemirror-model";
import { AllSelection, Selection, TextSelection } from "prosemirror-state";
import { AllSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import {
@@ -148,7 +148,10 @@ class DocumentScene extends React.Component<Props> {
* @param template The template to use
* @param selection The selection to replace, if any
*/
replaceSelection = (template: Document | Revision, selection?: Selection) => {
replaceSelection = (
template: Document | Revision,
selection?: TextSelection | AllSelection
) => {
const editorRef = this.editor.current;
if (!editorRef) {
@@ -247,7 +250,7 @@ class DocumentScene extends React.Component<Props> {
if (abilities.move) {
dialogs.openModal({
title: t("Move document"),
content: <DocumentMove document={document} />,
content: <DocumentMove documents={[document]} />,
});
}
};
@@ -415,19 +418,14 @@ class DocumentScene extends React.Component<Props> {
});
handleSelectTemplate = async (template: Document | Revision) => {
const editorRef = this.editor.current;
if (!editorRef) {
const doc = this.editor.current?.view.state.doc;
if (!doc) {
return;
}
const { view } = editorRef;
const doc = view.state.doc;
return this.replaceSelection(
template,
ProsemirrorHelper.isEmpty(doc)
? new AllSelection(doc)
: view.state.selection
ProsemirrorHelper.isEmpty(doc) ? new AllSelection(doc) : undefined
);
};
@@ -56,7 +56,7 @@ type Props = {
const lineHeight = "1.25";
const fontSize = "2.25em";
const DocumentTitle = React.forwardRef(function DocumentTitle_(
const DocumentTitle = React.forwardRef(function _DocumentTitle(
{
documentId,
title,
+98
View File
@@ -0,0 +1,98 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Document from "~/models/Document";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
type Props = {
documents: Document[];
onSubmit?: () => void;
};
function DocumentArchive({ documents, onSubmit }: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
const [isArchiving, setArchiving] = React.useState(false);
const isBulkAction = documents.length > 1;
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setArchiving(true);
try {
const results = await Promise.allSettled(
documents.map((document) => document.archive())
);
const errorCount = results.filter(
(r) => r.status === "rejected"
).length;
if (errorCount === documents.length) {
throw new Error(
t("Couldn't archive the {{noun}}, try again?", {
noun: isBulkAction ? "documents" : "document",
})
);
}
if (isBulkAction) {
const successCount = results.filter(
(r) => r.status === "fulfilled"
).length;
if (errorCount === 0) {
toast.success(
t("{{ count }} documents archived", { count: successCount })
);
} else {
toast.warning(
t("{{ errorCount }} documents failed to archive, try again?", {
errorCount,
})
);
}
} else {
toast.success(t("Document archived"));
}
onSubmit?.();
dialogs.closeAllModals();
} catch (err) {
toast.error(err.message);
} finally {
setArchiving(false);
}
},
[onSubmit, documents, t, isBulkAction, dialogs]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{isBulkAction ? (
<Trans
count={documents.length}
defaults="Are you sure you want to archive these <em>{{ count }} documents</em>? They will be removed from collections and search results."
values={{ count: documents.length }}
components={{ em: <strong /> }}
/>
) : (
<Trans defaults="Archiving this document will remove it from the collection and search results." />
)}
</Text>
<Flex justify="flex-end" gap={8}>
<Button type="submit">
{isArchiving ? `${t("Archiving")}` : t("Archive")}
</Button>
</Flex>
</form>
);
}
export default observer(DocumentArchive);
+197 -79
View File
@@ -11,77 +11,129 @@ import useStores from "~/hooks/useStores";
import {
collectionPath,
documentPath,
homePath,
settingsPath,
} from "~/utils/routeHelpers";
type Props = {
document: Document;
onSubmit: () => void;
documents: Document[];
onSubmit?: () => void;
};
function DocumentDelete({ document, onSubmit }: Props) {
function DocumentDelete({ documents, onSubmit }: Props) {
const { t } = useTranslation();
const { ui, documents, collections, userMemberships, groupMemberships } =
useStores();
const {
ui,
dialogs,
documents: documentsStore,
collections: collectionsStore,
userMemberships,
groupMemberships,
} = useStores();
const history = useHistory();
const [isDeleting, setDeleting] = React.useState(false);
const [isArchiving, setArchiving] = React.useState(false);
const canArchive =
!document.isDraft && !document.isArchived && !document.template;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length
: 0;
const isBulkAction = documents.length > 1;
const canArchiveAll = documents.every(
(doc) => !doc.isDraft && !doc.isArchived && !doc.template
);
const nestedDocumentsCount = React.useMemo(
() =>
documents.reduce((total, doc) => {
const collection = collectionsStore.get(doc.collectionId || "");
const childrenCount = collection?.getChildrenForDocument(doc.id).length;
return total + (childrenCount ?? 0);
}, 0),
[documents, collectionsStore]
);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setDeleting(true);
try {
await document.delete();
const failedIds: string[] = [];
let successCount = 0;
userMemberships
.getByDocumentId(document.id)
?.removeDocument(document.id);
groupMemberships
.getByDocumentId(document.id)
?.removeDocument(document.id);
// only redirect if we're currently viewing the document that's deleted
if (ui.activeDocumentId === document.id) {
// If the document has a parent and it's available in the store then
// redirect to it
if (document.parentDocumentId) {
const parent = documents.get(document.parentDocumentId);
if (parent) {
history.push(documentPath(parent));
onSubmit();
return;
}
// Delete documents
for (const document of documents) {
try {
await document.delete();
userMemberships
.getByDocumentId(document.id)
?.removeDocument(document.id);
groupMemberships
.getByDocumentId(document.id)
?.removeDocument(document.id);
successCount++;
} catch {
failedIds.push(document.id);
}
// If template, redirect to the template settings.
// Otherwise redirect to the collection (or) home.
const path = document.template
? settingsPath("templates")
: collection
? collectionPath(collection)
: homePath();
history.push(path);
}
onSubmit();
if (failedIds.length === documents.length) {
throw new Error(
t("Couldnt delete the {{noun}}, try again?", {
noun: isBulkAction ? "documents" : "document",
})
);
}
onSubmit?.();
dialogs.closeAllModals();
// Show toast messages
if (isBulkAction) {
const message = failedIds.length
? t("{{ errorCount }} documents failed to delete, try again?", {
errorCount: failedIds.length,
})
: t("{{ count }} documents deleted", { count: successCount });
failedIds.length ? toast.warning(message) : toast.success(message);
} else {
toast.success(t("Document deleted"));
}
// only redirect if we're currently viewing one of the documents that have been deleted
const activeDocument = documents.find(
(doc) => ui.activeDocumentId === doc.id
);
if (activeDocument && !failedIds.includes(activeDocument.id)) {
const parent = activeDocument.parentDocumentId
? documentsStore.get(activeDocument.parentDocumentId)
: null;
const path = parent
? documentPath(parent)
: activeDocument.template
? settingsPath("templates")
: collectionPath(
collectionsStore.get(activeDocument.collectionId || "")
?.path || "/"
);
history.push(path);
}
} catch (err) {
toast.error(err.message);
} finally {
setDeleting(false);
}
},
[onSubmit, ui, document, documents, history, collection]
[
documents,
userMemberships,
groupMemberships,
ui,
documentsStore,
collectionsStore,
history,
dialogs,
onSubmit,
isBulkAction,
t,
]
);
const handleArchive = React.useCallback(
@@ -90,68 +142,134 @@ function DocumentDelete({ document, onSubmit }: Props) {
setArchiving(true);
try {
await document.archive();
onSubmit();
const results = await Promise.allSettled(
documents.map((doc) => doc.archive())
);
const errorCount = results.filter(
(r) => r.status === "rejected"
).length;
if (errorCount === documents.length) {
throw new Error(
t("Couldnt archive the {{noun}}, try again?", {
noun: isBulkAction ? "documents" : "document",
})
);
}
onSubmit?.();
dialogs.closeAllModals();
// Show toast messages
if (isBulkAction) {
const successCount = results.filter(
(r) => r.status === "fulfilled"
).length;
const message = errorCount
? t("{{ successCount }} archived, {{ errorCount }} failed", {
successCount,
errorCount,
})
: t("{{ count }} documents archived", { count: successCount });
errorCount ? toast.warning(message) : toast.success(message);
} else {
toast.success(t("Document archived"));
}
} catch (err) {
toast.error(err.message);
} finally {
setArchiving(false);
}
},
[onSubmit, document]
[documents, dialogs, isBulkAction, t, onSubmit]
);
const NoChildBody = () =>
isBulkAction ? (
<Trans
count={documents.length}
defaults="Are you sure you want to delete these <em>{{ count }} documents</em>? This action will delete all their history."
values={{ count: documents.length }}
components={{ em: <strong /> }}
/>
) : (
<Trans
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>."
values={{
documentTitle: documents[0].titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
);
const HasChildBody = () =>
isBulkAction ? (
<Trans
count={documents.length}
defaults="Are you sure about that? Deleting these <em>{{ count }} documents</em> will delete all their history and their combined <em>{{ any }} nested documents.</em>."
values={{ count: documents.length, any: nestedDocumentsCount }}
components={{
em: <strong />,
}}
/>
) : (
<Trans
count={nestedDocumentsCount}
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>."
values={{
documentTitle: documents[0].titleWithDefault,
any: nestedDocumentsCount,
}}
components={{
em: <strong />,
}}
/>
);
const ArchiveInsteadBody = () =>
isBulkAction ? (
<Trans>
If youd like the option of referencing or restoring these documents in
the future, consider archiving them instead.
</Trans>
) : (
<Trans>
If youd like the option of referencing or restoring the document in the
future, consider archiving it instead.
</Trans>
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{document.isTemplate ? (
{!isBulkAction && documents[0].isTemplate ? (
<Trans
defaults="Are you sure you want to delete the <em>{{ documentTitle }}</em> template?"
values={{
documentTitle: document.titleWithDefault,
documentTitle: documents[0].titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
) : nestedDocumentsCount < 1 ? (
<Trans
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>."
values={{
documentTitle: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
<NoChildBody />
) : (
<Trans
count={nestedDocumentsCount}
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>."
values={{
documentTitle: document.titleWithDefault,
any: nestedDocumentsCount,
}}
components={{
em: <strong />,
}}
/>
<HasChildBody />
)}
</Text>
{canArchive && (
{canArchiveAll && (
<Text as="p" type="secondary">
<Trans>
If youd like the option of referencing or restoring the{" "}
{{
noun: document.noun,
}}{" "}
in the future, consider archiving it instead.
</Trans>
<ArchiveInsteadBody />
</Text>
)}
<Flex justify="flex-end" gap={8}>
{canArchive && (
{canArchiveAll && (
<Button type="button" onClick={handleArchive} neutral>
{isArchiving ? `${t("Archiving")}` : t("Archive")}
</Button>
+103 -38
View File
@@ -14,23 +14,38 @@ import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
type Props = {
document: Document;
documents: Document[];
onSubmit?: () => void;
};
function DocumentMove({ document }: Props) {
function DocumentMove({ documents, onSubmit }: Props) {
const { dialogs, policies } = useStores();
const { t } = useTranslation();
const collectionTrees = useCollectionTrees();
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const [isMoving, setMoving] = useState(false);
const isBulkAction = documents.length > 1;
const documentIds = useMemo(
() => new Set(documents.map((doc) => doc.id)),
[documents]
);
const items = useMemo(() => {
// Recursively filter out the document itself and its existing parent doc, if any.
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
...node,
children: node.children
?.filter(
(c) => c.id !== document.id && c.id !== document.parentDocumentId
)
?.filter((c) => {
// if multiple documents are selected we want to only filter out the selected documents.
if (isBulkAction) {
return !documentIds.has(c.id);
}
return (
c.id !== documents[0].id && c.id !== documents[0].parentDocumentId
);
})
.map(filterSourceDocument),
});
@@ -45,19 +60,14 @@ function DocumentMove({ document }: Props) {
// If the document we're moving is a template, only show collections as
// move targets.
if (document.isTemplate) {
const hasTemplates = documents.some((doc) => doc.isTemplate);
if (hasTemplates) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [
policies,
collectionTrees,
document.id,
document.parentDocumentId,
document.isTemplate,
]);
}, [policies, collectionTrees, documentIds, documents, isBulkAction]);
const move = async () => {
if (!selectedPath) {
@@ -65,46 +75,101 @@ function DocumentMove({ document }: Props) {
return;
}
try {
const { type, id: parentDocumentId } = selectedPath;
setMoving(true);
const collectionId = selectedPath.collectionId as string;
const { type, id: parentDocumentId } = selectedPath;
const collectionId = selectedPath.collectionId as string;
if (type === "document") {
await document.move({ collectionId, parentDocumentId });
} else {
await document.move({ collectionId });
let successCount = 0;
let errorCount = 0;
for (const document of documents) {
try {
if (type === "document") {
await document.move({ collectionId, parentDocumentId });
} else {
await document.move({ collectionId });
}
successCount++;
} catch {
errorCount++;
}
toast.success(t("Document moved"));
dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldnt move the document, try again?"));
}
if (errorCount === documents.length) {
toast.error(
t("Couldnt move the {{noun}}, try again?", {
noun: isBulkAction ? "documents" : "document",
})
);
setMoving(false);
return;
}
onSubmit?.();
if (!isBulkAction) {
toast.success(t("Document moved"));
} else {
if (errorCount === 0) {
toast.success(
t("{{ count }} documents moved", { count: successCount })
);
} else {
toast.warning(
t("{{ errorCount }} documents failed to move, try again?", {
errorCount,
})
);
}
}
dialogs.closeAllModals();
setMoving(false);
};
const SelectedPathFooter = ({ title }: { title: string }) =>
isBulkAction ? (
<Trans
defaults="Move {{ count }} documents to <em>{{ location }}</em>"
values={{
count: documents.length,
location: title || t("Untitled"),
}}
components={{
em: <strong />,
}}
/>
) : (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: title || t("Untitled"),
}}
components={{
em: <strong />,
}}
/>
);
const NoSelectedPathFooter = isBulkAction
? t("Select a location to move {{ count }} documents", {
count: documents.length,
})
: t("Select a location to move");
return (
<FlexContainer column>
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: selectedPath.title || t("Untitled"),
}}
components={{
em: <strong />,
}}
/>
<SelectedPathFooter title={selectedPath.title} />
) : (
t("Select a location to move")
NoSelectedPathFooter
)}
</StyledText>
<Button disabled={!selectedPath} onClick={move}>
{t("Move")}
<Button disabled={!selectedPath || isMoving} onClick={move}>
{isMoving ? `${t("Moving")}` : t("Move")}
</Button>
</Footer>
</FlexContainer>
+15 -20
View File
@@ -10,7 +10,6 @@ import UsersStore, { queriedUsers } from "~/stores/UsersStore";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import { ConditionalFade } from "~/components/Fade";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
@@ -22,7 +21,6 @@ import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import { ExportCSV } from "./components/ExportCSV";
import { MembersTable } from "./components/MembersTable";
import { StickyFilters } from "./components/StickyFilters";
import UserRoleFilter from "./components/UserRoleFilter";
@@ -146,24 +144,21 @@ function Members() {
{{ signinMethods: team.signinMethods }} but havent signed in yet.
</Trans>
</Text>
<StickyFilters gap={8} justify="space-between">
<Flex gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeUserStatusFilter
activeKey={reqParams.filter ?? ""}
onSelect={handleStatusFilter}
/>
<LargeUserRoleFilter
activeKey={reqParams.role ?? ""}
onSelect={handleRoleFilter}
/>
</Flex>
<ExportCSV reqParams={reqParams} />
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
<LargeUserStatusFilter
activeKey={reqParams.filter ?? ""}
onSelect={handleStatusFilter}
/>
<LargeUserRoleFilter
activeKey={reqParams.role ?? ""}
onSelect={handleRoleFilter}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<MembersTable
@@ -1,86 +0,0 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import Button from "~/components/Button";
import { CSVHelper } from "@shared/utils/csv";
import download from "~/utils/download";
import useStores from "~/hooks/useStores";
import usePolicy from "~/hooks/usePolicy";
import useCurrentTeam from "~/hooks/useCurrentTeam";
type Props = {
/** Request parameters for filtering users */
reqParams: {
query?: string;
filter?: string;
role?: string;
sort?: string;
direction?: "ASC" | "DESC";
};
};
/**
* A button that exports all members to a CSV file.
*/
export function ExportCSV({ reqParams }: Props) {
const { t } = useTranslation();
const { users } = useStores();
const team = useCurrentTeam();
const can = usePolicy(team);
const [isExporting, setIsExporting] = useState(false);
const handleExportCSV = useCallback(async () => {
setIsExporting(true);
try {
const allUsers = await users.fetchAll({
...reqParams,
limit: 100,
});
// Convert to CSV format with formatted dates
const csvData = allUsers.map((user) => ({
id: user.id,
name: user.name,
email: user.email || "",
role: user.role,
lastActiveAt: user.lastActiveAt
? new Date(user.lastActiveAt).toISOString()
: "",
createdAt: user.createdAt ? new Date(user.createdAt).toISOString() : "",
}));
const headers: (keyof (typeof csvData)[0])[] = [
"id",
"name",
"email",
"role",
"lastActiveAt",
"createdAt",
];
const csv = CSVHelper.convertToCSV(csvData, headers);
// Trigger download
download(csv, "members.csv", "text/csv");
toast.success(t("Members exported successfully"));
} catch {
toast.error(t("Failed to export members"));
} finally {
setIsExporting(false);
}
}, [users, reqParams, t]);
if (!can.createExport) {
return null;
}
return (
<Button
type="button"
onClick={handleExportCSV}
disabled={isExporting}
neutral
>
{isExporting ? t("Exporting") + "…" : t("Download CSV")}
</Button>
);
}
+2 -2
View File
@@ -40,7 +40,7 @@ function SharedCollection({ collection }: Props) {
as={Link}
icon={<EditIcon />}
to={{
pathname: collectionPath(collection, "overview"),
pathname: collectionPath(collection.path, "overview"),
}}
neutral
>
@@ -83,7 +83,7 @@ function SharedCollection({ collection }: Props) {
</SharedMeta>
) : null}
</Flex>
<Overview collection={collection} readOnly />
<Overview collection={collection} shareId={shareId} />
</CenteredContent>
</Scene>
);
+72
View File
@@ -52,6 +52,14 @@ export default class DocumentsStore extends Store<Document> {
@observable
movingDocumentId: string | null | undefined;
/** Set of selected document IDs for bulk operations */
@observable
selectedIds: Set<string> = new Set();
/** Whether selection mode is active */
@observable
isSelectionMode = false;
importFileTypes: string[] = [
".md",
".doc",
@@ -772,4 +780,68 @@ export default class DocumentsStore extends Store<Document> {
? this.rootStore.collections.get(document.collectionId)
: undefined;
}
// Selection methods for bulk operations
/**
* Returns an array of selected document IDs.
*/
@computed
get selectedDocumentIds(): string[] {
return Array.from(this.selectedIds);
}
/**
* Returns the selected documents.
*/
@computed
get selectedDocuments(): Document[] {
return compact(this.selectedDocumentIds.map((id) => this.get(id)));
}
/**
* Checks if a document is selected.
*
* @param id - the document id to check.
* @returns true if the document is selected.
*/
isSelected(id: string): boolean {
return this.selectedIds.has(id);
}
/**
* Selects a document.
*
* @param id - the document id to select.
*/
@action
select(id: string): void {
this.selectedIds.add(id);
void this.fetch(id);
if (!this.isSelectionMode) {
this.isSelectionMode = true;
}
}
/**
* Deselects a document.
*
* @param id - the document id to deselect.
*/
@action
deselect(id: string): void {
this.selectedIds.delete(id);
if (this.selectedIds.size === 0) {
this.isSelectionMode = false;
}
}
/**
* Clears all selections and exits selection mode.
*/
@action
clearSelection(): void {
this.selectedIds.clear();
this.isSelectionMode = false;
}
}
+6 -4
View File
@@ -15,7 +15,7 @@ describe("generateEmojiNameFromFilename", () => {
);
});
test("should replace spaces and dashes with underscores", () => {
test("should replace spaces with underscores", () => {
expect(generateEmojiNameFromFilename("party parrot.gif")).toBe(
"party_parrot"
);
@@ -26,7 +26,7 @@ describe("generateEmojiNameFromFilename", () => {
test("should remove invalid characters", () => {
expect(generateEmojiNameFromFilename("party-parrot.gif")).toBe(
"party_parrot"
"partyparrot"
);
expect(generateEmojiNameFromFilename("happy!@#$%.png")).toBe("happy");
expect(generateEmojiNameFromFilename("emoji(1).png")).toBe("emoji");
@@ -57,7 +57,9 @@ describe("generateEmojiNameFromFilename", () => {
expect(generateEmojiNameFromFilename("party___parrot.gif")).toBe(
"party_parrot"
);
expect(generateEmojiNameFromFilename("test__emoji.png")).toBe("test_emoji");
expect(generateEmojiNameFromFilename("test__emoji.png")).toBe(
"test_emoji"
);
});
test("should handle complex filenames", () => {
@@ -65,7 +67,7 @@ describe("generateEmojiNameFromFilename", () => {
"party_parrot"
);
expect(generateEmojiNameFromFilename("dumpster-fire-2023.png")).toBe(
"dumpster_fire"
"dumpsterfire"
);
});
+3 -6
View File
@@ -6,7 +6,7 @@ export function emojiToUrl(text: string) {
* Generates a valid emoji name from a filename by:
* - Removing file extension
* - Converting to lowercase
* - Replacing spaces and dashes with underscores
* - Replacing spaces with underscores
* - Removing invalid characters (only allowing lowercase letters and underscores)
* - Removing numbers
* - Removing leading/trailing underscores
@@ -18,11 +18,8 @@ export function generateEmojiNameFromFilename(filename: string): string {
// Remove file extension
const nameWithoutExt = filename.replace(/\.[^.]+$/, "");
// Convert to lowercase, replace spaces and dashes with underscores
let name = nameWithoutExt
.toLowerCase()
.replace(/\s+/g, "_")
.replace(/-+/g, "_");
// Convert to lowercase, replace spaces with underscores
let name = nameWithoutExt.toLowerCase().replace(/\s+/g, "_");
// Remove all characters that aren't lowercase letters or underscores (including numbers)
name = name.replace(/[^a-z_]/g, "");
+8 -2
View File
@@ -14,14 +14,20 @@ export const isURLMentionable = ({
integration: Integration;
}): boolean => {
const { hostname, pathname } = url;
const pathParts = pathname.split("/");
switch (integration.service) {
case IntegrationService.GitHub: {
return hostname === "github.com";
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "github.com" &&
settings.github?.installation.account.name === pathParts[1] // ensure installed org/account name matches with the provided url.
);
}
case IntegrationService.Linear: {
const pathParts = pathname.split("/");
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
+6 -15
View File
@@ -1,7 +1,7 @@
import queryString from "query-string";
import type Collection from "~/models/Collection";
import type Comment from "~/models/Comment";
import type Document from "~/models/Document";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
import env from "~/env";
export function homePath(): string {
@@ -37,18 +37,11 @@ export function commentPath(document: Document, comment: Comment): string {
}`;
}
export function collectionPath(
collection: Collection,
section?: string
): string {
export function collectionPath(url: string, section?: string): string {
if (section) {
return `${collection.path}/${section}`;
return `${url}/${section}`;
}
return collection.path;
}
export function collectionEditPath(collection: Collection): string {
return collectionPath(collection, "overview/edit");
return url;
}
export function updateCollectionPath(
@@ -151,8 +144,6 @@ export function urlify(path: string): string {
export const matchCollectionSlug =
":collectionSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";
export const matchCollectionEdit = `/collection/${matchCollectionSlug}/overview/edit`;
export const matchDocumentSlug =
":documentSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";
+10 -10
View File
@@ -51,11 +51,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.946.0",
"@aws-sdk/lib-storage": "3.946.0",
"@aws-sdk/s3-presigned-post": "3.946.0",
"@aws-sdk/s3-request-presigner": "3.946.0",
"@aws-sdk/signature-v4-crt": "^3.946.0",
"@aws-sdk/client-s3": "3.927.0",
"@aws-sdk/lib-storage": "3.927.0",
"@aws-sdk/s3-presigned-post": "3.927.0",
"@aws-sdk/s3-request-presigner": "3.927.0",
"@aws-sdk/signature-v4-crt": "^3.927.0",
"@babel/core": "^7.28.5",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
@@ -136,7 +136,7 @@
"es6-error": "^4.1.1",
"fast-deep-equal": "^3.1.3",
"fetch-retry": "^5.0.6",
"form-data": "^4.0.5",
"form-data": "^4.0.4",
"fractional-index": "^1.0.0",
"framer-motion": "^4.1.17",
"franc": "^6.2.0",
@@ -182,7 +182,7 @@
"node-fetch": "2.7.0",
"nodemailer": "^7.0.11",
"octokit": "^3.2.2",
"outline-icons": "^3.15.0",
"outline-icons": "^3.13.1",
"oy-vey": "^0.12.1",
"pako": "^2.1.0",
"passport": "^0.7.0",
@@ -200,7 +200,7 @@
"prosemirror-dropcursor": "^1.8.2",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-inputrules": "^1.5.0",
"prosemirror-keymap": "^1.2.3",
"prosemirror-markdown": "^1.13.2",
"prosemirror-model": "^1.25.4",
@@ -353,10 +353,10 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.38.36",
"discord-api-types": "^0.38.30",
"husky": "^8.0.3",
"i18next-parser": "^8.13.0",
"ioredis-mock": "^8.13.1",
"ioredis-mock": "^8.9.0",
"jest-cli": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
+1 -13
View File
@@ -4,7 +4,6 @@ import {
type OAuthWebFlowAuthOptions,
type InstallationAuthOptions,
} from "@octokit/auth-app";
import { Sequelize } from "sequelize";
import { Endpoints, OctokitResponse } from "@octokit/types";
import { Octokit } from "octokit";
import pluralize from "pluralize";
@@ -230,22 +229,11 @@ export class GitHub {
return;
}
// Find integration, prioritizing one where the installation account matches the resource owner
const integration = (await Integration.findOne({
where: {
service: IntegrationService.GitHub,
teamId: actor.teamId,
},
order: [
[
Sequelize.literal(
`CASE WHEN "settings"->'github'->'installation'->'account'->>'name' = :owner THEN 0 ELSE 1 END`
),
"ASC",
],
],
replacements: {
owner: resource.owner,
"settings.github.installation.account.name": resource.owner,
},
})) as Integration<IntegrationType.Embed>;
@@ -1,55 +0,0 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("mentions", {
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "users",
},
},
documentId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "documents",
},
},
mentionedUserId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "users",
},
},
mentionType: {
type: Sequelize.STRING,
allowNull: false,
},
mentionId: {
type: Sequelize.STRING,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
await queryInterface.addIndex("mentions", ["mentionedUserId"]);
await queryInterface.addIndex("mentions", ["documentId"]);
await queryInterface.addIndex("mentions", ["mentionId", "mentionType"]);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable("mentions");
},
};
@@ -1,16 +0,0 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addIndex("authentications", ["teamId", "service"], {
name: "authentications_team_id_service",
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeIndex(
"authentications",
"authentications_team_id_service"
);
},
};
@@ -1,27 +0,0 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
// Add composite index on service and type for better filtering
await queryInterface.addIndex("integrations", ["service", "type"], {
name: "integrations_service_type",
});
// Add GIN index on settings for JSONB queries
// Using raw SQL as Sequelize doesn't support GIN index type natively
await queryInterface.sequelize.query(
'CREATE INDEX "integrations_settings_gin" ON "integrations" USING GIN ("settings");'
);
},
async down(queryInterface, Sequelize) {
// Drop indexes in reverse order of creation
await queryInterface.sequelize.query(
'DROP INDEX IF EXISTS "integrations_settings_gin";'
);
await queryInterface.removeIndex(
"integrations",
"integrations_service_type"
);
},
};
-4
View File
@@ -62,7 +62,6 @@ import Group from "./Group";
import GroupMembership from "./GroupMembership";
import GroupUser from "./GroupUser";
import Import from "./Import";
import Mention from "./Mention";
import Relationship from "./Relationship";
import Revision from "./Revision";
import Star from "./Star";
@@ -669,9 +668,6 @@ class Document extends ArchivableModel<
@HasMany(() => Relationship)
relationships: Relationship[];
@HasMany(() => Mention)
mentions: Mention[];
@HasMany(() => Star)
starred: Star[];
-104
View File
@@ -1,104 +0,0 @@
import { InferAttributes, InferCreationAttributes } from "sequelize";
import {
DataType,
BelongsTo,
ForeignKey,
Column,
Table,
} from "sequelize-typescript";
import { MentionType } from "@shared/types";
import Document from "./Document";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
@Table({ tableName: "mentions", modelName: "mention" })
@Fix
class Mention extends IdModel<
InferAttributes<Mention>,
Partial<InferCreationAttributes<Mention>>
> {
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
@BelongsTo(() => Document, "documentId")
document: Document;
@ForeignKey(() => Document)
@Column(DataType.UUID)
documentId: string;
@BelongsTo(() => User, "mentionedUserId")
mentionedUser: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
mentionedUserId: string;
@Column(DataType.STRING)
mentionType: MentionType;
@Column(DataType.STRING)
mentionId: string;
/**
* Find all mentions for a user in documents they have access to
*
* @param userId The user ID to find mentions for
* @param user The user to check document access for
*/
public static async findMentionsForUser(userId: string, user: User) {
// Lazy import to avoid circular dependency
const { can } = await import("@server/policies");
const mentions = await this.findAll({
where: {
mentionedUserId: userId,
},
include: [
{
model: Document,
as: "document",
},
],
});
// Filter mentions to only include documents the user has access to
const accessibleMentions = [];
for (const mention of mentions) {
if (mention.document) {
const hasAccess = can(user, "read", mention.document);
if (hasAccess) {
accessibleMentions.push(mention);
}
}
}
return accessibleMentions;
}
/**
* Find all mentions in a specific document
*
* @param documentId The document ID to find mentions for
*/
public static async findMentionsInDocument(documentId: string) {
return this.findAll({
where: {
documentId,
},
include: [
{
model: User,
as: "mentionedUser",
},
],
});
}
}
export default Mention;
-4
View File
@@ -57,7 +57,6 @@ import Attachment from "./Attachment";
import AuthenticationProvider from "./AuthenticationProvider";
import Collection from "./Collection";
import Group from "./Group";
import Mention from "./Mention";
import Team from "./Team";
import UserAuthentication from "./UserAuthentication";
import UserMembership from "./UserMembership";
@@ -249,9 +248,6 @@ class User extends ParanoidModel<
@HasMany(() => UserAuthentication)
authentications: UserAuthentication[];
@HasMany(() => Mention, "mentionedUserId")
mentions: Mention[];
// getters
get isSuspended(): boolean {
-2
View File
@@ -32,8 +32,6 @@ export { default as Integration } from "./Integration";
export { default as IntegrationAuthentication } from "./IntegrationAuthentication";
export { default as Mention } from "./Mention";
export { default as Notification } from "./Notification";
export { default as OAuthAuthentication } from "./oauth/OAuthAuthentication";
+43 -98
View File
@@ -1,4 +1,6 @@
import flattenDeep from "lodash/flattenDeep";
import isObject from "lodash/isPlainObject";
import uniq from "lodash/uniq";
import { Model } from "sequelize-typescript";
import { AuthorizationError } from "@server/errors";
@@ -24,6 +26,8 @@ type Ability = {
* This is originally adapted from https://www.npmjs.com/package/cancan
*/
export class CanCan {
public abilities: Ability[] = [];
/**
* Define an authorized ability for a model, action, and target.
*
@@ -54,17 +58,7 @@ export class CanCan {
(this.toArray(actions) as string[]).forEach((action) => {
(this.toArray(targets) as T[]).forEach((target) => {
const ability = { model, action, target, condition } as Ability;
// Add to index
if (!this.abilities.has(model)) {
this.abilities.set(model, new Map());
}
const actionMap = this.abilities.get(model)!;
if (!actionMap.has(action)) {
actionMap.set(action, []);
}
actionMap.get(action)!.push(ability);
this.abilities.push({ model, action, target, condition } as Ability);
});
});
};
@@ -91,31 +85,25 @@ export class CanCan {
);
// Check conditions only for matching abilities
const seenConditions = new Set<boolean | string>();
const membershipIds: string[] = [];
let hasNonMembershipMatch = false;
const conditions = uniq(
flattenDeep(
matchingAbilities.map((ability) => {
if (!ability.condition) {
return false;
}
return ability.condition(performer, target, options || {});
})
)
);
for (const ability of matchingAbilities) {
if (!ability.condition) {
continue;
}
const matchingConditions = conditions.filter(Boolean);
const matchingMembershipIds = matchingConditions.filter(
(m) => typeof m === "string"
) as string[];
const result = ability.condition(performer, target, options);
if (!result || seenConditions.has(result)) {
continue;
}
seenConditions.add(result);
if (typeof result === "string") {
membershipIds.push(result);
} else {
hasNonMembershipMatch = true;
}
}
return membershipIds.length > 0 ? membershipIds : hasNonMembershipMatch;
return matchingMembershipIds.length > 0
? matchingMembershipIds
: matchingConditions.length > 0;
};
/*
@@ -125,31 +113,22 @@ export class CanCan {
*/
public serialize = (performer: Model, target: Model | null): Policy => {
const output: Record<string, boolean | string[]> = {};
abilities.forEach((ability) => {
if (
performer instanceof ability.model &&
target instanceof (ability.target as any)
) {
let response: boolean | string[] = true;
// Get all unique actions to check from the index
const actionsToCheck = new Set<string>();
for (const [model, actionMap] of this.abilities.entries()) {
if (performer instanceof model) {
for (const [action, abilities] of actionMap.entries()) {
for (const ability of abilities) {
if (target instanceof (ability.target as any)) {
actionsToCheck.add(action);
break;
}
}
try {
response = this.can(performer, ability.action, target);
} catch (_err) {
response = false;
}
}
}
// Check each unique action once
actionsToCheck.forEach((action) => {
try {
output[action] = this.can(performer, action, target);
} catch (_err) {
output[action] = false;
output[ability.action] = response;
}
});
return output;
};
@@ -195,49 +174,15 @@ export class CanCan {
performer: Model,
action: string,
target: Model | null | undefined
) => {
const matchingAbilities: Ability[] = [];
// Use index to find abilities by model and action
for (const [model, actionMap] of this.abilities.entries()) {
if (!(performer instanceof model)) {
continue;
}
// Check for specific action
const specificAbilities = actionMap.get(action);
if (specificAbilities) {
for (const ability of specificAbilities) {
if (
ability.target === "all" ||
target === ability.target ||
target instanceof (ability.target as any)
) {
matchingAbilities.push(ability);
}
}
}
// Check for "manage" action (applies to all actions)
const manageAbilities = actionMap.get("manage");
if (manageAbilities) {
for (const ability of manageAbilities) {
if (
ability.target === "all" ||
target === ability.target ||
target instanceof (ability.target as any)
) {
matchingAbilities.push(ability);
}
}
}
}
return matchingAbilities;
};
// Index for fast lookups: Map<model, Map<action, Ability[]>>
private abilities: Map<Constructor, Map<string, Ability[]>> = new Map();
) =>
this.abilities.filter(
(ability) =>
performer instanceof ability.model &&
(ability.target === "all" ||
target === ability.target ||
target instanceof (ability.target as any)) &&
(ability.action === "manage" || action === ability.action)
);
private get = <T extends object>(obj: T, key: keyof T) =>
"get" in obj && typeof obj.get === "function" ? obj.get(key) : obj[key];
@@ -274,7 +219,7 @@ export class CanCan {
const cancan = new CanCan();
export const { allow, can, cannot, serialize } = cancan;
export const { allow, can, cannot, abilities, serialize } = cancan;
// This is exported separately as a workaround for the following issue:
// https://github.com/microsoft/TypeScript/issues/36931
+9 -18
View File
@@ -1,4 +1,5 @@
import invariant from "invariant";
import filter from "lodash/filter";
import { CollectionPermission } from "@shared/types";
import { Collection, User, Team } from "@server/models";
import { allow } from "./cancan";
@@ -6,10 +7,10 @@ import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
allow(User, "createCollection", Team, (actor, team) =>
and(
!actor.isGuest,
!actor.isViewer,
isTeamModel(actor, team),
isTeamMutable(actor),
!actor.isGuest,
!actor.isViewer,
or(actor.isAdmin, !!team?.memberCollectionCreate)
)
);
@@ -25,9 +26,9 @@ allow(User, "importCollection", Team, (actor, team) =>
allow(User, "move", Collection, (actor, collection) =>
and(
//
!!collection?.isActive,
isTeamAdmin(actor, collection),
isTeamMutable(actor)
isTeamMutable(actor),
!!collection?.isActive
)
);
@@ -193,20 +194,10 @@ function includesMembership(
"Development: collection groupMemberships not preloaded, did you forget `withMembership` scope?"
);
const permissionSet = new Set(permissions);
const membershipIds: string[] = [];
for (const membership of collection.memberships) {
if (permissionSet.has(membership.permission as CollectionPermission)) {
membershipIds.push(membership.id);
}
}
for (const membership of collection.groupMemberships) {
if (permissionSet.has(membership.permission as CollectionPermission)) {
membershipIds.push(membership.id);
}
}
const membershipIds = filter(
[...collection.memberships, ...collection.groupMemberships],
(m) => permissions.includes(m.permission as CollectionPermission)
).map((m) => m.id);
return membershipIds.length > 0 ? membershipIds : false;
}
+30 -44
View File
@@ -1,4 +1,5 @@
import invariant from "invariant";
import filter from "lodash/filter";
import { DocumentPermission, TeamPreference } from "@shared/types";
import { Document, Revision, User, Team } from "@server/models";
import { allow, cannot, can } from "./cancan";
@@ -35,8 +36,8 @@ allow(User, "read", Document, (actor, document) =>
allow(User, ["listRevisions", "listViews"], Document, (actor, document) =>
or(
and(!actor.isGuest, can(actor, "read", document)),
and(actor.isGuest, can(actor, "update", document))
and(can(actor, "read", document), !actor.isGuest),
and(can(actor, "update", document), actor.isGuest)
)
);
@@ -52,14 +53,14 @@ allow(User, "download", Document, (actor, document) =>
allow(User, "comment", Document, (actor, document) =>
and(
!!document?.isActive,
!document?.template,
isTeamMutable(actor),
// TODO: We'll introduce a separate permission for commenting
or(
and(!actor.isGuest, can(actor, "read", document)),
and(actor.isGuest, can(actor, "update", document))
and(can(actor, "read", document), !actor.isGuest),
and(can(actor, "update", document), actor.isGuest)
),
isTeamMutable(actor),
!!document?.isActive,
!document?.template,
or(!document?.collection, document?.collection?.commenting !== false)
)
);
@@ -71,26 +72,26 @@ allow(
(actor, document) =>
and(
//
!document?.template,
can(actor, "read", document)
can(actor, "read", document),
!document?.template
)
);
allow(User, "share", Document, (actor, document) =>
and(
can(actor, "read", document),
isTeamMutable(actor),
!!document?.isActive,
!document?.template,
isTeamMutable(actor),
can(actor, "read", document),
or(!document?.collection, can(actor, "share", document?.collection))
)
);
allow(User, "update", Document, (actor, document) =>
and(
!!document?.isActive,
isTeamMutable(actor),
can(actor, "read", document),
isTeamMutable(actor),
!!document?.isActive,
or(
includesMembership(document, [
DocumentPermission.ReadWrite,
@@ -114,8 +115,8 @@ allow(User, "update", Document, (actor, document) =>
allow(User, "publish", Document, (actor, document) =>
and(
//
!!document?.isDraft,
can(actor, "update", document)
can(actor, "update", document),
!!document?.isDraft
)
);
@@ -170,40 +171,35 @@ allow(User, "move", Document, (actor, document) =>
);
allow(User, "createChildDocument", Document, (actor, document) =>
and(
//
!document?.isDraft,
!document?.template,
can(actor, "update", document)
)
and(can(actor, "update", document), !document?.isDraft, !document?.template)
);
allow(User, ["updateInsights", "pin", "unpin"], Document, (actor, document) =>
and(
can(actor, "update", document),
can(actor, "update", document?.collection),
!document?.isDraft,
!document?.template,
!actor.isGuest,
can(actor, "update", document),
can(actor, "update", document?.collection)
!actor.isGuest
)
);
allow(User, "pinToHome", Document, (actor, document) =>
and(
//
isTeamAdmin(actor, document),
isTeamMutable(actor),
!document?.isDraft,
!document?.template,
!!document?.isActive,
isTeamAdmin(actor, document),
isTeamMutable(actor)
!!document?.isActive
)
);
allow(User, "delete", Document, (actor, document) =>
and(
!document?.isDeleted,
isTeamModel(actor, document),
isTeamMutable(actor),
!document?.isDeleted,
or(
can(actor, "unarchive", document),
can(actor, "update", document),
@@ -214,9 +210,9 @@ allow(User, "delete", Document, (actor, document) =>
allow(User, "restore", Document, (actor, document) =>
and(
isTeamModel(actor, document),
!actor.isGuest,
!!document?.isDeleted,
isTeamModel(actor, document),
or(
includesMembership(document, [
DocumentPermission.ReadWrite,
@@ -235,9 +231,9 @@ allow(User, "restore", Document, (actor, document) =>
allow(User, "permanentDelete", Document, (actor, document) =>
and(
isTeamModel(actor, document),
!actor.isGuest,
!!document?.isDeleted,
isTeamModel(actor, document),
isTeamAdmin(actor, document)
)
);
@@ -326,20 +322,10 @@ function includesMembership(
"Development: document groupMemberships should be preloaded, did you forget withMembership scope?"
);
const permissionSet = new Set(permissions);
const membershipIds: string[] = [];
for (const membership of document.memberships) {
if (permissionSet.has(membership.permission as DocumentPermission)) {
membershipIds.push(membership.id);
}
}
for (const membership of document.groupMemberships) {
if (permissionSet.has(membership.permission as DocumentPermission)) {
membershipIds.push(membership.id);
}
}
const membershipIds = filter(
[...document.memberships, ...document.groupMemberships],
(m) => permissions.includes(m.permission as DocumentPermission)
).map((m) => m.id);
return membershipIds.length > 0 ? membershipIds : false;
}
+2 -6
View File
@@ -7,12 +7,8 @@ import invariant from "invariant";
type Args = boolean | string | Args[];
export function and(...args: Args[]) {
for (const arg of args) {
if (!arg) {
return false;
}
}
return args;
const filtered = args.filter(Boolean);
return filtered.length === args.length ? filtered : false;
}
export function or(...args: Args[]) {
+2 -2
View File
@@ -14,7 +14,7 @@ type Options = {
includeText?: boolean;
/** Always include the data of the document in the payload. */
includeData?: boolean;
/** Include the updatedAt timestamp for public documents. */
includeUpdatedAt?: boolean;
};
@@ -56,6 +56,7 @@ async function presentDocument(
text,
icon: document.icon,
color: document.color,
tasks: document.tasks,
language: document.language,
createdAt: document.createdAt,
createdBy: undefined,
@@ -84,7 +85,6 @@ async function presentDocument(
if (!options.isPublic) {
const source = await document.$get("import");
res.tasks = document.tasks;
res.isCollectionDeleted = await document.isCollectionDeleted();
res.collectionId = document.collectionId;
res.parentDocumentId = document.parentDocumentId;
@@ -1,232 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import { parser } from "@server/editor";
import { Mention } from "@server/models";
import { buildDocument, buildUser } from "@server/test/factories";
import MentionsProcessor from "./MentionsProcessor";
describe("MentionsProcessor", () => {
it("should create new mention records", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const mentionId = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId}/user/${mentionedUser.id})!`,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.publish",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(1);
});
it("should not create mention records for unpublished documents", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const mentionId = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId}/user/${mentionedUser.id})!`,
publishedAt: null,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.update",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
createdAt: new Date().toISOString(),
actorId: user.id!,
data: {
title: document.title,
autosave: false,
done: true,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(0);
});
it("should update mention records when document is updated", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const anotherMentionedUser = await buildUser({ teamId: user.teamId });
const mentionId1 = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId1}/user/${mentionedUser.id})!`,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.publish",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
// Update document to mention a different user
const mentionId2 = uuidv4();
const newText = `Hello @[${anotherMentionedUser.name}](mention://${mentionId2}/user/${anotherMentionedUser.id})!`;
document.text = newText;
document.content = parser.parse(newText)?.toJSON() || document.content;
await document.save();
await processor.perform({
name: "documents.update",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
createdAt: new Date().toISOString(),
data: {
title: document.title,
autosave: false,
done: true,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(1);
expect(mentions[0].mentionedUserId).toBe(anotherMentionedUser.id);
});
it("should destroy removed mention records", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const anotherMentionedUser = await buildUser({ teamId: user.teamId });
const mentionId1 = uuidv4();
const mentionId2 = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId1}/user/${mentionedUser.id}) and @[${anotherMentionedUser.name}](mention://${mentionId2}/user/${anotherMentionedUser.id})!`,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.publish",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
// Update document to remove one mention
const mentionId3 = uuidv4();
const newText = `Hello @[${mentionedUser.name}](mention://${mentionId3}/user/${mentionedUser.id})!`;
document.text = newText;
document.content = parser.parse(newText)?.toJSON() || document.content;
await document.save();
await processor.perform({
name: "documents.update",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
createdAt: new Date().toISOString(),
data: {
title: document.title,
autosave: false,
done: true,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(1);
expect(mentions[0].mentionedUserId).toBe(mentionedUser.id);
});
it("should destroy related mentions", async () => {
const user = await buildUser();
const mentionedUser = await buildUser({ teamId: user.teamId });
const mentionId = uuidv4();
const document = await buildDocument({
userId: user.id!,
teamId: user.teamId!,
text: `Hello @[${mentionedUser.name}](mention://${mentionId}/user/${mentionedUser.id})!`,
});
const processor = new MentionsProcessor();
await processor.perform({
name: "documents.publish",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
await processor.perform({
name: "documents.delete",
documentId: document.id!,
collectionId: document.collectionId!,
teamId: document.teamId!,
actorId: user.id!,
data: {
title: document.title,
},
ip: "127.0.0.1",
});
const mentions = await Mention.findAll({
where: {
documentId: document.id,
},
});
expect(mentions.length).toBe(0);
});
});
@@ -1,119 +0,0 @@
import { Op } from "sequelize";
import { MentionType } from "@shared/types";
import { Document, Mention, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { Event, DocumentEvent, RevisionEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
export default class MentionsProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = [
"documents.publish",
"documents.update",
"documents.delete",
];
async perform(event: DocumentEvent | RevisionEvent) {
switch (event.name) {
case "documents.publish": {
const document = await Document.findByPk(event.documentId);
if (!document) {
return;
}
const mentions = DocumentHelper.parseMentions(document, {
type: MentionType.User,
});
await Promise.all(
mentions.map(async (mention) => {
const mentionedUser = await User.findByPk(mention.modelId);
if (
!mentionedUser ||
mentionedUser.id === document.lastModifiedById
) {
return;
}
await Mention.findOrCreate({
where: {
documentId: document.id,
mentionedUserId: mentionedUser.id,
mentionId: mention.id,
mentionType: mention.type,
},
defaults: {
userId: document.lastModifiedById,
},
});
})
);
break;
}
case "documents.update": {
const document = await Document.findByPk(event.documentId);
if (!document) {
return;
}
// mentions are only created for published documents
if (!document.publishedAt) {
return;
}
const mentions = DocumentHelper.parseMentions(document, {
type: MentionType.User,
});
const mentionIds: string[] = [];
// create or find existing mention records for mentioned users
await Promise.all(
mentions.map(async (mention) => {
const mentionedUser = await User.findByPk(mention.modelId);
if (
!mentionedUser ||
mentionedUser.id === document.lastModifiedById
) {
return;
}
await Mention.findOrCreate({
where: {
documentId: document.id,
mentionedUserId: mentionedUser.id,
mentionId: mention.id,
mentionType: mention.type,
},
defaults: {
userId: document.lastModifiedById,
},
});
mentionIds.push(mention.id);
})
);
// delete any mentions that no longer exist
await Mention.destroy({
where: {
mentionId: {
[Op.notIn]: mentionIds,
},
documentId: event.documentId,
},
});
break;
}
case "documents.delete": {
await Mention.destroy({
where: {
documentId: event.documentId,
},
});
break;
}
default:
}
}
}
@@ -65,9 +65,8 @@ export default class UpdateDocumentsPopularityScoreTask extends CronTask {
const threshold = subWeeks(now, env.POPULARITY_ACTIVITY_THRESHOLD_WEEKS);
// Generate unique table name for this run to prevent conflicts
const dateStr = now.toISOString().slice(0, 19).replace(/[-:T]/g, "");
const uniqueId = crypto.randomBytes(4).toString("hex");
this.workingTable = `${WORKING_TABLE_PREFIX}_${dateStr}_${uniqueId}`;
const uniqueId = crypto.randomBytes(8).toString("hex");
this.workingTable = `${WORKING_TABLE_PREFIX}_${uniqueId}`;
try {
// Setup: Create working table and populate with active document IDs
@@ -151,64 +150,31 @@ export default class UpdateDocumentsPopularityScoreTask extends CronTask {
const [startUuid, endUuid] = this.getPartitionBounds(partition);
// Populate with documents that have recent activity and are valid
// (published, not deleted). Process in chunks to avoid long-running queries.
let offset = 0;
let insertedCount = 0;
const chunkSize = 500;
while (true) {
const result = await sequelize.query<{ documentId: string }>(
`
INSERT INTO ${this.workingTable} ("documentId")
SELECT DISTINCT d.id as "documentId"
FROM documents d
WHERE d."publishedAt" IS NOT NULL
AND d."deletedAt" IS NULL
AND (
EXISTS (
SELECT 1 FROM revisions r
WHERE r."documentId" = d.id AND r."createdAt" >= :threshold
)
OR EXISTS (
SELECT 1 FROM comments c
WHERE c."documentId" = d.id AND c."createdAt" >= :threshold
)
OR EXISTS (
SELECT 1 FROM views v
WHERE v."documentId" = d.id AND v."updatedAt" >= :threshold
)
// (published, not deleted). Using JOINs to filter upfront.
await sequelize.query(
`
INSERT INTO ${this.workingTable} ("documentId")
SELECT DISTINCT d.id
FROM documents d
WHERE d."publishedAt" IS NOT NULL
AND d."deletedAt" IS NULL
AND (
EXISTS (
SELECT 1 FROM revisions r
WHERE r."documentId" = d.id AND r."createdAt" >= :threshold
)
${startUuid && endUuid ? "AND d.id >= :startUuid AND d.id <= :endUuid" : ""}
ORDER BY d.id
LIMIT :limit
OFFSET :offset
ON CONFLICT ("documentId") DO NOTHING
RETURNING "documentId"
`,
{
replacements: {
threshold,
startUuid,
endUuid,
limit: chunkSize,
offset,
},
type: QueryTypes.SELECT,
}
);
insertedCount += result.length;
if (result.length < chunkSize) {
break;
}
offset += chunkSize;
}
Logger.debug(
"task",
`Populated working table with ${insertedCount} documents in ${Math.ceil(insertedCount / chunkSize)} chunks`
OR EXISTS (
SELECT 1 FROM comments c
WHERE c."documentId" = d.id AND c."createdAt" >= :threshold
)
OR EXISTS (
SELECT 1 FROM views v
WHERE v."documentId" = d.id AND v."updatedAt" >= :threshold
)
)
${startUuid && endUuid ? "AND d.id >= :startUuid AND d.id <= :endUuid" : ""}
`,
{ replacements: { threshold, startUuid, endUuid } }
);
// Create index on processed column for efficient batch selection
@@ -10,7 +10,6 @@ import {
buildComment,
buildCommentMark,
buildDocument,
buildEmoji,
buildResolvedComment,
buildTeam,
buildUser,
@@ -887,72 +886,6 @@ describe("#comments.add_reaction", () => {
]);
expect(addedReaction).toBeTruthy();
});
it("should add a custom emoji reaction to a comment", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
const emoji = await buildEmoji({
teamId: team.id,
createdById: user.id,
});
const res = await server.post("/api/comments.add_reaction", {
body: {
token: user.getJwtToken(),
id: comment.id,
emoji: emoji.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
const updatedComment = await Comment.findByPk(comment.id);
const addedReaction = await Reaction.findOne({
where: { commentId: comment.id, emoji: emoji.id, userId: user.id },
});
expect(updatedComment?.reactions).toEqual([
{ emoji: emoji.id, userIds: [user.id] },
]);
expect(addedReaction).toBeTruthy();
});
it("should fail with custom emoji from different team", async () => {
const team = await buildTeam();
const otherTeam = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const comment = await buildComment({
userId: user.id,
documentId: document.id,
});
const emoji = await buildEmoji({
teamId: otherTeam.id,
});
const res = await server.post("/api/comments.add_reaction", {
body: {
token: user.getJwtToken(),
id: comment.id,
emoji: emoji.id,
},
});
expect(res.status).toEqual(403);
});
});
describe("#comments.remove_reaction", () => {
+1 -10
View File
@@ -5,16 +5,14 @@ import {
CommentStatusFilter,
TeamPreference,
MentionType,
IconType,
} from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import { parser } from "@server/editor";
import auth from "@server/middlewares/authentication";
import { feature } from "@server/middlewares/feature";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Document, Comment, Collection, Reaction, Emoji } from "@server/models";
import { Document, Comment, Collection, Reaction } from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
import { authorize } from "@server/policies";
@@ -409,13 +407,6 @@ router.post(
authorize(user, "comment", document);
authorize(user, "addReaction", comment);
if (determineIconType(emoji) === IconType.Custom) {
const customEmoji = await Emoji.findByPk(emoji, {
transaction,
});
authorize(user, "read", customEmoji);
}
await Reaction.findOrCreate({
where: {
emoji,
+2 -2
View File
@@ -1,8 +1,8 @@
import emojiRegex from "emoji-regex";
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { CommentStatusFilter } from "@shared/types";
import { BaseSchema, ProsemirrorSchema } from "@server/routes/api/schema";
import { zodEmojiType } from "@server/utils/zod";
const BaseIdSchema = z.object({
/** Comment Id */
@@ -104,7 +104,7 @@ export type CommentsUnresolveReq = z.infer<typeof CommentsUnresolveSchema>;
export const CommentsReactionSchema = z.object({
body: BaseIdSchema.extend({
/** Emoji that's added to (or) removed from a comment as a reaction. */
emoji: zodEmojiType(),
emoji: z.string().regex(emojiRegex()),
}),
});
-2
View File
@@ -469,7 +469,6 @@ router.post(
]),
required: true,
where: {
teamId: user.teamId,
collectionId: collectionIds,
},
include: [
@@ -523,7 +522,6 @@ router.post(
? [collectionId]
: await user.collectionIds();
const where: WhereOptions = {
teamId: user.teamId,
createdById: user.id,
collectionId: {
[Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }],
-1
View File
@@ -2,7 +2,6 @@ if (process.env.NODE_ENV !== "test") {
// oxlint-disable-next-line @typescript-eslint/no-var-requires
require("@dotenvx/dotenvx").config({
silent: true,
ignore: ["MISSING_ENV_FILE"],
});
}

Some files were not shown because too many files have changed in this diff Show More