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
@@ -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', () => {