diff --git a/apps/client/src/common/api/excel.ts b/apps/client/src/common/api/excel.ts index 87706029e..b71d17665 100644 --- a/apps/client/src/common/api/excel.ts +++ b/apps/client/src/common/api/excel.ts @@ -1,5 +1,9 @@ import axios, { AxiosResponse } from 'axios'; -import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata, SpreadsheetWorksheetOptions } from 'ontime-types'; +import type { + SpreadsheetPreviewResponse, + SpreadsheetWorksheetMetadata, + SpreadsheetWorksheetOptions, +} from 'ontime-types'; import { ImportMap } from 'ontime-utils'; import { apiEntryUrl } from './constants'; diff --git a/apps/client/src/common/api/sheets.ts b/apps/client/src/common/api/sheets.ts index 1c1654a7f..75df282d0 100644 --- a/apps/client/src/common/api/sheets.ts +++ b/apps/client/src/common/api/sheets.ts @@ -76,6 +76,9 @@ export const previewRundown = async ( return response.data; }; +/** + * Fetches derived metadata for a single worksheet by reading its rows and detecting headers. + */ export const getWorksheetMetadata = async ( sheetId: string, worksheet: string, @@ -92,12 +95,15 @@ export const getWorksheetMetadata = async ( return response.data; }; -export const getWorksheetNames = async ( +/** + * Fetches the available worksheets for a Google Sheet. Metadata is loaded lazily per worksheet. + */ +export const getWorksheetOptions = async ( sheetId: string, requestOptions?: RequestOptions, ): Promise => { const response: AxiosResponse = await axios.post( - `${sheetsPath}/${sheetId}/worksheets`, + `${sheetsPath}/${sheetId}/worksheet-options`, undefined, { signal: requestOptions?.signal, diff --git a/apps/client/src/common/components/autocomplete-input/AutocompleteInput.tsx b/apps/client/src/common/components/autocomplete-input/AutocompleteInput.tsx index bdf57f12b..0c43a51e6 100644 --- a/apps/client/src/common/components/autocomplete-input/AutocompleteInput.tsx +++ b/apps/client/src/common/components/autocomplete-input/AutocompleteInput.tsx @@ -1,5 +1,5 @@ import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete'; -import type { ReactNode } from 'react'; +import type { ReactNode, Ref } from 'react'; import { useEffect, useRef, useState } from 'react'; import { cx } from '../../utils/styleUtils'; @@ -15,6 +15,7 @@ export interface AutocompleteInputProps extends Omit void; emptyLabel?: string; trailingElement?: (option: string) => ReactNode; + inputRef?: Ref; } export default function AutocompleteInput({ @@ -23,6 +24,7 @@ export default function AutocompleteInput({ emptyLabel, fluid, height = 'medium', + inputRef, onValueChange, options, trailingElement, @@ -30,16 +32,31 @@ export default function AutocompleteInput({ variant = 'subtle', ...inputProps }: AutocompleteInputProps) { - const inputRef = useRef(null); + const internalInputRef = useRef(null); const [open, setOpen] = useState(false); + const handleInputRef = (node: HTMLInputElement | null) => { + internalInputRef.current = node; + + if (!inputRef) { + return; + } + + if (typeof inputRef === 'function') { + inputRef(node); + return; + } + + inputRef.current = node; + }; + // close the popover when the parent scrollable container scrolls or the window resizes useEffect(() => { if (!open) { return; } - const scrollTarget = getScrollParent(inputRef.current); + const scrollTarget = getScrollParent(internalInputRef.current); const handleScroll = () => setOpen(false); scrollTarget.addEventListener('scroll', handleScroll, { passive: true }); @@ -63,7 +80,7 @@ export default function AutocompleteInput({ onValueChange={onValueChange} > {showBackdrop && } - +
- {title} + {title ? {title} :
} {showCloseButton && ( diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/GSheetSetup.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/GSheetSetup.tsx index 4278cff77..b6eb4acc5 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/GSheetSetup.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/GSheetSetup.tsx @@ -1,7 +1,8 @@ +import type { SpreadsheetWorksheetOptions } from 'ontime-types'; import { ChangeEvent, useEffect, useState } from 'react'; import { IoCheckmark, IoShieldCheckmarkOutline } from 'react-icons/io5'; -import { getWorksheetNames } from '../../../../../common/api/sheets'; +import { getWorksheetOptions } from '../../../../../common/api/sheets'; import { maybeAxiosError } from '../../../../../common/api/utils'; import Button from '../../../../../common/components/buttons/Button'; import CopyTag from '../../../../../common/components/copy-tag/CopyTag'; @@ -13,10 +14,11 @@ import { useSheetStore } from './useSheetStore'; interface GSheetSetupProps { onCancel: () => void; + onWorksheetOptionsLoaded?: (options: SpreadsheetWorksheetOptions) => void; } export default function GSheetSetup(props: GSheetSetupProps) { - const { onCancel } = props; + const { onCancel, onWorksheetOptionsLoaded } = props; const { revoke, connect, verifyAuth } = useGoogleSheet(); const [file, setFile] = useState(null); @@ -92,8 +94,9 @@ export default function GSheetSetup(props: GSheetSetupProps) { if (result.authenticated !== 'pending') { if (result.authenticated == 'authenticated') { try { - const names = await getWorksheetNames(result.sheetId); - setWorksheets(names); + const worksheetOptions = await getWorksheetOptions(result.sheetId); + setWorksheets(worksheetOptions.worksheets); + onWorksheetOptionsLoaded?.(worksheetOptions); } catch (error) { const message = maybeAxiosError(error); patchStepData({ worksheet: { available: false, error: message } }); diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/ImportReview.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/ImportReview.tsx deleted file mode 100644 index e49ce11f1..000000000 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/ImportReview.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { CustomFields, Rundown, RundownSummary } from 'ontime-types'; -import { millisToString } from 'ontime-utils'; -import { useState } from 'react'; - -import Button from '../../../../../common/components/buttons/Button'; -import useRundown from '../../../../../common/hooks-query/useRundown'; -import { formatDuration } from '../../../../../common/utils/time'; -import * as Panel from '../../../panel-utils/PanelUtils'; -import PreviewSpreadsheet from './preview/PreviewRundown'; -import useGoogleSheet from './useGoogleSheet'; -import { useSheetStore } from './useSheetStore'; - -interface ImportReviewProps { - rundown: Rundown; - customFields: CustomFields; - summary: RundownSummary; - onFinished: () => void; - onCancel: () => void; - onBack: () => void; -} - -export default function ImportReview({ - rundown, - customFields, - summary, - onFinished, - onCancel, - onBack, -}: ImportReviewProps) { - const { data: currentRundown } = useRundown(); - const [loading, setLoading] = useState(false); - const { importRundown } = useGoogleSheet(); - const resetPreview = useSheetStore((state) => state.resetPreview); - - const handleCancel = () => { - resetPreview(); - onCancel(); - }; - - const applyImport = async () => { - setLoading(true); - - // we need to import on-top of the currently loaded rundown - // so the id needs to match - await importRundown( - { - [currentRundown.id]: { ...rundown, id: currentRundown.id, title: currentRundown.title }, - }, - customFields, - ); - setLoading(false); - onFinished(); - }; - - return ( - - - Review Rundown - - - - - - - - - Title {rundown.title} - - - Number of entries {rundown.flatOrder.length} - - - Start time {millisToString(summary.start)} - - - End time {millisToString(summary.end)} - - - Total duration {formatDuration(summary.duration)} - - - - - ); -} 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 1907a0f31..84df6e84e 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 @@ -7,7 +7,7 @@ justify-content: center; background-color: $gray-1350; border: 1px solid $white-10; - border-radius: 3px; + border-radius: $component-border-radius-md; } .uploadSection { @@ -29,7 +29,8 @@ } } -.singleActionCell { - width: 50px; - text-align: center; +@media (max-width: $medium-screen) { + .uploadSection { + flex-direction: column; + } } 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 7ef60534f..367867b5c 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 @@ -1,21 +1,29 @@ -import { ImportMap, getErrorMessage } from 'ontime-utils'; -import { ChangeEvent, useRef, useState } from 'react'; +import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types'; +import { getErrorMessage, ImportMap } from 'ontime-utils'; +import { ChangeEvent, useCallback, useRef, useState } from 'react'; import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5'; import { - importRundownPreview as importRundownPreviewExcel, + getWorksheetMetadata as getExcelWorksheetMetadata, + importRundownPreview as importExcelPreview, upload as uploadExcel, } from '../../../../../common/api/excel'; -import { getWorksheetNames } from '../../../../../common/api/sheets'; +import { + getWorksheetMetadata as getGoogleWorksheetMetadata, + getWorksheetOptions, + previewRundown as previewGoogleSheet, + uploadRundown, +} from '../../../../../common/api/sheets'; import { maybeAxiosError } from '../../../../../common/api/utils'; import Button from '../../../../../common/components/buttons/Button'; import * as Editor from '../../../../../common/components/editor-utils/EditorUtils'; +import Modal from '../../../../../common/components/modal/Modal'; +import useRundown from '../../../../../common/hooks-query/useRundown'; import { validateExcelImport } from '../../../../../common/utils/uploadUtils'; import * as Panel from '../../../panel-utils/PanelUtils'; import GSheetInfo from './GSheetInfo'; import GSheetSetup from './GSheetSetup'; -import ImportMapForm from './import-map/ImportMapForm'; -import ImportReview from './ImportReview'; +import SheetImportEditor from './sheet-import/SheetImportEditor'; import useGoogleSheet from './useGoogleSheet'; import { useSheetStore } from './useSheetStore'; @@ -25,21 +33,17 @@ export default function SourcesPanel() { const [importFlow, setImportFlow] = useState<'none' | 'excel' | 'gsheet' | 'finished'>('none'); const [error, setError] = useState(''); const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none'); + const [initialWorksheetMetadata, setInitialWorksheetMetadata] = useState(null); - const { exportRundown, importRundownPreview, verifyAuth } = useGoogleSheet(); + const { data: currentRundown } = useRundown(); + const { importRundown, verifyAuth } = useGoogleSheet(); const setWorksheets = useSheetStore((state) => state.setWorksheets); + const worksheetNames = useSheetStore((state) => state.worksheetNames); const authenticationStatus = useSheetStore((state) => state.authenticationStatus); const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus); - const rundown = useSheetStore((state) => state.rundown); - const setRundown = useSheetStore((state) => state.setRundown); - const customFields = useSheetStore((state) => state.customFields); - const setCustomFields = useSheetStore((state) => state.setCustomFields); - const summary = useSheetStore((state) => state.summary); - const setSummary = useSheetStore((state) => state.setSummary); const setSheetId = useSheetStore((state) => state.setSheetId); const sheetId = useSheetStore((state) => state.sheetId); - const resetPreview = useSheetStore((state) => state.resetPreview); const fileInputRef = useRef(null); @@ -53,15 +57,18 @@ export default function SourcesPanel() { } try { setHasFile('loading'); + setError(''); validateExcelImport(fileToUpload); - const names = await uploadExcel(fileToUpload); - setWorksheets(names); + const worksheetOptions = await uploadExcel(fileToUpload); + setWorksheets(worksheetOptions.worksheets); + setInitialWorksheetMetadata(worksheetOptions.metadata); setImportFlow('excel'); setHasFile('done'); } catch (error) { const errorMessage = getErrorMessage(error); setError(`Error uploading file: ${errorMessage}`); setWorksheets(null); + setInitialWorksheetMetadata(null); setHasFile('none'); } }; @@ -73,27 +80,29 @@ export default function SourcesPanel() { const resetFlow = () => { // we purposely omit clearing the authentication status setImportFlow('none'); - setRundown(null); setHasFile('none'); setWorksheets(null); - setCustomFields(null); - setSummary(null); setError(''); setSheetId(null); + setInitialWorksheetMetadata(null); }; const openGSheetFlow = async () => { + setError(''); + setInitialWorksheetMetadata(null); const result = await verifyAuth(); if (result) { setAuthenticationStatus(result.authenticated); setSheetId(result.sheetId); if (result.authenticated === 'authenticated' && result.sheetId) { try { - const names = await getWorksheetNames(result.sheetId); - setWorksheets(names); + const worksheetOptions = await getWorksheetOptions(result.sheetId); + setWorksheets(worksheetOptions.worksheets); + setInitialWorksheetMetadata(worksheetOptions.metadata); } catch (error) { const message = maybeAxiosError(error); setError(`Error getting worksheets: ${message}`); + setInitialWorksheetMetadata(null); } } } @@ -104,57 +113,65 @@ export default function SourcesPanel() { resetFlow(); }; - const handleSubmitImportPreview = async (importMap: ImportMap) => { - setError(''); // to clear previous error - if (importFlow === 'excel') { - try { - const previewData = await importRundownPreviewExcel(importMap); - setRundown(previewData.rundown); - setCustomFields(previewData.customFields); - setSummary(previewData.summary); - } catch (error) { - setError(maybeAxiosError(error)); - } - } - - if (importFlow === 'gsheet') { - if (!sheetId) return; - await importRundownPreview(sheetId, importMap); - } - }; - - const cancelImportMap = async () => { + const cancelImportFlow = () => { resetFlow(); - if (authenticationStatus === 'authenticated') { - const result = await verifyAuth(); - if (result) { - setAuthenticationStatus(result.authenticated); - } - } }; const handleFinished = () => { setImportFlow('finished'); - setRundown(null); setHasFile('none'); setWorksheets(null); - setCustomFields(null); setError(''); }; - const handleSubmitExport = async (importMap: ImportMap) => { - if (!sheetId) return; - await exportRundown(sheetId, importMap); + const handleApplyImport = async (preview: SpreadsheetPreviewResponse) => { + if (!currentRundown) { + throw new Error('No current rundown loaded'); + } + + await importRundown( + { + [currentRundown.id]: { + ...preview.rundown, + id: currentRundown.id, + title: currentRundown.title, + }, + }, + preview.customFields, + ); + handleFinished(); }; + const loadWorksheetMetadata = useCallback( + (worksheet: string) => + importFlow === 'excel' + ? getExcelWorksheetMetadata(worksheet) + : getGoogleWorksheetMetadata(sheetId as string, worksheet), + [importFlow, sheetId], + ); + + const previewImport = useCallback( + (importMap: ImportMap): Promise => + importFlow === 'excel' ? importExcelPreview(importMap) : previewGoogleSheet(sheetId as string, importMap), + [importFlow, sheetId], + ); + + const exportToGoogleSheet = useCallback( + (importMap: ImportMap): Promise => uploadRundown(sheetId as string, importMap), + [sheetId], + ); + const isExcelFlow = importFlow === 'excel'; const isGSheetFlow = importFlow === 'gsheet'; const isAuthenticated = authenticationStatus === 'authenticated'; const showInput = importFlow === 'none'; const showCompleted = importFlow === 'finished'; - const showAuth = isGSheetFlow && !isAuthenticated; - const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done'); - const showReview = rundown !== null && customFields !== null && summary !== null; + const showAuth = isGSheetFlow && (!isAuthenticated || !worksheetNames?.length); + const showImportWorkspace = + (isExcelFlow && hasFile === 'done' && Boolean(worksheetNames?.length)) || + (isGSheetFlow && isAuthenticated && Boolean(sheetId) && Boolean(worksheetNames?.length)); + const importModalTitle = isExcelFlow ? 'Import spreadsheet' : 'Synchronise with Google Sheet'; + const sourceKey = isExcelFlow ? 'excel' : sheetId ? `gsheet:${sheetId}` : null; return ( @@ -207,26 +224,35 @@ export default function SourcesPanel() {
)} - {showAuth && } - {showImportMap && !showReview && ( - - )} - {showReview && ( - { + setWorksheets(worksheetOptions.worksheets); + setInitialWorksheetMetadata(worksheetOptions.metadata); + }} /> )} + + } + /> ); diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/import-map/ImportMapForm.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/import-map/ImportMapForm.tsx deleted file mode 100644 index c82fbf970..000000000 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/import-map/ImportMapForm.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { ImportMap, checkRegex } from 'ontime-utils'; -import { useEffect, useState } from 'react'; -import { useFieldArray, useForm } from 'react-hook-form'; -import { IoAdd, IoTrash } from 'react-icons/io5'; - -import Button from '../../../../../../common/components/buttons/Button'; -import IconButton from '../../../../../../common/components/buttons/IconButton'; -import Info from '../../../../../../common/components/info/Info'; -import Input from '../../../../../../common/components/input/input/Input'; -import Select from '../../../../../../common/components/select/Select'; -import Tooltip from '../../../../../../common/components/tooltip/Tooltip'; -import * as Panel from '../../../../panel-utils/PanelUtils'; -import useGoogleSheet from '../useGoogleSheet'; -import { useSheetStore } from '../useSheetStore'; -import { NamedImportMap, convertToImportMap, getPersistedOptions, persistImportMap } from './importMapUtils'; - -import style from '../SourcesPanel.module.scss'; - -interface ImportMapFormProps { - hasErrors: boolean; - isSpreadsheet: boolean; - onCancel: () => void; - onSubmitExport: (importMap: ImportMap) => Promise; - onSubmitImport: (importMap: ImportMap) => Promise; -} - -export default function ImportMapForm({ - hasErrors, - isSpreadsheet, - onCancel, - onSubmitExport, - onSubmitImport, -}: ImportMapFormProps) { - const namedImportMap = getPersistedOptions(); - const { revoke } = useGoogleSheet(); - const { - control, - handleSubmit, - register, - setValue, - watch, - formState: { errors, isValid }, - } = useForm({ - mode: 'onChange', - defaultValues: namedImportMap, - values: namedImportMap, - }); - - const { fields, append, remove } = useFieldArray({ - control, - name: 'custom', - }); - - const stepData = useSheetStore((state) => state.stepData); - const worksheetNames = useSheetStore((state) => state.worksheetNames); - - const [loading, setLoading] = useState<'' | 'export' | 'import'>(''); - - // Set first sheet as default worksheet when 'event schedule' sheet is not there - useEffect(() => { - if (!worksheetNames || worksheetNames.length === 0) return; - if (!worksheetNames.includes(namedImportMap.Worksheet)) { - setValue('Worksheet', worksheetNames[0], { shouldValidate: true, shouldDirty: true }); - } - }, [worksheetNames, setValue, namedImportMap.Worksheet]); - - const handleExport = async (values: NamedImportMap) => { - setLoading('export'); - const importMap = convertToImportMap(values); - - await onSubmitExport(importMap); - setLoading(''); - }; - - const handleRevoke = async () => { - await revoke(); - onCancel(); - }; - - const handleImportPreview = async (values: NamedImportMap) => { - setLoading('import'); - const importMap = convertToImportMap(values); - persistImportMap(values); - await onSubmitImport(importMap); - setLoading(''); - }; - - const deleteCustomImport = (index: number) => { - remove(index); - }; - - const addCustomImport = () => { - append({}); - }; - - const isLoading = Boolean(loading); - const canSubmitSpreadsheet = isSpreadsheet && !isLoading; - const canSubmitGSheet = !isLoading && !stepData.worksheet.error; - const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet); - - return ( - - - Import options - - {!isSpreadsheet && ( - } - onClick={handleRevoke} - disabled={isLoading} - > - Revoke - - )} - - {!isSpreadsheet && ( - - )} - - - - - Match your spreadsheet columns to Ontime fields.
- You can also add Custom Fields by providing a name for Ontime and the spreadsheet column name. -
- - - - Ontime field - Column name in spreadsheet - - - - - {Object.entries(namedImportMap).map(([label, importName]) => { - if (label === 'custom') { - return null; - } - if (label === 'Worksheet') { - return ( - - {label} - - - - - - ); - })} - {fields.map((field, index) => { - const ontimeName = field.ontimeName; - const importName = field.importName; - const maybeOntimeError = errors.custom?.[index]?.ontimeName?.message; - const key = `custom.${index}.ontimeName`; - return ( - - - { - if (!checkRegex.isAlphanumericWithSpace(value)) - return 'Only alphanumeric characters and space are allowed'; - return true; - }, - })} - /> - {maybeOntimeError && {maybeOntimeError}} - - - - - - deleteCustomImport(index)} - > - - - - - ); - })} - - - - - - - - - - {stepData.worksheet.error} -
- ); -} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/import-map/__test__/importMapUtils.test.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/import-map/__test__/importMapUtils.test.ts deleted file mode 100644 index 60b7ab656..000000000 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/import-map/__test__/importMapUtils.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ImportCustom } from 'ontime-utils'; - -import { NamedImportMap, convertToImportMap } from '../importMapUtils'; - -describe('convertToImportMap', () => { - it('converts a namedImportMap to a importMap', () => { - const defaultNamedImporMap = { - Worksheet: 'event schedule', - Start: 'time start', - 'Link start': 'link start', - End: 'time end', - Duration: 'duration', - Cue: 'cue', - Title: 'title', - Skip: 'skip', - Note: 'notes', - Colour: 'colour', - 'End action': 'end action', - 'Timer type': 'timer type', - 'Time warning': 'warning time', - 'Time danger': 'danger time', - custom: [ - { ontimeName: 'Custom1 ', importName: 'custom1' }, - { ontimeName: 'Custom2', importName: 'custom2' }, - { ontimeName: 'Custom3', importName: 'custom3' }, - { ontimeName: 'EmptyImportName', importName: '' }, - { ontimeName: '', importName: 'EmptyOntimeName' }, - ] as ImportCustom[], - } as NamedImportMap; - - const importMap = convertToImportMap(defaultNamedImporMap); - expect(importMap.custom).toStrictEqual({ - Custom1: 'custom1', - Custom2: 'custom2', - Custom3: 'custom3', - }); - }); -}); diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/import-map/importMapUtils.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/import-map/importMapUtils.ts deleted file mode 100644 index e0b28439a..000000000 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/import-map/importMapUtils.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { ImportCustom, ImportMap } from 'ontime-utils'; - -import { makeStageKey } from '../../../../../../common/utils/localStorage'; - -export type NamedImportMap = typeof namedImportMap; - -// Record of label and import name -const namedImportMap = { - Worksheet: 'event schedule', - Flag: 'flag', - Start: 'time start', - 'Link start': 'link start', - End: 'time end', - Duration: 'duration', - Cue: 'cue', - Title: 'title', - 'Count to end': 'count to end', - Skip: 'skip', - Note: 'notes', - Colour: 'colour', - 'End action': 'end action', - 'Timer type': 'timer type', - 'Time warning': 'warning time', - 'Time danger': 'danger time', - ID: 'id', - custom: [] as ImportCustom[], -}; - -function isNamedImportMap(obj: unknown): obj is NamedImportMap { - if (typeof obj !== 'object' || obj === null) { - return false; - } - - const keys = Object.keys(namedImportMap); - return keys.every((key) => Object.hasOwn(obj, key)); -} - -export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap { - const custom = namedImportMap.custom.reduce((accumulator, { ontimeName, importName }) => { - if (ontimeName && importName) { - accumulator[ontimeName.trim()] = importName.trim(); - } - return accumulator; - }, {}); - - return { - worksheet: namedImportMap.Worksheet, - flag: namedImportMap.Flag, - timeStart: namedImportMap.Start, - linkStart: namedImportMap['Link start'], - timeEnd: namedImportMap.End, - duration: namedImportMap.Duration, - cue: namedImportMap.Cue, - title: namedImportMap.Title, - countToEnd: namedImportMap['Count to end'], - skip: namedImportMap.Skip, - note: namedImportMap.Note, - colour: namedImportMap.Colour, - endAction: namedImportMap['End action'], - timerType: namedImportMap['Timer type'], - timeWarning: namedImportMap['Time warning'], - timeDanger: namedImportMap['Time danger'], - custom, - id: namedImportMap.ID, - }; -} - -const importMapKey = makeStageKey('import-map'); - -export function persistImportMap(options: NamedImportMap) { - localStorage.setItem(importMapKey, JSON.stringify(options)); -} - -function getPersistImportMap(): unknown { - const options = localStorage.getItem(importMapKey); - if (!options) { - throw new Error('no import options found'); - } - return JSON.parse(options); -} - -export function getPersistedOptions(): NamedImportMap { - try { - const options = getPersistImportMap(); - if (!isNamedImportMap(options)) { - return namedImportMap; - } - return options; - } catch { - return namedImportMap; - } -} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.module.scss b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.module.scss deleted file mode 100644 index 23c0e0d70..000000000 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.module.scss +++ /dev/null @@ -1,28 +0,0 @@ -.center { - text-align: center; -} - -.nowrap { - white-space: nowrap; -} - -tr .secondaryRow { - background-color: $white-7; - padding-left: 2em; -} - -.linkStartActive { - flex-shrink: 0; - color: $active-indicator; - transform: rotate(-45deg); -} - -.flex { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.subdued { - opacity: $opacity-disabled; -} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.tsx deleted file mode 100644 index dbe3e04b7..000000000 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/preview/PreviewRundown.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { CustomFields, Rundown, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; -import { millisToString } from 'ontime-utils'; -import { Fragment } from 'react'; -import { IoLink } from 'react-icons/io5'; - -import Tag from '../../../../../../common/components/tag/Tag'; -import { getAccessibleColour } from '../../../../../../common/utils/styleUtils'; -import * as Panel from '../../../../panel-utils/PanelUtils'; - -import style from './PreviewRundown.module.scss'; - -interface PreviewRundownProps { - rundown: Rundown; - customFields: CustomFields; -} - -function booleanToText(value?: boolean) { - return value ? 'Yes' : undefined; -} - -export default function PreviewRundown({ rundown, customFields }: PreviewRundownProps) { - // we only count Ontime Events which are 1 based in client - let eventIndex = 0; - - const fieldKeys = Object.keys(customFields); - const fieldLabels = fieldKeys.map((key) => customFields[key].label); - - return ( - - - - # - Type - Cue - Title - Flag - Time Start - Time End - Duration - Warning Time - Danger Time - Count to end - Skip - Colour - Timer Type - End Action - {fieldLabels.map((label) => ( - {label} - ))} - ID - - - - {rundown.flatOrder.map((entryId) => { - const entry = rundown.entries[entryId]; - if (isOntimeGroup(entry)) { - const colour = entry.colour ? getAccessibleColour(entry.colour) : {}; - return ( - - {/** Index */} - - {entry.type} - - {/** CUE */} - {entry.title} - {/** Flag */} - {/** Time Start */} - {/** Time End */} - {/** Duration */} - {/** Warning Time */} - {/** Danger Time */} - {/** Count to end */} - {/** Skip */} - {entry.colour} - {/** Timer Type */} - {/** End Action */} - {fieldKeys.map((field) => { - let value = ''; - if (field in entry.custom) { - value = entry.custom[field]; - } - return {value}; - })} - - {entry.id} - - - ); - } - if (isOntimeMilestone(entry)) { - const colour = entry.colour ? getAccessibleColour(entry.colour) : {}; - return ( - - - {/** Index */} - - {entry.type} - - {entry.cue} - {entry.title} - {/** Flag */} - {/** Time Start */} - {/** Time End */} - {/** Duration */} - {/** Warning Time */} - {/** Danger Time */} - {/** Count to end */} - {/** Skip */} - {entry.colour} - {/** Timer Type */} - {/** End Action */} - {fieldKeys.map((field) => { - let value = ''; - if (field in entry.custom) { - value = entry.custom[field]; - } - return {value}; - })} - - {entry.id} - - - {entry.note && ( - - - Note: {entry.note} - - - )} - - ); - } - if (!isOntimeEvent(entry)) { - return null; - } - eventIndex += 1; - const colour = entry.colour ? getAccessibleColour(entry.colour) : {}; - const countToEnd = booleanToText(entry.countToEnd); - const skip = booleanToText(entry.skip); - const flag = booleanToText(entry.flag); - - return ( - - - - {eventIndex} - - - {entry.type} - - {entry.cue} - {entry.title} - {flag && {flag}} - - {millisToString(entry.timeStart)} - {entry.linkStart && } - - {millisToString(entry.timeEnd)} - {millisToString(entry.duration)} - {millisToString(entry.timeWarning)} - {millisToString(entry.timeDanger)} - {countToEnd && {countToEnd}} - {skip && {skip}} - {entry.colour} - - {entry.timerType} - - - {entry.endAction} - - {fieldKeys.map((field) => { - let value = ''; - if (field in entry.custom) { - value = entry.custom[field]; - } - return {value}; - })} - - {entry.id} - - - {entry.note && ( - - - Note: {entry.note} - - - )} - - ); - })} - - - ); -} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/MappingFieldRow.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/MappingFieldRow.tsx new file mode 100644 index 000000000..b68dd6ec8 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/MappingFieldRow.tsx @@ -0,0 +1,63 @@ +import type { ReactNode } from 'react'; +import { IoCheckmark } from 'react-icons/io5'; + +import AutocompleteInput from '../../../../../../common/components/autocomplete-input/AutocompleteInput'; +import { cx } from '../../../../../../common/utils/styleUtils'; +import type { MappingWarning } from './importMapUtils'; + +import style from './SheetImportEditor.module.scss'; + +export function getWarningText(warning: MappingWarning): string { + switch (warning.kind) { + case 'duplicate': + return 'Column mapped more than once'; + case 'missing': + return 'Column not in current headers, check preview'; + case 'invalid-name': + return 'Column cannot be converted into an Ontime field name'; + case 'name-collision': + return 'Column name resolves to a duplicate column'; + default: + return ''; + } +} + +interface MappingFieldRowProps { + header: ReactNode; + value: string; + onValueChange: (value: string) => void; + warning?: MappingWarning; + options: string[]; + assigned: Set; + disabled?: boolean; +} + +export default function MappingFieldRow({ + header, + value, + onValueChange, + warning, + options, + assigned, + disabled = false, +}: MappingFieldRowProps) { + const warningText = warning ? getWarningText(warning) : undefined; + + return ( +
+ {header} + {warningText && {warningText}} + (assigned.has(option) ? : null)} + placeholder='Spreadsheet column' + disabled={disabled} + title={warningText} + value={value} + onValueChange={onValueChange} + /> +
+ ); +} 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 new file mode 100644 index 000000000..8e5d26081 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.module.scss @@ -0,0 +1,162 @@ +.editor { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + max-width: none; + height: 100%; + min-height: 0; +} + +.editorToolbar { + display: flex; + gap: 0.75rem 1rem; + align-items: center; + justify-content: space-between; + padding-inline: 0.5rem 0; +} + +.editorFooter { + padding: 0.75rem 0 0.5rem; +} + +.worksheetControl { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.worksheetLabel { + color: $gray-300; + font-size: 0.9rem; + font-weight: 600; + white-space: nowrap; +} + +.addColumnTrigger { + justify-content: center; + white-space: nowrap; +} + +.editorBody { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr); + gap: 1rem; +} + +.mappingPane, +.previewPane { + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + border: 1px solid $white-10; + background-color: $gray-1350; + border-radius: $component-border-radius-md; +} + +.mappingPaneHeader, +.previewPaneHeader { + box-sizing: border-box; + display: flex; + align-items: center; + min-height: 3.5rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid $white-10; +} + +.mappingPaneHeader { + align-items: flex-start; + gap: 0.75rem; + flex-wrap: wrap; +} + +.mappingPaneActions { + justify-content: flex-end; + flex-wrap: wrap; + row-gap: 0.5rem; + max-width: 100%; +} + +.mappingPaneTitle, +.previewPaneTitle { + color: $ui-white; + font-size: 0.95rem; + font-weight: 600; +} + +.mappingPaneTitle { + align-self: center; +} + +.mappingList { + min-height: 0; + overflow: auto; + padding: 0.75rem 0.75rem 2rem; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.mappingField { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.mappingFieldHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.mappingFieldTitle { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.mappingFieldLabel { + color: $ui-white; + font-size: 0.9rem; + font-weight: 600; +} + +.mappingFieldWarning { + color: $orange-400; + font-size: 0.8rem; + line-height: 1.3; +} + +.tableShell { + flex: 1; + min-height: 0; + overflow: auto; +} + +.columnInput { + width: 100%; +} + +.columnInputWarn { + border-color: $orange-400; + box-shadow: 0 0 0 1px rgba($orange-400, 0.2); +} + +@media (max-width: $medium-screen) { + .editorBody { + grid-template-columns: 1fr; + } + + .editorToolbar { + align-items: center; + } + + .worksheetControl { + justify-content: space-between; + } +} 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 new file mode 100644 index 000000000..895c9a006 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.tsx @@ -0,0 +1,140 @@ +import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types'; +import type { ImportMap } from 'ontime-utils'; +import { IoArrowUpOutline, IoEye } from 'react-icons/io5'; + +import Button from '../../../../../../common/components/buttons/Button'; +import Select from '../../../../../../common/components/select/Select'; +import * as Panel from '../../../../panel-utils/PanelUtils'; +import PreviewTable from './preview/PreviewTable'; +import SheetImportMappingPane from './SheetImportMappingPane'; +import { useSheetImportForm } from './useSheetImportForm'; + +import style from './SheetImportEditor.module.scss'; + +interface SheetImportEditorProps { + sourceKey: string; + worksheetNames: string[]; + initialMetadata: SpreadsheetWorksheetMetadata | null; + loadMetadata: (worksheet: string) => Promise; + previewImport: (importMap: ImportMap) => Promise; + onApply: (preview: SpreadsheetPreviewResponse) => Promise; + onCancel: () => void; + onExport?: (importMap: ImportMap) => Promise; +} + +export default function SheetImportEditor({ + sourceKey, + worksheetNames, + initialMetadata, + loadMetadata, + previewImport, + onApply, + onCancel, + onExport, +}: SheetImportEditorProps) { + const { + values, + setValue, + fields, + addCustomField, + removeCustomField, + sampleHeaders, + assignedHeaders, + warnings, + columnLabels, + worksheetHeaders, + state, + toolbarStatus, + isLoadingMetadata, + isBusy, + canPreview, + displayError, + handlePreviewSubmit, + handleExportSubmit, + handleApply, + } = useSheetImportForm({ + sourceKey, + worksheetNames, + initialMetadata, + loadMetadata, + previewImport, + onApply, + onExport, + }); + + return ( + + +