Compare commits

...

26 Commits

Author SHA1 Message Date
Tom Moor 09f7f6b3f3 fix: Migration integer -> number 2024-08-02 09:42:34 +01:00
Tom Moor eed1f26911 test 2024-07-27 22:42:04 -04:00
Tom Moor d9e52f2b01 Allow passing dataAttributes on documents.create 2024-07-27 21:23:08 -04:00
Tom Moor 961e9c1cea Remove feature flag, fixes 2024-07-27 16:21:41 -04:00
Tom Moor 9ff0f9f12a Switch icon 2024-07-27 16:21:41 -04:00
Tom Moor a965dd3a33 fix: Hover on DD
fix: Guard editing state
2024-07-27 16:21:41 -04:00
Tom Moor 0461ec2d06 Fixed attribute order 2024-07-27 16:21:41 -04:00
Tom Moor fc52fee781 Remove save button 2024-07-27 16:21:41 -04:00
Tom Moor 3db5462db7 fix: Correct updatedAt on document data attributes 2024-07-27 16:21:41 -04:00
Tom Moor 6a29e91ddf Add realtime events 2024-07-27 16:21:41 -04:00
Tom Moor 220e3c02cc fix initial attribute 2024-07-27 16:21:41 -04:00
Tom Moor ac613101a3 Do not render properties div if none 2024-07-27 16:21:41 -04:00
Tom Moor 5cd0105da8 Property editing 2024-07-27 16:21:41 -04:00
Tom Moor 3bb4c33539 tsc 2024-07-27 16:21:41 -04:00
Tom Moor a4f2d98953 wip 2024-07-27 16:21:41 -04:00
Tom Moor 35f251027b wip 2024-07-27 16:21:41 -04:00
Tom Moor 165537d46f Settings UI 2024-07-27 16:21:39 -04:00
Tom Moor c950788ef1 dataAttributes.delete endpoint 2024-07-27 16:21:11 -04:00
Tom Moor 8120ef0ce8 stash 2024-07-27 16:21:11 -04:00
Tom Moor e6f7c95617 wip 2024-07-27 16:21:11 -04:00
Tom Moor c3ffdb714e stash 2024-07-27 16:21:11 -04:00
Tom Moor ab7395f5ae stash 2024-07-27 16:21:11 -04:00
Tom Moor 804e2dd378 stash 2024-07-27 16:21:11 -04:00
Tom Moor 669240492b stash 2024-07-27 16:21:11 -04:00
Tom Moor 253d652a20 Model, endpoints 2024-07-27 16:21:11 -04:00
Tom Moor ef9f96d891 Migration 2024-07-27 16:21:11 -04:00
49 changed files with 2088 additions and 79 deletions
@@ -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} />,
});
},
});
+1 -1
View File
@@ -22,7 +22,7 @@ type Props = {
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactElement;
icon?: React.ReactElement | null;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
@@ -0,0 +1,34 @@
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import DataAttribute from "~/models/DataAttribute";
import { DataAttributeForm, FormData } from "./DataAttributeForm";
type Props = {
dataAttribute: DataAttribute;
onSubmit: () => void;
};
export const DataAttributeEdit = observer(function DataAttributeEdit_({
dataAttribute,
onSubmit,
}: Props) {
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await dataAttribute.save(data);
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
},
[dataAttribute, onSubmit]
);
return (
<DataAttributeForm
dataAttribute={dataAttribute}
handleSubmit={handleSubmit}
/>
);
});
@@ -0,0 +1,212 @@
import { observer } from "mobx-react";
import { CloseIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import {
DataAttributeDataType,
type DataAttributeOptions,
} from "@shared/models/types";
import { DataAttributeValidation } from "@shared/validations";
import type DataAttribute from "~/models/DataAttribute";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import { DataAttributesHelper } from "~/utils/DataAttributesHelper";
import InputSelect from "../InputSelect";
import NudeButton from "../NudeButton";
type Props = {
handleSubmit: (data: FormData) => void;
dataAttribute?: DataAttribute;
};
export interface FormData {
name: string;
description?: string;
dataType: DataAttributeDataType;
options?: DataAttributeOptions;
}
export const DataAttributeForm = observer(function DataAttributeForm_({
handleSubmit,
dataAttribute,
}: Props) {
const theme = useTheme();
const { t } = useTranslation();
const {
register,
handleSubmit: formHandleSubmit,
formState,
watch,
control,
setFocus,
setValue,
} = useForm<FormData>({
mode: "all",
defaultValues: {
name: dataAttribute?.name,
description: dataAttribute?.description ?? undefined,
dataType: dataAttribute?.dataType ?? DataAttributeDataType.String,
options: dataAttribute?.options ?? undefined,
},
});
const values = watch();
const isEditing = !!dataAttribute;
React.useEffect(() => {
if (isEditing) {
return;
}
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [isEditing, setFocus]);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<div>
<Controller
control={control}
name="dataType"
render={({ field }) => (
<InputSelect
ref={field.ref}
value={field.value}
disabled={isEditing}
onChange={(value: DataAttributeDataType) => {
field.onChange(value);
if (value === DataAttributeDataType.List) {
setValue("options", {
options: [
{
value: "",
},
{
value: "",
},
],
});
}
}}
ariaLabel={t("Format")}
label={t("Format")}
options={Object.values(DataAttributeDataType).map((dataType) => ({
value: dataType,
label: DataAttributesHelper.getName(dataType, t),
}))}
style={{ width: "auto" }}
/>
)}
/>
</div>
{values.dataType === DataAttributeDataType.List && (
<Options gap={8} column>
{values.options?.options?.map((option, index) => (
<Flex gap={4} align="center" key={index}>
<Input
value={option.value}
onChange={(event) => {
const newOptions = [...(values.options?.options ?? [])];
newOptions[index] = { value: event.target.value };
setValue("options", { options: newOptions });
}}
type="text"
autoComplete="off"
autoFocus={index !== 1}
minLength={DataAttributeValidation.minOptionLength}
maxLength={DataAttributeValidation.maxOptionLength}
margin={0}
required
flex
/>
<NudeButton
disabled={
(values.options?.options?.length ?? 0) <=
DataAttributeValidation.minOptions
}
onClick={() => {
const newOptions = [...(values.options?.options ?? [])];
newOptions.splice(index, 1);
setValue("options", { options: newOptions });
}}
>
<CloseIcon color={theme.textSecondary} />
</NudeButton>
</Flex>
))}
<div>
<Controller
control={control}
name="options"
render={({ field }) => (
<Button
neutral
borderOnHover
icon={<PlusIcon size={20} />}
disabled={
(values.options?.options?.length ?? 0) >=
DataAttributeValidation.maxOptions
}
onClick={() => {
field.onChange({
options: [
...(field.value?.options ?? []),
{
value: "",
},
],
});
}}
>
{t("Add option")}
</Button>
)}
/>
</div>
</Options>
)}
<Input
type="text"
label={t("Name")}
{...register("name", {
required: true,
minLength: DataAttributeValidation.minNameLength,
maxLength: DataAttributeValidation.maxNameLength,
})}
autoComplete="off"
autoFocus
flex
/>
<Input
type="text"
label={t("Description")}
placeholder={t("Optional")}
{...register("description", {
maxLength: DataAttributeValidation.maxDescriptionLength,
})}
autoComplete="off"
flex
/>
<Flex justify="flex-end">
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{dataAttribute
? formState.isSubmitting
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
);
});
const Options = styled(Flex)`
margin-left: 16px;
margin-bottom: 16px;
`;
@@ -0,0 +1,30 @@
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import DataAttribute from "~/models/DataAttribute";
import useStores from "~/hooks/useStores";
import { DataAttributeForm, FormData } from "./DataAttributeForm";
type Props = {
onSubmit: () => void;
};
export const DataAttributeNew = observer(function DataAttributeNew_({
onSubmit,
}: Props) {
const { dataAttributes } = useStores();
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const dataAttribute = new DataAttribute(data, dataAttributes);
await dataAttribute.save();
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
},
[dataAttributes, onSubmit]
);
return <DataAttributeForm handleSubmit={handleSubmit} />;
});
+12 -4
View File
@@ -143,8 +143,12 @@ export interface Props
onRequestSubmit?: (
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
onFocus?: (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onBlur?: (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
}
function Input(
@@ -154,7 +158,9 @@ function Input(
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
const [focused, setFocused] = React.useState(false);
const handleBlur = (ev: React.SyntheticEvent) => {
const handleBlur = (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFocused(false);
if (props.onBlur) {
@@ -162,7 +168,9 @@ function Input(
}
};
const handleFocus = (ev: React.SyntheticEvent) => {
const handleFocus = (
ev: React.SyntheticEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFocused(true);
if (props.onFocus) {
+8 -1
View File
@@ -55,6 +55,8 @@ export type Props = {
* The Modal will take care of preventing body scroll behaviour.
*/
skipBodyScroll?: boolean;
autoFocus?: boolean;
placeholder?: string;
};
export interface InputSelectRef {
@@ -85,6 +87,8 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
icon,
nude,
skipBodyScroll,
autoFocus,
placeholder,
...rest
} = props;
@@ -214,6 +218,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
neutral
disclosure
className={className}
autoFocus={autoFocus}
icon={icon}
$nude={nude}
{...buttonProps}
@@ -221,7 +226,9 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
{option ? (
labelForOption(option)
) : (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
<Placeholder>
{placeholder ?? `Select a ${ariaLabel.toLowerCase()}`}
</Placeholder>
)}
</StyledButton>
)}
+1 -1
View File
@@ -271,7 +271,7 @@ const Small = styled.div`
outline: none;
${NudeButton} {
&:hover,
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
+23
View File
@@ -10,6 +10,7 @@ import { FileOperationState, FileOperationType } from "@shared/types";
import RootStore from "~/stores/RootStore";
import Collection from "~/models/Collection";
import Comment from "~/models/Comment";
import DataAttribute from "~/models/DataAttribute";
import Document from "~/models/Document";
import FileOperation from "~/models/FileOperation";
import Group from "~/models/Group";
@@ -82,6 +83,7 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.authenticated = false;
const {
auth,
dataAttributes,
documents,
collections,
groups,
@@ -290,6 +292,27 @@ class WebsocketProvider extends React.Component<Props> {
}
);
this.socket.on(
"dataAttributes.create",
(event: PartialWithId<DataAttribute>) => {
dataAttributes.add(event);
}
);
this.socket.on(
"dataAttributes.update",
(event: PartialWithId<DataAttribute>) => {
dataAttributes.add(event);
}
);
this.socket.on(
"dataAttributes.delete",
(event: WebsocketEntityDeletedEvent) => {
dataAttributes.remove(event.modelId);
}
);
this.socket.on("comments.create", (event: PartialWithId<Comment>) => {
comments.add(event);
});
+10
View File
@@ -14,6 +14,7 @@ import {
ImportIcon,
ShapesIcon,
Icon,
DatabaseIcon,
} from "outline-icons";
import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
@@ -29,6 +30,7 @@ import useCurrentUser from "./useCurrentUser";
import usePolicy from "./usePolicy";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const DataAttributes = lazy(() => import("~/scenes/Settings/DataAttributes"));
const Details = lazy(() => import("~/scenes/Settings/Details"));
const Export = lazy(() => import("~/scenes/Settings/Export"));
const Features = lazy(() => import("~/scenes/Settings/Features"));
@@ -119,6 +121,14 @@ const useSettingsConfig = () => {
group: t("Workspace"),
icon: BeakerIcon,
},
{
name: t("Data Attributes"),
path: settingsPath("attributes"),
component: DataAttributes,
enabled: can.createDataAttribute,
group: t("Workspace"),
icon: DatabaseIcon,
},
{
name: t("Members"),
path: settingsPath("members"),
+67
View File
@@ -0,0 +1,67 @@
import copy from "copy-to-clipboard";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import { toast } from "sonner";
import DataAttribute from "~/models/DataAttribute";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Separator from "~/components/ContextMenu/Separator";
import { DataAttributeEdit } from "~/components/DataAttribute/DataAttributeEdit";
import useStores from "~/hooks/useStores";
type Props = {
/** The DataAttribute to associate with the menu */
dataAttribute: DataAttribute;
};
function DataAttributeMenu({ dataAttribute }: Props) {
const menu = useMenuState({
modal: true,
});
const { dialogs } = useStores();
const { t } = useTranslation();
const handleEdit = React.useCallback(() => {
dialogs.openModal({
title: t("Edit attribute"),
content: (
<DataAttributeEdit
dataAttribute={dataAttribute}
onSubmit={dialogs.closeAllModals}
/>
),
});
}, [t, dialogs, dataAttribute]);
const handleCopy = React.useCallback(() => {
copy(dataAttribute.id);
toast.success("Copied to clipboard");
}, [dataAttribute]);
const handleDelete = React.useCallback(() => {
void dataAttribute.delete();
}, [dataAttribute]);
return (
<>
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
<ContextMenu {...menu}>
<MenuItem {...menu} onClick={handleEdit}>
{t("Edit")}
</MenuItem>
<MenuItem {...menu} onClick={handleCopy}>
{t("Copy ID")}
</MenuItem>
<Separator />
<MenuItem {...menu} onClick={handleDelete} dangerous>
{t("Delete")}
</MenuItem>
</ContextMenu>
</>
);
}
export default observer(DataAttributeMenu);
+52
View File
@@ -0,0 +1,52 @@
import { observable } from "mobx";
import {
DataAttributeDataType,
type DataAttributeOptions,
} from "@shared/models/types";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
class DataAttribute extends ParanoidModel {
static modelName = "DataAttribute";
/** The name of this data attribute. */
@Field
@observable
name: string;
/** A user-facing description for the data attribute. */
@Field
@observable
description: string | null;
/** The data type of this data attribute. Cannot be changed after creation. */
@Field
@observable
dataType: DataAttributeDataType;
/** Options for `list` data type. */
@Field
@observable
options: DataAttributeOptions | null;
/** Whether this data attribute is pinned to the top of the document. */
@Field
@observable
pinned: boolean;
/** The sort index of this data attribute. */
@Field
@observable
index: number;
/** The user that created this data attribute. */
@Relation(() => User, { onDelete: "cascade" })
createdBy?: User;
/** The user ID that created this data attribute. */
createdById: string;
}
export default DataAttribute;
+51
View File
@@ -4,8 +4,13 @@ import capitalize from "lodash/capitalize";
import floor from "lodash/floor";
import { action, autorun, computed, observable, set } from "mobx";
import { Node, Schema } from "prosemirror-model";
import { Primitive } from "utility-types";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { richExtensions, withComments } from "@shared/editor/nodes";
import {
DataAttributeDataType,
DocumentDataAttribute,
} from "@shared/models/types";
import type {
JSONObject,
NavigationNode,
@@ -62,6 +67,10 @@ export default class Document extends ParanoidModel {
@observable
lastViewedAt: string | undefined;
@Field
@observable
dataAttributes: DocumentDataAttribute[];
store: DocumentsStore;
@Field
@@ -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;
+17 -2
View File
@@ -53,8 +53,16 @@ type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
};
function DataLoader({ match, children }: Props) {
const { ui, views, shares, comments, documents, revisions, subscriptions } =
useStores();
const {
ui,
views,
shares,
comments,
documents,
revisions,
subscriptions,
dataAttributes,
} = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const [error, setError] = React.useState<Error | null>(null);
@@ -80,8 +88,15 @@ function DataLoader({ match, children }: Props) {
match.path === matchDocumentEdit || match.path.startsWith(settingsPath());
const isEditing = isEditRoute || !user?.separateEditMode;
const can = usePolicy(document);
const canTeam = usePolicy(team);
const location = useLocation<LocationState>();
React.useEffect(() => {
if (!dataAttributes.isLoaded && canTeam.listDataAttribute) {
void dataAttributes.fetchAll();
}
}, [dataAttributes, canTeam]);
React.useEffect(() => {
async function fetchDocument() {
try {
+113 -39
View File
@@ -1,19 +1,24 @@
import { LocationDescriptor } from "history";
import { observer, useObserver } from "mobx-react";
import { CommentIcon } from "outline-icons";
import { CommentIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, useRouteMatch } from "react-router-dom";
import { MenuButton, useMenuState } from "reakit";
import styled from "styled-components";
import { TeamPreference } from "@shared/types";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import DocumentMeta from "~/components/DocumentMeta";
import Fade from "~/components/Fade";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { DataAttributesHelper } from "~/utils/DataAttributesHelper";
import { documentPath, documentInsightsPath } from "~/utils/routeHelpers";
import { Properties, PropertiesRef } from "./Properties";
type Props = {
/* The document to display meta data for */
@@ -24,7 +29,7 @@ type Props = {
};
function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const { views, comments, ui } = useStores();
const { views, comments, dataAttributes, ui } = useStores();
const { t } = useTranslation();
const match = useRouteMatch();
const team = useCurrentTeam();
@@ -33,53 +38,122 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const onlyYou = totalViewers === 1 && documentViews[0].userId;
const viewsLoadedOnMount = React.useRef(totalViewers > 0);
const can = usePolicy(document);
const canTeam = usePolicy(team);
const propertiesRef = React.useRef<PropertiesRef>(null);
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
const insightsPath = documentInsightsPath(document);
const commentsCount = comments.unresolvedCommentsInDocumentCount(document.id);
const dataAttributesAvailable =
canTeam.listDataAttribute && dataAttributes.orderedData.length > 0;
const missingDataAttributes =
!document.dataAttributes ||
document.dataAttributes?.length < dataAttributes.orderedData.length;
return (
<Meta document={document} revision={revision} to={to} replace {...rest}>
{team.getPreference(TeamPreference.Commenting) && can.comment && (
<>
&nbsp;&nbsp;
<CommentLink
to={documentPath(document)}
onClick={() => ui.toggleComments(document.id)}
>
<CommentIcon size={18} />
{commentsCount
? t("{{ count }} comment", { count: commentsCount })
: t("Comment")}
</CommentLink>
</>
)}
{totalViewers &&
can.listViews &&
!document.isDraft &&
!document.isTemplate ? (
<Wrapper>
&nbsp;&nbsp;
<Link
to={
match.url === insightsPath ? documentPath(document) : insightsPath
}
>
{t("Viewed by")}{" "}
{onlyYou
? t("only you")
: `${totalViewers} ${
totalViewers === 1 ? t("person") : t("people")
}`}
</Link>
</Wrapper>
) : null}
</Meta>
<>
<Meta document={document} revision={revision} to={to} replace {...rest}>
{team.getPreference(TeamPreference.Commenting) && can.comment && (
<>
&nbsp;&nbsp;
<InlineLink
to={documentPath(document)}
onClick={() => ui.toggleComments(document.id)}
>
<CommentIcon size={18} />
{commentsCount
? t("{{ count }} comment", { count: commentsCount })
: t("Comment")}
</InlineLink>
</>
)}
{dataAttributesAvailable && missingDataAttributes && can.update ? (
<>
&nbsp;&nbsp;
<AddPropertyMenu
document={document}
propertiesRef={propertiesRef}
/>
</>
) : null}
{totalViewers &&
can.listViews &&
!document.isDraft &&
!document.isTemplate ? (
<Wrapper>
&nbsp;&nbsp;
<Link
to={
match.url === insightsPath
? documentPath(document)
: insightsPath
}
>
{t("Viewed by")}{" "}
{onlyYou
? t("only you")
: `${totalViewers} ${
totalViewers === 1 ? t("person") : t("people")
}`}
</Link>
</Wrapper>
) : null}
</Meta>
<Properties document={document} ref={propertiesRef} />
</>
);
}
const CommentLink = styled(Link)`
const AddPropertyMenu = ({
propertiesRef,
document,
}: {
document: Document;
propertiesRef: React.RefObject<PropertiesRef>;
}) => {
const { dataAttributes } = useStores();
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
});
return (
<>
<MenuButton {...menu}>
{(props) => (
<InlineLink to={documentPath(document)} {...props}>
<PlusIcon size={18} /> {t("Property")}
</InlineLink>
)}
</MenuButton>
<ContextMenu {...menu}>
{dataAttributes.orderedData
.filter(
(a) =>
!a.deletedAt &&
!document.dataAttributes?.find((d) => d.dataAttributeId === a.id)
)
.map((attribute) => (
<MenuItem
key={attribute.id}
icon={DataAttributesHelper.getIcon(
attribute.dataType,
attribute.name
)}
{...menu}
onClick={() => propertiesRef.current?.addProperty(attribute.id)}
>
{attribute.name}
</MenuItem>
))}
</ContextMenu>
</>
);
};
const InlineLink = styled(Link)`
display: inline-flex;
align-items: center;
`;
@@ -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;
}
}
`;
+67
View File
@@ -0,0 +1,67 @@
import { observer } from "mobx-react";
import { DatabaseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import DataAttribute from "~/models/DataAttribute";
import { Action } from "~/components/Actions";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import { createDataAttribute } from "~/actions/definitions/dataAttributes";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { DataAttributeListItem } from "./components/DataAttributeListItem";
function DataAttributes() {
const team = useCurrentTeam();
const { t } = useTranslation();
const { dataAttributes } = useStores();
const can = usePolicy(team);
const context = useActionContext();
return (
<Scene
title={t("Data Attributes")}
icon={<DatabaseIcon />}
actions={
<>
{can.createDataAttribute && (
<Action>
<Button
type="submit"
value={`${t("New Attribute")}`}
action={createDataAttribute}
context={context}
/>
</Action>
)}
</>
}
>
<Heading>{t("Data Attributes")}</Heading>
<Text as="p" type="secondary">
<Trans>
Attributes allow you to define data to be stored with your documents.
They can be used to store custom properties, metadata, or any other
structured information that is common across documents.
</Trans>
</Text>
<PaginatedList
fetch={dataAttributes.fetchAll}
items={dataAttributes.orderedData}
renderItem={(dataAttribute: DataAttribute) => (
<DataAttributeListItem
key={dataAttribute.id}
dataAttribute={dataAttribute}
/>
)}
/>
</Scene>
);
}
export default observer(DataAttributes);
@@ -0,0 +1,29 @@
import { observer } from "mobx-react";
import * as React from "react";
import DataAttribute from "~/models/DataAttribute";
import ListItem from "~/components/List/Item";
import DataAttributeMenu from "~/menus/DataAttributeMenu";
import { DataAttributesHelper } from "~/utils/DataAttributesHelper";
type Props = {
dataAttribute: DataAttribute;
};
export const DataAttributeListItem = observer(function DataAttributeListItem_({
dataAttribute,
}: Props) {
const image = DataAttributesHelper.getIcon(
dataAttribute.dataType,
dataAttribute.name
);
return (
<ListItem
key={dataAttribute.id}
title={dataAttribute.name}
subtitle={dataAttribute.description || dataAttribute.dataType}
image={image}
actions={<DataAttributeMenu dataAttribute={dataAttribute} />}
/>
);
});
+30
View File
@@ -0,0 +1,30 @@
import orderBy from "lodash/orderBy";
import { computed } from "mobx";
import DataAttribute from "~/models/DataAttribute";
import RootStore from "./RootStore";
import Store, { RPCAction } from "./base/Store";
export default class DataAttributesStore extends Store<DataAttribute> {
actions = [
RPCAction.List,
RPCAction.Create,
RPCAction.Update,
RPCAction.Delete,
];
constructor(rootStore: RootStore) {
super(rootStore, DataAttribute);
}
@computed
get active(): DataAttribute[] {
return this.orderedData.filter((d) => !d.deletedAt);
}
@computed
get deleted(): DataAttribute[] {
return orderBy(this.orderedData, "deletedAt", "desc").filter(
(d) => d.deletedAt
);
}
}
+3
View File
@@ -6,6 +6,7 @@ import AuthStore from "./AuthStore";
import AuthenticationProvidersStore from "./AuthenticationProvidersStore";
import CollectionsStore from "./CollectionsStore";
import CommentsStore from "./CommentsStore";
import DataAttributesStore from "./DataAttributesStore";
import DialogsStore from "./DialogsStore";
import DocumentPresenceStore from "./DocumentPresenceStore";
import DocumentsStore from "./DocumentsStore";
@@ -39,6 +40,7 @@ export default class RootStore {
groupMemberships: GroupMembershipsStore;
comments: CommentsStore;
dialogs: DialogsStore;
dataAttributes: DataAttributesStore;
documents: DocumentsStore;
events: EventsStore;
groups: GroupsStore;
@@ -68,6 +70,7 @@ export default class RootStore {
this.registerStore(CollectionsStore);
this.registerStore(GroupMembershipsStore);
this.registerStore(CommentsStore);
this.registerStore(DataAttributesStore);
this.registerStore(DocumentsStore);
this.registerStore(EventsStore);
this.registerStore(GroupsStore);
+74
View File
@@ -0,0 +1,74 @@
import { TFunction } from "i18next";
import {
CaseSensitiveIcon,
DoneIcon,
HashtagIcon,
TableOfContentsIcon,
} from "outline-icons";
import * as React from "react";
import { DataAttributeDataType } from "@shared/models/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import DataAttribute from "~/models/DataAttribute";
export class DataAttributesHelper {
public static getName(dataType: DataAttributeDataType, t: TFunction) {
switch (dataType) {
case DataAttributeDataType.Boolean:
return t("Boolean");
case DataAttributeDataType.Number:
return t("Number");
case DataAttributeDataType.String:
return t("Text");
case DataAttributeDataType.List:
return t("List");
default:
return "";
}
}
/**
* Returns an appropriate icon for the data attribute based on it's type
*
* @param dataAttribute The data attribute to get the icon for
* @param props Additional props to pass to the icon
* @returns An icon or null
*/
public static getIcon(
dataType: DataAttributeDataType,
keyword?: string,
props?: React.ComponentProps<typeof DoneIcon>
) {
const match = keyword ? IconLibrary.findIconByKeyword(keyword) : undefined;
if (match) {
const IconComponent = IconLibrary.getComponent(match);
return <IconComponent {...props} />;
}
switch (dataType) {
case DataAttributeDataType.Boolean:
return <DoneIcon {...props} />;
case DataAttributeDataType.Number:
return <HashtagIcon {...props} />;
case DataAttributeDataType.String:
return <CaseSensitiveIcon {...props} />;
case DataAttributeDataType.List:
return <TableOfContentsIcon {...props} />;
default:
return null;
}
}
/**
* Returns the regex to validate the input of the data attribute
* @param dataAttribute
* @returns A regex or undefined
*/
public static getValidationRegex(dataAttribute: DataAttribute) {
switch (dataAttribute.dataType) {
case DataAttributeDataType.Number:
return /^-?\d+(\.\d+)?$/;
default:
return undefined;
}
}
}
@@ -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);
}
+11
View File
@@ -1,5 +1,6 @@
import { Transaction } from "sequelize";
import { Optional } from "utility-types";
import { DocumentDataAttribute } from "@shared/models/types";
import { Document, Event, User } from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
@@ -29,6 +30,7 @@ type Props = Optional<
state?: Buffer;
publish?: boolean;
templateDocument?: Document | null;
dataAttributes?: Omit<DocumentDataAttribute, "updatedAt">[] | null;
user: User;
ip?: string;
transaction?: Transaction;
@@ -45,6 +47,7 @@ export default async function documentCreator({
publish,
collectionId,
parentDocumentId,
dataAttributes,
content,
template,
templateDocument,
@@ -86,6 +89,14 @@ export default async function documentCreator({
id,
urlId,
parentDocumentId,
dataAttributes: dataAttributes?.map(
({ dataAttributeId, value }) =>
({
dataAttributeId,
value,
updatedAt: new Date().toISOString(),
} as DocumentDataAttribute)
),
editorVersion,
collectionId,
teamId: user.teamId,
+27
View File
@@ -1,4 +1,6 @@
import uniqBy from "lodash/uniqBy";
import { Transaction } from "sequelize";
import { DocumentDataAttribute } from "@shared/models/types";
import { Event, Document, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
@@ -7,6 +9,8 @@ type Props = {
user: User;
/** The existing document */
document: Document;
/** Data attributes to apply to the document */
dataAttributes?: Omit<DocumentDataAttribute, "updatedAt">[] | null;
/** The new title */
title?: string;
/** The document icon */
@@ -47,6 +51,7 @@ type Props = {
export default async function documentUpdater({
user,
document,
dataAttributes,
title,
icon,
color,
@@ -65,6 +70,28 @@ export default async function documentUpdater({
const previousTitle = document.title;
const cId = collectionId || document.collectionId;
if (dataAttributes !== undefined) {
document.dataAttributes = dataAttributes
? uniqBy(
dataAttributes.map(({ dataAttributeId, value }) => {
const existing = document.dataAttributes?.find(
(da) => da.dataAttributeId === dataAttributeId
);
if (existing?.value === value) {
return existing as DocumentDataAttribute;
}
return {
dataAttributeId,
value,
updatedAt: new Date().toISOString(),
} as DocumentDataAttribute;
}),
"dataAttributeId"
)
: null;
}
if (title !== undefined) {
document.title = title.trim();
}
@@ -0,0 +1,91 @@
"use strict";
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.createTable(
"data_attributes",
{
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
createdById: {
type: Sequelize.UUID,
allowNull: false,
onDelete: "cascade",
references: {
model: "users",
},
},
teamId: {
type: Sequelize.UUID,
allowNull: false,
onDelete: "cascade",
references: {
model: "teams",
}
},
name: {
type: Sequelize.STRING,
allowNull: false,
},
description: {
type: Sequelize.STRING,
allowNull: true,
},
dataType: {
type: Sequelize.ENUM("string", "number", "boolean", "list"),
allowNull: false,
},
options: {
type: Sequelize.JSONB,
allowNull: true,
},
pinned: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
},
index: {
type: Sequelize.STRING,
allowNull: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
deletedAt: {
type: Sequelize.DATE,
allowNull: true,
},
},
{ transaction }
);
await queryInterface.addIndex(
"data_attributes",
["teamId"],
{ transaction }
);
await queryInterface.addColumn("documents", "dataAttributes", {
type: Sequelize.JSONB,
allowNull: true,
});
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.dropTable("data_attributes", { transaction });
await queryInterface.removeColumn("documents", "dataAttributes", {
transaction,
});
});
}
};
+131
View File
@@ -0,0 +1,131 @@
import {
InferAttributes,
InferCreationAttributes,
type SaveOptions,
} from "sequelize";
import {
Column,
Table,
BelongsTo,
ForeignKey,
AllowNull,
IsIn,
DataType,
Default,
BeforeCreate,
} from "sequelize-typescript";
import { z } from "zod";
import {
DataAttributeDataType,
type DataAttributeOptions,
} from "@shared/models/types";
import { DataAttributeValidation } from "@shared/validations";
import Team from "./Team";
import User from "./User";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
import Length from "./validators/Length";
import NotContainsUrl from "./validators/NotContainsUrl";
@Table({
tableName: "data_attributes",
modelName: "data_attribute",
paranoid: true,
})
@Fix
class DataAttribute extends ParanoidModel<
InferAttributes<DataAttribute>,
Partial<InferCreationAttributes<DataAttribute>>
> {
/** The name of this data attribute. */
@Length({
min: DataAttributeValidation.minNameLength,
max: DataAttributeValidation.maxNameLength,
msg: `Name must be between ${DataAttributeValidation.minNameLength} and ${DataAttributeValidation.maxNameLength} characters`,
})
@NotContainsUrl
@Column
name: string;
/** A user-facing description for the data attribute. */
@AllowNull
@Column
description: string;
/** The data type of this data attribute. Cannot be changed after creation. */
@IsIn([Object.values(DataAttributeDataType)])
@Column(DataType.ENUM(...Object.values(DataAttributeDataType)))
dataType: DataAttributeDataType;
/** Additional options for some datatypes. */
@AllowNull
@Column(DataType.JSONB)
options: DataAttributeOptions | null;
/** Whether this data attribute is pinned to the top of the document. */
@Default(false)
@Column
pinned: boolean;
/** The sort index of this data attribute. */
@Column
index: string;
// hooks
@BeforeCreate
static async checkMaximumAttributes(
model: DataAttribute,
{ transaction }: SaveOptions<DataAttribute>
) {
const count = await this.count({
where: {
teamId: model.teamId,
},
transaction,
});
if (count >= DataAttributeValidation.max) {
throw new Error("Maximum number of attributes reached");
}
}
/**
* The Zod type for validating this data attribute.
*/
get zodType() {
switch (this.dataType) {
case DataAttributeDataType.String:
return z.string();
case DataAttributeDataType.Number:
return z.number();
case DataAttributeDataType.Boolean:
return z.boolean();
case DataAttributeDataType.List:
return z
.string()
.refine((value) =>
this.options?.options?.map((i) => i.value).includes(value)
);
default:
throw new Error(`Unknown data type: ${this.dataType}`);
}
}
// associations
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column
createdById: string;
@BelongsTo(() => Team)
team: Team;
@ForeignKey(() => Team)
@Column(DataType.UUID)
teamId: string;
}
export default DataAttribute;
+54
View File
@@ -41,6 +41,8 @@ import {
BelongsToMany,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import { ZodError } from "zod";
import { DocumentDataAttribute } from "@shared/models/types";
import type {
NavigationNode,
ProsemirrorData,
@@ -54,6 +56,7 @@ import { ValidationError } from "@server/errors";
import { generateUrlId } from "@server/utils/url";
import Backlink from "./Backlink";
import Collection from "./Collection";
import DataAttribute from "./DataAttribute";
import FileOperation from "./FileOperation";
import Revision from "./Revision";
import Star from "./Star";
@@ -279,6 +282,10 @@ class Document extends ParanoidModel<
@Column
color: string | null;
/** Attributes associated with the document. */
@Column(DataType.JSONB)
dataAttributes: DocumentDataAttribute[] | null;
/**
* The content of the document as Markdown.
*
@@ -365,6 +372,53 @@ class Document extends ParanoidModel<
// hooks
@BeforeSave
static async validateDataAttributes(
model: Document,
{ transaction }: SaveOptions<Document>
) {
if (model.changed("dataAttributes") && model.dataAttributes) {
const dataAttributeIds = model.dataAttributes.map(
(d) => d.dataAttributeId
);
const definitions = await DataAttribute.findAll({
where: {
id: dataAttributeIds,
},
transaction,
paranoid: false,
});
for (const attr of model.dataAttributes) {
const definition = definitions.find(
(d) => d.id === attr.dataAttributeId
);
if (!definition) {
throw ValidationError(
`Data attribute ${attr.dataAttributeId} not found`
);
}
try {
definition.zodType.parse(attr.value);
} catch (err) {
if (err instanceof ZodError) {
const { path, message } = err.issues[0];
const errMessage =
path.length > 0
? `${path[path.length - 1]}: ${message}`
: message;
throw ValidationError(
`Data attribute ${attr.dataAttributeId} has invalid value: ${errMessage}`
);
}
throw err;
}
}
}
}
@BeforeSave
static async updateCollectionStructure(
model: Document,
+2
View File
@@ -8,6 +8,8 @@ export { default as Backlink } from "./Backlink";
export { default as Collection } from "./Collection";
export { default as DataAttribute } from "./DataAttribute";
export { default as GroupMembership } from "./GroupMembership";
export { default as UserMembership } from "./UserMembership";
+28
View File
@@ -0,0 +1,28 @@
import env from "@server/env";
import { User, Team, DataAttribute } from "@server/models";
import { allow } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable } from "./utils";
const isEnabled = !env.isCloudHosted;
allow(User, "createDataAttribute", Team, (actor, team) =>
and(
//
isTeamAdmin(actor, team),
isTeamMutable(actor),
!actor.isSuspended,
!env.isCloudHosted
)
);
allow(User, "listDataAttribute", Team, (actor, team) =>
and(isTeamModel(actor, team), isEnabled)
);
allow(User, "read", DataAttribute, (actor, team) =>
and(isTeamModel(actor, team), isEnabled)
);
allow(User, ["update", "delete"], DataAttribute, (actor, team) =>
and(isTeamAdmin(actor, team), isEnabled)
);
+3
View File
@@ -9,6 +9,7 @@ import {
Group,
Notification,
UserMembership,
DataAttribute,
} from "@server/models";
import { _abilities, _can, _cannot, _authorize } from "./cancan";
import "./apiKey";
@@ -16,6 +17,7 @@ import "./attachment";
import "./authenticationProvider";
import "./collection";
import "./comment";
import "./dataAttribute";
import "./document";
import "./fileOperation";
import "./integration";
@@ -54,6 +56,7 @@ export function serialize(
| Attachment
| Collection
| Comment
| DataAttribute
| FileOperation
| Team
| Document
+15
View File
@@ -0,0 +1,15 @@
import DataAttribute from "@server/models/DataAttribute";
export default function presentDataAttribute(dataAttribute: DataAttribute) {
return {
id: dataAttribute.id,
name: dataAttribute.name,
description: dataAttribute.description,
dataType: dataAttribute.dataType,
options: dataAttribute.options,
pinned: dataAttribute.pinned,
createdAt: dataAttribute.createdAt,
updatedAt: dataAttribute.updatedAt,
deletedAt: dataAttribute.deletedAt,
};
}
+1
View File
@@ -76,6 +76,7 @@ async function presentDocument(
if (!options.isPublic) {
const source = await document.$get("import");
data.dataAttributes = document.dataAttributes;
data.isCollectionDeleted = await document.isCollectionDeleted();
data.collectionId = document.collectionId;
data.parentDocumentId = document.parentDocumentId;
+2
View File
@@ -4,6 +4,7 @@ import presentAuthenticationProvider from "./authenticationProvider";
import presentAvailableTeam from "./availableTeam";
import presentCollection from "./collection";
import presentComment from "./comment";
import presentDataAttribute from "./dataAttribute";
import presentDocument from "./document";
import presentEvent from "./event";
import presentFileOperation from "./fileOperation";
@@ -32,6 +33,7 @@ export {
presentAvailableTeam,
presentCollection,
presentComment,
presentDataAttribute,
presentDocument,
presentEvent,
presentFileOperation,
@@ -16,10 +16,12 @@ import {
Notification,
UserMembership,
User,
DataAttribute,
} from "@server/models";
import {
presentComment,
presentCollection,
presentDataAttribute,
presentDocument,
presentFileOperation,
presentGroup,
@@ -371,6 +373,22 @@ export default class WebsocketsProcessor {
.emit(event.name, presentFileOperation(fileOperation));
}
case "dataAttributes.create":
case "dataAttributes.update": {
const dataAttribute = await DataAttribute.findByPk(event.modelId);
if (!dataAttribute) {
return;
}
return socketio
.to(`team-${dataAttribute.teamId}`)
.emit(event.name, presentDataAttribute(dataAttribute));
}
case "dataAttributes.delete": {
return socketio.to(`team-${event.teamId}`).emit(event.name, {
modelId: event.modelId,
});
}
case "pins.create":
case "pins.update": {
const pin = await Pin.findByPk(event.modelId);
+3 -15
View File
@@ -1,10 +1,8 @@
import emojiRegex from "emoji-regex";
import isUndefined from "lodash/isUndefined";
import { z } from "zod";
import { CollectionPermission, FileOperationFormat } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { Collection } from "@server/models";
import { zodEnumFromObjectKeys } from "@server/utils/zod";
import { zodIconType } from "@server/utils/zod";
import { ValidateColor, ValidateIndex } from "@server/validation";
import { BaseSchema, ProsemirrorSchema } from "../schema";
@@ -27,12 +25,7 @@ export const CollectionsCreateSchema = BaseSchema.extend({
.nullish()
.transform((val) => (isUndefined(val) ? null : val)),
sharing: z.boolean().default(true),
icon: z
.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
])
.optional(),
icon: zodIconType().optional(),
sort: z
.object({
field: z.union([z.literal("title"), z.literal("index")]),
@@ -171,12 +164,7 @@ export const CollectionsUpdateSchema = BaseSchema.extend({
name: z.string().optional(),
description: z.string().nullish(),
data: ProsemirrorSchema.nullish(),
icon: z
.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
])
.nullish(),
icon: zodIconType().nullish(),
permission: z.nativeEnum(CollectionPermission).nullish(),
color: z
.string()
@@ -0,0 +1,175 @@
import Router from "koa-router";
import { UserRole } from "@shared/types";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { DataAttribute, Event } from "@server/models";
import { authorize } from "@server/policies";
import { presentDataAttribute, presentPolicies } from "@server/presenters";
import { APIContext } from "@server/types";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"dataAttributes.info",
auth(),
validate(T.DataAttributesInfoSchema),
async (ctx: APIContext<T.DataAttributesInfoReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const dataAttribute = await DataAttribute.findByPk(id, {
rejectOnEmpty: true,
});
authorize(user, "read", dataAttribute);
ctx.body = {
data: presentDataAttribute(dataAttribute),
};
}
);
router.post(
"dataAttributes.list",
auth(),
validate(T.DataAttributesListSchema),
pagination(),
async (ctx: APIContext<T.DataAttributesListReq>) => {
const { sort, direction } = ctx.input.body;
const { user } = ctx.state.auth;
const dataAttributes = await DataAttribute.findAll({
where: { teamId: user.teamId },
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = {
data: dataAttributes.map(presentDataAttribute),
};
}
);
router.post(
"dataAttributes.create",
auth({ role: UserRole.Admin }),
validate(T.DataAttributesCreateSchema),
transaction(),
async (ctx: APIContext<T.DataAttributesCreateReq>) => {
const { name, description, dataType, options, pinned } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const dataAttribute = await DataAttribute.create(
{
name,
description,
createdById: user.id,
teamId: user.teamId,
dataType,
options,
pinned,
},
{ transaction }
);
await Event.createFromContext(
ctx,
{
name: "dataAttributes.create",
modelId: dataAttribute.id,
data: {
name,
},
},
{ transaction }
);
ctx.body = {
data: presentDataAttribute(dataAttribute),
policies: presentPolicies(user, [dataAttribute]),
};
}
);
router.post(
"dataAttributes.update",
auth({ role: UserRole.Admin }),
validate(T.DataAttributesUpdateSchema),
transaction(),
async (ctx: APIContext<T.DataAttributesUpdateReq>) => {
const { id, ...input } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const dataAttribute = await DataAttribute.findByPk(id, {
rejectOnEmpty: true,
transaction,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "update", dataAttribute);
dataAttribute.set(input);
const changes = dataAttribute.changeset;
await dataAttribute.save({ transaction });
await Event.createFromContext(
ctx,
{
name: "dataAttributes.update",
modelId: dataAttribute.id,
changes,
},
{ transaction }
);
ctx.body = {
data: presentDataAttribute(dataAttribute),
policies: presentPolicies(user, [dataAttribute]),
};
}
);
router.post(
"dataAttributes.delete",
auth({ role: UserRole.Admin }),
validate(T.DataAttributesDeleteSchema),
transaction(),
async (ctx: APIContext<T.DataAttributesDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const dataAttribute = await DataAttribute.findByPk(id, {
lock: transaction.LOCK.UPDATE,
rejectOnEmpty: true,
transaction,
});
authorize(user, "delete", dataAttribute);
await dataAttribute.destroy({ transaction });
await Event.createFromContext(
ctx,
{
name: "dataAttributes.delete",
modelId: dataAttribute.id,
data: {
name: dataAttribute.name,
},
},
{ transaction }
);
ctx.body = {
success: true,
};
}
);
export default router;
@@ -0,0 +1 @@
export { default } from "./dataAttributes";
+118
View File
@@ -0,0 +1,118 @@
import z from "zod";
import { DataAttributeDataType } from "@shared/models/types";
import { zodIconType } from "@server/utils/zod";
import { BaseSchema } from "../schema";
const BaseIdSchema = z.object({
/** Id of the data attribute */
id: z.string().uuid(),
});
const DataAttributesSortParamsSchema = z.object({
/** Specifies the attributes by which data attributes will be sorted in the list */
sort: z
.string()
.refine((val) => ["createdAt", "updatedAt"].includes(val))
.default("createdAt"),
/** Specifies the sort order with respect to sort field */
direction: z
.string()
.optional()
.transform((val) => (val !== "ASC" ? "DESC" : val)),
});
export const DataAttributesInfoSchema = BaseSchema.extend({
body: BaseIdSchema,
});
export type DataAttributesInfoReq = z.infer<typeof DataAttributesInfoSchema>;
export const DataAttributesListSchema = BaseSchema.extend({
body: DataAttributesSortParamsSchema,
});
export type DataAttributesListReq = z.infer<typeof DataAttributesListSchema>;
export const DataAttributesCreateSchema = BaseSchema.extend({
body: z
.object({
/** Name of the data attribute */
name: z.string(),
/** Description of the data attribute */
description: z.string().optional(),
/** Type of the data attribute */
dataType: z.nativeEnum(DataAttributeDataType),
/** Additional options for the data attribute */
options: z
.object({
/** An icon representing the data attribute */
icon: zodIconType().optional(),
options: z.array(
z.object({
/** Label of the option */
value: z.string(),
/** Color of the option */
color: z.string().optional(),
})
),
})
.optional(),
/** Whether the data attribute is pinned to the top of document */
pinned: z.boolean().optional(),
})
.refine(
(val) =>
val.dataType !== DataAttributeDataType.List ||
(val.dataType === DataAttributeDataType.List && val.options)
),
});
export type DataAttributesCreateReq = z.infer<
typeof DataAttributesCreateSchema
>;
export const DataAttributesUpdateSchema = BaseSchema.extend({
body: BaseIdSchema.extend({
/** Name of the data attribute */
name: z.string(),
/** Description of the data attribute */
description: z.string().optional(),
/** Additional options for the data attribute */
options: z
.object({
/** An icon representing the data attribute */
icon: zodIconType().nullish(),
options: z.array(
z.object({
/** Label of the option */
value: z.string(),
/** Color of the option */
color: z.string().optional(),
})
),
})
.optional(),
/** Whether the data attribute is pinned to the top of document */
pinned: z.boolean().optional(),
}),
});
export type DataAttributesUpdateReq = z.infer<
typeof DataAttributesUpdateSchema
>;
export const DataAttributesDeleteSchema = BaseSchema.extend({
body: BaseIdSchema,
});
export type DataAttributesDeleteReq = z.infer<
typeof DataAttributesDeleteSchema
>;
@@ -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 });
+2
View File
@@ -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,
+23 -14
View File
@@ -4,10 +4,9 @@ import isEmpty from "lodash/isEmpty";
import isUUID from "validator/lib/isUUID";
import { z } from "zod";
import { DocumentPermission, StatusFilter } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { BaseSchema } from "@server/routes/api/schema";
import { zodEnumFromObjectKeys } from "@server/utils/zod";
import { zodIconType } from "@server/utils/zod";
import { ValidateColor } from "@server/validation";
const DocumentsSortParamsSchema = z.object({
@@ -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()
+2
View File
@@ -14,6 +14,7 @@ import authenticationProviders from "./authenticationProviders";
import collections from "./collections";
import comments from "./comments/comments";
import cron from "./cron";
import dataAttributes from "./dataAttributes";
import developer from "./developer";
import documents from "./documents";
import events from "./events";
@@ -70,6 +71,7 @@ router.use("/", events.routes());
router.use("/", users.routes());
router.use("/", collections.routes());
router.use("/", comments.routes());
router.use("/", dataAttributes.routes());
router.use("/", documents.routes());
router.use("/", pins.routes());
router.use("/", revisions.routes());
+15
View File
@@ -4,6 +4,7 @@ import isNull from "lodash/isNull";
import randomstring from "randomstring";
import { InferCreationAttributes } from "sequelize";
import { v4 as uuidv4 } from "uuid";
import { DataAttributeDataType } from "@shared/models/types";
import {
CollectionPermission,
FileOperationState,
@@ -19,6 +20,7 @@ import {
Team,
User,
Event,
DataAttribute,
Document,
Star,
Collection,
@@ -400,6 +402,19 @@ export async function buildDocument(
return document;
}
export async function buildDataAttribute({
userId,
...overrides
}: Partial<DataAttribute> & { userId: string }) {
const dataAttribute = await DataAttribute.create({
dataType: DataAttributeDataType.String,
createdById: userId,
name: faker.company.name(),
...overrides,
});
return dataAttribute;
}
export async function buildComment(overrides: {
userId: string;
documentId: string;
+11
View File
@@ -36,6 +36,7 @@ import type {
Notification,
Share,
GroupMembership,
DataAttribute,
} from "./models";
export enum AuthenticationType {
@@ -259,6 +260,15 @@ export type CollectionGroupEvent = BaseEvent<GroupMembership> & {
data: { name: string };
};
export type DataAttributeEvent = BaseEvent<DataAttribute> & {
name:
| "dataAttributes.create"
| "dataAttributes.update"
| "dataAttributes.delete";
modelId: string;
data: { name: string };
};
export type DocumentUserEvent = BaseEvent<UserMembership> & {
name: "documents.add_user" | "documents.remove_user";
userId: string;
@@ -439,6 +449,7 @@ export type Event =
| ApiKeyEvent
| AttachmentEvent
| AuthenticationProviderEvent
| DataAttributeEvent
| DocumentEvent
| DocumentUserEvent
| DocumentGroupEvent
+8
View File
@@ -1,4 +1,6 @@
import emojiRegex from "emoji-regex";
import { z } from "zod";
import { IconLibrary } from "@shared/utils/IconLibrary";
export function zodEnumFromObjectKeys<
TI extends Record<string, any>,
@@ -7,3 +9,9 @@ export function zodEnumFromObjectKeys<
const [firstKey, ...otherKeys] = Object.keys(input) as [R, ...R[]];
return z.enum([firstKey, ...otherKeys]);
}
export const zodIconType = () =>
z.union([
z.string().regex(emojiRegex()),
zodEnumFromObjectKeys(IconLibrary.mapping),
]);
@@ -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 youre online": "Edits you make will sync once youre online",
"Submenu": "Submenu",
"Format": "Format",
"Add option": "Add option",
"Description": "Description",
"Optional": "Optional",
"Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
"Default collection": "Default collection",
"Install now": "Install now",
@@ -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",
+24
View File
@@ -0,0 +1,24 @@
import { Primitive } from "utility-types";
export enum DataAttributeDataType {
String = "string",
Number = "number",
Boolean = "boolean",
List = "list",
}
export type DocumentDataAttribute = {
dataAttributeId: string;
value: Primitive;
updatedAt: string;
};
export type DataAttributeOptions = {
/** An icon to display next to the attribute. */
icon?: string;
/** Valid options for list data type. */
options?: {
value: string;
color?: string;
}[];
};
+3 -2
View File
@@ -309,7 +309,7 @@ export class IconLibrary {
},
globe: {
component: GlobeIcon,
keywords: "world translate",
keywords: "world website translate",
},
hashtag: {
component: HashtagIcon,
@@ -365,7 +365,8 @@ export class IconLibrary {
},
padlock: {
component: PadlockIcon,
keywords: "padlock private security authentication authorization auth",
keywords:
"padlock private privacy security authentication authorization auth",
},
palette: {
component: PaletteIcon,
+30
View File
@@ -22,10 +22,40 @@ export const AttachmentValidation = {
export const ApiKeyValidation = {
/** The minimum length of the API key name */
minNameLength: 3,
/** The maximum length of the API key name */
maxNameLength: 255,
};
export const DataAttributeValidation = {
/** The minimum length of the name */
minNameLength: 3,
/** The maximum length of the name */
maxNameLength: 255,
/** The minimum length of a list option */
minOptionLength: 1,
/** The maximum length of a list option */
maxOptionLength: 50,
/** The maximum length of the description */
maxDescriptionLength: 1000,
/** The maximum number of data attributes */
max: 25,
/** The maximum number of data attributes that can be pinned */
maxPinned: 3,
/** The minimum number of options for a list attribute */
minOptions: 2,
/** The maximum number of options for a list attribute */
maxOptions: 10,
};
export const CollectionValidation = {
/** The maximum length of the collection description */
maxDescriptionLength: 10 * 1000,