diff --git a/docs/page-meta-types.md b/docs/page-meta-types.md index fc7e90f56..d79abe9f4 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()`: @@ -87,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/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/page-meta-types.ts b/packages/client/src/lib/page-meta-types.ts index 62bcb5c3f..1af49b1ce 100644 --- a/packages/client/src/lib/page-meta-types.ts +++ b/packages/client/src/lib/page-meta-types.ts @@ -25,6 +25,293 @@ 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 +567,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 +639,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/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 new file mode 100644 index 000000000..c0113229e --- /dev/null +++ b/packages/client/src/lib/todo-list-system.ts @@ -0,0 +1,574 @@ +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'; +import { ensureScheduledTaskMetaType } from '@colanode/client/lib/scheduled-task-meta-type'; +import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; +import { + DatabaseAttributes, + DatabaseViewAttributes, + DatabaseViewFieldAttributes, + FieldAttributes, + MultiSelectFieldAttributes, + RecordAttributes, + RelationFieldAttributes, + REMINDERS_RELATION_FIELD_ID, + REMINDERS_RELATION_FIELD_NAME, + TODO_LIST_FIELD_IDS, + TODO_LIST_SYSTEM_KIND, + createDebugger, + getNextFractionalIndex, +} from '@colanode/core'; + +const debug = createDebugger('desktop:todo-list-system'); + +const buildTodoListRemindersRelationField = ( + scheduledTaskDatabaseId: string, + existingFields: Record +): RelationFieldAttributes => ({ + id: REMINDERS_RELATION_FIELD_ID, + type: 'relation', + name: REMINDERS_RELATION_FIELD_NAME, + index: getNextFractionalIndex( + Object.values(existingFields).map((field) => field.index) + ), + databaseId: scheduledTaskDatabaseId, +}); + +const ensureTodoListRemindersField = async ( + workspace: WorkspaceService, + todoDatabaseId: string +): Promise => { + const scheduledTaskDatabaseId = await ensureScheduledTaskMetaType(workspace); + + const row = await workspace.database + .selectFrom('nodes') + .select(['id', 'attributes']) + .where('id', '=', todoDatabaseId) + .executeTakeFirst(); + + if (!row) { + return; + } + + const attributes = JSON.parse(row.attributes) as DatabaseAttributes; + if (attributes.fields?.[REMINDERS_RELATION_FIELD_ID]) { + return; + } + + const relationField = buildTodoListRemindersRelationField( + scheduledTaskDatabaseId, + attributes.fields ?? {} + ); + + const result = await workspace.nodes.updateNode(todoDatabaseId, (draft) => { + if (draft.type !== 'database') { + return draft; + } + + draft.fields = { + ...draft.fields, + [REMINDERS_RELATION_FIELD_ID]: relationField, + }; + return draft; + }); + + if (result === 'success') { + debug(`Injected reminders relation field on todo list database ${todoDatabaseId}`); + } +}; + +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 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 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', + }; + } + + 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 => { + 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(dedupeTodoListFields(existing)); + const next = { ...normalized }; + + 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 && + fieldId !== TODO_LIST_FIELD_IDS.labels + ); + if (!byName) { + next[fieldId] = field; + } + } + } + + 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 +): 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}`); + } + + await migrateTodoListAssigneeRecordValues(workspace, databaseId); + await ensureTodoListRemindersField(workspace, databaseId); + + const views = await listTodoListViews(workspace, databaseId); + 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'; + + if (!needsFields) { + continue; + } + + if ( + !hasNewViewFields && + !needsGroupBy && + !needsListDefaults && + !needsListDisplay && + Object.keys(view.attributes.fields ?? {}).length > 0 + ) { + continue; + } + + await workspace.nodes.updateNode(view.id, (draft) => { + if (draft.type !== 'database_view') { + return draft; + } + + 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 = + viewKey === `board:${TODO_LIST_FIELD_IDS.labels}` + ? TODO_LIST_FIELD_IDS.labels + : statusFieldId; + } + + return draft; + }); + } + + 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, + existingViewKeys, + 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 bundle = buildTodoListBundle({ + parentId: spaceId, + rootId: spaceId, + userId: workspace.userId, + }); + + 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, + }); + + for (const view of [ + bundle.listView, + bundle.tableView, + bundle.statusBoardView, + bundle.tagsBoardView, + ]) { + 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 ( + workspace: WorkspaceService +): Promise => { + const existing = await findTodoListSystemDatabaseId(workspace); + if (existing) { + await upgradeTodoListSystemDatabase(workspace, existing); + return existing; + } + + const spaceId = await findOrCreateMetaTypesSpace(workspace); + const databaseId = await createTodoListSystemDatabase(workspace, spaceId); + await ensureTodoListRemindersField(workspace, databaseId); + return databaseId; +}; diff --git a/packages/client/src/lib/todo-list.ts b/packages/client/src/lib/todo-list.ts new file mode 100644 index 000000000..72f138e77 --- /dev/null +++ b/packages/client/src/lib/todo-list.ts @@ -0,0 +1,568 @@ +import { + LocalDatabaseNode, + LocalDatabaseViewNode, +} 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, +} 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: TODO_LIST_DONE_STATUS_OPTION_ID, +} 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; + +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; + +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]: { + 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.startDate]: { + id: TODO_LIST_FIELD_IDS.startDate, + type: '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: 'collaborator', + 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: 'g', + }, + [TODO_LIST_FIELD_IDS.labels]: { + id: TODO_LIST_FIELD_IDS.labels, + type: 'multi_select', + name: 'Tags', + index: 'h', + options: buildDefaultTodoTagOptions(), + }, + [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', + }, + }; +}; + +const buildTodoListViewFieldEntries = () => { + const statusIndex = generateFractionalIndex(null, null); + const priorityIndex = generateFractionalIndex(statusIndex, 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); + const createdAtIndex = generateFractionalIndex(labelsIndex, null); + const updatedAtIndex = generateFractionalIndex(createdAtIndex, null); + + return { + [TODO_LIST_FIELD_IDS.status]: { + id: TODO_LIST_FIELD_IDS.status, + index: statusIndex, + }, + [TODO_LIST_FIELD_IDS.priority]: { + id: TODO_LIST_FIELD_IDS.priority, + index: priorityIndex, + }, + [TODO_LIST_FIELD_IDS.startDate]: { + id: TODO_LIST_FIELD_IDS.startDate, + index: startDateIndex, + }, + [TODO_LIST_FIELD_IDS.targetDate]: { + id: TODO_LIST_FIELD_IDS.targetDate, + index: targetDateIndex, + }, + [TODO_LIST_FIELD_IDS.assignee]: { + id: TODO_LIST_FIELD_IDS.assignee, + index: assigneeIndex, + }, + [TODO_LIST_FIELD_IDS.size]: { + id: TODO_LIST_FIELD_IDS.size, + index: sizeIndex, + }, + [TODO_LIST_FIELD_IDS.estimate]: { + id: TODO_LIST_FIELD_IDS.estimate, + index: estimateIndex, + }, + [TODO_LIST_FIELD_IDS.labels]: { + id: TODO_LIST_FIELD_IDS.labels, + index: labelsIndex, + }, + [TODO_LIST_FIELD_IDS.createdAt]: { + id: TODO_LIST_FIELD_IDS.createdAt, + index: createdAtIndex, + }, + [TODO_LIST_FIELD_IDS.updatedAt]: { + id: TODO_LIST_FIELD_IDS.updatedAt, + index: updatedAtIndex, + }, + }; +}; + +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', + groupBy: TODO_LIST_FIELD_IDS.status, + }, + { + 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; + existingViewKeys: Set; + lastViewIndex: string | null; + firstViewIndex?: string | null; +}): LocalDatabaseViewNode[] => { + const now = new Date().toISOString(); + let previousIndex = input.lastViewIndex; + const views: LocalDatabaseViewNode[] = []; + + for (const spec of TODO_LIST_VIEW_SPECS) { + if (input.existingViewKeys.has(spec.key)) { + continue; + } + + const index = + spec.layout === 'list' && input.firstViewIndex + ? generateFractionalIndex(null, input.firstViewIndex) + : generateFractionalIndex(previousIndex, null); + previousIndex = index; + + views.push( + buildTodoListViewNode({ + spec, + index, + databaseId: input.databaseId, + rootId: input.rootId, + userId: input.userId, + now, + }) + ); + } + + return views; +}; + +export const buildTodoListBundle = (input: { + parentId: string; + rootId: string; + userId: string; +}): { + database: LocalDatabaseNode; + listView: LocalDatabaseViewNode; + tableView: LocalDatabaseViewNode; + statusBoardView: LocalDatabaseViewNode; + tagsBoardView: LocalDatabaseViewNode; +} => { + const databaseId = generateId(IdType.Database); + const now = new Date().toISOString(); + + 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 = buildTodoListViewNode({ + spec: TODO_LIST_VIEW_SPECS[0]!, + index: generateFractionalIndex(null, null), + databaseId, + rootId: input.rootId, + userId: input.userId, + now, + }); + + const tableView = buildTodoListViewNode({ + spec: TODO_LIST_VIEW_SPECS[1]!, + index: generateFractionalIndex(listView.index, null), + databaseId, + rootId: input.rootId, + userId: input.userId, + now, + }); + + const statusBoardView = buildTodoListViewNode({ + spec: TODO_LIST_VIEW_SPECS[2]!, + index: generateFractionalIndex(tableView.index, null), + databaseId, + rootId: input.rootId, + 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, + statusBoardView, + tagsBoardView, + }; +}; + +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/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/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/page-meta-types.test.ts b/packages/client/test/page-meta-types.test.ts index e057c6d2b..3024ff531 100644 --- a/packages/client/test/page-meta-types.test.ts +++ b/packages/client/test/page-meta-types.test.ts @@ -24,6 +24,7 @@ import { import { createWorkspaceDatabase, createWorkspaceDatabaseUpTo } from './helpers/sqlite'; + const insertNode = async ( database: Kysely, id: string, @@ -379,6 +380,82 @@ 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); 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..b2d6b54a1 --- /dev/null +++ b/packages/client/test/todo-list-system.test.ts @@ -0,0 +1,201 @@ +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, REMINDERS_RELATION_FIELD_ID, TODO_LIST_FIELD_IDS, 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(false); + expect(attributes.fields?.__colanode_todo_estimate?.default).toBeUndefined(); + + const fieldNames = Object.values(attributes.fields ?? {}).map( + (field) => field.name + ); + expect(fieldNames).toEqual( + expect.arrayContaining([ + 'Status', + 'Priority', + 'Start date', + 'Target date', + 'Assignees', + 'Size', + 'Estimate', + 'Tags', + 'Created', + 'Updated', + ]) + ); + + const views = await database + .selectFrom('nodes') + .select(['attributes']) + .where('type', '=', 'database_view') + .where('parent_id', '=', firstId) + .execute(); + + const viewNames = views.map( + (view) => JSON.parse(view.attributes).name as string + ); + expect(viewNames).toEqual( + expect.arrayContaining(['List', 'Table', 'By status', 'By tags']) + ); + + expect(attributes.fields?.[REMINDERS_RELATION_FIELD_ID]?.type).toBe( + 'relation' + ); + }); + + 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.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, + existingViewKeys: new Set(['list']), + lastViewIndex: bundle.listView.index, + }); + + 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/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/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/lib/todo-list.ts b/packages/core/src/lib/todo-list.ts new file mode 100644 index 000000000..815bd99d9 --- /dev/null +++ b/packages/core/src/lib/todo-list.ts @@ -0,0 +1,47 @@ +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', + 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', + createdAt: '__colanode_todo_created_at', + updatedAt: '__colanode_todo_updated_at', +} as const; + +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 TODO_LIST_DONE_STATUS_OPTION_ID = '__colanode_todo_status_done'; + +export const todoListSelectedRecordKey = (databaseId: string): string => + `${databaseId}.selectedRecordId`; + +export const TODO_LIST_DUE_REMINDER_NAME_PREFIX = 'Due · '; + +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 5145b18a1..9aa20602f 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,16 @@ 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) { + 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; } @@ -99,7 +107,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(() => { 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/databases/list/list-view-collection-row.tsx b/packages/ui/src/components/databases/list/list-view-collection-row.tsx index 6f4515cda..50bb7f2ea 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,6 +1,6 @@ import { debounceStrategy, usePacedMutations } from '@tanstack/react-db'; -import { useNavigate } from '@tanstack/react-router'; -import { SquareArrowOutUpRight } from 'lucide-react'; +import { useLocation, useNavigate } from '@tanstack/react-router'; +import { PanelRightOpen, SquareArrowOutUpRight } from 'lucide-react'; import { LocalNode } from '@colanode/client/types'; import { isRemindersRelationFieldId, RelationFieldAttributes } from '@colanode/core'; @@ -12,9 +12,15 @@ import { Input } from '@colanode/ui/components/ui/input'; import { Link } from '@colanode/ui/components/ui/link'; import { useDatabase } from '@colanode/ui/contexts/database'; import { useDatabaseView } from '@colanode/ui/contexts/database-view'; +import { useTodoListSplit } from '@colanode/ui/contexts/todo-list-split'; 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 +31,10 @@ export const ListViewCollectionRow = ({ row }: ListViewCollectionRowProps) => { const database = useDatabase(); const view = useDatabaseView(); const navigate = useNavigate(); + const location = useLocation(); + const isTodoListSettings = isTodoListSettingsPath(location.pathname); + const split = useTodoListSplit(); + const isSelected = row.nodeType === 'record' && split?.selectedRecordId === row.id; const showPageId = row.nodeType === 'page' && (database.purpose ?? 'records') === 'meta_type'; @@ -53,22 +63,64 @@ export const ListViewCollectionRow = ({ row }: ListViewCollectionRowProps) => { to: '/workspace/$userId/$nodeId', params: { userId: workspace.userId, nodeId: row.id }, }); + return; + } + + if (isTodoListSettings && split && view.layout === 'list') { + split.selectRecord(row.id); } }; return ( -
- +
{ + if (shouldIgnoreListRowNavigation(event.target)) { + return; + } + + if (row.nodeType === 'record' && isTodoListSettings) { + openNode(); + return; + } + + if (row.nodeType === 'page') { + openNode(); + } + }} + > +
mutate(event.target.value)} - className="h-7 border-none bg-transparent pl-1 shadow-none focus-visible:ring-0" + onKeyDown={(event) => { + if (event.key === 'Enter' && isTodoListSettings && row.nodeType === 'record') { + event.preventDefault(); + window.dispatchEvent(new Event('todo-list:focus-quick-create')); + } + }} + className="h-6 border-none bg-transparent pl-1 shadow-none focus-visible:ring-0" placeholder="Unnamed" /> {showPageId && ( @@ -100,21 +152,41 @@ export const ListViewCollectionRow = ({ row }: ListViewCollectionRowProps) => {
)} {row.nodeType === 'record' ? ( - - - + isTodoListSettings ? ( + + ) : ( + event.stopPropagation()} + title="Open detail" + > + + Detail + + ) ) : ( )}
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..7d5146af2 --- /dev/null +++ b/packages/ui/src/components/databases/list/list-view-quick-create.tsx @@ -0,0 +1,70 @@ +import { useEffect, 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; + } + + useEffect(() => { + const onFocus = () => inputRef.current?.focus(); + window.addEventListener('todo-list:focus-quick-create', onFocus); + return () => + window.removeEventListener('todo-list:focus-quick-create', onFocus); + }, []); + + 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..ad12c8420 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,5 +1,6 @@ +import { useLocation, useNavigate } from '@tanstack/react-router'; import { debounceStrategy, usePacedMutations } from '@tanstack/react-db'; -import { SquareArrowOutUpRight } from 'lucide-react'; +import { PanelRightOpen, SquareArrowOutUpRight } from 'lucide-react'; import { LocalNode } from '@colanode/client/types'; import { isRemindersRelationFieldId, RelationFieldAttributes } from '@colanode/core'; @@ -10,13 +11,24 @@ import { Input } from '@colanode/ui/components/ui/input'; import { Link } from '@colanode/ui/components/ui/link'; import { useDatabaseView } from '@colanode/ui/contexts/database-view'; import { useRecord } from '@colanode/ui/contexts/record'; +import { useTodoListSplit } from '@colanode/ui/contexts/todo-list-split'; import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { + isTodoListSettingsPath, + shouldIgnoreListRowNavigation, +} from '@colanode/ui/lib/todo-list'; import { applyNodeTransaction } from '@colanode/ui/lib/nodes'; +import { cn } from '@colanode/ui/lib/utils'; export const ListViewRecordRow = () => { const workspace = useWorkspace(); const view = useDatabaseView(); const record = useRecord(); + const location = useLocation(); + const navigate = useNavigate(); + const isTodoListSettings = isTodoListSettingsPath(location.pathname); + const split = useTodoListSplit(); + const isSelected = split?.selectedRecordId === record.id; const name = record.name; const hasName = name !== null && name !== ''; @@ -37,19 +49,55 @@ export const ListViewRecordRow = () => { strategy: debounceStrategy({ wait: 500 }), }); + const openRecord = () => { + if (isTodoListSettings && split && view.layout === 'list') { + split.selectRecord(record.id); + } + }; + return ( -
- +
{ + if (!isTodoListSettings || shouldIgnoreListRowNavigation(event.target)) { + return; + } + + openRecord(); + }} + > + mutate(event.target.value)} - className="h-7 min-w-0 flex-1 border-none bg-transparent pl-1 shadow-none focus-visible:ring-0" + onKeyDown={(event) => { + if (event.key === 'Enter' && isTodoListSettings) { + event.preventDefault(); + window.dispatchEvent(new Event('todo-list:focus-quick-create')); + } + }} + className="h-6 min-w-0 flex-1 border-none bg-transparent pl-1 shadow-none focus-visible:ring-0" placeholder="Unnamed" /> {view.fields.length > 0 && ( @@ -76,14 +124,32 @@ export const ListViewRecordRow = () => { })}
)} - - - + {isTodoListSettings ? ( + + ) : ( + event.stopPropagation()} + title="Open detail" + > + + Detail + + )}
); }; diff --git a/packages/ui/src/components/databases/list/list-view.tsx b/packages/ui/src/components/databases/list/list-view.tsx index 81f6eb93f..09f9fe01d 100644 --- a/packages/ui/src/components/databases/list/list-view.tsx +++ b/packages/ui/src/components/databases/list/list-view.tsx @@ -1,4 +1,5 @@ import { Fragment } from 'react'; +import { useLocation } from '@tanstack/react-router'; import { MultiSelectFieldAttributes, @@ -14,10 +15,14 @@ import { ViewFullscreenButton } from '@colanode/ui/components/databases/view-ful import { ViewTabs } from '@colanode/ui/components/databases/view-tabs'; import { useDatabase } from '@colanode/ui/contexts/database'; import { useDatabaseView } from '@colanode/ui/contexts/database-view'; +import { isTodoListSettingsPath } from '@colanode/ui/lib/todo-list'; +import { cn } from '@colanode/ui/lib/utils'; export const ListView = () => { const database = useDatabase(); const view = useDatabaseView(); + const location = useLocation(); + const isTodoListSettings = isTodoListSettingsPath(location.pathname); const groupByField = database.fields.find( (field) => field.id === view.groupBy @@ -38,18 +43,25 @@ export const ListView = () => { -
- {isGrouped ? ( - - ) : ( - - )} +
+
+ {isGrouped ? ( + + ) : ( + + )} +
); diff --git a/packages/ui/src/components/databases/view.tsx b/packages/ui/src/components/databases/view.tsx index 9167f1832..8aa2b5003 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,7 @@ import { DatabaseViewFilterAttributes, DatabaseViewSortAttributes, SpecialId, - generateId, - IdType, + todoListSelectedRecordKey, } from '@colanode/core'; import { BoardView } from '@colanode/ui/components/databases/boards/board-view'; import { CalendarView } from '@colanode/ui/components/databases/calendars/calendar-view'; @@ -23,11 +21,11 @@ import { TableView } from '@colanode/ui/components/databases/tables/table-view'; import { useDatabase } from '@colanode/ui/contexts/database'; import { DatabaseViewContext } from '@colanode/ui/contexts/database-view'; import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { useMetadata } from '@colanode/ui/hooks/use-metadata'; 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'; +import { isTodoListSettingsPath } from '@colanode/ui/lib/todo-list'; interface ViewProps { view: LocalDatabaseViewNode; @@ -37,6 +35,12 @@ export const View = ({ view }: ViewProps) => { const workspace = useWorkspace(); const database = useDatabase(); const navigate = useNavigate(); + const location = useLocation(); + const isTodoListSettings = isTodoListSettingsPath(location.pathname); + const [, setSelectedRecordId] = useMetadata( + workspace.userId, + todoListSelectedRecordKey(database.id) + ); const fields: ViewField[] = buildDatabaseViewFields( database.fields, @@ -157,39 +161,32 @@ 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 (isTodoListSettings) { + if (view.layout === 'list') { + setSelectedRecordId(record.id); + } + return; + } + + if (view.layout !== 'list') { + navigate({ + from: '/workspace/$userId/$nodeId', + to: 'modal/$modalNodeId', + params: { modalNodeId: record.id }, + }); + } }, }} > 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 d427725c9..8c09f80c7 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 { useLocation, 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,28 @@ interface SidebarMenuProps { 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); @@ -39,7 +62,7 @@ export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => { { - onChange('chats'); + handleMenuClick('chats'); }} isActive={value === 'chats'} unreadBadge={{ @@ -51,7 +74,7 @@ export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => { { - onChange('spaces'); + handleMenuClick('spaces'); }} isActive={value === 'spaces'} unreadBadge={{ @@ -63,7 +86,7 @@ export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => { { - onChange('notifications'); + handleMenuClick('notifications'); }} isActive={value === 'notifications'} unreadBadge={{ @@ -72,11 +95,18 @@ export const SidebarMenu = ({ value, onChange }: SidebarMenuProps) => { maxCount: 99, }} /> + { + handleMenuClick('todo-list'); + }} + isActive={value === 'todo-list'} + />
{ - onChange('settings'); + handleMenuClick('settings'); }} className="mt-auto" isActive={value === 'settings'} 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 }) => ( { + const field = database.fields[TODO_LIST_FIELD_IDS.status]; + const rawValue = record.fields[TODO_LIST_FIELD_IDS.status] 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; +}; + +const isDoneTask = (record: LocalRecordNode): boolean => { + const status = record.fields[TODO_LIST_FIELD_IDS.status] as + | { type?: string; value?: string } + | undefined; + + return ( + status?.type === 'select' && + status.value === TODO_LIST_DONE_STATUS_OPTION_ID + ); +}; + +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; + + 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(({ nodes }) => eq(nodes.id, '__disabled__')); + } + + 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 recentTasks = useMemo(() => { + const records = (recordsQuery.data ?? []) as LocalRecordNode[]; + return records.filter((record) => !isDoneTask(record)).slice(0, RECENT_TASK_LIMIT); + }, [recordsQuery.data]); + + const openTask = (recordId: string) => { + setSelectedRecordId(recordId); + navigate({ + to: '/workspace/$userId/settings/todo-list', + params: { userId: workspace.userId }, + }); + }; + + 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 && recentTasks.length > 0 ? ( +
+

+ Open tasks +

+ {recentTasks.map((record) => { + const name = record.name?.trim() || 'Unnamed'; + const status = getStatusLabel(database, record); + + return ( + + ); + })} +
+ ) : ( +

+ Add a task above, or open the full database view. +

+ )} +
+ + + Open TodoList + +
+ ); +}; diff --git a/packages/ui/src/components/layouts/sidebars/sidebar.tsx b/packages/ui/src/components/layouts/sidebars/sidebar.tsx index 1f37c70fc..340f08d0e 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,9 +18,22 @@ interface SidebarProps { export const Sidebar = ({ collapsed = false, onToggleCollapse }: SidebarProps) => { const app = useApp(); + 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'); + } + }, [location.pathname]); + const handleMenuChange = (next: SidebarMenuType) => { + if (isTodoListFullView && next !== 'todo-list') { + setMenu(next); + return; + } if (collapsed) { onToggleCollapse?.(); setMenu(next); @@ -39,11 +54,12 @@ export const Sidebar = ({ collapsed = false, onToggleCollapse }: SidebarProps) = )} > - {!collapsed && ( + {!collapsed && !(menu === 'todo-list' && isTodoListFullView) && (
{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/components/records/record-attributes.tsx b/packages/ui/src/components/records/record-attributes.tsx index 40f71f03c..177587c64 100644 --- a/packages/ui/src/components/records/record-attributes.tsx +++ b/packages/ui/src/components/records/record-attributes.tsx @@ -9,14 +9,19 @@ import { useDatabase } from '@colanode/ui/contexts/database'; interface RecordAttributesProps { defaultExpanded?: boolean; + hiddenFieldIds?: string[]; } export const RecordAttributes = ({ defaultExpanded = false, + hiddenFieldIds = [], }: RecordAttributesProps) => { const database = useDatabase(); const [expanded, setExpanded] = useState(defaultExpanded); - const fields = [...database.fields].sort((a, b) => a.index.localeCompare(b.index)); + const hidden = new Set(hiddenFieldIds); + const fields = [...database.fields] + .filter((field) => !hidden.has(field.id)) + .sort((a, b) => a.index.localeCompare(b.index)); return (
@@ -48,10 +53,10 @@ export const RecordAttributes = ({ fields.map((field) => (
-
+
-
+
diff --git a/packages/ui/src/components/workspaces/todo-list/todo-list-due-reminder.tsx b/packages/ui/src/components/workspaces/todo-list/todo-list-due-reminder.tsx new file mode 100644 index 000000000..de351a4bd --- /dev/null +++ b/packages/ui/src/components/workspaces/todo-list/todo-list-due-reminder.tsx @@ -0,0 +1,342 @@ +import { useLiveQuery } from '@tanstack/react-db'; +import { Clock, X } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; + +import { listScheduledTaskLeafChecks } from '@colanode/client/lib'; +import { + LocalDatabaseNode, + LocalRecordNode, + ScheduledTask, + ScheduledTaskInput, +} from '@colanode/client/types'; +import { + NodeRole, + REMINDERS_RELATION_FIELD_ID, + TODO_LIST_DUE_REMINDER_NAME_PREFIX, + TODO_LIST_FIELD_IDS, + hasNodeRole, +} from '@colanode/core'; +import { + formatRecordCheckSummary, + formatTaskScheduleShort, + taskRelatesToRecord, +} from '@colanode/ui/components/nodes/node-reminder-helpers'; +import { RecordReminderDialog } from '@colanode/ui/components/nodes/record-reminder-dialog'; +import { Button } from '@colanode/ui/components/ui/button'; +import { useApp } from '@colanode/ui/contexts/app'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { useMutation } from '@colanode/ui/hooks/use-mutation'; +import { useRecordField } from '@colanode/ui/hooks/use-record-field'; + +type DialogState = + | { mode: 'closed' } + | { mode: 'create' } + | { mode: 'edit'; task: ScheduledTask }; + +const hasTargetDate = (record: LocalRecordNode): boolean => { + const value = record.fields[TODO_LIST_FIELD_IDS.targetDate]; + return Boolean( + value && + value.type === 'string' && + typeof value.value === 'string' && + value.value.length > 0 + ); +}; + +const isDueDateReminder = ( + task: ScheduledTask, + database: LocalDatabaseNode, + record: LocalRecordNode +): boolean => { + if (!taskRelatesToRecord(task, record)) { + return false; + } + + return listScheduledTaskLeafChecks(task.check).some( + (leaf) => + leaf.type === 'database_record_date_due' && + leaf.databaseId === database.id && + leaf.recordId === record.id && + leaf.fieldId === TODO_LIST_FIELD_IDS.targetDate + ); +}; + +const buildDueReminderInput = ( + record: LocalRecordNode, + database: LocalDatabaseNode, + appType: string +): ScheduledTaskInput => ({ + name: `${TODO_LIST_DUE_REMINDER_NAME_PREFIX}${record.name?.trim() || 'Unnamed'}`, + enabled: true, + schedule: { + type: 'daily', + time: '09:00', + }, + check: { + type: 'database_record_date_due', + databaseId: database.id, + recordId: record.id, + fieldId: TODO_LIST_FIELD_IDS.targetDate, + }, + notify: { + silent: appType !== 'desktop', + }, + scope: { + kind: 'records', + databaseId: database.id, + nodeId: record.id, + }, +}); + +const taskToInput = (task: ScheduledTask): ScheduledTaskInput => ({ + name: task.name, + enabled: task.enabled, + schedule: task.schedule, + check: task.check, + notify: task.notify, + scope: task.scope, + context: task.context, +}); + +interface TodoListDueReminderProps { + record: LocalRecordNode; + database: LocalDatabaseNode; + role: NodeRole; +} + +export const TodoListDueReminder = ({ + record, + database, + role, +}: TodoListDueReminderProps) => { + const app = useApp(); + const workspace = useWorkspace(); + const { mutate, isPending } = useMutation(); + const [dialogState, setDialogState] = useState({ mode: 'closed' }); + + const databaseFields = useMemo( + () => Object.values(database.fields), + [database.fields] + ); + + const remindersField = databaseFields.find( + (field) => field.id === REMINDERS_RELATION_FIELD_ID + ); + + const { value, setValue } = useRecordField({ + field: + remindersField?.type === 'relation' + ? remindersField + : { + id: REMINDERS_RELATION_FIELD_ID, + type: 'relation', + name: 'Reminders', + index: 'z', + databaseId: '', + }, + }); + + const relationIds = useMemo( + () => (value?.type === 'string_array' ? value.value : []), + [value] + ); + + const tasksQuery = useLiveQuery( + (q) => + q + .from({ tasks: workspace.collections.scheduledTasks }) + .orderBy(({ tasks }) => tasks.createdAt, 'desc'), + [workspace.userId] + ); + + const dueReminders = useMemo(() => { + const tasks = tasksQuery.data ?? []; + return tasks.filter((task) => + isDueDateReminder(task, database, record) + ); + }, [tasksQuery.data, database, record]); + + const canEdit = + record.createdBy === workspace.userId || hasNodeRole(role, 'editor'); + + const dialogInitialValue = useMemo((): ScheduledTaskInput | null => { + if (dialogState.mode === 'edit') { + return taskToInput(dialogState.task); + } + + if (dialogState.mode === 'create') { + return buildDueReminderInput(record, database, app.type); + } + + return null; + }, [app.type, database, dialogState, record]); + + if (!remindersField || remindersField.type !== 'relation') { + return null; + } + + const appendTaskId = (taskId: string) => { + if (relationIds.includes(taskId)) { + return; + } + + setValue({ + type: 'string_array', + value: [...relationIds, taskId], + }); + }; + + const removeTaskId = (taskId: string) => { + const nextIds = relationIds.filter((id) => id !== taskId); + if (nextIds.length === 0) { + setValue({ + type: 'string_array', + value: [], + }); + return; + } + + setValue({ + type: 'string_array', + value: nextIds, + }); + }; + + const handleSave = (draft: ScheduledTaskInput) => { + if (dialogState.mode === 'edit') { + mutate({ + input: { + type: 'scheduledTask.update', + userId: workspace.userId, + taskId: dialogState.task.id, + task: draft, + }, + onSuccess() { + toast.success('Due date reminder updated'); + setDialogState({ mode: 'closed' }); + }, + onError(error) { + toast.error(error.message); + }, + }); + return; + } + + mutate({ + input: { + type: 'scheduledTask.create', + userId: workspace.userId, + task: draft, + }, + onSuccess(output) { + if (output?.task?.id) { + appendTaskId(output.task.id); + } + toast.success('Due date reminder enabled'); + setDialogState({ mode: 'closed' }); + }, + onError(error) { + toast.error(error.message); + }, + }); + }; + + const handleDelete = (task: ScheduledTask) => { + mutate({ + input: { + type: 'scheduledTask.delete', + userId: workspace.userId, + taskId: task.id, + }, + onSuccess() { + removeTaskId(task.id); + toast.success('Due date reminder removed'); + }, + onError(error) { + toast.error(error.message); + }, + }); + }; + + const targetDateReady = hasTargetDate(record); + + return ( +
+
+
+

Due date reminder

+

+ Notify when the target date is today or earlier. +

+
+ {canEdit && targetDateReady && dueReminders.length === 0 && ( + + )} +
+ + {!targetDateReady ? ( +

+ Set a target date first to enable due reminders. +

+ ) : ( + dueReminders.map((task) => ( +
+ + {canEdit && ( + + )} +
+ )) + )} + + { + if (!nextOpen) { + setDialogState({ mode: 'closed' }); + } + }} + mode={dialogState.mode === 'edit' ? 'edit' : 'create'} + initialValue={dialogInitialValue} + record={record} + database={database} + appType={app.type} + isPending={isPending} + onSubmit={handleSave} + /> +
+ ); +}; diff --git a/packages/ui/src/components/workspaces/todo-list/todo-list-record-detail.tsx b/packages/ui/src/components/workspaces/todo-list/todo-list-record-detail.tsx new file mode 100644 index 000000000..a656d4a17 --- /dev/null +++ b/packages/ui/src/components/workspaces/todo-list/todo-list-record-detail.tsx @@ -0,0 +1,39 @@ +import { LocalDatabaseNode, LocalRecordNode } from '@colanode/client/types'; +import { NodeRole, REMINDERS_RELATION_FIELD_ID, hasNodeRole } from '@colanode/core'; +import { Document } from '@colanode/ui/components/documents/document'; +import { RecordAttributes } from '@colanode/ui/components/records/record-attributes'; +import { RecordDatabase } from '@colanode/ui/components/records/record-database'; +import { RecordProvider } from '@colanode/ui/components/records/record-provider'; +import { Separator } from '@colanode/ui/components/ui/separator'; +import { TodoListDueReminder } from '@colanode/ui/components/workspaces/todo-list/todo-list-due-reminder'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; + +interface TodoListRecordDetailProps { + record: LocalRecordNode; + database: LocalDatabaseNode; + role: NodeRole; +} + +export const TodoListRecordDetail = ({ + record, + database, + role, +}: TodoListRecordDetailProps) => { + const workspace = useWorkspace(); + const canEdit = + record.createdBy === workspace.userId || hasNodeRole(role, 'editor'); + + return ( + + + + + + + + + ); +}; 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..e64505d49 --- /dev/null +++ b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-container.tsx @@ -0,0 +1,45 @@ +import { LocalDatabaseNode } from '@colanode/client/types'; +import { NodeRole } from '@colanode/core'; +import { Database } from '@colanode/ui/components/databases/database'; +import { Container } from '@colanode/ui/components/layouts/containers/container'; +import { WorkspaceTodoListBreadcrumb } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-breadcrumb'; +import { WorkspaceTodoListViews } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-views'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; + +const workspaceRoleToNodeRole = (role: string): NodeRole => { + if (role === 'owner' || role === 'admin') { + return 'admin'; + } + + if (role === 'guest' || role === 'none') { + return 'viewer'; + } + + return 'collaborator'; +}; + +interface WorkspaceTodoListContainerProps { + database: LocalDatabaseNode; +} + +export const WorkspaceTodoListContainer = ({ + database, +}: WorkspaceTodoListContainerProps) => { + const workspace = useWorkspace(); + + return ( + }> +
+ + + +
+
+ ); +}; diff --git a/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-detail-panel.tsx b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-detail-panel.tsx new file mode 100644 index 000000000..c0e70f096 --- /dev/null +++ b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-detail-panel.tsx @@ -0,0 +1,118 @@ +import { eq, useLiveQuery } from '@tanstack/react-db'; +import { X } from 'lucide-react'; +import { useEffect } from 'react'; + +import { LocalDatabaseNode, LocalRecordNode } from '@colanode/client/types'; +import { NodeRole } from '@colanode/core'; +import { Button } from '@colanode/ui/components/ui/button'; +import { TodoListRecordDetail } from '@colanode/ui/components/workspaces/todo-list/todo-list-record-detail'; +import { useDatabase } from '@colanode/ui/contexts/database'; +import { useTodoListSplit } from '@colanode/ui/contexts/todo-list-split'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; + +interface WorkspaceTodoListDetailPanelProps { + role: NodeRole; + database: LocalDatabaseNode; +} + +export const WorkspaceTodoListDetailPanel = ({ + role, + database: databaseNode, +}: WorkspaceTodoListDetailPanelProps) => { + const workspace = useWorkspace(); + const database = useDatabase(); + const split = useTodoListSplit(); + + const selectedRecordId = split?.selectedRecordId ?? null; + + const recordQuery = useLiveQuery( + (q) => { + if (!selectedRecordId) { + return q + .from({ nodes: workspace.collections.nodes }) + .where(({ nodes }) => eq(nodes.id, '__disabled__')); + } + + return q + .from({ nodes: workspace.collections.nodes }) + .where(({ nodes }) => eq(nodes.id, selectedRecordId)); + }, + [workspace.userId, selectedRecordId] + ); + + const recordNode = (recordQuery.data ?? []).find( + (node) => node.id === selectedRecordId && node.type === 'record' + ); + const record = + recordNode?.type === 'record' ? (recordNode as LocalRecordNode) : undefined; + + useEffect(() => { + if (!record || !split) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target; + if ( + target instanceof HTMLElement && + (target.isContentEditable || + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT') + ) { + return; + } + + if (event.key === 'Escape') { + split.clearSelection(); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + split.selectAdjacentRecord('prev'); + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + split.selectAdjacentRecord('next'); + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [record, split]); + + return ( + + ); +}; 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-tab.tsx b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-tab.tsx new file mode 100644 index 000000000..29e144c36 --- /dev/null +++ b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-tab.tsx @@ -0,0 +1,6 @@ +import { TabItem } from '@colanode/ui/components/layouts/tabs/tab-item'; +import { defaultEmojis } from '@colanode/ui/lib/assets'; + +export const WorkspaceTodoListTab = () => { + return ; +}; diff --git a/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-views.tsx b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-views.tsx new file mode 100644 index 000000000..74e52a468 --- /dev/null +++ b/packages/ui/src/components/workspaces/todo-list/workspace-todo-list-views.tsx @@ -0,0 +1,107 @@ +import { eq, useLiveQuery } from '@tanstack/react-db'; +import { useCallback, useMemo } from 'react'; + +import { LocalDatabaseViewNode, LocalDatabaseNode } from '@colanode/client/types'; +import { NodeRole, todoListSelectedRecordKey } from '@colanode/core'; +import { DatabaseViews } from '@colanode/ui/components/databases/database-views'; +import { WorkspaceTodoListDetailPanel } from '@colanode/ui/components/workspaces/todo-list/workspace-todo-list-detail-panel'; +import { useDatabase } from '@colanode/ui/contexts/database'; +import { TodoListSplitProvider } from '@colanode/ui/contexts/todo-list-split'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { useCollectionQuery } from '@colanode/ui/hooks/use-collection-query'; +import { useMetadata } from '@colanode/ui/hooks/use-metadata'; + +interface WorkspaceTodoListViewsProps { + role: NodeRole; + database: LocalDatabaseNode; +} + +export const WorkspaceTodoListViews = ({ + role, + database: databaseNode, +}: WorkspaceTodoListViewsProps) => { + const workspace = useWorkspace(); + const database = useDatabase(); + const [activeViewId] = useMetadata( + workspace.userId, + `${database.id}.activeViewId` + ); + const [selectedRecordId, setSelectedRecordId] = useMetadata( + workspace.userId, + todoListSelectedRecordKey(database.id) + ); + + const viewsQuery = useLiveQuery( + (q) => + q + .from({ nodes: workspace.collections.nodes }) + .where(({ nodes }) => eq(nodes.type, 'database_view')) + .where(({ nodes }) => eq(nodes.parentId, database.id)), + [workspace.userId, database.id] + ); + + const activeView = useMemo(() => { + const views = (viewsQuery.data ?? []) as LocalDatabaseViewNode[]; + return views.find((view) => view.id === activeViewId) ?? views[0]; + }, [activeViewId, viewsQuery.data]); + + const isListView = activeView?.layout === 'list'; + + const { data: collectionRows } = useCollectionQuery( + Object.values(activeView?.filters ?? {}), + Object.values(activeView?.sorts ?? {}) + ); + + const recordIds = useMemo(() => { + return (collectionRows ?? []) + .filter((row) => row.nodeType === 'record') + .map((row) => row.id); + }, [collectionRows]); + + const selectAdjacentRecord = useCallback( + (direction: 'prev' | 'next') => { + if (!selectedRecordId || recordIds.length === 0) { + return; + } + + const currentIndex = recordIds.indexOf(selectedRecordId); + if (currentIndex === -1) { + return; + } + + const nextIndex = + direction === 'prev' ? currentIndex - 1 : currentIndex + 1; + const nextRecordId = recordIds[nextIndex]; + if (nextRecordId) { + setSelectedRecordId(nextRecordId); + } + }, + [recordIds, selectedRecordId, setSelectedRecordId] + ); + + const splitValue = useMemo( + () => ({ + selectedRecordId: selectedRecordId ?? null, + selectRecord: (recordId: string) => setSelectedRecordId(recordId), + clearSelection: () => setSelectedRecordId(undefined), + selectAdjacentRecord, + }), + [selectedRecordId, selectAdjacentRecord, setSelectedRecordId] + ); + + return ( + +
+
+ +
+ {isListView && ( + + )} +
+
+ ); +}; 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/contexts/todo-list-split.tsx b/packages/ui/src/contexts/todo-list-split.tsx new file mode 100644 index 000000000..ff72d4be7 --- /dev/null +++ b/packages/ui/src/contexts/todo-list-split.tsx @@ -0,0 +1,18 @@ +import { createContext, useContext } from 'react'; + +interface TodoListSplitContextValue { + selectedRecordId: string | null; + selectRecord: (recordId: string) => void; + clearSelection: () => void; + selectAdjacentRecord: (direction: 'prev' | 'next') => void; +} + +const TodoListSplitContext = createContext( + null +); + +export const TodoListSplitProvider = TodoListSplitContext.Provider; + +export const useTodoListSplit = (): TodoListSplitContextValue | null => { + return useContext(TodoListSplitContext); +}; 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/lib/todo-list.ts b/packages/ui/src/lib/todo-list.ts new file mode 100644 index 000000000..af85ca5da --- /dev/null +++ b/packages/ui/src/lib/todo-list.ts @@ -0,0 +1,188 @@ +import { + applyTodoListListViewFields, + buildMissingTodoListViews, + buildTodoListListViewDefaults, + buildTodoListViewFields, + getTodoListViewKey, + resolveTodoListStatusFieldId, +} from '@colanode/client/lib'; +import { LocalDatabaseNode, LocalDatabaseViewNode } from '@colanode/client/types'; +import { TODO_LIST_FIELD_IDS, TODO_LIST_SYSTEM_KIND } from '@colanode/core'; +import { WorkspaceCollections } from '@colanode/ui/collections'; +import { updateNodeInCollection } from '@colanode/ui/lib/nodes'; + +type WorkspaceLike = { + userId: string; + collections: WorkspaceCollections; +}; + +export const isTodoListSettingsPath = (pathname: string): boolean => + pathname.includes('/settings/todo-list'); + +export const shouldIgnoreListRowNavigation = ( + target: EventTarget | null +): boolean => { + return Boolean( + target instanceof Element && + target.closest( + 'input, textarea, button, a, [role="combobox"], [data-radix-popper-content-wrapper]' + ) + ); +}; + +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 statusFieldId = resolveTodoListStatusFieldId(database.fields ?? {}); + + for (const view of sortedViews) { + const viewKey = getTodoListViewKey({ + layout: view.layout, + groupBy: view.groupBy, + name: view.name, + }); + const mergedViewFields = + view.layout === 'list' + ? applyTodoListListViewFields(view.fields ?? {}) + : buildTodoListViewFields({ + layout: + view.layout === 'table' || view.layout === 'board' + ? view.layout + : 'table', + }); + const listDefaults = + view.layout === 'list' ? buildTodoListListViewDefaults() : null; + const hasNewViewFields = + Object.keys(mergedViewFields).length > Object.keys(view.fields ?? {}).length; + const needsGroupBy = view.layout === 'board' && !view.groupBy; + const needsListDefaults = + view.layout === 'list' && + ((!view.filters || Object.keys(view.filters).length === 0) || + (!view.sorts || Object.keys(view.sorts).length === 0)); + + if (!hasNewViewFields && !needsGroupBy && !needsListDefaults) { + continue; + } + + await updateNodeInCollection( + workspace.collections.nodes, + workspace.userId, + view, + (draft) => { + if (draft.type !== 'database_view') { + return; + } + + draft.fields = + draft.layout === 'list' + ? applyTodoListListViewFields(draft.fields ?? {}) + : buildTodoListViewFields({ + layout: + draft.layout === 'table' || draft.layout === 'board' + ? draft.layout + : 'table', + }); + + if (draft.layout === 'list' && listDefaults) { + if (!draft.filters || Object.keys(draft.filters).length === 0) { + draft.filters = listDefaults.filters; + } + if (!draft.sorts || Object.keys(draft.sorts).length === 0) { + draft.sorts = listDefaults.sorts; + } + } + + if (draft.layout === 'board' && !draft.groupBy) { + draft.groupBy = + viewKey === `board:${TODO_LIST_FIELD_IDS.labels}` + ? TODO_LIST_FIELD_IDS.labels + : statusFieldId; + } + } + ); + } + + const existingViewKeys = new Set( + sortedViews.map((view) => + getTodoListViewKey({ + layout: view.layout, + groupBy: view.groupBy, + name: view.name, + }) + ) + ); + const lastViewIndex = sortedViews.at(-1)?.index ?? null; + + const missingViews = buildMissingTodoListViews({ + databaseId: database.id, + rootId: database.rootId, + userId: workspace.userId, + existingViewKeys, + 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: workspace.userId, + filters: [ + { field: ['type'], operator: 'in', value: ['database'] }, + { field: ['systemKind'], operator: 'eq', value: TODO_LIST_SYSTEM_KIND }, + ], + sorts: [], + limit: 1, + }); + + const node = databases[0]; + 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); +}; 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..c8579382c --- /dev/null +++ b/packages/ui/src/routes/workspace/todo-list-settings.tsx @@ -0,0 +1,36 @@ +import { createRoute, redirect } from '@tanstack/react-router'; + +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 { + workspaceRoute, + workspaceMaskRoute, +} from '@colanode/ui/routes/workspace'; + +export const workspaceTodoListRoute = createRoute({ + getParentRoute: () => workspaceRoute, + path: '/settings/todo-list', + component: WorkspaceTodoListPage, + 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, + }); + } + }, +});