Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/specifications/collection-views.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Each is a standalone component (`src/lib/components/*CollectionView.svelte`) tak

**Table** (`TableCollectionView.svelte`) — a grid over `visibleProperties(schema, config)` columns and `projectRecords(rows, config)` rows, each cell a `PropertyValueCell.svelte` (the same property-type-to-editor mapping every view shares — text/number/date/select/checkbox/relation, one implementation, not reimplemented per view). Each column header carries its own `FieldMenu.svelte` (rename/change type, insert left/right, duplicate, hide in this view, delete — see §7), and `ViewToolbar` exposes a "Manage fields" entry point (`FieldManagerDialog.svelte`) shared by Table, Board, and Calendar alike. It takes a `variant: 'embedded' | 'full-page'` prop (default `'embedded'`): embedded mode shows a small link to `/table/[id]` when the collection has no properties yet (creating the very first field needs a real row/column grid to attach to) and an "Open full table →" footer link; `/table/[id]` itself renders this same component with `variant="full-page"`, which suppresses both (a self-link to the page already showing would be nonsensical) and always renders the grid, even with zero fields, matching the full-page route's pre-#189 behavior. Two callback props, `onConnect`/`onSnapshot`, let a composing parent (only `/table/[id]` today) observe the component's own resolved `Y.Doc` and collection snapshot — e.g. for its own title-editing input — without maintaining a second, redundant `useCollectionConnection`/`useCollectionView` pair alongside the one this component already owns.

**Board** (`BoardCollectionView.svelte`) — `config.groupBy` names the `select` property driving columns; a dropdown lets the user switch it if the schema has more than one, or the collection gets prompted to add one (`appendCollectionField`) if it has none, rather than silently rendering nothing. Cards show the record's first `text`-type property as a directly-editable title, plus whatever other schema properties are in `visibleProperties`. Moving a card between columns — via native HTML5 drag-and-drop, a card's own "Move to" `<select>` (a keyboard/screen-reader-accessible alternative to drag-and-drop), or the card's own field editor if the grouping property is also a visible field — updates that record's existing grouping property (`setCollectionCell`); it is never a reorder of Collection membership or a record copy. Manual sort mode (`config.sort.mode === 'manual'`, the default) additionally supports dragging a card to a specific position within a column — that ordering is **session-local component state** (`manualOrder: Record<columnKey, recordId[]>`), not written to the block or the Collection (see §8 for why manual order specifically stays ephemeral even though the rest of `viewConfig` is now a draft that gets explicitly saved — §9).
**Board** (`BoardCollectionView.svelte`) — `config.groupBy` names the `select` property driving columns; a dropdown lets the user switch it if the schema has more than one, or the collection gets prompted to add one (`appendCollectionField`) if it has none, rather than silently rendering nothing. Cards show the Collection's resolved primary field (§7) as the title — normally a directly-editable `PropertyValueCell`, but a plain non-editable label when that field is also `groupBy` or `swimlaneBy` (§7's issue #104 collision handling) — plus whatever other schema properties are in `visibleProperties`. Moving a card between columns — via native HTML5 drag-and-drop, a card's own "Move to" `<select>` (a keyboard/screen-reader-accessible alternative to drag-and-drop), or the card's own field editor if the grouping property is also a visible field — updates that record's existing grouping property (`setCollectionCell`); it is never a reorder of Collection membership or a record copy. Manual sort mode (`config.sort.mode === 'manual'`, the default) additionally supports dragging a card to a specific position within a column — that ordering is **session-local component state** (`manualOrder: Record<columnKey, recordId[]>`), not written to the block or the Collection (see §8 for why manual order specifically stays ephemeral even though the rest of `viewConfig` is now a draft that gets explicitly saved — §9).

**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 `<property>`" 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" `<select>` and a parallel "Move to swimlane" `<select>` 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.

