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
57 changes: 57 additions & 0 deletions .changeset/9295-percent-surfaces-read-scale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-grid': minor
'@object-ui/plugin-detail': minor
---

All three percent surfaces read `scale` for their fraction width, not
`precision` (objectui#9295).

`PercentCellRenderer` in `@object-ui/fields`, the `colType === 'percent'` arm of
`formatSummaryLabel` in `@object-ui/plugin-grid`'s `useColumnSummary`, and the
record summary chip's percent branch in `@object-ui/plugin-detail`'s
`DetailView` each took `precision` and handed it to `Intl` as BOTH the minimum
and the maximum fraction digits. `@objectstack/spec` declares the pair in its own words on both
the field face and the column face: `precision` is the "Total digits" of a
`decimal(p, s)` column and `scale` is its "Decimal places" — so a percent field
was padded out to the column's TOTAL width. A `decimal(10, 2)` percent field
rendered `25.0000000000%` in the cell, `Sum: 25.0000000000%` in the footer
directly beneath it, and `25.0000000000%` again on the record summary chip. This
is the identical defect objectui#2131 removed from the currency arm and
objectui#2134 from the number arm, arriving one type later; in `useColumnSummary`
the corrected percent arm now sits four lines below a currency arm it finally
agrees with.

The summary chip moves because objectui#9167 routed it onto the LIST CELL as its
authority and its ruling turns on the two being byte-equal, so the member was
always incidental there: following the cell is what KEEPS that ruling. Its pin
asserts both halves and is what caught the chip being left behind.

**Breaking, deliberately — filed as `minor` because this repo's fixed release
group forbids `major`.** Percent rendering moves in two directions:

- A percent field, column or summary chip declaring `scale` now honours it.
Declaring `scale: 2` previously rendered `25%` and now renders `25.00%`.
- A percent field, column or summary chip declaring `precision` no longer pads
to it. Declaring `precision: 10` previously rendered `25.0000000000%` and now
renders `25%`.

**Migration.** Restate the intended fraction width as `scale`, which is the
member the contract has always declared for it. Metadata carrying an accurate
`decimal(p, s)` pair — both members, as a database column exposes them — needs no
change and simply stops being padded.

**Unchanged: a percent field that declares neither member.** An absent `scale`
is still zero fraction digits, matching the currency arm beside it, so this is
invisible to metadata that declares nothing. That default is a decision rather
than a leftover: the number cell renderer spells the same absence as
`undefined` (minimum 0, maximum 20), and copying it here would print binary
floating-point residue, because the percent path multiplies by 100 first and
`Intl` renders from the shortest decimal representation of the resulting double
— a stored `0.07` becomes `7.000000000000001` and `0.29` becomes
`28.999999999999996`. The number arm can afford an unbounded maximum because it
performs no arithmetic on the value.

`CurrencyConfigSchema.precision` is untouched and must not be conflated with
this: it is a different surface with the opposite convention and its own
`scale` alias, and the spec says so at the field-face declaration.
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,20 @@ describe('PercentCellRenderer keeps its scaling contract (objectui#4553 must-not

renderCell(0.5, { name: 'progress' }, 'en');
// Whole-percent field: 0.5 really is half a percent, rounded to 1% at
// precision 0 — NOT 50%.
// zero fraction digits (this field declares no `scale`) — NOT 50%.
expect(cellText()).toContain('1%');
expect(cellText()).not.toContain('50%');
});

/** PIN: small-value English output is byte-identical across the change. */
/**
* PIN: small-value English output is byte-identical across the change.
*
* The two-decimal width is declared with `scale`, ⛔ not `precision`
* (objectui#9295) — `precision` is the column's TOTAL digit count and this
* renderer no longer reads it.
*/
it('en small-value output is unchanged (must-not-change)', () => {
renderCell(33.33, { name: 'win_rate', precision: 2 }, 'en');
renderCell(33.33, { name: 'win_rate', scale: 2 }, 'en');
expect(cellText()).toContain('33.33%');
});

