mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6863fe3698 | |||
| 948e557bdd | |||
| d5dbf286cc | |||
| 27f4ba7062 | |||
| c3ffcd8d38 | |||
| b68997f78a | |||
| e19b23c22f | |||
| 2e471f88be | |||
| 8cb07889ce | |||
| f8a79f9e79 | |||
| 1e894aabdf | |||
| 6cd2346d46 | |||
| 35510fb4be | |||
| ac460318fd | |||
| 6b3900cfc5 | |||
| 2543d6d56c | |||
| 5140d2434e | |||
| 108e14338b | |||
| df284756f1 | |||
| 410c196943 | |||
| 4893c61a1f | |||
| 6e2793a751 | |||
| 5e5b37c418 | |||
| e30c35acdb | |||
| c3ad5bb7f6 | |||
| 971c542613 | |||
| 3681d1c9b2 | |||
| 621409ae0b | |||
| 8af6fdcc4f | |||
| ccbc7779ea | |||
| 2eeeae4a7c | |||
| 050499b8fc | |||
| 7d88c97914 | |||
| e82c848051 | |||
| 4b6c6f7b36 | |||
| d795e78b79 | |||
| 5b3d6c3535 | |||
| 6f3534c713 | |||
| 133ec073be | |||
| 305b81fbf4 | |||
| aeb777b2f5 | |||
| f307c678c2 | |||
| ac23277b7c | |||
| eee64e363f | |||
| 55116b4761 | |||
| ac2d3bf3cb | |||
| 94a8326c68 |
@@ -1,6 +1,10 @@
|
||||
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
|
||||
@@ -12,6 +16,7 @@ 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;
|
||||
|
||||
+2
-1
@@ -94,7 +94,8 @@
|
||||
"args": "after-used",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"react/rules-of-hooks": "error"
|
||||
},
|
||||
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
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,
|
||||
];
|
||||
@@ -47,6 +47,7 @@ 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";
|
||||
@@ -80,7 +81,6 @@ 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 documents={[document]} />,
|
||||
content: <DocumentMove document={document} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1094,7 +1094,19 @@ export const archiveDocument = createAction({
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Are you sure you want to archive this document?"),
|
||||
content: <DocumentArchive documents={[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>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1218,7 +1230,12 @@ export const deleteDocument = createAction({
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
}),
|
||||
content: <DocumentDelete documents={[document]} />,
|
||||
content: (
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
) {
|
||||
|
||||
@@ -21,6 +21,7 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export default Badge;
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
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);
|
||||
@@ -28,7 +28,7 @@ export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
||||
name: collection.name,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
to: collectionPath(collection),
|
||||
}),
|
||||
],
|
||||
[collection, t]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 from "~/components/Input";
|
||||
import Input, { LabelText } from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
@@ -108,12 +108,16 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const compressed = await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
// Skip compression for GIFs to preserve animation
|
||||
const fileToUpload =
|
||||
file.type === "image/gif"
|
||||
? file
|
||||
: await compressImage(file, {
|
||||
maxHeight: 64,
|
||||
maxWidth: 64,
|
||||
});
|
||||
|
||||
const attachment = await uploadFile(compressed, {
|
||||
const attachment = await uploadFile(fileToUpload, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Emoji,
|
||||
});
|
||||
@@ -147,26 +151,11 @@ export function EmojiCreateDialog({ onSubmit }: Props) {
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"The emoji name should be unique and contain only lowercase letters, numbers, and underscores."
|
||||
"Square images with transparent backgrounds work best. If your image is too large, we’ll try to resize it for you."
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<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
|
||||
}
|
||||
/>
|
||||
|
||||
<LabelText as="label">{t("Upload an image")}</LabelText>
|
||||
<DropZone {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<Flex column align="center" gap={8}>
|
||||
@@ -197,6 +186,22 @@ 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>
|
||||
@@ -213,6 +218,7 @@ 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,4 +9,5 @@ export const UserInputContainer = styled(Flex)`
|
||||
|
||||
export const StyledInputSearch = styled(InputSearch)`
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
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,14 +1,23 @@
|
||||
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 } from "./GridTemplate";
|
||||
import GridTemplate, { DataNode, EmojiNode } 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;
|
||||
|
||||
@@ -30,9 +39,15 @@ 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,
|
||||
@@ -41,11 +56,20 @@ 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);
|
||||
@@ -60,23 +84,68 @@ 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);
|
||||
incrementIconCount(id);
|
||||
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
[onEmojiChange, incrementIconCount]
|
||||
[
|
||||
onEmojiChange,
|
||||
incrementIconCount,
|
||||
incrementCustomIconCount,
|
||||
customEmojis,
|
||||
freqCustomEmojis,
|
||||
]
|
||||
);
|
||||
|
||||
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(() => {
|
||||
@@ -89,7 +158,7 @@ const EmojiPanel = ({
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<UserInputContainer align="center" gap={12}>
|
||||
<UserInputContainer align="center" gap={8}>
|
||||
<StyledInputSearch
|
||||
ref={searchRef}
|
||||
value={query}
|
||||
@@ -97,6 +166,14 @@ 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}
|
||||
@@ -104,6 +181,11 @@ const EmojiPanel = ({
|
||||
height={height - 48}
|
||||
data={templateData}
|
||||
onIconSelect={handleEmojiSelection}
|
||||
empty={
|
||||
<IconButton onClick={handleUploadClick}>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
@@ -112,19 +194,32 @@ 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: emojis.map((emoji) => ({
|
||||
type: IconType.Emoji,
|
||||
id: emoji.id,
|
||||
value: emoji.value,
|
||||
})),
|
||||
icons: allResults,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -132,21 +227,32 @@ 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 });
|
||||
return {
|
||||
category: DisplayCategory.Frequent,
|
||||
icons: emojis.map((emoji) => ({
|
||||
type: IconType.Emoji,
|
||||
|
||||
// Combine frequent standard and custom emojis
|
||||
const allFrequent = [
|
||||
...emojis.map((emoji) => ({
|
||||
type: IconType.Emoji as const,
|
||||
id: emoji.id,
|
||||
value: emoji.value,
|
||||
})),
|
||||
...freqCustomEmojis,
|
||||
];
|
||||
|
||||
return {
|
||||
category: DisplayCategory.Frequent,
|
||||
icons: allFrequent,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -162,7 +268,7 @@ const getAllEmojis = ({
|
||||
};
|
||||
};
|
||||
|
||||
return concat(
|
||||
const allData = concat(
|
||||
getFrequentIcons(),
|
||||
getCategoryData(EmojiCategory.People),
|
||||
getCategoryData(EmojiCategory.Nature),
|
||||
@@ -173,6 +279,22 @@ 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,14 +37,20 @@ 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, onIconSelect }: Props,
|
||||
{ width, height, data, empty, onIconSelect }: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) => {
|
||||
// 24px padding for the Grid Container
|
||||
@@ -52,10 +58,6 @@ const GridTemplate = (
|
||||
|
||||
const gridItems = compact(
|
||||
data.flatMap((node) => {
|
||||
if (node.icons.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const category = (
|
||||
<CategoryName
|
||||
key={node.category}
|
||||
@@ -67,6 +69,13 @@ 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 (
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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,18 +1,17 @@
|
||||
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,
|
||||
@@ -57,9 +56,9 @@ const SkinTonePicker = ({
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger>
|
||||
<StyledMenuButton aria-label={t("Choose default skin tone")}>
|
||||
<MenuButton aria-label={t("Choose default skin tone")}>
|
||||
{handEmojiVariants[skinTone]?.value}
|
||||
</StyledMenuButton>
|
||||
</MenuButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
@@ -79,15 +78,4 @@ 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;
|
||||
|
||||
@@ -21,13 +21,11 @@ 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];
|
||||
@@ -175,7 +173,7 @@ const IconPicker = ({
|
||||
if (open) {
|
||||
void emojis.fetchAll();
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, emojis]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@@ -254,13 +252,6 @@ 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>
|
||||
@@ -287,15 +278,6 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -904,7 +904,7 @@ type ImageProps = {
|
||||
onMaxZoom: () => void;
|
||||
};
|
||||
|
||||
const Image = forwardRef<HTMLImageElement, ImageProps>(function _Image(
|
||||
const Image = forwardRef<HTMLImageElement, ImageProps>(function Image_(
|
||||
{
|
||||
src,
|
||||
alt,
|
||||
|
||||
@@ -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>
|
||||
) {
|
||||
|
||||
@@ -13,6 +13,9 @@ 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. */
|
||||
@@ -39,13 +42,25 @@ 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 }}", {
|
||||
@@ -120,7 +135,11 @@ const Reaction: React.FC<Props> = ({
|
||||
() => (
|
||||
<EmojiButton disabled={disabled} $active={active} onClick={handleClick}>
|
||||
<Flex gap={6} justify="center" align="center">
|
||||
<Emoji size={15}>{reaction.emoji}</Emoji>
|
||||
{isUUID(reaction.emoji) ? (
|
||||
<CustomEmoji size={15} value={reaction.emoji} />
|
||||
) : (
|
||||
<Emoji size={15}>{reaction.emoji}</Emoji>
|
||||
)}
|
||||
<Count weight="xbold">{reaction.userIds.length}</Count>
|
||||
</Flex>
|
||||
</EmojiButton>
|
||||
|
||||
@@ -1,27 +1,43 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import styled, { css } 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>
|
||||
@@ -36,14 +52,17 @@ function Scrollable(
|
||||
return;
|
||||
}
|
||||
const scrollTop = c.scrollTop;
|
||||
const tsv = !!((shadow || topShadow) && scrollTop > 0);
|
||||
const tsv = !!((shadow || topShadow || fadeTo) && scrollTop > 0);
|
||||
|
||||
if (tsv !== topShadowVisible) {
|
||||
setTopShadow(tsv);
|
||||
}
|
||||
|
||||
const wrapperHeight = c.scrollHeight - c.clientHeight;
|
||||
const bsv = !!((shadow || bottomShadow) && wrapperHeight - scrollTop !== 0);
|
||||
const bsv = !!(
|
||||
(shadow || bottomShadow || fadeTo) &&
|
||||
wrapperHeight - scrollTop !== 0
|
||||
);
|
||||
|
||||
if (bsv !== bottomShadowVisible) {
|
||||
setBottomShadow(bsv);
|
||||
@@ -52,6 +71,7 @@ function Scrollable(
|
||||
shadow,
|
||||
topShadow,
|
||||
bottomShadow,
|
||||
fadeTo,
|
||||
ref,
|
||||
topShadowVisible,
|
||||
bottomShadowVisible,
|
||||
@@ -67,21 +87,59 @@ function Scrollable(
|
||||
onScroll={updateShadows}
|
||||
$flex={flex}
|
||||
$hiddenScrollbars={hiddenScrollbars}
|
||||
$topShadowVisible={topShadowVisible}
|
||||
$bottomShadowVisible={bottomShadowVisible}
|
||||
$topShadowVisible={topShadowVisible && !fadeTo}
|
||||
$bottomShadowVisible={bottomShadowVisible && !fadeTo}
|
||||
$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.path))}
|
||||
url={urlify(collectionPath(collection))}
|
||||
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,
|
||||
|
||||
@@ -30,7 +30,6 @@ 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();
|
||||
@@ -132,7 +131,6 @@ function AppSidebar() {
|
||||
<SidebarAction action={inviteUser} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
<BulkSelectionToolbar />
|
||||
</DndProvider>
|
||||
)}
|
||||
</Sidebar>
|
||||
|
||||
@@ -88,7 +88,8 @@ function SharedSidebar({ share }: Props) {
|
||||
) : (
|
||||
<SharedDocumentLink
|
||||
index={0}
|
||||
depth={0}
|
||||
// If the root node has an icon we need some extra space for it
|
||||
depth={rootNode.icon ? 1 : 0}
|
||||
shareId={shareId}
|
||||
node={rootNode}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
|
||||
@@ -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,18 +78,6 @@ 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 &&
|
||||
@@ -446,12 +434,6 @@ 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} />
|
||||
))}
|
||||
{!end && (
|
||||
{!loading && !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,7 +14,6 @@ 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.
|
||||
@@ -57,14 +56,6 @@ 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 = {
|
||||
@@ -97,12 +88,6 @@ function SidebarLink(
|
||||
disabled,
|
||||
unreadBadge,
|
||||
contextAction,
|
||||
selectionState = {
|
||||
isSelected: false,
|
||||
showCheckbox: false,
|
||||
hasAnySelection: false,
|
||||
},
|
||||
onSelectionChange,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
@@ -111,7 +96,6 @@ 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`,
|
||||
@@ -165,7 +149,6 @@ function SidebarLink(
|
||||
$isActiveDrop={isActiveDrop}
|
||||
$isDraft={isDraft}
|
||||
$disabled={disabled}
|
||||
$hasCheckbox={showCheckbox}
|
||||
style={style}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
onClick={handleClick}
|
||||
@@ -183,17 +166,6 @@ 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}
|
||||
@@ -212,23 +184,13 @@ 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 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;
|
||||
transition: opacity 200ms ease-in-out;
|
||||
`;
|
||||
|
||||
const Content = styled.span`
|
||||
@@ -277,7 +239,6 @@ const Link = styled(NavLink)<{
|
||||
$isActiveDrop?: boolean;
|
||||
$isDraft?: boolean;
|
||||
$disabled?: boolean;
|
||||
$hasCheckbox?: boolean;
|
||||
}>`
|
||||
&:hover,
|
||||
&:active {
|
||||
@@ -365,14 +326,6 @@ 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} />
|
||||
))}
|
||||
{!end && (
|
||||
{!loading && !end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
|
||||
@@ -24,7 +24,12 @@ function TrashLink() {
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: document?.noun,
|
||||
}),
|
||||
content: <DocumentDelete documents={[document]} />,
|
||||
content: (
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
canDrop: (item) => policies.abilities(item.id).delete,
|
||||
|
||||
@@ -67,8 +67,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
|
||||
!props.disabled &&
|
||||
`
|
||||
&[data-highlighted],
|
||||
&:focus-visible,
|
||||
&:hover {
|
||||
&:focus-visible {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
|
||||
outline-color: ${
|
||||
|
||||
@@ -302,9 +302,8 @@ export default function FindAndReplace({
|
||||
const style: React.CSSProperties = React.useMemo(
|
||||
() => ({
|
||||
position: "fixed",
|
||||
left: "initial",
|
||||
top: 60,
|
||||
right: 16,
|
||||
top: 0,
|
||||
right: 0,
|
||||
zIndex: depths.popover,
|
||||
}),
|
||||
[]
|
||||
@@ -375,6 +374,7 @@ export default function FindAndReplace({
|
||||
minWidth={420}
|
||||
scrollable={false}
|
||||
onPointerDownOutside={() => setLocalOpen(false)}
|
||||
style={{ marginRight: 16, marginTop: 60 }}
|
||||
>
|
||||
<Content column>
|
||||
<Flex gap={4}>
|
||||
|
||||
@@ -294,7 +294,7 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
<Portal>
|
||||
<Wrapper
|
||||
active={props.active && position.visible}
|
||||
arrow={!position.blockSelection}
|
||||
arrow={!!props.children && !position.blockSelection}
|
||||
ref={menuRef}
|
||||
$offset={position.offset}
|
||||
style={{
|
||||
@@ -304,7 +304,9 @@ const FloatingToolbar = React.forwardRef(function FloatingToolbar_(
|
||||
left: `${position.left}px`,
|
||||
}}
|
||||
>
|
||||
<Background align={props.align}>{props.children}</Background>
|
||||
{props.children && (
|
||||
<Background align={props.align}>{props.children}</Background>
|
||||
)}
|
||||
</Wrapper>
|
||||
</Portal>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ArrowIcon, CloseIcon, DocumentIcon, OpenIcon } from "outline-icons";
|
||||
import {
|
||||
ArrowIcon,
|
||||
CloseIcon,
|
||||
DocumentIcon,
|
||||
OpenIcon,
|
||||
ReturnIcon,
|
||||
} 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";
|
||||
@@ -28,9 +33,25 @@ 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 }) => {
|
||||
const LinkEditor: React.FC<Props> = ({
|
||||
mark,
|
||||
dictionary,
|
||||
view,
|
||||
onLinkAdd,
|
||||
onLinkUpdate,
|
||||
onLinkRemove,
|
||||
onEscape,
|
||||
onClickOutside,
|
||||
onClickBack,
|
||||
}) => {
|
||||
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
|
||||
const initialValue = getHref();
|
||||
const { commands } = useEditor();
|
||||
@@ -58,7 +79,7 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
|
||||
}
|
||||
}, [trimmedQuery, request]);
|
||||
|
||||
useOnClickOutside(wrapperRef, () => {
|
||||
useOnClickOutside(wrapperRef, (ev) => {
|
||||
// If the link is totally empty or only spaces then remove the mark
|
||||
if (!trimmedQuery) {
|
||||
return removeLink();
|
||||
@@ -66,9 +87,14 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
@@ -78,26 +104,23 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
|
||||
|
||||
const removeLink = React.useCallback(() => {
|
||||
commands["removeLink"]();
|
||||
}, []);
|
||||
onLinkRemove();
|
||||
}, [commands, onLinkRemove]);
|
||||
|
||||
const updateLink = (link: string) => {
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
commands["updateLink"]({ href: sanitizeUrl(link) ?? "" });
|
||||
onLinkUpdate();
|
||||
};
|
||||
|
||||
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));
|
||||
const addLink = (link: string) => {
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
view.focus();
|
||||
commands["addLink"]({ href: sanitizeUrl(link) ?? "" });
|
||||
onLinkAdd();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
@@ -119,9 +142,11 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
|
||||
|
||||
if (selectedIndex >= 0 && results[selectedIndex]) {
|
||||
const selectedDoc = results[selectedIndex];
|
||||
updateLink(selectedDoc.url);
|
||||
!mark ? addLink(selectedDoc.url) : updateLink(selectedDoc.url);
|
||||
} else if (!trimmedQuery) {
|
||||
removeLink();
|
||||
} else if (!mark) {
|
||||
addLink(trimmedQuery);
|
||||
} else {
|
||||
updateLink(trimmedQuery);
|
||||
}
|
||||
@@ -135,11 +160,7 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
|
||||
return removeLink();
|
||||
}
|
||||
|
||||
// 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();
|
||||
onEscape();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -169,6 +190,13 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
|
||||
disabled: false,
|
||||
handler: removeLink,
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.formattingControls,
|
||||
icon: <ReturnIcon />,
|
||||
visible: view.editable,
|
||||
disabled: false,
|
||||
handler: onClickBack,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -208,7 +236,7 @@ const LinkEditor: React.FC<Props> = ({ mark, dictionary, view }) => {
|
||||
{results.map((doc, index) => (
|
||||
<SuggestionsMenuItem
|
||||
onPointerDown={() => {
|
||||
updateLink(doc.url);
|
||||
!mark ? addLink(doc.url) : updateLink(doc.url);
|
||||
}}
|
||||
onPointerMove={() => setSelectedIndex(index)}
|
||||
selected={index === selectedIndex}
|
||||
|
||||
@@ -2,24 +2,43 @@ import { OpenIcon, TrashIcon } from "outline-icons";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { Selection, TextSelection } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, 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, autoFocus }: Props) {
|
||||
const url = (node.attrs.href ?? node.attrs.src) as string;
|
||||
export function MediaLinkEditor({
|
||||
node,
|
||||
view,
|
||||
dictionary,
|
||||
onLinkUpdate,
|
||||
onLinkRemove,
|
||||
onEscape,
|
||||
onClickOutside,
|
||||
}: 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
|
||||
// unexpectedly–leaving that out for now
|
||||
const isEditingImgUrl = node?.type.name === "image";
|
||||
|
||||
const moveSelectionToEnd = useCallback(() => {
|
||||
const { state, dispatch } = view;
|
||||
@@ -41,20 +60,24 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
|
||||
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) {
|
||||
@@ -71,6 +94,7 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
|
||||
case "Escape": {
|
||||
event.preventDefault();
|
||||
moveSelectionToEnd();
|
||||
onEscape();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -78,10 +102,14 @@ export function MediaLinkEditor({ node, view, dictionary, autoFocus }: Props) {
|
||||
[update, moveSelectionToEnd]
|
||||
);
|
||||
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Wrapper ref={wrapperRef}>
|
||||
<Input
|
||||
autoFocus={autoFocus}
|
||||
autoFocus={isEditingImgUrl}
|
||||
value={localUrl}
|
||||
placeholder={dictionary.pasteLink}
|
||||
onChange={(e) => setLocalUrl(e.target.value)}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Selection, NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
|
||||
import {
|
||||
getMarkRange,
|
||||
getMarkRangeNodeSelection,
|
||||
} 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";
|
||||
@@ -30,6 +33,7 @@ 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 */
|
||||
@@ -56,6 +60,12 @@ 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();
|
||||
@@ -64,11 +74,41 @@ export function SelectionToolbar(props: Props) {
|
||||
const isMobile = useMobile();
|
||||
const isActive = props.isActive || isMobile;
|
||||
const isDragging = useIsDragging();
|
||||
const [isEditingImgUrl, setIsEditingImgUrl] = React.useState(false);
|
||||
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
const [activeToolbar, setActiveToolbar] = React.useState<Toolbar | null>(
|
||||
null
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsEditingImgUrl(false);
|
||||
}, [isActive]);
|
||||
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]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (ev: MouseEvent): void => {
|
||||
@@ -91,8 +131,6 @@ export function SelectionToolbar(props: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEditingImgUrl(false);
|
||||
|
||||
const { dispatch } = view;
|
||||
dispatch(
|
||||
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
|
||||
@@ -106,27 +144,46 @@ 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";
|
||||
@@ -178,47 +235,73 @@ export function SelectionToolbar(props: Props) {
|
||||
});
|
||||
|
||||
items = filterExcessSeparators(items);
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
items = items.map((item) => {
|
||||
if (item.children) {
|
||||
item.children = item.children.map((child) => {
|
||||
if (child.name === "editImageUrl") {
|
||||
child.onClick = () => {
|
||||
setActiveToolbar(Toolbar.Media);
|
||||
};
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
const showLinkToolbar =
|
||||
link && link.from === selection.from && link.to === selection.to;
|
||||
if (item.name === "linkOnImage" || item.name === "addLink") {
|
||||
item.onClick = () => {
|
||||
setActiveToolbar(Toolbar.Link);
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
const isEditingMedia =
|
||||
isEmbedSelection || (isImageSelection && isEditingImgUrl);
|
||||
const handleClickOutsideLinkEditor = (ev: MouseEvent | TouchEvent) => {
|
||||
if (ev.target instanceof Element && ev.target.closest(".image-wrapper")) {
|
||||
return;
|
||||
}
|
||||
setActiveToolbar(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
align={align}
|
||||
active={isActive}
|
||||
ref={menuRef}
|
||||
width={showLinkToolbar || isEmbedSelection ? 336 : undefined}
|
||||
width={
|
||||
activeToolbar === Toolbar.Link || activeToolbar === Toolbar.Media
|
||||
? 336
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{showLinkToolbar ? (
|
||||
{activeToolbar === Toolbar.Link ? (
|
||||
<LinkEditor
|
||||
key={`${link.from}-${link.to}`}
|
||||
key={`${selection.from}-${selection.to}`}
|
||||
dictionary={dictionary}
|
||||
view={view}
|
||||
mark={link.mark}
|
||||
mark={link ? link.mark : undefined}
|
||||
onLinkAdd={() => setActiveToolbar(null)}
|
||||
onLinkUpdate={() => setActiveToolbar(null)}
|
||||
onLinkRemove={() => setActiveToolbar(null)}
|
||||
onEscape={() => setActiveToolbar(Toolbar.Menu)}
|
||||
onClickOutside={handleClickOutsideLinkEditor}
|
||||
onClickBack={() => setActiveToolbar(Toolbar.Menu)}
|
||||
/>
|
||||
) : isEditingMedia ? (
|
||||
) : activeToolbar === Toolbar.Media ? (
|
||||
<MediaLinkEditor
|
||||
key={`embed-${selection.from}`}
|
||||
node={selection.node}
|
||||
node={
|
||||
"node" in selection ? (selection as NodeSelection).node : undefined
|
||||
}
|
||||
view={view}
|
||||
dictionary={dictionary}
|
||||
autoFocus={isEditingImgUrl}
|
||||
onLinkUpdate={() => setActiveToolbar(null)}
|
||||
onLinkRemove={() => setActiveToolbar(null)}
|
||||
onEscape={() => setActiveToolbar(Toolbar.Menu)}
|
||||
onClickOutside={handleClickOutsideLinkEditor}
|
||||
/>
|
||||
) : (
|
||||
<ToolbarMenu
|
||||
items={items}
|
||||
{...rest}
|
||||
handlers={{
|
||||
editImageUrl: () => setIsEditingImgUrl(true),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) : activeToolbar === Toolbar.Menu && items.length ? (
|
||||
<ToolbarMenu items={items} {...rest} />
|
||||
) : null}
|
||||
</FloatingToolbar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,20 +20,15 @@ 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;
|
||||
handlers?: Record<string, Function>;
|
||||
}) {
|
||||
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
|
||||
const { commands, view } = useEditor();
|
||||
const { t } = useTranslation();
|
||||
const { item, handlers } = props;
|
||||
const { item } = props;
|
||||
const { state } = view;
|
||||
|
||||
const items: TMenuItem[] = useMemo(() => {
|
||||
@@ -48,12 +43,8 @@ function ToolbarDropdown(props: {
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
} else if (handlers && handlers[menuItem.name]) {
|
||||
handlers[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
} else if (menuItem.onClick) {
|
||||
menuItem.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,6 +104,13 @@ 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
|
||||
);
|
||||
@@ -141,7 +139,6 @@ function ToolbarMenu(props: Props) {
|
||||
<MediaDimension key={index} />
|
||||
) : item.children ? (
|
||||
<ToolbarDropdown
|
||||
handlers={props.handlers}
|
||||
active={isActive && !item.label}
|
||||
item={item}
|
||||
/>
|
||||
|
||||
@@ -282,28 +282,22 @@ 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 loop over
|
||||
// it's content and insert each node one at a time to allow it to
|
||||
// be pasted inline with surrounding content.
|
||||
// If the pasted content is a single paragraph then we slice
|
||||
// the outer paragraph so that the text is inserted directly.
|
||||
const singleNode = sliceSingleNode(slice);
|
||||
if (singleNode?.type === this.editor.schema.nodes.paragraph) {
|
||||
singleNode.forEach((node) => {
|
||||
tr.insert(currentPos, node);
|
||||
currentPos += node.nodeSize;
|
||||
});
|
||||
} else {
|
||||
if (singleNode) {
|
||||
if (isList(singleNode, this.editor.schema)) {
|
||||
this.handleList(singleNode);
|
||||
return true;
|
||||
} else {
|
||||
tr.replaceSelectionWith(singleNode, this.shiftKey);
|
||||
}
|
||||
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.replaceSelection(slice);
|
||||
tr.replaceSelectionWith(singleNode, this.shiftKey);
|
||||
}
|
||||
} else {
|
||||
tr.replaceSelection(slice);
|
||||
}
|
||||
|
||||
view.dispatch(
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -258,6 +258,7 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+K`,
|
||||
icon: <LinkIcon />,
|
||||
attrs: { href: "" },
|
||||
active: isMarkActive(schema.marks.link, undefined, { exact: true }),
|
||||
visible: !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
AlignFullWidthIcon,
|
||||
EditIcon,
|
||||
CommentIcon,
|
||||
LinkIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
@@ -16,6 +17,7 @@ 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,
|
||||
@@ -123,6 +125,13 @@ 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,4 +1,9 @@
|
||||
import { AlignFullWidthIcon, DownloadIcon, TrashIcon } from "outline-icons";
|
||||
import {
|
||||
AlignFullWidthIcon,
|
||||
DownloadIcon,
|
||||
TableColumnsDistributeIcon,
|
||||
TrashIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { MenuItem, TableLayout } from "@shared/editor/types";
|
||||
@@ -13,6 +18,7 @@ export default function tableMenuItems(
|
||||
return [];
|
||||
}
|
||||
const { schema } = state;
|
||||
|
||||
const isFullWidth = isNodeActive(schema.nodes.table, {
|
||||
layout: TableLayout.fullWidth,
|
||||
})(state);
|
||||
@@ -27,6 +33,11 @@ export default function tableMenuItems(
|
||||
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
|
||||
active: () => isFullWidth,
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
tooltip: dictionary.distributeColumns,
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
|
||||
@@ -11,11 +11,13 @@ 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";
|
||||
@@ -38,6 +40,7 @@ export default function tableColMenuItems(
|
||||
|
||||
const { index, rtl } = options;
|
||||
const { schema, selection } = state;
|
||||
const selectedCols = getAllSelectedColumns(state);
|
||||
|
||||
if (!(selection instanceof CellSelection)) {
|
||||
return [];
|
||||
@@ -147,6 +150,12 @@ export default function tableColMenuItems(
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
visible: selectedCols.length > 1,
|
||||
label: dictionary.distributeColumns,
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -110,6 +110,8 @@ 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]
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function useEditorClickHandlers({ shareId }: Params) {
|
||||
if (href[0] !== "/") {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
navigateTo = url.pathname + url.hash;
|
||||
navigateTo = url.pathname + url.search + url.hash;
|
||||
} catch (_err) {
|
||||
navigateTo = href;
|
||||
}
|
||||
|
||||
+6
-1
@@ -1,4 +1,4 @@
|
||||
import { observable } from "mobx";
|
||||
import { computed, observable } from "mobx";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
@@ -36,6 +36,11 @@ class Emoji extends Model {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
@computed
|
||||
get shortName(): string {
|
||||
return `:${this.name}:`;
|
||||
}
|
||||
|
||||
/**
|
||||
* emoji name
|
||||
*/
|
||||
|
||||
@@ -177,7 +177,7 @@ class Notification extends Model {
|
||||
const collection = this.collectionId
|
||||
? this.store.rootStore.collections.get(this.collectionId)
|
||||
: undefined;
|
||||
return collection ? collectionPath(collection.path) : "";
|
||||
return collection ? collectionPath(collection) : "";
|
||||
}
|
||||
case NotificationEventType.AddUserToDocument:
|
||||
case NotificationEventType.GroupMentionedInDocument:
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
+13
-1
@@ -65,7 +65,7 @@ class User extends ParanoidModel implements Searchable {
|
||||
|
||||
@computed
|
||||
get searchContent(): string[] {
|
||||
return [this.name, this.email].filter(Boolean);
|
||||
return [this.name, this.email, this.initials].filter(Boolean);
|
||||
}
|
||||
|
||||
@computed
|
||||
@@ -78,6 +78,18 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
homePath,
|
||||
searchPath,
|
||||
settingsPath,
|
||||
matchDocumentSlug as slug,
|
||||
matchDocumentSlug as documentSlug,
|
||||
matchCollectionSlug as collectionSlug,
|
||||
trashPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
@@ -80,22 +81,39 @@ function AuthenticatedRoutes() {
|
||||
to={settingsPath("templates")}
|
||||
/>
|
||||
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||
<Route
|
||||
exact
|
||||
path="/collection/:id/:tab?"
|
||||
path={`/collection/${collectionSlug}/new`}
|
||||
component={DocumentNew}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`/collection/${collectionSlug}/overview/edit`}
|
||||
component={Collection}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`/collection/${collectionSlug}/:tab?`}
|
||||
component={Collection}
|
||||
/>
|
||||
<Route exact path="/doc/new" component={DocumentNew} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
path={`/d/${documentSlug}`}
|
||||
component={RedirectDocument}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${documentSlug}/history/:revisionId?`}
|
||||
component={Document}
|
||||
/>
|
||||
|
||||
<Route exact path={`/doc/${slug}/edit`} component={Document} />
|
||||
<Route path={`/doc/${slug}`} component={Document} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${documentSlug}/edit`}
|
||||
component={Document}
|
||||
/>
|
||||
<Route path={`/doc/${documentSlug}`} component={Document} />
|
||||
<Route
|
||||
exact
|
||||
path={`${searchPath()}/:query?`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import { EditIcon, PlusIcon } from "outline-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import Collection from "~/models/Collection";
|
||||
@@ -8,18 +8,76 @@ import Button from "~/components/Button";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import { newDocumentPath } from "~/utils/routeHelpers";
|
||||
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"));
|
||||
|
||||
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 }: Props) {
|
||||
function Actions({ collection, isEditing, sidebarContext }: 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>
|
||||
@@ -33,6 +91,7 @@ function Actions({ collection }: Props) {
|
||||
to={collection ? newDocumentPath(collection.id) : ""}
|
||||
disabled={!collection}
|
||||
icon={<PlusIcon />}
|
||||
neutral={isEditing}
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
`}
|
||||
`;
|
||||
@@ -0,0 +1,78 @@
|
||||
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("A–Z")}</Tab>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
});
|
||||
|
||||
export default Navigation;
|
||||
@@ -1,6 +1,6 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { useMemo, useRef, useCallback, Suspense } from "react";
|
||||
import { useMemo, useRef, useCallback, useEffect, 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;
|
||||
shareId?: string;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
function Overview({ collection, shareId }: Props) {
|
||||
function Overview({ collection, readOnly }: Props) {
|
||||
const { documents, collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
@@ -48,6 +48,13 @@ function Overview({ collection, shareId }: Props) {
|
||||
[collection, t]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
handleSave.flush();
|
||||
},
|
||||
[handleSave]
|
||||
);
|
||||
|
||||
const childRef = useRef<HTMLDivElement>(null);
|
||||
const childOffsetHeight = childRef.current?.offsetHeight || 0;
|
||||
const editorStyle = useMemo(
|
||||
@@ -91,7 +98,7 @@ function Overview({ collection, shareId }: Props) {
|
||||
maxLength={CollectionValidation.maxDescriptionLength}
|
||||
onCreateLink={onCreateLink}
|
||||
canUpdate={can.update}
|
||||
readOnly={!can.update || !!shareId}
|
||||
readOnly={!can.update || readOnly}
|
||||
userId={user?.id}
|
||||
editorStyle={editorStyle}
|
||||
/>
|
||||
|
||||
+69
-156
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useCallback, useEffect, Suspense } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useParams,
|
||||
@@ -11,13 +11,9 @@ 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";
|
||||
@@ -28,77 +24,62 @@ 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 { collectionPath, updateCollectionPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
collectionEditPath,
|
||||
collectionPath,
|
||||
matchCollectionEdit,
|
||||
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 first from "lodash/first";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import { Header } from "./components/Header";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
|
||||
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 CollectionScene = observer(function CollectionScene_() {
|
||||
const params = useParams<{ collectionSlug?: 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.id || "";
|
||||
const id = params.collectionSlug || "";
|
||||
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<CollectionPath>(
|
||||
|
||||
const [collectionTab, setCollectionTab] = usePersistedState<CollectionTab>(
|
||||
`collection-tab:${collection?.id}`,
|
||||
collection?.hasDescription
|
||||
? CollectionPath.Overview
|
||||
: CollectionPath.Recent,
|
||||
collection?.hasDescription ? CollectionTab.Overview : CollectionTab.Recent,
|
||||
{
|
||||
listen: false,
|
||||
}
|
||||
);
|
||||
|
||||
const handleIconChange = useCallback(
|
||||
(icon: string | null, color: string | null) =>
|
||||
collection?.save({ icon, color }),
|
||||
[collection]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLastVisitedPath(currentPath);
|
||||
}, [currentPath, setLastVisitedPath]);
|
||||
@@ -149,23 +130,13 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
if (!collection && error) {
|
||||
return <Error404 />;
|
||||
}
|
||||
if (!collection) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const hasOverview = can.update || collection?.hasDescription;
|
||||
const showOverview = can.update || collection?.hasDescription;
|
||||
|
||||
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 ? (
|
||||
return (
|
||||
<Scene
|
||||
centered={false}
|
||||
textTitle={collection.name}
|
||||
@@ -190,10 +161,11 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
actions={
|
||||
<>
|
||||
<MembershipPreview collection={collection} />
|
||||
<Action>
|
||||
{can.update && <ShareButton collection={collection} />}
|
||||
</Action>
|
||||
<Actions collection={collection} />
|
||||
<Actions
|
||||
collection={collection}
|
||||
isEditing={isEditRoute}
|
||||
sidebarContext={sidebarContext}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -204,28 +176,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
>
|
||||
<CenteredContent withStickyHeader>
|
||||
<Notices 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>
|
||||
<Header collection={collection} />
|
||||
|
||||
<PinnedDocuments
|
||||
pins={pins}
|
||||
@@ -233,54 +184,39 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
placeholderCount={count}
|
||||
/>
|
||||
|
||||
<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("A–Z")}
|
||||
</Tab>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
<Content>
|
||||
<Navigation
|
||||
collection={collection}
|
||||
onChangeTab={setCollectionTab}
|
||||
showOverview={showOverview}
|
||||
sidebarContext={sidebarContext}
|
||||
/>
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path)} exact>
|
||||
<Route path={collectionPath(collection)} exact>
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: collectionPath(collection!.path, collectionTab),
|
||||
pathname: collectionPath(collection!, collectionTab),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(collection.path, CollectionPath.Overview)}
|
||||
path={[
|
||||
collectionPath(collection, CollectionTab.Overview),
|
||||
collectionEditPath(collection),
|
||||
]}
|
||||
>
|
||||
{hasOverview ? (
|
||||
<Overview collection={collection} />
|
||||
{showOverview ? (
|
||||
<Overview
|
||||
collection={collection}
|
||||
readOnly={!isEditRoute && !!user?.separateEditMode}
|
||||
/>
|
||||
) : (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Recent
|
||||
collection,
|
||||
CollectionTab.Recent
|
||||
),
|
||||
state: { sidebarContext },
|
||||
}}
|
||||
@@ -293,8 +229,8 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
<>
|
||||
<Route
|
||||
path={collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Alphabetical
|
||||
collection,
|
||||
CollectionTab.Alphabetical
|
||||
)}
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
@@ -308,9 +244,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
}}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(collection.path, CollectionPath.Old)}
|
||||
>
|
||||
<Route path={collectionPath(collection, CollectionTab.Old)}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
@@ -323,10 +257,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Published
|
||||
)}
|
||||
path={collectionPath(collection, CollectionTab.Published)}
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
@@ -341,10 +272,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Updated
|
||||
)}
|
||||
path={collectionPath(collection, CollectionTab.Updated)}
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
@@ -358,10 +286,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Popular
|
||||
)}
|
||||
path={collectionPath(collection, CollectionTab.Popular)}
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
key="popular"
|
||||
@@ -373,10 +298,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path={collectionPath(
|
||||
collection.path,
|
||||
CollectionPath.Recent
|
||||
)}
|
||||
path={collectionPath(collection, CollectionTab.Recent)}
|
||||
exact
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
@@ -394,7 +316,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
</>
|
||||
) : (
|
||||
<Route
|
||||
path={collectionPath(collection.path, CollectionPath.Recent)}
|
||||
path={collectionPath(collection, CollectionTab.Recent)}
|
||||
exact
|
||||
>
|
||||
<PaginatedDocumentList
|
||||
@@ -412,20 +334,22 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
</Route>
|
||||
)}
|
||||
</Switch>
|
||||
</Documents>
|
||||
</Content>
|
||||
</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 }>();
|
||||
|
||||
@@ -434,20 +358,9 @@ const KeyedCollection = () => {
|
||||
return <CollectionScene key={params.id} />;
|
||||
};
|
||||
|
||||
const Documents = styled.div`
|
||||
const Content = 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;
|
||||
|
||||
@@ -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, TextSelection } from "prosemirror-state";
|
||||
import { AllSelection, Selection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -148,10 +148,7 @@ 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?: TextSelection | AllSelection
|
||||
) => {
|
||||
replaceSelection = (template: Document | Revision, selection?: Selection) => {
|
||||
const editorRef = this.editor.current;
|
||||
|
||||
if (!editorRef) {
|
||||
@@ -250,7 +247,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
if (abilities.move) {
|
||||
dialogs.openModal({
|
||||
title: t("Move document"),
|
||||
content: <DocumentMove documents={[document]} />,
|
||||
content: <DocumentMove document={document} />,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -418,14 +415,19 @@ class DocumentScene extends React.Component<Props> {
|
||||
});
|
||||
|
||||
handleSelectTemplate = async (template: Document | Revision) => {
|
||||
const doc = this.editor.current?.view.state.doc;
|
||||
if (!doc) {
|
||||
const editorRef = this.editor.current;
|
||||
if (!editorRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { view } = editorRef;
|
||||
const doc = view.state.doc;
|
||||
|
||||
return this.replaceSelection(
|
||||
template,
|
||||
ProsemirrorHelper.isEmpty(doc) ? new AllSelection(doc) : undefined
|
||||
ProsemirrorHelper.isEmpty(doc)
|
||||
? new AllSelection(doc)
|
||||
: view.state.selection
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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);
|
||||
+78
-196
@@ -11,129 +11,77 @@ import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
collectionPath,
|
||||
documentPath,
|
||||
homePath,
|
||||
settingsPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
documents: Document[];
|
||||
onSubmit?: () => void;
|
||||
document: Document;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function DocumentDelete({ documents, onSubmit }: Props) {
|
||||
function DocumentDelete({ document, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
ui,
|
||||
dialogs,
|
||||
documents: documentsStore,
|
||||
collections: collectionsStore,
|
||||
userMemberships,
|
||||
groupMemberships,
|
||||
} = useStores();
|
||||
const { ui, documents, collections, userMemberships, groupMemberships } =
|
||||
useStores();
|
||||
const history = useHistory();
|
||||
const [isDeleting, setDeleting] = React.useState(false);
|
||||
const [isArchiving, setArchiving] = React.useState(false);
|
||||
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 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 handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setDeleting(true);
|
||||
|
||||
try {
|
||||
const failedIds: string[] = [];
|
||||
let successCount = 0;
|
||||
await document.delete();
|
||||
|
||||
// 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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failedIds.length === documents.length) {
|
||||
throw new Error(
|
||||
t("Couldn’t 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 || "/"
|
||||
);
|
||||
|
||||
// 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();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
documents,
|
||||
userMemberships,
|
||||
groupMemberships,
|
||||
ui,
|
||||
documentsStore,
|
||||
collectionsStore,
|
||||
history,
|
||||
dialogs,
|
||||
onSubmit,
|
||||
isBulkAction,
|
||||
t,
|
||||
]
|
||||
[onSubmit, ui, document, documents, history, collection]
|
||||
);
|
||||
|
||||
const handleArchive = React.useCallback(
|
||||
@@ -142,134 +90,68 @@ function DocumentDelete({ documents, onSubmit }: Props) {
|
||||
setArchiving(true);
|
||||
|
||||
try {
|
||||
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("Couldn’t 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"));
|
||||
}
|
||||
await document.archive();
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
} finally {
|
||||
setArchiving(false);
|
||||
}
|
||||
},
|
||||
[documents, dialogs, isBulkAction, t, onSubmit]
|
||||
[onSubmit, document]
|
||||
);
|
||||
|
||||
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 you’d like the option of referencing or restoring these documents in
|
||||
the future, consider archiving them instead.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
If you’d 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">
|
||||
{!isBulkAction && documents[0].isTemplate ? (
|
||||
{document.isTemplate ? (
|
||||
<Trans
|
||||
defaults="Are you sure you want to delete the <em>{{ documentTitle }}</em> template?"
|
||||
values={{
|
||||
documentTitle: documents[0].titleWithDefault,
|
||||
documentTitle: document.titleWithDefault,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
) : nestedDocumentsCount < 1 ? (
|
||||
<NoChildBody />
|
||||
<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 />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<HasChildBody />
|
||||
<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 />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
{canArchiveAll && (
|
||||
{canArchive && (
|
||||
<Text as="p" type="secondary">
|
||||
<ArchiveInsteadBody />
|
||||
<Trans>
|
||||
If you’d like the option of referencing or restoring the{" "}
|
||||
{{
|
||||
noun: document.noun,
|
||||
}}{" "}
|
||||
in the future, consider archiving it instead.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Flex justify="flex-end" gap={8}>
|
||||
{canArchiveAll && (
|
||||
{canArchive && (
|
||||
<Button type="button" onClick={handleArchive} neutral>
|
||||
{isArchiving ? `${t("Archiving")}…` : t("Archive")}
|
||||
</Button>
|
||||
|
||||
+37
-102
@@ -14,38 +14,23 @@ import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
documents: Document[];
|
||||
onSubmit?: () => void;
|
||||
document: Document;
|
||||
};
|
||||
|
||||
function DocumentMove({ documents, onSubmit }: Props) {
|
||||
function DocumentMove({ document }: 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) => {
|
||||
// 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
|
||||
);
|
||||
})
|
||||
?.filter(
|
||||
(c) => c.id !== document.id && c.id !== document.parentDocumentId
|
||||
)
|
||||
.map(filterSourceDocument),
|
||||
});
|
||||
|
||||
@@ -60,14 +45,19 @@ function DocumentMove({ documents, onSubmit }: Props) {
|
||||
|
||||
// If the document we're moving is a template, only show collections as
|
||||
// move targets.
|
||||
const hasTemplates = documents.some((doc) => doc.isTemplate);
|
||||
if (hasTemplates) {
|
||||
if (document.isTemplate) {
|
||||
return nodes
|
||||
.filter((node) => node.type === "collection")
|
||||
.map((node) => ({ ...node, children: [] }));
|
||||
}
|
||||
return nodes;
|
||||
}, [policies, collectionTrees, documentIds, documents, isBulkAction]);
|
||||
}, [
|
||||
policies,
|
||||
collectionTrees,
|
||||
document.id,
|
||||
document.parentDocumentId,
|
||||
document.isTemplate,
|
||||
]);
|
||||
|
||||
const move = async () => {
|
||||
if (!selectedPath) {
|
||||
@@ -75,101 +65,46 @@ function DocumentMove({ documents, onSubmit }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMoving(true);
|
||||
try {
|
||||
const { type, id: parentDocumentId } = selectedPath;
|
||||
|
||||
const { type, id: parentDocumentId } = selectedPath;
|
||||
const collectionId = selectedPath.collectionId as string;
|
||||
const collectionId = selectedPath.collectionId as string;
|
||||
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount === documents.length) {
|
||||
toast.error(
|
||||
t("Couldn’t 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 })
|
||||
);
|
||||
if (type === "document") {
|
||||
await document.move({ collectionId, parentDocumentId });
|
||||
} else {
|
||||
toast.warning(
|
||||
t("{{ errorCount }} documents failed to move, try again?", {
|
||||
errorCount,
|
||||
})
|
||||
);
|
||||
await document.move({ collectionId });
|
||||
}
|
||||
|
||||
toast.success(t("Document moved"));
|
||||
|
||||
dialogs.closeAllModals();
|
||||
} catch (_err) {
|
||||
toast.error(t("Couldn’t move the document, try again?"));
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<SelectedPathFooter title={selectedPath.title} />
|
||||
<Trans
|
||||
defaults="Move to <em>{{ location }}</em>"
|
||||
values={{
|
||||
location: selectedPath.title || t("Untitled"),
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
NoSelectedPathFooter
|
||||
t("Select a location to move")
|
||||
)}
|
||||
</StyledText>
|
||||
<Button disabled={!selectedPath || isMoving} onClick={move}>
|
||||
{isMoving ? `${t("Moving")}…` : t("Move")}
|
||||
<Button disabled={!selectedPath} onClick={move}>
|
||||
{t("Move")}
|
||||
</Button>
|
||||
</Footer>
|
||||
</FlexContainer>
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -21,6 +22,7 @@ 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";
|
||||
@@ -144,21 +146,24 @@ function Members() {
|
||||
{{ signinMethods: team.signinMethods }} but haven’t signed in yet.
|
||||
</Trans>
|
||||
</Text>
|
||||
<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 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>
|
||||
<ConditionalFade animate={!data}>
|
||||
<MembersTable
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ function SharedCollection({ collection }: Props) {
|
||||
as={Link}
|
||||
icon={<EditIcon />}
|
||||
to={{
|
||||
pathname: collectionPath(collection.path, "overview"),
|
||||
pathname: collectionPath(collection, "overview"),
|
||||
}}
|
||||
neutral
|
||||
>
|
||||
@@ -83,7 +83,7 @@ function SharedCollection({ collection }: Props) {
|
||||
</SharedMeta>
|
||||
) : null}
|
||||
</Flex>
|
||||
<Overview collection={collection} shareId={shareId} />
|
||||
<Overview collection={collection} readOnly />
|
||||
</CenteredContent>
|
||||
</Scene>
|
||||
);
|
||||
|
||||
@@ -52,14 +52,6 @@ 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",
|
||||
@@ -780,68 +772,4 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("generateEmojiNameFromFilename", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should replace spaces with underscores", () => {
|
||||
test("should replace spaces and dashes 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(
|
||||
"partyparrot"
|
||||
"party_parrot"
|
||||
);
|
||||
expect(generateEmojiNameFromFilename("happy!@#$%.png")).toBe("happy");
|
||||
expect(generateEmojiNameFromFilename("emoji(1).png")).toBe("emoji");
|
||||
@@ -57,9 +57,7 @@ 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", () => {
|
||||
@@ -67,7 +65,7 @@ describe("generateEmojiNameFromFilename", () => {
|
||||
"party_parrot"
|
||||
);
|
||||
expect(generateEmojiNameFromFilename("dumpster-fire-2023.png")).toBe(
|
||||
"dumpsterfire"
|
||||
"dumpster_fire"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+6
-3
@@ -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 with underscores
|
||||
* - Replacing spaces and dashes with underscores
|
||||
* - Removing invalid characters (only allowing lowercase letters and underscores)
|
||||
* - Removing numbers
|
||||
* - Removing leading/trailing underscores
|
||||
@@ -18,8 +18,11 @@ export function generateEmojiNameFromFilename(filename: string): string {
|
||||
// Remove file extension
|
||||
const nameWithoutExt = filename.replace(/\.[^.]+$/, "");
|
||||
|
||||
// Convert to lowercase, replace spaces with underscores
|
||||
let name = nameWithoutExt.toLowerCase().replace(/\s+/g, "_");
|
||||
// Convert to lowercase, replace spaces and dashes with underscores
|
||||
let name = nameWithoutExt
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/-+/g, "_");
|
||||
|
||||
// Remove all characters that aren't lowercase letters or underscores (including numbers)
|
||||
name = name.replace(/[^a-z_]/g, "");
|
||||
|
||||
@@ -14,20 +14,14 @@ export const isURLMentionable = ({
|
||||
integration: Integration;
|
||||
}): boolean => {
|
||||
const { hostname, pathname } = url;
|
||||
const pathParts = pathname.split("/");
|
||||
|
||||
switch (integration.service) {
|
||||
case IntegrationService.GitHub: {
|
||||
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.
|
||||
);
|
||||
return hostname === "github.com";
|
||||
}
|
||||
|
||||
case IntegrationService.Linear: {
|
||||
const pathParts = pathname.split("/");
|
||||
const settings =
|
||||
integration.settings as IntegrationSettings<IntegrationType.Embed>;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import queryString from "query-string";
|
||||
import Collection from "~/models/Collection";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import type Collection from "~/models/Collection";
|
||||
import type Comment from "~/models/Comment";
|
||||
import type Document from "~/models/Document";
|
||||
import env from "~/env";
|
||||
|
||||
export function homePath(): string {
|
||||
@@ -37,11 +37,18 @@ export function commentPath(document: Document, comment: Comment): string {
|
||||
}`;
|
||||
}
|
||||
|
||||
export function collectionPath(url: string, section?: string): string {
|
||||
export function collectionPath(
|
||||
collection: Collection,
|
||||
section?: string
|
||||
): string {
|
||||
if (section) {
|
||||
return `${url}/${section}`;
|
||||
return `${collection.path}/${section}`;
|
||||
}
|
||||
return url;
|
||||
return collection.path;
|
||||
}
|
||||
|
||||
export function collectionEditPath(collection: Collection): string {
|
||||
return collectionPath(collection, "overview/edit");
|
||||
}
|
||||
|
||||
export function updateCollectionPath(
|
||||
@@ -144,6 +151,8 @@ 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
@@ -51,11 +51,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"@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.4",
|
||||
"form-data": "^4.0.5",
|
||||
"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.13.1",
|
||||
"outline-icons": "^3.15.0",
|
||||
"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.0",
|
||||
"prosemirror-inputrules": "^1.5.1",
|
||||
"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.30",
|
||||
"discord-api-types": "^0.38.36",
|
||||
"husky": "^8.0.3",
|
||||
"i18next-parser": "^8.13.0",
|
||||
"ioredis-mock": "^8.9.0",
|
||||
"ioredis-mock": "^8.13.1",
|
||||
"jest-cli": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -229,11 +230,22 @@ 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,
|
||||
"settings.github.installation.account.name": resource.owner,
|
||||
},
|
||||
order: [
|
||||
[
|
||||
Sequelize.literal(
|
||||
`CASE WHEN "settings"->'github'->'installation'->'account'->>'name' = :owner THEN 0 ELSE 1 END`
|
||||
),
|
||||
"ASC",
|
||||
],
|
||||
],
|
||||
replacements: {
|
||||
owner: resource.owner,
|
||||
},
|
||||
})) as Integration<IntegrationType.Embed>;
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
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");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"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"
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
"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"
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -62,6 +62,7 @@ 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";
|
||||
@@ -668,6 +669,9 @@ class Document extends ArchivableModel<
|
||||
@HasMany(() => Relationship)
|
||||
relationships: Relationship[];
|
||||
|
||||
@HasMany(() => Mention)
|
||||
mentions: Mention[];
|
||||
|
||||
@HasMany(() => Star)
|
||||
starred: Star[];
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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;
|
||||
@@ -57,6 +57,7 @@ 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";
|
||||
@@ -248,6 +249,9 @@ class User extends ParanoidModel<
|
||||
@HasMany(() => UserAuthentication)
|
||||
authentications: UserAuthentication[];
|
||||
|
||||
@HasMany(() => Mention, "mentionedUserId")
|
||||
mentions: Mention[];
|
||||
|
||||
// getters
|
||||
|
||||
get isSuspended(): boolean {
|
||||
|
||||
@@ -32,6 +32,8 @@ 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";
|
||||
|
||||
+98
-43
@@ -1,6 +1,4 @@
|
||||
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";
|
||||
|
||||
@@ -26,8 +24,6 @@ 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.
|
||||
*
|
||||
@@ -58,7 +54,17 @@ export class CanCan {
|
||||
|
||||
(this.toArray(actions) as string[]).forEach((action) => {
|
||||
(this.toArray(targets) as T[]).forEach((target) => {
|
||||
this.abilities.push({ model, action, target, condition } as Ability);
|
||||
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);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -85,25 +91,31 @@ export class CanCan {
|
||||
);
|
||||
|
||||
// Check conditions only for matching abilities
|
||||
const conditions = uniq(
|
||||
flattenDeep(
|
||||
matchingAbilities.map((ability) => {
|
||||
if (!ability.condition) {
|
||||
return false;
|
||||
}
|
||||
return ability.condition(performer, target, options || {});
|
||||
})
|
||||
)
|
||||
);
|
||||
const seenConditions = new Set<boolean | string>();
|
||||
const membershipIds: string[] = [];
|
||||
let hasNonMembershipMatch = false;
|
||||
|
||||
const matchingConditions = conditions.filter(Boolean);
|
||||
const matchingMembershipIds = matchingConditions.filter(
|
||||
(m) => typeof m === "string"
|
||||
) as string[];
|
||||
for (const ability of matchingAbilities) {
|
||||
if (!ability.condition) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return matchingMembershipIds.length > 0
|
||||
? matchingMembershipIds
|
||||
: matchingConditions.length > 0;
|
||||
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;
|
||||
};
|
||||
|
||||
/*
|
||||
@@ -113,22 +125,31 @@ 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;
|
||||
|
||||
try {
|
||||
response = this.can(performer, ability.action, target);
|
||||
} catch (_err) {
|
||||
response = false;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output[ability.action] = response;
|
||||
// Check each unique action once
|
||||
actionsToCheck.forEach((action) => {
|
||||
try {
|
||||
output[action] = this.can(performer, action, target);
|
||||
} catch (_err) {
|
||||
output[action] = false;
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
@@ -174,15 +195,49 @@ export class CanCan {
|
||||
performer: Model,
|
||||
action: string,
|
||||
target: Model | null | undefined
|
||||
) =>
|
||||
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)
|
||||
);
|
||||
) => {
|
||||
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();
|
||||
|
||||
private get = <T extends object>(obj: T, key: keyof T) =>
|
||||
"get" in obj && typeof obj.get === "function" ? obj.get(key) : obj[key];
|
||||
@@ -219,7 +274,7 @@ export class CanCan {
|
||||
|
||||
const cancan = new CanCan();
|
||||
|
||||
export const { allow, can, cannot, abilities, serialize } = cancan;
|
||||
export const { allow, can, cannot, serialize } = cancan;
|
||||
|
||||
// This is exported separately as a workaround for the following issue:
|
||||
// https://github.com/microsoft/TypeScript/issues/36931
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -7,10 +6,10 @@ import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
|
||||
|
||||
allow(User, "createCollection", Team, (actor, team) =>
|
||||
and(
|
||||
isTeamModel(actor, team),
|
||||
isTeamMutable(actor),
|
||||
!actor.isGuest,
|
||||
!actor.isViewer,
|
||||
isTeamModel(actor, team),
|
||||
isTeamMutable(actor),
|
||||
or(actor.isAdmin, !!team?.memberCollectionCreate)
|
||||
)
|
||||
);
|
||||
@@ -26,9 +25,9 @@ allow(User, "importCollection", Team, (actor, team) =>
|
||||
allow(User, "move", Collection, (actor, collection) =>
|
||||
and(
|
||||
//
|
||||
!!collection?.isActive,
|
||||
isTeamAdmin(actor, collection),
|
||||
isTeamMutable(actor),
|
||||
!!collection?.isActive
|
||||
isTeamMutable(actor)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -194,10 +193,20 @@ function includesMembership(
|
||||
"Development: collection groupMemberships not preloaded, did you forget `withMembership` scope?"
|
||||
);
|
||||
|
||||
const membershipIds = filter(
|
||||
[...collection.memberships, ...collection.groupMemberships],
|
||||
(m) => permissions.includes(m.permission as CollectionPermission)
|
||||
).map((m) => m.id);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return membershipIds.length > 0 ? membershipIds : false;
|
||||
}
|
||||
|
||||
+45
-31
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -36,8 +35,8 @@ allow(User, "read", Document, (actor, document) =>
|
||||
|
||||
allow(User, ["listRevisions", "listViews"], Document, (actor, document) =>
|
||||
or(
|
||||
and(can(actor, "read", document), !actor.isGuest),
|
||||
and(can(actor, "update", document), actor.isGuest)
|
||||
and(!actor.isGuest, can(actor, "read", document)),
|
||||
and(actor.isGuest, can(actor, "update", document))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -53,14 +52,14 @@ allow(User, "download", Document, (actor, document) =>
|
||||
|
||||
allow(User, "comment", Document, (actor, document) =>
|
||||
and(
|
||||
// TODO: We'll introduce a separate permission for commenting
|
||||
or(
|
||||
and(can(actor, "read", document), !actor.isGuest),
|
||||
and(can(actor, "update", document), actor.isGuest)
|
||||
),
|
||||
isTeamMutable(actor),
|
||||
!!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))
|
||||
),
|
||||
or(!document?.collection, document?.collection?.commenting !== false)
|
||||
)
|
||||
);
|
||||
@@ -72,26 +71,26 @@ allow(
|
||||
(actor, document) =>
|
||||
and(
|
||||
//
|
||||
can(actor, "read", document),
|
||||
!document?.template
|
||||
!document?.template,
|
||||
can(actor, "read", document)
|
||||
)
|
||||
);
|
||||
|
||||
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(
|
||||
can(actor, "read", document),
|
||||
isTeamMutable(actor),
|
||||
!!document?.isActive,
|
||||
isTeamMutable(actor),
|
||||
can(actor, "read", document),
|
||||
or(
|
||||
includesMembership(document, [
|
||||
DocumentPermission.ReadWrite,
|
||||
@@ -115,8 +114,8 @@ allow(User, "update", Document, (actor, document) =>
|
||||
allow(User, "publish", Document, (actor, document) =>
|
||||
and(
|
||||
//
|
||||
can(actor, "update", document),
|
||||
!!document?.isDraft
|
||||
!!document?.isDraft,
|
||||
can(actor, "update", document)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -171,35 +170,40 @@ allow(User, "move", Document, (actor, document) =>
|
||||
);
|
||||
|
||||
allow(User, "createChildDocument", Document, (actor, document) =>
|
||||
and(can(actor, "update", document), !document?.isDraft, !document?.template)
|
||||
and(
|
||||
//
|
||||
!document?.isDraft,
|
||||
!document?.template,
|
||||
can(actor, "update", document)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, ["updateInsights", "pin", "unpin"], Document, (actor, document) =>
|
||||
and(
|
||||
can(actor, "update", document),
|
||||
can(actor, "update", document?.collection),
|
||||
!document?.isDraft,
|
||||
!document?.template,
|
||||
!actor.isGuest
|
||||
!actor.isGuest,
|
||||
can(actor, "update", document),
|
||||
can(actor, "update", document?.collection)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "pinToHome", Document, (actor, document) =>
|
||||
and(
|
||||
//
|
||||
isTeamAdmin(actor, document),
|
||||
isTeamMutable(actor),
|
||||
!document?.isDraft,
|
||||
!document?.template,
|
||||
!!document?.isActive
|
||||
!!document?.isActive,
|
||||
isTeamAdmin(actor, document),
|
||||
isTeamMutable(actor)
|
||||
)
|
||||
);
|
||||
|
||||
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),
|
||||
@@ -210,9 +214,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,
|
||||
@@ -231,9 +235,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)
|
||||
)
|
||||
);
|
||||
@@ -322,10 +326,20 @@ function includesMembership(
|
||||
"Development: document groupMemberships should be preloaded, did you forget withMembership scope?"
|
||||
);
|
||||
|
||||
const membershipIds = filter(
|
||||
[...document.memberships, ...document.groupMemberships],
|
||||
(m) => permissions.includes(m.permission as DocumentPermission)
|
||||
).map((m) => m.id);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return membershipIds.length > 0 ? membershipIds : false;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@ import invariant from "invariant";
|
||||
type Args = boolean | string | Args[];
|
||||
|
||||
export function and(...args: Args[]) {
|
||||
const filtered = args.filter(Boolean);
|
||||
return filtered.length === args.length ? filtered : false;
|
||||
for (const arg of args) {
|
||||
if (!arg) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function or(...args: Args[]) {
|
||||
|
||||
@@ -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,7 +56,6 @@ async function presentDocument(
|
||||
text,
|
||||
icon: document.icon,
|
||||
color: document.color,
|
||||
tasks: document.tasks,
|
||||
language: document.language,
|
||||
createdAt: document.createdAt,
|
||||
createdBy: undefined,
|
||||
@@ -85,6 +84,7 @@ 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;
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
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,8 +65,9 @@ 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 uniqueId = crypto.randomBytes(8).toString("hex");
|
||||
this.workingTable = `${WORKING_TABLE_PREFIX}_${uniqueId}`;
|
||||
const dateStr = now.toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
||||
const uniqueId = crypto.randomBytes(4).toString("hex");
|
||||
this.workingTable = `${WORKING_TABLE_PREFIX}_${dateStr}_${uniqueId}`;
|
||||
|
||||
try {
|
||||
// Setup: Create working table and populate with active document IDs
|
||||
@@ -150,31 +151,64 @@ 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). 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
|
||||
// (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
|
||||
)
|
||||
)
|
||||
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 } }
|
||||
${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`
|
||||
);
|
||||
|
||||
// Create index on processed column for efficient batch selection
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildComment,
|
||||
buildCommentMark,
|
||||
buildDocument,
|
||||
buildEmoji,
|
||||
buildResolvedComment,
|
||||
buildTeam,
|
||||
buildUser,
|
||||
@@ -886,6 +887,72 @@ 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", () => {
|
||||
|
||||
@@ -5,14 +5,16 @@ 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 } from "@server/models";
|
||||
import { Document, Comment, Collection, Reaction, Emoji } from "@server/models";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { TextHelper } from "@server/models/helpers/TextHelper";
|
||||
import { authorize } from "@server/policies";
|
||||
@@ -407,6 +409,13 @@ 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,
|
||||
|
||||
@@ -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: z.string().regex(emojiRegex()),
|
||||
emoji: zodEmojiType(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -469,6 +469,7 @@ router.post(
|
||||
]),
|
||||
required: true,
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
collectionId: collectionIds,
|
||||
},
|
||||
include: [
|
||||
@@ -522,6 +523,7 @@ router.post(
|
||||
? [collectionId]
|
||||
: await user.collectionIds();
|
||||
const where: WhereOptions = {
|
||||
teamId: user.teamId,
|
||||
createdById: user.id,
|
||||
collectionId: {
|
||||
[Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }],
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
Reference in New Issue
Block a user