diff --git a/packages/client/src/handlers/mutations/channels/channel-create.ts b/packages/client/src/handlers/mutations/channels/channel-create.ts deleted file mode 100644 index 6d073321..00000000 --- a/packages/client/src/handlers/mutations/channels/channel-create.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { WorkspaceMutationHandlerBase } from '@colanode/client/handlers/mutations/workspace-mutation-handler-base'; -import { MutationHandler } from '@colanode/client/lib/types'; -import { MutationError, MutationErrorCode } from '@colanode/client/mutations'; -import { - ChannelCreateMutationInput, - ChannelCreateMutationOutput, -} from '@colanode/client/mutations/channels/channel-create'; -import { ChannelAttributes, generateId, IdType } from '@colanode/core'; - -export class ChannelCreateMutationHandler - extends WorkspaceMutationHandlerBase - implements MutationHandler -{ - async handleMutation( - input: ChannelCreateMutationInput - ): Promise { - const workspace = this.getWorkspace(input.userId); - - const space = await workspace.database - .selectFrom('nodes') - .selectAll() - .where('id', '=', input.spaceId) - .executeTakeFirst(); - - if (!space) { - throw new MutationError( - MutationErrorCode.SpaceNotFound, - 'Space not found or has been deleted.' - ); - } - - const id = generateId(IdType.Channel); - const attributes: ChannelAttributes = { - type: 'channel', - name: input.name, - avatar: input.avatar, - parentId: input.spaceId, - }; - - await workspace.nodes.createNode({ - id, - attributes, - parentId: input.spaceId, - }); - - return { - id: id, - }; - } -} diff --git a/packages/client/src/handlers/mutations/index.ts b/packages/client/src/handlers/mutations/index.ts index 7b4f1626..1f7cbec4 100644 --- a/packages/client/src/handlers/mutations/index.ts +++ b/packages/client/src/handlers/mutations/index.ts @@ -16,7 +16,6 @@ import { EmailRegisterMutationHandler } from './auth/email-register'; import { EmailVerifyMutationHandler } from './auth/email-verify'; import { GoogleLoginMutationHandler } from './auth/google-login'; import { AvatarUploadMutationHandler } from './avatars/avatar-upload'; -import { ChannelCreateMutationHandler } from './channels/channel-create'; import { ChannelUpdateMutationHandler } from './channels/channel-update'; import { ChatCreateMutationHandler } from './chats/chat-create'; import { DatabaseCreateMutationHandler } from './databases/database-create'; @@ -42,6 +41,7 @@ import { MessageCreateMutationHandler } from './messages/message-create'; import { NodeCollaboratorCreateMutationHandler } from './nodes/node-collaborator-create'; import { NodeCollaboratorDeleteMutationHandler } from './nodes/node-collaborator-delete'; import { NodeCollaboratorUpdateMutationHandler } from './nodes/node-collaborator-update'; +import { NodeCreateMutationHandler } from './nodes/node-create'; import { NodeDeleteMutationHandler } from './nodes/node-delete'; import { NodeInteractionOpenedMutationHandler } from './nodes/node-interaction-opened'; import { NodeInteractionSeenMutationHandler } from './nodes/node-interaction-seen'; @@ -80,8 +80,8 @@ export const buildMutationHandlerMap = ( 'email.verify': new EmailVerifyMutationHandler(app), 'google.login': new GoogleLoginMutationHandler(app), 'view.create': new ViewCreateMutationHandler(app), - 'channel.create': new ChannelCreateMutationHandler(app), 'node.delete': new NodeDeleteMutationHandler(app), + 'node.create': new NodeCreateMutationHandler(app), 'chat.create': new ChatCreateMutationHandler(app), 'database.create': new DatabaseCreateMutationHandler(app), 'database.name.field.update': new DatabaseNameFieldUpdateMutationHandler( diff --git a/packages/client/src/handlers/mutations/nodes/node-create.ts b/packages/client/src/handlers/mutations/nodes/node-create.ts new file mode 100644 index 00000000..1a5c89f3 --- /dev/null +++ b/packages/client/src/handlers/mutations/nodes/node-create.ts @@ -0,0 +1,22 @@ +import { WorkspaceMutationHandlerBase } from '@colanode/client/handlers/mutations/workspace-mutation-handler-base'; +import { MutationHandler } from '@colanode/client/lib/types'; +import { + NodeCreateMutationInput, + NodeCreateMutationOutput, +} from '@colanode/client/mutations/nodes/node-create'; + +export class NodeCreateMutationHandler + extends WorkspaceMutationHandlerBase + implements MutationHandler +{ + async handleMutation( + input: NodeCreateMutationInput + ): Promise { + const workspace = this.getWorkspace(input.userId); + await workspace.nodes.insertNode(input.node); + + return { + success: true, + }; + } +} diff --git a/packages/client/src/mutations/channels/channel-create.ts b/packages/client/src/mutations/channels/channel-create.ts deleted file mode 100644 index ad0b3361..00000000 --- a/packages/client/src/mutations/channels/channel-create.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type ChannelCreateMutationInput = { - type: 'channel.create'; - userId: string; - spaceId: string; - name: string; - avatar?: string | null; -}; - -export type ChannelCreateMutationOutput = { - id: string; -}; - -declare module '@colanode/client/mutations' { - interface MutationMap { - 'channel.create': { - input: ChannelCreateMutationInput; - output: ChannelCreateMutationOutput; - }; - } -} diff --git a/packages/client/src/mutations/index.ts b/packages/client/src/mutations/index.ts index 8c357835..230140ac 100644 --- a/packages/client/src/mutations/index.ts +++ b/packages/client/src/mutations/index.ts @@ -9,7 +9,6 @@ export * from './auth/google-login'; export * from './apps/metadata-delete'; export * from './apps/metadata-update'; export * from './avatars/avatar-upload'; -export * from './channels/channel-create'; export * from './channels/channel-update'; export * from './chats/chat-create'; export * from './databases/database-create'; @@ -62,6 +61,7 @@ export * from './apps/tab-update'; export * from './servers/server-sync'; export * from './apps/tab-delete'; export * from './nodes/node-delete'; +export * from './nodes/node-create'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface MutationMap {} diff --git a/packages/client/src/mutations/nodes/node-create.ts b/packages/client/src/mutations/nodes/node-create.ts new file mode 100644 index 00000000..fc0dc3b0 --- /dev/null +++ b/packages/client/src/mutations/nodes/node-create.ts @@ -0,0 +1,20 @@ +import { LocalNode } from '@colanode/client/types'; + +export type NodeCreateMutationInput = { + type: 'node.create'; + userId: string; + node: LocalNode; +}; + +export type NodeCreateMutationOutput = { + success: boolean; +}; + +declare module '@colanode/client/mutations' { + interface MutationMap { + 'node.create': { + input: NodeCreateMutationInput; + output: NodeCreateMutationOutput; + }; + } +} diff --git a/packages/client/src/services/workspaces/node-service.ts b/packages/client/src/services/workspaces/node-service.ts index 4f8baf65..3f7473bb 100644 --- a/packages/client/src/services/workspaces/node-service.ts +++ b/packages/client/src/services/workspaces/node-service.ts @@ -16,7 +16,7 @@ import { } from '@colanode/client/lib/mentions'; import { deleteNodeRelations, fetchNodeTree } from '@colanode/client/lib/utils'; import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; -import { DownloadStatus } from '@colanode/client/types'; +import { DownloadStatus, LocalNode } from '@colanode/client/types'; import { generateId, IdType, @@ -225,6 +225,173 @@ export class NodeService { return createdNode; } + public async insertNode(input: LocalNode): Promise { + debug(`Inserting node ${input.id} with type ${input.attributes.type}`); + + const tree = input.parentId + ? await fetchNodeTree(this.workspace.database, input.parentId) + : []; + + const model = getNodeModel(input.attributes.type); + const canCreateNodeContext: CanCreateNodeContext = { + user: { + id: this.workspace.userId, + role: this.workspace.role, + workspaceId: this.workspace.workspaceId, + accountId: this.workspace.accountId, + }, + tree: tree, + attributes: input.attributes, + }; + + if (!model.canCreate(canCreateNodeContext)) { + throw new Error('Insufficient permissions'); + } + + const ydoc = new YDoc(); + const update = ydoc.update(model.attributesSchema, input.attributes); + + if (!update) { + throw new Error('Invalid attributes'); + } + + const updateId = generateId(IdType.Update); + const createdAt = new Date().toISOString(); + const rootId = tree[0]?.id ?? input.id; + const nodeText = model.extractText(input.id, input.attributes); + const mentions = model.extractMentions(input.id, input.attributes); + const nodeReferencesToCreate: CreateNodeReference[] = mentions.map( + (mention) => ({ + node_id: input.id, + reference_id: mention.target, + inner_id: mention.id, + type: 'mention', + created_at: createdAt, + created_by: this.workspace.userId, + }) + ); + + const { createdNode, createdMutation, createdNodeReferences } = + await this.workspace.database.transaction().execute(async (trx) => { + const createdNode = await trx + .insertInto('nodes') + .returningAll() + .values({ + id: input.id, + root_id: rootId, + attributes: JSON.stringify(input.attributes), + created_at: createdAt, + created_by: this.workspace.userId, + local_revision: '0', + server_revision: '0', + }) + .executeTakeFirst(); + + if (!createdNode) { + throw new Error('Failed to create node'); + } + + const createdNodeUpdate = await trx + .insertInto('node_updates') + .returningAll() + .values({ + id: updateId, + node_id: input.id, + data: update, + created_at: createdAt, + }) + .executeTakeFirst(); + + if (!createdNodeUpdate) { + throw new Error('Failed to create node update'); + } + + const mutationData: CreateNodeMutationData = { + nodeId: input.id, + updateId: updateId, + data: encodeState(update), + createdAt: createdAt, + }; + + const createdMutation = await trx + .insertInto('mutations') + .returningAll() + .values({ + id: generateId(IdType.Mutation), + type: 'node.create', + data: JSON.stringify(mutationData), + created_at: createdAt, + retries: 0, + }) + .executeTakeFirst(); + + if (!createdMutation) { + throw new Error('Failed to create mutation'); + } + + if (nodeText) { + await trx + .insertInto('node_texts') + .values({ + id: input.id, + name: nodeText.name, + attributes: nodeText.attributes, + }) + .execute(); + } + + let createdNodeReferences: SelectNodeReference[] = []; + if (nodeReferencesToCreate.length > 0) { + createdNodeReferences = await trx + .insertInto('node_references') + .values(nodeReferencesToCreate) + .returningAll() + .execute(); + } + + return { + createdNode, + createdMutation, + createdNodeReferences, + }; + }); + + if (!createdNode) { + throw new Error('Failed to create node'); + } + + if (!createdMutation) { + throw new Error('Failed to create mutation'); + } + + debug(`Created node ${createdNode.id} with type ${createdNode.type}`); + + eventBus.publish({ + type: 'node.created', + workspace: { + workspaceId: this.workspace.workspaceId, + userId: this.workspace.userId, + accountId: this.workspace.accountId, + }, + node: mapNode(createdNode), + }); + + for (const createdNodeReference of createdNodeReferences) { + eventBus.publish({ + type: 'node.reference.created', + workspace: { + workspaceId: this.workspace.workspaceId, + userId: this.workspace.userId, + accountId: this.workspace.accountId, + }, + nodeReference: mapNodeReference(createdNodeReference), + }); + } + + this.workspace.mutations.scheduleSync(); + return mapNode(createdNode); + } + public async updateNode( nodeId: string, updater: (attributes: T) => T diff --git a/packages/ui/src/collections/nodes.ts b/packages/ui/src/collections/nodes.ts index a60144e0..c2ddb47c 100644 --- a/packages/ui/src/collections/nodes.ts +++ b/packages/ui/src/collections/nodes.ts @@ -60,15 +60,15 @@ export const createNodesCollection = (userId: string) => { }; }, }, - // onInsert: async ({ transaction }) => { - // for (const mutation of transaction.mutations) { - // await window.colanode.executeMutation({ - // type: 'node.create', - // userId, - // node: mutation.modified, - // }); - // } - // }, + onInsert: async ({ transaction }) => { + for (const mutation of transaction.mutations) { + await window.colanode.executeMutation({ + type: 'node.create', + userId, + node: mutation.modified, + }); + } + }, // onUpdate: async ({ transaction }) => { // for (const mutation of transaction.mutations) { // const attributes = cloneDeep(mutation.modified.attributes); diff --git a/packages/ui/src/components/channels/channel-create-dialog.tsx b/packages/ui/src/components/channels/channel-create-dialog.tsx index 30f53393..d039c7fa 100644 --- a/packages/ui/src/components/channels/channel-create-dialog.tsx +++ b/packages/ui/src/components/channels/channel-create-dialog.tsx @@ -1,8 +1,14 @@ +import { useMutation } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { toast } from 'sonner'; +import { LocalChannelNode } from '@colanode/client/types'; import { generateId, IdType } from '@colanode/core'; -import { ChannelForm } from '@colanode/ui/components/channels/channel-form'; +import { collections } from '@colanode/ui/collections'; +import { + ChannelForm, + ChannelFormValues, +} from '@colanode/ui/components/channels/channel-form'; import { Dialog, DialogContent, @@ -11,7 +17,6 @@ import { DialogTitle, } from '@colanode/ui/components/ui/dialog'; import { useWorkspace } from '@colanode/ui/contexts/workspace'; -import { useMutation } from '@colanode/ui/hooks/use-mutation'; interface ChannelCreateDialogProps { spaceId: string; @@ -26,7 +31,46 @@ export const ChannelCreateDialog = ({ }: ChannelCreateDialogProps) => { const workspace = useWorkspace(); const navigate = useNavigate({ from: '/workspace/$userId' }); - const { mutate, isPending } = useMutation(); + + const { mutate } = useMutation({ + mutationFn: async (values: ChannelFormValues) => { + const channelId = generateId(IdType.Channel); + const nodes = collections.workspace(workspace.userId).nodes; + + const channel: LocalChannelNode = { + id: channelId, + type: 'channel', + attributes: { + type: 'channel', + name: values.name, + parentId: spaceId, + }, + parentId: spaceId, + rootId: spaceId, + createdAt: new Date().toISOString(), + createdBy: workspace.userId, + updatedAt: null, + updatedBy: null, + localRevision: '0', + serverRevision: '0', + }; + + nodes.insert(channel); + return channel; + }, + onSuccess: (channel) => { + navigate({ + to: '$nodeId', + params: { + nodeId: channel.id, + }, + }); + onOpenChange(false); + }, + onError: (error) => { + toast.error(error.message); + }, + }); return ( @@ -42,38 +86,11 @@ export const ChannelCreateDialog = ({ values={{ name: '', }} - isPending={isPending} submitText="Create" - handleCancel={() => { + onCancel={() => { onOpenChange(false); }} - handleSubmit={(values) => { - if (isPending) { - return; - } - - mutate({ - input: { - type: 'channel.create', - spaceId: spaceId, - name: values.name, - avatar: values.avatar, - userId: workspace.userId, - }, - onSuccess(output) { - onOpenChange(false); - navigate({ - to: '$nodeId', - params: { - nodeId: output.id, - }, - }); - }, - onError(error) { - toast.error(error.message); - }, - }); - }} + onSubmit={(values) => mutate(values)} /> diff --git a/packages/ui/src/components/channels/channel-form.tsx b/packages/ui/src/components/channels/channel-form.tsx index 23e56c93..a192f10d 100644 --- a/packages/ui/src/components/channels/channel-form.tsx +++ b/packages/ui/src/components/channels/channel-form.tsx @@ -14,30 +14,29 @@ import { FormMessage, } from '@colanode/ui/components/ui/form'; import { Input } from '@colanode/ui/components/ui/input'; -import { Spinner } from '@colanode/ui/components/ui/spinner'; const formSchema = z.object({ name: z.string().min(3, 'Name must be at least 3 characters long.'), avatar: z.string().optional().nullable(), }); +export type ChannelFormValues = z.infer; + interface ChannelFormProps { id: string; values: z.infer; - isPending: boolean; submitText: string; - handleCancel: () => void; - handleSubmit: (values: z.infer) => void; + onCancel: () => void; + onSubmit: (values: z.infer) => void; readOnly?: boolean; } export const ChannelForm = ({ id, values, - isPending, submitText, - handleCancel, - handleSubmit, + onCancel, + onSubmit, readOnly = false, }: ChannelFormProps) => { const form = useForm>({ @@ -60,10 +59,7 @@ export const ChannelForm = ({ return (
- +
{readOnly ? (
- -
diff --git a/packages/ui/src/components/channels/channel-update-dialog.tsx b/packages/ui/src/components/channels/channel-update-dialog.tsx index 5fbd5947..60b89d07 100644 --- a/packages/ui/src/components/channels/channel-update-dialog.tsx +++ b/packages/ui/src/components/channels/channel-update-dialog.tsx @@ -45,13 +45,12 @@ export const ChannelUpdateDialog = ({ name: channel.attributes.name, avatar: channel.attributes.avatar, }} - isPending={isPending} submitText="Update" readOnly={!canEdit} - handleCancel={() => { + onCancel={() => { onOpenChange(false); }} - handleSubmit={(values) => { + onSubmit={(values) => { if (isPending) { return; }