From 7d5ca5b101496336ba539029196fdcd86c1ca5ba Mon Sep 17 00:00:00 2001 From: Brylie Christopher Oxley Date: Sat, 12 Sep 2026 18:15:04 +0300 Subject: [PATCH] Calendar entry titles render primary field as editable cell, matching Board (closes #105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the UX call from #105: Calendar entries now show the resolved primary field as a directly-editable PropertyValueCell (scheduled and unscheduled), mirroring Board's existing behavior — Calendar's entries already supported inline editing for the date field, so this extends an established pattern rather than introducing a new one. When the primary field is also the date property driving groupBy, the title falls back to a plain non-editable label to avoid a duplicate editable control for the same value, mirroring Board's groupBy/swimlaneBy collision handling. Co-Authored-By: Claude Sonnet 5 --- docs/specifications/collection-views.md | 4 +- .../components/CalendarCollectionView.svelte | 35 ++++++- .../CalendarCollectionView.svelte.test.ts | 92 ++++++++++++++++++- 3 files changed, 124 insertions(+), 7 deletions(-) diff --git a/docs/specifications/collection-views.md b/docs/specifications/collection-views.md index d576073..f05e6ac 100644 --- a/docs/specifications/collection-views.md +++ b/docs/specifications/collection-views.md @@ -52,7 +52,7 @@ Each is a standalone component (`src/lib/components/*CollectionView.svelte`) tak **Board swimlanes** (`config.swimlaneBy`, issue #67/#165) — an optional second grouping dimension, rows crossing the existing columns, GitLab/Jira-style. A "Swimlane by" dropdown next to "Group by" offers every `select` property in the schema except whichever one is already `groupBy` (Board never lets one property drive both dimensions); it's hidden entirely when no other `select` property exists, rather than prompting to create one the way the initial "Group by" empty state does — the swimlane dimension is optional, not required to use Board at all. When set, `groupBySwimlaneAndColumn` (§3) replaces the flat column render with one row per swimlane (plus a trailing "No ``" catch-all), each rendering the same full column set — every column still appears inside every swimlane, including an otherwise-empty one, matching `groupBySelectProperty`'s own "preserve empty groups" rule at the second dimension. Both the "Move to column" `` are per-card, keyboard/screen-reader-accessible alternatives to drag-and-drop; dragging a card into a different swimlane's column cell (or dropping it via native drag-and-drop) sets both the column and swimlane grouping properties in one `setCollectionCell` call, and the swimlane-only move sets just the swimlane property, leaving the record's column untouched. Manual per-cell card order (§8) is scoped by swimlane too, keyed by `` `${groupBy}:${swimlaneBy}:${swimlaneOptionId}:${columnOptionId}` `` — switching either grouping property, or moving a card across swimlanes, never resurrects a stale order saved under a different cell. Retargeting "Group by" onto the property currently driving swimlanes clears `swimlaneBy` (rather than leaving it pointing at what's now the column property too — a duplicate-dimension state `swimlaneCandidates` can never resolve back to) as part of that same `onConfigChange` call; a different retarget leaves `swimlaneBy` untouched. If `swimlaneBy` ever names a property that's been deleted out from under it instead, the swimlane row disappears and Board falls back to its flat single-dimension column view — the same graceful-degradation `groupProperty` itself already has when `groupBy` names a missing field. -**Calendar** (`CalendarCollectionView.svelte`) — `config.groupBy` names the `date` property driving placement, same add-one-if-missing prompt as Board. A fixed 6-row/42-cell month grid (leading/trailing days from adjacent months included, so the grid's shape doesn't jump between 5- and 6-row months) places each record on the day matching `dateKeyForRecord`; a record with no value for the date property renders in an "Unscheduled" section below rather than being hidden. Every entry (scheduled or unscheduled) renders its date property inline via `PropertyValueCell`, so rescheduling — including giving an unscheduled record its first date — is a direct edit. A day cell's "+" button creates a record with that day pre-filled (`createCollectionRow`). +**Calendar** (`CalendarCollectionView.svelte`) — `config.groupBy` names the `date` property driving placement, same add-one-if-missing prompt as Board. A fixed 6-row/42-cell month grid (leading/trailing days from adjacent months included, so the grid's shape doesn't jump between 5- and 6-row months) places each record on the day matching `dateKeyForRecord`; a record with no value for the date property renders in an "Unscheduled" section below rather than being hidden. Every entry (scheduled or unscheduled) shows the Collection's resolved primary field (§7) as its title — normally a directly-editable `PropertyValueCell`, matching Board, rendered `compact` given Calendar's denser rows — plus its date property (also inline via `PropertyValueCell`, so rescheduling, including giving an unscheduled record its first date, is a direct edit) and whatever other schema properties are in `visibleProperties` (issue #105: this predates #96 as a plain-text-only title and was made editable to match Board once "primary field" became a named concept applied identically everywhere else — Calendar's entries already supported inline editing for the date field, so this isn't a new interaction pattern for the view, just extending it to one more field). A day cell's "+" button creates a record with that day pre-filled (`createCollectionRow`). ## 5. Permission scoping and `get_document` @@ -86,7 +86,7 @@ Board and Calendar cards used to title themselves off "the first `text`-type fie - **Resolution: `resolvePrimaryField(schema, primaryFieldKey)`.** Returns the schema field named by `primaryFieldKey` when it exists and is an eligible type; otherwise falls back to the first `text` field in schema order — the exact pre-#96 implicit rule — so a Collection created before this feature, or one whose chosen primary field was since deleted or retyped away, keeps showing the same title without a migration step. The fallback only ever considers `text` fields, not every eligible type, so this returns `undefined` both when the schema has no eligible field at all and when it has eligible fields (e.g. a lone `number` or `select` field) but none of type `text` (Board/Calendar's card/entry title then reads "Untitled", same as before). Board and Calendar both call this in place of their old `schema.find(p => p.type === 'text')` line; `FieldMenu`'s primary-field indicator (next bullet) calls it too, so the star always matches what's actually titling a card. - **Eligible types: everything except `relation`.** A `relation` value is a list of record IDs with no display string of its own (data-model.md's `PropertyValue`) — every other type (`text`, `number`, `date`, `select`, `checkbox`) already renders as one displayable value via `PropertyValueCell`, so `setPrimaryField` rejects choosing a `relation` field (`ValidationError`), and a retype that turns the current primary field _into_ `relation` clears `primaryFieldKey` in the same transaction rather than leaving it pointing at a now-invalid field. Deleting the current primary field clears it the same way. Both repairs mirror the existing `deleteCollectionProperty`/`updateCollectionProperty` pattern of fixing up stale schema references in one transaction rather than leaving a dangling key for a reader to notice later. - **Display value: `primaryFieldDisplayValue(value, property)`** (`src/lib/data/views.ts`) — a plain-text rendering of any eligible type's value (a `select` value resolves through `property.options` to its label; `checkbox` renders "Checked"/empty), used for Board/Calendar's card-title `aria-label`s and Calendar's static entry-title text. Board's own card _editor_ still renders the primary field as a full `PropertyValueCell` (so whichever type is chosen — not just `text` — stays directly editable inline); this function is only for the places a plain string is needed instead of an editable cell. -- **Colliding with `groupBy` or `swimlaneBy` (issue #104).** `primaryFieldKey` and Board/Calendar's `groupBy` (§3), or Board's `swimlaneBy`, are independent choices — nothing stops a user from pointing any of them at the same `select`/`date` field. Calendar's entry title is _always_ the plain, non-editable `primaryFieldDisplayValue` text described above (it never renders the title as a `PropertyValueCell` at all), so this collision doesn't create a duplicate control there. Board's card title normally _is_ a directly-editable `PropertyValueCell` (previous bullet), which — when `titleProperty.key` matches either `groupProperty.key` or `swimlaneProperty.key` — would otherwise sit right above the matching "Move to column"/"Move to swimlane" `` (§3), or Calendar's own date `PropertyValueCell` rendered lower in the same entry (§4). Both views resolve this identically, by falling back to the same plain non-editable label used when there's no primary field at all (`titleEditableViaCell` in `BoardCollectionView.svelte`, checked against both properties; the same-named derived value in `CalendarCollectionView.svelte`, checked against `groupBy` only, since Calendar has no swimlane dimension): the other control stays the one editable path to that value, and the title cell for every other field keeps behaving exactly as described above. Nothing prevents choosing the same field for more than one of these roles — there's no correctness reason to forbid it — this only changes which control is editable when a choice collides. - **Control and indicator: `FieldMenu`.** A "Set as primary field" / "Unset primary field" menu item (disabled, with an explanatory `title`, for a `relation` field) calls `setPrimaryField`/`setPrimaryField(..., null)` directly — the same direct-Yjs-mutation pattern every other `FieldMenu` action already uses. The toggle's own `isPrimary` state compares the Collection's raw `primaryFieldKey` prop against `property.key` directly — deliberately **not** through `resolvePrimaryField` — because a resolved comparison would make the auto-fallback field's own toggle a permanent no-op: with `primaryFieldKey` unset, the fallback field already resolves as primary, so a resolved `isPrimary` would read `true` and the menu would only ever offer "Unset," writing `null` onto an already-`null` key with no way to actually promote that field to an explicit choice. The small star indicator next to a field's label (rendered by each of `FieldMenu`'s call sites — `TableCollectionView` and `FieldManagerDialog`; `/table/[id]` inherits it by composing `TableCollectionView` (§4) rather than rendering its own `FieldMenu` any more; Board/Calendar show no column headers, so neither renders `FieldMenu` at all) is unaffected by this distinction: those call `resolvePrimaryField` themselves for display, so the star still reflects the _resolved_ primary field, including the auto-fallback case, even while the field's own toggle correctly reads as "not yet explicitly set." - **MCP-visible schema.** `collections.listCollections` and `collections.queryCollection` (`mcp-tools.md`) both include a `primaryFieldKey` alongside `schema` in their response — the _resolved_ key (`resolvePrimaryField`'s result), not the raw possibly-unset stored value, so an agent always sees which field is actually titling a record right now rather than having to reimplement the fallback rule itself. - **Deliberately not built.** A per-view override (a Board embed titling its cards differently than a Table view of the same Collection) — data-model.md §2's "a view never introduces view-specific row fields" argues directly against it, and no user story in this repo has asked for cards and rows to disagree about a record's name. Record templates, formula/computed primary fields, and multi-field display identities (e.g. "first + last name") are out of scope for the same reason #96 itself lists them as non-goals — they're a different, larger feature than "pick which existing field is the title." diff --git a/src/lib/components/CalendarCollectionView.svelte b/src/lib/components/CalendarCollectionView.svelte index e6746a2..dfbc0a7 100644 --- a/src/lib/components/CalendarCollectionView.svelte +++ b/src/lib/components/CalendarCollectionView.svelte @@ -121,6 +121,15 @@ const dateProperties = $derived(schema.filter((p) => p.type === 'date')); const dateProperty = $derived(schema.find((p) => p.key === config.groupBy)); const titleProperty = $derived(resolvePrimaryField(schema, primaryFieldKey)); + // Mirrors BoardCollectionView's titleEditableViaCell (issue #104/#105): + // when the primary field is also the date property driving groupBy, the + // date PropertyValueCell rendered lower in the entry is already the one + // editable control for that value — an editable title cell for the same + // field would just be a second, redundant control. Calendar has no + // swimlaneBy, so groupBy is the only collision to guard against. + const titleEditableViaCell = $derived( + titleProperty != null && titleProperty.key !== dateProperty?.key + ); const projected = $derived(projectRecords(rows, schema, config)); const entryFields = $derived( visibleProperties(schema, config).filter( @@ -380,7 +389,18 @@ {#each scheduled.get(key) ?? [] as row (row.id)}
- {entryTitle(row)} + {#if titleEditableViaCell && titleProperty} +
+ setCell(row, titleProperty, value)} + compact + /> +
+ {:else} + {entryTitle(row)} + {/if}