mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-07 08:23:55 +00:00
refactor: improve handling of multi-fields
This commit is contained in:
committed by
Carlos Valente
parent
b2b115c329
commit
5c86914cda
@@ -18,19 +18,21 @@ import {
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
import InlineColourPicker from './InlineColourPicker';
|
||||
import { ParamField } from './types';
|
||||
import { ParamField } from './viewParams.types';
|
||||
|
||||
interface EditFormInputProps {
|
||||
interface ParamInputProps {
|
||||
paramField: ParamField;
|
||||
}
|
||||
|
||||
export default function ParamInput(props: EditFormInputProps) {
|
||||
const { paramField } = props;
|
||||
export default function ParamInput({ paramField }: ParamInputProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { id, type, defaultValue } = paramField;
|
||||
|
||||
if (type === 'persist') {
|
||||
return null;
|
||||
if (!paramField.values || !paramField.values.length) {
|
||||
return null;
|
||||
}
|
||||
return <input hidden name={id} readOnly value={paramField.values.join(',')} />;
|
||||
}
|
||||
|
||||
if (type === 'option') {
|
||||
|
||||
@@ -15,74 +15,12 @@ import {
|
||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||
import Info from '../info/Info';
|
||||
|
||||
import { ViewOption } from './types';
|
||||
import { ViewOption } from './viewParams.types';
|
||||
import { getURLSearchParamsFromObj } from './viewParams.utils';
|
||||
import ViewParamsSection from './ViewParamsSection';
|
||||
|
||||
import style from './ViewParamsEditor.module.scss';
|
||||
|
||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||
|
||||
/**
|
||||
* Utility remove the # character from a hex string
|
||||
*/
|
||||
function sanitiseColour(colour: string) {
|
||||
if (colour.startsWith('#')) {
|
||||
return colour.substring(1);
|
||||
}
|
||||
return colour;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a new URLSearchParams object from the given params object
|
||||
*/
|
||||
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOption[]) => {
|
||||
const newSearchParams = new URLSearchParams();
|
||||
|
||||
// Convert paramFields to an object that contains default values
|
||||
const defaultValues: Record<string, string> = {};
|
||||
paramFields.forEach((section) => {
|
||||
section.options.forEach((option) => {
|
||||
defaultValues[option.id] = String(option.defaultValue);
|
||||
|
||||
// extract persisted values
|
||||
if ('type' in option && option.type === 'persist') {
|
||||
newSearchParams.set(option.id, option.value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// compare which values are different from the default values
|
||||
Object.entries(paramsObj).forEach(([id, value]) => {
|
||||
if (typeof value === 'string' && value.length) {
|
||||
// we dont know which values contain colours
|
||||
// unfortunately this means we run all the strings through the sanitation
|
||||
const valueWithoutHash = sanitiseColour(value);
|
||||
if (defaultValues[id] !== valueWithoutHash) {
|
||||
handleValueString(id, valueWithoutHash);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** Utility function contains logic to add a value into the searchParams object */
|
||||
function handleValueString(id: string, value: string) {
|
||||
const maybeMultipleValues = value.split(',');
|
||||
|
||||
// we need to check if the value contains comma separated list, for the case of the multi-select data
|
||||
if (Array.isArray(maybeMultipleValues) && maybeMultipleValues.length > 1) {
|
||||
const added = new Set();
|
||||
maybeMultipleValues.forEach((v) => {
|
||||
if (!added.has(v)) {
|
||||
added.add(v);
|
||||
newSearchParams.append(id, v);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
newSearchParams.set(id, value);
|
||||
}
|
||||
}
|
||||
return newSearchParams;
|
||||
};
|
||||
|
||||
interface EditFormDrawerProps {
|
||||
viewOptions: ViewOption[];
|
||||
}
|
||||
@@ -94,6 +32,7 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
|
||||
// handle opening the drawer
|
||||
useEffect(() => {
|
||||
const isEditing = searchParams.get('edit');
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useLocalStorage } from '@mantine/hooks';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import { OptionTitle } from './constants';
|
||||
import ParamInput from './ParamInput';
|
||||
import { type ParamField } from './types';
|
||||
import { type ParamField } from './viewParams.types';
|
||||
|
||||
import style from './ViewParamsSection.module.scss';
|
||||
|
||||
@@ -14,9 +15,7 @@ interface ViewParamsSectionProps {
|
||||
options: ParamField[];
|
||||
}
|
||||
|
||||
export default function ViewParamsSection(props: ViewParamsSectionProps) {
|
||||
const { title, collapsible, options } = props;
|
||||
|
||||
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
||||
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
||||
|
||||
const handleCollapse = () => {
|
||||
@@ -27,28 +26,52 @@ export default function ViewParamsSection(props: ViewParamsSectionProps) {
|
||||
|
||||
return (
|
||||
<section className={style.section}>
|
||||
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
|
||||
{title}
|
||||
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
{title === OptionTitle.Hidden ? (
|
||||
<HiddenContents options={options} />
|
||||
) : (
|
||||
<>
|
||||
{options.map((option) => {
|
||||
if (option.type === 'persist') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<label key={option.title} className={style.label}>
|
||||
<span className={style.title}>{option.title}</span>
|
||||
<span className={style.description}>{option.description}</span>
|
||||
<ParamInput paramField={option} />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
|
||||
{title}
|
||||
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
|
||||
</div>
|
||||
<SectionContents options={options} collapsed={collapsed} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionContentsProps {
|
||||
options: ParamField[];
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
function SectionContents({ options, collapsed }: SectionContentsProps) {
|
||||
if (collapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<label key={option.title} className={style.label}>
|
||||
<span className={style.title}>{option.title}</span>
|
||||
<span className={style.description}>{option.description}</span>
|
||||
<ParamInput paramField={option} />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HiddenContents({ options }: { options: ParamField[] }) {
|
||||
return (
|
||||
<>
|
||||
{options.map((option, index) => {
|
||||
return <ParamInput key={option.title + index} paramField={option} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import { makeOptionsFromCustomFields } from '../constants';
|
||||
|
||||
describe('makeOptionsFromCustomFields', () => {
|
||||
const testCustomFields: CustomFields = {
|
||||
field1: { label: 'Field 1', colour: 'red', type: 'string' },
|
||||
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
|
||||
};
|
||||
|
||||
it('creates a record of keys for the given custom fields', () => {
|
||||
const result = makeOptionsFromCustomFields(testCustomFields);
|
||||
expect(result).toStrictEqual({
|
||||
'custom-field1': 'Custom: Field 1',
|
||||
'custom-field2': 'Custom: Field 2',
|
||||
});
|
||||
});
|
||||
|
||||
it('appends additional data', () => {
|
||||
const additionalData = {
|
||||
test1: 'test1',
|
||||
test2: 'test2',
|
||||
};
|
||||
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
|
||||
expect(result).toStrictEqual({
|
||||
'custom-field1': 'Custom: Field 1',
|
||||
'custom-field2': 'Custom: Field 2',
|
||||
test1: 'test1',
|
||||
test2: 'test2',
|
||||
});
|
||||
});
|
||||
|
||||
it('filtersImageTypes', () => {
|
||||
const customFieldsWIthImage: CustomFields = {
|
||||
...testCustomFields,
|
||||
field3: { label: 'Field 3', colour: 'green', type: 'image' },
|
||||
};
|
||||
|
||||
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
|
||||
expect(result).toStrictEqual({
|
||||
'custom-field1': 'Custom: Field 1',
|
||||
'custom-field2': 'Custom: Field 2',
|
||||
});
|
||||
});
|
||||
});
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { OptionTitle } from '../constants';
|
||||
import type { ViewOption } from '../viewParams.types';
|
||||
import { getURLSearchParamsFromObj, makeOptionsFromCustomFields } from '../viewParams.utils';
|
||||
|
||||
describe('makeOptionsFromCustomFields', () => {
|
||||
const testCustomFields: CustomFields = {
|
||||
field1: { label: 'Field 1', colour: 'red', type: 'string' },
|
||||
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
|
||||
};
|
||||
|
||||
it('creates a record of keys for the given custom fields', () => {
|
||||
const result = makeOptionsFromCustomFields(testCustomFields);
|
||||
expect(result).toStrictEqual({
|
||||
'custom-field1': 'Custom: Field 1',
|
||||
'custom-field2': 'Custom: Field 2',
|
||||
});
|
||||
});
|
||||
|
||||
it('appends additional data', () => {
|
||||
const additionalData = {
|
||||
test1: 'test1',
|
||||
test2: 'test2',
|
||||
};
|
||||
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
|
||||
expect(result).toStrictEqual({
|
||||
'custom-field1': 'Custom: Field 1',
|
||||
'custom-field2': 'Custom: Field 2',
|
||||
test1: 'test1',
|
||||
test2: 'test2',
|
||||
});
|
||||
});
|
||||
|
||||
it('filtersImageTypes', () => {
|
||||
const customFieldsWIthImage: CustomFields = {
|
||||
...testCustomFields,
|
||||
field3: { label: 'Field 3', colour: 'green', type: 'image' },
|
||||
};
|
||||
|
||||
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
|
||||
expect(result).toStrictEqual({
|
||||
'custom-field1': 'Custom: Field 1',
|
||||
'custom-field2': 'Custom: Field 2',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getURLSearchParamsFromObj', () => {
|
||||
// Mock view options for testing
|
||||
const mockViewOptions: ViewOption[] = [
|
||||
{
|
||||
title: OptionTitle.DataSources,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'color',
|
||||
title: 'Color',
|
||||
description: 'The color value',
|
||||
type: 'colour',
|
||||
defaultValue: 'ff0000',
|
||||
},
|
||||
{
|
||||
id: 'persist-field',
|
||||
title: 'Persistent Field',
|
||||
description: 'A field that persists',
|
||||
type: 'persist',
|
||||
values: ['persisted-value'],
|
||||
},
|
||||
{
|
||||
id: 'multi-select',
|
||||
title: 'Multi Select',
|
||||
description: 'A multi-select field',
|
||||
type: 'option',
|
||||
values: { value1: 'Value 1', value2: 'Value 2' },
|
||||
defaultValue: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it('should handle empty params object', () => {
|
||||
const params = {};
|
||||
const result = getURLSearchParamsFromObj(params, mockViewOptions);
|
||||
|
||||
// Should only include persisted values
|
||||
expect(result.get('persist-field')).toBe('persisted-value');
|
||||
expect(result.get('color')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not include values that match defaults', () => {
|
||||
const params = {
|
||||
color: 'ff0000', // same as the default
|
||||
'other-param': 'value',
|
||||
};
|
||||
const result = getURLSearchParamsFromObj(params, mockViewOptions);
|
||||
|
||||
expect(result.get('color')).toBeNull();
|
||||
expect(result.get('other-param')).toBe('value');
|
||||
});
|
||||
|
||||
it('should sanitize color values with #', () => {
|
||||
const params = {
|
||||
color: '#00ff00', // different from default and includes #
|
||||
};
|
||||
const result = getURLSearchParamsFromObj(params, mockViewOptions);
|
||||
|
||||
expect(result.get('color')).toBe('00ff00');
|
||||
});
|
||||
|
||||
it('should handle multi-select values', () => {
|
||||
const params = {
|
||||
'multi-select': 'value1,value2,value3',
|
||||
};
|
||||
const result = getURLSearchParamsFromObj(params, mockViewOptions);
|
||||
|
||||
// Should have multiple entries for the same key
|
||||
const values = result.getAll('multi-select');
|
||||
expect(values).toEqual(['value1', 'value2', 'value3']);
|
||||
});
|
||||
|
||||
it('should not include empty string values', () => {
|
||||
const params = {
|
||||
'empty-param': '',
|
||||
};
|
||||
const result = getURLSearchParamsFromObj(params, mockViewOptions);
|
||||
expect(result.get('empty-param')).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow multiple values for persisted fields', () => {
|
||||
const mockOptionsWithMultiPersist: ViewOption[] = [
|
||||
{
|
||||
title: OptionTitle.DataSources,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'sub',
|
||||
title: 'Event subscription',
|
||||
description: 'The events to follow',
|
||||
values: [],
|
||||
type: 'persist',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const params = {
|
||||
sub: 'value1,value2,value3',
|
||||
};
|
||||
const result = getURLSearchParamsFromObj(params, mockOptionsWithMultiPersist);
|
||||
const values = result.getAll('sub');
|
||||
|
||||
expect(values).toStrictEqual(['value1', 'value2', 'value3']);
|
||||
});
|
||||
|
||||
it('should allow adding multiple values for any field', () => {
|
||||
const params = {
|
||||
'regular-field': 'value1,value2,value3',
|
||||
};
|
||||
const result = getURLSearchParamsFromObj(params, mockViewOptions);
|
||||
|
||||
const values = result.getAll('regular-field');
|
||||
expect(values).toHaveLength(3);
|
||||
expect(values).toEqual(['value1', 'value2', 'value3']);
|
||||
});
|
||||
|
||||
it('should deduplicate repeated key-value pairs', () => {
|
||||
const params = {
|
||||
multiValue: 'value1,value1,value2,value2,value3',
|
||||
repeatedField: 'same,same,same',
|
||||
};
|
||||
|
||||
const result = getURLSearchParamsFromObj(params, mockViewOptions);
|
||||
|
||||
// Check multiValue field has unique values
|
||||
expect(result.getAll('multiValue')).toEqual(['value1', 'value2', 'value3']);
|
||||
|
||||
// Check repeatedField has only one instance of the value
|
||||
expect(result.getAll('repeatedField')).toEqual(['same']);
|
||||
});
|
||||
|
||||
it('should deduplicate persisted values while maintaining order', () => {
|
||||
const mockOptionsWithDuplicates: ViewOption[] = [
|
||||
{
|
||||
title: OptionTitle.StyleOverride,
|
||||
options: [
|
||||
{
|
||||
id: 'sub',
|
||||
title: 'Subscription',
|
||||
description: 'Persisted subscription values',
|
||||
type: 'persist',
|
||||
values: ['value1', 'value1', 'value2', 'value2', 'value3', 'value1'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = getURLSearchParamsFromObj({}, mockOptionsWithDuplicates);
|
||||
|
||||
// Should only include unique values while maintaining order
|
||||
expect(result.getAll('sub')).toEqual(['value1', 'value2', 'value3']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ParamField } from './viewParams.types';
|
||||
|
||||
export const getTimeOption = (timeFormat: string): ParamField => {
|
||||
const placeholder = `${timeFormat} (default)`;
|
||||
return {
|
||||
id: 'timeformat',
|
||||
title: 'Time format string, taken from the Application Settings',
|
||||
description: 'Format for auxiliar time fields (not the running), eg. HH:mm:ss or hh:mm:ss a, see docs for help',
|
||||
type: 'string',
|
||||
placeholder,
|
||||
};
|
||||
};
|
||||
|
||||
export const hideTimerSeconds: ParamField = {
|
||||
id: 'hideTimerSeconds',
|
||||
title: 'Hide seconds in timer',
|
||||
description: 'Whether to hide seconds in the running timer',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
export const showLeadingZeros: ParamField = {
|
||||
id: 'showLeadingZeros',
|
||||
title: 'Show leading zeros in timer',
|
||||
description: 'Whether to show leading zeros in the running timer',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
};
|
||||
@@ -1,61 +1,6 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import type { MultiselectOptions, ParamField } from './types';
|
||||
|
||||
export const makeOptionsFromCustomFields = (
|
||||
customFields: CustomFields,
|
||||
additionalOptions: Readonly<Record<string, string>> = {},
|
||||
filterImageType = true,
|
||||
) => {
|
||||
const options = { ...additionalOptions };
|
||||
for (const [key, value] of Object.entries(customFields)) {
|
||||
if (filterImageType && value.type === 'image') {
|
||||
continue;
|
||||
}
|
||||
|
||||
options[`custom-${key}`] = `Custom: ${value.label}`;
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
|
||||
const options: MultiselectOptions = {};
|
||||
for (const [key, value] of Object.entries(customFields)) {
|
||||
if (filterImageType && value.type === 'image') {
|
||||
continue;
|
||||
}
|
||||
options[key] = { value: key, label: value.label, colour: value.colour };
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export const getTimeOption = (timeFormat: string): ParamField => {
|
||||
const placeholder = `${timeFormat} (default)`;
|
||||
return {
|
||||
id: 'timeformat',
|
||||
title: 'Time format string, taken from the Application Settings',
|
||||
description: 'Format for auxiliar time fields (not the running), eg. HH:mm:ss or hh:mm:ss a, see docs for help',
|
||||
type: 'string',
|
||||
placeholder,
|
||||
};
|
||||
};
|
||||
|
||||
export const hideTimerSeconds: ParamField = {
|
||||
id: 'hideTimerSeconds',
|
||||
title: 'Hide seconds in timer',
|
||||
description: 'Whether to hide seconds in the running timer',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
export const showLeadingZeros: ParamField = {
|
||||
id: 'showLeadingZeros',
|
||||
title: 'Show leading zeros in timer',
|
||||
description: 'Whether to show leading zeros in the running timer',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Gathers possible titles for view options
|
||||
*/
|
||||
export enum OptionTitle {
|
||||
ClockOptions = 'Clock Options',
|
||||
TimerOptions = 'Timer Options',
|
||||
@@ -65,4 +10,7 @@ export enum OptionTitle {
|
||||
StyleOverride = 'View style override',
|
||||
Animation = 'View animation',
|
||||
Schedule = 'Schedule options',
|
||||
|
||||
/** rendered as hidden inputs */
|
||||
Hidden = 'Hidden options',
|
||||
}
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ type StringField = { type: 'string'; defaultValue?: string; prefix?: string; pla
|
||||
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
||||
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
||||
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
|
||||
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
|
||||
type PersistedField = { type: 'persist'; defaultValue?: string[]; values: string[] };
|
||||
|
||||
export type ParamField = BaseField &
|
||||
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField);
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { CustomFields } from 'ontime-types';
|
||||
|
||||
import type { MultiselectOptions, ViewOption } from './viewParams.types';
|
||||
|
||||
/**
|
||||
* Creates a list of custom fields for a select
|
||||
* Filters out image type custom fields
|
||||
*/
|
||||
export function makeOptionsFromCustomFields(
|
||||
customFields: CustomFields,
|
||||
additionalOptions: Readonly<Record<string, string>> = {},
|
||||
filterImageType = true,
|
||||
): Record<string, string> {
|
||||
const options = { ...additionalOptions };
|
||||
for (const [key, value] of Object.entries(customFields)) {
|
||||
if (filterImageType && value.type === 'image') {
|
||||
continue;
|
||||
}
|
||||
|
||||
options[`custom-${key}`] = `Custom: ${value.label}`;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates data for a multiselect component from custom fields
|
||||
* Filters out image type custom fields
|
||||
*/
|
||||
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
|
||||
const options: MultiselectOptions = {};
|
||||
for (const [key, value] of Object.entries(customFields)) {
|
||||
if (filterImageType && value.type === 'image') {
|
||||
continue;
|
||||
}
|
||||
options[key] = { value: key, label: value.label, colour: value.colour };
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||
|
||||
/**
|
||||
* Utility remove the # character from a hex string
|
||||
*/
|
||||
function sanitiseColour(colour: string) {
|
||||
if (colour.startsWith('#')) {
|
||||
return colour.substring(1);
|
||||
}
|
||||
return colour;
|
||||
}
|
||||
|
||||
type FieldMetadata = {
|
||||
defaultValues: Record<string, string>;
|
||||
colorFields: Set<string>;
|
||||
isPersistedField: Set<string>;
|
||||
persistedValues: Record<string, string[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility collects metadata about fields from view options
|
||||
*/
|
||||
function collectFieldMetadata(paramFields: ViewOption[]): FieldMetadata {
|
||||
const metadata: FieldMetadata = {
|
||||
defaultValues: {},
|
||||
colorFields: new Set(),
|
||||
isPersistedField: new Set(),
|
||||
persistedValues: {},
|
||||
};
|
||||
|
||||
paramFields.forEach((section) => {
|
||||
section.options.forEach((option) => {
|
||||
if (option.type === 'persist') {
|
||||
metadata.isPersistedField.add(option.id);
|
||||
if (option.values) {
|
||||
metadata.persistedValues[option.id] = option.values;
|
||||
}
|
||||
} else {
|
||||
metadata.defaultValues[option.id] = String(option.defaultValue);
|
||||
}
|
||||
|
||||
if (option.type === 'colour') {
|
||||
metadata.colorFields.add(option.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a new URLSearchParams object from the given params object
|
||||
* @param paramsObj - The object containing parameters to be converted
|
||||
* @param paramFields - The view options that define the parameters
|
||||
* @returns A new URLSearchParams object with the parameters
|
||||
*/
|
||||
export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields: ViewOption[]) {
|
||||
const newSearchParams = new URLSearchParams();
|
||||
const addedPairs = new Set<string>();
|
||||
const metadata = collectFieldMetadata(paramFields);
|
||||
|
||||
// Utility function to safely add params without duplicates
|
||||
const addUniqueParam = (id: string, value: string) => {
|
||||
const pair = `${id}:${value}`;
|
||||
if (!addedPairs.has(pair)) {
|
||||
addedPairs.add(pair);
|
||||
newSearchParams.append(id, value);
|
||||
}
|
||||
};
|
||||
|
||||
// First add all persisted values
|
||||
Object.entries(metadata.persistedValues).forEach(([id, values]) => {
|
||||
values.forEach((value) => {
|
||||
if (value) {
|
||||
addUniqueParam(id, value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Then process user-provided values
|
||||
Object.entries(paramsObj).forEach(([id, value]) => {
|
||||
if (typeof value === 'string' && value.length) {
|
||||
// For persisted fields, clear existing values before adding new ones
|
||||
if (metadata.isPersistedField.has(id)) {
|
||||
// Clear tracking of previous values for this field
|
||||
Array.from(addedPairs).forEach((pair) => {
|
||||
if (pair.startsWith(`${id}:`)) {
|
||||
addedPairs.delete(pair);
|
||||
}
|
||||
});
|
||||
newSearchParams.delete(id);
|
||||
}
|
||||
|
||||
// Process and add new values
|
||||
value.split(',').forEach((v) => {
|
||||
const processedValue = metadata.colorFields.has(id) ? sanitiseColour(v) : v;
|
||||
if (metadata.isPersistedField.has(id) || metadata.defaultValues[id] !== processedValue) {
|
||||
addUniqueParam(id, processedValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return newSearchParams;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||
import {
|
||||
getTimeOption,
|
||||
makeCustomFieldSelectOptions,
|
||||
makeOptionsFromCustomFields,
|
||||
OptionTitle,
|
||||
} from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||
} from '../../common/components/view-params-editor/viewParams.utils';
|
||||
|
||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
||||
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTimeOption, OptionTitle } from '../../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
import { getTimeOption } from '../../../common/components/view-params-editor/common.options';
|
||||
import { OptionTitle } from '../../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/viewParams.types';
|
||||
|
||||
export const getClockOptions = (timeFormat: string): ViewOption[] => [
|
||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import { makeOptionsFromCustomFields, OptionTitle } from '../../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
import { OptionTitle } from '../../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/viewParams.types';
|
||||
import { makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/viewParams.utils';
|
||||
import safeParseNumber from '../../../common/utils/safeParseNumber';
|
||||
|
||||
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import {
|
||||
hideTimerSeconds,
|
||||
OptionTitle,
|
||||
showLeadingZeros,
|
||||
} from '../../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
import { hideTimerSeconds, showLeadingZeros } from '../../../common/components/view-params-editor/common.options';
|
||||
import { OptionTitle } from '../../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/viewParams.types';
|
||||
|
||||
export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
|
||||
{ title: OptionTitle.TimerOptions, collapsible: true, options: [hideTimerSeconds, showLeadingZeros] },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTimeOption, hideTimerSeconds, OptionTitle } from '../../../common/components/view-params-editor/constants';
|
||||
import type { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
import { getTimeOption, hideTimerSeconds } from '../../../common/components/view-params-editor/common.options';
|
||||
import { OptionTitle } from '../../../common/components/view-params-editor/constants';
|
||||
import type { ViewOption } from '../../../common/components/view-params-editor/viewParams.types';
|
||||
|
||||
export const getStudioClockOptions = (timeFormat: string): ViewOption[] => [
|
||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||
|
||||
@@ -2,12 +2,10 @@ import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomFields, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import {
|
||||
getTimeOption,
|
||||
makeOptionsFromCustomFields,
|
||||
OptionTitle,
|
||||
} from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
||||
import { scheduleOptions } from '../common/schedule/schedule.options';
|
||||
|
||||
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { OptionTitle } from '../../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/viewParams.types';
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
export const scheduleOptions: ViewOption = {
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function Countdown({
|
||||
|
||||
// gather option data
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const countdownOptions = getCountdownOptions(defaultFormat, customFields);
|
||||
const countdownOptions = getCountdownOptions(defaultFormat, customFields, subscriptions);
|
||||
|
||||
return (
|
||||
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
||||
|
||||
@@ -2,15 +2,17 @@ import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomFields, EntryId, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import {
|
||||
getTimeOption,
|
||||
makeOptionsFromCustomFields,
|
||||
OptionTitle,
|
||||
} from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
||||
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
|
||||
|
||||
export const getCountdownOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||
export const getCountdownOptions = (
|
||||
timeFormat: string,
|
||||
customFields: CustomFields,
|
||||
persistedSubscriptions: EntryId[],
|
||||
): ViewOption[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||
|
||||
return [
|
||||
@@ -20,15 +22,6 @@ export const getCountdownOptions = (timeFormat: string, customFields: CustomFiel
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'sub',
|
||||
title: 'Event subscription',
|
||||
description: 'The events to follow',
|
||||
value: '',
|
||||
type: 'persist',
|
||||
},
|
||||
{
|
||||
// TODO: adding a secondary source is removing the subscriptions
|
||||
// this seems to be a bug with persist assuming that the property has a single entry
|
||||
id: 'secondary-src',
|
||||
title: 'Event secondary text',
|
||||
description: 'Select the data source for auxiliary text shown in the card',
|
||||
@@ -51,6 +44,18 @@ export const getCountdownOptions = (timeFormat: string, customFields: CustomFiel
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: OptionTitle.Hidden,
|
||||
options: [
|
||||
{
|
||||
id: 'sub',
|
||||
title: 'Event subscription',
|
||||
description: 'The events to follow',
|
||||
values: persistedSubscriptions,
|
||||
type: 'persist',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||
|
||||
export const projectInfoOptions: ViewOption[] = [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTimeOption, OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
|
||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||
|
||||
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
|
||||
return [
|
||||
|
||||
@@ -6,11 +6,11 @@ import { validateTimerType } from 'ontime-utils';
|
||||
import {
|
||||
getTimeOption,
|
||||
hideTimerSeconds,
|
||||
makeOptionsFromCustomFields,
|
||||
OptionTitle,
|
||||
showLeadingZeros,
|
||||
} from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||
} from '../../common/components/view-params-editor/common.options';
|
||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
||||
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
|
||||
|
||||
// manually match the properties of TimerType excluding the None
|
||||
|
||||
Reference in New Issue
Block a user