mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 09:23:51 +00:00
Url presets (#807)
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
|
||||
import withAlias from './features/AliasWrapper';
|
||||
import Log from './features/log/Log';
|
||||
import withPreset from './features/PresetWrapper';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
|
||||
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
|
||||
@@ -19,14 +19,14 @@ const Public = lazy(() => import('./features/viewers/public/Public'));
|
||||
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
|
||||
const STimer = withAlias(withData(TimerView));
|
||||
const SMinimalTimer = withAlias(withData(MinimalTimerView));
|
||||
const SClock = withAlias(withData(ClockView));
|
||||
const SCountdown = withAlias(withData(Countdown));
|
||||
const SBackstage = withAlias(withData(Backstage));
|
||||
const SPublic = withAlias(withData(Public));
|
||||
const SLowerThird = withAlias(withData(Lower));
|
||||
const SStudio = withAlias(withData(StudioClock));
|
||||
const STimer = withPreset(withData(TimerView));
|
||||
const SMinimalTimer = withPreset(withData(MinimalTimerView));
|
||||
const SClock = withPreset(withData(ClockView));
|
||||
const SCountdown = withPreset(withData(Countdown));
|
||||
const SBackstage = withPreset(withData(Backstage));
|
||||
const SPublic = withPreset(withData(Public));
|
||||
const SLowerThird = withPreset(withData(Lower));
|
||||
const SStudio = withPreset(withData(StudioClock));
|
||||
|
||||
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
|
||||
const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import { Alias } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const aliasesPath = `${apiEntryUrl}/aliases`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve aliases
|
||||
*/
|
||||
export async function getAliases(): Promise<Alias[]> {
|
||||
const res = await axios.get(aliasesPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate aliases
|
||||
*/
|
||||
export async function postAliases(data: Alias[]): Promise<Alias[]> {
|
||||
return axios.post(aliasesPath, data);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// keys in tanstack store
|
||||
export const ALIASES = ['aliases'];
|
||||
export const APP_INFO = ['appinfo'];
|
||||
export const APP_SETTINGS = ['appSettings'];
|
||||
export const CUSTOM_FIELDS = ['customFields'];
|
||||
export const HTTP_SETTINGS = ['httpSettings'];
|
||||
export const OSC_SETTINGS = ['oscSettings'];
|
||||
export const PROJECT_DATA = ['project'];
|
||||
@@ -9,7 +9,7 @@ export const PROJECT_LIST = ['projectList'];
|
||||
export const RUNDOWN = ['rundown'];
|
||||
export const RUNTIME = ['runtimeStore'];
|
||||
export const SHEET_STATE = ['sheetState'];
|
||||
export const CUSTOM_FIELDS = ['customFields'];
|
||||
export const URL_PRESETS = ['urlpresets'];
|
||||
export const VIEW_SETTINGS = ['viewSettings'];
|
||||
|
||||
// resolve location
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import axios from 'axios';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const urlPresetsPath = `${apiEntryUrl}/url-presets`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve aliases
|
||||
*/
|
||||
export async function getUrlPresets(): Promise<URLPreset[]> {
|
||||
const res = await axios.get(urlPresetsPath);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to mutate aliases
|
||||
*/
|
||||
export async function postUrlPresets(data: URLPreset[]): Promise<URLPreset[]> {
|
||||
return axios.post(urlPresetsPath, data);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { getAliases } from '../api/aliases';
|
||||
import { ALIASES } from '../api/constants';
|
||||
|
||||
export default function useAliases() {
|
||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: ALIASES,
|
||||
queryFn: getAliases,
|
||||
placeholderData: [],
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data, status, isFetching, isError, refetch };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { URL_PRESETS } from '../api/constants';
|
||||
import { getUrlPresets } from '../api/urlPresets';
|
||||
|
||||
export default function useUrlPresets() {
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
queryKey: URL_PRESETS,
|
||||
queryFn: getUrlPresets,
|
||||
placeholderData: [],
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data: data ?? [], status, isError, refetch };
|
||||
}
|
||||
+17
-17
@@ -1,8 +1,8 @@
|
||||
import { resolvePath } from 'react-router-dom';
|
||||
|
||||
import { generateURLFromAlias, getAliasRoute, validateAlias } from '../aliases';
|
||||
import { generateUrlFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
|
||||
|
||||
describe('An alias fails if incorrect', () => {
|
||||
describe('A preset fails if incorrect', () => {
|
||||
const testsToFail = [
|
||||
// no empty
|
||||
'',
|
||||
@@ -21,11 +21,11 @@ describe('An alias fails if incorrect', () => {
|
||||
|
||||
testsToFail.forEach((t) =>
|
||||
it(`${t}`, () => {
|
||||
expect(validateAlias(t).status).toBeFalsy();
|
||||
expect(validateUrlPresetPath(t).isValid).toBeFalsy();
|
||||
}),
|
||||
);
|
||||
});
|
||||
describe('generateURLFromAlias and getAliasRoute function', () => {
|
||||
describe('generateUrlFromPreset and getRouteFromPreset function', () => {
|
||||
test('generate the expected url from an alias', () => {
|
||||
const testData = [
|
||||
{
|
||||
@@ -41,10 +41,10 @@ describe('generateURLFromAlias and getAliasRoute function', () => {
|
||||
},
|
||||
];
|
||||
|
||||
expect(generateURLFromAlias(testData[0])).toStrictEqual(expected[0].url);
|
||||
expect(generateUrlFromPreset(testData[0])).toStrictEqual(expected[0].url);
|
||||
});
|
||||
test('generate the url to redirect to when the current URL is just the alias', () => {
|
||||
const aliases = [
|
||||
const presets = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'demopage',
|
||||
@@ -52,7 +52,7 @@ describe('generateURLFromAlias and getAliasRoute function', () => {
|
||||
},
|
||||
];
|
||||
// let current location be the alias
|
||||
const location = resolvePath(aliases[0].alias);
|
||||
const location = resolvePath(presets[0].alias);
|
||||
|
||||
const expected = [
|
||||
{
|
||||
@@ -60,10 +60,10 @@ describe('generateURLFromAlias and getAliasRoute function', () => {
|
||||
},
|
||||
];
|
||||
|
||||
expect(getAliasRoute(location, aliases, null)).toStrictEqual(expected[0].url);
|
||||
expect(getRouteFromPreset(location, presets, null)).toStrictEqual(expected[0].url);
|
||||
});
|
||||
test('generate the url to redirect to when the current URL the same url but with a change of params', () => {
|
||||
const aliases = [
|
||||
const presets = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'demopage',
|
||||
@@ -71,22 +71,22 @@ describe('generateURLFromAlias and getAliasRoute function', () => {
|
||||
},
|
||||
];
|
||||
// let current location be the actual url with alias attached to it
|
||||
const location = resolvePath(aliases[0].pathAndParams);
|
||||
const location = resolvePath(presets[0].pathAndParams);
|
||||
const urlSearchParams = new URLSearchParams(location.search);
|
||||
urlSearchParams.append('alias', aliases[0].alias); //
|
||||
urlSearchParams.append('alias', presets[0].alias); //
|
||||
|
||||
// update current alias with extra param
|
||||
aliases[0].pathAndParams += '&eventId=674';
|
||||
presets[0].pathAndParams += '&eventId=674';
|
||||
const expected = [
|
||||
{
|
||||
url: '/timer?user=guest&eventId=674&alias=demopage',
|
||||
},
|
||||
];
|
||||
|
||||
expect(getAliasRoute(location, aliases, urlSearchParams)).toStrictEqual(expected[0].url);
|
||||
expect(getRouteFromPreset(location, presets, urlSearchParams)).toStrictEqual(expected[0].url);
|
||||
});
|
||||
test('generate no url to redirect to when the current URL the same url', () => {
|
||||
const aliases = [
|
||||
const presets = [
|
||||
{
|
||||
enabled: true,
|
||||
alias: 'demopage',
|
||||
@@ -94,10 +94,10 @@ describe('generateURLFromAlias and getAliasRoute function', () => {
|
||||
},
|
||||
];
|
||||
// let current location be the actual url with alias attached to it
|
||||
const location = resolvePath(aliases[0].pathAndParams);
|
||||
const location = resolvePath(presets[0].pathAndParams);
|
||||
const urlSearchParams = new URLSearchParams(location.search);
|
||||
urlSearchParams.append('alias', aliases[0].alias); //
|
||||
urlSearchParams.append('alias', presets[0].alias); //
|
||||
|
||||
expect(getAliasRoute(location, aliases, urlSearchParams)).toBeNull();
|
||||
expect(getRouteFromPreset(location, presets, urlSearchParams)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { Location, resolvePath } from 'react-router-dom';
|
||||
import { Alias } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Validates an alias against defined parameters
|
||||
* @param {string} alias
|
||||
* @returns {{message: string, status: boolean}}
|
||||
*/
|
||||
export const validateAlias = (alias: string) => {
|
||||
const valid = { status: true, message: 'ok' };
|
||||
|
||||
if (alias === '' || alias == null) {
|
||||
// cannot be empty
|
||||
valid.status = false;
|
||||
valid.message = 'should not be empty';
|
||||
} else if (alias.includes('http') || alias.includes('https') || alias.includes('www')) {
|
||||
// cannot contain http, https or www
|
||||
valid.status = false;
|
||||
valid.message = 'should not include http, https, www';
|
||||
} else if (alias.includes('127.0.0.1') || alias.includes('localhost') || alias.includes('0.0.0.0')) {
|
||||
// aliases cannot contain hostname
|
||||
valid.status = false;
|
||||
valid.message = 'should not include hostname';
|
||||
} else if (alias.includes('editor')) {
|
||||
// no editor
|
||||
valid.status = false;
|
||||
valid.message = 'No aliases to editor page allowed';
|
||||
}
|
||||
|
||||
return valid;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the URL to send an alias to
|
||||
* @param location
|
||||
* @param data
|
||||
* @param searchParams
|
||||
*/
|
||||
export const getAliasRoute = (location: Location, data: Alias[], searchParams: URLSearchParams) => {
|
||||
const currentURL = location.pathname.substring(1);
|
||||
// we need to check if the whole url here is an alias, so we can redirect
|
||||
const foundAlias = data.filter((d) => d.alias === currentURL && d.enabled)[0];
|
||||
if (foundAlias) {
|
||||
return generateURLFromAlias(foundAlias);
|
||||
}
|
||||
const aliasOnPage = searchParams.get('alias');
|
||||
for (const d of data) {
|
||||
if (aliasOnPage) {
|
||||
// if the alias fits the alias on this page, but the URL is different, we redirect user to the new URL
|
||||
// if we have the same alias and its enabled and its not empty
|
||||
if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) {
|
||||
const newAliasPath = resolvePath(d.pathAndParams);
|
||||
const urlParams = new URLSearchParams(newAliasPath.search);
|
||||
urlParams.set('alias', d.alias);
|
||||
// we confirm either the url parameters does not match or the url path doesnt
|
||||
if (!isEqual(urlParams, searchParams) || newAliasPath.pathname !== location.pathname) {
|
||||
// we then redirect to the alias route, since the view listening to this alias has an outdated URL
|
||||
return `${newAliasPath.pathname}?${urlParams}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate URL from an alias
|
||||
* @param aliasData
|
||||
*/
|
||||
export const generateURLFromAlias = (aliasData: Alias) => {
|
||||
const newAliasPath = resolvePath(aliasData.pathAndParams);
|
||||
const urlParams = new URLSearchParams(newAliasPath.search);
|
||||
urlParams.set('alias', aliasData.alias);
|
||||
|
||||
return `${newAliasPath.pathname}?${urlParams}`;
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { Location, resolvePath } from 'react-router-dom';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Validates a preset against defined parameters
|
||||
* @param {string} preset
|
||||
* @returns {{message: string, isValid: boolean}}
|
||||
*/
|
||||
export const validateUrlPresetPath = (preset: string): { message: string; isValid: boolean } => {
|
||||
if (preset === '' || preset == null) {
|
||||
return { isValid: false, message: 'Path cannot be empty' };
|
||||
}
|
||||
|
||||
if (preset.includes('http') || preset.includes('https') || preset.includes('www')) {
|
||||
return { isValid: false, message: 'Path should not include http, https, www' };
|
||||
}
|
||||
|
||||
if (preset.includes('127.0.0.1') || preset.includes('localhost') || preset.includes('0.0.0.0')) {
|
||||
return { isValid: false, message: 'Path should not include hostname' };
|
||||
}
|
||||
|
||||
if (preset.includes('editor')) {
|
||||
// no editor
|
||||
return { isValid: false, message: 'No path to editor page allowed' };
|
||||
}
|
||||
|
||||
return { isValid: true, message: 'ok' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the URL to send a preset to
|
||||
* @param location
|
||||
* @param data
|
||||
* @param searchParams
|
||||
*/
|
||||
export const getRouteFromPreset = (location: Location, data: URLPreset[], searchParams: URLSearchParams) => {
|
||||
const currentURL = location.pathname.substring(1);
|
||||
|
||||
// we need to check if the whole url here is an alias, so we can redirect
|
||||
const foundPreset = data.filter((d) => d.alias === currentURL && d.enabled)[0];
|
||||
if (foundPreset) {
|
||||
return generateUrlFromPreset(foundPreset);
|
||||
}
|
||||
|
||||
const presetOnPage = searchParams.get('alias');
|
||||
for (const d of data) {
|
||||
if (presetOnPage) {
|
||||
// if the alias fits the preset on this page, but the URL is different, we redirect user to the new URL
|
||||
// if we have the same alias and its enabled and its not empty
|
||||
if (d.alias !== '' && d.enabled && d.alias === presetOnPage) {
|
||||
const newPath = resolvePath(d.pathAndParams);
|
||||
const urlParams = new URLSearchParams(newPath.search);
|
||||
urlParams.set('alias', d.alias);
|
||||
// we confirm either the url parameters does not match or the url path doesnt
|
||||
if (!isEqual(urlParams, searchParams) || newPath.pathname !== location.pathname) {
|
||||
// we then redirect to the alias route, since the view listening to this alias has an outdated URL
|
||||
return `${newPath.pathname}?${urlParams}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate URL from an preset
|
||||
* @param presetData
|
||||
*/
|
||||
export const generateUrlFromPreset = (presetData: URLPreset) => {
|
||||
const newPresetPath = resolvePath(presetData.pathAndParams);
|
||||
const urlParams = new URLSearchParams(newPresetPath.search);
|
||||
urlParams.set('alias', presetData.alias);
|
||||
|
||||
return `${newPresetPath.pathname}?${urlParams}`;
|
||||
};
|
||||
+6
-6
@@ -2,12 +2,12 @@
|
||||
import { ComponentType, useEffect } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import useAliases from '../common/hooks-query/useAliases';
|
||||
import { getAliasRoute } from '../common/utils/aliases';
|
||||
import useUrlPresets from '../common/hooks-query/useUrlPresets';
|
||||
import { getRouteFromPreset } from '../common/utils/urlPresets';
|
||||
|
||||
const withAlias = <P extends object>(Component: ComponentType<P>) => {
|
||||
const withPreset = <P extends object>(Component: ComponentType<P>) => {
|
||||
return (props: Partial<P>) => {
|
||||
const { data } = useAliases();
|
||||
const { data } = useUrlPresets();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -15,7 +15,7 @@ const withAlias = <P extends object>(Component: ComponentType<P>) => {
|
||||
// navigate if is alias route
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const url = getAliasRoute(location, data, searchParams);
|
||||
const url = getRouteFromPreset(location, data, searchParams);
|
||||
// navigate to this route if its not empty
|
||||
if (url) {
|
||||
navigate(url);
|
||||
@@ -26,4 +26,4 @@ const withAlias = <P extends object>(Component: ComponentType<P>) => {
|
||||
};
|
||||
};
|
||||
|
||||
export default withAlias;
|
||||
export default withPreset;
|
||||
@@ -2,6 +2,7 @@
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function PanelList() {
|
||||
</li>
|
||||
{panel.secondary?.map((secondary) => {
|
||||
return (
|
||||
<li key={secondary.id} onClick={() => handleSelect(panel)} className={style.secondary}>
|
||||
<li key={secondary.id} onClick={() => handleSelect(panel)} className={style.secondary} role='button'>
|
||||
{secondary.label}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -42,8 +42,9 @@ $inner-padding: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
padding: 2rem;
|
||||
background-color: $white-1;
|
||||
background-color: $white-3;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -55,7 +56,7 @@ $inner-padding: 1rem;
|
||||
}
|
||||
|
||||
.pad {
|
||||
margin: 0 2rem;
|
||||
padding: 0 2rem;
|
||||
max-height: 550px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
@@ -138,7 +139,7 @@ $loader-size: 4rem;
|
||||
z-index: 10;
|
||||
width: calc(100% - 2rem);
|
||||
left: 1rem;
|
||||
height: calc(100% - 1rem);
|
||||
height: calc(80% - 1rem);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
backdrop-filter: blur(5px);
|
||||
|
||||
@@ -2,3 +2,23 @@
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.pad {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.fit {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.aliasConstrain {
|
||||
min-width: 12em;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import GeneralPanelForm from './GeneralPanelForm';
|
||||
import UrlPresetsForm from './UrlPresetsForm';
|
||||
import ViewSettingsForm from './ViewSettingsForm';
|
||||
|
||||
export default function GeneralPanel() {
|
||||
@@ -9,6 +10,7 @@ export default function GeneralPanel() {
|
||||
<Panel.Header>Settings</Panel.Header>
|
||||
<GeneralPanelForm />
|
||||
<ViewSettingsForm />
|
||||
<UrlPresetsForm />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Alert, AlertDescription, AlertIcon, Button, IconButton, Input, Switch } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { URLPreset } from 'ontime-types';
|
||||
|
||||
import { postUrlPresets } from '../../../../common/api/urlPresets';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import style from './GeneralPanel.module.scss';
|
||||
|
||||
const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
|
||||
|
||||
type FormData = {
|
||||
data: URLPreset[];
|
||||
};
|
||||
|
||||
export default function UrlPresetsForm() {
|
||||
const { data, status, refetch } = useUrlPresets();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isSubmitting, isDirty, isValid, errors },
|
||||
} = useForm<FormData>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: { data },
|
||||
values: { data },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
const { fields, prepend, remove } = useFieldArray({
|
||||
name: 'data',
|
||||
control,
|
||||
});
|
||||
|
||||
// reset form if we get new data from backend
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset({ data });
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = async (formData: FormData) => {
|
||||
for (let i = 0; i < formData.data.length; i++) {
|
||||
const preset = formData.data[i];
|
||||
const { isValid, message } = validateUrlPresetPath(preset.pathAndParams);
|
||||
if (!isValid) {
|
||||
setError(`data.${i}.pathAndParams`, { message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await postUrlPresets(formData.data);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
reset({ data });
|
||||
};
|
||||
|
||||
const addNew = () => {
|
||||
prepend({
|
||||
enabled: false,
|
||||
alias: '',
|
||||
pathAndParams: '',
|
||||
});
|
||||
};
|
||||
|
||||
const isPending = status === 'pending';
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} data-testId='url-preset-form'>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
URL Presets
|
||||
<div className={style.actionButtons}>
|
||||
<Button variant='ontime-ghosted' size='md' onClick={onReset} isDisabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button variant='ontime-filled' size='md' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
{isPending && <Panel.Loader />}
|
||||
<Panel.Section>
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
URL Presets
|
||||
<br />
|
||||
<br />
|
||||
Custom presets allow providing a short name for any ontime URL. <br />
|
||||
- Providing dynamic URLs for automation or unattended screens <br />- Simplifying complex URLs
|
||||
<br />
|
||||
<br />
|
||||
<ExternalLink href={urlPresetsDocs}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Panel.Title>
|
||||
Manage presets
|
||||
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={addNew}>
|
||||
New
|
||||
</Button>
|
||||
</Panel.Title>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
{errors?.data && <Panel.Error>{errors.data.message}</Panel.Error>}
|
||||
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={style.fit}>Active</th>
|
||||
<th className={style.aliasConstrain}>Preset</th>
|
||||
<th className={style.fullWidth}>URL</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((preset, index) => {
|
||||
const maybeAliasError = errors.data?.[index]?.alias?.message;
|
||||
const maybeUrlError = errors.data?.[index]?.pathAndParams?.message;
|
||||
return (
|
||||
<tr key={preset.id}>
|
||||
<td className={style.fit}>
|
||||
<Switch
|
||||
{...register(`data.${index}.enabled`)}
|
||||
variant='ontime'
|
||||
data-testid={`field__enable_${index}`}
|
||||
/>
|
||||
</td>
|
||||
<td className={style.aliasConstrain}>
|
||||
<Input
|
||||
{...register(`data.${index}.alias`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
placeholder='URL Preset'
|
||||
data-testid={`field__alias_${index}`}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{maybeAliasError}</Panel.Error>
|
||||
</td>
|
||||
<td className={style.fullWidth}>
|
||||
<Input
|
||||
{...register(`data.${index}.pathAndParams`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
placeholder='URL (portion after ontime Port)'
|
||||
data-testid={`field__url_${index}`}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{maybeUrlError}</Panel.Error>
|
||||
</td>
|
||||
<td className={style.flex}>
|
||||
<TooltipActionBtn
|
||||
size='sm'
|
||||
clickHandler={(event) => handleLinks(event, preset.alias)}
|
||||
tooltip='Test preset'
|
||||
aria-label='Test preset'
|
||||
variant='ontime-ghosted'
|
||||
color='#e2e2e2' // $gray-200
|
||||
icon={<IoOpenOutline />}
|
||||
data-testid={`field__test_${index}`}
|
||||
/>
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={() => remove(index)}
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
data-testid={`field__delete_${index}`}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export default function HttpIntegrations() {
|
||||
HTTP
|
||||
<div className={style.flex}>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
|
||||
Reset
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
@@ -87,7 +87,7 @@ export default function HttpIntegrations() {
|
||||
isDisabled={!canSubmit}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save changes
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function OscIntegrations() {
|
||||
Open Sound Control
|
||||
<div className={style.flex}>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
|
||||
Reset
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
@@ -92,7 +92,7 @@ export default function OscIntegrations() {
|
||||
isDisabled={!canSubmit}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save changes
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
|
||||
@@ -12,7 +12,6 @@ import styles from './Editor.module.scss';
|
||||
const Rundown = lazy(() => import('../rundown/RundownExport'));
|
||||
const TimerControl = lazy(() => import('../control/playback/TimerControlExport'));
|
||||
const MessageControl = lazy(() => import('../control/message/MessageControlExport'));
|
||||
const SettingsModal = lazy(() => import('../modals/settings-modal/SettingsModal'));
|
||||
|
||||
export default function Editor() {
|
||||
const showSettings = useSettingsStore((state) => state.showSettings);
|
||||
@@ -32,33 +31,28 @@ export default function Editor() {
|
||||
const isSettingsOpen = Boolean(showSettings);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||
<ErrorBoundary>
|
||||
<SettingsModal isOpen={isOldSettingsOpen} onClose={onSettingsClose} />
|
||||
<MenuBar
|
||||
isOldSettingsOpen={isOldSettingsOpen}
|
||||
onSettingsOpen={onSettingsOpen}
|
||||
onSettingsClose={onSettingsClose}
|
||||
openSettings={handleSettings}
|
||||
isSettingsOpen={isSettingsOpen}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||
<ErrorBoundary>
|
||||
<MenuBar
|
||||
isOldSettingsOpen={isOldSettingsOpen}
|
||||
onSettingsOpen={onSettingsOpen}
|
||||
onSettingsClose={onSettingsClose}
|
||||
openSettings={handleSettings}
|
||||
isSettingsOpen={isSettingsOpen}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
{showSettings ? (
|
||||
<AppSettings />
|
||||
) : (
|
||||
<div id='panels' className={styles.panelContainer}>
|
||||
<div className={styles.left}>
|
||||
<TimerControl />
|
||||
<MessageControl />
|
||||
</div>
|
||||
<Rundown />
|
||||
{showSettings ? (
|
||||
<AppSettings />
|
||||
) : (
|
||||
<div id='panels' className={styles.panelContainer}>
|
||||
<div className={styles.left}>
|
||||
<TimerControl />
|
||||
<MessageControl />
|
||||
</div>
|
||||
)}
|
||||
<Overview />
|
||||
</div>
|
||||
</>
|
||||
<Rundown />
|
||||
</div>
|
||||
)}
|
||||
<Overview />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const buttonStyle = {
|
||||
};
|
||||
|
||||
const MenuBar = (props: MenuBarProps) => {
|
||||
const { isOldSettingsOpen, onSettingsOpen, onSettingsClose, openSettings, isSettingsOpen } = props;
|
||||
const { onSettingsOpen, onSettingsClose, openSettings, isSettingsOpen } = props;
|
||||
const { isElectron, sendToElectron } = useElectronEvent();
|
||||
|
||||
const sendShutdown = () => {
|
||||
@@ -50,6 +50,8 @@ const MenuBar = (props: MenuBarProps) => {
|
||||
if (event.key === ',') {
|
||||
// open if not open
|
||||
isSettingsOpen ? onSettingsClose() : onSettingsOpen();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -70,18 +72,6 @@ const MenuBar = (props: MenuBarProps) => {
|
||||
return (
|
||||
<div className={style.menu}>
|
||||
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} />
|
||||
<div className={style.gap} />
|
||||
|
||||
<div className={style.gap} />
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
icon={<IoSettingsOutline />}
|
||||
className={isOldSettingsOpen ? style.open : ''}
|
||||
clickHandler={onSettingsOpen}
|
||||
tooltip='Settings deprecated'
|
||||
aria-label='Settings deprecated'
|
||||
/>
|
||||
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
className={cx([isSettingsOpen ? style.open : null, style.bottom])}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
$el-padding-with-compensation: 24px; // 16 + 8
|
||||
|
||||
@mixin modal-link {
|
||||
color: $blue-500;
|
||||
transition-property: color;
|
||||
transition-duration: $transition-time-action;
|
||||
|
||||
&:hover {
|
||||
color: $ontime-color;
|
||||
}
|
||||
}
|
||||
|
||||
.headerNotes {
|
||||
font-size: $text-body-size;
|
||||
width: 100%;
|
||||
padding: 0 $el-padding-with-compensation;
|
||||
color: $modal-note-color;
|
||||
margin-bottom: $section-spacing;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
@include modal-link
|
||||
}
|
||||
}
|
||||
|
||||
.footerNotes {
|
||||
font-size: $inner-section-text-size;
|
||||
padding: 0 $el-padding-with-compensation;
|
||||
color: $modal-note-color;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: $element-spacing 0;
|
||||
border: 0;
|
||||
border-top: 1px solid $gray-100;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $gray-500;
|
||||
padding-left: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sectionContainer {
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%
|
||||
}
|
||||
|
||||
@mixin sectionSpacing {
|
||||
border-radius: $component-border-radius-md;
|
||||
padding: $element-spacing;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
.columnSection {
|
||||
@include sectionSpacing;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.splitSection {
|
||||
@include sectionSpacing;
|
||||
gap: $section-spacing;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entryRow {
|
||||
@include sectionSpacing;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
&.main {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin subsection {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sectionSubtitle {
|
||||
@include subsection;
|
||||
color: $modal-note-color;
|
||||
}
|
||||
|
||||
.error {
|
||||
@include subsection;
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
.success {
|
||||
@include subsection;
|
||||
color: $action-blue;
|
||||
}
|
||||
|
||||
.feedbackSection {
|
||||
justify-content: flex-start;
|
||||
|
||||
}
|
||||
|
||||
.buttonSection {
|
||||
margin-top: $section-spacing;
|
||||
display: flex;
|
||||
gap: $section-spacing;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.vSpacer {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.shiftRight {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.showPointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.twoColumn {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.twoEqualColumn {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.padBottom {
|
||||
padding-bottom: $element-spacing;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: inline-block;
|
||||
vertical-align: text-bottom;
|
||||
margin-right: $section-spacing;
|
||||
}
|
||||
|
||||
.updateSection {
|
||||
padding-top: $el-padding-with-compensation;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: $element-inner-spacing;
|
||||
|
||||
.error {
|
||||
font-size: $error-red;
|
||||
}
|
||||
}
|
||||
|
||||
.overflowContainer {
|
||||
overflow-y: auto;
|
||||
max-height: 40%;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { FormControl } from '@chakra-ui/react';
|
||||
|
||||
import style from './settings-modal/SettingsModal.module.scss';
|
||||
|
||||
interface ModalInputProps {
|
||||
field: string;
|
||||
title: string;
|
||||
description: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function ModalInput(props: PropsWithChildren<ModalInputProps>) {
|
||||
const { field, title, description, error, children } = props;
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={!!error} className={style.columnSection}>
|
||||
<label htmlFor={field}>
|
||||
<span className={style.sectionTitle}>{title}</span>
|
||||
{error ? (
|
||||
<span className={style.error}>{error}</span>
|
||||
) : (
|
||||
<span className={style.sectionSubtitle}>{description}</span>
|
||||
)}
|
||||
</label>
|
||||
{children}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
color: $blue-500;
|
||||
transition-property: color;
|
||||
transition-duration: $transition-time-action;
|
||||
|
||||
&.inline {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $ontime-color;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||
|
||||
import { openLink } from '../../common/utils/linkUtils';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
|
||||
import style from './ModalLink.module.scss';
|
||||
|
||||
interface ModalLinkProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export default function ModalLink(props: ModalLinkProps) {
|
||||
const { href, inline, children } = props;
|
||||
const classes = cx([style.link, inline ? style.inline : null]);
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
openLink(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<a href='#!' target='_blank' rel='noreferrer' className={classes} onClick={handleClick}>
|
||||
{children} <IoOpenOutline />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { FormControl } from '@chakra-ui/react';
|
||||
|
||||
import style from './settings-modal/SettingsModal.module.scss';
|
||||
|
||||
interface ModalSplitInputProps {
|
||||
field: string;
|
||||
title: string;
|
||||
description: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function ModalSplitInput(props: PropsWithChildren<ModalSplitInputProps>) {
|
||||
const { field, title, description, error, children } = props;
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={!!error} className={style.splitSection}>
|
||||
<label htmlFor={field}>
|
||||
<span className={style.sectionTitle}>{title}</span>
|
||||
{error ? (
|
||||
<span className={style.error}>{error}</span>
|
||||
) : (
|
||||
<span className={style.sectionSubtitle}>{description}</span>
|
||||
)}
|
||||
</label>
|
||||
{children}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Modal, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
|
||||
|
||||
interface ModalWrapperProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export default function ModalWrapper(props: PropsWithChildren<ModalWrapperProps>) {
|
||||
const { isOpen, onClose, title, size = 'xl', children } = props;
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
isOpen={isOpen}
|
||||
closeOnOverlayClick={false}
|
||||
motionPreset='slideInBottom'
|
||||
size={size}
|
||||
scrollBehavior='inside'
|
||||
preserveScrollBarGap
|
||||
variant='ontime'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>{title}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
{children}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Button, ModalFooter } from '@chakra-ui/react';
|
||||
|
||||
import styles from './Modal.module.scss';
|
||||
|
||||
interface OntimeModalFooterProps {
|
||||
formId: string;
|
||||
handleRevert: () => void;
|
||||
isDirty: boolean;
|
||||
isValid: boolean;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export default function OntimeModalFooter(props: OntimeModalFooterProps) {
|
||||
const { formId, handleRevert, isDirty, isValid, isSubmitting } = props;
|
||||
|
||||
const disableRevert = !isDirty;
|
||||
const disableSubmit = isSubmitting || !isDirty || !isValid;
|
||||
|
||||
return (
|
||||
<ModalFooter className={styles.buttonSection}>
|
||||
<Button isDisabled={disableRevert} variant='ontime-ghost-on-light' size='sm' onClick={handleRevert}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
form={formId}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={disableSubmit}
|
||||
variant='ontime-filled'
|
||||
padding='0 2em'
|
||||
size='sm'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
.screenLoader {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
top: 0;
|
||||
background-color: $white-60;
|
||||
}
|
||||
|
||||
$loader-size: 48px;
|
||||
|
||||
.loader {
|
||||
width: $loader-size;
|
||||
height: $loader-size;
|
||||
background: $blue-500;
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
animation: animloader 1s ease-in infinite;
|
||||
}
|
||||
|
||||
@keyframes animloader {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes animloader {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import style from './ModalLoader.module.scss';
|
||||
|
||||
export default function ModalLoader() {
|
||||
return (
|
||||
<div className={style.screenLoader}>
|
||||
<span className={style.loader} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export const inputProps = {
|
||||
size: 'sm',
|
||||
autoComplete: 'off',
|
||||
};
|
||||
@@ -1,178 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Alert, AlertDescription, AlertIcon, AlertTitle, Button, IconButton, Input, Switch } from '@chakra-ui/react';
|
||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
|
||||
import { Alias } from 'ontime-types';
|
||||
|
||||
import { postAliases } from '../../../common/api/aliases';
|
||||
import { logAxiosError } from '../../../common/api/utils';
|
||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||
import useAliases from '../../../common/hooks-query/useAliases';
|
||||
import { useEmitLog } from '../../../common/stores/logger';
|
||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||
import ModalLoader from '../modal-loader/ModalLoader';
|
||||
import { inputProps } from '../modalHelper';
|
||||
import ModalLink from '../ModalLink';
|
||||
import OntimeModalFooter from '../OntimeModalFooter';
|
||||
|
||||
import style from './SettingsModal.module.scss';
|
||||
|
||||
const aliasesDocsUrl = 'https://docs.getontime.no/features/url-presets/';
|
||||
|
||||
// we wrap the array in an object to be simplify react-hook-form
|
||||
type Aliases = {
|
||||
aliases: Alias[];
|
||||
};
|
||||
|
||||
export default function AliasesForm() {
|
||||
const { data, status, isFetching, refetch } = useAliases();
|
||||
const { emitError } = useEmitLog();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { isSubmitting, isDirty, isValid },
|
||||
} = useForm<Aliases>({
|
||||
defaultValues: { aliases: data },
|
||||
values: { aliases: data || [] },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'aliases',
|
||||
control,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset({ aliases: data });
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = async (formData: Aliases) => {
|
||||
try {
|
||||
await postAliases(formData.aliases);
|
||||
} catch (error) {
|
||||
logAxiosError('Error saving aliases', error);
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
reset({ aliases: data });
|
||||
};
|
||||
|
||||
const addNew = () => {
|
||||
if (fields.length > 20) {
|
||||
emitError('Maximum amount of aliases reached (20)');
|
||||
return;
|
||||
}
|
||||
append({
|
||||
enabled: false,
|
||||
alias: '',
|
||||
pathAndParams: '',
|
||||
});
|
||||
};
|
||||
|
||||
const disableInputs = status === 'pending';
|
||||
const hasTooManyOptions = fields.length >= 20;
|
||||
|
||||
if (isFetching) {
|
||||
return <ModalLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} id='aliases' className={style.sectionContainer}>
|
||||
<div style={{ height: '16px' }} />
|
||||
<Alert status='info' variant='ontime-on-light-info'>
|
||||
<AlertIcon />
|
||||
<div className={style.column}>
|
||||
<AlertTitle>URL Aliases</AlertTitle>
|
||||
<AlertDescription>
|
||||
Custom aliases allow providing a short name for any ontime URL. <br />
|
||||
It serves two primary purposes: <br />
|
||||
- Providing dynamic URLs for automation or unattended screens <br />- Simplifying complex URLs
|
||||
<ModalLink href={aliasesDocsUrl}>For more information, see the docs</ModalLink>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
<div style={{ height: '16px' }} />
|
||||
<ul className={style.aliases}>
|
||||
{fields.map((alias, index) => {
|
||||
return (
|
||||
<li className={style.aliasRow} key={alias.id}>
|
||||
<IconButton
|
||||
onClick={() => remove(index)}
|
||||
aria-label='delete'
|
||||
size='xs'
|
||||
icon={<IoRemove />}
|
||||
colorScheme='red'
|
||||
isDisabled={disableInputs}
|
||||
data-testid={`field__delete_${index}`}
|
||||
/>
|
||||
<Input
|
||||
{...inputProps}
|
||||
{...register(`aliases.${index}.alias`)}
|
||||
width='12em'
|
||||
size='xs'
|
||||
variant='ontime-filled-on-light'
|
||||
placeholder='URL Alias'
|
||||
isDisabled={disableInputs}
|
||||
data-testid={`field__alias_${index}`}
|
||||
/>
|
||||
<Input
|
||||
{...inputProps}
|
||||
{...register(`aliases.${index}.pathAndParams`)}
|
||||
className={style.grow}
|
||||
size='xs'
|
||||
variant='ontime-filled-on-light'
|
||||
placeholder='URL (portion after ontime Port)'
|
||||
isDisabled={disableInputs}
|
||||
data-testid={`field__url_${index}`}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
clickHandler={(event) => handleLinks(event, alias.alias)}
|
||||
tooltip='Test alias'
|
||||
aria-label='Test alias'
|
||||
size='xs'
|
||||
variant='ontime-ghost-on-light'
|
||||
icon={<IoOpenOutline />}
|
||||
colorScheme='red'
|
||||
isDisabled={disableInputs}
|
||||
data-testid={`field__test_${index}`}
|
||||
/>
|
||||
<Switch
|
||||
{...register(`aliases.${index}.enabled`)}
|
||||
variant='ontime-on-light'
|
||||
isDisabled={disableInputs}
|
||||
data-testid={`field__enable_${index}`}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<Button
|
||||
onClick={addNew}
|
||||
className={style.shiftRight}
|
||||
isDisabled={hasTooManyOptions}
|
||||
size='xs'
|
||||
colorScheme='blue'
|
||||
variant='outline'
|
||||
padding='0 2em'
|
||||
>
|
||||
Add new
|
||||
</Button>
|
||||
<OntimeModalFooter
|
||||
formId='aliases'
|
||||
handleRevert={onReset}
|
||||
isDirty={isDirty}
|
||||
isValid={isValid}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Input, Textarea } from '@chakra-ui/react';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
|
||||
import { postProjectData } from '../../../common/api/project';
|
||||
import { logAxiosError } from '../../../common/api/utils';
|
||||
import useProjectData from '../../../common/hooks-query/useProjectData';
|
||||
import ModalLoader from '../modal-loader/ModalLoader';
|
||||
import { inputProps } from '../modalHelper';
|
||||
import ModalInput from '../ModalInput';
|
||||
import OntimeModalFooter from '../OntimeModalFooter';
|
||||
|
||||
import style from './SettingsModal.module.scss';
|
||||
|
||||
export default function ProjectDataForm() {
|
||||
const { data, status, isFetching, refetch } = useProjectData();
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<ProjectData>({
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = async (formData: ProjectData) => {
|
||||
try {
|
||||
await postProjectData(formData);
|
||||
} catch (error) {
|
||||
logAxiosError('Error saving project data', error);
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
reset(data);
|
||||
};
|
||||
|
||||
const disableInputs = status === 'pending';
|
||||
|
||||
if (isFetching) {
|
||||
return <ModalLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} id='project-data' className={style.sectionContainer}>
|
||||
<ModalInput
|
||||
field='title'
|
||||
title='Project title'
|
||||
description='Shown in overview screens'
|
||||
error={errors.title?.message}
|
||||
>
|
||||
<Input
|
||||
{...inputProps}
|
||||
variant='ontime-filled-on-light'
|
||||
maxLength={50}
|
||||
placeholder='Eurovision song contest'
|
||||
isDisabled={disableInputs}
|
||||
{...register('title')}
|
||||
/>
|
||||
</ModalInput>
|
||||
<ModalInput
|
||||
field='description'
|
||||
title='Project description'
|
||||
description='Free field, shown in editor'
|
||||
error={errors.description?.message}
|
||||
>
|
||||
<Input
|
||||
{...inputProps}
|
||||
variant='ontime-filled-on-light'
|
||||
maxLength={100}
|
||||
placeholder='Euro Love, Malmö 2024'
|
||||
isDisabled={disableInputs}
|
||||
{...register('description')}
|
||||
/>
|
||||
</ModalInput>
|
||||
<div style={{ height: '16px' }} />
|
||||
<ModalInput field='publicInfo' title='Public info' description='Information shown in public screens'>
|
||||
<Textarea
|
||||
{...inputProps}
|
||||
variant='ontime-filled-on-light'
|
||||
maxLength={150}
|
||||
placeholder='Shows always start ontime'
|
||||
isDisabled={disableInputs}
|
||||
{...register('publicInfo')}
|
||||
/>
|
||||
</ModalInput>
|
||||
<ModalInput field='publicUrl' title='Public URL' description='QR code to be shown on public screens'>
|
||||
<Input
|
||||
{...inputProps}
|
||||
variant='ontime-filled-on-light'
|
||||
placeholder='www.getontime.no'
|
||||
isDisabled={disableInputs}
|
||||
{...register('publicUrl')}
|
||||
/>
|
||||
</ModalInput>
|
||||
<div style={{ height: '16px' }} />
|
||||
<ModalInput field='backstageInfo' title='Backstage info' description='Information shown in public screens'>
|
||||
<Textarea
|
||||
{...inputProps}
|
||||
variant='ontime-filled-on-light'
|
||||
maxLength={150}
|
||||
placeholder='Wi-Fi password: 1234'
|
||||
isDisabled={disableInputs}
|
||||
{...register('backstageInfo')}
|
||||
/>
|
||||
</ModalInput>
|
||||
<ModalInput field='backstageUrl' title='Backstage URL' description='QR code to be shown on public screens'>
|
||||
<Input
|
||||
{...inputProps}
|
||||
variant='ontime-filled-on-light'
|
||||
size='sm'
|
||||
placeholder='https://docs.getontime.no'
|
||||
isDisabled={disableInputs}
|
||||
{...register('backstageUrl')}
|
||||
/>
|
||||
</ModalInput>
|
||||
<OntimeModalFooter
|
||||
formId='project-data'
|
||||
handleRevert={onReset}
|
||||
isDirty={isDirty}
|
||||
isValid={isValid}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
@import "../Modal.module.scss";
|
||||
|
||||
.aliases {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
.aliasRow {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.url {
|
||||
font-size: calc(1rem - 2px);
|
||||
user-select: text;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { ModalBody, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
|
||||
|
||||
import ModalWrapper from '../ModalWrapper';
|
||||
|
||||
import AliasesForm from './AliasesForm';
|
||||
import ProjectDataForm from './ProjectDataForm';
|
||||
|
||||
interface ModalManagerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SettingsModal(props: ModalManagerProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
return (
|
||||
<ModalWrapper title='Ontime Settings' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalBody>
|
||||
<Tabs variant='ontime' size='sm' isLazy>
|
||||
<TabList>
|
||||
<Tab>Project Data</Tab>
|
||||
<Tab>URL Aliases</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<ProjectDataForm />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<AliasesForm />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
</ModalWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Alias, ErrorResponse } from 'ontime-types';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { failIsNotArray } from '../../utils/routerUtils.js';
|
||||
|
||||
export async function getAliases(_req: Request, res: Response<Alias[]>) {
|
||||
const aliases = DataProvider.getAliases();
|
||||
res.status(200).send(aliases);
|
||||
}
|
||||
|
||||
export async function postAliases(req: Request, res: Response<Alias[] | ErrorResponse>) {
|
||||
if (failIsNotArray(req.body, res)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newAliases: Alias[] = [];
|
||||
req.body.forEach((a) => {
|
||||
newAliases.push({
|
||||
enabled: a.enabled,
|
||||
alias: a.alias,
|
||||
pathAndParams: a.pathAndParams,
|
||||
});
|
||||
});
|
||||
await DataProvider.setAliases(newAliases);
|
||||
res.status(200).send(newAliases);
|
||||
} catch (error) {
|
||||
res.status(400).send({ message: String(error) });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import express from 'express';
|
||||
import { getAliases, postAliases } from './aliases.controller.js';
|
||||
import { validateAliases } from './aliases.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.get('/', getAliases);
|
||||
router.post('/', validateAliases, postAliases);
|
||||
@@ -36,7 +36,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
|
||||
settings: req.body?.settings,
|
||||
viewSettings: req.body?.viewSettings,
|
||||
osc: req.body?.osc,
|
||||
aliases: req.body?.aliases,
|
||||
urlPresets: req.body?.urlPresets,
|
||||
customFields: req.body?.customFields,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
|
||||
import { router as aliasesRouter } from './aliases/aliases.router.js';
|
||||
import { router as urlPresetsRouter } from './url-presets/urlPresets.router.js';
|
||||
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
|
||||
import { router as dbRouter } from './db/db.router.js';
|
||||
import { router as httpRouter } from './http/http.router.js';
|
||||
@@ -13,7 +13,6 @@ import { router as viewSettingsRouter } from './view-settings/viewSettings.route
|
||||
|
||||
export const appRouter = express.Router();
|
||||
|
||||
appRouter.use('/aliases', aliasesRouter);
|
||||
appRouter.use('/custom-fields', customFieldsRouter);
|
||||
appRouter.use('/db', dbRouter);
|
||||
appRouter.use('/http', httpRouter);
|
||||
@@ -22,4 +21,5 @@ appRouter.use('/project', projectRouter);
|
||||
appRouter.use('/rundown', rundownRouter);
|
||||
appRouter.use('/settings', settingsRouter);
|
||||
appRouter.use('/sheets', sheetsRouter);
|
||||
appRouter.use('/url-presets', urlPresetsRouter);
|
||||
appRouter.use('/view-settings', viewSettingsRouter);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ErrorResponse, URLPreset } from 'ontime-types';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { failIsNotArray } from '../../utils/routerUtils.js';
|
||||
|
||||
export async function getUrlPresets(_req: Request, res: Response<URLPreset[]>) {
|
||||
const presets = DataProvider.getUrlPresets();
|
||||
res.status(200).send(presets);
|
||||
}
|
||||
|
||||
export async function postUrlPresets(req: Request, res: Response<URLPreset[] | ErrorResponse>) {
|
||||
if (failIsNotArray(req.body, res)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newPresets: URLPreset[] = req.body.map((preset) => ({
|
||||
enabled: preset.enabled,
|
||||
alias: preset.alias,
|
||||
pathAndParams: preset.pathAndParams,
|
||||
}));
|
||||
await DataProvider.setUrlPresets(newPresets);
|
||||
res.status(200).send(newPresets);
|
||||
} catch (error) {
|
||||
res.status(400).send({ message: String(error) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import express from 'express';
|
||||
import { getUrlPresets, postUrlPresets } from './urlPresets.controller.js';
|
||||
import { validateUrlPresets } from './urlPresets.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.get('/', getUrlPresets);
|
||||
router.post('/', validateUrlPresets, postUrlPresets);
|
||||
+2
-2
@@ -2,9 +2,9 @@ import { body, validationResult } from 'express-validator';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
/**
|
||||
* @description Validates object for POST /ontime/aliases
|
||||
* validate array of URL preset objects
|
||||
*/
|
||||
export const validateAliases = [
|
||||
export const validateUrlPresets = [
|
||||
body().isArray(),
|
||||
body('*.enabled').isBoolean(),
|
||||
body('*.alias').isString().trim(),
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
ViewSettings,
|
||||
DatabaseModel,
|
||||
OSCSettings,
|
||||
Alias,
|
||||
Settings,
|
||||
CustomFields,
|
||||
HttpSettings,
|
||||
URLPreset,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { data, db } from '../../setup/loadDb.js';
|
||||
@@ -65,12 +65,12 @@ export class DataProvider {
|
||||
return data.http;
|
||||
}
|
||||
|
||||
static getAliases(): Alias[] {
|
||||
return data.aliases;
|
||||
static getUrlPresets(): URLPreset[] {
|
||||
return data.urlPresets;
|
||||
}
|
||||
|
||||
static async setAliases(newData: Alias[]) {
|
||||
data.aliases = newData;
|
||||
static async setUrlPresets(newData: URLPreset[]) {
|
||||
data.urlPresets = newData;
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ export class DataProvider {
|
||||
data.viewSettings = mergedData.viewSettings;
|
||||
data.osc = mergedData.osc;
|
||||
data.http = mergedData.http;
|
||||
data.aliases = mergedData.aliases;
|
||||
data.urlPresets = mergedData.urlPresets;
|
||||
data.customFields = mergedData.customFields;
|
||||
data.rundown = mergedData.rundown;
|
||||
await this.persist();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DatabaseModel } from 'ontime-types';
|
||||
* @param {object} newData
|
||||
*/
|
||||
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
|
||||
const { rundown, project, settings, viewSettings, aliases, customFields, osc, http } = newData || {};
|
||||
const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = newData || {};
|
||||
|
||||
return {
|
||||
...existing,
|
||||
@@ -14,7 +14,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
|
||||
project: { ...existing.project, ...project },
|
||||
settings: { ...existing.settings, ...settings },
|
||||
viewSettings: { ...existing.viewSettings, ...viewSettings },
|
||||
aliases: aliases ?? existing.aliases,
|
||||
urlPresets: urlPresets ?? existing.urlPresets,
|
||||
customFields: customFields ?? existing.customFields,
|
||||
osc: { ...existing.osc, ...osc },
|
||||
http: { ...existing.http, ...http },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alias, DatabaseModel, OntimeRundown, Settings } from 'ontime-types';
|
||||
import { DatabaseModel, OntimeRundown, Settings, URLPreset } from 'ontime-types';
|
||||
import { safeMerge } from '../DataProvider.utils.js';
|
||||
|
||||
describe('safeMerge', () => {
|
||||
@@ -28,7 +28,7 @@ describe('safeMerge', () => {
|
||||
warningColor: '#FFAB33',
|
||||
dangerColor: '#ED3333',
|
||||
},
|
||||
aliases: [],
|
||||
urlPresets: [],
|
||||
customFields: {
|
||||
lighting: { type: 'string', label: 'lighting', colour: 'red' },
|
||||
vfx: { type: 'string', label: 'vfx', colour: 'blue' },
|
||||
@@ -130,7 +130,7 @@ describe('safeMerge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge the aliases key when present', () => {
|
||||
it('should merge the urlPresets key when present', () => {
|
||||
const existingData = {
|
||||
rundown: [],
|
||||
project: {
|
||||
@@ -153,7 +153,7 @@ describe('safeMerge', () => {
|
||||
overrideStyles: false,
|
||||
endMessage: '',
|
||||
},
|
||||
aliases: [],
|
||||
urlPresets: [],
|
||||
osc: {
|
||||
portIn: 8888,
|
||||
portOut: 9999,
|
||||
@@ -165,15 +165,15 @@ describe('safeMerge', () => {
|
||||
} as DatabaseModel;
|
||||
|
||||
const newData = {
|
||||
aliases: [
|
||||
urlPresets: [
|
||||
{ enabled: true, alias: 'alias1', pathAndParams: '' },
|
||||
{ enabled: true, alias: 'alias2', pathAndParams: '' },
|
||||
] as Alias[],
|
||||
] as URLPreset[],
|
||||
};
|
||||
|
||||
const mergedData = safeMerge(existingData, newData);
|
||||
|
||||
expect(mergedData.aliases).toEqual(newData.aliases);
|
||||
expect(mergedData.urlPresets).toEqual(newData.urlPresets);
|
||||
});
|
||||
|
||||
it('merges customFields into existing object', () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ export const dbModel: DatabaseModel = {
|
||||
dangerColor: '#ED3333',
|
||||
endMessage: '',
|
||||
},
|
||||
aliases: [],
|
||||
urlPresets: [],
|
||||
customFields: {},
|
||||
osc: {
|
||||
portIn: 8888,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { dbModel } from '../../models/dataModel.js';
|
||||
|
||||
import { parseExcel, parseJson, createEvent, getCustomFieldData } from '../parser.js';
|
||||
import { makeString } from '../parserUtils.js';
|
||||
import { parseAliases, parseViewSettings } from '../parserFunctions.js';
|
||||
import { parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
|
||||
|
||||
describe('test json parser with valid def', () => {
|
||||
const testData: Partial<DatabaseModel> = {
|
||||
@@ -514,23 +514,23 @@ describe('test event validator', () => {
|
||||
});
|
||||
|
||||
describe('test aliases import', () => {
|
||||
it('imports a well defined alias', () => {
|
||||
it('imports a well defined urlPreset', () => {
|
||||
const testData = {
|
||||
rundown: [],
|
||||
settings: {
|
||||
app: 'ontime',
|
||||
version: '2.0.0',
|
||||
},
|
||||
aliases: [
|
||||
urlPresets: [
|
||||
{
|
||||
enabled: false,
|
||||
alias: 'testalias',
|
||||
pathAndParams: 'testpathAndParams',
|
||||
},
|
||||
],
|
||||
};
|
||||
} as DatabaseModel;
|
||||
|
||||
const parsed = parseAliases(testData);
|
||||
const parsed = parseUrlPresets(testData);
|
||||
expect(parsed.length).toBe(1);
|
||||
|
||||
// generates missing id
|
||||
@@ -552,8 +552,10 @@ describe('test views import', () => {
|
||||
dangerColor: '#ED3333',
|
||||
endMessage: '',
|
||||
overrideStyles: false,
|
||||
// known error: properties do not exist
|
||||
notAthing: true,
|
||||
},
|
||||
// known error: views does not exist
|
||||
views: {
|
||||
overrideStyles: true,
|
||||
},
|
||||
@@ -565,6 +567,7 @@ describe('test views import', () => {
|
||||
endMessage: '',
|
||||
overrideStyles: false,
|
||||
};
|
||||
// @ts-expect-error -- we know the above is incorrect
|
||||
const parsed = parseViewSettings(testData);
|
||||
expect(parsed).toStrictEqual(expectedParsedViewSettings);
|
||||
});
|
||||
@@ -576,7 +579,7 @@ describe('test views import', () => {
|
||||
app: 'ontime',
|
||||
version: '2.0.0',
|
||||
},
|
||||
};
|
||||
} as DatabaseModel;
|
||||
const parsed = parseViewSettings(testData);
|
||||
expect(parsed).toStrictEqual({});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ import { event as eventDef } from '../models/eventsDefinition.js';
|
||||
import { dbModel } from '../models/dataModel.js';
|
||||
import { deleteFile, makeString } from './parserUtils.js';
|
||||
import {
|
||||
parseAliases,
|
||||
parseUrlPresets,
|
||||
parseProject,
|
||||
parseOsc,
|
||||
parseHttp,
|
||||
@@ -274,7 +274,7 @@ export const parseJson = async (jsonData: Partial<DatabaseModel>): Promise<Datab
|
||||
project: parseProject(jsonData) ?? dbModel.project,
|
||||
settings: parseSettings(jsonData) ?? dbModel.settings,
|
||||
viewSettings: parseViewSettings(jsonData) ?? dbModel.viewSettings,
|
||||
aliases: parseAliases(jsonData),
|
||||
urlPresets: parseUrlPresets(jsonData),
|
||||
customFields: parseCustomFields(jsonData),
|
||||
osc: parseOsc(jsonData) ?? dbModel.osc,
|
||||
http: parseHttp(jsonData) ?? dbModel.http,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { generateId } from 'ontime-utils';
|
||||
import {
|
||||
Alias,
|
||||
OntimeRundown,
|
||||
HttpSettings,
|
||||
OSCSettings,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
CustomFields,
|
||||
isOntimeCycle,
|
||||
HttpSubscription,
|
||||
URLPreset,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
|
||||
@@ -74,7 +74,7 @@ export const parseRundown = (data: Partial<DatabaseModel>): OntimeRundown => {
|
||||
* @param {object} data - data object
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseProject = (data): ProjectData => {
|
||||
export const parseProject = (data: Partial<DatabaseModel>): ProjectData => {
|
||||
let newProjectData: Partial<ProjectData> = {};
|
||||
// we are adding this here to aid transition, should be removed once enough time has past that users have fully migrated
|
||||
if ('project' in data) {
|
||||
@@ -134,7 +134,7 @@ export const parseSettings = (data): Settings => {
|
||||
* @param {object} data - data object
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseViewSettings = (data): ViewSettings => {
|
||||
export const parseViewSettings = (data: Partial<DatabaseModel>): ViewSettings => {
|
||||
let newViews: Partial<ViewSettings> = {};
|
||||
if ('viewSettings' in data) {
|
||||
console.log('Found view definition, importing...');
|
||||
@@ -225,29 +225,29 @@ export const parseHttp = (data: { http?: Partial<HttpSettings> }): HttpSettings
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse aliases portion of an entry
|
||||
* Parse URL preset portion of an entry
|
||||
* @param {object} data - data object
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseAliases = (data): Alias[] => {
|
||||
const newAliases: Alias[] = [];
|
||||
if ('aliases' in data) {
|
||||
console.log('Found Aliases definition, importing...');
|
||||
export const parseUrlPresets = (data: Partial<DatabaseModel>): URLPreset[] => {
|
||||
const newPresets: URLPreset[] = [];
|
||||
if ('urlPresets' in data) {
|
||||
console.log('Found URL presets definition, importing...');
|
||||
try {
|
||||
for (const alias of data.aliases) {
|
||||
const newAlias = {
|
||||
enabled: alias.enabled ?? false,
|
||||
alias: alias.alias ?? '',
|
||||
pathAndParams: alias.pathAndParams ?? '',
|
||||
for (const preset of data.urlPresets) {
|
||||
const newPreset = {
|
||||
enabled: preset.enabled ?? false,
|
||||
alias: preset.alias ?? '',
|
||||
pathAndParams: preset.pathAndParams ?? '',
|
||||
};
|
||||
newAliases.push(newAlias);
|
||||
newPresets.push(newPreset);
|
||||
}
|
||||
console.log(`Uploaded ${newAliases.length} alias(es)`);
|
||||
console.log(`Uploaded ${newPresets.length} preset(s)`);
|
||||
} catch (error) {
|
||||
console.log(`Error: ${error}`);
|
||||
}
|
||||
}
|
||||
return newAliases;
|
||||
return newPresets;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -255,7 +255,7 @@ export const parseAliases = (data): Alias[] => {
|
||||
* @param {object} data - data object
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseCustomFields = (data): CustomFields => {
|
||||
export const parseCustomFields = (data: Partial<DatabaseModel>): CustomFields => {
|
||||
let newCustomFields: CustomFields = { ...dbModel.customFields };
|
||||
|
||||
if ('customFields' in data) {
|
||||
|
||||
@@ -13,19 +13,19 @@ test('test project file upload', async ({ page }) => {
|
||||
// workaround to upload file on hidden input
|
||||
// https://playwright.dev/docs/api/class-filechooser
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
await page.getByRole('button', { name: 'Import' }).click();
|
||||
await page.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(fileToUpload);
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
// asset test events
|
||||
const firstTitle = page.getByTestId('entry-1').getByTestId('block__title')
|
||||
const firstTitle = page.getByTestId('entry-1').getByTestId('block__title');
|
||||
await expect(firstTitle).toHaveValue('Albania');
|
||||
|
||||
const secondTitle = page.getByTestId('entry-2').getByTestId('block__title')
|
||||
const secondTitle = page.getByTestId('entry-2').getByTestId('block__title');
|
||||
await expect(secondTitle).toHaveValue('Latvia');
|
||||
|
||||
const thirdTitle = page.getByTestId('entry-3').getByTestId('block__title')
|
||||
const thirdTitle = page.getByTestId('entry-3').getByTestId('block__title');
|
||||
await expect(thirdTitle).toHaveValue('Lithuania');
|
||||
});
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
test('test aliases feature, it should redirect to given alias', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/editor');
|
||||
|
||||
// open settings
|
||||
await page.getByRole('button', { name: 'Settings deprecated' }).click();
|
||||
await page.getByRole('tab', { name: 'URL Aliases' }).click();
|
||||
|
||||
// create alias
|
||||
await page.getByRole('button', { name: 'Add new' }).click();
|
||||
await page.getByTestId('field__alias_1').fill('testing');
|
||||
await page.getByTestId('field__url_1').fill('countdown');
|
||||
await page.getByTestId('field__enable_1').click();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// make sure alias works
|
||||
await page.goto('http://localhost:4001/testing');
|
||||
await page.getByTestId('countdown__select').click();
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
test('test URL preset feature, it should redirect to given URL', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/editor');
|
||||
|
||||
// open settings
|
||||
await page.getByRole('button', { name: 'Application settings' }).click();
|
||||
await page.getByRole('button', { name: 'General' }).click();
|
||||
|
||||
// create preset
|
||||
await page.getByTestId('url-preset-form').scrollIntoViewIfNeeded();
|
||||
|
||||
await page.getByRole('button', { name: 'New' }).scrollIntoViewIfNeeded();
|
||||
await page.getByRole('button', { name: 'New' }).click();
|
||||
|
||||
await page.getByTestId('field__alias_0').click();
|
||||
await page.getByTestId('field__alias_0').fill('testing');
|
||||
|
||||
await page.getByTestId('field__url_0').click();
|
||||
await page.getByTestId('field__url_0').fill('countdown');
|
||||
|
||||
await page.getByTestId('field__enable_0').click();
|
||||
|
||||
await page.getByTestId('url-preset-form').getByRole('button', { name: 'Save', exact: true }).click();
|
||||
|
||||
// make sure preset works
|
||||
await page.goto('http://localhost:4001/testing');
|
||||
await page.getByTestId('countdown__select').click();
|
||||
});
|
||||
@@ -1,17 +1,20 @@
|
||||
import { Alias } from './core/Alias.type.js';
|
||||
import { ProjectData } from './core/ProjectData.type.js';
|
||||
import { OntimeRundown } from './core/Rundown.type.js';
|
||||
import { OSCSettings } from './core/OscSettings.type.js';
|
||||
import { Settings } from './core/Settings.type.js';
|
||||
import { ViewSettings } from './core/Views.type.js';
|
||||
import { CustomFields, HttpSettings } from '../index.js';
|
||||
import {
|
||||
CustomFields,
|
||||
HttpSettings,
|
||||
OSCSettings,
|
||||
OntimeRundown,
|
||||
ProjectData,
|
||||
Settings,
|
||||
URLPreset,
|
||||
ViewSettings,
|
||||
} from '../index.js';
|
||||
|
||||
export type DatabaseModel = {
|
||||
rundown: OntimeRundown;
|
||||
project: ProjectData;
|
||||
settings: Settings;
|
||||
viewSettings: ViewSettings;
|
||||
aliases: Alias[];
|
||||
urlPresets: URLPreset[];
|
||||
customFields: CustomFields;
|
||||
osc: OSCSettings;
|
||||
http: HttpSettings;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export type Alias = {
|
||||
export type URLPreset = {
|
||||
enabled: boolean;
|
||||
alias: string;
|
||||
pathAndParams: string;
|
||||
@@ -24,8 +24,8 @@ export type { Settings } from './definitions/core/Settings.type.js';
|
||||
export type { ViewSettings } from './definitions/core/Views.type.js';
|
||||
export type { TimeFormat } from './definitions/core/TimeFormat.type.js';
|
||||
|
||||
// ---> Aliases
|
||||
export type { Alias } from './definitions/core/Alias.type.js';
|
||||
// ---> URL Presets
|
||||
export type { URLPreset } from './definitions/core/UrlPreset.type.js';
|
||||
|
||||
// ---> Custom Fields
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user