mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 14:39:06 +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 { AutoTextarea } from '../../../../common/components/input/auto-textarea/AutoTextarea';
|
||||||
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
@@ -8,19 +8,36 @@ interface MultiLineCellProps {
|
|||||||
fieldId?: string;
|
fieldId?: string;
|
||||||
fieldLabel?: string;
|
fieldLabel?: string;
|
||||||
handleUpdate: (newValue: string) => void;
|
handleUpdate: (newValue: string) => void;
|
||||||
|
handleCancelUpdate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(MultiLineCell);
|
const MultiLineCell = forwardRef(function MultiLineCell(
|
||||||
|
{ initialValue, fieldId, fieldLabel, handleUpdate, handleCancelUpdate }: MultiLineCellProps,
|
||||||
function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: MultiLineCellProps) {
|
inputRef,
|
||||||
|
) {
|
||||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
const ref = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
|
||||||
|
|
||||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||||
submitOnCtrlEnter: true,
|
submitOnCtrlEnter: true,
|
||||||
allowKeyboardNavigation: 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 (
|
return (
|
||||||
<AutoTextarea
|
<AutoTextarea
|
||||||
inputref={ref}
|
inputref={ref}
|
||||||
@@ -36,4 +53,6 @@ function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: Mult
|
|||||||
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
|
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default memo(MultiLineCell);
|
||||||
|
|||||||
+20
-1
@@ -1,6 +1,8 @@
|
|||||||
/* element matching input styles */
|
/* element matching input styles */
|
||||||
.textInput {
|
.textInput {
|
||||||
height: 2rem;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 2rem;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-radius: $component-border-radius-md;
|
border-radius: $component-border-radius-md;
|
||||||
text-wrap: nowrap;
|
text-wrap: nowrap;
|
||||||
@@ -32,4 +34,21 @@
|
|||||||
background-color: $gray-1100;
|
background-color: $gray-1100;
|
||||||
cursor: text;
|
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;
|
offset?: 'over' | 'under' | 'muted' | null;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
disabled?: 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(
|
const TextLikeInput = forwardRef(
|
||||||
(
|
(
|
||||||
{ offset, muted, disabled, children, className, ...elementProps }: PropsWithChildren<TextLikeInputProps>,
|
{
|
||||||
|
offset,
|
||||||
|
muted,
|
||||||
|
disabled,
|
||||||
|
multiline,
|
||||||
|
topAligned,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...elementProps
|
||||||
|
}: PropsWithChildren<TextLikeInputProps>,
|
||||||
textRef,
|
textRef,
|
||||||
) => {
|
) => {
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
const ref = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -21,6 +33,8 @@ const TextLikeInput = forwardRef(
|
|||||||
offset && style[offset],
|
offset && style[offset],
|
||||||
muted && style.muted,
|
muted && style.muted,
|
||||||
disabled && style.disabled,
|
disabled && style.disabled,
|
||||||
|
multiline && style.multiline,
|
||||||
|
topAligned && style.topAligned,
|
||||||
className,
|
className,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -9,12 +9,11 @@ import { formatDuration, formatTime } from '../../../../common/utils/time';
|
|||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
||||||
import DurationInput from './DurationInput';
|
import DurationInput from './DurationInput';
|
||||||
|
import EditableCell from './EditableCell';
|
||||||
import EditableImage from './EditableImage';
|
import EditableImage from './EditableImage';
|
||||||
import FlagCell from './FlagCell';
|
import FlagCell from './FlagCell';
|
||||||
import GhostedText from './GhostedText';
|
import GhostedText from './GhostedText';
|
||||||
import MultiLineCell from './MultiLineCell';
|
|
||||||
import MutedText from './MutedText';
|
import MutedText from './MutedText';
|
||||||
import SingleLineCell from './SingleLineCell';
|
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
|
|
||||||
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
|
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
|
||||||
@@ -151,7 +150,8 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiLineCell
|
<EditableCell
|
||||||
|
multiline
|
||||||
initialValue={initialValue as string}
|
initialValue={initialValue as string}
|
||||||
fieldId={column.id}
|
fieldId={column.id}
|
||||||
fieldLabel={getColumnLabel(column)}
|
fieldLabel={getColumnLabel(column)}
|
||||||
@@ -198,7 +198,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SingleLineCell
|
<EditableCell
|
||||||
initialValue={initialValue as string}
|
initialValue={initialValue as string}
|
||||||
fieldId={column.id}
|
fieldId={column.id}
|
||||||
fieldLabel={getColumnLabel(column)}
|
fieldLabel={getColumnLabel(column)}
|
||||||
@@ -238,7 +238,8 @@ function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unkn
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiLineCell
|
<EditableCell
|
||||||
|
multiline
|
||||||
initialValue={initialValue}
|
initialValue={initialValue}
|
||||||
fieldId={column.id}
|
fieldId={column.id}
|
||||||
fieldLabel={getColumnLabel(column)}
|
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();
|
const firstEvent = page.getByTestId('cuesheet-event').first();
|
||||||
await expect(firstEvent).toBeVisible();
|
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 cueEditor = firstEvent.getByTestId('cuesheet-editor-cue');
|
||||||
const titleEditor = firstEvent.getByTestId('cuesheet-editor-title');
|
const titleEditor = firstEvent.getByTestId('cuesheet-editor-title');
|
||||||
const noteEditor = firstEvent.getByTestId('cuesheet-editor-note');
|
const noteEditor = firstEvent.getByTestId('cuesheet-editor-note');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1. focus a cell in the datagrid single line text
|
* 1. clicking a single line text cell opens the editor (mounted on demand)
|
||||||
* submitting the data returns the focus to the parent
|
* submitting with Enter closes the editor and returns focus to the parent cell
|
||||||
*/
|
*/
|
||||||
await titleEditor.click();
|
await titleCell.click();
|
||||||
await expect(titleEditor).toBeFocused();
|
await expect(titleEditor).toBeFocused();
|
||||||
const updatedTitle = `focus-title-${Date.now()}`;
|
const updatedTitle = `focus-title-${Date.now()}`;
|
||||||
await titleEditor.fill(updatedTitle);
|
await titleEditor.fill(updatedTitle);
|
||||||
await titleEditor.press('Enter');
|
await titleEditor.press('Enter');
|
||||||
await expect(titleEditor).not.toBeFocused();
|
await expect(titleEditor).toHaveCount(0);
|
||||||
await expect(titleEditor).toHaveValue(updatedTitle);
|
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
|
* submitting works with ctrl/cmd + enter and the focus returns to the parent
|
||||||
*/
|
*/
|
||||||
await page.keyboard.press('ArrowRight');
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(noteCell).toBeFocused();
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await expect(noteEditor).toBeFocused();
|
await expect(noteEditor).toBeFocused();
|
||||||
const updatedNote = `focus-note-${Date.now()}`;
|
const updatedNote = `focus-note-${Date.now()}`;
|
||||||
await noteEditor.fill(updatedNote);
|
await noteEditor.fill(updatedNote);
|
||||||
await noteEditor.press('ControlOrMeta+Enter');
|
await noteEditor.press('ControlOrMeta+Enter');
|
||||||
await expect(noteEditor).not.toBeFocused();
|
await expect(noteEditor).toHaveCount(0);
|
||||||
await expect(noteEditor).toHaveValue(updatedNote);
|
await expect(noteCell).toContainText(updatedNote);
|
||||||
|
await expect(noteCell).toBeFocused();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 2. navigate and modify single line text cell again
|
* 3. navigating back returns focus to the title cell
|
||||||
* pressing escape cancels the edit and the focus returns to the parent
|
* opening the cue cell and pressing escape cancels the edit and reverts the value
|
||||||
*/
|
*/
|
||||||
await page.keyboard.press('ArrowLeft');
|
await page.keyboard.press('ArrowLeft');
|
||||||
await page.keyboard.press('Enter');
|
await expect(titleCell).toBeFocused();
|
||||||
await expect(titleEditor).toBeFocused();
|
|
||||||
|
await cueCell.click();
|
||||||
|
await expect(cueEditor).toBeFocused();
|
||||||
const cueBeforeCancel = await cueEditor.inputValue();
|
const cueBeforeCancel = await cueEditor.inputValue();
|
||||||
await cueEditor.click();
|
|
||||||
await cueEditor.fill(`${cueBeforeCancel} temporary`);
|
await cueEditor.fill(`${cueBeforeCancel} temporary`);
|
||||||
await cueEditor.press('Escape');
|
await cueEditor.press('Escape');
|
||||||
await expect(cueEditor).not.toBeFocused();
|
await expect(cueEditor).toHaveCount(0);
|
||||||
await expect(cueEditor).toHaveValue(cueBeforeCancel);
|
await expect(cueCell).toContainText(cueBeforeCancel);
|
||||||
|
await expect(cueCell).toBeFocused();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cuesheet background edit from empty state', async ({ page }) => {
|
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('textbox', { name: 'Rundown title' }).fill(emptyName);
|
||||||
await page.getByRole('button', { name: 'Create rundown' }).click();
|
await page.getByRole('button', { name: 'Create rundown' }).click();
|
||||||
|
|
||||||
// edit it in the cuesheet
|
// edit it in the cuesheet (scope to the rundown we just created so retries stay isolated)
|
||||||
await page.getByRole('row', { name: '0 empty-' }).getByTestId('rundown_menu').click();
|
await page.getByRole('row', { name: emptyName }).getByTestId('rundown_menu').click();
|
||||||
await page.getByText('Edit in cuesheet').click();
|
await page.getByText('Edit in cuesheet').click();
|
||||||
|
|
||||||
// expect to see and empty screen
|
// expect to see and empty screen
|
||||||
|
|||||||
@@ -231,9 +231,10 @@ test.describe('Sharing from cuesheet', () => {
|
|||||||
|
|
||||||
// Verify that the title is visible and editable
|
// Verify that the title is visible and editable
|
||||||
await expect(page.getByTestId('cuesheet-event').getByRole('cell', { name: 'title' })).toBeVisible();
|
await expect(page.getByTestId('cuesheet-event').getByRole('cell', { name: 'title' })).toBeVisible();
|
||||||
const titleEditor = page.getByTestId('cuesheet-event').getByTestId('cuesheet-editor-title');
|
// the editor mounts on demand: clicking the cell opens it
|
||||||
await titleEditor.click();
|
const firstEvent = page.getByTestId('cuesheet-event').first();
|
||||||
await expect(titleEditor).toBeEditable();
|
await firstEvent.getByTestId('cuesheet-cell-title').click();
|
||||||
|
await expect(firstEvent.getByTestId('cuesheet-editor-title')).toBeEditable();
|
||||||
|
|
||||||
// other elements are not there
|
// other elements are not there
|
||||||
await expect(page.getByRole('cell', { name: 'Duration' })).toBeHidden();
|
await expect(page.getByRole('cell', { name: 'Duration' })).toBeHidden();
|
||||||
|
|||||||
Reference in New Issue
Block a user