Skip to content

Commit e199adf

Browse files
fix(fields): an empty currency box shows the field's own placeholder, or none, instead of 0.00 (#27398) (#28003)
domain:ui#3 · os-bill · session_01Utp6CaJoinvGQSbHeDaWBe <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4i9JiKjEjwtuVzd4gKy9hB)_ ## 维护者速读 - 改了什么:金额输入框为空时不再显示占位符「0.00」(百分比输入框的「0」同样去掉),只显示字段自己声明的占位符,没有就留空。公开表单、记录表单、表格内联编辑三处都生效。 - 为什么改:HotCRM 的「联系我们」公开表单里,没人填的「年收入」看起来像已经填了 0;日元这类无小数的币种还被提示两位小数。 - 风险与回滚:一行改动,形状与数字字段(NumberField)一致;字段自带的占位符、表单行覆盖的占位符、真实的 0 都照旧显示。回滚即还原这一行。 - 先看:`ui/packages/fields/src/widgets/CurrencyField.tsx`(placeholder 那一行);`ui/packages/fields/src/__tests__/CurrencyField.emptyPlaceholder-27398.test.tsx`;`ui/apps/console/src/components/FormPage.currencyPlaceholder-27398.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/fields/src/__tests__/CurrencyField.emptyPlaceholder-27398.test.tsx apps/console/src/components/FormPage.currencyPlaceholder-27398.test.tsx`,预期 2 个文件 10 个用例全绿。 - 需要你做的:无,席位落地。 Fixes #27398 Clause-②: no Metadata-protocol: none ## What changes Row 2 of #27398 (row 1 is #27606; row 3 is carded, see Acceptance notes). - `CurrencyField` (edit mode) passed `placeholder={field.placeholder || '0.00'}` to its number input, so an empty optional amount with no declared placeholder showed `0.00`, which reads as a filled-in zero. An authored empty placeholder was replaced by `0.00` too, and a zero-decimal currency (JPY) was offered a two-decimal stand-in. It now passes the field's own `placeholder`, or none, the same shape `NumberField` has. - One change point covers every host: the public form page (`FormPage`, through the lazy field-widget registry, handing the row's merged placeholder), the record form (the form renderer's `currency` widget) and the grid's inline editor (`FieldEditWidget`) all draw this widget with the field they were handed. No host passes a placeholder of its own that the old fallback was standing in for. - Hosts that relied on the `0.00` hint, measured on `origin/main` at 4a5396b: none. No test, e2e spec, doc or README asserts it; the only `0.00` placeholders in the tree are authored ones (`ui/examples/schema-catalog` currency and number samples, a FlowRunner `number` field fixture), and an authored placeholder is still shown. - Changeset: `@object-ui/fields` patch. ## Tests - `ui/packages/fields/src/__tests__/CurrencyField.emptyPlaceholder-27398.test.tsx` (7 cases): the widget with no placeholder, with JPY, with an authored empty placeholder, two controls (authored placeholder shown, a real zero shown as a value), and the two package hosts: the form renderer and `FieldEditWidget`. - `ui/apps/console/src/components/FormPage.currencyPlaceholder-27398.test.tsx` (3 cases): the real public route `/f/:slug` over a served payload: an empty optional amount has no placeholder; controls for the object field's placeholder and the form row's override. <details><summary>Runs</summary> - New files: `Test Files 2 passed (2)`, `Tests 10 passed (10)`. - Ablation (fix reverted to the `|| '0.00'` fallback through `scripts/ablation-replace.mjs`, restored with blob equal to HEAD and an empty `git diff HEAD`): `Tests 6 failed | 4 passed (10)`. The 6 reds are every no-placeholder pin across the widget, both package hosts and the public form; the 4 greens are the controls. - `pnpm --dir ui exec vitest run packages/fields/`: `Test Files 285 passed | 1 skipped (286)`, `Tests 4349 passed | 7 skipped (4356)`. - `pnpm --filter @object-ui/fields run type-check`: exit 0. </details> ## Acceptance notes Per-row disposition of #27398 rows 2–3, re-measured on `origin/main` at 4a5396b: | Row | Bucket | Reason | |---|---|---| | 2 · empty currency shows `0.00` | TAKE | This PR. | | 3a · thank-you text cannot name the created record | CARD (spec, then server) | The thank-you `title` / `message` are plain strings with no template slot, and `POST /api/v1/forms/:slug/submit` answers `{ id }` only on purpose: the form-routes comment says the stored row can carry values an elevated hook derived from records the anonymous grant may never read, so the answer echoes nothing the insert stored. Naming a case number re-opens that trade-off, which is a contract decision, not a renderer change. | | 3b · `submitBehavior` text is not translated | CARD (spec) | Not ui-only. The translation bundle's `_views.VIEW` entry (strict) declares `label`, `description`, `title`, `emptyState` and `bulkActions`, and no `submitBehavior` copy; `translateView` overlays only `bulkActionDefs`, `sections` and `title`. The public form door already serves the translated view config under `Accept-Language`, and the page reads `form.submitBehavior` from it, so a spec-side key plus overlay reaches both form pages with no ui change. | The seat filed both cards: 3a is #28005, 3b is #28004. `PercentField` fell back to the placeholder `0` the same way; on the seat's rework it is fixed here too, as one line in `PercentField.tsx` pinned by `PercentField.emptyPlaceholder-27398.test.tsx`, and the changeset names it. ## Verification Local gate subset derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff, each recorded with its exit code before any pipe and reconciled with `--ran`; every family CI runs is read from this PR's check-runs. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZBg559ZS19rzt9ki8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 10cbfac commit e199adf

