diff --git a/.husky/pre-commit b/.husky/pre-commit index 6b587447b..3e25b9805 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,3 @@ +npm run typecheck npm run lint:fix git add . \ No newline at end of file diff --git a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Implementation.snap.png b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Implementation.snap.png index 6dc7853cb..bf62990d4 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Implementation.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Implementation.snap.png differ diff --git a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Pattern In TypeScript.snap.png b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Pattern In TypeScript.snap.png index 2bbf9ff92..a04912b90 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Pattern In TypeScript.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Mediator Pattern In TypeScript.snap.png differ diff --git a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Notifications Management with Mediator.snap.png b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Notifications Management with Mediator.snap.png index 472d01147..911534043 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Notifications Management with Mediator.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Notifications Management with Mediator.snap.png differ diff --git a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Other Use Cases Ideas.snap.png b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Other Use Cases Ideas.snap.png index 213a5e293..e2e86c67e 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Other Use Cases Ideas.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Other Use Cases Ideas.snap.png differ diff --git a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Summary.snap.png b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Summary.snap.png index 7aa3b6866..20b432c26 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Summary.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Summary.snap.png differ diff --git a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Too Big Mediators - God Classes Issue.snap.png b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Too Big Mediators - God Classes Issue.snap.png index e38da772b..bf02fb77f 100644 Binary files a/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Too Big Mediators - God Classes Issue.snap.png and b/cypress/snapshots/docs-display.cy.ts/document-preview-heading-Too Big Mediators - God Classes Issue.snap.png differ diff --git a/src/actions/log-out.action.ts b/src/actions/log-out.action.ts index 89c60d744..27d36ae91 100644 --- a/src/actions/log-out.action.ts +++ b/src/actions/log-out.action.ts @@ -9,6 +9,7 @@ const logOut = async (): Promise => { `getYourDocuments`, `getYourAccount`, `getYourMindmaps`, + `getUserResourceCompletions`, ); } catch {} }; diff --git a/src/api-4markdown-contracts/atoms.ts b/src/api-4markdown-contracts/atoms.ts index 6d3ddc3eb..2386c6afa 100644 --- a/src/api-4markdown-contracts/atoms.ts +++ b/src/api-4markdown-contracts/atoms.ts @@ -1,3 +1,4 @@ +import { SUID } from "development-kit/suid"; import { Brand } from "development-kit/utility-types"; type Id = string; @@ -14,6 +15,17 @@ type Url = string; type UserProfileId = Brand; type CommentId = Brand; +type DocumentId = Brand; +type MindmapNodeId = Brand; +type MindmapId = Brand; + +type ResourceId = DocumentId | MindmapNodeId | MindmapId; + +const RESOURCE_TYPES = ["document", "mindmap", "mindmap-node"] as const; + +type ResourceType = (typeof RESOURCE_TYPES)[number]; + +export { RESOURCE_TYPES }; export type { Id, Name, @@ -28,4 +40,9 @@ export type { Slug, UserProfileId, CommentId, + ResourceId, + DocumentId, + MindmapId, + MindmapNodeId, + ResourceType, }; diff --git a/src/api-4markdown-contracts/contracts/index.ts b/src/api-4markdown-contracts/contracts/index.ts index 945371742..679e19cdf 100644 --- a/src/api-4markdown-contracts/contracts/index.ts +++ b/src/api-4markdown-contracts/contracts/index.ts @@ -1,5 +1,16 @@ import { Brand, type Prettify } from "development-kit/utility-types"; -import type { Base64, Date, Id, Url, UserProfileId } from "../atoms"; +import type { + Base64, + Date, + DocumentId, + Id, + MindmapId, + MindmapNodeId, + ResourceId, + ResourceType, + Url, + UserProfileId, +} from "../atoms"; import type { DocumentDto, PermanentDocumentDto, @@ -14,6 +25,7 @@ import type { RewriteAssistantPersona, YourAccountDto, CommentDto, + ResourceCompletionDto, } from "../dtos"; // @TODO[PRIO=1]: [Add better error handling and throwing custom errors]. @@ -234,6 +246,29 @@ type AddUserProfileCommentContract = Contract< } >; +type GetUserResourceCompletionsContract = Contract< + `getUserResourceCompletions`, + Record +>; + +type SetUserResourceCompletionContract = Contract< + "setUserResourceCompletion", + ResourceCompletionDto | null, + | { + type: Extract; + resourceId: DocumentId; + } + | { + type: Extract; + resourceId: MindmapId; + } + | { + type: Extract; + resourceId: MindmapNodeId; + parentId: MindmapId; + } +>; + type API4MarkdownContracts = | CreateMindmapContract | GetYourDocumentsContract @@ -261,7 +296,9 @@ type API4MarkdownContracts = | CreateContentWithAIContract | GetYourAccountContract | GetUserProfileContract - | AddUserProfileCommentContract; + | AddUserProfileCommentContract + | GetUserResourceCompletionsContract + | SetUserResourceCompletionContract; type API4MarkdownContractKey = API4MarkdownContracts["key"]; type API4MarkdownDto = Extract< diff --git a/src/api-4markdown-contracts/dtos/index.ts b/src/api-4markdown-contracts/dtos/index.ts index 7624cdad9..227523f66 100644 --- a/src/api-4markdown-contracts/dtos/index.ts +++ b/src/api-4markdown-contracts/dtos/index.ts @@ -7,3 +7,4 @@ export * from "./full-mindmap.dto"; export * from "./rewrite-assistant.dto"; export * from "./your-account.dto"; export * from "./comment.dto"; +export * from "./resource-completion.dto"; diff --git a/src/api-4markdown-contracts/dtos/resource-completion.dto.ts b/src/api-4markdown-contracts/dtos/resource-completion.dto.ts new file mode 100644 index 000000000..8732c923c --- /dev/null +++ b/src/api-4markdown-contracts/dtos/resource-completion.dto.ts @@ -0,0 +1,27 @@ +import type { + Date, + DocumentId, + MindmapId, + MindmapNodeId, + ResourceType, +} from "../atoms"; + +type ResourceCompletionDto = + | { + cdate: Date; + type: Extract; + resourceId: DocumentId; + } + | { + cdate: Date; + type: Extract; + resourceId: MindmapId; + } + | { + cdate: Date; + type: Extract; + resourceId: MindmapNodeId; + parentId: MindmapId; + }; + +export type { ResourceCompletionDto }; diff --git a/src/components/education-documents-list.tsx b/src/components/education-documents-list.tsx index 9124d7442..4202c6344 100644 --- a/src/components/education-documents-list.tsx +++ b/src/components/education-documents-list.tsx @@ -5,6 +5,9 @@ import { Link } from "gatsby"; import type { RichEducationDocumentModel } from "models/page-models"; import React from "react"; import { meta } from "../../meta"; +import { BiCheckboxChecked } from "react-icons/bi"; +import { useResourceCompletion } from "modules/resource-completions"; +import { DocumentId } from "api-4markdown-contracts"; type EducationDocumentsListProps = { documents: RichEducationDocumentModel[]; @@ -12,6 +15,21 @@ type EducationDocumentsListProps = { const now = new Date(); +const ResourceCompletionMarkerContainer = ({ id }: { id: DocumentId }) => { + const completion = useResourceCompletion(id); + + if (!completion) { + return null; + } + + return ( + + + ); +}; + const EducationDocumentsList = ({ documents }: EducationDocumentsListProps) => { return (
    @@ -56,10 +74,13 @@ const EducationDocumentsList = ({ documents }: EducationDocumentsListProps) => { {document.name} -

    {document.description}

    -

    - {document.tags.join(`, `)} -

    +

    {document.description}

    +
    + + + {document.tags.join(`, `)} + +
    {RATING_ICONS.map(([Icon, category]) => (
    diff --git a/src/components/markdown-widget.tsx b/src/components/markdown-widget.tsx index f973d076f..4b8d1bdd4 100644 --- a/src/components/markdown-widget.tsx +++ b/src/components/markdown-widget.tsx @@ -44,10 +44,7 @@ const MarkdownWidget = ({ const asideNavigation = useSimpleFeature(); const [activeHeading, setActiveHeading] = React.useState(null); - const headings = React.useMemo( - () => (asideNavigation.isOn ? extractHeadings(markdown) : []), - [markdown, asideNavigation.isOn], - ); + const headings = React.useMemo(() => extractHeadings(markdown), [markdown]); const chunks = React.useMemo((): string[] => { if (chunksMode.isOff) return []; @@ -216,13 +213,6 @@ const MarkdownWidget = ({ > {chunksMode.isOn ? : } - -
    + +
    + +
    + {finalHeadings.length > 1 && ( + + )} + {chunksMode.isOn && ( <> + ); +}; + +const ResourceCompletionMarkerContainer = () => { + const [{ document }] = useDocumentLayoutContext(); + const completion = useResourceCompletion(document.id as DocumentId); + + if (!completion) { + return null; + } + + return ( +

    + + + You're browsing already completed resource. + +

    + ); +}; + const DocumentLayoutContainer = () => { const [{ document }] = useDocumentLayoutContext(); const { code, author } = document; @@ -40,6 +107,7 @@ const DocumentLayoutContainer = () => { <>
    +
    - } - onClose={closeNodePreviewAction} - markdown={nodePreview.data.content} - /> + + + + + } + onClose={closeNodePreviewAction} + markdown={nodePreview.data.content} + /> + ); }; diff --git a/src/modules/mindmap-preview/components/external-node-tile.tsx b/src/modules/mindmap-preview/components/external-node-tile.tsx deleted file mode 100644 index 3d84d0997..000000000 --- a/src/modules/mindmap-preview/components/external-node-tile.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from "react"; -import { HandleX, HandleY } from "./handles"; -import { type NodeProps } from "@xyflow/react"; -import { NodeTile } from "./node-tile"; -import { Button } from "design-system/button"; -import { BiWorld } from "react-icons/bi"; -import type { MindmapPreviewExternalNode } from "store/mindmap-preview/models"; - -type ExternalNodeTileProps = NodeProps; - -const ExternalNodeTile = ({ data }: ExternalNodeTileProps) => { - return ( - - External Resource - {data.name} - {data.description && ( - {data.description} - )} - - - - - - - ); -}; - -const ExternalNodeTileX = (props: ExternalNodeTileProps) => ( - - - -); - -const ExternalNodeTileY = (props: ExternalNodeTileProps) => ( - - - -); - -export { ExternalNodeTileX, ExternalNodeTileY }; diff --git a/src/modules/mindmap-preview/components/handles.tsx b/src/modules/mindmap-preview/components/handles.tsx index 282582ecb..c21ca6abe 100644 --- a/src/modules/mindmap-preview/components/handles.tsx +++ b/src/modules/mindmap-preview/components/handles.tsx @@ -7,12 +7,14 @@ const HandleX = ({ children }: { children: ReactNode }) => ( className="!bg-zinc-200 dark:!bg-gray-950 border-zinc-400 dark:border-zinc-700 border-2 w-3.5 h-10 !left-[1px] rounded-md" type="target" position={Position.Left} + isConnectable={false} /> {children} ); @@ -23,12 +25,14 @@ const HandleY = ({ children }: { children: ReactNode }) => ( className="!bg-zinc-200 dark:!bg-gray-950 border-zinc-400 dark:border-zinc-700 border-2 w-10 h-3.5 !top-[1px] rounded-md" type="target" position={Position.Top} + isConnectable={false} /> {children} ); diff --git a/src/modules/mindmap-preview/components/node-tile.tsx b/src/modules/mindmap-preview/components/node-tile.tsx index f8e32818c..b5c616b46 100644 --- a/src/modules/mindmap-preview/components/node-tile.tsx +++ b/src/modules/mindmap-preview/components/node-tile.tsx @@ -1,20 +1,18 @@ +import { c } from "design-system/c"; import React, { type ReactNode } from "react"; -import c from "classnames"; const NodeTile = ({ + className, children, - selected, }: { + className?: string; children: ReactNode; - selected: boolean; }) => { return (
    {children} @@ -35,10 +33,11 @@ NodeTile.Label = ({ children }: { children: ReactNode }) => { NodeTile.Description = ({ children }: { children: ReactNode }) => { return

    {children}

    ; }; + // eslint-disable-next-line react/display-name NodeTile.Toolbox = ({ children }: { children: ReactNode }) => { return ( -
    +
    {children}
    ); diff --git a/src/modules/mindmap-preview/containers/embedded-node-tile.container.tsx b/src/modules/mindmap-preview/containers/embedded-node-tile.container.tsx index bdb889a66..896c8598f 100644 --- a/src/modules/mindmap-preview/containers/embedded-node-tile.container.tsx +++ b/src/modules/mindmap-preview/containers/embedded-node-tile.container.tsx @@ -3,46 +3,81 @@ import React from "react"; import { HandleX, HandleY } from "../components/handles"; import { NodeTile } from "../components/node-tile"; import { Button } from "design-system/button"; -import { BiBook } from "react-icons/bi"; -import type { MindmapPreviewEmbeddedNode } from "store/mindmap-preview/models"; +import { BiBook, BiCheckboxChecked, BiCheckboxMinus } from "react-icons/bi"; import { openNodePreviewAction } from "store/mindmap-preview/actions"; +import { MindmapPreviewEmbeddedNodeWithCompletion } from "../models"; +import { + useResourceCompletionToggle, + useResourcesCompletionState, +} from "modules/resource-completions"; +import { MindmapNodeId } from "api-4markdown-contracts"; -type EmbeddedNodeTileContainerProps = NodeProps; +type EmbeddedNodeTileContainerProps = + NodeProps; const EmbeddedNodeTileContainer = ({ id, positionAbsoluteX, positionAbsoluteY, data, -}: EmbeddedNodeTileContainerProps) => ( - - Embedded Resource - {data.name} - {data.description && ( - {data.description} - )} - - - - -); +}: EmbeddedNodeTileContainerProps) => { + const resourcesCompletionState = useResourcesCompletionState(); + const [state, completion, toggle] = useResourceCompletionToggle({ + type: "mindmap-node", + resourceId: id as MindmapNodeId, + parentId: data.mindmapId, + }); + + return ( + + Embedded Resource + {data.name} + {data.description && ( + {data.description} + )} + + + + + + ); +}; const EmbeddedNodeTileContainerX = (props: EmbeddedNodeTileContainerProps) => ( diff --git a/src/modules/mindmap-preview/containers/external-node-tile.container.tsx b/src/modules/mindmap-preview/containers/external-node-tile.container.tsx new file mode 100644 index 000000000..bbbaf2b03 --- /dev/null +++ b/src/modules/mindmap-preview/containers/external-node-tile.container.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { HandleX, HandleY } from "../components/handles"; +import { type NodeProps } from "@xyflow/react"; +import { NodeTile } from "../components/node-tile"; +import { Button } from "design-system/button"; +import { BiCheckboxChecked, BiCheckboxMinus, BiWorld } from "react-icons/bi"; +import { MindmapPreviewExternalNodeWithCompletion } from "../models"; +import { + useResourceCompletionToggle, + useResourcesCompletionState, +} from "modules/resource-completions"; +import { MindmapNodeId } from "api-4markdown-contracts"; + +type ExternalNodeTileContainerProps = + NodeProps; + +const ExternalNodeTileContainer = ({ + id, + data, +}: ExternalNodeTileContainerProps) => { + const resourcesCompletionState = useResourcesCompletionState(); + const [state, completion, toggle] = useResourceCompletionToggle({ + type: "mindmap-node", + resourceId: id as MindmapNodeId, + parentId: data.mindmapId, + }); + + return ( + + External Resource + {data.name} + {data.description && ( + {data.description} + )} + + + + + + + + ); +}; + +const ExternalNodeTileContainerX = (props: ExternalNodeTileContainerProps) => ( + + + +); + +const ExternalNodeTileContainerY = (props: ExternalNodeTileContainerProps) => ( + + + +); + +export { ExternalNodeTileContainerX, ExternalNodeTileContainerY }; diff --git a/src/modules/mindmap-preview/mindmap-preview.module.tsx b/src/modules/mindmap-preview/mindmap-preview.module.tsx index 061ddb1b7..25b7d1d79 100644 --- a/src/modules/mindmap-preview/mindmap-preview.module.tsx +++ b/src/modules/mindmap-preview/mindmap-preview.module.tsx @@ -12,13 +12,12 @@ import React, { type ComponentType } from "react"; import { useMindmapPreviewState } from "store/mindmap-preview"; import { readyMindmapPreviewSelector } from "store/mindmap-preview/selectors"; import { - ExternalNodeTileX, - ExternalNodeTileY, -} from "./components/external-node-tile"; + ExternalNodeTileContainerX, + ExternalNodeTileContainerY, +} from "./containers/external-node-tile.container"; import { SolidEdge } from "./components/solid-edge"; import type { MindmapPreviewEdge, - MindmapPreviewNode, MindmapPreviewOkMindmap, } from "store/mindmap-preview/models"; import { @@ -26,12 +25,26 @@ import { EmbeddedNodeTileContainerY, } from "./containers/embedded-node-tile.container"; import { closeNodePreviewAction } from "store/mindmap-preview/actions"; -import { MarkdownWidget } from "components/markdown-widget"; +import { useResourceCompletionToggle } from "modules/resource-completions"; +import { + API4MarkdownPayload, + MindmapId, + MindmapNodeId, +} from "api-4markdown-contracts"; +import { MindmapPreviewNodeWithCompletion } from "./models"; +import { Button } from "design-system/button"; +import { BiCheckboxChecked, BiCheckboxMinus } from "react-icons/bi"; + +const MarkdownWidget = React.lazy(() => + import("components/markdown-widget").then(({ MarkdownWidget }) => ({ + default: MarkdownWidget, + })), +); type MindmapNodeTypes = { [Orientation in MindmapPreviewOkMindmap["orientation"]]: { - [Type in MindmapPreviewNode["type"]]: ComponentType< - NodeProps> + [Type in MindmapPreviewNodeWithCompletion["type"]]: ComponentType< + NodeProps> >; }; }; @@ -44,15 +57,30 @@ type MindmapEdgeTypes = { const mindmapNodeTypes: MindmapNodeTypes = { x: { - external: ExternalNodeTileX, + external: ExternalNodeTileContainerX, embedded: EmbeddedNodeTileContainerX, }, y: { - external: ExternalNodeTileY, + external: ExternalNodeTileContainerY, embedded: EmbeddedNodeTileContainerY, }, }; +const ResourceCompletionTriggerContainer = ( + props: API4MarkdownPayload<"setUserResourceCompletion">, +) => { + const [state, completion, toggle] = useResourceCompletionToggle(props); + // @TODO[PRIO=2]: [Handle error case with some toast or error message]. + return ( + + ); +}; const edgeTypes: MindmapEdgeTypes = { solid: SolidEdge, }; @@ -63,10 +91,22 @@ const MindmapPreviewModule = () => { ); const nodePreview = useMindmapPreviewState((state) => state.nodePreview); + const nodes = React.useMemo( + () => + mindmap.nodes.map((node) => ({ + ...node, + data: { + ...node.data, + mindmapId: mindmap.id, + }, + })), + [mindmap.nodes, mindmap.id], + ); + return ( <> { {nodePreview.is === `on` && ( - + + + } + chunksActive={false} + onClose={closeNodePreviewAction} + markdown={nodePreview.data.content || `No content for this node`} + /> + )} ); diff --git a/src/modules/mindmap-preview/models/index.ts b/src/modules/mindmap-preview/models/index.ts new file mode 100644 index 000000000..d885054cb --- /dev/null +++ b/src/modules/mindmap-preview/models/index.ts @@ -0,0 +1,36 @@ +import { MindmapId, ResourceCompletionDto } from "api-4markdown-contracts"; +import { Prettify } from "development-kit/utility-types"; +import { + MindmapPreviewEmbeddedNode, + MindmapPreviewExternalNode, +} from "store/mindmap-preview/models"; + +type MindmapPreviewEmbeddedNodeWithCompletion = Prettify< + Omit & { + data: Prettify< + MindmapPreviewEmbeddedNode["data"] & { + mindmapId: MindmapId; + } + >; + } +>; + +type MindmapPreviewExternalNodeWithCompletion = Prettify< + Omit & { + data: Prettify< + MindmapPreviewExternalNode["data"] & { + mindmapId: MindmapId; + } + >; + } +>; + +type MindmapPreviewNodeWithCompletion = + | MindmapPreviewEmbeddedNodeWithCompletion + | MindmapPreviewExternalNodeWithCompletion; + +export type { + MindmapPreviewNodeWithCompletion, + MindmapPreviewEmbeddedNodeWithCompletion, + MindmapPreviewExternalNodeWithCompletion, +}; diff --git a/src/modules/resource-completions/acts/load-resource-completions.act.ts b/src/modules/resource-completions/acts/load-resource-completions.act.ts new file mode 100644 index 000000000..d4ac0ee62 --- /dev/null +++ b/src/modules/resource-completions/acts/load-resource-completions.act.ts @@ -0,0 +1,37 @@ +import { getAPI, getCache, parseError, setCache } from "api-4markdown"; +import { useResourcesCompletionState } from "../store"; +import { API4MarkdownContractKey } from "api-4markdown-contracts"; + +const loadResourceCompletionsAct = async (): Promise => { + try { + const key: API4MarkdownContractKey = `getUserResourceCompletions`; + + const cachedCompletions = getCache(key); + + if (cachedCompletions !== null) { + useResourcesCompletionState.swap({ + is: `ok`, + data: cachedCompletions, + }); + return; + } + + useResourcesCompletionState.swap({ is: `busy` }); + + const completions = await getAPI().call(key)(); + + useResourcesCompletionState.swap({ + is: `ok`, + data: completions, + }); + + setCache(key, completions); + } catch (error) { + useResourcesCompletionState.swap({ + is: `fail`, + error: parseError(error), + }); + } +}; + +export { loadResourceCompletionsAct }; diff --git a/src/modules/resource-completions/acts/toggle-resource-completion.act.ts b/src/modules/resource-completions/acts/toggle-resource-completion.act.ts new file mode 100644 index 000000000..d2688591d --- /dev/null +++ b/src/modules/resource-completions/acts/toggle-resource-completion.act.ts @@ -0,0 +1,54 @@ +import { API4MarkdownPayload } from "api-4markdown-contracts"; +import { getAPI, parseError, setCache } from "api-4markdown"; +import { AsyncResult } from "development-kit/utility-types"; +import { useResourcesCompletionState } from "../store"; +import { okResourcesCompletionSelector } from "../store/selectors"; + +const toggleResourceCompletionAct = async ( + payload: API4MarkdownPayload<"setUserResourceCompletion">, +): AsyncResult => { + try { + const completion = await getAPI().call("setUserResourceCompletion")( + payload, + ); + + const currentCompletions = { + ...okResourcesCompletionSelector(useResourcesCompletionState.get()).data, + }; + + if (completion) { + const newCompletions = { + ...currentCompletions, + [payload.resourceId]: completion, + }; + + useResourcesCompletionState.swap({ + is: `ok`, + data: newCompletions, + }); + + setCache("getUserResourceCompletions", newCompletions); + + return { is: `ok` }; + } + + const { [payload.resourceId]: completionToRemove, ...newCompletions } = + currentCompletions; + + useResourcesCompletionState.swap({ + is: `ok`, + data: newCompletions, + }); + + setCache("getUserResourceCompletions", newCompletions); + + return { is: `ok` }; + } catch (error) { + return { + is: `fail`, + error: parseError(error), + }; + } +}; + +export { toggleResourceCompletionAct }; diff --git a/src/modules/resource-completions/hooks/use-is-resource-completed.ts b/src/modules/resource-completions/hooks/use-is-resource-completed.ts new file mode 100644 index 000000000..c53ec0dce --- /dev/null +++ b/src/modules/resource-completions/hooks/use-is-resource-completed.ts @@ -0,0 +1,19 @@ +import { useShallow } from "zustand/react/shallow"; +import { useResourcesCompletionState } from "../store"; +import { rawResourcesCompletionSelector } from "../store/selectors"; +import { ResourceCompletionDto, ResourceId } from "api-4markdown-contracts"; + +const useResourceCompletion = (resourceId: ResourceId) => { + const completion = useResourcesCompletionState( + useShallow( + (state) => + rawResourcesCompletionSelector(state)[resourceId] as + | ResourceCompletionDto + | undefined, + ), + ); + + return completion; +}; + +export { useResourceCompletion }; diff --git a/src/modules/resource-completions/hooks/use-resource-completion-toggle.ts b/src/modules/resource-completions/hooks/use-resource-completion-toggle.ts new file mode 100644 index 000000000..10b9d85f6 --- /dev/null +++ b/src/modules/resource-completions/hooks/use-resource-completion-toggle.ts @@ -0,0 +1,32 @@ +import React from "react"; +import { useAuthStore } from "store/auth/auth.store"; +import { toggleResourceCompletionAct } from "../acts/toggle-resource-completion.act"; +import { logIn } from "actions/log-in.action"; +import { API4MarkdownPayload } from "api-4markdown-contracts"; +import { Transaction } from "development-kit/utility-types"; +import { useResourceCompletion } from "./use-is-resource-completed"; + +const useResourceCompletionToggle = ( + payload: API4MarkdownPayload<"setUserResourceCompletion">, +) => { + const [state, setState] = React.useState({ + is: `idle`, + }); + + const completion = useResourceCompletion(payload.resourceId); + + const toggle = React.useCallback(async () => { + const authStore = useAuthStore.getState(); + + if (authStore.is === "authorized") { + setState({ is: `busy` }); + setState(await toggleResourceCompletionAct(payload)); + } else { + logIn(); + } + }, [payload]); + + return [state, completion, toggle] as const; +}; + +export { useResourceCompletionToggle }; diff --git a/src/modules/resource-completions/index.ts b/src/modules/resource-completions/index.ts new file mode 100644 index 000000000..ccbf5ffcd --- /dev/null +++ b/src/modules/resource-completions/index.ts @@ -0,0 +1,4 @@ +export { useResourcesCompletionState } from "./store"; +export { loadResourceCompletionsAct } from "./acts/load-resource-completions.act"; +export { useResourceCompletionToggle } from "./hooks/use-resource-completion-toggle"; +export { useResourceCompletion } from "./hooks/use-is-resource-completed"; diff --git a/src/modules/resource-completions/store/index.ts b/src/modules/resource-completions/store/index.ts new file mode 100644 index 000000000..12ff761e7 --- /dev/null +++ b/src/modules/resource-completions/store/index.ts @@ -0,0 +1,8 @@ +import { state } from "development-kit/state"; +import type { ResourcesCompletionState } from "./models"; + +const useResourcesCompletionState = state({ + is: `idle`, +}); + +export { useResourcesCompletionState }; diff --git a/src/modules/resource-completions/store/models.ts b/src/modules/resource-completions/store/models.ts new file mode 100644 index 000000000..ec800a5bb --- /dev/null +++ b/src/modules/resource-completions/store/models.ts @@ -0,0 +1,13 @@ +import { ResourceCompletionDto, ResourceId } from "api-4markdown-contracts"; +import { Transaction } from "development-kit/utility-types"; + +type ResourcesCompletionState = Transaction<{ + data: Record; +}>; + +type OkResourcesCompletionState = Extract< + ResourcesCompletionState, + { is: `ok` } +>; + +export type { ResourcesCompletionState, OkResourcesCompletionState }; diff --git a/src/modules/resource-completions/store/selectors.ts b/src/modules/resource-completions/store/selectors.ts new file mode 100644 index 000000000..f8000ebde --- /dev/null +++ b/src/modules/resource-completions/store/selectors.ts @@ -0,0 +1,20 @@ +import { OkResourcesCompletionState, ResourcesCompletionState } from "./models"; + +const okResourcesCompletionSelector = ( + state: ResourcesCompletionState, +): OkResourcesCompletionState => { + if (state.is !== `ok`) + throw Error(`Invalid reading attempt. Cannot find resource completion`); + + return state; +}; + +const rawResourcesCompletionSelector = ( + state: ResourcesCompletionState, +): OkResourcesCompletionState["data"] => { + if (state.is !== `ok`) return {}; + + return state.data; +}; + +export { okResourcesCompletionSelector, rawResourcesCompletionSelector };