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
96 changed files with 3710 additions and 822 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)
@@ -0,0 +1,25 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { DataAttributeNew } from "~/components/DataAttribute/DataAttributeNew";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createDataAttribute = createAction({
name: ({ t }) => t("New attribute"),
analyticsName: "New attribute",
section: SettingsSection,
icon: <PlusIcon />,
keywords: "create",
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createDataAttribute,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("New attribute"),
content: <DataAttributeNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+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,
+1 -1
View File
@@ -22,7 +22,7 @@ type Props = {
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactElement;
icon?: React.ReactElement | null;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
@@ -0,0 +1,34 @@
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import DataAttribute from "~/models/DataAttribute";
import { DataAttributeForm, FormData } from "./DataAttributeForm";
type Props = {
dataAttribute: DataAttribute;
onSubmit: () => void;
};
export const DataAttributeEdit = observer(function DataAttributeEdit_({
dataAttribute,
onSubmit,
}: Props) {
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await dataAttribute.save(data);
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
},
[dataAttribute, onSubmit]
);
return (
<DataAttributeForm
dataAttribute={dataAttribute}
handleSubmit={handleSubmit}
/>
);
});
@@ -0,0 +1,212 @@
import { observer } from "mobx-react";
import { CloseIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import {
DataAttributeDataType,
type DataAttributeOptions,
} from "@shared/models/types";
import { DataAttributeValidation } from "@shared/validations";
import type DataAttribute from "~/models/DataAttribute";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import { DataAttributesHelper } from "~/utils/DataAttributesHelper";
import InputSelect from "../InputSelect";
import NudeButton from "../NudeButton";
type Props = {
handleSubmit: (data: FormData) => void;
dataAttribute?: DataAttribute;
};
export interface FormData {
name: string;
description?: string;
dataType: DataAttributeDataType;
options?: DataAttributeOptions;
}
export const DataAttributeForm = observer(function DataAttributeForm_({
handleSubmit,
dataAttribute,
}: Props) {
const theme = useTheme();
const { t } = useTranslation();
const {
register,
handleSubmit: formHandleSubmit,
formState,
watch,
control,
setFocus,
setValue,
} = useForm<FormData>({
mode: "all",
defaultValues: {
name: dataAttribute?.name,
description: dataAttribute?.description ?? undefined,
dataType: dataAttribute?.dataType ?? DataAttributeDataType.String,
options: dataAttribute?.options ?? undefined,
},
});
const values = watch();
const isEditing = !!dataAttribute;
React.useEffect(() => {
if (isEditing) {
return;
}
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [isEditing, setFocus]);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<div>
<Controller
control={control}
name="dataType"
render={({ field }) => (
<InputSelect
ref={field.ref}
value={field.value}
disabled={isEditing}
onChange={(value: DataAttributeDataType) => {
field.onChange(value);
if (value === DataAttributeDataType.List) {
setValue("options", {
options: [
{
value: "",
},
{
value: "",
},
],
});
}
}}
ariaLabel={t("Format")}
label={t("Format")}
options={Object.values(DataAttributeDataType).map((dataType) => ({
value: dataType,
label: DataAttributesHelper.getName(dataType, t),
}))}
style={{ width: "auto" }}
/>
)}
/>
</div>
{values.dataType === DataAttributeDataType.List && (
<Options gap={8} column>
{values.options?.options?.map((option, index) => (
<Flex gap={4} align="center" key={index}>
<Input
value={option.value}
onChange={(event) => {
const newOptions = [...(values.options?.options ?? [])];
newOptions[index] = { value: event.target.value };
setValue("options", { options: newOptions });
}}
type="text"
autoComplete="off"
autoFocus={index !== 1}
minLength={DataAttributeValidation.minOptionLength}
maxLength={DataAttributeValidation.maxOptionLength}
margin={0}
required
flex
/>
<NudeButton
disabled={
(values.options?.options?.length ?? 0) <=
DataAttributeValidation.minOptions
}
onClick={() => {
const newOptions = [...(values.options?.options ?? [])];
newOptions.splice(index, 1);
setValue("options", { options: newOptions });
}}
>
<CloseIcon color={theme.textSecondary} />
</NudeButton>
</Flex>
))}
<div>
<Controller
control={control}
name="options"
render={({ field }) => (
<Button
neutral
borderOnHover
icon={<PlusIcon size={20} />}
disabled={
(values.options?.options?.length ?? 0) >=
DataAttributeValidation.maxOptions
}
onClick={() => {
field.onChange({
options: [
...(field.value?.options ?? []),
{
value: "",
},
],
});
}}
>
{t("Add option")}
</Button>
)}
/>
</div>
</Options>
)}
<Input
type="text"
label={t("Name")}
{...register("name", {
required: true,
minLength: DataAttributeValidation.minNameLength,
maxLength: DataAttributeValidation.maxNameLength,
})}
autoComplete="off"
autoFocus
flex
/>
<Input
type="text"
label={t("Description")}
placeholder={t("Optional")}
{...register("description", {
maxLength: DataAttributeValidation.maxDescriptionLength,
})}
autoComplete="off"
flex
/>
<Flex justify="flex-end">
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{dataAttribute
? formState.isSubmitting
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
);
});
const Options = styled(Flex)`
margin-left: 16px;
margin-bottom: 16px;
`;
@@ -0,0 +1,30 @@
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import DataAttribute from "~/models/DataAttribute";
import useStores from "~/hooks/useStores";
import { DataAttributeForm, FormData } from "./DataAttributeForm";
type Props = {
onSubmit: () => void;
};
export const DataAttributeNew = observer(function DataAttributeNew_({
onSubmit,
}: Props) {
const { dataAttributes } = useStores();
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const dataAttribute = new DataAttribute(data, dataAttributes);
await dataAttribute.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
},
[dataAttributes, onSubmit]
);
return <DataAttributeForm handleSubmit={handleSubmit} />;
});
+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);
+12 -4
View File
@@ -143,8 +143,12 @@ export interface Props
onRequestSubmit?: (
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
onFocus?: (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onBlur?: (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
}
function Input(
@@ -154,7 +158,9 @@ function Input(
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
const [focused, setFocused] = React.useState(false);
const handleBlur = (ev: React.SyntheticEvent) => {
const handleBlur = (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFocused(false);
if (props.onBlur) {
@@ -162,7 +168,9 @@ function Input(
}
};
const handleFocus = (ev: React.SyntheticEvent) => {
const handleFocus = (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFocused(true);
if (props.onFocus) {
+8 -1
View File
@@ -55,6 +55,8 @@ export type Props = {
* The Modal will take care of preventing body scroll behaviour.
*/
skipBodyScroll?: boolean;
autoFocus?: boolean;
placeholder?: string;
};
export interface InputSelectRef {
@@ -85,6 +87,8 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
icon,
nude,
skipBodyScroll,
autoFocus,
placeholder,
...rest
} = props;
@@ -214,6 +218,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
neutral
disclosure
className={className}
autoFocus={autoFocus}
icon={icon}
$nude={nude}
{...buttonProps}
@@ -221,7 +226,9 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
{option ? (
labelForOption(option)
) : (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
<Placeholder>
{placeholder ?? `Select a ${ariaLabel.toLowerCase()}`}
</Placeholder>
)}
</StyledButton>
)}
+1 -1
View File
@@ -271,7 +271,7 @@ const Small = styled.div`
outline: none;
${NudeButton} {
&:hover,
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
@@ -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);
+23
View File
@@ -10,6 +10,7 @@ import { FileOperationState, FileOperationType } from "@shared/types";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
import DataAttribute from "~/models/DataAttribute";
import Document from "~/models/Document";
import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
@@ -82,6 +83,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.authenticated = false;
const {
auth,
dataAttributes,
documents,
collections,
groups,
@@ -290,6 +292,27 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on(
"dataAttributes.create",
(event: PartialWithId<DataAttribute>) => {
dataAttributes.add(event);
}
);
this.socket.on(
"dataAttributes.update",
(event: PartialWithId<DataAttribute>) => {
dataAttributes.add(event);
}
);
this.socket.on(
"dataAttributes.delete",
(event: WebsocketEntityDeletedEvent) => {
dataAttributes.remove(event.modelId);
}
);
this.socket.on("comments.create", (event: PartialWithId<Comment>) => {
comments.add(event);
});
+11 -1
View File
@@ -14,6 +14,7 @@ import {
ImportIcon,
ShapesIcon,
Icon,
DatabaseIcon,
} from "outline-icons";
import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
@@ -29,6 +30,7 @@ import useCurrentUser from "./useCurrentUser";
import usePolicy from "./usePolicy";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const DataAttributes = lazy(() => import("~/scenes/Settings/DataAttributes"));
const Details = lazy(() => import("~/scenes/Settings/Details"));
const Export = lazy(() => import("~/scenes/Settings/Export"));
const Features = lazy(() => import("~/scenes/Settings/Features"));
@@ -119,6 +121,14 @@ const useSettingsConfig = () => {
group: t("Workspace"),
icon: BeakerIcon,
},
{
name: t("Data Attributes"),
path: settingsPath("attributes"),
component: DataAttributes,
enabled: can.createDataAttribute,
group: t("Workspace"),
icon: DatabaseIcon,
},
{
name: t("Members"),
path: settingsPath("members"),
@@ -139,7 +149,7 @@ const useSettingsConfig = () => {
name: t("Templates"),
path: settingsPath("templates"),
component: Templates,
enabled: can.update,
enabled: can.readTemplate,
group: t("Workspace"),
icon: ShapesIcon,
},
+67
View File
@@ -0,0 +1,67 @@
import copy from "copy-to-clipboard";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import { toast } from "sonner";
import DataAttribute from "~/models/DataAttribute";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Separator from "~/components/ContextMenu/Separator";
import { DataAttributeEdit } from "~/components/DataAttribute/DataAttributeEdit";
import useStores from "~/hooks/useStores";
type Props = {
/** The DataAttribute to associate with the menu */
dataAttribute: DataAttribute;
};
function DataAttributeMenu({ dataAttribute }: Props) {
const menu = useMenuState({
modal: true,
});
const { dialogs } = useStores();
const { t } = useTranslation();
const handleEdit = React.useCallback(() => {
dialogs.openModal({
title: t("Edit attribute"),
content: (
<DataAttributeEdit
dataAttribute={dataAttribute}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [t, dialogs, dataAttribute]);
const handleCopy = React.useCallback(() => {
copy(dataAttribute.id);
toast.success("Copied to clipboard");
}, [dataAttribute]);
const handleDelete = React.useCallback(() => {
void dataAttribute.delete();
}, [dataAttribute]);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu}>
<MenuItem {...menu} onClick={handleEdit}>
{t("Edit")}
</MenuItem>
<MenuItem {...menu} onClick={handleCopy}>
{t("Copy ID")}
</MenuItem>
<Separator />
<MenuItem {...menu} onClick={handleDelete} dangerous>
{t("Delete")}
</MenuItem>
</ContextMenu>
</>
);
}
export default observer(DataAttributeMenu);
+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>
</>
);
+52
View File
@@ -0,0 +1,52 @@
import { observable } from "mobx";
import {
DataAttributeDataType,
type DataAttributeOptions,
} from "@shared/models/types";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
class DataAttribute extends ParanoidModel {
static modelName = "DataAttribute";
/** The name of this data attribute. */
@Field
@observable
name: string;
/** A user-facing description for the data attribute. */
@Field
@observable
description: string | null;
/** The data type of this data attribute. Cannot be changed after creation. */
@Field
@observable
dataType: DataAttributeDataType;
/** Options for `list` data type. */
@Field
@observable
options: DataAttributeOptions | null;
/** Whether this data attribute is pinned to the top of the document. */
@Field
@observable
pinned: boolean;
/** The sort index of this data attribute. */
@Field
@observable
index: number;
/** The user that created this data attribute. */
@Relation(() => User, { onDelete: "cascade" })
createdBy?: User;
/** The user ID that created this data attribute. */
createdById: string;
}
export default DataAttribute;
+67 -3
View File
@@ -4,8 +4,13 @@ import capitalize from "lodash/capitalize";
import floor from "lodash/floor";
import { action, autorun, computed, observable, set } from "mobx";
import { Node, Schema } from "prosemirror-model";
import { Primitive } from "utility-types";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { richExtensions, withComments } from "@shared/editor/nodes";
import {
DataAttributeDataType,
DocumentDataAttribute,
} from "@shared/models/types";
import type {
JSONObject,
NavigationNode,
@@ -62,6 +67,10 @@ export default class Document extends ParanoidModel {
@observable
lastViewedAt: string | undefined;
@Field
@observable
dataAttributes: DocumentDataAttribute[];
store: DocumentsStore;
@Field
@@ -381,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");
}
@@ -484,13 +498,61 @@ export default class Document extends ParanoidModel {
});
};
@action
setDataAttribute = (dataAttributeId: string, input: Primitive) => {
const definition = this.store.rootStore.dataAttributes.get(dataAttributeId);
if (!definition) {
throw new Error(`Data attribute ${dataAttributeId} not found`);
}
let value: Primitive = input;
switch (definition.dataType) {
case DataAttributeDataType.Number:
value = Number(input);
break;
case DataAttributeDataType.Boolean:
value = input === true || input === "true";
break;
case DataAttributeDataType.List:
if (
!(definition.options?.options ?? [])
.map((i) => i.value)
.includes(input as string)
) {
throw new Error(
`Invalid value for data attribute ${dataAttributeId}`
);
}
break;
}
this.dataAttributes = (this.dataAttributes ?? [])
.filter((attr) => attr.dataAttributeId !== dataAttributeId)
.concat({ dataAttributeId, value, updatedAt: new Date().toISOString() });
return this;
};
@action
deleteDataAttribute = (dataAttributeId: string) => {
this.dataAttributes = this.dataAttributes?.filter(
(attr) => attr.dataAttributeId !== dataAttributeId
);
return this;
};
@action
updateLastViewed = (view: View) => {
this.lastViewedAt = view.lastViewedAt;
};
@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 (
@@ -517,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];
+18 -3
View File
@@ -53,8 +53,16 @@ type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
};
function DataLoader({ match, children }: Props) {
const { ui, views, shares, comments, documents, revisions, subscriptions } =
useStores();
const {
ui,
views,
shares,
comments,
documents,
revisions,
subscriptions,
dataAttributes,
} = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const [error, setError] = React.useState<Error | null>(null);
@@ -80,8 +88,15 @@ 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 && canTeam.listDataAttribute) {
void dataAttributes.fetchAll();
}
}, [dataAttributes, canTeam]);
React.useEffect(() => {
async function fetchDocument() {
try {
@@ -177,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;
}
+113 -39
View File
@@ -1,19 +1,24 @@
import { LocationDescriptor } from "history";
import { observer, useObserver } from "mobx-react";
import { CommentIcon } from "outline-icons";
import { CommentIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, useRouteMatch } from "react-router-dom";
import { MenuButton, useMenuState } from "reakit";
import styled from "styled-components";
import { TeamPreference } from "@shared/types";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import DocumentMeta from "~/components/DocumentMeta";
import Fade from "~/components/Fade";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { DataAttributesHelper } from "~/utils/DataAttributesHelper";
import { documentPath, documentInsightsPath } from "~/utils/routeHelpers";
import { Properties, PropertiesRef } from "./Properties";
type Props = {
/* The document to display meta data for */
@@ -24,7 +29,7 @@ type Props = {
};
function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const { views, comments, ui } = useStores();
const { views, comments, dataAttributes, ui } = useStores();
const { t } = useTranslation();
const match = useRouteMatch();
const team = useCurrentTeam();
@@ -33,53 +38,122 @@ 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;
const insightsPath = documentInsightsPath(document);
const commentsCount = comments.unresolvedCommentsInDocumentCount(document.id);
const dataAttributesAvailable =
canTeam.listDataAttribute && dataAttributes.orderedData.length > 0;
const missingDataAttributes =
!document.dataAttributes ||
document.dataAttributes?.length < dataAttributes.orderedData.length;
return (
<Meta document={document} revision={revision} to={to} replace {...rest}>
{team.getPreference(TeamPreference.Commenting) && can.comment && (
<>
&nbsp;&nbsp;
<CommentLink
to={documentPath(document)}
onClick={() => ui.toggleComments(document.id)}
>
<CommentIcon size={18} />
{commentsCount
? t("{{ count }} comment", { count: commentsCount })
: t("Comment")}
</CommentLink>
</>
)}
{totalViewers &&
can.listViews &&
!document.isDraft &&
!document.isTemplate ? (
<Wrapper>
&nbsp;&nbsp;
<Link
to={
match.url === insightsPath ? documentPath(document) : insightsPath
}
>
{t("Viewed by")}{" "}
{onlyYou
? t("only you")
: `${totalViewers} ${
totalViewers === 1 ? t("person") : t("people")
}`}
</Link>
</Wrapper>
) : null}
</Meta>
<>
<Meta document={document} revision={revision} to={to} replace {...rest}>
{team.getPreference(TeamPreference.Commenting) && can.comment && (
<>
&nbsp;&nbsp;
<InlineLink
to={documentPath(document)}
onClick={() => ui.toggleComments(document.id)}
>
<CommentIcon size={18} />
{commentsCount
? t("{{ count }} comment", { count: commentsCount })
: t("Comment")}
</InlineLink>
</>
)}
{dataAttributesAvailable && missingDataAttributes && can.update ? (
<>
&nbsp;&nbsp;
<AddPropertyMenu
document={document}
propertiesRef={propertiesRef}
/>
</>
) : null}
{totalViewers &&
can.listViews &&
!document.isDraft &&
!document.isTemplate ? (
<Wrapper>
&nbsp;&nbsp;
<Link
to={
match.url === insightsPath
? documentPath(document)
: insightsPath
}
>
{t("Viewed by")}{" "}
{onlyYou
? t("only you")
: `${totalViewers} ${
totalViewers === 1 ? t("person") : t("people")
}`}
</Link>
</Wrapper>
) : null}
</Meta>
<Properties document={document} ref={propertiesRef} />
</>
);
}
const CommentLink = styled(Link)`
const AddPropertyMenu = ({
propertiesRef,
document,
}: {
document: Document;
propertiesRef: React.RefObject<PropertiesRef>;
}) => {
const { dataAttributes } = useStores();
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
});
return (
<>
<MenuButton {...menu}>
{(props) => (
<InlineLink to={documentPath(document)} {...props}>
<PlusIcon size={18} /> {t("Property")}
</InlineLink>
)}
</MenuButton>
<ContextMenu {...menu}>
{dataAttributes.orderedData
.filter(
(a) =>
!a.deletedAt &&
!document.dataAttributes?.find((d) => d.dataAttributeId === a.id)
)
.map((attribute) => (
<MenuItem
key={attribute.id}
icon={DataAttributesHelper.getIcon(
attribute.dataType,
attribute.name
)}
{...menu}
onClick={() => propertiesRef.current?.addProperty(attribute.id)}
>
{attribute.name}
</MenuItem>
))}
</ContextMenu>
</>
);
};
const InlineLink = styled(Link)`
display: inline-flex;
align-items: center;
`;
+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>
)}
@@ -0,0 +1,379 @@
import { isEmail, isPhoneNumber } from "class-validator";
import { observer } from "mobx-react";
import { CloseIcon, EditIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { Primitive } from "utility-types";
import Flex from "@shared/components/Flex";
import {
DataAttributeDataType,
DocumentDataAttribute,
} from "@shared/models/types";
import { s } from "@shared/styles";
import { isUrl } from "@shared/utils/urls";
import Document from "~/models/Document";
import { Inner } from "~/components/Button";
import Input from "~/components/Input";
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";
const PropertyHeight = 30;
type Props = {
document: Document;
};
export type PropertiesRef = {
addProperty: (dataAttributeId: string) => void;
};
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);
const handleAddProperty = (dataAttributeId: string) => {
setDraftAttribute((state) =>
state
? null
: {
value: "",
dataAttributeId,
updatedAt: new Date().toISOString(),
}
);
};
React.useImperativeHandle(ref, () => ({
addProperty: handleAddProperty,
}));
const saveOrToast = async () => {
try {
await document.save();
} catch (error) {
toast.error(error.message);
}
};
const handleSave = async (dataAttribute: DocumentDataAttribute) => {
if (dataAttribute) {
document.setDataAttribute(
dataAttribute.dataAttributeId,
dataAttribute.value
);
await saveOrToast();
}
};
if (!canTeam.listDataAttribute) {
return null;
}
if ((document.dataAttributes ?? []).length === 0 && !draftAttribute) {
return null;
}
return (
<List>
{dataAttributes.orderedData.map((definition) => {
const dataAttribute = document.dataAttributes?.find(
(da) => da.dataAttributeId === definition.id
);
if (!dataAttribute) {
return null;
}
return (
<Property
key={dataAttribute.dataAttributeId}
dataAttribute={dataAttribute}
canUpdate={can.update}
onChange={(value) => {
document.setDataAttribute(dataAttribute.dataAttributeId, value);
}}
onSubmit={() => {
void saveOrToast();
}}
onRemove={() => {
document.deleteDataAttribute(dataAttribute.dataAttributeId);
void saveOrToast();
}}
/>
);
})}
{draftAttribute && (
<Property
key={draftAttribute.dataAttributeId}
dataAttribute={draftAttribute}
canUpdate={can.update}
onChange={(value) => {
setDraftAttribute({
...draftAttribute,
value,
});
}}
onRemove={() => setDraftAttribute(null)}
onSubmit={(value) => {
setDraftAttribute(null);
void handleSave({
...draftAttribute,
value,
});
}}
isEditing
/>
)}
</List>
);
})
);
const Property = observer(function Property_({
dataAttribute,
canUpdate,
onChange,
onSubmit,
onRemove,
...props
}: {
dataAttribute: DocumentDataAttribute;
canUpdate: boolean;
isEditing?: boolean;
onChange: (value: Primitive) => void;
onSubmit: (value: Primitive) => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const { dataAttributes } = useStores();
const [isEditing, setIsEditing] = React.useState(props.isEditing ?? false);
const definition = dataAttributes.get(dataAttribute.dataAttributeId);
const value = String(dataAttribute.value);
if (!definition) {
return null;
}
const displayedValue = isUrl(value) ? (
<a href={value} target="_blank" rel="noopener noreferrer">
{value.replace(/^https?:\/\//, "")}
</a>
) : isEmail(value) ? (
<a href={`mailto:${value}`} target="_blank" rel="noopener noreferrer">
{value}
</a>
) : isPhoneNumber(value) ? (
<a href={`tel:${value}`} target="_blank" rel="noopener noreferrer">
{value}
</a>
) : dataAttribute.value === true ? (
t("Yes")
) : dataAttribute.value === false ? (
t("No")
) : (
value
);
const handleSubmit = (newValue: Primitive) => {
setIsEditing(false);
onSubmit(newValue);
};
const inputId = `data-attribute-${dataAttribute.dataAttributeId}`;
const displayedInput = isEditing ? (
<>
{definition?.dataType === DataAttributeDataType.List ? (
<StyledInputSelect
id={inputId}
placeholder={t("Select an option")}
ariaLabel={definition.name}
options={
definition.options?.options?.map((option) => ({
label: option.value,
value: option.value,
})) ?? []
}
value={String(dataAttribute.value) ?? ""}
onChange={(val) => {
onChange(val);
handleSubmit(val);
}}
/>
) : definition?.dataType === DataAttributeDataType.Boolean ? (
<StyledInputSelect
id={inputId}
ariaLabel={definition.name}
placeholder={t("Select an option")}
options={[
{ label: t("Yes"), value: "true" },
{ label: t("No"), value: "false" },
]}
value={
dataAttribute.value === true
? "true"
: dataAttribute.value === false
? "false"
: ""
}
onChange={(val) => {
onChange(val);
handleSubmit(val);
}}
/>
) : (
<StyledInput
id={inputId}
labelHidden
margin={0}
onBlur={(event) => handleSubmit(event.currentTarget.value)}
onRequestSubmit={(event) => handleSubmit(event.currentTarget.value)}
placeholder={definition.description ?? ""}
value={String(dataAttribute.value) ?? ""}
pattern={
definition
? DataAttributesHelper.getValidationRegex(definition)?.source
: undefined
}
required
onChange={(event) => onChange(event.currentTarget.value)}
autoFocus
/>
)}
</>
) : null;
return (
<React.Fragment key={dataAttribute.dataAttributeId}>
<Dt type="tertiary" weight="bold" as="dt">
<Label htmlFor={inputId}>
{definition
? DataAttributesHelper.getIcon(
definition.dataType,
definition.name,
{
size: 18,
}
)
: null}
{definition?.name}
</Label>
</Dt>
<Dd type="tertiary" as="dd">
{displayedInput ?? displayedValue}
{canUpdate && (
<Actions align="center">
{!isEditing && (
<Tooltip content={t("Edit")} delay={500}>
<HoverButton onClick={() => setIsEditing(true)}>
<EditIcon size={18} />
</HoverButton>
</Tooltip>
)}
<Tooltip content={t("Remove")} delay={500}>
<HoverButton onClick={onRemove} $isEditing={isEditing}>
<CloseIcon size={18} />
</HoverButton>
</Tooltip>
</Actions>
)}
</Dd>
</React.Fragment>
);
});
const Actions = styled(Flex)`
display: flex;
margin-left: 8px;
`;
const Label = styled.label`
white-space: nowrap;
`;
const HoverButton = styled(NudeButton)<{ $isEditing?: boolean }>`
opacity: ${(props) => (props.$isEditing ? 1 : 0)};
margin-left: -4px;
&:hover,
&:focus {
color: ${s("text")};
}
`;
const StyledInputSelect = styled(InputSelect)`
padding: 0;
margin: -4px;
margin-right: 8px;
height: ${PropertyHeight}px;
${Inner} {
line-height: 14px;
min-height: auto;
}
`;
const StyledInput = styled(Input)`
padding: 0;
margin: -4px;
margin-right: 4px;
input {
padding: 4px 8px;
height: 24px;
}
`;
const List = styled.dl`
display: flex;
flex-flow: row wrap;
margin-top: -1.4em;
margin-bottom: 1.6em;
font-size: 14px;
position: relative;
`;
const Dd = styled(Text)`
flex-basis: 70%;
display: flex;
align-items: center;
margin: 0;
height: ${PropertyHeight}px;
gap: 4px;
&:hover {
${HoverButton} {
opacity: 1;
}
}
`;
const Dt = styled(Text)`
float: left;
clear: left;
flex-basis: 30%;
margin: 0;
height: ${PropertyHeight}px;
svg {
position: relative;
top: 4px;
margin-right: 4px;
}
&:hover + ${Dd} {
${HoverButton} {
opacity: 1;
}
}
`;
+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) {
+67
View File
@@ -0,0 +1,67 @@
import { observer } from "mobx-react";
import { DatabaseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import DataAttribute from "~/models/DataAttribute";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { createDataAttribute } from "~/actions/definitions/dataAttributes";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { DataAttributeListItem } from "./components/DataAttributeListItem";
function DataAttributes() {
const team = useCurrentTeam();
const { t } = useTranslation();
const { dataAttributes } = useStores();
const can = usePolicy(team);
const context = useActionContext();
return (
<Scene
title={t("Data Attributes")}
icon={<DatabaseIcon />}
actions={
<>
{can.createDataAttribute && (
<Action>
<Button
type="submit"
value={`${t("New Attribute")}`}
action={createDataAttribute}
context={context}
/>
</Action>
)}
</>
}
>
<Heading>{t("Data Attributes")}</Heading>
<Text as="p" type="secondary">
<Trans>
Attributes allow you to define data to be stored with your documents.
They can be used to store custom properties, metadata, or any other
structured information that is common across documents.
</Trans>
</Text>
<PaginatedList
fetch={dataAttributes.fetchAll}
items={dataAttributes.orderedData}
renderItem={(dataAttribute: DataAttribute) => (
<DataAttributeListItem
key={dataAttribute.id}
dataAttribute={dataAttribute}
/>
)}
/>
</Scene>
);
}
export default observer(DataAttributes);
@@ -0,0 +1,29 @@
import { observer } from "mobx-react";
import * as React from "react";
import DataAttribute from "~/models/DataAttribute";
import ListItem from "~/components/List/Item";
import DataAttributeMenu from "~/menus/DataAttributeMenu";
import { DataAttributesHelper } from "~/utils/DataAttributesHelper";
type Props = {
dataAttribute: DataAttribute;
};
export const DataAttributeListItem = observer(function DataAttributeListItem_({
dataAttribute,
}: Props) {
const image = DataAttributesHelper.getIcon(
dataAttribute.dataType,
dataAttribute.name
);
return (
<ListItem
key={dataAttribute.id}
title={dataAttribute.name}
subtitle={dataAttribute.description || dataAttribute.dataType}
image={image}
actions={<DataAttributeMenu dataAttribute={dataAttribute} />}
/>
);
});
+30
View File
@@ -0,0 +1,30 @@
import orderBy from "lodash/orderBy";
import { computed } from "mobx";
import DataAttribute from "~/models/DataAttribute";
import RootStore from "./RootStore";
import Store, { RPCAction } from "./base/Store";
export default class DataAttributesStore extends Store<DataAttribute> {
actions = [
RPCAction.List,
RPCAction.Create,
RPCAction.Update,
RPCAction.Delete,
];
constructor(rootStore: RootStore) {
super(rootStore, DataAttribute);
}
@computed
get active(): DataAttribute[] {
return this.orderedData.filter((d) => !d.deletedAt);
}
@computed
get deleted(): DataAttribute[] {
return orderBy(this.orderedData, "deletedAt", "desc").filter(
(d) => d.deletedAt
);
}
}
+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
@@ -6,6 +6,7 @@ import AuthStore from "./AuthStore";
import AuthenticationProvidersStore from "./AuthenticationProvidersStore";
import CollectionsStore from "./CollectionsStore";
import CommentsStore from "./CommentsStore";
import DataAttributesStore from "./DataAttributesStore";
import DialogsStore from "./DialogsStore";
import DocumentPresenceStore from "./DocumentPresenceStore";
import DocumentsStore from "./DocumentsStore";
@@ -39,6 +40,7 @@ export default class RootStore {
groupMemberships: GroupMembershipsStore;
comments: CommentsStore;
dialogs: DialogsStore;
dataAttributes: DataAttributesStore;
documents: DocumentsStore;
events: EventsStore;
groups: GroupsStore;
@@ -68,6 +70,7 @@ export default class RootStore {
this.registerStore(CollectionsStore);
this.registerStore(GroupMembershipsStore);
this.registerStore(CommentsStore);
this.registerStore(DataAttributesStore);
this.registerStore(DocumentsStore);
this.registerStore(EventsStore);
this.registerStore(GroupsStore);
+74
View File
@@ -0,0 +1,74 @@
import { TFunction } from "i18next";
import {
CaseSensitiveIcon,
DoneIcon,
HashtagIcon,
TableOfContentsIcon,
} from "outline-icons";
import * as React from "react";
import { DataAttributeDataType } from "@shared/models/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import DataAttribute from "~/models/DataAttribute";
export class DataAttributesHelper {
public static getName(dataType: DataAttributeDataType, t: TFunction) {
switch (dataType) {
case DataAttributeDataType.Boolean:
return t("Boolean");
case DataAttributeDataType.Number:
return t("Number");
case DataAttributeDataType.String:
return t("Text");
case DataAttributeDataType.List:
return t("List");
default:
return "";
}
}
/**
* Returns an appropriate icon for the data attribute based on it's type
*
* @param dataAttribute The data attribute to get the icon for
* @param props Additional props to pass to the icon
* @returns An icon or null
*/
public static getIcon(
dataType: DataAttributeDataType,
keyword?: string,
props?: React.ComponentProps<typeof DoneIcon>
) {
const match = keyword ? IconLibrary.findIconByKeyword(keyword) : undefined;
if (match) {
const IconComponent = IconLibrary.getComponent(match);
return <IconComponent {...props} />;
}
switch (dataType) {
case DataAttributeDataType.Boolean:
return <DoneIcon {...props} />;
case DataAttributeDataType.Number:
return <HashtagIcon {...props} />;
case DataAttributeDataType.String:
return <CaseSensitiveIcon {...props} />;
case DataAttributeDataType.List:
return <TableOfContentsIcon {...props} />;
default:
return null;
}
}
/**
* Returns the regex to validate the input of the data attribute
* @param dataAttribute
* @returns A regex or undefined
*/
public static getValidationRegex(dataAttribute: DataAttribute) {
switch (dataAttribute.dataType) {
case DataAttributeDataType.Number:
return /^-?\d+(\.\d+)?$/;
default:
return undefined;
}
}
}
+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;
@@ -225,6 +225,11 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
case "userMemberships.update":
// Ignored
return;
case "dataAttributes.create":
case "dataAttributes.update":
case "dataAttributes.delete":
// Ignored
return;
default:
assertUnreachable(event);
}
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;
+28 -1
View File
@@ -1,4 +1,6 @@
import uniqBy from "lodash/uniqBy";
import { Transaction } from "sequelize";
import { DocumentDataAttribute } from "@shared/models/types";
import { Event, Document, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
@@ -7,6 +9,8 @@ type Props = {
user: User;
/** The existing document */
document: Document;
/** Data attributes to apply to the document */
dataAttributes?: Omit<DocumentDataAttribute, "updatedAt">[] | null;
/** The new title */
title?: string;
/** The document icon */
@@ -47,6 +51,7 @@ type Props = {
export default async function documentUpdater({
user,
document,
dataAttributes,
title,
icon,
color,
@@ -65,6 +70,28 @@ export default async function documentUpdater({
const previousTitle = document.title;
const cId = collectionId || document.collectionId;
if (dataAttributes !== undefined) {
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;
}
return {
dataAttributeId,
value,
updatedAt: new Date().toISOString(),
} as DocumentDataAttribute;
}),
"dataAttributeId"
)
: null;
}
if (title !== undefined) {
document.title = title.trim();
}
@@ -106,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.
*/
@@ -0,0 +1,91 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.createTable(
"data_attributes",
{
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
createdById: {
type: Sequelize.UUID,
allowNull: false,
onDelete: "cascade",
references: {
model: "users",
},
},
teamId: {
type: Sequelize.UUID,
allowNull: false,
onDelete: "cascade",
references: {
model: "teams",
}
},
name: {
type: Sequelize.STRING,
allowNull: false,
},
description: {
type: Sequelize.STRING,
allowNull: true,
},
dataType: {
type: Sequelize.ENUM("string", "number", "boolean", "list"),
allowNull: false,
},
options: {
type: Sequelize.JSONB,
allowNull: true,
},
pinned: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
},
index: {
type: Sequelize.STRING,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
},
{ transaction }
);
await queryInterface.addIndex(
"data_attributes",
["teamId"],
{ transaction }
);
await queryInterface.addColumn("documents", "dataAttributes", {
type: Sequelize.JSONB,
allowNull: true,
});
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.dropTable("data_attributes", { transaction });
await queryInterface.removeColumn("documents", "dataAttributes", {
transaction,
});
});
}
};
+131
View File
@@ -0,0 +1,131 @@
import {
InferAttributes,
InferCreationAttributes,
type SaveOptions,
} from "sequelize";
import {
Column,
Table,
BelongsTo,
ForeignKey,
AllowNull,
IsIn,
DataType,
Default,
BeforeCreate,
} from "sequelize-typescript";
import { z } from "zod";
import {
DataAttributeDataType,
type DataAttributeOptions,
} from "@shared/models/types";
import { DataAttributeValidation } from "@shared/validations";
import Team from "./Team";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl";
@Table({
tableName: "data_attributes",
modelName: "data_attribute",
paranoid: true,
})
@Fix
class DataAttribute extends ParanoidModel<
InferAttributes<DataAttribute>,
Partial<InferCreationAttributes<DataAttribute>>
> {
/** The name of this data attribute. */
@Length({
min: DataAttributeValidation.minNameLength,
max: DataAttributeValidation.maxNameLength,
msg: `Name must be between ${DataAttributeValidation.minNameLength} and ${DataAttributeValidation.maxNameLength} characters`,
})
@NotContainsUrl
@Column
name: string;
/** A user-facing description for the data attribute. */
@AllowNull
@Column
description: string;
/** The data type of this data attribute. Cannot be changed after creation. */
@IsIn([Object.values(DataAttributeDataType)])
@Column(DataType.ENUM(...Object.values(DataAttributeDataType)))
dataType: DataAttributeDataType;
/** Additional options for some datatypes. */
@AllowNull
@Column(DataType.JSONB)
options: DataAttributeOptions | null;
/** Whether this data attribute is pinned to the top of the document. */
@Default(false)
@Column
pinned: boolean;
/** The sort index of this data attribute. */
@Column
index: string;
// hooks
@BeforeCreate
static async checkMaximumAttributes(
model: DataAttribute,
{ transaction }: SaveOptions<DataAttribute>
) {
const count = await this.count({
where: {
teamId: model.teamId,
},
transaction,
});
if (count >= DataAttributeValidation.max) {
throw new Error("Maximum number of attributes reached");
}
}
/**
* The Zod type for validating this data attribute.
*/
get zodType() {
switch (this.dataType) {
case DataAttributeDataType.String:
return z.string();
case DataAttributeDataType.Number:
return z.number();
case DataAttributeDataType.Boolean:
return z.boolean();
case DataAttributeDataType.List:
return z
.string()
.refine((value) =>
this.options?.options?.map((i) => i.value).includes(value)
);
default:
throw new Error(`Unknown data type: ${this.dataType}`);
}
}
// associations
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column
createdById: string;
@BelongsTo(() => Team)
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
}
export default DataAttribute;
+63 -2
View File
@@ -41,6 +41,8 @@ import {
BelongsToMany,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import { ZodError } from "zod";
import { DocumentDataAttribute } from "@shared/models/types";
import type {
NavigationNode,
ProsemirrorData,
@@ -54,6 +56,7 @@ import { ValidationError } from "@server/errors";
import { generateUrlId } from "@server/utils/url";
import Backlink from "./Backlink";
import Collection from "./Collection";
import DataAttribute from "./DataAttribute";
import FileOperation from "./FileOperation";
import Revision from "./Revision";
import Star from "./Star";
@@ -279,6 +282,10 @@ class Document extends ParanoidModel<
@Column
color: string | null;
/** Attributes associated with the document. */
@Column(DataType.JSONB)
dataAttributes: DocumentDataAttribute[] | null;
/**
* The content of the document as Markdown.
*
@@ -365,6 +372,53 @@ class Document extends ParanoidModel<
// hooks
@BeforeSave
static async validateDataAttributes(
model: Document,
{ transaction }: SaveOptions<Document>
) {
if (model.changed("dataAttributes") && model.dataAttributes) {
const dataAttributeIds = model.dataAttributes.map(
(d) => d.dataAttributeId
);
const definitions = await DataAttribute.findAll({
where: {
id: dataAttributeIds,
},
transaction,
paranoid: false,
});
for (const attr of model.dataAttributes) {
const definition = definitions.find(
(d) => d.id === attr.dataAttributeId
);
if (!definition) {
throw ValidationError(
`Data attribute ${attr.dataAttributeId} not found`
);
}
try {
definition.zodType.parse(attr.value);
} catch (err) {
if (err instanceof ZodError) {
const { path, message } = err.issues[0];
const errMessage =
path.length > 0
? `${path[path.length - 1]}: ${message}`
: message;
throw ValidationError(
`Data attribute ${attr.dataAttributeId} has invalid value: ${errMessage}`
);
}
throw err;
}
}
}
}
@BeforeSave
static async updateCollectionStructure(
model: Document,
@@ -722,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.
*
@@ -817,7 +878,7 @@ class Document extends ParanoidModel<
publish = async (
user: User,
collectionId: string,
collectionId: string | null | undefined,
options: SaveOptions
): Promise<this> => {
const { transaction } = options;
@@ -832,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(/&$/, "")
);
+2
View File
@@ -8,6 +8,8 @@ export { default as Backlink } from "./Backlink";
export { default as Collection } from "./Collection";
export { default as DataAttribute } from "./DataAttribute";
export { default as GroupMembership } from "./GroupMembership";
export { default as UserMembership } from "./UserMembership";
+28
View File
@@ -0,0 +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,
!env.isCloudHosted
)
);
allow(User, "listDataAttribute", Team, (actor, team) =>
and(isTeamModel(actor, team), isEnabled)
);
allow(User, "read", DataAttribute, (actor, team) =>
and(isTeamModel(actor, team), isEnabled)
);
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);
});
+3
View File
@@ -9,6 +9,7 @@ import {
Group,
Notification,
UserMembership,
DataAttribute,
} from "@server/models";
import { _abilities, _can, _cannot, _authorize } from "./cancan";
import "./apiKey";
@@ -16,6 +17,7 @@ import "./attachment";
import "./authenticationProvider";
import "./collection";
import "./comment";
import "./dataAttribute";
import "./document";
import "./fileOperation";
import "./integration";
@@ -54,6 +56,7 @@ export function serialize(
| Attachment
| Collection
| Comment
| DataAttribute
| FileOperation
| Team
| Document
+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)
)
);
+15
View File
@@ -0,0 +1,15 @@
import DataAttribute from "@server/models/DataAttribute";
export default function presentDataAttribute(dataAttribute: DataAttribute) {
return {
id: dataAttribute.id,
name: dataAttribute.name,
description: dataAttribute.description,
dataType: dataAttribute.dataType,
options: dataAttribute.options,
pinned: dataAttribute.pinned,
createdAt: dataAttribute.createdAt,
updatedAt: dataAttribute.updatedAt,
deletedAt: dataAttribute.deletedAt,
};
}
+1
View File
@@ -76,6 +76,7 @@ async function presentDocument(
if (!options.isPublic) {
const source = await document.$get("import");
data.dataAttributes = document.dataAttributes;
data.isCollectionDeleted = await document.isCollectionDeleted();
data.collectionId = document.collectionId;
data.parentDocumentId = document.parentDocumentId;
+2
View File
@@ -4,6 +4,7 @@ import presentAuthenticationProvider from "./authenticationProvider";
import presentAvailableTeam from "./availableTeam";
import presentCollection from "./collection";
import presentComment from "./comment";
import presentDataAttribute from "./dataAttribute";
import presentDocument from "./document";
import presentEvent from "./event";
import presentFileOperation from "./fileOperation";
@@ -32,6 +33,7 @@ export {
presentAvailableTeam,
presentCollection,
presentComment,
presentDataAttribute,
presentDocument,
presentEvent,
presentFileOperation,
@@ -16,10 +16,12 @@ import {
Notification,
UserMembership,
User,
DataAttribute,
} from "@server/models";
import {
presentComment,
presentCollection,
presentDataAttribute,
presentDocument,
presentFileOperation,
presentGroup,
@@ -371,6 +373,22 @@ export default class WebsocketsProcessor {
.emit(event.name, presentFileOperation(fileOperation));
}
case "dataAttributes.create":
case "dataAttributes.update": {
const dataAttribute = await DataAttribute.findByPk(event.modelId);
if (!dataAttribute) {
return;
}
return socketio
.to(`team-${dataAttribute.teamId}`)
.emit(event.name, presentDataAttribute(dataAttribute));
}
case "dataAttributes.delete": {
return socketio.to(`team-${event.teamId}`).emit(event.name, {
modelId: event.modelId,
});
}
case "pins.create":
case "pins.update": {
const pin = await Pin.findByPk(event.modelId);
+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 }
);
+3 -15
View File
@@ -1,10 +1,8 @@
import emojiRegex from "emoji-regex";
import isUndefined from "lodash/isUndefined";
import { z } from "zod";
import { CollectionPermission, FileOperationFormat } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { Collection } from "@server/models";
import { zodEnumFromObjectKeys } from "@server/utils/zod";
import { zodIconType } from "@server/utils/zod";
import { ValidateColor, ValidateIndex } from "@server/validation";
import { BaseSchema, ProsemirrorSchema } from "../schema";
@@ -27,12 +25,7 @@ export const CollectionsCreateSchema = BaseSchema.extend({
.nullish()
.transform((val) => (isUndefined(val) ? null : val)),
sharing: z.boolean().default(true),
icon: z
.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
])
.optional(),
icon: zodIconType().optional(),
sort: z
.object({
field: z.union([z.literal("title"), z.literal("index")]),
@@ -171,12 +164,7 @@ export const CollectionsUpdateSchema = BaseSchema.extend({
name: z.string().optional(),
description: z.string().nullish(),
data: ProsemirrorSchema.nullish(),
icon: z
.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
])
.nullish(),
icon: zodIconType().nullish(),
permission: z.nativeEnum(CollectionPermission).nullish(),
color: z
.string()
@@ -0,0 +1,175 @@
import Router from "koa-router";
import { UserRole } from "@shared/types";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { DataAttribute, Event } from "@server/models";
import { authorize } from "@server/policies";
import { presentDataAttribute, presentPolicies } from "@server/presenters";
import { APIContext } from "@server/types";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"dataAttributes.info",
auth(),
validate(T.DataAttributesInfoSchema),
async (ctx: APIContext<T.DataAttributesInfoReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const dataAttribute = await DataAttribute.findByPk(id, {
rejectOnEmpty: true,
});
authorize(user, "read", dataAttribute);
ctx.body = {
data: presentDataAttribute(dataAttribute),
};
}
);
router.post(
"dataAttributes.list",
auth(),
validate(T.DataAttributesListSchema),
pagination(),
async (ctx: APIContext<T.DataAttributesListReq>) => {
const { sort, direction } = ctx.input.body;
const { user } = ctx.state.auth;
const dataAttributes = await DataAttribute.findAll({
where: { teamId: user.teamId },
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = {
data: dataAttributes.map(presentDataAttribute),
};
}
);
router.post(
"dataAttributes.create",
auth({ role: UserRole.Admin }),
validate(T.DataAttributesCreateSchema),
transaction(),
async (ctx: APIContext<T.DataAttributesCreateReq>) => {
const { name, description, dataType, options, pinned } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const dataAttribute = await DataAttribute.create(
{
name,
description,
createdById: user.id,
teamId: user.teamId,
dataType,
options,
pinned,
},
{ transaction }
);
await Event.createFromContext(
ctx,
{
name: "dataAttributes.create",
modelId: dataAttribute.id,
data: {
name,
},
},
{ transaction }
);
ctx.body = {
data: presentDataAttribute(dataAttribute),
policies: presentPolicies(user, [dataAttribute]),
};
}
);
router.post(
"dataAttributes.update",
auth({ role: UserRole.Admin }),
validate(T.DataAttributesUpdateSchema),
transaction(),
async (ctx: APIContext<T.DataAttributesUpdateReq>) => {
const { id, ...input } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const dataAttribute = await DataAttribute.findByPk(id, {
rejectOnEmpty: true,
transaction,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "update", dataAttribute);
dataAttribute.set(input);
const changes = dataAttribute.changeset;
await dataAttribute.save({ transaction });
await Event.createFromContext(
ctx,
{
name: "dataAttributes.update",
modelId: dataAttribute.id,
changes,
},
{ transaction }
);
ctx.body = {
data: presentDataAttribute(dataAttribute),
policies: presentPolicies(user, [dataAttribute]),
};
}
);
router.post(
"dataAttributes.delete",
auth({ role: UserRole.Admin }),
validate(T.DataAttributesDeleteSchema),
transaction(),
async (ctx: APIContext<T.DataAttributesDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const dataAttribute = await DataAttribute.findByPk(id, {
lock: transaction.LOCK.UPDATE,
rejectOnEmpty: true,
transaction,
});
authorize(user, "delete", dataAttribute);
await dataAttribute.destroy({ transaction });
await Event.createFromContext(
ctx,
{
name: "dataAttributes.delete",
modelId: dataAttribute.id,
data: {
name: dataAttribute.name,
},
},
{ transaction }
);
ctx.body = {
success: true,
};
}
);
export default router;
@@ -0,0 +1 @@
export { default } from "./dataAttributes";
+118
View File
@@ -0,0 +1,118 @@
import z from "zod";
import { DataAttributeDataType } from "@shared/models/types";
import { zodIconType } from "@server/utils/zod";
import { BaseSchema } from "../schema";
const BaseIdSchema = z.object({
/** Id of the data attribute */
id: z.string().uuid(),
});
const DataAttributesSortParamsSchema = z.object({
/** Specifies the attributes by which data attributes will be sorted in the list */
sort: z
.string()
.refine((val) => ["createdAt", "updatedAt"].includes(val))
.default("createdAt"),
/** Specifies the sort order with respect to sort field */
direction: z
.string()
.optional()
.transform((val) => (val !== "ASC" ? "DESC" : val)),
});
export const DataAttributesInfoSchema = BaseSchema.extend({
body: BaseIdSchema,
});
export type DataAttributesInfoReq = z.infer<typeof DataAttributesInfoSchema>;
export const DataAttributesListSchema = BaseSchema.extend({
body: DataAttributesSortParamsSchema,
});
export type DataAttributesListReq = z.infer<typeof DataAttributesListSchema>;
export const DataAttributesCreateSchema = BaseSchema.extend({
body: z
.object({
/** Name of the data attribute */
name: z.string(),
/** Description of the data attribute */
description: z.string().optional(),
/** Type of the data attribute */
dataType: z.nativeEnum(DataAttributeDataType),
/** Additional options for the data attribute */
options: z
.object({
/** An icon representing the data attribute */
icon: zodIconType().optional(),
options: z.array(
z.object({
/** Label of the option */
value: z.string(),
/** Color of the option */
color: z.string().optional(),
})
),
})
.optional(),
/** Whether the data attribute is pinned to the top of document */
pinned: z.boolean().optional(),
})
.refine(
(val) =>
val.dataType !== DataAttributeDataType.List ||
(val.dataType === DataAttributeDataType.List && val.options)
),
});
export type DataAttributesCreateReq = z.infer<
typeof DataAttributesCreateSchema
>;
export const DataAttributesUpdateSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
/** Name of the data attribute */
name: z.string(),
/** Description of the data attribute */
description: z.string().optional(),
/** Additional options for the data attribute */
options: z
.object({
/** An icon representing the data attribute */
icon: zodIconType().nullish(),
options: z.array(
z.object({
/** Label of the option */
value: z.string(),
/** Color of the option */
color: z.string().optional(),
})
),
})
.optional(),
/** Whether the data attribute is pinned to the top of document */
pinned: z.boolean().optional(),
}),
});
export type DataAttributesUpdateReq = z.infer<
typeof DataAttributesUpdateSchema
>;
export const DataAttributesDeleteSchema = BaseSchema.extend({
body: BaseIdSchema,
});
export type DataAttributesDeleteReq = z.infer<
typeof DataAttributesDeleteSchema
>;
+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,
+37 -31
View File
@@ -4,10 +4,9 @@ import isEmpty from "lodash/isEmpty";
import isUUID from "validator/lib/isUUID";
import { z } from "zod";
import { DocumentPermission, StatusFilter } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { BaseSchema } from "@server/routes/api/schema";
import { zodEnumFromObjectKeys } from "@server/utils/zod";
import { zodIconType } from "@server/utils/zod";
import { ValidateColor } from "@server/validation";
const DocumentsSortParamsSchema = z.object({
@@ -196,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>;
@@ -213,12 +217,7 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
emoji: z.string().regex(emojiRegex()).nullish(),
/** Icon displayed alongside doc title */
icon: z
.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
])
.nullish(),
icon: zodIconType().nullish(),
/** Icon color */
color: z
@@ -249,6 +248,16 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
/** Whether the editing session is complete */
done: z.boolean().optional(),
/** Data attributes to be updated, attributes not included will be removed */
dataAttributes: z
.array(
z.object({
dataAttributeId: z.string(),
value: z.string().or(z.boolean()).or(z.number()),
})
)
.nullish(),
}),
}).refine((req) => !(req.body.append && !req.body.text), {
message: "text is required while appending",
@@ -259,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(),
@@ -325,12 +334,7 @@ export const DocumentsCreateSchema = BaseSchema.extend({
emoji: z.string().regex(emojiRegex()).nullish(),
/** Icon displayed alongside doc title */
icon: z
.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
])
.optional(),
icon: zodIconType().optional(),
/** Icon color */
color: z
@@ -350,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()
@@ -364,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: {
+2
View File
@@ -14,6 +14,7 @@ import authenticationProviders from "./authenticationProviders";
import collections from "./collections";
import comments from "./comments/comments";
import cron from "./cron";
import dataAttributes from "./dataAttributes";
import developer from "./developer";
import documents from "./documents";
import events from "./events";
@@ -70,6 +71,7 @@ router.use("/", events.routes());
router.use("/", users.routes());
router.use("/", collections.routes());
router.use("/", comments.routes());
router.use("/", dataAttributes.routes());
router.use("/", documents.routes());
router.use("/", pins.routes());
router.use("/", revisions.routes());
@@ -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;
+11
View File
@@ -36,6 +36,7 @@ import type {
Notification,
Share,
GroupMembership,
DataAttribute,
} from "./models";
export enum AuthenticationType {
@@ -259,6 +260,15 @@ export type CollectionGroupEvent = BaseEvent<GroupMembership> & {
data: { name: string };
};
export type DataAttributeEvent = BaseEvent<DataAttribute> & {
name:
| "dataAttributes.create"
| "dataAttributes.update"
| "dataAttributes.delete";
modelId: string;
data: { name: string };
};
export type DocumentUserEvent = BaseEvent<UserMembership> & {
name: "documents.add_user" | "documents.remove_user";
userId: string;
@@ -439,6 +449,7 @@ export type Event =
| ApiKeyEvent
| AttachmentEvent
| AuthenticationProviderEvent
| DataAttributeEvent
| DocumentEvent
| DocumentUserEvent
| DocumentGroupEvent
+8
View File
@@ -1,4 +1,6 @@
import emojiRegex from "emoji-regex";
import { z } from "zod";
import { IconLibrary } from "@shared/utils/IconLibrary";
export function zodEnumFromObjectKeys<
TI extends Record<string, any>,
@@ -7,3 +9,9 @@ export function zodEnumFromObjectKeys<
const [firstKey, ...otherKeys] = Object.keys(input) as [R, ...R[]];
return z.enum([firstKey, ...otherKeys]);
}
export const zodIconType = () =>
z.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
]);
+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",
+26 -3
View File
@@ -18,6 +18,7 @@
"Mark as resolved": "Mark as resolved",
"Thread resolved": "Thread resolved",
"Mark as unresolved": "Mark as unresolved",
"New attribute": "New attribute",
"Copy ID": "Copy ID",
"Clear IndexedDB cache": "Clear IndexedDB cache",
"IndexedDB cache cleared": "IndexedDB cache cleared",
@@ -68,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",
@@ -167,6 +170,10 @@
"Server connection lost": "Server connection lost",
"Edits you make will sync once youre online": "Edits you make will sync once youre online",
"Submenu": "Submenu",
"Format": "Format",
"Add option": "Add option",
"Description": "Description",
"Optional": "Optional",
"Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
"Default collection": "Default collection",
"Install now": "Install now",
@@ -200,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 }}",
@@ -350,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.",
@@ -454,6 +463,7 @@
"Details": "Details",
"Security": "Security",
"Features": "Features",
"Data Attributes": "Data Attributes",
"Members": "Members",
"Groups": "Groups",
"Shared Links": "Shared Links",
@@ -472,7 +482,8 @@
"Alphabetical sort": "Alphabetical sort",
"Manual sort": "Manual sort",
"Comment options": "Comment options",
"Document restored": "Document restored",
"Edit attribute": "Edit attribute",
"{{ documentName }} restored": "{{ documentName }} restored",
"Document options": "Document options",
"Restore": "Restore",
"Choose a collection": "Choose a collection",
@@ -484,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",
@@ -558,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",
@@ -565,6 +578,7 @@
"only you": "only you",
"person": "person",
"people": "people",
"Property": "Property",
"Type '/' to insert, or start writing…": "Type '/' to insert, or start writing…",
"Hide contents": "Hide contents",
"Show contents": "Show contents",
@@ -612,6 +626,9 @@
"Archived by {{userName}}": "Archived by {{userName}}",
"Deleted by {{userName}}": "Deleted by {{userName}}",
"Observing {{ userName }}": "Observing {{ userName }}",
"Yes": "Yes",
"No": "No",
"Select an option": "Select an option",
"Backlinks": "Backlinks",
"Close": "Close",
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
@@ -840,6 +857,8 @@
"Editors": "Editors",
"All status": "All status",
"Active": "Active",
"New Attribute": "New Attribute",
"Attributes allow you to define data to be stored with your documents. They can be used to store custom properties, metadata, or any other structured information that is common across documents.": "Attributes allow you to define data to be stored with your documents. They can be used to store custom properties, metadata, or any other structured information that is common across documents.",
"Settings saved": "Settings saved",
"Logo updated": "Logo updated",
"Unable to upload new logo": "Unable to upload new logo",
@@ -985,6 +1004,10 @@
"A confirmation code has been sent to your email address, please enter the code below to permanently destroy your account.": "A confirmation code has been sent to your email address, please enter the code below to permanently destroy your account.",
"Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.": "Are you sure? Deleting your account will destroy identifying data associated with your user and cannot be undone. You will be immediately logged out of {{appName}} and all your API tokens will be revoked.",
"Delete my account": "Delete my account",
"Boolean": "Boolean",
"Number": "Number",
"Text": "Text",
"List": "List",
"Today": "Today",
"Yesterday": "Yesterday",
"Last week": "Last week",
+24
View File
@@ -0,0 +1,24 @@
import { Primitive } from "utility-types";
export enum DataAttributeDataType {
String = "string",
Number = "number",
Boolean = "boolean",
List = "list",
}
export type DocumentDataAttribute = {
dataAttributeId: string;
value: Primitive;
updatedAt: string;
};
export type DataAttributeOptions = {
/** An icon to display next to the attribute. */
icon?: string;
/** Valid options for list data type. */
options?: {
value: string;
color?: string;
}[];
};
+3 -2
View File
@@ -309,7 +309,7 @@ export class IconLibrary {
},
globe: {
component: GlobeIcon,
keywords: "world translate",
keywords: "world website translate",
},
hashtag: {
component: HashtagIcon,
@@ -365,7 +365,8 @@ export class IconLibrary {
},
padlock: {
component: PadlockIcon,
keywords: "padlock private security authentication authorization auth",
keywords:
"padlock private privacy security authentication authorization auth",
},
palette: {
component: PaletteIcon,
+30
View File
@@ -22,10 +22,40 @@ export const AttachmentValidation = {
export const ApiKeyValidation = {
/** The minimum length of the API key name */
minNameLength: 3,
/** The maximum length of the API key name */
maxNameLength: 255,
};
export const DataAttributeValidation = {
/** The minimum length of the name */
minNameLength: 3,
/** The maximum length of the name */
maxNameLength: 255,
/** The minimum length of a list option */
minOptionLength: 1,
/** The maximum length of a list option */
maxOptionLength: 50,
/** The maximum length of the description */
maxDescriptionLength: 1000,
/** The maximum number of data attributes */
max: 25,
/** The maximum number of data attributes that can be pinned */
maxPinned: 3,
/** The minimum number of options for a list attribute */
minOptions: 2,
/** The maximum number of options for a list attribute */
maxOptions: 10,
};
export const CollectionValidation = {
/** The maximum length of the collection description */
maxDescriptionLength: 10 * 1000,
+384 -369
View File
File diff suppressed because it is too large Load Diff