Expand Down Expand Up @@ -86,6 +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" `<select>` (§3) as a second, redundant editable control for the identical value. Board resolves this by falling back to the same plain non-editable label it already uses when there's no primary field at all (`titleEditableViaCell` in `BoardCollectionView.svelte`, checked against both properties): the relevant "Move to…" select stays the one editable control for 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."
Expand Down
15 changes: 14 additions & 1 deletion src/lib/components/BoardCollectionView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,19 @@
: null
);
const titleProperty = $derived(resolvePrimaryField(schema, primaryFieldKey));
// Issue #104: when the primary field is also the grouping property, the
// title cell and the "Move to column" select below it would otherwise be
// two separate editable controls for the identical underlying value —
// and the same collision applies to the "Move to swimlane" select when
// the primary field is the swimlane property instead. Whichever select
// stays the one editable control in that case; the title renders as the
// same plain, non-editable label already used when there's no primary
// field at all (cardTitle below).
const titleEditableViaCell = $derived(
titleProperty != null &&
titleProperty.key !== groupProperty?.key &&
titleProperty.key !== swimlaneProperty?.key
);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
const cardFields = $derived(
visibleProperties(schema, config).filter(
(p) =>
Expand Down Expand Up @@ -467,7 +480,7 @@
ondrop={(e) => handleCardDrop(e, column, row.id, swimlane)}
>
<div class="mb-1.5 flex items-start justify-between gap-2">
{#if titleProperty}
{#if titleEditableViaCell && titleProperty}
<div class="flex-1">
<PropertyValueCell
property={titleProperty}
Expand Down
77 changes: 77 additions & 0 deletions src/lib/components/BoardCollectionView.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,83 @@ describe('BoardCollectionView', () => {
expect(await screen.findByDisplayValue('Still primary')).toBeInTheDocument();
});

it('falls back to a plain, non-editable title label when the primary field is also the grouping property (issue #104)', async () => {
createCollection(ydoc, {
id: 'col-1',
title: 'Board',
schema: [
{
key: 'status',
label: 'Status',
type: 'select',
options: [{ id: 'todo', label: 'To do' }]
}
]
});
setPrimaryField(ydoc, 'col-1', 'status');
createRecord(
ydoc,
{ parentId: 'col-1', properties: { status: { type: 'select', value: 'todo' } } },
actor
);
renderBoard('col-1', { sort: { mode: 'manual' }, groupBy: 'status' });

const moveSelect = await screen.findByLabelText('Move To do to column');
const card = moveSelect.closest('[draggable="true"]') as HTMLElement;

// The title reads the resolved primary field's display value, but as
// plain text rather than its own editable control...
expect(within(card).getByText('To do', { selector: 'span' })).toBeInTheDocument();
// ...so the "Move to column" select is the only editable control for
// this value anywhere on the card.
expect(within(card).getAllByRole('combobox')).toHaveLength(1);
});

it('falls back to a plain, non-editable title label when the primary field is also the swimlane property (issue #104)', async () => {
createCollection(ydoc, {
id: 'col-1',
title: 'Board',
schema: [
{
key: 'status',
label: 'Status',
type: 'select',
options: [{ id: 'todo', label: 'To do' }]
},
{
key: 'priority',
label: 'Priority',
type: 'select',
options: [{ id: 'high', label: 'High' }]
}
]
});
setPrimaryField(ydoc, 'col-1', 'priority');
createRecord(
ydoc,
{
parentId: 'col-1',
properties: {
status: { type: 'select', value: 'todo' },
priority: { type: 'select', value: 'high' }
}
},
actor
);
renderBoard('col-1', { sort: { mode: 'manual' }, groupBy: 'status', swimlaneBy: 'priority' });

const swimlaneSelect = await screen.findByLabelText('Move High to swimlane');
const card = swimlaneSelect.closest('[draggable="true"]') as HTMLElement;

// The title reads the resolved primary field's (priority's) display
// value, but as plain text rather than its own editable control...
expect(within(card).getByText('High', { selector: 'span' })).toBeInTheDocument();
// ...so the "Move to column"/"Move to swimlane" selects are the only
// editable controls for their respective values — no third, redundant
// editable control for the primary field itself.
expect(within(card).getAllByRole('combobox')).toHaveLength(2);
});

it('adds a new option to a non-grouping select field from a card without touching the grouping property', async () => {
createCollection(ydoc, {
id: 'col-1',
Expand Down
Loading