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
12 changes: 12 additions & 0 deletions .changeset/27398-currency-empty-placeholder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@object-ui/fields': patch
---

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.
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={['/f/contact-us']}>
<Routes>
<Route path="/f/:slug" element={<FormPage mode="public" />} />
</Routes>
</MemoryRouter>,
);
}

/** The currency box of one row, once its lazy widget has resolved. */
async function currencyBox(container: HTMLElement, name: string): Promise<HTMLInputElement> {
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');
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, value: number | null = null) =>
render(
<LocalizationProvider value={{ locale: 'en' }}>
<CurrencyField value={value as any} onChange={vi.fn()} field={{ type: 'currency', ...field } as any} />
</LocalizationProvider>,
);

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(
<Form
schema={{
type: 'form',
mode: 'create',
showSubmit: true,
showCancel: false,
defaultValues: {},
fields: [{ name: 'annual_revenue', label: 'Annual Revenue', type: 'currency', currency: 'USD' }],
onSubmit: () => {},
}}
/>,
);
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(
<FieldEditWidget
field={{ name: 'annual_revenue', type: 'currency', label: 'Annual Revenue', currency: 'USD' } as never}
value={null}
onChange={() => {}}
/>,
);
expect(shownPlaceholder(container)).toBe('');
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, value: number | null = null) =>
render(<PercentField value={value as any} onChange={vi.fn()} field={{ type: 'percent', ...field } as any} />);

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(
<FieldEditWidget
field={{ name: 'discount', type: 'percent', label: 'Discount' } as never}
value={null}
onChange={() => {}}
/>,
);
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');
});
});
5 changes: 4 additions & 1 deletion ui/packages/fields/src/widgets/CurrencyField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion ui/packages/fields/src/widgets/PercentField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
Loading