|
| 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 | + * objectui#11562 — `record:details` edit mode edits a `textarea` field in a |
| 11 | + * MULTI-line editor, so a save writes back the text that was stored, line |
| 12 | + * breaks included. |
| 13 | + * |
| 14 | + * Before, `textarea` was a member of `INLINE_PLAIN_TEXT_FIELD_TYPES` (class D, |
| 15 | + * objectui#4220: "the stored value is already a string, nothing is lost"), so |
| 16 | + * the row edited in this component's terminal input, an `<input type="text">`. |
| 17 | + * That holds for a single-line value only. The browser strips every line break |
| 18 | + * from the value a text input is seeded with, so a stored note with blank lines |
| 19 | + * and a trailing newline was shown flattened, and one keystroke made the |
| 20 | + * flattened string the value the Save batch wrote. Nothing warned. |
| 21 | + * |
| 22 | + * The row now routes `textarea` to the fields package's `TextAreaField`, the |
| 23 | + * widget `markdown` got in objectui#11541 and the one a `textarea` field |
| 24 | + * already edits with in the record form and in a grid cell. The pins below |
| 25 | + * compare values BYTE for byte with blank lines and a trailing newline in them: |
| 26 | + * a single-line editor cannot pass them. A single-line value is pinned beside |
| 27 | + * them, so the move is shown to change nothing for it. |
| 28 | + * |
| 29 | + * The hosts' gate needed no change: `textarea` is not in the fields package's |
| 30 | + * shared `INLINE_EXCLUDED_FIELD_TYPES`, so both detail hosts already opened an |
| 31 | + * editor for it. It was the editor that was wrong. |
| 32 | + */ |
| 33 | + |
| 34 | +import * as React from 'react'; |
| 35 | +import { describe, it, expect, vi, beforeAll } from 'vitest'; |
| 36 | +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; |
| 37 | +import { InlineEditProvider, useInlineEdit } from '@object-ui/react'; |
| 38 | +import type { DetailViewSection } from '@object-ui/types'; |
| 39 | +import { FieldEditWidget } from '@object-ui/fields'; |
| 40 | +import { DetailSection } from '../DetailSection'; |
| 41 | +import { HeaderHighlight } from '../HeaderHighlight'; |
| 42 | +import { InlineEditSaveBar } from '../InlineEditSaveBar'; |
| 43 | +import { InlineFieldInput, INLINE_PLAIN_TEXT_INPUT_TESTID } from '../InlineFieldInput'; |
| 44 | + |
| 45 | +/** A blank line inside and a trailing newline: the bytes a one-line box drops. */ |
| 46 | +const MULTI_LINE = 'Call back Monday.\n\nBudget approved, needs legal review.\n'; |
| 47 | +/** The control: a value a one-line box already kept intact. */ |
| 48 | +const SINGLE_LINE = 'Call back Monday.'; |
| 49 | + |
| 50 | +const plainInput = (root: ParentNode = document) => |
| 51 | + root.querySelector(`[data-testid="${INLINE_PLAIN_TEXT_INPUT_TESTID}"]`); |
| 52 | +const editors = (root: ParentNode) => root.querySelectorAll('input, textarea'); |
| 53 | +const textareaIn = (root: ParentNode) => root.querySelector('textarea') as HTMLTextAreaElement | null; |
| 54 | +const hasPencil = () => screen.queryAllByLabelText('Double-click to edit').length > 0; |
| 55 | + |
| 56 | +beforeAll(() => { |
| 57 | + // Desktop layout — the mobile branch renders its own read-only row shape. |
| 58 | + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); |
| 59 | +}); |
| 60 | + |
| 61 | +// --------------------------------------------------------------------------- |
| 62 | +// The editor itself |
| 63 | +// --------------------------------------------------------------------------- |
| 64 | + |
| 65 | +describe('objectui#11562 — InlineFieldInput edits textarea in a multi-line textarea', () => { |
| 66 | + for (const [label, stored] of [['multi-line', MULTI_LINE], ['single-line', SINGLE_LINE]] as const) { |
| 67 | + it(`a ${label} value seeds a textarea byte for byte, never the terminal input`, () => { |
| 68 | + const { container } = render( |
| 69 | + <InlineFieldInput field={{ name: 'notes', type: 'textarea' }} value={stored} onChange={vi.fn()} />, |
| 70 | + ); |
| 71 | + expect(plainInput(container)).toBeNull(); |
| 72 | + expect(textareaIn(container)).not.toBeNull(); |
| 73 | + expect(textareaIn(container)!.value).toBe(stored); |
| 74 | + }); |
| 75 | + |
| 76 | + it(`one keystroke on a ${label} value emits the stored text plus that keystroke, exactly`, () => { |
| 77 | + const onChange = vi.fn(); |
| 78 | + const { container } = render( |
| 79 | + <InlineFieldInput field={{ name: 'notes', type: 'textarea' }} value={stored} onChange={onChange} />, |
| 80 | + ); |
| 81 | + // Typed at the end of whatever the editor shows: the issue's repro. A |
| 82 | + // one-line box shows the flattened text, so it would emit that. |
| 83 | + const editor = container.querySelector('input, textarea') as HTMLTextAreaElement; |
| 84 | + fireEvent.change(editor, { target: { value: `${editor.value}!` } }); |
| 85 | + expect(onChange).toHaveBeenCalledTimes(1); |
| 86 | + expect(onChange.mock.calls[0][0]).toBe(`${stored}!`); |
| 87 | + }); |
| 88 | + } |
| 89 | + |
| 90 | + it('takes the entered field’s focus, like the other routed editors', () => { |
| 91 | + const { container } = render( |
| 92 | + <InlineFieldInput field={{ name: 'notes', type: 'textarea' }} value={MULTI_LINE} onChange={vi.fn()} autoFocus />, |
| 93 | + ); |
| 94 | + expect(textareaIn(container)).toBe(document.activeElement); |
| 95 | + }); |
| 96 | +}); |
| 97 | + |
| 98 | +// --------------------------------------------------------------------------- |
| 99 | +// The two detail hosts open it |
| 100 | +// --------------------------------------------------------------------------- |
| 101 | + |
| 102 | +const textareaSchema = { fields: { notes: { type: 'textarea', label: 'Notes' } } }; |
| 103 | +const notesSection = { fields: [{ name: 'notes', label: 'Notes' }] } as unknown as DetailViewSection; |
| 104 | + |
| 105 | +describe('objectui#11562 — both detail hosts open the multi-line editor', () => { |
| 106 | + it('the details body shows the inline-edit affordance on a textarea row', () => { |
| 107 | + render( |
| 108 | + <DetailSection section={notesSection} data={{ notes: MULTI_LINE }} objectSchema={textareaSchema} onEnterInlineEdit={vi.fn()} />, |
| 109 | + ); |
| 110 | + expect(hasPencil()).toBe(true); |
| 111 | + }); |
| 112 | + |
| 113 | + it('the details body renders one editor for the textarea row in edit mode, seeded byte for byte', () => { |
| 114 | + const { container } = render( |
| 115 | + <DetailSection |
| 116 | + section={notesSection} |
| 117 | + data={{ notes: MULTI_LINE }} |
| 118 | + objectSchema={textareaSchema} |
| 119 | + isEditing |
| 120 | + onEnterInlineEdit={vi.fn()} |
| 121 | + />, |
| 122 | + ); |
| 123 | + expect(editors(container)).toHaveLength(1); |
| 124 | + expect(plainInput(container)).toBeNull(); |
| 125 | + expect(textareaIn(container)!.value).toBe(MULTI_LINE); |
| 126 | + }); |
| 127 | + |
| 128 | + it('the highlights strip opens the same editor inside the edit session', () => { |
| 129 | + function EnterEdit() { |
| 130 | + const inline = useInlineEdit(); |
| 131 | + return <button type="button" onClick={() => inline!.enter('notes')}>enter-edit</button>; |
| 132 | + } |
| 133 | + const { container } = render( |
| 134 | + <InlineEditProvider canEdit> |
| 135 | + <HeaderHighlight fields={[{ name: 'notes', label: 'Notes' }]} data={{ notes: MULTI_LINE }} objectSchema={textareaSchema} /> |
| 136 | + <EnterEdit /> |
| 137 | + </InlineEditProvider>, |
| 138 | + ); |
| 139 | + fireEvent.click(screen.getByText('enter-edit')); |
| 140 | + expect(plainInput(container)).toBeNull(); |
| 141 | + expect(textareaIn(container)!.value).toBe(MULTI_LINE); |
| 142 | + }); |
| 143 | +}); |
| 144 | + |
| 145 | +// --------------------------------------------------------------------------- |
| 146 | +// The save reads back unchanged |
| 147 | +// --------------------------------------------------------------------------- |
| 148 | + |
| 149 | +describe('objectui#11562 — a textarea save reads back unchanged, line breaks included', () => { |
| 150 | + // The multi-line edit appends a line that ends in a newline too; the |
| 151 | + // single-line control appends words only, so it stays a one-line value. |
| 152 | + const CASES = [ |
| 153 | + ['multi-line', MULTI_LINE, 'Follow-up booked.\n'], |
| 154 | + ['single-line', SINGLE_LINE, ' Follow-up booked.'], |
| 155 | + ] as const; |
| 156 | + for (const [label, stored, typed] of CASES) { |
| 157 | + it(`a ${label} value: the PATCH body carries the edit byte for byte, and the refetched record seeds it back`, async () => { |
| 158 | + /** The server's copy of the record; `update` writes through the wire's JSON. */ |
| 159 | + const store: Record<string, unknown> = { id: 'r1', notes: stored, updated_at: 'v1' }; |
| 160 | + const wire: string[] = []; |
| 161 | + const update = vi.fn(async (_object: string, _id: string, patch: Record<string, unknown>) => { |
| 162 | + const body = JSON.stringify(patch); |
| 163 | + wire.push(body); |
| 164 | + Object.assign(store, JSON.parse(body), { updated_at: 'v2' }); |
| 165 | + return {}; |
| 166 | + }); |
| 167 | + const edited = `${stored}${typed}`; |
| 168 | + |
| 169 | + /** Stand-in for `DetailView`: the fetched record overlaid with the draft. */ |
| 170 | + function Body({ record }: { record: Record<string, unknown> }) { |
| 171 | + const inline = useInlineEdit()!; |
| 172 | + return ( |
| 173 | + <> |
| 174 | + <button type="button" onClick={() => inline.enter()}>enter-edit</button> |
| 175 | + <DetailSection |
| 176 | + section={notesSection} |
| 177 | + data={{ ...record, ...inline.draft }} |
| 178 | + objectSchema={textareaSchema} |
| 179 | + isEditing={inline.editing} |
| 180 | + onFieldChange={inline.setField} |
| 181 | + /> |
| 182 | + </> |
| 183 | + ); |
| 184 | + } |
| 185 | + function Page() { |
| 186 | + const [record, setRecord] = React.useState<Record<string, unknown>>(() => ({ ...store })); |
| 187 | + const refresh = React.useCallback(async () => setRecord({ ...store }), []); |
| 188 | + return ( |
| 189 | + <InlineEditProvider canEdit> |
| 190 | + <Body record={record} /> |
| 191 | + <InlineEditSaveBar dataSource={{ update }} objectName="lead" recordId="r1" data={record} refresh={refresh} /> |
| 192 | + </InlineEditProvider> |
| 193 | + ); |
| 194 | + } |
| 195 | + |
| 196 | + const { container } = render(<Page />); |
| 197 | + fireEvent.click(screen.getByText('enter-edit')); |
| 198 | + // Whatever editor the row renders, the user types onto what it SHOWS. |
| 199 | + const editor = container.querySelector('input, textarea') as HTMLTextAreaElement; |
| 200 | + expect(editor.value).toBe(stored); |
| 201 | + fireEvent.change(editor, { target: { value: `${editor.value}${typed}` } }); |
| 202 | + |
| 203 | + await act(async () => { |
| 204 | + fireEvent.click(screen.getByRole('button', { name: 'Save' })); |
| 205 | + }); |
| 206 | + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); |
| 207 | + expect(wire).toHaveLength(1); |
| 208 | + expect(JSON.parse(wire[0])).toEqual({ notes: edited }); |
| 209 | + expect(store.notes).toBe(edited); |
| 210 | + // Save leaves edit mode; re-entering seeds the editor from the refetched |
| 211 | + // record. Editor-agnostic on purpose, like the reads above: the editor's |
| 212 | + // KIND is pinned by the first describe, and this case is about bytes, so |
| 213 | + // the single-line control reads the same before the fix and after it. |
| 214 | + await waitFor(() => expect(container.querySelector('input, textarea')).toBeNull()); |
| 215 | + fireEvent.click(screen.getByText('enter-edit')); |
| 216 | + expect((container.querySelector('input, textarea') as HTMLTextAreaElement).value).toBe(edited); |
| 217 | + }); |
| 218 | + } |
| 219 | +}); |
| 220 | + |
| 221 | +// --------------------------------------------------------------------------- |
| 222 | +// The grid cell: unchanged, and now the same editor as the row |
| 223 | +// --------------------------------------------------------------------------- |
| 224 | + |
| 225 | +describe('objectui#11562 — the grid cell editor is the same TextAreaField, unchanged', () => { |
| 226 | + it('`FieldEditWidget`, which the grid cell editor delegates to, seeds a textarea byte for byte', () => { |
| 227 | + // `ObjectGrid`'s `renderCellEditor` hands a `textarea` cell to |
| 228 | + // `FieldEditWidget`; this file's change does not touch that path. The pin |
| 229 | + // records that the row and the cell now edit with one widget. |
| 230 | + const { container } = render( |
| 231 | + <FieldEditWidget field={{ name: 'notes', type: 'textarea' }} value={MULTI_LINE} onChange={vi.fn()} />, |
| 232 | + ); |
| 233 | + expect(textareaIn(container)!.value).toBe(MULTI_LINE); |
| 234 | + }); |
| 235 | +}); |
0 commit comments