Repository navigation
Conversation
… decimal/thousands separators distinct Show unit label (and every other trait checkbox) could not be cleared when traits were persisted in PascalCase. Fixes #1824 (comma allowed as both separators).
…ter in-place format set updates Fixes #1825.
…units; show default unit label placeholder DDMMSS was unreachable: GRAD was offered under degrees and ARC_MINUTE as its own sub-unit. Removes unused internal UnitDescr component; log through Logger.
…, stricter sub-unit tolerance, test gaps - Resolve separator conflicts against the formatter's locale defaults; empty separators never collide; leave the thousands separator alone while grouping is off - Tighten whole-subdivision tolerance to 1e-9 relative - Tests: PascalCase traits for every checkbox, string formatTraits, non-whole large ratios, deterministic option waits
…uth of horizontal directions FormatTypeOption hard-coded Units.REVOLUTION and Units.ARC_DEG, which cannot convert to HORIZONTAL_DIRECTION units, so switching a horizontal-direction format to bearing/azimuth made the formatter throw.
There was a problem hiding this comment.
🟡 Changes recommended
Large fractional ratios can pass the subdivision check, and asynchronous type changes can overwrite newer user edits.
2 open findings
What changed in this PR
Fixes five quantity-format editing defects involving separators, stale formats, trait casing, composite sub-units, and horizontal-direction formats.
Changes:
- Centralizes trait and separator handling.
- Corrects format selection, sub-unit filtering, and direction-unit resolution.
- Adds localization, regression tests, and patch changesets.
| File | Description |
|---|---|
src/test/quantityformat/internal/TraitCheckboxes.test.tsx |
Tests PascalCase traits. |
src/test/quantityformat/internal/ThousandsSeparator.test.tsx |
Tests separator conflicts. |
src/test/quantityformat/internal/ShowTrailingZeros.test.tsx |
Corrects string-trait coverage. |
src/test/quantityformat/internal/FormatUnits.test.tsx |
Tests sub-units and labels. |
src/test/quantityformat/internal/FormatUnitLabel.test.tsx |
Tests unit-label traits. |
src/test/quantityformat/internal/FormatType.test.tsx |
Tests direction units. |
src/test/quantityformat/internal/FormatPropsUtils.test.ts |
Tests shared utilities. |
src/test/quantityformat/internal/DecimalSeparator.test.tsx |
Tests decimal conflicts. |
src/test/quantityformat/FormatSelector.test.tsx |
Tests current format retrieval. |
src/components/quantityformat/panels/Station.tsx |
Uses shared trait and unit logic. |
src/components/quantityformat/panels/Scientific.tsx |
Uses shared trait and unit logic. |
src/components/quantityformat/panels/Ratio.tsx |
Uses shared trait and unit logic. |
src/components/quantityformat/panels/Fractional.tsx |
Uses shared trait and unit logic. |
src/components/quantityformat/panels/Decimal.tsx |
Uses shared trait and unit logic. |
src/components/quantityformat/panels/Bearing.tsx |
Supplies the units provider. |
src/components/quantityformat/panels/Azimuth.tsx |
Uses shared trait and unit logic. |
src/components/quantityformat/internal/ZeroEmpty.tsx |
Centralizes trait updates. |
src/components/quantityformat/internal/ThousandsSeparator.tsx |
Resolves grouping conflicts. |
src/components/quantityformat/internal/ShowTrailingZeros.tsx |
Centralizes trait updates. |
src/components/quantityformat/internal/misc/UnitDescr.tsx |
Removes unused component code. |
src/components/quantityformat/internal/misc/UnitDescr.ts |
Retains the unit-name helper. |
src/components/quantityformat/internal/misc/FormatType.tsx |
Resolves direction-family units. |
src/components/quantityformat/internal/KeepSingleZero.tsx |
Centralizes trait updates. |
src/components/quantityformat/internal/KeepDecimalPoint.tsx |
Centralizes trait updates. |
src/components/quantityformat/internal/FractionDash.tsx |
Centralizes trait updates. |
src/components/quantityformat/internal/FormatUnits.tsx |
Filters sub-units and improves labels. |
src/components/quantityformat/internal/FormatUnitLabel.tsx |
Handles trait casing. |
src/components/quantityformat/internal/FormatPropsUtils.ts |
Adds shared format utilities. |
src/components/quantityformat/internal/DecimalSeparator.tsx |
Resolves decimal conflicts. |
src/components/quantityformat/internal/AzimuthOptions.tsx |
Uses structured logging. |
src/components/quantityformat/FormatSelector.tsx |
Reads current format definitions. |
public/locales/en/QuantityFormat.json |
Adds the empty-search translation. |
.changeset/@itwin-quantity-formatting-react-stale-format-selection.md |
Documents stale-format fix. |
.changeset/@itwin-quantity-formatting-react-horizontal-direction-bearing.md |
Documents direction-unit fix. |
.changeset/@itwin-quantity-formatting-react-format-trait-casing.md |
Documents trait-casing fix. |
.changeset/@itwin-quantity-formatting-react-distinct-separators.md |
Documents separator fix. |
.changeset/@itwin-quantity-formatting-react-composite-sub-units.md |
Documents sub-unit fix. |
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
…ups, cap sub-unit tolerance - FormatTypeOption ignores a bearing/azimuth unit lookup that resolves after a newer type change or props change - Cap the whole-subdivision tolerance at 1e-3 parts so very large non-integral ratios are rejected
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); |
There was a problem hiding this comment.
shouldn't this be inside the describe block?
There was a problem hiding this comment.
oof yep, removed now tks
|
|
||
| import type { FormatDefinition } from "@itwin/core-quantity"; | ||
|
|
||
| describe("AppendUnitLabel", () => { |
There was a problem hiding this comment.
Should file name of this, as well as FormatUnitLabel.tsx, be changed to match component name?
There was a problem hiding this comment.
Yeah it can be aligned, FormatUnitLabel had to be split tho into 2 separate src files, cuz I had AppendUnitLabel and UomSeparatorSelector components in that one source file. I'm keeping the name of the test file same but splitting the source files
| [formatProps, onChange] | ||
| ); | ||
|
|
||
| const handleUseFractionDashChange = React.useCallback( |
| // Not memoized: format set providers (e.g. FormatSetFormatsProvider.addFormat) update `formats` in place, so the | ||
| // object identity does not change after a format is applied. | ||
| const formatEntries = Object.entries(activeFormatSet?.formats ?? {}) | ||
| .filter((entry): entry is [string, FormatDefinition] => typeof entry[1] === "object" && entry[1] !== null) | ||
| .map(([key, formatDef]) => ({ key, formatDef, label: formatDef.label || key })); | ||
|
|
||
| return Object.entries(activeFormatSet.formats) | ||
| .filter(([, formatDef]) => typeof formatDef === "object" && formatDef !== null) | ||
| .map(([key, formatDef]) => ({ | ||
| key, | ||
| formatDef: formatDef as FormatDefinition, | ||
| label: (formatDef as FormatDefinition).label || key, | ||
| })); | ||
| }, [activeFormatSet?.formats]); | ||
| const lowerSearchTerm = searchTerm.trim().toLowerCase(); | ||
| const filteredFormats = lowerSearchTerm | ||
| ? formatEntries.filter(({ label }) => label.toLowerCase().includes(lowerSearchTerm)) | ||
| : formatEntries; |
There was a problem hiding this comment.
If FormatSetFormatsProvider changes formats in place, there's nothing that would cause FormatSelector component to rerender and cause the format updates to be reflected. In addition, React components by design require props to be immutable, and that should be taken care of at the source rather than in a low level component.
IMO, whoever has access to FormatSetFormatsProvider and passes its format set as the activeFormatSet prop to this component, is also responsible for creating an immutable format set and keeping it up to date with the help of FormatSetFormatsProvider.onFormatsChanged.
There was a problem hiding this comment.
That's a good point, okay I reverted this, and documented the instruction in the README. there's also a doccomment on the activeFormatSet prop to recommend devs to pass a new object after FormatSetFormatsProvider.onFormatsChanged
| await user.click(screen.getByText("Area Format")); | ||
| await user.click(screen.getByText("Length Format")); | ||
|
|
||
| expect(mockOnListItemChange).toHaveBeenLastCalledWith(updatedDefinition, "length-format"); |
There was a problem hiding this comment.
This validates that FormatSelector raises onListItemChange with "live" format set, but it doesn't verify that it uses "live" format set for rendering. I recommend adding a new format to the format set and validating that its list item is rendered, as well as the onListItemChange is raised for it.
There was a problem hiding this comment.
sounds good, I've changed the testing now
| return onUnitsChange; | ||
| } | ||
|
|
||
| // Civil-iTwin #2096641: DDMMSS angle formats need arc minutes, not GRAD, as the sub-unit of degrees. |
There was a problem hiding this comment.
not sure we should be referencing internal repo work items
There was a problem hiding this comment.
removed all references to internal items
| fireEvent.click(screen.getByRole("option", { name: "azimuth" })); | ||
| // Another edit lands while the lookup is pending. | ||
| rerender(<FormatTypeOption formatProps={{ ...formatProps, precision: 4 }} unitsProvider={unitsProvider} onChange={onChange} />); | ||
| await waitFor(() => expect(resolveLookup).toBeDefined()); |
There was a problem hiding this comment.
wouldn't it make more sense to wait for resolveLookup as soon as we click on the select option?
There was a problem hiding this comment.
soounds good, switched now
| await new Promise((resolve) => setTimeout(resolve, 0)); | ||
|
|
||
| expect(onChange).not.toHaveBeenCalled(); |
There was a problem hiding this comment.
feels a bit fragile, why not use await waitFor(() => expect(onChange).not.toHaveBeenCalled())?
There was a problem hiding this comment.
I tried waitFor(() => expect(onChange).not.toHaveBeenCalled()) and it passes on its first try, so it would also pass if the late call never got a chance to run.
But instead I changed this so the test now resolves the lookup inside an act and waits for it to finish
This one was AI tho
| const requestRef = React.useRef(0); | ||
| React.useEffect(() => { | ||
| requestRef.current++; | ||
| }, [formatProps, unitsProvider]); |
There was a problem hiding this comment.
The effect invalidates requests when props change, but has no cleanup. Selecting Azimuth/Bearing and then switching to a format of another type unmounts the picker. When its lookup completes, it still calls onChange, overwriting the newly selected draft and enabling Apply.
There was a problem hiding this comment.
good point, okay there's a cleanup for the effect now that will invalidate the pending lookup on unmount, and added a test
… publishes a new format set on changes FormatSelector goes back to memoizing by the formats object. Owners of a FormatSetFormatsProvider must pass a new format set after onFormatsChanged, which the README now documents and test-viewer's FormatManager does.
…dead FractionDash handler, scope test hooks


