` SelectCellRenderer produces for `[]`',
+ ).not.toBeNull();
+ expect(affordances(), 'exactly the `tags` row is empty').toHaveLength(1);
+ expect(
+ container.textContent,
+ 'the em-dash reached the DOM (before the fix this row rendered zero em-dashes)',
+ ).toContain('—');
+ });
+
+ it('COPY AFFORDANCE — the row that says `No value` does not also offer to copy it', () => {
+ // `canCopy` is `hasCellValue(value)` by design (objectui#8376): a row that
+ // says `No value` must not offer to copy it. Before the fix this row carried
+ // a copy button whose click wrote `[]` to the clipboard.
+ renderSection(['industry', 'tags', 'amount'], {
+ industry: 'Manufacturing',
+ tags: [],
+ amount: 42,
+ });
+
+ expect(shown('Details'), 'CONTROL: the section heading rendered').toBe(true);
+ // CONTROL — a genuinely filled row DOES offer the copy affordance, so the
+ // absence below is a decision about this value, not about the feature.
+ expect(
+ rowOf('industry').querySelector('[role="button"]'),
+ 'a filled row still offers click-to-copy (control)',
+ ).not.toBeNull();
+
+ expect(
+ rowOf('tags').querySelector('[role="button"]'),
+ 'the empty-array row must not offer click-to-copy',
+ ).toBeNull();
+ });
+
+ it('COUNTER — the empty-array row is counted among the empty fields, and revealing them reveals IT', () => {
+ // 4 fields, 2 of them empty once `[]` counts: `tags` (`[]`) and
+ // `close_date` (absent). At/above both thresholds, so auto-hide fires and
+ // the toggle appears. Before the fix this read `Show 1 empty fields`.
+ renderSection(['industry', 'stage', 'tags', 'close_date'], {
+ industry: 'Manufacturing',
+ stage: 'Won',
+ tags: [],
+ });
+
+ // CONTROLS — the section rendered and kept its filled rows.
+ expect(shown('Details'), 'CONTROL: the section heading rendered').toBe(true);
+ expect(shown('Manufacturing'), 'CONTROL: filled row 1 rendered').toBe(true);
+ expect(shown('Won'), 'CONTROL: filled row 2 rendered').toBe(true);
+
+ const toggle = screen.getByRole('button', { name: /empty fields/i });
+ expect(
+ toggle.textContent,
+ 'the toggle must count the empty-array field: 2 empty fields, not 1',
+ ).toContain('Show 2 empty fields');
+
+ // It was hidden BECAUSE it was counted…
+ expect(shown('tags'), 'the empty-array row is hidden along with the other empty rows').toBe(false);
+
+ // …and revealing the empty fields brings it back, with the affordance.
+ fireEvent.click(toggle);
+ expect(shown('tags'), 'revealing the empty fields reveals the empty-array row').toBe(true);
+ expect(shown('close_date'), 'revealing the empty fields reveals the absent row').toBe(true);
+ expect(affordances(), 'both revealed rows draw the affordance').toHaveLength(2);
+ });
+
+ it('⭐ AMPLIFICATION — one empty array must not suppress the all-empty skeleton', () => {
+ // The consequence that reaches furthest, and the one a single-row fixture
+ // misses. 4 fields, every one of them empty except `tags`, which holds `[]`.
+ // Before the fix that was `filledCount === 1`, all `shouldAutoHideEmpty`
+ // needs: the section auto-hid its three genuinely empty rows and rendered
+ // `"Detailstags"` + `Show 3 empty fields` — a lone label over a blank.
+ renderSection(['tags', 'stage', 'amount', 'close_date'], { tags: [] });
+
+ // CONTROL — the section rendered at all.
+ expect(shown('Details'), 'CONTROL: the section heading rendered').toBe(true);
+
+ // The skeleton the heuristic reserves for an all-empty section: every label
+ // keeps its row, so the record reads as a structure waiting to be filled.
+ for (const label of ['tags', 'stage', 'amount', 'close_date']) {
+ expect(
+ shown(label),
+ `row \`${label}\` must survive the all-empty skeleton — one empty array must not arm auto-hide and bury the genuinely empty rows`,
+ ).toBe(true);
+ }
+ expect(affordances(), 'all four rows draw the `No value` affordance').toHaveLength(4);
+
+ // Nothing was hidden, so there is nothing to offer to reveal.
+ expect(
+ screen.queryByRole('button', { name: /empty fields/i }),
+ 'no rows were hidden, so no show-empty toggle is offered',
+ ).toBeNull();
+ });
+
+ it('NON-REGRESSION — a POPULATED array is still a value, and so is a populated object', () => {
+ // The axis that refuses `Array.isArray(value) => EMPTY` — an over-correction
+ // that would satisfy every case above while deleting select badges from the
+ // page. The geolocation row is the objectui#8376 / objectui#8394 axis,
+ // still red for a wholesale delegation to the display-name authority.
+ const { container } = renderSection(['office_location', 'tags', 'industry'], {
+ office_location: { latitude: 30.2741, longitude: 120.1551 },
+ tags: ['alpha', 'beta'],
+ industry: 'Manufacturing',
+ });
+
+ expect(shown('Details'), 'CONTROL: the section heading rendered').toBe(true);
+ expect(shown('Manufacturing'), 'CONTROL: the ordinary filled row rendered').toBe(true);
+
+ expect(
+ container.textContent,
+ 'a geolocation object renders as coordinates, not as `No value`',
+ ).toContain('30.2741, 120.1551');
+ expect(shown('Alpha'), 'a POPULATED array renders its option badges, not `No value`').toBe(true);
+ expect(shown('Beta'), 'a POPULATED array renders its option badges, not `No value`').toBe(true);
+
+ expect(
+ affordances(),
+ 'NO row here is empty — only the EMPTY array moved, not the object half',
+ ).toHaveLength(0);
+ });
+
+ it('NON-REGRESSION — `0` and `false` are still values', () => {
+ // The axis that refuses an emptiness test answering EMPTY for everything —
+ // an implementation strictly worse than the bug, which every case above
+ // would otherwise accept.
+ renderSection(['industry', 'amount', 'stage', 'close_date'], {
+ industry: 'Manufacturing',
+ amount: 0,
+ stage: false,
+ });
+
+ expect(shown('Details'), 'CONTROL: the section heading rendered').toBe(true);
+ expect(shown('Manufacturing'), 'a plain string is a value').toBe(true);
+
+ // 4 fields with exactly ONE empty row (`close_date`), so auto-hide fires and
+ // hides it: `0` and `false` are on the FILLED side of the count.
+ const toggle = screen.getByRole('button', { name: /empty fields/i });
+ expect(
+ toggle.textContent,
+ '`0` and `false` are values: exactly one field (`close_date`) is empty',
+ ).toContain('Show 1 empty fields');
+ expect(affordances(), 'no visible row draws the affordance').toHaveLength(0);
+ expect(shown('amount'), '`0` keeps its row and is not hidden as empty').toBe(true);
+ expect(shown('stage'), '`false` keeps its row and is not hidden as empty').toBe(true);
+ });
+
+ it('THE BOUNDARY — `{}` is a VALUE: it DRAWS, so it did not move', () => {
+ // Measured, not assumed. `{}` on `json`, `object` and `location` fields all
+ // reach `JsonCellRenderer` (`location` falls back to it when the lat/lng
+ // chain yields nothing) and draw the literal `{}` — terse, but drawn. No
+ // blank cell, so no defect of the kind this card fixes. This case is also
+ // RED for an emptiness test answering EMPTY for everything.
+ const { container } = renderSection(
+ ['industry', 'payload', 'profile', 'office_location'],
+ { industry: 'Manufacturing', payload: {}, profile: {}, office_location: {} },
+ );
+
+ expect(shown('Details'), 'CONTROL: the section heading rendered').toBe(true);
+ expect(shown('Manufacturing'), 'CONTROL: the ordinary filled row rendered').toBe(true);
+
+ for (const label of ['payload', 'profile', 'office_location']) {
+ expect(shown(label), `CONTROL: the \`${label}\` row is on screen`).toBe(true);
+ expect(
+ rowOf(label).querySelector('[title="No value"]'),
+ `\`{}\` on \`${label}\` DRAWS, so it must not take the affordance branch`,
+ ).toBeNull();
+ }
+ expect(
+ (container.textContent || '').split('{}').length - 1,
+ 'all three `{}` cells rendered their JSON text',
+ ).toBe(3);
+ expect(affordances(), 'no row here is empty — `{}` is a value on this surface').toHaveLength(0);
+ });
+
+ it('DECLARED COST — a `json` field holding `[]` now draws the placeholder instead of the literal `[]`', () => {
+ // The one behaviour change a reviewer should see stated rather than
+ // discover. `JsonCellRenderer` DID draw `[]` for an empty array, so unlike
+ // the select family this cell was never blank — it printed two characters of
+ // punctuation. `No value` is the better answer for "no items", and one
+ // predicate cannot tell the two field families apart by value alone. Pinned
+ // so the trade is deliberate.
+ const { container } = renderSection(['industry', 'payload'], {
+ industry: 'Manufacturing',
+ payload: [],
+ });
+
+ expect(shown('Details'), 'CONTROL: the section heading rendered').toBe(true);
+ expect(shown('Manufacturing'), 'CONTROL: the ordinary filled row rendered').toBe(true);
+ expect(shown('payload'), 'CONTROL: the `[]` row is on screen at all').toBe(true);
+
+ expect(
+ rowOf('payload').querySelector('[title="No value"]'),
+ 'a `json` field holding `[]` draws the `No value` affordance',
+ ).not.toBeNull();
+ expect(
+ container.textContent,
+ 'the literal `[]` no longer reaches the DOM as a rendered value',
+ ).not.toContain('[]');
+ });
+
+ /**
+ * The predicate-level boundary, asserted rather than cited. An ADDITION to
+ * the DOM cases above, never a substitute — the pin has to assert the
+ * rendered outcome, and it does, seven times.
+ */
+ it('MEASUREMENT — the arm is `Array.isArray && length === 0`, and the wider shape it rejects is unsafe', () => {
+ expect(hasCellValue([]), 'an EMPTY array is EMPTY').toBe(false);
+ expect(hasCellValue(['alpha']), 'a POPULATED array is a value').toBe(true);
+ expect(hasCellValue({}), '`{}` is a value — it draws (see THE BOUNDARY)').toBe(true);
+ expect(hasCellValue({ latitude: 1, longitude: 2 }), 'a populated object is a value').toBe(true);
+ expect(hasCellValue(new Date(0)), 'a Date is a value').toBe(true);
+ expect(hasCellValue(0), '`0` is a value').toBe(true);
+ expect(hasCellValue(false), '`false` is a value').toBe(true);
+
+ // ⛔ Why the arm is NOT `Object.keys(value).length === 0`: that shape is
+ // true of four things that render, so it would answer EMPTY for values the
+ // page draws — strictly worse than the bug it set out to fix.
+ class GetterBacked {
+ #n = 'Ada';
+ get name() { return this.#n; }
+ }
+ expect(Object.keys(new Date(0)), 'a Date has no OWN enumerable keys').toHaveLength(0);
+ expect(Object.keys(new Map([[1, 2]])), 'a populated Map has no OWN enumerable keys').toHaveLength(0);
+ expect(Object.keys(new Set([1])), 'a populated Set has no OWN enumerable keys').toHaveLength(0);
+ expect(Object.keys(new GetterBacked()), 'a getter-backed instance has no OWN enumerable keys').toHaveLength(0);
+ // …and all four are values here, which is the point of not using it.
+ expect(hasCellValue(new Map([[1, 2]])), 'a populated Map is a value').toBe(true);
+ expect(hasCellValue(new Set([1])), 'a populated Set is a value').toBe(true);
+ expect(hasCellValue(new GetterBacked()), 'a getter-backed instance is a value').toBe(true);
+ });
+});
diff --git a/packages/plugin-detail/src/emptiness.ts b/packages/plugin-detail/src/emptiness.ts
index d765171ff7..91e9fdde8a 100644
--- a/packages/plugin-detail/src/emptiness.ts
+++ b/packages/plugin-detail/src/emptiness.ts
@@ -68,8 +68,57 @@ import { recordDisplayValueAt } from '@object-ui/core';
* display chain, any other object as JSON — none of which carries a name-ish key
* in the general case, so delegating this half would replace populated cells
* with `No value`, drop them out of `filledCount`, and let auto-hide bury them.
- * An object is therefore a VALUE here: this function moves whitespace-only
- * strings and nothing else.
+ * A POPULATED object is therefore a VALUE here.
+ *
+ * ## The one object shape that is NOT a value: `[]` (objectui#8474)
+ *
+ * Every example above is a POPULATED object. `typeof [] === 'object'`, so until
+ * objectui#8474 an EMPTY array took the same branch and was a value — and there
+ * the reasoning stops holding, because the type-aware renderer has nothing to
+ * draw. `SelectCellRenderer` tests `value == null || value === ''`, which `[]`
+ * passes, and then maps it over zero entries: the cell renders as
+ * `
`, a visually blank cell — the exact
+ * UI the em-dash exists to prevent, reached through the function that exists to
+ * prevent it. And it brought the whole objectui#8376 triple with it: the row
+ * escaped `emptyCount` so the toggle read one too low, `canCopy` offered to copy
+ * it, and because `shouldAutoHideEmpty` only needs `filledCount > 0` a section
+ * whose one non-null value was `[]` auto-hid every genuinely empty row around it
+ * and rendered a lone label over a blank.
+ *
+ * ⚠️ The declared cost, measured rather than waved past: a `json`-family field
+ * holding `[]` used to render the literal two-character text `[]` through
+ * `JsonCellRenderer`, and now draws the `No value` placeholder. That is
+ * intended — "no items" is what the placeholder says — but it IS a render
+ * change, and it is pinned as one.
+ *
+ * ## Why `{}` is NOT included — measured on this surface, not argued by symmetry
+ *
+ * `{}` is a VALUE here, and that is a measurement rather than an oversight.
+ * Rendered in a real `DetailSection`, `{}` on a `json`, an `object` and a
+ * `location` field all draw the literal `{}` through `JsonCellRenderer`
+ * (`location` falls back to it when the lat/lng chain yields nothing) — a terse
+ * cell, but a DRAWN one. There is no blank cell, so there is no defect of the
+ * kind above to fix, and turning a visible `{}` into an em-dash would be a taste
+ * change dressed up as a bug fix.
+ *
+ * ⛔ And the shape that would sweep `{}` in is actively unsafe here:
+ * `Object.keys(value).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 (all four measured). Widening that way would call those
+ * EMPTY — a false-empty on values that render, strictly worse than the bug it
+ * set out to fix. So this function moves whitespace-only strings and empty
+ * arrays, and nothing else.
+ *
+ * ## Agreement with `RelatedList`'s local predicate (objectui#8459)
+ *
+ * `RelatedList.isValueEmpty` has drawn this finer line for some time, and
+ * objectui#8459 / PR #8476 measured it as the better-shaped answer for a grid,
+ * declining to delegate here BECAUSE of this hole. After objectui#8474 the two
+ * agree on every probe measured (`[]` empty; `{}`, `[1]`, `{ a: 1 }`, `0` and a
+ * `Date` all values). ⛔ Note the DIRECTION: the SHARED authority moved toward
+ * the local predicate. `RelatedList.isValueEmpty` is untouched and must stay
+ * that way — a grid column and a record row ask this at two granularities, and
+ * the local one is pinned in its own file.
*
* ## Not every emptiness question on this page is THIS question
*
@@ -80,8 +129,9 @@ import { recordDisplayValueAt } from '@object-ui/core';
* to tell apart. Converging it would delete information rather than add it.
*
* Pinned end-to-end (DOM, not predicate) in
- * `__tests__/DetailSection.emptinessAuthority-8376.test.tsx` and
- * `__tests__/detailPage.emptinessAuthority-8394.test.tsx`, whose NON-REGRESSION
+ * `__tests__/DetailSection.emptinessAuthority-8376.test.tsx`,
+ * `__tests__/detailPage.emptinessAuthority-8394.test.tsx` and
+ * `__tests__/DetailSection.emptyArray-8474.test.tsx`, whose NON-REGRESSION
* cases are red for a wholesale delegation and red for an emptiness test that
* answers EMPTY for everything.
*/
@@ -89,7 +139,15 @@ export function hasCellValue(value: unknown): boolean {
// Object/array values belong to the cell renderers, not to the display-name
// chain — see the docblock above. `typeof null === 'object'`, so null is
// excluded here and answered by the authority below.
- if (value !== null && typeof value === 'object') return true;
+ if (value !== null && typeof value === 'object') {
+ // …with exactly one exception: an EMPTY array, which no cell renderer has
+ // anything to draw for (objectui#8474). Answered HERE rather than by
+ // falling through to the authority below: that function answers "does this
+ // resolve to a NAME", and it calls `{}` and a `Date` empty too — correct
+ // for a title, a false-empty for a cell.
+ if (Array.isArray(value) && value.length === 0) return false;
+ return true;
+ }
// A one-key synthetic record is how a VALUE asks the authority its question:
// `recordDisplayValueAt` is keyed `(record, field)` because its callers read
// a field off a record, while a call site's value has several sources (the