|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectstack#26579 — a flow screen's `date` / `datetime` and `boolean` fields |
| 5 | + * draw the platform controls the record form and `ActionParamDialog` draw. |
| 6 | + * |
| 7 | + * A date field was the browser's native input (`mm/dd/yyyy` under zh-CN), a |
| 8 | + * date-time field a bare text box, and a boolean a lone checkbox on the line |
| 9 | + * under its label. Now a date or date-time field is the record form's picker |
| 10 | + * in the display locale's format, writing the stored shape the native input |
| 11 | + * wrote, and a boolean is `ActionParamDialog`'s inline row: the checkbox with |
| 12 | + * its label beside it and the help text under the row. |
| 13 | + */ |
| 14 | + |
| 15 | +import { describe, it, expect, afterEach, vi } from 'vitest'; |
| 16 | +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; |
| 17 | +import { I18nProvider } from '@object-ui/i18n'; |
| 18 | +// Module scope, per AGENTS.md 测试纪律: the date, date-time and boolean arms |
| 19 | +// reach their widgets through `React.lazy` factories inside `@object-ui/fields`; |
| 20 | +// importing the barrel here resolves them before any test's `findBy` budget. |
| 21 | +import '@object-ui/fields'; |
| 22 | +import { ScreenView, type ScreenSpec } from './ScreenView'; |
| 23 | + |
| 24 | +afterEach(cleanup); |
| 25 | + |
| 26 | +function specOf(fields: ScreenSpec['fields']): ScreenSpec { |
| 27 | + return { nodeId: 'n1', title: 'Step', fields } as ScreenSpec; |
| 28 | +} |
| 29 | + |
| 30 | +function renderScreen( |
| 31 | + fields: ScreenSpec['fields'], |
| 32 | + extra: { values?: Record<string, unknown>; onValueChange?: (name: string, v: unknown) => void } = {}, |
| 33 | +) { |
| 34 | + return render( |
| 35 | + <ScreenView screen={specOf(fields)} values={extra.values ?? {}} onValueChange={extra.onValueChange ?? (() => {})} />, |
| 36 | + ); |
| 37 | +} |
| 38 | + |
| 39 | +const typeAndCommit = (input: HTMLElement, text: string) => { |
| 40 | + fireEvent.change(input, { target: { value: text } }); |
| 41 | + fireEvent.blur(input); |
| 42 | +}; |
| 43 | + |
| 44 | +describe('objectstack#26579 — a `date` screen field is the platform picker', () => { |
| 45 | + it('draws the locale-format text box and calendar, never input[type=date]', async () => { |
| 46 | + const { container } = renderScreen([{ name: 'start', label: 'Start', type: 'date' }], { values: { start: '2026-10-16' } }); |
| 47 | + const input = await screen.findByRole('textbox', { name: 'Start' }); |
| 48 | + expect(container.querySelector('input[type="date"]')).toBeNull(); |
| 49 | + expect(input.closest('[data-slot="date-picker"]')).not.toBeNull(); |
| 50 | + expect(input).toHaveValue('10/16/2026'); |
| 51 | + expect(screen.getByTestId('date-picker-trigger')).toBeTruthy(); |
| 52 | + }); |
| 53 | + |
| 54 | + it('a zh session reads and shows the zh format', async () => { |
| 55 | + render( |
| 56 | + <I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }} persistLanguage={false}> |
| 57 | + <ScreenView screen={specOf([{ name: 'start', label: 'Start', type: 'date' }])} values={{ start: '2026-10-16' }} onValueChange={() => {}} /> |
| 58 | + </I18nProvider>, |
| 59 | + ); |
| 60 | + const input = await screen.findByRole('textbox', { name: 'Start' }); |
| 61 | + expect(input).toHaveValue('2026/10/16'); |
| 62 | + expect(input).toHaveAttribute('placeholder', 'YYYY/MM/DD'); |
| 63 | + }); |
| 64 | + |
| 65 | + it('typed text is written as the ISO day the native input wrote', async () => { |
| 66 | + const onValueChange = vi.fn(); |
| 67 | + renderScreen([{ name: 'start', label: 'Start', type: 'date' }], { onValueChange }); |
| 68 | + typeAndCommit(await screen.findByRole('textbox', { name: 'Start' }), '10/20/2026'); |
| 69 | + expect(onValueChange).toHaveBeenCalledWith('start', '2026-10-20'); |
| 70 | + }); |
| 71 | + |
| 72 | + it('clearing writes the empty string, as the native input did', async () => { |
| 73 | + const onValueChange = vi.fn(); |
| 74 | + renderScreen([{ name: 'start', label: 'Start', type: 'date' }], { values: { start: '2026-10-16' }, onValueChange }); |
| 75 | + await screen.findByRole('textbox', { name: 'Start' }); |
| 76 | + fireEvent.click(screen.getByTestId('date-picker-clear')); |
| 77 | + expect(onValueChange).toHaveBeenCalledWith('start', ''); |
| 78 | + }); |
| 79 | + |
| 80 | + it('keeps the required state and the help text on the control', async () => { |
| 81 | + renderScreen([{ name: 'start', label: 'Start', type: 'date', required: true, inlineHelpText: 'Start help' }]); |
| 82 | + const input = await screen.findByRole('textbox', { name: 'Start' }); |
| 83 | + expect(input).toHaveAttribute('aria-required', 'true'); |
| 84 | + expect(input).toHaveAccessibleDescription('Start help'); |
| 85 | + }); |
| 86 | +}); |
| 87 | + |
| 88 | +describe('objectstack#26579 — a `datetime` screen field is the platform date-time picker', () => { |
| 89 | + it('draws the picker and writes an ISO instant', async () => { |
| 90 | + const onValueChange = vi.fn(); |
| 91 | + const { container } = renderScreen([{ name: 'remind', label: 'Remind', type: 'datetime' }], { onValueChange }); |
| 92 | + const input = await screen.findByRole('textbox', { name: 'Remind' }); |
| 93 | + expect(input.closest('[data-slot="date-picker"]')).not.toBeNull(); |
| 94 | + expect(container.querySelector('input[type="datetime-local"]')).toBeNull(); |
| 95 | + typeAndCommit(input, '10/16/2026 14:30'); |
| 96 | + expect(onValueChange).toHaveBeenCalledWith('remind', new Date(2026, 9, 16, 14, 30).toISOString()); |
| 97 | + }); |
| 98 | +}); |
| 99 | + |
| 100 | +describe('objectstack#26579 — a `boolean` screen field is the inline checkbox row', () => { |
| 101 | + it('draws the label BESIDE the checkbox, in one row', async () => { |
| 102 | + renderScreen([{ name: 'estimate', label: 'Estimated amount', type: 'boolean' }]); |
| 103 | + const box = await screen.findByRole('checkbox', { name: 'Estimated amount' }); |
| 104 | + const label = screen.getByText('Estimated amount'); |
| 105 | + const row = label.parentElement as HTMLElement; |
| 106 | + expect(row).toHaveClass('flex'); |
| 107 | + expect(row.contains(box)).toBe(true); |
| 108 | + // The control comes first, the label after it. |
| 109 | + expect(box.compareDocumentPosition(label) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); |
| 110 | + }); |
| 111 | + |
| 112 | + it('a `checkbox` widget hint draws the same row', async () => { |
| 113 | + renderScreen([{ name: 'submit_now', label: 'Submit now', type: 'checkbox' }]); |
| 114 | + const box = await screen.findByRole('checkbox', { name: 'Submit now' }); |
| 115 | + expect((screen.getByText('Submit now').parentElement as HTMLElement).contains(box)).toBe(true); |
| 116 | + }); |
| 117 | + |
| 118 | + it('ticking answers `true` and unticking `false`; clicking the label toggles it', async () => { |
| 119 | + const onValueChange = vi.fn(); |
| 120 | + const { rerender } = renderScreen([{ name: 'estimate', label: 'Estimated amount', type: 'boolean' }], { onValueChange }); |
| 121 | + fireEvent.click(await screen.findByRole('checkbox', { name: 'Estimated amount' })); |
| 122 | + expect(onValueChange).toHaveBeenLastCalledWith('estimate', true); |
| 123 | + rerender( |
| 124 | + <ScreenView |
| 125 | + screen={specOf([{ name: 'estimate', label: 'Estimated amount', type: 'boolean' }])} |
| 126 | + values={{ estimate: true }} |
| 127 | + onValueChange={onValueChange} |
| 128 | + />, |
| 129 | + ); |
| 130 | + expect(screen.getByRole('checkbox', { name: 'Estimated amount' })).toBeChecked(); |
| 131 | + fireEvent.click(screen.getByText('Estimated amount')); |
| 132 | + expect(onValueChange).toHaveBeenLastCalledWith('estimate', false); |
| 133 | + }); |
| 134 | + |
| 135 | + it('only a real `true` is checked', async () => { |
| 136 | + renderScreen([{ name: 'estimate', label: 'Estimated amount', type: 'boolean' }], { values: { estimate: 'true' } }); |
| 137 | + expect(await screen.findByRole('checkbox', { name: 'Estimated amount' })).not.toBeChecked(); |
| 138 | + }); |
| 139 | + |
| 140 | + it('carries the required state and marker, and its help text under the row describes it', async () => { |
| 141 | + renderScreen([{ name: 'estimate', label: 'Estimated amount', type: 'boolean', required: true, inlineHelpText: 'Tick when estimated' }]); |
| 142 | + const box = await screen.findByRole('checkbox', { name: 'Estimated amount' }); |
| 143 | + expect(box).toHaveAttribute('aria-required', 'true'); |
| 144 | + expect(box).toHaveAccessibleDescription('Tick when estimated'); |
| 145 | + const label = screen.getByText('Estimated amount'); |
| 146 | + expect(label.querySelector('[data-required-marker="true"]')).not.toBeNull(); |
| 147 | + const help = screen.getByText('Tick when estimated'); |
| 148 | + expect(help.parentElement?.contains(label.parentElement)).toBe(true); |
| 149 | + expect(label.parentElement?.contains(help)).toBe(false); |
| 150 | + }); |
| 151 | + |
| 152 | + it('the widget draws no second label for the host-named control', async () => { |
| 153 | + const { container } = renderScreen([{ name: 'estimate', label: 'Estimated amount', type: 'boolean' }]); |
| 154 | + await screen.findByRole('checkbox', { name: 'Estimated amount' }); |
| 155 | + expect(container.querySelectorAll('label[for="ff-estimate"]')).toHaveLength(1); |
| 156 | + }); |
| 157 | + |
| 158 | + it('CONTROL — a boolean field declaring `options` stays a select with its label above', () => { |
| 159 | + const { container } = renderScreen([ |
| 160 | + { name: 'flag', label: 'Flag', type: 'boolean', options: [{ label: 'Yes', value: true }, { label: 'No', value: false }] }, |
| 161 | + ]); |
| 162 | + expect(screen.getByRole('combobox', { name: 'Flag' })).toBeTruthy(); |
| 163 | + expect(screen.queryByRole('checkbox')).toBeNull(); |
| 164 | + expect(container.querySelector('.space-y-1\\.5')?.children).toHaveLength(2); |
| 165 | + }); |
| 166 | +}); |
0 commit comments