Use tanstackdb for select option mutations

This commit is contained in:
Hakan Shehu
2025-11-21 08:00:55 -08:00
parent 3033d12a24
commit 0c70ac0a35
13 changed files with 138 additions and 390 deletions

View File

@@ -1,83 +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 {
SelectOptionCreateMutationInput,
SelectOptionCreateMutationOutput,
} from '@colanode/client/mutations/databases/select-option-create';
import {
compareString,
DatabaseAttributes,
generateId,
generateFractionalIndex,
IdType,
} from '@colanode/core';
export class SelectOptionCreateMutationHandler
extends WorkspaceMutationHandlerBase
implements MutationHandler<SelectOptionCreateMutationInput>
{
async handleMutation(
input: SelectOptionCreateMutationInput
): Promise<SelectOptionCreateMutationOutput> {
const workspace = this.getWorkspace(input.userId);
const id = generateId(IdType.SelectOption);
const result = await workspace.nodes.updateNode<DatabaseAttributes>(
input.databaseId,
(attributes) => {
const field = attributes.fields[input.fieldId];
if (!field) {
throw new MutationError(
MutationErrorCode.FieldNotFound,
'The field you are trying to create a select option in does not exist.'
);
}
if (field.type !== 'multi_select' && field.type !== 'select') {
throw new MutationError(
MutationErrorCode.FieldTypeInvalid,
'The field you are trying to create a select option in is not a "Select" or "Multi-Select" field.'
);
}
if (!field.options) {
field.options = {};
}
const maxIndex = Object.values(field.options)
.map((selectOption) => selectOption.index)
.sort((a, b) => -compareString(a, b))[0];
const index = generateFractionalIndex(maxIndex, null);
field.options[id] = {
name: input.name,
id: id,
color: input.color,
index: index,
};
return attributes;
}
);
if (result === 'unauthorized') {
throw new MutationError(
MutationErrorCode.SelectOptionCreateForbidden,
"You don't have permission to create a select option in this field."
);
}
if (result !== 'success') {
throw new MutationError(
MutationErrorCode.SelectOptionCreateFailed,
'Something went wrong while creating the select option.'
);
}
return {
id: id,
};
}
}

View File

@@ -1,75 +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 {
SelectOptionDeleteMutationInput,
SelectOptionDeleteMutationOutput,
} from '@colanode/client/mutations/databases/select-option-delete';
import { DatabaseAttributes } from '@colanode/core';
export class SelectOptionDeleteMutationHandler
extends WorkspaceMutationHandlerBase
implements MutationHandler<SelectOptionDeleteMutationInput>
{
async handleMutation(
input: SelectOptionDeleteMutationInput
): Promise<SelectOptionDeleteMutationOutput> {
const workspace = this.getWorkspace(input.userId);
const result = await workspace.nodes.updateNode<DatabaseAttributes>(
input.databaseId,
(attributes) => {
const field = attributes.fields[input.fieldId];
if (!field) {
throw new MutationError(
MutationErrorCode.FieldNotFound,
'The field you are trying to delete a select option from does not exist.'
);
}
if (field.type !== 'multi_select' && field.type !== 'select') {
throw new MutationError(
MutationErrorCode.FieldTypeInvalid,
'The field you are trying to delete a select option from is not a "Select" or "Multi-Select" field.'
);
}
if (!field.options) {
throw new MutationError(
MutationErrorCode.SelectOptionNotFound,
'The field you are trying to delete a select option from does not have any select options.'
);
}
if (!field.options[input.optionId]) {
throw new MutationError(
MutationErrorCode.SelectOptionNotFound,
'The select option you are trying to delete does not exist.'
);
}
delete field.options[input.optionId];
return attributes;
}
);
if (result === 'unauthorized') {
throw new MutationError(
MutationErrorCode.SelectOptionDeleteForbidden,
"You don't have permission to delete this select option."
);
}
if (result !== 'success') {
throw new MutationError(
MutationErrorCode.SelectOptionDeleteFailed,
'Something went wrong while deleting the select option.'
);
}
return {
id: input.optionId,
};
}
}

View File

