mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09f7f6b3f3 | |||
| eed1f26911 | |||
| d9e52f2b01 | |||
| 961e9c1cea | |||
| 9ff0f9f12a | |||
| a965dd3a33 | |||
| 0461ec2d06 | |||
| fc52fee781 | |||
| 3db5462db7 | |||
| 6a29e91ddf | |||
| 220e3c02cc | |||
| ac613101a3 | |||
| 5cd0105da8 | |||
| 3bb4c33539 | |||
| a4f2d98953 | |||
| 35f251027b | |||
| 165537d46f | |||
| c950788ef1 | |||
| 8120ef0ce8 | |||
| e6f7c95617 | |||
| c3ffdb714e | |||
| ab7395f5ae | |||
| 804e2dd378 | |||
| 669240492b | |||
| 253d652a20 | |||
| ef9f96d891 | |||
| 336e424b8b | |||
| 0bb993634a | |||
| 2f26e76b1e | |||
| 93a89eeef3 | |||
| 6e6a5014af | |||
| 3da1945bea | |||
| c2fbb31e77 | |||
| 4c999d00d2 | |||
| 738449a7d0 | |||
| ae80128396 | |||
| 1da5ac0bfe | |||
| f56f240d9b | |||
| 7de0ffb7f7 | |||
| 0e667c5d3d | |||
| 465c935879 |
@@ -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} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />;
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
@@ -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),
|
||||
{
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
•
|
||||
<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>
|
||||
•
|
||||
<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 && (
|
||||
<>
|
||||
•
|
||||
<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 ? (
|
||||
<>
|
||||
•
|
||||
<AddPropertyMenu
|
||||
document={document}
|
||||
propertiesRef={propertiesRef}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{totalViewers &&
|
||||
can.listViews &&
|
||||
!document.isDraft &&
|
||||
!document.isTemplate ? (
|
||||
<Wrapper>
|
||||
•
|
||||
<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;
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 |
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(/&$/, "")
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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?"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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 you’re online": "Edits you make will sync once you’re 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",
|
||||
|
||||
@@ -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;
|
||||
}[];
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user