mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
26 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 |
@@ -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} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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} />;
|
||||
});
|
||||
@@ -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")};
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -489,6 +498,48 @@ 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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
@@ -3007,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 });
|
||||
|
||||
@@ -1409,6 +1409,7 @@ router.post(
|
||||
publish,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
dataAttributes,
|
||||
fullWidth,
|
||||
templateId,
|
||||
template,
|
||||
@@ -1477,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({
|
||||
@@ -218,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
|
||||
@@ -254,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",
|
||||
@@ -330,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
|
||||
@@ -355,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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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",
|
||||
@@ -169,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",
|
||||
@@ -458,6 +463,7 @@
|
||||
"Details": "Details",
|
||||
"Security": "Security",
|
||||
"Features": "Features",
|
||||
"Data Attributes": "Data Attributes",
|
||||
"Members": "Members",
|
||||
"Groups": "Groups",
|
||||
"Shared Links": "Shared Links",
|
||||
@@ -476,6 +482,7 @@
|
||||
"Alphabetical sort": "Alphabetical sort",
|
||||
"Manual sort": "Manual sort",
|
||||
"Comment options": "Comment options",
|
||||
"Edit attribute": "Edit attribute",
|
||||
"{{ documentName }} restored": "{{ documentName }} restored",
|
||||
"Document options": "Document options",
|
||||
"Restore": "Restore",
|
||||
@@ -571,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",
|
||||
@@ -618,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.",
|
||||
@@ -846,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",
|
||||
@@ -991,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