@@ -1,73 +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 {
SelectOptionUpdateMutationInput,
SelectOptionUpdateMutationOutput,
} from '@colanode/client/mutations/databases/select-option-update';
import { DatabaseAttributes } from '@colanode/core';
export class SelectOptionUpdateMutationHandler
extends WorkspaceMutationHandlerBase
implements MutationHandler<SelectOptionUpdateMutationInput>
{
async handleMutation(
input: SelectOptionUpdateMutationInput
): Promise<SelectOptionUpdateMutationOutput> {
const workspace = this.getWorkspace(input.userId);
const result = await workspace.nodes.updateNode<DatabaseAttributes>(
input.databaseId,
(attributes) => {
const field = attributes.fields[input.fieldId];
if (!field) {
throw new MutationError(
MutationErrorCode.FieldNotFound,
'The field you are trying to update a select option in does not exist.'
);
}
if (field.type !== 'multi_select' && field.type !== 'select') {
throw new MutationError(
MutationErrorCode.FieldTypeInvalid,
'The field you are trying to update a select option in is not a "Select" or "Multi-Select" field.'
);
}
if (!field.options) {
field.options = {};
}
const option = field.options[input.optionId];
if (!option) {
throw new MutationError(
MutationErrorCode.SelectOptionNotFound,
'The select option you are trying to update does not exist.'
);
}
option.name = input.name;
option.color = input.color;
return attributes;
}
);
if (result === 'unauthorized') {
throw new MutationError(
MutationErrorCode.SelectOptionUpdateForbidden,
"You don't have permission to update this select option."
);
}
if (result !== 'success') {
throw new MutationError(
MutationErrorCode.SelectOptionUpdateFailed,
'Something went wrong while updating the select option.'
);
}
return {
id: input.optionId,
};
}
}

View File

