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
4 changes: 2 additions & 2 deletions docs/specifications/collection-views.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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.

**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`

Expand Down Expand Up @@ -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" `<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.
- **Colliding with `groupBy` or `swimlaneBy` (issue #104, #105).** `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. Both Board's card title and Calendar's entry title normally render the primary field as a directly-editable `PropertyValueCell` (§4), which — when `titleProperty.key` matches `groupProperty.key` (Board or Calendar) or Board's `swimlaneProperty.key` — would otherwise sit right next to another editable control for the identical value: Board's matching "Move to column"/"Move to swimlane" `<select>` (§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."
Expand Down
35 changes: 33 additions & 2 deletions src/lib/components/CalendarCollectionView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -380,7 +389,18 @@
{#each scheduled.get(key) ?? [] as row (row.id)}
<div class="rounded border border-border bg-bg px-1.5 py-1 text-xs">
<div class="flex items-center justify-between gap-1">
<span class="truncate font-medium text-fg">{entryTitle(row)}</span>
{#if titleEditableViaCell && titleProperty}
<div class="min-w-0 flex-1">
<PropertyValueCell
property={titleProperty}
value={row.properties?.[titleProperty.key]}
oninput={(value) => setCell(row, titleProperty, value)}
Comment on lines +394 to +397

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Screen readers cannot name title fields 📜 Skill insight ☑ Accessibility

PropertyValueCell renders its text, number, date, checkbox, and select controls without a label or
ARIA naming attribute, while the new scheduled and unscheduled Calendar title call sites pass only
property, value, and oninput. Whenever an entry's primary field is editable, screen-reader
users encounter an unnamed control and cannot determine that it edits the entry title, even though
the field label remains available in component data.
Agent Prompt
## Issue description
The newly editable scheduled and unscheduled Calendar title fields render through `PropertyValueCell`, whose generated form controls have no accessible name, so assistive technology cannot identify their purpose.

## Fix Focus Areas
- src/lib/components/CalendarCollectionView.svelte[392-399]
- src/lib/components/CalendarCollectionView.svelte[457-464]
- src/lib/components/PropertyValueCell.svelte[5-73]

## Recommended Fix
Give every form control emitted by `PropertyValueCell` an accessible name derived from `property.label`, or add an accessible-label prop, apply it to every generated form control, and pass a descriptive primary-field title label from both scheduled and unscheduled Calendar title call sites. Ensure the text, number, date, checkbox, and select variants are all covered.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

compact
/>
</div>
{:else}
<span class="truncate font-medium text-fg">{entryTitle(row)}</span>
{/if}
<div class="flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
Expand Down Expand Up @@ -434,7 +454,18 @@
<div
class="flex items-center gap-2 rounded-md border border-border bg-bg px-2.5 py-1.5 text-sm"
>
<span class="flex-1 truncate text-fg">{entryTitle(row)}</span>
{#if titleEditableViaCell && titleProperty}
<div class="min-w-0 flex-1">
<PropertyValueCell
property={titleProperty}
value={row.properties?.[titleProperty.key]}
oninput={(value) => setCell(row, titleProperty, value)}
compact
/>
</div>
{:else}
<span class="flex-1 truncate text-fg">{entryTitle(row)}</span>
{/if}
{#if dateProperty}
<div class="w-36">
<PropertyValueCell
Expand Down
92 changes: 89 additions & 3 deletions src/lib/components/CalendarCollectionView.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, within } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import * as Y from 'yjs';
import { createCollection, getCollection } from '$lib/data/collection-ops';
import { createCollection, getCollection, setPrimaryField } from '$lib/data/collection-ops';
import {
createRecord,
deleteRecord,
Expand Down Expand Up @@ -113,7 +113,7 @@ describe('CalendarCollectionView', () => {
);
renderCalendar('col-1', { groupBy: 'due' });

expect(await screen.findByText('Launch')).toBeInTheDocument();
expect(await screen.findByDisplayValue('Launch')).toBeInTheDocument();
expect(screen.queryByText('Unscheduled')).not.toBeInTheDocument();
});

Expand All @@ -134,7 +134,7 @@ describe('CalendarCollectionView', () => {
renderCalendar('col-1', { groupBy: 'due' });

expect(await screen.findByText('Unscheduled')).toBeInTheDocument();
expect(screen.getByText('No date yet')).toBeInTheDocument();
expect(screen.getByDisplayValue('No date yet')).toBeInTheDocument();
});

it("resolves a relation field's value to the target Collection's record title on a scheduled entry (issue #15)", async () => {
Expand Down Expand Up @@ -224,6 +224,92 @@ describe('CalendarCollectionView', () => {
});
});

it('edits a scheduled entry title inline via its primary-field cell, matching Board (issue #105)', async () => {
createCollection(ydoc, {
id: 'col-1',
title: 'Cal',
schema: [
{ key: 'title', label: 'Title', type: 'text' },
{ key: 'due', label: 'Due', type: 'date' }
]
});
const record = createRecord(
ydoc,
{
parentId: 'col-1',
properties: {
title: { type: 'text', value: 'Launch' },
due: { type: 'date', value: '2026-03-05' }
}
},
actor
);
renderCalendar('col-1', { groupBy: 'due' });

const titleInput = await screen.findByDisplayValue('Launch');
await fireEvent.change(titleInput, { target: { value: 'Launch v2' } });

expect(getRecord(ydoc, record.id)?.properties?.title).toEqual({
type: 'text',
value: 'Launch v2'
});
});

it('edits an unscheduled entry title inline via its primary-field cell (issue #105)', async () => {
createCollection(ydoc, {
id: 'col-1',
title: 'Cal',
schema: [
{ key: 'title', label: 'Title', type: 'text' },
{ key: 'due', label: 'Due', type: 'date' }
]
});
const record = createRecord(
ydoc,
{ parentId: 'col-1', properties: { title: { type: 'text', value: 'Needs a date' } } },
actor
);
renderCalendar('col-1', { groupBy: 'due' });

const titleInput = await screen.findByDisplayValue('Needs a date');
await fireEvent.change(titleInput, { target: { value: 'Still needs a date' } });

expect(getRecord(ydoc, record.id)?.properties?.title).toEqual({
type: 'text',
value: 'Still needs a date'
});
});

it('falls back to a plain non-editable title when the primary field is also the groupBy date property (issue #105)', async () => {
createCollection(ydoc, {
id: 'col-1',
title: 'Cal',
schema: [
{ key: 'due', label: 'Due', type: 'date' },
{ key: 'notes', label: 'Notes', type: 'text' }
]
});
setPrimaryField(ydoc, 'col-1', 'due');
createRecord(
ydoc,
{
parentId: 'col-1',
properties: {
due: { type: 'date', value: '2026-03-20' },
notes: { type: 'text', value: 'Kickoff' }
}
},
actor
);
renderCalendar('col-1', { groupBy: 'due' });

// The date value shows up as exactly one editable control (the date
// PropertyValueCell) — the title renders the same value as static text
// instead of a second, redundant editable cell for the same field.
expect(await screen.findByText('2026-03-20')).toBeInTheDocument();
expect(screen.getAllByDisplayValue('2026-03-20')).toHaveLength(1);
});

it('sets a date on an unscheduled record from its inline date field', async () => {
createCollection(ydoc, {
id: 'col-1',
Expand Down
Loading