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
23 changes: 23 additions & 0 deletions .changeset/8496-emptiness-floor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@object-ui/core': minor
'@object-ui/fields': patch
'@object-ui/plugin-detail': patch
'@object-ui/plugin-list': patch
'@object-ui/plugin-kanban': patch
---

Add `isEmptyValue` to `@object-ui/core` — the weakest common claim about "is
this value empty": `null`, `undefined`, the empty string, the empty array, and
never a fifth member (objectui#8496, director seat, decision batch #86).

Five surfaces had each grown their own copy of those four members, and
objectui#8481 was the third rediscovery of the same hole. They now call the
shared floor and state their own answer against it: `record:details`'
`hasCellValue` and `RelatedList` extend it with a trim, `BooleanCellRenderer`
with every non-boolean, the date cells with every falsy scalar; `JsonCellRenderer`
declines its `[]` member out loud (the array literal is drawn on purpose) and
`FileCellRenderer` states "0 files" instead.

Two visible fixes come with it: a gallery card and a kanban card holding an
empty array in a card field now OMIT that field, as they already did for `null`,
instead of drawing a labelled "No value" em-dash for it.
11 changes: 7 additions & 4 deletions packages/core/src/evaluator/optionRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
*/
import type { DependsOnInput } from '@object-ui/types';
import { evalFieldPredicate, type FieldRulePredicate } from './fieldRules.js';
import { isEmptyValue } from '../utils/emptiness.js';

/**
* Minimal shape of a select/radio option this module reads. Deliberately has no
Expand Down Expand Up @@ -68,10 +69,12 @@ export function resolveDependsOnFields(dependsOn: DependsOnInput): string[] {
.filter((f): f is string => typeof f === 'string' && f.length > 0);
}

/** A value counts as "empty" (dependency unmet) when nullish, blank, or an empty array. */
function isEmptyValue(v: unknown): boolean {
return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
}
// A dependency counts as UNMET on exactly the shared floor — `null`,
// `undefined`, the empty string, the empty array — and this module is where
// those four members were first written down. objectui#8496 promoted them out
// of here into `utils/emptiness.ts` (byte-for-byte the same four) so the four
// other surfaces that had each re-spelled them could stop. No extension and no
// declension: a gated option list asks the floor and nothing more.

/**
* True when at least one `dependsOn` field is empty in the record — the option
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ export * from './utils/dom-props.js';
export * from './utils/filter-converter.js';
export * from './utils/managedBy.js';
export * from './utils/extract-records.js';
// The emptiness FLOOR (objectui#8496, director seat, decision batch #86): the
// weakest common claim about "is this value empty" — `null`, `undefined`, the
// empty string, the empty array — below `plugin-detail`, `plugin-list`,
// `plugin-kanban` and `@object-ui/fields`, each of which used to spell those
// four members privately. Surfaces EXTEND it or DECLINE a member out loud; ⛔
// the floor itself never grows past the four.
export * from './utils/emptiness.js';
export * from './utils/expand-fields.js';
// The RETIREMENT gate (objectui#4914, maintainer ruling B). Homed here rather
// than in `@object-ui/fields` because `@object-ui/components` is one of its six
Expand Down
128 changes: 128 additions & 0 deletions packages/core/src/utils/__tests__/emptiness-floor-8496.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* 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.
*/

/**
* The FLOOR itself (objectui#8496 — director seat, decision batch #86).
*
* This file pins the cheap half: the four members, and the ⛔ that keeps them
* four. The EXPENSIVE half — that no surface's deliberate disagreement was
* flattened into the floor — is pinned next to each surface:
* `emptinessFloorExtensions-8496.test.tsx` in `@object-ui/fields` and in
* `@object-ui/plugin-detail`, `galleryEmptinessFloor-8496.test.tsx` in
* `@object-ui/plugin-list`, `kanbanEmptinessFloor-8496.test.tsx` in
* `@object-ui/plugin-kanban`.
*
* ⚠️ A suite that only proves the floor works proves the half that was never
* in doubt. Read the four files above as one pin.
*/

import { describe, it, expect } from 'vitest';
import { isEmptyValue } from '../emptiness.js';
import { isOptionGroupGated, isValueStillOffered } from '../../evaluator/optionRules.js';

/** The four members, and nothing else is one. */
const MEMBERS: Array<[string, unknown]> = [
['null', null],
['undefined', undefined],
['the empty string', ''],
['the empty array', []],
];

/**
* Every candidate FIFTH member, each with the measurement that refused it.
* These are values, and the floor calling any of them empty is the failure
* this table exists to catch.
*/
const REFUSED_FIFTH_MEMBERS: Array<[string, unknown, string]> = [
['a whitespace-only string', ' ',
'EMPTY only on record:details and RelatedList (objectui#8350) — an extension, not a member'],
['an empty object literal', {},
'measured a VALUE and pinned (objectui#8474): a type-aware renderer draws it'],
['a populated object', { a: 1 }, 'a populated object is drawn by a type-aware renderer'],
['a one-entry array', [1], 'one entry is one thing to draw'],
['an array of one undefined', [undefined], 'length 1: the container has an entry'],
['zero', 0, 'a stored zero is a value on every surface'],
['false', false, 'BooleanCellRenderer keeps false a value (objectui#8582)'],
['the numeric epoch', 0, "DateCellRenderer's `!value` calls it empty — that is its extension"],
['the Date epoch', new Date(0),
'Object.keys(new Date(0)).length === 0, which is why that shape is not the test'],
['a populated Map', new Map([['a', 1]]),
'Object.keys() is empty on it — a false-empty the floor must not have'],
['a populated Set', new Set([1]), 'same false-empty shape as Map'],
['a class instance behind getters', new (class { get a() { return 1; } })(),
'same false-empty shape: state that Object.keys() cannot see'],
['the string "0"', '0', 'a non-empty string is a value however falsy it coerces'],
['NaN', NaN, 'falsy, but not one of the four members'],
];

describe('objectui#8496 — the emptiness floor in @object-ui/core', () => {
describe('THE FLOOR — exactly four members', () => {
for (const [label, value] of MEMBERS) {
it(`${label} is EMPTY`, () => {
expect(isEmptyValue(value), `${label} must be a floor member`).toBe(true);
});
}
});

describe('⛔ THE FLOOR NEVER GROWS — every candidate fifth member is a VALUE', () => {
for (const [label, value, why] of REFUSED_FIFTH_MEMBERS) {
it(`${label} is a VALUE — ${why}`, () => {
expect(
isEmptyValue(value),
`${label}: the floor grew a fifth member. ${why}`,
).toBe(false);
});
}
});

describe('THE MEMBER COUNT — stated as a number, so a widening cannot pass unnoticed', () => {
it('exactly 4 of the probed shapes are empty', () => {
const probes: unknown[] = [
...MEMBERS.map(([, v]) => v),
...REFUSED_FIFTH_MEMBERS.map(([, v]) => v),
];
expect(
probes.filter((v) => isEmptyValue(v)).length,
'the floor answered EMPTY for something outside its four members',
).toBe(MEMBERS.length);
});
});

/**
* The floor was not invented: it was PROMOTED out of this package's own
* private copy in `evaluator/optionRules.ts`, which had spelled the same four
* members since before the card. These two exports are that copy's only
* readers, so their answers are the promotion's non-regression evidence.
*/
describe('THE PROMOTION — core’s own former private copy still answers the same', () => {
for (const [label, value] of MEMBERS) {
it(`a dependency holding ${label} gates the option list`, () => {
expect(
isOptionGroupGated('parent', { parent: value }),
`${label}: an unmet dependency must still gate`,
).toBe(true);
});
}

it('a dependency holding a value does NOT gate', () => {
expect(isOptionGroupGated('parent', { parent: 'cn' })).toBe(false);
expect(isOptionGroupGated('parent', { parent: 0 })).toBe(false);
expect(isOptionGroupGated('parent', { parent: false })).toBe(false);
});

it('an empty value is always still offered (nothing to clear)', () => {
for (const [label, value] of MEMBERS) {
expect(
isValueStillOffered(value, [{ label: 'A', value: 'a' }]),
`${label}: an empty value has no stale choice to clear`,
).toBe(true);
}
expect(isValueStillOffered('gone', [{ label: 'A', value: 'a' }])).toBe(false);
});
});
});
104 changes: 104 additions & 0 deletions packages/core/src/utils/emptiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* ObjectUI — the shared emptiness floor
* 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.
*/

/**
* THE floor under "is this value empty" (objectui#8496 — director seat,
* decision batch #86, 2026-09-08, option B).
*
* Exactly four members, and it never grows past them:
*
* `null` · `undefined` · the empty string · the empty array
*
* ## What a floor IS, and what it is not
*
* It is the WEAKEST claim the surfaces below it can all make — not the answer
* any one of them gives. A caller does one of two things with it, and both are
* legitimate:
*
* - **extends** it — `isEmptyValue(v) || <its own clause>` — when its surface
* calls MORE things empty (a grid trims whitespace; a boolean column calls
* every non-boolean empty);
* - **declines a member** in the open — `isEmptyValue(v) && !Array.isArray(v)`
* — when its surface has MEASURED that member to be a value there (a `json`
* cell draws the two-character literal `[]` on purpose, objectui#8474).
*
* What is NOT legitimate is a sixth private re-spelling of these four members.
* That is the defect this function exists to close: `plugin-detail`'s
* `hasCellValue`, `RelatedList`'s `isValueEmpty`, `ObjectGallery`'s and
* `ObjectKanban`'s inline guards and the guard idioms across
* `@object-ui/fields`' cell renderers each grew their own copy, and
* objectui#8481 was the THIRD rediscovery of the same hole — objectui#8474 and
* objectui#8459 had each closed it at their own door first. A copy that agrees
* today stops agreeing; one definition cannot.
*
* ## ⛔ The floor never grows past those four members
*
* The ruling fixed the member list, and every candidate fifth member is a
* measured disagreement rather than an oversight:
*
* - **whitespace-only strings.** `' '` is EMPTY on `record:details` and in
* `RelatedList` (objectui#8350 measured the damage a blank cell does there)
* and a VALUE on the gallery, the kanban and the shared renderers. Both are
* right for their surface, so the trim is an EXTENSION, not a member.
* - **`{}`.** Measured as a VALUE and pinned (objectui#8474): a populated or
* empty object literal is handed to a type-aware renderer that draws it, and
* the shape that would sweep it in — `Object.keys(v).length === 0` — is also
* true of `new Date(0)`, of a populated `Map`, of a populated `Set` and of
* any class instance whose state sits behind getters.
* - **`0` / `false`.** Values everywhere. `BooleanCellRenderer` keeping
* `false` a value is the pinned case (objectui#8582).
*
* ## Why `@object-ui/core` and not `@object-ui/types`
*
* It is a runtime predicate, not a protocol type, so it belongs in the engine.
* The ruling made that conditional on a measurement — `@object-ui/fields` is
* the lowest consumer, and if it did not already depend on `core` the floor
* would have had to fall back to `types`. Measured on the implementing branch:
* `@object-ui/fields`' `package.json` lists `@object-ui/core` in
* `dependencies`, and its barrel already imports from it. No new dependency
* edge is created by this file, in either direction — `core` reaches no
* consumer, which is why exporting the helper from `@object-ui/fields` instead
* (option C) was refused: the gallery and the kanban would then import a
* `fields` helper to decide whether to call a `fields` renderer.
*
* ## "Empty" is two questions; this floor answers the half both share
*
* objectui#8496's later evidence (comment 5603203484) measured that the word
* has split in two on this codebase: SCALAR-MISSING (`EmptyValue`, the em-dash
* affordance whose accessible name is fixed) and COLLECTION-EMPTY
* (`EmptyDescription`, an author's own sentence). The floor serves both and
* does not have to choose: its four members ARE two scalar-missing members,
* one blank scalar and one empty collection, and no call site asks a boolean to
* tell those apart — each one knows statically which affordance it is drawing.
* ⛔ So this function is deliberately NOT the place to grow a second axis. Which
* COMPONENT states the emptiness is a different question, carried by
* objectui#8570 / objectui#8526 / objectui#8507.
*
* ## Readers
*
* `isOptionGroupGated` / `isValueStillOffered` here in `core` (this function's
* origin: it was written privately in `evaluator/optionRules.ts`, byte-for-byte
* these four members, before the ruling promoted it); `hasCellValue` and
* `RelatedList.isValueEmpty` in `@object-ui/plugin-detail`; `ObjectGallery`'s
* card-field row filter; `ObjectKanban`'s card-field loop; and the cell-renderer
* guards in `@object-ui/fields`.
*
* The extensions and the declensions are pinned — the assertion that each
* surface still answers DIFFERENTLY from the floor, not merely that the floor
* works — in `__tests__/emptiness-floor-8496.test.ts` here and in
* `emptinessFloorExtensions-8496.test.tsx` in `@object-ui/fields` and
* `@object-ui/plugin-detail`.
*/
export function isEmptyValue(value: unknown): boolean {
return (
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0)
);
}
Loading
Loading