Feat/table part1 (#197)

* feat(csv): export data as csv file
* feat(table): toggle fullscreen
* ux: coordinate tooltip open delay
* feat(excelDates): update tests
* refactor: folder structure
This commit is contained in:
Carlos Valente
2022-09-04 22:44:03 +02:00
committed by GitHub
parent 0651777055
commit 0e450cb6cb
43 changed files with 628 additions and 126 deletions
@@ -7,6 +7,8 @@ import { FiMinusCircle } from '@react-icons/all-files/fi/FiMinusCircle';
import { FiPlus } from '@react-icons/all-files/fi/FiPlus';
import PropTypes from 'prop-types';
import { tooltipDelayMid } from '../../../ontimeConfig';
export default function ActionButtons(props) {
const { showAdd, showDelay, showBlock, actionHandler } = props;
@@ -17,7 +19,7 @@ export default function ActionButtons(props) {
return (
<Menu isLazy lazyBehavior='unmount'>
<Tooltip label='Add ...' delay={500}>
<Tooltip label='Add ...' delay={tooltipDelayMid}>
<MenuButton
as={IconButton}
aria-label='Options'
@@ -4,10 +4,12 @@ import { Tooltip } from '@chakra-ui/tooltip';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import PropTypes from 'prop-types';
import { tooltipDelayMid } from '../../../ontimeConfig';
export default function PauseIconBtn(props) {
const { clickhandler, active, disabled, ...rest } = props;
return (
<Tooltip label='Pause timer' openDelay={500} shouldWrapChildren={disabled}>
<Tooltip label='Pause timer' openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
<IconButton
icon={<IoPause size='24px' />}
colorScheme='orange'
@@ -4,10 +4,12 @@ import { Tooltip } from '@chakra-ui/tooltip';
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
import PropTypes from 'prop-types';
import { tooltipDelayMid } from '../../../ontimeConfig';
export default function RollIconBtn(props) {
const { clickhandler, active, disabled, ...rest } = props;
return (
<Tooltip label='Roll mode' openDelay={500} shouldWrapChildren={disabled}>
<Tooltip label='Roll mode' openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
<IconButton
icon={<IoTimeOutline size='24px' />}
colorScheme='blue'
@@ -4,10 +4,12 @@ import { Tooltip } from '@chakra-ui/tooltip';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import PropTypes from 'prop-types';
import { tooltipDelayMid } from '../../../ontimeConfig';
export default function StartIconBtn(props) {
const { clickhandler, active, disabled, ...rest } = props;
return (
<Tooltip label='Start timer' openDelay={500} shouldWrapChildren={disabled}>
<Tooltip label='Start timer' openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
<IconButton
icon={<IoPlay size='24px' />}
colorScheme='green'
@@ -4,9 +4,9 @@ import { Tooltip } from '@chakra-ui/tooltip';
import PropTypes from 'prop-types';
export default function TooltipActionBtn(props) {
const { clickHandler, icon, color, size='xs', tooltip, ...rest } = props;
const { clickHandler, icon, color, size='xs', tooltip, openDelay = 0, ...rest } = props;
return (
<Tooltip label={tooltip}>
<Tooltip label={tooltip} openDelay={openDelay}>
<IconButton
aria-label={tooltip}
size={size}
@@ -23,5 +23,6 @@ TooltipActionBtn.propTypes = {
icon: PropTypes.element,
color: PropTypes.string,
size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']),
tooltip: PropTypes.string
tooltip: PropTypes.string,
openDelay: PropTypes.number
}
@@ -3,10 +3,12 @@ import { IconButton } from '@chakra-ui/button';
import { Tooltip } from '@chakra-ui/tooltip';
import PropTypes from 'prop-types';
import { tooltipDelayMid } from '../../../ontimeConfig';
export default function TransportIconBtn(props) {
const { clickHandler, icon, tooltip, disabled, ...rest } = props;
return (
<Tooltip label={tooltip} openDelay={500} shouldWrapChildren={disabled}>
<Tooltip label={tooltip} openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
<IconButton
icon={icon}
colorScheme='white'
@@ -4,10 +4,12 @@ import { Tooltip } from '@chakra-ui/tooltip';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import PropTypes from 'prop-types';
import { tooltipDelayMid } from '../../../ontimeConfig';
export default function UnloadIconBtn(props) {
const { clickHandler, disabled, ...rest } = props;
return (
<Tooltip label='Unload event' openDelay={500} shouldWrapChildren={disabled}>
<Tooltip label='Unload event' openDelay={tooltipDelayMid} shouldWrapChildren={disabled}>
<IconButton
icon={<IoStop size='22px' />}
colorScheme='red'
+7 -16
View File
@@ -2,11 +2,14 @@ import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { IconButton } from '@chakra-ui/button';
import { Image } from '@chakra-ui/react';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import navlogo from 'assets/images/logos/LOGO-72.png';
import { AnimatePresence, motion } from 'framer-motion';
import PropTypes from 'prop-types';
import useFullscreen from '../../hooks/useFullscreen';
import navigatorConstants from './navigatorConstants';
import style from './NavLogo.module.scss';
@@ -22,6 +25,7 @@ const navButtonStyle = {
export default function NavLogo(props) {
const { isHidden } = props;
const [showNav, setShowNav] = useState(false);
const { isFullScreen, toggleFullScreen } = useFullscreen();
const handleClick = useCallback(() => {
setShowNav((prev) => !prev);
@@ -37,16 +41,6 @@ export default function NavLogo(props) {
}
}, []);
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
}, []);
useEffect(() => {
// attach the event listener
document.addEventListener('keydown', handleKeyPress);
@@ -83,8 +77,8 @@ export default function NavLogo(props) {
>
<IconButton
aria-label='Toggle Fullscreen'
icon={<IoExpand />}
onClick={toggleFullscreen}
icon={isFullScreen ? <IoContract /> : <IoExpand />}
onClick={toggleFullScreen}
{...navButtonStyle}
/>
</motion.div>
@@ -96,10 +90,7 @@ export default function NavLogo(props) {
className={style.nav}
>
{navigatorConstants.map((route) => (
<Link
to={route.url}
key={route.url}
{...tabProps}>
<Link to={route.url} key={route.url} {...tabProps}>
{route.label}
</Link>
))}
+31
View File
@@ -0,0 +1,31 @@
import { useCallback, useEffect, useState } from 'react';
export default function useFullscreen() {
const [isFullScreen, setFullScreen] = useState(document.fullscreenElement);
useEffect(() => {
const handleChange = () => {
setFullScreen(document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleChange, { passive: true });
document.addEventListener('resize', handleChange, { passive: true });
return () => {
document.removeEventListener('fullscreenchange', handleChange, { passive: true });
document.removeEventListener('resize', handleChange, { passive: true });
}; }, []);
const toggleFullScreen = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
setFullScreen(document.fullscreenElement);
}, []);
return { isFullScreen, toggleFullScreen };
}
+2 -4
View File
@@ -1,10 +1,8 @@
import { mth, mtm, mts } from './timeConstants';
export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss';
const mts = 1000; // millis to seconds
const mtm = 1000 * 60; // millis to minutes
const mth = 1000 * 60 * 60; // millis to hours
/**
* another go at simpler string formatting (counters)
* @description Converts seconds to string representing time
+2 -3
View File
@@ -3,9 +3,8 @@ import { DateTime } from 'luxon';
import { ontimeQueryClient } from '../../App';
import { APP_SETTINGS } from '../api/apiConstants';
const mts = 1000; // millis to seconds
const mtm = 1000 * 60; // millis to minutes
const mth = 1000 * 60 * 60; // millis to hours
import { mth, mtm, mts } from './timeConstants';
/**
* Returns current time in milliseconds
+24
View File
@@ -0,0 +1,24 @@
/**
* millis to seconds
* @type {number}
*/
export const mts = 1000;
/**
* millis to minutes
* @type {number}
*/
export const mtm = 1000 * 60;
/**
* millis to hours
* @type {number}
*/
export const mth = 1000 * 60 * 60;
/**
* milliseconds in a day
* @type {number}
*/
export const DAY_TO_MS = 86400000;
@@ -5,13 +5,15 @@ import { Tooltip } from '@chakra-ui/tooltip';
import { IoSunny } from '@react-icons/all-files/io5/IoSunny';
import PropTypes from 'prop-types';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './MessageControl.module.scss';
export default function InputRow(props) {
const { label, placeholder, text, visible, actionHandler, changeHandler } = props;
return (
<div className={visible && style.inputRowActive}>
<div className={`${visible ? style.inputRowActive: ''}`}>
<span className={style.label}>{label}</span>
<div className={style.inputItems}>
<Editable
@@ -21,10 +23,10 @@ export default function InputRow(props) {
className={style.inline}
color={text === '' ? '#666' : 'inherit'}
>
<EditablePreview className={style.padleft} />
<EditablePreview className={`${style.padleft} ${style.fullWidth}`} />
<EditableInput className={style.padleft} />
</Editable>
<Tooltip label={visible ? 'Make invisible' : 'Make visible'} openDelay={500}>
<Tooltip label={visible ? 'Make invisible' : 'Make visible'} openDelay={tooltipDelayMid}>
<IconButton
aria-label='Toggle visibility'
size='sm'
@@ -5,6 +5,8 @@ import { IoMicOffOutline } from '@react-icons/all-files/io5/IoMicOffOutline';
import { IoMicSharp } from '@react-icons/all-files/io5/IoMicSharp';
import { useSocket } from 'common/context/socketContext';
import { tooltipDelayMid } from '../../../ontimeConfig';
import InputRow from './InputRow';
import style from './MessageControl.module.scss';
@@ -122,7 +124,7 @@ export default function MessageControl() {
/>
</div>
<div className={style.onAirToggle}>
<Tooltip label={onAir ? 'Go Off Air' : 'Go On Air'} openDelay={500}>
<Tooltip label={onAir ? 'Go Off Air' : 'Go On Air'} openDelay={tooltipDelayMid}>
<IconButton
className={style.btn}
size='md'
@@ -36,10 +36,15 @@
border-radius: 2px;
}
.fullWidth {
width: 100%;
}
.inputRowActive {
.label {
color: $light-bg;
}
.inline {
background-color: $light-bg-transparent;
}
@@ -5,6 +5,7 @@ import TimerDisplay from 'common/components/countdown/TimerDisplay';
import PropTypes from 'prop-types';
import { stringFromMillis } from '../../../common/utils/time';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './PlaybackControl.module.scss';
@@ -69,7 +70,7 @@ const PlaybackTimer = (props) => {
</>
)}
<div className={style.btn}>
<Tooltip label='Remove 1 minute' openDelay={500} shouldWrapChildren={disableButtons}>
<Tooltip label='Remove 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<Button
{...incrementProps}
disabled={disableButtons}
@@ -79,7 +80,7 @@ const PlaybackTimer = (props) => {
-1
</Button>
</Tooltip>
<Tooltip label='Add 1 minute' openDelay={500} shouldWrapChildren={disableButtons}>
<Tooltip label='Add 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<Button
{...incrementProps}
disabled={disableButtons}
@@ -89,7 +90,7 @@ const PlaybackTimer = (props) => {
+1
</Button>
</Tooltip>
<Tooltip label='Remove 5 minutes' openDelay={500} shouldWrapChildren={disableButtons}>
<Tooltip label='Remove 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<Button
{...incrementProps}
disabled={disableButtons}
@@ -99,7 +100,7 @@ const PlaybackTimer = (props) => {
-5
</Button>
</Tooltip>
<Tooltip label='Add 5 minutes' openDelay={500} shouldWrapChildren={disableButtons}>
<Tooltip label='Add 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<Button
{...incrementProps}
disabled={disableButtons}
@@ -8,6 +8,7 @@ import {
defaultPublicAtom,
startTimeIsLastEndAtom,
} from '../../../common/atoms/LocalEventSettings';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './EntryBlock.module.scss';
@@ -35,7 +36,7 @@ export default function EntryBlock(props) {
return (
<div className={`${style.create} ${visible ? style.visible : ''}`}>
<Tooltip label='Add Event' openDelay={300}>
<Tooltip label='Add Event' openDelay={tooltipDelayMid}>
<span
className={style.createEvent}
onClick={() =>
@@ -50,7 +51,7 @@ export default function EntryBlock(props) {
E{showKbd && <span className={style.keyboard}>Alt + E</span>}
</span>
</Tooltip>
<Tooltip label='Add Delay' openDelay={300}>
<Tooltip label='Add Delay' openDelay={tooltipDelayMid}>
<span
className={`${style.createDelay} ${disableAddDelay ? style.disabled : ''}`}
onClick={() => eventsHandler('add', { type: 'delay', after: previousId })}
@@ -59,7 +60,7 @@ export default function EntryBlock(props) {
D{showKbd && <span className={style.keyboard}>Alt + D</span>}
</span>
</Tooltip>
<Tooltip label='Add Block' openDelay={300}>
<Tooltip label='Add Block' openDelay={tooltipDelayMid}>
<span
className={`${style.createBlock} ${disableAddBlock ? style.disabled : ''}`}
onClick={() => eventsHandler('add', { type: 'block', after: previousId })}
+4 -3
View File
@@ -15,6 +15,7 @@ import { getAliases, postAliases } from '../../common/api/ontimeApi';
import { LoggingContext } from '../../common/context/LoggingContext';
import { validateAlias } from '../../common/utils/aliases';
import { handleLinks, host } from '../../common/utils/linkUtils';
import { tooltipDelayFast } from '../../ontimeConfig';
import SubmitContainer from './SubmitContainer';
@@ -252,7 +253,7 @@ export default function AliasesModal() {
isInvalid={alias.urlError}
onChange={(event) => handleChange(index, 'pathAndParams', event.target.value)}
/>
<Tooltip label={`Test /${alias.pathAndParams}`} openDelay={500}>
<Tooltip label={`Test /${alias.pathAndParams}`} openDelay={tooltipDelayFast}>
<a
href='#!'
target='_blank'
@@ -260,7 +261,7 @@ export default function AliasesModal() {
onClick={(e) => handleLinks(e, alias.pathAndParams)}
/>
</Tooltip>
<Tooltip label='Enable alias' openDelay={500}>
<Tooltip label='Enable alias' openDelay={tooltipDelayFast}>
<IconButton
aria-label='Enable alias'
size='xs'
@@ -270,7 +271,7 @@ export default function AliasesModal() {
onClick={() => setEnabled(alias.id, !alias.enabled)}
/>
</Tooltip>
<Tooltip label='Delete alias' openDelay={500}>
<Tooltip label='Delete alias' openDelay={tooltipDelayFast}>
<IconButton
aria-label='Delete alias'
size='xs'
+2 -1
View File
@@ -19,6 +19,7 @@ import PropTypes from 'prop-types';
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { tooltipDelayFast } from '../../ontimeConfig';
import SortableCell from './tableElements/SortableCell';
import TableSettings from './tableElements/TableSettings';
@@ -184,7 +185,7 @@ export default function OntimeTable({ tableData, userFields, handleUpdate, selec
>
<tr {...restHeaderGroupProps}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={300}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
+23 -2
View File
@@ -222,6 +222,12 @@ $text-theme-dark: white;
background-color: $cell-theme-light;
border: 1px solid $bg-theme-light;
}
.actionText:hover,
.actionIcon:hover,
.actionDisabled:hover {
color: black;
transition: 300ms;
}
}
.tableWrapper__dark {
@@ -237,6 +243,12 @@ $text-theme-dark: white;
background-color: $cell-theme-dark;
border: 1px solid $bg-theme-dark;
}
.actionText:hover,
.actionIcon:hover,
.actionDisabled:hover {
color: white;
transition: 300ms;
}
}
.timer {
@@ -257,12 +269,21 @@ svg {
background-color: inherit !important;
}
.actionIcon {
@mixin action-element() {
cursor: pointer;
}
.actionIcon {
@include action-element();
}
.actionText {
@include action-element();
font-size: 0.65em;
}
.actionDisabled {
cursor: pointer;
@include action-element();
opacity: 0.6;
}
+31 -4
View File
@@ -1,25 +1,32 @@
import React, { useContext, useEffect, useState } from 'react';
import { Divider } from '@chakra-ui/layout';
import { Tooltip } from '@chakra-ui/tooltip';
import { FiSettings } from '@react-icons/all-files/fi/FiSettings';
import { FiTarget } from '@react-icons/all-files/fi/FiTarget';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoMoon } from '@react-icons/all-files/io5/IoMoon';
import PropTypes from 'prop-types';
import { EVENT_TABLE } from '../../common/api/apiConstants';
import { fetchEvent } from '../../common/api/eventApi';
import { useSocket } from '../../common/context/socketContext';
import { TableSettingsContext } from '../../common/context/TableSettingsContext';
import { useFetch } from '../../common/hooks/useFetch';
import useFullscreen from '../../common/hooks/useFullscreen';
import { formatDisplay } from '../../common/utils/dateConfig';
import { formatTime } from '../../common/utils/time';
import { tooltipDelayFast } from '../../ontimeConfig';
import PlaybackIcon from './tableElements/PlaybackIcon';
import style from './Table.module.scss';
export default function TableHeader() {
export default function TableHeader({ handleCSVExport }) {
const { followSelected, showSettings, toggleTheme, toggleSettings, toggleFollow } =
useContext(TableSettingsContext);
const { data } = useFetch(EVENT_TABLE, fetchEvent);
const { isFullScreen, toggleFullScreen } = useFullscreen();
const socket = useSocket();
const [timer, setTimer] = useState({
@@ -101,6 +108,7 @@ export default function TableHeader() {
showSeconds: true,
format: 'hh:mm:ss a',
});
return (
<div className={style.header}>
<div className={style.headerName}>{data?.title || ''}</div>
@@ -121,22 +129,41 @@ export default function TableHeader() {
<span className={style.timer}>{timeNow}</span>
</div>
<div className={style.headerActions}>
<Tooltip openDelay={300} label='Follow selected'>
<Tooltip openDelay={tooltipDelayFast} label='Follow selected'>
<span className={followSelected ? style.actionIcon : style.actionDisabled}>
<FiTarget onClick={() => toggleFollow()} />
</span>
</Tooltip>
<Tooltip openDelay={300} label='Show settings'>
<Tooltip openDelay={tooltipDelayFast} label='Show settings'>
<span className={showSettings ? style.actionIcon : style.actionDisabled}>
<FiSettings onClick={() => toggleSettings()} />
</span>
</Tooltip>
<Tooltip openDelay={300} label='Toggle dark mode'>
<Tooltip openDelay={tooltipDelayFast} label='Toggle dark mode'>
<span className={style.actionIcon}>
<IoMoon onClick={() => toggleTheme()} />
</span>
</Tooltip>
<Tooltip openDelay={tooltipDelayFast} label='Toggle Fullscreen'>
<span className={style.actionIcon}>
{isFullScreen ? (
<IoContract onClick={() => toggleFullScreen()} />
) : (
<IoExpand onClick={() => toggleFullScreen()} />
)}
</span>
</Tooltip>
<Divider />
<Tooltip openDelay={tooltipDelayFast} label='Export to CSV'>
<span className={style.actionText} onClick={() => handleCSVExport(data)}>
CSV
</span>
</Tooltip>
</div>
</div>
);
}
TableHeader.propTypes = {
handleCSVExport: PropTypes.func.isRequired,
};
+54 -34
View File
@@ -10,6 +10,7 @@ import useMutateEvents from '../../common/hooks/useMutateEvents';
import OntimeTable from './OntimeTable';
import TableHeader from './TableHeader';
import { makeCSV, makeTable } from './utils';
import style from './Table.module.scss';
@@ -22,9 +23,7 @@ export default function TableWrapper() {
const { theme } = useContext(TableSettingsContext);
// Set window title
useEffect(() => {
document.title = 'ontime - Cuesheet';
}, []);
document.title = 'ontime - Cuesheet';
/**
* Handle incoming data from socket
@@ -46,47 +45,68 @@ export default function TableWrapper() {
};
}, [socket]);
const handleUpdate = useCallback(async (rowIndex, accessor, payload) => {
if (rowIndex == null || accessor == null || payload == null) {
return;
}
const handleUpdate = useCallback(
async (rowIndex, accessor, payload) => {
if (rowIndex == null || accessor == null || payload == null) {
return;
}
// check if value is the same
const event = tableData[rowIndex];
if (event == null) {
return;
}
// check if value is the same
const event = tableData[rowIndex];
if (event == null) {
return;
}
if (event[accessor] === payload) {
return;
}
// check if value is valid
// as of now, the fields do not have any validation
if (typeof payload !== 'string') {
return;
}
if (event[accessor] === payload) {
return;
}
// check if value is valid
// as of now, the fields do not have any validation
if (typeof payload !== 'string') {
return;
}
// cleanup
const cleanVal = payload.trim();
const mutationObject = {
id: event.id,
[accessor]: cleanVal,
};
// cleanup
const cleanVal = payload.trim();
const mutationObject = {
id: event.id,
[accessor]: cleanVal,
};
// submit
try {
await mutation.mutateAsync(mutationObject);
} catch (error) {
console.error(error);
}
}, [mutation, tableData]);
// submit
try {
await mutation.mutateAsync(mutationObject);
} catch (error) {
console.error(error);
}
},
[mutation, tableData]
);
const exportHandler = useCallback(
(headerData) => {
if (!headerData || !tableData || !userFields) {
return;
}
const sheetData = makeTable(headerData, tableData, userFields);
const csvContent = makeCSV(sheetData);
const encodedUri = encodeURI(csvContent);
const link = document.createElement('a');
link.setAttribute('href', encodedUri);
link.setAttribute('download', 'ontime export.csv');
document.body.appendChild(link);
link.click();
},
[tableData, userFields]
);
if (typeof tableData === 'undefined' || typeof userFields === 'undefined') {
return <span>loading...</span>;
}
return (
<div className={theme === 'dark' ? style.tableWrapper__dark : style.tableWrapper}>
<TableHeader />
<TableHeader handleCSVExport={exportHandler} />
<OntimeTable
tableData={tableData}
userFields={userFields}
@@ -0,0 +1,49 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`makeTable() returns array of arrays with given fields 1`] = `
Array [
Array [
"Ontime · Schedule Template",
],
Array [
"Event Name",
"",
],
Array [
"Event URL",
"",
],
Array [],
Array [
"Time Start",
"Time End",
"Event Title",
"Presenter Name",
"Event Subtitle",
"Is Public? (x)",
"Notes",
"Colour",
"user0:test",
],
Array [
"00:00:00",
"00:00:00",
"test title 1",
"",
"",
"x",
"",
"",
"test",
"test",
"",
"",
"",
"",
"",
"",
"",
"",
],
]
`;
@@ -0,0 +1,93 @@
import { makeCSV, makeTable, parseField } from '../utils';
describe('parseField()', () => {
it('returns a string from given millis on timeStart and TimeEnd', () => {
const testData1 = 1000;
const testData2 = 60000;
expect(parseField('timeStart', testData1)).toBe('00:00:01');
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
});
describe('returns an x when isPublic is truthy, empty string otherwise', () => {
const testTruthy = [1, true, 'x', 'test'];
const testFalsy = ['', null, undefined, false, 0];
testTruthy.forEach((value) => {
test(`${value}`, () => {
expect(parseField('isPublic', value)).toBe('x');
});
});
testFalsy.forEach((value) => {
test(`${value}`, () => {
expect(parseField('isPublic', value)).toBe('');
});
});
});
it('returns an empty string on undefined fields', () => {
expect(parseField('presenter', undefined)).toBe('');
});
describe('simply returns any other value in any other field', () => {
const testFields = [
{ field: 'nothing', value: 123 },
{ field: 'title', value: 'test' },
{ field: 'presenter', value: 'test' },
{ field: 'subtitle', value: 'test' },
{ field: 'notes', value: 'test' },
{ field: 'colour', value: 'test' },
{ field: 'user0', value: 'test' },
{ field: 'user1', value: 'test' },
{ field: 'user2', value: 'test' },
{ field: 'user3', value: 'test' },
{ field: 'user4', value: 'test' },
{ field: 'user5', value: 'test' },
{ field: 'user6', value: 'test' },
{ field: 'user7', value: 'test' },
{ field: 'user8', value: 'test' },
{ field: 'user9', value: 'test' },
];
testFields.forEach((testCase) => {
test(`${testCase.field}:${testCase.value}`, () => {
expect(parseField(testCase.field, testCase.value)).toBe(testCase.value);
});
});
});
});
describe('makeTable()', () => {
it('returns array of arrays with given fields', () => {
const headerData = {};
const tableData = [
{
title: 'test title 1',
presenter: '',
timeStart: 0,
timeEnd: 0,
isPublic: 'x',
user0: 'test',
user1: 'test',
},
];
const userFields = {
user0: 'test',
};
const table = makeTable(headerData, tableData, userFields);
expect(table).toMatchSnapshot();
});
});
describe('make CSV()', () => {
it('joins an array of arrays with commas and newlines', () => {
const testdata = [['field'], ['after newline', 'after comma'], ['', 'after empty']];
expect(makeCSV(testdata)).toMatchInlineSnapshot(`
"data:text/csv;charset=utf-8,field
after newline,after comma
,after empty
"
`);
});
});
@@ -6,12 +6,14 @@ import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTimeOutline } from '@react-icons/all-files/io5/IoTimeOutline';
import PropTypes from 'prop-types';
import { tooltipDelayFast } from '../../../ontimeConfig';
export default function PlaybackIcon(props) {
const { state } = props;
if (state === 'stop') {
return (
<Tooltip openDelay={300} label='Timer Stopped' shouldWrapChildren>
<Tooltip openDelay={tooltipDelayFast} label='Timer Stopped' shouldWrapChildren>
<IoStop />
</Tooltip>
);
@@ -19,7 +21,7 @@ export default function PlaybackIcon(props) {
if (state === 'start') {
return (
<Tooltip openDelay={300} label='Timer Playing' shouldWrapChildren>
<Tooltip openDelay={tooltipDelayFast} label='Timer Playing' shouldWrapChildren>
<IoPlay />
</Tooltip>
);
@@ -27,7 +29,7 @@ export default function PlaybackIcon(props) {
if (state === 'pause') {
return (
<Tooltip openDelay={300} label='Timer Paused' shouldWrapChildren>
<Tooltip openDelay={tooltipDelayFast} label='Timer Paused' shouldWrapChildren>
<IoPause />
</Tooltip>
);
@@ -35,7 +37,7 @@ export default function PlaybackIcon(props) {
if (state === 'roll') {
return (
<Tooltip openDelay={300} label='Timer Rolling' shouldWrapChildren>
<Tooltip openDelay={tooltipDelayFast} label='Timer Rolling' shouldWrapChildren>
<IoTimeOutline />
</Tooltip>
);
@@ -4,6 +4,8 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import PropTypes from 'prop-types';
import { tooltipDelayFast } from '../../../ontimeConfig';
import styles from '../Table.module.scss';
export default function SortableCell({ column }) {
@@ -29,7 +31,7 @@ export default function SortableCell({ column }) {
return (
<th {...restColumn} ref={setNodeRef} style={{...dragStyle}} className={isDragging ? styles.dragging: ''}>
<div {...attributes} {...listeners}>
<Tooltip label={column.Header} openDelay={300}>
<Tooltip label={column.Header} openDelay={tooltipDelayFast}>
{column.render('Header')}
</Tooltip>
</div>
+107
View File
@@ -0,0 +1,107 @@
/**
* @description parses a field for export
* @param {string} field
* @param {*} data
* @return {string}
*/
import { stringFromMillis } from '../../common/utils/time';
export const parseField = (field, data) => {
let val;
switch (field) {
case 'timeStart':
case 'timeEnd':
val = stringFromMillis(data);
break;
case 'isPublic':
val = data ? 'x' : '';
break;
default:
val = data;
break;
}
if (typeof data === 'undefined') {
return ''
}
return val;
};
/**
* @description Creates an array of arrays usable by xlsx for export
* @param {object} headerData
* @param {array} tableData
* @param {object} userFields
* @return {(string[])[]}
*/
export const makeTable = (headerData, tableData, userFields) => {
const data = [
['Ontime · Schedule Template'],
['Event Name', headerData?.title || ''],
['Event URL', headerData?.url || ''],
[],
];
const fieldOrder = [
'timeStart',
'timeEnd',
'title',
'presenter',
'subtitle',
'isPublic',
'notes',
'colour',
'user0',
'user1',
'user2',
'user3',
'user4',
'user5',
'user6',
'user7',
'user8',
'user9',
];
const fieldTitles = [
'Time Start',
'Time End',
'Event Title',
'Presenter Name',
'Event Subtitle',
'Is Public? (x)',
'Notes',
'Colour',
];
for (const field in userFields) {
const fieldValue = userFields[field];
const displayName = `${field}${
fieldValue !== field && fieldValue !== '' ? `:${fieldValue}` : ''
}`;
fieldTitles.push(displayName);
}
data.push(fieldTitles);
tableData.forEach((entry) => {
const row = [];
fieldOrder.forEach((field) => row.push(parseField(field, entry[field])));
data.push(row);
});
return data;
};
/**
* @description Converts an array of arrays to a csv file
* @param {array[]} arrayOfArrays
* @return {string}
*/
export const makeCSV = (arrayOfArrays) => {
let csvData = 'data:text/csv;charset=utf-8,';
arrayOfArrays.forEach((rowArray) => {
const row = rowArray.join(',');
csvData += `${row}\n`;
});
return csvData;
};
@@ -1,5 +1,5 @@
import { DAY_TO_MS } from '../../../../../../server/src/classes/classUtils';
import { millisToSeconds } from '../../../../common/utils/dateConfig';
import { DAY_TO_MS } from '../../../../common/utils/timeConstants';
import { fetchTimerData, sanitiseTitle, timerMessages } from '../countdown.helpers';
describe('sanitiseTitle() function', () => {
+3
View File
@@ -0,0 +1,3 @@
export const tooltipDelaySlow = 1000;
export const tooltipDelayMid = 500
export const tooltipDelayFast = 300;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "1.6.0",
"version": "1.7.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -20,7 +20,7 @@ import { router as ontimeRouter } from './routes/ontimeRouter.js';
import { router as playbackRouter } from './routes/playbackRouter.js';
// Global Objects
import { EventTimer } from './classes/EventTimer.js';
import { EventTimer } from './classes/timer/EventTimer.js';
// Start OSC server
import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js';
import { fileURLToPath } from 'url';
@@ -3,10 +3,10 @@ import { Server } from 'socket.io';
import { DAY_TO_MS, getSelectionByRoll, replacePlaceholder, updateRoll } from './classUtils.js';
import { OSCIntegration } from './integrations/Osc.js';
import { HTTPIntegration } from './integrations/Http.js';
import { cleanURL } from '../utils/url.js';
import getRandomName from '../utils/getRandomName.js';
import { generateId } from '../utils/generate_id.js';
import { stringFromMillis } from '../utils/time.js';
import { cleanURL } from '../../utils/url.js';
import getRandomName from '../../utils/getRandomName.js';
import { generateId } from '../../utils/generate_id.js';
import { stringFromMillis } from '../../utils/time.js';
/*
* Class EventTimer adds functions specific to APP
@@ -1,4 +1,4 @@
import { stringFromMillis } from '../utils/time.js';
import { stringFromMillis } from '../../utils/time.js';
/**
* @description Implements simple countdown timer functions
+28
View File
@@ -0,0 +1,28 @@
import { parseExcelDate } from '../time';
describe('parseExcelDate', () => {
it('parses a valid date string as expected from excel', () => {
const millis = parseExcelDate('1899-12-30T07:00:00.000Z');
expect(millis).not.toBe(0);
});
describe('parses a time string that passes validation', () => {
const validFields = ['10:00:00', '10:00'];
validFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).not.toBe(0);
});
});
});
describe('returns 0 on other strings', () => {
const invalidFields = ['10', 'test', ''];
invalidFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).toBe(0);
});
});
});
});
+3 -3
View File
@@ -12,7 +12,7 @@ import {
parseSettings_v1,
parseUserFields_v1,
} from './parserUtils_v1.js';
import { excelDateStringToMillis } from './time.js';
import { parseExcelDate } from './time.js';
import { generateId } from './generate_id.js';
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
@@ -79,9 +79,9 @@ export const parseExcel_v1 = async (excelData) => {
eventData.url = column;
eventUrlNext = false;
} else if (j === timeStartIndex) {
event.timeStart = excelDateStringToMillis(column);
event.timeStart = parseExcelDate(column);
} else if (j === timeEndIndex) {
event.timeEnd = excelDateStringToMillis(column);
event.timeEnd = parseExcelDate(column);
} else if (j === titleIndex) {
event.title = column;
} else if (j === presenterIndex) {
+104 -23
View File
@@ -2,22 +2,6 @@ const mts = 1000; // millis to seconds
const mtm = 1000 * 60; // millis to minutes
const mth = 1000 * 60 * 60; // millis to hours
/**
* Returns current time in milliseconds
* @returns {number}
*/
export const nowInMillis = () => {
const now = new Date();
// extract milliseconds since midnight
let elapsed = now.getHours() * 3600000;
elapsed += now.getMinutes() * 60000;
elapsed += now.getSeconds() * 1000;
elapsed += now.getMilliseconds();
return elapsed;
};
/**
* @description Converts milliseconds to string representing time
* @param {number} ms - time in milliseconds
@@ -51,17 +35,114 @@ export const stringFromMillis = (ms, showSeconds = true, delim = ':', ifNull = '
/**
* @description Converts an excel date to milliseconds
* @argument {string} excelDate - excel string date
* @argument {string} date - excel string date
* @returns {number} - time in milliseconds
*/
export const excelDateStringToMillis = (excelDate) => {
export const dateToMillis = (date) => {
const h = date.getHours();
const m = date.getMinutes();
const s = date.getSeconds();
return h * mth + m * mtm + s * mts;
};
/**
* @description Parses an excel date using the correct parser
* @param {string} excelDate
* @returns {number} - time in milliseconds
*/
export const parseExcelDate = (excelDate) => {
// attempt converting to date object
const date = new Date(excelDate);
if (date instanceof Date && !isNaN(date)) {
const h = date.getHours();
const m = date.getMinutes();
const s = date.getSeconds();
return h * mth + m * mtm + s * mts;
return dateToMillis(date);
} else if (isTimeString(excelDate)) {
return forgivingStringToMillis(excelDate);
}
return 0;
};
export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss';
/**
* @description Validates a time string
* @param {string} string - time string "23:00:12"
* @returns {boolean} string represents time
*/
export const isTimeString = (string) => {
// ^ # Start of string
// (?: # Try to match...
// (?: # Try to match...
// ([01]?\d|2[0-3]): # HH:
// )? # (optionally).
// ([0-5]?\d): # MM: (required)
// )? # (entire group optional, so either HH:MM:, MM: or nothing)
// ([0-5]?\d) # SS (required)
// $ # End of string
const regex = /^(?:(?:([01]?\d|2[0-3])[:,.])?([0-5]?\d)[:,.])?([0-5]?\d)$/;
return regex.test(string);
};
/**
* @description safe parse string to int, copied from client code
* @param valueAsString
* @return {number}
*/
const parse = (valueAsString) => {
const parsed = parseInt(valueAsString, 10);
if (isNaN(parsed)) {
return 0;
}
return Math.abs(parsed);
};
/**
* @description Parses a time string to millis, copied from client code
* @param {string} value - time string
* @param {boolean} fillLeft - autofill left = hours / right = seconds
* @returns {number} - time string in millis
*/
export const forgivingStringToMillis = (value, fillLeft = true) => {
let millis = 0;
// split string at known separators : , .
const separatorRegex = /[\s,:.]+/;
const [first, second, third] = value.split(separatorRegex);
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
millis = parse(first) * mth;
millis += parse(second) * mtm;
millis += parse(third) * mts;
} else if (first != null && second == null && third == null) {
// if string has one section,
// could be a complete string like 121010 - 12:10:10
if (first.length === 6) {
const hours = first.substring(0, 2);
const minutes = first.substring(2, 4);
const seconds = first.substring(4);
millis = parse(hours) * mth;
millis += parse(minutes) * mtm;
millis += parse(seconds) * mts;
} else {
// otherwise lets treat as [minutes]
millis = parse(first) * mtm;
}
}
if (first != null && second != null && third == null) {
// if string has two sections
if (fillLeft) {
// treat as [hours] [minutes]
millis = parse(first) * mth;
millis += parse(second) * mtm;
} else {
// treat as [minutes] [seconds]
millis = parse(first) * mtm;
millis += parse(second) * mts;
}
}
return millis;
};