mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b8885d0b7 | |||
| dd8cfef4de | |||
| bdd4f75c4a | |||
| 3a740ec713 | |||
| dcdfd9ca85 | |||
| d17a7e9321 | |||
| c960a983a0 | |||
| 7498abb326 | |||
| a0d72d1776 | |||
| eab63e838d |
+101
@@ -0,0 +1,101 @@
|
||||
import { memo, useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import MultiLineCell from './MultiLineCell';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TextLikeInput from './TextLikeInput';
|
||||
|
||||
interface EditableCellProps {
|
||||
initialValue: string;
|
||||
multiline?: boolean;
|
||||
fieldId?: string;
|
||||
fieldLabel?: string;
|
||||
handleUpdate: (newValue: string) => void;
|
||||
}
|
||||
|
||||
interface FocusableEditor {
|
||||
focus: () => void;
|
||||
select?: () => void;
|
||||
}
|
||||
|
||||
interface FocusableDisplay {
|
||||
focusParentElement: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily mounts the text editor for a cell.
|
||||
*
|
||||
* Mounting an `<input>`/`<textarea>` editor (with its reactive-input hooks and autosize) for every
|
||||
* cell is expensive when many rows mount at once during virtualised scroll. While the cell is not
|
||||
* being edited we render a lightweight, focusable display element and only mount the real editor
|
||||
* when the user clicks/focuses the cell — mirroring how the time/duration cells already behave.
|
||||
*
|
||||
* On exit we return focus to the parent cell (through the display element, in a layout effect once
|
||||
* it is back in the DOM) so the table keyboard navigation keeps working — the editor is unmounted
|
||||
* by then, so we cannot rely on its own ref.
|
||||
*/
|
||||
function EditableCell({ initialValue, multiline, fieldId, fieldLabel, handleUpdate }: EditableCellProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const wasEditing = useRef(false);
|
||||
const editorRef = useRef<FocusableEditor | null>(null);
|
||||
const displayRef = useRef<FocusableDisplay | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isEditing) {
|
||||
// focus the editor once it mounts on entering edit mode
|
||||
editorRef.current?.focus();
|
||||
editorRef.current?.select?.();
|
||||
} else if (wasEditing.current) {
|
||||
// returning from edit: hand focus back to the cell so table keyboard navigation continues
|
||||
displayRef.current?.focusParentElement();
|
||||
}
|
||||
wasEditing.current = isEditing;
|
||||
}, [isEditing]);
|
||||
|
||||
const enterEdit = useCallback(() => setIsEditing(true), []);
|
||||
const exitEdit = useCallback(() => setIsEditing(false), []);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(newValue: string) => {
|
||||
setIsEditing(false);
|
||||
handleUpdate(newValue);
|
||||
},
|
||||
[handleUpdate],
|
||||
);
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<TextLikeInput
|
||||
ref={displayRef}
|
||||
onClick={enterEdit}
|
||||
onFocus={enterEdit}
|
||||
multiline={multiline}
|
||||
topAligned
|
||||
aria-label={fieldLabel ? `${fieldLabel} cell` : undefined}
|
||||
>
|
||||
{initialValue}
|
||||
</TextLikeInput>
|
||||
);
|
||||
}
|
||||
|
||||
return multiline ? (
|
||||
<MultiLineCell
|
||||
ref={editorRef}
|
||||
initialValue={initialValue}
|
||||
fieldId={fieldId}
|
||||
fieldLabel={fieldLabel}
|
||||
handleUpdate={onSubmit}
|
||||
handleCancelUpdate={exitEdit}
|
||||
/>
|
||||
) : (
|
||||
<SingleLineCell
|
||||
ref={editorRef}
|
||||
initialValue={initialValue}
|
||||
fieldId={fieldId}
|
||||
fieldLabel={fieldLabel}
|
||||
handleUpdate={onSubmit}
|
||||
handleCancelUpdate={exitEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(EditableCell);
|
||||
+24
-5
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
import { forwardRef, memo, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
|
||||
import { AutoTextarea } from '../../../../common/components/input/auto-textarea/AutoTextarea';
|
||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||
@@ -8,19 +8,36 @@ interface MultiLineCellProps {
|
||||
fieldId?: string;
|
||||
fieldLabel?: string;
|
||||
handleUpdate: (newValue: string) => void;
|
||||
handleCancelUpdate?: () => void;
|
||||
}
|
||||
|
||||
export default memo(MultiLineCell);
|
||||
|
||||
function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: MultiLineCellProps) {
|
||||
const MultiLineCell = forwardRef(function MultiLineCell(
|
||||
{ initialValue, fieldId, fieldLabel, handleUpdate, handleCancelUpdate }: MultiLineCellProps,
|
||||
inputRef,
|
||||
) {
|
||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
||||
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||
submitOnCtrlEnter: true,
|
||||
allowKeyboardNavigation: true,
|
||||
onCancelUpdate: handleCancelUpdate,
|
||||
});
|
||||
|
||||
// expose focus to the parent so the editor can be focused when mounted on demand
|
||||
useImperativeHandle(
|
||||
inputRef,
|
||||
() => ({
|
||||
focus() {
|
||||
ref.current?.focus();
|
||||
},
|
||||
select() {
|
||||
ref.current?.select();
|
||||
},
|
||||
}),
|
||||
[ref],
|
||||
);
|
||||
|
||||
return (
|
||||
<AutoTextarea
|
||||
inputref={ref}
|
||||
@@ -36,4 +53,6 @@ function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: Mult
|
||||
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default memo(MultiLineCell);
|
||||
|
||||
+20
-1
@@ -1,6 +1,8 @@
|
||||
/* element matching input styles */
|
||||
.textInput {
|
||||
height: 2rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 2rem;
|
||||
background-color: transparent;
|
||||
border-radius: $component-border-radius-md;
|
||||
text-wrap: nowrap;
|
||||
@@ -32,4 +34,21 @@
|
||||
background-color: $gray-1100;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
// single-line placeholder: keep the glyph in a 2rem band at the top of the cell so it lines up
|
||||
// with the editor (which mounts top-aligned) and does not jump when the cell is taller than 2rem
|
||||
&.topAligned:not(.multiline) {
|
||||
align-items: flex-start;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
&.multiline {
|
||||
height: auto;
|
||||
min-height: 100%; // fill the cell so the whole area is clickable (grows with content)
|
||||
text-wrap: wrap;
|
||||
white-space: break-spaces;
|
||||
overflow: hidden;
|
||||
align-items: flex-start;
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -8,11 +8,23 @@ interface TextLikeInputProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
offset?: 'over' | 'under' | 'muted' | null;
|
||||
muted?: boolean;
|
||||
disabled?: boolean;
|
||||
multiline?: boolean;
|
||||
/** keep the content at the top of the cell (matches an editor that mounts top-aligned) */
|
||||
topAligned?: boolean;
|
||||
}
|
||||
|
||||
const TextLikeInput = forwardRef(
|
||||
(
|
||||
{ offset, muted, disabled, children, className, ...elementProps }: PropsWithChildren<TextLikeInputProps>,
|
||||
{
|
||||
offset,
|
||||
muted,
|
||||
disabled,
|
||||
multiline,
|
||||
topAligned,
|
||||
children,
|
||||
className,
|
||||
...elementProps
|
||||
}: PropsWithChildren<TextLikeInputProps>,
|
||||
textRef,
|
||||
) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
@@ -21,6 +33,8 @@ const TextLikeInput = forwardRef(
|
||||
offset && style[offset],
|
||||
muted && style.muted,
|
||||
disabled && style.disabled,
|
||||
multiline && style.multiline,
|
||||
topAligned && style.topAligned,
|
||||
className,
|
||||
]);
|
||||
|
||||
|
||||
+6
-5
@@ -9,12 +9,11 @@ import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
||||
import DurationInput from './DurationInput';
|
||||
import EditableCell from './EditableCell';
|
||||
import EditableImage from './EditableImage';
|
||||
import FlagCell from './FlagCell';
|
||||
import GhostedText from './GhostedText';
|
||||
import MultiLineCell from './MultiLineCell';
|
||||
import MutedText from './MutedText';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
|
||||
@@ -151,7 +150,8 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiLineCell
|
||||
<EditableCell
|
||||
multiline
|
||||
initialValue={initialValue as string}
|
||||
fieldId={column.id}
|
||||
fieldLabel={getColumnLabel(column)}
|
||||
@@ -198,7 +198,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
|
||||
}
|
||||
|
||||
return (
|
||||
<SingleLineCell
|
||||
<EditableCell
|
||||
initialValue={initialValue as string}
|
||||
fieldId={column.id}
|
||||
fieldLabel={getColumnLabel(column)}
|
||||
@@ -238,7 +238,8 @@ function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unkn
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiLineCell
|
||||
<EditableCell
|
||||
multiline
|
||||
initialValue={initialValue}
|
||||
fieldId={column.id}
|
||||
fieldLabel={getColumnLabel(column)}
|
||||
|
||||
@@ -57,48 +57,56 @@ test('cuesheet datagrid keeps keyboard focus flow while editing text cells', asy
|
||||
const firstEvent = page.getByTestId('cuesheet-event').first();
|
||||
await expect(firstEvent).toBeVisible();
|
||||
|
||||
const cueCell = firstEvent.getByTestId('cuesheet-cell-cue');
|
||||
const titleCell = firstEvent.getByTestId('cuesheet-cell-title');
|
||||
const noteCell = firstEvent.getByTestId('cuesheet-cell-note');
|
||||
const cueEditor = firstEvent.getByTestId('cuesheet-editor-cue');
|
||||
const titleEditor = firstEvent.getByTestId('cuesheet-editor-title');
|
||||
const noteEditor = firstEvent.getByTestId('cuesheet-editor-note');
|
||||
|
||||
/**
|
||||
* 1. focus a cell in the datagrid single line text
|
||||
* submitting the data returns the focus to the parent
|
||||
* 1. clicking a single line text cell opens the editor (mounted on demand)
|
||||
* submitting with Enter closes the editor and returns focus to the parent cell
|
||||
*/
|
||||
await titleEditor.click();
|
||||
await titleCell.click();
|
||||
await expect(titleEditor).toBeFocused();
|
||||
const updatedTitle = `focus-title-${Date.now()}`;
|
||||
await titleEditor.fill(updatedTitle);
|
||||
await titleEditor.press('Enter');
|
||||
await expect(titleEditor).not.toBeFocused();
|
||||
await expect(titleEditor).toHaveValue(updatedTitle);
|
||||
await expect(titleEditor).toHaveCount(0);
|
||||
await expect(titleCell).toContainText(updatedTitle);
|
||||
await expect(titleCell).toBeFocused();
|
||||
|
||||
/**
|
||||
* 2. navigate and modify multiline text cell
|
||||
* 2. navigate to the multiline text cell with the keyboard and open it with Enter
|
||||
* submitting works with ctrl/cmd + enter and the focus returns to the parent
|
||||
*/
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(noteCell).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(noteEditor).toBeFocused();
|
||||
const updatedNote = `focus-note-${Date.now()}`;
|
||||
await noteEditor.fill(updatedNote);
|
||||
await noteEditor.press('ControlOrMeta+Enter');
|
||||
await expect(noteEditor).not.toBeFocused();
|
||||
await expect(noteEditor).toHaveValue(updatedNote);
|
||||
await expect(noteEditor).toHaveCount(0);
|
||||
await expect(noteCell).toContainText(updatedNote);
|
||||
await expect(noteCell).toBeFocused();
|
||||
|
||||
/**
|
||||
* 2. navigate and modify single line text cell again
|
||||
* pressing escape cancels the edit and the focus returns to the parent
|
||||
* 3. navigating back returns focus to the title cell
|
||||
* opening the cue cell and pressing escape cancels the edit and reverts the value
|
||||
*/
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(titleEditor).toBeFocused();
|
||||
await expect(titleCell).toBeFocused();
|
||||
|
||||
await cueCell.click();
|
||||
await expect(cueEditor).toBeFocused();
|
||||
const cueBeforeCancel = await cueEditor.inputValue();
|
||||
await cueEditor.click();
|
||||
await cueEditor.fill(`${cueBeforeCancel} temporary`);
|
||||
await cueEditor.press('Escape');
|
||||
await expect(cueEditor).not.toBeFocused();
|
||||
await expect(cueEditor).toHaveValue(cueBeforeCancel);
|
||||
await expect(cueEditor).toHaveCount(0);
|
||||
await expect(cueCell).toContainText(cueBeforeCancel);
|
||||
await expect(cueCell).toBeFocused();
|
||||
});
|
||||
|
||||
test('cuesheet background edit from empty state', async ({ page }) => {
|
||||
@@ -111,8 +119,8 @@ test('cuesheet background edit from empty state', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Rundown title' }).fill(emptyName);
|
||||
await page.getByRole('button', { name: 'Create rundown' }).click();
|
||||
|
||||
// edit it in the cuesheet
|
||||
await page.getByRole('row', { name: '0 empty-' }).getByTestId('rundown_menu').click();
|
||||
// edit it in the cuesheet (scope to the rundown we just created so retries stay isolated)
|
||||
await page.getByRole('row', { name: emptyName }).getByTestId('rundown_menu').click();
|
||||
await page.getByText('Edit in cuesheet').click();
|
||||
|
||||
// expect to see and empty screen
|
||||
|
||||
@@ -231,9 +231,10 @@ test.describe('Sharing from cuesheet', () => {
|
||||
|
||||
// Verify that the title is visible and editable
|
||||
await expect(page.getByTestId('cuesheet-event').getByRole('cell', { name: 'title' })).toBeVisible();
|
||||
const titleEditor = page.getByTestId('cuesheet-event').getByTestId('cuesheet-editor-title');
|
||||
await titleEditor.click();
|
||||
await expect(titleEditor).toBeEditable();
|
||||
// the editor mounts on demand: clicking the cell opens it
|
||||
const firstEvent = page.getByTestId('cuesheet-event').first();
|
||||
await firstEvent.getByTestId('cuesheet-cell-title').click();
|
||||
await expect(firstEvent.getByTestId('cuesheet-editor-title')).toBeEditable();
|
||||
|
||||
// other elements are not there
|
||||
await expect(page.getByRole('cell', { name: 'Duration' })).toBeHidden();
|
||||
|
||||
Reference in New Issue
Block a user