Compare commits

..

41 Commits

Author SHA1 Message Date
Tom Moor 09f7f6b3f3 fix: Migration integer -> number 2024-08-02 09:42:34 +01:00
Tom Moor eed1f26911 test 2024-07-27 22:42:04 -04:00
Tom Moor d9e52f2b01 Allow passing dataAttributes on documents.create 2024-07-27 21:23:08 -04:00
Tom Moor 961e9c1cea Remove feature flag, fixes 2024-07-27 16:21:41 -04:00
Tom Moor 9ff0f9f12a Switch icon 2024-07-27 16:21:41 -04:00
Tom Moor a965dd3a33 fix: Hover on DD
fix: Guard editing state
2024-07-27 16:21:41 -04:00
Tom Moor 0461ec2d06 Fixed attribute order 2024-07-27 16:21:41 -04:00
Tom Moor fc52fee781 Remove save button 2024-07-27 16:21:41 -04:00
Tom Moor 3db5462db7 fix: Correct updatedAt on document data attributes 2024-07-27 16:21:41 -04:00
Tom Moor 6a29e91ddf Add realtime events 2024-07-27 16:21:41 -04:00
Tom Moor 220e3c02cc fix initial attribute 2024-07-27 16:21:41 -04:00
Tom Moor ac613101a3 Do not render properties div if none 2024-07-27 16:21:41 -04:00
Tom Moor 5cd0105da8 Property editing 2024-07-27 16:21:41 -04:00
Tom Moor 3bb4c33539 tsc 2024-07-27 16:21:41 -04:00
Tom Moor a4f2d98953 wip 2024-07-27 16:21:41 -04:00
Tom Moor 35f251027b wip 2024-07-27 16:21:41 -04:00
Tom Moor 165537d46f Settings UI 2024-07-27 16:21:39 -04:00
Tom Moor c950788ef1 dataAttributes.delete endpoint 2024-07-27 16:21:11 -04:00
Tom Moor 8120ef0ce8 stash 2024-07-27 16:21:11 -04:00
Tom Moor e6f7c95617 wip 2024-07-27 16:21:11 -04:00
Tom Moor c3ffdb714e stash 2024-07-27 16:21:11 -04:00
Tom Moor ab7395f5ae stash 2024-07-27 16:21:11 -04:00
Tom Moor 804e2dd378 stash 2024-07-27 16:21:11 -04:00
Tom Moor 669240492b stash 2024-07-27 16:21:11 -04:00
Tom Moor 253d652a20 Model, endpoints 2024-07-27 16:21:11 -04:00
Tom Moor ef9f96d891 Migration 2024-07-27 16:21:11 -04:00
Tom Moor 336e424b8b docs 2024-07-27 16:10:09 -04:00
Tom Moor 0bb993634a fix: Allow starring drafts from document lists 2024-07-27 15:48:32 -04:00
Tom Moor 2f26e76b1e chore: Add transactions to stars mutations 2024-07-27 15:47:23 -04:00
Tom Moor 93a89eeef3 chore: Add transaction to integrations.update 2024-07-27 15:41:55 -04:00
Tom Moor 6e6a5014af chore: Add transactions to groups mutations 2024-07-27 15:37:45 -04:00
Tom Moor 3da1945bea perf: Optimize common path in presentDocument to not include JSON parsing 2024-07-27 15:12:11 -04:00
Hemachandar c2fbb31e77 Workspace templates (#7150)
* feat: Workspace templates

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-07-27 08:38:16 -07:00
Hemachandar 4c999d00d2 fix: use all properties from zip when importing a collection (#7318)
* fix: use all properties from zip when importing a collection

* use shared defaults
2024-07-27 08:31:00 -07:00
Tom Moor 738449a7d0 fix: Catch Iframely non-json response correctly in lib.
closes #7306
2024-07-27 09:49:56 -04:00
Tom Moor ae80128396 chore: aws-sdk upgrade 2024-07-27 09:47:59 -04:00
Tom Moor 1da5ac0bfe chore: Remove no longer required resolutions 2024-07-27 09:47:59 -04:00
Apoorv Mishra f56f240d9b Remove trailing spaces from search query (#7314)
* fix: tsquery err

* fix: test
2024-07-26 20:27:56 -07:00
github-actions[bot] 7de0ffb7f7 chore: Auto Compress Images (#7310)
Co-authored-by: tommoor <tommoor@users.noreply.github.com>
2024-07-26 05:49:18 -07:00
Baboon 0e667c5d3d add Dropbox embeddings support (#7299)
* add Dropbox embedder support

* Update embeds.ts

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2024-07-26 05:47:35 -07:00
Tom Moor 465c935879 fix: Remove .at usage, closes #7305 2024-07-26 08:47:09 -04:00
63 changed files with 1738 additions and 789 deletions
+4
View File
@@ -189,6 +189,10 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
DROPBOX_APP_KEY=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
+84 -6
View File
@@ -37,10 +37,10 @@ import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import DuplicateDialog from "~/components/DuplicateDialog";
import SharePopover from "~/components/Sharing/Document";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
import { createAction } from "~/actions";
import { DocumentSection, TrashSection } from "~/actions/sections";
import env from "~/env";
@@ -223,7 +223,7 @@ export const publishDocument = createAction({
return;
}
if (document?.collectionId) {
if (document?.collectionId || document?.template) {
await document.save(undefined, {
publish: true,
});
@@ -688,7 +688,7 @@ export const createTemplateFromDocument = createAction({
}
return !!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update
stores.policies.abilities(activeCollectionId).updateDocument
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
@@ -735,11 +735,50 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: async ({ activeDocumentId, stores }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.move({
collectionId: null,
});
}
},
});
export const moveDocumentToCollection = createAction({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
}
const document = stores.documents.get(activeDocumentId);
return document?.template && document?.collectionId
? t("Move to collection")
: t("Move");
},
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
@@ -763,6 +802,44 @@ export const moveDocument = createAction({
},
});
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the button if this is a non-workspace template.
if (!document || (document.template && !document.isWorkspaceTemplate)) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: DocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
// Don't show the menu if this is not a template (or) a workspace template.
if (!document || !document.template || document.isWorkspaceTemplate) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Archive document",
@@ -997,7 +1074,8 @@ export const rootDocumentActions = [
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
moveDocument,
moveTemplateToWorkspace,
moveDocumentToCollection,
openRandomDocument,
permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
+6 -7
View File
@@ -76,8 +76,7 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const canStar = !document.isArchived && !document.isTemplate;
return (
<DocumentLink
@@ -111,11 +110,6 @@ function DocumentListItem(
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isDraft && showDraft && (
<Tooltip
content={t("Only visible to you")}
@@ -125,6 +119,11 @@ function DocumentListItem(
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
@@ -1,49 +0,0 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
type Props = {
documentId: string;
};
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { t } = useTranslation();
const { documents } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize();
if (template) {
history.push(documentPath(template));
toast.success(t("Template created, go ahead and customize it"));
}
}, [document, history, t]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Create template")}
savingText={`${t("Creating")}`}
>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export default observer(DocumentTemplatizeDialog);
@@ -100,7 +100,7 @@ const CollectionLink: React.FC<Props> = ({
),
});
} else {
await documents.move(id, collection.id);
await documents.move({ documentId: id, collectionId: collection.id });
if (!expanded) {
onDisclosureClick();
@@ -52,7 +52,11 @@ function CollectionLinkChildren({
if (!collection) {
return;
}
void documents.move(item.id, collection.id, undefined, 0);
void documents.move({
documentId: item.id,
collectionId: collection.id,
index: 0,
});
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
@@ -187,7 +187,11 @@ function InnerDocumentLink(
if (!collection) {
return;
}
await documents.move(item.id, collection.id, node.id);
await documents.move({
documentId: item.id,
collectionId: collection.id,
parentDocumentId: node.id,
});
setExpanded(true);
},
canDrop: (item, monitor) =>
@@ -249,11 +253,21 @@ function InnerDocumentLink(
}
if (expanded) {
void documents.move(item.id, collection.id, node.id, 0);
void documents.move({
documentId: item.id,
collectionId: collection.id,
parentDocumentId: node.id,
index: 0,
});
return;
}
void documents.move(item.id, collection.id, parentId, index + 1);
void documents.move({
documentId: item.id,
collectionId: collection.id,
parentDocumentId: parentId,
index: index + 1,
});
},
collect: (monitor) => ({
isOverReorder: monitor.isOver(),
+22
View File
@@ -0,0 +1,22 @@
import React from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
const Label = ({ icon, value }: { icon: React.ReactNode; value: string }) => (
<Flex align="center" gap={4}>
<IconWrapper>{icon}</IconWrapper>
{value}
</Flex>
);
const IconWrapper = styled.span`
display: flex;
justify-content: center;
align-items: center;
height: 24px;
width: 24px;
overflow: hidden;
flex-shrink: 0;
`;
export default Label;
@@ -0,0 +1,113 @@
import { observer } from "mobx-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AvatarSize } from "~/components/Avatar/Avatar";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSelect, { Option } from "~/components/InputSelect";
import TeamLogo from "~/components/TeamLogo";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import Label from "./Label";
type Props = {
/** Collection ID to select by default. */
defaultCollectionId?: string | null;
/** Callback to be called when a collection is selected. */
onSelect: (collectionId: string | null) => void;
};
const SelectLocation = ({ defaultCollectionId, onSelect }: Props) => {
const { t } = useTranslation();
const team = useCurrentTeam();
const { collections, policies } = useStores();
const can = usePolicy(team);
const { loading, error } = useRequest(
React.useCallback(async () => {
if (!collections.isLoaded) {
await collections.fetchAll({
limit: 100,
});
}
}, [collections])
);
const workspaceOption: Option | null = can.createTemplate
? {
label: (
<Label
icon={<TeamLogo model={team} size={AvatarSize.Toast} />}
value={t("Workspace")}
/>
),
value: "workspace",
}
: null;
const collectionOptions: Option[] = React.useMemo(
() =>
collections.orderedData.reduce<Option[]>((memo, collection) => {
const canCollection = policies.abilities(collection.id);
if (canCollection.createDocument) {
memo.push({
label: (
<Label
icon={<CollectionIcon collection={collection} />}
value={collection.name}
/>
),
value: collection.id,
});
}
return memo;
}, []),
[collections.orderedData, policies]
);
const options: Option[] = workspaceOption
? collectionOptions.length
? [
workspaceOption,
...collectionOptions.map((opt, idx) => {
if (idx !== 0) {
return opt;
}
opt.divider = true;
return opt;
}),
]
: [workspaceOption]
: collectionOptions;
const handleSelection = React.useCallback(
(value: string | null) => {
onSelect(value === "workspace" ? null : value);
},
[onSelect]
);
if (error) {
toast.error(t("Collections could not be loaded, please reload the app"));
}
if (loading || !options.length) {
return null;
}
return (
<InputSelect
value={defaultCollectionId ?? "workspace"}
options={options}
onChange={handleSelection}
ariaLabel={t("Location")}
label={t("Location")}
/>
);
};
export default observer(SelectLocation);
+82
View File
@@ -0,0 +1,82 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Switch from "~/components/Switch";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
import SelectLocation from "./SelectLocation";
type Props = {
documentId: string;
};
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { t } = useTranslation();
const { documents } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
const [publish, setPublish] = React.useState(true);
const [collectionId, setCollectionId] = React.useState(
document.collectionId ?? null
);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize({
collectionId,
publish,
});
if (template) {
history.push(documentPath(template));
toast.success(t("Template created, go ahead and customize it"));
}
}, [t, document, history, collectionId, publish]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Create template")}
savingText={`${t("Creating")}`}
>
<Flex column gap={12}>
<div>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</div>
<SelectLocation
defaultCollectionId={collectionId}
onSelect={setCollectionId}
/>
<Switch
name="publish"
label={t("Published")}
note={t("Enable other members to use the template immediately")}
checked={publish}
onChange={handlePublishChange}
/>
</Flex>
</ConfirmationDialog>
);
}
export default observer(DocumentTemplatizeDialog);
+2 -5
View File
@@ -20,7 +20,6 @@ import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import { Hook, PluginManager } from "~/utils/PluginManager";
import isCloudHosted from "~/utils/isCloudHosted";
import lazy from "~/utils/lazyWithRetry";
@@ -126,9 +125,7 @@ const useSettingsConfig = () => {
name: t("Data Attributes"),
path: settingsPath("attributes"),
component: DataAttributes,
enabled:
can.createDataAttribute &&
FeatureFlags.isEnabled(Feature.dataAttributes),
enabled: can.createDataAttribute,
group: t("Workspace"),
icon: DatabaseIcon,
},
@@ -152,7 +149,7 @@ const useSettingsConfig = () => {
name: t("Templates"),
path: settingsPath("templates"),
component: Templates,
enabled: can.update,
enabled: can.readTemplate,
group: t("Workspace"),
icon: ShapesIcon,
},
+16 -3
View File
@@ -1,3 +1,4 @@
import capitalize from "lodash/capitalize";
import { observer } from "mobx-react";
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
import * as React from "react";
@@ -44,6 +45,7 @@ import {
shareDocument,
copyDocument,
searchInDocument,
moveTemplate,
} from "~/actions/definitions/documents";
import useActionContext from "~/hooks/useActionContext";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -124,7 +126,11 @@ function DocumentMenu({
}
) => {
await document.restore(options);
toast.success(t("Document restored"));
toast.success(
t("{{ documentName }} restored", {
documentName: capitalize(document.noun),
})
);
},
[t, document]
);
@@ -228,7 +234,10 @@ function DocumentMenu({
{
type: "button",
title: t("Restore"),
visible: (!!collection && can.restore) || can.unarchive,
visible:
((document.isWorkspaceTemplate || !!collection) &&
can.restore) ||
can.unarchive,
onClick: (ev) => handleRestore(ev),
icon: <RestoreIcon />,
},
@@ -236,7 +245,10 @@ function DocumentMenu({
type: "submenu",
title: t("Restore"),
visible:
!collection && !!can.restore && restoreItems.length !== 0,
!document.isWorkspaceTemplate &&
!collection &&
!!can.restore &&
restoreItems.length !== 0,
style: {
left: -170,
position: "relative",
@@ -290,6 +302,7 @@ function DocumentMenu({
actionToMenuItem(unpublishDocument, context),
actionToMenuItem(archiveDocument, context),
actionToMenuItem(moveDocument, context),
actionToMenuItem(moveTemplate, context),
actionToMenuItem(pinDocument, context),
actionToMenuItem(createDocumentFromTemplate, context),
{
+33 -4
View File
@@ -5,9 +5,9 @@ import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import Header from "~/components/ContextMenu/Header";
import Template from "~/components/ContextMenu/Template";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import TeamLogo from "~/components/TeamLogo";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -28,7 +28,16 @@ function NewTemplateMenu() {
});
}, [collections]);
const items = React.useMemo(
const workspaceItem: MenuItem | null = can.createTemplate
? {
type: "route",
to: newTemplatePath(),
title: t("Save in workspace"),
icon: <TeamLogo model={team} />,
}
: null;
const collectionItems = React.useMemo(
() =>
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
const can = policies.abilities(collection.id);
@@ -47,7 +56,28 @@ function NewTemplateMenu() {
[collections.orderedData, policies]
);
if (!can.createDocument || items.length === 0) {
const collectionItemsWithHeader: MenuItem[] = React.useMemo(
() =>
collectionItems.length
? [
{ type: "heading", title: t("Choose a collection") },
...collectionItems,
]
: [],
[t, collectionItems]
);
const items = workspaceItem
? collectionItemsWithHeader.length
? [
workspaceItem,
{ type: "separator" } as MenuItem,
...collectionItemsWithHeader,
]
: [workspaceItem]
: collectionItemsWithHeader;
if (items.length === 0) {
return null;
}
@@ -61,7 +91,6 @@ function NewTemplateMenu() {
)}
</MenuButton>
<ContextMenu aria-label={t("New template")} {...menu}>
<Header>{t("Choose a collection")}</Header>
<Template {...menu} items={items} />
</ContextMenu>
</>
+49 -33
View File
@@ -6,11 +6,11 @@ import { MenuButton, useMenuState } from "reakit/Menu";
import Document from "~/models/Document";
import Button from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Separator from "~/components/ContextMenu/Separator";
import Template from "~/components/ContextMenu/Template";
import Icon from "~/components/Icon";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { MenuItem } from "~/types";
import { replaceTitleVariables } from "~/utils/date";
type Props = {
@@ -25,36 +25,56 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
const user = useCurrentUser();
const { documents } = useStores();
const { t } = useTranslation();
const templates = documents.templates;
if (!templates.length) {
const templateToMenuItem = React.useCallback(
(tmpl: Document): MenuItem => ({
type: "button",
title: replaceTitleVariables(tmpl.titleWithDefault, user),
icon: tmpl.icon ? (
<Icon value={tmpl.icon} color={tmpl.color ?? undefined} />
) : (
<DocumentIcon />
),
onClick: () => onSelectTemplate(tmpl),
}),
[user, onSelectTemplate]
);
const templates = documents.templates.filter((tmpl) => tmpl.publishedAt);
const collectionItems = templates
.filter(
(tmpl) =>
!tmpl.isWorkspaceTemplate && tmpl.collectionId === document.collectionId
)
.map(templateToMenuItem);
const workspaceTemplates = templates
.filter((tmpl) => tmpl.isWorkspaceTemplate)
.map(templateToMenuItem);
const workspaceItems: MenuItem[] = React.useMemo(
() =>
workspaceTemplates.length
? [{ type: "heading", title: t("Workspace") }, ...workspaceTemplates]
: [],
[t, workspaceTemplates]
);
const items = collectionItems
? workspaceItems.length
? [
...collectionItems,
{ type: "separator" } as MenuItem,
...workspaceItems,
]
: collectionItems
: workspaceItems;
if (!items.length) {
return null;
}
const templatesInCollection = templates.filter(
(t) => t.collectionId === document.collectionId
);
const otherTemplates = templates.filter(
(t) => t.collectionId !== document.collectionId
);
const renderTemplate = (template: Document) => (
<MenuItem
key={template.id}
onClick={() => onSelectTemplate(template)}
icon={
template.icon ? (
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<DocumentIcon />
)
}
{...menu}
>
{replaceTitleVariables(template.titleWithDefault, user)}
</MenuItem>
);
return (
<>
<MenuButton {...menu}>
@@ -65,11 +85,7 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Templates")}>
{templatesInCollection.map(renderTemplate)}
{otherTemplates.length && templatesInCollection.length ? (
<Separator />
) : undefined}
{otherTemplates.map(renderTemplate)}
<Template {...menu} items={items} />
</ContextMenu>
</>
);
+16 -3
View File
@@ -390,6 +390,11 @@ export default class Document extends ParanoidModel {
return this.collection?.pathToDocument(this.id) ?? [];
}
@computed
get isWorkspaceTemplate() {
return this.template && !this.collectionId;
}
get titleWithDefault(): string {
return this.title || i18n.t("Untitled");
}
@@ -541,7 +546,13 @@ export default class Document extends ParanoidModel {
};
@action
templatize = () => this.store.templatize(this.id);
templatize = ({
collectionId,
publish,
}: {
collectionId: string | null;
publish: boolean;
}) => this.store.templatize({ id: this.id, collectionId, publish });
@action
save = async (
@@ -568,8 +579,10 @@ export default class Document extends ParanoidModel {
}
};
move = (collectionId: string, parentDocumentId?: string | undefined) =>
this.store.move(this.id, collectionId, parentDocumentId);
move = (options: {
collectionId?: string | null;
parentDocumentId?: string;
}) => this.store.move({ documentId: this.id, ...options });
duplicate = (options?: {
title?: string;
+1 -1
View File
@@ -25,7 +25,7 @@ export default function Contents({ headings }: Props) {
});
React.useEffect(() => {
let activeId = headings.at(0)?.id;
let activeId = headings[0]?.id;
for (let key = 0; key < headings.length; key++) {
const heading = headings[key];
@@ -13,7 +13,6 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import Logger from "~/utils/Logger";
import {
NotFoundError,
@@ -89,16 +88,14 @@ function DataLoader({ match, children }: Props) {
match.path === matchDocumentEdit || match.path.startsWith(settingsPath());
const isEditing = isEditRoute || !user?.separateEditMode;
const can = usePolicy(document);
const canTeam = usePolicy(team);
const location = useLocation<LocationState>();
React.useEffect(() => {
if (
!dataAttributes.isLoaded &&
FeatureFlags.isEnabled(Feature.dataAttributes)
) {
if (!dataAttributes.isLoaded && canTeam.listDataAttribute) {
void dataAttributes.fetchAll();
}
}, [dataAttributes]);
}, [dataAttributes, canTeam]);
React.useEffect(() => {
async function fetchDocument() {
@@ -195,7 +192,7 @@ function DataLoader({ match, children }: Props) {
// If we're attempting to update an archived, deleted, or otherwise
// uneditable document then forward to the canonical read url.
if (!can.update && isEditRoute) {
if (!can.update && isEditRoute && !document.template) {
history.push(document.url);
return;
}
@@ -17,7 +17,6 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { DataAttributesHelper } from "~/utils/DataAttributesHelper";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import { documentPath, documentInsightsPath } from "~/utils/routeHelpers";
import { Properties, PropertiesRef } from "./Properties";
@@ -39,6 +38,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const onlyYou = totalViewers === 1 && documentViews[0].userId;
const viewsLoadedOnMount = React.useRef(totalViewers > 0);
const can = usePolicy(document);
const canTeam = usePolicy(team);
const propertiesRef = React.useRef<PropertiesRef>(null);
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
@@ -47,8 +47,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const commentsCount = comments.unresolvedCommentsInDocumentCount(document.id);
const dataAttributesAvailable =
FeatureFlags.isEnabled(Feature.dataAttributes) &&
dataAttributes.orderedData.length > 0;
canTeam.listDataAttribute && dataAttributes.orderedData.length > 0;
const missingDataAttributes =
!document.dataAttributes ||
document.dataAttributes?.length < dataAttributes.orderedData.length;
+6 -3
View File
@@ -116,8 +116,9 @@ function DocumentHeader({
activeDocumentId: document?.id,
});
const { isDeleted, isTemplate } = document;
const can = usePolicy(document);
const { isDeleted, isTemplate } = document;
const isTemplateEditable = can.update && isTemplate;
const canToggleEmbeds = team?.documentEmbeds;
const isShare = !!shareId;
const showContents =
@@ -276,7 +277,7 @@ function DocumentHeader({
<ShareButton document={document} />
</Action>
)}
{(isEditing || isTemplate) && (
{(isEditing || isTemplateEditable) && (
<Action>
<Tooltip
content={t("Save")}
@@ -351,7 +352,9 @@ function DocumentHeader({
hideOnActionDisabled
hideIcon
>
{document.collectionId ? t("Publish") : `${t("Publish")}`}
{document.collectionId || document.isWorkspaceTemplate
? t("Publish")
: `${t("Publish")}`}
</Button>
</Action>
)}
@@ -20,10 +20,10 @@ import InputSelect from "~/components/InputSelect";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { DataAttributesHelper } from "~/utils/DataAttributesHelper";
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
const PropertyHeight = 30;
@@ -38,7 +38,9 @@ export type PropertiesRef = {
export const Properties = observer(
React.forwardRef(function Properties_({ document }: Props, ref) {
const { dataAttributes } = useStores();
const team = useCurrentTeam();
const can = usePolicy(document);
const canTeam = usePolicy(team);
const [draftAttribute, setDraftAttribute] =
React.useState<DocumentDataAttribute | null>(null);
@@ -76,7 +78,7 @@ export const Properties = observer(
}
};
if (!FeatureFlags.isEnabled(Feature.dataAttributes)) {
if (!canTeam.listDataAttribute) {
return null;
}
+13 -4
View File
@@ -8,7 +8,11 @@ import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { collectionPath, documentPath } from "~/utils/routeHelpers";
import {
collectionPath,
documentPath,
settingsPath,
} from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -21,7 +25,8 @@ function DocumentDelete({ document, onSubmit }: Props) {
const history = useHistory();
const [isDeleting, setDeleting] = React.useState(false);
const [isArchiving, setArchiving] = React.useState(false);
const canArchive = !document.isDraft && !document.isArchived;
const canArchive =
!document.isDraft && !document.isArchived && !document.template;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -50,8 +55,12 @@ function DocumentDelete({ document, onSubmit }: Props) {
}
}
// otherwise, redirect to the collection home
history.push(collectionPath(collection?.path || "/"));
// If template, redirect to the template settings.
// Otherwise redirect to the collection (or) home.
const path = document.template
? settingsPath("templates")
: collectionPath(collection?.path || "/");
history.push(path);
}
onSubmit();
+2 -2
View File
@@ -68,9 +68,9 @@ function DocumentMove({ document }: Props) {
const collectionId = selectedPath.collectionId as string;
if (type === "document") {
await document.move(collectionId, parentDocumentId);
await document.move({ collectionId, parentDocumentId });
} else {
await document.move(collectionId);
await document.move({ collectionId });
}
toast.success(t("Document moved"));
+1 -1
View File
@@ -50,7 +50,7 @@ function DocumentPublish({ document }: Props) {
// Also move it under if selected path corresponds to another doc
if (type === "document") {
await document.move(collectionId, parentDocumentId);
await document.move({ collectionId, parentDocumentId });
}
document.collectionId = collectionId;
+4 -1
View File
@@ -48,7 +48,10 @@ function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
setIsSaving(true);
try {
await documents.move(item.id, collection.id);
await documents.move({
documentId: item.id,
collectionId: collection.id,
});
toast.message(t("Document moved"));
onSubmit();
} catch (err) {
+22 -7
View File
@@ -457,7 +457,15 @@ export default class DocumentsStore extends Store<Document> {
};
@action
templatize = async (id: string): Promise<Document | null | undefined> => {
templatize = async ({
id,
collectionId,
publish,
}: {
id: string;
collectionId: string | null;
publish: boolean;
}): Promise<Document | null | undefined> => {
const doc: Document | null | undefined = this.data.get(id);
invariant(doc, "Document should exist");
@@ -467,6 +475,8 @@ export default class DocumentsStore extends Store<Document> {
const res = await client.post("/documents.templatize", {
id,
collectionId,
publish,
});
invariant(res?.data, "Document not available");
this.addPolicies(res.policies);
@@ -546,12 +556,17 @@ export default class DocumentsStore extends Store<Document> {
};
@action
move = async (
documentId: string,
collectionId: string,
parentDocumentId?: string | null,
index?: number | null
) => {
move = async ({
documentId,
collectionId,
parentDocumentId,
index,
}: {
documentId: string;
collectionId?: string | null;
parentDocumentId?: string | null;
index?: number | null;
}) => {
this.movingDocumentId = documentId;
try {
-3
View File
@@ -4,14 +4,11 @@ import Storage from "@shared/utils/Storage";
export enum Feature {
/** New collection permissions UI */
newCollectionSharing = "newCollectionSharing",
/** Document data attributes */
dataAttributes = "dataAttributes",
}
/** Default values for feature flags */
const FeatureDefaults: Record<Feature, boolean> = {
[Feature.newCollectionSharing]: true,
[Feature.dataAttributes]: false,
};
/**
+4 -2
View File
@@ -81,8 +81,10 @@ export function updateDocumentPath(oldUrl: string, document: Document): string {
);
}
export function newTemplatePath(collectionId: string) {
return settingsPath("templates") + `/new?collectionId=${collectionId}`;
export function newTemplatePath(collectionId?: string) {
return collectionId
? settingsPath("templates") + `/new?collectionId=${collectionId}`
: `${settingsPath("templates")}/new`;
}
export function newDocumentPath(
+10 -11
View File
@@ -8,10 +8,10 @@ Outline's frontend is a React application compiled with [Vite](https://vitejs.de
```
app
├── components - React components reusable across scenes
├── embeds - Embed definitions that represent rich interactive embeds in the editor
├── hooks - Reusable React hooks
├── actions - Reusable actions such as navigating, opening, creating entities
├── components - React components reusable across scenes
├── editor - React components specific to the editor
├── hooks - Reusable React hooks
├── menus - Context menus, often appear in multiple places in the UI
├── models - State models using MobX observables
├── routes - Route definitions, note that chunks are async loaded with suspense
@@ -30,15 +30,14 @@ Interested in more documentation on the API routes? Check out the [API documenta
```
server
├── api - All API routes are contained within here
── middlewares - Koa middlewares specific to the API
── auth - Authentication logic
│ └── providers - Authentication providers export passport.js strategies and config
├── commands - We are gradually moving to the command pattern for new write logic
├── routes - All API routes are contained within here
── api - API routes
│ └── auth - Authentication routes
├── commands - Complex commands that perform actions across multiple models
├── config - Database configuration
├── emails - Transactional email templates
│ └── templates - Classes that define each possible email template
├── middlewares - Koa middlewares
├── middlewares - Shared Koa middlewares
├── migrations - Database migrations
├── models - Sequelize models
├── onboarding - Markdown templates for onboarding documents
@@ -60,10 +59,10 @@ small utilities.
```
shared
├── components - Shared React components that are used in both the frontend and backend
├── editor - The text editor, based on Prosemirror
├── i18n - Internationalization configuration
│ └── locales - Language specific translation files
├── styles - Styles, colors and other global aesthetics
── utils - Shared utility methods
└── constants - Shared constants
── utils - Shared utility methods
```
-14
View File
@@ -1,14 +0,0 @@
# Authentication Providers
A new auth provider can be added with the addition of a plugin with a koa router
as the default export in /server/auth/[provider].ts and (optionally) a matching
logo in `/client/Icon.tsx` that will appear on the sign-in button.
Auth providers generally use [Passport](http://www.passportjs.org/) strategies,
although they can use any custom logic if needed. See the `google` auth provider
for the cleanest example of what is required some rules:
- The strategy name _must_ be lowercase
- The strategy _must_ call the `accountProvisioner` command in the verify callback
- The auth file _must_ export a `config` object with `name` and `enabled` keys
- The auth file _must_ have a default export with a koa-router
+5 -7
View File
@@ -47,11 +47,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.609.0",
"@aws-sdk/lib-storage": "3.609.0",
"@aws-sdk/s3-presigned-post": "3.609.0",
"@aws-sdk/s3-request-presigner": "3.609.0",
"@aws-sdk/signature-v4-crt": "^3.609.0",
"@aws-sdk/client-s3": "3.616.0",
"@aws-sdk/lib-storage": "3.616.0",
"@aws-sdk/s3-presigned-post": "3.616.0",
"@aws-sdk/s3-request-presigner": "3.616.0",
"@aws-sdk/signature-v4-crt": "^3.616.0",
"@babel/core": "^7.24.7",
"@babel/plugin-proposal-decorators": "^7.24.7",
"@babel/plugin-transform-class-properties": "^7.24.7",
@@ -364,9 +364,7 @@
"d3": "^7.0.0",
"debug": "4.3.4",
"node-fetch": "^2.6.12",
"dot-prop": "^5.2.0",
"js-yaml": "^3.14.1",
"jpeg-js": "0.4.4",
"qs": "6.9.7",
"rollup": "^4.5.1"
},
+1 -1
View File
@@ -22,7 +22,7 @@ class Iframely {
env.IFRAMELY_API_KEY
}`
);
return res.json();
return await res.json();
} catch (err) {
Logger.error(`Error fetching data from Iframely for url: ${url}`, err);
return;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+13 -5
View File
@@ -1,5 +1,6 @@
import { Transaction } from "sequelize";
import { Optional } from "utility-types";
import { DocumentDataAttribute } from "@shared/models/types";
import { Document, Event, User } from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
@@ -29,6 +30,7 @@ type Props = Optional<
state?: Buffer;
publish?: boolean;
templateDocument?: Document | null;
dataAttributes?: Omit<DocumentDataAttribute, "updatedAt">[] | null;
user: User;
ip?: string;
transaction?: Transaction;
@@ -45,6 +47,7 @@ export default async function documentCreator({
publish,
collectionId,
parentDocumentId,
dataAttributes,
content,
template,
templateDocument,
@@ -86,6 +89,14 @@ export default async function documentCreator({
id,
urlId,
parentDocumentId,
dataAttributes: dataAttributes?.map(
({ dataAttributeId, value }) =>
({
dataAttributeId,
value,
updatedAt: new Date().toISOString(),
} as DocumentDataAttribute)
),
editorVersion,
collectionId,
teamId: user.teamId,
@@ -148,14 +159,11 @@ export default async function documentCreator({
);
if (publish) {
if (!collectionId) {
if (!collectionId && !template) {
throw new Error("Collection ID is required to publish");
}
await document.publish(user, collectionId, {
silent: true,
transaction,
});
await document.publish(user, collectionId, { silent: true, transaction });
if (document.title) {
await Event.create(
{
-5
View File
@@ -1,6 +1,5 @@
import invariant from "invariant";
import { Transaction } from "sequelize";
import { ValidationError } from "@server/errors";
import { traceFunction } from "@server/logging/tracing";
import {
User,
@@ -58,10 +57,6 @@ async function documentMover({
}
if (document.template) {
if (!document.collectionId) {
throw ValidationError("Templates must be in a collection");
}
document.collectionId = collectionId;
document.parentDocumentId = null;
document.lastModifiedById = user.id;
+20 -20
View File
@@ -10,7 +10,7 @@ type Props = {
/** The existing document */
document: Document;
/** Data attributes to apply to the document */
dataAttributes?: Omit<DocumentDataAttribute, "updatedAt">[];
dataAttributes?: Omit<DocumentDataAttribute, "updatedAt">[] | null;
/** The new title */
title?: string;
/** The document icon */
@@ -71,25 +71,25 @@ export default async function documentUpdater({
const cId = collectionId || document.collectionId;
if (dataAttributes !== undefined) {
// TODO: Validate schema
document.dataAttributes = dataAttributes
? uniqBy(
dataAttributes.map(({ dataAttributeId, value }) => {
const existing = document.dataAttributes?.find(
(da) => da.dataAttributeId === dataAttributeId
);
if (existing?.value === value) {
return existing as DocumentDataAttribute;
}
document.dataAttributes = uniqBy(
dataAttributes.map(({ dataAttributeId, value }) => {
const existing = document.dataAttributes.find(
(da) => da.dataAttributeId === dataAttributeId
);
if (existing?.value === value) {
return existing as DocumentDataAttribute;
}
return {
dataAttributeId,
value,
updatedAt: new Date().toISOString(),
} as DocumentDataAttribute;
}),
"dataAttributeId"
);
return {
dataAttributeId,
value,
updatedAt: new Date().toISOString(),
} as DocumentDataAttribute;
}),
"dataAttributeId"
)
: null;
}
if (title !== undefined) {
@@ -133,7 +133,7 @@ export default async function documentUpdater({
ip,
};
if (publish && cId) {
if (publish && (document.template || cId)) {
if (!document.collectionId) {
document.collectionId = cId;
}
+14 -25
View File
@@ -1,6 +1,5 @@
import { Transaction } from "sequelize";
import { Event, Star, User } from "@server/models";
import { sequelize } from "@server/storage/database";
type Props = {
/** The user destroying the star */
@@ -24,31 +23,21 @@ export default async function starDestroyer({
user,
star,
ip,
transaction: t,
transaction,
}: Props): Promise<Star> {
const transaction = t || (await sequelize.transaction());
try {
await star.destroy({ transaction });
await Event.create(
{
name: "stars.delete",
modelId: star.id,
teamId: user.teamId,
actorId: user.id,
userId: star.userId,
documentId: star.documentId,
ip,
},
{ transaction }
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
await star.destroy({ transaction });
await Event.create(
{
name: "stars.delete",
modelId: star.id,
teamId: user.teamId,
actorId: user.id,
userId: star.userId,
documentId: star.documentId,
ip,
},
{ transaction }
);
return star;
}
+18 -24
View File
@@ -1,5 +1,5 @@
import { Transaction } from "sequelize";
import { Event, Star, User } from "@server/models";
import { sequelize } from "@server/storage/database";
type Props = {
/** The user updating the star */
@@ -10,6 +10,8 @@ type Props = {
index: string;
/** The IP address of the user creating the star */
ip: string;
/** Optional existing transaction */
transaction?: Transaction;
};
/**
@@ -24,30 +26,22 @@ export default async function starUpdater({
star,
index,
ip,
transaction,
}: Props): Promise<Star> {
const transaction = await sequelize.transaction();
try {
star.index = index;
await star.save({ transaction });
await Event.create(
{
name: "stars.update",
modelId: star.id,
userId: star.userId,
teamId: user.teamId,
actorId: user.id,
documentId: star.documentId,
ip,
},
{ transaction }
);
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
star.index = index;
await star.save({ transaction });
await Event.create(
{
name: "stars.update",
modelId: star.id,
userId: star.userId,
teamId: user.teamId,
actorId: user.id,
documentId: star.documentId,
ip,
},
{ transaction }
);
return star;
}
+7
View File
@@ -348,6 +348,13 @@ export class Environment {
*/
public SMTP_SECURE = this.toBoolean(environment.SMTP_SECURE ?? "true");
/**
* Dropbox app key for embedding Dropbox files
*/
@Public
@IsOptional()
public DROPBOX_APP_KEY = this.toOptionalString(environment.DROPBOX_APP_KEY);
/**
* Sentry DSN for capturing errors and frontend performance.
*/
@@ -36,7 +36,7 @@ module.exports = {
allowNull: true,
},
dataType: {
type: Sequelize.ENUM("string", "integer", "boolean", "list"),
type: Sequelize.ENUM("string", "number", "boolean", "list"),
allowNull: false,
},
options: {
+12 -5
View File
@@ -282,9 +282,9 @@ class Document extends ParanoidModel<
@Column
color: string | null;
// TODO
/** Attributes associated with the document. */
@Column(DataType.JSONB)
dataAttributes: DocumentDataAttribute[];
dataAttributes: DocumentDataAttribute[] | null;
/**
* The content of the document as Markdown.
@@ -377,7 +377,7 @@ class Document extends ParanoidModel<
model: Document,
{ transaction }: SaveOptions<Document>
) {
if (model.changed("dataAttributes")) {
if (model.changed("dataAttributes") && model.dataAttributes) {
const dataAttributeIds = model.dataAttributes.map(
(d) => d.dataAttributeId
);
@@ -776,6 +776,13 @@ class Document extends ParanoidModel<
return !!(this.importId && this.sourceMetadata?.trial);
}
/**
* Returns whether this document is a template created at the workspace level.
*/
get isWorkspaceTemplate() {
return this.template && !this.collectionId;
}
/**
* Revert the state of the document to match the passed revision.
*
@@ -871,7 +878,7 @@ class Document extends ParanoidModel<
publish = async (
user: User,
collectionId: string,
collectionId: string | null | undefined,
options: SaveOptions
): Promise<this> => {
const { transaction } = options;
@@ -886,7 +893,7 @@ class Document extends ParanoidModel<
this.collectionId = collectionId;
}
if (!this.template) {
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId, {
transaction,
lock: Transaction.LOCK.UPDATE,
+10 -2
View File
@@ -12,11 +12,19 @@ describe("DocumentHelper", () => {
jest.useRealTimers();
});
describe("toJSON", () => {
it("should return content directly if no transformation required", async () => {
const document = await buildDocument();
const result = await DocumentHelper.toJSON(document);
expect(result === document.content).toBe(true);
});
});
describe("parseMentions", () => {
it("should not parse normal links as mentions", async () => {
const document = await buildDocument({
text: `# Header
[link not mention](http://google.com)`,
});
const result = DocumentHelper.parseMentions(document);
@@ -26,7 +34,7 @@ describe("DocumentHelper", () => {
it("should return an array of mentions", async () => {
const document = await buildDocument({
text: `# Header
@[Alan Kay](mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/34095ac1-c808-45c0-8c6e-6c554497de64) :wink:
More text
+4
View File
@@ -83,6 +83,10 @@ export class DocumentHelper {
let json;
if ("content" in document && document.content) {
// Optimized path for documents with content available and no transformation required.
if (!options?.removeMarks && !options?.signedUrls) {
return document.content;
}
doc = Node.fromJSON(schema, document.content);
} else if ("state" in document && document.state) {
const ydoc = new Y.Doc();
@@ -486,6 +486,25 @@ describe("SearchHelper", () => {
);
expect(totalCount).toBe(1);
});
test("should correctly handle removal of trailing spaces", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
userId: user.id,
});
const document = await buildDocument({
teamId: team.id,
userId: user.id,
collectionId: collection.id,
text: "env: some env",
});
document.title = "change";
await document.save();
const { totalCount } = await SearchHelper.searchForUser(user, "env: ");
expect(totalCount).toBe(1);
});
});
describe("#searchTitlesForUser", () => {
+6 -1
View File
@@ -555,7 +555,12 @@ export default class SearchHelper {
}
return (
queryParser()(quotedSearch ? limitedQuery : `${limitedQuery}*`)
queryParser()(
// Although queryParser trims the query, looks like there's a
// bug for certain cases where it removes other characters in addition to
// spaces. Ref: https://github.com/caub/pg-tsquery/issues/27
quotedSearch ? limitedQuery.trim() : `${limitedQuery.trim()}*`
)
// Remove any trailing join characters
.replace(/&$/, "")
);
+14 -4
View File
@@ -1,18 +1,28 @@
import env from "@server/env";
import { User, Team, DataAttribute } from "@server/models";
import { allow } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable } from "./utils";
const isEnabled = !env.isCloudHosted;
allow(User, "createDataAttribute", Team, (actor, team) =>
and(
//
isTeamAdmin(actor, team),
isTeamMutable(actor),
!actor.isSuspended
!actor.isSuspended,
!env.isCloudHosted
)
);
allow(User, "listDataAttribute", Team, isTeamModel);
allow(User, "listDataAttribute", Team, (actor, team) =>
and(isTeamModel(actor, team), isEnabled)
);
allow(User, "read", DataAttribute, isTeamModel);
allow(User, "read", DataAttribute, (actor, team) =>
and(isTeamModel(actor, team), isEnabled)
);
allow(User, ["update", "delete"], DataAttribute, isTeamAdmin);
allow(User, ["update", "delete"], DataAttribute, (actor, team) =>
and(isTeamAdmin(actor, team), isEnabled)
);
+41 -4
View File
@@ -29,6 +29,10 @@ allow(User, "read", Document, (actor, document) =>
DocumentPermission.Admin,
]),
and(!!document?.isDraft, actor.id === document?.createdById),
and(
!!document?.isWorkspaceTemplate,
can(actor, "readTemplate", actor.team)
),
can(actor, "readDocument", document?.collection)
)
)
@@ -98,7 +102,14 @@ allow(User, "update", Document, (actor, document) =>
]),
or(
can(actor, "updateDocument", document?.collection),
and(!!document?.isDraft && actor.id === document?.createdById)
and(!!document?.isDraft && actor.id === document?.createdById),
and(
!!document?.isWorkspaceTemplate,
or(
actor.id === document?.createdById,
can(actor, "updateTemplate", actor.team)
)
)
)
)
)
@@ -118,7 +129,14 @@ allow(User, ["manageUsers", "duplicate"], Document, (actor, document) =>
or(
includesMembership(document, [DocumentPermission.Admin]),
can(actor, "updateDocument", document?.collection),
!!document?.isDraft && actor.id === document?.createdById
!!document?.isDraft && actor.id === document?.createdById,
and(
!!document?.isWorkspaceTemplate,
or(
actor.id === document?.createdById,
can(actor, "updateTemplate", actor.team)
)
)
)
)
);
@@ -128,7 +146,14 @@ allow(User, "move", Document, (actor, document) =>
can(actor, "update", document),
or(
can(actor, "updateDocument", document?.collection),
and(!!document?.isDraft && actor.id === document?.createdById)
and(!!document?.isDraft && actor.id === document?.createdById),
and(
!!document?.isWorkspaceTemplate,
or(
actor.id === document?.createdById,
can(actor, "updateTemplate", actor.team)
)
)
)
)
);
@@ -166,7 +191,7 @@ allow(User, "delete", Document, (actor, document) =>
or(
can(actor, "unarchive", document),
can(actor, "update", document),
!document?.collection
and(!document?.isWorkspaceTemplate, !document?.collection)
)
)
);
@@ -183,6 +208,10 @@ allow(User, ["restore", "permanentDelete"], Document, (actor, document) =>
]),
can(actor, "updateDocument", document?.collection),
and(!!document?.isDraft && actor.id === document?.createdById),
and(
!!document?.isWorkspaceTemplate,
can(actor, "updateTemplate", actor.team)
),
!document?.collection
)
)
@@ -236,6 +265,14 @@ allow(User, "unpublish", Document, (user, document) => {
) {
return false;
}
if (
document.isWorkspaceTemplate &&
(user.id === document.createdById || can(user, "updateTemplate", user.team))
) {
return true;
}
invariant(
document.collection,
"collection is missing, did you forget to include in the query scope?"
+1 -1
View File
@@ -14,6 +14,6 @@ it("should serialize domain policies on Team", async () => {
teamId: team.id,
});
const response = serialize(user, team);
expect(response.createDocument).toEqual(true);
expect(response.createTemplate).toEqual(true);
expect(response.inviteUser).toEqual(true);
});
+68 -4
View File
@@ -1,8 +1,9 @@
import { UserRole } from "@shared/types";
import { buildUser, buildTeam, buildAdmin } from "@server/test/factories";
import { setSelfHosted } from "@server/test/support";
import { serialize } from "./index";
describe.skip("policies/team", () => {
describe("policies/team", () => {
it("should allow reading only", async () => {
setSelfHosted();
@@ -15,7 +16,7 @@ describe.skip("policies/team", () => {
expect(abilities.createTeam).toEqual(false);
expect(abilities.createAttachment).toEqual(true);
expect(abilities.createCollection).toEqual(true);
expect(abilities.createDocument).toEqual(true);
expect(abilities.createTemplate).toEqual(true);
expect(abilities.createGroup).toEqual(false);
expect(abilities.createIntegration).toEqual(false);
});
@@ -32,7 +33,7 @@ describe.skip("policies/team", () => {
expect(abilities.createTeam).toEqual(false);
expect(abilities.createAttachment).toEqual(true);
expect(abilities.createCollection).toEqual(true);
expect(abilities.createDocument).toEqual(true);
expect(abilities.createTemplate).toEqual(true);
expect(abilities.createGroup).toEqual(true);
expect(abilities.createIntegration).toEqual(true);
});
@@ -47,8 +48,71 @@ describe.skip("policies/team", () => {
expect(abilities.createTeam).toEqual(true);
expect(abilities.createAttachment).toEqual(true);
expect(abilities.createCollection).toEqual(true);
expect(abilities.createDocument).toEqual(true);
expect(abilities.createTemplate).toEqual(true);
expect(abilities.createGroup).toEqual(true);
expect(abilities.createIntegration).toEqual(true);
});
describe("read template", () => {
const permissions = new Map<UserRole, boolean>([
[UserRole.Admin, true],
[UserRole.Member, true],
[UserRole.Viewer, false],
[UserRole.Guest, false],
]);
for (const [role, permission] of permissions.entries()) {
it(`check permission for ${role}`, async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
role,
});
const abilities = serialize(user, team);
expect(abilities.readTemplate).toEqual(permission);
});
}
});
describe("create template", () => {
const permissions = new Map<UserRole, boolean>([
[UserRole.Admin, true],
[UserRole.Member, true],
[UserRole.Viewer, false],
[UserRole.Guest, false],
]);
for (const [role, permission] of permissions.entries()) {
it(`check permission for ${role}`, async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
role,
});
const abilities = serialize(user, team);
expect(abilities.createTemplate).toEqual(permission);
});
}
});
describe("update template", () => {
const permissions = new Map<UserRole, boolean>([
[UserRole.Admin, true],
[UserRole.Member, false],
[UserRole.Viewer, false],
[UserRole.Guest, false],
]);
for (const [role, permission] of permissions.entries()) {
it(`check permission for ${role}`, async () => {
const team = await buildTeam();
const user = await buildUser({
teamId: team.id,
role,
});
const abilities = serialize(user, team);
expect(abilities.updateTemplate).toEqual(permission);
});
}
});
});
+27 -1
View File
@@ -1,6 +1,13 @@
import { Team, User } from "@server/models";
import { allow } from "./cancan";
import { and, isCloudHosted, isTeamAdmin, isTeamModel, or } from "./utils";
import {
and,
isCloudHosted,
isTeamAdmin,
isTeamModel,
isTeamMutable,
or,
} from "./utils";
allow(User, "read", Team, isTeamModel);
@@ -32,3 +39,22 @@ allow(User, ["delete", "audit"], Team, (actor, team) =>
isTeamAdmin(actor, team)
)
);
allow(User, ["createTemplate", "readTemplate"], Team, (actor, team) =>
and(
//
!actor.isGuest,
!actor.isViewer,
isTeamModel(actor, team),
isTeamMutable(actor)
)
);
allow(User, "updateTemplate", Team, (actor, team) =>
and(
//
actor.isAdmin,
isTeamModel(actor, team),
isTeamMutable(actor)
)
);
+19 -22
View File
@@ -2,6 +2,7 @@ import path from "path";
import fs from "fs-extra";
import chunk from "lodash/chunk";
import truncate from "lodash/truncate";
import { InferCreationAttributes } from "sequelize";
import tmp from "tmp";
import {
AttachmentPreset,
@@ -358,20 +359,28 @@ export default abstract class ImportTask extends BaseTask<Props> {
})
: null;
const sharedDefaults: Partial<InferCreationAttributes<Collection>> = {
...options,
id: item.id,
description: truncatedDescription,
color: item.color,
icon: item.icon,
sort: item.sort,
createdById: fileOperation.userId,
permission:
item.permission ?? fileOperation.options?.permission !== undefined
? fileOperation.options?.permission
: CollectionPermission.ReadWrite,
importId: fileOperation.id,
};
// check if collection with name exists
const response = await Collection.findOrCreate({
where: {
teamId: fileOperation.teamId,
name: item.name,
},
defaults: {
...options,
id: item.id,
description: truncatedDescription,
createdById: fileOperation.userId,
permission: CollectionPermission.ReadWrite,
importId: fileOperation.id,
},
defaults: sharedDefaults,
transaction,
});
@@ -385,21 +394,9 @@ export default abstract class ImportTask extends BaseTask<Props> {
const name = `${item.name} (Imported)`;
collection = await Collection.create(
{
...options,
id: item.id,
description: truncatedDescription,
color: item.color,
icon: item.icon,
sort: item.sort,
teamId: fileOperation.teamId,
createdById: fileOperation.userId,
...sharedDefaults,
name,
permission:
item.permission ??
fileOperation.options?.permission !== undefined
? fileOperation.options?.permission
: CollectionPermission.ReadWrite,
importId: fileOperation.id,
teamId: fileOperation.teamId,
},
{ transaction }
);
+252 -22
View File
@@ -22,6 +22,7 @@ import {
buildShare,
buildCollection,
buildUser,
buildDataAttribute,
buildDocument,
buildDraftDocument,
buildViewer,
@@ -1929,6 +1930,140 @@ describe("#documents.templatize", () => {
expect(res.status).toBe(400);
expect(body.message).toBe("id: Required");
});
it("should require publish", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.templatize", {
body: {
token: user.getJwtToken(),
id: "random-id",
},
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.message).toBe("publish: Required");
});
it("should create a published non-workspace template", async () => {
const user = await buildUser();
const collection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post("/api/documents.templatize", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
publish: true,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.publishedAt).toBeTruthy();
expect(body.data.collectionId).toEqual(collection.id);
});
it("should create a published workspace template", async () => {
const user = await buildUser();
const collection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post("/api/documents.templatize", {
body: {
token: user.getJwtToken(),
id: document.id,
publish: true,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.publishedAt).toBeTruthy();
expect(body.data.collectionId).toBeNull();
});
it("should create a draft non-workspace template", async () => {
const user = await buildUser();
const collection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post("/api/documents.templatize", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
publish: false,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.publishedAt).toBeNull();
expect(body.data.collectionId).toEqual(collection.id);
});
it("should create a draft workspace template", async () => {
const user = await buildUser();
const collection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post("/api/documents.templatize", {
body: {
token: user.getJwtToken(),
id: document.id,
publish: false,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.publishedAt).toBeNull();
expect(body.data.collectionId).toBeNull();
});
it("should create a template in a different collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
});
const anotherCollection = await buildCollection({
createdById: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post("/api/documents.templatize", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: anotherCollection.id,
publish: true,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.publishedAt).toBeTruthy();
expect(body.data.collectionId).toEqual(anotherCollection.id);
});
});
describe("#documents.archived", () => {
@@ -2285,23 +2420,6 @@ describe("#documents.move", () => {
expect(body.message).toEqual("id: Required");
});
it("should require collectionId", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("collectionId: Required");
});
it("should fail for invalid index", async () => {
const user = await buildUser();
const collection = await buildCollection({
@@ -2389,6 +2507,56 @@ describe("#documents.move", () => {
expect(res.status).toEqual(403);
});
it("should move a template to workspace", async () => {
const user = await buildAdmin();
const collection = await buildCollection({
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
template: true,
});
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.documents[0].collectionId).toBeNull();
expect(body.policies[0].abilities.move).toEqual(true);
});
it("should move a workspace template to collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
template: true,
});
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.documents[0].collectionId).toEqual(collection.id);
expect(body.policies[0].abilities.move).toEqual(true);
});
it("should require authentication", async () => {
const res = await server.post("/api/documents.move");
expect(res.status).toEqual(401);
@@ -2840,6 +3008,35 @@ describe("#documents.create", () => {
expect(body.policies[0].abilities.update).toEqual(true);
});
it("should create as a new document with data attributes", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const dataAttribute = await buildDataAttribute({
userId: user.id,
teamId: team.id,
});
const res = await server.post("/api/documents.create", {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
dataAttributes: [
{
dataAttributeId: dataAttribute.id,
value: "123",
},
],
title: "new document",
text: "hello",
publish: true,
},
});
expect(res.status).toEqual(200);
});
it("should create a draft document not belonging to any collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
@@ -2858,7 +3055,7 @@ describe("#documents.create", () => {
expect(body.data.collectionId).toBeNull();
});
it("should not allow creating a template with a collection", async () => {
it("should allow creating a draft template without a collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/documents.create", {
@@ -2871,10 +3068,10 @@ describe("#documents.create", () => {
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toBe(
"collectionId is required to create a template document"
);
expect(res.status).toEqual(200);
expect(body.data.template).toBe(true);
expect(body.data.publishedAt).toBeNull();
expect(body.data.collectionId).toBeNull();
});
it("should not allow publishing without specifying the collection", async () => {
@@ -3094,6 +3291,39 @@ describe("#documents.update", () => {
expect(body.data.text).toBe("Updated text");
});
it("should successfully publish a draft template without collection", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
userId: user.id,
teamId: team.id,
});
const document = await buildDraftDocument({
title: "title",
text: "text",
teamId: team.id,
userId: user.id,
collectionId: null,
template: true,
});
const res = await server.post("/api/documents.update", {
body: {
token: user.getJwtToken(),
id: document.id,
title: "Updated title",
text: "Updated text",
collectionId: collection.id,
publish: true,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.collectionId).toBe(collection.id);
expect(body.data.title).toBe("Updated title");
expect(body.data.text).toBe("Updated text");
expect(body.data.publishedAt).toBeTruthy();
});
it("should not allow publishing by another collection's user", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
+43 -12
View File
@@ -48,7 +48,7 @@ import AttachmentHelper from "@server/models/helpers/AttachmentHelper";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import SearchHelper from "@server/models/helpers/SearchHelper";
import { authorize, cannot } from "@server/policies";
import { authorize, can, cannot } from "@server/policies";
import {
presentCollection,
presentDocument,
@@ -129,7 +129,15 @@ router.post(
} // otherwise, filter by all collections the user has access to
} else {
const collectionIds = await user.collectionIds();
where = { ...where, collectionId: collectionIds };
where = {
...where,
collectionId:
template && can(user, "readTemplate", user.team)
? {
[Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }],
}
: collectionIds,
};
}
if (parentDocumentId) {
@@ -915,7 +923,7 @@ router.post(
validate(T.DocumentsTemplatizeSchema),
transaction(),
async (ctx: APIContext<T.DocumentsTemplatizeReq>) => {
const { id } = ctx.input.body;
const { id, collectionId, publish } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
@@ -926,12 +934,21 @@ router.post(
authorize(user, "update", original);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
authorize(user, "createDocument", collection);
} else {
authorize(user, "createTemplate", user.team);
}
const document = await Document.create(
{
editorVersion: original.editorVersion,
collectionId: original.collectionId,
teamId: original.teamId,
publishedAt: new Date(),
collectionId,
teamId: user.teamId,
publishedAt: publish ? new Date() : null,
lastModifiedById: user.id,
createdById: user.id,
template: true,
@@ -1007,7 +1024,7 @@ router.post(
authorize(user, "publish", document);
}
if (!document.collectionId) {
if (!document.collectionId && !document.isWorkspaceTemplate) {
assertPresent(
collectionId,
"collectionId is required to publish a draft without collection"
@@ -1026,6 +1043,8 @@ router.post(
}
);
authorize(user, "createChildDocument", parentDocument, { collection });
} else if (document.isWorkspaceTemplate) {
authorize(user, "createTemplate", user.team);
} else {
authorize(user, "createDocument", collection);
}
@@ -1076,6 +1095,8 @@ router.post(
if (collection) {
authorize(user, "updateDocument", collection);
} else if (document.isWorkspaceTemplate) {
authorize(user, "createTemplate", user.team);
}
if (parentDocumentId) {
@@ -1128,10 +1149,16 @@ router.post(
});
authorize(user, "move", document);
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
authorize(user, "updateDocument", collection);
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId, { transaction });
authorize(user, "updateDocument", collection);
} else if (document.template) {
authorize(user, "updateTemplate", user.team);
} else {
throw InvalidRequestError("collectionId is required to move a document");
}
if (parentDocumentId) {
const parent = await Document.findByPk(parentDocumentId, {
@@ -1148,7 +1175,7 @@ router.post(
const { documents, collections, collectionChanged } = await documentMover({
user,
document,
collectionId,
collectionId: collectionId ?? null,
parentDocumentId,
index,
ip: ctx.request.ip,
@@ -1382,6 +1409,7 @@ router.post(
publish,
collectionId,
parentDocumentId,
dataAttributes,
fullWidth,
templateId,
template,
@@ -1427,6 +1455,8 @@ router.post(
transaction,
});
authorize(user, "createDocument", collection);
} else if (!!template && !collectionId) {
authorize(user, "createTemplate", user.team);
}
let templateDocument: Document | null | undefined;
@@ -1448,6 +1478,7 @@ router.post(
publish,
collectionId: collection?.id,
parentDocumentId,
dataAttributes,
templateDocument,
template,
fullWidth,
+25 -18
View File
@@ -195,7 +195,12 @@ export const DocumentsDuplicateSchema = BaseSchema.extend({
export type DocumentsDuplicateReq = z.infer<typeof DocumentsDuplicateSchema>;
export const DocumentsTemplatizeSchema = BaseSchema.extend({
body: BaseIdSchema,
body: BaseIdSchema.extend({
/** Id of the collection inside which the template should be created */
collectionId: z.string().nullish(),
/** Whether the new template should be published */
publish: z.boolean(),
}),
});
export type DocumentsTemplatizeReq = z.infer<typeof DocumentsTemplatizeSchema>;
@@ -252,7 +257,7 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
value: z.string().or(z.boolean()).or(z.number()),
})
)
.optional(),
.nullish(),
}),
}).refine((req) => !(req.body.append && !req.body.text), {
message: "text is required while appending",
@@ -263,7 +268,7 @@ export type DocumentsUpdateReq = z.infer<typeof DocumentsUpdateSchema>;
export const DocumentsMoveSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
/** Id of collection to which the doc is supposed to be moved */
collectionId: z.string().uuid(),
collectionId: z.string().uuid().nullish(),
/** Parent Id, in case if the doc is moved to a new parent */
parentDocumentId: z.string().uuid().nullish(),
@@ -349,6 +354,16 @@ export const DocumentsCreateSchema = BaseSchema.extend({
/** A template to create the document from */
templateId: z.string().uuid().optional(),
/** Data attributes to be included on the document */
dataAttributes: z
.array(
z.object({
dataAttributeId: z.string(),
value: z.string().or(z.boolean()).or(z.number()),
})
)
.optional(),
/** Optionally set the created date in the past */
createdAt: z.coerce
.date()
@@ -363,21 +378,13 @@ export const DocumentsCreateSchema = BaseSchema.extend({
/** Whether this should be considered a template */
template: z.boolean().optional(),
}),
})
.refine((req) => !(req.body.template && !req.body.collectionId), {
message: "collectionId is required to create a template document",
})
.refine(
(req) =>
!(
req.body.publish &&
!req.body.parentDocumentId &&
!req.body.collectionId
),
{
message: "collectionId or parentDocumentId is required to publish",
}
);
}).refine(
(req) =>
!(req.body.publish && !req.body.parentDocumentId && !req.body.collectionId),
{
message: "collectionId or parentDocumentId is required to publish",
}
);
export type DocumentsCreateReq = z.infer<typeof DocumentsCreateSchema>;
+90 -50
View File
@@ -3,6 +3,7 @@ import { Op, WhereOptions } from "sequelize";
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { User, Event, Group, GroupUser } from "@server/models";
import { authorize } from "@server/policies";
@@ -99,27 +100,39 @@ router.post(
rateLimiter(RateLimiterStrategy.TenPerHour),
auth(),
validate(T.GroupsCreateSchema),
transaction(),
async (ctx: APIContext<T.GroupsCreateReq>) => {
const { name } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
authorize(user, "createGroup", user.team);
const g = await Group.create({
name,
teamId: user.teamId,
createdById: user.id,
});
const g = await Group.create(
{
name,
teamId: user.teamId,
createdById: user.id,
},
{ transaction }
);
// reload to get default scope
const group = await Group.findByPk(g.id, { rejectOnEmpty: true });
await Event.createFromContext(ctx, {
name: "groups.create",
modelId: group.id,
data: {
name: group.name,
},
const group = await Group.findByPk(g.id, {
transaction,
rejectOnEmpty: true,
});
await Event.createFromContext(
ctx,
{
name: "groups.create",
modelId: group.id,
data: {
name: group.name,
},
},
{ transaction }
);
ctx.body = {
data: presentGroup(group),
policies: presentPolicies(user, [group]),
@@ -131,24 +144,30 @@ router.post(
"groups.update",
auth(),
validate(T.GroupsUpdateSchema),
transaction(),
async (ctx: APIContext<T.GroupsUpdateReq>) => {
const { id, name } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const group = await Group.findByPk(id);
const group = await Group.findByPk(id, { transaction });
authorize(user, "update", group);
group.name = name;
if (group.changed()) {
await group.save();
await Event.createFromContext(ctx, {
name: "groups.update",
modelId: group.id,
data: {
name,
await group.save({ transaction });
await Event.createFromContext(
ctx,
{
name: "groups.update",
modelId: group.id,
data: {
name,
},
},
});
{ transaction }
);
}
ctx.body = {
@@ -162,21 +181,27 @@ router.post(
"groups.delete",
auth(),
validate(T.GroupsDeleteSchema),
transaction(),
async (ctx: APIContext<T.GroupsDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const group = await Group.findByPk(id);
const group = await Group.findByPk(id, { transaction });
authorize(user, "delete", group);
await group.destroy();
await Event.createFromContext(ctx, {
name: "groups.delete",
modelId: group.id,
data: {
name: group.name,
await group.destroy({ transaction });
await Event.createFromContext(
ctx,
{
name: "groups.delete",
modelId: group.id,
data: {
name: group.name,
},
},
});
{ transaction }
);
ctx.body = {
success: true,
@@ -238,14 +263,16 @@ router.post(
"groups.add_user",
auth(),
validate(T.GroupsAddUserSchema),
transaction(),
async (ctx: APIContext<T.GroupsAddUserReq>) => {
const { id, userId } = ctx.input.body;
const actor = ctx.state.auth.user;
const { transaction } = ctx.state;
const user = await User.findByPk(userId);
const user = await User.findByPk(userId, { transaction });
authorize(actor, "read", user);
let group = await Group.findByPk(id);
let group = await Group.findByPk(id, { transaction });
authorize(actor, "update", group);
let groupUser = await GroupUser.findOne({
@@ -253,6 +280,7 @@ router.post(
groupId: id,
userId,
},
transaction,
});
if (!groupUser) {
@@ -260,6 +288,7 @@ router.post(
through: {
createdById: actor.id,
},
transaction,
});
// reload to get default scope
groupUser = await GroupUser.findOne({
@@ -268,19 +297,24 @@ router.post(
userId,
},
rejectOnEmpty: true,
transaction,
});
// reload to get default scope
group = await Group.findByPk(id, { rejectOnEmpty: true });
group = await Group.findByPk(id, { transaction, rejectOnEmpty: true });
await Event.createFromContext(ctx, {
name: "groups.add_user",
userId,
modelId: group.id,
data: {
name: user.name,
await Event.createFromContext(
ctx,
{
name: "groups.add_user",
userId,
modelId: group.id,
data: {
name: user.name,
},
},
});
{ transaction }
);
}
ctx.body = {
@@ -297,28 +331,34 @@ router.post(
"groups.remove_user",
auth(),
validate(T.GroupsRemoveUserSchema),
transaction(),
async (ctx: APIContext<T.GroupsRemoveUserReq>) => {
const { id, userId } = ctx.input.body;
const actor = ctx.state.auth.user;
const { transaction } = ctx.state;
let group = await Group.findByPk(id);
let group = await Group.findByPk(id, { transaction });
authorize(actor, "update", group);
const user = await User.findByPk(userId);
const user = await User.findByPk(userId, { transaction });
authorize(actor, "read", user);
await group.$remove("user", user);
await Event.createFromContext(ctx, {
name: "groups.remove_user",
userId,
modelId: group.id,
data: {
name: user.name,
await group.$remove("user", user, { transaction });
await Event.createFromContext(
ctx,
{
name: "groups.remove_user",
userId,
modelId: group.id,
data: {
name: user.name,
},
},
});
{ transaction }
);
// reload to get default scope
group = await Group.findByPk(id, { rejectOnEmpty: true });
group = await Group.findByPk(id, { transaction, rejectOnEmpty: true });
ctx.body = {
data: {
@@ -115,11 +115,16 @@ router.post(
"integrations.update",
auth({ role: UserRole.Admin }),
validate(T.IntegrationsUpdateSchema),
transaction(),
async (ctx: APIContext<T.IntegrationsUpdateReq>) => {
const { id, events, settings } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const integration = await Integration.findByPk(id);
const integration = await Integration.findByPk(id, {
transaction,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "update", integration);
if (integration.type === IntegrationType.Post) {
@@ -130,7 +135,7 @@ router.post(
integration.settings = settings;
await integration.save();
await integration.save({ transaction });
ctx.body = {
data: presentIntegration(integration),
@@ -152,6 +157,7 @@ router.post(
const integration = await Integration.findByPk(id, {
rejectOnEmpty: true,
transaction,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "delete", integration);
+16 -5
View File
@@ -124,11 +124,16 @@ router.post(
"stars.update",
auth(),
validate(T.StarsUpdateSchema),
transaction(),
async (ctx: APIContext<T.StarsUpdateReq>) => {
const { id, index } = ctx.input.body;
const { user } = ctx.state.auth;
let star = await Star.findByPk(id);
const { transaction } = ctx.state;
let star = await Star.findByPk(id, {
transaction,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "update", star);
star = await starUpdater({
@@ -136,6 +141,7 @@ router.post(
star,
ip: ctx.request.ip,
index,
transaction,
});
ctx.body = {
@@ -149,14 +155,19 @@ router.post(
"stars.delete",
auth(),
validate(T.StarsDeleteSchema),
transaction(),
async (ctx: APIContext<T.StarsDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const star = await Star.findByPk(id);
const { transaction } = ctx.state;
const star = await Star.findByPk(id, {
transaction,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "delete", star);
await starDestroyer({ user, star, ip: ctx.request.ip });
await starDestroyer({ user, star, ip: ctx.request.ip, transaction });
ctx.body = {
success: true,
+34
View File
@@ -1,5 +1,6 @@
import escape from "escape-html";
import { Context, Next } from "koa";
import env from "@server/env";
/**
* Resize observer script that sends a message to the parent window when content is resized. Inject
@@ -117,5 +118,38 @@ ${resizeObserverScript(ctx)}
return;
}
if (
parsed.host === "www.dropbox.com" &&
parsed.protocol === "https:" &&
ctx.path === "/embeds/dropbox"
) {
const dropboxJs = "https://www.dropbox.com/static/api/2/dropins.js";
const csp = ctx.response.get("Content-Security-Policy");
// Inject Dropbox domain into the script-src directive
ctx.set(
"Content-Security-Policy",
csp.replace("script-src", "script-src www.dropbox.com")
);
ctx.set("X-Frame-Options", "sameorigin");
ctx.type = "html";
ctx.body = `
<html>
<head>
<style>body { margin: 0; }</style>
<base target="_parent">
${iframeCheckScript(ctx)}
</head>
<body>
<a href="${parsed}" class="dropbox-embed">
<script type="text/javascript" src="${dropboxJs}"
id="dropboxjs" data-app-key="${env.DROPBOX_APP_KEY}"></script>
${resizeObserverScript(ctx)}
</body>
`;
return;
}
return next();
};
+1
View File
@@ -131,6 +131,7 @@ router.get("/s/:shareId/*", shareDomains(), renderShare);
router.get("/embeds/gitlab", renderEmbed);
router.get("/embeds/github", renderEmbed);
router.get("/embeds/dropbox", renderEmbed);
// catch all for application
router.get("*", shareDomains(), async (ctx, next) => {
+15
View File
@@ -4,6 +4,7 @@ import isNull from "lodash/isNull";
import randomstring from "randomstring";
import { InferCreationAttributes } from "sequelize";
import { v4 as uuidv4 } from "uuid";
import { DataAttributeDataType } from "@shared/models/types";
import {
CollectionPermission,
FileOperationState,
@@ -19,6 +20,7 @@ import {
Team,
User,
Event,
DataAttribute,
Document,
Star,
Collection,
@@ -400,6 +402,19 @@ export async function buildDocument(
return document;
}
export async function buildDataAttribute({
userId,
...overrides
}: Partial<DataAttribute> & { userId: string }) {
const dataAttribute = await DataAttribute.create({
dataType: DataAttributeDataType.String,
createdById: userId,
name: faker.company.name(),
...overrides,
});
return dataAttribute;
}
export async function buildComment(overrides: {
userId: string;
documentId: string;
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import Frame from "../components/Frame";
import { EmbedProps as Props } from ".";
function Dropbox({ matches, ...props }: Props) {
// "fi" = file
// "fo" = folder
// Files need more vertical space to be readable
const embedHeight = matches[3].split("/")[0] === "fi" ? "550px" : "350px";
// Wrap inside an iframe to isolate external script and losened CSP
return (
<Frame
src={`/embeds/dropbox?url=${encodeURIComponent(props.attrs.href)}`}
className={props.isSelected ? "ProseMirror-selectednode" : ""}
width="100%"
height={embedHeight}
title="Dropbox"
/>
);
}
export default Dropbox;
+15
View File
@@ -1,12 +1,14 @@
import * as React from "react";
import styled from "styled-components";
import { Primitive } from "utility-types";
import env from "../../env";
import { IntegrationService, IntegrationType } from "../../types";
import type { IntegrationSettings } from "../../types";
import { urlRegex } from "../../utils/urls";
import Image from "../components/Img";
import Berrycast from "./Berrycast";
import Diagrams from "./Diagrams";
import Dropbox from "./Dropbox";
import Gist from "./Gist";
import GitLabSnippet from "./GitLabSnippet";
import InVision from "./InVision";
@@ -228,6 +230,19 @@ const embeds: EmbedDescriptor[] = [
`https://share.descript.com/embed/${matches[1]}`,
icon: <Img src="/images/descript.png" alt="Descript" />,
}),
...(env.DROPBOX_APP_KEY
? [
new EmbedDescriptor({
title: "Dropbox",
keywords: "file document",
regexMatch: [
new RegExp("^https?://(www.)?dropbox.com/(s|scl)/(.*)$"),
],
icon: <Img src="/images/dropbox.png" alt="Dropbox" />,
component: Dropbox,
}),
]
: []),
new EmbedDescriptor({
title: "Figma",
keywords: "design svg vector",
+9 -3
View File
@@ -69,7 +69,9 @@
"Create template": "Create template",
"Open random document": "Open random document",
"Search documents for \"{{searchQuery}}\"": "Search documents for \"{{searchQuery}}\"",
"Move to workspace": "Move to workspace",
"Move": "Move",
"Move to collection": "Move to collection",
"Move {{ documentType }}": "Move {{ documentType }}",
"Archive": "Archive",
"Document archived": "Document archived",
@@ -205,8 +207,6 @@
"{{ completed }} task done": "{{ completed }} task done",
"{{ completed }} task done_plural": "{{ completed }} tasks done",
"{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks",
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.",
"Currently editing": "Currently editing",
"Currently viewing": "Currently viewing",
"Viewed {{ timeAgo }}": "Viewed {{ timeAgo }}",
@@ -355,6 +355,10 @@
"No results": "No results",
"Previous page": "Previous page",
"Next page": "Next page",
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
"Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.",
"Enable other members to use the template immediately": "Enable other members to use the template immediately",
"Location": "Location",
"Admins can manage the workspace and access billing.": "Admins can manage the workspace and access billing.",
"Editors can create, edit, and comment on documents.": "Editors can create, edit, and comment on documents.",
"Viewers can only view and comment on documents.": "Viewers can only view and comment on documents.",
@@ -479,7 +483,7 @@
"Manual sort": "Manual sort",
"Comment options": "Comment options",
"Edit attribute": "Edit attribute",
"Document restored": "Document restored",
"{{ documentName }} restored": "{{ documentName }} restored",
"Document options": "Document options",
"Restore": "Restore",
"Choose a collection": "Choose a collection",
@@ -491,6 +495,7 @@
"Member options": "Member options",
"New document in <em>{{ collectionName }}</em>": "New document in <em>{{ collectionName }}</em>",
"New child document": "New child document",
"Save in workspace": "Save in workspace",
"Notification settings": "Notification settings",
"Revision options": "Revision options",
"Share link revoked": "Share link revoked",
@@ -565,6 +570,7 @@
"No resolved comments": "No resolved comments",
"No comments yet": "No comments yet",
"Error updating comment": "Error updating comment",
"Document restored": "Document restored",
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
"{{ count }} comment": "{{ count }} comment",
"{{ count }} comment_plural": "{{ count }} comments",
+384 -369
View File
File diff suppressed because it is too large Load Diff