diff --git a/.eslintrc b/.eslintrc index 44af6bf47..341ee5eaf 100644 --- a/.eslintrc +++ b/.eslintrc @@ -21,6 +21,6 @@ } ], "rules": { - "no-console": "warn" + "no-console": ["warn", { "allow": ["warn", "error"]}] } } \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index f272a5417..5d8bda1da 100644 Binary files a/.nvmrc and b/.nvmrc differ diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index 6e9d6b4c6..593f7d35d 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -3,6 +3,7 @@ import { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, import { apiRepoLatest } from '../../externals'; import { InfoType } from '../models/Info'; +import fileDownload from '../utils/fileDownload'; import { ontimeURL } from './apiConstants'; @@ -109,32 +110,17 @@ export async function postOscSubscriptions(data: OscSubscription) { } /** - * @description HTTP request to download db - * @return {Promise} + * @description HTTP request to download db in CSV format */ -export const downloadRundown = async () => { - await axios({ - url: `${ontimeURL}/db`, - method: 'GET', - responseType: 'blob', // important - }).then((response) => { - const headerLine = response.headers['Content-Disposition']; - let filename = 'rundown.json'; +export const downloadCSV = () => { + return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' }); +}; - // try and get the filename from the response - if (headerLine != null) { - const startFileNameIndex = headerLine.indexOf('"') + 1; - const endFileNameIndex = headerLine.lastIndexOf('"'); - filename = headerLine.substring(startFileNameIndex, endFileNameIndex); - } - - const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/json' })); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', filename); - document.body.appendChild(link); - link.click(); - }); +/** + * @description HTTP request to download db in JSON format + */ +export const downloadRundown = () => { + return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' }); }; /** diff --git a/apps/client/src/common/utils/fileDownload.ts b/apps/client/src/common/utils/fileDownload.ts new file mode 100644 index 000000000..38bd07b10 --- /dev/null +++ b/apps/client/src/common/utils/fileDownload.ts @@ -0,0 +1,56 @@ +import axios from 'axios'; + +import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils'; + +type FileOptions = { + name: string; + type: string; +}; + +type BlobOptions = { + type: string; +}; + +export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) { + const response = await axios({ + url: `${url}/db`, + method: 'GET', + }); + + const headerLine = response.headers['Content-Disposition']; + let { name: fileName } = fileOptions; + const { type: fileType } = fileOptions; + const { project, rundown, userFields } = response.data; + + // try and get the filename from the response + if (headerLine != null) { + const startFileNameIndex = headerLine.indexOf('"') + 1; + const endFileNameIndex = headerLine.lastIndexOf('"'); + fileName = headerLine.substring(startFileNameIndex, endFileNameIndex); + } + + let fileContent = ''; + + if (fileType === 'json') { + fileContent = JSON.stringify(response.data); + fileName += '.json'; + } + + if (fileType === 'csv') { + const sheetData = makeTable(project, rundown, userFields); + fileContent = makeCSV(sheetData); + fileName += '.csv'; + } + + const blob = new Blob([fileContent], { type: blobOptions.type }); + const downloadUrl = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.setAttribute('href', downloadUrl); + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); + // Clean up the URL.createObjectURL to release resources + URL.revokeObjectURL(downloadUrl); + return; +} diff --git a/apps/client/src/features/cuesheet/CuesheetWrapper.module.scss b/apps/client/src/features/cuesheet/CuesheetWrapper.module.scss index 851ddadef..cac2a120f 100644 --- a/apps/client/src/features/cuesheet/CuesheetWrapper.module.scss +++ b/apps/client/src/features/cuesheet/CuesheetWrapper.module.scss @@ -21,4 +21,4 @@ border: 1px solid $white-10; border-radius: 4px; } -} +} \ No newline at end of file diff --git a/apps/client/src/features/cuesheet/CuesheetWrapper.tsx b/apps/client/src/features/cuesheet/CuesheetWrapper.tsx index e6e8e432f..9aab2414b 100644 --- a/apps/client/src/features/cuesheet/CuesheetWrapper.tsx +++ b/apps/client/src/features/cuesheet/CuesheetWrapper.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { OntimeRundownEntry, ProjectData } from 'ontime-types'; import Empty from '../../common/components/state/Empty'; @@ -6,6 +6,7 @@ import { useEventAction } from '../../common/hooks/useEventAction'; import { useCuesheet } from '../../common/hooks/useSocket'; import useRundown from '../../common/hooks-query/useRundown'; import useUserFields from '../../common/hooks-query/useUserFields'; +import ExportModal, { ExportType } from '../modals/export-modal/ExportModal'; import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader'; import Cuesheet from './Cuesheet'; @@ -20,6 +21,8 @@ export default function CuesheetWrapper() { const { updateEvent } = useEventAction(); const featureData = useCuesheet(); const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [headerData, setheaderData] = useState(null); // Set window title useEffect(() => { @@ -69,37 +72,80 @@ export default function CuesheetWrapper() { ); const exportHandler = useCallback( - (headerData: ProjectData) => { + (headerData: ProjectData, exportType: ExportType) => { if (!headerData || !rundown || !userFields) { return; } - const sheetData = makeTable(headerData, rundown, userFields); - const csvContent = makeCSV(sheetData); + let fileName = ''; + let url = ''; - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); + if (exportType === 'json') { + const jsonContent = JSON.stringify({ + headerData, + rundown, + userFields, + }); + + fileName = 'ontime export.json'; + + const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' }); + url = URL.createObjectURL(blob); + } else if (exportType === 'csv') { + const sheetData = makeTable(headerData, rundown, userFields); + const csvContent = makeCSV(sheetData); + + fileName = 'ontime export.csv'; + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + url = URL.createObjectURL(blob); + } else { + console.error('Invalid export type: ', exportType); + return; + } const link = document.createElement('a'); link.setAttribute('href', url); - link.setAttribute('download', 'ontime export.csv'); + link.setAttribute('download', fileName); document.body.appendChild(link); link.click(); - // Clean up the URL.createObjectURL to release resources URL.revokeObjectURL(url); + return; }, [rundown, userFields], ); + const onModalClose = (exportType?: ExportType) => { + setIsModalOpen(false); + + if (!exportType) { + return; + } + + if (headerData) { + exportHandler(headerData, exportType); + } + }; + + const handleOpenModal = (projectData: ProjectData) => { + setheaderData(projectData); + setIsModalOpen(true); + }; + if (!rundown || !userFields) { return ; } return (
- + +
); } diff --git a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx index 3e7e5e896..d260f42e6 100644 --- a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx +++ b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx @@ -16,7 +16,7 @@ import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers'; import style from './CuesheetTableHeader.module.scss'; interface CuesheetTableHeaderProps { - handleCSVExport: (headerData: ProjectData) => void; + handleExport: (headerData: ProjectData) => void; featureData: { playback: Playback; selectedEventIndex: number | null; @@ -25,7 +25,7 @@ interface CuesheetTableHeaderProps { }; } -export default function CuesheetTableHeader({ handleCSVExport, featureData }: CuesheetTableHeaderProps) { +export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) { const followSelected = useCuesheetSettings((state) => state.followSelected); const showSettings = useCuesheetSettings((state) => state.showSettings); const toggleSettings = useCuesheetSettings((state) => state.toggleSettings); @@ -33,9 +33,9 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu const { isFullScreen, toggleFullScreen } = useFullscreen(); const { data: project } = useProjectData(); - const exportCsv = () => { + const exportProject = () => { if (project) { - handleCSVExport(project); + handleExport(project); } }; @@ -72,9 +72,9 @@ export default function CuesheetTableHeader({ handleCSVExport, featureData }: Cu {isFullScreen ? : } - - - CSV + + + Export diff --git a/apps/client/src/features/menu/MenuBar.tsx b/apps/client/src/features/menu/MenuBar.tsx index 854449fcc..994f08a5a 100644 --- a/apps/client/src/features/menu/MenuBar.tsx +++ b/apps/client/src/features/menu/MenuBar.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; import { VStack } from '@chakra-ui/react'; import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand'; import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle'; @@ -10,11 +10,12 @@ import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline'; import { IoSaveOutline } from '@react-icons/all-files/io5/IoSaveOutline'; import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; -import { downloadRundown } from '../../common/api/ontimeApi'; +import { downloadCSV, downloadRundown } from '../../common/api/ontimeApi'; import QuitIconBtn from '../../common/components/buttons/QuitIconBtn'; import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn'; import useElectronEvent from '../../common/hooks/useElectronEvent'; import { AppMode, useAppMode } from '../../common/stores/appModeStore'; +import ExportModal, { ExportType } from '../modals/export-modal/ExportModal'; import style from './MenuBar.module.scss'; @@ -100,6 +101,22 @@ const MenuBar = (props: MenuBarProps) => { }; }, [handleKeyPress, isElectron]); + const [isModalOpen, setIsModalOpen] = useState(false); + + const onModalClose = (exportType?: ExportType) => { + setIsModalOpen(false); + + if (!exportType) { + return; + } + + if (exportType === 'json') { + downloadRundown(); + } else if (exportType === 'csv') { + downloadCSV(); + } + }; + return ( @@ -128,12 +145,18 @@ const MenuBar = (props: MenuBarProps) => { {...buttonStyle} icon={} isDisabled={appMode === AppMode.Run} - clickHandler={downloadRundown} + clickHandler={() => setIsModalOpen(true)} tooltip='Export project file' aria-label='Export project file' size='sm' /> + +
void; + buttonVariants: { + csv: string; + json: string; + }; +} + +export default function ExportModal(props: ExportModalProps) { + const { isOpen, onClose, buttonVariants } = props; + + return ( + + + + Download options + + + + + + + + ); +} diff --git a/apps/test-db/db.json b/apps/test-db/db.json index fad9cb591..63c4dc6ea 100644 --- a/apps/test-db/db.json +++ b/apps/test-db/db.json @@ -1,16 +1,20 @@ { "rundown": [ { - "title": "First test event", + "type": "block", + "id": "18797" + }, + { + "title": "title 1", "subtitle": "", "presenter": "", "note": "", "endAction": "none", "timerType": "count-down", - "timeStart": 32400000, - "timeEnd": 36000000, - "duration": 3600000, - "isPublic": true, + "timeStart": 60000, + "timeEnd": 120000, + "duration": 60000, + "isPublic": false, "skip": false, "colour": "", "user0": "", @@ -29,22 +33,16 @@ "cue": "1" }, { - "duration": 600000, - "type": "delay", - "revision": 0, - "id": "b1d5a" - }, - { - "title": "Second test event", + "title": "title 2", "subtitle": "", "presenter": "", "note": "", "endAction": "none", "timerType": "count-down", - "timeStart": 36000000, - "timeEnd": 39600000, - "duration": 3600000, - "isPublic": true, + "timeStart": 120000, + "timeEnd": 180000, + "duration": 60000, + "isPublic": false, "skip": false, "colour": "", "user0": "", @@ -63,12 +61,7 @@ "cue": "2" }, { - "title": "Lunch", - "type": "block", - "id": "91682" - }, - { - "title": "Third test event", + "title": "title 3", "subtitle": "", "presenter": "", "note": "", diff --git a/e2e/tests/features/202-cuesheet.spec.ts b/e2e/tests/features/202-cuesheet.spec.ts index 1c9547252..14b6b4fc6 100644 --- a/e2e/tests/features/202-cuesheet.spec.ts +++ b/e2e/tests/features/202-cuesheet.spec.ts @@ -19,7 +19,8 @@ test('cuesheet displays events and exports csv', async ({ page }) => { await page.getByRole('cell', { name: '+10 min' }).click(); await page.getByRole('cell', { name: 'Lunch' }).click(); const downloadPromise = page.waitForEvent('download'); - await page.getByTestId('cuesheet').getByText('CSV').click(); + await page.getByTestId('cuesheet').getByText('Export').click(); + await page.getByText('CSV').click(); // From here we test the CSV download feature