mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
feat(web): filter and group collection views by a relation field (TASK-2998 / PLAN-2857 U7) (#1327)
* feat(web): the lane rules for grouping a view by a relation field (TASK-2998 / PLAN-2857 U7, part 1) Every other groupable field carries its own lane vocabulary — a `select` has `options`, and `bucketByColumn` buckets against that fixed list. A relation has none: its lanes are whatever targets the rows point at. So they have to be DERIVED from the items and resolved against the local index, and that is a set of decisions rather than a rendering detail. The decisions, each with a test and a mutant: - LANES COME FROM THE ITEMS, ordered alphabetically by target title rather than by first appearance. Items arrive asynchronously through the local index, and a first-appearance order reshuffles the board as they land. - A DELETED TARGET KEEPS ITS OWN LANE, labelled with its ref and title. It has both, so it can be honest, and merging it would combine rows pointing at different things. - ONLY VALUES THAT RESOLVE TO NOTHING SHARE A LANE, labelled "Unresolved reference" — never the stored string. That string is usually free text the pre-TASK-2878 fallback wrote, and rendering it as a lane name presents a typo as a category. The test asserts the lane contains neither the uuid nor the free-text value, not merely that the label looks right. - THE THREE FACTS STAY APART. `bucketByColumn` routes any unrecognised value to UNCATEGORIZED, which would put "no value", "target deleted" and "points at nothing" in one lane. `relationLaneValueFor` rewrites to a reserved sentinel first, so that helper's own rule — every item in exactly one lane — holds unchanged. That is the design table's proving row, asserted by placing six items and counting. - ONLY A LIVE LANE ACCEPTS A DROP. Dropping on one sets the relation, which is the natural board gesture; UNCATEGORIZED would clear the field, and the other two would write a reference TASK-2878's validator refuses — the card would move on screen and the write would fail behind it. `narrowRelationRow` carries the two narrowings a workspace-wide lookup needs: ID ONLY (a slug match makes the label lie about what is stored, and slugs are mutable) and TARGET COLLECTION, the latter only when both slugs are ones the collection list knows, so a rename in flight does not flash every lane to unresolved. The same rule lives inline in FieldEditor's relation branch; it is duplicated rather than extracted because that file is being edited under TASK-2996 as this lands, and the duplication is written down where it will be found. Pure module, no runes, like `unparentedFilter` and `boardColumns` beside it — these rules are exactly what nobody tests when they can only be reached by mounting a board. 13 tests, 7 mutants, each dying. The proving row's cross-check against U5's reverse index is added when U5 merges; until then it is against a fixture this file builds, as the design row says. No UI yet — BoardView wiring and the filter half are part 2. Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt * feat(web): BoardView groups by a relation field, with the chip as the lane label (TASK-2998 / PLAN-2857 U7, part 2) Part 1 wrote the lane rules; this wires them. `columns` came from `field.options` alone, and a relation has none — so before this, grouping a board by a relation put every card in UNCATEGORIZED and there was no lane to label at all. Now: when the group field is a relation, the lanes come from the targets the rows point at, resolved through the local index, and the header carries U2's chip vocabulary — `COLOR-1 Red`, `COLOR-9 Gone (deleted)`, and one "Unresolved reference" lane for values that resolve to nothing. Never the stored id. `bucketByColumn` gains an optional lane-value mapper rather than a relation branch: the relation caller folds unresolvable values onto a sentinel before bucketing, so that helper's own rule — every item lands in exactly one lane — holds unchanged and every existing caller is byte-identical. Two affordances are OFF for a relation lane, for reasons rather than caution: a column reorder has nowhere to persist to (the order is alphabetical, not schema-held) and would snap back, and lane-level "+ add" means writing a relation, which belongs to the picker. A drop is allowed, but only onto a LIVE lane — the other three would either clear the field or write a reference TASK-2878's validator refuses, so the card would move on screen and the write would fail behind it. Six tests. THREE THINGS WERE MEASURED RATHER THAN ASSUMED, and each changed the tests: - `toContain('Red')` passed against a board that fell back to formatting the raw id, because `formatLabel('id-red')` renders "Id-Red" — which contains "Red". The lane assertions read the DOM structurally now (ref span, note span, the rest), and the label mutant went from killing one test to killing three. - the drop gate's mutant SURVIVED every rendering test, so the gate was unobserved. It has a source guard now, with a header saying what a source guard cannot do — the gate is reached through svelte-dnd-action's drag events, and driving those in jsdom tests the drag library instead. - the counterfactual leg: an ordinary `select` field must still bucket against its own options. A board that asked the local index for lanes on every field type would relabel ordinary lanes with whatever an id-shaped option resolved to. Full web suite 2483 passed; svelte-check 0 errors. The filter half of U7 is next. Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt * feat(web): filter a collection view by a relation value, picker-driven (TASK-2998 / PLAN-2857 U7, part 3) The page's `filteredItems` has compared `fields[key] === value` for any key since long before this plan, so the FILTERING mechanism was never missing. What was missing is a way to SET one — a relation value is an item id, and nobody types an id — and a chip that says what the stored id means. Both reuse what the earlier units built rather than inventing a second way to choose and name an item: U3's `ItemPicker`, scoped to the field's DECLARED target collection, and U2's chip vocabulary via `relationChipFor`. That helper is now the single source for how a relation value presents, and the lane headers from part 2 are built from it too — a test asserts the chip and the lane are `toEqual`, because two functions describing the same value drift, and the drift would first be seen by a user watching a board lane and a filter chip disagree about the same id. Deliberately untouched: the hardcoded tasks→plans parent filter. It filters on `parent_link_id`, not on a field — a different mechanism wearing a similar hat, and generalising it is not this unit. No relation filter renders without a `wsSlug`. The public share view has no workspace context, so it cannot resolve a title, and a chip showing a bare id is worse than no chip. Nine tests, and TWO OF THEM SURVIVED THEIR OWN MUTANTS FIRST: - "opens a picker scoped to the declared collection" asserted `input[placeholder="Search colors…"]` — text this file's own `placeholder` prop produces — so deleting the `collection` prop entirely left it green. It asserts what the picker ASKS THE INDEX FOR now. - "the × clears the filter" only ever rendered with a filter set, so a bar showing a permanent clear control passed. There is a counterfactual leg. Both are recorded in the test file. A test that measures the wrong thing looks exactly like one that measures the right thing, right up until the mutant. Full web suite 2496 passed; svelte-check 0 errors. Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt * test(e2e): relation filter and grouping, driven in a browser against this tree (TASK-2998 / PLAN-2857 U7, part 4) CONVE-34 / CONVE-2729: a `web/src` claim is proved against a binary built from the tree it is about — `npx vite build` then `make build-go` — because the server embeds `web/build` and a stale bundle tests someone else's frontend. The leg creates a Colours collection, a Cars collection whose schema carries a relation field aimed at it, and four cars: two pointing at live colours, one with no colour, and one pointing at a colour that is then DELETED. It asserts the lanes come from the targets, that the deleted one keeps its own lane and says "(deleted)", that no lane is labelled with a raw id, that all four cards are placed, and that filtering by Red narrows to one card and clearing restores four. COUNTERFACTUAL RUN, not assumed: against a binary built before this unit the leg fails at the first lane assertion — `element(s) not found ... filter({ hasText: 'Red …' })` — because a relation field has no `options` and every card landed in UNCATEGORIZED with no lane to name. THREE THINGS THE RUN TAUGHT ME THAT READING DID NOT: - A value that resolves to NOTHING cannot be created through the API any more: U1's validator refuses it. That lane exists only for legacy rows, so it is covered by unit tests and the spec says so — "the e2e does not test it" and "the e2e cannot test it" are different facts, and the second one is the interesting one. - My own cleanup helper masked the real failure. I reimplemented `deleteCollection` without the swallow the repo's version carries, so a timeout surfaced as `apiRequestContext.delete: Target page, context or browser has been closed` and hid the assertion that actually failed. The warning in the original helper says exactly this; I had to rediscover it. - The FilterBar lives behind the toolbar's filter toggle, and ItemPicker's input is an ARIA combobox rather than a textbox. Both found by the leg timing out, not by reading the page. Full web suite and svelte-check clean; the e2e leg passes against a binary built from 3c4b96ae. Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt * refactor(web): one implementation of the relation narrowings, now that TASK-2996 has merged (TASK-2998 / PLAN-2857 U7) Part 1 duplicated FieldEditor's two narrowings into `narrowRelationRow` rather than extracting them, because that file was being edited under TASK-2996 at the time, and said so in a comment. 2996 has merged (162eccb4) without touching it, so the fork is closed rather than left as a note for someone else. FieldEditor's relation branch now calls the helper. The two were equivalent by construction, and the one condition that looked different is not: `relationTarget === 'live'` and "the collection list knows the declared slug" are the same fact — `relationTarget` is derived from that same list — so the helper's guard already covers the stale and unknown cases the inline version named. The helper's `knownCollectionSlugs` accepts `null` as well as undefined, because FieldEditor's own set is `Set<string> | null` (null while the collection list has not loaded) and "we do not know the collections yet" is exactly the case that argument's guard already treats as "do not judge". Typed rather than coerced at the call site, so the rule stays stated in one place, with its own test. Pinned by the tests that were already there: removing the narrowing from FieldEditor kills three of its relation legs — the slug-match case, the wrong-collection case, and the control that proves the check still fires when both slugs are known. Rebased onto main. Full web suite passes; svelte-check 0 errors. Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt * fix(web): a refused drop puts the card back, a share never shows a raw id, and padded legacy values resolve (TASK-2998, codex round 1) Four findings, and the first is one my own comment claimed to have avoided. THE REFUSED DROP LEFT THE CARD WHERE IT LANDED. The gate's comment said refusing was better than "the card moves on screen and the write fails behind it" — and the implementation produced exactly that, one step later: svelte-dnd-action has already moved the card in `columnData` by the time `commitColumnMove` runs, so returning early without undoing it leaves the board showing a move that never happened. It re-reads `propColumnData` now, which is how the existing failure path reverts; assigning directly rather than releasing a cooldown nobody set is the same repair without the two-second window. A PUBLIC SHARE WOULD HAVE RENDERED RAW IDS. `resolveGroupField` honoured a saved `board_group_by` naming a relation, and the public board has no local index — the payload carries field VALUES — so it grouped on the stored id and labelled each lane with `formatLabel(uuid)`: a wall of title-cased UUIDs, which is the precise thing this unit exists to stop showing. A relation is not groupable there now, and the view falls through to the owner's next grouping. The real fix is for the share payload to carry the target's ref and title; that is a server change and a separate unit. PADDED LEGACY VALUES RESOLVED AS A CHIP AND NOT AS A LANE. `relationChipFor` has always trimmed and `relationLaneValueFor` did not, so `" id-red "` produced a live chip and an unresolved lane — the same value described two ways by the two functions this module exists to keep in agreement. The pre-TASK-2878 write path accepted any string, padding included, so those rows exist. Trimmed at the lane seam, with a test that padded and unpadded forms collapse into ONE lane, because two "Red" lanes would be worse than either bug alone. THE ACCESSIBLE LANE NAME STILL CARRIED THE ID. The group's `aria-label` formatted the raw column value, so a lane reading "COLOR-1 Red" announced as "Id-Red column" and the unresolved lane as "$Unresolved column". A claim that a lane is named from its target rather than its stored id is not the claim if it holds only for sighted users. `relationLaneAriaName` gives assistive technology the same words, including "(deleted)". Nine tests added; four mutants, each dying. The rollback is asserted as a source-level ASSIGNMENT rather than as a board that visibly snaps back — the honest instrument for that is a drag driven through svelte-dnd-action, which tests the drag library as much as this decision, and the test file says so. Full web suite 2506 passed; svelte-check 0 errors. Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt * fix(web): the list view was the other public door, and the filter still read padded values strictly (TASK-2998, codex round 2) Two P1s and two P2s. Both P1s are places where round 1's fixes stopped one step short of the class they were closing. THE SHARE REFUSAL ONLY COVERED THE BOARD. `resolveGroupField` guards `board_group_by`; a saved view with `view_type: "list"` routes its `group_by` to `list_group_by`, and `PublicListView` resolves that key itself. So a shared LIST still rendered one group per stored relation id — the same wall of raw ids the board refusal had just closed, through a door I did not look for. The predicate is exported as `isPublicGroupable` and both views ask it. THE FILTER STILL COMPARED PADDED VALUES STRICTLY. Round 1 trimmed the LANE seam and left `fields[key] === value` alone, so an item storing `" id-red "` sat in the Red lane and vanished when you filtered for Red — one value, two answers, one screen apart, and a worse state than before the trim. `relationFilterMatches` trims both sides for relation keys only; every other field type keeps strict equality. Scalar only, deliberately: `multi_relation` (U4) stores an ARRAY, and guessing at array matching here would fix half of that unit in a way it would have to undo. Also: the board required only `type === 'relation'` to group, so a legacy or half-written field with no declared `collection` resolved ids ANYWHERE in the workspace (the filter UI already required one); and the lane-actions menu still passed `onAddItem` for relation lanes, an undocumented second creation path into a lane whose visible "+" this component deliberately withholds. NOT FIXED, and named rather than left silent: no lane cap (5,000 distinct targets is 5,000 DOM columns — real for imported collections, and what a board should do there is a product decision, not a review-round guess); a saved view whose relation field was later removed leaves an active filter with no control to clear it; and `multi_relation` arrays. All three are follow-ups. TWO TESTS MEASURED THE WRONG THING AND THE MUTANTS SAID SO: - the public-list test asserted `not.toContain(RELATION_ID)`, but `formatLabel` replaces `-` with spaces and title-cases, so a uuid renders as "F47ac10b 58cc …" and the id string is never present verbatim. It passed against the broken component. It asserts the ABSENCE OF A GROUP HEADING now. - the predicate had unit tests while nothing pinned PublicListView asking it, so reverting that one call site left every test green. That is a rendered test now, not a source guard. Full web suite 2515 passed; svelte-check 0 errors. Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt * fix(web): a relation-grouped board turned the card's status chip into a relation setter (TASK-2998, codex round 3) One P1, and it is the sharpest finding on this unit. Cards were handed `statusOptions={columns}`. That is correct while a lane value IS a status option — under relation grouping the lanes are ITEM IDS, and the parent's handler writes whatever it receives into `fields[groupField]`. So clicking a card's status chip SET THE CARD'S RELATION, cycling through target ids, and could land on the deleted or unresolved lane, which the write path then refuses. A status affordance quietly repurposed into a relation editor by a grouping choice made somewhere else. The chip is withheld on a relation-grouped board rather than repointed at the real status field: a status chip cycling a field the board is not showing is a different feature, and not one this unit was asked for. TWO TESTS WERE UNABLE TO SEE THEIR OWN DEFECTS UNTIL THEY WERE REBUILT: - The card-placement gap codex named: every existing assertion read lane HEADERS, so removing `bucketByColumn`'s `valueFor` callback left every relation lane rendered and EMPTY while the cards fell into Uncategorized — and all of them stayed green. There are per-lane card COUNTS now, including the padded-value case, and that mutant dies. - The status-chip test itself passed against the broken board at first, because the fixture collection had no `status` FIELD, so no card rendered a chip for an unrelated reason. It carries a status field now, with a precondition that the cards rendered at all, and a counterfactual proving an ordinary board still offers cycling — withholding it everywhere would remove a working affordance from every board on the instance. Round 3's P2s are the three already named as deferred (no lane cap, removed-field saved filters, `multi_relation` arrays) plus one note I accept as documented: relation filter semantics follow the CURRENT schema, so a key that stops being a relation silently reverts to strict equality. Codex calls the no-target-collection fallback safer than what preceded it and not a regression. Full web suite 2519 passed; svelte-check 0 errors. Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt * fix(web): the lane-order write is guarded at its destructive end, not only at the affordance (TASK-2998) Found by walking round 4's own question ahead of it — "enumerate every other behaviour that reads `columns`, `field` or `groupField` and say whether it is correct when the lanes are item ids". `handleGroupReorder` persists the board's lane order as the group field's schema `options`. Under relation grouping those lanes are ITEM IDS, so that write would put uuids into the schema as select options: meaningless, and hard to undo by hand. Not reachable today — BoardView withholds column dragging on a relation board, and `handleColumnDrop` returns unless a drag actually started — so this is defence at the destructive end rather than a bug report. The reason to put it there: a guard at the affordance protects only the affordances somebody remembered, and this page has several. Two other candidates from the same sweep came back CLEAN by reading rather than by assumption, and are worth recording so the next pass does not redo them: - the lane menu's bulk "move all to…" is already gated on `groupField === 'status'`, so it never offers a relation lane as a target; - `columnAccentClassFor` degrades to no class for an id, which is the same thing it does for any non-canonical value. Source guard, with a counterfactual leg asserting an ordinary field still persists its order — a guard that refused everything would silently stop saving column order on every board on the instance, which is worse than the defect it prevents. Its header says what a source guard cannot do. Full web suite 2521 passed; svelte-check 0 errors. Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt * fix(web): the LIST view had every hazard the board did (TASK-2998, codex round 4) Round 4's P1, and the third time on this unit that a fix covered the surface I was looking at and not its sibling: board and list on the SHARE in round 2, board and list authenticated now. It matters more here than it looks. `ListView` DISCOVERS group values from the items — that is what makes a text field groupable — so a relation grouped there rendered one group per stored ITEM ID rather than collapsing into an uncategorised lane. It gets the same three pieces from the same module: lanes derived from the targets, values folded onto a sentinel before bucketing, and the chip vocabulary for the heading, plus the live-lane-only drop rule. AND THE SAME STATUS DEFECT, ARRIVING FROM THE OPPOSITE DIRECTION. The board handed cards its LANES as `statusOptions`, so the chip cycled relation ids. The list takes real `statusOptions` as a prop — right options — and hands the click to `onStatusChange`, which writes what it receives into `fields[groupField]`. So the chip showed correct statuses and wrote them INTO THE RELATION FIELD. Withheld, with a counterfactual proving an ordinary list still cycles. Round 4's three test weaknesses, all real, all fixed: - the picker test proved the picker was SCOPED and never proved what SELECTING does; a mutant writing `row.slug` survived it. The id is the whole contract, since the filter compares the stored field value against it. - the "same vocabulary" leg was partly tautological — `relationLanes` calls `relationChipFor`, so a change to the shared helper moved both sides. There is an independent anchor now: the expected shape written out by hand, so the helper cannot redefine what agreement means. - the e2e asserted a TOTAL card count, which survives a mutant putting every card in one lane while the headings still render — precisely what dropping the bucketing callback does. Per-lane counts now; re-run green against a binary rebuilt from this tree. Full web suite 2525 passed; svelte-check 0 errors; e2e green. Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt * fix(web): the list offered a group reorder that could not persist (TASK-2998, surface matrix) Found by building the surface matrix the lead asked for rather than by a review round — which is the point of building it. `ListView` receives `onGroupReorder` from the page just as the board does, and I had only withheld the gesture on the BOARD. The page refuses the write for a relation field, so nothing could corrupt the schema; what remained was a drag that appeared to work and then snapped back on the next derivation, because a relation's group order is alphabetical by target title rather than schema-held. Withheld in the view too. The matrix is on the trail: renderers × (relation handling, lane source, lane label, drop writes, status chip, group reorder), plus every writer of a group value, each cell marked handled / refused / not-applicable-with-reason. Two things it settled by reading rather than by assumption — the authenticated and public TABLE views and the print view do not group at all, and the lane menu's bulk "move all to…" has been gated on `groupField === 'status'` since before this unit. Three cells stay uncovered and are named there: no lane cap, a removed-field saved filter with no field-specific clear, and `multi_relation` arrays (U4's). Full web suite 2528 passed; svelte-check 0 errors. Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt * fix(web): a refused list drop was still rewriting sort_order, and a malformed relation still grouped by ids (TASK-2998, codex round 5) THE REFUSED DROP HAD A SIDE EFFECT LEFT OVER. Round 4's list gate skipped `onStatusChange` and left `onReorder(reorderUpdates)` running unconditionally, so a drop the component had just declined still rewrote every `sort_order` in the destination group. The relation was not written and the ordering was — a persisted consequence of a gesture that did not happen. It now restores from props and returns before either write, as the board's refusal does. The "already in this group?" check alongside it compared the RAW stored value, so a padded legacy id fired a pointless write on a drop into the group it was already in. Trimmed, like every other comparison against a stored relation value on this branch. A MALFORMED RELATION FIELD — `type: relation` with no `collection` — grouped the list by RAW IDS. The board falls into UNCATEGORIZED for that schema because its lanes come from `field.options` and a relation has none; this view DISCOVERS values from the items, so the two siblings disagreed and the list landed on the one outcome this unit exists to prevent. It is not groupable here at all now. AND THE SIXTH AND SEVENTH WRONG-REASON TESTS, both caught in the round they were written: - the group-reorder guard test searched the handler for `!isRelationGroup` appearing before `onGroupReorder(` — a condition the file satisfies in ways that leave the callback unconditional. It asserts the flag is IN the `if` that wraps the call now. - the malformed-relation test asserted `not.toContain('id-red')`, and `formatLabel('id-red')` renders "Id-Red" — so it passed against a list grouping by raw values. That is the same substring trap for the third time on this branch; it asserts the group SHAPE now. Round 5 found no P0 and confirms the rest of the matrix: collapse, item reorder and `preserveOrder` ranking all route through `groupValueFor`; the three deferred items are unchanged and not worsened. Full web suite 2530 passed; svelte-check 0 errors. Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt * fix(web): the board's lane comparison was untrimmed, the eighth wrong-reason test, and a menu-move finding I could not reproduce (TASK-2998, codex round 6) Round 6 found NO P1s. Three P2s, and the honest disposition differs per finding. FIXED, and clearly real: the board's "already in this lane?" check compared the RAW stored value while the list compared the folded one, so a legacy `" id-red "` dropped into the `id-red` lane fired a pointless relation write. The same asymmetry between these two views as round 4, pointing the other way. FIXED: the eighth wrong-reason test on this branch. `gates on isRelationGroup` asserted only that the source CONTAINS `if (isRelationGroup)` — a mutant leaving that block empty and calling `onStatusChange` unconditionally passed it. It now requires the refusal to live inside the block, ahead of the write, and returning rather than falling through. Verified with a brace-matched mutant that compiles, because a mutant that does not compile reads as "no tests" and I nearly filed one as a survivor. NOT CONFIRMED, and the change kept anyway with that said plainly: codex reports that a MENU move into a refused lane leaves the card displaced, because `moveItem` mutates `columnData[col]` in place and the sync effect assigns the DERIVED VALUE into `columnData` — so the two alias, and restoring by assigning `propColumnData` back would restore nothing. The mechanism is plausible and the restore is now a COPY. But I could not reproduce the defect: a rendered test that opens the card menu and moves into a deleted lane passes against BOTH implementations, so the alias mutant SURVIVES it. What that test does prove — verified by the gate mutant killing it — is that it drives the refusal path for real. So the copy is precautionary, not a demonstrated fix, and this message says so rather than claiming a defect I only read about. The rendered menu test is the first behavioural coverage of that path on this branch; every other drop assertion here is source-level, and no source guard could have seen an aliasing question at all. Full web suite 2532 passed; svelte-check 0 errors. Claude-Session: https://claude.ai/code/session_0115vpkjH5mmFdjRdRmZRuZt * test(web): the failure exit was uncovered, and the aliasing premise two rounds rested on is false (TASK-2998, codex round 7) Round 7 returned ONE finding, a P1: the exit where `onStatusChange` rejects restores lanes only by releasing `dropCooldown`, so — the argument goes — the sync effect re-assigns a `$derived.by` cache that `moveItem` has already written through, and the card stays in a lane it never reached. That is round 6's aliasing argument at the next exit of the same function. REFUTED, by measurement rather than by reading. A probe over exactly that shape — a `$derived.by` object assigned into a `$state`, a property written on the `$state`, then the derived read back — reports the derived's cached value UNCHANGED: `after mutation, derived.a=[1]`, the pre-mutation value, and the subsequent restore produces the original lanes. A `$state` holding a derived's object is a deep proxy, and its property writes do not reach what the derivation cached. No exit of `commitColumnMove` can be corrupted the way either round described. The rendered tests agree from the other side, and round 6 had already recorded half of it: the alias mutant SURVIVES the round-6 menu test. What was missing was the other half, so this commit adds it — a rendered test that drives the menu move whose WRITE FAILS, previously the only exit of that function with no behavioural coverage at all. It discriminates, which is the whole reason to keep a test that passes on the first run: removing `dropCooldown = false` turns it RED (measured), so its green says the cooldown release is what restores the board rather than a card that never moved — and `toHaveBeenCalledTimes(1)` rules that second reading out separately. The class is every exit that must restore lanes after `moveItem` mutated them; there are two, refusal and failure, and both are now covered and neither is defective. The durable part of this round is the prose. Round 6's source comment asserted the write-through as something observed ("that mutation had already written THROUGH ... so assigning it back restored nothing and the card stayed"), while round 6's own commit message said plainly that it could not be reproduced. The comment is what round 7 then re-derived the finding from. Both comments now carry the measurement and say not to re-derive the story from the shape of the code. The defensive copy stays, on its own much smaller justification: a restore path should not hand its caller an object somebody else owns. Gates on this tip: web 2533 passed (178 files), svelte-check 0 errors, go test green, golangci-lint 0 issues, vite build + build-go clean. Rebased onto main at63adbebb(U5) before the round; the round read the rebased tip. Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { browserLogin } from './lib/collab-helpers';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import type { SuiteFixture } from './fixtures';
|
||||
|
||||
/**
|
||||
* TASK-2998 / PLAN-2857 U7 — filtering and grouping a collection view by a
|
||||
* RELATION field, driven through a real browser against a binary built from
|
||||
* this tree (CONVE-34 / CONVE-2729).
|
||||
*
|
||||
* The unit tests cover the rules and the wiring; what only this leg can show is
|
||||
* that the whole path holds together — a schema with a relation field, rows
|
||||
* carrying target ids, the local index resolving them, the board deriving lanes
|
||||
* from them, and the filter narrowing the list to one target.
|
||||
*
|
||||
* The design table's proving row is the grouping assertion: every car lands in
|
||||
* exactly one lane, and the one pointing at nothing lands in an honest lane
|
||||
* rather than under a uuid.
|
||||
*/
|
||||
|
||||
function authJson(fixture: SuiteFixture) {
|
||||
return {
|
||||
Authorization: `Bearer ${fixture.apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async function createCollection(
|
||||
fixture: SuiteFixture,
|
||||
request: APIRequestContext,
|
||||
name: string,
|
||||
schema?: unknown,
|
||||
settings?: unknown,
|
||||
): Promise<{ slug: string; prefix: string }> {
|
||||
const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections`, {
|
||||
headers: authJson(fixture),
|
||||
data: { name },
|
||||
});
|
||||
if (!resp.ok()) throw new Error(`collection create failed (${resp.status()}): ${await resp.text()}`);
|
||||
const coll = (await resp.json()) as { slug: string; prefix: string };
|
||||
if (schema || settings) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (schema) patch.schema = JSON.stringify(schema);
|
||||
if (settings) patch.settings = JSON.stringify(settings);
|
||||
const up = await request.patch(
|
||||
`/api/v1/workspaces/${fixture.workspaceSlug}/collections/${coll.slug}`,
|
||||
{ headers: authJson(fixture), data: patch },
|
||||
);
|
||||
if (!up.ok()) throw new Error(`collection patch failed (${up.status()}): ${await up.text()}`);
|
||||
}
|
||||
return coll;
|
||||
}
|
||||
|
||||
async function createItem(
|
||||
fixture: SuiteFixture,
|
||||
request: APIRequestContext,
|
||||
collSlug: string,
|
||||
title: string,
|
||||
fields: Record<string, unknown> = {},
|
||||
): Promise<{ id: string; slug: string; item_number: number }> {
|
||||
const resp = await request.post(
|
||||
`/api/v1/workspaces/${fixture.workspaceSlug}/collections/${collSlug}/items`,
|
||||
{ headers: authJson(fixture), data: { title, fields: JSON.stringify(fields), content: '' } },
|
||||
);
|
||||
if (!resp.ok()) throw new Error(`item create failed (${resp.status()}): ${await resp.text()}`);
|
||||
return (await resp.json()) as { id: string; slug: string; item_number: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort, and it SWALLOWS everything — including the request throwing
|
||||
* because the context is already closed after a timeout. The repo's own
|
||||
* `deleteCollection` helper carries this warning and I reimplemented it without
|
||||
* the protection, so the first failing run reported
|
||||
* `apiRequestContext.delete: Target page, context or browser has been closed`
|
||||
* and hid the assertion that actually failed. A leaked scratch collection is
|
||||
* visible in the warning and harmless; a swallowed assertion failure is not.
|
||||
*/
|
||||
async function deleteCollection(fixture: SuiteFixture, request: APIRequestContext, slug: string) {
|
||||
try {
|
||||
const resp = await request.delete(
|
||||
`/api/v1/workspaces/${fixture.workspaceSlug}/collections/${slug}`,
|
||||
{ headers: authJson(fixture) },
|
||||
);
|
||||
if (!resp.ok()) console.warn(`cleanup: collection ${slug} not deleted (${resp.status()})`);
|
||||
} catch (err) {
|
||||
console.warn(`cleanup: collection ${slug} not deleted: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE ON WHAT THIS LEG CANNOT COVER, found by trying it. A value that resolves
|
||||
* to NOTHING cannot be created through the API any more: U1's validator
|
||||
* (TASK-2878) refuses it with
|
||||
* `"…" does not name an item in collection "colors-…"`. That lane only exists
|
||||
* for LEGACY rows written before U1 landed, so it is covered by the unit tests
|
||||
* — where the fixture can hold one — and named here rather than quietly
|
||||
* dropped, because "the e2e does not test it" and "the e2e cannot test it" are
|
||||
* different facts.
|
||||
*
|
||||
* The DELETED branch IS reachable, and is the one this leg drives: soft-delete
|
||||
* a colour and the local index still holds the row, so the lane can name it
|
||||
* honestly instead of losing it.
|
||||
*/
|
||||
|
||||
test.describe('relation filter and group (PLAN-2857 U7)', () => {
|
||||
test.setTimeout(90_000);
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== 'desktop-chromium',
|
||||
'one project is enough for a data-shape concern',
|
||||
);
|
||||
});
|
||||
|
||||
test('board lanes come from the relation targets, and the filter narrows to one', async ({
|
||||
page,
|
||||
fixture,
|
||||
request,
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const colors = await createCollection(fixture, request, `Colors ${stamp}`);
|
||||
const cars = await createCollection(
|
||||
fixture,
|
||||
request,
|
||||
`Cars ${stamp}`,
|
||||
{
|
||||
fields: [
|
||||
{ key: 'car_color', label: 'Colour', type: 'relation', collection: colors.slug },
|
||||
],
|
||||
},
|
||||
{ board_group_by: 'car_color' },
|
||||
);
|
||||
|
||||
try {
|
||||
const red = await createItem(fixture, request, colors.slug, `Red ${stamp}`);
|
||||
const blue = await createItem(fixture, request, colors.slug, `Blue ${stamp}`);
|
||||
await createItem(fixture, request, cars.slug, `Ferrari ${stamp}`, { car_color: red.id });
|
||||
await createItem(fixture, request, cars.slug, `Bugatti ${stamp}`, { car_color: blue.id });
|
||||
await createItem(fixture, request, cars.slug, `Unpainted ${stamp}`);
|
||||
// Points at a colour that is then DELETED — the lane must keep
|
||||
// naming it rather than folding it in with "no colour".
|
||||
const doomed = await createItem(fixture, request, colors.slug, `Doomed ${stamp}`);
|
||||
await createItem(fixture, request, cars.slug, `Ghost ${stamp}`, { car_color: doomed.id });
|
||||
const del = await request.delete(
|
||||
`/api/v1/workspaces/${fixture.workspaceSlug}/items/${doomed.slug}`,
|
||||
{ headers: authJson(fixture) },
|
||||
);
|
||||
if (!del.ok()) throw new Error(`colour delete failed (${del.status()})`);
|
||||
|
||||
await browserLogin(page);
|
||||
await page.goto(
|
||||
`/${fixture.adminUsername}/${fixture.workspaceSlug}/${cars.slug}?view=board`,
|
||||
);
|
||||
|
||||
const laneNames = page.locator('.kanban-column .column-name');
|
||||
await expect(laneNames.filter({ hasText: `Red ${stamp}` })).toBeVisible();
|
||||
await expect(laneNames.filter({ hasText: `Blue ${stamp}` })).toBeVisible();
|
||||
// The deleted colour keeps its own lane, and says what it is.
|
||||
const doomedLane = laneNames.filter({ hasText: `Doomed ${stamp}` });
|
||||
await expect(doomedLane).toBeVisible();
|
||||
await expect(doomedLane).toContainText('(deleted)');
|
||||
// And no lane anywhere is labelled with a raw id.
|
||||
await expect(page.locator('.board-view')).not.toContainText(red.id);
|
||||
|
||||
// The ref is on the lane, so the label is the chip rather than a
|
||||
// bare title — the thing that distinguishes this from grouping by
|
||||
// any old text field.
|
||||
await expect(
|
||||
page.locator('.kanban-column .column-name .lane-ref').first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Every car is placed exactly once: four cars, four cards.
|
||||
await expect(page.locator('.kanban-column .item-card')).toHaveCount(4);
|
||||
|
||||
// AND IN THE RIGHT LANES (codex round 4). A total count survives a
|
||||
// mutant that puts every card in one lane while the headings still
|
||||
// render, which is exactly what dropping the bucketing callback
|
||||
// does.
|
||||
const laneWith = (name: string) =>
|
||||
page.locator('.kanban-column').filter({ has: page.locator('.column-name', { hasText: name }) });
|
||||
await expect(laneWith(`Red ${stamp}`).locator('.item-card')).toHaveCount(1);
|
||||
await expect(laneWith(`Blue ${stamp}`).locator('.item-card')).toHaveCount(1);
|
||||
await expect(laneWith(`Doomed ${stamp}`).locator('.item-card')).toHaveCount(1);
|
||||
await expect(laneWith('Uncategorized').locator('.item-card')).toHaveCount(1);
|
||||
|
||||
// FILTER by Red: the picker is scoped to the colours collection.
|
||||
// The bar lives behind the toolbar's filter toggle — found by the
|
||||
// first run of this leg timing out on a trigger that was never
|
||||
// rendered, not by reading the page.
|
||||
await page.getByRole('button', { name: 'Toggle filters' }).click();
|
||||
await page.locator('.relation-filter-trigger').click();
|
||||
const picker = page.locator('.relation-filter-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
// role=combobox, not textbox — ItemPicker's input is an ARIA
|
||||
// combobox, which is not a textbox to Playwright's role engine.
|
||||
await picker.getByRole('combobox').fill(`Red ${stamp}`);
|
||||
await picker.getByRole('option', { name: new RegExp(`Red ${stamp}`) }).first().click();
|
||||
|
||||
await expect(page.locator('.relation-filter-trigger')).toContainText(`Red ${stamp}`);
|
||||
await expect(page.locator('.kanban-column .item-card')).toHaveCount(1);
|
||||
await expect(page.locator('.kanban-column .item-card')).toContainText(`Ferrari ${stamp}`);
|
||||
|
||||
// And clearing restores every card — a filter that could not be
|
||||
// cleared would pass every assertion above.
|
||||
await page.locator('.relation-filter-clear').click();
|
||||
await expect(page.locator('.kanban-column .item-card')).toHaveCount(4);
|
||||
} finally {
|
||||
await deleteCollection(fixture, request, cars.slug);
|
||||
await deleteCollection(fixture, request, colors.slug);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,15 @@ function laneValue(raw: unknown): string {
|
||||
export function bucketByColumn(
|
||||
items: Item[],
|
||||
groupField: string,
|
||||
columns: string[]
|
||||
columns: string[],
|
||||
/**
|
||||
* How to read an item's lane value, when the field's own value is not it.
|
||||
* A RELATION field needs this (TASK-2998): values that resolve to nothing
|
||||
* are folded onto a sentinel first, so "no value", "target deleted" and
|
||||
* "points at nothing" do not all collapse into UNCATEGORIZED. Defaults to
|
||||
* the field's own value, so every existing caller is unchanged.
|
||||
*/
|
||||
valueFor?: (item: Item) => string
|
||||
): Record<string, Item[]> {
|
||||
const known = new Set(columns);
|
||||
const result: Record<string, Item[]> = { [UNCATEGORIZED]: [] };
|
||||
@@ -48,7 +56,7 @@ export function bucketByColumn(
|
||||
result[col] = [];
|
||||
}
|
||||
for (const item of items) {
|
||||
const value = laneValue(parseFields(item)[groupField]);
|
||||
const value = valueFor ? valueFor(item) : laneValue(parseFields(item)[groupField]);
|
||||
if (value && known.has(value)) {
|
||||
result[value].push(item);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Item, ItemIndexRow } from '$lib/types';
|
||||
import { UNCATEGORIZED, bucketByColumn } from './boardColumns';
|
||||
import {
|
||||
UNRESOLVED_LANE,
|
||||
narrowRelationRow,
|
||||
relationChipFor,
|
||||
relationFilterMatches,
|
||||
relationLaneAcceptsDrop,
|
||||
relationLaneAriaName,
|
||||
relationLaneValueFor,
|
||||
relationLanes,
|
||||
} from './relationGroups';
|
||||
|
||||
/**
|
||||
* TASK-2998 / PLAN-2857 U7 — grouping a view by a relation field.
|
||||
*
|
||||
* The proving row on the design table: every item with a value lands in exactly
|
||||
* ONE group, and an item with a dangling value lands in an honest group rather
|
||||
* than under a bare UUID. Both are asserted here against a fixture this file
|
||||
* builds; when U5's reverse index lands the same assertions get a second
|
||||
* source to cross-check against, which is the point of that row.
|
||||
*/
|
||||
|
||||
// `Item.fields` is a JSON STRING on the wire (`parseFields` JSON.parses it), so
|
||||
// the fixture builds it the same way the API does. An object here would make
|
||||
// every lane empty and the whole file pass vacuously in the other direction.
|
||||
function item(id: string, value?: string): Item {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
title: id,
|
||||
fields: JSON.stringify(value === undefined ? {} : { car: value }),
|
||||
} as unknown as Item;
|
||||
}
|
||||
|
||||
function row(id: string, title: string, opts: { number?: number; deleted?: boolean } = {}): ItemIndexRow {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
item_number: opts.number ?? 1,
|
||||
collection_prefix: 'COLOR',
|
||||
// Set, so the positive narrowing case exercises the collection check
|
||||
// rather than skipping it on an undefined slug.
|
||||
collection_slug: 'colors',
|
||||
deleted_at: opts.deleted ? '2026-01-01T00:00:00Z' : null,
|
||||
} as unknown as ItemIndexRow;
|
||||
}
|
||||
|
||||
const ROWS: Record<string, ItemIndexRow> = {
|
||||
red: row('red', 'Red', { number: 1 }),
|
||||
blue: row('blue', 'Blue', { number: 2 }),
|
||||
gone: row('gone', 'Gone', { number: 3, deleted: true }),
|
||||
};
|
||||
const resolve = (id: string) => ROWS[id] ?? null;
|
||||
|
||||
describe('relationLanes', () => {
|
||||
it('derives lanes from the ITEMS, because a relation has no options', () => {
|
||||
const lanes = relationLanes([item('a', 'red'), item('b', 'blue'), item('c', 'red')], 'car', resolve);
|
||||
expect(lanes.map((l) => l.value)).toEqual(['blue', 'red']);
|
||||
});
|
||||
|
||||
it('orders alphabetically rather than by first appearance', () => {
|
||||
// Items arrive asynchronously through the local index; a
|
||||
// first-appearance order would reshuffle the board as they land.
|
||||
const lanes = relationLanes([item('a', 'red'), item('b', 'blue')], 'car', resolve);
|
||||
expect(lanes.map((l) => l.title)).toEqual(['Blue', 'Red']);
|
||||
});
|
||||
|
||||
it('gives a DELETED target its own lane, with its ref and title', () => {
|
||||
// It still has both, so it can be labelled honestly — and merging it
|
||||
// with the unresolved lane would combine rows pointing at different
|
||||
// things.
|
||||
const lanes = relationLanes([item('a', 'gone')], 'car', resolve);
|
||||
expect(lanes).toHaveLength(1);
|
||||
expect(lanes[0]).toMatchObject({ value: 'gone', ref: 'COLOR-3', title: 'Gone', state: 'deleted' });
|
||||
});
|
||||
|
||||
it('folds values that resolve to NOTHING into one honest lane, never a bare id', () => {
|
||||
const lanes = relationLanes(
|
||||
[item('a', 'f47ac10b-58cc-4372-a567-0e02b2c3d479'), item('b', 'purple')],
|
||||
'car',
|
||||
resolve,
|
||||
);
|
||||
expect(lanes).toHaveLength(1);
|
||||
expect(lanes[0].value).toBe(UNRESOLVED_LANE);
|
||||
expect(lanes[0].label).toBe('Unresolved reference');
|
||||
// The assertion that matters: nothing in the lane repeats the stored
|
||||
// string, which is usually free text an older unvalidated write left.
|
||||
expect(JSON.stringify(lanes[0])).not.toContain('f47ac10b');
|
||||
expect(JSON.stringify(lanes[0])).not.toContain('purple');
|
||||
});
|
||||
|
||||
it('has no lanes when nothing carries a value', () => {
|
||||
expect(relationLanes([item('a'), item('b', '')], 'car', resolve)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relationLaneValueFor + bucketByColumn', () => {
|
||||
it('puts every item in EXACTLY ONE lane — the proving row', () => {
|
||||
const items = [
|
||||
item('live-1', 'red'),
|
||||
item('live-2', 'red'),
|
||||
item('live-3', 'blue'),
|
||||
item('deleted', 'gone'),
|
||||
item('dangling', 'purple'),
|
||||
item('empty'),
|
||||
];
|
||||
const lanes = relationLanes(items, 'car', resolve);
|
||||
const rewritten = items.map((i) => ({
|
||||
...i,
|
||||
fields: JSON.stringify({ car: relationLaneValueFor(i, 'car', resolve) }),
|
||||
})) as Item[];
|
||||
|
||||
const buckets = bucketByColumn(rewritten, 'car', lanes.map((l) => l.value));
|
||||
|
||||
const placements = Object.entries(buckets).flatMap(([lane, list]) =>
|
||||
list.map((i) => ({ id: i.id, lane })),
|
||||
);
|
||||
// Exactly one placement per item, and no item lost.
|
||||
expect(placements).toHaveLength(items.length);
|
||||
expect(new Set(placements.map((p) => p.id)).size).toBe(items.length);
|
||||
|
||||
const laneOf = (id: string) => placements.find((p) => p.id === id)?.lane;
|
||||
expect(laneOf('live-1')).toBe('red');
|
||||
expect(laneOf('live-2')).toBe('red');
|
||||
expect(laneOf('live-3')).toBe('blue');
|
||||
expect(laneOf('deleted')).toBe('gone');
|
||||
expect(laneOf('dangling')).toBe(UNRESOLVED_LANE);
|
||||
expect(laneOf('empty')).toBe(UNCATEGORIZED);
|
||||
});
|
||||
|
||||
it('keeps "no value", "deleted target" and "points at nothing" APART', () => {
|
||||
// Without the rewrite, `bucketByColumn` routes every value it does not
|
||||
// recognise to UNCATEGORIZED — collapsing three different facts about an
|
||||
// item into the lane that means "this field is empty".
|
||||
expect(relationLaneValueFor(item('a'), 'car', resolve)).toBe(UNCATEGORIZED);
|
||||
expect(relationLaneValueFor(item('b', 'gone'), 'car', resolve)).toBe('gone');
|
||||
expect(relationLaneValueFor(item('c', 'nope'), 'car', resolve)).toBe(UNRESOLVED_LANE);
|
||||
});
|
||||
|
||||
it('treats a non-string value as no value rather than crashing', () => {
|
||||
const weird = {
|
||||
id: 'w',
|
||||
slug: 'w',
|
||||
title: 'w',
|
||||
fields: JSON.stringify({ car: { id: 'red' } }),
|
||||
} as unknown as Item;
|
||||
expect(relationLaneValueFor(weird, 'car', resolve)).toBe(UNCATEGORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relationLaneAcceptsDrop', () => {
|
||||
it('accepts a live lane and refuses the other three', () => {
|
||||
// A live lane is the natural board gesture: dropping a card sets the
|
||||
// relation. The others would either clear the field or write a
|
||||
// reference TASK-2878's validator refuses, so the card would move on
|
||||
// screen and the write would fail behind it.
|
||||
expect(relationLaneAcceptsDrop({ state: 'live' })).toBe(true);
|
||||
expect(relationLaneAcceptsDrop({ state: 'deleted' })).toBe(false);
|
||||
expect(relationLaneAcceptsDrop({ state: 'unresolved' })).toBe(false);
|
||||
expect(relationLaneAcceptsDrop({ state: 'empty' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('narrowRelationRow', () => {
|
||||
const live = row('red', 'Red');
|
||||
const known = new Set(['colors', 'tasks']);
|
||||
|
||||
it('refuses a SLUG match, because the field stores an id', () => {
|
||||
// The pre-TASK-2878 fallback wrote free text, so a value like "red"
|
||||
// resolves through `findByIdOrSlug` to whatever item is slugged "red"
|
||||
// and would render as a working reference to something nobody chose.
|
||||
const bySlug = { ...live, id: 'a-real-uuid' } as ItemIndexRow;
|
||||
expect(narrowRelationRow(bySlug, 'red', 'colors', known)).toBeNull();
|
||||
expect(narrowRelationRow(live, 'red', 'colors', known)).toBe(live);
|
||||
});
|
||||
|
||||
it('refuses a row from the WRONG collection', () => {
|
||||
const stray = { ...live, collection_slug: 'tasks' } as ItemIndexRow;
|
||||
expect(narrowRelationRow(stray, 'red', 'colors', known)).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT judge the collection when either slug is unknown to the list', () => {
|
||||
// A renamed collection, or a rename the index has applied and the
|
||||
// collection list has not, is not the value's fault — and judging it
|
||||
// would flash every lane to unresolved during that window.
|
||||
const stray = { ...live, collection_slug: 'tasks' } as ItemIndexRow;
|
||||
expect(narrowRelationRow(stray, 'red', 'colors', new Set(['colors']))).toBe(stray);
|
||||
expect(narrowRelationRow(stray, 'red', 'colors', undefined)).toBe(stray);
|
||||
});
|
||||
|
||||
it('passes a missing row straight through as null', () => {
|
||||
expect(narrowRelationRow(null, 'red', 'colors', known)).toBeNull();
|
||||
expect(narrowRelationRow(undefined, 'red', 'colors', known)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('relationChipFor', () => {
|
||||
it('is the SAME vocabulary a lane header uses', () => {
|
||||
// The filter chip and the lane header describe the same value, so they
|
||||
// are built from one function. Two would drift, and the drift would be
|
||||
// invisible until a user saw a board lane and a filter chip disagree
|
||||
// about what the same id is called.
|
||||
//
|
||||
// `relationLanes` CALLS `relationChipFor`, so this leg alone is partly
|
||||
// tautological — a change to the shared helper moves both sides (codex
|
||||
// round 4). The independent anchor is below: the expected shape is
|
||||
// written out by hand, so the helper cannot redefine what agreement
|
||||
// means.
|
||||
const chip = relationChipFor('red', resolve);
|
||||
const lane = relationLanes([item('a', 'red')], 'car', resolve)[0];
|
||||
expect(chip).toEqual(lane);
|
||||
});
|
||||
|
||||
it('describes a live value in the shape both surfaces render', () => {
|
||||
// The independent half: this is what a chip and a lane are, spelled out
|
||||
// rather than derived from either producer.
|
||||
expect(relationChipFor('red', resolve)).toEqual({
|
||||
value: 'red',
|
||||
ref: 'COLOR-1',
|
||||
title: 'Red',
|
||||
label: 'Red',
|
||||
state: 'live',
|
||||
});
|
||||
});
|
||||
|
||||
it('is null for an empty value — the caller owns what "no filter" says', () => {
|
||||
expect(relationChipFor('', resolve)).toBeNull();
|
||||
expect(relationChipFor(' ', resolve)).toBeNull();
|
||||
});
|
||||
|
||||
it('never echoes an unresolvable value back', () => {
|
||||
const chip = relationChipFor('not-an-item', resolve);
|
||||
expect(chip?.state).toBe('unresolved');
|
||||
expect(JSON.stringify(chip)).not.toContain('not-an-item');
|
||||
});
|
||||
|
||||
it('marks a deleted target without hiding which one it is', () => {
|
||||
expect(relationChipFor('gone', resolve)).toMatchObject({
|
||||
ref: 'COLOR-3',
|
||||
title: 'Gone',
|
||||
state: 'deleted',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('narrowRelationRow with no collection list yet', () => {
|
||||
it('does not judge the collection when the set is null', () => {
|
||||
// FieldEditor's own set is `Set<string> | null` — null while the
|
||||
// collection list has not loaded — and that is the same "do not judge"
|
||||
// case as a slug the list does not know. Typed rather than coerced at
|
||||
// the call site, so the shared helper states the rule.
|
||||
const stray = { ...row('red', 'Red'), collection_slug: 'tasks' } as ItemIndexRow;
|
||||
expect(narrowRelationRow(stray, 'red', 'colors', null)).toBe(stray);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codex round 1 — values the write path used to accept', () => {
|
||||
it('trims a padded value, so the lane and the chip agree', () => {
|
||||
// `relationChipFor` has always trimmed; `relationLaneValueFor` did not.
|
||||
// A legacy `" red "` therefore produced a LIVE chip and an UNRESOLVED
|
||||
// lane — the same value described two ways by the two functions this
|
||||
// module exists to keep in agreement. The pre-TASK-2878 write path
|
||||
// accepted any string, padding included.
|
||||
expect(relationLaneValueFor(item('a', ' red '), 'car', resolve)).toBe('red');
|
||||
expect(relationChipFor(' red ', resolve)).toMatchObject({ value: 'red', state: 'live' });
|
||||
const lanes = relationLanes([item('a', ' red ')], 'car', resolve);
|
||||
expect(lanes).toHaveLength(1);
|
||||
expect(lanes[0]).toMatchObject({ value: 'red', state: 'live' });
|
||||
});
|
||||
|
||||
it('collapses padded and unpadded forms of the same value into ONE lane', () => {
|
||||
// Otherwise the board shows "Red" twice, which is worse than either
|
||||
// version of the bug on its own.
|
||||
const lanes = relationLanes([item('a', 'red'), item('b', ' red')], 'car', resolve);
|
||||
expect(lanes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relationLaneAriaName', () => {
|
||||
it('gives assistive technology the words the eye gets', () => {
|
||||
// The group's aria-label used to format the raw column VALUE, so a lane
|
||||
// reading "COLOR-1 Red" announced as "Id-Red column". A claim that a
|
||||
// lane is named from its target rather than its id is not a claim if it
|
||||
// holds only for sighted users.
|
||||
const [live] = relationLanes([item('a', 'red')], 'car', resolve);
|
||||
expect(relationLaneAriaName(live, 'fallback')).toBe('COLOR-1 Red');
|
||||
});
|
||||
|
||||
it('says a deleted target is deleted, and never echoes an unresolved value', () => {
|
||||
const [deleted] = relationLanes([item('a', 'gone')], 'car', resolve);
|
||||
expect(relationLaneAriaName(deleted, 'fallback')).toBe('COLOR-3 Gone (deleted)');
|
||||
const [unresolved] = relationLanes([item('a', 'nope')], 'car', resolve);
|
||||
expect(relationLaneAriaName(unresolved, 'fallback')).toBe('Unresolved reference');
|
||||
});
|
||||
|
||||
it('falls back for a lane that is not a relation lane at all', () => {
|
||||
// UNCATEGORIZED and every ordinary select lane come through here too.
|
||||
expect(relationLaneAriaName(undefined, 'Uncategorized')).toBe('Uncategorized');
|
||||
});
|
||||
});
|
||||
|
||||
describe('relationFilterMatches (codex round 2)', () => {
|
||||
it('matches a padded stored value against an unpadded filter', () => {
|
||||
// The defect it closes: the board trimmed and the filter did not, so an
|
||||
// item storing `" red "` sat in the Red LANE and vanished when you
|
||||
// filtered for Red. One value, two answers, one screen apart.
|
||||
expect(relationFilterMatches(' red ', 'red')).toBe(true);
|
||||
expect(relationFilterMatches('red', ' red ')).toBe(true);
|
||||
expect(relationFilterMatches('red', 'red')).toBe(true);
|
||||
});
|
||||
|
||||
it('still refuses a different value', () => {
|
||||
// The counterfactual: trimming must not become "matches anything".
|
||||
expect(relationFilterMatches('blue', 'red')).toBe(false);
|
||||
expect(relationFilterMatches('', 'red')).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a non-string, including the multi_relation ARRAY shape', () => {
|
||||
// U4 stores an array, and no amount of trimming makes `===` match one.
|
||||
// Matching an array is that unit's to define; guessing here would fix
|
||||
// half of it in a way U4 would have to undo.
|
||||
expect(relationFilterMatches(['red'], 'red')).toBe(false);
|
||||
expect(relationFilterMatches(null, 'red')).toBe(false);
|
||||
expect(relationFilterMatches(42, 'red')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
// Grouping a board or list by a RELATION field (TASK-2998 / PLAN-2857 U7).
|
||||
//
|
||||
// Every other groupable field type carries its own lane vocabulary: a `select`
|
||||
// has `options`, and `bucketByColumn` buckets against that fixed list. A
|
||||
// relation has no options — its lanes are whatever target items the rows
|
||||
// actually point at — so the lanes have to be DERIVED from the data, and their
|
||||
// labels resolved against the local index.
|
||||
//
|
||||
// WHY A PURE MODULE, like `unparentedFilter` and `boardColumns` beside it: the
|
||||
// rules below (which lanes exist, how each is labelled, which lane a dangling
|
||||
// value lands in, which lanes accept a drop) are decisions, and a decision that
|
||||
// can only be exercised by mounting a board view is a decision nobody tests.
|
||||
// Nothing here imports a rune.
|
||||
|
||||
import type { Item, ItemIndexRow } from '$lib/types';
|
||||
import { formatItemRef, parseFields } from '$lib/types';
|
||||
import { UNCATEGORIZED } from './boardColumns';
|
||||
|
||||
/**
|
||||
* The lane a value with no resolvable target lands in.
|
||||
*
|
||||
* Deliberately NOT a bare UUID and not the stored string. A relation value that
|
||||
* resolves to nothing is usually free text an older, unvalidated write left
|
||||
* behind (the pre-TASK-2878 fallback accepted any string), so showing it as
|
||||
* though it were a lane name would present a typo as a category. `FieldEditor`
|
||||
* says "Unresolved reference" for exactly this state; the lane says the same.
|
||||
*
|
||||
* Reserved with a `$` prefix for the reason `$unparented` is: a real field
|
||||
* value can never collide with it, because schema validation refuses new field
|
||||
* keys starting with `$` and an item id is a UUID.
|
||||
*/
|
||||
export const UNRESOLVED_LANE = '$unresolved';
|
||||
|
||||
export type RelationLaneState = 'empty' | 'live' | 'deleted' | 'unresolved';
|
||||
|
||||
/**
|
||||
* How a single relation VALUE presents — the lane header and the filter chip
|
||||
* say the same things about the same value, so they are built from one
|
||||
* function rather than two that drift.
|
||||
*/
|
||||
export interface RelationChip {
|
||||
value: string;
|
||||
ref: string | null;
|
||||
title: string | null;
|
||||
/** What to say when there is no ref/title — never the stored value. */
|
||||
label: string;
|
||||
state: RelationLaneState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe one relation value. `null` for an empty value (the caller decides
|
||||
* what "no filter" or "uncategorised" looks like); an unresolvable value
|
||||
* becomes the honest unresolved chip rather than the string it holds.
|
||||
*/
|
||||
export function relationChipFor(value: string, resolve: ResolveRow): RelationChip | null {
|
||||
const raw = typeof value === 'string' ? value.trim() : '';
|
||||
if (!raw) return null;
|
||||
const row = resolve(raw);
|
||||
if (!row) {
|
||||
return {
|
||||
value: UNRESOLVED_LANE,
|
||||
ref: null,
|
||||
title: null,
|
||||
label: 'Unresolved reference',
|
||||
state: 'unresolved',
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: raw,
|
||||
ref: formatItemRef(row),
|
||||
title: row.title ?? null,
|
||||
label: row.title ?? '',
|
||||
state: row.deleted_at ? 'deleted' : 'live',
|
||||
};
|
||||
}
|
||||
|
||||
/** A lane IS a chip plus its position on the board. Same vocabulary. */
|
||||
export type RelationLane = RelationChip;
|
||||
|
||||
/** Resolve an item id against whatever row source the caller has. */
|
||||
export type ResolveRow = (id: string) => ItemIndexRow | null | undefined;
|
||||
|
||||
/**
|
||||
* The two narrowings a relation value needs on top of a workspace-wide lookup.
|
||||
*
|
||||
* `localIndex.findByIdOrSlug` resolves by id OR SLUG and searches the whole
|
||||
* workspace, and both of those are wrong for a relation:
|
||||
*
|
||||
* 1. ID ONLY. A legacy free-text value like "red" — exactly what the
|
||||
* pre-TASK-2878 fallback wrote — resolves to whatever item is SLUGGED
|
||||
* "red" and would render as a working reference. The field stores an item
|
||||
* id; a slug match makes the label lie about what is stored, and slugs are
|
||||
* mutable, so the same value could point somewhere else tomorrow.
|
||||
* 2. TARGET COLLECTION. Without it, a relation declared against `colors` can
|
||||
* label a lane with an item from `tasks` that happens to share an
|
||||
* identifier.
|
||||
*
|
||||
* The collection check only fires when both the declared target and the row's
|
||||
* own collection are slugs the current list knows — a renamed collection, or a
|
||||
* rename applied to the index before the collection list caught up, are not the
|
||||
* value's fault and must not turn every lane unresolved.
|
||||
*
|
||||
* WORKED OUT FIRST IN `FieldEditor.svelte`'s relation branch, which now calls
|
||||
* this rather than carrying its own copy. It was briefly duplicated — that file
|
||||
* was being edited under TASK-2996 when the board needed the same rule — and
|
||||
* the fork was closed as soon as 2996 merged without touching it. The reason to
|
||||
* keep it single is concrete: a divergence shows up as a board lane and a
|
||||
* properties chip disagreeing about what the same id is called.
|
||||
*/
|
||||
export function narrowRelationRow(
|
||||
row: ItemIndexRow | null | undefined,
|
||||
value: string,
|
||||
declaredCollection: string | undefined,
|
||||
// `null` accepted as well as omitted: FieldEditor's own set is
|
||||
// `Set<string> | null` — null while the collection list has not loaded —
|
||||
// and "we do not know the collections yet" is exactly the case this
|
||||
// argument's guard already treats as "do not judge".
|
||||
knownCollectionSlugs?: ReadonlySet<string> | null,
|
||||
): ItemIndexRow | null {
|
||||
if (!row) return null;
|
||||
if (row.id !== value) return null;
|
||||
if (
|
||||
declaredCollection &&
|
||||
knownCollectionSlugs?.has(row.collection_slug ?? '') &&
|
||||
knownCollectionSlugs.has(declaredCollection) &&
|
||||
row.collection_slug !== declaredCollection
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* TRIMMED (codex round 1, P1). `relationChipFor` has always trimmed, so a
|
||||
* legacy value stored as `" id-red "` produced a live CHIP and an unresolved
|
||||
* LANE — the same value described two ways by the two functions this module
|
||||
* exists to keep in agreement. The pre-TASK-2878 write path accepted any
|
||||
* string, padding included, so those rows exist.
|
||||
*/
|
||||
function laneValue(raw: unknown): string {
|
||||
return typeof raw === 'string' ? raw.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The lanes a relation-grouped view should render, in display order.
|
||||
*
|
||||
* Derived from the ITEMS rather than from the schema, because a relation field
|
||||
* has no option list — the lanes are the targets the rows point at. An empty
|
||||
* result means nothing in view carries a value, which the caller renders as the
|
||||
* uncategorised lane alone.
|
||||
*
|
||||
* ORDERING: live lanes first, alphabetically by title, then deleted ones, then
|
||||
* the single unresolved lane. Alphabetical rather than first-appearance because
|
||||
* items arrive asynchronously through the local index, and a first-appearance
|
||||
* order would reshuffle the board as they land.
|
||||
*
|
||||
* DELETED TARGETS KEEP THEIR OWN LANE. They still have a ref and a title, so
|
||||
* they can be labelled honestly, and merging them would silently combine rows
|
||||
* that point at different things. Only values that resolve to NOTHING share a
|
||||
* lane, because there is nothing to tell them apart with.
|
||||
*/
|
||||
export function relationLanes(items: Item[], fieldKey: string, resolve: ResolveRow): RelationLane[] {
|
||||
const seen = new Set<string>();
|
||||
const live: RelationLane[] = [];
|
||||
const deleted: RelationLane[] = [];
|
||||
let hasUnresolved = false;
|
||||
|
||||
for (const item of items) {
|
||||
const value = laneValue(parseFields(item)[fieldKey]);
|
||||
if (!value || seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
const chip = relationChipFor(value, resolve);
|
||||
if (!chip || chip.state === 'unresolved') {
|
||||
hasUnresolved = true;
|
||||
continue;
|
||||
}
|
||||
(chip.state === 'deleted' ? deleted : live).push(chip);
|
||||
}
|
||||
|
||||
const byTitle = (a: RelationLane, b: RelationLane) =>
|
||||
(a.title ?? '').localeCompare(b.title ?? '');
|
||||
live.sort(byTitle);
|
||||
deleted.sort(byTitle);
|
||||
|
||||
const lanes = [...live, ...deleted];
|
||||
if (hasUnresolved) {
|
||||
lanes.push({
|
||||
value: UNRESOLVED_LANE,
|
||||
ref: null,
|
||||
title: null,
|
||||
label: 'Unresolved reference',
|
||||
state: 'unresolved',
|
||||
});
|
||||
}
|
||||
return lanes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value each item should be bucketed on, with unresolvable values folded
|
||||
* onto the sentinel.
|
||||
*
|
||||
* `bucketByColumn` routes any value not in its column list to UNCATEGORIZED,
|
||||
* which would put "points at a deleted item" and "points at nothing" in the
|
||||
* same lane as "has no value at all" — three different facts. Rewriting the
|
||||
* value first keeps them apart while leaving that helper's own rule intact:
|
||||
* every item still lands in exactly one lane.
|
||||
*/
|
||||
export function relationLaneValueFor(item: Item, fieldKey: string, resolve: ResolveRow): string {
|
||||
const value = laneValue(parseFields(item)[fieldKey]);
|
||||
if (!value) return UNCATEGORIZED;
|
||||
return resolve(value) ? value : UNRESOLVED_LANE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane label for assistive technology — the same words the eye gets.
|
||||
*
|
||||
* The board's group `aria-label` used to format the raw column VALUE, so a lane
|
||||
* reading "COLOR-1 Red" announced as "Id-Red column" and the unresolved lane as
|
||||
* "$Unresolved column" (codex round 1). The claim this unit makes is that a
|
||||
* lane is named from its target rather than from the stored id, and a claim
|
||||
* that holds only for sighted users is not the claim.
|
||||
*/
|
||||
export function relationLaneAriaName(lane: RelationLane | undefined, fallback: string): string {
|
||||
if (!lane) return fallback;
|
||||
const parts = [lane.ref, lane.title ?? lane.label].filter(Boolean);
|
||||
const name = parts.join(' ');
|
||||
return lane.state === 'deleted' ? `${name} (deleted)` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can a board drop write this lane's value onto an item?
|
||||
*
|
||||
* A live lane is a legitimate drop target — dragging a card there sets the
|
||||
* relation to that item, which is the natural board gesture. The other three
|
||||
* are not: UNCATEGORIZED would clear the field (a delete disguised as a move),
|
||||
* and the deleted and unresolved lanes would write a reference the validator
|
||||
* refuses anyway (TASK-2878 — a relation value must name a LIVE item in the
|
||||
* declared collection), so the drop would fail at the server after moving the
|
||||
* card on screen.
|
||||
*/
|
||||
export function relationLaneAcceptsDrop(lane: Pick<RelationLane, 'state'>): boolean {
|
||||
return lane.state === 'live';
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this item's value for a RELATION field match a filter value?
|
||||
*
|
||||
* Strict `===` was the whole comparison, and the board had already started
|
||||
* trimming — so an item storing `" id-red "` appeared in the `Red` LANE and
|
||||
* vanished when you filtered for `Red` (codex round 2). The same value, two
|
||||
* answers, one screen apart.
|
||||
*
|
||||
* Trimming on BOTH sides rather than normalising the stored data: the write
|
||||
* path has refused padded values since TASK-2878, so there is nothing new to
|
||||
* clean up and nothing to migrate — only old rows to read correctly.
|
||||
*
|
||||
* Scalar only, deliberately. `multi_relation` (U4) stores an ARRAY and no
|
||||
* amount of trimming makes `===` match one; matching an array is that unit's
|
||||
* to define, and guessing here would fix half of it in a way U4 would have to
|
||||
* undo.
|
||||
*/
|
||||
export function relationFilterMatches(stored: unknown, filterValue: string): boolean {
|
||||
if (typeof stored !== 'string') return false;
|
||||
return stored.trim() === filterValue.trim();
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, cleanup } from '@testing-library/svelte';
|
||||
import { tick } from 'svelte';
|
||||
import type { Collection, Item, ItemIndexRow } from '$lib/types';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* TASK-2998 / PLAN-2857 U7 — the BoardView half of grouping by a relation.
|
||||
*
|
||||
* The rules are unit-tested in `relationGroups.test.ts`; what is only
|
||||
* observable HERE is the wiring (CONVE-19): that the board asks for relation
|
||||
* lanes at all when the group field is a relation, labels them with the chip
|
||||
* vocabulary rather than the stored id, and keeps the three non-live cases
|
||||
* apart on screen.
|
||||
*/
|
||||
|
||||
const ROWS: Record<string, ItemIndexRow> = {
|
||||
'id-red': {
|
||||
id: 'id-red',
|
||||
title: 'Red',
|
||||
item_number: 1,
|
||||
collection_prefix: 'COLOR',
|
||||
collection_slug: 'colors',
|
||||
deleted_at: null,
|
||||
} as unknown as ItemIndexRow,
|
||||
'id-blue': {
|
||||
id: 'id-blue',
|
||||
title: 'Blue',
|
||||
item_number: 2,
|
||||
collection_prefix: 'COLOR',
|
||||
collection_slug: 'colors',
|
||||
deleted_at: null,
|
||||
} as unknown as ItemIndexRow,
|
||||
'id-gone': {
|
||||
id: 'id-gone',
|
||||
title: 'Gone',
|
||||
item_number: 9,
|
||||
collection_prefix: 'COLOR',
|
||||
collection_slug: 'colors',
|
||||
deleted_at: '2026-01-01T00:00:00Z',
|
||||
} as unknown as ItemIndexRow,
|
||||
};
|
||||
|
||||
vi.mock('$lib/stores/localIndex.svelte', () => ({
|
||||
localIndex: {
|
||||
findByIdOrSlug: (_ws: string, id: string) => ROWS[id] ?? null,
|
||||
getByCollection: () => [],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/collections.svelte', () => ({
|
||||
collectionStore: { collections: [{ slug: 'colors' }, { slug: 'cars' }] },
|
||||
}));
|
||||
|
||||
import BoardView from './BoardView.svelte';
|
||||
|
||||
/** BoardView's source with comments stripped, so a guard cannot be satisfied by prose. */
|
||||
const SRC = readFileSync(resolve(__dirname, './BoardView.svelte'), 'utf8').replace(
|
||||
/^[ \t]*\/\/.*$/gm,
|
||||
'',
|
||||
);
|
||||
|
||||
function collection(): Collection {
|
||||
return {
|
||||
id: 'c1',
|
||||
workspace_id: 'ws1',
|
||||
name: 'Cars',
|
||||
slug: 'cars',
|
||||
icon: '',
|
||||
description: '',
|
||||
schema: JSON.stringify({
|
||||
fields: [{ key: 'car_color', label: 'Colour', type: 'relation', collection: 'colors' }],
|
||||
}),
|
||||
settings: JSON.stringify({}),
|
||||
sort_order: 0,
|
||||
is_default: true,
|
||||
is_system: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
prefix: 'CAR',
|
||||
} as unknown as Collection;
|
||||
}
|
||||
|
||||
function item(id: string, value?: string): Item {
|
||||
return {
|
||||
id,
|
||||
workspace_id: 'ws1',
|
||||
collection_id: 'c1',
|
||||
slug: id,
|
||||
title: id,
|
||||
content: '',
|
||||
fields: JSON.stringify(value === undefined ? {} : { car_color: value }),
|
||||
tags: '[]',
|
||||
status: 'open',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
} as unknown as Item;
|
||||
}
|
||||
|
||||
const DANGLING = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
|
||||
|
||||
function renderBoard(items: Item[]) {
|
||||
return render(BoardView, {
|
||||
props: {
|
||||
items,
|
||||
collection: collection(),
|
||||
wsSlug: 'ws',
|
||||
groupField: 'car_color',
|
||||
onStatusChange: vi.fn(),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('BoardView grouped by a relation field', () => {
|
||||
/**
|
||||
* Lane names read from the DOM, NOT by substring over the whole page.
|
||||
* `formatLabel('id-red')` renders "Id-Red", which contains "Red" — so a
|
||||
* `toContain('Red')` assertion passes against a board that fell back to
|
||||
* formatting the raw id, which is the defect. Measured: the label mutant
|
||||
* survived that version of this test.
|
||||
*/
|
||||
const lanes = (screen: { container: HTMLElement }) =>
|
||||
[...screen.container.querySelectorAll('.column-name')].map((el) => ({
|
||||
ref: el.querySelector('.lane-ref')?.textContent?.trim() ?? null,
|
||||
note: el.querySelector('.lane-note')?.textContent?.trim() ?? null,
|
||||
// Everything the lane says that is not the ref or the note.
|
||||
name: [...el.childNodes]
|
||||
.filter(
|
||||
(n) =>
|
||||
!(n instanceof HTMLElement) ||
|
||||
(!n.classList.contains('lane-ref') && !n.classList.contains('lane-note')),
|
||||
)
|
||||
.map((n) => n.textContent ?? '')
|
||||
.join('')
|
||||
.trim(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Cards per lane, read from the DOM. Header assertions alone cannot see the
|
||||
* bucketing (codex round 3): removing `bucketByColumn`'s `valueFor`
|
||||
* callback leaves every relation lane RENDERED and EMPTY while the cards
|
||||
* fall into Uncategorized, and every label test stays green.
|
||||
*/
|
||||
const cardsByLane = (screen: { container: HTMLElement }) => {
|
||||
const out: Record<string, number> = {};
|
||||
for (const col of screen.container.querySelectorAll('.kanban-column')) {
|
||||
// Whitespace stripped ENTIRELY: the ref, name and note are separate
|
||||
// spans with CSS spacing, so their textContent runs together in one
|
||||
// place and not another depending on markup indentation.
|
||||
const name = (col.querySelector('.column-name')?.textContent ?? '').replace(/\s+/g, '');
|
||||
out[name] = col.querySelectorAll('.item-card').length;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
it('puts the cards in the lanes, not merely the lanes on the board', () => {
|
||||
const screen = renderBoard([
|
||||
item('car-1', 'id-red'),
|
||||
item('car-2', 'id-red'),
|
||||
item('car-3', 'id-gone'),
|
||||
item('car-4', DANGLING),
|
||||
item('car-5'),
|
||||
]);
|
||||
|
||||
const byLane = cardsByLane(screen);
|
||||
// PRECONDITION: every card is somewhere, so a lane reading 0 is a
|
||||
// misplacement rather than a card that never rendered.
|
||||
expect(Object.values(byLane).reduce((a, b) => a + b, 0)).toBe(5);
|
||||
expect(byLane['COLOR-1Red']).toBe(2);
|
||||
expect(byLane['COLOR-9Gone(deleted)']).toBe(1);
|
||||
expect(byLane['Unresolvedreference']).toBe(1);
|
||||
expect(byLane['Uncategorized']).toBe(1);
|
||||
});
|
||||
|
||||
it('places a PADDED legacy value in the same lane as its clean form', () => {
|
||||
// The seam the trim closed, asserted where a user would see it.
|
||||
const screen = renderBoard([item('car-1', 'id-red'), item('car-2', ' id-red ')]);
|
||||
expect(cardsByLane(screen)['COLOR-1Red']).toBe(2);
|
||||
});
|
||||
|
||||
it('does not turn the card\'s status chip into a RELATION setter', () => {
|
||||
// codex round 3, P1. Cards were handed `statusOptions={columns}`, which
|
||||
// is fine while a lane value is a status option — under relation
|
||||
// grouping the lanes are ITEM IDS, and the parent handler writes what it
|
||||
// receives into `fields[groupField]`. So a click on the status chip set
|
||||
// the card's relation, cycling through target ids and able to land on
|
||||
// the deleted or unresolved lane, which the write path then refuses.
|
||||
// The collection needs a STATUS field as well as the relation, or the
|
||||
// card renders no status chip for an unrelated reason and the test
|
||||
// cannot discriminate. Measured: without it the mutant survived.
|
||||
const coll = collection();
|
||||
coll.schema = JSON.stringify({
|
||||
fields: [
|
||||
{ key: 'car_color', label: 'Colour', type: 'relation', collection: 'colors' },
|
||||
{ key: 'status', label: 'Status', type: 'select', options: ['open', 'done'] },
|
||||
],
|
||||
});
|
||||
const withStatus = (id: string, color: string) =>
|
||||
({
|
||||
...item(id, color),
|
||||
fields: JSON.stringify({ car_color: color, status: 'open' }),
|
||||
}) as Item;
|
||||
|
||||
const screen = render(BoardView, {
|
||||
props: {
|
||||
items: [withStatus('car-1', 'id-red'), withStatus('car-2', 'id-blue')],
|
||||
collection: coll,
|
||||
wsSlug: 'ws',
|
||||
groupField: 'car_color',
|
||||
onStatusChange: vi.fn(),
|
||||
} as never,
|
||||
});
|
||||
|
||||
// PRECONDITION: the cards rendered, so "no chip" is not "no card".
|
||||
expect(screen.container.querySelectorAll('.item-card')).toHaveLength(2);
|
||||
expect(screen.container.querySelector('[title="Click to cycle status"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('STILL offers status cycling on an ordinary board — the counterfactual', () => {
|
||||
// Withholding it everywhere would remove a working affordance from every
|
||||
// board on the instance, which is worse than the defect.
|
||||
const coll = collection();
|
||||
coll.schema = JSON.stringify({
|
||||
fields: [{ key: 'status', label: 'Status', type: 'select', options: ['open', 'done'] }],
|
||||
});
|
||||
const withStatus = (id: string) =>
|
||||
({
|
||||
id,
|
||||
workspace_id: 'ws1',
|
||||
collection_id: 'c1',
|
||||
slug: id,
|
||||
title: id,
|
||||
content: '',
|
||||
fields: JSON.stringify({ status: 'open' }),
|
||||
tags: '[]',
|
||||
status: 'open',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}) as unknown as Item;
|
||||
|
||||
const screen = render(BoardView, {
|
||||
props: {
|
||||
items: [withStatus('car-1')],
|
||||
collection: coll,
|
||||
wsSlug: 'ws',
|
||||
groupField: 'status',
|
||||
onStatusChange: vi.fn(),
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(screen.container.querySelector('[title="Click to cycle status"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('labels a live lane with REF and title, not the stored id', () => {
|
||||
const screen = renderBoard([item('car-1', 'id-red')]);
|
||||
|
||||
// PRECONDITION: the lane exists at all. A relation field has no
|
||||
// `options`, so before this unit every card landed in UNCATEGORIZED and
|
||||
// there was no lane to label.
|
||||
expect(lanes(screen)).toContainEqual({ ref: 'COLOR-1', note: null, name: 'Red' });
|
||||
expect(screen.container.innerHTML).not.toContain('id-red');
|
||||
});
|
||||
|
||||
it('keeps a DELETED target as its own lane, and says so', () => {
|
||||
const screen = renderBoard([item('car-1', 'id-red'), item('car-2', 'id-gone')]);
|
||||
const html = screen.container.innerHTML;
|
||||
|
||||
expect(lanes(screen)).toContainEqual({ ref: 'COLOR-9', note: '(deleted)', name: 'Gone' });
|
||||
// And not merged with the live one, which keeps its own honest label.
|
||||
expect(lanes(screen)).toContainEqual({ ref: 'COLOR-1', note: null, name: 'Red' });
|
||||
expect(html).not.toContain('id-gone');
|
||||
});
|
||||
|
||||
it('renders a dangling value as an honest lane, never the bare id', () => {
|
||||
const screen = renderBoard([item('car-1', DANGLING)]);
|
||||
const html = screen.container.innerHTML;
|
||||
|
||||
expect(lanes(screen)).toContainEqual({ ref: null, note: null, name: 'Unresolved reference' });
|
||||
expect(html).not.toContain(DANGLING);
|
||||
expect(html).not.toContain('f47ac10b');
|
||||
});
|
||||
|
||||
it('does NOT group by relation when the field is an ordinary select', () => {
|
||||
// The counterfactual for the whole branch: a select must still bucket
|
||||
// against its own options, and a board that asked the local index for
|
||||
// lanes on every field type would relabel ordinary lanes with whatever
|
||||
// an id-shaped option resolved to.
|
||||
const coll = collection();
|
||||
coll.schema = JSON.stringify({
|
||||
fields: [{ key: 'car_color', label: 'Colour', type: 'select', options: ['id-red'] }],
|
||||
});
|
||||
const screen = render(BoardView, {
|
||||
props: {
|
||||
items: [item('car-1', 'id-red')],
|
||||
collection: coll,
|
||||
wsSlug: 'ws',
|
||||
groupField: 'car_color',
|
||||
onStatusChange: vi.fn(),
|
||||
} as never,
|
||||
});
|
||||
|
||||
const html = screen.container.innerHTML;
|
||||
expect(html).not.toContain('COLOR-1');
|
||||
// formatLabel's own casing of the option value, not a resolved title.
|
||||
expect(lanes(screen)).toContainEqual({ ref: null, note: null, name: 'Id-Red' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the drop gate on a relation lane', () => {
|
||||
/**
|
||||
* A SOURCE guard, and the header says why rather than leaving it implied:
|
||||
* the gate lives inside `commitColumnMove`, which is reached through
|
||||
* svelte-dnd-action's drag events, and driving those in jsdom tests the
|
||||
* drag library rather than this decision. Measured first — removing the
|
||||
* gate leaves every rendering test in this file green, so without something
|
||||
* here the wiring is unobserved (CONVE-19).
|
||||
*
|
||||
* WHAT A SOURCE GUARD CANNOT DO: it checks spellings, not behaviour. A gate
|
||||
* that consulted the wrong lane, or one made unreachable by an earlier
|
||||
* return, would still pass — and the rollback below is asserted as an
|
||||
* ASSIGNMENT rather than as a board that visibly snaps back. The honest
|
||||
* instrument for that is a drag driven through svelte-dnd-action, which
|
||||
* tests the drag library as much as this decision.
|
||||
*/
|
||||
it('puts the card BACK when it refuses the drop', () => {
|
||||
// codex round 1, P1, and the most important mutant this file missed:
|
||||
// svelte-dnd-action has already moved the card in `columnData` by the
|
||||
// time the gate runs, so refusing the WRITE without undoing that leaves
|
||||
// the board showing a move that never happened — the exact failure the
|
||||
// refusal exists to avoid, one step later. Every rendering test stayed
|
||||
// green through it.
|
||||
const start = SRC.indexOf('async function commitColumnMove(');
|
||||
const body = SRC.slice(start, SRC.indexOf('\n\t}', start));
|
||||
const gate = body.indexOf('relationLaneAcceptsDrop');
|
||||
// Matched as "reassigns columnData from propColumnData", not as one exact
|
||||
// spelling: the restore became a COPY in round 6 (the two aliased, so
|
||||
// assigning the derived back restored nothing), and a guard pinned to
|
||||
// the old literal failed the fix rather than the defect.
|
||||
const restore = body.indexOf('columnData =');
|
||||
expect(restore, 'the refused drop is not reverted').toBeGreaterThan(-1);
|
||||
expect(restore).toBeGreaterThan(gate);
|
||||
expect(restore).toBeLessThan(body.indexOf('onStatusChange('));
|
||||
expect(body.slice(restore, body.indexOf('onStatusChange('))).toContain('propColumnData');
|
||||
});
|
||||
|
||||
it('consults relationLaneAcceptsDrop BEFORE calling onStatusChange', () => {
|
||||
const start = SRC.indexOf('async function commitColumnMove(');
|
||||
expect(start, 'commitColumnMove was renamed or removed').toBeGreaterThan(-1);
|
||||
const body = SRC.slice(start, SRC.indexOf('\n\t}', start));
|
||||
|
||||
const gate = body.indexOf('relationLaneAcceptsDrop');
|
||||
const write = body.indexOf('onStatusChange(');
|
||||
expect(gate, 'the relation drop gate is gone').toBeGreaterThan(-1);
|
||||
expect(write).toBeGreaterThan(-1);
|
||||
expect(gate, 'the write happens before the gate').toBeLessThan(write);
|
||||
});
|
||||
|
||||
it('gates on isRelationGroup, so an ordinary board is untouched', () => {
|
||||
// THE EIGHTH WRONG-REASON TEST (codex round 6). It asserted only that
|
||||
// the source CONTAINS `if (isRelationGroup)` — a mutant leaving that
|
||||
// block empty and calling `onStatusChange` unconditionally would pass.
|
||||
// It now requires the refusal to live INSIDE that block, ahead of the
|
||||
// write.
|
||||
const start = SRC.indexOf('async function commitColumnMove(');
|
||||
const body = SRC.slice(start, SRC.indexOf('\n\t}', start));
|
||||
const gate = body.indexOf('if (isRelationGroup)');
|
||||
const refuse = body.indexOf('relationLaneAcceptsDrop');
|
||||
const write = body.indexOf('onStatusChange(');
|
||||
expect(gate).toBeGreaterThan(-1);
|
||||
expect(refuse).toBeGreaterThan(gate);
|
||||
expect(refuse).toBeLessThan(write);
|
||||
// and the block returns rather than falling through
|
||||
expect(body.slice(gate, write)).toContain('return;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a relation field with no declared target collection', () => {
|
||||
it('is NOT grouped as a relation', () => {
|
||||
// `narrowRelationRow` skips the collection check when there is nothing
|
||||
// to check against, so a legacy or half-written relation field would
|
||||
// resolve ids ANYWHERE in the workspace and label lanes with whatever it
|
||||
// found. The filter UI already required a target; the board did not
|
||||
// (codex round 2).
|
||||
const coll = collection();
|
||||
coll.schema = JSON.stringify({
|
||||
fields: [{ key: 'car_color', label: 'Colour', type: 'relation' }],
|
||||
});
|
||||
const screen = render(BoardView, {
|
||||
props: {
|
||||
items: [item('car-1', 'id-red')],
|
||||
collection: coll,
|
||||
wsSlug: 'ws',
|
||||
groupField: 'car_color',
|
||||
onStatusChange: vi.fn(),
|
||||
} as never,
|
||||
});
|
||||
|
||||
// No lane resolved from the workspace-wide index — the ref is the tell.
|
||||
expect(screen.container.querySelector('.lane-ref')).toBeNull();
|
||||
});
|
||||
|
||||
it('withholds the lane menu\'s "add" for a relation lane, like the visible +', () => {
|
||||
// Otherwise the menu is an undocumented second creation path into a
|
||||
// lane whose "+" was deliberately withheld, and it contradicts this
|
||||
// component's own stated design rather than merely duplicating it.
|
||||
const start = SRC.indexOf('onAddItem={onCreateInColumn');
|
||||
expect(start, 'the lane menu no longer passes onAddItem').toBeGreaterThan(-1);
|
||||
expect(SRC.slice(start, start + 160)).toContain('!isRelationGroup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the MENU move into a refused relation lane', () => {
|
||||
/**
|
||||
* codex round 6, P2 — and the reason this leg is rendered rather than
|
||||
* source-guarded like its drag sibling.
|
||||
*
|
||||
* Round 6 reported that `moveItem`'s `columnData[col] = ...` writes THROUGH
|
||||
* to `propColumnData`'s cached object, so restoring by assigning it back
|
||||
* restores nothing. That premise is FALSE (settled in round 7, see the
|
||||
* failure-exit block at the end of this file): the alias mutant SURVIVES
|
||||
* this test, and a direct probe of the shape reports the derived's cache
|
||||
* unchanged by the write.
|
||||
*
|
||||
* What this test does prove — verified by a gate mutant killing it — is
|
||||
* that it drives the refusal path for real. No source guard could have done
|
||||
* that; it took driving the menu.
|
||||
*/
|
||||
const cardsIn = (screen: { container: HTMLElement }, laneName: string) => {
|
||||
for (const col of screen.container.querySelectorAll('.kanban-column')) {
|
||||
const name = (col.querySelector('.column-name')?.textContent ?? '').replace(/\s+/g, '');
|
||||
if (name === laneName) return [...col.querySelectorAll('.card-title')].map((e) => e.textContent?.trim());
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
it('leaves the card where it was', async () => {
|
||||
const onStatusChange = vi.fn();
|
||||
const screen = render(BoardView, {
|
||||
props: {
|
||||
items: [item('car-1', 'id-red'), item('car-2', 'id-gone')],
|
||||
collection: collection(),
|
||||
wsSlug: 'ws',
|
||||
groupField: 'car_color',
|
||||
onStatusChange,
|
||||
onReorder: vi.fn(),
|
||||
} as never,
|
||||
});
|
||||
|
||||
// PRECONDITION: two lanes, each holding its own card, and the deleted
|
||||
// lane is to the RIGHT of the live one (lanes sort live-then-deleted).
|
||||
expect(cardsIn(screen, 'COLOR-1Red')).toEqual(['car-1']);
|
||||
expect(cardsIn(screen, 'COLOR-9Gone(deleted)')).toEqual(['car-2']);
|
||||
|
||||
// Open car-1's action menu and move it right, into the deleted lane.
|
||||
// The card's own ⋮ (ItemActionsMenu), not the lane's ⋯ — and the menu may
|
||||
// render into <body>, so the search below is document-wide.
|
||||
const cardMenuButtons = [...screen.container.querySelectorAll('.item-card button')].filter(
|
||||
(b) => (b.textContent ?? '').trim() === '⋮',
|
||||
) as HTMLButtonElement[];
|
||||
expect(cardMenuButtons.length, 'no card action menu trigger — re-point this test').toBeGreaterThan(0);
|
||||
cardMenuButtons[0].click();
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const moveRight = [...document.querySelectorAll('button')].find((b) =>
|
||||
(b.textContent ?? '').includes('Move right'),
|
||||
);
|
||||
expect(moveRight, '"Move right" was not offered — re-point this test').toBeTruthy();
|
||||
moveRight!.click();
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
// The write was refused AND the board did not keep the card there.
|
||||
expect(onStatusChange).not.toHaveBeenCalled();
|
||||
expect(cardsIn(screen, 'COLOR-1Red')).toEqual(['car-1']);
|
||||
expect(cardsIn(screen, 'COLOR-9Gone(deleted)')).toEqual(['car-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the MENU move whose WRITE fails', () => {
|
||||
/**
|
||||
* codex round 7, P1 — REFUTED, and this test is what refutes it.
|
||||
*
|
||||
* Rounds 6 and 7 both reasoned that the sync `$effect` assigns the
|
||||
* `$derived.by` VALUE into `columnData`, so `moveItem`'s
|
||||
* `columnData[col] = …` writes THROUGH to the derived cache — round 6
|
||||
* against the refusal exit, round 7 against this one, the failure exit
|
||||
* where `onStatusChange` rejects and the restore rests on
|
||||
* `dropCooldown = false` alone.
|
||||
*
|
||||
* The premise does not hold. A probe over exactly that shape — a
|
||||
* `$derived.by` object assigned into a `$state`, a property written on the
|
||||
* `$state`, then the derived read back — reported the derived's cached
|
||||
* value UNCHANGED (`after mutation, derived.a=[1]`), and the restore then
|
||||
* produced the original lanes. A `$state` holding a derived's object is a
|
||||
* deep proxy; its property writes do not reach what the derivation cached.
|
||||
*
|
||||
* So this test passes against the tree as written, and that is the finding
|
||||
* rather than a fix. It is kept because the failure exit had NO behavioural
|
||||
* coverage before it and because it discriminates: removing
|
||||
* `dropCooldown = false` turns it red (measured), so its green is evidence
|
||||
* that the cooldown release is what restores the board, not an accident of
|
||||
* a card that never moved — which the `toHaveBeenCalledTimes(1)`
|
||||
* precondition rules out separately.
|
||||
*
|
||||
* The class is every exit from `commitColumnMove` that must restore lanes
|
||||
* after `moveItem` mutated them. There are two, the refusal and the
|
||||
* failure; both are now covered, and neither is defective.
|
||||
*/
|
||||
const cardsIn = (screen: { container: HTMLElement }, laneName: string) => {
|
||||
for (const col of screen.container.querySelectorAll('.kanban-column')) {
|
||||
const name = (col.querySelector('.column-name')?.textContent ?? '').replace(/\s+/g, '');
|
||||
if (name === laneName) return [...col.querySelectorAll('.card-title')].map((e) => e.textContent?.trim());
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
it('puts the card back when onStatusChange rejects', async () => {
|
||||
const onStatusChange = vi.fn().mockRejectedValue(new Error('nope'));
|
||||
const screen = render(BoardView, {
|
||||
props: {
|
||||
items: [item('car-1', 'id-blue'), item('car-2', 'id-red')],
|
||||
collection: collection(),
|
||||
wsSlug: 'ws',
|
||||
groupField: 'car_color',
|
||||
onStatusChange,
|
||||
onReorder: vi.fn(),
|
||||
} as never,
|
||||
});
|
||||
|
||||
// PRECONDITION: two LIVE lanes, sorted by title, each holding its card.
|
||||
expect(cardsIn(screen, 'COLOR-2Blue')).toEqual(['car-1']);
|
||||
expect(cardsIn(screen, 'COLOR-1Red')).toEqual(['car-2']);
|
||||
|
||||
const cardMenuButtons = [...screen.container.querySelectorAll('.item-card button')].filter(
|
||||
(b) => (b.textContent ?? '').trim() === '⋮',
|
||||
) as HTMLButtonElement[];
|
||||
expect(cardMenuButtons.length, 'no card action menu trigger — re-point this test').toBeGreaterThan(0);
|
||||
cardMenuButtons[0].click();
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const moveRight = [...document.querySelectorAll('button')].find((b) =>
|
||||
(b.textContent ?? '').includes('Move right'),
|
||||
);
|
||||
expect(moveRight, '"Move right" was not offered — re-point this test').toBeTruthy();
|
||||
moveRight!.click();
|
||||
await tick();
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
// PRECONDITION for the assertion below: the write was ATTEMPTED and
|
||||
// failed. Without this, a board that never moved the card would pass.
|
||||
expect(onStatusChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The write failed, so the board must show the state it started in.
|
||||
expect(cardsIn(screen, 'COLOR-2Blue')).toEqual(['car-1']);
|
||||
expect(cardsIn(screen, 'COLOR-1Red')).toEqual(['car-2']);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,16 @@
|
||||
import { itemComparator, type SortMode } from '$lib/collections/itemSort';
|
||||
import { reorderGroup, disabledDirections, adjacentColumn, type ReorderDirection } from '$lib/collections/reorder';
|
||||
import { bucketByColumn, UNCATEGORIZED } from '$lib/collections/boardColumns';
|
||||
import {
|
||||
narrowRelationRow,
|
||||
relationLaneAcceptsDrop,
|
||||
relationLaneAriaName,
|
||||
relationLaneValueFor,
|
||||
relationLanes,
|
||||
type RelationLane,
|
||||
} from '$lib/collections/relationGroups';
|
||||
import { localIndex } from '$lib/stores/localIndex.svelte';
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
import { columnAccentClassFor } from '$lib/utils/fieldColors';
|
||||
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
|
||||
import type { DndEvent } from 'svelte-dnd-action';
|
||||
@@ -191,9 +201,61 @@
|
||||
|
||||
let schema = $derived(parseSchema(collection));
|
||||
let field = $derived(schema.fields.find((f) => f.key === groupField));
|
||||
let columns = $derived(field?.options ?? []);
|
||||
|
||||
// GROUPING BY A RELATION (TASK-2998 / PLAN-2857 U7). Every other groupable
|
||||
// type has an option list to bucket against; a relation's lanes are the
|
||||
// targets the rows point at, so they are derived from the items and
|
||||
// resolved through the local index. The rules — ordering, what a deleted or
|
||||
// dangling target is labelled, which lanes accept a drop — live in
|
||||
// `$lib/collections/relationGroups`, because they are decisions and a
|
||||
// decision reachable only by mounting a board is one nobody tests.
|
||||
// A DECLARED TARGET IS REQUIRED (codex round 2). `narrowRelationRow` skips
|
||||
// the collection check when there is nothing to check against, so a legacy
|
||||
// or half-written relation field with no `collection` would resolve ids
|
||||
// ANYWHERE in the workspace and label lanes with whatever it found. The
|
||||
// filter UI already requires it; the board did not.
|
||||
let isRelationGroup = $derived(field?.type === 'relation' && !!field?.collection);
|
||||
let knownCollectionSlugs = $derived(
|
||||
new Set(collectionStore.collections.map((c) => c.slug)),
|
||||
);
|
||||
let resolveRelation = $derived((id: string) =>
|
||||
wsSlug
|
||||
? narrowRelationRow(
|
||||
localIndex.findByIdOrSlug(wsSlug, id),
|
||||
id,
|
||||
field?.collection,
|
||||
knownCollectionSlugs,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
let relationLaneList = $derived<RelationLane[]>(
|
||||
isRelationGroup ? relationLanes(items, groupField, resolveRelation) : [],
|
||||
);
|
||||
let relationLaneByValue = $derived(
|
||||
new Map(relationLaneList.map((lane) => [lane.value, lane])),
|
||||
);
|
||||
let columns = $derived(
|
||||
isRelationGroup ? relationLaneList.map((lane) => lane.value) : (field?.options ?? []),
|
||||
);
|
||||
|
||||
// Column order state — tracks the displayed order, syncs from schema when not dragging
|
||||
/**
|
||||
* What a CARD's status chip cycles through — the board's lanes for every
|
||||
* ordinary grouping, and nothing at all when the lanes are relation targets
|
||||
* (codex round 3, P1).
|
||||
*
|
||||
* `statusOptions={columns}` was fine while a lane value was always a status
|
||||
* option. Under relation grouping the lanes are ITEM IDS, and the parent's
|
||||
* handler writes whatever it receives into `fields[groupField]` — so a
|
||||
* click on the status chip set the card's RELATION, cycling through target
|
||||
* ids and able to land on the deleted or unresolved lane, which the write
|
||||
* path then refuses. The chip is withheld rather than repointed at the real
|
||||
* status field: a status chip on a relation-grouped board would be cycling
|
||||
* a field the board is not showing, which is a different feature and not
|
||||
* one this unit was asked for.
|
||||
*/
|
||||
let cardStatusOptions = $derived(isRelationGroup ? [] : columns);
|
||||
|
||||
let columnOrder = $state<string[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
@@ -257,7 +319,12 @@
|
||||
let propColumnData = $derived.by(() => {
|
||||
// Bucket items into their lanes, routing empty/unknown-value items
|
||||
// into the UNCATEGORIZED ('') lane instead of dropping them (IDEA-2275).
|
||||
const result = bucketByColumn(items, groupField, columns);
|
||||
const result = bucketByColumn(
|
||||
items,
|
||||
groupField,
|
||||
columns,
|
||||
isRelationGroup ? (item) => relationLaneValueFor(item, groupField, resolveRelation) : undefined,
|
||||
);
|
||||
// `preserveOrder` opts out of the in-column sort so search rank
|
||||
// from the parent isn't overridden — TASK-1367. Otherwise sort
|
||||
// each lane by its effective mode — the per-lane override if set,
|
||||
@@ -363,11 +430,63 @@
|
||||
// dnd-provided target order (e.detail.items); the menu passes 'top' (the
|
||||
// card has already been inserted at the target lane's head). DR-7.
|
||||
async function commitColumnMove(item: Item, targetColumn: string, placement: Item[] | 'top') {
|
||||
// A RELATION LANE ONLY ACCEPTS A DROP WHEN ITS TARGET IS LIVE
|
||||
// (TASK-2998). Writing the value of the uncategorised, deleted or
|
||||
// unresolved lanes would either clear the field or write a reference
|
||||
// TASK-2878's validator refuses — so the card would move on screen and
|
||||
// the write would fail behind it, which is worse than not moving.
|
||||
if (isRelationGroup) {
|
||||
const lane = relationLaneByValue.get(targetColumn);
|
||||
if (!lane || !relationLaneAcceptsDrop(lane)) {
|
||||
// AND PUT THE CARD BACK (codex round 1, P1). By the time this
|
||||
// runs, svelte-dnd-action has already moved the card in
|
||||
// `columnData` — refusing the WRITE without undoing that leaves
|
||||
// the board showing a move that never happened, which is the
|
||||
// exact failure the refusal exists to avoid, one step later.
|
||||
//
|
||||
// Re-reading `propColumnData` is how the failure path below
|
||||
// reverts too: it is the props-derived truth, and the sync
|
||||
// effect is gated on the cooldown rather than owning the
|
||||
// restore. Assigning here rather than releasing a cooldown
|
||||
// nobody set is the same repair without the two-second window.
|
||||
//
|
||||
// COPIED rather than aliased — defensive, and NOT for the
|
||||
// reason rounds 6 and 7 gave. Both rounds argued that the sync
|
||||
// effect assigns the derived VALUE into `columnData`, so a
|
||||
// later `columnData[col] = ...` writes THROUGH to
|
||||
// `propColumnData`'s cached object and assigning it back
|
||||
// restores nothing. That premise is FALSE, measured rather than
|
||||
// argued: a `$state` assigned a `$derived`'s object is a deep
|
||||
// proxy whose property writes do not reach the derived's cache.
|
||||
// A probe over exactly this shape ($derived.by object → $state
|
||||
// → property write → read the derived) reported
|
||||
// `after mutation, derived.a=[1]`, the pre-mutation value. The
|
||||
// rendered tests agree from the other side: the round-6 menu
|
||||
// test survives the alias mutant, and the failure-exit test
|
||||
// below passes on the cooldown release alone while going red
|
||||
// when that release is removed.
|
||||
// So the copy buys nothing against aliasing; it stays only
|
||||
// because a restore path should not hand its caller an object
|
||||
// somebody else owns. Do not re-derive the aliasing story from
|
||||
// the shape of this code — it has now cost two review rounds.
|
||||
columnData = Object.fromEntries(
|
||||
Object.entries(propColumnData).map(([key, list]) => [key, [...list]]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
dropCooldown = true;
|
||||
|
||||
let moveSucceeded = true;
|
||||
const fields = parseFields(item);
|
||||
if (fields[groupField] !== targetColumn) {
|
||||
// TRIMMED for a relation, as the list already was (codex round 6). A
|
||||
// legacy value of `" id-red "` is ALREADY in the `id-red` lane, and a
|
||||
// raw `!==` fired a pointless write for it — the same asymmetry between
|
||||
// these two views, in the other direction this time.
|
||||
const currentValue = isRelationGroup
|
||||
? relationLaneValueFor(item, groupField, resolveRelation)
|
||||
: fields[groupField];
|
||||
if (currentValue !== targetColumn) {
|
||||
try {
|
||||
await onStatusChange(item, targetColumn);
|
||||
} catch {
|
||||
@@ -483,14 +602,24 @@
|
||||
{#each renderColumns as colValue (colValue)}
|
||||
{@const colItems = columnData[colValue] ?? []}
|
||||
{@const isUncategorized = colValue === UNCATEGORIZED}
|
||||
{@const colDraggable = canEdit && !isUncategorized}
|
||||
{@const relLane = relationLaneByValue.get(colValue)}
|
||||
{@const laneName = relLane ? (relLane.title ?? relLane.label) : formatLabel(colValue)}
|
||||
{@const laneRef = relLane?.ref ?? null}
|
||||
<!--
|
||||
A relation lane is NOT column-draggable and offers no "+". Its order
|
||||
is alphabetical rather than schema-held, so a reorder would have
|
||||
nowhere to persist to and would snap back; and creating an item
|
||||
"into" a lane means writing a relation, which the picker owns
|
||||
(TASK-2998 keeps that to the drop gesture in this unit).
|
||||
-->
|
||||
{@const colDraggable = canEdit && !isUncategorized && !isRelationGroup}
|
||||
<div
|
||||
class="kanban-column"
|
||||
class:drag-over-left={dragOverColumn === colValue}
|
||||
class:dragging-source={draggedColumn === colValue}
|
||||
class:uncategorized-column={isUncategorized}
|
||||
role="group"
|
||||
aria-label="{formatLabel(colValue)} column"
|
||||
aria-label="{relationLaneAriaName(relLane, formatLabel(colValue))} column"
|
||||
ondragover={(e) => handleColumnDragOver(e, colValue)}
|
||||
ondragleave={handleColumnDragLeave}
|
||||
ondrop={(e) => handleColumnDrop(e, colValue)}
|
||||
@@ -507,7 +636,10 @@
|
||||
{#if colDraggable}
|
||||
<span class="column-drag-handle" title="Drag to reorder">⠿</span>
|
||||
{/if}
|
||||
<span class="column-name">{formatLabel(colValue)}</span>
|
||||
<span class="column-name">
|
||||
{#if laneRef}<span class="lane-ref">{laneRef}</span>{/if}{laneName}
|
||||
{#if relLane?.state === 'deleted'}<span class="lane-note" title="This item has been deleted.">(deleted)</span>{/if}
|
||||
</span>
|
||||
<div class="column-actions">
|
||||
<span class="column-count">{colItems.length}</span>
|
||||
<!-- Affordance visibility is driven purely by callback
|
||||
@@ -520,7 +652,7 @@
|
||||
UNCATEGORIZED lane hides "add" (creating an explicitly
|
||||
uncategorized item makes no sense) but keeps the bulk
|
||||
⋯ menu — "move/tag/assign all" is useful for triage. -->
|
||||
{#if onCreateInColumn && !isUncategorized}
|
||||
{#if onCreateInColumn && !isUncategorized && !isRelationGroup}
|
||||
<button
|
||||
class="lane-btn lane-add-btn"
|
||||
title="Add item to {formatLabel(colValue).toLowerCase()}"
|
||||
@@ -535,7 +667,7 @@
|
||||
<button
|
||||
class="lane-btn lane-menu-btn"
|
||||
title="Lane actions"
|
||||
aria-label="{formatLabel(colValue)} lane actions"
|
||||
aria-label="{relationLaneAriaName(relLane, formatLabel(colValue))} lane actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={openMenuColumn === colValue}
|
||||
onclick={(e) => { e.stopPropagation(); toggleMenu(colValue); }}
|
||||
@@ -553,7 +685,9 @@
|
||||
laneSort={laneSortOverrides[colValue]}
|
||||
onSetLaneSort={(m) => setLaneSort(colValue, m)}
|
||||
onClose={closeMenu}
|
||||
onAddItem={onCreateInColumn && !isUncategorized ? () => openDraft(colValue) : undefined}
|
||||
onAddItem={onCreateInColumn && !isUncategorized && !isRelationGroup
|
||||
? () => openDraft(colValue)
|
||||
: undefined}
|
||||
onArchive={onArchiveColumn ? () => onArchiveColumn?.(colItems) : undefined}
|
||||
onMove={onMoveColumn ? (status) => onMoveColumn?.(colItems, status) : undefined}
|
||||
onTag={onTagColumn ? (tag) => onTagColumn?.(colItems, tag) : undefined}
|
||||
@@ -627,8 +761,8 @@
|
||||
{collection}
|
||||
compact={true}
|
||||
focused={focusedItemId === item.id}
|
||||
statusOptions={columns}
|
||||
onStatusClick={onStatusChange}
|
||||
statusOptions={cardStatusOptions}
|
||||
onStatusClick={isRelationGroup ? undefined : onStatusChange}
|
||||
progress={itemProgress?.[item.id] ?? null}
|
||||
{progressLabel}
|
||||
onReorderItem={canReorderLane(colValue) ? (it, dir) => reorderItem(colValue, it, dir) : undefined}
|
||||
@@ -765,6 +899,20 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.lane-ref {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
margin-right: 0.35em;
|
||||
}
|
||||
|
||||
.lane-note {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.35em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.column-name {
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, cleanup, fireEvent } from '@testing-library/svelte';
|
||||
import { tick } from 'svelte';
|
||||
import type { Collection, ItemIndexRow } from '$lib/types';
|
||||
|
||||
/**
|
||||
* TASK-2998 / PLAN-2857 U7 — the filter half.
|
||||
*
|
||||
* The page's `filteredItems` has always compared `fields[key] === value` for
|
||||
* any key, so the FILTERING mechanism is not new. What is new, and what is only
|
||||
* observable here, is that a relation field gets a way to SET one and a chip
|
||||
* that says what the stored id means (CONVE-19).
|
||||
*/
|
||||
|
||||
const ROWS: Record<string, ItemIndexRow> = {
|
||||
'id-red': {
|
||||
id: 'id-red',
|
||||
title: 'Red',
|
||||
item_number: 1,
|
||||
collection_prefix: 'COLOR',
|
||||
collection_slug: 'colors',
|
||||
deleted_at: null,
|
||||
} as unknown as ItemIndexRow,
|
||||
'id-gone': {
|
||||
id: 'id-gone',
|
||||
title: 'Gone',
|
||||
item_number: 9,
|
||||
collection_prefix: 'COLOR',
|
||||
collection_slug: 'colors',
|
||||
deleted_at: '2026-01-01T00:00:00Z',
|
||||
} as unknown as ItemIndexRow,
|
||||
};
|
||||
|
||||
// vi.hoisted, because vi.mock factories are hoisted above ordinary consts.
|
||||
const getByCollection = vi.hoisted(() => vi.fn(() => [] as unknown[]));
|
||||
vi.mock('$lib/stores/localIndex.svelte', () => ({
|
||||
localIndex: {
|
||||
findByIdOrSlug: (_ws: string, id: string) => ROWS[id] ?? null,
|
||||
getByCollection,
|
||||
bootstrapStateFor: () => 'ready',
|
||||
},
|
||||
}));
|
||||
vi.mock('$lib/stores/collections.svelte', () => ({
|
||||
collectionStore: { collections: [{ slug: 'colors' }, { slug: 'cars' }] },
|
||||
}));
|
||||
|
||||
import FilterBar from './FilterBar.svelte';
|
||||
|
||||
function collection(fields: unknown[]): Collection {
|
||||
return {
|
||||
id: 'c1',
|
||||
workspace_id: 'ws1',
|
||||
name: 'Cars',
|
||||
slug: 'cars',
|
||||
icon: '',
|
||||
description: '',
|
||||
schema: JSON.stringify({ fields }),
|
||||
settings: JSON.stringify({}),
|
||||
sort_order: 0,
|
||||
is_default: true,
|
||||
is_system: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
prefix: 'CAR',
|
||||
} as unknown as Collection;
|
||||
}
|
||||
|
||||
const RELATION_FIELD = {
|
||||
key: 'car_color',
|
||||
label: 'Colour',
|
||||
type: 'relation',
|
||||
collection: 'colors',
|
||||
};
|
||||
|
||||
function renderBar(overrides: Record<string, unknown> = {}) {
|
||||
return render(FilterBar, {
|
||||
props: {
|
||||
collection: collection([RELATION_FIELD]),
|
||||
activeFilters: {},
|
||||
searchQuery: '',
|
||||
onFilterChange: vi.fn(),
|
||||
onSearchChange: vi.fn(),
|
||||
wsSlug: 'ws',
|
||||
...overrides,
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
const trigger = (screen: { container: HTMLElement }) =>
|
||||
screen.container.querySelector('.relation-filter-trigger') as HTMLButtonElement | null;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('FilterBar relation filter', () => {
|
||||
it('offers a filter for each relation field, named by its label', () => {
|
||||
const screen = renderBar();
|
||||
expect(trigger(screen)?.textContent?.trim()).toBe('All colour');
|
||||
});
|
||||
|
||||
it('renders the ACTIVE value as a chip — ref and title, never the id', () => {
|
||||
const screen = renderBar({ activeFilters: { car_color: 'id-red' } });
|
||||
const el = trigger(screen)!;
|
||||
expect(el.querySelector('.relation-filter-ref')?.textContent).toBe('COLOR-1');
|
||||
expect(el.textContent).toContain('Red');
|
||||
expect(screen.container.innerHTML).not.toContain('id-red');
|
||||
});
|
||||
|
||||
it('says so when the filtered target has been deleted', () => {
|
||||
// Filtering by a deleted item is legitimate — the rows still carry the
|
||||
// value — so the chip names it rather than pretending the filter is off.
|
||||
const screen = renderBar({ activeFilters: { car_color: 'id-gone' } });
|
||||
const el = trigger(screen)!;
|
||||
expect(el.querySelector('.relation-filter-note')?.textContent).toBe('(deleted)');
|
||||
expect(el.textContent).toContain('Gone');
|
||||
});
|
||||
|
||||
it('never echoes an unresolvable value back at the user', () => {
|
||||
const dangling = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
|
||||
const screen = renderBar({ activeFilters: { car_color: dangling } });
|
||||
expect(trigger(screen)?.textContent).toContain('Unresolved reference');
|
||||
expect(screen.container.innerHTML).not.toContain(dangling);
|
||||
});
|
||||
|
||||
it('clears the filter through the × rather than by re-picking', () => {
|
||||
const onFilterChange = vi.fn();
|
||||
const screen = renderBar({ activeFilters: { car_color: 'id-red' }, onFilterChange });
|
||||
const clear = screen.container.querySelector('.relation-filter-clear') as HTMLButtonElement;
|
||||
// PRECONDITION: the clear only exists while a filter is set.
|
||||
expect(clear).not.toBeNull();
|
||||
|
||||
clear.click();
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('opens a picker scoped to the field\'s DECLARED collection', async () => {
|
||||
// Scoped, not workspace-wide: a relation aimed at `colors` must not
|
||||
// offer a task.
|
||||
//
|
||||
// Asserted through what the picker ASKS THE INDEX FOR, not through the
|
||||
// placeholder text. The first version of this test checked
|
||||
// `input[placeholder="Search colors…"]` — which this file's own
|
||||
// `placeholder` prop produces — so dropping the `collection` prop
|
||||
// entirely left it green. Measured.
|
||||
getByCollection.mockClear();
|
||||
const screen = renderBar();
|
||||
await fireEvent.click(trigger(screen)!);
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const picker = screen.container.querySelector('.relation-filter-picker');
|
||||
expect(picker).not.toBeNull();
|
||||
expect(picker?.getAttribute('aria-label')).toBe('Filter by Colour');
|
||||
expect(getByCollection).toHaveBeenCalledWith('ws', 'colors');
|
||||
});
|
||||
|
||||
it('sets the filter to the picked item\'s ID', async () => {
|
||||
// The picker test above proves the picker is SCOPED; nothing proved what
|
||||
// selecting does. A mutant writing `row.slug`, `row.title` or nothing at
|
||||
// all survived it (codex round 4) — and the id is the whole contract,
|
||||
// since `filteredItems` compares the stored field value against this.
|
||||
getByCollection.mockReturnValue([
|
||||
{
|
||||
id: 'id-red',
|
||||
title: 'Red',
|
||||
slug: 'red',
|
||||
item_number: 1,
|
||||
collection_prefix: 'COLOR',
|
||||
collection_slug: 'colors',
|
||||
deleted_at: null,
|
||||
} as unknown as ItemIndexRow,
|
||||
]);
|
||||
const onFilterChange = vi.fn();
|
||||
const screen = renderBar({ onFilterChange });
|
||||
await fireEvent.click(trigger(screen)!);
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const option = screen.container.querySelector('.picker-result') as HTMLButtonElement | null;
|
||||
// PRECONDITION: the picker offered something to pick.
|
||||
expect(option, 'the picker rendered no options').not.toBeNull();
|
||||
option!.click();
|
||||
await tick();
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledWith({ car_color: 'id-red' });
|
||||
});
|
||||
|
||||
it('offers no × until a filter is actually set', () => {
|
||||
// The counterfactual for the clear button: without it, "the × clears the
|
||||
// filter" passes against a bar that shows a clear control permanently.
|
||||
const screen = renderBar();
|
||||
expect(screen.container.querySelector('.relation-filter-clear')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders NO relation filter without a workspace slug', () => {
|
||||
// The public share view has no workspace context, so it cannot resolve a
|
||||
// title — and a chip showing a bare id is worse than no chip.
|
||||
const screen = renderBar({ wsSlug: '' });
|
||||
expect(trigger(screen)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders no relation filter for an ordinary field', () => {
|
||||
const screen = renderBar({
|
||||
collection: collection([{ key: 'status', label: 'Status', type: 'select', options: ['open'] }]),
|
||||
});
|
||||
expect(trigger(screen)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@
|
||||
import BottomSheet from '$lib/components/common/BottomSheet.svelte';
|
||||
import { viewport } from '$lib/stores/breakpoint.svelte';
|
||||
import TagFilter from '$lib/components/collections/TagFilter.svelte';
|
||||
import ItemPicker from '$lib/components/items/ItemPicker.svelte';
|
||||
import { localIndex } from '$lib/stores/localIndex.svelte';
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
import { narrowRelationRow, relationChipFor } from '$lib/collections/relationGroups';
|
||||
|
||||
interface Props {
|
||||
collection: Collection;
|
||||
@@ -28,6 +32,14 @@
|
||||
/** Current effective state of the unparented chip. */
|
||||
unparentedActive?: boolean;
|
||||
onUnparentedChange?: (value: boolean) => void;
|
||||
/**
|
||||
* Workspace slug, for the relation filters (TASK-2998 / PLAN-2857 U7).
|
||||
* Omitted = no relation filter renders, which is what a caller with no
|
||||
* workspace context (the public share view) wants: it cannot resolve a
|
||||
* target's title, and a filter chip showing a bare id is worse than no
|
||||
* chip.
|
||||
*/
|
||||
wsSlug?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -44,6 +56,7 @@
|
||||
unparentedAvailable = false,
|
||||
unparentedActive = false,
|
||||
onUnparentedChange = () => {},
|
||||
wsSlug = '',
|
||||
}: Props = $props();
|
||||
|
||||
let schema = $derived(parseSchema(collection));
|
||||
@@ -63,6 +76,44 @@
|
||||
onFilterChange(next);
|
||||
}
|
||||
|
||||
// FILTERING BY A RELATION VALUE (TASK-2998 / PLAN-2857 U7).
|
||||
//
|
||||
// The page's `filteredItems` already compares `fields[key] === value` for
|
||||
// any key, so the mechanism has always been there — what was missing is a
|
||||
// way to SET one, and a chip that says what the stored id means. Both reuse
|
||||
// U3's picker and U2's chip vocabulary rather than inventing a second way
|
||||
// to choose and name an item.
|
||||
//
|
||||
// The hardcoded tasks→plans parent filter above is deliberately left alone:
|
||||
// it filters on `parent_link_id`, not on a field, so it is a different
|
||||
// mechanism wearing a similar hat. Generalising THAT is not this unit.
|
||||
let knownCollectionSlugs = $derived(new Set(collectionStore.collections.map((c) => c.slug)));
|
||||
let relationFields = $derived(
|
||||
wsSlug ? schema.fields.filter((f) => f.type === 'relation' && !!f.collection) : [],
|
||||
);
|
||||
let openPickerFor = $state<string | null>(null);
|
||||
|
||||
function resolveRelation(id: string, declared: string | undefined) {
|
||||
if (!wsSlug) return null;
|
||||
return narrowRelationRow(
|
||||
localIndex.findByIdOrSlug(wsSlug, id),
|
||||
id,
|
||||
declared,
|
||||
knownCollectionSlugs,
|
||||
);
|
||||
}
|
||||
|
||||
function setRelationFilter(key: string, value: string) {
|
||||
const next = { ...activeFilters };
|
||||
if (value) {
|
||||
next[key] = value;
|
||||
} else {
|
||||
delete next[key];
|
||||
}
|
||||
openPickerFor = null;
|
||||
onFilterChange(next);
|
||||
}
|
||||
|
||||
let hasParentFilter = $derived(Object.keys(relationLabels).length > 0);
|
||||
let activeParent = $derived(activeFilters.parent ?? '');
|
||||
let activeParentLabel = $derived(activeParent ? (relationLabels[activeParent] ?? activeParent) : 'All plans');
|
||||
@@ -183,6 +234,50 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#each relationFields as rf (rf.key)}
|
||||
{@const active = activeFilters[rf.key] ?? ''}
|
||||
{@const chip = active ? relationChipFor(active, (id) => resolveRelation(id, rf.collection)) : null}
|
||||
<div class="relation-filter">
|
||||
<button
|
||||
type="button"
|
||||
class="relation-filter-trigger"
|
||||
class:active={!!active}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={openPickerFor === rf.key}
|
||||
onclick={() => (openPickerFor = openPickerFor === rf.key ? null : rf.key)}
|
||||
>
|
||||
{#if chip}
|
||||
{#if chip.ref}<span class="relation-filter-ref">{chip.ref}</span>{/if}
|
||||
<span>{chip.title ?? chip.label}</span>
|
||||
{#if chip.state === 'deleted'}<span class="relation-filter-note">(deleted)</span>{/if}
|
||||
{:else}
|
||||
All {(rf.label ?? rf.key).toLowerCase()}
|
||||
{/if}
|
||||
</button>
|
||||
{#if active}
|
||||
<button
|
||||
type="button"
|
||||
class="relation-filter-clear"
|
||||
aria-label="Clear {rf.label ?? rf.key} filter"
|
||||
onclick={() => setRelationFilter(rf.key, '')}
|
||||
>×</button>
|
||||
{/if}
|
||||
{#if openPickerFor === rf.key}
|
||||
<div class="relation-filter-picker" role="dialog" aria-label="Filter by {rf.label ?? rf.key}">
|
||||
<ItemPicker
|
||||
{wsSlug}
|
||||
collection={rf.collection}
|
||||
source="index"
|
||||
autofocus
|
||||
label="Filter by {rf.label ?? rf.key}"
|
||||
placeholder="Search {rf.collection}…"
|
||||
onselect={(row) => setRelationFilter(rf.key, row.id)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if unparentedAvailable}
|
||||
<!--
|
||||
"Unparented only" chip (TASK-2099 / PLAN-2095). Rendered only when
|
||||
@@ -367,6 +462,66 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.relation-filter {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.relation-filter-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-elevated, var(--bg));
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.relation-filter-trigger.active {
|
||||
border-color: var(--accent, var(--border));
|
||||
}
|
||||
|
||||
.relation-filter-ref {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.relation-filter-note {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.relation-filter-clear {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted, var(--text));
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.relation-filter-picker {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35rem);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
min-width: min(22rem, 90vw);
|
||||
max-width: 90vw;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg-elevated, var(--bg));
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, cleanup } from '@testing-library/svelte';
|
||||
import type { Collection, Item, ItemIndexRow } from '$lib/types';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
/**
|
||||
* TASK-2998, codex round 4 — the LIST half of relation grouping.
|
||||
*
|
||||
* BoardView was fixed and this was not, which is the same class one component
|
||||
* over. It matters more here than it looks: this view DISCOVERS group values
|
||||
* from the items (that is what makes a text field groupable), so a relation
|
||||
* grouped here rendered one group per stored ITEM ID rather than falling into
|
||||
* an uncategorised lane.
|
||||
*/
|
||||
|
||||
const ROWS: Record<string, ItemIndexRow> = {
|
||||
'id-red': {
|
||||
id: 'id-red',
|
||||
title: 'Red',
|
||||
item_number: 1,
|
||||
collection_prefix: 'COLOR',
|
||||
collection_slug: 'colors',
|
||||
deleted_at: null,
|
||||
} as unknown as ItemIndexRow,
|
||||
'id-gone': {
|
||||
id: 'id-gone',
|
||||
title: 'Gone',
|
||||
item_number: 9,
|
||||
collection_prefix: 'COLOR',
|
||||
collection_slug: 'colors',
|
||||
deleted_at: '2026-01-01T00:00:00Z',
|
||||
} as unknown as ItemIndexRow,
|
||||
};
|
||||
|
||||
vi.mock('$lib/stores/localIndex.svelte', () => ({
|
||||
localIndex: {
|
||||
findByIdOrSlug: (_ws: string, id: string) => ROWS[id] ?? null,
|
||||
getByCollection: () => [],
|
||||
},
|
||||
}));
|
||||
vi.mock('$lib/stores/collections.svelte', () => ({
|
||||
collectionStore: { collections: [{ slug: 'colors' }, { slug: 'cars' }] },
|
||||
}));
|
||||
|
||||
import ListView from './ListView.svelte';
|
||||
|
||||
const DANGLING = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
|
||||
|
||||
function collection(fields: unknown[]): Collection {
|
||||
return {
|
||||
id: 'c1',
|
||||
workspace_id: 'ws1',
|
||||
name: 'Cars',
|
||||
slug: 'cars',
|
||||
icon: '',
|
||||
description: '',
|
||||
schema: JSON.stringify({ fields }),
|
||||
settings: JSON.stringify({}),
|
||||
sort_order: 0,
|
||||
is_default: true,
|
||||
is_system: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
prefix: 'CAR',
|
||||
} as unknown as Collection;
|
||||
}
|
||||
|
||||
const RELATION_FIELDS = [
|
||||
{ key: 'car_color', label: 'Colour', type: 'relation', collection: 'colors' },
|
||||
{ key: 'status', label: 'Status', type: 'select', options: ['open', 'done'] },
|
||||
];
|
||||
|
||||
function item(id: string, color?: string): Item {
|
||||
return {
|
||||
id,
|
||||
workspace_id: 'ws1',
|
||||
collection_id: 'c1',
|
||||
slug: id,
|
||||
title: id,
|
||||
content: '',
|
||||
fields: JSON.stringify(color === undefined ? { status: 'open' } : { car_color: color, status: 'open' }),
|
||||
tags: '[]',
|
||||
status: 'open',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
} as unknown as Item;
|
||||
}
|
||||
|
||||
function renderList(items: Item[], fields: unknown[] = RELATION_FIELDS, groupField = 'car_color') {
|
||||
return render(ListView, {
|
||||
props: {
|
||||
items,
|
||||
collection: collection(fields),
|
||||
wsSlug: 'ws',
|
||||
groupField,
|
||||
statusOptions: ['open', 'done'],
|
||||
onStatusChange: vi.fn(),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
/** Group headings, split into ref / note / name the way the markup builds them. */
|
||||
const groups = (screen: { container: HTMLElement }) =>
|
||||
[...screen.container.querySelectorAll('.group-title')].map((el) => ({
|
||||
ref: el.querySelector('.group-ref')?.textContent?.trim() ?? null,
|
||||
note: el.querySelector('.group-note')?.textContent?.trim() ?? null,
|
||||
name: [...el.childNodes]
|
||||
.filter(
|
||||
(n) =>
|
||||
!(n instanceof HTMLElement) ||
|
||||
(!n.classList.contains('group-ref') && !n.classList.contains('group-note')),
|
||||
)
|
||||
.map((n) => n.textContent ?? '')
|
||||
.join('')
|
||||
.trim(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('ListView grouped by a relation field', () => {
|
||||
it('names groups from the target, not from the stored id', () => {
|
||||
const screen = renderList([item('car-1', 'id-red'), item('car-2', 'id-gone')]);
|
||||
|
||||
expect(groups(screen)).toContainEqual({ ref: 'COLOR-1', note: null, name: 'Red' });
|
||||
expect(groups(screen)).toContainEqual({ ref: 'COLOR-9', note: '(deleted)', name: 'Gone' });
|
||||
expect(screen.container.innerHTML).not.toContain('id-red');
|
||||
});
|
||||
|
||||
it('folds a dangling value into one honest group', () => {
|
||||
const screen = renderList([item('car-1', DANGLING)]);
|
||||
expect(groups(screen)).toContainEqual({ ref: null, note: null, name: 'Unresolved reference' });
|
||||
expect(screen.container.innerHTML).not.toContain(DANGLING);
|
||||
});
|
||||
|
||||
it('does not turn the status chip into a relation setter', () => {
|
||||
// `onStatusChange` writes what it receives into `fields[groupField]`, so
|
||||
// on a relation-grouped list the chip sent a STATUS STRING to the
|
||||
// relation field — the same defect the board had, arriving from the
|
||||
// opposite direction: here the options were right and the target wrong.
|
||||
const screen = renderList([item('car-1', 'id-red')]);
|
||||
// PRECONDITION: a card rendered, so "no chip" is not "no card".
|
||||
expect(screen.container.querySelectorAll('.item-card').length).toBeGreaterThan(0);
|
||||
expect(screen.container.querySelector('[title="Click to cycle status"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('STILL groups and cycles status on an ordinary field — the counterfactual', () => {
|
||||
const screen = renderList(
|
||||
[item('car-1')],
|
||||
[{ key: 'status', label: 'Status', type: 'select', options: ['open', 'done'] }],
|
||||
'status',
|
||||
);
|
||||
expect(groups(screen)).toContainEqual({ ref: null, note: null, name: 'Open' });
|
||||
expect(screen.container.querySelector('[title="Click to cycle status"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('group reorder under relation grouping', () => {
|
||||
/** ListView's source with comments stripped. */
|
||||
const SRC = readFileSync(resolve(__dirname, './ListView.svelte'), 'utf8').replace(
|
||||
/^[ \t]*\/\/.*$/gm,
|
||||
'',
|
||||
);
|
||||
|
||||
it('gates the onGroupReorder CALL, not merely mentions the flag nearby', () => {
|
||||
// THE SIXTH WRONG-REASON TEST ON THIS BRANCH, and codex caught it in the
|
||||
// same round it was written. The first version searched the handler for
|
||||
// `!isRelationGroup` appearing before `onGroupReorder(` — a condition
|
||||
// the file satisfies in several ways that leave the callback
|
||||
// unconditional. It asserts the guard is IN the `if` that wraps the
|
||||
// call now.
|
||||
//
|
||||
// Still a source guard: the handler is reached through
|
||||
// svelte-dnd-action's group zone, and driving that tests the drag
|
||||
// library. WHAT IT CANNOT DO: it checks spellings, not behaviour.
|
||||
const start = SRC.indexOf('function handleGroupFinalize(');
|
||||
expect(start, 'handleGroupFinalize was renamed').toBeGreaterThan(-1);
|
||||
const body = SRC.slice(start, SRC.indexOf('\n\t}', start));
|
||||
expect(body).toMatch(/if\s*\(\s*onGroupReorder\s*&&\s*!isRelationGroup\s*\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a rejected drop in the list', () => {
|
||||
/** ListView's source with comments stripped. */
|
||||
const SRC2 = readFileSync(resolve(__dirname, './ListView.svelte'), 'utf8').replace(
|
||||
/^[ \t]*\/\/.*$/gm,
|
||||
'',
|
||||
);
|
||||
|
||||
it('writes NEITHER the relation NOR the sort order', () => {
|
||||
// codex round 5, P1. Skipping only `onStatusChange` left
|
||||
// `onReorder(reorderUpdates)` running unconditionally, so a refused
|
||||
// cross-group drop still rewrote every `sort_order` in the destination
|
||||
// — a persisted side effect of a gesture the component had just
|
||||
// declined.
|
||||
const start = SRC2.indexOf('async function handleFinalize(');
|
||||
expect(start, 'handleFinalize was renamed').toBeGreaterThan(-1);
|
||||
const body = SRC2.slice(start, SRC2.indexOf('\n\t}', start));
|
||||
|
||||
const refuse = body.indexOf('if (!dropAllowed)');
|
||||
const statusWrite = body.indexOf('onStatusChange(');
|
||||
const reorderWrite = body.indexOf('onReorder(');
|
||||
expect(refuse, 'the refusal is gone').toBeGreaterThan(-1);
|
||||
expect(refuse).toBeLessThan(statusWrite);
|
||||
expect(refuse).toBeLessThan(reorderWrite);
|
||||
// and it RETURNS rather than falling through to either write
|
||||
expect(body.slice(refuse, statusWrite)).toContain('return;');
|
||||
});
|
||||
|
||||
it('restores the rendered order from props', () => {
|
||||
const start = SRC2.indexOf('async function handleFinalize(');
|
||||
const body = SRC2.slice(start, SRC2.indexOf('\n\t}', start));
|
||||
expect(body).toContain('groupData = propGroupData');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a relation field with no declared target, in the list', () => {
|
||||
it('is not grouped at all, rather than grouped by raw ids', () => {
|
||||
// The board falls into UNCATEGORIZED for this, because its lanes come
|
||||
// from `field.options` and a relation has none. This view DISCOVERS
|
||||
// values from the items, so the same malformed schema produced one
|
||||
// group per raw ITEM ID — the two siblings disagreeing, with the list
|
||||
// landing on the one outcome this unit exists to prevent (codex round
|
||||
// 5).
|
||||
const screen = renderList(
|
||||
[item('car-1', 'id-red'), item('car-2', 'id-gone')],
|
||||
[{ key: 'car_color', label: 'Colour', type: 'relation' }],
|
||||
'car_color',
|
||||
);
|
||||
|
||||
// PRECONDITION: the cards rendered, so "one group" is not "no list".
|
||||
expect(screen.container.querySelectorAll('.item-card').length).toBe(2);
|
||||
|
||||
// ASSERTED AS THE GROUP SHAPE, not as the absence of the id string.
|
||||
// `formatLabel('id-red')` renders "Id-Red", so `not.toContain('id-red')`
|
||||
// passes against a list grouping by raw values — the SEVENTH time that
|
||||
// exact trap has caught a test on this branch. One group, and it is not
|
||||
// named after a target.
|
||||
expect(groups(screen)).toHaveLength(1);
|
||||
expect(groups(screen)[0].ref).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,15 @@
|
||||
import { parseSchema, parseFields } from '$lib/types';
|
||||
import { itemComparator, type SortMode } from '$lib/collections/itemSort';
|
||||
import { reorderGroup, disabledDirections, type ReorderDirection } from '$lib/collections/reorder';
|
||||
import {
|
||||
narrowRelationRow,
|
||||
relationLaneAcceptsDrop,
|
||||
relationLaneValueFor,
|
||||
relationLanes,
|
||||
type RelationLane,
|
||||
} from '$lib/collections/relationGroups';
|
||||
import { localIndex } from '$lib/stores/localIndex.svelte';
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
|
||||
import type { DndEvent } from 'svelte-dnd-action';
|
||||
@@ -90,6 +99,62 @@
|
||||
|
||||
let schema = $derived(parseSchema(collection));
|
||||
let field = $derived(schema.fields.find((f) => f.key === groupField));
|
||||
|
||||
// GROUPING BY A RELATION (TASK-2998 / PLAN-2857 U7, codex round 4).
|
||||
//
|
||||
// This component discovers extra group values from the ITEMS already, which
|
||||
// is what makes a text field groupable — and is exactly why a relation
|
||||
// grouped here rendered one group per stored ITEM ID. The board was fixed
|
||||
// and this was not: the same class, one component over.
|
||||
//
|
||||
// Same three pieces as BoardView, from the same module: lanes derived from
|
||||
// the targets, values folded onto a sentinel before bucketing, and the
|
||||
// chip vocabulary for the label.
|
||||
let isRelationGroup = $derived(field?.type === 'relation' && !!field?.collection);
|
||||
/**
|
||||
* A relation field with no declared target (legacy or half-written) is not
|
||||
* groupable AT ALL here (codex round 5).
|
||||
*
|
||||
* The board falls into UNCATEGORIZED for this, because its lanes come from
|
||||
* `field.options` and a relation has none. This view DISCOVERS values from
|
||||
* the items, so the same schema produced one group per raw ITEM ID — the
|
||||
* two siblings disagreeing about a malformed field, with the list landing
|
||||
* on the one outcome this whole unit exists to prevent.
|
||||
*/
|
||||
let relationWithoutTarget = $derived(field?.type === 'relation' && !field?.collection);
|
||||
let knownCollectionSlugs = $derived(
|
||||
new Set(collectionStore.collections.map((c) => c.slug)),
|
||||
);
|
||||
let resolveRelation = $derived((id: string) =>
|
||||
wsSlug
|
||||
? narrowRelationRow(
|
||||
localIndex.findByIdOrSlug(wsSlug, id),
|
||||
id,
|
||||
field?.collection,
|
||||
knownCollectionSlugs,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
let relationLaneList = $derived<RelationLane[]>(
|
||||
isRelationGroup ? relationLanes(items, groupField, resolveRelation) : [],
|
||||
);
|
||||
let relationLaneByValue = $derived(
|
||||
new Map(relationLaneList.map((lane) => [lane.value, lane])),
|
||||
);
|
||||
// THE STATUS CHIP IS WITHHELD ON A RELATION-GROUPED LIST, for the reason the
|
||||
// board withholds it: `onStatusChange` writes what it receives into
|
||||
// `fields[groupField]`, so a status click on a list grouped by a relation
|
||||
// wrote a STATUS STRING into the relation field. This component takes real
|
||||
// `statusOptions` as a prop rather than reusing its lanes, so the chip
|
||||
// showed the right options and sent them to the wrong field — the same
|
||||
// defect as the board's, arriving from the opposite direction.
|
||||
|
||||
/** The value an item is grouped under — sentinel-folded for a relation. */
|
||||
function groupValueFor(item: Item): string {
|
||||
if (relationWithoutTarget) return '';
|
||||
if (isRelationGroup) return relationLaneValueFor(item, groupField, resolveRelation);
|
||||
return (parseFields(item)[groupField] ?? '') as string;
|
||||
}
|
||||
let groupOptions = $derived(field?.options ?? []);
|
||||
|
||||
/**
|
||||
@@ -97,6 +162,12 @@
|
||||
* values discovered from items (handles text fields with no options).
|
||||
*/
|
||||
let displayGroups = $derived.by(() => {
|
||||
if (relationWithoutTarget) return [''];
|
||||
if (isRelationGroup) {
|
||||
const lanes = relationLaneList.map((lane) => lane.value);
|
||||
const hasEmpty = items.some((i) => groupValueFor(i) === '');
|
||||
return hasEmpty ? [...lanes, ''] : lanes;
|
||||
}
|
||||
const known = new Set(groupOptions);
|
||||
const extra: string[] = [];
|
||||
let hasUngrouped = false;
|
||||
@@ -136,7 +207,14 @@
|
||||
function handleGroupFinalize(e: CustomEvent<DndEvent<GroupItem>>) {
|
||||
groupItems = e.detail.items;
|
||||
isDraggingGroup = false;
|
||||
if (onGroupReorder) {
|
||||
// NO GROUP REORDER UNDER RELATION GROUPING (TASK-2998). The order is
|
||||
// alphabetical by target title, not schema-held, so there is nowhere to
|
||||
// persist it — and what `handleGroupReorder` WOULD persist is the lane
|
||||
// values, which for a relation are item ids going into the schema's
|
||||
// `options`. The page refuses that write at its own end; this stops the
|
||||
// gesture from looking like it worked and then snapping back on the
|
||||
// next derivation.
|
||||
if (onGroupReorder && !isRelationGroup) {
|
||||
const newOrder = groupItems
|
||||
.filter((g: any) => !g[SHADOW_ITEM_MARKER_PROPERTY_NAME])
|
||||
.map((g) => g.id);
|
||||
@@ -157,8 +235,7 @@
|
||||
result[opt] = [];
|
||||
}
|
||||
for (const item of items) {
|
||||
const fields = parseFields(item);
|
||||
const value = fields[groupField] ?? '';
|
||||
const value = groupValueFor(item);
|
||||
if (result[value] !== undefined) {
|
||||
result[value].push(item);
|
||||
} else {
|
||||
@@ -215,9 +292,30 @@
|
||||
|
||||
if (trigger === TRIGGERS.DROPPED_INTO_ZONE) {
|
||||
const originalItem = items.find((i) => i.id === itemId);
|
||||
// A RELATION GROUP ONLY ACCEPTS A DROP WHEN ITS TARGET IS LIVE
|
||||
// (TASK-2998) — same rule as the board. The other three would clear
|
||||
// the field or write a reference the validator refuses.
|
||||
const relLane = isRelationGroup ? relationLaneByValue.get(groupName) : undefined;
|
||||
const dropAllowed = !isRelationGroup || (!!relLane && relationLaneAcceptsDrop(relLane));
|
||||
if (!dropAllowed) {
|
||||
// AND NO REORDER EITHER (codex round 5). Skipping only the
|
||||
// relation write left `onReorder` running unconditionally, so a
|
||||
// refused cross-group drop still rewrote every `sort_order` in
|
||||
// the destination — a persisted side effect of a gesture the
|
||||
// component had just declined. Re-deriving from props puts the
|
||||
// card back, as the board's refusal does.
|
||||
groupData = propGroupData;
|
||||
return;
|
||||
}
|
||||
if (originalItem && onStatusChange) {
|
||||
const fields = parseFields(originalItem);
|
||||
if (fields[groupField] !== groupName) {
|
||||
// TRIMMED, like every other comparison against a stored relation
|
||||
// value: an item holding `" id-red "` is already in this group,
|
||||
// and a raw `!==` would fire a pointless write for it.
|
||||
const current = isRelationGroup
|
||||
? relationLaneValueFor(originalItem, groupField, resolveRelation)
|
||||
: (fields[groupField] ?? '');
|
||||
if (current !== groupName) {
|
||||
await onStatusChange(originalItem, groupName);
|
||||
}
|
||||
}
|
||||
@@ -306,7 +404,18 @@
|
||||
<span class="collapse-icon" class:collapsed={collapsedGroups.has(groupName)}
|
||||
>▾</span
|
||||
>
|
||||
<span class="group-title">{formatLabel(groupName)}</span>
|
||||
<span class="group-title">
|
||||
{#if relationLaneByValue.get(groupName)?.ref}<span class="group-ref"
|
||||
>{relationLaneByValue.get(groupName)?.ref}</span
|
||||
>{/if}{relationLaneByValue.get(groupName)
|
||||
? (relationLaneByValue.get(groupName)?.title ??
|
||||
relationLaneByValue.get(groupName)?.label)
|
||||
: formatLabel(groupName)}
|
||||
{#if relationLaneByValue.get(groupName)?.state === 'deleted'}<span
|
||||
class="group-note"
|
||||
title="This item has been deleted.">(deleted)</span
|
||||
>{/if}
|
||||
</span>
|
||||
<span class="group-actions">
|
||||
<span class="group-count">{itemCount(grpItems)}</span>
|
||||
<!-- Gate on onArchiveGroup alone (not canEdit): archive-all
|
||||
@@ -361,8 +470,8 @@
|
||||
{collection}
|
||||
compact={false}
|
||||
focused={focusedItemId === item.id}
|
||||
{statusOptions}
|
||||
onStatusClick={onStatusChange}
|
||||
statusOptions={isRelationGroup ? [] : statusOptions}
|
||||
onStatusClick={isRelationGroup ? undefined : onStatusChange}
|
||||
progress={itemProgress?.[item.id] ?? null}
|
||||
{progressLabel}
|
||||
onReorderItem={canReorderItems ? (it, dir) => reorderItem(groupName, it, dir) : undefined}
|
||||
@@ -506,6 +615,20 @@
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.group-ref {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
margin-right: 0.35em;
|
||||
}
|
||||
|
||||
.group-note {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.35em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -18,6 +18,7 @@ handlers — onchange is never called.
|
||||
import { onDestroy } from 'svelte';
|
||||
import { formatItemRef, type FieldDef, type ItemIndexRow, type PaneTarget } from '$lib/types';
|
||||
import { localIndex } from '$lib/stores/localIndex.svelte';
|
||||
import { narrowRelationRow } from '$lib/collections/relationGroups';
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
@@ -114,50 +115,23 @@ handlers — onchange is never called.
|
||||
if (!isRelation || !wsSlug) return null;
|
||||
const raw = typeof value === 'string' ? value.trim() : '';
|
||||
if (!raw) return null;
|
||||
const row = localIndex.findByIdOrSlug(wsSlug, raw);
|
||||
if (!row) return null;
|
||||
// TWO narrowings on what `findByIdOrSlug` will happily return, both
|
||||
// found by driving this in a real browser.
|
||||
// ONE IMPLEMENTATION OF THE TWO NARROWINGS (TASK-2998). They were worked
|
||||
// out here and duplicated into `relationGroups` when the board needed
|
||||
// them; TASK-2996 has merged without touching this file, so the fork is
|
||||
// closed rather than left as a note. The reasoning — why an id-only
|
||||
// match, why the collection check only fires when both slugs are known —
|
||||
// lives on `narrowRelationRow`.
|
||||
//
|
||||
// 1. ID ONLY. That helper resolves by id OR SLUG, so a legacy free-text
|
||||
// value like "red" — exactly what the old text fallback wrote — RESOLVES
|
||||
// to the item whose slug is "red" and renders as a working reference.
|
||||
// The field's contract is that it stores an item ID; displaying a slug
|
||||
// match makes the chip lie about what is stored, and slugs are mutable,
|
||||
// so the same value could point somewhere else tomorrow.
|
||||
// 2. TARGET COLLECTION. The helper is workspace-wide, so without this a
|
||||
// relation declared against `colors` could render an item from `tasks`
|
||||
// that happens to share the identifier. This is the same defect
|
||||
// PLAN-2857's recon recorded against the SERVER's `ResolveItem`
|
||||
// (workspace-wide, not collection-scoped); I wrote that finding down and
|
||||
// then reproduced it here.
|
||||
if (row.id !== raw) return null;
|
||||
// The collection mismatch is only EVIDENCE that the value is wrong when
|
||||
// the collection list and the item index agree about the world — that is,
|
||||
// when both the declared target and the row's own collection are slugs
|
||||
// the current list knows.
|
||||
//
|
||||
// Two ways they disagree, and neither is the value's fault:
|
||||
// * the target was renamed, so it names nothing (`relationTarget`
|
||||
// 'stale');
|
||||
// * the rename has been applied to the INDEX but not yet to the
|
||||
// collection list — `retagCollection` moves the rows immediately and
|
||||
// `loadCollections` is fired without being awaited (and with its
|
||||
// rejection swallowed by `void`), so the row's new slug is unknown to
|
||||
// a list that still lists the old one. That window is brief when the
|
||||
// refetch succeeds and PERMANENT when it fails (codex round 2 P1).
|
||||
//
|
||||
// Requiring both to be known collapses both cases to "don't judge", while
|
||||
// a genuine cross-collection value — target `colors`, row `tasks`, both
|
||||
// live — still resolves to null.
|
||||
if (
|
||||
relationTarget === 'live' &&
|
||||
knownCollectionSlugs?.has(row.collection_slug ?? '') &&
|
||||
row.collection_slug !== field.collection
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
// `relationTarget === 'live'` and "the collection list knows the
|
||||
// declared slug" are the same condition: `relationTarget` is derived
|
||||
// from that same list, so the helper's own guard covers the stale and
|
||||
// unknown cases this branch used to name.
|
||||
return narrowRelationRow(
|
||||
localIndex.findByIdOrSlug(wsSlug, raw),
|
||||
raw,
|
||||
field.collection,
|
||||
knownCollectionSlugs,
|
||||
);
|
||||
});
|
||||
let relationState = $derived.by((): 'empty' | 'live' | 'deleted' | 'unresolved' => {
|
||||
if (!isRelation) return 'empty';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { render, cleanup } from '@testing-library/svelte';
|
||||
import PublicListView from './PublicListView.svelte';
|
||||
import type { PublicCollection, PublicItem } from './shareView';
|
||||
|
||||
/**
|
||||
* TASK-2998, codex round 2 — the LIST door onto public grouping.
|
||||
*
|
||||
* `resolveGroupField` guards the public BOARD. A saved view with
|
||||
* `view_type: "list"` routes its `group_by` to `list_group_by`, which this
|
||||
* component resolves itself — so the board-only refusal left a shared list
|
||||
* rendering one group per stored relation id.
|
||||
*
|
||||
* Rendered rather than source-guarded, because the predicate already has unit
|
||||
* tests and what was unobserved is whether THIS component asks it (CONVE-19):
|
||||
* the mutant that reverted this call site left every predicate test green.
|
||||
*/
|
||||
|
||||
const RELATION_ID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479';
|
||||
|
||||
function collection(groupBy: string, fieldType: string): PublicCollection {
|
||||
return {
|
||||
name: 'Cars',
|
||||
slug: 'cars',
|
||||
fields: [
|
||||
{ key: 'car_color', label: 'Colour', type: fieldType },
|
||||
{ key: 'title', label: 'Title', type: 'text' },
|
||||
],
|
||||
settings: { list_group_by: groupBy },
|
||||
} as unknown as PublicCollection;
|
||||
}
|
||||
|
||||
function item(key: string, value: string): PublicItem {
|
||||
return {
|
||||
key,
|
||||
title: key,
|
||||
fields: { car_color: value },
|
||||
tags: [],
|
||||
} as unknown as PublicItem;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('PublicListView grouped by a relation field', () => {
|
||||
it('does not render a group per stored id', () => {
|
||||
const screen = render(PublicListView, {
|
||||
props: {
|
||||
collection: collection('car_color', 'relation'),
|
||||
items: [item('a', RELATION_ID)],
|
||||
} as never,
|
||||
});
|
||||
|
||||
// PRECONDITION: the item rendered at all, so "no group" is not a claim
|
||||
// about an empty list.
|
||||
expect(screen.container.textContent).toContain('a');
|
||||
|
||||
// ASSERTED AS THE ABSENCE OF A GROUP HEADING, not as the absence of the
|
||||
// id string. `formatLabel` replaces `-` with spaces and title-cases, so
|
||||
// a uuid renders as "F47ac10b 58cc …" and `not.toContain(RELATION_ID)`
|
||||
// passed against the broken component. Measured: that is exactly how
|
||||
// the mutant survived the first version of this test.
|
||||
expect(screen.container.querySelectorAll('.group-heading')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('STILL groups by an ordinary field — the counterfactual', () => {
|
||||
// A refusal that fired for every field would silently ungroup every
|
||||
// shared list on the instance, which is a worse regression than the one
|
||||
// being fixed.
|
||||
const screen = render(PublicListView, {
|
||||
props: {
|
||||
collection: collection('car_color', 'select'),
|
||||
items: [item('a', 'red')],
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(screen.container.querySelectorAll('.group-heading')).toHaveLength(1);
|
||||
expect(screen.container.querySelector('.group-heading')?.textContent).toContain('Red');
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,8 @@
|
||||
findField,
|
||||
groupItems,
|
||||
formatLabel,
|
||||
fieldValueColor
|
||||
fieldValueColor,
|
||||
isPublicGroupable
|
||||
} from './shareView';
|
||||
import PublicItemExpansion from './PublicItemExpansion.svelte';
|
||||
|
||||
@@ -40,7 +41,11 @@
|
||||
|
||||
let groupField = $derived.by(() => {
|
||||
const key = collection.settings.list_group_by;
|
||||
return key && findField(collection.fields, key) ? key : '';
|
||||
// Same refusal the board makes (TASK-2998, codex round 2). A share has
|
||||
// no local index, so grouping by a relation renders one group per
|
||||
// stored id. A saved LIST view routes its `group_by` here, which is the
|
||||
// door the board-only fix left open.
|
||||
return key && isPublicGroupable(collection, key) ? key : '';
|
||||
});
|
||||
|
||||
let statusFieldDef = $derived<FieldDef | undefined>(findField(collection.fields, 'status'));
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { UNPARENTED_FILTER_FIELD, filterEvaluable, matchesFilter, parsePublicItem } from './shareView';
|
||||
import {
|
||||
UNPARENTED_FILTER_FIELD,
|
||||
filterEvaluable,
|
||||
matchesFilter,
|
||||
parsePublicItem,
|
||||
resolveGroupField,
|
||||
isPublicGroupable,
|
||||
} from './shareView';
|
||||
|
||||
function item(fields: Record<string, unknown>) {
|
||||
return parsePublicItem({ title: 'x', ref: 'TASK-1', fields });
|
||||
@@ -83,3 +90,68 @@ describe('matchesFilter', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGroupField with a relation field (TASK-2998, codex round 1)', () => {
|
||||
function coll(fields: { key: string; type: string; options?: string[] }[], boardGroupBy?: string) {
|
||||
return {
|
||||
fields,
|
||||
settings: boardGroupBy ? { board_group_by: boardGroupBy } : {},
|
||||
} as unknown as Parameters<typeof resolveGroupField>[0];
|
||||
}
|
||||
|
||||
it('does NOT group a public board by a relation field', () => {
|
||||
// The authenticated board resolves the value against the local index and
|
||||
// labels the lane with the target's ref and title. A share has neither —
|
||||
// the payload carries field VALUES — so grouping by a relation renders a
|
||||
// lane per stored id, formatted: a wall of title-cased UUIDs, which is
|
||||
// the exact thing this unit exists to stop showing.
|
||||
const c = coll(
|
||||
[
|
||||
{ key: 'car_color', type: 'relation' },
|
||||
{ key: 'status', type: 'select', options: ['open'] },
|
||||
],
|
||||
'car_color',
|
||||
);
|
||||
expect(resolveGroupField(c)).toBe('status');
|
||||
});
|
||||
|
||||
it('falls all the way through to ungrouped when there is nothing else', () => {
|
||||
const c = coll([{ key: 'car_color', type: 'relation' }], 'car_color');
|
||||
expect(resolveGroupField(c)).toBe('');
|
||||
});
|
||||
|
||||
it('still honours an ordinary group field — the counterfactual', () => {
|
||||
// A refusal that fired for every field would silently ungroup every
|
||||
// shared board on the instance.
|
||||
const c = coll(
|
||||
[
|
||||
{ key: 'phase', type: 'select', options: ['a'] },
|
||||
{ key: 'status', type: 'select', options: ['open'] },
|
||||
],
|
||||
'phase',
|
||||
);
|
||||
expect(resolveGroupField(c)).toBe('phase');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPublicGroupable (TASK-2998, codex round 2)', () => {
|
||||
function coll(fields: { key: string; type: string }[]) {
|
||||
return { fields, settings: {} } as unknown as Parameters<typeof isPublicGroupable>[0];
|
||||
}
|
||||
|
||||
it('refuses a relation field, which is the LIST view\'s door', () => {
|
||||
// `resolveGroupField` only guards the board. A saved view with
|
||||
// `view_type: "list"` routes its `group_by` to `list_group_by`, which
|
||||
// PublicListView resolves itself — so refusing in one place left a
|
||||
// shared LIST rendering one group per stored id, the same wall of raw
|
||||
// ids the board refusal had just closed.
|
||||
const c = coll([{ key: 'car_color', type: 'relation' }]);
|
||||
expect(isPublicGroupable(c, 'car_color')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts an ordinary field, and refuses one that does not exist', () => {
|
||||
const c = coll([{ key: 'phase', type: 'select' }]);
|
||||
expect(isPublicGroupable(c, 'phase')).toBe(true);
|
||||
expect(isPublicGroupable(c, 'nope')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,12 +273,38 @@ export function visibleFields(fields: FieldDef[]): FieldDef[] {
|
||||
* else `status` if the schema has one, else the first select field, else ''. */
|
||||
export function resolveGroupField(collection: PublicCollection): string {
|
||||
const explicit = collection.settings.board_group_by;
|
||||
if (explicit && findField(collection.fields, explicit)) return explicit;
|
||||
// A RELATION FIELD IS NOT GROUPABLE HERE (TASK-2998, codex round 1).
|
||||
//
|
||||
// The authenticated board resolves a relation value against the local index
|
||||
// and labels the lane with the target's ref and title. A public share has
|
||||
// neither: the payload carries field VALUES, so grouping by a relation
|
||||
// would render a lane per stored id, formatted — a wall of title-cased
|
||||
// UUIDs, which is the exact thing this unit exists to stop showing.
|
||||
//
|
||||
// Falling through to the owner's next grouping is better than one honest-
|
||||
// but-useless lane per id, and better than showing ids. The real fix is for
|
||||
// the share payload to carry the target's ref and title, which is a server
|
||||
// change and a separate unit.
|
||||
if (explicit && isPublicGroupable(collection, explicit)) return explicit;
|
||||
if (findField(collection.fields, 'status')) return 'status';
|
||||
const firstSelect = collection.fields.find((f) => f.type === 'select');
|
||||
return firstSelect?.key ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A field a PUBLIC view can group by: it exists, and it is not a relation.
|
||||
*
|
||||
* Exported because the BOARD is not the only door (codex round 2): a saved view
|
||||
* with `view_type: "list"` routes its `group_by` to `list_group_by`, and
|
||||
* `PublicListView` resolves that key itself. Refusing in one place and not the
|
||||
* other left a shared LIST rendering one group per stored id — the same wall of
|
||||
* raw ids the board refusal had just closed.
|
||||
*/
|
||||
export function isPublicGroupable(collection: PublicCollection, key: string): boolean {
|
||||
const field = findField(collection.fields, key);
|
||||
return !!field && field.type !== 'relation';
|
||||
}
|
||||
|
||||
// ── Presentation helpers (mirror the in-app vocabularies) ───────────────────
|
||||
|
||||
/** Title-case a snake/kebab field key or value for display. */
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
unparentedEffective,
|
||||
viewHasUnparentedFilter,
|
||||
} from '$lib/collections/unparentedFilter';
|
||||
import { relationFilterMatches } from '$lib/collections/relationGroups';
|
||||
import { KNOWN_COLLECTION_URL_PARAMS, buildCollectionUrlParams } from '$lib/collections/paneUrlParams';
|
||||
import { type ResolvedPaneState } from '$lib/collections/paneController';
|
||||
import { createPaneController } from '$lib/collections/paneHostController';
|
||||
@@ -1315,6 +1316,11 @@
|
||||
|
||||
let statusOptions = $derived(collection ? getStatusOptions(collection) : []);
|
||||
|
||||
/** Schema keys whose filter value names an item rather than an option. */
|
||||
let relationFilterKeys = $derived(
|
||||
new Set((schema?.fields ?? []).filter((f) => f.type === 'relation').map((f) => f.key)),
|
||||
);
|
||||
|
||||
let filteredItems = $derived.by(() => {
|
||||
let result = items;
|
||||
|
||||
@@ -1327,6 +1333,14 @@
|
||||
return item.parent_link_id === value;
|
||||
}
|
||||
const fields = parseFields(item);
|
||||
// A RELATION value is compared trimmed (TASK-2998, codex round
|
||||
// 2). The board already trimmed, so without this an item
|
||||
// storing `" id-red "` sat in the `Red` lane and vanished when
|
||||
// you filtered for `Red` — one value, two answers, one screen
|
||||
// apart. Every other field type keeps strict equality.
|
||||
if (relationFilterKeys.has(key)) {
|
||||
return relationFilterMatches(fields[key], value);
|
||||
}
|
||||
return fields[key] === value;
|
||||
});
|
||||
}
|
||||
@@ -1776,6 +1790,17 @@
|
||||
const s = parseSchema(base);
|
||||
const idx = s.fields.findIndex((f) => f.key === groupField);
|
||||
if (idx === -1) return;
|
||||
// NEVER WRITE LANE ORDER BACK FOR A RELATION FIELD (TASK-2998).
|
||||
//
|
||||
// `newOrder` is the board's lane values, and for a relation those are
|
||||
// ITEM IDS — writing them here would persist uuids into the schema as
|
||||
// `options`, which is both meaningless and hard to undo by hand.
|
||||
//
|
||||
// BoardView already withholds column dragging on a relation board, so
|
||||
// nothing should reach this; the guard is here because this is the
|
||||
// DESTRUCTIVE end, and a guard at the affordance protects only the
|
||||
// affordances somebody remembered.
|
||||
if (s.fields[idx].type === 'relation') return;
|
||||
s.fields[idx].options = newOrder;
|
||||
const schemaStr = JSON.stringify(s);
|
||||
|
||||
@@ -3399,6 +3424,7 @@
|
||||
unparentedAvailable={unparentedMetadataAvailable}
|
||||
unparentedActive={unparentedApplied}
|
||||
onUnparentedChange={handleUnparentedChange}
|
||||
{wsSlug}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Node-project test (no DOM): a SOURCE guard that lane order is never written
|
||||
// back to the schema for a RELATION field (TASK-2998).
|
||||
//
|
||||
// `handleGroupReorder` persists the board's lane order as the group field's
|
||||
// `options`. For a relation the lanes are ITEM IDS, so that write would put
|
||||
// uuids into the schema as select options — meaningless, and hard to undo by
|
||||
// hand.
|
||||
//
|
||||
// BoardView already withholds column dragging on a relation board, so nothing
|
||||
// should reach this. The guard is here anyway because this is the DESTRUCTIVE
|
||||
// end: a guard at the affordance protects only the affordances somebody
|
||||
// remembered, and this file is ~3,600 lines with several of them.
|
||||
//
|
||||
// WHY A SOURCE GUARD: the collection page cannot be rendered in a unit test
|
||||
// without standing up the whole workspace shell. WHAT IT CANNOT DO: it checks
|
||||
// spellings, not behaviour — a guard comparing the wrong field, or one made
|
||||
// unreachable by an earlier return, would still pass.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const SRC = readFileSync(resolve(__dirname, './+page.svelte'), 'utf8').replace(
|
||||
/^[ \t]*\/\/.*$/gm,
|
||||
'',
|
||||
);
|
||||
|
||||
describe('handleGroupReorder', () => {
|
||||
it('refuses to persist lane order for a relation field', () => {
|
||||
const start = SRC.indexOf('async function handleGroupReorder(');
|
||||
expect(start, 'handleGroupReorder was renamed or removed').toBeGreaterThan(-1);
|
||||
const body = SRC.slice(start, SRC.indexOf('\n\t}', start));
|
||||
|
||||
const guard = body.indexOf("type === 'relation'");
|
||||
const write = body.indexOf('.options = newOrder');
|
||||
expect(guard, 'the relation guard is gone').toBeGreaterThan(-1);
|
||||
expect(write, 're-point this guard: the schema write moved').toBeGreaterThan(-1);
|
||||
expect(guard, 'the write happens before the guard').toBeLessThan(write);
|
||||
});
|
||||
|
||||
it('still writes lane order for an ordinary field', () => {
|
||||
// The counterfactual: a guard that refused everything would silently
|
||||
// stop persisting column order on every board on the instance, which is
|
||||
// a worse regression than the one it prevents.
|
||||
const start = SRC.indexOf('async function handleGroupReorder(');
|
||||
const body = SRC.slice(start, SRC.indexOf('\n\t}', start));
|
||||
expect(body).toContain('.options = newOrder');
|
||||
// and the refusal is conditional rather than unconditional
|
||||
expect(body).not.toMatch(/\n\t\treturn;\s*\n\t\ts\.fields\[idx\]\.options/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user