|
persistPreset({ ...preset, enabled: checked })}
+ onCheckedChange={(enabled) => persistPreset({ ...preset, enabled })}
disabled={isMutating}
+ aria-label='Toggle preset enabled'
/>
|
persistPreset({ ...preset, displayInNav: checked })}
- disabled={isMutating}
+ disabled={isMutating || isCuesheet}
/>
|
diff --git a/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx b/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx
index faa264e31..2d87612fc 100644
--- a/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx
+++ b/apps/client/src/features/app-settings/panel/feature-panel/composite/URLPresetForm.tsx
@@ -1,5 +1,5 @@
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
@@ -12,6 +12,7 @@ import { preventEscape } from '../../../../../common/utils/keyEvent';
import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
+import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from './URLPresetForm.module.scss';
@@ -62,12 +63,42 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
});
const urlRef = useRef(null);
+ // Cuesheet read/write permissions live outside react-hook-form
+ const initialPermissions = useRef({
+ read: urlPreset?.options?.read ?? 'full',
+ write: urlPreset?.options?.write ?? 'full',
+ });
+ const [cuesheetPermissions, setCuesheetPermissions] = useState(initialPermissions.current);
+
+ // update initial permissions on mount
+ useEffect(() => {
+ initialPermissions.current = {
+ read: urlPreset?.options?.read ?? 'full',
+ write: urlPreset?.options?.write ?? 'full',
+ };
+ setCuesheetPermissions(initialPermissions.current);
+ // oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps -- run on mount
+ }, []);
+
+ const isEditingCuesheet = urlPreset && urlPreset.target === OntimeView.Cuesheet;
+ const isCuesheet = watch('target') === OntimeView.Cuesheet;
+ const permissionsDirty =
+ isCuesheet &&
+ (cuesheetPermissions.read !== initialPermissions.current.read ||
+ cuesheetPermissions.write !== initialPermissions.current.write);
+ const noReadAccess = isCuesheet && cuesheetPermissions.read === '-';
+
const setupSubmit = async (data: URLPreset) => {
try {
+ // Preserve / apply cuesheet permissions, which are not part of the form fields
+ const payload: URLPreset =
+ data.target === OntimeView.Cuesheet
+ ? { ...data, target: OntimeView.Cuesheet, options: cuesheetPermissions }
+ : data;
if (urlPreset) {
- await updatePreset(urlPreset.alias, data);
+ await updatePreset(urlPreset.alias, payload);
} else {
- await addPreset(data);
+ await addPreset(payload);
}
onClose();
} catch (error) {
@@ -75,6 +106,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
}
};
+ // focus on alias when the form opens
useEffect(() => {
setFocus('alias');
}, [setFocus]);
@@ -127,44 +159,66 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
{errors.alias?.message && {errors.alias.message}}
-
- {enDash} or {enDash}
-
- 2. Choose a view and its parameters
-
- Target
-
-
- Parameters
-
- {errors.search?.message}
-
+ {!isEditingCuesheet && (
+ <>
+
+ {enDash} or {enDash}
+
+ 2. Choose a view and its parameters
+
+ Target
+
+
+ Parameters
+
+ {errors.search?.message}
+
+ >
+ )}
+
+ {isCuesheet && (
+
+ )}
{errors.root?.message}
-
diff --git a/apps/client/src/features/sharing/GenerateLinkForm.tsx b/apps/client/src/features/sharing/GenerateLinkForm.tsx
index f6fa07f23..6e4a08dd5 100644
--- a/apps/client/src/features/sharing/GenerateLinkForm.tsx
+++ b/apps/client/src/features/sharing/GenerateLinkForm.tsx
@@ -1,6 +1,6 @@
import { OntimeView, URLPreset } from 'ontime-types';
import { generateId } from 'ontime-utils';
-import { useRef, useState } from 'react';
+import { useCallback, useRef, useState } from 'react';
import { FieldErrors, useForm } from 'react-hook-form';
import { generateUrl } from '../../common/api/session';
@@ -18,7 +18,7 @@ import { preventEscape } from '../../common/utils/keyEvent';
import { isUrlSafe } from '../../common/utils/regex';
import { isOntimeCloud, serverURL } from '../../externals';
import * as Panel from '../app-settings/panel-utils/PanelUtils';
-import CuesheetLinkOptions from './composite/CuesheetLinkOptions';
+import CuesheetLinkOptions, { CuesheetPermissionValues } from './composite/CuesheetLinkOptions';
import style from './GenerateLinkForm.module.scss';
@@ -53,12 +53,26 @@ type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) {
const [formState, setFormState] = useState ('pending');
- const [url, setUrl] = useState(serverURL);
- const cuesheetReadRef = useRef(null);
- const cuesheetWriteRef = useRef(null);
+ const [url, setUrl] = useState('');
+ const [cuesheetPermissions, setCuesheetPermissions] = useState({
+ read: 'full',
+ write: 'full',
+ });
const generatedAlias = useRef(`cuesheet-${generateId()}`);
- const { addPreset } = useUpdateUrlPreset();
+ const { addPreset, updatePreset } = useUpdateUrlPreset();
+ // Tracks the alias we already created this session so re-generating updates rather than duplicates it
+ const createdAlias = useRef(null);
+
+ /**
+ * Permissions live outside react-hook-form, so we reset a successful state manually
+ * whenever they change - this re-arms the "Create share link" button as the previous
+ * link no longer reflects the selected permissions.
+ */
+ const handlePermissionsChange = useCallback((permissions: CuesheetPermissionValues) => {
+ setCuesheetPermissions(permissions);
+ setFormState((current) => (current === 'success' ? 'pending' : current));
+ }, []);
const {
handleSubmit,
@@ -90,7 +104,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
if (options.read === '-') {
throw new Error('Cannot create a share with no read permissions');
}
- const presets = await addPreset({
+ const payload = {
target: OntimeView.Cuesheet,
enabled: true,
alias,
@@ -100,7 +114,10 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
read: options.read,
write: options.write,
},
- });
+ } as const;
+ // Re-generating with the same name updates the existing preset instead of failing on a duplicate alias
+ const presets = createdAlias.current === alias ? await updatePreset(alias, payload) : await addPreset(payload);
+ createdAlias.current = alias;
return presets.find((preset) => preset.alias === alias);
};
@@ -109,8 +126,8 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
setFormState('loading');
if (options.path === OntimeView.Cuesheet) {
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
- read: cuesheetReadRef.current?.value ?? 'full',
- write: cuesheetWriteRef.current?.value ?? 'full',
+ read: cuesheetPermissions.read,
+ write: cuesheetPermissions.write,
});
if (!urlPreset) {
@@ -158,6 +175,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
}
};
+ const noReadAccess = watch('path') === OntimeView.Cuesheet && cuesheetPermissions.read === '-';
const canSubmit = isDirty || formState !== 'success';
return (
@@ -221,7 +239,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
})}
/>
-
+
>
)}
@@ -269,18 +287,29 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
{errors.root?.message}
-
+
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
Share this link
-
-
- {url}
-
- Copy link
+ {url ? (
+ <>
+
+
+ {url}
+
+ Copy link
+ >
+ ) : (
+ Your link will appear here once you create it.
+ )}
diff --git a/apps/client/src/features/sharing/composite/CuesheetLinkOptions.tsx b/apps/client/src/features/sharing/composite/CuesheetLinkOptions.tsx
index ac83f34e2..d24a1b899 100644
--- a/apps/client/src/features/sharing/composite/CuesheetLinkOptions.tsx
+++ b/apps/client/src/features/sharing/composite/CuesheetLinkOptions.tsx
@@ -1,4 +1,4 @@
-import { Fragment, RefObject, useMemo, useState } from 'react';
+import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import RadioGroup from '../../../common/components/radio-group/RadioGroup';
import Switch from '../../../common/components/switch/Switch';
@@ -10,118 +10,141 @@ import style from './CuesheetLinkOptions.module.scss';
type AccessMode = 'full' | 'custom';
-interface CuesheetLinkOptionsProps {
- readRef?: RefObject;
- writeRef?: RefObject;
+export interface CuesheetPermissionValues {
+ read: string;
+ write: string;
}
-export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkOptionsProps) {
+interface CuesheetLinkOptionsProps {
+ /** Existing read permission to seed the form with ('full' | '-' | comma separated keys) */
+ initialRead?: string;
+ /** Existing write permission to seed the form with ('full' | '-' | comma separated keys) */
+ initialWrite?: string;
+ /** Notifies the parent whenever the resolved read/write permissions change */
+ onChange: (permissions: CuesheetPermissionValues) => void;
+}
+
+/** A null result means "full or unset" - there is no explicit per-column selection to seed */
+function parseKeys(permission: string | undefined): Set | null {
+ if (permission == null || permission === 'full') {
+ return null;
+ }
+ if (permission === '-') {
+ return new Set();
+ }
+ return new Set(permission.split(','));
+}
+
+function modeFromPermission(permission: string | undefined): AccessMode {
+ return permission == null || permission === 'full' ? 'full' : 'custom';
+}
+
+export default function CuesheetLinkOptions({ initialRead, initialWrite, onChange }: CuesheetLinkOptionsProps) {
const { data } = useCustomFields();
const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
+ const allColumns = useMemo(() => [...cuesheetDefaultColumns, ...customFieldColumns], [customFieldColumns]);
- const [readPermissions, setReadPermissions] = useState('full');
- const [writePermissions, setWritePermissions] = useState('full');
+ // Parsed seed values - stable for the lifetime of a given preset
+ const initialReadKeys = useMemo(() => parseKeys(initialRead), [initialRead]);
+ const initialWriteKeys = useMemo(() => parseKeys(initialWrite), [initialWrite]);
- const [readSwitches, setReadSwitches] = useState>(() => {
- const initialState: Record = {};
- [...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
- initialState[column.value] = true;
+ const [readPermissions, setReadPermissions] = useState(() => modeFromPermission(initialRead));
+ const [writePermissions, setWritePermissions] = useState(() => modeFromPermission(initialWrite));
+
+ // Default for a column we have not seen yet: honour the seed in custom mode, otherwise grant access
+ const defaultRead = useCallback(
+ (key: string) => (initialReadKeys ? initialReadKeys.has(key) : true),
+ [initialReadKeys],
+ );
+ const defaultWrite = useCallback(
+ (key: string) => (initialWriteKeys ? initialWriteKeys.has(key) : true),
+ [initialWriteKeys],
+ );
+
+ const [readSwitches, setReadSwitches] = useState>({});
+ const [writeSwitches, setWriteSwitches] = useState>({});
+
+ // Custom fields load asynchronously, so reconcile the switch maps whenever the column list grows.
+ // Newly seen columns are seeded from the initial values (or default to on for a fresh link).
+ useEffect(() => {
+ setReadSwitches((prev) => {
+ const next = { ...prev };
+ for (const column of allColumns) {
+ if (!(column.value in next)) next[column.value] = defaultRead(column.value);
+ }
+ return next;
});
- return initialState;
- });
-
- const [writeSwitches, setWriteSwitches] = useState>(() => {
- const initialState: Record = {};
- [...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
- initialState[column.value] = true;
+ setWriteSwitches((prev) => {
+ const next = { ...prev };
+ for (const column of allColumns) {
+ if (!(column.value in next)) next[column.value] = defaultWrite(column.value);
+ }
+ return next;
});
- return initialState;
- });
+ }, [allColumns, defaultRead, defaultWrite]);
+
+ const isReadOn = (key: string) => readSwitches[key] ?? defaultRead(key);
+ const isWriteOn = (key: string) => writeSwitches[key] ?? defaultWrite(key);
const handleReadModeChange = (value: AccessMode) => {
setReadPermissions(value);
-
- setReadSwitches((prevReadSwitches) => {
- const updatedReadSwitches = { ...prevReadSwitches };
- Object.keys(updatedReadSwitches).forEach((key) => {
- updatedReadSwitches[key] = true;
- });
- return updatedReadSwitches;
- });
};
const handleWriteModeChange = (value: AccessMode) => {
+ setWritePermissions(value);
+ // Full write implies full read
if (value === 'full') {
setReadPermissions('full');
}
- setWritePermissions(value);
-
- setReadSwitches((prevReadSwitches) => {
- const updatedReadSwitches = { ...prevReadSwitches };
- setWriteSwitches((prevWriteSwitches) => {
- const updatedWriteSwitches = { ...prevWriteSwitches };
- [...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
- updatedReadSwitches[column.value] = true;
- updatedWriteSwitches[column.value] = true;
- });
- return updatedWriteSwitches;
- });
- return updatedReadSwitches;
- });
};
- const handleSwitchChange = (key: string, type: 'read' | 'write', value: boolean) => {
- if (type === 'read') {
- setReadSwitches((prevReadSwitches) => {
- const updatedReadSwitches = { ...prevReadSwitches, [key]: value };
- return updatedReadSwitches;
- });
- } else {
- setWriteSwitches((prevWriteSwitches) => {
- const updatedWriteSwitches = { ...prevWriteSwitches, [key]: value };
- return updatedWriteSwitches;
- });
+ const handleReadSwitch = (key: string, value: boolean) => {
+ setReadSwitches((prev) => ({ ...prev, [key]: value }));
+ // A column the recipient cannot read cannot be written either
+ if (!value) {
+ setWriteSwitches((prev) => ({ ...prev, [key]: false }));
}
};
- const getReadPermissions = () => {
+ const handleWriteSwitch = (key: string, value: boolean) => {
+ setWriteSwitches((prev) => ({ ...prev, [key]: value }));
+ // Granting write access requires read access
+ if (value) {
+ setReadSwitches((prev) => ({ ...prev, [key]: true }));
+ }
+ };
+
+ const resolvedRead = useMemo(() => {
if (readPermissions === 'full' || writePermissions === 'full') {
return 'full';
}
+ const keys = allColumns.filter((column) => isReadOn(column.value)).map((column) => column.value);
+ return keys.length ? keys.join(',') : '-';
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [readPermissions, writePermissions, readSwitches, allColumns]);
- return Object.entries(readSwitches)
- .filter(([_, value]) => value)
- .map(([key]) => key)
- .join(',');
- };
-
- const getWritePermissions = () => {
+ const resolvedWrite = useMemo(() => {
if (writePermissions === 'full') {
return 'full';
}
+ const keys = allColumns.filter((column) => isWriteOn(column.value)).map((column) => column.value);
+ return keys.length ? keys.join(',') : '-';
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [writePermissions, writeSwitches, allColumns]);
- return Object.entries(writeSwitches)
- .filter(([_, value]) => value)
- .map(([key]) => key)
- .join(',');
- };
+ // Notify the parent of the resolved permissions. onChange is expected to be stable.
+ useEffect(() => {
+ onChange({ read: resolvedRead, write: resolvedWrite });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [resolvedRead, resolvedWrite]);
+
+ const noReadAccess = resolvedRead === '-';
return (
-
-
+ {noReadAccess && Links must contain at least one readable column.}
Ontime columns
@@ -143,14 +176,14 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
{column.label}
handleSwitchChange(column.value, 'read', value)}
+ checked={isReadOn(column.value)}
+ onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
disabled={readPermissions === 'full' || writePermissions === 'full'}
data-testid={`read-${column.value}`}
/>
handleSwitchChange(column.value, 'write', value)}
+ checked={isWriteOn(column.value)}
+ onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
disabled={writePermissions === 'full'}
data-testid={`write-${column.value}`}
/>
@@ -164,16 +197,16 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
Write
{customFieldColumns.map((column) => (
- {column.label}
+ {column.label}
handleSwitchChange(column.value, 'read', value)}
+ checked={isReadOn(column.value)}
+ onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
disabled={readPermissions === 'full' || writePermissions === 'full'}
data-testid={`read-${column.value}`}
/>
handleSwitchChange(column.value, 'write', value)}
+ checked={isWriteOn(column.value)}
+ onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
disabled={writePermissions === 'full'}
data-testid={`write-${column.value}`}
/>
|