URL Params Form (#420)

* feat create form to edit URL params in views
This commit is contained in:
asharonbaltazar
2023-06-05 13:54:03 -04:00
committed by GitHub
parent 5cb8020ab9
commit bfb483508d
17 changed files with 549 additions and 99 deletions
@@ -0,0 +1,45 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
.drawerContent {
background-color: $gray-1200;
}
.drawerHeader {
@extend .drawerContent;
color: $section-white;
}
.drawerFooter {
@extend .drawerContent;
display: flex;
gap: $element-spacing;
button[type='submit'] {
padding: 0 2em;
}
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
}
.columnSection {
display: flex;
padding: $element-spacing;
flex-direction: column;
gap: $element-inner-spacing;
}
.title {
font-size: $inner-section-text-size;
display: block;
width: 100%;
}
.description {
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
}
@@ -0,0 +1,95 @@
import { FormEvent, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
useDisclosure,
} from '@chakra-ui/react';
import EditFormInput from './EditFormInput';
import { ParamField } from './types';
import style from './EditFormDrawer.module.scss';
interface EditFormDrawerProps {
paramFields: ParamField[];
}
export default function EditFormDrawer({ paramFields }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure();
useEffect(() => {
const isEditing = searchParams.get('edit');
if (isEditing === 'true') {
return onOpen();
}
}, [searchParams, onOpen]);
const onEditDrawerClose = () => {
onClose();
searchParams.delete('edit');
setSearchParams(searchParams);
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
formEvent.preventDefault();
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = Object.entries(newParamsObject).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) {
newSearchParams.set(id, value);
return newSearchParams;
}
return newSearchParams;
}, new URLSearchParams());
onEditDrawerClose();
setSearchParams(newSearchParams);
};
return (
<Drawer isOpen={isOpen} placement='right' onClose={onEditDrawerClose} size='lg'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader className={style.drawerHeader}>
<DrawerCloseButton _hover={{ bg: '#ebedf0', color: '#333' }} size='lg' />
Customise
</DrawerHeader>
<DrawerBody className={style.drawerContent}>
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{paramFields.map((field) => (
<div key={field.title} className={style.columnSection}>
<label className={style.label}>
<span className={style.title}>{field.title}</span>
<span className={style.description}>{field.description}</span>
<EditFormInput key={field.title} paramField={field} />
</label>
</div>
))}
</form>
</DrawerBody>
<DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-subtle' onClick={onEditDrawerClose}>
Cancel
</Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
Save
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
@@ -0,0 +1,47 @@
import { useSearchParams } from 'react-router-dom';
import { Input, Select, Switch } from '@chakra-ui/react';
import { isStringBoolean } from '../../../common/utils/viewUtils';
import { ParamField } from './types';
interface EditFormInputProps {
paramField: ParamField;
}
export default function EditFormInput({ paramField }: EditFormInputProps) {
const [searchParams] = useSearchParams();
const { id, type } = paramField;
if (type === 'option') {
const optionFromParams = searchParams.get(id);
const defaultOptionValue = paramField.values.find((value) => value === optionFromParams);
return (
<Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}>
{paramField.values.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</Select>
);
}
if (type === 'boolean') {
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) ?? false;
// checked value should be 'true', so it can be captured by the form event
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
}
if (type === 'number') {
const defaultNumberValue = searchParams.get(id) ?? '';
return <Input type='number' variant='ontime-filled' name={id} defaultValue={defaultNumberValue} />;
}
const defaultStringValue = searchParams.get(id) ?? '';
return <Input variant='ontime-filled' name={id} defaultValue={defaultStringValue} />;
}
@@ -0,0 +1,219 @@
import { ParamField } from './types';
export const TIME_FORMAT_OPTION: ParamField = {
id: 'format',
title: '12 / 24 hour timer',
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
type: 'option',
values: ['12', '24'],
};
export const CLOCK_OPTIONS: ParamField[] = [
{
id: 'key',
title: 'Key',
description: 'Background colour in hexadecimal',
type: 'string',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
type: 'string',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
type: 'string',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: ['start', 'center', 'end'],
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: ['start', 'center', 'end'],
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
},
{
id: 'hidenav',
title: 'Hide Nav',
description: 'Whether to hide the nav logo in the right corner',
type: 'boolean',
},
TIME_FORMAT_OPTION,
];
export const TIMER_OPTIONS: ParamField[] = [
{
id: 'progress',
title: 'Progress Bar',
description: 'Whether bar counts up or down',
type: 'option',
values: ['up', 'down'],
},
TIME_FORMAT_OPTION,
];
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
{
id: 'key',
title: 'Key',
description: 'Background colour in hexadecimal',
type: 'string',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
type: 'string',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
type: 'string',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: ['start', 'center', 'end'],
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: ['start', 'center', 'end'],
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
},
{
id: 'hidenav',
title: 'Hide Nav',
description: 'Whether to hide the nav logo in the right corner',
type: 'boolean',
},
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to supress overtime styles (red borders and red text)',
type: 'boolean',
},
{
id: 'hidemessages',
title: 'Hide Message Overlay',
description: 'Whether to hide the overlay from showing timer screen messages',
type: 'boolean',
},
];
export const LOWER_THIRDS_OPTIONS: ParamField[] = [
{
id: 'preset',
title: 'Preset',
description: 'Selects a style preset',
type: 'number',
},
{
id: 'size',
title: 'Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 5)',
type: 'number',
},
{
id: 'text',
title: 'Text',
description: 'Text colour in hexadecimal',
type: 'string',
},
{
id: 'bg',
title: 'BG',
description: 'Text background colour in hexadecimal',
type: 'string',
},
{
id: 'key',
title: 'Key',
description: 'Screen background colour in hexadecimal',
type: 'string',
},
{
id: 'fadeout',
title: 'Fadeout',
description: 'Time (in seconds) the lower third displays before fading out',
type: 'number',
},
TIME_FORMAT_OPTION,
];
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
{
id: 'seconds',
title: 'Seconds',
description: 'Shows seconds in clock',
type: 'boolean',
},
TIME_FORMAT_OPTION,
];
@@ -0,0 +1,12 @@
type BaseField = {
id: string;
title: string;
description: string;
};
type OptionsField = { type: 'option'; values: string[] };
type StringField = { type: 'string' };
type BooleanField = { type: 'boolean' };
type NumberField = { type: 'number' };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField);
@@ -1,6 +1,6 @@
@use "../../../theme/v2Styles" as *;
@use "../../../theme/mixins" as *;
@use "../../../theme/ontimeColours" as *;
@use '../../../theme/v2Styles' as *;
@use '../../../theme/mixins' as *;
@use '../../../theme/ontimeColours' as *;
$menu-bg: $gray-1200;
$menu-hover-bg: $gray-1350;
@@ -14,14 +14,26 @@ $button-size: 48px;
transform: rotate(180deg);
}
.navButton {
z-index: 2;
position: absolute;
left: 0.5em;
top: 0.5em;
.buttonContainer {
display: flex;
flex-direction: column;
row-gap: 1rem;
padding: 0.5em;
transition-property: opacity;
transition-duration: 0.3s;
opacity: 1;
position: fixed;
left: 0;
top: 0;
&.hidden {
opacity: 0;
}
}
.button {
font-size: 24px;
color: $icon-color;
background-color: $button-bg;
@@ -30,10 +42,11 @@ $button-size: 48px;
display: grid;
place-content: center;
border-radius: 3px;
}
&.hidden {
opacity: 0;
}
.navButton {
@extend .button;
z-index: 2;
}
.menuContainer {
@@ -1,10 +1,11 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoContract } from '@react-icons/all-files/io5/IoContract';
import { IoExpand } from '@react-icons/all-files/io5/IoExpand';
import { IoPencilSharp } from '@react-icons/all-files/io5/IoPencilSharp';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
@@ -21,12 +22,13 @@ export default function NavigationMenu() {
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false));
const toggleMenu = () => setShowMenu((prev) => !prev);
useKeyDown(toggleMenu, ' ');
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' });
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
@@ -49,63 +51,68 @@ export default function NavigationMenu() {
const isKeyEnter = (event: KeyboardEvent<HTMLDivElement>) => event.key === 'Enter';
const handleFullscreen = () => toggleFullScreen();
const handleMirror = () => toggleMirror();
const showEditFormDrawer = () => {
searchParams.append('edit', 'true');
setSearchParams(searchParams);
};
return createPortal(
<div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
className={`${style.navButton} ${!showButton && !showMenu ? style.hidden : ''}`}
>
<IoApps />
</button>
{showMenu && (
<div className={style.menuContainer} data-testid='navigation-menu'>
<div className={style.buttonsContainer}>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={handleFullscreen}
onKeyDown={(event) => {
isKeyEnter(event) && handleFullscreen();
}}
>
Toggle Fullscreen
{isFullScreen ? <IoContract /> : <IoExpand />}
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
<IoApps />
</button>
<button className={style.button} onClick={showEditFormDrawer}>
<IoPencilSharp />
</button>
{showMenu && (
<div className={style.menuContainer} data-testid='navigation-menu'>
<div className={style.buttonsContainer}>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={handleFullscreen}
onKeyDown={(event) => {
isKeyEnter(event) && handleFullscreen();
}}
>
Toggle Fullscreen
{isFullScreen ? <IoContract /> : <IoExpand />}
</div>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={handleMirror}
onKeyDown={(event) => {
isKeyEnter(event) && handleMirror();
}}
>
Flip Screen
<IoSwapVertical />
</div>
{/*<div className={style.link} tabIndex={0}>*/}
{/* Rename Client*/}
{/*</div>*/}
</div>
<div
className={style.link}
tabIndex={0}
role='button'
onClick={handleMirror}
onKeyDown={(event) => {
isKeyEnter(event) && handleMirror();
}}
>
Flip Screen
<IoSwapVertical />
</div>
{/*<div className={style.link} tabIndex={0}>*/}
{/* Rename Client*/}
{/*</div>*/}
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<Link
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
tabIndex={0}
>
{route.label}
<IoArrowUp className={style.linkIcon} />
</Link>
))}
</div>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<Link
key={route.url}
to={route.url}
className={`${style.link} ${route.url === location.pathname ? style.current : undefined}`}
tabIndex={0}
>
{route.label}
<IoArrowUp className={style.linkIcon} />
</Link>
))}
</div>
)}
)}
</div>
</div>,
document.body,
);
}
+17 -10
View File
@@ -1,18 +1,25 @@
import { useEffect } from 'react';
import { useCallback, useEffect } from 'react';
export const useKeyDown = (callback: () => void, targetKey: string) => {
const onKeyDown = (event: KeyboardEvent) => {
const targetKeyPressed = event.key === targetKey && !event.repeat;
if (targetKeyPressed) {
event.preventDefault();
callback();
}
};
type UseKeyDown = (callback: () => void, targetKey: string, options?: { isDisabled?: boolean }) => void;
export const useKeyDown: UseKeyDown = (callback, targetKey, options = {}) => {
const { isDisabled = false } = options;
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
const targetKeyPressed = event.key === targetKey && !event.repeat;
if (targetKeyPressed && isDisabled === false) {
event.preventDefault();
callback();
}
},
[callback, isDisabled, targetKey],
);
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, []);
}, [onKeyDown]);
};