From 04d0dc2bdb3986e105b4a7da6cd5db44b03d251a Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Sat, 15 Jul 2023 22:29:36 +0200 Subject: [PATCH] feat: Variants --- apps/backend/collaboration/src/writing.ts | 45 +++- apps/web/src/context/authenticated.tsx | 17 +- apps/web/src/context/cache/content-pieces.ts | 19 +- apps/web/src/context/cache/index.tsx | 26 +- .../src/context/cache/opened-content-piece.ts | 46 ++-- apps/web/src/context/ui.tsx | 1 + apps/web/src/layout/secured-layout.tsx | 2 +- apps/web/src/layout/side-panel.tsx | 9 +- apps/web/src/layout/sidebar-menu.tsx | 82 +++--- apps/web/src/lib/utils/validate.ts | 7 +- apps/web/src/views/content-piece/index.tsx | 47 +++- apps/web/src/views/content-piece/metadata.tsx | 51 ++-- .../src/views/content-piece/sections/index.ts | 1 + .../views/content-piece/sections/variants.tsx | 122 +++++++++ apps/web/src/views/dashboard/column.tsx | 19 +- apps/web/src/views/dashboard/index.tsx | 67 ++++- apps/web/src/views/editor/editor.tsx | 6 +- apps/web/src/views/editor/index.tsx | 23 +- .../src/views/extensions/extension-card.tsx | 58 ++--- .../settings/api/configure-subsection.tsx | 5 + apps/web/src/views/settings/api/index.tsx | 3 +- .../variants/configure-subsection.tsx | 140 +++++++++++ .../web/src/views/settings/variants/index.tsx | 206 ++++++++++++++++ apps/web/src/views/settings/view.tsx | 8 +- .../web/src/views/settings/webhooks/index.tsx | 2 +- .../workspace/configure-role-subsection.tsx | 10 + .../backend/src/database/comment-threads.ts | 2 +- .../src/database/content-piece-variants.ts | 58 +++++ .../backend/src/database/content-pieces.ts | 2 +- .../backend/src/database/content-variants.ts | 18 ++ packages/backend/src/database/index.ts | 3 + packages/backend/src/database/roles.ts | 3 +- packages/backend/src/database/tokens.ts | 4 +- packages/backend/src/database/variants.ts | 29 +++ packages/backend/src/lib/workspace.ts | 4 +- packages/backend/src/plugins/database.ts | 14 +- packages/backend/src/routes/content-pieces.ts | 233 +++++++++++++++--- packages/backend/src/routes/extensions.ts | 1 - packages/backend/src/routes/index.ts | 4 +- packages/backend/src/routes/variants.ts | 121 +++++++++ packages/backend/src/routes/webhooks.ts | 5 +- 41 files changed, 1302 insertions(+), 221 deletions(-) create mode 100644 apps/web/src/views/content-piece/sections/variants.tsx create mode 100644 apps/web/src/views/settings/variants/configure-subsection.tsx create mode 100644 apps/web/src/views/settings/variants/index.tsx create mode 100644 packages/backend/src/database/content-piece-variants.ts create mode 100644 packages/backend/src/database/content-variants.ts create mode 100644 packages/backend/src/database/variants.ts create mode 100644 packages/backend/src/routes/variants.ts diff --git a/apps/backend/collaboration/src/writing.ts b/apps/backend/collaboration/src/writing.ts index a05c37e1..fed937db 100644 --- a/apps/backend/collaboration/src/writing.ts +++ b/apps/backend/collaboration/src/writing.ts @@ -1,4 +1,4 @@ -import { publicPlugin, getContentsCollection } from "@vrite/backend"; +import { publicPlugin, getContentsCollection, getContentVariantsCollection } from "@vrite/backend"; import { Server } from "@hocuspocus/server"; import { Database } from "@hocuspocus/extension-database"; import { ObjectId, Binary } from "mongodb"; @@ -7,6 +7,7 @@ import { unauthorized } from "@vrite/backend/src/lib/errors"; const writingPlugin = publicPlugin(async (fastify) => { const contentsCollection = getContentsCollection(fastify.mongo.db!); + const contentVariantsCollection = getContentVariantsCollection(fastify.mongo.db!); const server = Server.configure({ port: fastify.config.PORT, address: fastify.config.HOST, @@ -38,24 +39,54 @@ const writingPlugin = publicPlugin(async (fastify) => { return null; } - const articleContent = await contentsCollection.findOne({ - contentPieceId: new ObjectId(documentName) + const [contentPieceId, variantId] = documentName.split(":"); + + if (variantId) { + const contentVariant = await contentVariantsCollection.findOne({ + contentPieceId: new ObjectId(contentPieceId), + variantId: new ObjectId(variantId) + }); + + if (contentVariant && contentVariant.content) { + return new Uint8Array(contentVariant.content.buffer); + } + } + + const content = await contentsCollection.findOne({ + contentPieceId: new ObjectId(contentPieceId) }); - if (articleContent && articleContent.content) { - return new Uint8Array(articleContent.content.buffer); + if (content && content.content) { + return new Uint8Array(content.content.buffer); } return null; }, - store({ documentName, state }) { + store({ documentName, state, ...details }) { + const [contentPieceId, variantId] = documentName.split(":"); + if (documentName.startsWith("workspace:")) { return; } if (state) { + if (variantId) { + if (!(details as { update?: any }).update) { + return; + } + + return contentVariantsCollection?.updateOne( + { + contentPieceId: new ObjectId(contentPieceId), + variantId: new ObjectId(variantId) + }, + { $set: { content: new Binary(state) } }, + { upsert: true } + ); + } + return contentsCollection?.updateOne( - { contentPieceId: new ObjectId(documentName) }, + { contentPieceId: new ObjectId(contentPieceId) }, { $set: { content: new Binary(state) } }, { upsert: true } ); diff --git a/apps/web/src/context/authenticated.tsx b/apps/web/src/context/authenticated.tsx index 745f1d6b..0d3255f7 100644 --- a/apps/web/src/context/authenticated.tsx +++ b/apps/web/src/context/authenticated.tsx @@ -19,7 +19,7 @@ interface AuthenticatedContextValue { membership: Accessor; workspace: Accessor | null>; workspaceSettings: Accessor; - role: Accessor; + role: Accessor | null>; deletedTags: Accessor; currentWorkspaceId: Accessor; } @@ -75,13 +75,12 @@ const AuthenticatedContextProvider: ParentComponent = (props) => { >(currentWorkspaceId, () => client.workspaceSettings.get.query(), { initialValue: null }); - const [role, { mutate: setRole }] = createResource( - currentWorkspaceId, - () => client.roles.get.query(), - { - initialValue: null - } - ); + const [role, { mutate: setRole }] = createResource< + App.ExtendedRole<"baseType"> | null, + string | null + >(currentWorkspaceId, () => client.roles.get.query(), { + initialValue: null + }); const loading = (): boolean => { return ( currentWorkspaceId.loading || @@ -235,7 +234,7 @@ const useAuthenticatedContext = (): AuthenticatedContextValue => { const hasPermission = (permission: App.Permission): boolean => { const { role } = useAuthenticatedContext(); - return role()?.permissions.includes(permission) || false; + return role()?.permissions.includes(permission) || role()?.baseType === "admin" || false; }; export { AuthenticatedContextProvider, useAuthenticatedContext, hasPermission }; diff --git a/apps/web/src/context/cache/content-pieces.ts b/apps/web/src/context/cache/content-pieces.ts index 591adb29..fd884fd6 100644 --- a/apps/web/src/context/cache/content-pieces.ts +++ b/apps/web/src/context/cache/content-pieces.ts @@ -37,7 +37,9 @@ const useContentPieces = (contentGroupId: string): UseContentPieces => { const contentPiecesChanges = client.contentPieces.changes.subscribe( { contentGroupId }, { - onData({ action, data }) { + onData(value) { + const { action, data } = value; + switch (action) { case "delete": setState("contentPieces", (contentPieces) => { @@ -51,11 +53,16 @@ const useContentPieces = (contentGroupId: string): UseContentPieces => { setState("contentPieces", (contentPieces) => [data, ...contentPieces]); break; case "update": - setState( - "contentPieces", - state.contentPieces.findIndex((contentPiece) => contentPiece.id === data.id), - (contentPiece) => ({ ...contentPiece, ...data }) - ); + if (!("variantId" in value)) { + setState( + "contentPieces", + state.contentPieces.findIndex((contentPiece) => contentPiece.id === data.id), + (contentPiece) => { + return { ...contentPiece, ...data }; + } + ); + } + break; case "move": setState("contentPieces", (contentPieces) => { diff --git a/apps/web/src/context/cache/index.tsx b/apps/web/src/context/cache/index.tsx index f3d08db7..1f6f3112 100644 --- a/apps/web/src/context/cache/index.tsx +++ b/apps/web/src/context/cache/index.tsx @@ -1,43 +1,19 @@ -import { UseContentGroups, useContentGroups } from "./content-groups"; -import { UseContentPieces, useContentPieces } from "./content-pieces"; import { UseOpenedContentPiece, useOpenedContentPiece } from "./opened-content-piece"; -import { ParentComponent, createContext, createEffect, on, onCleanup, useContext } from "solid-js"; +import { ParentComponent, createContext, useContext } from "solid-js"; interface CacheContextData { - useContentGroups(): UseContentGroups; useOpenedContentPiece(): UseOpenedContentPiece; - useContentPieces(contentGroupId: string): UseContentPieces; } const CacheContext = createContext(); const CacheContextProvider: ParentComponent = (props) => { - const useContentGroupsCache = useContentGroups(); const useOpenedContentPieceCache = useOpenedContentPiece(); - let useContentPiecesCache: Record = {}; - - createEffect( - on(useContentGroupsCache.contentGroups, (contentGroups) => { - contentGroups.forEach(({ id }) => { - useContentPiecesCache[id] = useContentPiecesCache[id] || useContentPieces(id); - }); - onCleanup(() => { - useContentPiecesCache = {}; - }); - }) - ); - return ( diff --git a/apps/web/src/context/cache/opened-content-piece.ts b/apps/web/src/context/cache/opened-content-piece.ts index 5c35a57b..b6253ea3 100644 --- a/apps/web/src/context/cache/opened-content-piece.ts +++ b/apps/web/src/context/cache/opened-content-piece.ts @@ -1,10 +1,11 @@ -import { createSignal, createEffect, on, onCleanup } from "solid-js"; +import { createSignal, createEffect, on, onCleanup, Accessor } from "solid-js"; import { createStore } from "solid-js/store"; import { useAuthenticatedContext } from "#context/authenticated"; import { useClientContext, App } from "#context/client"; import { useUIContext } from "#context/ui"; interface UseOpenedContentPiece { + activeVariant: Accessor; setContentPiece< K extends keyof App.ExtendedContentPieceWithAdditionalData<"locked" | "coverWidth"> >( @@ -12,10 +13,12 @@ interface UseOpenedContentPiece { value?: App.ExtendedContentPieceWithAdditionalData<"locked" | "coverWidth">[K] ): void; loading(): boolean; + setActiveVariant(variant: App.Variant | null): void; contentPiece(): App.ExtendedContentPieceWithAdditionalData<"locked" | "coverWidth"> | null; } const useOpenedContentPiece = (): UseOpenedContentPiece => { + const [activeVariant, setActiveVariant] = createSignal(null); const { deletedTags } = useAuthenticatedContext(); const { profile } = useAuthenticatedContext(); const { storage, setStorage } = useUIContext(); @@ -32,7 +35,8 @@ const useOpenedContentPiece = (): UseOpenedContentPiece => { if (contentPieceId) { try { const contentPiece = await client.contentPieces.get.query({ - id: contentPieceId + id: contentPieceId, + variant: activeVariant()?.id }); setState({ contentPiece }); @@ -48,9 +52,11 @@ const useOpenedContentPiece = (): UseOpenedContentPiece => { createEffect( on( - () => storage().contentPieceId, - (contentPieceId, previousContentPieceId) => { - if (contentPieceId !== previousContentPieceId) { + [() => storage().contentPieceId, () => activeVariant()?.id || ""], + ([contentPieceId, variantId], previous) => { + const [previousContentPieceId, previousVariantId] = previous || []; + + if (contentPieceId !== previousContentPieceId || variantId !== previousVariantId) { fetchContentPiece(); } @@ -69,22 +75,26 @@ const useOpenedContentPiece = (): UseOpenedContentPiece => { contentGroupId }, { - onData({ data, action, userId = "" }) { + onData(value) { + const { data, action, userId = "" } = value; + if (action === "update") { - const { tags, members, ...updateData } = data; - const update: Partial< - App.ExtendedContentPieceWithAdditionalData<"locked" | "coverWidth"> - > = { ...updateData }; + if (!("variantId" in value) || value.variantId === activeVariant()?.id) { + const { tags, members, ...updateData } = data; + const update: Partial< + App.ExtendedContentPieceWithAdditionalData<"locked" | "coverWidth"> + > = { ...updateData }; - if (data.members && userId !== profile()?.id) { - update.members = data.members.filter((member) => member.id !== profile()?.id); - } + if (data.members && userId !== profile()?.id) { + update.members = data.members.filter((member) => member.id !== profile()?.id); + } - if (data.tags && userId !== profile()?.id) { - update.tags = data.tags.filter((tag) => !deletedTags().includes(tag.id)); - } + if (data.tags && userId !== profile()?.id) { + update.tags = data.tags.filter((tag) => !deletedTags().includes(tag.id)); + } - setState("contentPiece", update); + setState("contentPiece", update); + } } else if (action === "delete") { setState("contentPiece", null); setStorage((storage) => ({ ...storage, contentPieceId: undefined })); @@ -125,6 +135,8 @@ const useOpenedContentPiece = (): UseOpenedContentPiece => { return { loading, + activeVariant, + setActiveVariant, contentPiece: () => state.contentPiece, setContentPiece: (keyOrObject, value) => { if (typeof keyOrObject === "string" && value) { diff --git a/apps/web/src/context/ui.tsx b/apps/web/src/context/ui.tsx index eac61e98..f2f8f99d 100644 --- a/apps/web/src/context/ui.tsx +++ b/apps/web/src/context/ui.tsx @@ -24,6 +24,7 @@ interface StorageData { } interface ReferencesData { editedContentPiece?: App.ExtendedContentPieceWithAdditionalData<"locked">; + activeVariant?: App.Variant; provider?: HocuspocusProvider; editor?: SolidEditor; } diff --git a/apps/web/src/layout/secured-layout.tsx b/apps/web/src/layout/secured-layout.tsx index 99e97ba7..ed997e2e 100644 --- a/apps/web/src/layout/secured-layout.tsx +++ b/apps/web/src/layout/secured-layout.tsx @@ -51,7 +51,7 @@ const SecuredLayout: ParentComponent = (props) => {
diff --git a/apps/web/src/layout/side-panel.tsx b/apps/web/src/layout/side-panel.tsx index 6a03741b..a50a6af0 100644 --- a/apps/web/src/layout/side-panel.tsx +++ b/apps/web/src/layout/side-panel.tsx @@ -89,11 +89,11 @@ const SidePanel: Component = () => { return (
{ >
diff --git a/apps/web/src/layout/sidebar-menu.tsx b/apps/web/src/layout/sidebar-menu.tsx index a6a3e9bf..14f2945a 100644 --- a/apps/web/src/layout/sidebar-menu.tsx +++ b/apps/web/src/layout/sidebar-menu.tsx @@ -127,7 +127,7 @@ const ProfileMenu: Component<{ close(): void }> = (props) => { const menuItems = useMenuItems(); return ( -
+
Profile @@ -166,7 +166,7 @@ const ProfileMenu: Component<{ close(): void }> = (props) => {
- + { > { ( - - )} + + } + > +
+ + +
+ Menu +
+ + ); + }} opened={profileMenuOpened()} setOpened={setProfileMenuOpened} placement="right-end" diff --git a/apps/web/src/lib/utils/validate.ts b/apps/web/src/lib/utils/validate.ts index 8e2cb229..7083fa3a 100644 --- a/apps/web/src/lib/utils/validate.ts +++ b/apps/web/src/lib/utils/validate.ts @@ -10,6 +10,11 @@ const validateUsername = (input: string): boolean => { return usernameRegex.test(input); }; +const validateVariantName = (input: string): boolean => { + const variantRegex = /^[a-z0-9_]*$/; + + return variantRegex.test(input); +}; const validateURL = (input: string): boolean => { let url: URL | null = null; @@ -27,4 +32,4 @@ const validatePassword = (input: string): boolean => { return passwordRegex.test(input); }; -export { validateEmail, validateUsername, validateURL, validatePassword }; +export { validateEmail, validateUsername, validateVariantName, validateURL, validatePassword }; diff --git a/apps/web/src/views/content-piece/index.tsx b/apps/web/src/views/content-piece/index.tsx index aaccfa49..0c17d8b6 100644 --- a/apps/web/src/views/content-piece/index.tsx +++ b/apps/web/src/views/content-piece/index.tsx @@ -2,12 +2,24 @@ import { ContentPieceTitle } from "./title"; import { ContentPieceDescription } from "./description"; import { ContentPieceMetadata } from "./metadata"; import { Component, createEffect, createMemo, createSignal, on, Show } from "solid-js"; -import { mdiDotsVertical, mdiTrashCan, mdiPencil, mdiLock, mdiEye, mdiClose } from "@mdi/js"; +import { + mdiDotsVertical, + mdiTrashCan, + mdiPencil, + mdiLock, + mdiEye, + mdiClose, + mdiCardsOutline, + mdiCards, + mdiInformationOutline, + mdiCodeJson, + mdiPuzzleOutline +} from "@mdi/js"; import dayjs from "dayjs"; import CustomParseFormat from "dayjs/plugin/customParseFormat"; import { useLocation, useNavigate } from "@solidjs/router"; import { Image } from "#lib/editor"; -import { Card, IconButton, Dropdown, Loader } from "#components/primitives"; +import { Card, IconButton, Dropdown, Loader, Tooltip } from "#components/primitives"; import { useConfirmationContext, App, @@ -21,17 +33,25 @@ import { MiniEditor } from "#components/fragments"; dayjs.extend(CustomParseFormat); const ContentPieceView: Component = () => { + const sections = [ + { label: "Details", id: "details", icon: mdiInformationOutline }, + { label: "Custom data", id: "custom-data", icon: mdiCodeJson }, + { label: "Extensions", id: "extensions", icon: mdiPuzzleOutline }, + { label: "Variants", id: "variants", icon: mdiCardsOutline } + ]; const { useOpenedContentPiece } = useCacheContext(); const { client } = useClientContext(); const { setStorage, breakpoints } = useUIContext(); const { confirmDelete } = useConfirmationContext(); - const { contentPiece, setContentPiece, loading } = useOpenedContentPiece(); + const { contentPiece, setContentPiece, loading, activeVariant, setActiveVariant } = + useOpenedContentPiece(); const location = useLocation(); const navigate = useNavigate(); const [dropdownMenuOpened, setDropdownMenuOpened] = createSignal(false); const [coverInitialValue, setCoverInitialValue] = createSignal(""); const [titleInitialValue, setTitleInitialValue] = createSignal(""); const [descriptionInitialValue, setDescriptionInitialValue] = createSignal(""); + const [activeSection, setActiveSection] = createSignal(sections[0]); const editable = createMemo(() => { return !contentPiece()?.locked && hasPermission("editMetadata"); }); @@ -59,6 +79,7 @@ const ContentPieceView: Component = () => { client.contentPieces.update.mutate({ id, + variant: activeVariant()?.id, ...contentPieceUpdate }); }; @@ -201,6 +222,21 @@ const ContentPieceView: Component = () => {
+
+ + { + setActiveSection(sections[3]); + }} + /> + +
{ }} /> ; editable?: boolean; + activeSection: ContentPieceMetadataSection; + sections: ContentPieceMetadataSection[]; + activeVariant: App.Variant | null; + setActiveVariant(variant: App.Variant | null): void; + setActiveSection(activeSection: ContentPieceMetadataSection): void; setContentPiece( value: Partial> ): void; @@ -14,18 +25,6 @@ interface ContentPieceMetadataProps { const ContentPieceMetadata: Component = (props) => { const [menuOpened, setMenuOpened] = createSignal(false); - const sections = [ - { label: "Details", id: "details", icon: mdiInformationOutline }, - { label: "Custom data", id: "custom-data", icon: mdiCodeJson }, - { label: "Extensions", id: "extensions", icon: mdiPuzzleOutline } - ]; - const [activeSection, setActiveSection] = createSignal(sections[0]); - - createEffect(() => { - if (props.contentPiece.locked) { - setActiveSection(sections[0]); - } - }); return ( <> @@ -36,7 +35,7 @@ const ContentPieceMetadata: Component = (props) => { class="mx-0" label={ - {activeSection().label} + {props.activeSection.label} } variant="text" @@ -46,18 +45,18 @@ const ContentPieceMetadata: Component = (props) => { setOpened={setMenuOpened} >
- + {(section) => { return ( { - setActiveSection(section); + props.setActiveSection(section); setMenuOpened(false); }} > @@ -67,7 +66,7 @@ const ContentPieceMetadata: Component = (props) => {
- + = (props) => { }} /> - + = (props) => { }} /> - + { @@ -111,6 +110,12 @@ const ContentPieceMetadata: Component = (props) => { }} /> + + + ); diff --git a/apps/web/src/views/content-piece/sections/index.ts b/apps/web/src/views/content-piece/sections/index.ts index fabe9768..94fd4191 100644 --- a/apps/web/src/views/content-piece/sections/index.ts +++ b/apps/web/src/views/content-piece/sections/index.ts @@ -1,3 +1,4 @@ export * from "./details"; export * from "./custom-data"; export * from "./extensions"; +export * from "./variants"; diff --git a/apps/web/src/views/content-piece/sections/variants.tsx b/apps/web/src/views/content-piece/sections/variants.tsx new file mode 100644 index 00000000..9d110c2d --- /dev/null +++ b/apps/web/src/views/content-piece/sections/variants.tsx @@ -0,0 +1,122 @@ +import { Accessor, Component, For, Show, createSignal, onCleanup } from "solid-js"; +import { createStore } from "solid-js/store"; +import clsx from "clsx"; +import { App, useClientContext, useUIContext } from "#context"; +import { Button, Loader } from "#components/primitives"; + +interface VariantsSectionProps { + activeVariant: App.Variant | null; + setActiveVariant(variant: App.Variant | null): void; +} + +const useVariants = (): { + loading: Accessor; + variants(): Array; +} => { + const { client } = useClientContext(); + const [loading, setLoading] = createSignal(false); + const [state, setState] = createStore<{ + variants: Array; + }>({ + variants: [] + }); + const loadData = async (): Promise => { + setLoading(true); + client.variants.list.query().then((data) => { + setLoading(false); + setState("variants", (variants) => [...variants, ...data]); + }); + }; + const variantsChanges = client.variants.changes.subscribe(undefined, { + onData({ action, data }) { + switch (action) { + case "create": + setState("variants", (variants) => [data, ...variants]); + break; + case "update": + setState("variants", (variants) => { + return variants.map((variant) => { + if (variant.id === data.id) { + return { ...variant, ...data }; + } + + return variant; + }); + }); + break; + case "delete": + setState("variants", (variants) => { + return variants.filter((variant) => variant.id !== data.id); + }); + break; + } + } + }); + + loadData(); + onCleanup(() => { + variantsChanges.unsubscribe(); + }); + + return { loading, variants: () => state.variants }; +}; +const VariantsSection: Component = (props) => { + const { setReferences } = useUIContext(); + const { loading, variants } = useVariants(); + + return ( +
+
+ No Variants found + } + > + + + } + > + {(variant) => { + const active = (): boolean => { + return props.activeVariant?.id === variant.id; + }; + + return ( + + + ); + }} + +
+
+ ); +}; + +export { VariantsSection }; diff --git a/apps/web/src/views/dashboard/column.tsx b/apps/web/src/views/dashboard/column.tsx index 8afef76c..5757b393 100644 --- a/apps/web/src/views/dashboard/column.tsx +++ b/apps/web/src/views/dashboard/column.tsx @@ -85,7 +85,9 @@ const useContentPieces = (contentGroupId: string): UseContentPieces => { const contentPiecesChanges = client.contentPieces.changes.subscribe( { contentGroupId }, { - onData({ action, data }) { + onData(value) { + const { action, data } = value; + switch (action) { case "delete": setState("contentPieces", (contentPieces) => { @@ -99,11 +101,16 @@ const useContentPieces = (contentGroupId: string): UseContentPieces => { setState("contentPieces", (contentPieces) => [data, ...contentPieces]); break; case "update": - setState( - "contentPieces", - state.contentPieces.findIndex((contentPiece) => contentPiece.id === data.id), - (contentPiece) => ({ ...contentPiece, ...data }) - ); + if (!("variantId" in value)) { + setState( + "contentPieces", + state.contentPieces.findIndex((contentPiece) => contentPiece.id === data.id), + (contentPiece) => { + return { ...contentPiece, ...data }; + } + ); + } + break; case "move": setState("contentPieces", (contentPieces) => { diff --git a/apps/web/src/views/dashboard/index.tsx b/apps/web/src/views/dashboard/index.tsx index eb502396..5b4436c2 100644 --- a/apps/web/src/views/dashboard/index.tsx +++ b/apps/web/src/views/dashboard/index.tsx @@ -1,9 +1,10 @@ import { AddColumn, Column } from "./column"; import { ColumnsContextProvider } from "./columns-context"; -import { Component, Show, createEffect, createSignal, on, onCleanup } from "solid-js"; +import { Accessor, Component, Show, createEffect, createSignal, on, onCleanup } from "solid-js"; import clsx from "clsx"; import { HocuspocusProvider } from "@hocuspocus/provider"; import * as Y from "yjs"; +import { createStore } from "solid-js/store"; import { Sortable } from "#components/primitives"; import { App, @@ -16,8 +17,70 @@ import { import { ScrollShadow } from "#components/fragments"; import { createRef, getSelectionColor } from "#lib/utils"; +interface UseContentGroups { + contentGroups: Accessor; + setContentGroups: (contentGroups: App.ContentGroup[]) => void; +} + +const useContentGroups = (): UseContentGroups => { + const { client } = useClientContext(); + const [state, setState] = createStore<{ + contentGroups: App.ContentGroup[]; + }>({ + contentGroups: [] + }); + const moveContentGroup = (contentGroupId: string, index: number): void => { + const newContentGroups = [...state.contentGroups]; + const contentGroupIndex = newContentGroups.findIndex((contentGroup) => { + return contentGroup.id === contentGroupId; + }); + const [contentGroup] = newContentGroups.splice(contentGroupIndex, 1); + + newContentGroups.splice(index, 0, contentGroup); + setState({ + contentGroups: newContentGroups + }); + }; + + client.contentGroups.list.query().then((contentGroups) => { + setState("contentGroups", contentGroups); + }); + + const contentGroupsChanges = client.contentGroups.changes.subscribe(undefined, { + onData({ action, data }) { + switch (action) { + case "create": + setState({ contentGroups: [...state.contentGroups, data] }); + break; + case "update": + setState( + "contentGroups", + state.contentGroups.findIndex((column) => column.id === data.id), + (contentGroup) => ({ ...contentGroup, ...data }) + ); + break; + case "delete": + setState({ + contentGroups: state.contentGroups.filter((column) => column.id !== data.id) + }); + break; + case "move": + moveContentGroup(data.id, data.index); + break; + } + } + }); + + onCleanup(() => { + contentGroupsChanges.unsubscribe(); + }); + + return { + contentGroups: () => state.contentGroups, + setContentGroups: (contentGroups) => setState("contentGroups", contentGroups) + }; +}; const DashboardView: Component = () => { - const { useContentGroups } = useCacheContext(); const { workspace, profile } = useAuthenticatedContext(); const { contentGroups, setContentGroups } = useContentGroups(); const { storage, setStorage, setReferences } = useUIContext(); diff --git a/apps/web/src/views/editor/editor.tsx b/apps/web/src/views/editor/editor.tsx index f874a334..14de6804 100644 --- a/apps/web/src/views/editor/editor.tsx +++ b/apps/web/src/views/editor/editor.tsx @@ -49,7 +49,7 @@ interface EditorProps { } const Editor: Component = (props) => { - const { setStorage, setReferences, breakpoints } = useUIContext(); + const { setStorage, setReferences, references, breakpoints } = useUIContext(); const navigate = useNavigate(); const ydoc = new Y.Doc(); const provider = new HocuspocusProvider({ @@ -68,7 +68,9 @@ const Editor: Component = (props) => { props.reload?.(); } }, - name: props.editedContentPiece.id || "", + name: `${props.editedContentPiece.id || ""}${references.activeVariant ? ":" : ""}${ + references.activeVariant?.id || "" + }`, document: ydoc }); const [containerRef, setContainerRef] = createRef(null); diff --git a/apps/web/src/views/editor/index.tsx b/apps/web/src/views/editor/index.tsx index 4878acee..ac6afd09 100644 --- a/apps/web/src/views/editor/index.tsx +++ b/apps/web/src/views/editor/index.tsx @@ -1,5 +1,13 @@ import { Editor } from "./editor"; -import { Component, createEffect, createResource, createSignal, onCleanup, Show } from "solid-js"; +import { + Component, + createEffect, + createResource, + createSignal, + on, + onCleanup, + Show +} from "solid-js"; import clsx from "clsx"; import { Loader } from "#components/primitives"; import { useClientContext, useUIContext } from "#context"; @@ -7,7 +15,7 @@ import { createRef } from "#lib/utils"; const EditorView: Component = () => { const { client } = useClientContext(); - const { storage, setStorage } = useUIContext(); + const { storage, setStorage, references } = useUIContext(); const [syncing, setSyncing] = createSignal(true); const [lastScrollTop, setLastScrollTop] = createSignal(0); const [reloaded, setReloaded] = createSignal(false); @@ -18,7 +26,8 @@ const EditorView: Component = () => { setReloaded(false); return client.contentPieces.get.query({ - id: editedArticleId + id: editedArticleId, + variant: references.activeVariant?.id }); } @@ -47,6 +56,14 @@ const EditorView: Component = () => { }); } }); + createEffect( + on( + () => references.activeVariant, + () => { + refetch(); + } + ) + ); return ( <> diff --git a/apps/web/src/views/extensions/extension-card.tsx b/apps/web/src/views/extensions/extension-card.tsx index f9ccee5b..28c6bb87 100644 --- a/apps/web/src/views/extensions/extension-card.tsx +++ b/apps/web/src/views/extensions/extension-card.tsx @@ -1,8 +1,8 @@ -import { ExtensionDetails, useClientContext } from "#context"; +import { ExtensionIcon } from "./extension-icon"; import { mdiTune, mdiDownloadOutline } from "@mdi/js"; +import { Component, Show } from "solid-js"; +import { ExtensionDetails, hasPermission, useClientContext } from "#context"; import { Card, Heading, IconButton } from "#components/primitives"; -import { Component } from "solid-js"; -import { ExtensionIcon } from "./extension-icon"; interface ExtensionCardProps { extension: ExtensionDetails; @@ -21,32 +21,34 @@ const ExtensionCard: Component = (props) => { {props.extension.spec.displayName}
- { - if (props.extension.id) { - props.setOpenedExtension({ - ...props.extension, - config: { ...props.extension.config } - }); - } else { - const { id, token } = await client.extensions.install.mutate({ - extension: { - name: props.extension.spec.name, - displayName: props.extension.spec.displayName, - permissions: props.extension.spec.permissions || [] - } - }); + + { + if (props.extension.id) { + props.setOpenedExtension({ + ...props.extension, + config: { ...props.extension.config } + }); + } else { + const { id, token } = await client.extensions.install.mutate({ + extension: { + name: props.extension.spec.name, + displayName: props.extension.spec.displayName, + permissions: props.extension.spec.permissions || [] + } + }); - props.setOpenedExtension({ ...props.extension, config: {}, id, token }); - } - }} - class="m-0 my-1" - size="small" - /> + props.setOpenedExtension({ ...props.extension, config: {}, id, token }); + } + }} + class="m-0 my-1" + size="small" + /> +

{props.extension.spec.description}

diff --git a/apps/web/src/views/settings/api/configure-subsection.tsx b/apps/web/src/views/settings/api/configure-subsection.tsx index dec5e190..a1648d53 100644 --- a/apps/web/src/views/settings/api/configure-subsection.tsx +++ b/apps/web/src/views/settings/api/configure-subsection.tsx @@ -65,6 +65,11 @@ const ConfigureTokenSubSection: Component = (prop description: "Access and manage Webhooks", permissions: ["webhooks:read", "webhooks:write"] }, + { + label: "Variants", + description: "Access and manage registered Variants", + permissions: ["variants:read", "variants:write"] + }, { label: "Profile", description: "Access your personal profile settings", diff --git a/apps/web/src/views/settings/api/index.tsx b/apps/web/src/views/settings/api/index.tsx index 381dc867..287cab10 100644 --- a/apps/web/src/views/settings/api/index.tsx +++ b/apps/web/src/views/settings/api/index.tsx @@ -1,6 +1,5 @@ import { ConfigureTokenSubSection, FreshToken } from "./configure-subsection"; import { SettingsSectionComponent } from "../view"; -import { TitledCard } from "#components/fragments"; import { mdiClipboardOutline, mdiKey, @@ -24,7 +23,7 @@ import { import { createStore } from "solid-js/store"; import { IconButton, Heading, Input, Tooltip, Loader, Card, Button } from "#components/primitives"; import { App, hasPermission, useClientContext, useNotificationsContext } from "#context"; -import { Motion } from "@motionone/solid"; +import { TitledCard } from "#components/fragments"; const useTokens = (): { loading: Accessor; diff --git a/apps/web/src/views/settings/variants/configure-subsection.tsx b/apps/web/src/views/settings/variants/configure-subsection.tsx new file mode 100644 index 00000000..5b2eba36 --- /dev/null +++ b/apps/web/src/views/settings/variants/configure-subsection.tsx @@ -0,0 +1,140 @@ +import { mdiCheck, mdiTune } from "@mdi/js"; +import { Component, createEffect, createMemo, createSignal, on } from "solid-js"; +import { createStore } from "solid-js/store"; +import { InputField, TitledCard } from "#components/fragments"; +import { IconButton, Button, Tooltip } from "#components/primitives"; +import { App, useClientContext, useNotificationsContext } from "#context"; +import { validateVariantName } from "#lib/utils"; + +interface ConfigureVariantSubsectionProps { + editedVariantData: App.Variant | null; + onVariantConfigured?(): void; + setActionComponent(component: Component<{}> | null): void; +} + +const ConfigureVariantSubsection: Component = (props) => { + const { client } = useClientContext(); + const { notify } = useNotificationsContext(); + const [loading, setLoading] = createSignal(false); + const [variantData, setVariantData] = createStore>({ + description: "", + label: "", + name: "" + }); + const filled = createMemo(() => { + return Boolean(variantData.label && variantData.name && validateVariantName(variantData.name)); + }); + const onClick = async (): Promise => { + setLoading(true); + + try { + if (props.editedVariantData) { + await client.variants.update.mutate({ + id: props.editedVariantData.id, + ...variantData + }); + } else { + await client.variants.create.mutate(variantData); + } + + setLoading(false); + notify({ + type: "success", + text: props.editedVariantData ? "Variant updated" : "New Variant created" + }); + props.onVariantConfigured?.(); + } catch (e) { + let text = "Failed to create new Variant"; + + if (props.editedVariantData) text = "Failed to update the Variant"; + + setLoading(false); + notify({ + type: "error", + text + }); + } + }; + + props.setActionComponent(() => { + return ( + <> + + + + + + ); + }); + createEffect( + on( + () => props.editedVariantData, + (editedVariantData) => { + if (editedVariantData) { + setVariantData((variantData) => editedVariantData || variantData); + } + } + ) + ); + + return ( + + setVariantData("label", value)} + > + Identifiable label for the Variant + + setVariantData("name", value)} + > + Unique name for the Variant. Can only contain lowercase letters, numbers, and underscores. + + setVariantData("description", value)} + > + Additional details about the Variant + + + ); +}; + +export { ConfigureVariantSubsection }; diff --git a/apps/web/src/views/settings/variants/index.tsx b/apps/web/src/views/settings/variants/index.tsx new file mode 100644 index 00000000..380346ec --- /dev/null +++ b/apps/web/src/views/settings/variants/index.tsx @@ -0,0 +1,206 @@ +import { ConfigureVariantSubsection } from "./configure-subsection"; +import { SettingsSectionComponent } from "../view"; +import { + Accessor, + Component, + For, + Show, + createEffect, + createSignal, + on, + onCleanup +} from "solid-js"; +import { mdiFormatListBulleted, mdiPlusCircle, mdiPuzzle, mdiTrashCan, mdiTune } from "@mdi/js"; +import { createStore } from "solid-js/store"; +import { TitledCard } from "#components/fragments"; +import { App, hasPermission, useClientContext, useNotificationsContext } from "#context"; +import { Button, Card, Heading, IconButton, Loader, Tooltip } from "#components/primitives"; + +interface VariantDetailsProps { + variant: App.Variant; + onEdit?(): void; + onDelete?(): void; +} + +const useVariants = (): { + loading: Accessor; + variants(): Array; +} => { + const { client } = useClientContext(); + const [loading, setLoading] = createSignal(false); + const [state, setState] = createStore<{ + variants: Array; + }>({ + variants: [] + }); + const loadData = async (): Promise => { + setLoading(true); + client.variants.list.query().then((data) => { + setLoading(false); + setState("variants", (variants) => [...variants, ...data]); + }); + }; + const variantsChanges = client.variants.changes.subscribe(undefined, { + onData({ action, data }) { + switch (action) { + case "create": + setState("variants", (variants) => [data, ...variants]); + break; + case "update": + setState("variants", (variants) => { + return variants.map((variant) => { + if (variant.id === data.id) { + return { ...variant, ...data }; + } + + return variant; + }); + }); + break; + case "delete": + setState("variants", (variants) => { + return variants.filter((variant) => variant.id !== data.id); + }); + break; + } + } + }); + + loadData(); + onCleanup(() => { + variantsChanges.unsubscribe(); + }); + + return { loading, variants: () => state.variants }; +}; +const VariantDetails: Component = (props) => { + const { client } = useClientContext(); + const { notify } = useNotificationsContext(); + const [loading, setLoading] = createSignal(false); + + return ( + +
+ +
+ +
+ + { + props.onEdit?.(); + }} + /> + + + { + setLoading(true); + await client.variants.delete.mutate({ id: props.variant.id }); + setLoading(false); + props.onDelete?.(); + notify({ text: "Variant deleted", type: "success" }); + }} + /> + +
+
+
+ + {props.variant.label} + +

+ {props.variant.description} +

+ + ); +}; +const VariantsSection: SettingsSectionComponent = (props) => { + const [editedVariantData, setEditedVariantData] = createSignal(null); + const { variants, loading } = useVariants(); + const [configureVariantSectionOpened, setConfigureVariantSectionOpened] = createSignal(false); + + createEffect( + on(configureVariantSectionOpened, (configureVariantSectionOpened) => { + if (!configureVariantSectionOpened) { + setEditedVariantData(null); + props.setSubSection(null); + props.setActionComponent(() => { + return ( + + + + ); + }); + } + }) + ); + + return ( + { + setConfigureVariantSectionOpened(false); + }} + /> + } + > + + }> + No registered Variants

} + > + {(variant) => ( + { + setEditedVariantData(variant); + setConfigureVariantSectionOpened(true); + props.setSubSection({ + label: "Edit Variant", + icon: mdiTune, + goBack() { + setConfigureVariantSectionOpened(false); + } + }); + }} + /> + )} +
+
+
+
+ ); +}; + +export { VariantsSection }; diff --git a/apps/web/src/views/settings/view.tsx b/apps/web/src/views/settings/view.tsx index 8e4ab9f2..559bb157 100644 --- a/apps/web/src/views/settings/view.tsx +++ b/apps/web/src/views/settings/view.tsx @@ -7,10 +7,12 @@ import { ProfileSection } from "./profile"; import { EditorSection } from "./editor"; import { SecuritySection } from "./security"; import { MetadataSection } from "./metadata"; +import { VariantsSection } from "./variants"; import clsx from "clsx"; import { Dynamic } from "solid-js/web"; import { mdiAccount, + mdiCards, mdiChevronLeft, mdiClose, mdiDatabase, @@ -68,7 +70,8 @@ const SettingsView: Component = () => { resize: true, section: "editor" }, - { label: "Metadata", resize: true, icon: mdiDatabase, section: "metadata" } + { label: "Metadata", resize: true, icon: mdiDatabase, section: "metadata" }, + { label: "Variants", icon: mdiCards, section: "variants" } ]; const sections: Record = { menu() { @@ -86,7 +89,8 @@ const SettingsView: Component = () => { profile: ProfileSection, editor: EditorSection, security: SecuritySection, - metadata: MetadataSection + metadata: MetadataSection, + variants: VariantsSection }; const currentSection = createMemo(() => { const sectionMenuItem = sectionMenuItems.find((menuItem) => { diff --git a/apps/web/src/views/settings/webhooks/index.tsx b/apps/web/src/views/settings/webhooks/index.tsx index 524cc710..a4e8704b 100644 --- a/apps/web/src/views/settings/webhooks/index.tsx +++ b/apps/web/src/views/settings/webhooks/index.tsx @@ -1,7 +1,6 @@ import { ConfigureWebhookSubsection } from "./configure-webhook-subsection"; import { webhookEvents } from "./events"; import { SettingsSectionComponent } from "../view"; -import { TitledCard } from "#components/fragments"; import { Accessor, Component, @@ -14,6 +13,7 @@ import { } from "solid-js"; import { mdiFormatListBulleted, mdiPlusCircle, mdiPuzzle, mdiTrashCan, mdiTune } from "@mdi/js"; import { createStore } from "solid-js/store"; +import { TitledCard } from "#components/fragments"; import { App, hasPermission, useClientContext, useNotificationsContext } from "#context"; import { Button, Card, Heading, IconButton, Loader, Tooltip } from "#components/primitives"; diff --git a/apps/web/src/views/settings/workspace/configure-role-subsection.tsx b/apps/web/src/views/settings/workspace/configure-role-subsection.tsx index bea5fec3..1b81a05b 100644 --- a/apps/web/src/views/settings/workspace/configure-role-subsection.tsx +++ b/apps/web/src/views/settings/workspace/configure-role-subsection.tsx @@ -56,6 +56,16 @@ const ConfigureRoleSubsection: Component = (props) description: "Create, edit, and delete webhooks", permission: "manageWebhooks" }, + { + label: "Manage Extensions", + description: "Install, configure, and delete Extensions", + permission: "manageExtensions" + }, + { + label: "Manage Variants", + description: "Create, edit, and delete Variants", + permission: "manageVariants" + }, { label: "Manage workspace", description: diff --git a/packages/backend/src/database/comment-threads.ts b/packages/backend/src/database/comment-threads.ts index b4be97d8..ff248253 100644 --- a/packages/backend/src/database/comment-threads.ts +++ b/packages/backend/src/database/comment-threads.ts @@ -1,6 +1,6 @@ import { Collection, Db, ObjectId } from "mongodb"; -import { UnderscoreID, zodId } from "#lib/mongo"; import { z } from "zod"; +import { UnderscoreID, zodId } from "#lib/mongo"; const commentThread = z.object({ id: zodId(), diff --git a/packages/backend/src/database/content-piece-variants.ts b/packages/backend/src/database/content-piece-variants.ts new file mode 100644 index 00000000..f0e76d62 --- /dev/null +++ b/packages/backend/src/database/content-piece-variants.ts @@ -0,0 +1,58 @@ +import { Tag } from "./tags"; +import { ContentPiece, ContentPieceMember, contentPiece } from "./content-pieces"; +import { Collection, Db, ObjectId } from "mongodb"; +import { z } from "zod"; +import { UnderscoreID, zodId } from "#lib/mongo"; + +const contentPieceVariant = contentPiece + .omit({ contentGroupId: true }) + .partial() + .required({ id: true }) + .merge(z.object({ variantId: zodId(), contentPieceId: zodId() })); + +interface ContentPieceVariant + extends Partial, "contentGroupId" | "contentPieceId">> { + variantId: ID; + contentPieceId: ID; +} +interface ContentPieceVariantWithAdditionalData + extends Omit, "tags" | "members"> { + tags?: Array>; + members?: Array>; +} +interface FullContentPieceVariant + extends ContentPieceVariant { + coverWidth?: string; +} +interface FullContentPieceVariantWithAdditionalData + extends Omit, "tags" | "members"> { + tags?: Array>; + members?: Array>; +} + +type ExtendedContentPieceVariant< + K extends keyof Omit | undefined = undefined, + ID extends string | ObjectId = string +> = ContentPieceVariant & Pick, Exclude>; + +type ExtendedContentPieceVariantWithAdditionalData< + K extends keyof Omit | undefined = undefined, + ID extends string | ObjectId = string +> = ContentPieceVariantWithAdditionalData & + Pick, Exclude>; + +const getContentPieceVariantsCollection = ( + db: Db +): Collection>> => { + return db.collection("content-piece-variants"); +}; + +export { contentPieceVariant, getContentPieceVariantsCollection }; +export type { + ContentPieceVariant, + ContentPieceVariantWithAdditionalData, + FullContentPieceVariant, + FullContentPieceVariantWithAdditionalData, + ExtendedContentPieceVariant, + ExtendedContentPieceVariantWithAdditionalData +}; diff --git a/packages/backend/src/database/content-pieces.ts b/packages/backend/src/database/content-pieces.ts index 06521369..8f50a2c2 100644 --- a/packages/backend/src/database/content-pieces.ts +++ b/packages/backend/src/database/content-pieces.ts @@ -1,8 +1,8 @@ import { Tag } from "./tags"; +import { Profile, profile } from "./users"; import { Collection, Db, ObjectId } from "mongodb"; import { z } from "zod"; import { UnderscoreID, zodId } from "#lib/mongo"; -import { Profile, profile } from "./users"; const contentPiece = z.object({ id: zodId(), diff --git a/packages/backend/src/database/content-variants.ts b/packages/backend/src/database/content-variants.ts new file mode 100644 index 00000000..a27838da --- /dev/null +++ b/packages/backend/src/database/content-variants.ts @@ -0,0 +1,18 @@ +import { Binary, Collection, Db, ObjectId } from "mongodb"; +import { UnderscoreID } from "#lib/mongo"; + +interface ContentVariant { + contentPieceId: ID; + variantId: ID; + content?: Binary; + id: ID; +} + +const getContentVariantsCollection = ( + db: Db +): Collection>> => { + return db.collection("content-variants"); +}; + +export { getContentVariantsCollection }; +export type { ContentVariant }; diff --git a/packages/backend/src/database/index.ts b/packages/backend/src/database/index.ts index 629b92a4..42d7e0cb 100644 --- a/packages/backend/src/database/index.ts +++ b/packages/backend/src/database/index.ts @@ -12,3 +12,6 @@ export * from "./workspaces"; export * from "./extensions"; export * from "./comment-threads"; export * from "./comments"; +export * from "./content-piece-variants"; +export * from "./content-variants"; +export * from "./variants"; diff --git a/packages/backend/src/database/roles.ts b/packages/backend/src/database/roles.ts index 23b73442..7863fdda 100644 --- a/packages/backend/src/database/roles.ts +++ b/packages/backend/src/database/roles.ts @@ -9,7 +9,8 @@ const permission = z.enum([ "manageTokens", "manageWebhooks", "manageWorkspace", - "manageExtensions" + "manageExtensions", + "manageVariants" ]); const role = z.object({ id: zodId(), diff --git a/packages/backend/src/database/tokens.ts b/packages/backend/src/database/tokens.ts index 34613ff2..f841fb45 100644 --- a/packages/backend/src/database/tokens.ts +++ b/packages/backend/src/database/tokens.ts @@ -19,7 +19,9 @@ const tokenPermission = z.enum([ "userSettings:write", "webhooks:write", "workspaceMemberships:write", - "workspace:write" + "workspace:write", + "variants:read", + "variants:write" ]); const token = z.object({ id: zodId(), diff --git a/packages/backend/src/database/variants.ts b/packages/backend/src/database/variants.ts new file mode 100644 index 00000000..26c87c5a --- /dev/null +++ b/packages/backend/src/database/variants.ts @@ -0,0 +1,29 @@ +import { Collection, Db, ObjectId } from "mongodb"; +import { z } from "zod"; +import { UnderscoreID, zodId } from "#lib/mongo"; + +const variant = z.object({ + id: zodId(), + label: z.string().min(1).max(50), + description: z.string().optional(), + name: z + .string() + .min(1) + .max(20) + .regex(/^[a-z0-9_]*$/) +}); + +interface Variant + extends Omit, "id"> { + id: ID; +} +interface FullVariant extends Variant { + workspaceId: ID; +} + +const getVariantsCollection = (db: Db): Collection>> => { + return db.collection("variants"); +}; + +export { variant, getVariantsCollection }; +export type { Variant }; diff --git a/packages/backend/src/lib/workspace.ts b/packages/backend/src/lib/workspace.ts index f981ec5e..c1199e62 100644 --- a/packages/backend/src/lib/workspace.ts +++ b/packages/backend/src/lib/workspace.ts @@ -70,7 +70,9 @@ const createWorkspace = async ( "manageDashboard", "manageTokens", "manageWebhooks", - "manageWorkspace" + "manageWorkspace", + "manageExtensions", + "manageVariants" ] }, { diff --git a/packages/backend/src/plugins/database.ts b/packages/backend/src/plugins/database.ts index 7fa66d79..a5bdfd3d 100644 --- a/packages/backend/src/plugins/database.ts +++ b/packages/backend/src/plugins/database.ts @@ -10,6 +10,11 @@ import { getContentsCollection } from "#database/contents"; import { getUsersCollection } from "#database/users"; import { getCommentThreadsCollection } from "#database/comment-threads"; import { getCommentsCollection } from "#database/comments"; +import { + getContentPieceVariantsCollection, + getContentVariantsCollection, + getVariantsCollection +} from "#database"; const databasePlugin = publicPlugin(async (fastify) => { const db = fastify.mongo.db!; @@ -26,15 +31,20 @@ const databasePlugin = publicPlugin(async (fastify) => { const workspaceMembershipsCollection = getWorkspaceSettingsCollection(db); const workspaceSettingsCollection = getWorkspaceSettingsCollection(db); const extensionsCollection = getWorkspaceSettingsCollection(db); + const variantsCollection = getVariantsCollection(db); + const contentPieceVariantsCollection = getContentPieceVariantsCollection(db); + const contentVariants = getContentVariantsCollection(db); await Promise.all([ contentPiecesCollection.createIndex({ workspaceId: 1 }), contentPiecesCollection.createIndex({ contentGroupId: 1 }), contentPiecesCollection.createIndex({ tags: 1 }), + contentPieceVariantsCollection.createIndex({ variantId: 1 }), commentThreadsCollection.createIndex({ contentPieceId: 1, workspaceId: 1 }), commentThreadsCollection.createIndex({ fragment: 1, workspaceId: 1 }), commentsCollection.createIndex({ threadId: 1, workspaceId: 1 }), contentsCollection.createIndex({ contentPieceId: 1 }), + contentVariants.createIndex({ contentPieceId: 1, variantId: 1 }), rolesCollection.createIndex({ workspaceId: 1 }), tagsCollection.createIndex({ workspaceId: 1 }), tokensCollection.createIndex({ workspaceId: 1 }), @@ -57,7 +67,9 @@ const databasePlugin = publicPlugin(async (fastify) => { workspaceSettingsCollection.createIndex({ workspaceId: 1 }), usersCollection.createIndex({ email: 1 }, { unique: true }), extensionsCollection.createIndex({ workspaceId: 1 }), - extensionsCollection.createIndex({ name: 1 }) + extensionsCollection.createIndex({ name: 1 }), + variantsCollection.createIndex({ workspaceId: 1 }), + variantsCollection.createIndex({ name: 1 }) ]); }); diff --git a/packages/backend/src/routes/content-pieces.ts b/packages/backend/src/routes/content-pieces.ts index e1037367..6314edba 100644 --- a/packages/backend/src/routes/content-pieces.ts +++ b/packages/backend/src/routes/content-pieces.ts @@ -24,7 +24,11 @@ import { getContentsCollection } from "#database/contents"; import { runWebhooks } from "#lib/webhooks"; import { createEventPublisher, createEventSubscription } from "#lib/pub-sub"; import { + FullContentPieceVariant, + getContentPieceVariantsCollection, + getContentVariantsCollection, getUsersCollection, + getVariantsCollection, getWorkspaceMembershipsCollection, getWorkspaceSettingsCollection } from "#database"; @@ -35,6 +39,7 @@ type ContentPieceEvent = | { action: "update"; userId: string; + variantId?: string; data: Partial & { id: string }; } | { @@ -125,6 +130,41 @@ const fetchContentPieceMembers = async ( }) .filter((value) => value) as Array; }; +const mergeVariantData = ( + contentPiece: UnderscoreID>, + contentPieceVariant: UnderscoreID> +): UnderscoreID> => { + const { _id, contentPieceId, variantId, ...variantData } = contentPieceVariant; + const mergedVariantData = Object.fromEntries( + Object.keys(variantData).map((key) => { + const typedKey = key as keyof Omit< + UnderscoreID>, + "_id" | "contentPieceId" | "variantId" + >; + + return [typedKey, variantData[typedKey] || contentPiece[typedKey]]; + }) + ); + + return { ...contentPiece, ...mergedVariantData }; +}; +const getVariantIdFromName = async (db: Db, variantName: string): Promise => { + const variantsCollection = getVariantsCollection(db); + const variant = await variantsCollection.findOne({ name: variantName }); + + if (!variant) throw errors.notFound("variant"); + + return variant._id; +}; +const getVariantId = async (db: Db, variant?: string): Promise => { + if (!variant) return null; + + if (ObjectId.isValid(variant)) { + return new ObjectId(variant); + } + + return await getVariantIdFromName(db, variant); +}; const basePath = "/content-pieces"; const authenticatedProcedure = procedure.use(isAuthenticated); const contentPiecesRouter = router({ @@ -137,6 +177,15 @@ const contentPiecesRouter = router({ z.object({ id: zodId(), content: z.boolean().default(false), + variant: zodId() + .or( + z + .string() + .min(1) + .max(20) + .regex(/^[a-z0-9_]*$/) + ) + .optional(), description: z.enum(["html", "text"]).default("html") }) ) @@ -154,15 +203,30 @@ const contentPiecesRouter = router({ const workspaceSettingsCollection = getWorkspaceSettingsCollection(ctx.db); const contentPiecesCollection = getContentPiecesCollection(ctx.db); const workspacesCollection = getWorkspacesCollection(ctx.db); + const contentPieceVariantsCollection = getContentPieceVariantsCollection(ctx.db); const workspaceSettings = await workspaceSettingsCollection.findOne({ workspaceId: ctx.auth.workspaceId }); const workspace = await workspacesCollection.findOne({ _id: ctx.auth.workspaceId }); - const contentPiece = await contentPiecesCollection.findOne({ + const variantId = await getVariantId(ctx.db, input.variant); + const baseContentPiece = await contentPiecesCollection.findOne({ _id: new ObjectId(input.id) }); - if (!contentPiece) throw errors.notFound("contentPiece"); + if (!baseContentPiece) throw errors.notFound("contentPiece"); + + let contentPiece = baseContentPiece; + + if (variantId) { + const contentPieceVariant = await contentPieceVariantsCollection.findOne({ + contentPieceId: new ObjectId(input.id), + variantId + }); + + if (contentPieceVariant) { + contentPiece = mergeVariantData(contentPiece, contentPieceVariant); + } + } const contentGroup = workspace?.contentGroups.find((contentGroup) => { return contentGroup._id.equals(contentPiece.contentGroupId); @@ -175,15 +239,30 @@ const contentPiecesRouter = router({ let content: DocJSON | null = null; if (input.content) { - const contentsCollection = await getContentsCollection(ctx.db); - const contents = await contentsCollection.findOne({ - contentPieceId: new ObjectId(input.id) - }); + const contentsCollection = getContentsCollection(ctx.db); + const contentVariantsCollection = getContentVariantsCollection(ctx.db); - if (contents && contents.content) { - content = bufferToJSON(Buffer.from(contents.content.buffer)); - } else { - content = { type: "doc", content: [] }; + if (variantId) { + const contentVariant = await contentVariantsCollection.findOne({ + contentPieceId: new ObjectId(input.id), + variantId + }); + + if (contentVariant && contentVariant.content) { + content = bufferToJSON(Buffer.from(contentVariant.content.buffer)); + } + } + + if (!content) { + const retrievedContent = await contentsCollection.findOne({ + contentPieceId: new ObjectId(input.id) + }); + + if (retrievedContent && retrievedContent.content) { + content = bufferToJSON(Buffer.from(retrievedContent.content.buffer)); + } else { + content = { type: "doc", content: [] }; + } } } @@ -225,6 +304,15 @@ const contentPiecesRouter = router({ .input( z.object({ contentGroupId: zodId(), + variant: zodId() + .or( + z + .string() + .min(1) + .max(20) + .regex(/^[a-z0-9_]*$/) + ) + .optional(), slug: z.string().optional(), tagId: zodId().optional(), lastOrder: z.string().optional(), @@ -246,6 +334,7 @@ const contentPiecesRouter = router({ .query(async ({ ctx, input }) => { const workspaceSettingsCollection = getWorkspaceSettingsCollection(ctx.db); const contentPiecesCollection = getContentPiecesCollection(ctx.db); + const contentPieceVariantsCollection = getContentPieceVariantsCollection(ctx.db); const workspacesCollection = getWorkspacesCollection(ctx.db); const workspaceSettings = await workspaceSettingsCollection.findOne({ workspaceId: ctx.auth.workspaceId @@ -253,6 +342,7 @@ const contentPiecesRouter = router({ const workspace = await workspacesCollection.findOne({ _id: ctx.auth.workspaceId }); + const variantId = await getVariantId(ctx.db, input.variant); const contentGroup = workspace?.contentGroups.find((contentGroup) => { return contentGroup._id.equals(input.contentGroupId); }); @@ -275,7 +365,28 @@ const contentPiecesRouter = router({ cursor.skip((input.page - 1) * input.perPage); } - const contentPieces = await cursor.limit(input.perPage).toArray(); + let contentPieces = await cursor.limit(input.perPage).toArray(); + + if (variantId) { + const contentPieceVariants = await contentPieceVariantsCollection + .find({ + contentPieceId: { $in: contentPieces.map((contentPiece) => contentPiece._id) }, + variantId + }) + .toArray(); + + contentPieces = contentPieces.map((contentPiece) => { + const contentPieceVariant = contentPieceVariants.find((contentPieceVariant) => { + return `${contentPieceVariant.contentPieceId}` === `${contentPiece._id}`; + }); + + if (contentPieceVariant) { + return mergeVariantData(contentPiece, contentPieceVariant); + } + + return contentPiece; + }); + } return Promise.all( contentPieces.map(async (contentPiece) => { @@ -317,7 +428,6 @@ const contentPiecesRouter = router({ .output(z.object({ id: zodId() })) .mutation(async ({ ctx, input }) => { const { referenceId, contentGroupId, customData, content, ...create } = input; - const workspaceSettingsCollection = getWorkspaceSettingsCollection(ctx.db); const contentPiecesCollection = getContentPiecesCollection(ctx.db); const contentsCollection = getContentsCollection(ctx.db); @@ -417,7 +527,16 @@ const contentPiecesRouter = router({ contentPiece .extend({ coverWidth: z.string(), - content: z.string() + content: z.string(), + variant: zodId() + .or( + z + .string() + .min(1) + .max(20) + .regex(/^[a-z0-9_]*$/) + ) + .optional() }) .partial() .required({ id: true }) @@ -426,6 +545,7 @@ const contentPiecesRouter = router({ .mutation(async ({ ctx, input }) => { const { id, + variant, content: updatedContent, contentGroupId: updatedContentGroupId, customData: updatedCustomData, @@ -436,18 +556,34 @@ const contentPiecesRouter = router({ } = input; const extensionId = ctx.req.headers["x-vrite-extension-id"] as string | undefined; const contentPiecesCollection = getContentPiecesCollection(ctx.db); + const contentPieceVariantsCollection = getContentPieceVariantsCollection(ctx.db); const contentsCollection = getContentsCollection(ctx.db); + const contentVariantsCollection = getContentVariantsCollection(ctx.db); const workspacesCollection = getWorkspacesCollection(ctx.db); const workspaceSettingsCollection = getWorkspaceSettingsCollection(ctx.db); const workspaceSettings = await workspaceSettingsCollection.findOne({ workspaceId: ctx.auth.workspaceId }); const workspace = await workspacesCollection.findOne({ _id: ctx.auth.workspaceId }); - const contentPiece = await contentPiecesCollection.findOne({ + const variantId = await getVariantId(ctx.db, variant); + const baseContentPiece = await contentPiecesCollection.findOne({ _id: new ObjectId(id) }); - if (!contentPiece) throw errors.notFound("contentPiece"); + if (!baseContentPiece) throw errors.notFound("contentPiece"); + + let contentPiece = baseContentPiece; + + if (variantId) { + const contentPieceVariant = await contentPieceVariantsCollection.findOne({ + contentPieceId: new ObjectId(id), + variantId + }); + + if (contentPieceVariant) { + contentPiece = mergeVariantData(contentPiece, contentPieceVariant); + } + } const contentGroup = await workspace?.contentGroups.find((contentGroup) => { return contentGroup._id.equals(contentPiece.contentGroupId); @@ -474,9 +610,11 @@ const contentPiecesRouter = router({ } if (updatedTags) contentPieceUpdates.tags = updatedTags.map((tag) => new ObjectId(tag)); + if (updatedMembers) { contentPieceUpdates.members = updatedMembers.map((member) => new ObjectId(member)); } + if (updatedDate) contentPieceUpdates.date = new Date(updatedDate); if (updatedDate === null) contentPieceUpdates.date = null; @@ -498,22 +636,44 @@ const contentPiecesRouter = router({ const newContentPiece = { ...contentPiece, ...contentPieceUpdates }; - await contentPiecesCollection.updateOne( - { _id: new ObjectId(id) }, - { $set: contentPieceUpdates } - ); + if (variantId) { + await contentPieceVariantsCollection.updateOne( + { contentPieceId: new ObjectId(id), variantId }, + { $set: contentPieceUpdates }, + { upsert: true } + ); + } else { + await contentPiecesCollection.updateOne( + { _id: new ObjectId(id) }, + { $set: contentPieceUpdates } + ); + } if (updatedContent) { - await contentsCollection.updateOne( - { - contentPieceId: contentPiece._id - }, - { - $set: { - content: new Binary(jsonToBuffer(htmlToJSON(updatedContent))) + if (variantId) { + await contentVariantsCollection.updateOne( + { + contentPieceId: contentPiece._id, + variantId + }, + { + $set: { + content: new Binary(jsonToBuffer(htmlToJSON(updatedContent))) + } } - } - ); + ); + } else { + await contentsCollection.updateOne( + { + contentPieceId: contentPiece._id + }, + { + $set: { + content: new Binary(jsonToBuffer(htmlToJSON(updatedContent))) + } + } + ); + } } if (!updatedContentGroupId || contentPiece.contentGroupId.equals(updatedContentGroupId)) { @@ -547,7 +707,8 @@ const contentPiecesRouter = router({ workspaceId: `${newContentPiece.workspaceId}`, date: newContentPiece.date?.toISOString() || null, tags, - members + members, + ...(variantId ? { variantId } : {}) } }); }), @@ -560,15 +721,24 @@ const contentPiecesRouter = router({ .output(z.object({ id: zodId().or(z.null()) })) .mutation(async ({ ctx, input }) => { const contentPiecesCollection = getContentPiecesCollection(ctx.db); + const contentPieceVariantsCollection = getContentPieceVariantsCollection(ctx.db); const contentsCollection = getContentsCollection(ctx.db); + const contentVariantsCollection = getContentVariantsCollection(ctx.db); const contentPiece = await contentPiecesCollection.findOne({ - _id: new ObjectId(input.id) + _id: new ObjectId(input.id), + workspaceId: ctx.auth.workspaceId }); if (!contentPiece) throw errors.notFound("contentPiece"); await contentPiecesCollection.deleteOne({ _id: contentPiece._id }); - await contentsCollection.deleteOne({ _id: contentPiece._id }); + await contentsCollection.deleteOne({ contentPieceId: contentPiece._id }); + await contentPieceVariantsCollection.deleteMany({ + contentPieceId: contentPiece._id + }); + await contentVariantsCollection.deleteMany({ + contentPieceId: contentPiece._id + }); publishEvent(ctx, `${contentPiece.contentGroupId}`, { action: "delete", userId: `${ctx.auth.userId}`, @@ -602,7 +772,6 @@ const contentPiecesRouter = router({ const contentPiece = await contentPiecesCollection.findOne({ _id: new ObjectId(input.id) }); - const workspace = await workspacesCollection.findOne({ _id: ctx.auth.workspaceId }); const contentGroup = workspace?.contentGroups.find((contentGroup) => { const contentGroupId = input.contentGroupId || contentPiece?.contentGroupId; diff --git a/packages/backend/src/routes/extensions.ts b/packages/backend/src/routes/extensions.ts index 37ef694a..8d072d81 100644 --- a/packages/backend/src/routes/extensions.ts +++ b/packages/backend/src/routes/extensions.ts @@ -120,7 +120,6 @@ const extensionsRouter = router({ } } ); - publishContentPieceEvent(ctx, `${contentPiece.contentGroupId}`, { action: "update", userId: `${ctx.auth.userId}`, diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 2fc940ca..f6b82559 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -14,6 +14,7 @@ import { authRouter } from "./auth"; import { contentGroupsRouter } from "./content-groups"; import { extensionsRouter } from "./extensions"; import { commentsRouter } from "./comments"; +import { variantsRouter } from "./variants"; import type { TRPCClientError } from "@trpc/client"; import { Context, createContext } from "#lib/context"; import { router } from "#lib/trpc"; @@ -34,7 +35,8 @@ const appRouter = router({ workspaceSettings: workspaceSettingsRouter, verification: verificationRouter, extensions: extensionsRouter, - comments: commentsRouter + comments: commentsRouter, + variants: variantsRouter }); type Router = typeof appRouter; diff --git a/packages/backend/src/routes/variants.ts b/packages/backend/src/routes/variants.ts new file mode 100644 index 00000000..09a4c4bf --- /dev/null +++ b/packages/backend/src/routes/variants.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; +import { isAuthenticated } from "#lib/middleware"; +import { procedure, router } from "#lib/trpc"; +import * as errors from "#lib/errors"; +import { createEventPublisher, createEventSubscription } from "#lib/pub-sub"; +import { Variant, getVariantsCollection, variant } from "#database"; +import { ObjectId, zodId } from "#lib/mongo"; + +type VariantsEvent = + | { + action: "create"; + data: Variant & { id: string }; + } + | { + action: "update"; + data: Partial & { id: string }; + } + | { + action: "delete"; + data: { id: string }; + }; + +const publishEvent = createEventPublisher( + (workspaceId) => `variants:${workspaceId}` +); +const authenticatedProcedure = procedure.use(isAuthenticated); +const basePath = "/variants"; +const variantsRouter = router({ + create: authenticatedProcedure + .meta({ + openapi: { method: "POST", path: basePath, protect: true }, + permissions: { session: ["manageVariants"], token: ["variants:write"] } + }) + .input(variant.omit({ id: true })) + .output(z.object({ id: zodId() })) + .mutation(async ({ ctx, input }) => { + const variantsCollection = getVariantsCollection(ctx.db); + const variant = { + _id: new ObjectId(), + workspaceId: ctx.auth.workspaceId, + ...input + }; + + await variantsCollection.insertOne(variant); + publishEvent(ctx, `${ctx.auth.workspaceId}`, { + action: "create", + data: { ...input, id: `${variant._id}` } + }); + + return { id: `${variant._id}` }; + }), + update: authenticatedProcedure + .meta({ + openapi: { method: "PUT", path: basePath, protect: true }, + permissions: { session: ["manageVariants"], token: ["variants:write"] } + }) + .input(variant.partial().required({ id: true })) + .output(z.void()) + .mutation(async ({ ctx, input }) => { + const variantsCollection = getVariantsCollection(ctx.db); + const { id, ...update } = input; + const { matchedCount } = await variantsCollection.updateOne( + { _id: new ObjectId(id), workspaceId: ctx.auth.workspaceId }, + { $set: update } + ); + + if (!matchedCount) throw errors.notFound("variant"); + + publishEvent(ctx, `${ctx.auth.workspaceId}`, { action: "update", data: input }); + }), + delete: authenticatedProcedure + .meta({ + openapi: { method: "DELETE", path: basePath, protect: true }, + permissions: { session: ["manageVariants"], token: ["variants:write"] } + }) + .input(z.object({ id: zodId() })) + .output(z.void()) + .mutation(async ({ ctx, input }) => { + const variantsCollection = getVariantsCollection(ctx.db); + const { deletedCount } = await variantsCollection.deleteOne({ + _id: new ObjectId(input.id), + workspaceId: ctx.auth.workspaceId + }); + + if (!deletedCount) throw errors.notFound("variant"); + + publishEvent(ctx, `${ctx.auth.workspaceId}`, { action: "delete", data: input }); + }), + list: authenticatedProcedure + .meta({ + openapi: { method: "GET", path: `${basePath}/list`, protect: true }, + permissions: { token: ["variants:read"] } + }) + .input(z.void()) + .output(z.array(variant)) + .query(async ({ ctx }) => { + const variantsCollection = getVariantsCollection(ctx.db); + const variants = await variantsCollection + .find({ + workspaceId: ctx.auth.workspaceId + }) + .sort("_id", -1) + .toArray(); + + return variants.map(({ _id, label, name, description }) => { + return { + id: `${_id}`, + label, + name, + description + }; + }); + }), + + changes: authenticatedProcedure.input(z.void()).subscription(async ({ ctx }) => { + return createEventSubscription(ctx, `variants:${ctx.auth.workspaceId}`); + }) +}); + +export { variantsRouter }; +export type { VariantsEvent }; diff --git a/packages/backend/src/routes/webhooks.ts b/packages/backend/src/routes/webhooks.ts index 3466651c..7f737f91 100644 --- a/packages/backend/src/routes/webhooks.ts +++ b/packages/backend/src/routes/webhooks.ts @@ -95,7 +95,7 @@ const webhooksRouter = router({ if (!webhook) throw errors.notFound("webhook"); let metadata: Webhook["metadata"] | null = null; - let extension: boolean | undefined = undefined; + let extension = false; if (webhook.metadata) { metadata = { @@ -103,6 +103,7 @@ const webhooksRouter = router({ contentGroupId: `${webhook.metadata.contentGroupId}` }; } + if (webhook.extensionId) { extension = true; } @@ -198,7 +199,7 @@ const webhooksRouter = router({ openapi: { method: "DELETE", path: basePath, protect: true }, permissions: { session: ["manageWebhooks"], token: ["webhooks:write"] } }) - .input(z.object({ id: z.string() })) + .input(z.object({ id: zodId() })) .output(z.void()) .mutation(async ({ ctx, input }) => { const webhooksCollection = getWebhooksCollection(ctx.db);