@@ -17,9 +17,6 @@ import { EmailVerifyMutationHandler } from './auth/email-verify';
import { GoogleLoginMutationHandler } from './auth/google-login';
import { AvatarUploadMutationHandler } from './avatars/avatar-upload';
import { ChatCreateMutationHandler } from './chats/chat-create';
import { SelectOptionCreateMutationHandler } from './databases/select-option-create';
import { SelectOptionDeleteMutationHandler } from './databases/select-option-delete';
import { SelectOptionUpdateMutationHandler } from './databases/select-option-update';
import { ViewUpdateMutationHandler } from './databases/view-update';
import { DocumentUpdateMutationHandler } from './documents/document-update';
import { FileCreateMutationHandler } from './files/file-create';
@@ -71,9 +68,6 @@ export const buildMutationHandlerMap = (
'node.interaction.seen': new NodeInteractionSeenMutationHandler(app),
'node.reaction.create': new NodeReactionCreateMutationHandler(app),
'node.reaction.delete': new NodeReactionDeleteMutationHandler(app),
'select.option.create': new SelectOptionCreateMutationHandler(app),
'select.option.delete': new SelectOptionDeleteMutationHandler(app),
'select.option.update': new SelectOptionUpdateMutationHandler(app),
'server.create': new ServerCreateMutationHandler(app),
'server.delete': new ServerDeleteMutationHandler(app),
'server.sync': new ServerSyncMutationHandler(app),

View File

@@ -1,21 +0,0 @@
export type SelectOptionCreateMutationInput = {
type: 'select.option.create';
userId: string;
databaseId: string;
fieldId: string;
name: string;
color: string;
};
export type SelectOptionCreateMutationOutput = {
id: string;
};
declare module '@colanode/client/mutations' {
interface MutationMap {
'select.option.create': {
input: SelectOptionCreateMutationInput;
output: SelectOptionCreateMutationOutput;
};
}
}

View File

@@ -1,20 +0,0 @@
export type SelectOptionDeleteMutationInput = {
type: 'select.option.delete';
userId: string;
databaseId: string;
fieldId: string;
optionId: string;
};
export type SelectOptionDeleteMutationOutput = {
id: string;
};
declare module '@colanode/client/mutations' {
interface MutationMap {
'select.option.delete': {
input: SelectOptionDeleteMutationInput;
output: SelectOptionDeleteMutationOutput;
};
}
}

View File

@@ -1,22 +0,0 @@
export type SelectOptionUpdateMutationInput = {
type: 'select.option.update';
userId: string;
databaseId: string;
fieldId: string;
optionId: string;
name: string;
color: string;
};
export type SelectOptionUpdateMutationOutput = {
id: string;
};
declare module '@colanode/client/mutations' {
interface MutationMap {
'select.option.update': {
input: SelectOptionUpdateMutationInput;
output: SelectOptionUpdateMutationOutput;
};
}
}

View File

@@ -10,9 +10,6 @@ export * from './apps/metadata-delete';
export * from './apps/metadata-update';
export * from './avatars/avatar-upload';
export * from './chats/chat-create';
export * from './databases/select-option-create';
export * from './databases/select-option-delete';
export * from './databases/select-option-update';
export * from './databases/view-update';
export * from './documents/document-update';
export * from './files/file-create';

View File

@@ -1,10 +1,8 @@
import { ReactNode } from 'react';
import { toast } from 'sonner';
import { LocalDatabaseNode } from '@colanode/client/types';
import { NodeRole, hasNodeRole } from '@colanode/core';
import { DatabaseContext } from '@colanode/ui/contexts/database';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
interface DatabaseProps {
database: LocalDatabaseNode;
@@ -13,7 +11,6 @@ interface DatabaseProps {
}
export const Database = ({ database, role, children }: DatabaseProps) => {
const workspace = useWorkspace();
const canEdit = hasNodeRole(role, 'editor');
const canCreateRecord = hasNodeRole(role, 'editor');
@@ -28,54 +25,6 @@ export const Database = ({ database, role, children }: DatabaseProps) => {
canEdit,
canCreateRecord,
rootId: database.rootId,
createSelectOption: async (fieldId, name, color) => {
if (!canEdit) return;
const result = await window.colanode.executeMutation({
type: 'select.option.create',
databaseId: database.id,
fieldId,
name,
color,
userId: workspace.userId,
});
if (!result.success) {
toast.error(result.error.message);
}
},
updateSelectOption: async (fieldId, attributes) => {
if (!canEdit) return;
const result = await window.colanode.executeMutation({
type: 'select.option.update',
databaseId: database.id,
fieldId,
optionId: attributes.id,
name: attributes.name,
color: attributes.color,
userId: workspace.userId,
});
if (!result.success) {
toast.error(result.error.message);
}
},
deleteSelectOption: async (fieldId, optionId) => {
if (!canEdit) return;
const result = await window.colanode.executeMutation({
type: 'select.option.delete',
databaseId: database.id,
fieldId,
optionId,
userId: workspace.userId,
});
if (!result.success) {
toast.error(result.error.message);
}
},
}}
>
{children}

View File

@@ -1,11 +1,15 @@
import { Check, Plus, X } from 'lucide-react';
import { Fragment, useState } from 'react';
import { toast } from 'sonner';
import {
compareString,
generateFractionalIndex,
generateId,
IdType,
MultiSelectFieldAttributes,
SelectFieldAttributes,
} from '@colanode/core';
import { collections } from '@colanode/ui/collections';
import { SelectOptionBadge } from '@colanode/ui/components/databases/fields/select-option-badge';
import { SelectOptionSettingsPopover } from '@colanode/ui/components/databases/fields/select-option-settings-popover';
import {
@@ -18,7 +22,6 @@ import {
} from '@colanode/ui/components/ui/command';
import { useDatabase } from '@colanode/ui/contexts/database';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useMutation } from '@colanode/ui/hooks/use-mutation';
import { getRandomSelectOptionColor } from '@colanode/ui/lib/databases';
interface SelectFieldOptionsProps {
@@ -36,7 +39,6 @@ export const SelectFieldOptions = ({
}: SelectFieldOptionsProps) => {
const workspace = useWorkspace();
const database = useDatabase();
const { mutate, isPending } = useMutation();
const selectOptions = Object.values(field.options ?? {});
@@ -104,32 +106,54 @@ export const SelectFieldOptions = ({
key={inputValue.trim()}
value={inputValue.trim()}
onSelect={() => {
if (isPending) {
return;
}
if (inputValue.trim().length === 0) {
return;
}
mutate({
input: {
type: 'select.option.create',
databaseId: database.id,
fieldId: field.id,
const id = generateId(IdType.SelectOption);
const nodes = collections.workspace(workspace.userId).nodes;
nodes.update(database.id, (draft) => {
if (draft.type !== 'database') {
return;
}
const fieldAttributes = draft.fields[field.id];
if (!fieldAttributes) {
return;
}
if (
fieldAttributes.type !== 'select' &&
fieldAttributes.type !== 'multi_select'
) {
return;
}
const selectOptions = {
...(fieldAttributes.options ?? {}),
};
const maxIndex = Object.values(selectOptions)
.map((selectOption) => selectOption.index)
.sort((a, b) => -compareString(a, b))[0];
const index = generateFractionalIndex(maxIndex, null);
selectOptions[id] = {
id,
index,
name: inputValue.trim(),
color,
userId: workspace.userId,
},
onSuccess(output) {
setInputValue('');
setColor(getRandomSelectOptionColor());
onSelect(output.id);
},
onError(error) {
toast.error(error.message);
},
};
draft.fields[field.id] = {
...fieldAttributes,
options: selectOptions,
};
});
onSelect(id);
setInputValue('');
setColor(getRandomSelectOptionColor());
}}
className="flex flex-row items-center gap-2"
>

View File

@@ -1,3 +1,4 @@
import { collections } from '@colanode/ui/collections';
import {
AlertDialog,
AlertDialogCancel,
@@ -9,6 +10,7 @@ import {
} from '@colanode/ui/components/ui/alert-dialog';
import { Button } from '@colanode/ui/components/ui/button';
import { useDatabase } from '@colanode/ui/contexts/database';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
interface SelectOptionDeleteDialogProps {
fieldId: string;
@@ -23,6 +25,7 @@ export const SelectOptionDeleteDialog = ({
open,
onOpenChange,
}: SelectOptionDeleteDialogProps) => {
const workspace = useWorkspace();
const database = useDatabase();
return (
@@ -42,7 +45,36 @@ export const SelectOptionDeleteDialog = ({
<Button
variant="destructive"
onClick={() => {
database.deleteSelectOption(fieldId, optionId);
const nodes = collections.workspace(workspace.userId).nodes;
nodes.update(database.id, (draft) => {
if (draft.type !== 'database') {
return;
}
const fieldAttributes = draft.fields[fieldId];
if (!fieldAttributes) {
return;
}
if (
fieldAttributes.type !== 'select' &&
fieldAttributes.type !== 'multi_select'
) {
return;
}
const selectOptions = {
...(fieldAttributes.options ?? {}),
};
const { [optionId]: _removed, ...rest } = selectOptions;
draft.fields[fieldId] = {
...fieldAttributes,
options: rest,
};
});
onOpenChange(false);
}}
>
Delete

View File

@@ -2,6 +2,7 @@ import { Ellipsis, Trash2 } from 'lucide-react';
import { Fragment, useState } from 'react';
import { SelectOptionAttributes } from '@colanode/core';
import { collections } from '@colanode/ui/collections';
import { SelectOptionDeleteDialog } from '@colanode/ui/components/databases/fields/select-option-delete-dialog';
import { Label } from '@colanode/ui/components/ui/label';
import {
@@ -12,6 +13,7 @@ import {
import { Separator } from '@colanode/ui/components/ui/separator';
import { SmartTextInput } from '@colanode/ui/components/ui/smart-text-input';
import { useDatabase } from '@colanode/ui/contexts/database';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { selectOptionColors } from '@colanode/ui/lib/databases';
import { cn } from '@colanode/ui/lib/utils';
@@ -24,6 +26,7 @@ export const SelectOptionSettingsPopover = ({
fieldId,
option,
}: SelectOptionSettingsPopoverProps) => {
const workspace = useWorkspace();
const database = useDatabase();
const [openSetttingsPopover, setOpenSetttingsPopover] = useState(false);
@@ -46,9 +49,34 @@ export const SelectOptionSettingsPopover = ({
onChange={(newName) => {
if (newName === option.name) return;
database.updateSelectOption(fieldId, {
...option,
name: newName,
const nodes = collections.workspace(workspace.userId).nodes;
nodes.update(database.id, (draft) => {
if (draft.type !== 'database') {
return;
}
const fieldAttributes = draft.fields[fieldId];
if (!fieldAttributes) {
return;
}
if (
fieldAttributes.type !== 'select' &&
fieldAttributes.type !== 'multi_select'
) {
return;
}
if (!fieldAttributes.options) {
return;
}
const selectOption = fieldAttributes.options[option.id];
if (!selectOption) {
return;
}
selectOption.name = newName;
});
}}
/>
@@ -61,9 +89,34 @@ export const SelectOptionSettingsPopover = ({
key={color.value}
className="flex cursor-pointer flex-row items-center gap-2 rounded-md p-1 hover:bg-accent"
onClick={() => {
database.updateSelectOption(fieldId, {
...option,
color: color.value,
const nodes = collections.workspace(workspace.userId).nodes;
nodes.update(database.id, (draft) => {
if (draft.type !== 'database') {
return;
}
const fieldAttributes = draft.fields[fieldId];
if (!fieldAttributes) {
return;
}
if (
fieldAttributes.type !== 'select' &&
fieldAttributes.type !== 'multi_select'
) {
return;
}
if (!fieldAttributes.options) {
return;
}
const selectOption = fieldAttributes.options[option.id];
if (!selectOption) {
return;
}
selectOption.color = color.value;
});
}}
>

View File

@@ -4,7 +4,6 @@ import {
DatabaseNameFieldAttributes,
FieldAttributes,
NodeRole,
SelectOptionAttributes,
} from '@colanode/core';
interface DatabaseContext {
@@ -16,12 +15,6 @@ interface DatabaseContext {
canCreateRecord: boolean;
role: NodeRole;
rootId: string;
createSelectOption: (fieldId: string, name: string, color: string) => void;
updateSelectOption: (
fieldId: string,
attributes: SelectOptionAttributes
) => void;
deleteSelectOption: (fieldId: string, optionId: string) => void;
}
export const DatabaseContext = createContext<DatabaseContext>(