Expand Down
107 changes: 107 additions & 0 deletions packages/fields/src/__tests__/PercentCellRenderer.scale-9295.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* 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#9295 — `PercentCellRenderer` read `precision` as a FRACTION-digit
* count, in the same file whose number arm records that `precision` is the
* TOTAL digit count of a `decimal(p, s)` column.
*
* `@objectstack/spec` declares the pair on the field face in its own words —
* `precision` is "Total digits (non-negative integer)" and `scale` is "Decimal
* places (non-negative integer)" — so the member to read is `scale`. This is
* the identical repair objectui#2131 made on the currency arm and
* objectui#2134 on the number arm, arriving one type later.
*
* ── Why the assertions are on RENDERED OUTPUT ───────────────────────────
* The defect is what the user sees in the cell, and `formatPercent` is a
* shared helper that was never wrong — it formats to the width it is handed.
* Asserting the helper's return value would pin the wrong end and stay green
* on the defect. Every row below goes through the component.
*
* ── What fails before the repair ────────────────────────────────────────
* `decimal(10, 2)` — `{ precision: 10, scale: 2 }` — rendered
* `25.0000000000%` for a stored `0.25`, padded out to the column's TOTAL
* width. The grid footer beneath it read `Sum: 25.0000000000%` for the same
* reason; its half of this card is pinned in `@object-ui/plugin-grid`.
*
* ── The ABSENT-`scale` case is a DECISION, not a leftover ───────────────
* An absent `scale` stays `0` here, and deliberately NOT the `undefined`
* (min 0 / max 20) that `NumberCellRenderer` uses for the same absence. This
* path multiplies by 100 first (`percentDisplayValue`) and `Intl` renders from
* the shortest decimal representation of the resulting double, so an unbounded
* maximum prints binary residue: measured, a stored `0.07` scales to
* `7.000000000000001` and `0.29` to `28.999999999999996`. The last two cases
* below pin that those values stay readable.
*/
import { describe, it, expect } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { PercentCellRenderer } from '../index';

const renderPercent = (value: unknown, field: Record<string, unknown> = {}) =>
render(
<PercentCellRenderer value={value as any} field={{ type: 'percent', ...field } as any} />,
);

/** The cell's whole text, bar included — the bar contributes none. */
const cellText = () => screen.getByRole('progressbar').parentElement!.textContent ?? '';

