From c6248b0c725f4c5951a52d3c9597fa7e65285d96 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 25 Jul 2026 12:27:34 +0200 Subject: [PATCH] refactor(import): improve preview UI --- .../sources-panel/SourcesPanel.module.scss | 7 + .../sources-panel/SourcesPanel.tsx | 30 +++- .../SheetImportEditor.module.scss | 4 + .../sheet-import/SheetImportEditor.tsx | 19 ++- .../preview/PreviewTable.module.scss | 126 +++++++++++++--- .../sheet-import/preview/PreviewTable.tsx | 140 ++++++++++++++++-- .../sheet-import/useSheetImportForm.ts | 47 ++++-- .../features/301-spreadsheet-import.spec.ts | 4 +- 8 files changed, 316 insertions(+), 61 deletions(-) diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.module.scss b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.module.scss index 8d293de17..4152bc3b5 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.module.scss +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.module.scss @@ -25,6 +25,13 @@ margin-top: 1rem; } +.finishActions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; +} + .sourceGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx index 6a90e15f0..87180a840 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx @@ -8,6 +8,7 @@ import type { import { getErrorMessage, ImportMap } from 'ontime-utils'; import { ChangeEvent, useCallback, useRef, useState } from 'react'; import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5'; +import { useNavigate } from 'react-router'; import { getWorksheetMetadata as getExcelWorksheetMetadata, @@ -56,9 +57,11 @@ export default function SourcesPanel() { const [error, setError] = useState(''); const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none'); const [activeSource, setActiveSource] = useState(null); + const [completedRundownTitle, setCompletedRundownTitle] = useState(''); const { data: currentRundown } = useRundown(); const { applyImport } = useSpreadsheetImport(); + const navigate = useNavigate(); const fileInputRef = useRef(null); @@ -101,6 +104,7 @@ export default function SourcesPanel() { setHasFile('none'); setActiveSource(null); setError(''); + setCompletedRundownTitle(''); }; const openGSheetFlow = () => { @@ -123,11 +127,12 @@ export default function SourcesPanel() { } }; - const handleFinished = () => { + const handleFinished = (rundownTitle: string) => { setImportFlow('finished'); setHasFile('none'); setActiveSource(null); setError(''); + setCompletedRundownTitle(rundownTitle); }; const handleApplyImport = async ( @@ -139,7 +144,7 @@ export default function SourcesPanel() { if (mode === 'new') { const title = newRundownTitle.trim() || preview.rundown.title; await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields }); - handleFinished(); + handleFinished(title); return; } @@ -156,7 +161,7 @@ export default function SourcesPanel() { customFields: preview.customFields, providedFields, }); - handleFinished(); + handleFinished(currentRundown.title); }; const loadWorksheetMetadata = useCallback( @@ -289,11 +294,20 @@ export default function SourcesPanel() { {showCompleted && (
Import complete -
Spreadsheet data applied.
-
You can close this flow or start another import.
- +
+ Spreadsheet data applied to {completedRundownTitle || 'your rundown'}. +
+
+ Review the imported rundown in the editor or start another import. +
+
+ + +
)} {isGSheetFlow && ( diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.module.scss b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.module.scss index bf6fd8f01..44c605899 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.module.scss +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.module.scss @@ -92,6 +92,10 @@ font-weight: 600; } +.toolbarWarning { + color: $orange-400; +} + .mappingPaneTitle { align-self: center; } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.tsx index 7deb7d997..b0510edea 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.tsx @@ -132,7 +132,16 @@ export default function SheetImportEditor({ } /> - {toolbarStatus && {toolbarStatus}} + {toolbarStatus && ( + + {toolbarStatus.entries === '–' + ? 'No import preview yet' + : `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}–${toolbarStatus.end} · ${toolbarStatus.duration}`} + {toolbarStatus.warnings > 0 && ( + · {toolbarStatus.warnings} warnings + )} + + )}
@@ -150,13 +159,19 @@ export default function SheetImportEditor({
- Import preview +
+ Import preview +
diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.module.scss b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.module.scss index 631c9afbf..4c223d366 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.module.scss +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.module.scss @@ -1,27 +1,35 @@ .emptyState { - height: 100%; - min-height: 16rem; - display: grid; - place-content: center; - gap: 0.35rem; - padding: 1.5rem; + padding: 3rem 1.5rem; text-align: center; } +.emptyMessage { + width: min(30rem, 100%); + margin-inline: auto; +} + .emptyTitle { - color: $ui-white; - font-size: 1rem; - font-weight: 600; + margin-bottom: 0.25rem; + color: rgba($gray-200, 0.72); + font-size: calc(1rem + 2px); + font-weight: 400; } .emptyBody { - color: $gray-400; - font-size: 0.95rem; + color: rgba($gray-200, 0.55); + font-size: calc(1rem - 3px); + line-height: 1.5; +} + +.emptyAction { + margin: 1rem auto 0; } .table { width: 100%; - border-collapse: collapse; + border-collapse: separate; + border-spacing: 0; + color: $ui-white; font-size: calc(1rem - 2px); text-align: left; table-layout: auto; @@ -34,31 +42,101 @@ } th { - font-weight: 400; - color: $gray-400; - text-transform: capitalize; - vertical-align: top; + color: $gray-300; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + vertical-align: bottom; white-space: normal; } th, td { - padding: 0.5rem; + box-sizing: border-box; min-width: 8rem; - vertical-align: top; + max-width: 20rem; + padding: 0.55rem 0.65rem; + border-bottom: 1px solid $white-10; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + white-space: nowrap; } - tr:nth-child(even) { - background-color: $white-1; + tbody tr { + --entry-colour: transparent; + background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%); + box-shadow: inset 3px 0 var(--entry-colour); } + + td[data-empty='true'] { + color: $gray-600; + } +} + +.rowNumber, +.rowType { + position: sticky; + z-index: 1; + background-color: inherit; } .rowNumber { - width: 4.5rem; - min-width: 4.5rem; + left: 0; + width: 3.25rem; + min-width: 3.25rem !important; + color: $gray-400; + text-align: right; } .rowType { - width: 7rem; - min-width: 7rem; + left: 3.25rem; + width: 6.25rem; + min-width: 6.25rem !important; + color: $gray-400; + font-size: 0.8rem; + box-shadow: 1px 0 $white-10; +} + +thead .rowNumber, +thead .rowType { + z-index: 2; + background-color: $gray-1350; +} + +.numericCell { + font-variant-numeric: tabular-nums; + text-align: right; +} + +.multilineCell { + max-width: 30rem !important; + overflow-wrap: anywhere; + text-overflow: clip !important; + white-space: pre-wrap !important; +} + +.eventRow { + .rowNumber { + font-variant-numeric: tabular-nums; + } +} + +.groupRow { + background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important; + box-shadow: inset 4px 0 var(--entry-colour) !important; + font-weight: 600; + + td { + min-height: 3.25rem; + border-top: 0.75rem solid $gray-1350; + } +} + +.milestoneRow { + background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important; + box-shadow: inset 3px 0 var(--entry-colour) !important; + color: $gray-300; + font-style: italic; } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.tsx index 7945b9db1..b69cdca5d 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/preview/PreviewTable.tsx @@ -1,7 +1,11 @@ import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types'; -import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; +import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; +import type { CSSProperties } from 'react'; import { useMemo } from 'react'; +import Button from '../../../../../../../common/components/buttons/Button'; +import Tag from '../../../../../../../common/components/tag/Tag'; +import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata'; import { getCellValue } from './previewTableUtils'; import style from './PreviewTable.module.scss'; @@ -9,14 +13,85 @@ import style from './PreviewTable.module.scss'; interface PreviewTableProps { preview: SpreadsheetPreviewResponse | null; columnLabels: string[]; + canRefresh: boolean; isLoadingMetadata: boolean; + isRefreshing: boolean; + needsPreviewRefresh: boolean; + onRefresh: () => void; worksheetHeaders: string[]; } +const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration']; +const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']); +const transparentColour = 'transparent'; + +type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string]; + +function getEntryDisplay(entry: PreviewEntry, groupColour?: string) { + if (isOntimeGroup(entry)) { + return { + rowClassName: style.groupRow, + entryColour: entry.colour, + entryType: 'Group', + }; + } + + const entryColour = groupColour ?? transparentColour; + + if (isOntimeMilestone(entry)) { + return { + rowClassName: style.milestoneRow, + entryColour, + entryType: 'Milestone', + }; + } + + if (isOntimeDelay(entry)) { + return { + rowClassName: style.eventRow, + entryColour, + entryType: 'Delay', + }; + } + + return { + rowClassName: style.eventRow, + entryColour, + entryType: 'Event', + }; +} + +function getCellClassName(label: string, value: string) { + if (value.includes('\n')) { + return style.multilineCell; + } + + if (numericColumns.has(label)) { + return style.numericCell; + } + + return undefined; +} + +function getDisplayColumns(columnLabels: string[]) { + return [...columnLabels].sort((left, right) => { + const leftPriority = priorityColumns.indexOf(left); + const rightPriority = priorityColumns.indexOf(right); + return ( + (leftPriority === -1 ? priorityColumns.length : leftPriority) - + (rightPriority === -1 ? priorityColumns.length : rightPriority) + ); + }); +} + export default function PreviewTable({ preview, columnLabels, + canRefresh, isLoadingMetadata, + isRefreshing, + needsPreviewRefresh, + onRefresh, worksheetHeaders, }: PreviewTableProps) { const customFieldKeyByLabel = useMemo(() => { @@ -24,33 +99,57 @@ export default function PreviewTable({ return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId])); }, [preview]); + const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]); + + const previewMetadata = useMemo(() => { + if (!preview) return null; + return getRundownMetadata(preview.rundown, null); + }, [preview]); + if (!preview) { + let emptyTitle = 'Preview not generated'; let emptyContent = 'Select the fields you want to import, then click Preview import.'; if (isLoadingMetadata) { + emptyTitle = 'Loading worksheet'; emptyContent = 'Loading worksheet metadata...'; } else if (worksheetHeaders.length === 0) { + emptyTitle = 'No headers found'; emptyContent = 'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.'; + } else if (needsPreviewRefresh) { + emptyTitle = 'Preview needs updating'; + emptyContent = 'Your column mapping changed. Preview the import again to update this table.'; } return (
-
Preview not generated
-
{emptyContent}
+
+
{emptyTitle}
+
{emptyContent}
+ {needsPreviewRefresh && ( + + )} +
); } - let eventIndex = 0; - return ( - {columnLabels.map((label, index) => ( + {displayColumns.map((label, index) => ( ))} @@ -59,16 +158,29 @@ export default function PreviewTable({ {preview.rundown.flatOrder.map((entryId) => { const entry = preview.rundown.entries[entryId]; const isEvent = isOntimeEvent(entry); - if (isEvent) eventIndex++; - const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry); + const entryMetadata = previewMetadata?.[entryId]; + const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour); return ( - - - - {columnLabels.map((label, colIndex) => ( - - ))} + + + + {displayColumns.map((label, colIndex) => { + const value = getCellValue(label, entry, customFieldKeyByLabel); + const cellClassName = getCellClassName(label, value); + return ( + + ); + })} ); })} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/useSheetImportForm.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/useSheetImportForm.ts index b1b05cc55..d7246b7b3 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/useSheetImportForm.ts +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/useSheetImportForm.ts @@ -5,7 +5,8 @@ import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata, } from 'ontime-types'; -import { millisToString } from 'ontime-utils'; +import { isOntimeGroup, isOntimeMilestone } from 'ontime-types'; +import { millisToString, removeTrailingZero } from 'ontime-utils'; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; @@ -32,7 +33,7 @@ type ImportAction = | { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse } | { type: 'applySuccess' } | { type: 'exportSuccess' } - | { type: 'clearPreview'; error?: string } + | { type: 'clearPreview'; error?: string; needsRefresh?: boolean } | { type: 'failure'; error: string } | { type: 'reset' }; @@ -40,12 +41,14 @@ type ImportState = { loading: '' | 'preview' | 'apply' | 'export'; error: string; preview: SpreadsheetPreviewResponse | null; + needsPreviewRefresh: boolean; }; const initialImportState: ImportState = { loading: '', error: '', preview: null, + needsPreviewRefresh: false, }; function importReducer(state: ImportState, action: ImportAction): ImportState { @@ -57,15 +60,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState { case 'startExport': return { ...state, loading: 'export', error: '' }; case 'previewSuccess': - return { loading: '', error: '', preview: action.preview }; + return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false }; case 'applySuccess': case 'exportSuccess': return { ...state, loading: '' }; case 'clearPreview': - return { ...state, error: action.error ?? '', preview: null }; + return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false }; case 'failure': { if (state.loading === 'preview') { - return { loading: '', error: action.error, preview: null }; + return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false }; } return { ...state, loading: '', error: action.error }; } @@ -221,7 +224,7 @@ export function useSheetImportForm({ const sub = watch(() => { if (!previewRef.current) return; previewRef.current = null; - dispatch({ type: 'clearPreview' }); + dispatch({ type: 'clearPreview', needsRefresh: true }); }); return () => sub.unsubscribe(); }, [watch]); @@ -297,15 +300,37 @@ export function useSheetImportForm({ }, [append]); const toolbarStatus = (() => { - const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : ''; - if (!state.preview) { - return `entries: – | start: – | end: – | duration: –${warningText}`; + return { + entries: '–', + groups: '–', + milestones: '–', + start: '–', + end: '–', + duration: '–', + warnings: warningCount, + }; } - const { flatOrder } = state.preview.rundown; + const { entries, flatOrder } = state.preview.rundown; const { start, end, duration } = state.preview.summary; - return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`; + let groups = 0; + let milestones = 0; + for (const entryId of flatOrder) { + const entry = entries[entryId]; + if (isOntimeGroup(entry)) groups++; + else if (isOntimeMilestone(entry)) milestones++; + } + + return { + entries: flatOrder.length, + groups, + milestones, + start: removeTrailingZero(millisToString(start)), + end: removeTrailingZero(millisToString(end)), + duration: formatDuration(duration), + warnings: warningCount, + }; })(); return { diff --git a/e2e/tests/features/301-spreadsheet-import.spec.ts b/e2e/tests/features/301-spreadsheet-import.spec.ts index d1ad406a7..06cb4a7ff 100644 --- a/e2e/tests/features/301-spreadsheet-import.spec.ts +++ b/e2e/tests/features/301-spreadsheet-import.spec.ts @@ -33,8 +33,8 @@ test('imports spreadsheet and applies imported rundown to editor', async ({ page await page.getByRole('button', { name: 'Preview import' }).click(); await page.getByRole('button', { name: 'Apply import' }).click(); await expect(page.getByText('Import complete')).toBeVisible(); - await expect(page.getByText('Spreadsheet data applied.')).toBeVisible(); - await page.getByRole('button', { name: 'Reset flow' }).click(); + await expect(page.getByRole('button', { name: 'Open editor' })).toBeVisible(); + await page.getByRole('button', { name: 'Import another' }).click(); // verify the data in the rundown await page.getByRole('button', { name: 'Close settings' }).scrollIntoViewIfNeeded();
# Type{label}
{isEvent ? eventIndex : ''}{hasType ? entry.type : ''}{getCellValue(label, entry, customFieldKeyByLabel)}
{isEvent ? entryMetadata?.eventIndex : ''} + {entryType} + + {value} +