From b60393fef4527889ea2ab52e98c475d6cd64774e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 06:28:10 +0000 Subject: [PATCH 1/3] fix(fields): an empty currency box shows the field's own placeholder, or none The edit-mode CurrencyField fell back to the placeholder 0.00 when the field declared none, so an optional amount nobody filled in read as a filled-in zero. It now reads the field's placeholder the way NumberField does. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Utp6CZBg559ZS19rzt9ki8 --- ...rencyField.emptyPlaceholder-27398.test.tsx | 119 ++++++++++++++++++ .../fields/src/widgets/CurrencyField.tsx | 5 +- 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 ui/packages/fields/src/__tests__/CurrencyField.emptyPlaceholder-27398.test.tsx diff --git a/ui/packages/fields/src/__tests__/CurrencyField.emptyPlaceholder-27398.test.tsx b/ui/packages/fields/src/__tests__/CurrencyField.emptyPlaceholder-27398.test.tsx new file mode 100644 index 00000000000..aceee5ccd87 --- /dev/null +++ b/ui/packages/fields/src/__tests__/CurrencyField.emptyPlaceholder-27398.test.tsx @@ -0,0 +1,119 @@ +/** + * 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. + */ + +/** + * objectstack-ai/objectstack#27398 row 2 — an EMPTY currency box reads as + * empty. + * + * The edit-mode input used to fall back to the placeholder `0.00` whenever the + * field declared none, so an optional amount nobody filled in (a public form's + * Annual Revenue) looked like a filled-in zero. The widget now shows the + * field's own `placeholder`, or none — the shape `NumberField` already has. + * + * Every host draws this one widget with the field it was handed, so the pins + * below cover the widget itself and the two package hosts that mount it: the + * form renderer (record form) and `FieldEditWidget` (the grid's inline + * editor). The public form page's own pin lives beside `FormPage.tsx`. + */ + +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +// Module scope: pulls in the form renderer's registration side effect. +import '@object-ui/components'; +import { LocalizationProvider } from '@object-ui/i18n'; +import { CurrencyField } from '../widgets/CurrencyField'; +import { FieldEditWidget } from '../FieldEditWidget'; + +beforeAll(() => { + ComponentRegistry.register('currency', CurrencyField as any, { + namespace: 'field', + skipFallback: true, + }); +}, 30000); + +afterEach(() => cleanup()); + +const box = (container: HTMLElement): HTMLInputElement => { + const el = container.querySelector('input[type=number]'); + if (!el) throw new Error('no currency input rendered'); + return el as HTMLInputElement; +}; + +/** The attribute as the browser would show it: absent and empty read alike. */ +const shownPlaceholder = (container: HTMLElement) => box(container).getAttribute('placeholder') ?? ''; + +const renderWidget = (field: Record, value: number | null = null) => + render( + + + , + ); + +describe('CurrencyField — an empty box shows no numeric stand-in (objectstack#27398 row 2)', () => { + it('an empty field that declares no placeholder shows none', () => { + const { container } = renderWidget({ name: 'annual_revenue', currency: 'USD' }); + expect(box(container).value).toBe(''); + expect(shownPlaceholder(container)).toBe(''); + }); + + it('a zero-decimal currency is not offered a two-decimal stand-in either', () => { + const { container } = renderWidget({ name: 'budget', currency: 'JPY' }); + expect(shownPlaceholder(container)).toBe(''); + }); + + it('an authored empty placeholder stays empty rather than being replaced', () => { + const { container } = renderWidget({ name: 'annual_revenue', currency: 'USD', placeholder: '' }); + expect(shownPlaceholder(container)).toBe(''); + }); + + it("CONTROL — the field's own placeholder is still shown", () => { + const { container } = renderWidget({ name: 'annual_revenue', currency: 'USD', placeholder: 'e.g. 250000' }); + expect(shownPlaceholder(container)).toBe('e.g. 250000'); + }); + + it('CONTROL — a real zero still shows as a value', () => { + const { container } = renderWidget({ name: 'annual_revenue', currency: 'USD' }, 0); + expect(box(container).value).toBe('0'); + }); +}); + +describe('the hosts that mount CurrencyField hand it no stand-in of their own (objectstack#27398 row 2)', () => { + it('the form renderer (record form) draws an empty optional amount with no placeholder', () => { + const Form = ComponentRegistry.get('form')!; + const { container } = render( +
{}, + }} + />, + ); + const row = container.querySelector('[data-field="annual_revenue"]') as HTMLElement | null; + if (!row) throw new Error('the form renderer drew no annual_revenue row'); + expect(shownPlaceholder(row)).toBe(''); + }); + + it('FieldEditWidget (the inline editor) draws an empty amount with no placeholder', () => { + const { container } = render( + {}} + />, + ); + expect(shownPlaceholder(container)).toBe(''); + }); +}); diff --git a/ui/packages/fields/src/widgets/CurrencyField.tsx b/ui/packages/fields/src/widgets/CurrencyField.tsx index 93d429a066c..9e0971a0318 100644 --- a/ui/packages/fields/src/widgets/CurrencyField.tsx +++ b/ui/packages/fields/src/widgets/CurrencyField.tsx @@ -185,7 +185,10 @@ export function CurrencyField({ value, onChange, field, readonly, error, classNa onChange(val as any); }} onBlur={handleBlur} - placeholder={currencyField?.placeholder || '0.00'} + // The field's own placeholder, or none: an empty box must read as + // empty. A numeric stand-in such as `0.00` reads as a filled-in zero + // (and carries the wrong width for a JPY or KWD field). + placeholder={currencyField?.placeholder} disabled={readonly || props.disabled} className={cn(symbol ? 'pl-8' : '', refusal ? BAD_INPUT_BORDER : '', className)} // Surface the field's declared range (e.g. `min: 0` on a budget) so the From 6712189da0116ddace7f439526ffd630d80472d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 06:29:23 +0000 Subject: [PATCH 2/3] test(console): pin the public form's empty currency box, and add the changeset Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Utp6CZBg559ZS19rzt9ki8 --- .../27398-currency-empty-placeholder.md | 10 ++ ...ormPage.currencyPlaceholder-27398.test.tsx | 98 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 .changeset/27398-currency-empty-placeholder.md create mode 100644 ui/apps/console/src/components/FormPage.currencyPlaceholder-27398.test.tsx diff --git a/.changeset/27398-currency-empty-placeholder.md b/.changeset/27398-currency-empty-placeholder.md new file mode 100644 index 00000000000..08401a71907 --- /dev/null +++ b/.changeset/27398-currency-empty-placeholder.md @@ -0,0 +1,10 @@ +--- +'@object-ui/fields': patch +--- + +fix(fields): an empty currency box shows the field's own placeholder, or none, instead of `0.00` (#27398) + +Clause-②: no +Metadata-protocol: none + +The edit-mode `CurrencyField` fell back to the placeholder `0.00` whenever the field declared none, so an optional amount nobody had filled in (a public form's Annual Revenue) read as a filled-in zero, and a JPY or KWD field was offered a two-decimal stand-in. It now shows the field's `placeholder` when one is declared and nothing otherwise, as `NumberField` does. It reaches every host that draws the widget: the public form, the record form and the grid's inline editor. A real zero still shows as `0`, and an authored placeholder (including one a form row overrides) is shown as before. diff --git a/ui/apps/console/src/components/FormPage.currencyPlaceholder-27398.test.tsx b/ui/apps/console/src/components/FormPage.currencyPlaceholder-27398.test.tsx new file mode 100644 index 00000000000..1a0e36bc986 --- /dev/null +++ b/ui/apps/console/src/components/FormPage.currencyPlaceholder-27398.test.tsx @@ -0,0 +1,98 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack-ai/objectstack#27398 row 2 — on the public form, an empty + * optional currency field reads as empty. + * + * Measured on HotCRM's Contact Us form: the optional Annual Revenue box showed + * `0.00` before anyone typed, which reads as an answer. The page hands the + * shared `CurrencyField` the row's merged `placeholder`; the widget used to + * fall back to `0.00` when that was absent. These pins drive the real public + * route (`/f/:slug`) over a served payload. + */ + +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { FormPage } from './FormPage'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const LEAD_SCHEMA = { + name: 'crm_lead', + label: 'Lead', + fields: { + company: { type: 'text', label: 'Company', required: true }, + annual_revenue: { type: 'currency', label: 'Annual Revenue', currency: 'USD' }, + budget: { type: 'currency', label: 'Budget', currency: 'USD', placeholder: 'Rough figure' }, + }, +}; + +function stubFetch(body: unknown) { + return vi.fn(async (url: string) => { + if (!String(url).includes('/forms/contact-us')) throw new Error(`unstubbed fetch: ${url}`); + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response; + }); +} + +function renderPublic(fields: unknown[]) { + vi.stubGlobal( + 'fetch', + stubFetch({ + slug: 'contact-us', + object: 'crm_lead', + form: { type: 'simple', title: 'Contact us', sections: [{ name: 'about', label: 'About you', fields }] }, + objectSchema: LEAD_SCHEMA, + }), + ); + return render( + + + } /> + + , + ); +} + +/** The currency box of one row, once its lazy widget has resolved. */ +async function currencyBox(container: HTMLElement, name: string): Promise { + let el: HTMLInputElement | null = null; + await waitFor(() => { + el = container.querySelector(`[data-field="${name}"] input[type=number]`) + ?? container.querySelector(`input[type=number][name="${name}"]`); + expect(el).not.toBeNull(); + }); + return el!; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('public form — an empty currency field shows no 0.00 stand-in (objectstack#27398 row 2)', () => { + it('an optional amount with no placeholder renders an empty box with no placeholder', async () => { + const { container } = renderPublic(['company', 'annual_revenue']); + const input = await currencyBox(container, 'annual_revenue'); + expect(input.value).toBe(''); + expect(input.getAttribute('placeholder') ?? '').toBe(''); + }); + + it("CONTROL — the object field's own placeholder still reaches the box", async () => { + const { container } = renderPublic(['budget']); + const input = await currencyBox(container, 'budget'); + expect(input).toHaveAttribute('placeholder', 'Rough figure'); + }); + + it("CONTROL — the form row's placeholder override still wins", async () => { + const { container } = renderPublic([{ field: 'annual_revenue', placeholder: 'Optional' }]); + const input = await currencyBox(container, 'annual_revenue'); + expect(input).toHaveAttribute('placeholder', 'Optional'); + }); +}); From e2b63076a89a7247cef8203c2549315312f919dc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 07:21:19 +0000 Subject: [PATCH 3/3] fix(fields): an empty percent box shows the field's own placeholder, or none PercentField fell back to the placeholder 0, which beside its % adornment read as a filled-in 0%. Same shape as the CurrencyField fix. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Utp6CZBg559ZS19rzt9ki8 --- .../27398-currency-empty-placeholder.md | 4 +- ...rcentField.emptyPlaceholder-27398.test.tsx | 72 +++++++++++++++++++ .../fields/src/widgets/PercentField.tsx | 4 +- 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 ui/packages/fields/src/__tests__/PercentField.emptyPlaceholder-27398.test.tsx diff --git a/.changeset/27398-currency-empty-placeholder.md b/.changeset/27398-currency-empty-placeholder.md index 08401a71907..62f7a68c25a 100644 --- a/.changeset/27398-currency-empty-placeholder.md +++ b/.changeset/27398-currency-empty-placeholder.md @@ -2,9 +2,11 @@ '@object-ui/fields': patch --- -fix(fields): an empty currency box shows the field's own placeholder, or none, instead of `0.00` (#27398) +fix(fields): an empty currency or percent box shows the field's own placeholder, or none, instead of `0.00` / `0` (#27398) Clause-②: no Metadata-protocol: none The edit-mode `CurrencyField` fell back to the placeholder `0.00` whenever the field declared none, so an optional amount nobody had filled in (a public form's Annual Revenue) read as a filled-in zero, and a JPY or KWD field was offered a two-decimal stand-in. It now shows the field's `placeholder` when one is declared and nothing otherwise, as `NumberField` does. It reaches every host that draws the widget: the public form, the record form and the grid's inline editor. A real zero still shows as `0`, and an authored placeholder (including one a form row overrides) is shown as before. + +The edit-mode `PercentField` had the same fallback, the placeholder `0`, which beside its `%` adornment read as a filled-in 0%; it now shows the field's `placeholder` or nothing, the same way. diff --git a/ui/packages/fields/src/__tests__/PercentField.emptyPlaceholder-27398.test.tsx b/ui/packages/fields/src/__tests__/PercentField.emptyPlaceholder-27398.test.tsx new file mode 100644 index 00000000000..e9628a9187a --- /dev/null +++ b/ui/packages/fields/src/__tests__/PercentField.emptyPlaceholder-27398.test.tsx @@ -0,0 +1,72 @@ +/** + * 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. + */ + +/** + * objectstack-ai/objectstack#27398 row 2, same family — an EMPTY percent box + * reads as empty. + * + * The edit-mode input fell back to the placeholder `0` whenever the field + * declared none, which beside the `%` adornment reads as a filled-in 0%. The + * widget now shows the field's own `placeholder`, or none, as `CurrencyField` + * and `NumberField` do. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { PercentField } from '../widgets/PercentField'; +import { FieldEditWidget } from '../FieldEditWidget'; + +afterEach(() => cleanup()); + +const box = (container: HTMLElement): HTMLInputElement => { + const el = container.querySelector('input[type=number]'); + if (!el) throw new Error('no percent input rendered'); + return el as HTMLInputElement; +}; + +/** The attribute as the browser would show it: absent and empty read alike. */ +const shownPlaceholder = (container: HTMLElement) => box(container).getAttribute('placeholder') ?? ''; + +const renderWidget = (field: Record, value: number | null = null) => + render(); + +describe('PercentField — an empty box shows no numeric stand-in (objectstack#27398 row 2)', () => { + it('an empty field that declares no placeholder shows none', () => { + const { container } = renderWidget({ name: 'discount' }); + expect(box(container).value).toBe(''); + expect(shownPlaceholder(container)).toBe(''); + }); + + it('an authored empty placeholder stays empty rather than being replaced', () => { + const { container } = renderWidget({ name: 'discount', placeholder: '' }); + expect(shownPlaceholder(container)).toBe(''); + }); + + it('FieldEditWidget (the inline editor) draws an empty percent with no placeholder', () => { + const { container } = render( + {}} + />, + ); + expect(shownPlaceholder(container)).toBe(''); + }); + + it("CONTROL — the field's own placeholder is still shown", () => { + const { container } = renderWidget({ name: 'discount', placeholder: 'e.g. 15' }); + expect(shownPlaceholder(container)).toBe('e.g. 15'); + }); + + it('CONTROL — a real zero still shows as a value', () => { + const { container } = renderWidget({ name: 'discount' }, 0); + expect(box(container).value).toBe('0'); + }); +}); diff --git a/ui/packages/fields/src/widgets/PercentField.tsx b/ui/packages/fields/src/widgets/PercentField.tsx index c08553c97d5..1c1575bb18a 100644 --- a/ui/packages/fields/src/widgets/PercentField.tsx +++ b/ui/packages/fields/src/widgets/PercentField.tsx @@ -272,7 +272,9 @@ export function PercentField({ value, onChange, field, readonly, error, classNam value={displayValue} onChange={handleChange} onBlur={handleBlur} - placeholder={percentField?.placeholder || '0'} + // The field's own placeholder, or none: an empty box must read as + // empty, and a `0` stand-in beside the `%` reads as a filled-in 0%. + placeholder={percentField?.placeholder} disabled={readonly || props.disabled} className={cn('pr-8', refusal ? BAD_INPUT_BORDER : '', className)} step={Math.pow(10, -scale).toFixed(scale)}