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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/9653-kanban-stray-groupby-refusal-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@object-ui/types": patch
---

**The stray-`groupBy` kanban refusal no longer tells an author their view "never came through the validated path".**

The refusal an author meets for a view-level `kanban.groupBy` (objectui#8365) said
that a view carrying the key "never came through the validated path", and, in the
same message, that the key "rode this object's `.passthrough()`" into `ListView`
and changed the board. Only the second was true. Measured on the tree before the
refusal landed: `safeValidateSchema` accepted the key green in both nestings and
kept it, and `ListView` grouped the board by it. The protocol is what refuses the
key (`@objectstack/spec`'s strict `KanbanConfigSchema`); this package did not.

The false clause is gone. The message's explanatory tail now says what happened:
this package accepted the key until the refusal, kept by the kanban
configuration's `.passthrough()` or by the untyped legacy `options` bag, and
`ListView` let it override the lane resolved from `groupByField`. It also says
why the key is refused now: `ListView` drops it, so it is refused instead of
being kept and then ignored.

What is refused is unchanged: the same key at the same two paths
(`kanban.groupBy` as `invalid_type`, `options.kanban.groupBy` as `custom`), with
the same lead sentence and the same remedy. Only the explanatory tail of the
message changed.

Also corrected, and shipped only as a comment in the built JavaScript: the
docblock of the named-view calendar check on `ObjectViewSchema` said every alias
refusal in the module "stops at `listViews`", while that check itself refuses
`listViews.KEY.calendar.dateField`. It now says no alias refusal reached inside
`listViews` until that check, and that the check judges the two calendar
spellings only.
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#9653 — `KanbanStrayGroupByRefusal`'s message must not deny the
* validation that emits it.
*
* The objectui#8365 message said a view carrying `groupBy` "never came through
* the validated path", and a few clauses later that the key rode this mirror's
* `.passthrough()` into `ListView` and changed the board. Only the second was
* true: at the parent of the commit that added the refusal, `safeValidateSchema`
* ACCEPTED the key in both nestings and KEPT it, and `ListView` grouped the board
* by it. The protocol is what refuses it (`@objectstack/spec`'s strict
* `KanbanConfigSchema`); this package did not, until the refusal.
*
* The message is read by an AUTHOR, from this package's own validator — so a
* clause telling them their document was never validated is false at the very
* place they read it. The same false clause was caught once before, in
* objectui#8355's calendar stem, before it landed (objectui#9648).
*
* ## What is asserted, and why not the wording
*
* The refusal itself (`code` and `path` on both channels) is pinned by the
* objectui#8365 tests; this file re-reads it only to find the message. It does
* NOT pin the message's sentences. It asserts two things about CONTENT:
*
* - NEGATIVE — no clause denies that the document passed a validator.
* - LIT CONTROL, on the true clause — the message still names the mechanism
* that kept the key (`.passthrough()`). Without it, the negative is satisfied
* by a message that says nothing about the history at all.
*
* A third arm proves the negative's matcher fires on the retired clause, so a
* later loosening of the pattern cannot turn the negative into a no-op.
*
* REVERSE VERIFICATION, predicted before running: restore the retired clause
* in the message ⇒ the NEGATIVE arm goes red on both channels, while the lit
* control and the matcher control stay green (the retired text described the
* passthrough too — that is the contradiction). Drop the history clause
* instead ⇒ the lit control goes red and the negative stays green.
*/

import { describe, it, expect } from 'vitest';
import { ListViewSchema } from '../zod/objectql.zod';

/** A clause that tells the author their document never passed a validator. */
const DENIES_VALIDATION =
/never\s+(?:came|went|passed)\s+through\s+(?:the\s+|a\s+)?(?:validated\s+path|validator|validation)/i;

/** The refusal message at one channel, read through the published door. */
function refusalMessage(doc: Record<string, unknown>, path: string, code: string): string {
const result = ListViewSchema.safeParse({ type: 'list-view', objectName: 'deal', ...doc });
expect(result.success, 'the fixture must be refused, or there is no message to read').toBe(false);
const issue = result.success ? undefined : result.error.issues.find((i) => i.path.join('.') === path);
expect(issue?.code).toBe(code);
return issue?.message ?? '';
}

const CHANNELS = [
{ name: 'declared `kanban` slot', doc: { kanban: { groupByField: 'stage', groupBy: 'stage' } }, path: 'kanban.groupBy', code: 'invalid_type' },
{ name: 'legacy `options.kanban` bag', doc: { options: { kanban: { groupBy: 'stage' } } }, path: 'options.kanban.groupBy', code: 'custom' },
] as const;

describe('objectui#9653 · the stray-`groupBy` refusal does not deny the validation that emits it', () => {
for (const channel of CHANNELS) {
it(`NEGATIVE (${channel.name}): no clause says the document never passed a validator`, () => {
const message = refusalMessage(channel.doc, channel.path, channel.code);
expect(message).not.toMatch(DENIES_VALIDATION);
});

it(`LIT CONTROL (${channel.name}): the message still describes the passthrough that kept the key`, () => {
const message = refusalMessage(channel.doc, channel.path, channel.code);
expect(message).toContain('`.passthrough()`');
});
}

it('MATCHER CONTROL: the negative fires on the retired clause', () => {
expect('so a view carrying it never came through the validated path.').toMatch(DENIES_VALIDATION);
});
});
28 changes: 17 additions & 11 deletions packages/types/src/zod/objectql.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,11 +841,13 @@ const KanbanStrayGroupByRefusal = aliasKeyRefusal(
'`groupBy` is the lane key of the generated `object-kanban` NODE, not of the view-level '
+ 'kanban configuration (objectui#8365). `@objectstack/spec`\'s `KanbanConfigSchema` is a '
+ 'strict object of `columns` / `groupByField` / `summarizeField` and refuses `groupBy` by '
+ 'name, so a view carrying it never came through the validated path. Write `groupByField` '
+ '(or the deprecated `groupField`, which folds onto it). Until this refusal the key rode '
+ 'this object\'s `.passthrough()` into `ListView`\'s kanban branch and OVERRODE the lane '
+ 'that branch had already resolved from `groupByField` — the board grouped by the stray '
+ 'key, and nothing said so.',
+ 'name. Write `groupByField` (or the deprecated `groupField`, which folds onto it). This '
+ 'package, by contrast, accepted the key green until this refusal: it passed '
+ '`safeValidateSchema` in either nesting, kept by this object\'s `.passthrough()` or by the '
+ 'untyped legacy `options` bag, and reached `ListView`\'s kanban branch, where it OVERRODE the '
+ 'lane that branch had already resolved from `groupByField` — the board grouped by the stray '
+ 'key, and nothing said so. That branch now drops the key, so it is refused here instead of '
+ 'being kept and then ignored.',
);

/**
Expand Down Expand Up @@ -1104,12 +1106,16 @@ const CalendarNodeDateAliasRefusals = {
* check is for. The gap here is NOT specific to the calendar and was NOT opened
* by this card: measured on the same instrument, a named view carrying the
* objectui#8365 stray `kanban.groupBy` is ACCEPTED, while the identical key on a
* `list-view` document is refused. Every alias refusal this module declares
* stops at `listViews`. What this card regressed on the object-view route is the
* BEHAVIOUR — a document that drew at the merge-base goes mute once the ladder
* is gone — and this check is what makes that failure loud. ⛔ It is not a
* general repair of the unmirrored key, and the kanban twin is still silent
* here; that belongs to objectui#8365's own text, not to this card.
* `list-view` document is refused. Until this check, no alias refusal this
* module declares reached inside `listViews`. This check is the only door in,
* and it judges the two calendar spellings only, at both nestings (so
* `listViews.KEY.calendar.dateField` IS refused); a search for `listViews` in
* this file re-derives that. What this card regressed on the object-view route
* is the BEHAVIOUR — a document that drew at the merge-base goes mute once the
* ladder is gone — and this check is what makes that failure loud. ⛔ It is not
* a general repair of the unmirrored key, and the kanban twin is still silent
* here (no pin re-derives that silence); that belongs to objectui#8365's own
* text, not to this card.
*
* ⛔ Scoped to the TWO keys under `calendar`: `timeline.dateField` on a named
* view stays accepted (the timeline alias is live by ruling), and nothing else
Expand Down
Loading