TL;DR
Five bugs reported by our users made formats hard or impossible to edit in the quantity formatting UI. Four are fixed in the package. The fifth (#1825) comes from apps changing the format set in place, so
activeFormatSetis now documented as immutable and test-viewer publishes a new format set instead. Regression tests fail on the current code. Patch only: no API or peer dependency changes.Stacked on #1826. Closes #1824, closes #1825.
What was wrong
.and,.FormatSetFormatsProvider.addFormatupdates the format set in place, so the object passed toFormatSelectornever changes and its memoized list keeps the old definitions.activeFormatSetis documented as immutable. test-viewer publishes a new format set afteronFormatsChanged; apps holding aFormatSetFormatsProviderneed to do the same.ShowUnitLabel; the checkboxes only matchedshowUnitLabel. Core reads either, so the label kept showing. Same for every trait checkbox.What
internal/FormatPropsUtils.ts(new)DecimalSeparator/ThousandsSeparatorFormatSelector/ READMEactiveFormatSetmust be replaced, not changed in place, with an example. Also localizes the "no formats found" message.FormatManager/QuantityFormatButtononFormatsChanged, so the selector shows applied changes.FormatUnitsLoggerinstead ofconsole.warn.FormatType(format type picker)HORIZONTAL_DIR_*units. Ignores a unit lookup that finishes after a newer change or after the picker unmounts.AppendUnitLabel.tsx/UomSeparatorSelector.tsxFormatUnitLabel.tsx, one component per file.misc/UnitDescrgetUnitNamehelper is kept.Tests
The regression tests fail against the original source. Control tests, such as the azimuth defaults being applied when nothing changed, pass on both.
New and updated test coverage
TraitCheckboxes.test.tsx— PascalCase trait shown checked / cleared (×6 controls)AppendUnitLabel.test.tsx— PascalCaseShowUnitLabelFormatPropsUtils.test.ts— trait helpersFormatPropsUtils.test.ts— separator conflictsThousandsSeparator.test.tsx— enabling with comma decimalDecimalSeparator.test.tsx— clash / grouping off / locale defaultFormatUnits.test.tsx— degree→minute, minute→second, horizontal-direction degree→minuteFormatUnits.test.tsx— excludes grad, excludes large non-whole ratioFormatUnits.test.tsx— default label placeholderFormatType.test.tsx— azimuth/bearing for horizontal directions and angles45/N45Eend to end.FormatType.test.tsx— pending azimuth lookuponChange; one that finishes with nothing changed does.Validation
tsc,pnpm lint,pnpm test(197/197),pnpm build,extract-api(no API change), andcheck-extractionsall pass. test-viewer typechecks and lints clean; it has not been smoke-tested again since the review changes.Nambot 🤖 (powered by claude-opus-5.5)