describe('PercentCellRenderer reads `scale`, not `precision` (objectui#9295)', () => {
it('does not pad a decimal(10, 2) percent field out to ten fraction digits', () => {
renderPercent(0.25, { name: 'rate', precision: 10, scale: 2 });

// The card's headline reading, and the row that fails before the repair.
expect(cellText()).toContain('25.00%');
expect(cellText()).not.toContain('25.0000000000%');
});

it('ignores `precision` entirely when no `scale` is declared', () => {
// decimal(10, 0) — ten total digits, zero decimal places. `precision`
// alone must move nothing.
renderPercent(0.25, { name: 'rate', precision: 10 });

expect(cellText()).toContain('25%');
expect(cellText()).not.toMatch(/\.0{3,}/);
});

it('honours a declared `scale` on its own', () => {
renderPercent(0.25, { name: 'rate', scale: 3 });
expect(cellText()).toContain('25.000%');
});

it('applies the same member on the WHOLE-percent branch', () => {
// `progress` takes the other scaling arm (`formatPercentBody`), which was
// handed the same wrong member. A stored 25 is 25% here, not 2500%.
renderPercent(25, { name: 'progress', precision: 10, scale: 2 });

expect(cellText()).toContain('25.00%');
expect(cellText()).not.toContain('25.0000000000%');
});

it('leaves a field declaring neither member exactly where it was', () => {
// MUST-NOT-CHANGE control: absent `scale` is still zero fraction digits,
// so this repair is invisible to every field that declares nothing.
renderPercent(0.12345, { name: 'rate' });
expect(cellText()).toContain('12%');
});

it('keeps an absent `scale` free of binary floating-point residue', () => {
// The measured reason the absent case is `0` and not `undefined`
// (min 0 / max 20): `0.07 * 100` is `7.000000000000001` as a double.
renderPercent(0.07, { name: 'rate' });
expect(cellText()).toContain('7%');
expect(cellText()).not.toContain('7.000000000000001%');
cleanup();

renderPercent(0.29, { name: 'rate' });
expect(cellText()).toContain('29%');
expect(cellText()).not.toContain('28.999999999999996%');
});
});
6 changes: 4 additions & 2 deletions packages/fields/src/__tests__/PercentCellRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@ describe('PercentCellRenderer — value beats the decorative bar (issue #5066)',
expect(screen.getByText('33%')).toBeInTheDocument();
first.unmount();

// Declared precision keeps the decimals — the widest text, worst overflow.
renderPercent(33.33, { precision: 2 });
// A declared `scale` keeps the decimals — the widest text, worst overflow.
// ⛔ NOT `precision` (objectui#9295): that is the column's TOTAL digit
// count, and reading it here is the defect that card removed.
renderPercent(33.33, { scale: 2 });
const wide = screen.getByText('33.33%');
expect(wide).toBeInTheDocument();
expect(wide).toHaveClass('shrink-0');
Expand Down
30 changes: 27 additions & 3 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -794,7 +794,31 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
if (isBlankCellText(safe)) return <EmptyValue />;

const percentField = field as any;
const precision = percentField.precision ?? 0;
// Decimal places come from `scale`, NOT `precision` — the same correction
// objectui#2131 made on the currency arm and objectui#2134 on the number
// arm, arriving one type later (objectui#9295). `@objectstack/spec` declares
// the pair on the field face in its own words: `precision` is "Total digits
// (non-negative integer)" and `scale` is "Decimal places (non-negative
// integer)", so reading `precision` here padded every value out to the
// column's TOTAL width — a decimal(10, 2) percent field rendered
// `25.0000000000%`, and the grid footer beneath it read
// `Sum: 25.0000000000%` for the same reason.
//
// ⛔ NOT `CurrencyConfigSchema.precision`, which is a different surface with
// the opposite convention and its own `scale` alias — the spec warns against
// conflating them at the field-face declaration itself.
//
// An ABSENT `scale` keeps today's `0`, deliberately, and ⛔ NOT the
// `undefined` (min 0 / max 20) that `NumberCellRenderer` above uses for the
// same absence. The two are not interchangeable HERE because this path
// multiplies by 100 first (`percentDisplayValue`), and `Intl` renders from
// the shortest decimal representation of the resulting double: measured, a
// stored `0.07` becomes `7.000000000000001` and `0.29` becomes
// `28.999999999999996`, so an unbounded maximum prints binary residue
// straight to the user. `NumberCellRenderer` can afford max 20 because it
// does no arithmetic on the value. The grid footer's currency arm spells the
// same absence the same way (`?? 0`), so the cell and the footer agree.
const scale = percentField.scale ?? 0;
const numValue = Number(safe);
if (isNaN(numValue)) {
return <span className="tabular-nums whitespace-nowrap">{String(safe)}</span>;
Expand All @@ -813,8 +837,8 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
// would have made ONE grid internally inconsistent, which is worse than the
// uniform defect it had.
const formatted = isWholePercentField
? formatPercentBody(numValue, precision, locale)
: formatPercent(numValue, precision, locale);
? formatPercentBody(numValue, scale, locale)
: formatPercent(numValue, scale, locale);
const clampedBar = Math.max(0, Math.min(100, barValue));

// Layout contract (objectstack#5066): THE NUMBER IS THE CONTENT, THE BAR IS
Expand Down
26 changes: 20 additions & 6 deletions packages/plugin-detail/src/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1194,12 +1194,26 @@ export const DetailView: React.FC<DetailViewProps> = ({
// to the field's precision would make this chip
// disagree with the cell it just started agreeing with.
const percentField = { ...(objField as any), ...(sectionField as any) };
// The field's declared precision, resolved with the
// same view-over-object precedence the currency branch
// above spells, and floored at the cell's own default:
// `PercentCellRenderer` reads `field.precision ?? 0`.
const precision = percentField.precision ?? 0;
display = formatPercent(num, precision, displayLocale);
// The field's declared width, resolved with the same
// view-over-object precedence the currency branch above
// spells, and floored at the cell's own default:
// `PercentCellRenderer` reads `field.scale ?? 0`.
//
// ⭐ The MEMBER moved and the AUTHORITY did not
// (objectui#9295). This read was `precision ?? 0` until
// `@objectstack/spec` was read at source: it declares
// `precision` as the "Total digits" of a decimal(p, s)
// column and `scale` as its "Decimal places", so the
// cell was padding a decimal(10, 2) percent field out to
// ten fraction digits and this chip mirrored it there.
// objectui#9167 routed this chip onto the LIST CELL as
// the authority — its ruling turns on the two being
// byte-equal — so when the cell's member moved, staying
// on `precision` is what would have BROKEN that ruling,
// not what would have kept it. Whatever the cell reads,
// this reads; that is the whole of the coupling.
const scale = percentField.scale ?? 0;
display = formatPercent(num, scale, displayLocale);
const points = summaryChipPercentPoints(num);
percentValue = Math.max(0, Math.min(100, points));
}
Expand Down
Loading
Loading