6 files changed

Lines changed: 308 additions & 2 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/fields': patch
3+
---
4+
5+
fix(fields): an empty currency or percent box shows the field's own placeholder, or none, instead of `0.00` / `0` (#27398)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
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.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack-ai/objectstack#27398 row 2 — on the public form, an empty
5+
* optional currency field reads as empty.
6+
*
7+
* Measured on HotCRM's Contact Us form: the optional Annual Revenue box showed
8+
* `0.00` before anyone typed, which reads as an answer. The page hands the
9+
* shared `CurrencyField` the row's merged `placeholder`; the widget used to
10+
* fall back to `0.00` when that was absent. These pins drive the real public
11+
* route (`/f/:slug`) over a served payload.
12+
*/
13+
14+
import { describe, expect, it, vi, afterEach } from 'vitest';
15+
import { render, waitFor } from '@testing-library/react';
16+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
17+
import { FormPage } from './FormPage';
18+
19+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
20+
21+
const LEAD_SCHEMA = {
22+
name: 'crm_lead',
23+
label: 'Lead',
24+
fields: {
25+
company: { type: 'text', label: 'Company', required: true },
26+
annual_revenue: { type: 'currency', label: 'Annual Revenue', currency: 'USD' },
27+
budget: { type: 'currency', label: 'Budget', currency: 'USD', placeholder: 'Rough figure' },
28+
},
29+
};
30+
31+
function stubFetch(body: unknown) {
32+
return vi.fn(async (url: string) => {
33+
if (!String(url).includes('/forms/contact-us')) throw new Error(`unstubbed fetch: ${url}`);
34+
return {
35+
ok: true,
36+
status: 200,
37+
statusText: 'OK',
38+
json: async () => body,
39+
text: async () => JSON.stringify(body),
40+
} as unknown as Response;
41+
});
42+
}
43+
44+
function renderPublic(fields: unknown[]) {
45+
vi.stubGlobal(
46+
'fetch',
47+
stubFetch({
48+
slug: 'contact-us',
49+
object: 'crm_lead',
50+
form: { type: 'simple', title: 'Contact us', sections: [{ name: 'about', label: 'About you', fields }] },
51+
objectSchema: LEAD_SCHEMA,
52+
}),
53+
);
54+
return render(
55+
<MemoryRouter initialEntries={['/f/contact-us']}>
56+
<Routes>
57+
<Route path="/f/:slug" element={<FormPage mode="public" />} />
58+
</Routes>
59+
</MemoryRouter>,
60+
);
61+
}
62+
63+
/** The currency box of one row, once its lazy widget has resolved. */
64+
async function currencyBox(container: HTMLElement, name: string): Promise<HTMLInputElement> {
65+
let el: HTMLInputElement | null = null;
66+
await waitFor(() => {
67+
el = container.querySelector(`[data-field="${name}"] input[type=number]`)
68+
?? container.querySelector(`input[type=number][name="${name}"]`);
69+
expect(el).not.toBeNull();
70+
});
71+
return el!;
72+
}
73+
74+
afterEach(() => {
75+
vi.unstubAllGlobals();
76+
vi.restoreAllMocks();
77+
});
78+
79+
describe('public form — an empty currency field shows no 0.00 stand-in (objectstack#27398 row 2)', () => {
80+
it('an optional amount with no placeholder renders an empty box with no placeholder', async () => {
81+
const { container } = renderPublic(['company', 'annual_revenue']);
82+
const input = await currencyBox(container, 'annual_revenue');
83+
expect(input.value).toBe('');
84+
expect(input.getAttribute('placeholder') ?? '').toBe('');
85+
});
86+
87+
it("CONTROL — the object field's own placeholder still reaches the box", async () => {
88+
const { container } = renderPublic(['budget']);
89+
const input = await currencyBox(container, 'budget');
90+
expect(input).toHaveAttribute('placeholder', 'Rough figure');
91+
});
92+
93+
it("CONTROL — the form row's placeholder override still wins", async () => {
94+
const { container } = renderPublic([{ field: 'annual_revenue', placeholder: 'Optional' }]);
95+
const input = await currencyBox(container, 'annual_revenue');
96+
expect(input).toHaveAttribute('placeholder', 'Optional');
97+
});
98+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack-ai/objectstack#27398 row 2 — an EMPTY currency box reads as
11+
* empty.
12+
*
13+
* The edit-mode input used to fall back to the placeholder `0.00` whenever the
14+
* field declared none, so an optional amount nobody filled in (a public form's
15+
* Annual Revenue) looked like a filled-in zero. The widget now shows the
16+
* field's own `placeholder`, or none — the shape `NumberField` already has.
17+
*
18+
* Every host draws this one widget with the field it was handed, so the pins
19+
* below cover the widget itself and the two package hosts that mount it: the
20+
* form renderer (record form) and `FieldEditWidget` (the grid's inline
21+
* editor). The public form page's own pin lives beside `FormPage.tsx`.
22+
*/
23+
24+
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
25+
import React from 'react';
26+
import { render, cleanup } from '@testing-library/react';
27+
import '@testing-library/jest-dom';
28+
import { ComponentRegistry } from '@object-ui/core';
29+
// Module scope: pulls in the form renderer's registration side effect.
30+
import '@object-ui/components';
31+
import { LocalizationProvider } from '@object-ui/i18n';
32+
import { CurrencyField } from '../widgets/CurrencyField';
33+
import { FieldEditWidget } from '../FieldEditWidget';
34+
35+
beforeAll(() => {
36+
ComponentRegistry.register('currency', CurrencyField as any, {
37+
namespace: 'field',
38+
skipFallback: true,
39+
});
40+
}, 30000);
41+
42+
afterEach(() => cleanup());
43+
44+
const box = (container: HTMLElement): HTMLInputElement => {
45+
const el = container.querySelector('input[type=number]');
46+
if (!el) throw new Error('no currency input rendered');
47+
return el as HTMLInputElement;
48+
};
49+
50+
/** The attribute as the browser would show it: absent and empty read alike. */
51+
const shownPlaceholder = (container: HTMLElement) => box(container).getAttribute('placeholder') ?? '';
52+
53+
const renderWidget = (field: Record<string, unknown>, value: number | null = null) =>
54+
render(
55+
<LocalizationProvider value={{ locale: 'en' }}>
56+
<CurrencyField value={value as any} onChange={vi.fn()} field={{ type: 'currency', ...field } as any} />
57+
</LocalizationProvider>,
58+
);
59+
60+
describe('CurrencyField — an empty box shows no numeric stand-in (objectstack#27398 row 2)', () => {
61+
it('an empty field that declares no placeholder shows none', () => {
62+
const { container } = renderWidget({ name: 'annual_revenue', currency: 'USD' });
63+
expect(box(container).value).toBe('');
64+
expect(shownPlaceholder(container)).toBe('');
65+
});
66+
67+
it('a zero-decimal currency is not offered a two-decimal stand-in either', () => {
68+
const { container } = renderWidget({ name: 'budget', currency: 'JPY' });
69+
expect(shownPlaceholder(container)).toBe('');
70+
});
71+
72+
it('an authored empty placeholder stays empty rather than being replaced', () => {
73+
const { container } = renderWidget({ name: 'annual_revenue', currency: 'USD', placeholder: '' });
74+
expect(shownPlaceholder(container)).toBe('');
75+
});
76+
77+
it("CONTROL — the field's own placeholder is still shown", () => {
78+
const { container } = renderWidget({ name: 'annual_revenue', currency: 'USD', placeholder: 'e.g. 250000' });
79+
expect(shownPlaceholder(container)).toBe('e.g. 250000');
80+
});
81+
82+
it('CONTROL — a real zero still shows as a value', () => {
83+
const { container } = renderWidget({ name: 'annual_revenue', currency: 'USD' }, 0);
84+
expect(box(container).value).toBe('0');
85+
});
86+
});
87+
88+
describe('the hosts that mount CurrencyField hand it no stand-in of their own (objectstack#27398 row 2)', () => {
89+
it('the form renderer (record form) draws an empty optional amount with no placeholder', () => {
90+
const Form = ComponentRegistry.get('form')!;
91+
const { container } = render(
92+
<Form
93+
schema={{
94+
type: 'form',
95+
mode: 'create',
96+
showSubmit: true,
97+
showCancel: false,
98+
defaultValues: {},
99+
fields: [{ name: 'annual_revenue', label: 'Annual Revenue', type: 'currency', currency: 'USD' }],
100+
onSubmit: () => {},
101+
}}
102+
/>,
103+
);
104+
const row = container.querySelector('[data-field="annual_revenue"]') as HTMLElement | null;
105+
if (!row) throw new Error('the form renderer drew no annual_revenue row');
106+
expect(shownPlaceholder(row)).toBe('');
107+
});
108+
109+
it('FieldEditWidget (the inline editor) draws an empty amount with no placeholder', () => {
110+
const { container } = render(
111+
<FieldEditWidget
112+
field={{ name: 'annual_revenue', type: 'currency', label: 'Annual Revenue', currency: 'USD' } as never}
113+
value={null}
114+
onChange={() => {}}
115+
/>,
116+
);
117+
expect(shownPlaceholder(container)).toBe('');
118+
});
119+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack-ai/objectstack#27398 row 2, same family — an EMPTY percent box
11+
* reads as empty.
12+
*
13+
* The edit-mode input fell back to the placeholder `0` whenever the field
14+
* declared none, which beside the `%` adornment reads as a filled-in 0%. The
15+
* widget now shows the field's own `placeholder`, or none, as `CurrencyField`
16+
* and `NumberField` do.
17+
*/
18+
19+
import { describe, it, expect, vi, afterEach } from 'vitest';
20+
import React from 'react';
21+
import { render, cleanup } from '@testing-library/react';
22+
import '@testing-library/jest-dom';
23+
import { PercentField } from '../widgets/PercentField';
24+
import { FieldEditWidget } from '../FieldEditWidget';
25+
26+
afterEach(() => cleanup());
27+
28+
const box = (container: HTMLElement): HTMLInputElement => {
29+
const el = container.querySelector('input[type=number]');
30+
if (!el) throw new Error('no percent input rendered');
31+
return el as HTMLInputElement;
32+
};
33+
34+
/** The attribute as the browser would show it: absent and empty read alike. */
35+
const shownPlaceholder = (container: HTMLElement) => box(container).getAttribute('placeholder') ?? '';
36+
37+
const renderWidget = (field: Record<string, unknown>, value: number | null = null) =>
38+
render(<PercentField value={value as any} onChange={vi.fn()} field={{ type: 'percent', ...field } as any} />);
39+
40+
describe('PercentField — an empty box shows no numeric stand-in (objectstack#27398 row 2)', () => {
41+
it('an empty field that declares no placeholder shows none', () => {
42+
const { container } = renderWidget({ name: 'discount' });
43+
expect(box(container).value).toBe('');
44+
expect(shownPlaceholder(container)).toBe('');
45+
});
46+
47+
it('an authored empty placeholder stays empty rather than being replaced', () => {
48+
const { container } = renderWidget({ name: 'discount', placeholder: '' });
49+
expect(shownPlaceholder(container)).toBe('');
50+
});
51+
52+
it('FieldEditWidget (the inline editor) draws an empty percent with no placeholder', () => {
53+
const { container } = render(
54+
<FieldEditWidget
55+
field={{ name: 'discount', type: 'percent', label: 'Discount' } as never}
56+
value={null}
57+
onChange={() => {}}
58+
/>,
59+
);
60+
expect(shownPlaceholder(container)).toBe('');
61+
});
62+
63+
it("CONTROL — the field's own placeholder is still shown", () => {
64+
const { container } = renderWidget({ name: 'discount', placeholder: 'e.g. 15' });
65+
expect(shownPlaceholder(container)).toBe('e.g. 15');
66+
});
67+
68+
it('CONTROL — a real zero still shows as a value', () => {
69+
const { container } = renderWidget({ name: 'discount' }, 0);
70+
expect(box(container).value).toBe('0');
71+
});
72+
});

‎ui/packages/fields/src/widgets/CurrencyField.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,10 @@ export function CurrencyField({ value, onChange, field, readonly, error, classNa
185185
onChange(val as any);
186186
}}
187187
onBlur={handleBlur}
188-
placeholder={currencyField?.placeholder || '0.00'}
188+
// The field's own placeholder, or none: an empty box must read as
189+
// empty. A numeric stand-in such as `0.00` reads as a filled-in zero
190+
// (and carries the wrong width for a JPY or KWD field).
191+
placeholder={currencyField?.placeholder}
189192
disabled={readonly || props.disabled}
190193
className={cn(symbol ? 'pl-8' : '', refusal ? BAD_INPUT_BORDER : '', className)}
191194
// Surface the field's declared range (e.g. `min: 0` on a budget) so the

‎ui/packages/fields/src/widgets/PercentField.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,9 @@ export function PercentField({ value, onChange, field, readonly, error, classNam
272272
value={displayValue}
273273
onChange={handleChange}
274274
onBlur={handleBlur}
275-
placeholder={percentField?.placeholder || '0'}
275+
// The field's own placeholder, or none: an empty box must read as
276+
// empty, and a `0` stand-in beside the `%` reads as a filled-in 0%.
277+
placeholder={percentField?.placeholder}
276278
disabled={readonly || props.disabled}
277279
className={cn('pr-8', refusal ? BAD_INPUT_BORDER : '', className)}
278280
step={Math.pow(10, -scale).toFixed(scale)}

0 commit comments

Comments
 (0)