diff --git a/apps/web/vite.config.js b/apps/web/vite.config.js index aa1c224bb..c8532b13d 100644 --- a/apps/web/vite.config.js +++ b/apps/web/vite.config.js @@ -48,4 +48,41 @@ export default defineConfig({ }, }), ], + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (!id.includes('node_modules')) { + return undefined; + } + + if ( + id.includes('/react/') || + id.includes('/react-dom/') || + id.includes('/scheduler/') + ) { + return 'vendor-react'; + } + + if ( + id.includes('/yjs/') || + id.includes('/y-protocols/') || + id.includes('/lib0/') + ) { + return 'vendor-crdt'; + } + + if ( + id.includes('/@radix-ui/') || + id.includes('/@dnd-kit/') || + id.includes('/lucide-react/') + ) { + return 'vendor-ui'; + } + + return undefined; + }, + }, + }, + }, }); diff --git a/docs/file-meta-types.md b/docs/file-meta-types.md new file mode 100644 index 000000000..e106cf086 --- /dev/null +++ b/docs/file-meta-types.md @@ -0,0 +1,486 @@ +# File Meta Types — Design + +> Status: **implemented (Phase 1 MVP)** +> Branch target: `main` +> Related: [page-meta-types.md](./page-meta-types.md), [asset-library.md](./asset-library.md), [roadmap.md](./roadmap.md) §4 + +## Goal + +Generalize the existing **Meta Types** model from `page` to `file`, so binary assets can carry structured `fields` (Status, Tags, Owner, …), appear in aggregated database views, and participate in automation — without building a full DAM. + +**Non-goals (this project):** + +- Full digital asset management (version branches, rights management, CDN) +- Merging Asset Library into File Meta Types (Asset Library stays **static resources**) +- Workspace global tags +- Changing Page Meta Types behavior + +--- + +## Confirmed product decisions + +| # | Question | Decision | +|---|----------|----------| +| 1 | All new files require `metaTypeId`? | **Yes** — default to canonical **File** meta type when user skips picker | +| 2 | Calendar view for file meta types? | **Reuse in MVP** — same calendar component as pages where date fields exist | +| 3 | Editor-attached files inherit page meta type? | **No** — always use file meta type (default **File**) | +| 4 | Separate hidden space for file meta types? | **No** — same Meta Types space; `instanceKind` badge distinguishes schemas | +| 5 | Asset Library vs File Meta Types? | **Static resources** — Asset Library remains a separate static-asset subsystem; no convergence | + +--- + +## Product summary + +| Today | After File Meta Types | +|-------|------------------------| +| `page` has `metaTypeId` + `fields` | `file` also has `metaTypeId` + `fields` | +| Meta type DB views aggregate **pages** | Meta type DB views can aggregate **files** (per schema) | +| `file` only has binary metadata (mime, size, …) | Typed files get user-defined properties | +| Asset Library metadata lives on **records** | **Permanent** — static assets; metadata on record, not File Meta Types | + +**User stories (MVP):** + +1. Admin creates a file meta type (e.g. **Contract**, **Photo**) with custom fields. +2. User uploads a PDF to a Space folder and picks **Contract**; fields appear in a properties panel. +3. Settings → Meta Types → **Contract** shows a Gallery/Table of all contract files workspace-wide. +4. Existing page-attachment files can be backfilled to a default **File** meta type (system). + +--- + +## Core model + +### Three layers (same as Page Meta Types) + +| Layer | Node | Key attributes | Tree placement | +|-------|------|----------------|----------------| +| Schema | `database` (`purpose: meta_type`) | `fields`, **`instanceKind`** | Hidden Meta Types space | +| Instance | `file` | `metaTypeId`, `fields`, binary attrs | Space / folder / record child / message attachment | +| Collection | `database_view` | filters, layout | Under the meta type database | + +**Linking pattern:** Files follow the **page pattern** — instances live elsewhere in the tree; the meta type database does **not** own them as children. Collection queries use: + +```sql +n.type = 'file' AND n.meta_type_id = '' +``` + +Records DBs continue to use `parent_id = databaseId`. + +### New schema field: `instanceKind` + +Add to `database` attributes when `purpose === 'meta_type'`: + +```ts +instanceKind: 'page' | 'file' // default: 'page' (backward compatible) +``` + +| `instanceKind` | Aggregates | Default name constant | +|----------------|------------|------------------------| +| `page` | `page` nodes | `DEFAULT_PAGE_META_TYPE_NAME` (`Page`) | +| `file` | `file` nodes | `DEFAULT_FILE_META_TYPE_NAME` (`File`) | + +**Rules:** + +- Immutable after create (like `metaTypeId` on instances). +- UI shows instance kind when creating a meta type schema. +- `purpose: asset_library` is unchanged (`instanceKind` N/A). + +### File instance attributes (extended) + +```ts +// packages/core/src/registry/nodes/file.ts +export const fileAttributesSchema = z.object({ + type: z.literal('file'), + subtype: fileSubtypeSchema, + parentId: z.string(), + index: z.string().optional(), + name: z.string(), + originalName: z.string(), + mimeType: z.string(), + extension: z.string(), + size: z.number(), + version: z.string(), + status: z.number(), + metaTypeId: z.string(), // NEW — required for new files + fields: z.record(z.string(), fieldValueSchema).optional(), // NEW +}); +``` + +**Immutability:** Mirror `pageModel.canUpdateAttributes` — `metaTypeId` cannot change unless `allowMetaTypeIdChange: true` (system backfill only). + +**Name field:** Display name remains `file.name` (editable). Schema may add separate text fields. + +--- + +## Relationship to Asset Library + +### Product boundary: static resources + +**Decision:** Asset Library is a **static resource subsystem** — binary catalog for uploads/imports (Gallery, Folder filters). It is **not** part of File Meta Types and will **not** be merged in a later phase. + +| Concern | Asset Library (static) | File Meta Types (typed workspace files) | +|---------|------------------------|----------------------------------------| +| Metadata home | `record.fields` (`__colanode_asset_*`) | `file.metaTypeId` + `file.fields` | +| Aggregation | `purpose: asset_library` views | `purpose: meta_type`, `instanceKind: file` | +| Upload path | `importAssetFilesToLibrary` | `file.create` + meta type picker | +| User mental model | Media / static asset bin | Structured files in workspace (contracts, photos, attachments) | + +### Architecture today + +``` +record (Assets DB) ──fields──► __colanode_asset_file → [fileId] + __colanode_asset_folder, tags, … +file node (binary child of record) +``` + +### Rules (locked) + +1. **No dual-write** — never copy asset record fields onto `file.fields` or vice versa. +2. **Asset library binaries are exempt from File Meta Types aggregation** — queries for file meta type views exclude files whose parent is an asset-library `record` (see `isAssetLibraryFile` helper below). +3. **`metaTypeId` on asset-library child files** — optional at binary level for schema consistency; if backfilled, use default **File** meta type but **exclude from** `file.byMetaType` / meta type DB views via parent filter. UI for static assets stays record-based. +4. **No Phase 3 merge** — Asset Library import, Gallery, and Folder filter flows remain unchanged. + +```ts +// packages/client/src/lib/file-meta-types.ts (planned) +export const isAssetLibraryFile = ( + file: FileNode, + parent: LocalNode | null +): boolean => + parent?.type === 'record' && + parent.parentId === assetLibraryDatabaseId; // resolved per workspace +``` + +**Phase 1 explicit scope:** + +- **In scope:** `file.create` with `metaTypeId`; folder direct upload; editor file blocks; record `file` field uploads. +- **Out of scope:** Asset Library import, Gallery cards, record wrapper, Folder asset filter queries. +- **Editor files:** default **File** meta type only — never inherit hosting page’s `metaTypeId`. + +### Folder dual-path note + +| Path | Active? | File Meta Types | +|------|---------|-----------------| +| Folder → filter asset **records** (`folder-container.tsx`) | Yes | **Unchanged** (static resources) | +| Folder → direct **file** children | Legacy / partial | **Target for typed uploads** | + +MVP adds typed upload on the **direct file** folder path. Asset Library folder flow stays record-based. + +## SQL & indexing + +**No new migration required** for `meta_type_id`: + +```sql +-- Already exists (VIRTUAL) +meta_type_id = json_extract(attributes, '$.metaTypeId') +``` + +Index `nodes_meta_type_id_type_index` on `(meta_type_id, type)` supports `type = 'file'` queries. + +**Optional Phase 2:** partial index or materialized counts if file volume is high (not MVP). + +--- + +## Client architecture changes + +### 1. Collection source resolution + +**File:** `packages/client/src/lib/record-query/resolve-source.ts` + +```ts +export type DatabaseCollectionNodeType = 'record' | 'page' | 'file'; + +// purpose === 'meta_type' +nodeType = database.instanceKind ?? 'page'; + +// buildCollectionBaseWhereClause +if (nodeType === 'file') { + return `n.type = 'file' AND n.meta_type_id = '${databaseId}'`; +} +``` + +Thread `nodeType: 'file'` through: + +- `build-sql.ts` (list + aggregate queries) +- `database-query.ts` handler (`mapFileRow`) +- `DatabaseQueryRow` type +- `collection-row.ts` (UI) +- `collection-apply-actions.ts` (inline field edits) + +### 2. Queries + +**New:** `file.byMetaType` + +```ts +{ + type: 'file.byMetaType'; + userId: string; + metaTypeId: string; + searchQuery?: string; + exclude?: string[]; +} +``` + +Mirror `page.byMetaType` handler; filter `type = 'file'`, optional `node_texts` search on name. + +**Extend:** `useMetaTypeDatabases` → expose `instanceKind` for filtering UI. + +### 3. Mutations + +**Extend:** `file.create` input + +```ts +{ + type: 'file.create'; + userId: string; + parentId: string; + tempFileId: string; + metaTypeId: string; // NEW — required + initialFields?: Record<...>; // NEW — optional +} +``` + +`FileService.createFile`: + +1. Resolve `metaTypeId` (explicit or default file meta type). +2. `resolveInitialFileFields(metaTypeId, initialFields)` — same pattern as pages. +3. Write attributes including `metaTypeId` + `fields`. + +**Backfill:** `ensureFileMetaTypes` assigns default meta type to files missing `metaTypeId` (system-only `allowMetaTypeIdChange`). + +### 4. Bootstrap + +**New file:** `packages/client/src/lib/file-meta-types.ts` + +| Function | Purpose | +|----------|---------| +| `ensureFileMetaTypes(workspace)` | Create default **File** meta type; seed starter presets; backfill orphan files | +| `findDefaultFileMetaTypeId(workspace)` | Canonical default schema id | +| `consolidateDuplicateFileMetaTypes` | Dedup default **File** schemas (mirror pages) | + +**Invocation order** (same as pages): + +``` +cloud-sync first import (if any) → initWorkspace → ensurePageMetaTypes → ensureFileMetaTypes +``` + +**Starter file meta type presets (MVP):** + +| Name | Fields | +|------|--------| +| `File` (default) | Tags (multi_select), Description (text), Source (text) | +| `Photo` | Caption, Location, Taken at (date), License (select) | +| `Contract` | Status (select), Counterparty, Effective date, Expiry date | +| `Design` | Status, Tool, Version, Reviewer (collaborator) | + +### 5. Constants + +**File:** `packages/core/src/lib/meta-types.ts` + +```ts +export const DEFAULT_FILE_META_TYPE_NAME = 'File'; +export type MetaTypeInstanceKind = 'page' | 'file'; +``` + +--- + +## UI design + +### Settings → Meta Types + +- Create dialog: **Instance type** radio — Page / File. +- List: badge or subtitle showing Page vs File meta types. +- File meta type schema editor: reuse existing field editor (no document tab). + +### Meta type database views + +| Layout | File-specific behavior | +|--------|------------------------| +| Gallery | `FileGalleryCard` — thumbnail from file binary, name, key fields | +| Table | **File ID** column (copy); inline field edit | +| List | Compact row + detail panel | +| Board | Group by select / multi_select (same as pages) | +| Calendar | **Reuse page calendar** — group by date field in MVP | + +**Labels:** `isFileMetaType` branches — "Add file", "No files", "File ID" (parallel to page strings). + +**Create:** `MetaTypeFileCreateButton` — upload dialog with meta type fixed to current schema; pick parent folder/space. + +### File detail + +**New:** `FileMetadata` panel (mirror `PageMetadata`) + +- Read-only meta type name +- Editable schema fields via `FileFieldsProvider` → `EntityFieldsContext` +- Binary props (mime, size) stay in `FileSidebar` + +**Route:** `FileContainer` adds properties tab or split sidebar. + +### Upload entry points (Phase 1) + +| Entry | Change | +|-------|--------| +| Space/folder upload (direct file path) | Meta type picker + optional field defaults | +| Editor file block | Default **File** meta type only (never page meta type) | +| Record `file` field upload | Default file meta type; fields on **file**, not duplicated on record | +| Asset Library import | **No change** | + +--- + +## Scheduled tasks (Phase 2) + +Defer to Phase 2; design hooks now: + +| Check type | Trigger | +|------------|---------| +| `meta_type_files_created` | New file with matching `metaTypeId` | +| `meta_type_file_field_changed` | File field update | +| `meta_type_file_field_match` | Scheduled query over file fields | +| View digest | Reuse `database_query` with file meta type DB | + +Mirror `scheduled-task-service.ts` event matching using `node.metaTypeId` on files. + +--- + +## Local sync + +| Concern | Approach | +|---------|----------| +| `metaTypeId` + `fields` on file | Sync via existing `node.update` op-log | +| Binary bytes | Unchanged (`exportFiles` / shared storage) | +| Bootstrap dedup | `ensureFileMetaTypes` after first import (same guard as pages) | +| Tombstones | File delete already tombstones node + GC blob | + +--- + +## Migration & backfill + +### Existing files + +1. `ensureFileMetaTypes` creates default **File** meta type (`instanceKind: file`). +2. Scan `nodes WHERE type = 'file' AND meta_type_id IS NULL`. +3. For each **non–asset-library** file: `updateNode({ metaTypeId: defaultId, fields: {} }, { allowMetaTypeIdChange: true })`. +4. Asset-library child files: optional schema backfill only; **excluded from file meta type view queries** via `isAssetLibraryFile` parent check. Catalog metadata remains on the asset **record**. + +### Existing page meta type databases + +- `instanceKind` absent → treat as `'page'` (no data migration). + +### New workspace + +- `ensurePageMetaTypes` + `ensureFileMetaTypes` both run on init. + +--- + +## Permissions + +Reuse node role checks on `fileModel`: + +| Action | Rule | +|--------|------| +| Create typed file | Editor on parent | +| Edit `fields` | Editor on file (via tree role) | +| Change `metaTypeId` | Denied (except system backfill) | +| Delete file | Existing `canDelete` rules | + +Meta type schema edits: same as page meta types (admin on schema DB). + +--- + +## Testing plan + +| Area | Tests | +|------|-------| +| Schema | `fileAttributesSchema` + `canUpdateAttributes` immutability | +| Bootstrap | `file-meta-types.test.ts` — create default, backfill, dedup | +| Query | `file.byMetaType`, `database.query` with `instanceKind: file` | +| Mutation | `file.create` with `metaTypeId` + initial fields | +| Collection actions | Inline edit file field from table view | +| Sync | `cloud-sync.test.ts` — file meta survives import/export roundtrip | +| UI | Vitest for `resolveInitialFileFields`; manual gallery + upload smoke | + +--- + +## Implementation phases + +### Phase 1 — MVP (ship first) + +**Core** + +- [ ] `instanceKind` on meta type databases (default `page`) +- [ ] `file.metaTypeId` + `fields` in core schema + immutability +- [ ] `ensureFileMetaTypes` + default **File** schema + backfill +- [ ] `resolveDatabaseCollectionSource` + SQL for `file` +- [ ] `file.byMetaType` query +- [ ] `file.create` accepts `metaTypeId` +- [ ] `database.query` maps file rows + +**UI** + +- [ ] Meta type create: pick Page / File +- [ ] `FileMetadata` + `FileFieldsProvider` +- [ ] Meta type views: gallery/table/list/**calendar**/board for files +- [ ] Upload with meta type picker (folder direct path) + +**Docs** + +- [ ] This doc → status **implemented** when done +- [ ] Update `roadmap.md`, `AGENTS.md`, skill reference + +### Phase 2 — Automation & polish + +- [x] Scheduled task checks for files (`meta_type_files_created`, `meta_type_file_field_changed`) +- [x] `meta_type_view_digest` for file meta types +- [x] Relation field: search `file.byMetaType` when target is file meta type +- [x] Folder direct upload: meta type picker before upload +- [x] File meta type starter presets beyond default + +*(No Phase 3 Asset Library work — static resources remain separate.)* + +--- + +## Resolved decisions (was open questions) + +1. **`metaTypeId` required on new files** — Yes; default **File** meta type if picker skipped. Asset-library binaries excluded from typed aggregation (see static resources rules). +2. **Calendar view** — Include in MVP; reuse page calendar component. +3. **Board drag to update file fields** — Yes; same `collection-apply-actions` path as pages. +4. **Editor-attached files** — Never inherit page meta type; default **File** only. +5. **Meta Types space** — Shared with page schemas; `instanceKind` distinguishes. +6. **Asset Library** — Static resource subsystem; permanent separation from File Meta Types. + +## File map (implementation touchpoints) + +| Area | Paths | +|------|-------| +| Core schema | `packages/core/src/registry/nodes/file.ts`, `database.ts`, `lib/meta-types.ts` | +| Bootstrap | `packages/client/src/lib/file-meta-types.ts` (new) | +| Query routing | `packages/client/src/lib/record-query/resolve-source.ts`, `build-sql.ts` | +| Handlers | `handlers/queries/files/`, `handlers/mutations/files/file-create.ts` | +| Services | `file-service.ts`, `app-service.ts` (init order) | +| UI metadata | `components/files/file-metadata.tsx` (new), `file-container.tsx` | +| Meta type UI | `meta-type-create-dialog.tsx`, `database.tsx`, view components | +| Tests | `packages/client/test/file-meta-types.test.ts` (new) | + +--- + +## Diagram + +```mermaid +flowchart TB + subgraph schema [Schema layer] + MT_PAGE["database purpose=meta_type instanceKind=page"] + MT_FILE["database purpose=meta_type instanceKind=file"] + end + + subgraph instances [Instances in Space tree] + PAGE[page metaTypeId fields] + FILE[file metaTypeId fields binary] + end + + subgraph asset [Asset Library — static resources] + REC[record fields metadata] + REC --> FILE_ASSET[file binary only] + end + + MT_PAGE -->|meta_type_id| PAGE + MT_FILE -->|meta_type_id excludes asset-library files| FILE + REC -.->|__colanode_asset_file no meta type views| FILE_ASSET +``` diff --git a/docs/roadmap.md b/docs/roadmap.md index c654541fd..ac6327c38 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,6 +9,7 @@ | 主题 | 文档 | |------|------| | Page Meta Types | [page-meta-types.md](./page-meta-types.md) | +| File Meta Types | [file-meta-types.md](./file-meta-types.md) | | TodoList(系统任务库) | [todo-list.md](./todo-list.md) | | 本地同步 | [local-sync.md](./local-sync.md) | | Page Mermaid | [mermaid.md](./mermaid.md) | @@ -222,7 +223,8 @@ → ~~Mermaid polish + docs~~ ✓ → ~~Scheduled Tasks view digest + notification node nav~~ ✓ → ~~Local Sync compaction + semantic warnings~~ ✓ - → **5 白板** 或 **资源 Meta Types** ← 当前 + → ~~File Meta Types (Phase 1)~~ ✓ + → **5 白板** 或 **资源 Meta Types 深化** ← 当前 ``` ### 研发效率优先(AI 协作开发) diff --git a/packages/client/src/handlers/mutations/collections/collection-apply-actions.ts b/packages/client/src/handlers/mutations/collections/collection-apply-actions.ts index 92bc7536f..fd3bc775a 100644 --- a/packages/client/src/handlers/mutations/collections/collection-apply-actions.ts +++ b/packages/client/src/handlers/mutations/collections/collection-apply-actions.ts @@ -11,6 +11,7 @@ import { } from '@colanode/client/mutations'; import { DatabaseNode, + FileAttributes, PageAttributes, RecordAttributes, } from '@colanode/core'; @@ -95,9 +96,47 @@ export class CollectionApplyActionsMutationHandler return { success: true }; } + if (attributes.type === 'file') { + if (!attributes.metaTypeId) { + throw new MutationError( + MutationErrorCode.FieldUpdateForbidden, + 'Untyped files do not support field actions.' + ); + } + + const database = await this.fetchDatabase( + workspace.database, + attributes.metaTypeId + ); + const result = await workspace.nodes.updateNode( + input.nodeId, + (current) => { + const withFields = { + ...current, + fields: current.fields ?? {}, + }; + + return applyCollectionFieldActions( + withFields, + input.actions, + database.fields ?? {} + ); + } + ); + + if (result !== 'success') { + throw new MutationError( + MutationErrorCode.FieldUpdateFailed, + 'Failed to apply file actions.' + ); + } + + return { success: true }; + } + throw new MutationError( MutationErrorCode.FieldUpdateForbidden, - 'Actions are only supported on record and page nodes.' + 'Actions are only supported on record, page, and file nodes.' ); } diff --git a/packages/client/src/handlers/mutations/files/file-create.ts b/packages/client/src/handlers/mutations/files/file-create.ts index 6ebcd05ad..10940a63b 100644 --- a/packages/client/src/handlers/mutations/files/file-create.ts +++ b/packages/client/src/handlers/mutations/files/file-create.ts @@ -1,7 +1,6 @@ import { WorkspaceMutationHandlerBase } from '@colanode/client/handlers/mutations/workspace-mutation-handler-base'; import { MutationHandler } from '@colanode/client/lib/types'; -import { - FileCreateMutationInput, +import { FileCreateMutationInput, FileCreateMutationOutput, } from '@colanode/client/mutations'; import { generateId, IdType } from '@colanode/core'; @@ -16,7 +15,10 @@ export class FileCreateMutationHandler const workspace = this.getWorkspace(input.userId); const fileId = generateId(IdType.File); - await workspace.files.createFile(fileId, input.tempFileId, input.parentId); + await workspace.files.createFile(fileId, input.tempFileId, input.parentId, { + metaTypeId: input.metaTypeId, + initialFields: input.initialFields, + }); return { id: fileId, diff --git a/packages/client/src/handlers/queries/databases/database-query.ts b/packages/client/src/handlers/queries/databases/database-query.ts index b261b4844..6b4bbddba 100644 --- a/packages/client/src/handlers/queries/databases/database-query.ts +++ b/packages/client/src/handlers/queries/databases/database-query.ts @@ -3,6 +3,7 @@ import { sql } from 'kysely'; import { SelectNode } from '@colanode/client/databases/workspace'; import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base'; import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib'; +import { findAssetLibraryDatabaseId } from '@colanode/client/lib/file-meta-types'; import { mapNode } from '@colanode/client/lib/mappers'; import { isArchivedNodeAttributes } from '@colanode/client/lib/nodes'; import { @@ -18,7 +19,7 @@ import { DatabaseQueryRow, } from '@colanode/client/queries/databases/database-query'; import { Event } from '@colanode/client/types/events'; -import { LocalPageNode, LocalRecordNode } from '@colanode/client/types/nodes'; +import { LocalFileNode, LocalPageNode, LocalRecordNode } from '@colanode/client/types/nodes'; import { DatabaseNode, FieldValue, @@ -35,7 +36,7 @@ export class DatabaseQueryHandler input: DatabaseQueryInput ): Promise { const database = await this.fetchDatabase(input); - const source = resolveDatabaseCollectionSource(database); + const source = await this.resolveSource(input, database); if (this.isAggregateQuery(input)) { return { @@ -108,6 +109,22 @@ export class DatabaseQueryHandler }; } + private async resolveSource( + input: DatabaseQueryInput, + database: DatabaseNode + ) { + const source = resolveDatabaseCollectionSource(database); + + if (source.nodeType === 'file') { + const workspace = this.getWorkspace(input.userId); + source.assetLibraryDatabaseId = await findAssetLibraryDatabaseId( + workspace.database + ); + } + + return source; + } + private isAggregateQuery(input: DatabaseQueryInput): boolean { return (input.aggregate?.length ?? 0) > 0; } @@ -166,7 +183,7 @@ export class DatabaseQueryHandler private mapNodeRow( row: SelectNode, - nodeType: 'record' | 'page' + nodeType: 'record' | 'page' | 'file' ): DatabaseQueryRow { const node = mapNode(row); @@ -174,6 +191,10 @@ export class DatabaseQueryHandler return this.mapPageRow(node); } + if (nodeType === 'file' && node.type === 'file') { + return this.mapFileRow(node); + } + if (node.type === 'record') { return this.mapRecordRow(node); } @@ -201,6 +222,23 @@ export class DatabaseQueryHandler }; } + private mapFileRow(file: LocalFileNode): DatabaseQueryRow { + return { + id: file.id, + name: file.name, + nodeType: 'file', + rootId: file.rootId, + parentId: file.parentId, + metaTypeId: file.metaTypeId, + avatar: null, + fields: file.fields ?? {}, + createdAt: file.createdAt, + createdBy: file.createdBy, + updatedAt: file.updatedAt, + updatedBy: file.updatedBy, + }; + } + private mapPageRow(page: LocalPageNode): DatabaseQueryRow { return { id: page.id, @@ -309,5 +347,9 @@ const sourceMatchesEvent = (input: DatabaseQueryInput, event: Event): boolean => return event.node.metaTypeId === input.databaseId; } + if (event.node.type === 'file') { + return event.node.metaTypeId === input.databaseId; + } + return false; }; diff --git a/packages/client/src/handlers/queries/files/file-by-meta-type.ts b/packages/client/src/handlers/queries/files/file-by-meta-type.ts new file mode 100644 index 000000000..a3e6b7253 --- /dev/null +++ b/packages/client/src/handlers/queries/files/file-by-meta-type.ts @@ -0,0 +1,157 @@ +import { sql } from 'kysely'; + +import { SelectNode } from '@colanode/client/databases/workspace'; +import { WorkspaceQueryHandlerBase } from '@colanode/client/handlers/queries/workspace-query-handler-base'; +import { ChangeCheckResult, QueryHandler } from '@colanode/client/lib'; +import { findAssetLibraryDatabaseId } from '@colanode/client/lib/file-meta-types'; +import { mapNode } from '@colanode/client/lib/mappers'; +import { isArchivedNodeAttributes } from '@colanode/client/lib/nodes'; +import { FileByMetaTypeQueryInput } from '@colanode/client/queries/files/file-by-meta-type'; +import { Event } from '@colanode/client/types/events'; +import { LocalFileNode } from '@colanode/client/types/nodes'; + +export class FileByMetaTypeQueryHandler + extends WorkspaceQueryHandlerBase + implements QueryHandler +{ + public async handleQuery( + input: FileByMetaTypeQueryInput + ): Promise { + const rows = + (input.searchQuery?.length ?? 0) > 0 + ? await this.searchFiles(input) + : await this.fetchFiles(input); + + return rows + .filter((row) => !isArchivedNodeAttributes(row.type, row.attributes)) + .map((row) => mapNode(row) as LocalFileNode); + } + + public async checkForChanges( + event: Event, + input: FileByMetaTypeQueryInput, + _: LocalFileNode[] + ): Promise> { + if ( + event.type === 'workspace.deleted' && + event.workspace.userId === input.userId + ) { + return { hasChanges: true, result: [] }; + } + + if ( + (event.type === 'node.created' || + event.type === 'node.updated' || + event.type === 'node.deleted') && + event.workspace.userId === input.userId && + event.node.type === 'file' + ) { + return { + hasChanges: true, + result: await this.handleQuery(input), + }; + } + + return { hasChanges: false }; + } + + private async searchFiles( + input: FileByMetaTypeQueryInput + ): Promise { + const workspace = this.getWorkspace(input.userId); + const exclude = input.exclude ?? []; + const searchQuery = input.searchQuery ?? ''; + const assetLibraryId = await findAssetLibraryDatabaseId(workspace.database); + const assetExclude = assetLibraryId + ? sql`AND NOT EXISTS ( + SELECT 1 FROM nodes parent + WHERE parent.id = n.parent_id + AND parent.type = 'record' + AND parent.parent_id = ${assetLibraryId} + )` + : sql``; + + const query = sql` + SELECT n.* + FROM nodes n + JOIN node_texts nt ON n.id = nt.id + WHERE n.type = 'file' + AND n.meta_type_id = ${input.metaTypeId} + AND ( + nt.name LIKE ${'%' + searchQuery + '%'} + OR nt.attributes LIKE ${'%' + searchQuery + '%'} + ) + ${assetExclude} + ${ + exclude.length > 0 + ? sql`AND n.id NOT IN (${sql.join( + exclude.map((id) => sql`${id}`), + sql`, ` + )})` + : sql`` + } + `.compile(workspace.database); + + const result = await workspace.database.executeQuery(query); + return result.rows; + } + + private async fetchFiles( + input: FileByMetaTypeQueryInput + ): Promise { + const workspace = this.getWorkspace(input.userId); + const exclude = input.exclude ?? []; + const assetLibraryId = await findAssetLibraryDatabaseId(workspace.database); + + let query = workspace.database + .selectFrom('nodes') + .where('type', '=', 'file') + .where('meta_type_id', '=', input.metaTypeId) + .selectAll(); + + if (exclude.length > 0) { + query = query.where('id', 'not in', exclude); + } + + const rows = await query.execute(); + + if (!assetLibraryId) { + return rows; + } + + const assetParentIds = new Set(); + for (const row of rows) { + const attributes = JSON.parse(row.attributes) as { parentId?: string }; + if (attributes.parentId) { + assetParentIds.add(attributes.parentId); + } + } + + if (assetParentIds.size === 0) { + return rows; + } + + const parents = await workspace.database + .selectFrom('nodes') + .select(['id', 'type', 'parent_id']) + .where('id', 'in', [...assetParentIds]) + .execute(); + + const staticAssetParentIds = new Set( + parents + .filter( + (parent) => + parent.type === 'record' && parent.parent_id === assetLibraryId + ) + .map((parent) => parent.id) + ); + + return rows.filter((row) => { + const attributes = JSON.parse(row.attributes) as { parentId?: string }; + return !( + attributes.parentId && + staticAssetParentIds.has(attributes.parentId) + ); + }); + } +} diff --git a/packages/client/src/handlers/queries/index.ts b/packages/client/src/handlers/queries/index.ts index 6e013058e..c7a039f7b 100644 --- a/packages/client/src/handlers/queries/index.ts +++ b/packages/client/src/handlers/queries/index.ts @@ -16,6 +16,7 @@ import { EmojiGetBySkinIdQueryHandler } from './emojis/emoji-get-by-skin-id'; import { EmojiListQueryHandler } from './emojis/emoji-list'; import { EmojiSearchQueryHandler } from './emojis/emoji-search'; import { EmojiSvgGetQueryHandler } from './emojis/emoji-svg-get'; +import { FileByMetaTypeQueryHandler } from './files/file-by-meta-type'; import { DownloadListQueryHandler } from './files/download-list'; import { FileDownloadRequestGetQueryHandler } from './files/file-download-request-get'; import { LocalFileGetQueryHandler } from './files/local-file-get'; @@ -53,6 +54,7 @@ export const buildQueryHandlerMap = (app: AppService): QueryHandlerMap => { 'node.reaction.list': new NodeReactionsListQueryHandler(app), 'node.list': new NodeListQueryHandler(app), 'page.byMetaType': new PageByMetaTypeQueryHandler(app), + 'file.byMetaType': new FileByMetaTypeQueryHandler(app), 'node.backlinks.get': new NodeBacklinksGetQueryHandler(app), 'record.field.value.count': new RecordFieldValueCountQueryHandler(app), 'database.query': new DatabaseQueryHandler(app), diff --git a/packages/client/src/lib/file-meta-types.ts b/packages/client/src/lib/file-meta-types.ts new file mode 100644 index 000000000..6ac303260 --- /dev/null +++ b/packages/client/src/lib/file-meta-types.ts @@ -0,0 +1,346 @@ +import { Kysely } from 'kysely'; + +import { WorkspaceDatabaseSchema } from '@colanode/client/databases'; +import { + buildPresetFields, + findOrCreateMetaTypesSpace, +} from '@colanode/client/lib/page-meta-types'; +import { fetchNode } from '@colanode/client/lib/utils'; +import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; +import { + ASSET_LIBRARY_SYSTEM_KIND, + createDebugger, + DatabaseAttributes, + DEFAULT_FILE_META_TYPE_NAME, + FileAttributes, + generateFractionalIndex, + generateId, + getMetaTypeInstanceKind, + IdType, + NodeAttributes, +} from '@colanode/core'; + +const debug = createDebugger('desktop:file-meta-types'); + +const isFileMetaTypeDatabase = (attributes: NodeAttributes): boolean => + attributes.type === 'database' && + (attributes.purpose ?? 'records') === 'meta_type' && + getMetaTypeInstanceKind(attributes) === 'file'; + +type FileMetaTypeRow = { + id: string; + attributes: DatabaseAttributes; + createdAt: string; +}; + +type FileMetaTypePreset = { + name: string; + fields: Parameters[0]; +}; + +const DEFAULT_FILE_META_TYPE_PRESETS: FileMetaTypePreset[] = [ + { + name: 'Photo', + fields: [ + { type: 'text', name: 'Caption' }, + { type: 'text', name: 'Location' }, + { type: 'date', name: 'Taken at' }, + { + type: 'select', + name: 'License', + options: [ + { name: 'All rights reserved', color: 'gray' }, + { name: 'CC BY', color: 'blue' }, + { name: 'CC0', color: 'green' }, + ], + }, + ], + }, + { + name: 'Contract', + fields: [ + { + type: 'select', + name: 'Status', + options: [ + { name: 'Draft', color: 'gray' }, + { name: 'Review', color: 'blue' }, + { name: 'Signed', color: 'green' }, + { name: 'Expired', color: 'red' }, + ], + }, + { type: 'text', name: 'Counterparty' }, + { type: 'date', name: 'Effective date' }, + { type: 'date', name: 'Expiry date' }, + ], + }, + { + name: 'Design', + fields: [ + { + type: 'select', + name: 'Status', + options: [ + { name: 'Draft', color: 'gray' }, + { name: 'Review', color: 'blue' }, + { name: 'Approved', color: 'green' }, + ], + }, + { type: 'text', name: 'Tool' }, + { type: 'text', name: 'Version' }, + { type: 'text', name: 'Reviewer' }, + ], + }, +]; + +export const findAssetLibraryDatabaseId = async ( + database: Kysely +): Promise => { + const rows = await database + .selectFrom('nodes') + .select(['id', 'attributes']) + .where('type', '=', 'database') + .execute(); + + for (const row of rows) { + const attributes = JSON.parse(row.attributes) as DatabaseAttributes; + if (attributes.systemKind === ASSET_LIBRARY_SYSTEM_KIND) { + return row.id; + } + } + + return null; +}; + +export const isAssetLibraryRecordParent = async ( + workspace: WorkspaceService, + parentId: string +): Promise => { + const assetLibraryId = await findAssetLibraryDatabaseId(workspace.database); + if (!assetLibraryId) { + return false; + } + + const parent = await fetchNode(workspace.database, parentId); + if (!parent) { + return false; + } + + return parent.type === 'record' && parent.parentId === assetLibraryId; +}; + +const readFileMetaTypeId = ( + attributes: FileAttributes & { metaTypeId?: string } +): string | null => { + return typeof attributes.metaTypeId === 'string' && + attributes.metaTypeId.length > 0 + ? attributes.metaTypeId + : null; +}; + +const listDefaultFileMetaTypes = async ( + workspace: WorkspaceService +): Promise => { + const databases = await workspace.database + .selectFrom('nodes') + .select(['id', 'attributes', 'created_at']) + .where('type', '=', 'database') + .execute(); + + return databases.flatMap((row) => { + const attributes = JSON.parse(row.attributes) as DatabaseAttributes; + if ( + !isFileMetaTypeDatabase(attributes) || + attributes.name !== DEFAULT_FILE_META_TYPE_NAME + ) { + return []; + } + + return [ + { + id: row.id, + attributes, + createdAt: row.created_at, + }, + ]; + }); +}; + +export const findDefaultFileMetaTypeId = async ( + workspace: WorkspaceService +): Promise => { + const fileMetaTypes = await listDefaultFileMetaTypes(workspace); + if (fileMetaTypes.length === 0) { + return null; + } + + return fileMetaTypes[0]!.id; +}; + +const createDefaultFileMetaType = async ( + workspace: WorkspaceService, + spaceId: string +): Promise => { + const databaseId = generateId(IdType.Database); + const viewId = generateId(IdType.DatabaseView); + + await workspace.nodes.insertNode(databaseId, { + type: 'database', + name: DEFAULT_FILE_META_TYPE_NAME, + parentId: spaceId, + purpose: 'meta_type', + instanceKind: 'file', + fields: buildPresetFields([ + { type: 'text', name: 'Description' }, + { type: 'text', name: 'Source' }, + ]), + }); + + await workspace.nodes.insertNode(viewId, { + type: 'database_view', + name: 'Default', + parentId: databaseId, + index: generateFractionalIndex(null, null), + layout: 'gallery', + }); + + debug(`Created default File meta type ${databaseId}`); + return databaseId; +}; + +const createFileMetaType = async ( + workspace: WorkspaceService, + spaceId: string, + preset: FileMetaTypePreset +): 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', + instanceKind: 'file', + fields: buildPresetFields(preset.fields), + }); + + await workspace.nodes.insertNode(viewId, { + type: 'database_view', + name: 'Default', + parentId: databaseId, + index: generateFractionalIndex(null, null), + layout: 'gallery', + }); + + debug(`Created default ${preset.name} file meta type ${databaseId}`); + return databaseId; +}; + +const ensureDefaultFileMetaTypePresets = 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 (!isFileMetaTypeDatabase(attributes)) { + continue; + } + existingNames.add(attributes.name.trim().toLowerCase()); + } + + for (const preset of DEFAULT_FILE_META_TYPE_PRESETS) { + const key = preset.name.trim().toLowerCase(); + if (existingNames.has(key)) { + continue; + } + + await createFileMetaType(workspace, spaceId, preset); + existingNames.add(key); + } +}; + +export const ensureFileMetaTypes = async ( + workspace: WorkspaceService +): Promise => { + let defaultMetaTypeId = await findDefaultFileMetaTypeId(workspace); + + if (!defaultMetaTypeId) { + const spaceId = await findOrCreateMetaTypesSpace(workspace); + defaultMetaTypeId = await createDefaultFileMetaType(workspace, spaceId); + } + + const metaTypesSpaceId = await findOrCreateMetaTypesSpace(workspace); + await ensureDefaultFileMetaTypePresets(workspace, metaTypesSpaceId); + + const fileMetaTypeIds = new Set(); + const databases = await workspace.database + .selectFrom('nodes') + .select(['id', 'attributes']) + .where('type', '=', 'database') + .execute(); + + for (const database of databases) { + const attributes = JSON.parse(database.attributes) as DatabaseAttributes; + if (isFileMetaTypeDatabase(attributes)) { + fileMetaTypeIds.add(database.id); + } + } + + const assetLibraryId = await findAssetLibraryDatabaseId(workspace.database); + const files = await workspace.database + .selectFrom('nodes') + .select(['id', 'attributes']) + .where('type', '=', 'file') + .execute(); + + for (const file of files) { + const attributes = JSON.parse(file.attributes) as FileAttributes; + const parentId = attributes.parentId; + + if (assetLibraryId && parentId) { + const parent = await fetchNode(workspace.database, parentId); + if ( + parent?.type === 'record' && + parent.parentId === assetLibraryId + ) { + continue; + } + } + + const metaTypeId = readFileMetaTypeId(attributes); + if (metaTypeId && fileMetaTypeIds.has(metaTypeId)) { + continue; + } + + const result = await workspace.nodes.updateNode( + file.id, + (draft) => { + if (draft.type !== 'file') { + return draft; + } + + draft.metaTypeId = defaultMetaTypeId!; + if (!draft.fields) { + draft.fields = {}; + } + + return draft; + }, + { allowMetaTypeIdChange: true } + ); + + if (result !== 'success') { + debug( + `Failed to backfill file ${file.id} with meta type ${defaultMetaTypeId}: ${result}` + ); + } + } +}; diff --git a/packages/client/src/lib/meta-type-field-values.ts b/packages/client/src/lib/meta-type-field-values.ts new file mode 100644 index 000000000..24ed496d6 --- /dev/null +++ b/packages/client/src/lib/meta-type-field-values.ts @@ -0,0 +1,28 @@ +import { + FieldAttributes, + FieldValue, + generateSchemaDefaultFieldValues, + mergeFieldValues, +} from '@colanode/core'; + +type MetaTypeFieldsSource = { + fields?: Record | FieldAttributes[]; +} | null | undefined; + +export const resolveInitialMetaTypeFields = ( + metaType: MetaTypeFieldsSource, + options?: { + filterValues?: Record; + initialFields?: Record; + } +): Record => { + const schemaFields = Array.isArray(metaType?.fields) + ? metaType.fields + : Object.values(metaType?.fields ?? {}); + + return mergeFieldValues( + generateSchemaDefaultFieldValues(schemaFields), + options?.filterValues ?? {}, + options?.initialFields ?? {} + ); +}; diff --git a/packages/client/src/lib/page-meta-types.ts b/packages/client/src/lib/page-meta-types.ts index 1af49b1ce..05659f826 100644 --- a/packages/client/src/lib/page-meta-types.ts +++ b/packages/client/src/lib/page-meta-types.ts @@ -245,7 +245,7 @@ const buildSelectOptions = ( return result; }; -const buildPresetFields = ( +export const buildPresetFields = ( fieldPresets: FieldPreset[] ): NonNullable => { const fields: NonNullable = {}; @@ -552,6 +552,7 @@ const createDefaultPageMetaType = async ( name: DEFAULT_PAGE_META_TYPE_NAME, parentId: spaceId, purpose: 'meta_type', + instanceKind: 'page', fields: {}, }); @@ -580,6 +581,7 @@ const createMetaType = async ( name: preset.name, parentId: spaceId, purpose: 'meta_type', + instanceKind: 'page', fields: buildPresetFields(preset.fields), }); diff --git a/packages/client/src/lib/record-query/resolve-source.ts b/packages/client/src/lib/record-query/resolve-source.ts index 2ab7ca066..7efe6d3ea 100644 --- a/packages/client/src/lib/record-query/resolve-source.ts +++ b/packages/client/src/lib/record-query/resolve-source.ts @@ -2,15 +2,18 @@ import { DatabaseNode, DatabasePurpose, FieldAttributes, + getMetaTypeInstanceKind, } from '@colanode/core'; -export type DatabaseCollectionNodeType = 'record' | 'page'; +export type DatabaseCollectionNodeType = 'record' | 'page' | 'file'; export type DatabaseCollectionSource = { nodeType: DatabaseCollectionNodeType; databaseId: string; purpose: DatabasePurpose; fields: Record; + excludeAssetLibraryFiles?: boolean; + assetLibraryDatabaseId?: string | null; }; export const resolveDatabaseCollectionSource = ( @@ -19,11 +22,14 @@ export const resolveDatabaseCollectionSource = ( const purpose = database.purpose ?? 'records'; if (purpose === 'meta_type') { + const instanceKind = getMetaTypeInstanceKind(database); + return { - nodeType: 'page', + nodeType: instanceKind, databaseId: database.id, purpose, fields: database.fields ?? {}, + excludeAssetLibraryFiles: instanceKind === 'file', }; } @@ -35,6 +41,21 @@ export const resolveDatabaseCollectionSource = ( }; }; +const buildAssetLibraryFileExclusionClause = ( + assetLibraryDatabaseId: string | null | undefined +): string => { + if (!assetLibraryDatabaseId) { + return ''; + } + + return ` AND NOT EXISTS ( + SELECT 1 FROM nodes parent + WHERE parent.id = n.parent_id + AND parent.type = 'record' + AND parent.parent_id = '${assetLibraryDatabaseId}' + )`; +}; + export const buildCollectionBaseWhereClause = ( source: DatabaseCollectionSource ): string => { @@ -42,5 +63,14 @@ export const buildCollectionBaseWhereClause = ( return `n.type = 'page' AND n.meta_type_id = '${source.databaseId}'`; } + if (source.nodeType === 'file') { + const assetExclude = + source.excludeAssetLibraryFiles === true + ? buildAssetLibraryFileExclusionClause(source.assetLibraryDatabaseId) + : ''; + + return `n.type = 'file' AND n.meta_type_id = '${source.databaseId}'${assetExclude}`; + } + return `n.type = 'record' AND n.parent_id = '${source.databaseId}'`; }; diff --git a/packages/client/src/lib/scheduled-task-check-utils.ts b/packages/client/src/lib/scheduled-task-check-utils.ts index ff18c056a..60f2e858e 100644 --- a/packages/client/src/lib/scheduled-task-check-utils.ts +++ b/packages/client/src/lib/scheduled-task-check-utils.ts @@ -2,6 +2,7 @@ import { ScheduledTaskCheck, ScheduledTaskDatabaseQueryCheck, ScheduledTaskEventCheck, + ScheduledTaskFileEventCheck, ScheduledTaskLeafCheck, ScheduledTaskPageEventCheck, ScheduledTaskRecordEventCheck, @@ -28,6 +29,21 @@ export const isScheduledTaskPageEventCheck = ( ); }; +export const isScheduledTaskFileEventCheck = ( + check: ScheduledTaskCheck +): check is ScheduledTaskFileEventCheck => { + return ( + check.type === 'meta_type_files_created' || + check.type === 'meta_type_file_field_changed' + ); +}; + +export const isScheduledTaskMetaTypeEventCheck = ( + check: ScheduledTaskCheck +): check is ScheduledTaskPageEventCheck | ScheduledTaskFileEventCheck => { + return isScheduledTaskPageEventCheck(check) || isScheduledTaskFileEventCheck(check); +}; + export const isScheduledTaskRecordEventCheck = ( check: ScheduledTaskCheck ): check is ScheduledTaskRecordEventCheck => { @@ -40,7 +56,11 @@ export const isScheduledTaskRecordEventCheck = ( export const isScheduledTaskEventCheck = ( check: ScheduledTaskCheck ): check is ScheduledTaskEventCheck => { - return isScheduledTaskPageEventCheck(check) || isScheduledTaskRecordEventCheck(check); + return ( + isScheduledTaskPageEventCheck(check) || + isScheduledTaskFileEventCheck(check) || + isScheduledTaskRecordEventCheck(check) + ); }; export const isScheduledTaskRecordLeafCheck = ( @@ -118,6 +138,22 @@ export const buildDefaultPageFieldChangedCheck = ( fieldId, }); +export const buildDefaultFilesCreatedCheck = ( + metaTypeId: string +): ScheduledTaskFileEventCheck => ({ + type: 'meta_type_files_created', + metaTypeId, +}); + +export const buildDefaultFileFieldChangedCheck = ( + metaTypeId: string, + fieldId: string +): ScheduledTaskFileEventCheck => ({ + type: 'meta_type_file_field_changed', + metaTypeId, + fieldId, +}); + export const buildDefaultRecordsCreatedCheck = ( databaseId: string ): ScheduledTaskRecordEventCheck => ({ @@ -184,7 +220,7 @@ export const buildScheduledTaskCheck = ( export const getScheduledTaskCheckMetaTypeId = ( check: ScheduledTaskCheck ): string => { - if (isScheduledTaskPageEventCheck(check)) { + if (isScheduledTaskPageEventCheck(check) || isScheduledTaskFileEventCheck(check)) { return check.metaTypeId; } diff --git a/packages/client/src/lib/scheduled-task-checks.ts b/packages/client/src/lib/scheduled-task-checks.ts index 7c9413fbc..839356719 100644 --- a/packages/client/src/lib/scheduled-task-checks.ts +++ b/packages/client/src/lib/scheduled-task-checks.ts @@ -1,6 +1,7 @@ import { sql } from 'kysely'; import { SelectNode } from '@colanode/client/databases'; +import { findAssetLibraryDatabaseId } from '@colanode/client/lib/file-meta-types'; import { mapNode } from '@colanode/client/lib/mappers'; import { isArchivedNodeAttributes } from '@colanode/client/lib/nodes'; import { @@ -12,6 +13,7 @@ import { getLocalDateKey } from '@colanode/client/lib/scheduled-task-schedule'; import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; import { ScheduledTaskCheck, + ScheduledTaskFileEventCheck, ScheduledTaskLeafCheck, ScheduledTaskPageEventCheck, ScheduledTaskRecordEventCheck, @@ -23,6 +25,8 @@ import { DatabaseAttributes, DatabaseNode, FieldAttributes, + FileAttributes, + getMetaTypeInstanceKind, PageAttributes, parseRecordQueryDsl, RecordAttributes, @@ -132,6 +136,14 @@ export async function runScheduledTaskCheck( return runMetaTypeFieldChangedCheck(workspace, check, scope, runContext); } + if (check.type === 'meta_type_files_created') { + return runMetaTypeFilesCreatedCheck(workspace, check, scope, runContext); + } + + if (check.type === 'meta_type_file_field_changed') { + return runMetaTypeFileFieldChangedCheck(workspace, check, scope, runContext); + } + if (check.type === 'database_records_created') { return runDatabaseRecordsCreatedCheck(workspace, check, scope, runContext); } @@ -229,6 +241,44 @@ const filterRowsByScope = ( return rows.filter((row) => row.id === scope.nodeId); }; +const resolveMetaTypeScope = ( + scope: ScheduledTaskScope | null | undefined, + metaTypeId: string, + instanceKind: 'page' | 'file' +): ScheduledTaskScope | null => { + if (!scope?.nodeId) { + return null; + } + + if (instanceKind === 'file' && scope.kind === 'files' && scope.metaTypeId === metaTypeId) { + return scope; + } + + if (instanceKind === 'page' && scope.kind === 'pages' && scope.metaTypeId === metaTypeId) { + return scope; + } + + return null; +}; + +async function fetchMetaTypeInstanceKind( + workspace: WorkspaceService, + metaTypeId: string +): Promise<'page' | 'file'> { + const row = await workspace.database + .selectFrom('nodes') + .select(['attributes']) + .where('id', '=', metaTypeId) + .executeTakeFirst(); + + if (!row) { + return 'page'; + } + + const attributes = JSON.parse(row.attributes) as DatabaseAttributes; + return getMetaTypeInstanceKind(attributes); +} + async function fetchMetaTypePages( workspace: WorkspaceService, metaTypeId: string @@ -243,6 +293,75 @@ async function fetchMetaTypePages( return rows.filter((row) => !isArchivedNodeAttributes(row.type, row.attributes)); } +async function fetchMetaTypeFiles( + workspace: WorkspaceService, + metaTypeId: string +): Promise { + const assetLibraryId = await findAssetLibraryDatabaseId(workspace.database); + + const rows = await workspace.database + .selectFrom('nodes') + .selectAll() + .where('type', '=', 'file') + .where('meta_type_id', '=', metaTypeId) + .execute(); + + const activeRows = rows.filter( + (row) => !isArchivedNodeAttributes(row.type, row.attributes) + ); + + if (!assetLibraryId) { + return activeRows; + } + + const assetParentIds = new Set(); + for (const row of activeRows) { + const attributes = JSON.parse(row.attributes) as { parentId?: string }; + if (attributes.parentId) { + assetParentIds.add(attributes.parentId); + } + } + + if (assetParentIds.size === 0) { + return activeRows; + } + + const parents = await workspace.database + .selectFrom('nodes') + .select(['id', 'type', 'parent_id']) + .where('id', 'in', [...assetParentIds]) + .execute(); + + const staticAssetParentIds = new Set( + parents + .filter( + (parent) => + parent.type === 'record' && parent.parent_id === assetLibraryId + ) + .map((parent) => parent.id) + ); + + return activeRows.filter((row) => { + const attributes = JSON.parse(row.attributes) as { parentId?: string }; + return !( + attributes.parentId && staticAssetParentIds.has(attributes.parentId) + ); + }); +} + +async function fetchMetaTypeInstances( + workspace: WorkspaceService, + metaTypeId: string +): Promise<{ instanceKind: 'page' | 'file'; rows: SelectNode[] }> { + const instanceKind = await fetchMetaTypeInstanceKind(workspace, metaTypeId); + const rows = + instanceKind === 'file' + ? await fetchMetaTypeFiles(workspace, metaTypeId) + : await fetchMetaTypePages(workspace, metaTypeId); + + return { instanceKind, rows }; +} + async function fetchMetaTypeFieldDefinitions( workspace: WorkspaceService, metaTypeId: string @@ -261,8 +380,8 @@ async function fetchMetaTypeFieldDefinitions( return attributes.fields ?? {}; } -const pageMatchesFieldValue = ( - attributes: PageAttributes, +const metaTypeInstanceMatchesFieldValue = ( + attributes: PageAttributes | FileAttributes, fieldId: string, expected: string, operator: 'equals' | 'contains' = 'equals' @@ -279,9 +398,9 @@ const pageMatchesFieldValue = ( : fieldValueEquals(fieldValue, expected); }; -const resolvePageFieldDate = ( +const resolveMetaTypeInstanceFieldDate = ( row: SelectNode, - attributes: PageAttributes, + attributes: PageAttributes | FileAttributes, fieldId: string, fieldDefinitions: Record ): Date | null => { @@ -309,18 +428,30 @@ async function runMetaTypeFieldMatchCheck( check: Extract, scope?: ScheduledTaskScope | null ): Promise { + const { instanceKind, rows: instanceRows } = await fetchMetaTypeInstances( + workspace, + check.metaTypeId + ); const rows = filterRowsByScope( - await fetchMetaTypePages(workspace, check.metaTypeId), - scope?.kind === 'pages' ? scope : null + instanceRows, + resolveMetaTypeScope(scope, check.metaTypeId, instanceKind) ); + const instanceLabel = instanceKind === 'file' ? 'files' : 'pages'; const matchedPageIds: string[] = []; const matchedPageNames: string[] = []; for (const row of rows) { - const attributes = JSON.parse(row.attributes) as PageAttributes; + const attributes = JSON.parse(row.attributes) as PageAttributes | FileAttributes; const operator = check.operator ?? 'equals'; - if (!pageMatchesFieldValue(attributes, check.fieldId, check.value, operator)) { + if ( + !metaTypeInstanceMatchesFieldValue( + attributes, + check.fieldId, + check.value, + operator + ) + ) { continue; } @@ -337,7 +468,15 @@ async function runMetaTypeFieldMatchCheck( matchedPageIds: matchedPageIds.toSorted(), }); - return buildResult(matchedPageIds, matchedPageNames, resultHash); + const result = buildResult(matchedPageIds, matchedPageNames, resultHash); + if (matchedPageIds.length === 0 && result.summary === 'No matching pages') { + return { + ...result, + summary: `No matching ${instanceLabel}`, + }; + } + + return result; } async function runMetaTypeDateDueCheck( @@ -345,10 +484,16 @@ async function runMetaTypeDateDueCheck( check: Extract, scope?: ScheduledTaskScope | null ): Promise { + const { instanceKind, rows: instanceRows } = await fetchMetaTypeInstances( + workspace, + check.metaTypeId + ); const rows = filterRowsByScope( - await fetchMetaTypePages(workspace, check.metaTypeId), - scope?.kind === 'pages' ? scope : null + instanceRows, + resolveMetaTypeScope(scope, check.metaTypeId, instanceKind) ); + const instanceLabel = instanceKind === 'file' ? 'file' : 'page'; + const instanceLabelPlural = instanceKind === 'file' ? 'files' : 'pages'; const fieldDefinitions = await fetchMetaTypeFieldDefinitions( workspace, check.metaTypeId @@ -359,8 +504,8 @@ async function runMetaTypeDateDueCheck( const matchedPageNames: string[] = []; for (const row of rows) { - const attributes = JSON.parse(row.attributes) as PageAttributes; - const date = resolvePageFieldDate( + const attributes = JSON.parse(row.attributes) as PageAttributes | FileAttributes; + const date = resolveMetaTypeInstanceFieldDate( row, attributes, check.fieldId, @@ -384,10 +529,10 @@ async function runMetaTypeDateDueCheck( const summary = matchedPageIds.length === 0 - ? 'No pages due' + ? `No ${instanceLabelPlural} due` : matchedPageIds.length === 1 - ? `1 page due: ${matchedPageNames[0]}` - : `${matchedPageIds.length} pages due by ${endOfTodayLocal(today).toLocaleDateString()}`; + ? `1 ${instanceLabel} due: ${matchedPageNames[0]}` + : `${matchedPageIds.length} ${instanceLabelPlural} due by ${endOfTodayLocal(today).toLocaleDateString()}`; return { matchedPageIds, @@ -747,9 +892,9 @@ const filterRowsCreatedSince = ( }); }; -const serializePageFieldSnapshot = ( +const serializeMetaTypeFieldSnapshot = ( row: SelectNode, - attributes: PageAttributes, + attributes: PageAttributes | FileAttributes, fieldId: string, fieldDefinitions: Record ): string => { @@ -769,6 +914,13 @@ const serializePageFieldSnapshot = ( return JSON.stringify(attributes.fields?.[fieldId] ?? null); }; +const serializePageFieldSnapshot = ( + row: SelectNode, + attributes: PageAttributes, + fieldId: string, + fieldDefinitions: Record +): string => serializeMetaTypeFieldSnapshot(row, attributes, fieldId, fieldDefinitions); + const serializeRecordFieldSnapshot = ( row: SelectNode, attributes: RecordAttributes, @@ -796,7 +948,7 @@ const diffFieldSnapshots = ( fieldId: string, readSnapshot: ( row: SelectNode, - attributes: PageAttributes | RecordAttributes + attributes: PageAttributes | FileAttributes | RecordAttributes ) => string, previousSnapshots: Record | null ): { @@ -811,6 +963,7 @@ const diffFieldSnapshots = ( for (const row of rows) { const attributes = JSON.parse(row.attributes) as | PageAttributes + | FileAttributes | RecordAttributes; const snapshot = readSnapshot(row, attributes); snapshots[row.id] = snapshot; @@ -934,6 +1087,115 @@ async function runMetaTypeFieldChangedCheck( }; } +async function runMetaTypeFilesCreatedCheck( + workspace: WorkspaceService, + check: Extract, + scope?: ScheduledTaskScope | null, + runContext?: ScheduledTaskRunContext | null +): Promise { + const rows = filterRowsCreatedSince( + filterRowsByScope( + await fetchMetaTypeFiles(workspace, check.metaTypeId), + scope?.kind === 'files' ? scope : null + ), + runContext?.lastRunAt + ); + + const matchedPageIds: string[] = []; + const matchedPageNames: string[] = []; + + for (const row of rows) { + const attributes = JSON.parse(row.attributes) as FileAttributes; + matchedPageIds.push(row.id); + matchedPageNames.push(attributes.name); + } + + const resultHash = JSON.stringify({ + type: check.type, + metaTypeId: check.metaTypeId, + matchedPageIds: matchedPageIds.toSorted(), + }); + + const summary = + matchedPageIds.length === 0 + ? runContext?.lastRunAt + ? 'No new files' + : 'Baseline established — new files will notify on the next run' + : matchedPageIds.length === 1 + ? `New file: ${matchedPageNames[0]}` + : `${matchedPageIds.length} new files`; + + return { + matchedPageIds, + matchedPageNames, + summary, + resultHash, + hasMatches: matchedPageIds.length > 0, + }; +} + +async function runMetaTypeFileFieldChangedCheck( + workspace: WorkspaceService, + check: Extract< + ScheduledTaskFileEventCheck, + { type: 'meta_type_file_field_changed' } + >, + scope?: ScheduledTaskScope | null, + runContext?: ScheduledTaskRunContext | null +): Promise { + const rows = filterRowsByScope( + await fetchMetaTypeFiles(workspace, check.metaTypeId), + scope?.kind === 'files' ? scope : null + ); + const fieldDefinitions = await fetchMetaTypeFieldDefinitions( + workspace, + check.metaTypeId + ); + const previous = parseFieldSnapshotState( + runContext?.lastResultHash, + check.type + ); + + const { matchedPageIds, matchedPageNames, snapshots } = diffFieldSnapshots( + rows, + check.fieldId, + (row, attributes) => + serializeMetaTypeFieldSnapshot( + row, + attributes as FileAttributes, + check.fieldId, + fieldDefinitions + ), + previous?.snapshots ?? null + ); + + const resultHash = JSON.stringify({ + type: check.type, + metaTypeId: check.metaTypeId, + fieldId: check.fieldId, + snapshots, + matchedIds: matchedPageIds.toSorted(), + }); + + const fieldName = fieldDefinitions[check.fieldId]?.name ?? 'field'; + const summary = + matchedPageIds.length === 0 + ? previous + ? `No "${fieldName}" changes` + : `Baseline established for "${fieldName}"` + : matchedPageIds.length === 1 + ? `"${fieldName}" changed on ${matchedPageNames[0]}` + : `"${fieldName}" changed on ${matchedPageIds.length} files`; + + return { + matchedPageIds, + matchedPageNames, + summary, + resultHash, + hasMatches: matchedPageIds.length > 0, + }; +} + async function runDatabaseRecordsCreatedCheck( workspace: WorkspaceService, check: Extract, diff --git a/packages/client/src/lib/scheduled-task-scope.ts b/packages/client/src/lib/scheduled-task-scope.ts index 2c0c322a7..7c78fd216 100644 --- a/packages/client/src/lib/scheduled-task-scope.ts +++ b/packages/client/src/lib/scheduled-task-scope.ts @@ -2,6 +2,7 @@ import { getScheduledTaskCheckDatabaseId, getScheduledTaskCheckMetaTypeId, getScheduledTaskCheckRecordId, + isScheduledTaskFileEventCheck, } from '@colanode/client/lib/scheduled-task-check-utils'; import { ScheduledTaskContext, @@ -24,6 +25,14 @@ export const normalizeScheduledTaskScope = ( }; } + if (input.context?.type === 'file') { + return { + kind: 'files', + metaTypeId: input.context.metaTypeId, + nodeId: input.context.nodeId, + }; + } + if (input.context?.type === 'record') { return { kind: 'records', @@ -35,7 +44,7 @@ export const normalizeScheduledTaskScope = ( const metaTypeId = getScheduledTaskCheckMetaTypeId(input.check); if (metaTypeId) { return { - kind: 'pages', + kind: isScheduledTaskFileEventCheck(input.check) ? 'files' : 'pages', metaTypeId, }; } @@ -68,6 +77,14 @@ export const scopeToContext = ( }; } + if (scope.kind === 'files' && scope.nodeId) { + return { + type: 'file', + nodeId: scope.nodeId, + metaTypeId: scope.metaTypeId, + }; + } + if (scope.kind === 'records' && scope.nodeId) { return { type: 'record', diff --git a/packages/client/src/lib/scheduled-task-templates.ts b/packages/client/src/lib/scheduled-task-templates.ts index 0af44d5f6..9641b2229 100644 --- a/packages/client/src/lib/scheduled-task-templates.ts +++ b/packages/client/src/lib/scheduled-task-templates.ts @@ -1,5 +1,7 @@ import { buildDefaultDatabaseQueryCheck, + buildDefaultFileFieldChangedCheck, + buildDefaultFilesCreatedCheck, buildDefaultPageCreatedCheck, buildDefaultPageFieldChangedCheck, buildDefaultRecordFieldChangedCheck, @@ -21,6 +23,8 @@ export type ScheduledTaskTemplateId = | 'database_query_digest' | 'pages_created' | 'pages_field_changed' + | 'files_created' + | 'files_field_changed' | 'records_created' | 'records_field_changed' | 'meta_type_view_digest'; @@ -48,9 +52,17 @@ export type ScheduledTaskTemplateView = { export type ScheduledTaskTemplateContext = { metaTypes: ScheduledTaskTemplateMetaType[]; + fileMetaTypes?: ScheduledTaskTemplateMetaType[]; databases?: ScheduledTaskTemplateDatabase[]; views?: ScheduledTaskTemplateView[]; defaultMetaTypeId?: string; + defaultFileMetaTypeId?: string; +}; + +const listAllMetaTypes = ( + context: ScheduledTaskTemplateContext +): ScheduledTaskTemplateMetaType[] => { + return [...context.metaTypes, ...(context.fileMetaTypes ?? [])]; }; export type ScheduledTaskTemplate = { @@ -140,6 +152,22 @@ const pickPreferredMetaType = ( return context.metaTypes.find(predicate) ?? null; }; +const pickPreferredFileMetaType = ( + context: ScheduledTaskTemplateContext, + predicate: (metaType: ScheduledTaskTemplateMetaType) => boolean +): ScheduledTaskTemplateMetaType | null => { + const fileMetaTypes = context.fileMetaTypes ?? []; + + const defaultMetaType = fileMetaTypes.find( + (metaType) => metaType.id === context.defaultFileMetaTypeId + ); + if (defaultMetaType && predicate(defaultMetaType)) { + return defaultMetaType; + } + + return fileMetaTypes.find(predicate) ?? null; +}; + const pickStatusOptionId = (field: SelectFieldAttributes): string => { const options = Object.values(field.options ?? {}); const preferredNames = ['due', 'open', 'in progress', 'todo', 'active']; @@ -222,14 +250,21 @@ const buildPagesStatusMatchTask = ( const buildDatabaseQueryDigestTask = ( context: ScheduledTaskTemplateContext ): ScheduledTaskInput | null => { + const allMetaTypes = listAllMetaTypes(context); const metaType = pickPreferredMetaType(context, () => true) ?? + pickPreferredFileMetaType(context, () => true) ?? (context.defaultMetaTypeId - ? (context.metaTypes.find( + ? (allMetaTypes.find( (item) => item.id === context.defaultMetaTypeId ) ?? null) : null) ?? - context.metaTypes[0] ?? + (context.defaultFileMetaTypeId + ? (allMetaTypes.find( + (item) => item.id === context.defaultFileMetaTypeId + ) ?? null) + : null) ?? + allMetaTypes[0] ?? null; if (!metaType) { @@ -257,7 +292,24 @@ const pickPreferredDatabase = ( const pickPreferredView = ( context: ScheduledTaskTemplateContext ): ScheduledTaskTemplateView | null => { - return context.views?.[0] ?? null; + const views = context.views ?? []; + if (views.length === 0) { + return null; + } + + const preferredDatabaseIds = [ + context.defaultMetaTypeId, + context.defaultFileMetaTypeId, + ].filter((id): id is string => Boolean(id)); + + for (const databaseId of preferredDatabaseIds) { + const view = views.find((item) => item.databaseId === databaseId); + if (view) { + return view; + } + } + + return views[0] ?? null; }; const buildMetaTypeViewDigestTask = ( @@ -342,6 +394,45 @@ const buildPagesFieldChangedTask = ( }; }; +const buildFilesCreatedTask = ( + context: ScheduledTaskTemplateContext +): ScheduledTaskInput | null => { + const metaType = pickPreferredFileMetaType(context, () => true); + if (!metaType) { + return null; + } + + return { + name: `New ${metaType.name} files`, + enabled: true, + schedule: { type: 'interval', intervalMs: 60_000 }, + check: buildDefaultFilesCreatedCheck(metaType.id), + notify: defaultNotify(), + }; +}; + +const buildFilesFieldChangedTask = ( + context: ScheduledTaskTemplateContext +): ScheduledTaskInput | null => { + const metaType = pickPreferredFileMetaType( + context, + (item) => findStatusField(item) !== null + ); + const statusField = metaType ? findStatusField(metaType) : null; + + if (!metaType || !statusField) { + return null; + } + + return { + name: `${metaType.name} file status changes`, + enabled: true, + schedule: { type: 'interval', intervalMs: 60_000 }, + check: buildDefaultFileFieldChangedCheck(metaType.id, statusField.id), + notify: defaultNotify(), + }; +}; + const buildRecordsCreatedTask = ( context: ScheduledTaskTemplateContext ): ScheduledTaskInput | null => { @@ -416,9 +507,10 @@ export const SCHEDULED_TASK_TEMPLATES: ScheduledTaskTemplate[] = [ }, { id: 'database_query_digest', - name: 'Page digest', - description: 'Notify when a database query returns rows (up to 50).', - isAvailable: (context) => context.metaTypes.length > 0, + name: 'Meta type digest', + description: + 'Notify when a page/file type query returns rows (up to 50).', + isAvailable: (context) => listAllMetaTypes(context).length > 0, build: buildDatabaseQueryDigestTask, }, { @@ -439,6 +531,24 @@ export const SCHEDULED_TASK_TEMPLATES: ScheduledTaskTemplate[] = [ ) !== null, build: buildPagesFieldChangedTask, }, + { + id: 'files_created', + name: 'New files', + description: 'Notify when new files are uploaded for a file type.', + isAvailable: (context) => (context.fileMetaTypes?.length ?? 0) > 0, + build: buildFilesCreatedTask, + }, + { + id: 'files_field_changed', + name: 'File field changed', + description: 'Notify when a Status (or similar) field changes on files.', + isAvailable: (context) => + pickPreferredFileMetaType( + context, + (metaType) => findStatusField(metaType) !== null + ) !== null, + build: buildFilesFieldChangedTask, + }, { id: 'records_created', name: 'New records', @@ -457,7 +567,8 @@ export const SCHEDULED_TASK_TEMPLATES: ScheduledTaskTemplate[] = [ { id: 'meta_type_view_digest', name: 'View digest', - description: 'Notify when rows match a saved database view filter.', + description: + 'Notify when page/file rows match a saved meta type view filter.', isAvailable: (context) => pickPreferredView(context) !== null, build: buildMetaTypeViewDigestTask, }, @@ -485,8 +596,12 @@ export const buildScheduledTaskFromTemplate = ( export const buildDefaultScheduledTaskTemplateContext = ( metaTypes: ScheduledTaskTemplateMetaType[], - defaultMetaTypeId?: string + defaultMetaTypeId?: string, + fileMetaTypes: ScheduledTaskTemplateMetaType[] = [], + defaultFileMetaTypeId?: string ): ScheduledTaskTemplateContext => ({ metaTypes, + fileMetaTypes, defaultMetaTypeId, + defaultFileMetaTypeId, }); diff --git a/packages/client/src/mutations/files/file-create.ts b/packages/client/src/mutations/files/file-create.ts index a47116ea5..25040a136 100644 --- a/packages/client/src/mutations/files/file-create.ts +++ b/packages/client/src/mutations/files/file-create.ts @@ -1,8 +1,12 @@ +import { FieldValue } from '@colanode/core'; + export type FileCreateMutationInput = { type: 'file.create'; userId: string; parentId: string; tempFileId: string; + metaTypeId?: string; + initialFields?: Record; }; export type FileCreateMutationOutput = { diff --git a/packages/client/src/queries/databases/database-query.ts b/packages/client/src/queries/databases/database-query.ts index f020933bd..0b9862bb0 100644 --- a/packages/client/src/queries/databases/database-query.ts +++ b/packages/client/src/queries/databases/database-query.ts @@ -21,7 +21,7 @@ export type DatabaseQueryInput = { export type DatabaseQueryRow = { id: string; name: string; - nodeType: 'record' | 'page'; + nodeType: 'record' | 'page' | 'file'; rootId: string; parentId: string; databaseId?: string; diff --git a/packages/client/src/queries/files/file-by-meta-type.ts b/packages/client/src/queries/files/file-by-meta-type.ts new file mode 100644 index 000000000..c97f52c35 --- /dev/null +++ b/packages/client/src/queries/files/file-by-meta-type.ts @@ -0,0 +1,18 @@ +import { LocalFileNode } from '@colanode/client/types/nodes'; + +export type FileByMetaTypeQueryInput = { + type: 'file.byMetaType'; + userId: string; + metaTypeId: string; + searchQuery?: string; + exclude?: string[]; +}; + +declare module '@colanode/client/queries' { + interface QueryMap { + 'file.byMetaType': { + input: FileByMetaTypeQueryInput; + output: LocalFileNode[]; + }; + } +} diff --git a/packages/client/src/queries/index.ts b/packages/client/src/queries/index.ts index 2377164d9..3a2b3d6fc 100644 --- a/packages/client/src/queries/index.ts +++ b/packages/client/src/queries/index.ts @@ -35,6 +35,7 @@ export * from './emojis/emoji-svg-get'; export * from './apps/tabs-list'; export * from './nodes/node-list'; export * from './pages/page-by-meta-type'; +export * from './files/file-by-meta-type'; export * from './workspaces/local-sync-status-get'; export * from './notifications/notification-list'; export * from './scheduled-tasks/scheduled-task-list'; diff --git a/packages/client/src/services/app-service.ts b/packages/client/src/services/app-service.ts index 4bd047c5c..7ed925be7 100644 --- a/packages/client/src/services/app-service.ts +++ b/packages/client/src/services/app-service.ts @@ -9,6 +9,7 @@ import { import { Mediator } from '@colanode/client/handlers'; import { eventBus } from '@colanode/client/lib/event-bus'; import { mapAccount, mapWorkspace } from '@colanode/client/lib/mappers'; +import { ensureFileMetaTypes } from '@colanode/client/lib/file-meta-types'; 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'; @@ -281,6 +282,7 @@ export class AppService { if (this.workspaces.has(workspace.user_id)) { const existing = this.workspaces.get(workspace.user_id)!; await ensurePageMetaTypes(existing); + await ensureFileMetaTypes(existing); await ensureScheduledTaskMetaType(existing); await ensureTodoListSystemDatabase(existing); return existing; @@ -297,6 +299,7 @@ export class AppService { ); await workspaceService.init(); await ensurePageMetaTypes(workspaceService); + await ensureFileMetaTypes(workspaceService); await ensureScheduledTaskMetaType(workspaceService); await ensureTodoListSystemDatabase(workspaceService); diff --git a/packages/client/src/services/scheduled-task-service.ts b/packages/client/src/services/scheduled-task-service.ts index ae9f0f596..6762e633c 100644 --- a/packages/client/src/services/scheduled-task-service.ts +++ b/packages/client/src/services/scheduled-task-service.ts @@ -60,6 +60,22 @@ const taskMatchesNodeEvent = ( return node.metaTypeId === check.metaTypeId; } + if (check.type === 'meta_type_files_created') { + if (event.type !== 'node.created' || node.type !== 'file') { + return false; + } + + return node.metaTypeId === check.metaTypeId; + } + + if (check.type === 'meta_type_file_field_changed') { + if (event.type !== 'node.updated' || node.type !== 'file') { + return false; + } + + return node.metaTypeId === check.metaTypeId; + } + if (check.type === 'database_records_created') { if (event.type !== 'node.created' || node.type !== 'record') { return false; diff --git a/packages/client/src/services/workspaces/file-service.ts b/packages/client/src/services/workspaces/file-service.ts index 669bd2a1b..f61f975f5 100644 --- a/packages/client/src/services/workspaces/file-service.ts +++ b/packages/client/src/services/workspaces/file-service.ts @@ -5,6 +5,11 @@ import { SelectNode, } from '@colanode/client/databases/workspace'; import { eventBus } from '@colanode/client/lib/event-bus'; +import { + findDefaultFileMetaTypeId, + isAssetLibraryRecordParent, +} from '@colanode/client/lib/file-meta-types'; +import { resolveInitialMetaTypeFields } from '@colanode/client/lib/meta-type-field-values'; import { mapDownload, mapLocalFile, @@ -21,6 +26,7 @@ import { import { LocalFileNode } from '@colanode/client/types/nodes'; import { FileAttributes, + FieldValue, FileStatus, IdType, createDebugger, @@ -64,7 +70,11 @@ export class FileService { public async createFile( fileId: string, tempFileId: string, - parentId: string + parentId: string, + options?: { + metaTypeId?: string; + initialFields?: Record; + } ): Promise { const tempFile = await this.app.database .selectFrom('temp_files') @@ -105,6 +115,40 @@ export class FileService { await this.app.fs.copy(tempFile.path, destinationFilePath); await this.app.fs.delete(tempFile.path); + const isStaticAsset = await isAssetLibraryRecordParent( + this.workspace, + parentId + ); + + let metaTypeId: string | undefined; + let fields: Record | undefined; + + if (!isStaticAsset) { + metaTypeId = + options?.metaTypeId ?? + (await findDefaultFileMetaTypeId(this.workspace)) ?? + undefined; + + if (!metaTypeId) { + throw new MutationError( + MutationErrorCode.FileCreateFailed, + 'File meta type is not configured for this workspace.' + ); + } + + const metaTypeNode = await fetchNode(this.workspace.database, metaTypeId); + if (!metaTypeNode || metaTypeNode.type !== 'database') { + throw new MutationError( + MutationErrorCode.FileCreateFailed, + 'The selected file meta type is invalid.' + ); + } + + fields = resolveInitialMetaTypeFields(metaTypeNode, { + initialFields: options?.initialFields, + }); + } + const attributes: FileAttributes = { type: 'file', subtype: extractFileSubtype(tempFile.mime_type), @@ -116,6 +160,8 @@ export class FileService { size: tempFile.size, status: this.app.meta.localOnly ? FileStatus.Ready : FileStatus.Pending, version: generateId(IdType.Version), + ...(metaTypeId ? { metaTypeId } : {}), + ...(fields ? { fields } : {}), }; await this.workspace.nodes.createNode({ diff --git a/packages/client/src/types/scheduled-tasks.ts b/packages/client/src/types/scheduled-tasks.ts index 8849233e7..883b6d0dd 100644 --- a/packages/client/src/types/scheduled-tasks.ts +++ b/packages/client/src/types/scheduled-tasks.ts @@ -35,6 +35,17 @@ export type ScheduledTaskPageEventCheck = fieldId: string; }; +export type ScheduledTaskFileEventCheck = + | { + type: 'meta_type_files_created'; + metaTypeId: string; + } + | { + type: 'meta_type_file_field_changed'; + metaTypeId: string; + fieldId: string; + }; + export type ScheduledTaskRecordLeafCheck = | { type: 'database_record_field_match'; @@ -65,6 +76,7 @@ export type ScheduledTaskRecordEventCheck = export type ScheduledTaskEventCheck = | ScheduledTaskPageEventCheck + | ScheduledTaskFileEventCheck | ScheduledTaskRecordEventCheck; export type ScheduledTaskLeafCheck = @@ -84,6 +96,7 @@ export type ScheduledTaskCheck = | ScheduledTaskPageLeafCheck | ScheduledTaskRecordLeafCheck | ScheduledTaskPageEventCheck + | ScheduledTaskFileEventCheck | ScheduledTaskRecordEventCheck | ScheduledTaskDatabaseQueryCheck | { @@ -102,6 +115,11 @@ export type ScheduledTaskScope = metaTypeId: string; nodeId?: string; } + | { + kind: 'files'; + metaTypeId: string; + nodeId?: string; + } | { kind: 'records'; databaseId: string; @@ -115,6 +133,11 @@ export type ScheduledTaskContext = nodeId: string; metaTypeId: string; } + | { + type: 'file'; + nodeId: string; + metaTypeId: string; + } | { type: 'record'; nodeId: string; diff --git a/packages/client/test/file-meta-types.test.ts b/packages/client/test/file-meta-types.test.ts new file mode 100644 index 000000000..6b64c6e4e --- /dev/null +++ b/packages/client/test/file-meta-types.test.ts @@ -0,0 +1,172 @@ +import { Kysely } from 'kysely'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { WorkspaceDatabaseSchema } from '@colanode/client/databases'; +import { ensureNodesMetaTypeIdColumn } from '@colanode/client/databases/workspace/migrations/meta-type-id-column'; +import { FileByMetaTypeQueryHandler } from '@colanode/client/handlers/queries/files/file-by-meta-type'; +import { + ensureFileMetaTypes, + findDefaultFileMetaTypeId, +} from '@colanode/client/lib/file-meta-types'; +import { NodeService } from '@colanode/client/services/workspaces/node-service'; +import { WorkspaceService } from '@colanode/client/services/workspaces/workspace-service'; +import { + DEFAULT_FILE_META_TYPE_NAME, + FileAttributes, + generateId, + IdType, + UserStatus, +} from '@colanode/core'; + +import { createWorkspaceDatabase } from './helpers/sqlite'; + +const insertNode = async ( + database: Kysely, + id: string, + attributes: Record, + createdBy: string +) => { + const parentId = + typeof attributes.parentId === 'string' ? attributes.parentId : id; + + await database + .insertInto('nodes') + .values({ + id, + root_id: parentId, + attributes: JSON.stringify(attributes), + created_at: new Date().toISOString(), + created_by: createdBy, + local_revision: '0', + server_revision: '0', + }) + .execute(); +}; + +describe('file meta types', () => { + let database: Kysely; + const userId = generateId(IdType.User); + + beforeEach(async () => { + database = await createWorkspaceDatabase(); + await ensureNodesMetaTypeIdColumn(database); + 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(); + }); + + it('creates default File meta type and backfills orphan files', async () => { + const spaceId = generateId(IdType.Space); + const folderId = generateId(IdType.Folder); + const fileId = generateId(IdType.File); + + await insertNode( + database, + spaceId, + { + type: 'space', + name: 'Main', + description: '', + collaborators: { [userId]: 'admin' }, + visibility: 'private', + }, + userId + ); + + await insertNode( + database, + folderId, + { + type: 'folder', + name: 'Docs', + parentId: spaceId, + }, + userId + ); + + await insertNode( + database, + fileId, + { + type: 'file', + subtype: 'pdf', + parentId: folderId, + name: 'contract.pdf', + originalName: 'contract.pdf', + mimeType: 'application/pdf', + extension: 'pdf', + size: 100, + version: generateId(IdType.Version), + status: 1, + }, + userId + ); + + const workspace = { + userId, + workspaceId: generateId(IdType.Workspace), + accountId: generateId(IdType.Account), + role: 'owner', + database, + account: { app: { meta: { localOnly: true } } }, + } as unknown as WorkspaceService; + + const nodeService = new NodeService(workspace); + workspace.nodes = nodeService; + + await ensureFileMetaTypes(workspace); + + const defaultMetaTypeId = await findDefaultFileMetaTypeId(workspace); + expect(defaultMetaTypeId).not.toBeNull(); + + const metaTypeRow = await database + .selectFrom('nodes') + .select(['attributes']) + .where('id', '=', defaultMetaTypeId!) + .executeTakeFirstOrThrow(); + + const metaTypeAttributes = JSON.parse(metaTypeRow.attributes); + expect(metaTypeAttributes.name).toBe(DEFAULT_FILE_META_TYPE_NAME); + expect(metaTypeAttributes.instanceKind).toBe('file'); + + const fileRow = await database + .selectFrom('nodes') + .where('id', '=', fileId) + .selectAll() + .executeTakeFirstOrThrow(); + + const fileAttributes = JSON.parse(fileRow.attributes) as FileAttributes; + expect(fileAttributes.metaTypeId).toBe(defaultMetaTypeId); + expect(fileRow.meta_type_id).toBe(defaultMetaTypeId); + + const handler = new FileByMetaTypeQueryHandler({ + getWorkspace: () => workspace, + } as never); + + const files = await handler.handleQuery({ + type: 'file.byMetaType', + userId, + metaTypeId: defaultMetaTypeId!, + }); + + expect(files).toHaveLength(1); + expect(files[0]?.id).toBe(fileId); + }); +}); diff --git a/packages/client/test/scheduled-task-event-checks.test.ts b/packages/client/test/scheduled-task-event-checks.test.ts index a0bd6b0f3..b2d71f10d 100644 --- a/packages/client/test/scheduled-task-event-checks.test.ts +++ b/packages/client/test/scheduled-task-event-checks.test.ts @@ -37,9 +37,11 @@ describe('scheduled task event checks', () => { const userId = generateId(IdType.User); const spaceId = generateId(IdType.Space); const metaTypeId = generateId(IdType.Database); + const fileMetaTypeId = generateId(IdType.Database); const recordsDbId = generateId(IdType.Database); const statusFieldId = 'status-field'; const pageId = generateId(IdType.Page); + const fileId = generateId(IdType.File); const recordId = generateId(IdType.Record); beforeEach(async () => { @@ -84,6 +86,37 @@ describe('scheduled task event checks', () => { '2026-01-01T00:00:00.000Z' ); + await insertNode( + database, + fileMetaTypeId, + { + type: 'database', + name: 'Photo', + parentId: spaceId, + purpose: 'meta_type', + instanceKind: 'file', + fields: { + [statusFieldId]: { + id: statusFieldId, + type: 'select', + name: 'Status', + index: 'a', + options: { + draft: { id: 'draft', name: 'Draft', color: 'gray', index: 'a' }, + approved: { + id: 'approved', + name: 'Approved', + color: 'green', + index: 'b', + }, + }, + }, + }, + }, + userId, + '2026-01-01T00:00:00.000Z' + ); + await insertNode( database, recordsDbId, @@ -234,6 +267,119 @@ describe('scheduled task event checks', () => { expect(changed.matchedPageIds).toEqual([pageId]); }); + it('detects files created since the last run', async () => { + await insertNode( + database, + fileId, + { + type: 'file', + subtype: 'generic', + name: 'photo.jpg', + originalName: 'photo.jpg', + parentId: spaceId, + metaTypeId: fileMetaTypeId, + mimeType: 'image/jpeg', + extension: 'jpg', + size: 1024, + version: '1', + status: 1, + fields: {}, + }, + userId, + '2026-06-02T10:00:00.000Z' + ); + + const result = await runScheduledTaskCheck( + workspace(), + { type: 'meta_type_files_created', metaTypeId: fileMetaTypeId }, + null, + { lastRunAt: '2026-06-01T00:00:00.000Z', lastResultHash: null } + ); + + expect(result.hasMatches).toBe(true); + expect(result.matchedPageIds).toEqual([fileId]); + expect(result.matchedPageNames).toEqual(['photo.jpg']); + }); + + it('detects file field changes after baseline snapshots', async () => { + await insertNode( + database, + fileId, + { + type: 'file', + subtype: 'generic', + name: 'photo.jpg', + originalName: 'photo.jpg', + parentId: spaceId, + metaTypeId: fileMetaTypeId, + mimeType: 'image/jpeg', + extension: 'jpg', + size: 1024, + version: '1', + status: 1, + fields: { + [statusFieldId]: { type: 'select', value: 'draft' }, + }, + }, + userId, + '2026-06-01T00:00:00.000Z' + ); + + const baseline = await runScheduledTaskCheck( + workspace(), + { + type: 'meta_type_file_field_changed', + metaTypeId: fileMetaTypeId, + fieldId: statusFieldId, + }, + null, + { lastRunAt: null, lastResultHash: null } + ); + + expect(baseline.hasMatches).toBe(false); + + await database + .updateTable('nodes') + .set({ + attributes: JSON.stringify({ + type: 'file', + subtype: 'generic', + name: 'photo.jpg', + originalName: 'photo.jpg', + parentId: spaceId, + metaTypeId: fileMetaTypeId, + mimeType: 'image/jpeg', + extension: 'jpg', + size: 1024, + version: '1', + status: 1, + fields: { + [statusFieldId]: { type: 'select', value: 'approved' }, + }, + }), + updated_at: '2026-06-02T10:00:00.000Z', + }) + .where('id', '=', fileId) + .execute(); + + const changed = await runScheduledTaskCheck( + workspace(), + { + type: 'meta_type_file_field_changed', + metaTypeId: fileMetaTypeId, + fieldId: statusFieldId, + }, + null, + { + lastRunAt: '2026-06-01T12:00:00.000Z', + lastResultHash: baseline.resultHash, + } + ); + + expect(changed.hasMatches).toBe(true); + expect(changed.matchedPageIds).toEqual([fileId]); + }); + it('detects records created since the last run', async () => { await insertNode( database, diff --git a/packages/core/src/lib/meta-types.ts b/packages/core/src/lib/meta-types.ts index f587d270d..f900158f0 100644 --- a/packages/core/src/lib/meta-types.ts +++ b/packages/core/src/lib/meta-types.ts @@ -1,5 +1,23 @@ +import { z } from 'zod/v4'; + +import type { DatabaseAttributes } from '@colanode/core/registry/nodes/database'; + export const META_TYPES_SPACE_MARKER = '__colanode_meta_types__'; export const DEFAULT_PAGE_META_TYPE_NAME = 'Page'; +export const DEFAULT_FILE_META_TYPE_NAME = 'File'; + +export const metaTypeInstanceKindSchema = z.enum(['page', 'file']); +export type MetaTypeInstanceKind = z.infer; + +export const getMetaTypeInstanceKind = ( + database: Pick +): MetaTypeInstanceKind => { + if ((database.purpose ?? 'records') !== 'meta_type') { + return 'page'; + } + + return database.instanceKind ?? 'page'; +}; export const TODO_LIST_SYSTEM_KIND = 'todo_list' as const; export const TODO_LIST_META_TYPE_NAME = 'TodoList'; diff --git a/packages/core/src/registry/nodes/database.ts b/packages/core/src/registry/nodes/database.ts index 9aa20602f..bf79349c3 100644 --- a/packages/core/src/registry/nodes/database.ts +++ b/packages/core/src/registry/nodes/database.ts @@ -2,7 +2,10 @@ import { z } from 'zod/v4'; import { ASSET_LIBRARY_SYSTEM_KIND } from '@colanode/core/lib/asset-library'; import { + DEFAULT_FILE_META_TYPE_NAME, DEFAULT_PAGE_META_TYPE_NAME, + getMetaTypeInstanceKind, + metaTypeInstanceKindSchema, SCHEDULED_TASK_SYSTEM_KIND, TODO_LIST_SYSTEM_KIND, } from '@colanode/core/lib/meta-types'; @@ -41,6 +44,7 @@ export const databaseAttributesSchema = z.object({ avatar: z.string().nullable().optional(), parentId: z.string(), purpose: databasePurposeSchema.default('records'), + instanceKind: metaTypeInstanceKindSchema.optional(), systemKind: databaseSystemKindSchema.optional(), fields: z.record(z.string(), fieldAttributesSchema), nameField: databaseNameFieldAttributesSchema.nullable().optional(), @@ -100,11 +104,21 @@ export const databaseModel: NodeModel = { if (context.node.type === 'database') { const purpose = context.node.purpose ?? 'records'; - if ( - purpose === 'meta_type' && - context.node.name === DEFAULT_PAGE_META_TYPE_NAME - ) { - return false; + if (purpose === 'meta_type') { + const instanceKind = getMetaTypeInstanceKind(context.node); + if ( + instanceKind === 'page' && + context.node.name === DEFAULT_PAGE_META_TYPE_NAME + ) { + return false; + } + + if ( + instanceKind === 'file' && + context.node.name === DEFAULT_FILE_META_TYPE_NAME + ) { + return false; + } } if (context.node.systemKind) { diff --git a/packages/core/src/registry/nodes/file.ts b/packages/core/src/registry/nodes/file.ts index 6eeb84727..2dc61a434 100644 --- a/packages/core/src/registry/nodes/file.ts +++ b/packages/core/src/registry/nodes/file.ts @@ -3,6 +3,7 @@ import { z } from 'zod/v4'; import { extractNodeRole } from '@colanode/core/lib/nodes'; import { hasNodeRole } from '@colanode/core/lib/permissions'; import { NodeModel } from '@colanode/core/registry/nodes/core'; +import { fieldValueSchema } from '@colanode/core/registry/nodes/field-value'; import { fileSubtypeSchema } from '@colanode/core/types/files'; export const fileAttributesSchema = z.object({ @@ -17,6 +18,8 @@ export const fileAttributesSchema = z.object({ size: z.number(), version: z.string(), status: z.number(), + metaTypeId: z.string().optional(), + fields: z.record(z.string(), fieldValueSchema).optional(), }); export type FileAttributes = z.infer; @@ -56,6 +59,17 @@ export const fileModel: NodeModel = { return parent.createdBy === context.user.id || hasNodeRole(role, 'admin'); } + if ( + !context.allowMetaTypeIdChange && + context.node.type === 'file' && + context.attributes.type === 'file' && + context.node.metaTypeId && + context.attributes.metaTypeId !== undefined && + context.node.metaTypeId !== context.attributes.metaTypeId + ) { + return false; + } + return hasNodeRole(role, 'editor'); }, canUpdateDocument: () => { @@ -86,9 +100,18 @@ export const fileModel: NodeModel = { throw new Error('Invalid node type'); } + const texts: string[] = []; + if (attributes.fields) { + for (const field of Object.values(attributes.fields)) { + if (field.type === 'string' || field.type === 'text') { + texts.push(field.value); + } + } + } + return { name: attributes.name, - attributes: null, + attributes: texts.length > 0 ? texts.join('\n') : null, }; }, extractMentions: () => { diff --git a/packages/ui/src/components/databases/boards/board-view-column-records.tsx b/packages/ui/src/components/databases/boards/board-view-column-records.tsx index 8ae367ee4..09a314739 100644 --- a/packages/ui/src/components/databases/boards/board-view-column-records.tsx +++ b/packages/ui/src/components/databases/boards/board-view-column-records.tsx @@ -1,6 +1,8 @@ import { extractNodeRole } from '@colanode/core'; import { BoardViewCollectionCard } from '@colanode/ui/components/databases/boards/board-view-collection-card'; import { BoardViewRecordCreateCard } from '@colanode/ui/components/databases/boards/board-view-record-create-card'; +import { FileFieldsProvider } from '@colanode/ui/components/files/file-fields-provider'; +import { MetaTypeFileUploadButton } from '@colanode/ui/components/files/meta-type-file-upload-button'; import { MetaTypePageCreateButton } from '@colanode/ui/components/pages/meta-type-page-create-button'; import { PageFieldsProvider } from '@colanode/ui/components/pages/page-fields-provider'; import { RecordProvider } from '@colanode/ui/components/records/record-provider'; @@ -34,6 +36,14 @@ export const BoardViewColumnRecords = () => { ); } + if (row.nodeType === 'file') { + return ( + + + + ); + } + return ( @@ -47,6 +57,10 @@ export const BoardViewColumnRecords = () => { label="Add page" className="animate-fade-in flex min-h-12 cursor-pointer flex-row items-center justify-center gap-1 rounded-md border border-dashed text-muted-foreground hover:bg-accent" /> + ); }; diff --git a/packages/ui/src/components/databases/calendars/calendar-view-day.tsx b/packages/ui/src/components/databases/calendars/calendar-view-day.tsx index a4dd3932e..a435eb7ff 100644 --- a/packages/ui/src/components/databases/calendars/calendar-view-day.tsx +++ b/packages/ui/src/components/databases/calendars/calendar-view-day.tsx @@ -2,6 +2,7 @@ import { Plus } from 'lucide-react'; import { extractNodeRole, isSameDay } from '@colanode/core'; import { CollectionViewCard } from '@colanode/ui/components/databases/collection-view-card'; +import { FileFieldsProvider } from '@colanode/ui/components/files/file-fields-provider'; import { PageFieldsProvider } from '@colanode/ui/components/pages/page-fields-provider'; import { RecordProvider } from '@colanode/ui/components/records/record-provider'; import { useDatabase } from '@colanode/ui/contexts/database'; @@ -65,6 +66,14 @@ export const CalendarViewDay = ({ ); } + if (row.nodeType === 'file') { + return ( + + + + ); + } + return ( diff --git a/packages/ui/src/components/databases/calendars/calendar-view-no-value-list.tsx b/packages/ui/src/components/databases/calendars/calendar-view-no-value-list.tsx index 51a217852..9d7ca01a8 100644 --- a/packages/ui/src/components/databases/calendars/calendar-view-no-value-list.tsx +++ b/packages/ui/src/components/databases/calendars/calendar-view-no-value-list.tsx @@ -27,9 +27,12 @@ export const CalendarViewNoValueList = ({ )} {rows.map((row) => { const name = row.name ?? 'Unnamed'; - const avatar = row.source.avatar; + const avatar = + row.source.type === 'record' || row.source.type === 'page' + ? row.source.avatar + : null; - if (row.nodeType === 'page') { + if (row.nodeType === 'page' || row.nodeType === 'file') { return ( )} + {showFileId && ( + + )} {view.fields.length > 0 && (
{view.fields.map((viewField) => { @@ -90,7 +93,7 @@ export const CollectionViewCard = ({ className ); - if (row.nodeType === 'page') { + if (row.nodeType === 'page' || row.nodeType === 'file') { return ( @@ -126,6 +132,13 @@ export const ListViewCollectionRow = ({ row }: ListViewCollectionRowProps) => { {showPageId && ( )} + {showFileId && ( + + )}
{view.fields.length > 0 && (
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 fc7b4c4b9..c06b924c0 100644 --- a/packages/ui/src/components/databases/list/list-view-flat.tsx +++ b/packages/ui/src/components/databases/list/list-view-flat.tsx @@ -3,6 +3,8 @@ import { InView } from 'react-intersection-observer'; import { extractNodeRole } from '@colanode/core'; import { ListViewCollectionRow } from '@colanode/ui/components/databases/list/list-view-collection-row'; +import { FileFieldsProvider } from '@colanode/ui/components/files/file-fields-provider'; +import { MetaTypeFileUploadButton } from '@colanode/ui/components/files/meta-type-file-upload-button'; import { MetaTypePageCreateButton } from '@colanode/ui/components/pages/meta-type-page-create-button'; import { PageFieldsProvider } from '@colanode/ui/components/pages/page-fields-provider'; import { RecordProvider } from '@colanode/ui/components/records/record-provider'; @@ -10,6 +12,7 @@ import { useDatabase } from '@colanode/ui/contexts/database'; import { useDatabaseView } from '@colanode/ui/contexts/database-view'; import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useCollectionQuery } from '@colanode/ui/hooks/use-collection-query'; +import { getMetaTypeEmptyLabel } from '@colanode/ui/lib/meta-types'; export const ListViewFlat = () => { const workspace = useWorkspace(); @@ -24,7 +27,11 @@ export const ListViewFlat = () => { <> {rows.length === 0 && (

- {database.purpose === 'meta_type' ? 'No pages' : 'No records'} + {getMetaTypeEmptyLabel({ + purpose: database.purpose, + instanceKind: database.instanceKind, + isAssetLibrary: database.isAssetLibrary, + })}

)}
@@ -40,6 +47,14 @@ export const ListViewFlat = () => { ); } + if (row.nodeType === 'file') { + return ( + + + + ); + } + return ( @@ -51,6 +66,9 @@ export const ListViewFlat = () => { + { diff --git a/packages/ui/src/components/databases/list/list-view-group.tsx b/packages/ui/src/components/databases/list/list-view-group.tsx index 6180ae44c..4ee7b7873 100644 --- a/packages/ui/src/components/databases/list/list-view-group.tsx +++ b/packages/ui/src/components/databases/list/list-view-group.tsx @@ -3,6 +3,7 @@ import { ReactNode, useMemo, useState } from 'react'; import { InView } from 'react-intersection-observer'; import { DatabaseViewFilterAttributes, extractNodeRole } from '@colanode/core'; +import { FileFieldsProvider } from '@colanode/ui/components/files/file-fields-provider'; import { ListViewCollectionRow } from '@colanode/ui/components/databases/list/list-view-collection-row'; import { MetaTypePageCreateFromFilter } from '@colanode/ui/components/pages/meta-type-page-create-from-filter'; import { PageFieldsProvider } from '@colanode/ui/components/pages/page-fields-provider'; @@ -61,6 +62,14 @@ export const ListViewGroup = ({ header, filter }: ListViewGroupProps) => { ); } + if (row.nodeType === 'file') { + return ( + + + + ); + } + return ( diff --git a/packages/ui/src/components/databases/tables/table-view-empty-placeholder.tsx b/packages/ui/src/components/databases/tables/table-view-empty-placeholder.tsx index 67e4a5d2e..c5db12cfb 100644 --- a/packages/ui/src/components/databases/tables/table-view-empty-placeholder.tsx +++ b/packages/ui/src/components/databases/tables/table-view-empty-placeholder.tsx @@ -1,12 +1,19 @@ import { useDatabase } from '@colanode/ui/contexts/database'; +import { getMetaTypeEmptyLabel } from '@colanode/ui/lib/meta-types'; + export const TableViewEmptyPlaceholder = () => { const database = useDatabase(); - const isMetaType = (database.purpose ?? 'records') === 'meta_type'; return (
-

{isMetaType ? 'No pages' : 'No records'}

+

+ {getMetaTypeEmptyLabel({ + purpose: database.purpose, + instanceKind: database.instanceKind, + isAssetLibrary: database.isAssetLibrary, + })} +

); }; diff --git a/packages/ui/src/components/databases/tables/table-view-name-cell.tsx b/packages/ui/src/components/databases/tables/table-view-name-cell.tsx index 3c4a7898e..871d39e1a 100644 --- a/packages/ui/src/components/databases/tables/table-view-name-cell.tsx +++ b/packages/ui/src/components/databases/tables/table-view-name-cell.tsx @@ -105,7 +105,7 @@ export const TableViewNameCell = ({ row }: TableViewNameCellProps) => { }; const openNode = () => { - if (row.nodeType === 'page') { + if (row.nodeType === 'page' || row.nodeType === 'file') { navigate({ to: '/workspace/$userId/$nodeId', params: { userId: workspace.userId, nodeId: row.id }, diff --git a/packages/ui/src/components/databases/tables/table-view-row.tsx b/packages/ui/src/components/databases/tables/table-view-row.tsx index dee37da1c..6b496ae77 100644 --- a/packages/ui/src/components/databases/tables/table-view-row.tsx +++ b/packages/ui/src/components/databases/tables/table-view-row.tsx @@ -2,6 +2,7 @@ import { extractNodeRole, isRemindersRelationFieldId, RelationFieldAttributes } import { TableViewNameCell } from '@colanode/ui/components/databases/tables/table-view-name-cell'; import { TableViewPageIdCell } from '@colanode/ui/components/databases/tables/table-view-page-id-cell'; import { RemindersReadonlyCell } from '@colanode/ui/components/nodes/reminders-readonly-cell'; +import { FileFieldsProvider } from '@colanode/ui/components/files/file-fields-provider'; import { PageFieldsProvider } from '@colanode/ui/components/pages/page-fields-provider'; import { RecordFieldValue } from '@colanode/ui/components/records/record-field-value'; import { RecordProvider } from '@colanode/ui/components/records/record-provider'; @@ -31,7 +32,7 @@ export const TableViewRow = ({ index, row }: TableViewRowProps) => { const isTodoListSettings = isTodoListSettingsPath(location.pathname); const isSelected = row.nodeType === 'record' && split?.selectedRecordId === row.id; - const isMetaType = (database.purpose ?? 'records') === 'meta_type'; + const isMetaType = database.isPageMetaType || database.isFileMetaType; const role = extractNodeRole(row.source, workspace.userId) ?? database.role; @@ -104,6 +105,14 @@ export const TableViewRow = ({ index, row }: TableViewRowProps) => { ); } + if (row.nodeType === 'file') { + return ( + + {content} + + ); + } + return ( {content} diff --git a/packages/ui/src/components/files/file-container.tsx b/packages/ui/src/components/files/file-container.tsx index 2d533231e..395ad4fe8 100644 --- a/packages/ui/src/components/files/file-container.tsx +++ b/packages/ui/src/components/files/file-container.tsx @@ -1,4 +1,6 @@ import { LocalFileNode } from '@colanode/client/types'; +import { NodeRole } from '@colanode/core'; +import { FileMetadata } from '@colanode/ui/components/files/file-metadata'; import { FileNoPreview } from '@colanode/ui/components/files/file-no-preview'; import { FilePreview } from '@colanode/ui/components/files/file-preview'; import { FileSaveButton } from '@colanode/ui/components/files/file-save-button'; @@ -7,9 +9,10 @@ import { canPreviewFile } from '@colanode/ui/lib/files'; interface FileContainerProps { file: LocalFileNode; + role: NodeRole; } -export const FileContainer = ({ file }: FileContainerProps) => { +export const FileContainer = ({ file, role }: FileContainerProps) => { const canPreview = canPreviewFile(file.subtype); return ( @@ -27,7 +30,8 @@ export const FileContainer = ({ file }: FileContainerProps) => { )}
-
+
+ {file.metaTypeId && }
diff --git a/packages/ui/src/components/files/file-fields-provider.tsx b/packages/ui/src/components/files/file-fields-provider.tsx new file mode 100644 index 000000000..bc2bd06c3 --- /dev/null +++ b/packages/ui/src/components/files/file-fields-provider.tsx @@ -0,0 +1,51 @@ +import { LocalFileNode } from '@colanode/client/types'; +import { NodeRole, hasNodeRole } from '@colanode/core'; +import { EntityFieldsContext } from '@colanode/ui/contexts/entity-fields'; +import { RecordContext } from '@colanode/ui/contexts/record'; + +export const FileFieldsProvider = ({ + file, + role, + children, +}: { + file: LocalFileNode; + role: NodeRole; + children: React.ReactNode; +}) => { + const canEdit = hasNodeRole(role, 'editor'); + + const entityValue = { + entityType: 'file' as const, + id: file.id, + name: file.name, + fields: file.fields ?? {}, + createdBy: file.createdBy, + createdAt: file.createdAt, + updatedBy: file.updatedBy, + updatedAt: file.updatedAt, + schemaId: file.metaTypeId ?? '', + localRevision: file.localRevision, + canEdit, + }; + + return ( + + + {children} + + + ); +}; diff --git a/packages/ui/src/components/files/file-meta-type-picker.tsx b/packages/ui/src/components/files/file-meta-type-picker.tsx new file mode 100644 index 000000000..6a154bf80 --- /dev/null +++ b/packages/ui/src/components/files/file-meta-type-picker.tsx @@ -0,0 +1,25 @@ +import { LocalFileNode } from '@colanode/client/types'; +import { Label } from '@colanode/ui/components/ui/label'; +import { useMetaTypeDatabases } from '@colanode/ui/hooks/use-meta-type-databases'; + +interface FileMetaTypePickerProps { + file: LocalFileNode; +} + +export const FileMetaTypePicker = ({ file }: FileMetaTypePickerProps) => { + const { fileMetaTypes, isLoading } = useMetaTypeDatabases(); + + if (!file.metaTypeId || isLoading || fileMetaTypes.length === 0) { + return null; + } + + const currentMetaType = + fileMetaTypes.find((metaType) => metaType.id === file.metaTypeId) ?? null; + + return ( +
+ +

{currentMetaType?.name ?? 'Unknown'}

+
+ ); +}; diff --git a/packages/ui/src/components/files/file-metadata.tsx b/packages/ui/src/components/files/file-metadata.tsx new file mode 100644 index 000000000..a6a407027 --- /dev/null +++ b/packages/ui/src/components/files/file-metadata.tsx @@ -0,0 +1,79 @@ +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { Fragment, useState } from 'react'; + +import { LocalFileNode } from '@colanode/client/types'; +import { NodeRole } from '@colanode/core'; +import { FileFieldsProvider } from '@colanode/ui/components/files/file-fields-provider'; +import { FileMetaTypePicker } from '@colanode/ui/components/files/file-meta-type-picker'; +import { EntityFieldLabel } from '@colanode/ui/components/records/entity-field-label'; +import { RecordDatabase } from '@colanode/ui/components/records/record-database'; +import { RecordFieldValue } from '@colanode/ui/components/records/record-field-value'; +import { useDatabase } from '@colanode/ui/contexts/database'; + +interface FileMetadataProps { + file: LocalFileNode; + role: NodeRole; +} + +export const FileMetadata = ({ file, role }: FileMetadataProps) => { + if (!file.metaTypeId) { + return null; + } + + return ( +
+ + + + + + +
+ ); +}; + +const FileMetadataFields = () => { + const database = useDatabase(); + const [expanded, setExpanded] = useState(false); + const fields = [...database.fields].sort((a, b) => a.index.localeCompare(b.index)); + + return ( +
+ + {expanded && ( + <> + {fields.length === 0 ? ( +

No properties defined.

+ ) : ( + fields.map((field) => ( + +
+
+ +
+
+ +
+
+
+ )) + )} + + )} +
+ ); +}; diff --git a/packages/ui/src/components/files/meta-type-file-upload-button.tsx b/packages/ui/src/components/files/meta-type-file-upload-button.tsx new file mode 100644 index 000000000..9a55948d5 --- /dev/null +++ b/packages/ui/src/components/files/meta-type-file-upload-button.tsx @@ -0,0 +1,61 @@ +import { Plus } from 'lucide-react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { useDatabase } from '@colanode/ui/contexts/database'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { openFileDialog } from '@colanode/ui/lib/files'; + +interface MetaTypeFileUploadButtonProps { + label?: string; + className?: string; +} + +export const MetaTypeFileUploadButton = ({ + label = 'Add file', + className, +}: MetaTypeFileUploadButtonProps) => { + const database = useDatabase(); + const workspace = useWorkspace(); + + const { mutate, isPending } = useMutation({ + mutationFn: async () => { + const result = await openFileDialog({ multiple: true }); + if (result.type !== 'success') { + return; + } + + for (const tempFile of result.files) { + await window.colanode.executeMutation({ + type: 'file.create', + userId: workspace.userId, + parentId: database.rootId, + tempFileId: tempFile.id, + metaTypeId: database.id, + }); + } + }, + onError: (error) => { + toast.error(error.message); + }, + }); + + if (!database.canCreateFile) { + return null; + } + + return ( + + ); +}; diff --git a/packages/ui/src/components/folders/folder-body.tsx b/packages/ui/src/components/folders/folder-body.tsx index 036caaaa3..2465e720f 100644 --- a/packages/ui/src/components/folders/folder-body.tsx +++ b/packages/ui/src/components/folders/folder-body.tsx @@ -7,11 +7,11 @@ import { Upload, } from 'lucide-react'; import { useState } from 'react'; -import { toast } from 'sonner'; import { LocalFolderNode, FolderLayoutType } from '@colanode/client/types'; import { NodeRole, hasNodeRole } from '@colanode/core'; import { FolderFiles } from '@colanode/ui/components/folders/folder-files'; +import { FolderFileUploadDialog } from '@colanode/ui/components/folders/folder-file-upload-dialog'; import { Button } from '@colanode/ui/components/ui/button'; import { DropdownMenu, @@ -22,8 +22,6 @@ import { DropdownMenuTrigger, } from '@colanode/ui/components/ui/dropdown-menu'; import { Dropzone } from '@colanode/ui/components/ui/dropzone'; -import { useWorkspace } from '@colanode/ui/contexts/workspace'; -import { openFileDialog } from '@colanode/ui/lib/files'; export type FolderLayoutOption = { value: FolderLayoutType; @@ -63,39 +61,26 @@ interface FolderBodyProps { } export const FolderBody = ({ folder, role }: FolderBodyProps) => { - const workspace = useWorkspace(); const canEdit = hasNodeRole(role, 'editor'); const [layout, setLayout] = useState('grid'); + const [uploadOpen, setUploadOpen] = useState(false); const currentLayout = folderLayouts.find((l) => l.value === layout) ?? folderLayouts[0]; - const handleUploadClick = async () => { - const result = await openFileDialog(); - - if (result.type === 'success') { - result.files.forEach((tempFile) => { - window.colanode - .executeMutation({ - type: 'file.create', - userId: workspace.userId, - tempFileId: tempFile.id, - parentId: folder.id, - }) - .then((result) => { - if (!result.success) { - toast.error(result.error.message); - } - }); - }); - } else if (result.type === 'error') { - toast.error(result.error); - } + const handleUploadClick = () => { + setUploadOpen(true); }; return ( - + + { files.forEach((file) => console.log(file)); @@ -142,5 +127,6 @@ export const FolderBody = ({ folder, role }: FolderBodyProps) => { + ); }; diff --git a/packages/ui/src/components/folders/folder-file-upload-dialog.tsx b/packages/ui/src/components/folders/folder-file-upload-dialog.tsx new file mode 100644 index 000000000..1e3bb5cfc --- /dev/null +++ b/packages/ui/src/components/folders/folder-file-upload-dialog.tsx @@ -0,0 +1,132 @@ +import { Upload } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +import { MetaTypeSelect } from '@colanode/ui/components/workspaces/scheduled-tasks/scheduled-task-condition-editor'; +import { Button } from '@colanode/ui/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@colanode/ui/components/ui/dialog'; +import { FieldLabel } from '@colanode/ui/components/ui/field'; +import { useWorkspace } from '@colanode/ui/contexts/workspace'; +import { useMetaTypeDatabases } from '@colanode/ui/hooks/use-meta-type-databases'; +import { openFileDialog } from '@colanode/ui/lib/files'; + +interface FolderFileUploadDialogProps { + folderId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const FolderFileUploadDialog = ({ + folderId, + open, + onOpenChange, +}: FolderFileUploadDialogProps) => { + const workspace = useWorkspace(); + const { fileMetaTypes, defaultFileMetaType } = useMetaTypeDatabases(); + const [metaTypeId, setMetaTypeId] = useState(defaultFileMetaType?.id ?? ''); + const [isUploading, setIsUploading] = useState(false); + + useEffect(() => { + const resolved = defaultFileMetaType?.id ?? fileMetaTypes[0]?.id ?? ''; + if (resolved) { + setMetaTypeId(resolved); + } + }, [defaultFileMetaType?.id, fileMetaTypes]); + + const handleUpload = async () => { + const resolvedMetaTypeId = + metaTypeId || defaultFileMetaType?.id || fileMetaTypes[0]?.id; + + if (!resolvedMetaTypeId) { + toast.error('No file meta type is available.'); + return; + } + + const result = await openFileDialog(); + if (result.type === 'error') { + toast.error(result.error); + return; + } + + if (result.type !== 'success' || result.files.length === 0) { + return; + } + + setIsUploading(true); + + try { + const results = await Promise.all( + result.files.map((tempFile) => + window.colanode.executeMutation({ + type: 'file.create', + userId: workspace.userId, + tempFileId: tempFile.id, + parentId: folderId, + metaTypeId: resolvedMetaTypeId, + }) + ) + ); + + for (const uploadResult of results) { + if (!uploadResult.success) { + toast.error(uploadResult.error.message); + } + } + + onOpenChange(false); + } catch { + toast.error('Failed to upload one or more files.'); + } finally { + setIsUploading(false); + } + }; + + if (fileMetaTypes.length === 0) { + return null; + } + + return ( + + + + Upload files + + Choose a file type before uploading. Files are classified by their + meta type schema. + + + +
+ File type + +
+ + + + + +
+
+ ); +}; diff --git a/packages/ui/src/components/nodes/node-container.tsx b/packages/ui/src/components/nodes/node-container.tsx index 662a86232..dad841869 100644 --- a/packages/ui/src/components/nodes/node-container.tsx +++ b/packages/ui/src/components/nodes/node-container.tsx @@ -64,7 +64,9 @@ const NodeContent = ({ type, onFullscreen }: NodeContentProps) => { {data.node.type === 'message' && ( )} - {data.node.type === 'file' && } + {data.node.type === 'file' && ( + + )} ); }; diff --git a/packages/ui/src/components/nodes/node-reminders-panel.tsx b/packages/ui/src/components/nodes/node-reminders-panel.tsx index e3f3c8425..4a54ed51c 100644 --- a/packages/ui/src/components/nodes/node-reminders-panel.tsx +++ b/packages/ui/src/components/nodes/node-reminders-panel.tsx @@ -65,7 +65,12 @@ export const NodeRemindersPanel = ({ context }: NodeRemindersPanelProps) => { const app = useApp(); const workspace = useWorkspace(); const { mutate, isPending } = useMutation(); - const { metaTypes } = useMetaTypeDatabases(); + const { pageMetaTypes, fileMetaTypes, defaultFileMetaType } = + useMetaTypeDatabases(); + const metaTypes = useMemo( + () => [...pageMetaTypes, ...fileMetaTypes], + [pageMetaTypes, fileMetaTypes] + ); const [open, setOpen] = useState(true); const [dialogState, setDialogState] = useState({ mode: 'closed', @@ -333,9 +338,12 @@ export const NodeRemindersPanel = ({ context }: NodeRemindersPanelProps) => { mode={dialogState.mode === 'edit' ? 'edit' : 'create'} initialValue={dialogInitialValue} metaTypes={metaTypes} + pageMetaTypes={pageMetaTypes} + fileMetaTypes={fileMetaTypes} defaultMetaTypeId={ context.kind === 'page' ? context.page.metaTypeId : undefined } + defaultFileMetaTypeId={defaultFileMetaType?.id} appType={app.type} isPending={isPending} onSubmit={handleSave} diff --git a/packages/ui/src/components/nodes/reminders-relation-value.tsx b/packages/ui/src/components/nodes/reminders-relation-value.tsx index 95c0b13ff..db9e2131e 100644 --- a/packages/ui/src/components/nodes/reminders-relation-value.tsx +++ b/packages/ui/src/components/nodes/reminders-relation-value.tsx @@ -51,7 +51,12 @@ export const RemindersRelationValue = ({ const workspace = useWorkspace(); const entity = useEntityFields(); const record = useRecord(); - const { metaTypes } = useMetaTypeDatabases(); + const { pageMetaTypes, fileMetaTypes, defaultFileMetaType } = + useMetaTypeDatabases(); + const metaTypes = useMemo( + () => [...pageMetaTypes, ...fileMetaTypes], + [pageMetaTypes, fileMetaTypes] + ); const { mutate, isPending } = useMutation(); const { value, setValue, clearValue } = useRecordField({ field, @@ -351,7 +356,10 @@ export const RemindersRelationValue = ({ mode={dialogState.mode === 'edit' ? 'edit' : 'create'} initialValue={dialogInitialValue} metaTypes={metaTypes} + pageMetaTypes={pageMetaTypes} + fileMetaTypes={fileMetaTypes} defaultMetaTypeId={page?.metaTypeId} + defaultFileMetaTypeId={defaultFileMetaType?.id} appType={app.type} isPending={isPending} onSubmit={handleSave} diff --git a/packages/ui/src/components/pages/page-meta-type-picker.tsx b/packages/ui/src/components/pages/page-meta-type-picker.tsx index 8eee0be76..49ccf6559 100644 --- a/packages/ui/src/components/pages/page-meta-type-picker.tsx +++ b/packages/ui/src/components/pages/page-meta-type-picker.tsx @@ -7,14 +7,14 @@ interface PageMetaTypePickerProps { } export const PageMetaTypePicker = ({ page }: PageMetaTypePickerProps) => { - const { metaTypes, isLoading } = useMetaTypeDatabases(); + const { pageMetaTypes, isLoading } = useMetaTypeDatabases(); - if (isLoading || metaTypes.length === 0) { + if (isLoading || pageMetaTypes.length === 0) { return null; } const currentMetaType = - metaTypes.find((metaType) => metaType.id === page.metaTypeId) ?? null; + pageMetaTypes.find((metaType) => metaType.id === page.metaTypeId) ?? null; return (
diff --git a/packages/ui/src/components/records/relation-target-search.tsx b/packages/ui/src/components/records/relation-target-search.tsx index c03d3bf9d..ec6afb49d 100644 --- a/packages/ui/src/components/records/relation-target-search.tsx +++ b/packages/ui/src/components/records/relation-target-search.tsx @@ -2,6 +2,7 @@ import { eq, useLiveQuery } from '@tanstack/react-db'; import { useState } from 'react'; import { LocalDatabaseNode } from '@colanode/client/types'; +import { getMetaTypeInstanceKind } from '@colanode/core'; import { Avatar } from '@colanode/ui/components/avatars/avatar'; import { Command, @@ -53,6 +54,10 @@ export const RelationTargetSearch = ({ const isMetaType = targetDatabase?.type === 'database' && (targetDatabase.purpose ?? 'records') === 'meta_type'; + const isFileMetaType = + isMetaType && + targetDatabase !== undefined && + getMetaTypeInstanceKind(targetDatabase) === 'file'; const recordSearchQuery = useQuery( { @@ -73,10 +78,26 @@ export const RelationTargetSearch = ({ exclude, metaTypeId: databaseId, }, - { enabled: isMetaType } + { enabled: isMetaType && !isFileMetaType } ); - const results: RelationTarget[] = isMetaType + const fileSearchQuery = useQuery( + { + type: 'file.byMetaType', + searchQuery: query, + userId: workspace.userId, + exclude, + metaTypeId: databaseId, + }, + { enabled: isFileMetaType } + ); + + const results: RelationTarget[] = isFileMetaType + ? (fileSearchQuery.data ?? []).map((file) => ({ + id: file.id, + name: file.name, + })) + : isMetaType ? (pageSearchQuery.data ?? []).map((page) => ({ id: page.id, name: page.name, @@ -88,8 +109,16 @@ export const RelationTargetSearch = ({ avatar: record.avatar, })); - const emptyLabel = isMetaType ? 'No page found.' : 'No record found.'; - const placeholder = isMetaType ? 'Search pages...' : 'Search records...'; + const emptyLabel = isFileMetaType + ? 'No file found.' + : isMetaType + ? 'No page found.' + : 'No record found.'; + const placeholder = isFileMetaType + ? 'Search files...' + : isMetaType + ? 'Search pages...' + : 'Search records...'; return ( diff --git a/packages/ui/src/components/workspaces/meta-type-create-dialog.tsx b/packages/ui/src/components/workspaces/meta-type-create-dialog.tsx index 09b94e9ea..e08d99b74 100644 --- a/packages/ui/src/components/workspaces/meta-type-create-dialog.tsx +++ b/packages/ui/src/components/workspaces/meta-type-create-dialog.tsx @@ -1,12 +1,13 @@ import { useMutation } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { toast } from 'sonner'; +import { useState } from 'react'; import { LocalDatabaseNode, LocalDatabaseViewNode, } from '@colanode/client/types'; -import { generateFractionalIndex, generateId, IdType } from '@colanode/core'; +import { MetaTypeInstanceKind, generateFractionalIndex, generateId, IdType } from '@colanode/core'; import { DatabaseForm, DatabaseFormValues, @@ -18,6 +19,7 @@ import { DialogHeader, DialogTitle, } from '@colanode/ui/components/ui/dialog'; +import { Label } from '@colanode/ui/components/ui/label'; import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useMetaTypeDatabases } from '@colanode/ui/hooks/use-meta-type-databases'; @@ -32,11 +34,13 @@ export const MetaTypeCreateDialog = ({ }: MetaTypeCreateDialogProps) => { const workspace = useWorkspace(); const navigate = useNavigate({ from: '/workspace/$userId' }); - const { defaultPageMetaType } = useMetaTypeDatabases(); + const { defaultPageMetaType, defaultFileMetaType } = useMetaTypeDatabases(); + const [instanceKind, setInstanceKind] = useState('page'); const parentSpaceId = - defaultPageMetaType?.parentId ?? - defaultPageMetaType?.rootId ?? + (instanceKind === 'file' + ? defaultFileMetaType?.parentId ?? defaultFileMetaType?.rootId + : defaultPageMetaType?.parentId ?? defaultPageMetaType?.rootId) ?? workspace.userId; const { mutate } = useMutation({ @@ -51,6 +55,7 @@ export const MetaTypeCreateDialog = ({ name: values.name, parentId: parentSpaceId, purpose: 'meta_type', + instanceKind, fields: {}, rootId: parentSpaceId, createdAt: new Date().toISOString(), @@ -66,7 +71,7 @@ export const MetaTypeCreateDialog = ({ type: 'database_view', name: 'Default', index: generateFractionalIndex(null, null), - layout: 'table', + layout: instanceKind === 'file' ? 'gallery' : 'table', parentId: databaseId, rootId: databaseId, createdAt: new Date().toISOString(), @@ -98,9 +103,36 @@ export const MetaTypeCreateDialog = ({ Create meta type - Define a new page meta type with custom properties. + Define a schema for pages or files with custom properties. +
+ +
+ + +
+
- {selected?.name ?? 'Select page type…'} + {selected?.name ?? 'Select type…'} - - No page type found. + + No type found. {metaTypes.map((metaType) => { @@ -397,7 +397,7 @@ export const ScheduledTaskConditionRow = ({ ) : ( - {!metaTypeId ? 'Select page type' : 'Select field'} + {!metaTypeId ? 'Select type' : 'Select field'} )}
@@ -452,7 +452,7 @@ export const ScheduledTaskConditionEditor = ({ value={metaTypeId} onChange={(nextMetaTypeId) => onMetaTypeChange?.(nextMetaTypeId)} /> - pages where + entries where )} @@ -468,7 +468,7 @@ export const ScheduledTaskConditionEditor = ({ {allFields.length === 0 ? (

- {selectedMetaType?.name ?? 'This page type'} + {selectedMetaType?.name ?? 'This type'} {' '} has no custom properties yet. You can still match by{' '} Title. diff --git a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-conditions-section.tsx b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-conditions-section.tsx index 87a77a17e..e0468bd00 100644 --- a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-conditions-section.tsx +++ b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-conditions-section.tsx @@ -155,7 +155,7 @@ export const ScheduledTaskConditionsSection = ({ {!embedded && (

@@ -163,11 +163,11 @@ export const ScheduledTaskConditionsSection = ({ } />
-
@@ -188,7 +188,7 @@ export const ScheduledTaskConditionsSection = ({ value={metaTypeId} onChange={handleMetaTypeChange} /> - pages match + pages/files match
diff --git a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-dialog.tsx b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-dialog.tsx index b10b0c7b6..8c6dfe605 100644 --- a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-dialog.tsx +++ b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-dialog.tsx @@ -25,6 +25,8 @@ interface ScheduledTaskDialogProps { mode: 'create' | 'edit'; initialValue: ScheduledTaskInput | null; metaTypes: LocalDatabaseNode[]; + pageMetaTypes: LocalDatabaseNode[]; + fileMetaTypes: LocalDatabaseNode[]; databases?: LocalDatabaseNode[]; views?: Array<{ id: string; @@ -35,6 +37,7 @@ interface ScheduledTaskDialogProps { fields?: LocalDatabaseNode['fields']; }>; defaultMetaTypeId?: string; + defaultFileMetaTypeId?: string; appType: string; isPending: boolean; onSubmit: (task: ScheduledTaskInput) => void; @@ -46,9 +49,12 @@ export const ScheduledTaskDialog = ({ mode, initialValue, metaTypes, + pageMetaTypes, + fileMetaTypes, databases = [], views = [], defaultMetaTypeId, + defaultFileMetaTypeId, appType, isPending, onSubmit, @@ -92,10 +98,12 @@ export const ScheduledTaskDialog = ({ {mode === 'create' && (
@@ -104,6 +112,8 @@ export const ScheduledTaskDialog = ({ diff --git a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-events-section.tsx b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-events-section.tsx index 1047acf22..200cd9f7e 100644 --- a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-events-section.tsx +++ b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-events-section.tsx @@ -1,11 +1,14 @@ import { useMemo } from 'react'; import { + buildDefaultFileFieldChangedCheck, + buildDefaultFilesCreatedCheck, buildDefaultPageCreatedCheck, buildDefaultPageFieldChangedCheck, buildDefaultRecordFieldChangedCheck, buildDefaultRecordsCreatedCheck, isScheduledTaskEventCheck, + isScheduledTaskFileEventCheck, isScheduledTaskPageEventCheck, isScheduledTaskRecordEventCheck, } from '@colanode/client/lib'; @@ -16,9 +19,7 @@ import { } from '@colanode/client/types'; import { FieldAttributes, SpecialId } from '@colanode/core'; import { DatabaseSelect } from '@colanode/ui/components/databases/database-select'; -import { - MetaTypeSelect, -} from '@colanode/ui/components/workspaces/scheduled-tasks/scheduled-task-condition-editor'; +import { MetaTypeSelect } from '@colanode/ui/components/workspaces/scheduled-tasks/scheduled-task-condition-editor'; import { listConditionFields, nativeSelectClassName, @@ -30,6 +31,8 @@ import { FieldLabel } from '@colanode/ui/components/ui/field'; export type ScheduledTaskEventKind = | 'meta_type_pages_created' | 'meta_type_field_changed' + | 'meta_type_files_created' + | 'meta_type_file_field_changed' | 'database_records_created' | 'database_record_field_changed'; @@ -41,13 +44,23 @@ const EVENT_KIND_OPTIONS: Array<{ { id: 'meta_type_pages_created', label: 'Page created', - description: 'Notify when new pages appear in a page type.', + description: 'Notify when new pages appear in a type.', }, { id: 'meta_type_field_changed', label: 'Page field changed', description: 'Notify when a property value changes on any page.', }, + { + id: 'meta_type_files_created', + label: 'File created', + description: 'Notify when new files appear in a file type.', + }, + { + id: 'meta_type_file_field_changed', + label: 'File field changed', + description: 'Notify when a property value changes on any file.', + }, { id: 'database_records_created', label: 'Record created', @@ -69,6 +82,14 @@ const getEventKind = (check: ScheduledTaskCheck): ScheduledTaskEventKind => { return 'meta_type_field_changed'; } + if (check.type === 'meta_type_files_created') { + return 'meta_type_files_created'; + } + + if (check.type === 'meta_type_file_field_changed') { + return 'meta_type_file_field_changed'; + } + if (check.type === 'database_records_created') { return 'database_records_created'; } @@ -98,32 +119,51 @@ const getDefaultFieldId = ( interface ScheduledTaskEventsSectionProps { check: ScheduledTaskCheck; - metaTypes: LocalDatabaseNode[]; + pageMetaTypes: LocalDatabaseNode[]; + fileMetaTypes: LocalDatabaseNode[]; defaultDatabaseId?: string; onChange: (check: ScheduledTaskCheck) => void; } export const ScheduledTaskEventsSection = ({ check, - metaTypes, + pageMetaTypes, + fileMetaTypes, defaultDatabaseId, onChange, }: ScheduledTaskEventsSectionProps) => { + const defaultEventCheck: ScheduledTaskEventCheck = + pageMetaTypes[0] != null + ? buildDefaultPageCreatedCheck(pageMetaTypes[0].id) + : buildDefaultFilesCreatedCheck(fileMetaTypes[0]?.id ?? ''); const eventCheck: ScheduledTaskEventCheck = isScheduledTaskEventCheck(check) ? check - : buildDefaultPageCreatedCheck(metaTypes[0]?.id ?? ''); + : defaultEventCheck; const eventKind = getEventKind(eventCheck); + const isPageEvent = + eventKind === 'meta_type_pages_created' || + eventKind === 'meta_type_field_changed'; + const isFileEvent = + eventKind === 'meta_type_files_created' || + eventKind === 'meta_type_file_field_changed'; + const metaTypes = isFileEvent ? fileMetaTypes : pageMetaTypes; + const metaTypeId = isScheduledTaskPageEventCheck(eventCheck) ? eventCheck.metaTypeId - : metaTypes[0]?.id ?? ''; + : isScheduledTaskFileEventCheck(eventCheck) + ? eventCheck.metaTypeId + : isFileEvent + ? fileMetaTypes[0]?.id ?? '' + : pageMetaTypes[0]?.id ?? ''; + const databaseId = isScheduledTaskRecordEventCheck(eventCheck) ? eventCheck.databaseId : defaultDatabaseId ?? ''; const selectedMetaType = useResolvedMetaType(metaTypeId, metaTypes); const selectedDatabase = useResolvedMetaType(databaseId, []); - const pageFields = useMemo( + const metaTypeFields = useMemo( () => listConditionFields(selectedMetaType), [selectedMetaType] ); @@ -134,15 +174,34 @@ export const ScheduledTaskEventsSection = ({ const handleKindChange = (nextKind: ScheduledTaskEventKind) => { if (nextKind === 'meta_type_pages_created') { - onChange(buildDefaultPageCreatedCheck(metaTypeId || (metaTypes[0]?.id ?? ''))); + onChange( + buildDefaultPageCreatedCheck(metaTypeId || (pageMetaTypes[0]?.id ?? '')) + ); return; } if (nextKind === 'meta_type_field_changed') { onChange( buildDefaultPageFieldChangedCheck( - metaTypeId || (metaTypes[0]?.id ?? ''), - getDefaultFieldId(pageFields, ['Status', 'State']) + metaTypeId || (pageMetaTypes[0]?.id ?? ''), + getDefaultFieldId(metaTypeFields, ['Status', 'State']) + ) + ); + return; + } + + if (nextKind === 'meta_type_files_created') { + onChange( + buildDefaultFilesCreatedCheck(metaTypeId || (fileMetaTypes[0]?.id ?? '')) + ); + return; + } + + if (nextKind === 'meta_type_file_field_changed') { + onChange( + buildDefaultFileFieldChangedCheck( + metaTypeId || (fileMetaTypes[0]?.id ?? ''), + getDefaultFieldId(metaTypeFields, ['Status', 'State']) ) ); return; @@ -171,26 +230,50 @@ export const ScheduledTaskEventsSection = ({ if (eventKind === 'meta_type_field_changed') { const nextMetaType = - metaTypes.find((item) => item.id === nextMetaTypeId) ?? null; + pageMetaTypes.find((item) => item.id === nextMetaTypeId) ?? null; const fields = listConditionFields(nextMetaType); const fieldId = eventCheck.type === 'meta_type_field_changed' ? eventCheck.fieldId : getDefaultFieldId(fields, ['Status', 'State']); onChange(buildDefaultPageFieldChangedCheck(nextMetaTypeId, fieldId)); + return; + } + + if (eventKind === 'meta_type_files_created') { + onChange(buildDefaultFilesCreatedCheck(nextMetaTypeId)); + return; + } + + if (eventKind === 'meta_type_file_field_changed') { + const nextMetaType = + fileMetaTypes.find((item) => item.id === nextMetaTypeId) ?? null; + const fields = listConditionFields(nextMetaType); + const fieldId = + eventCheck.type === 'meta_type_file_field_changed' + ? eventCheck.fieldId + : getDefaultFieldId(fields, ['Status', 'State']); + onChange(buildDefaultFileFieldChangedCheck(nextMetaTypeId, fieldId)); } }; - const handlePageFieldChange = (fieldId: string) => { - if (eventCheck.type !== 'meta_type_field_changed') { + const handleMetaTypeFieldChange = (fieldId: string) => { + if (eventCheck.type === 'meta_type_field_changed') { + onChange({ + type: 'meta_type_field_changed', + metaTypeId, + fieldId, + }); return; } - onChange({ - type: 'meta_type_field_changed', - metaTypeId, - fieldId, - }); + if (eventCheck.type === 'meta_type_file_field_changed') { + onChange({ + type: 'meta_type_file_field_changed', + metaTypeId, + fieldId, + }); + } }; const handleRecordFieldChange = (fieldId: string) => { @@ -208,10 +291,14 @@ export const ScheduledTaskEventsSection = ({ }; const selectedOption = EVENT_KIND_OPTIONS.find((item) => item.id === eventKind); - const watchedPageField = - eventCheck.type === 'meta_type_field_changed' + const watchedMetaTypeField = + eventCheck.type === 'meta_type_field_changed' || + eventCheck.type === 'meta_type_file_field_changed' ? resolveConditionField(selectedMetaType, eventCheck.fieldId) : null; + const metaTypeLabel = isFileEvent ? 'File type' : 'Page type'; + const instanceLabel = isFileEvent ? 'file' : 'page'; + const instanceLabelPlural = isFileEvent ? 'files' : 'pages'; return (
@@ -235,11 +322,10 @@ export const ScheduledTaskEventsSection = ({ ) : null}
- {eventKind === 'meta_type_pages_created' || - eventKind === 'meta_type_field_changed' ? ( + {isPageEvent || isFileEvent ? (
- Page type + {metaTypeLabel}
- {eventKind === 'meta_type_field_changed' && - eventCheck.type === 'meta_type_field_changed' ? ( + {(eventKind === 'meta_type_field_changed' || + eventKind === 'meta_type_file_field_changed') && + (eventCheck.type === 'meta_type_field_changed' || + eventCheck.type === 'meta_type_file_field_changed') ? (
Watch field - {watchedPageField ? ( + {watchedMetaTypeField ? (

- Notifications fire when "{watchedPageField.name}" changes on - any page of this type. + Notifications fire when "{watchedMetaTypeField.name}" changes on + any {instanceLabel} of this type.

) : null}
) : (

- The first scheduled run establishes a baseline. New pages after that - run trigger notifications. + The first scheduled run establishes a baseline. New {instanceLabelPlural} after + that run trigger notifications.

)}
diff --git a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-form.tsx b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-form.tsx index 28a92a2b5..48cee1cb3 100644 --- a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-form.tsx +++ b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-form.tsx @@ -19,6 +19,8 @@ import { nativeSelectClassName } from '@colanode/ui/components/workspaces/schedu interface ScheduledTaskFormProps { draft: ScheduledTaskInput; metaTypes: LocalDatabaseNode[]; + pageMetaTypes: LocalDatabaseNode[]; + fileMetaTypes: LocalDatabaseNode[]; appType: string; onChange: (draft: ScheduledTaskInput) => void; } @@ -26,6 +28,8 @@ interface ScheduledTaskFormProps { export const ScheduledTaskForm = ({ draft, metaTypes, + pageMetaTypes, + fileMetaTypes, appType, onChange, }: ScheduledTaskFormProps) => { @@ -138,11 +142,13 @@ export const ScheduledTaskForm = ({ Trigger - Send a reminder every run, filter pages, or run a database query. + Send a reminder every run, filter pages/files, or run a database query. onChange({ ...draft, check })} /> diff --git a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-helpers.ts b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-helpers.ts index fc87dc78f..054136846 100644 --- a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-helpers.ts +++ b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-helpers.ts @@ -5,7 +5,9 @@ import { SCHEDULED_TASK_INTERVAL_OPTIONS , getScheduledTaskTriggerMode, isScheduledTaskDatabaseQueryCheck, isScheduledTaskEventCheck, + isScheduledTaskFileEventCheck, isScheduledTaskFilteringEnabled, + isScheduledTaskPageEventCheck, isScheduledTaskRecordLeafCheck, listScheduledTaskLeafChecks, } from '@colanode/client/lib'; @@ -112,6 +114,8 @@ const formatEventConditionPreview = ( type: | 'meta_type_pages_created' | 'meta_type_field_changed' + | 'meta_type_files_created' + | 'meta_type_file_field_changed' | 'database_records_created' | 'database_record_field_changed'; } @@ -122,6 +126,10 @@ const formatEventConditionPreview = ( return 'a new page is created'; } + if (check.type === 'meta_type_files_created') { + return 'a new file is created'; + } + if (check.type === 'database_records_created') { return 'a new record is created'; } @@ -133,6 +141,13 @@ const formatEventConditionPreview = ( return `"${field?.name ?? '…'}" changes`; } + if (check.type === 'meta_type_file_field_changed') { + const metaType = + metaTypes.find((item) => item.id === check.metaTypeId) ?? null; + const field = resolveConditionField(metaType, check.fieldId); + return `"${field?.name ?? '…'}" changes`; + } + const field = resolveConditionField(null, check.fieldId); return `"${field?.name ?? '…'}" changes on a record`; }; @@ -210,7 +225,8 @@ export const formatConditionPreview = ( const metaTypeName = metaTypes.find((item) => item.id === check.metaTypeId)?.name ?? '…'; - return `When ${metaTypeName} pages where ${formatEventConditionPreview(check, metaTypes)}`; + const instanceLabel = isScheduledTaskFileEventCheck(check) ? 'files' : 'pages'; + return `When ${metaTypeName} ${instanceLabel} where ${formatEventConditionPreview(check, metaTypes)}`; } const leaves = listScheduledTaskLeafChecks(check); @@ -230,10 +246,10 @@ export const formatConditionPreview = ( ); if (leaves.length === 1) { - return `When ${metaTypeName} pages where ${leafPreviews[0]}`; + return `When ${metaTypeName} pages/files where ${leafPreviews[0]}`; } - return `When ${metaTypeName} pages where ${leafPreviews.join(' AND ')}`; + return `When ${metaTypeName} pages/files where ${leafPreviews.join(' AND ')}`; }; export const resolveConditionField = ( @@ -274,7 +290,9 @@ export const isLeafCheck = ( check.type === 'meta_type_field_match' || check.type === 'meta_type_date_due' || check.type === 'meta_type_pages_created' || - check.type === 'meta_type_field_changed' + check.type === 'meta_type_field_changed' || + check.type === 'meta_type_files_created' || + check.type === 'meta_type_file_field_changed' ); }; @@ -440,12 +458,9 @@ export const validateTaskInput = ( } if (isScheduledTaskEventCheck(draft.check)) { - if ( - draft.check.type === 'meta_type_pages_created' || - draft.check.type === 'meta_type_field_changed' - ) { + if (isScheduledTaskPageEventCheck(draft.check)) { if (!draft.check.metaTypeId) { - return 'Choose a page type for this event'; + return 'Choose a type for this event'; } if (draft.check.type === 'meta_type_field_changed' && !draft.check.fieldId) { @@ -455,6 +470,21 @@ export const validateTaskInput = ( return null; } + if (isScheduledTaskFileEventCheck(draft.check)) { + if (!draft.check.metaTypeId) { + return 'Choose a file type for this event'; + } + + if ( + draft.check.type === 'meta_type_file_field_changed' && + !draft.check.fieldId + ) { + return 'Choose a field to watch'; + } + + return null; + } + if (!draft.check.databaseId) { return 'Choose a database for this event'; } @@ -486,7 +516,7 @@ export const validateTaskInput = ( } if (!leaf.metaTypeId) { - return 'Choose a page type for each condition'; + return 'Choose a page/file type for each condition'; } const metaType = diff --git a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-template-picker.tsx b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-template-picker.tsx index 9efca6ec2..8eebfe6e4 100644 --- a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-template-picker.tsx +++ b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-template-picker.tsx @@ -10,6 +10,7 @@ import { cn } from '@colanode/ui/lib/utils'; interface ScheduledTaskTemplatePickerProps { metaTypes: LocalDatabaseNode[]; + fileMetaTypes?: LocalDatabaseNode[]; databases?: LocalDatabaseNode[]; views?: Array<{ id: string; @@ -20,14 +21,17 @@ interface ScheduledTaskTemplatePickerProps { fields?: LocalDatabaseNode['fields']; }>; defaultMetaTypeId?: string; + defaultFileMetaTypeId?: string; onSelect: (task: ScheduledTaskInput) => void; } export const ScheduledTaskTemplatePicker = ({ metaTypes, + fileMetaTypes = [], databases = [], views = [], defaultMetaTypeId, + defaultFileMetaTypeId, onSelect, }: ScheduledTaskTemplatePickerProps) => { const context = { @@ -36,6 +40,11 @@ export const ScheduledTaskTemplatePicker = ({ name: metaType.name, fields: metaType.fields, })), + fileMetaTypes: fileMetaTypes.map((metaType) => ({ + id: metaType.id, + name: metaType.name, + fields: metaType.fields, + })), databases: databases.map((database) => ({ id: database.id, name: database.name, @@ -50,6 +59,7 @@ export const ScheduledTaskTemplatePicker = ({ fields: view.fields, })), defaultMetaTypeId, + defaultFileMetaTypeId, }; const templates = listAvailableScheduledTaskTemplates(context); diff --git a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-trigger-section.tsx b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-trigger-section.tsx index fd6154bee..5a86fad15 100644 --- a/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-trigger-section.tsx +++ b/packages/ui/src/components/workspaces/scheduled-tasks/scheduled-task-trigger-section.tsx @@ -19,6 +19,8 @@ import { interface ScheduledTaskTriggerSectionProps { check: ScheduledTaskCheck; metaTypes: LocalDatabaseNode[]; + pageMetaTypes: LocalDatabaseNode[]; + fileMetaTypes: LocalDatabaseNode[]; defaultDatabaseId?: string; onChange: (check: ScheduledTaskCheck) => void; } @@ -26,10 +28,22 @@ interface ScheduledTaskTriggerSectionProps { export const ScheduledTaskTriggerSection = ({ check, metaTypes, + pageMetaTypes, + fileMetaTypes, defaultDatabaseId, onChange, }: ScheduledTaskTriggerSectionProps) => { const mode = getScheduledTaskTriggerMode(check); + const defaultEventCheck = + pageMetaTypes[0] != null + ? { + type: 'meta_type_pages_created' as const, + metaTypeId: pageMetaTypes[0].id, + } + : { + type: 'meta_type_files_created' as const, + metaTypeId: fileMetaTypes[0]?.id ?? '', + }; const handleModeChange = (nextMode: ScheduledTaskTriggerMode) => { if (nextMode === 'reminder') { @@ -47,10 +61,7 @@ export const ScheduledTaskTriggerSection = ({ } if (nextMode === 'events') { - onChange({ - type: 'meta_type_pages_created', - metaTypeId: metaTypes[0]?.id ?? '', - }); + onChange(defaultEventCheck); return; } @@ -71,7 +82,7 @@ export const ScheduledTaskTriggerSection = ({ Reminder - Page filters + Type filters Query @@ -110,7 +121,8 @@ export const ScheduledTaskTriggerSection = ({ {mode === 'events' && isScheduledTaskEventCheck(check) && ( diff --git a/packages/ui/src/components/workspaces/scheduled-tasks/workspace-scheduled-tasks-container.tsx b/packages/ui/src/components/workspaces/scheduled-tasks/workspace-scheduled-tasks-container.tsx index e8bc37906..42aa8d7dd 100644 --- a/packages/ui/src/components/workspaces/scheduled-tasks/workspace-scheduled-tasks-container.tsx +++ b/packages/ui/src/components/workspaces/scheduled-tasks/workspace-scheduled-tasks-container.tsx @@ -25,7 +25,12 @@ export const WorkspaceScheduledTasksContainer = () => { const app = useApp(); const workspace = useWorkspace(); const { mutate, isPending } = useMutation(); - const { metaTypes, defaultPageMetaType } = useMetaTypeDatabases(); + const { pageMetaTypes, fileMetaTypes, defaultPageMetaType, defaultFileMetaType } = + useMetaTypeDatabases(); + const metaTypes = useMemo( + () => [...pageMetaTypes, ...fileMetaTypes], + [pageMetaTypes, fileMetaTypes] + ); const [dialogState, setDialogState] = useState({ mode: 'closed', @@ -244,7 +249,7 @@ export const WorkspaceScheduledTasksContainer = () => {

No scheduled tasks yet

- Set up daily or interval checks on your pages and get notified + Set up daily or interval checks on your pages/files and get notified when conditions are met.

))} diff --git a/packages/ui/src/contexts/database.ts b/packages/ui/src/contexts/database.ts index 965986a6a..920da292f 100644 --- a/packages/ui/src/contexts/database.ts +++ b/packages/ui/src/contexts/database.ts @@ -4,7 +4,9 @@ import { DatabaseNameFieldAttributes, DatabasePurpose, FieldAttributes, + MetaTypeInstanceKind, NodeRole, + getMetaTypeInstanceKind, } from '@colanode/core'; interface DatabaseContext { @@ -13,11 +15,15 @@ interface DatabaseContext { nameField: DatabaseNameFieldAttributes | null | undefined; fields: FieldAttributes[]; purpose: DatabasePurpose; + instanceKind: MetaTypeInstanceKind; isAssetLibrary: boolean; + isPageMetaType: boolean; + isFileMetaType: boolean; canEdit: boolean; isLocked: boolean; canCreateRecord: boolean; canCreatePage: boolean; + canCreateFile: boolean; role: NodeRole; rootId: string; toggleLock: () => void; diff --git a/packages/ui/src/contexts/entity-fields.ts b/packages/ui/src/contexts/entity-fields.ts index a2ff859fc..4561cf857 100644 --- a/packages/ui/src/contexts/entity-fields.ts +++ b/packages/ui/src/contexts/entity-fields.ts @@ -2,7 +2,7 @@ import { createContext, useContext } from 'react'; import { FieldValue } from '@colanode/core'; -export type EntityFieldType = 'record' | 'page'; +export type EntityFieldType = 'record' | 'page' | 'file'; export interface EntityFieldsContextValue { entityType: EntityFieldType; diff --git a/packages/ui/src/hooks/use-entity-field.tsx b/packages/ui/src/hooks/use-entity-field.tsx index 20db862b4..fef658b3c 100644 --- a/packages/ui/src/hooks/use-entity-field.tsx +++ b/packages/ui/src/hooks/use-entity-field.tsx @@ -38,7 +38,7 @@ export const useEntityField = ({ field }: Options) => { return; } - if (draft.type !== 'page') { + if (draft.type !== 'page' && draft.type !== 'file') { return; } diff --git a/packages/ui/src/hooks/use-meta-type-databases.ts b/packages/ui/src/hooks/use-meta-type-databases.ts index 8acc38e07..f311c0fd5 100644 --- a/packages/ui/src/hooks/use-meta-type-databases.ts +++ b/packages/ui/src/hooks/use-meta-type-databases.ts @@ -2,7 +2,7 @@ import { eq, useLiveQuery } from '@tanstack/react-db'; import { useMemo } from 'react'; import { LocalDatabaseNode } from '@colanode/client/types'; -import { DEFAULT_PAGE_META_TYPE_NAME, ASSET_LIBRARY_SYSTEM_KIND, SCHEDULED_TASK_SYSTEM_KIND } from '@colanode/core'; +import { DEFAULT_FILE_META_TYPE_NAME, DEFAULT_PAGE_META_TYPE_NAME, ASSET_LIBRARY_SYSTEM_KIND, SCHEDULED_TASK_SYSTEM_KIND, getMetaTypeInstanceKind } from '@colanode/core'; import { useWorkspace } from '@colanode/ui/contexts/workspace'; export const useMetaTypeDatabases = () => { @@ -26,17 +26,44 @@ export const useMetaTypeDatabases = () => { .filter((node) => node.systemKind !== ASSET_LIBRARY_SYSTEM_KIND); }, [query.data]); + const pageMetaTypes = useMemo(() => { + return metaTypes.filter( + (metaType) => getMetaTypeInstanceKind(metaType) === 'page' + ); + }, [metaTypes]); + + const fileMetaTypes = useMemo(() => { + return metaTypes.filter( + (metaType) => getMetaTypeInstanceKind(metaType) === 'file' + ); + }, [metaTypes]); + const defaultPageMetaType = useMemo( () => - metaTypes.find((metaType) => metaType.name === DEFAULT_PAGE_META_TYPE_NAME) ?? - metaTypes[0] ?? + pageMetaTypes.find( + (metaType) => metaType.name === DEFAULT_PAGE_META_TYPE_NAME + ) ?? + pageMetaTypes[0] ?? + null, + [pageMetaTypes] + ); + + const defaultFileMetaType = useMemo( + () => + fileMetaTypes.find( + (metaType) => metaType.name === DEFAULT_FILE_META_TYPE_NAME + ) ?? + fileMetaTypes[0] ?? null, - [metaTypes] + [fileMetaTypes] ); return { isLoading: query.isLoading, metaTypes, + pageMetaTypes, + fileMetaTypes, defaultPageMetaType, + defaultFileMetaType, }; }; diff --git a/packages/ui/src/lib/collection-row.ts b/packages/ui/src/lib/collection-row.ts index 058522ca8..71081d500 100644 --- a/packages/ui/src/lib/collection-row.ts +++ b/packages/ui/src/lib/collection-row.ts @@ -1,4 +1,4 @@ -import { LocalPageNode, LocalRecordNode } from '@colanode/client/types'; +import { LocalFileNode, LocalPageNode, LocalRecordNode } from '@colanode/client/types'; import { FieldValue } from '@colanode/core'; type CollectionRowBase = { @@ -13,7 +13,8 @@ type CollectionRowBase = { export type CollectionRow = | (CollectionRowBase & { nodeType: 'record'; source: LocalRecordNode }) - | (CollectionRowBase & { nodeType: 'page'; source: LocalPageNode }); + | (CollectionRowBase & { nodeType: 'page'; source: LocalPageNode }) + | (CollectionRowBase & { nodeType: 'file'; source: LocalFileNode }); export const recordToCollectionRow = ( record: LocalRecordNode @@ -40,3 +41,15 @@ export const pageToCollectionRow = (page: LocalPageNode): CollectionRow => ({ updatedBy: page.updatedBy, source: page, }); + +export const fileToCollectionRow = (file: LocalFileNode): CollectionRow => ({ + id: file.id, + name: file.name, + fields: file.fields ?? {}, + nodeType: 'file', + createdAt: file.createdAt, + createdBy: file.createdBy, + updatedAt: file.updatedAt, + updatedBy: file.updatedBy, + source: file, +}); diff --git a/packages/ui/src/lib/database-query-collection.ts b/packages/ui/src/lib/database-query-collection.ts index 8331194f9..622a406eb 100644 --- a/packages/ui/src/lib/database-query-collection.ts +++ b/packages/ui/src/lib/database-query-collection.ts @@ -1,11 +1,13 @@ import { DatabaseQueryRow } from '@colanode/client/queries'; import { + LocalFileNode, LocalNode, LocalPageNode, LocalRecordNode, } from '@colanode/client/types'; import { CollectionRow, + fileToCollectionRow, pageToCollectionRow, recordToCollectionRow, } from '@colanode/ui/lib/collection-row'; @@ -29,6 +31,29 @@ const queryRowToPageNode = (row: DatabaseQueryRow): LocalPageNode => ({ serverRevision: EMPTY_REVISION, }); +const queryRowToFileNode = (row: DatabaseQueryRow): LocalFileNode => ({ + id: row.id, + type: 'file', + rootId: row.rootId, + parentId: row.parentId, + metaTypeId: row.metaTypeId ?? '', + name: row.name, + originalName: row.name, + subtype: 'other', + mimeType: 'application/octet-stream', + extension: '', + size: 0, + version: '', + status: 1, + fields: row.fields, + createdAt: row.createdAt, + createdBy: row.createdBy, + updatedAt: row.updatedAt ?? null, + updatedBy: row.updatedBy ?? null, + localRevision: EMPTY_REVISION, + serverRevision: EMPTY_REVISION, +}); + const queryRowToRecordNode = (row: DatabaseQueryRow): LocalRecordNode => ({ id: row.id, type: 'record', @@ -54,6 +79,10 @@ export const databaseQueryRowToCollectionRow = ( return pageToCollectionRow(node); } + if (node?.type === 'file') { + return fileToCollectionRow(node); + } + if (node?.type === 'record') { return recordToCollectionRow(node); } @@ -62,5 +91,9 @@ export const databaseQueryRowToCollectionRow = ( return pageToCollectionRow(queryRowToPageNode(row)); } + if (row.nodeType === 'file') { + return fileToCollectionRow(queryRowToFileNode(row)); + } + return recordToCollectionRow(queryRowToRecordNode(row)); }; diff --git a/packages/ui/src/lib/meta-types.ts b/packages/ui/src/lib/meta-types.ts new file mode 100644 index 000000000..676495fe2 --- /dev/null +++ b/packages/ui/src/lib/meta-types.ts @@ -0,0 +1,46 @@ +import { LocalDatabaseNode } from '@colanode/client/types'; +import { getMetaTypeInstanceKind } from '@colanode/core'; + +export const isPageMetaTypeDatabase = ( + database: Pick +): boolean => + (database.purpose ?? 'records') === 'meta_type' && + getMetaTypeInstanceKind(database) === 'page'; + +export const isFileMetaTypeDatabase = ( + database: Pick +): boolean => + (database.purpose ?? 'records') === 'meta_type' && + getMetaTypeInstanceKind(database) === 'file'; + +export const getMetaTypeEmptyLabel = (options: { + purpose?: LocalDatabaseNode['purpose']; + instanceKind?: LocalDatabaseNode['instanceKind']; + isAssetLibrary?: boolean; +}): string => { + if ( + (options.purpose ?? 'records') === 'meta_type' && + getMetaTypeInstanceKind({ + purpose: options.purpose ?? 'records', + instanceKind: options.instanceKind, + }) === 'file' + ) { + return 'No files'; + } + + if ( + (options.purpose ?? 'records') === 'meta_type' && + getMetaTypeInstanceKind({ + purpose: options.purpose ?? 'records', + instanceKind: options.instanceKind, + }) === 'page' + ) { + return 'No pages'; + } + + if (options.isAssetLibrary) { + return 'No assets'; + } + + return 'No records'; +}; diff --git a/packages/ui/test/page-markdown-import.test.ts b/packages/ui/test/page-markdown-import.test.ts index b40f81b0a..dac81092a 100644 --- a/packages/ui/test/page-markdown-import.test.ts +++ b/packages/ui/test/page-markdown-import.test.ts @@ -51,13 +51,13 @@ Body text Tags: ['alpha'], }, [ - { id: 'field-status', type: 'select', name: 'Status', index: 'a0' }, + { id: 'field-status', type: 'text', name: 'Status', index: 'a0' }, { id: 'field-tags', type: 'multi_select', name: 'Tags', index: 'a1' }, ] ); expect(fields).toEqual({ - 'field-status': { type: 'string', value: 'Draft' }, + 'field-status': { type: 'text', value: 'Draft' }, 'field-tags': { type: 'string_array', value: ['alpha'] }, }); });