From 0b31872741ede30b4a1397dc43e000fda7d1954d Mon Sep 17 00:00:00 2001
From: barry
Date: Wed, 24 Jun 2026 17:11:39 +0800
Subject: [PATCH 01/15] feat(page-meta-types): introduce built-in starter meta
type presets
Add new meta type presets for 'Bookmark', 'Project', 'Issue', 'Repo', 'Team', 'Events', and 'People' to enhance workspace initialization. Update the `ensurePageMetaTypes` function to seed these schemas when missing, ensuring a more comprehensive setup for new workspaces. Enhance related tests to verify the creation of these presets during workspace setup.
---
docs/page-meta-types.md | 19 +-
packages/client/src/lib/page-meta-types.ts | 347 +++++++++++++++++++
packages/client/test/page-meta-types.test.ts | 75 ++++
3 files changed, 438 insertions(+), 3 deletions(-)
diff --git a/docs/page-meta-types.md b/docs/page-meta-types.md
index fc7e90f56..73f88b381 100644
--- a/docs/page-meta-types.md
+++ b/docs/page-meta-types.md
@@ -20,12 +20,25 @@ On workspace init, `ensurePageMetaTypes` creates:
1. Hidden space `Meta Types` (`description: __colanode_meta_types__`) — hidden from the Spaces sidebar
2. Default meta type database named `Page`
-3. Backfills missing or invalid `metaTypeId` on existing pages via `node.update` (creates syncable `node_updates`; SQLite is reconciled from YDoc when CRDT and SQLite drift)
-4. When local sync is enabled, the first import runs before `ensurePageMetaTypes` so a remote default `Page` schema is not duplicated locally
-5. If duplicate default `Page` meta types already exist, `consolidateDuplicatePageMetaTypes` keeps the schema with more fields / page references and removes the rest
+3. Built-in starter meta types (if missing): `Bookmark`, `Project`, `Issue`, `Repo`, `Team`, `Events`, `People`
+4. Backfills missing or invalid `metaTypeId` on existing pages via `node.update` (creates syncable `node_updates`; SQLite is reconciled from YDoc when CRDT and SQLite drift)
+5. When local sync is enabled, the first import runs before `ensurePageMetaTypes` so a remote default `Page` schema is not duplicated locally
+6. If duplicate default `Page` meta types already exist, `consolidateDuplicatePageMetaTypes` keeps the schema with more fields / page references and removes the rest
The default `Page` meta type cannot be deleted (`databaseModel.canDelete`).
+### Starter meta type presets
+
+`ensurePageMetaTypes` now seeds the following starter schemas (when missing):
+
+- `Bookmark` — `URL`, `Description`, `Tags`, `Status`, `Source`, `Saved At`
+- `Project` — `Status`, `Priority`, `Target Date`, `Owner`, `Progress`, `Start Date`
+- `Issue` — `Status`, `Priority`, `Severity`, `Assignee`, `Due Date`, `Reporter`
+- `Repo` — `URL`, `Provider`, `Language`, `Stars`, `Archived`, `License`, `Default Branch`
+- `Team` — `Lead`, `Members`, `Contact Email`, `Status`, `Focus`, `Docs`
+- `Events` — `Start Date`, `End Date`, `Location`, `Organizer`, `Event Link`, `Status`, `Type`
+- `People` — `Email`, `Phone`, `Role`, `Team`, `Profile`, `Timezone`, `Status`
+
### Field defaults
Meta type field schemas may define an optional `default` on supported field types (text, number, boolean, select, etc.). New pages receive `resolveInitialPageFields()`:
diff --git a/packages/client/src/lib/page-meta-types.ts b/packages/client/src/lib/page-meta-types.ts
index 62bcb5c3f..0bb3fe25e 100644
--- a/packages/client/src/lib/page-meta-types.ts
+++ b/packages/client/src/lib/page-meta-types.ts
@@ -25,6 +25,292 @@ type PageMetaTypeRow = {
createdAt: string;
};
+type SelectOptionPreset = {
+ name: string;
+ color: string;
+};
+
+type FieldPreset =
+ | {
+ type:
+ | 'text'
+ | 'url'
+ | 'email'
+ | 'date'
+ | 'number'
+ | 'boolean'
+ | 'phone';
+ name: string;
+ default?: string | number | boolean;
+ }
+ | {
+ type: 'select';
+ name: string;
+ options: SelectOptionPreset[];
+ default?: string;
+ };
+
+type MetaTypePreset = {
+ name: string;
+ fields: FieldPreset[];
+};
+
+const DEFAULT_META_TYPE_PRESETS: MetaTypePreset[] = [
+ {
+ name: 'Bookmark',
+ fields: [
+ { type: 'url', name: 'URL' },
+ { type: 'text', name: 'Description' },
+ { type: 'text', name: 'Tags' },
+ {
+ type: 'select',
+ name: 'Status',
+ options: [
+ { name: 'Inbox', color: 'gray' },
+ { name: 'Reading', color: 'blue' },
+ { name: 'Saved', color: 'green' },
+ { name: 'Archived', color: 'zinc' },
+ ],
+ },
+ { type: 'text', name: 'Source' },
+ { type: 'date', name: 'Saved At' },
+ ],
+ },
+ {
+ name: 'Project',
+ fields: [
+ {
+ type: 'select',
+ name: 'Status',
+ options: [
+ { name: 'Planning', color: 'blue' },
+ { name: 'Active', color: 'green' },
+ { name: 'On Hold', color: 'yellow' },
+ { name: 'Done', color: 'zinc' },
+ ],
+ },
+ {
+ type: 'select',
+ name: 'Priority',
+ options: [
+ { name: 'Low', color: 'gray' },
+ { name: 'Medium', color: 'blue' },
+ { name: 'High', color: 'orange' },
+ { name: 'Critical', color: 'red' },
+ ],
+ },
+ { type: 'date', name: 'Target Date' },
+ { type: 'text', name: 'Owner' },
+ { type: 'number', name: 'Progress', default: 0 },
+ { type: 'date', name: 'Start Date' },
+ ],
+ },
+ {
+ name: 'Issue',
+ fields: [
+ {
+ type: 'select',
+ name: 'Status',
+ options: [
+ { name: 'Todo', color: 'gray' },
+ { name: 'In Progress', color: 'blue' },
+ { name: 'Blocked', color: 'red' },
+ { name: 'Done', color: 'green' },
+ ],
+ },
+ {
+ type: 'select',
+ name: 'Priority',
+ options: [
+ { name: 'P0', color: 'red' },
+ { name: 'P1', color: 'orange' },
+ { name: 'P2', color: 'yellow' },
+ { name: 'P3', color: 'blue' },
+ ],
+ },
+ {
+ type: 'select',
+ name: 'Severity',
+ options: [
+ { name: 'Minor', color: 'blue' },
+ { name: 'Major', color: 'orange' },
+ { name: 'Critical', color: 'red' },
+ ],
+ },
+ { type: 'text', name: 'Assignee' },
+ { type: 'date', name: 'Due Date' },
+ { type: 'text', name: 'Reporter' },
+ ],
+ },
+ {
+ name: 'Repo',
+ fields: [
+ { type: 'url', name: 'URL' },
+ { type: 'text', name: 'Provider' },
+ { type: 'text', name: 'Language' },
+ { type: 'number', name: 'Stars' },
+ { type: 'boolean', name: 'Archived', default: false },
+ { type: 'text', name: 'License' },
+ { type: 'text', name: 'Default Branch', default: 'main' },
+ ],
+ },
+ {
+ name: 'Team',
+ fields: [
+ { type: 'text', name: 'Lead' },
+ { type: 'text', name: 'Members' },
+ { type: 'email', name: 'Contact Email' },
+ {
+ type: 'select',
+ name: 'Status',
+ options: [
+ { name: 'Active', color: 'green' },
+ { name: 'Paused', color: 'yellow' },
+ { name: 'Archived', color: 'zinc' },
+ ],
+ },
+ { type: 'text', name: 'Focus' },
+ { type: 'url', name: 'Docs' },
+ ],
+ },
+ {
+ name: 'Events',
+ fields: [
+ { type: 'date', name: 'Start Date' },
+ { type: 'date', name: 'End Date' },
+ { type: 'text', name: 'Location' },
+ { type: 'text', name: 'Organizer' },
+ { type: 'url', name: 'Event Link' },
+ {
+ type: 'select',
+ name: 'Status',
+ options: [
+ { name: 'Planned', color: 'blue' },
+ { name: 'Live', color: 'green' },
+ { name: 'Done', color: 'zinc' },
+ ],
+ },
+ {
+ type: 'select',
+ name: 'Type',
+ options: [
+ { name: 'Meeting', color: 'blue' },
+ { name: 'Release', color: 'green' },
+ { name: 'Workshop', color: 'purple' },
+ { name: 'Other', color: 'gray' },
+ ],
+ },
+ ],
+ },
+ {
+ name: 'People',
+ fields: [
+ { type: 'email', name: 'Email' },
+ { type: 'phone', name: 'Phone' },
+ { type: 'text', name: 'Role' },
+ { type: 'text', name: 'Team' },
+ { type: 'url', name: 'Profile' },
+ { type: 'text', name: 'Timezone' },
+ {
+ type: 'select',
+ name: 'Status',
+ options: [
+ { name: 'Active', color: 'green' },
+ { name: 'Away', color: 'yellow' },
+ { name: 'Inactive', color: 'zinc' },
+ ],
+ },
+ ],
+ },
+];
+
+const buildSelectOptions = (
+ options: SelectOptionPreset[]
+): Record => {
+ const result: Record = {};
+ let optionIndex: string | null = null;
+
+ for (const option of options) {
+ optionIndex = generateFractionalIndex(optionIndex, null);
+ const id = generateId(IdType.SelectOption);
+ result[id] = {
+ id,
+ name: option.name,
+ color: option.color,
+ index: optionIndex,
+ };
+ }
+
+ return result;
+};
+
+const buildPresetFields = (
+ fieldPresets: FieldPreset[]
+): NonNullable => {
+ const fields: NonNullable = {};
+ let fieldIndex: string | null = null;
+
+ for (const preset of fieldPresets) {
+ fieldIndex = generateFractionalIndex(fieldIndex, null);
+ const fieldId = generateId(IdType.Field);
+
+ if (preset.type === 'select') {
+ const options = buildSelectOptions(preset.options);
+ const defaultOptionId =
+ preset.default ??
+ Object.keys(options).find((optionId) => options[optionId]?.name === preset.options[0]?.name);
+
+ fields[fieldId] = {
+ id: fieldId,
+ type: 'select',
+ name: preset.name,
+ index: fieldIndex,
+ options,
+ default: defaultOptionId,
+ };
+ continue;
+ }
+
+ if (preset.type === 'boolean') {
+ fields[fieldId] = {
+ id: fieldId,
+ type: 'boolean',
+ name: preset.name,
+ index: fieldIndex,
+ ...(typeof preset.default === 'boolean'
+ ? { default: preset.default }
+ : {}),
+ };
+ continue;
+ }
+
+ if (preset.type === 'number') {
+ fields[fieldId] = {
+ id: fieldId,
+ type: 'number',
+ name: preset.name,
+ index: fieldIndex,
+ ...(typeof preset.default === 'number'
+ ? { default: preset.default }
+ : {}),
+ };
+ continue;
+ }
+
+ fields[fieldId] = {
+ id: fieldId,
+ type: preset.type,
+ name: preset.name,
+ index: fieldIndex,
+ ...(typeof preset.default === 'string'
+ ? { default: preset.default }
+ : {}),
+ };
+ }
+
+ return fields;
+};
+
const listPageMetaTypes = async (
workspace: WorkspaceService
): Promise => {
@@ -280,6 +566,64 @@ const createDefaultPageMetaType = async (
return databaseId;
};
+const createMetaType = async (
+ workspace: WorkspaceService,
+ spaceId: string,
+ preset: MetaTypePreset
+): Promise => {
+ const databaseId = generateId(IdType.Database);
+ const viewId = generateId(IdType.DatabaseView);
+
+ await workspace.nodes.insertNode(databaseId, {
+ type: 'database',
+ name: preset.name,
+ parentId: spaceId,
+ purpose: 'meta_type',
+ fields: buildPresetFields(preset.fields),
+ });
+
+ await workspace.nodes.insertNode(viewId, {
+ type: 'database_view',
+ name: 'Default',
+ parentId: databaseId,
+ index: generateFractionalIndex(null, null),
+ layout: 'table',
+ });
+
+ debug(`Created default ${preset.name} meta type ${databaseId}`);
+ return databaseId;
+};
+
+const ensureDefaultMetaTypePresets = async (
+ workspace: WorkspaceService,
+ spaceId: string
+): Promise => {
+ const databases = await workspace.database
+ .selectFrom('nodes')
+ .select(['id', 'attributes'])
+ .where('type', '=', 'database')
+ .execute();
+
+ const existingNames = new Set();
+ for (const row of databases) {
+ const attributes = JSON.parse(row.attributes) as DatabaseAttributes;
+ if (!isMetaTypeDatabase(attributes)) {
+ continue;
+ }
+ existingNames.add(attributes.name.trim().toLowerCase());
+ }
+
+ for (const preset of DEFAULT_META_TYPE_PRESETS) {
+ const key = preset.name.trim().toLowerCase();
+ if (existingNames.has(key)) {
+ continue;
+ }
+
+ await createMetaType(workspace, spaceId, preset);
+ existingNames.add(key);
+ }
+};
+
export const ensurePageMetaTypes = async (
workspace: WorkspaceService
): Promise => {
@@ -294,6 +638,9 @@ export const ensurePageMetaTypes = async (
defaultMetaTypeId = await createDefaultPageMetaType(workspace, spaceId);
}
+ const metaTypesSpaceId = await findOrCreateMetaTypesSpace(workspace);
+ await ensureDefaultMetaTypePresets(workspace, metaTypesSpaceId);
+
const metaTypeIds = new Set();
const databases = await workspace.database
.selectFrom('nodes')
diff --git a/packages/client/test/page-meta-types.test.ts b/packages/client/test/page-meta-types.test.ts
index e057c6d2b..c68bc91a7 100644
--- a/packages/client/test/page-meta-types.test.ts
+++ b/packages/client/test/page-meta-types.test.ts
@@ -379,6 +379,81 @@ describe('page meta types', () => {
expect(Number(updateCount.count)).toBeGreaterThan(0);
});
+ it('creates built-in meta type presets for new workspaces', async () => {
+ const spaceId = generateId(IdType.Space);
+
+ await insertNode(
+ database,
+ spaceId,
+ {
+ type: 'space',
+ name: 'Main',
+ description: '',
+ collaborators: { [userId]: 'admin' },
+ visibility: 'private',
+ },
+ userId
+ );
+
+ const workspace = {
+ userId,
+ workspaceId: generateId(IdType.Workspace),
+ accountId: generateId(IdType.Account),
+ role: 'owner',
+ database,
+ account: { app: { meta: { localOnly: true } } },
+ } as unknown as WorkspaceService;
+
+ workspace.nodes = new NodeService(workspace);
+ await ensurePageMetaTypes(workspace);
+
+ const metaTypeNames = await database
+ .selectFrom('nodes')
+ .where('type', '=', 'database')
+ .select(['attributes'])
+ .execute()
+ .then((rows) =>
+ rows
+ .map((row) => JSON.parse(row.attributes) as { purpose?: string; name?: string })
+ .filter((attributes) => attributes.purpose === 'meta_type')
+ .map((attributes) => attributes.name ?? '')
+ );
+
+ expect(metaTypeNames).toEqual(
+ expect.arrayContaining([
+ 'Page',
+ 'Bookmark',
+ 'Project',
+ 'Issue',
+ 'Repo',
+ 'Team',
+ 'Events',
+ 'People',
+ ])
+ );
+
+ const repoMetaType = await database
+ .selectFrom('nodes')
+ .where('type', '=', 'database')
+ .select(['attributes'])
+ .execute()
+ .then((rows) =>
+ rows
+ .map((row) => JSON.parse(row.attributes) as Record)
+ .find(
+ (attributes) =>
+ attributes.purpose === 'meta_type' && attributes.name === 'Repo'
+ )
+ );
+
+ const repoFields = Object.values(
+ ((repoMetaType?.fields as Record) ?? {})
+ ).map((field) => field.name);
+ expect(repoFields).toEqual(
+ expect.arrayContaining(['URL', 'Language', 'License', 'Default Branch'])
+ );
+ });
+
it('PageByMetaTypeQueryHandler returns pages for a meta type', async () => {
const spaceId = generateId(IdType.Space);
const metaTypeId = generateId(IdType.Database);
From 4e199d853c0ddd55887f0f12efc12dc4cba5cfc7 Mon Sep 17 00:00:00 2001
From: barry
Date: Wed, 24 Jun 2026 17:29:29 +0800
Subject: [PATCH 02/15] feat(page-meta-types): add 'TodoList' system meta type
and enhance initialization
Introduce a new built-in system meta type 'TodoList' with fields for task management, including 'Status', 'Priority', 'Due Date', 'Assignee', 'Estimate', and 'Labels'. Update the `ensurePageMetaTypes` function to seed this schema during workspace initialization. Enhance related tests to verify the creation and properties of the 'TodoList' meta type, ensuring a comprehensive setup for new workspaces.
---
docs/page-meta-types.md | 10 ++-
packages/client/src/lib/page-meta-types.ts | 74 +++++++++++++++++++
packages/client/test/page-meta-types.test.ts | 22 ++++++
packages/core/src/lib/meta-types.ts | 2 +
packages/core/src/registry/nodes/database.ts | 9 +--
.../databases/database-settings.tsx | 12 ++-
6 files changed, 117 insertions(+), 12 deletions(-)
diff --git a/docs/page-meta-types.md b/docs/page-meta-types.md
index 73f88b381..9ade63772 100644
--- a/docs/page-meta-types.md
+++ b/docs/page-meta-types.md
@@ -20,10 +20,11 @@ On workspace init, `ensurePageMetaTypes` creates:
1. Hidden space `Meta Types` (`description: __colanode_meta_types__`) — hidden from the Spaces sidebar
2. Default meta type database named `Page`
-3. Built-in starter meta types (if missing): `Bookmark`, `Project`, `Issue`, `Repo`, `Team`, `Events`, `People`
-4. Backfills missing or invalid `metaTypeId` on existing pages via `node.update` (creates syncable `node_updates`; SQLite is reconciled from YDoc when CRDT and SQLite drift)
-5. When local sync is enabled, the first import runs before `ensurePageMetaTypes` so a remote default `Page` schema is not duplicated locally
-6. If duplicate default `Page` meta types already exist, `consolidateDuplicatePageMetaTypes` keeps the schema with more fields / page references and removes the rest
+3. Built-in **system** meta type: `TodoList` (task list schema, locked, `systemKind: todo_list`)
+4. Built-in starter meta types (if missing): `Bookmark`, `Project`, `Issue`, `Repo`, `Team`, `Events`, `People`
+5. Backfills missing or invalid `metaTypeId` on existing pages via `node.update` (creates syncable `node_updates`; SQLite is reconciled from YDoc when CRDT and SQLite drift)
+6. When local sync is enabled, the first import runs before `ensurePageMetaTypes` so a remote default `Page` schema is not duplicated locally
+7. If duplicate default `Page` meta types already exist, `consolidateDuplicatePageMetaTypes` keeps the schema with more fields / page references and removes the rest
The default `Page` meta type cannot be deleted (`databaseModel.canDelete`).
@@ -31,6 +32,7 @@ The default `Page` meta type cannot be deleted (`databaseModel.canDelete`).
`ensurePageMetaTypes` now seeds the following starter schemas (when missing):
+- `TodoList` (system) — `Status`, `Priority`, `Due Date`, `Assignee`, `Estimate`, `Labels`
- `Bookmark` — `URL`, `Description`, `Tags`, `Status`, `Source`, `Saved At`
- `Project` — `Status`, `Priority`, `Target Date`, `Owner`, `Progress`, `Start Date`
- `Issue` — `Status`, `Priority`, `Severity`, `Assignee`, `Due Date`, `Reporter`
diff --git a/packages/client/src/lib/page-meta-types.ts b/packages/client/src/lib/page-meta-types.ts
index 0bb3fe25e..31b50290d 100644
--- a/packages/client/src/lib/page-meta-types.ts
+++ b/packages/client/src/lib/page-meta-types.ts
@@ -14,6 +14,8 @@ import {
const debug = createDebugger('desktop:page-meta-types');
+const TODO_LIST_META_TYPE_NAME = 'TodoList';
+const TODO_LIST_SYSTEM_KIND = 'todo_list';
const isMetaTypeDatabase = (attributes: NodeAttributes): boolean =>
attributes.type === 'database' &&
@@ -55,6 +57,33 @@ type MetaTypePreset = {
fields: FieldPreset[];
};
+const TODO_LIST_SYSTEM_META_TYPE_FIELDS: FieldPreset[] = [
+ {
+ type: 'select',
+ name: 'Status',
+ options: [
+ { name: 'Todo', color: 'gray' },
+ { name: 'In Progress', color: 'blue' },
+ { name: 'Blocked', color: 'red' },
+ { name: 'Done', color: 'green' },
+ ],
+ },
+ {
+ type: 'select',
+ name: 'Priority',
+ options: [
+ { name: 'Low', color: 'gray' },
+ { name: 'Medium', color: 'blue' },
+ { name: 'High', color: 'orange' },
+ { name: 'Critical', color: 'red' },
+ ],
+ },
+ { type: 'date', name: 'Due Date' },
+ { type: 'text', name: 'Assignee' },
+ { type: 'number', name: 'Estimate', default: 0 },
+ { type: 'text', name: 'Labels' },
+];
+
const DEFAULT_META_TYPE_PRESETS: MetaTypePreset[] = [
{
name: 'Bookmark',
@@ -624,6 +653,50 @@ const ensureDefaultMetaTypePresets = async (
}
};
+const ensureTodoListSystemMetaType = async (
+ workspace: WorkspaceService,
+ spaceId: string
+): Promise => {
+ const databases = await workspace.database
+ .selectFrom('nodes')
+ .select(['id', 'attributes'])
+ .where('type', '=', 'database')
+ .execute();
+
+ for (const row of databases) {
+ const attributes = JSON.parse(row.attributes) as DatabaseAttributes;
+ if (
+ attributes.type === 'database' &&
+ attributes.systemKind === TODO_LIST_SYSTEM_KIND
+ ) {
+ return;
+ }
+ }
+
+ const databaseId = generateId(IdType.Database);
+ const viewId = generateId(IdType.DatabaseView);
+
+ await workspace.nodes.insertNode(databaseId, {
+ type: 'database',
+ name: TODO_LIST_META_TYPE_NAME,
+ parentId: spaceId,
+ purpose: 'meta_type',
+ systemKind: TODO_LIST_SYSTEM_KIND,
+ fields: buildPresetFields(TODO_LIST_SYSTEM_META_TYPE_FIELDS),
+ locked: true,
+ });
+
+ await workspace.nodes.insertNode(viewId, {
+ type: 'database_view',
+ name: 'Default',
+ parentId: databaseId,
+ index: generateFractionalIndex(null, null),
+ layout: 'table',
+ });
+
+ debug(`Created system ${TODO_LIST_META_TYPE_NAME} meta type ${databaseId}`);
+};
+
export const ensurePageMetaTypes = async (
workspace: WorkspaceService
): Promise => {
@@ -639,6 +712,7 @@ export const ensurePageMetaTypes = async (
}
const metaTypesSpaceId = await findOrCreateMetaTypesSpace(workspace);
+ await ensureTodoListSystemMetaType(workspace, metaTypesSpaceId);
await ensureDefaultMetaTypePresets(workspace, metaTypesSpaceId);
const metaTypeIds = new Set();
diff --git a/packages/client/test/page-meta-types.test.ts b/packages/client/test/page-meta-types.test.ts
index c68bc91a7..403262426 100644
--- a/packages/client/test/page-meta-types.test.ts
+++ b/packages/client/test/page-meta-types.test.ts
@@ -24,6 +24,9 @@ import {
import { createWorkspaceDatabase, createWorkspaceDatabaseUpTo } from './helpers/sqlite';
+const TODO_LIST_META_TYPE_NAME = 'TodoList';
+const TODO_LIST_SYSTEM_KIND = 'todo_list';
+
const insertNode = async (
database: Kysely,
id: string,
@@ -422,6 +425,7 @@ describe('page meta types', () => {
expect(metaTypeNames).toEqual(
expect.arrayContaining([
'Page',
+ TODO_LIST_META_TYPE_NAME,
'Bookmark',
'Project',
'Issue',
@@ -452,6 +456,24 @@ describe('page meta types', () => {
expect(repoFields).toEqual(
expect.arrayContaining(['URL', 'Language', 'License', 'Default Branch'])
);
+
+ const todoListMetaType = await database
+ .selectFrom('nodes')
+ .where('type', '=', 'database')
+ .select(['attributes'])
+ .execute()
+ .then((rows) =>
+ rows
+ .map((row) => JSON.parse(row.attributes) as Record)
+ .find(
+ (attributes) =>
+ attributes.purpose === 'meta_type' &&
+ attributes.name === TODO_LIST_META_TYPE_NAME
+ )
+ );
+
+ expect(todoListMetaType?.systemKind).toBe(TODO_LIST_SYSTEM_KIND);
+ expect(todoListMetaType?.locked).toBe(true);
});
it('PageByMetaTypeQueryHandler returns pages for a meta type', async () => {
diff --git a/packages/core/src/lib/meta-types.ts b/packages/core/src/lib/meta-types.ts
index cf39afe33..f587d270d 100644
--- a/packages/core/src/lib/meta-types.ts
+++ b/packages/core/src/lib/meta-types.ts
@@ -1,5 +1,7 @@
export const META_TYPES_SPACE_MARKER = '__colanode_meta_types__';
export const DEFAULT_PAGE_META_TYPE_NAME = 'Page';
+export const TODO_LIST_SYSTEM_KIND = 'todo_list' as const;
+export const TODO_LIST_META_TYPE_NAME = 'TodoList';
export {
DEFAULT_SCHEDULED_TASK_DB_NAME,
diff --git a/packages/core/src/registry/nodes/database.ts b/packages/core/src/registry/nodes/database.ts
index 5145b18a1..1f70eb5eb 100644
--- a/packages/core/src/registry/nodes/database.ts
+++ b/packages/core/src/registry/nodes/database.ts
@@ -4,6 +4,7 @@ import { ASSET_LIBRARY_SYSTEM_KIND } from '@colanode/core/lib/asset-library';
import {
DEFAULT_PAGE_META_TYPE_NAME,
SCHEDULED_TASK_SYSTEM_KIND,
+ TODO_LIST_SYSTEM_KIND,
} from '@colanode/core/lib/meta-types';
import { extractNodeRole } from '@colanode/core/lib/nodes';
import { hasNodeRole } from '@colanode/core/lib/permissions';
@@ -29,6 +30,7 @@ export type DatabasePurpose = z.infer;
export const databaseSystemKindSchema = z.enum([
SCHEDULED_TASK_SYSTEM_KIND,
ASSET_LIBRARY_SYSTEM_KIND,
+ TODO_LIST_SYSTEM_KIND,
]);
export type DatabaseSystemKind = z.infer;
@@ -68,10 +70,7 @@ export const databaseModel: NodeModel = {
return false;
}
- if (
- context.node.type === 'database' &&
- context.node.systemKind === SCHEDULED_TASK_SYSTEM_KIND
- ) {
+ if (context.node.type === 'database' && context.node.systemKind) {
return false;
}
@@ -99,7 +98,7 @@ export const databaseModel: NodeModel = {
return false;
}
- if (context.node.systemKind === SCHEDULED_TASK_SYSTEM_KIND) {
+ if (context.node.systemKind) {
return false;
}
}
diff --git a/packages/ui/src/components/databases/database-settings.tsx b/packages/ui/src/components/databases/database-settings.tsx
index 1d2550c73..75c3c4bbe 100644
--- a/packages/ui/src/components/databases/database-settings.tsx
+++ b/packages/ui/src/components/databases/database-settings.tsx
@@ -17,7 +17,8 @@ import {
NodeRole,
hasNodeRole,
isNodeArchived,
- ASSET_LIBRARY_SYSTEM_KIND } from '@colanode/core';
+ ASSET_LIBRARY_SYSTEM_KIND,
+} from '@colanode/core';
import { NodeCollaboratorAudit } from '@colanode/ui/components/collaborators/node-collaborator-audit';
import { DatabaseUpdateDialog } from '@colanode/ui/components/databases/database-update-dialog';
import { NodeDeleteDialog } from '@colanode/ui/components/nodes/node-delete-dialog';
@@ -47,11 +48,16 @@ export const DatabaseSettings = ({ database, role }: DatabaseSettingsProps) => {
(database.purpose ?? 'records') === 'meta_type' &&
database.name === DEFAULT_PAGE_META_TYPE_NAME;
const isAssetLibrary = database.systemKind === ASSET_LIBRARY_SYSTEM_KIND;
+ const isSystemDatabase = Boolean(database.systemKind);
const canDelete =
- hasNodeRole(role, 'admin') && !isDefaultMetaType && !isAssetLibrary;
+ hasNodeRole(role, 'admin') &&
+ !isDefaultMetaType &&
+ !isAssetLibrary &&
+ !isSystemDatabase;
const isLocked = database.locked ?? false;
const archived = isNodeArchived(database);
- const canArchive = canEdit && !isDefaultMetaType && !isAssetLibrary;
+ const canArchive =
+ canEdit && !isDefaultMetaType && !isAssetLibrary && !isSystemDatabase;
const toggleArchive = useToggleNodeArchive(database);
const handleLockDatabase = useCallback(() => {
From 68ede87a4dd86520816a73ed3d87afc5df43ce6a Mon Sep 17 00:00:00 2001
From: barry
Date: Wed, 24 Jun 2026 17:47:51 +0800
Subject: [PATCH 03/15] refactor(page-meta-types): remove 'TodoList' system
meta type and update initialization logic
Eliminate the 'TodoList' system meta type from the initialization process, ensuring it is no longer created as part of the default meta types. Update the `ensurePageMetaTypes` function to reflect this change and adjust related tests accordingly. This refactor streamlines the workspace setup by focusing on essential meta types and their configurations.
---
docs/page-meta-types.md | 11 +-
packages/client/src/lib/page-meta-types.ts | 73 --------
packages/client/src/lib/todo-list-system.ts | 157 ++++++++++++++++++
packages/client/src/services/app-service.ts | 3 +
packages/client/test/page-meta-types.test.ts | 20 ---
packages/client/test/todo-list-system.test.ts | 96 +++++++++++
6 files changed, 261 insertions(+), 99 deletions(-)
create mode 100644 packages/client/src/lib/todo-list-system.ts
create mode 100644 packages/client/test/todo-list-system.test.ts
diff --git a/docs/page-meta-types.md b/docs/page-meta-types.md
index 9ade63772..d79abe9f4 100644
--- a/docs/page-meta-types.md
+++ b/docs/page-meta-types.md
@@ -20,11 +20,10 @@ On workspace init, `ensurePageMetaTypes` creates:
1. Hidden space `Meta Types` (`description: __colanode_meta_types__`) — hidden from the Spaces sidebar
2. Default meta type database named `Page`
-3. Built-in **system** meta type: `TodoList` (task list schema, locked, `systemKind: todo_list`)
-4. Built-in starter meta types (if missing): `Bookmark`, `Project`, `Issue`, `Repo`, `Team`, `Events`, `People`
-5. Backfills missing or invalid `metaTypeId` on existing pages via `node.update` (creates syncable `node_updates`; SQLite is reconciled from YDoc when CRDT and SQLite drift)
-6. When local sync is enabled, the first import runs before `ensurePageMetaTypes` so a remote default `Page` schema is not duplicated locally
-7. If duplicate default `Page` meta types already exist, `consolidateDuplicatePageMetaTypes` keeps the schema with more fields / page references and removes the rest
+3. Built-in starter meta types (if missing): `Bookmark`, `Project`, `Issue`, `Repo`, `Team`, `Events`, `People`
+4. Backfills missing or invalid `metaTypeId` on existing pages via `node.update` (creates syncable `node_updates`; SQLite is reconciled from YDoc when CRDT and SQLite drift)
+5. When local sync is enabled, the first import runs before `ensurePageMetaTypes` so a remote default `Page` schema is not duplicated locally
+6. If duplicate default `Page` meta types already exist, `consolidateDuplicatePageMetaTypes` keeps the schema with more fields / page references and removes the rest
The default `Page` meta type cannot be deleted (`databaseModel.canDelete`).
@@ -32,7 +31,6 @@ The default `Page` meta type cannot be deleted (`databaseModel.canDelete`).
`ensurePageMetaTypes` now seeds the following starter schemas (when missing):
-- `TodoList` (system) — `Status`, `Priority`, `Due Date`, `Assignee`, `Estimate`, `Labels`
- `Bookmark` — `URL`, `Description`, `Tags`, `Status`, `Source`, `Saved At`
- `Project` — `Status`, `Priority`, `Target Date`, `Owner`, `Progress`, `Start Date`
- `Issue` — `Status`, `Priority`, `Severity`, `Assignee`, `Due Date`, `Reporter`
@@ -102,3 +100,4 @@ Rows in a meta type view are real `page` nodes. Editing **Name** or schema field
## See also
- [roadmap.md](./roadmap.md) — product roadmap, next directions (notifications, doc import/export, Mermaid, etc.)
+- `TodoList` system database is not a page meta type; it is a built-in system records database (`systemKind: todo_list`) parallel to Scheduled Tasks.
diff --git a/packages/client/src/lib/page-meta-types.ts b/packages/client/src/lib/page-meta-types.ts
index 31b50290d..1af49b1ce 100644
--- a/packages/client/src/lib/page-meta-types.ts
+++ b/packages/client/src/lib/page-meta-types.ts
@@ -14,8 +14,6 @@ import {
const debug = createDebugger('desktop:page-meta-types');
-const TODO_LIST_META_TYPE_NAME = 'TodoList';
-const TODO_LIST_SYSTEM_KIND = 'todo_list';
const isMetaTypeDatabase = (attributes: NodeAttributes): boolean =>
attributes.type === 'database' &&
@@ -57,32 +55,6 @@ type MetaTypePreset = {
fields: FieldPreset[];
};
-const TODO_LIST_SYSTEM_META_TYPE_FIELDS: FieldPreset[] = [
- {
- type: 'select',
- name: 'Status',
- options: [
- { name: 'Todo', color: 'gray' },
- { name: 'In Progress', color: 'blue' },
- { name: 'Blocked', color: 'red' },
- { name: 'Done', color: 'green' },
- ],
- },
- {
- type: 'select',
- name: 'Priority',
- options: [
- { name: 'Low', color: 'gray' },
- { name: 'Medium', color: 'blue' },
- { name: 'High', color: 'orange' },
- { name: 'Critical', color: 'red' },
- ],
- },
- { type: 'date', name: 'Due Date' },
- { type: 'text', name: 'Assignee' },
- { type: 'number', name: 'Estimate', default: 0 },
- { type: 'text', name: 'Labels' },
-];
const DEFAULT_META_TYPE_PRESETS: MetaTypePreset[] = [
{
@@ -653,50 +625,6 @@ const ensureDefaultMetaTypePresets = async (
}
};
-const ensureTodoListSystemMetaType = async (
- workspace: WorkspaceService,
- spaceId: string
-): Promise => {
- const databases = await workspace.database
- .selectFrom('nodes')
- .select(['id', 'attributes'])
- .where('type', '=', 'database')
- .execute();
-
- for (const row of databases) {
- const attributes = JSON.parse(row.attributes) as DatabaseAttributes;
- if (
- attributes.type === 'database' &&
- attributes.systemKind === TODO_LIST_SYSTEM_KIND
- ) {
- return;
- }
- }
-
- const databaseId = generateId(IdType.Database);
- const viewId = generateId(IdType.DatabaseView);
-
- await workspace.nodes.insertNode(databaseId, {
- type: 'database',
- name: TODO_LIST_META_TYPE_NAME,
- parentId: spaceId,
- purpose: 'meta_type',
- systemKind: TODO_LIST_SYSTEM_KIND,
- fields: buildPresetFields(TODO_LIST_SYSTEM_META_TYPE_FIELDS),
- locked: true,
- });
-
- await workspace.nodes.insertNode(viewId, {
- type: 'database_view',
- name: 'Default',
- parentId: databaseId,
- index: generateFractionalIndex(null, null),
- layout: 'table',
- });
-
- debug(`Created system ${TODO_LIST_META_TYPE_NAME} meta type ${databaseId}`);
-};
-
export const ensurePageMetaTypes = async (
workspace: WorkspaceService
): Promise => {
@@ -712,7 +640,6 @@ export const ensurePageMetaTypes = async (
}
const metaTypesSpaceId = await findOrCreateMetaTypesSpace(workspace);
- await ensureTodoListSystemMetaType(workspace, metaTypesSpaceId);
await ensureDefaultMetaTypePresets(workspace, metaTypesSpaceId);
const metaTypeIds = new Set();
diff --git a/packages/client/src/lib/todo-list-system.ts b/packages/client/src/lib/todo-list-system.ts
new file mode 100644
index 000000000..68f029477
--- /dev/null
+++ b/packages/client/src/lib/todo-list-system.ts
@@ -0,0 +1,157 @@
+import { findOrCreateMetaTypesSpace } from '@colanode/client/lib/page-meta-types';
+import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service';
+import {
+ DatabaseAttributes,
+ FieldAttributes,
+ createDebugger,
+ generateFractionalIndex,
+ generateId,
+ IdType,
+ TODO_LIST_META_TYPE_NAME,
+ TODO_LIST_SYSTEM_KIND,
+} from '@colanode/core';
+
+const debug = createDebugger('desktop:todo-list-system');
+
+const buildTodoListFields = (): Record => {
+ const statusFieldId = generateId(IdType.Field);
+ const priorityFieldId = generateId(IdType.Field);
+ const dueDateFieldId = generateId(IdType.Field);
+ const assigneeFieldId = generateId(IdType.Field);
+ const estimateFieldId = generateId(IdType.Field);
+ const labelsFieldId = generateId(IdType.Field);
+
+ const todoId = generateId(IdType.SelectOption);
+ const inProgressId = generateId(IdType.SelectOption);
+ const blockedId = generateId(IdType.SelectOption);
+ const doneId = generateId(IdType.SelectOption);
+
+ const lowId = generateId(IdType.SelectOption);
+ const mediumId = generateId(IdType.SelectOption);
+ const highId = generateId(IdType.SelectOption);
+ const criticalId = generateId(IdType.SelectOption);
+
+ return {
+ [statusFieldId]: {
+ id: statusFieldId,
+ type: 'select',
+ name: 'Status',
+ index: 'a',
+ default: todoId,
+ options: {
+ [todoId]: { id: todoId, name: 'Todo', color: 'gray', index: 'a' },
+ [inProgressId]: {
+ id: inProgressId,
+ name: 'In Progress',
+ color: 'blue',
+ index: 'b',
+ },
+ [blockedId]: { id: blockedId, name: 'Blocked', color: 'red', index: 'c' },
+ [doneId]: { id: doneId, name: 'Done', color: 'green', index: 'd' },
+ },
+ },
+ [priorityFieldId]: {
+ id: priorityFieldId,
+ type: 'select',
+ name: 'Priority',
+ index: 'b',
+ default: mediumId,
+ options: {
+ [lowId]: { id: lowId, name: 'Low', color: 'gray', index: 'a' },
+ [mediumId]: { id: mediumId, name: 'Medium', color: 'blue', index: 'b' },
+ [highId]: { id: highId, name: 'High', color: 'orange', index: 'c' },
+ [criticalId]: {
+ id: criticalId,
+ name: 'Critical',
+ color: 'red',
+ index: 'd',
+ },
+ },
+ },
+ [dueDateFieldId]: {
+ id: dueDateFieldId,
+ type: 'date',
+ name: 'Due Date',
+ index: 'c',
+ },
+ [assigneeFieldId]: {
+ id: assigneeFieldId,
+ type: 'text',
+ name: 'Assignee',
+ index: 'd',
+ },
+ [estimateFieldId]: {
+ id: estimateFieldId,
+ type: 'number',
+ name: 'Estimate',
+ index: 'e',
+ default: 0,
+ },
+ [labelsFieldId]: {
+ id: labelsFieldId,
+ type: 'text',
+ name: 'Labels',
+ index: 'f',
+ },
+ };
+};
+
+const findTodoListSystemDatabaseId = async (
+ workspace: WorkspaceService
+): Promise => {
+ const databases = await workspace.database
+ .selectFrom('nodes')
+ .select(['id', 'attributes'])
+ .where('type', '=', 'database')
+ .execute();
+
+ for (const row of databases) {
+ const attributes = JSON.parse(row.attributes) as DatabaseAttributes;
+ if (attributes.systemKind === TODO_LIST_SYSTEM_KIND) {
+ return row.id;
+ }
+ }
+
+ return null;
+};
+
+const createTodoListSystemDatabase = async (
+ workspace: WorkspaceService,
+ spaceId: string
+): Promise => {
+ const databaseId = generateId(IdType.Database);
+ const viewId = generateId(IdType.DatabaseView);
+
+ await workspace.nodes.insertNode(databaseId, {
+ type: 'database',
+ name: TODO_LIST_META_TYPE_NAME,
+ parentId: spaceId,
+ purpose: 'records',
+ systemKind: TODO_LIST_SYSTEM_KIND,
+ locked: true,
+ fields: buildTodoListFields(),
+ });
+
+ await workspace.nodes.insertNode(viewId, {
+ type: 'database_view',
+ name: 'Default',
+ parentId: databaseId,
+ index: generateFractionalIndex(null, null),
+ layout: 'table',
+ });
+
+ debug(`Created todo list system database ${databaseId}`);
+ return databaseId;
+};
+
+export const ensureTodoListSystemDatabase = async (
+ workspace: WorkspaceService
+): Promise => {
+ const existing = await findTodoListSystemDatabaseId(workspace);
+ if (existing) {
+ return existing;
+ }
+
+ const spaceId = await findOrCreateMetaTypesSpace(workspace);
+ return createTodoListSystemDatabase(workspace, spaceId);
+};
diff --git a/packages/client/src/services/app-service.ts b/packages/client/src/services/app-service.ts
index 1528c2607..410a65618 100644
--- a/packages/client/src/services/app-service.ts
+++ b/packages/client/src/services/app-service.ts
@@ -11,6 +11,7 @@ import { eventBus } from '@colanode/client/lib/event-bus';
import { mapAccount, mapWorkspace } from '@colanode/client/lib/mappers';
import { ensurePageMetaTypes } from '@colanode/client/lib/page-meta-types';
import { ensureScheduledTaskMetaType } from '@colanode/client/lib/scheduled-task-meta-type';
+import { ensureTodoListSystemDatabase } from '@colanode/client/lib/todo-list-system';
import { AccountService } from '@colanode/client/services/accounts/account-service';
import { AppMeta, LocalSyncConfig } from '@colanode/client/services/app-meta';
import { AssetService } from '@colanode/client/services/asset-service';
@@ -193,6 +194,7 @@ export class AppService {
const existing = this.workspaces.get(workspace.user_id)!;
await ensurePageMetaTypes(existing);
await ensureScheduledTaskMetaType(existing);
+ await ensureTodoListSystemDatabase(existing);
return existing;
}
@@ -208,6 +210,7 @@ export class AppService {
await workspaceService.init();
await ensurePageMetaTypes(workspaceService);
await ensureScheduledTaskMetaType(workspaceService);
+ await ensureTodoListSystemDatabase(workspaceService);
this.workspaces.set(workspace.user_id, workspaceService);
return workspaceService;
diff --git a/packages/client/test/page-meta-types.test.ts b/packages/client/test/page-meta-types.test.ts
index 403262426..3024ff531 100644
--- a/packages/client/test/page-meta-types.test.ts
+++ b/packages/client/test/page-meta-types.test.ts
@@ -24,8 +24,6 @@ import {
import { createWorkspaceDatabase, createWorkspaceDatabaseUpTo } from './helpers/sqlite';
-const TODO_LIST_META_TYPE_NAME = 'TodoList';
-const TODO_LIST_SYSTEM_KIND = 'todo_list';
const insertNode = async (
database: Kysely,
@@ -425,7 +423,6 @@ describe('page meta types', () => {
expect(metaTypeNames).toEqual(
expect.arrayContaining([
'Page',
- TODO_LIST_META_TYPE_NAME,
'Bookmark',
'Project',
'Issue',
@@ -457,23 +454,6 @@ describe('page meta types', () => {
expect.arrayContaining(['URL', 'Language', 'License', 'Default Branch'])
);
- const todoListMetaType = await database
- .selectFrom('nodes')
- .where('type', '=', 'database')
- .select(['attributes'])
- .execute()
- .then((rows) =>
- rows
- .map((row) => JSON.parse(row.attributes) as Record)
- .find(
- (attributes) =>
- attributes.purpose === 'meta_type' &&
- attributes.name === TODO_LIST_META_TYPE_NAME
- )
- );
-
- expect(todoListMetaType?.systemKind).toBe(TODO_LIST_SYSTEM_KIND);
- expect(todoListMetaType?.locked).toBe(true);
});
it('PageByMetaTypeQueryHandler returns pages for a meta type', async () => {
diff --git a/packages/client/test/todo-list-system.test.ts b/packages/client/test/todo-list-system.test.ts
new file mode 100644
index 000000000..d2b82841d
--- /dev/null
+++ b/packages/client/test/todo-list-system.test.ts
@@ -0,0 +1,96 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { WorkspaceDatabaseSchema } from '@colanode/client/databases';
+import { ensureTodoListSystemDatabase } from '@colanode/client/lib/todo-list-system';
+import { NodeService } from '@colanode/client/services/workspaces/node-service';
+import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service';
+import { generateId, IdType, UserStatus } from '@colanode/core';
+import { Kysely } from 'kysely';
+
+import { createWorkspaceDatabase } from './helpers/sqlite';
+
+describe('todo list system database', () => {
+ let database: Kysely;
+ const userId = generateId(IdType.User);
+
+ beforeEach(async () => {
+ database = await createWorkspaceDatabase();
+ await database
+ .insertInto('users')
+ .values({
+ id: userId,
+ email: 'test@example.com',
+ name: 'Test User',
+ role: 'admin',
+ status: UserStatus.Active,
+ revision: '1',
+ created_at: new Date().toISOString(),
+ updated_at: null,
+ avatar: null,
+ custom_name: null,
+ custom_avatar: null,
+ })
+ .execute();
+ });
+
+ afterEach(async () => {
+ await database.destroy();
+ });
+
+ const createWorkspace = () => {
+ const workspace = {
+ userId,
+ workspaceId: generateId(IdType.Workspace),
+ accountId: generateId(IdType.Account),
+ role: 'owner',
+ database,
+ account: { app: { meta: { localOnly: true } } },
+ } as unknown as WorkspaceService;
+ workspace.nodes = new NodeService(workspace);
+ return workspace;
+ };
+
+ it('creates and reuses todo list system database', async () => {
+ const workspace = createWorkspace();
+
+ const firstId = await ensureTodoListSystemDatabase(workspace);
+ const secondId = await ensureTodoListSystemDatabase(workspace);
+
+ expect(firstId).toBe(secondId);
+
+ const row = await database
+ .selectFrom('nodes')
+ .where('id', '=', firstId)
+ .select(['attributes'])
+ .executeTakeFirstOrThrow();
+
+ const attributes = JSON.parse(row.attributes) as {
+ type: string;
+ purpose?: string;
+ name?: string;
+ systemKind?: string;
+ locked?: boolean;
+ fields?: Record;
+ };
+
+ expect(attributes.type).toBe('database');
+ expect(attributes.purpose).toBe('records');
+ expect(attributes.name).toBe('TodoList');
+ expect(attributes.systemKind).toBe('todo_list');
+ expect(attributes.locked).toBe(true);
+
+ const fieldNames = Object.values(attributes.fields ?? {}).map(
+ (field) => field.name
+ );
+ expect(fieldNames).toEqual(
+ expect.arrayContaining([
+ 'Status',
+ 'Priority',
+ 'Due Date',
+ 'Assignee',
+ 'Estimate',
+ 'Labels',
+ ])
+ );
+ });
+});
From 52003f0afc2fd5015277aeb18139e7e21c4d9951 Mon Sep 17 00:00:00 2001
From: barry
Date: Wed, 24 Jun 2026 18:09:59 +0800
Subject: [PATCH 04/15] feat(sidebar-settings): add TodoList link and enhance
workspace routing
Introduce a new link for the TodoList in the sidebar settings, allowing users to navigate to the TodoList settings page. Update the routing configuration to include the TodoList route and its corresponding mask, ensuring proper navigation and context handling for user workspaces. This enhancement improves the user experience by providing direct access to task management features.
---
.../layouts/sidebars/sidebar-settings.tsx | 11 ++++
.../todo-list/workspace-todo-list-tab.tsx | 6 ++
.../workspaces/workspace-system-types.tsx | 17 ++++-
packages/ui/src/routes/index.tsx | 6 ++
packages/ui/src/routes/masks.tsx | 13 ++++
packages/ui/src/routes/workspace/node.tsx | 13 +++-
.../routes/workspace/todo-list-settings.tsx | 62 +++++++++++++++++++
7 files changed, 126 insertions(+), 2 deletions(-)
create mode 100644 packages/ui/src/components/workspaces/todo-list/workspace-todo-list-tab.tsx
create mode 100644 packages/ui/src/routes/workspace/todo-list-settings.tsx
diff --git a/packages/ui/src/components/layouts/sidebars/sidebar-settings.tsx b/packages/ui/src/components/layouts/sidebars/sidebar-settings.tsx
index 54b59766d..4c0b52bba 100644
--- a/packages/ui/src/components/layouts/sidebars/sidebar-settings.tsx
+++ b/packages/ui/src/components/layouts/sidebars/sidebar-settings.tsx
@@ -5,6 +5,7 @@ import {
FolderArchive,
Inbox,
Info,
+ ListTodo,
Palette,
Search,
Settings,
@@ -47,6 +48,16 @@ export const SidebarSettings = () => {
)}
+
+ {({ isActive }) => (
+
+ )}
+
+
{({ isActive }) => (
{
+ return ;
+};
diff --git a/packages/ui/src/components/workspaces/workspace-system-types.tsx b/packages/ui/src/components/workspaces/workspace-system-types.tsx
index 5cdb7795b..c1622fb92 100644
--- a/packages/ui/src/components/workspaces/workspace-system-types.tsx
+++ b/packages/ui/src/components/workspaces/workspace-system-types.tsx
@@ -1,8 +1,9 @@
-import { Bell, FolderArchive } from 'lucide-react';
+import { Bell, FolderArchive, ListTodo } from 'lucide-react';
import {
DEFAULT_ASSET_LIBRARY_DB_NAME,
DEFAULT_SCHEDULED_TASK_DB_NAME,
+ TODO_LIST_META_TYPE_NAME,
} from '@colanode/core';
import { Link } from '@colanode/ui/components/ui/link';
import { Separator } from '@colanode/ui/components/ui/separator';
@@ -48,6 +49,20 @@ export const WorkspaceSystemTypes = () => {
+
+
+
+
{TODO_LIST_META_TYPE_NAME}
+
+ Built-in task list database for personal/team todos. It is a system
+ records database parallel to Scheduled Tasks.
+
+
+
diff --git a/packages/ui/src/routes/index.tsx b/packages/ui/src/routes/index.tsx
index 83842dc88..bd99ae021 100644
--- a/packages/ui/src/routes/index.tsx
+++ b/packages/ui/src/routes/index.tsx
@@ -54,6 +54,10 @@ import {
workspaceSettingsMaskRoute,
workspaceSettingsRoute,
} from '@colanode/ui/routes/workspace/settings';
+import {
+ workspaceTodoListMaskRoute,
+ workspaceTodoListRoute,
+} from '@colanode/ui/routes/workspace/todo-list-settings';
import {
workspaceUsersMaskRoute,
workspaceUsersRoute,
@@ -73,6 +77,7 @@ export const routeTree = rootRoute.addChildren([
workspaceScheduledTasksRoute,
workspaceScriptExplorerRoute,
workspaceQueryExplorerRoute,
+ workspaceTodoListRoute,
workspaceAssetsRoute,
workspaceNotificationsRoute,
infoRoute,
@@ -87,6 +92,7 @@ export const routeTree = rootRoute.addChildren([
workspaceScheduledTasksMaskRoute,
workspaceScriptExplorerMaskRoute,
workspaceQueryExplorerMaskRoute,
+ workspaceTodoListMaskRoute,
workspaceAssetsMaskRoute,
workspaceNotificationsMaskRoute,
workspaceUsersMaskRoute,
diff --git a/packages/ui/src/routes/masks.tsx b/packages/ui/src/routes/masks.tsx
index a0bea3da0..b8876cc4f 100644
--- a/packages/ui/src/routes/masks.tsx
+++ b/packages/ui/src/routes/masks.tsx
@@ -123,6 +123,18 @@ export const workspaceAssetsRouteMask = createRouteMask({
},
});
+export const workspaceTodoListRouteMask = createRouteMask({
+ routeTree: routeTree,
+ from: '/workspace/$userId/settings/todo-list',
+ to: '/$workspaceId/settings/todo-list',
+ params: (ctx) => {
+ const workspace = collections.workspaces.get(ctx.userId);
+ return {
+ workspaceId: workspace?.workspaceId ?? 'unknown',
+ };
+ },
+});
+
export const workspaceUsersRouteMask = createRouteMask({
routeTree: routeTree,
from: '/workspace/$userId/users',
@@ -194,6 +206,7 @@ export const routeMasks = [
workspaceNotificationsSettingsRouteMask,
workspaceScheduledTasksRouteMask,
workspaceScriptExplorerRouteMask,
+ workspaceTodoListRouteMask,
workspaceAssetsRouteMask,
workspaceUsersRouteMask,
workspaceDownloadsRouteMask,
diff --git a/packages/ui/src/routes/workspace/node.tsx b/packages/ui/src/routes/workspace/node.tsx
index e2c7f2b98..caac26816 100644
--- a/packages/ui/src/routes/workspace/node.tsx
+++ b/packages/ui/src/routes/workspace/node.tsx
@@ -1,6 +1,6 @@
import { createRoute, redirect } from '@tanstack/react-router';
-import { isAssetLibrarySystemDatabase } from '@colanode/core';
+import { TODO_LIST_SYSTEM_KIND, isAssetLibrarySystemDatabase } from '@colanode/core';
import { NodeContainer } from '@colanode/ui/components/nodes/node-container';
import { NodeErrorContainer } from '@colanode/ui/components/nodes/node-error-container';
import { NodeTab } from '@colanode/ui/components/nodes/node-tab';
@@ -30,6 +30,17 @@ export const nodeRoute = createRoute({
replace: true,
});
}
+
+ if (
+ node?.type === 'database' &&
+ node.systemKind === TODO_LIST_SYSTEM_KIND
+ ) {
+ throw redirect({
+ to: '/workspace/$userId/settings/todo-list',
+ params: { userId: params.userId },
+ replace: true,
+ });
+ }
},
component: () => {
const { nodeId } = nodeRoute.useParams();
diff --git a/packages/ui/src/routes/workspace/todo-list-settings.tsx b/packages/ui/src/routes/workspace/todo-list-settings.tsx
new file mode 100644
index 000000000..1343a5b5f
--- /dev/null
+++ b/packages/ui/src/routes/workspace/todo-list-settings.tsx
@@ -0,0 +1,62 @@
+import { createRoute, redirect } from '@tanstack/react-router';
+
+import { TODO_LIST_SYSTEM_KIND } from '@colanode/core';
+import { WorkspaceTodoListTab } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-tab';
+import { getWorkspaceUserId } from '@colanode/ui/routes/utils';
+import {
+ workspaceRoute,
+ workspaceMaskRoute,
+} from '@colanode/ui/routes/workspace';
+
+const findTodoListDatabaseId = async (userId: string): Promise => {
+ const databases = await window.colanode.executeQuery({
+ type: 'node.list',
+ userId,
+ filters: [
+ { field: ['type'], operator: 'in', value: ['database'] },
+ { field: ['systemKind'], operator: 'eq', value: TODO_LIST_SYSTEM_KIND },
+ ],
+ sorts: [],
+ limit: 1,
+ });
+
+ const node = databases[0];
+ return node?.type === 'database' ? node.id : null;
+};
+
+export const workspaceTodoListRoute = createRoute({
+ getParentRoute: () => workspaceRoute,
+ path: '/settings/todo-list',
+ beforeLoad: async ({ params }) => {
+ const todoListId = await findTodoListDatabaseId(params.userId);
+ if (todoListId) {
+ throw redirect({
+ to: '/workspace/$userId/$nodeId',
+ params: { userId: params.userId, nodeId: todoListId },
+ replace: true,
+ });
+ }
+ },
+ component: () => null,
+ context: () => {
+ return {
+ tab: ,
+ };
+ },
+});
+
+export const workspaceTodoListMaskRoute = createRoute({
+ getParentRoute: () => workspaceMaskRoute,
+ path: '/settings/todo-list',
+ component: () => null,
+ beforeLoad: (ctx) => {
+ const userId = getWorkspaceUserId(ctx.params.workspaceId);
+ if (userId) {
+ throw redirect({
+ to: '/workspace/$userId/settings/todo-list',
+ params: { userId },
+ replace: true,
+ });
+ }
+ },
+});
From 6ab06b9b194002862cdded59d710ba2daeff18f6 Mon Sep 17 00:00:00 2001
From: barry
Date: Wed, 24 Jun 2026 18:39:53 +0800
Subject: [PATCH 05/15] refactor(todo-list-settings): replace database query
logic with WorkspaceTodoListContainer
Refactor the todo list settings route by removing the database query logic for fetching the TodoList ID and replacing it with the WorkspaceTodoListContainer component. This change simplifies the routing logic and enhances the user interface by directly integrating the container for better task management experience.
---
.../workspace-todo-list-breadcrumb.tsx | 12 ++++
.../workspace-todo-list-container.tsx | 55 +++++++++++++++++++
packages/ui/src/lib/todo-list.ts | 20 +++++++
.../routes/workspace/todo-list-settings.tsx | 30 +---------
4 files changed, 89 insertions(+), 28 deletions(-)
create mode 100644 packages/ui/src/components/workspaces/todo-list/workspace-todo-list-breadcrumb.tsx
create mode 100644 packages/ui/src/components/workspaces/todo-list/workspace-todo-list-container.tsx
create mode 100644 packages/ui/src/lib/todo-list.ts
diff --git a/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-breadcrumb.tsx b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-breadcrumb.tsx
new file mode 100644
index 000000000..0d3216492
--- /dev/null
+++ b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-breadcrumb.tsx
@@ -0,0 +1,12 @@
+import { BreadcrumbItem } from '@colanode/ui/components/layouts/containers/breadcrumb-item';
+import { defaultEmojis } from '@colanode/ui/lib/assets';
+
+export const WorkspaceTodoListBreadcrumb = () => {
+ return (
+
+ );
+};
diff --git a/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-container.tsx b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-container.tsx
new file mode 100644
index 000000000..a69e984bc
--- /dev/null
+++ b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-container.tsx
@@ -0,0 +1,55 @@
+import { useEffect, useState } from 'react';
+
+import { LocalDatabaseNode } from '@colanode/client/types';
+import { NodeRole } from '@colanode/core';
+import { Database } from '@colanode/ui/components/databases/database';
+import { DatabaseViews } from '@colanode/ui/components/databases/database-views';
+import { Container } from '@colanode/ui/components/layouts/containers/container';
+import { WorkspaceTodoListBreadcrumb } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-breadcrumb';
+import { useWorkspace } from '@colanode/ui/contexts/workspace';
+import { getTodoListDatabase } from '@colanode/ui/lib/todo-list';
+
+const workspaceRoleToNodeRole = (role: string): NodeRole => {
+ if (role === 'owner' || role === 'admin') {
+ return 'admin';
+ }
+
+ if (role === 'guest' || role === 'none') {
+ return 'viewer';
+ }
+
+ return 'collaborator';
+};
+
+export const WorkspaceTodoListContainer = () => {
+ const workspace = useWorkspace();
+ const [database, setDatabase] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ void getTodoListDatabase(workspace.userId).then((nextDatabase) => {
+ if (!cancelled) {
+ setDatabase(nextDatabase);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [workspace.userId]);
+
+ if (!database) {
+ return null;
+ }
+
+ return (
+ }>
+
+
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/lib/todo-list.ts b/packages/ui/src/lib/todo-list.ts
new file mode 100644
index 000000000..0fc6bf810
--- /dev/null
+++ b/packages/ui/src/lib/todo-list.ts
@@ -0,0 +1,20 @@
+import { LocalDatabaseNode } from '@colanode/client/types';
+import { TODO_LIST_SYSTEM_KIND } from '@colanode/core';
+
+export const getTodoListDatabase = async (
+ userId: string
+): Promise => {
+ const databases = await window.colanode.executeQuery({
+ type: 'node.list',
+ userId,
+ filters: [
+ { field: ['type'], operator: 'in', value: ['database'] },
+ { field: ['systemKind'], operator: 'eq', value: TODO_LIST_SYSTEM_KIND },
+ ],
+ sorts: [],
+ limit: 1,
+ });
+
+ const node = databases[0];
+ return node?.type === 'database' ? node : null;
+};
diff --git a/packages/ui/src/routes/workspace/todo-list-settings.tsx b/packages/ui/src/routes/workspace/todo-list-settings.tsx
index 1343a5b5f..86349d26d 100644
--- a/packages/ui/src/routes/workspace/todo-list-settings.tsx
+++ b/packages/ui/src/routes/workspace/todo-list-settings.tsx
@@ -1,6 +1,6 @@
import { createRoute, redirect } from '@tanstack/react-router';
-import { TODO_LIST_SYSTEM_KIND } from '@colanode/core';
+import { WorkspaceTodoListContainer } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-container';
import { WorkspaceTodoListTab } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-tab';
import { getWorkspaceUserId } from '@colanode/ui/routes/utils';
import {
@@ -8,36 +8,10 @@ import {
workspaceMaskRoute,
} from '@colanode/ui/routes/workspace';
-const findTodoListDatabaseId = async (userId: string): Promise => {
- const databases = await window.colanode.executeQuery({
- type: 'node.list',
- userId,
- filters: [
- { field: ['type'], operator: 'in', value: ['database'] },
- { field: ['systemKind'], operator: 'eq', value: TODO_LIST_SYSTEM_KIND },
- ],
- sorts: [],
- limit: 1,
- });
-
- const node = databases[0];
- return node?.type === 'database' ? node.id : null;
-};
-
export const workspaceTodoListRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: '/settings/todo-list',
- beforeLoad: async ({ params }) => {
- const todoListId = await findTodoListDatabaseId(params.userId);
- if (todoListId) {
- throw redirect({
- to: '/workspace/$userId/$nodeId',
- params: { userId: params.userId, nodeId: todoListId },
- replace: true,
- });
- }
- },
- component: () => null,
+ component: WorkspaceTodoListContainer,
context: () => {
return {
tab: ,
From 8b3052815c239174a02a12bd88fa5694cfd83c26 Mon Sep 17 00:00:00 2001
From: barry
Date: Wed, 24 Jun 2026 18:58:17 +0800
Subject: [PATCH 06/15] feat(todo-list): enhance todo list functionality with
new views and database management
Add new features to the todo list system, including the ability to build and manage missing views, upgrade the todo list database, and ensure proper field configurations. Refactor the todo list database creation logic to improve integration with workspace collections. Update tests to validate the new functionality and ensure comprehensive coverage of the todo list features.
---
packages/client/src/lib/index.ts | 1 +
packages/client/src/lib/todo-list-system.ts | 276 +++++++++------
packages/client/src/lib/todo-list.ts | 330 ++++++++++++++++++
packages/client/test/todo-list-system.test.ts | 44 ++-
packages/core/src/index.ts | 1 +
packages/core/src/lib/todo-list.ts | 23 ++
packages/core/src/registry/nodes/database.ts | 9 +
.../databases/fields/field-delete-dialog.tsx | 4 +-
.../databases/fields/field-rename-input.tsx | 5 +-
.../workspace-todo-list-container.tsx | 6 +-
packages/ui/src/lib/todo-list.ts | 118 ++++++-
11 files changed, 699 insertions(+), 118 deletions(-)
create mode 100644 packages/client/src/lib/todo-list.ts
create mode 100644 packages/core/src/lib/todo-list.ts
diff --git a/packages/client/src/lib/index.ts b/packages/client/src/lib/index.ts
index 341a81492..50d0db934 100644
--- a/packages/client/src/lib/index.ts
+++ b/packages/client/src/lib/index.ts
@@ -10,5 +10,6 @@ export * from './scheduled-task-schedule';
export * from './scheduled-task-checks';
export * from './scheduled-task-check-utils';
export * from './asset-library';
+export * from './todo-list';
export * from './types';
export * from './utils';
diff --git a/packages/client/src/lib/todo-list-system.ts b/packages/client/src/lib/todo-list-system.ts
index 68f029477..ec5ca3c9b 100644
--- a/packages/client/src/lib/todo-list-system.ts
+++ b/packages/client/src/lib/todo-list-system.ts
@@ -1,101 +1,22 @@
+import {
+ buildMissingTodoListViews,
+ buildTodoListBundle,
+ buildTodoListFields,
+ buildTodoListViewFields,
+ resolveTodoListStatusFieldId,
+} from '@colanode/client/lib/todo-list';
import { findOrCreateMetaTypesSpace } from '@colanode/client/lib/page-meta-types';
import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service';
import {
DatabaseAttributes,
+ DatabaseViewAttributes,
FieldAttributes,
createDebugger,
- generateFractionalIndex,
- generateId,
- IdType,
- TODO_LIST_META_TYPE_NAME,
TODO_LIST_SYSTEM_KIND,
} from '@colanode/core';
const debug = createDebugger('desktop:todo-list-system');
-const buildTodoListFields = (): Record => {
- const statusFieldId = generateId(IdType.Field);
- const priorityFieldId = generateId(IdType.Field);
- const dueDateFieldId = generateId(IdType.Field);
- const assigneeFieldId = generateId(IdType.Field);
- const estimateFieldId = generateId(IdType.Field);
- const labelsFieldId = generateId(IdType.Field);
-
- const todoId = generateId(IdType.SelectOption);
- const inProgressId = generateId(IdType.SelectOption);
- const blockedId = generateId(IdType.SelectOption);
- const doneId = generateId(IdType.SelectOption);
-
- const lowId = generateId(IdType.SelectOption);
- const mediumId = generateId(IdType.SelectOption);
- const highId = generateId(IdType.SelectOption);
- const criticalId = generateId(IdType.SelectOption);
-
- return {
- [statusFieldId]: {
- id: statusFieldId,
- type: 'select',
- name: 'Status',
- index: 'a',
- default: todoId,
- options: {
- [todoId]: { id: todoId, name: 'Todo', color: 'gray', index: 'a' },
- [inProgressId]: {
- id: inProgressId,
- name: 'In Progress',
- color: 'blue',
- index: 'b',
- },
- [blockedId]: { id: blockedId, name: 'Blocked', color: 'red', index: 'c' },
- [doneId]: { id: doneId, name: 'Done', color: 'green', index: 'd' },
- },
- },
- [priorityFieldId]: {
- id: priorityFieldId,
- type: 'select',
- name: 'Priority',
- index: 'b',
- default: mediumId,
- options: {
- [lowId]: { id: lowId, name: 'Low', color: 'gray', index: 'a' },
- [mediumId]: { id: mediumId, name: 'Medium', color: 'blue', index: 'b' },
- [highId]: { id: highId, name: 'High', color: 'orange', index: 'c' },
- [criticalId]: {
- id: criticalId,
- name: 'Critical',
- color: 'red',
- index: 'd',
- },
- },
- },
- [dueDateFieldId]: {
- id: dueDateFieldId,
- type: 'date',
- name: 'Due Date',
- index: 'c',
- },
- [assigneeFieldId]: {
- id: assigneeFieldId,
- type: 'text',
- name: 'Assignee',
- index: 'd',
- },
- [estimateFieldId]: {
- id: estimateFieldId,
- type: 'number',
- name: 'Estimate',
- index: 'e',
- default: 0,
- },
- [labelsFieldId]: {
- id: labelsFieldId,
- type: 'text',
- name: 'Labels',
- index: 'f',
- },
- };
-};
-
const findTodoListSystemDatabaseId = async (
workspace: WorkspaceService
): Promise => {
@@ -115,33 +36,177 @@ const findTodoListSystemDatabaseId = async (
return null;
};
+const listTodoListViews = async (
+ workspace: WorkspaceService,
+ databaseId: string
+) => {
+ const rows = await workspace.database
+ .selectFrom('nodes')
+ .select(['id', 'attributes', 'root_id'])
+ .where('type', '=', 'database_view')
+ .where('parent_id', '=', databaseId)
+ .execute();
+
+ return rows
+ .map((row) => {
+ const attributes = JSON.parse(row.attributes) as DatabaseViewAttributes;
+ return {
+ id: row.id,
+ attributes,
+ index: attributes.index,
+ };
+ })
+ .toSorted((a, b) => a.index.localeCompare(b.index));
+};
+
+const mergeTodoListFields = (
+ existing: Record
+): Record => {
+ const required = buildTodoListFields();
+ const next = { ...existing };
+
+ for (const [fieldId, field] of Object.entries(required)) {
+ if (!next[fieldId]) {
+ const byName = Object.values(next).find(
+ (item) => item.name === field.name && item.type === field.type
+ );
+ if (!byName) {
+ next[fieldId] = field;
+ }
+ }
+ }
+
+ return next;
+};
+
+const upgradeTodoListSystemDatabase = async (
+ workspace: WorkspaceService,
+ databaseId: string
+): Promise => {
+ const row = await workspace.database
+ .selectFrom('nodes')
+ .select(['id', 'attributes', 'root_id'])
+ .where('id', '=', databaseId)
+ .executeTakeFirst();
+
+ if (!row) {
+ return;
+ }
+
+ const attributes = JSON.parse(row.attributes) as DatabaseAttributes;
+ const mergedFields = mergeTodoListFields(attributes.fields ?? {});
+
+ const databaseResult = await workspace.nodes.updateNode(
+ databaseId,
+ (draft) => {
+ if (draft.type !== 'database') {
+ return draft;
+ }
+
+ draft.purpose = 'records';
+ draft.locked = false;
+ draft.fields = mergeTodoListFields(draft.fields ?? {});
+ return draft;
+ }
+ );
+
+ if (databaseResult === 'success') {
+ debug(`Upgraded todo list system database ${databaseId}`);
+ }
+
+ const views = await listTodoListViews(workspace, databaseId);
+ const viewFields = buildTodoListViewFields();
+ const statusFieldId = resolveTodoListStatusFieldId(mergedFields);
+
+ for (const view of views) {
+ const needsFields =
+ view.attributes.layout === 'table' ||
+ view.attributes.layout === 'list' ||
+ view.attributes.layout === 'board';
+ const hasFields = Object.keys(view.attributes.fields ?? {}).length > 0;
+ const needsGroupBy =
+ view.attributes.layout === 'board' && !view.attributes.groupBy;
+
+ if (!needsFields || (hasFields && !needsGroupBy)) {
+ continue;
+ }
+
+ await workspace.nodes.updateNode(view.id, (draft) => {
+ if (draft.type !== 'database_view') {
+ return draft;
+ }
+
+ if (!draft.fields || Object.keys(draft.fields).length === 0) {
+ draft.fields = viewFields;
+ }
+
+ if (draft.layout === 'board' && !draft.groupBy) {
+ draft.groupBy = statusFieldId;
+ }
+
+ return draft;
+ });
+ }
+
+ const existingLayouts = new Set(views.map((view) => view.attributes.layout));
+ const lastViewIndex = views.at(-1)?.index ?? null;
+ const missingViews = buildMissingTodoListViews({
+ databaseId,
+ rootId: row.root_id,
+ userId: workspace.userId,
+ existingLayouts,
+ lastViewIndex,
+ firstViewIndex: views[0]?.index ?? null,
+ });
+
+ for (const view of missingViews) {
+ await workspace.nodes.insertNode(view.id, {
+ type: 'database_view',
+ name: view.name,
+ parentId: view.parentId,
+ index: view.index,
+ layout: view.layout,
+ fields: view.fields,
+ ...(view.groupBy ? { groupBy: view.groupBy } : {}),
+ });
+ debug(`Added missing todo list view ${view.layout} (${view.id})`);
+ }
+};
+
const createTodoListSystemDatabase = async (
workspace: WorkspaceService,
spaceId: string
): Promise => {
- const databaseId = generateId(IdType.Database);
- const viewId = generateId(IdType.DatabaseView);
-
- await workspace.nodes.insertNode(databaseId, {
- type: 'database',
- name: TODO_LIST_META_TYPE_NAME,
+ const bundle = buildTodoListBundle({
parentId: spaceId,
- purpose: 'records',
- systemKind: TODO_LIST_SYSTEM_KIND,
- locked: true,
- fields: buildTodoListFields(),
+ rootId: spaceId,
+ userId: workspace.userId,
});
- await workspace.nodes.insertNode(viewId, {
- type: 'database_view',
- name: 'Default',
- parentId: databaseId,
- index: generateFractionalIndex(null, null),
- layout: 'table',
+ await workspace.nodes.insertNode(bundle.database.id, {
+ type: 'database',
+ name: bundle.database.name,
+ parentId: bundle.database.parentId,
+ purpose: bundle.database.purpose,
+ systemKind: bundle.database.systemKind,
+ locked: bundle.database.locked,
+ fields: bundle.database.fields,
});
- debug(`Created todo list system database ${databaseId}`);
- return databaseId;
+ for (const view of [bundle.listView, bundle.tableView, bundle.boardView]) {
+ await workspace.nodes.insertNode(view.id, {
+ type: 'database_view',
+ name: view.name,
+ parentId: view.parentId,
+ index: view.index,
+ layout: view.layout,
+ fields: view.fields,
+ ...(view.groupBy ? { groupBy: view.groupBy } : {}),
+ });
+ }
+
+ debug(`Created todo list system database ${bundle.database.id}`);
+ return bundle.database.id;
};
export const ensureTodoListSystemDatabase = async (
@@ -149,6 +214,7 @@ export const ensureTodoListSystemDatabase = async (
): Promise => {
const existing = await findTodoListSystemDatabaseId(workspace);
if (existing) {
+ await upgradeTodoListSystemDatabase(workspace, existing);
return existing;
}
diff --git a/packages/client/src/lib/todo-list.ts b/packages/client/src/lib/todo-list.ts
new file mode 100644
index 000000000..6a19bbfcf
--- /dev/null
+++ b/packages/client/src/lib/todo-list.ts
@@ -0,0 +1,330 @@
+import {
+ LocalDatabaseNode,
+ LocalDatabaseViewNode,
+} from '@colanode/client/types';
+import {
+ DatabaseViewFieldAttributes,
+ FieldAttributes,
+ generateFractionalIndex,
+ generateId,
+ IdType,
+ TODO_LIST_DEFAULT_VIEW_NAME,
+ TODO_LIST_FIELD_IDS,
+ TODO_LIST_META_TYPE_NAME,
+ TODO_LIST_SYSTEM_KIND,
+} from '@colanode/core';
+
+const TODO_STATUS_OPTION_IDS = {
+ todo: '__colanode_todo_status_todo',
+ inProgress: '__colanode_todo_status_in_progress',
+ blocked: '__colanode_todo_status_blocked',
+ done: '__colanode_todo_status_done',
+} as const;
+
+const TODO_PRIORITY_OPTION_IDS = {
+ low: '__colanode_todo_priority_low',
+ medium: '__colanode_todo_priority_medium',
+ high: '__colanode_todo_priority_high',
+ critical: '__colanode_todo_priority_critical',
+} as const;
+
+export const buildTodoListFields = (): Record => {
+ return {
+ [TODO_LIST_FIELD_IDS.status]: {
+ id: TODO_LIST_FIELD_IDS.status,
+ type: 'select',
+ name: 'Status',
+ index: 'a',
+ default: TODO_STATUS_OPTION_IDS.todo,
+ options: {
+ [TODO_STATUS_OPTION_IDS.todo]: {
+ id: TODO_STATUS_OPTION_IDS.todo,
+ name: 'Todo',
+ color: 'gray',
+ index: 'a',
+ },
+ [TODO_STATUS_OPTION_IDS.inProgress]: {
+ id: TODO_STATUS_OPTION_IDS.inProgress,
+ name: 'In Progress',
+ color: 'blue',
+ index: 'b',
+ },
+ [TODO_STATUS_OPTION_IDS.blocked]: {
+ id: TODO_STATUS_OPTION_IDS.blocked,
+ name: 'Blocked',
+ color: 'red',
+ index: 'c',
+ },
+ [TODO_STATUS_OPTION_IDS.done]: {
+ id: TODO_STATUS_OPTION_IDS.done,
+ name: 'Done',
+ color: 'green',
+ index: 'd',
+ },
+ },
+ },
+ [TODO_LIST_FIELD_IDS.priority]: {
+ id: TODO_LIST_FIELD_IDS.priority,
+ type: 'select',
+ name: 'Priority',
+ index: 'b',
+ default: TODO_PRIORITY_OPTION_IDS.medium,
+ options: {
+ [TODO_PRIORITY_OPTION_IDS.low]: {
+ id: TODO_PRIORITY_OPTION_IDS.low,
+ name: 'Low',
+ color: 'gray',
+ index: 'a',
+ },
+ [TODO_PRIORITY_OPTION_IDS.medium]: {
+ id: TODO_PRIORITY_OPTION_IDS.medium,
+ name: 'Medium',
+ color: 'blue',
+ index: 'b',
+ },
+ [TODO_PRIORITY_OPTION_IDS.high]: {
+ id: TODO_PRIORITY_OPTION_IDS.high,
+ name: 'High',
+ color: 'orange',
+ index: 'c',
+ },
+ [TODO_PRIORITY_OPTION_IDS.critical]: {
+ id: TODO_PRIORITY_OPTION_IDS.critical,
+ name: 'Critical',
+ color: 'red',
+ index: 'd',
+ },
+ },
+ },
+ [TODO_LIST_FIELD_IDS.dueDate]: {
+ id: TODO_LIST_FIELD_IDS.dueDate,
+ type: 'date',
+ name: 'Due Date',
+ index: 'c',
+ },
+ [TODO_LIST_FIELD_IDS.assignee]: {
+ id: TODO_LIST_FIELD_IDS.assignee,
+ type: 'text',
+ name: 'Assignee',
+ index: 'd',
+ },
+ [TODO_LIST_FIELD_IDS.estimate]: {
+ id: TODO_LIST_FIELD_IDS.estimate,
+ type: 'number',
+ name: 'Estimate',
+ index: 'e',
+ default: 0,
+ },
+ [TODO_LIST_FIELD_IDS.labels]: {
+ id: TODO_LIST_FIELD_IDS.labels,
+ type: 'text',
+ name: 'Labels',
+ index: 'f',
+ },
+ };
+};
+
+export const buildTodoListViewFields = (): Record<
+ string,
+ DatabaseViewFieldAttributes
+> => {
+ const statusIndex = generateFractionalIndex(null, null);
+ const priorityIndex = generateFractionalIndex(statusIndex, null);
+ const dueDateIndex = generateFractionalIndex(priorityIndex, null);
+ const assigneeIndex = generateFractionalIndex(dueDateIndex, null);
+ const estimateIndex = generateFractionalIndex(assigneeIndex, null);
+ const labelsIndex = generateFractionalIndex(estimateIndex, null);
+
+ return {
+ [TODO_LIST_FIELD_IDS.status]: {
+ id: TODO_LIST_FIELD_IDS.status,
+ display: true,
+ index: statusIndex,
+ },
+ [TODO_LIST_FIELD_IDS.priority]: {
+ id: TODO_LIST_FIELD_IDS.priority,
+ display: true,
+ index: priorityIndex,
+ },
+ [TODO_LIST_FIELD_IDS.dueDate]: {
+ id: TODO_LIST_FIELD_IDS.dueDate,
+ display: true,
+ index: dueDateIndex,
+ },
+ [TODO_LIST_FIELD_IDS.assignee]: {
+ id: TODO_LIST_FIELD_IDS.assignee,
+ display: true,
+ index: assigneeIndex,
+ },
+ [TODO_LIST_FIELD_IDS.estimate]: {
+ id: TODO_LIST_FIELD_IDS.estimate,
+ display: true,
+ index: estimateIndex,
+ },
+ [TODO_LIST_FIELD_IDS.labels]: {
+ id: TODO_LIST_FIELD_IDS.labels,
+ display: true,
+ index: labelsIndex,
+ },
+ };
+};
+
+const TODO_LIST_VIEW_SPECS = [
+ { name: TODO_LIST_DEFAULT_VIEW_NAME, layout: 'list' as const },
+ { name: 'Table', layout: 'table' as const },
+ {
+ name: 'By status',
+ layout: 'board' as const,
+ groupBy: TODO_LIST_FIELD_IDS.status,
+ },
+] as const;
+
+export const buildMissingTodoListViews = (input: {
+ databaseId: string;
+ rootId: string;
+ userId: string;
+ existingLayouts: Set;
+ lastViewIndex: string | null;
+ firstViewIndex?: string | null;
+}): LocalDatabaseViewNode[] => {
+ const now = new Date().toISOString();
+ const viewFields = buildTodoListViewFields();
+ let previousIndex = input.lastViewIndex;
+ const views: LocalDatabaseViewNode[] = [];
+
+ for (const spec of TODO_LIST_VIEW_SPECS) {
+ if (input.existingLayouts.has(spec.layout)) {
+ continue;
+ }
+
+ const index =
+ spec.layout === 'list' && input.firstViewIndex
+ ? generateFractionalIndex(null, input.firstViewIndex)
+ : generateFractionalIndex(previousIndex, null);
+ previousIndex = index;
+
+ views.push({
+ id: generateId(IdType.DatabaseView),
+ type: 'database_view',
+ name: spec.name,
+ index,
+ layout: spec.layout,
+ parentId: input.databaseId,
+ rootId: input.rootId,
+ ...('groupBy' in spec ? { groupBy: spec.groupBy } : {}),
+ fields: viewFields,
+ createdAt: now,
+ createdBy: input.userId,
+ updatedAt: null,
+ updatedBy: null,
+ localRevision: '0',
+ serverRevision: '0',
+ });
+ }
+
+ return views;
+};
+
+export const buildTodoListBundle = (input: {
+ parentId: string;
+ rootId: string;
+ userId: string;
+}): {
+ database: LocalDatabaseNode;
+ listView: LocalDatabaseViewNode;
+ tableView: LocalDatabaseViewNode;
+ boardView: LocalDatabaseViewNode;
+} => {
+ const databaseId = generateId(IdType.Database);
+ const listViewId = generateId(IdType.DatabaseView);
+ const tableViewId = generateId(IdType.DatabaseView);
+ const boardViewId = generateId(IdType.DatabaseView);
+ const now = new Date().toISOString();
+ const viewFields = buildTodoListViewFields();
+
+ const database: LocalDatabaseNode = {
+ id: databaseId,
+ type: 'database',
+ name: TODO_LIST_META_TYPE_NAME,
+ parentId: input.parentId,
+ rootId: input.rootId,
+ purpose: 'records',
+ systemKind: TODO_LIST_SYSTEM_KIND,
+ locked: false,
+ fields: buildTodoListFields(),
+ createdAt: now,
+ createdBy: input.userId,
+ updatedAt: null,
+ updatedBy: null,
+ localRevision: '0',
+ serverRevision: '0',
+ };
+
+ const listView: LocalDatabaseViewNode = {
+ id: listViewId,
+ type: 'database_view',
+ name: TODO_LIST_DEFAULT_VIEW_NAME,
+ index: generateFractionalIndex(null, null),
+ layout: 'list',
+ parentId: databaseId,
+ rootId: input.rootId,
+ fields: viewFields,
+ createdAt: now,
+ createdBy: input.userId,
+ updatedAt: null,
+ updatedBy: null,
+ localRevision: '0',
+ serverRevision: '0',
+ };
+
+ const tableView: LocalDatabaseViewNode = {
+ id: tableViewId,
+ type: 'database_view',
+ name: 'Table',
+ index: generateFractionalIndex(listView.index, null),
+ layout: 'table',
+ parentId: databaseId,
+ rootId: input.rootId,
+ fields: viewFields,
+ createdAt: now,
+ createdBy: input.userId,
+ updatedAt: null,
+ updatedBy: null,
+ localRevision: '0',
+ serverRevision: '0',
+ };
+
+ const boardView: LocalDatabaseViewNode = {
+ id: boardViewId,
+ type: 'database_view',
+ name: 'By status',
+ index: generateFractionalIndex(tableView.index, null),
+ layout: 'board',
+ parentId: databaseId,
+ rootId: input.rootId,
+ groupBy: TODO_LIST_FIELD_IDS.status,
+ fields: viewFields,
+ createdAt: now,
+ createdBy: input.userId,
+ updatedAt: null,
+ updatedBy: null,
+ localRevision: '0',
+ serverRevision: '0',
+ };
+
+ return { database, listView, tableView, boardView };
+};
+
+export const resolveTodoListStatusFieldId = (
+ fields: Record
+): string => {
+ if (fields[TODO_LIST_FIELD_IDS.status]) {
+ return TODO_LIST_FIELD_IDS.status;
+ }
+
+ const byName = Object.values(fields).find(
+ (field) => field.name === 'Status' && field.type === 'select'
+ );
+
+ return byName?.id ?? TODO_LIST_FIELD_IDS.status;
+};
diff --git a/packages/client/test/todo-list-system.test.ts b/packages/client/test/todo-list-system.test.ts
index d2b82841d..5447141e7 100644
--- a/packages/client/test/todo-list-system.test.ts
+++ b/packages/client/test/todo-list-system.test.ts
@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { WorkspaceDatabaseSchema } from '@colanode/client/databases';
+import {
+ buildMissingTodoListViews,
+ buildTodoListBundle,
+} from '@colanode/client/lib/todo-list';
import { ensureTodoListSystemDatabase } from '@colanode/client/lib/todo-list-system';
import { NodeService } from '@colanode/client/services/workspaces/node-service';
import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service';
@@ -77,7 +81,7 @@ describe('todo list system database', () => {
expect(attributes.purpose).toBe('records');
expect(attributes.name).toBe('TodoList');
expect(attributes.systemKind).toBe('todo_list');
- expect(attributes.locked).toBe(true);
+ expect(attributes.locked).toBe(false);
const fieldNames = Object.values(attributes.fields ?? {}).map(
(field) => field.name
@@ -92,5 +96,43 @@ describe('todo list system database', () => {
'Labels',
])
);
+
+ const views = await database
+ .selectFrom('nodes')
+ .select(['attributes'])
+ .where('type', '=', 'database_view')
+ .where('parent_id', '=', firstId)
+ .execute();
+
+ const layouts = views.map(
+ (view) => JSON.parse(view.attributes).layout as string
+ );
+ expect(layouts).toEqual(
+ expect.arrayContaining(['list', 'table', 'board'])
+ );
+ });
+
+ it('builds list-first views for new databases', () => {
+ const bundle = buildTodoListBundle({
+ parentId: generateId(IdType.Space),
+ rootId: generateId(IdType.Space),
+ userId,
+ });
+
+ expect(bundle.database.purpose).toBe('records');
+ expect(bundle.listView.layout).toBe('list');
+ expect(bundle.tableView.layout).toBe('table');
+ expect(bundle.boardView.layout).toBe('board');
+ expect(bundle.boardView.groupBy).toBeDefined();
+
+ const missing = buildMissingTodoListViews({
+ databaseId: bundle.database.id,
+ rootId: bundle.database.rootId,
+ userId,
+ existingLayouts: new Set(['list']),
+ lastViewIndex: bundle.listView.index,
+ });
+
+ expect(missing.map((view) => view.layout)).toEqual(['table', 'board']);
});
});
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index bae8d598f..9648da924 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,6 +1,7 @@
export * from './lib/constants';
export * from './lib/meta-types';
export * from './lib/asset-library';
+export * from './lib/todo-list';
export * from './lib/field-defaults';
export * from './lib/record-query';
export * from './lib/record-query-dsl';
diff --git a/packages/core/src/lib/todo-list.ts b/packages/core/src/lib/todo-list.ts
new file mode 100644
index 000000000..e6d7af5af
--- /dev/null
+++ b/packages/core/src/lib/todo-list.ts
@@ -0,0 +1,23 @@
+import { TODO_LIST_SYSTEM_KIND } from '@colanode/core/lib/meta-types';
+
+export const TODO_LIST_FIELD_IDS = {
+ status: '__colanode_todo_status',
+ priority: '__colanode_todo_priority',
+ dueDate: '__colanode_todo_due_date',
+ assignee: '__colanode_todo_assignee',
+ estimate: '__colanode_todo_estimate',
+ labels: '__colanode_todo_labels',
+} as const;
+
+export const TODO_LIST_DEFAULT_VIEW_NAME = 'List';
+
+export const isTodoListSystemFieldId = (fieldId: string): boolean => {
+ return (Object.values(TODO_LIST_FIELD_IDS) as string[]).includes(fieldId);
+};
+
+export const isTodoListSystemDatabase = (node: {
+ type?: string;
+ systemKind?: string | null;
+}): boolean => {
+ return node.type === 'database' && node.systemKind === TODO_LIST_SYSTEM_KIND;
+};
diff --git a/packages/core/src/registry/nodes/database.ts b/packages/core/src/registry/nodes/database.ts
index 1f70eb5eb..9aa20602f 100644
--- a/packages/core/src/registry/nodes/database.ts
+++ b/packages/core/src/registry/nodes/database.ts
@@ -71,6 +71,15 @@ export const databaseModel: NodeModel = {
}
if (context.node.type === 'database' && context.node.systemKind) {
+ if (context.node.systemKind === TODO_LIST_SYSTEM_KIND) {
+ const role = extractNodeRole(context.tree, context.user.id);
+ if (!role) {
+ return false;
+ }
+
+ return hasNodeRole(role, 'editor');
+ }
+
return false;
}
diff --git a/packages/ui/src/components/databases/fields/field-delete-dialog.tsx b/packages/ui/src/components/databases/fields/field-delete-dialog.tsx
index 70ec12f5b..be7463b6c 100644
--- a/packages/ui/src/components/databases/fields/field-delete-dialog.tsx
+++ b/packages/ui/src/components/databases/fields/field-delete-dialog.tsx
@@ -1,4 +1,4 @@
-import { isRemindersRelationFieldId } from '@colanode/core';
+import { isRemindersRelationFieldId, isTodoListSystemFieldId } from '@colanode/core';
import {
AlertDialog,
AlertDialogCancel,
@@ -26,7 +26,7 @@ export const FieldDeleteDialog = ({
const workspace = useWorkspace();
const database = useDatabase();
- if (isRemindersRelationFieldId(id)) {
+ if (isRemindersRelationFieldId(id) || isTodoListSystemFieldId(id)) {
return null;
}
diff --git a/packages/ui/src/components/databases/fields/field-rename-input.tsx b/packages/ui/src/components/databases/fields/field-rename-input.tsx
index ea85b2977..7a165b55d 100644
--- a/packages/ui/src/components/databases/fields/field-rename-input.tsx
+++ b/packages/ui/src/components/databases/fields/field-rename-input.tsx
@@ -1,7 +1,7 @@
import { debounceStrategy, usePacedMutations } from '@tanstack/react-db';
import { LocalNode } from '@colanode/client/types';
-import { FieldAttributes, isRemindersRelationFieldId } from '@colanode/core';
+import { FieldAttributes, isRemindersRelationFieldId, isTodoListSystemFieldId } from '@colanode/core';
import { Input } from '@colanode/ui/components/ui/input';
import { useDatabase } from '@colanode/ui/contexts/database';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
@@ -43,7 +43,8 @@ export const FieldRenameInput = ({ field }: FieldRenameInputProps) => {
readOnly={
!database.canEdit ||
database.isLocked ||
- isRemindersRelationFieldId(field.id)
+ isRemindersRelationFieldId(field.id) ||
+ isTodoListSystemFieldId(field.id)
}
onChange={(event) => {
const newValue = event.target.value;
diff --git a/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-container.tsx b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-container.tsx
index a69e984bc..473440058 100644
--- a/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-container.tsx
+++ b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-container.tsx
@@ -7,7 +7,7 @@ import { DatabaseViews } from '@colanode/ui/components/databases/database-views'
import { Container } from '@colanode/ui/components/layouts/containers/container';
import { WorkspaceTodoListBreadcrumb } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-breadcrumb';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
-import { getTodoListDatabase } from '@colanode/ui/lib/todo-list';
+import { ensureTodoListDatabase } from '@colanode/ui/lib/todo-list';
const workspaceRoleToNodeRole = (role: string): NodeRole => {
if (role === 'owner' || role === 'admin') {
@@ -28,7 +28,7 @@ export const WorkspaceTodoListContainer = () => {
useEffect(() => {
let cancelled = false;
- void getTodoListDatabase(workspace.userId).then((nextDatabase) => {
+ void ensureTodoListDatabase(workspace).then((nextDatabase) => {
if (!cancelled) {
setDatabase(nextDatabase);
}
@@ -37,7 +37,7 @@ export const WorkspaceTodoListContainer = () => {
return () => {
cancelled = true;
};
- }, [workspace.userId]);
+ }, [workspace]);
if (!database) {
return null;
diff --git a/packages/ui/src/lib/todo-list.ts b/packages/ui/src/lib/todo-list.ts
index 0fc6bf810..308ca943b 100644
--- a/packages/ui/src/lib/todo-list.ts
+++ b/packages/ui/src/lib/todo-list.ts
@@ -1,12 +1,92 @@
-import { LocalDatabaseNode } from '@colanode/client/types';
+import {
+ buildMissingTodoListViews,
+ buildTodoListViewFields,
+ resolveTodoListStatusFieldId,
+} from '@colanode/client/lib';
+import { LocalDatabaseNode, LocalDatabaseViewNode } from '@colanode/client/types';
import { TODO_LIST_SYSTEM_KIND } from '@colanode/core';
+import { WorkspaceCollections } from '@colanode/ui/collections';
+import { updateNodeInCollection } from '@colanode/ui/lib/nodes';
-export const getTodoListDatabase = async (
- userId: string
+type WorkspaceLike = {
+ userId: string;
+ collections: WorkspaceCollections;
+};
+
+const ensureTodoListViews = async (
+ workspace: WorkspaceLike,
+ database: LocalDatabaseNode
+): Promise => {
+ const views = (await window.colanode.executeQuery({
+ type: 'node.list',
+ userId: workspace.userId,
+ filters: [
+ { field: ['type'], operator: 'in', value: ['database_view'] },
+ { field: ['parentId'], operator: 'eq', value: database.id },
+ ],
+ sorts: [],
+ })) as LocalDatabaseViewNode[];
+
+ const sortedViews = views.toSorted((a, b) => a.index.localeCompare(b.index));
+ const existingLayouts = new Set(sortedViews.map((view) => view.layout));
+ const lastViewIndex = sortedViews.at(-1)?.index ?? null;
+ const viewFields = buildTodoListViewFields();
+ const statusFieldId = resolveTodoListStatusFieldId(database.fields ?? {});
+
+ for (const view of sortedViews) {
+ const needsFields =
+ view.layout === 'table' ||
+ view.layout === 'list' ||
+ view.layout === 'board';
+ const hasFields = Object.keys(view.fields ?? {}).length > 0;
+ const needsGroupBy = view.layout === 'board' && !view.groupBy;
+
+ if (!needsFields || (hasFields && !needsGroupBy)) {
+ continue;
+ }
+
+ await updateNodeInCollection(
+ workspace.collections.nodes,
+ workspace.userId,
+ view,
+ (draft) => {
+ if (draft.type !== 'database_view') {
+ return;
+ }
+
+ if (!draft.fields || Object.keys(draft.fields).length === 0) {
+ draft.fields = viewFields;
+ }
+
+ if (draft.layout === 'board' && !draft.groupBy) {
+ draft.groupBy = statusFieldId;
+ }
+ }
+ );
+ }
+
+ const missingViews = buildMissingTodoListViews({
+ databaseId: database.id,
+ rootId: database.rootId,
+ userId: workspace.userId,
+ existingLayouts,
+ lastViewIndex,
+ firstViewIndex: sortedViews[0]?.index ?? null,
+ });
+
+ for (const view of missingViews) {
+ workspace.collections.nodes.insert(view);
+ }
+
+ return database;
+};
+
+export const ensureTodoListDatabase = async (
+ workspace: WorkspaceLike
): Promise => {
const databases = await window.colanode.executeQuery({
type: 'node.list',
- userId,
+ userId: workspace.userId,
filters: [
{ field: ['type'], operator: 'in', value: ['database'] },
{ field: ['systemKind'], operator: 'eq', value: TODO_LIST_SYSTEM_KIND },
@@ -16,5 +96,33 @@ export const getTodoListDatabase = async (
});
const node = databases[0];
- return node?.type === 'database' ? node : null;
+ if (node?.type !== 'database') {
+ return null;
+ }
+
+ let database = node as LocalDatabaseNode;
+
+ if (database.purpose !== 'records' || database.locked) {
+ await updateNodeInCollection(
+ workspace.collections.nodes,
+ workspace.userId,
+ database,
+ (draft) => {
+ if (draft.type !== 'database') {
+ return;
+ }
+
+ draft.purpose = 'records';
+ draft.locked = false;
+ }
+ );
+
+ database = {
+ ...database,
+ purpose: 'records',
+ locked: false,
+ };
+ }
+
+ return ensureTodoListViews(workspace, database);
};
From afe8b25ab055f8539e91e59173fe652c909deac9 Mon Sep 17 00:00:00 2001
From: barry
Date: Thu, 25 Jun 2026 10:13:03 +0800
Subject: [PATCH 07/15] feat(todo-list): update todo list fields and enhance
sidebar navigation
Refactor the todo list system by renaming fields for clarity, introducing a new size field, and updating the sidebar menu to include a direct link to the todo list settings. Enhance the routing logic to support navigation to the new todo list modal, improving user experience and accessibility of task management features. Update tests to reflect these changes and ensure comprehensive coverage.
---
packages/client/src/lib/todo-list-system.ts | 29 +++-
packages/client/src/lib/todo-list.ts | 93 +++++++++++--
packages/client/src/types/workspaces.ts | 7 +-
packages/client/test/todo-list-system.test.ts | 6 +-
packages/core/src/lib/todo-list.ts | 4 +-
.../databases/list/list-view-flat.tsx | 13 +-
.../databases/list/list-view-quick-create.tsx | 63 +++++++++
.../databases/list/list-view-record-row.tsx | 30 ++--
packages/ui/src/components/databases/view.tsx | 69 +++++-----
.../layouts/sidebars/sidebar-menu.tsx | 15 +-
.../layouts/sidebars/sidebar-todo-list.tsx | 130 ++++++++++++++++++
.../components/layouts/sidebars/sidebar.tsx | 12 +-
.../ui/src/components/nodes/node-modal.tsx | 8 +-
packages/ui/src/lib/record-create.ts | 54 ++++++++
packages/ui/src/routes/index.tsx | 3 +-
.../routes/workspace/todo-list-settings.tsx | 17 +++
16 files changed, 472 insertions(+), 81 deletions(-)
create mode 100644 packages/ui/src/components/databases/list/list-view-quick-create.tsx
create mode 100644 packages/ui/src/components/layouts/sidebars/sidebar-todo-list.tsx
create mode 100644 packages/ui/src/lib/record-create.ts
diff --git a/packages/client/src/lib/todo-list-system.ts b/packages/client/src/lib/todo-list-system.ts
index ec5ca3c9b..49df3ad00 100644
--- a/packages/client/src/lib/todo-list-system.ts
+++ b/packages/client/src/lib/todo-list-system.ts
@@ -11,8 +11,9 @@ import {
DatabaseAttributes,
DatabaseViewAttributes,
FieldAttributes,
- createDebugger,
+ TODO_LIST_FIELD_IDS,
TODO_LIST_SYSTEM_KIND,
+ createDebugger,
} from '@colanode/core';
const debug = createDebugger('desktop:todo-list-system');
@@ -59,11 +60,35 @@ const listTodoListViews = async (
.toSorted((a, b) => a.index.localeCompare(b.index));
};
+const normalizeTodoListFields = (
+ fields: Record
+): Record => {
+ const next = { ...fields };
+ const targetDate = next[TODO_LIST_FIELD_IDS.targetDate];
+ if (targetDate && targetDate.name === 'Due Date') {
+ next[TODO_LIST_FIELD_IDS.targetDate] = {
+ ...targetDate,
+ name: 'Target date',
+ };
+ }
+
+ const assignee = next[TODO_LIST_FIELD_IDS.assignee];
+ if (assignee && assignee.name === 'Assignee') {
+ next[TODO_LIST_FIELD_IDS.assignee] = {
+ ...assignee,
+ name: 'Assignees',
+ };
+ }
+
+ return next;
+};
+
const mergeTodoListFields = (
existing: Record
): Record => {
const required = buildTodoListFields();
- const next = { ...existing };
+ const normalized = normalizeTodoListFields(existing);
+ const next = { ...normalized };
for (const [fieldId, field] of Object.entries(required)) {
if (!next[fieldId]) {
diff --git a/packages/client/src/lib/todo-list.ts b/packages/client/src/lib/todo-list.ts
index 6a19bbfcf..1186140a3 100644
--- a/packages/client/src/lib/todo-list.ts
+++ b/packages/client/src/lib/todo-list.ts
@@ -28,6 +28,14 @@ const TODO_PRIORITY_OPTION_IDS = {
critical: '__colanode_todo_priority_critical',
} as const;
+const TODO_SIZE_OPTION_IDS = {
+ xs: '__colanode_todo_size_xs',
+ s: '__colanode_todo_size_s',
+ m: '__colanode_todo_size_m',
+ l: '__colanode_todo_size_l',
+ xl: '__colanode_todo_size_xl',
+} as const;
+
export const buildTodoListFields = (): Record => {
return {
[TODO_LIST_FIELD_IDS.status]: {
@@ -96,30 +104,75 @@ export const buildTodoListFields = (): Record => {
},
},
},
- [TODO_LIST_FIELD_IDS.dueDate]: {
- id: TODO_LIST_FIELD_IDS.dueDate,
+ [TODO_LIST_FIELD_IDS.startDate]: {
+ id: TODO_LIST_FIELD_IDS.startDate,
type: 'date',
- name: 'Due Date',
+ name: 'Start date',
index: 'c',
},
+ [TODO_LIST_FIELD_IDS.targetDate]: {
+ id: TODO_LIST_FIELD_IDS.targetDate,
+ type: 'date',
+ name: 'Target date',
+ index: 'd',
+ },
[TODO_LIST_FIELD_IDS.assignee]: {
id: TODO_LIST_FIELD_IDS.assignee,
type: 'text',
- name: 'Assignee',
- index: 'd',
+ name: 'Assignees',
+ index: 'e',
+ },
+ [TODO_LIST_FIELD_IDS.size]: {
+ id: TODO_LIST_FIELD_IDS.size,
+ type: 'select',
+ name: 'Size',
+ index: 'f',
+ options: {
+ [TODO_SIZE_OPTION_IDS.xs]: {
+ id: TODO_SIZE_OPTION_IDS.xs,
+ name: 'XS',
+ color: 'gray',
+ index: 'a',
+ },
+ [TODO_SIZE_OPTION_IDS.s]: {
+ id: TODO_SIZE_OPTION_IDS.s,
+ name: 'S',
+ color: 'blue',
+ index: 'b',
+ },
+ [TODO_SIZE_OPTION_IDS.m]: {
+ id: TODO_SIZE_OPTION_IDS.m,
+ name: 'M',
+ color: 'green',
+ index: 'c',
+ },
+ [TODO_SIZE_OPTION_IDS.l]: {
+ id: TODO_SIZE_OPTION_IDS.l,
+ name: 'L',
+ color: 'orange',
+ index: 'd',
+ },
+ [TODO_SIZE_OPTION_IDS.xl]: {
+ id: TODO_SIZE_OPTION_IDS.xl,
+ name: 'XL',
+ color: 'red',
+ index: 'e',
+ },
+ },
},
[TODO_LIST_FIELD_IDS.estimate]: {
id: TODO_LIST_FIELD_IDS.estimate,
type: 'number',
name: 'Estimate',
- index: 'e',
+ index: 'g',
default: 0,
},
[TODO_LIST_FIELD_IDS.labels]: {
id: TODO_LIST_FIELD_IDS.labels,
- type: 'text',
+ type: 'multi_select',
name: 'Labels',
- index: 'f',
+ index: 'h',
+ options: {},
},
};
};
@@ -130,9 +183,11 @@ export const buildTodoListViewFields = (): Record<
> => {
const statusIndex = generateFractionalIndex(null, null);
const priorityIndex = generateFractionalIndex(statusIndex, null);
- const dueDateIndex = generateFractionalIndex(priorityIndex, null);
- const assigneeIndex = generateFractionalIndex(dueDateIndex, null);
- const estimateIndex = generateFractionalIndex(assigneeIndex, null);
+ const startDateIndex = generateFractionalIndex(priorityIndex, null);
+ const targetDateIndex = generateFractionalIndex(startDateIndex, null);
+ const assigneeIndex = generateFractionalIndex(targetDateIndex, null);
+ const sizeIndex = generateFractionalIndex(assigneeIndex, null);
+ const estimateIndex = generateFractionalIndex(sizeIndex, null);
const labelsIndex = generateFractionalIndex(estimateIndex, null);
return {
@@ -146,16 +201,26 @@ export const buildTodoListViewFields = (): Record<
display: true,
index: priorityIndex,
},
- [TODO_LIST_FIELD_IDS.dueDate]: {
- id: TODO_LIST_FIELD_IDS.dueDate,
+ [TODO_LIST_FIELD_IDS.startDate]: {
+ id: TODO_LIST_FIELD_IDS.startDate,
display: true,
- index: dueDateIndex,
+ index: startDateIndex,
+ },
+ [TODO_LIST_FIELD_IDS.targetDate]: {
+ id: TODO_LIST_FIELD_IDS.targetDate,
+ display: true,
+ index: targetDateIndex,
},
[TODO_LIST_FIELD_IDS.assignee]: {
id: TODO_LIST_FIELD_IDS.assignee,
display: true,
index: assigneeIndex,
},
+ [TODO_LIST_FIELD_IDS.size]: {
+ id: TODO_LIST_FIELD_IDS.size,
+ display: true,
+ index: sizeIndex,
+ },
[TODO_LIST_FIELD_IDS.estimate]: {
id: TODO_LIST_FIELD_IDS.estimate,
display: true,
diff --git a/packages/client/src/types/workspaces.ts b/packages/client/src/types/workspaces.ts
index 2d232c79a..5ee2d052a 100644
--- a/packages/client/src/types/workspaces.ts
+++ b/packages/client/src/types/workspaces.ts
@@ -12,7 +12,12 @@ export type Workspace = {
status: WorkspaceStatus;
};
-export type SidebarMenuType = 'chats' | 'spaces' | 'notifications' | 'settings';
+export type SidebarMenuType =
+ | 'chats'
+ | 'spaces'
+ | 'notifications'
+ | 'todo-list'
+ | 'settings';
export type LocalSyncIdentityDrift = {
localWorkspaceId: string;
diff --git a/packages/client/test/todo-list-system.test.ts b/packages/client/test/todo-list-system.test.ts
index 5447141e7..b37643702 100644
--- a/packages/client/test/todo-list-system.test.ts
+++ b/packages/client/test/todo-list-system.test.ts
@@ -90,8 +90,10 @@ describe('todo list system database', () => {
expect.arrayContaining([
'Status',
'Priority',
- 'Due Date',
- 'Assignee',
+ 'Start date',
+ 'Target date',
+ 'Assignees',
+ 'Size',
'Estimate',
'Labels',
])
diff --git a/packages/core/src/lib/todo-list.ts b/packages/core/src/lib/todo-list.ts
index e6d7af5af..8c160b534 100644
--- a/packages/core/src/lib/todo-list.ts
+++ b/packages/core/src/lib/todo-list.ts
@@ -3,8 +3,10 @@ import { TODO_LIST_SYSTEM_KIND } from '@colanode/core/lib/meta-types';
export const TODO_LIST_FIELD_IDS = {
status: '__colanode_todo_status',
priority: '__colanode_todo_priority',
- dueDate: '__colanode_todo_due_date',
+ startDate: '__colanode_todo_start_date',
+ targetDate: '__colanode_todo_due_date',
assignee: '__colanode_todo_assignee',
+ size: '__colanode_todo_size',
estimate: '__colanode_todo_estimate',
labels: '__colanode_todo_labels',
} as const;
diff --git a/packages/ui/src/components/databases/list/list-view-flat.tsx b/packages/ui/src/components/databases/list/list-view-flat.tsx
index f18825739..fc7b4c4b9 100644
--- a/packages/ui/src/components/databases/list/list-view-flat.tsx
+++ b/packages/ui/src/components/databases/list/list-view-flat.tsx
@@ -1,4 +1,4 @@
-import { Plus } from 'lucide-react';
+import { ListViewQuickCreate } from '@colanode/ui/components/databases/list/list-view-quick-create';
import { InView } from 'react-intersection-observer';
import { extractNodeRole } from '@colanode/core';
@@ -47,16 +47,7 @@ export const ListViewFlat = () => {
);
})}
- {database.canCreateRecord && (
-
- )}
+ {database.canCreateRecord && }
diff --git a/packages/ui/src/components/databases/list/list-view-quick-create.tsx b/packages/ui/src/components/databases/list/list-view-quick-create.tsx
new file mode 100644
index 000000000..a11c04496
--- /dev/null
+++ b/packages/ui/src/components/databases/list/list-view-quick-create.tsx
@@ -0,0 +1,63 @@
+import { useRef, useState } from 'react';
+
+import { Plus } from 'lucide-react';
+
+import { Input } from '@colanode/ui/components/ui/input';
+import { useDatabase } from '@colanode/ui/contexts/database';
+import { useWorkspace } from '@colanode/ui/contexts/workspace';
+import { insertDatabaseRecord } from '@colanode/ui/lib/record-create';
+
+interface ListViewQuickCreateProps {
+ placeholder?: string;
+}
+
+export const ListViewQuickCreate = ({
+ placeholder = 'Add a task…',
+}: ListViewQuickCreateProps) => {
+ const workspace = useWorkspace();
+ const database = useDatabase();
+ const [value, setValue] = useState('');
+ const inputRef = useRef(null);
+
+ const handleSubmit = () => {
+ const name = value.trim();
+ if (!name) {
+ return;
+ }
+
+ insertDatabaseRecord(
+ workspace,
+ {
+ id: database.id,
+ rootId: database.rootId,
+ fields: database.fields,
+ },
+ { name }
+ );
+ setValue('');
+ inputRef.current?.focus();
+ };
+
+ if (!database.canCreateRecord) {
+ return null;
+ }
+
+ return (
+
+
+
setValue(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ handleSubmit();
+ }
+ }}
+ />
+
+ );
+};
diff --git a/packages/ui/src/components/databases/list/list-view-record-row.tsx b/packages/ui/src/components/databases/list/list-view-record-row.tsx
index 22b75336a..cfb73e13a 100644
--- a/packages/ui/src/components/databases/list/list-view-record-row.tsx
+++ b/packages/ui/src/components/databases/list/list-view-record-row.tsx
@@ -1,3 +1,4 @@
+import { useLocation } from '@tanstack/react-router';
import { debounceStrategy, usePacedMutations } from '@tanstack/react-db';
import { SquareArrowOutUpRight } from 'lucide-react';
@@ -17,6 +18,8 @@ export const ListViewRecordRow = () => {
const workspace = useWorkspace();
const view = useDatabaseView();
const record = useRecord();
+ const location = useLocation();
+ const isTodoListSettings = location.pathname.includes('/settings/todo-list');
const name = record.name;
const hasName = name !== null && name !== '';
@@ -76,14 +79,25 @@ export const ListViewRecordRow = () => {
})}
)}
-
-
-
+ {isTodoListSettings ? (
+
+
+
+ ) : (
+
+
+
+ )}
);
};
diff --git a/packages/ui/src/components/databases/view.tsx b/packages/ui/src/components/databases/view.tsx
index 9167f1832..6075ee885 100644
--- a/packages/ui/src/components/databases/view.tsx
+++ b/packages/ui/src/components/databases/view.tsx
@@ -1,10 +1,9 @@
-import { useNavigate } from '@tanstack/react-router';
+import { useNavigate, useLocation } from '@tanstack/react-router';
import { useState } from 'react';
import { match } from 'ts-pattern';
import {
LocalDatabaseViewNode,
- LocalRecordNode,
ViewField,
} from '@colanode/client/types';
import {
@@ -12,8 +11,6 @@ import {
DatabaseViewFilterAttributes,
DatabaseViewSortAttributes,
SpecialId,
- generateId,
- IdType,
} from '@colanode/core';
import { BoardView } from '@colanode/ui/components/databases/boards/board-view';
import { CalendarView } from '@colanode/ui/components/databases/calendars/calendar-view';
@@ -24,10 +21,8 @@ import { useDatabase } from '@colanode/ui/contexts/database';
import { DatabaseViewContext } from '@colanode/ui/contexts/database-view';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { buildDatabaseViewFields } from '@colanode/ui/lib/database-fields';
-import {
- generateFieldValuesFromFilters,
- getDefaultNameWidth,
-} from '@colanode/ui/lib/databases';
+import { getDefaultNameWidth } from '@colanode/ui/lib/databases';
+import { insertDatabaseRecord } from '@colanode/ui/lib/record-create';
interface ViewProps {
view: LocalDatabaseViewNode;
@@ -37,6 +32,7 @@ export const View = ({ view }: ViewProps) => {
const workspace = useWorkspace();
const database = useDatabase();
const navigate = useNavigate();
+ const location = useLocation();
const fields: ViewField[] = buildDatabaseViewFields(
database.fields,
@@ -157,39 +153,36 @@ export const View = ({ view }: ViewProps) => {
createRecord: async (filters?: DatabaseViewFilterAttributes[]) => {
const viewFilters = Object.values(view.filters ?? {}) ?? [];
const extraFilters = filters ?? [];
-
const allFilters = [...viewFilters, ...extraFilters];
- const fields = generateFieldValuesFromFilters(
- database.fields,
- allFilters,
- workspace.userId
+
+ const record = insertDatabaseRecord(
+ workspace,
+ {
+ id: database.id,
+ rootId: database.rootId,
+ fields: database.fields,
+ },
+ { filters: allFilters }
);
- const recordId = generateId(IdType.Record);
- const record: LocalRecordNode = {
- id: recordId,
- type: 'record',
- parentId: database.id,
- rootId: database.rootId,
- databaseId: database.id,
- name: '',
- fields,
- createdAt: new Date().toISOString(),
- createdBy: workspace.userId,
- updatedAt: null,
- updatedBy: null,
- localRevision: '0',
- serverRevision: '0',
- };
-
- const nodes = workspace.collections.nodes;
- nodes.insert(record);
-
- navigate({
- from: '/workspace/$userId/$nodeId',
- to: 'modal/$modalNodeId',
- params: { modalNodeId: record.id },
- });
+ if (view.layout !== 'list') {
+ if (location.pathname.includes('/settings/todo-list')) {
+ navigate({
+ to: '/workspace/$userId/settings/todo-list/modal/$modalNodeId',
+ params: {
+ userId: workspace.userId,
+ modalNodeId: record.id,
+ },
+ });
+ return;
+ }
+
+ navigate({
+ from: '/workspace/$userId/$nodeId',
+ to: 'modal/$modalNodeId',
+ params: { modalNodeId: record.id },
+ });
+ }
},
}}
>
diff --git a/packages/ui/src/components/layouts/sidebars/sidebar-menu.tsx b/packages/ui/src/components/layouts/sidebars/sidebar-menu.tsx
index d427725c9..a4cb86b1b 100644
--- a/packages/ui/src/components/layouts/sidebars/sidebar-menu.tsx
+++ b/packages/ui/src/components/layouts/sidebars/sidebar-menu.tsx
@@ -1,5 +1,6 @@
import { isNull, useLiveQuery } from '@tanstack/react-db';
-import { Bell, LayoutGrid, MessageCircle, Settings } from 'lucide-react';
+import { Bell, LayoutGrid, ListTodo, MessageCircle, Settings } from 'lucide-react';
+import { useNavigate } from '@tanstack/react-router';
import { SidebarMenuType } from '@colanode/client/types';
import { SidebarMenuFooter } from '@colanode/ui/components/layouts/sidebars/sidebar-menu-footer';
@@ -16,6 +17,7 @@ interface SidebarMenuProps {
export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => {
const workspace = useWorkspace();
const radar = useRadar();
+ const navigate = useNavigate();
const chatsState = radar.getChatsState(workspace.userId);
const channelsState = radar.getChannelsState(workspace.userId);
@@ -72,6 +74,17 @@ export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => {
maxCount: 99,
}}
/>
+ {
+ onChange('todo-list');
+ navigate({
+ to: '/workspace/$userId/settings/todo-list',
+ params: { userId: workspace.userId },
+ });
+ }}
+ isActive={value === 'todo-list'}
+ />
{
+ const workspace = useWorkspace();
+ const [database, setDatabase] = useState(null);
+ const [value, setValue] = useState('');
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ void ensureTodoListDatabase(workspace).then((nextDatabase) => {
+ if (!cancelled) {
+ setDatabase(nextDatabase);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [workspace]);
+
+ const recordsQuery = useLiveQuery(
+ (q) => {
+ if (!database) {
+ return q.from({ nodes: workspace.collections.nodes }).where(() => false);
+ }
+
+ return q
+ .from({ nodes: workspace.collections.nodes })
+ .where(({ nodes }) => eq(nodes.type, 'record'))
+ .where(({ nodes }) => eq(nodes.parentId, database.id))
+ .orderBy(({ nodes }) => nodes.createdAt, 'desc')
+ .limit(20);
+ },
+ [workspace.userId, database?.id]
+ );
+
+ const records = (recordsQuery.data ?? []) as LocalRecordNode[];
+
+ const handleSubmit = () => {
+ const name = value.trim();
+ if (!name || !database) {
+ return;
+ }
+
+ insertDatabaseRecord(
+ workspace,
+ {
+ id: database.id,
+ rootId: database.rootId,
+ fields: Object.values(database.fields),
+ },
+ { name }
+ );
+ setValue('');
+ inputRef.current?.focus();
+ };
+
+ return (
+
+
+
+
+ }
+ />
+
+
+
+ setValue(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ handleSubmit();
+ }
+ }}
+ />
+
+
+
+ {database && records.length === 0 && (
+
+ No tasks yet. Type above and press Enter.
+
+ )}
+
+
+ {records.map((record) => {
+ const name = record.name?.trim() || 'Unnamed';
+ return (
+
+ {defaultEmojis.check}
+ {name}
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/layouts/sidebars/sidebar.tsx b/packages/ui/src/components/layouts/sidebars/sidebar.tsx
index 1f37c70fc..ac97bd378 100644
--- a/packages/ui/src/components/layouts/sidebars/sidebar.tsx
+++ b/packages/ui/src/components/layouts/sidebars/sidebar.tsx
@@ -1,4 +1,5 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
+import { useLocation } from '@tanstack/react-router';
import { SidebarMenuType } from '@colanode/client/types';
import { SidebarChats } from '@colanode/ui/components/layouts/sidebars/sidebar-chats';
@@ -6,6 +7,7 @@ import { SidebarMenu } from '@colanode/ui/components/layouts/sidebars/sidebar-me
import { SidebarNotifications } from '@colanode/ui/components/layouts/sidebars/sidebar-notifications';
import { SidebarSettings } from '@colanode/ui/components/layouts/sidebars/sidebar-settings';
import { SidebarSpaces } from '@colanode/ui/components/layouts/sidebars/sidebar-spaces';
+import { SidebarTodoList } from '@colanode/ui/components/layouts/sidebars/sidebar-todo-list';
import { useApp } from '@colanode/ui/contexts/app';
import { cn } from '@colanode/ui/lib/utils';
@@ -16,8 +18,15 @@ interface SidebarProps {
export const Sidebar = ({ collapsed = false, onToggleCollapse }: SidebarProps) => {
const app = useApp();
+ const location = useLocation();
const [menu, setMenu] = useState('spaces');
+ useEffect(() => {
+ if (location.pathname.includes('/settings/todo-list')) {
+ setMenu('todo-list');
+ }
+ }, [location.pathname]);
+
const handleMenuChange = (next: SidebarMenuType) => {
if (collapsed) {
onToggleCollapse?.();
@@ -44,6 +53,7 @@ export const Sidebar = ({ collapsed = false, onToggleCollapse }: SidebarProps) =
{menu === 'spaces' && }
{menu === 'chats' && }
{menu === 'notifications' && }
+ {menu === 'todo-list' && }
{menu === 'settings' && }
)}
diff --git a/packages/ui/src/components/nodes/node-modal.tsx b/packages/ui/src/components/nodes/node-modal.tsx
index 72ba1b53f..0239215eb 100644
--- a/packages/ui/src/components/nodes/node-modal.tsx
+++ b/packages/ui/src/components/nodes/node-modal.tsx
@@ -11,8 +11,9 @@ import {
interface NodeModalProps {
nodeId: string;
+ closeTo?: string;
}
-export const NodeModal = ({ nodeId }: NodeModalProps) => {
+export const NodeModal = ({ nodeId, closeTo }: NodeModalProps) => {
const navigate = useNavigate();
return (
@@ -20,6 +21,11 @@ export const NodeModal = ({ nodeId }: NodeModalProps) => {
open={true}
onOpenChange={(open) => {
if (!open) {
+ if (closeTo) {
+ navigate({ to: closeTo });
+ return;
+ }
+
navigate({
from: '/workspace/$userId/$nodeId/modal/$modalNodeId',
to: '/workspace/$userId/$nodeId',
diff --git a/packages/ui/src/lib/record-create.ts b/packages/ui/src/lib/record-create.ts
new file mode 100644
index 000000000..acb9b7851
--- /dev/null
+++ b/packages/ui/src/lib/record-create.ts
@@ -0,0 +1,54 @@
+import {
+ FieldAttributes,
+ generateId,
+ IdType,
+ DatabaseViewFilterAttributes,
+} from '@colanode/core';
+import { LocalRecordNode } from '@colanode/client/types';
+import { WorkspaceCollections } from '@colanode/ui/collections';
+import { generateFieldValuesFromFilters } from '@colanode/ui/lib/databases';
+
+type WorkspaceLike = {
+ userId: string;
+ collections: WorkspaceCollections;
+};
+
+type DatabaseLike = {
+ id: string;
+ rootId: string;
+ fields: FieldAttributes[];
+};
+
+export const insertDatabaseRecord = (
+ workspace: WorkspaceLike,
+ database: DatabaseLike,
+ input?: {
+ name?: string;
+ filters?: DatabaseViewFilterAttributes[];
+ }
+): LocalRecordNode => {
+ const fields = generateFieldValuesFromFilters(
+ database.fields,
+ input?.filters ?? [],
+ workspace.userId
+ );
+
+ const record: LocalRecordNode = {
+ id: generateId(IdType.Record),
+ type: 'record',
+ parentId: database.id,
+ rootId: database.rootId,
+ databaseId: database.id,
+ name: input?.name ?? '',
+ fields,
+ createdAt: new Date().toISOString(),
+ createdBy: workspace.userId,
+ updatedAt: null,
+ updatedBy: null,
+ localRevision: '0',
+ serverRevision: '0',
+ };
+
+ workspace.collections.nodes.insert(record);
+ return record;
+};
diff --git a/packages/ui/src/routes/index.tsx b/packages/ui/src/routes/index.tsx
index bd99ae021..612dc19de 100644
--- a/packages/ui/src/routes/index.tsx
+++ b/packages/ui/src/routes/index.tsx
@@ -56,6 +56,7 @@ import {
} from '@colanode/ui/routes/workspace/settings';
import {
workspaceTodoListMaskRoute,
+ workspaceTodoListModalRoute,
workspaceTodoListRoute,
} from '@colanode/ui/routes/workspace/todo-list-settings';
import {
@@ -77,7 +78,7 @@ export const routeTree = rootRoute.addChildren([
workspaceScheduledTasksRoute,
workspaceScriptExplorerRoute,
workspaceQueryExplorerRoute,
- workspaceTodoListRoute,
+ workspaceTodoListRoute.addChildren([workspaceTodoListModalRoute]),
workspaceAssetsRoute,
workspaceNotificationsRoute,
infoRoute,
diff --git a/packages/ui/src/routes/workspace/todo-list-settings.tsx b/packages/ui/src/routes/workspace/todo-list-settings.tsx
index 86349d26d..77efb973e 100644
--- a/packages/ui/src/routes/workspace/todo-list-settings.tsx
+++ b/packages/ui/src/routes/workspace/todo-list-settings.tsx
@@ -1,5 +1,6 @@
import { createRoute, redirect } from '@tanstack/react-router';
+import { NodeModal } from '@colanode/ui/components/nodes/node-modal';
import { WorkspaceTodoListContainer } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-container';
import { WorkspaceTodoListTab } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-tab';
import { getWorkspaceUserId } from '@colanode/ui/routes/utils';
@@ -19,6 +20,22 @@ export const workspaceTodoListRoute = createRoute({
},
});
+export const workspaceTodoListModalRoute = createRoute({
+ getParentRoute: () => workspaceTodoListRoute,
+ path: '/modal/$modalNodeId',
+ component: () => {
+ const { userId } = workspaceTodoListRoute.useParams();
+ const { modalNodeId } = workspaceTodoListModalRoute.useParams();
+
+ return (
+
+ );
+ },
+});
+
export const workspaceTodoListMaskRoute = createRoute({
getParentRoute: () => workspaceMaskRoute,
path: '/settings/todo-list',
From d597554d51a1666a5196608d863ab2a59e459217 Mon Sep 17 00:00:00 2001
From: barry
Date: Thu, 25 Jun 2026 11:09:33 +0800
Subject: [PATCH 08/15] feat(todo-list): enhance todo list functionality with
new fields and sidebar improvements
Add 'Created' and 'Updated' fields to the todo list system, improving task management capabilities. Refactor sidebar components to support navigation to the todo list settings and enhance user experience. Update related tests to ensure comprehensive coverage of the new features and functionality.
---
.../src/lib/scheduled-task-meta-type.ts | 6 +-
packages/client/src/lib/todo-list-system.ts | 37 ++-
packages/client/src/lib/todo-list.ts | 24 ++
packages/client/test/todo-list-system.test.ts | 2 +
packages/core/src/lib/todo-list.ts | 23 ++
.../layouts/sidebars/sidebar-desktop.tsx | 16 +-
.../layouts/sidebars/sidebar-menu.tsx | 37 ++-
.../layouts/sidebars/sidebar-todo-list.tsx | 93 +++----
.../components/layouts/sidebars/sidebar.tsx | 8 +-
.../todo-list/todo-list-task-detail.tsx | 126 +++++++++
.../todo-list/todo-list-task-properties.tsx | 52 ++++
.../workspace-todo-list-container.tsx | 93 +++++--
.../todo-list/workspace-todo-list-page.tsx | 31 +++
.../workspace-todo-list-workspace.tsx | 244 ++++++++++++++++++
.../routes/workspace/todo-list-settings.tsx | 4 +-
15 files changed, 691 insertions(+), 105 deletions(-)
create mode 100644 packages/ui/src/components/workspaces/todo-list/todo-list-task-detail.tsx
create mode 100644 packages/ui/src/components/workspaces/todo-list/todo-list-task-properties.tsx
create mode 100644 packages/ui/src/components/workspaces/todo-list/workspace-todo-list-page.tsx
create mode 100644 packages/ui/src/components/workspaces/todo-list/workspace-todo-list-workspace.tsx
diff --git a/packages/client/src/lib/scheduled-task-meta-type.ts b/packages/client/src/lib/scheduled-task-meta-type.ts
index ca3419cb9..a623c3079 100644
--- a/packages/client/src/lib/scheduled-task-meta-type.ts
+++ b/packages/client/src/lib/scheduled-task-meta-type.ts
@@ -25,6 +25,7 @@ import {
REMINDERS_RELATION_FIELD_NAME,
SCHEDULED_TASK_FIELD_IDS,
SCHEDULED_TASK_SYSTEM_KIND,
+ TODO_LIST_SYSTEM_KIND,
SpaceAttributes,
StringArrayFieldValue,
} from '@colanode/core';
@@ -156,7 +157,10 @@ export const ensureRemindersRelationFields = async (
for (const database of databases) {
const attributes = JSON.parse(database.attributes) as DatabaseAttributes;
- if (attributes.systemKind === SCHEDULED_TASK_SYSTEM_KIND) {
+ if (
+ attributes.systemKind === SCHEDULED_TASK_SYSTEM_KIND ||
+ attributes.systemKind === TODO_LIST_SYSTEM_KIND
+ ) {
continue;
}
diff --git a/packages/client/src/lib/todo-list-system.ts b/packages/client/src/lib/todo-list-system.ts
index 49df3ad00..ce07c56cc 100644
--- a/packages/client/src/lib/todo-list-system.ts
+++ b/packages/client/src/lib/todo-list-system.ts
@@ -11,6 +11,7 @@ import {
DatabaseAttributes,
DatabaseViewAttributes,
FieldAttributes,
+ REMINDERS_RELATION_FIELD_ID,
TODO_LIST_FIELD_IDS,
TODO_LIST_SYSTEM_KIND,
createDebugger,
@@ -83,11 +84,45 @@ const normalizeTodoListFields = (
return next;
};
+const dedupeTodoListFields = (
+ fields: Record
+): Record => {
+ const required = buildTodoListFields();
+ const canonicalIds = new Set(Object.keys(required));
+ const canonicalNames = new Map(
+ Object.values(required).map((field) => [field.name, field.id])
+ );
+ const next: Record = {};
+
+ for (const [fieldId, field] of Object.entries(fields)) {
+ if (fieldId === REMINDERS_RELATION_FIELD_ID) {
+ continue;
+ }
+
+ const canonicalId = canonicalNames.get(field.name);
+ if (canonicalId && fieldId !== canonicalId) {
+ continue;
+ }
+
+ if (field.name === 'Due Date' && canonicalIds.has(TODO_LIST_FIELD_IDS.targetDate)) {
+ continue;
+ }
+
+ if (field.name === 'Assignee' && canonicalIds.has(TODO_LIST_FIELD_IDS.assignee)) {
+ continue;
+ }
+
+ next[fieldId] = field;
+ }
+
+ return next;
+};
+
const mergeTodoListFields = (
existing: Record
): Record => {
const required = buildTodoListFields();
- const normalized = normalizeTodoListFields(existing);
+ const normalized = normalizeTodoListFields(dedupeTodoListFields(existing));
const next = { ...normalized };
for (const [fieldId, field] of Object.entries(required)) {
diff --git a/packages/client/src/lib/todo-list.ts b/packages/client/src/lib/todo-list.ts
index 1186140a3..e85538f60 100644
--- a/packages/client/src/lib/todo-list.ts
+++ b/packages/client/src/lib/todo-list.ts
@@ -174,6 +174,18 @@ export const buildTodoListFields = (): Record => {
index: 'h',
options: {},
},
+ [TODO_LIST_FIELD_IDS.createdAt]: {
+ id: TODO_LIST_FIELD_IDS.createdAt,
+ type: 'created_at',
+ name: 'Created',
+ index: 'i',
+ },
+ [TODO_LIST_FIELD_IDS.updatedAt]: {
+ id: TODO_LIST_FIELD_IDS.updatedAt,
+ type: 'updated_at',
+ name: 'Updated',
+ index: 'j',
+ },
};
};
@@ -189,6 +201,8 @@ export const buildTodoListViewFields = (): Record<
const sizeIndex = generateFractionalIndex(assigneeIndex, null);
const estimateIndex = generateFractionalIndex(sizeIndex, null);
const labelsIndex = generateFractionalIndex(estimateIndex, null);
+ const createdAtIndex = generateFractionalIndex(labelsIndex, null);
+ const updatedAtIndex = generateFractionalIndex(createdAtIndex, null);
return {
[TODO_LIST_FIELD_IDS.status]: {
@@ -231,6 +245,16 @@ export const buildTodoListViewFields = (): Record<
display: true,
index: labelsIndex,
},
+ [TODO_LIST_FIELD_IDS.createdAt]: {
+ id: TODO_LIST_FIELD_IDS.createdAt,
+ display: true,
+ index: createdAtIndex,
+ },
+ [TODO_LIST_FIELD_IDS.updatedAt]: {
+ id: TODO_LIST_FIELD_IDS.updatedAt,
+ display: true,
+ index: updatedAtIndex,
+ },
};
};
diff --git a/packages/client/test/todo-list-system.test.ts b/packages/client/test/todo-list-system.test.ts
index b37643702..35b147800 100644
--- a/packages/client/test/todo-list-system.test.ts
+++ b/packages/client/test/todo-list-system.test.ts
@@ -96,6 +96,8 @@ describe('todo list system database', () => {
'Size',
'Estimate',
'Labels',
+ 'Created',
+ 'Updated',
])
);
diff --git a/packages/core/src/lib/todo-list.ts b/packages/core/src/lib/todo-list.ts
index 8c160b534..3448c19d2 100644
--- a/packages/core/src/lib/todo-list.ts
+++ b/packages/core/src/lib/todo-list.ts
@@ -9,8 +9,31 @@ export const TODO_LIST_FIELD_IDS = {
size: '__colanode_todo_size',
estimate: '__colanode_todo_estimate',
labels: '__colanode_todo_labels',
+ createdAt: '__colanode_todo_created_at',
+ updatedAt: '__colanode_todo_updated_at',
} as const;
+export const todoListSelectedRecordKey = (databaseId: string): string =>
+ `${databaseId}.selectedRecordId`;
+
+export const todoListViewModeKey = (databaseId: string): string =>
+ `${databaseId}.viewMode`;
+
+export type TodoListViewMode = 'tasks' | 'database';
+
+export const TODO_LIST_DISPLAY_FIELD_ORDER = [
+ TODO_LIST_FIELD_IDS.status,
+ TODO_LIST_FIELD_IDS.priority,
+ TODO_LIST_FIELD_IDS.startDate,
+ TODO_LIST_FIELD_IDS.targetDate,
+ TODO_LIST_FIELD_IDS.assignee,
+ TODO_LIST_FIELD_IDS.size,
+ TODO_LIST_FIELD_IDS.estimate,
+ TODO_LIST_FIELD_IDS.labels,
+ TODO_LIST_FIELD_IDS.createdAt,
+ TODO_LIST_FIELD_IDS.updatedAt,
+] as const;
+
export const TODO_LIST_DEFAULT_VIEW_NAME = 'List';
export const isTodoListSystemFieldId = (fieldId: string): boolean => {
diff --git a/packages/ui/src/components/layouts/sidebars/sidebar-desktop.tsx b/packages/ui/src/components/layouts/sidebars/sidebar-desktop.tsx
index da8f5f4f9..ce7fb6962 100644
--- a/packages/ui/src/components/layouts/sidebars/sidebar-desktop.tsx
+++ b/packages/ui/src/components/layouts/sidebars/sidebar-desktop.tsx
@@ -1,5 +1,6 @@
import { Resizable } from 're-resizable';
import { useCallback, useState } from 'react';
+import { useLocation } from '@tanstack/react-router';
import { Sidebar } from '@colanode/ui/components/layouts/sidebars/sidebar';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
@@ -10,6 +11,8 @@ const COLLAPSED_WIDTH = 65;
export const SidebarDesktop = () => {
const workspace = useWorkspace();
+ const location = useLocation();
+ const isTodoListFullView = location.pathname.includes('/settings/todo-list');
const [width, setWidth] = useMetadata(
workspace.userId,
'sidebar.width'
@@ -23,21 +26,22 @@ export const SidebarDesktop = () => {
[setWidth]
);
- const effectiveWidth = collapsed ? COLLAPSED_WIDTH : (width ?? DEFAULT_WIDTH);
+ const iconOnly = collapsed || isTodoListFullView;
+ const effectiveWidth = iconOnly ? COLLAPSED_WIDTH : (width ?? DEFAULT_WIDTH);
return (
{
},
}}
onResize={(_, __, ref) => {
- if (!collapsed) {
+ if (!iconOnly) {
handleResize(ref.offsetWidth);
}
}}
>
setCollapsed((c) => !c)}
/>
diff --git a/packages/ui/src/components/layouts/sidebars/sidebar-menu.tsx b/packages/ui/src/components/layouts/sidebars/sidebar-menu.tsx
index a4cb86b1b..8c09f80c7 100644
--- a/packages/ui/src/components/layouts/sidebars/sidebar-menu.tsx
+++ b/packages/ui/src/components/layouts/sidebars/sidebar-menu.tsx
@@ -1,6 +1,6 @@
import { isNull, useLiveQuery } from '@tanstack/react-db';
import { Bell, LayoutGrid, ListTodo, MessageCircle, Settings } from 'lucide-react';
-import { useNavigate } from '@tanstack/react-router';
+import { useLocation, useNavigate } from '@tanstack/react-router';
import { SidebarMenuType } from '@colanode/client/types';
import { SidebarMenuFooter } from '@colanode/ui/components/layouts/sidebars/sidebar-menu-footer';
@@ -18,6 +18,27 @@ export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => {
const workspace = useWorkspace();
const radar = useRadar();
const navigate = useNavigate();
+ const location = useLocation();
+ const isTodoListFullView = location.pathname.includes('/settings/todo-list');
+
+ const handleMenuClick = (menu: SidebarMenuType) => {
+ onChange(menu);
+
+ if (menu === 'todo-list') {
+ navigate({
+ to: '/workspace/$userId/settings/todo-list',
+ params: { userId: workspace.userId },
+ });
+ return;
+ }
+
+ if (isTodoListFullView) {
+ navigate({
+ to: '/workspace/$userId/home',
+ params: { userId: workspace.userId },
+ });
+ }
+ };
const chatsState = radar.getChatsState(workspace.userId);
const channelsState = radar.getChannelsState(workspace.userId);
@@ -41,7 +62,7 @@ export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => {
{
- onChange('chats');
+ handleMenuClick('chats');
}}
isActive={value === 'chats'}
unreadBadge={{
@@ -53,7 +74,7 @@ export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => {
{
- onChange('spaces');
+ handleMenuClick('spaces');
}}
isActive={value === 'spaces'}
unreadBadge={{
@@ -65,7 +86,7 @@ export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => {
{
- onChange('notifications');
+ handleMenuClick('notifications');
}}
isActive={value === 'notifications'}
unreadBadge={{
@@ -77,11 +98,7 @@ export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => {
{
- onChange('todo-list');
- navigate({
- to: '/workspace/$userId/settings/todo-list',
- params: { userId: workspace.userId },
- });
+ handleMenuClick('todo-list');
}}
isActive={value === 'todo-list'}
/>
@@ -89,7 +106,7 @@ export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => {
{
- onChange('settings');
+ handleMenuClick('settings');
}}
className="mt-auto"
isActive={value === 'settings'}
diff --git a/packages/ui/src/components/layouts/sidebars/sidebar-todo-list.tsx b/packages/ui/src/components/layouts/sidebars/sidebar-todo-list.tsx
index c6dbaef67..b5c3a23a4 100644
--- a/packages/ui/src/components/layouts/sidebars/sidebar-todo-list.tsx
+++ b/packages/ui/src/components/layouts/sidebars/sidebar-todo-list.tsx
@@ -1,22 +1,28 @@
-import { eq, useLiveQuery } from '@tanstack/react-db';
-import { ListTodo, SquareArrowOutUpRight } from 'lucide-react';
+import { ListTodo } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
+import { useNavigate } from '@tanstack/react-router';
-import { LocalDatabaseNode, LocalRecordNode } from '@colanode/client/types';
+import { LocalDatabaseNode } from '@colanode/client/types';
+import { todoListSelectedRecordKey } from '@colanode/core';
import { SidebarHeader } from '@colanode/ui/components/layouts/sidebars/sidebar-header';
import { Input } from '@colanode/ui/components/ui/input';
-import { Link } from '@colanode/ui/components/ui/link';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
+import { useMetadata } from '@colanode/ui/hooks/use-metadata';
import { ensureTodoListDatabase } from '@colanode/ui/lib/todo-list';
import { insertDatabaseRecord } from '@colanode/ui/lib/record-create';
-import { defaultEmojis } from '@colanode/ui/lib/assets';
export const SidebarTodoList = () => {
const workspace = useWorkspace();
+ const navigate = useNavigate();
const [database, setDatabase] = useState(null);
const [value, setValue] = useState('');
const inputRef = useRef(null);
+ const [, setSelectedRecordId] = useMetadata(
+ workspace.userId,
+ database ? todoListSelectedRecordKey(database.id) : '__todo_list_pending__'
+ );
+
useEffect(() => {
let cancelled = false;
@@ -31,23 +37,16 @@ export const SidebarTodoList = () => {
};
}, [workspace]);
- const recordsQuery = useLiveQuery(
- (q) => {
- if (!database) {
- return q.from({ nodes: workspace.collections.nodes }).where(() => false);
- }
-
- return q
- .from({ nodes: workspace.collections.nodes })
- .where(({ nodes }) => eq(nodes.type, 'record'))
- .where(({ nodes }) => eq(nodes.parentId, database.id))
- .orderBy(({ nodes }) => nodes.createdAt, 'desc')
- .limit(20);
- },
- [workspace.userId, database?.id]
- );
+ const openFullView = (recordId?: string) => {
+ if (recordId) {
+ setSelectedRecordId(recordId);
+ }
- const records = (recordsQuery.data ?? []) as LocalRecordNode[];
+ navigate({
+ to: '/workspace/$userId/settings/todo-list',
+ params: { userId: workspace.userId },
+ });
+ };
const handleSubmit = () => {
const name = value.trim();
@@ -55,7 +54,7 @@ export const SidebarTodoList = () => {
return;
}
- insertDatabaseRecord(
+ const record = insertDatabaseRecord(
workspace,
{
id: database.id,
@@ -66,25 +65,14 @@ export const SidebarTodoList = () => {
);
setValue('');
inputRef.current?.focus();
+ openFullView(record.id);
};
return (
-
-
-
- }
- />
+
-
+
{
/>
-
- {database && records.length === 0 && (
-
- No tasks yet. Type above and press Enter.
-
- )}
+
+ Tasks open in the main panel. Press Enter to add and jump there.
+
-
- {records.map((record) => {
- const name = record.name?.trim() || 'Unnamed';
- return (
-
- {defaultEmojis.check}
- {name}
-
- );
- })}
-
-
+
);
};
diff --git a/packages/ui/src/components/layouts/sidebars/sidebar.tsx b/packages/ui/src/components/layouts/sidebars/sidebar.tsx
index ac97bd378..340f08d0e 100644
--- a/packages/ui/src/components/layouts/sidebars/sidebar.tsx
+++ b/packages/ui/src/components/layouts/sidebars/sidebar.tsx
@@ -21,6 +21,8 @@ export const Sidebar = ({ collapsed = false, onToggleCollapse }: SidebarProps) =
const location = useLocation();
const [menu, setMenu] = useState('spaces');
+ const isTodoListFullView = location.pathname.includes('/settings/todo-list');
+
useEffect(() => {
if (location.pathname.includes('/settings/todo-list')) {
setMenu('todo-list');
@@ -28,6 +30,10 @@ export const Sidebar = ({ collapsed = false, onToggleCollapse }: SidebarProps) =
}, [location.pathname]);
const handleMenuChange = (next: SidebarMenuType) => {
+ if (isTodoListFullView && next !== 'todo-list') {
+ setMenu(next);
+ return;
+ }
if (collapsed) {
onToggleCollapse?.();
setMenu(next);
@@ -48,7 +54,7 @@ export const Sidebar = ({ collapsed = false, onToggleCollapse }: SidebarProps) =
)}
>
- {!collapsed && (
+ {!collapsed && !(menu === 'todo-list' && isTodoListFullView) && (
{menu === 'spaces' &&
}
{menu === 'chats' &&
}
diff --git a/packages/ui/src/components/workspaces/todo-list/todo-list-task-detail.tsx b/packages/ui/src/components/workspaces/todo-list/todo-list-task-detail.tsx
new file mode 100644
index 000000000..8d18e666a
--- /dev/null
+++ b/packages/ui/src/components/workspaces/todo-list/todo-list-task-detail.tsx
@@ -0,0 +1,126 @@
+import { eq, useLiveQuery } from '@tanstack/react-db';
+import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react';
+import { useState } from 'react';
+
+import { LocalDatabaseNode } from '@colanode/client/types';
+import { hasNodeRole, NodeRole } from '@colanode/core';
+import { Document } from '@colanode/ui/components/documents/document';
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@colanode/ui/components/ui/alert-dialog';
+import { TodoListTaskProperties } from '@colanode/ui/components/workspaces/todo-list/todo-list-task-properties';
+import { RecordDatabase } from '@colanode/ui/components/records/record-database';
+import { RecordProvider } from '@colanode/ui/components/records/record-provider';
+import { Button } from '@colanode/ui/components/ui/button';
+import { Separator } from '@colanode/ui/components/ui/separator';
+import { useWorkspace } from '@colanode/ui/contexts/workspace';
+
+interface TodoListTaskDetailProps {
+ database: LocalDatabaseNode;
+ recordId: string;
+ role: NodeRole;
+ onDeleted: () => void;
+}
+
+export const TodoListTaskDetail = ({
+ database,
+ recordId,
+ role,
+ onDeleted,
+}: TodoListTaskDetailProps) => {
+ const workspace = useWorkspace();
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const [notesOpen, setNotesOpen] = useState(false);
+
+ const recordQuery = useLiveQuery(
+ (q) =>
+ q
+ .from({ nodes: workspace.collections.nodes })
+ .where(({ nodes }) => eq(nodes.id, recordId))
+ .findOne(),
+ [workspace.userId, recordId]
+ );
+
+ const record = recordQuery.data;
+ if (!record || record.type !== 'record') {
+ return (
+
Task not found.
+ );
+ }
+
+ const canEdit =
+ record.createdBy === workspace.userId || hasNodeRole(role, 'editor');
+
+ return (
+
+
+
+
+ {canEdit && (
+
+ )}
+
+
+
+
+
+
+
+ {notesOpen && (
+
+ )}
+
+
+
+
+
+ Delete task?
+
+ "{record.name?.trim() || 'Unnamed'}" will be permanently deleted.
+
+
+
+ Cancel
+
+
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/workspaces/todo-list/todo-list-task-properties.tsx b/packages/ui/src/components/workspaces/todo-list/todo-list-task-properties.tsx
new file mode 100644
index 000000000..ec6ca0b3b
--- /dev/null
+++ b/packages/ui/src/components/workspaces/todo-list/todo-list-task-properties.tsx
@@ -0,0 +1,52 @@
+import {
+ FieldAttributes,
+ TODO_LIST_DISPLAY_FIELD_ORDER,
+ isTodoListSystemFieldId,
+} from '@colanode/core';
+import { EntityFieldLabel } from '@colanode/ui/components/records/entity-field-label';
+import { RecordFieldValue } from '@colanode/ui/components/records/record-field-value';
+import { RecordName } from '@colanode/ui/components/records/record-name';
+import { useDatabase } from '@colanode/ui/contexts/database';
+
+const isMetadataField = (field: FieldAttributes): boolean => {
+ return field.type === 'created_at' || field.type === 'updated_at';
+};
+
+export const TodoListTaskProperties = () => {
+ const database = useDatabase();
+
+ const orderedFields = TODO_LIST_DISPLAY_FIELD_ORDER.map((fieldId) => {
+ return database.fields.find((field) => field.id === fieldId);
+ }).filter((field): field is FieldAttributes => Boolean(field));
+
+ const customFields = database.fields
+ .filter(
+ (field) =>
+ !isTodoListSystemFieldId(field.id) &&
+ field.type !== 'button' &&
+ !isMetadataField(field)
+ )
+ .toSorted((a, b) => a.index.localeCompare(b.index));
+
+ const taskFields = [...orderedFields, ...customFields];
+
+ return (
+
+
+
+
+ {taskFields.map((field) => (
+
+ ))}
+
+
+ );
+};
diff --git a/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-container.tsx b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-container.tsx
index 473440058..8e63eb231 100644
--- a/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-container.tsx
+++ b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-container.tsx
@@ -1,13 +1,13 @@
-import { useEffect, useState } from 'react';
-
import { LocalDatabaseNode } from '@colanode/client/types';
-import { NodeRole } from '@colanode/core';
+import { NodeRole, TodoListViewMode, todoListViewModeKey } from '@colanode/core';
import { Database } from '@colanode/ui/components/databases/database';
import { DatabaseViews } from '@colanode/ui/components/databases/database-views';
import { Container } from '@colanode/ui/components/layouts/containers/container';
import { WorkspaceTodoListBreadcrumb } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-breadcrumb';
+import { WorkspaceTodoListWorkspace } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-workspace';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
-import { ensureTodoListDatabase } from '@colanode/ui/lib/todo-list';
+import { useMetadata } from '@colanode/ui/hooks/use-metadata';
+import { cn } from '@colanode/ui/lib/utils';
const workspaceRoleToNodeRole = (role: string): NodeRole => {
if (role === 'owner' || role === 'admin') {
@@ -21,34 +21,77 @@ const workspaceRoleToNodeRole = (role: string): NodeRole => {
return 'collaborator';
};
-export const WorkspaceTodoListContainer = () => {
- const workspace = useWorkspace();
- const [database, setDatabase] = useState
(null);
+interface WorkspaceTodoListModeTabsProps {
+ databaseId: string;
+ mode: TodoListViewMode;
+ onChange: (mode: TodoListViewMode) => void;
+}
- useEffect(() => {
- let cancelled = false;
+const WorkspaceTodoListModeTabs = ({
+ mode,
+ onChange,
+}: WorkspaceTodoListModeTabsProps) => {
+ const tabs: { id: TodoListViewMode; label: string }[] = [
+ { id: 'tasks', label: 'Tasks' },
+ { id: 'database', label: 'Database' },
+ ];
- void ensureTodoListDatabase(workspace).then((nextDatabase) => {
- if (!cancelled) {
- setDatabase(nextDatabase);
- }
- });
+ return (
+
+ {tabs.map((tab) => (
+
+ ))}
+
+ );
+};
- return () => {
- cancelled = true;
- };
- }, [workspace]);
+interface WorkspaceTodoListContainerProps {
+ database: LocalDatabaseNode;
+}
- if (!database) {
- return null;
- }
+export const WorkspaceTodoListContainer = ({
+ database,
+}: WorkspaceTodoListContainerProps) => {
+ const workspace = useWorkspace();
+ const [viewMode, setViewMode] = useMetadata(
+ workspace.userId,
+ todoListViewModeKey(database.id)
+ );
+ const mode: TodoListViewMode = viewMode ?? 'tasks';
+ const role = workspaceRoleToNodeRole(workspace.role);
return (
- }>
+ }
+ actions={
+
+ }
+ >
-
-
-
+ {mode === 'tasks' ? (
+
+ ) : (
+
+
+
+ )}
);
diff --git a/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-page.tsx b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-page.tsx
new file mode 100644
index 000000000..57096b1a8
--- /dev/null
+++ b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-page.tsx
@@ -0,0 +1,31 @@
+import { useEffect, useState } from 'react';
+
+import { LocalDatabaseNode } from '@colanode/client/types';
+import { WorkspaceTodoListContainer } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-container';
+import { useWorkspace } from '@colanode/ui/contexts/workspace';
+import { ensureTodoListDatabase } from '@colanode/ui/lib/todo-list';
+
+export const WorkspaceTodoListPage = () => {
+ const workspace = useWorkspace();
+ const [database, setDatabase] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ void ensureTodoListDatabase(workspace).then((nextDatabase) => {
+ if (!cancelled) {
+ setDatabase(nextDatabase);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [workspace]);
+
+ if (!database) {
+ return null;
+ }
+
+ return ;
+};
diff --git a/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-workspace.tsx b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-workspace.tsx
new file mode 100644
index 000000000..07da8b118
--- /dev/null
+++ b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-workspace.tsx
@@ -0,0 +1,244 @@
+import { eq, useLiveQuery } from '@tanstack/react-db';
+import { ListTodo, Trash2 } from 'lucide-react';
+import { useMemo, useState } from 'react';
+
+import { LocalDatabaseNode, LocalRecordNode } from '@colanode/client/types';
+import {
+ NodeRole,
+ SelectFieldAttributes,
+ TODO_LIST_FIELD_IDS,
+ hasNodeRole,
+ todoListSelectedRecordKey,
+} from '@colanode/core';
+import { TodoListTaskDetail } from '@colanode/ui/components/workspaces/todo-list/todo-list-task-detail';
+import { Button } from '@colanode/ui/components/ui/button';
+import { Input } from '@colanode/ui/components/ui/input';
+import { NodeDeleteDialog } from '@colanode/ui/components/nodes/node-delete-dialog';
+import { useWorkspace } from '@colanode/ui/contexts/workspace';
+import { useMetadata } from '@colanode/ui/hooks/use-metadata';
+import { insertDatabaseRecord } from '@colanode/ui/lib/record-create';
+import { cn } from '@colanode/ui/lib/utils';
+
+const workspaceRoleToNodeRole = (role: string): NodeRole => {
+ if (role === 'owner' || role === 'admin') {
+ return 'admin';
+ }
+
+ if (role === 'guest' || role === 'none') {
+ return 'viewer';
+ }
+
+ return 'collaborator';
+};
+
+const getSelectLabel = (
+ database: LocalDatabaseNode,
+ record: LocalRecordNode,
+ fieldId: string
+): string | null => {
+ const field = database.fields[fieldId];
+ const rawValue = record.fields[fieldId] as { type?: string; value?: string } | undefined;
+ if (field?.type !== 'select' || rawValue?.type !== 'select' || !rawValue.value) {
+ return null;
+ }
+
+ const selectField = field as SelectFieldAttributes;
+ return selectField.options?.[rawValue.value]?.name ?? null;
+};
+
+interface TodoListTaskListProps {
+ database: LocalDatabaseNode;
+ role: NodeRole;
+ selectedRecordId: string | null;
+ onSelectRecord: (recordId: string) => void;
+}
+
+export const TodoListTaskList = ({
+ database,
+ role,
+ selectedRecordId,
+ onSelectRecord,
+}: TodoListTaskListProps) => {
+ const workspace = useWorkspace();
+ const [quickAdd, setQuickAdd] = useState('');
+ const [deleteTargetId, setDeleteTargetId] = useState(null);
+
+ const recordsQuery = useLiveQuery(
+ (q) => {
+ return q
+ .from({ nodes: workspace.collections.nodes })
+ .where(({ nodes }) => eq(nodes.type, 'record'))
+ .where(({ nodes }) => eq(nodes.parentId, database.id))
+ .orderBy(({ nodes }) => nodes.createdAt, 'desc');
+ },
+ [workspace.userId, database.id]
+ );
+
+ const records = useMemo(
+ () => (recordsQuery.data ?? []) as LocalRecordNode[],
+ [recordsQuery.data]
+ );
+
+ const canCreate = hasNodeRole(role, 'editor');
+
+ const handleQuickAdd = () => {
+ const name = quickAdd.trim();
+ if (!name || !canCreate) {
+ return;
+ }
+
+ const record = insertDatabaseRecord(
+ workspace,
+ {
+ id: database.id,
+ rootId: database.rootId,
+ fields: Object.values(database.fields),
+ },
+ { name }
+ );
+ onSelectRecord(record.id);
+ setQuickAdd('');
+ };
+
+ const deleteTarget = records.find((record) => record.id === deleteTargetId);
+
+ return (
+
+
+
Tasks
+
+ {canCreate && (
+
+
+ setQuickAdd(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ handleQuickAdd();
+ }
+ }}
+ />
+
+ )}
+
+
+ {records.length === 0 ? (
+
+ No tasks yet.
+
+ ) : (
+
+ {records.map((record) => {
+ const name = record.name?.trim() || 'Unnamed';
+ const status = getSelectLabel(
+ database,
+ record,
+ TODO_LIST_FIELD_IDS.status
+ );
+ const isSelected = record.id === selectedRecordId;
+
+ return (
+
+
+ {canCreate && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ {deleteTarget && (
+
{
+ if (!open) {
+ setDeleteTargetId(null);
+ if (selectedRecordId === deleteTarget.id) {
+ onSelectRecord('');
+ }
+ }
+ }}
+ />
+ )}
+
+ );
+};
+
+interface WorkspaceTodoListWorkspaceProps {
+ database: LocalDatabaseNode;
+}
+
+export const WorkspaceTodoListWorkspace = ({
+ database,
+}: WorkspaceTodoListWorkspaceProps) => {
+ const workspace = useWorkspace();
+ const [selectedRecordId, setSelectedRecordId] = useMetadata(
+ workspace.userId,
+ todoListSelectedRecordKey(database.id)
+ );
+ const nodeRole = workspaceRoleToNodeRole(workspace.role);
+
+ return (
+
+
+ setSelectedRecordId(recordId ? recordId : undefined)
+ }
+ />
+
+ {selectedRecordId ? (
+
+ setSelectedRecordId(undefined)}
+ />
+
+ ) : (
+
+ Select a task or add one to get started.
+
+ )}
+
+
+ );
+};
diff --git a/packages/ui/src/routes/workspace/todo-list-settings.tsx b/packages/ui/src/routes/workspace/todo-list-settings.tsx
index 77efb973e..fc385e6f1 100644
--- a/packages/ui/src/routes/workspace/todo-list-settings.tsx
+++ b/packages/ui/src/routes/workspace/todo-list-settings.tsx
@@ -1,7 +1,7 @@
import { createRoute, redirect } from '@tanstack/react-router';
import { NodeModal } from '@colanode/ui/components/nodes/node-modal';
-import { WorkspaceTodoListContainer } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-container';
+import { WorkspaceTodoListPage } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-page';
import { WorkspaceTodoListTab } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-tab';
import { getWorkspaceUserId } from '@colanode/ui/routes/utils';
import {
@@ -12,7 +12,7 @@ import {
export const workspaceTodoListRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: '/settings/todo-list',
- component: WorkspaceTodoListContainer,
+ component: WorkspaceTodoListPage,
context: () => {
return {
tab: ,
From 46dbe1fb0be3ed1cd192969e9f65c6ba284fbcb5 Mon Sep 17 00:00:00 2001
From: barry
Date: Thu, 25 Jun 2026 11:53:06 +0800
Subject: [PATCH 09/15] feat(todo-list): enhance todo list system with new
fields, view management, and sidebar improvements
Introduce new functionalities to the todo list system, including the addition of 'Tags' and 'Assignees' fields, and improvements to view management with default settings for list views. Refactor sidebar components for better navigation to the todo list settings. Update tests to ensure comprehensive coverage of the new features and maintain functionality.
---
packages/client/src/lib/todo-list-system.ts | 252 ++++++++++++-
packages/client/src/lib/todo-list.ts | 335 +++++++++++++-----
packages/client/test/todo-list-system.test.ts | 77 +++-
packages/core/src/lib/todo-list.ts | 10 +-
.../list/list-view-collection-row.tsx | 70 +++-
.../databases/list/list-view-record-row.tsx | 34 +-
packages/ui/src/components/databases/view.tsx | 22 +-
.../layouts/sidebars/sidebar-todo-list.tsx | 136 +++++--
.../todo-list/todo-list-task-detail.tsx | 126 -------
.../todo-list/todo-list-task-properties.tsx | 52 ---
.../workspace-todo-list-container.tsx | 72 +---
.../workspace-todo-list-workspace.tsx | 244 -------------
packages/ui/src/lib/todo-list.ts | 88 ++++-
13 files changed, 844 insertions(+), 674 deletions(-)
delete mode 100644 packages/ui/src/components/workspaces/todo-list/todo-list-task-detail.tsx
delete mode 100644 packages/ui/src/components/workspaces/todo-list/todo-list-task-properties.tsx
delete mode 100644 packages/ui/src/components/workspaces/todo-list/workspace-todo-list-workspace.tsx
diff --git a/packages/client/src/lib/todo-list-system.ts b/packages/client/src/lib/todo-list-system.ts
index ce07c56cc..55575b5bd 100644
--- a/packages/client/src/lib/todo-list-system.ts
+++ b/packages/client/src/lib/todo-list-system.ts
@@ -1,8 +1,12 @@
import {
+ applyTodoListListViewFields,
buildMissingTodoListViews,
buildTodoListBundle,
buildTodoListFields,
+ buildTodoListListViewDefaults,
buildTodoListViewFields,
+ buildDefaultTodoTagOptions,
+ getTodoListViewKey,
resolveTodoListStatusFieldId,
} from '@colanode/client/lib/todo-list';
import { findOrCreateMetaTypesSpace } from '@colanode/client/lib/page-meta-types';
@@ -10,7 +14,10 @@ import { WorkspaceService } from '@colanode/client/services/workspaces/workspace
import {
DatabaseAttributes,
DatabaseViewAttributes,
+ DatabaseViewFieldAttributes,
FieldAttributes,
+ MultiSelectFieldAttributes,
+ RecordAttributes,
REMINDERS_RELATION_FIELD_ID,
TODO_LIST_FIELD_IDS,
TODO_LIST_SYSTEM_KIND,
@@ -81,9 +88,81 @@ const normalizeTodoListFields = (
};
}
+ const tags = next[TODO_LIST_FIELD_IDS.labels];
+ if (tags && tags.name === 'Labels') {
+ next[TODO_LIST_FIELD_IDS.labels] = {
+ ...tags,
+ name: 'Tags',
+ };
+ }
+
+ const assigneeField = next[TODO_LIST_FIELD_IDS.assignee];
+ if (assigneeField?.type === 'text') {
+ next[TODO_LIST_FIELD_IDS.assignee] = {
+ id: TODO_LIST_FIELD_IDS.assignee,
+ type: 'collaborator',
+ name: 'Assignees',
+ index: assigneeField.index,
+ };
+ }
+
return next;
};
+const mergeTodoListTagOptions = (
+ field: MultiSelectFieldAttributes
+): MultiSelectFieldAttributes => {
+ const defaults = buildDefaultTodoTagOptions();
+
+ return {
+ ...field,
+ name: 'Tags',
+ options: {
+ ...defaults,
+ ...(field.options ?? {}),
+ },
+ };
+};
+
+const mergeTodoListViewFields = (
+ existing: Record,
+ layout: DatabaseViewAttributes['layout']
+): Record => {
+ if (layout === 'list') {
+ return applyTodoListListViewFields(existing);
+ }
+
+ const tableLayout =
+ layout === 'table' || layout === 'board' ? layout : 'table';
+ const required = buildTodoListViewFields({ layout: tableLayout });
+ const next = { ...existing };
+
+ for (const [fieldId, viewField] of Object.entries(required)) {
+ if (!next[fieldId]) {
+ next[fieldId] = viewField;
+ }
+ }
+
+ return next;
+};
+
+const applyTodoListListViewDefaults = (
+ attributes: DatabaseViewAttributes
+): Pick => {
+ if (attributes.layout !== 'list') {
+ return {};
+ }
+
+ const defaults = buildTodoListListViewDefaults();
+ const hasFilters = Object.keys(attributes.filters ?? {}).length > 0;
+ const hasSorts = Object.keys(attributes.sorts ?? {}).length > 0;
+
+ return {
+ ...(hasFilters ? {} : { filters: defaults.filters }),
+ ...(hasSorts ? {} : { sorts: defaults.sorts }),
+ };
+};
+
const dedupeTodoListFields = (
fields: Record
): Record => {
@@ -128,7 +207,10 @@ const mergeTodoListFields = (
for (const [fieldId, field] of Object.entries(required)) {
if (!next[fieldId]) {
const byName = Object.values(next).find(
- (item) => item.name === field.name && item.type === field.type
+ (item) =>
+ item.name === field.name &&
+ item.type === field.type &&
+ fieldId !== TODO_LIST_FIELD_IDS.labels
);
if (!byName) {
next[fieldId] = field;
@@ -136,9 +218,94 @@ const mergeTodoListFields = (
}
}
+ const tagsField = next[TODO_LIST_FIELD_IDS.labels];
+ if (tagsField?.type === 'multi_select') {
+ next[TODO_LIST_FIELD_IDS.labels] = mergeTodoListTagOptions(tagsField);
+ }
+
+ const estimateField = next[TODO_LIST_FIELD_IDS.estimate];
+ if (
+ estimateField?.type === 'number' &&
+ estimateField.default !== undefined
+ ) {
+ const { default: _default, ...rest } = estimateField;
+ next[TODO_LIST_FIELD_IDS.estimate] = rest;
+ }
+
return next;
};
+const migrateTodoListAssigneeRecordValues = async (
+ workspace: WorkspaceService,
+ databaseId: string
+): Promise => {
+ const users = await workspace.database
+ .selectFrom('users')
+ .select(['id', 'name', 'email'])
+ .execute();
+
+ const userIdByName = new Map(
+ users.map((user) => [user.name.trim().toLowerCase(), user.id])
+ );
+ const userIdByEmail = new Map(
+ users
+ .filter((user) => user.email)
+ .map((user) => [user.email!.trim().toLowerCase(), user.id])
+ );
+
+ const records = await workspace.database
+ .selectFrom('nodes')
+ .select(['id', 'attributes'])
+ .where('type', '=', 'record')
+ .where('parent_id', '=', databaseId)
+ .execute();
+
+ for (const row of records) {
+ const attributes = JSON.parse(row.attributes) as RecordAttributes;
+ const assigneeValue = attributes.fields?.[TODO_LIST_FIELD_IDS.assignee] as
+ | { type?: string; value?: string | string[] }
+ | undefined;
+
+ if (!assigneeValue || assigneeValue.type === 'string_array') {
+ continue;
+ }
+
+ if (assigneeValue.type !== 'text' || !assigneeValue.value) {
+ await workspace.nodes.updateNode(row.id, (draft) => {
+ if (draft.type !== 'record') {
+ return draft;
+ }
+
+ delete draft.fields[TODO_LIST_FIELD_IDS.assignee];
+ return draft;
+ });
+ continue;
+ }
+
+ const raw = String(assigneeValue.value).trim();
+ const matchedUserId =
+ userIdByName.get(raw.toLowerCase()) ??
+ userIdByEmail.get(raw.toLowerCase());
+
+ await workspace.nodes.updateNode(row.id, (draft) => {
+ if (draft.type !== 'record') {
+ return draft;
+ }
+
+ if (matchedUserId) {
+ draft.fields[TODO_LIST_FIELD_IDS.assignee] = {
+ type: 'string_array',
+ value: [matchedUserId],
+ };
+ } else {
+ delete draft.fields[TODO_LIST_FIELD_IDS.assignee];
+ }
+
+ return draft;
+ });
+ }
+};
+
const upgradeTodoListSystemDatabase = async (
workspace: WorkspaceService,
databaseId: string
@@ -174,20 +341,57 @@ const upgradeTodoListSystemDatabase = async (
debug(`Upgraded todo list system database ${databaseId}`);
}
+ await migrateTodoListAssigneeRecordValues(workspace, databaseId);
+
const views = await listTodoListViews(workspace, databaseId);
- const viewFields = buildTodoListViewFields();
const statusFieldId = resolveTodoListStatusFieldId(mergedFields);
for (const view of views) {
+ const viewKey = getTodoListViewKey({
+ layout: view.attributes.layout,
+ groupBy: view.attributes.groupBy,
+ name: view.attributes.name,
+ });
+ const mergedViewFields = mergeTodoListViewFields(
+ view.attributes.fields ?? {},
+ view.attributes.layout
+ );
+ const listDefaults = applyTodoListListViewDefaults(view.attributes);
+ const hasNewViewFields =
+ Object.keys(mergedViewFields).length >
+ Object.keys(view.attributes.fields ?? {}).length;
+ const needsGroupBy =
+ view.attributes.layout === 'board' && !view.attributes.groupBy;
+ const needsListDefaults =
+ view.attributes.layout === 'list' &&
+ (Object.keys(listDefaults.filters ?? {}).length > 0 ||
+ Object.keys(listDefaults.sorts ?? {}).length > 0);
+ const needsListDisplay =
+ view.attributes.layout === 'list' &&
+ Object.entries(view.attributes.fields ?? {}).some(([fieldId, field]) => {
+ return (
+ field.display === true &&
+ fieldId !== TODO_LIST_FIELD_IDS.status &&
+ fieldId !== TODO_LIST_FIELD_IDS.labels &&
+ fieldId !== TODO_LIST_FIELD_IDS.targetDate
+ );
+ });
const needsFields =
view.attributes.layout === 'table' ||
view.attributes.layout === 'list' ||
view.attributes.layout === 'board';
- const hasFields = Object.keys(view.attributes.fields ?? {}).length > 0;
- const needsGroupBy =
- view.attributes.layout === 'board' && !view.attributes.groupBy;
- if (!needsFields || (hasFields && !needsGroupBy)) {
+ if (!needsFields) {
+ continue;
+ }
+
+ if (
+ !hasNewViewFields &&
+ !needsGroupBy &&
+ !needsListDefaults &&
+ !needsListDisplay &&
+ Object.keys(view.attributes.fields ?? {}).length > 0
+ ) {
continue;
}
@@ -196,25 +400,44 @@ const upgradeTodoListSystemDatabase = async (
return draft;
}
- if (!draft.fields || Object.keys(draft.fields).length === 0) {
- draft.fields = viewFields;
+ draft.fields = mergeTodoListViewFields(draft.fields ?? {}, draft.layout);
+
+ if (draft.layout === 'list') {
+ const defaults = applyTodoListListViewDefaults(draft);
+ if (defaults.filters) {
+ draft.filters = defaults.filters;
+ }
+ if (defaults.sorts) {
+ draft.sorts = defaults.sorts;
+ }
}
if (draft.layout === 'board' && !draft.groupBy) {
- draft.groupBy = statusFieldId;
+ draft.groupBy =
+ viewKey === `board:${TODO_LIST_FIELD_IDS.labels}`
+ ? TODO_LIST_FIELD_IDS.labels
+ : statusFieldId;
}
return draft;
});
}
- const existingLayouts = new Set(views.map((view) => view.attributes.layout));
+ const existingViewKeys = new Set(
+ views.map((view) =>
+ getTodoListViewKey({
+ layout: view.attributes.layout,
+ groupBy: view.attributes.groupBy,
+ name: view.attributes.name,
+ })
+ )
+ );
const lastViewIndex = views.at(-1)?.index ?? null;
const missingViews = buildMissingTodoListViews({
databaseId,
rootId: row.root_id,
userId: workspace.userId,
- existingLayouts,
+ existingViewKeys,
lastViewIndex,
firstViewIndex: views[0]?.index ?? null,
});
@@ -253,7 +476,12 @@ const createTodoListSystemDatabase = async (
fields: bundle.database.fields,
});
- for (const view of [bundle.listView, bundle.tableView, bundle.boardView]) {
+ for (const view of [
+ bundle.listView,
+ bundle.tableView,
+ bundle.statusBoardView,
+ bundle.tagsBoardView,
+ ]) {
await workspace.nodes.insertNode(view.id, {
type: 'database_view',
name: view.name,
diff --git a/packages/client/src/lib/todo-list.ts b/packages/client/src/lib/todo-list.ts
index e85538f60..72f138e77 100644
--- a/packages/client/src/lib/todo-list.ts
+++ b/packages/client/src/lib/todo-list.ts
@@ -4,11 +4,14 @@ import {
} from '@colanode/client/types';
import {
DatabaseViewFieldAttributes,
+ DatabaseViewFilterAttributes,
+ DatabaseViewSortAttributes,
FieldAttributes,
generateFractionalIndex,
generateId,
IdType,
TODO_LIST_DEFAULT_VIEW_NAME,
+ TODO_LIST_DONE_STATUS_OPTION_ID,
TODO_LIST_FIELD_IDS,
TODO_LIST_META_TYPE_NAME,
TODO_LIST_SYSTEM_KIND,
@@ -18,7 +21,7 @@ const TODO_STATUS_OPTION_IDS = {
todo: '__colanode_todo_status_todo',
inProgress: '__colanode_todo_status_in_progress',
blocked: '__colanode_todo_status_blocked',
- done: '__colanode_todo_status_done',
+ done: TODO_LIST_DONE_STATUS_OPTION_ID,
} as const;
const TODO_PRIORITY_OPTION_IDS = {
@@ -36,6 +39,82 @@ const TODO_SIZE_OPTION_IDS = {
xl: '__colanode_todo_size_xl',
} as const;
+const TODO_TAG_OPTION_IDS = {
+ bug: '__colanode_todo_tag_bug',
+ feature: '__colanode_todo_tag_feature',
+ docs: '__colanode_todo_tag_docs',
+ design: '__colanode_todo_tag_design',
+ urgent: '__colanode_todo_tag_urgent',
+} as const;
+
+const TODO_LIST_LIST_VISIBLE_FIELD_IDS = new Set([
+ TODO_LIST_FIELD_IDS.status,
+ TODO_LIST_FIELD_IDS.labels,
+ TODO_LIST_FIELD_IDS.targetDate,
+]);
+
+type TodoListViewLayout = 'list' | 'table' | 'board';
+
+type TodoListViewSpec = {
+ key: string;
+ name: string;
+ layout: TodoListViewLayout;
+ groupBy?: string;
+};
+
+export const buildDefaultTodoTagOptions = () => ({
+ [TODO_TAG_OPTION_IDS.bug]: {
+ id: TODO_TAG_OPTION_IDS.bug,
+ name: 'Bug',
+ color: 'red',
+ index: 'a',
+ },
+ [TODO_TAG_OPTION_IDS.feature]: {
+ id: TODO_TAG_OPTION_IDS.feature,
+ name: 'Feature',
+ color: 'blue',
+ index: 'b',
+ },
+ [TODO_TAG_OPTION_IDS.docs]: {
+ id: TODO_TAG_OPTION_IDS.docs,
+ name: 'Docs',
+ color: 'gray',
+ index: 'c',
+ },
+ [TODO_TAG_OPTION_IDS.design]: {
+ id: TODO_TAG_OPTION_IDS.design,
+ name: 'Design',
+ color: 'purple',
+ index: 'd',
+ },
+ [TODO_TAG_OPTION_IDS.urgent]: {
+ id: TODO_TAG_OPTION_IDS.urgent,
+ name: 'Urgent',
+ color: 'orange',
+ index: 'e',
+ },
+});
+
+export const getTodoListViewKey = (input: {
+ layout: string;
+ groupBy?: string | null;
+ name?: string;
+}): string => {
+ if (input.layout === 'board' && input.groupBy) {
+ return `board:${input.groupBy}`;
+ }
+
+ if (input.layout === 'board' && input.name === 'By tags') {
+ return `board:${TODO_LIST_FIELD_IDS.labels}`;
+ }
+
+ if (input.layout === 'board' && input.name === 'By status') {
+ return `board:${TODO_LIST_FIELD_IDS.status}`;
+ }
+
+ return input.layout;
+};
+
export const buildTodoListFields = (): Record => {
return {
[TODO_LIST_FIELD_IDS.status]: {
@@ -118,7 +197,7 @@ export const buildTodoListFields = (): Record => {
},
[TODO_LIST_FIELD_IDS.assignee]: {
id: TODO_LIST_FIELD_IDS.assignee,
- type: 'text',
+ type: 'collaborator',
name: 'Assignees',
index: 'e',
},
@@ -165,14 +244,13 @@ export const buildTodoListFields = (): Record => {
type: 'number',
name: 'Estimate',
index: 'g',
- default: 0,
},
[TODO_LIST_FIELD_IDS.labels]: {
id: TODO_LIST_FIELD_IDS.labels,
type: 'multi_select',
- name: 'Labels',
+ name: 'Tags',
index: 'h',
- options: {},
+ options: buildDefaultTodoTagOptions(),
},
[TODO_LIST_FIELD_IDS.createdAt]: {
id: TODO_LIST_FIELD_IDS.createdAt,
@@ -189,10 +267,7 @@ export const buildTodoListFields = (): Record => {
};
};
-export const buildTodoListViewFields = (): Record<
- string,
- DatabaseViewFieldAttributes
-> => {
+const buildTodoListViewFieldEntries = () => {
const statusIndex = generateFractionalIndex(null, null);
const priorityIndex = generateFractionalIndex(statusIndex, null);
const startDateIndex = generateFractionalIndex(priorityIndex, null);
@@ -207,82 +282,176 @@ export const buildTodoListViewFields = (): Record<
return {
[TODO_LIST_FIELD_IDS.status]: {
id: TODO_LIST_FIELD_IDS.status,
- display: true,
index: statusIndex,
},
[TODO_LIST_FIELD_IDS.priority]: {
id: TODO_LIST_FIELD_IDS.priority,
- display: true,
index: priorityIndex,
},
[TODO_LIST_FIELD_IDS.startDate]: {
id: TODO_LIST_FIELD_IDS.startDate,
- display: true,
index: startDateIndex,
},
[TODO_LIST_FIELD_IDS.targetDate]: {
id: TODO_LIST_FIELD_IDS.targetDate,
- display: true,
index: targetDateIndex,
},
[TODO_LIST_FIELD_IDS.assignee]: {
id: TODO_LIST_FIELD_IDS.assignee,
- display: true,
index: assigneeIndex,
},
[TODO_LIST_FIELD_IDS.size]: {
id: TODO_LIST_FIELD_IDS.size,
- display: true,
index: sizeIndex,
},
[TODO_LIST_FIELD_IDS.estimate]: {
id: TODO_LIST_FIELD_IDS.estimate,
- display: true,
index: estimateIndex,
},
[TODO_LIST_FIELD_IDS.labels]: {
id: TODO_LIST_FIELD_IDS.labels,
- display: true,
index: labelsIndex,
},
[TODO_LIST_FIELD_IDS.createdAt]: {
id: TODO_LIST_FIELD_IDS.createdAt,
- display: true,
index: createdAtIndex,
},
[TODO_LIST_FIELD_IDS.updatedAt]: {
id: TODO_LIST_FIELD_IDS.updatedAt,
- display: true,
index: updatedAtIndex,
},
};
};
-const TODO_LIST_VIEW_SPECS = [
- { name: TODO_LIST_DEFAULT_VIEW_NAME, layout: 'list' as const },
- { name: 'Table', layout: 'table' as const },
+export const buildTodoListViewFields = (options?: {
+ layout?: TodoListViewLayout;
+}): Record => {
+ const entries = buildTodoListViewFieldEntries();
+ const isListLayout = options?.layout === 'list';
+
+ return Object.fromEntries(
+ Object.entries(entries).map(([fieldId, field]) => [
+ fieldId,
+ {
+ ...field,
+ display: isListLayout
+ ? TODO_LIST_LIST_VISIBLE_FIELD_IDS.has(fieldId)
+ : true,
+ },
+ ])
+ );
+};
+
+export const applyTodoListListViewFields = (
+ existing: Record
+): Record => {
+ const merged = buildTodoListViewFields({ layout: 'table' });
+
+ for (const [fieldId, viewField] of Object.entries(existing)) {
+ merged[fieldId] = {
+ ...merged[fieldId],
+ ...viewField,
+ display: TODO_LIST_LIST_VISIBLE_FIELD_IDS.has(fieldId),
+ };
+ }
+
+ for (const [fieldId, viewField] of Object.entries(
+ buildTodoListViewFields({ layout: 'list' })
+ )) {
+ if (!merged[fieldId]) {
+ merged[fieldId] = viewField;
+ }
+ }
+
+ return merged;
+};
+
+export const buildTodoListListViewDefaults = (): {
+ filters: Record;
+ sorts: Record;
+} => ({
+ filters: {
+ [TODO_LIST_FIELD_IDS.status]: {
+ id: TODO_LIST_FIELD_IDS.status,
+ fieldId: TODO_LIST_FIELD_IDS.status,
+ type: 'field',
+ operator: 'is_not_equal_to',
+ value: TODO_LIST_DONE_STATUS_OPTION_ID,
+ },
+ },
+ sorts: {
+ [TODO_LIST_FIELD_IDS.targetDate]: {
+ id: TODO_LIST_FIELD_IDS.targetDate,
+ fieldId: TODO_LIST_FIELD_IDS.targetDate,
+ direction: 'asc',
+ },
+ },
+});
+
+const TODO_LIST_VIEW_SPECS: TodoListViewSpec[] = [
+ { key: 'list', name: TODO_LIST_DEFAULT_VIEW_NAME, layout: 'list' },
+ { key: 'table', name: 'Table', layout: 'table' },
{
+ key: `board:${TODO_LIST_FIELD_IDS.status}`,
name: 'By status',
- layout: 'board' as const,
+ layout: 'board',
groupBy: TODO_LIST_FIELD_IDS.status,
},
-] as const;
+ {
+ key: `board:${TODO_LIST_FIELD_IDS.labels}`,
+ name: 'By tags',
+ layout: 'board',
+ groupBy: TODO_LIST_FIELD_IDS.labels,
+ },
+];
+
+const buildTodoListViewNode = (input: {
+ spec: TodoListViewSpec;
+ index: string;
+ databaseId: string;
+ rootId: string;
+ userId: string;
+ now: string;
+}): LocalDatabaseViewNode => {
+ const viewFields = buildTodoListViewFields({ layout: input.spec.layout });
+ const listDefaults =
+ input.spec.layout === 'list' ? buildTodoListListViewDefaults() : null;
+
+ return {
+ id: generateId(IdType.DatabaseView),
+ type: 'database_view',
+ name: input.spec.name,
+ index: input.index,
+ layout: input.spec.layout,
+ parentId: input.databaseId,
+ rootId: input.rootId,
+ ...(input.spec.groupBy ? { groupBy: input.spec.groupBy } : {}),
+ fields: viewFields,
+ ...(listDefaults?.filters ? { filters: listDefaults.filters } : {}),
+ ...(listDefaults?.sorts ? { sorts: listDefaults.sorts } : {}),
+ createdAt: input.now,
+ createdBy: input.userId,
+ updatedAt: null,
+ updatedBy: null,
+ localRevision: '0',
+ serverRevision: '0',
+ };
+};
export const buildMissingTodoListViews = (input: {
databaseId: string;
rootId: string;
userId: string;
- existingLayouts: Set;
+ existingViewKeys: Set;
lastViewIndex: string | null;
firstViewIndex?: string | null;
}): LocalDatabaseViewNode[] => {
const now = new Date().toISOString();
- const viewFields = buildTodoListViewFields();
let previousIndex = input.lastViewIndex;
const views: LocalDatabaseViewNode[] = [];
for (const spec of TODO_LIST_VIEW_SPECS) {
- if (input.existingLayouts.has(spec.layout)) {
+ if (input.existingViewKeys.has(spec.key)) {
continue;
}
@@ -292,23 +461,16 @@ export const buildMissingTodoListViews = (input: {
: generateFractionalIndex(previousIndex, null);
previousIndex = index;
- views.push({
- id: generateId(IdType.DatabaseView),
- type: 'database_view',
- name: spec.name,
- index,
- layout: spec.layout,
- parentId: input.databaseId,
- rootId: input.rootId,
- ...('groupBy' in spec ? { groupBy: spec.groupBy } : {}),
- fields: viewFields,
- createdAt: now,
- createdBy: input.userId,
- updatedAt: null,
- updatedBy: null,
- localRevision: '0',
- serverRevision: '0',
- });
+ views.push(
+ buildTodoListViewNode({
+ spec,
+ index,
+ databaseId: input.databaseId,
+ rootId: input.rootId,
+ userId: input.userId,
+ now,
+ })
+ );
}
return views;
@@ -322,14 +484,11 @@ export const buildTodoListBundle = (input: {
database: LocalDatabaseNode;
listView: LocalDatabaseViewNode;
tableView: LocalDatabaseViewNode;
- boardView: LocalDatabaseViewNode;
+ statusBoardView: LocalDatabaseViewNode;
+ tagsBoardView: LocalDatabaseViewNode;
} => {
const databaseId = generateId(IdType.Database);
- const listViewId = generateId(IdType.DatabaseView);
- const tableViewId = generateId(IdType.DatabaseView);
- const boardViewId = generateId(IdType.DatabaseView);
const now = new Date().toISOString();
- const viewFields = buildTodoListViewFields();
const database: LocalDatabaseNode = {
id: databaseId,
@@ -349,59 +508,49 @@ export const buildTodoListBundle = (input: {
serverRevision: '0',
};
- const listView: LocalDatabaseViewNode = {
- id: listViewId,
- type: 'database_view',
- name: TODO_LIST_DEFAULT_VIEW_NAME,
+ const listView = buildTodoListViewNode({
+ spec: TODO_LIST_VIEW_SPECS[0]!,
index: generateFractionalIndex(null, null),
- layout: 'list',
- parentId: databaseId,
+ databaseId,
rootId: input.rootId,
- fields: viewFields,
- createdAt: now,
- createdBy: input.userId,
- updatedAt: null,
- updatedBy: null,
- localRevision: '0',
- serverRevision: '0',
- };
+ userId: input.userId,
+ now,
+ });
- const tableView: LocalDatabaseViewNode = {
- id: tableViewId,
- type: 'database_view',
- name: 'Table',
+ const tableView = buildTodoListViewNode({
+ spec: TODO_LIST_VIEW_SPECS[1]!,
index: generateFractionalIndex(listView.index, null),
- layout: 'table',
- parentId: databaseId,
+ databaseId,
rootId: input.rootId,
- fields: viewFields,
- createdAt: now,
- createdBy: input.userId,
- updatedAt: null,
- updatedBy: null,
- localRevision: '0',
- serverRevision: '0',
- };
+ userId: input.userId,
+ now,
+ });
- const boardView: LocalDatabaseViewNode = {
- id: boardViewId,
- type: 'database_view',
- name: 'By status',
+ const statusBoardView = buildTodoListViewNode({
+ spec: TODO_LIST_VIEW_SPECS[2]!,
index: generateFractionalIndex(tableView.index, null),
- layout: 'board',
- parentId: databaseId,
+ databaseId,
rootId: input.rootId,
- groupBy: TODO_LIST_FIELD_IDS.status,
- fields: viewFields,
- createdAt: now,
- createdBy: input.userId,
- updatedAt: null,
- updatedBy: null,
- localRevision: '0',
- serverRevision: '0',
- };
+ userId: input.userId,
+ now,
+ });
+
+ const tagsBoardView = buildTodoListViewNode({
+ spec: TODO_LIST_VIEW_SPECS[3]!,
+ index: generateFractionalIndex(statusBoardView.index, null),
+ databaseId,
+ rootId: input.rootId,
+ userId: input.userId,
+ now,
+ });
- return { database, listView, tableView, boardView };
+ return {
+ database,
+ listView,
+ tableView,
+ statusBoardView,
+ tagsBoardView,
+ };
};
export const resolveTodoListStatusFieldId = (
diff --git a/packages/client/test/todo-list-system.test.ts b/packages/client/test/todo-list-system.test.ts
index 35b147800..95fcb8ea4 100644
--- a/packages/client/test/todo-list-system.test.ts
+++ b/packages/client/test/todo-list-system.test.ts
@@ -2,13 +2,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { WorkspaceDatabaseSchema } from '@colanode/client/databases';
import {
+ applyTodoListListViewFields,
buildMissingTodoListViews,
buildTodoListBundle,
+ buildDefaultTodoTagOptions,
+ buildTodoListListViewDefaults,
+ getTodoListViewKey,
} from '@colanode/client/lib/todo-list';
import { ensureTodoListSystemDatabase } from '@colanode/client/lib/todo-list-system';
import { NodeService } from '@colanode/client/services/workspaces/node-service';
import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service';
-import { generateId, IdType, UserStatus } from '@colanode/core';
+import { generateId, IdType, TODO_LIST_FIELD_IDS, UserStatus } from '@colanode/core';
import { Kysely } from 'kysely';
import { createWorkspaceDatabase } from './helpers/sqlite';
@@ -74,7 +78,7 @@ describe('todo list system database', () => {
name?: string;
systemKind?: string;
locked?: boolean;
- fields?: Record;
+ fields?: Record;
};
expect(attributes.type).toBe('database');
@@ -82,6 +86,7 @@ describe('todo list system database', () => {
expect(attributes.name).toBe('TodoList');
expect(attributes.systemKind).toBe('todo_list');
expect(attributes.locked).toBe(false);
+ expect(attributes.fields?.__colanode_todo_estimate?.default).toBeUndefined();
const fieldNames = Object.values(attributes.fields ?? {}).map(
(field) => field.name
@@ -95,7 +100,7 @@ describe('todo list system database', () => {
'Assignees',
'Size',
'Estimate',
- 'Labels',
+ 'Tags',
'Created',
'Updated',
])
@@ -108,11 +113,11 @@ describe('todo list system database', () => {
.where('parent_id', '=', firstId)
.execute();
- const layouts = views.map(
- (view) => JSON.parse(view.attributes).layout as string
+ const viewNames = views.map(
+ (view) => JSON.parse(view.attributes).name as string
);
- expect(layouts).toEqual(
- expect.arrayContaining(['list', 'table', 'board'])
+ expect(viewNames).toEqual(
+ expect.arrayContaining(['List', 'Table', 'By status', 'By tags'])
);
});
@@ -126,17 +131,67 @@ describe('todo list system database', () => {
expect(bundle.database.purpose).toBe('records');
expect(bundle.listView.layout).toBe('list');
expect(bundle.tableView.layout).toBe('table');
- expect(bundle.boardView.layout).toBe('board');
- expect(bundle.boardView.groupBy).toBeDefined();
+ expect(bundle.statusBoardView.layout).toBe('board');
+ expect(bundle.statusBoardView.groupBy).toBe(TODO_LIST_FIELD_IDS.status);
+ expect(bundle.tagsBoardView.groupBy).toBe(TODO_LIST_FIELD_IDS.labels);
+
+ const listDefaults = buildTodoListListViewDefaults();
+ expect(bundle.listView.filters).toEqual(listDefaults.filters);
+ expect(bundle.listView.sorts).toEqual(listDefaults.sorts);
+
+ const listFields = applyTodoListListViewFields(bundle.listView.fields ?? {});
+ expect(listFields[TODO_LIST_FIELD_IDS.status]?.display).toBe(true);
+ expect(listFields[TODO_LIST_FIELD_IDS.labels]?.display).toBe(true);
+ expect(listFields[TODO_LIST_FIELD_IDS.targetDate]?.display).toBe(true);
+ expect(listFields[TODO_LIST_FIELD_IDS.priority]?.display).toBe(false);
const missing = buildMissingTodoListViews({
databaseId: bundle.database.id,
rootId: bundle.database.rootId,
userId,
- existingLayouts: new Set(['list']),
+ existingViewKeys: new Set(['list']),
lastViewIndex: bundle.listView.index,
});
- expect(missing.map((view) => view.layout)).toEqual(['table', 'board']);
+ expect(missing.map((view) => view.name)).toEqual([
+ 'Table',
+ 'By status',
+ 'By tags',
+ ]);
+ });
+
+ it('includes default tag options on new databases', () => {
+ const bundle = buildTodoListBundle({
+ parentId: generateId(IdType.Space),
+ rootId: generateId(IdType.Space),
+ userId,
+ });
+
+ const tagsField = bundle.database.fields.__colanode_todo_labels;
+ expect(tagsField?.name).toBe('Tags');
+ expect(tagsField?.type).toBe('multi_select');
+ expect(Object.keys(tagsField?.options ?? {})).toEqual(
+ Object.keys(buildDefaultTodoTagOptions())
+ );
+
+ const assigneeField = bundle.database.fields.__colanode_todo_assignee;
+ expect(assigneeField?.name).toBe('Assignees');
+ expect(assigneeField?.type).toBe('collaborator');
+ });
+
+ it('resolves board view keys by groupBy', () => {
+ expect(
+ getTodoListViewKey({
+ layout: 'board',
+ groupBy: TODO_LIST_FIELD_IDS.status,
+ })
+ ).toBe(`board:${TODO_LIST_FIELD_IDS.status}`);
+
+ expect(
+ getTodoListViewKey({
+ layout: 'board',
+ groupBy: TODO_LIST_FIELD_IDS.labels,
+ })
+ ).toBe(`board:${TODO_LIST_FIELD_IDS.labels}`);
});
});
diff --git a/packages/core/src/lib/todo-list.ts b/packages/core/src/lib/todo-list.ts
index 3448c19d2..51780f57c 100644
--- a/packages/core/src/lib/todo-list.ts
+++ b/packages/core/src/lib/todo-list.ts
@@ -13,14 +13,6 @@ export const TODO_LIST_FIELD_IDS = {
updatedAt: '__colanode_todo_updated_at',
} as const;
-export const todoListSelectedRecordKey = (databaseId: string): string =>
- `${databaseId}.selectedRecordId`;
-
-export const todoListViewModeKey = (databaseId: string): string =>
- `${databaseId}.viewMode`;
-
-export type TodoListViewMode = 'tasks' | 'database';
-
export const TODO_LIST_DISPLAY_FIELD_ORDER = [
TODO_LIST_FIELD_IDS.status,
TODO_LIST_FIELD_IDS.priority,
@@ -36,6 +28,8 @@ export const TODO_LIST_DISPLAY_FIELD_ORDER = [
export const TODO_LIST_DEFAULT_VIEW_NAME = 'List';
+export const TODO_LIST_DONE_STATUS_OPTION_ID = '__colanode_todo_status_done';
+
export const isTodoListSystemFieldId = (fieldId: string): boolean => {
return (Object.values(TODO_LIST_FIELD_IDS) as string[]).includes(fieldId);
};
diff --git a/packages/ui/src/components/databases/list/list-view-collection-row.tsx b/packages/ui/src/components/databases/list/list-view-collection-row.tsx
index 6f4515cda..894a9c4d8 100644
--- a/packages/ui/src/components/databases/list/list-view-collection-row.tsx
+++ b/packages/ui/src/components/databases/list/list-view-collection-row.tsx
@@ -1,5 +1,5 @@
import { debounceStrategy, usePacedMutations } from '@tanstack/react-db';
-import { useNavigate } from '@tanstack/react-router';
+import { useLocation, useNavigate } from '@tanstack/react-router';
import { SquareArrowOutUpRight } from 'lucide-react';
import { LocalNode } from '@colanode/client/types';
@@ -14,7 +14,12 @@ import { useDatabase } from '@colanode/ui/contexts/database';
import { useDatabaseView } from '@colanode/ui/contexts/database-view';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { CollectionRow } from '@colanode/ui/lib/collection-row';
+import {
+ isTodoListSettingsPath,
+ shouldIgnoreListRowNavigation,
+} from '@colanode/ui/lib/todo-list';
import { applyNodeTransaction } from '@colanode/ui/lib/nodes';
+import { cn } from '@colanode/ui/lib/utils';
interface ListViewCollectionRowProps {
row: CollectionRow;
@@ -25,6 +30,8 @@ export const ListViewCollectionRow = ({ row }: ListViewCollectionRowProps) => {
const database = useDatabase();
const view = useDatabaseView();
const navigate = useNavigate();
+ const location = useLocation();
+ const isTodoListSettings = isTodoListSettingsPath(location.pathname);
const showPageId =
row.nodeType === 'page' && (database.purpose ?? 'records') === 'meta_type';
@@ -53,11 +60,43 @@ export const ListViewCollectionRow = ({ row }: ListViewCollectionRowProps) => {
to: '/workspace/$userId/$nodeId',
params: { userId: workspace.userId, nodeId: row.id },
});
+ return;
+ }
+
+ if (isTodoListSettings) {
+ navigate({
+ to: '/workspace/$userId/settings/todo-list/modal/$modalNodeId',
+ params: {
+ userId: workspace.userId,
+ modalNodeId: row.id,
+ },
+ });
}
};
return (
-
+
{
+ if (shouldIgnoreListRowNavigation(event.target)) {
+ return;
+ }
+
+ if (row.nodeType === 'record' && isTodoListSettings) {
+ openNode();
+ return;
+ }
+
+ if (row.nodeType === 'page') {
+ openNode();
+ }
+ }}
+ >
{
)}
{row.nodeType === 'record' ? (
-
-
-
+ isTodoListSettings ? (
+
+
+
+ ) : (
+
+
+
+ )
) : (