mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-04 13:59:09 +00:00
allow editing cuesheet link permissions
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { URLPreset } from 'ontime-types';
|
import { OntimeView, URLPreset } from 'ontime-types';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
|
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
|
||||||
|
|
||||||
@@ -78,20 +78,22 @@ export default function URLPresets() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
|
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
|
||||||
{data.map((preset, index) => {
|
{data.map((preset, index) => {
|
||||||
|
const isCuesheet = preset.target === OntimeView.Cuesheet;
|
||||||
return (
|
return (
|
||||||
<tr key={preset.alias}>
|
<tr key={preset.alias}>
|
||||||
<td>
|
<td>
|
||||||
<Switch
|
<Switch
|
||||||
checked={preset.enabled}
|
checked={preset.enabled}
|
||||||
onCheckedChange={(checked) => persistPreset({ ...preset, enabled: checked })}
|
onCheckedChange={(enabled) => persistPreset({ ...preset, enabled })}
|
||||||
disabled={isMutating}
|
disabled={isMutating}
|
||||||
|
aria-label='Toggle preset enabled'
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Switch
|
<Switch
|
||||||
checked={preset.displayInNav}
|
checked={preset.displayInNav}
|
||||||
onCheckedChange={(checked) => persistPreset({ ...preset, displayInNav: checked })}
|
onCheckedChange={(checked) => persistPreset({ ...preset, displayInNav: checked })}
|
||||||
disabled={isMutating}
|
disabled={isMutating || isCuesheet}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
+87
-33
@@ -1,5 +1,5 @@
|
|||||||
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
|
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 { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
|
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
|
||||||
@@ -12,6 +12,7 @@ import { preventEscape } from '../../../../../common/utils/keyEvent';
|
|||||||
import { isUrlSafe } from '../../../../../common/utils/regex';
|
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||||
import { enDash } from '../../../../../common/utils/styleUtils';
|
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||||
|
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './URLPresetForm.module.scss';
|
import style from './URLPresetForm.module.scss';
|
||||||
@@ -62,12 +63,42 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
|||||||
});
|
});
|
||||||
const urlRef = useRef<HTMLInputElement>(null);
|
const urlRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Cuesheet read/write permissions live outside react-hook-form
|
||||||
|
const initialPermissions = useRef<CuesheetPermissionValues>({
|
||||||
|
read: urlPreset?.options?.read ?? 'full',
|
||||||
|
write: urlPreset?.options?.write ?? 'full',
|
||||||
|
});
|
||||||
|
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>(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) => {
|
const setupSubmit = async (data: URLPreset) => {
|
||||||
try {
|
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) {
|
if (urlPreset) {
|
||||||
await updatePreset(urlPreset.alias, data);
|
await updatePreset(urlPreset.alias, payload);
|
||||||
} else {
|
} else {
|
||||||
await addPreset(data);
|
await addPreset(payload);
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -75,6 +106,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// focus on alias when the form opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFocus('alias');
|
setFocus('alias');
|
||||||
}, [setFocus]);
|
}, [setFocus]);
|
||||||
@@ -127,44 +159,66 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
|||||||
<div className={style.expand}>
|
<div className={style.expand}>
|
||||||
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||||
<Panel.InlineElements>
|
<Panel.InlineElements>
|
||||||
<Input placeholder='Paste URL' fluid ref={urlRef} />
|
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
|
||||||
<Button onClick={generateOptions}>Generate</Button>
|
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</div>
|
</div>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
|
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
|
||||||
<div>
|
{!isEditingCuesheet && (
|
||||||
{enDash} or {enDash}
|
<>
|
||||||
</div>
|
<div>
|
||||||
<div>2. Choose a view and its parameters</div>
|
{enDash} or {enDash}
|
||||||
<div>
|
</div>
|
||||||
<Panel.Description>Target</Panel.Description>
|
<div>2. Choose a view and its parameters</div>
|
||||||
<Select
|
<div>
|
||||||
options={targetOptions}
|
<Panel.Description>Target</Panel.Description>
|
||||||
{...register('target', { required: 'Target is required' })}
|
<Select
|
||||||
value={watch('target')}
|
options={targetOptions}
|
||||||
onValueChange={(value: OntimeViewPresettable | null) => {
|
{...register('target', { required: 'Target is required' })}
|
||||||
if (value === null) return;
|
value={watch('target')}
|
||||||
setValue('target', value, { shouldDirty: true });
|
onValueChange={(value: OntimeViewPresettable | null) => {
|
||||||
}}
|
if (value === null) return;
|
||||||
/>
|
setValue('target', value, { shouldDirty: true });
|
||||||
</div>
|
}}
|
||||||
<div>
|
/>
|
||||||
<Panel.Description>Parameters</Panel.Description>
|
</div>
|
||||||
<Textarea
|
<div>
|
||||||
fluid
|
<Panel.Description>Parameters</Panel.Description>
|
||||||
rows={3}
|
<Textarea
|
||||||
{...register('search', {
|
fluid
|
||||||
validate: validateParams,
|
rows={3}
|
||||||
})}
|
{...register('search', {
|
||||||
/>
|
validate: validateParams,
|
||||||
<Panel.Error>{errors.search?.message}</Panel.Error>
|
})}
|
||||||
</div>
|
/>
|
||||||
|
<Panel.Error>{errors.search?.message}</Panel.Error>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCuesheet && (
|
||||||
|
<div>
|
||||||
|
<Panel.Description>Permissions</Panel.Description>
|
||||||
|
<CuesheetLinkOptions
|
||||||
|
initialRead={initialPermissions.current.read}
|
||||||
|
initialWrite={initialPermissions.current.write}
|
||||||
|
onChange={setCuesheetPermissions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||||
<Panel.InlineElements align='end'>
|
<Panel.InlineElements align='end'>
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
<Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}>
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
type='submit'
|
||||||
|
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
|
||||||
|
loading={isSubmitting || isMutating}
|
||||||
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { OntimeView, URLPreset } from 'ontime-types';
|
import { OntimeView, URLPreset } from 'ontime-types';
|
||||||
import { generateId } from 'ontime-utils';
|
import { generateId } from 'ontime-utils';
|
||||||
import { useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { FieldErrors, useForm } from 'react-hook-form';
|
import { FieldErrors, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { generateUrl } from '../../common/api/session';
|
import { generateUrl } from '../../common/api/session';
|
||||||
@@ -18,7 +18,7 @@ import { preventEscape } from '../../common/utils/keyEvent';
|
|||||||
import { isUrlSafe } from '../../common/utils/regex';
|
import { isUrlSafe } from '../../common/utils/regex';
|
||||||
import { isOntimeCloud, serverURL } from '../../externals';
|
import { isOntimeCloud, serverURL } from '../../externals';
|
||||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
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';
|
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) {
|
export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) {
|
||||||
const [formState, setFormState] = useState<GenerateLinkState>('pending');
|
const [formState, setFormState] = useState<GenerateLinkState>('pending');
|
||||||
const [url, setUrl] = useState(serverURL);
|
const [url, setUrl] = useState('');
|
||||||
const cuesheetReadRef = useRef<HTMLInputElement>(null);
|
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>({
|
||||||
const cuesheetWriteRef = useRef<HTMLInputElement>(null);
|
read: 'full',
|
||||||
|
write: 'full',
|
||||||
|
});
|
||||||
const generatedAlias = useRef<string>(`cuesheet-${generateId()}`);
|
const generatedAlias = useRef<string>(`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<string | null>(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 {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -90,7 +104,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
|||||||
if (options.read === '-') {
|
if (options.read === '-') {
|
||||||
throw new Error('Cannot create a share with no read permissions');
|
throw new Error('Cannot create a share with no read permissions');
|
||||||
}
|
}
|
||||||
const presets = await addPreset({
|
const payload = {
|
||||||
target: OntimeView.Cuesheet,
|
target: OntimeView.Cuesheet,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
alias,
|
alias,
|
||||||
@@ -100,7 +114,10 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
|||||||
read: options.read,
|
read: options.read,
|
||||||
write: options.write,
|
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);
|
return presets.find((preset) => preset.alias === alias);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,8 +126,8 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
|||||||
setFormState('loading');
|
setFormState('loading');
|
||||||
if (options.path === OntimeView.Cuesheet) {
|
if (options.path === OntimeView.Cuesheet) {
|
||||||
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
|
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
|
||||||
read: cuesheetReadRef.current?.value ?? 'full',
|
read: cuesheetPermissions.read,
|
||||||
write: cuesheetWriteRef.current?.value ?? 'full',
|
write: cuesheetPermissions.write,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!urlPreset) {
|
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';
|
const canSubmit = isDirty || formState !== 'success';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -221,7 +239,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
<CuesheetLinkOptions readRef={cuesheetReadRef} writeRef={cuesheetWriteRef} />
|
<CuesheetLinkOptions onChange={handlePermissionsChange} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -269,18 +287,29 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
|||||||
</Panel.ListGroup>
|
</Panel.ListGroup>
|
||||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||||
<Panel.InlineElements align='end' className={style.end}>
|
<Panel.InlineElements align='end' className={style.end}>
|
||||||
<Button type='submit' variant={canSubmit ? 'primary' : 'subtle'} loading={formState === 'loading'}>
|
<Button
|
||||||
|
type='submit'
|
||||||
|
variant={canSubmit ? 'primary' : 'subtle'}
|
||||||
|
loading={formState === 'loading'}
|
||||||
|
disabled={noReadAccess}
|
||||||
|
>
|
||||||
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
|
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</div>
|
</div>
|
||||||
<Panel.Section className={style.column}>
|
<Panel.Section className={style.column}>
|
||||||
<Panel.Description>Share this link</Panel.Description>
|
<Panel.Description>Share this link</Panel.Description>
|
||||||
<QRCode size={172} value={url} />
|
{url ? (
|
||||||
<div className={style.copiableLink} data-testid='copy-link'>
|
<>
|
||||||
{url}
|
<QRCode size={172} value={url} />
|
||||||
</div>
|
<div className={style.copiableLink} data-testid='copy-link'>
|
||||||
<CopyTag copyValue={url}>Copy link</CopyTag>
|
{url}
|
||||||
|
</div>
|
||||||
|
<CopyTag copyValue={url}>Copy link</CopyTag>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Panel.Description>Your link will appear here once you create it.</Panel.Description>
|
||||||
|
)}
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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 RadioGroup from '../../../common/components/radio-group/RadioGroup';
|
||||||
import Switch from '../../../common/components/switch/Switch';
|
import Switch from '../../../common/components/switch/Switch';
|
||||||
@@ -10,118 +10,141 @@ import style from './CuesheetLinkOptions.module.scss';
|
|||||||
|
|
||||||
type AccessMode = 'full' | 'custom';
|
type AccessMode = 'full' | 'custom';
|
||||||
|
|
||||||
interface CuesheetLinkOptionsProps {
|
export interface CuesheetPermissionValues {
|
||||||
readRef?: RefObject<HTMLInputElement | null>;
|
read: string;
|
||||||
writeRef?: RefObject<HTMLInputElement | null>;
|
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<string> | 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 { data } = useCustomFields();
|
||||||
const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
|
const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
|
||||||
|
const allColumns = useMemo(() => [...cuesheetDefaultColumns, ...customFieldColumns], [customFieldColumns]);
|
||||||
|
|
||||||
const [readPermissions, setReadPermissions] = useState<AccessMode>('full');
|
// Parsed seed values - stable for the lifetime of a given preset
|
||||||
const [writePermissions, setWritePermissions] = useState<AccessMode>('full');
|
const initialReadKeys = useMemo(() => parseKeys(initialRead), [initialRead]);
|
||||||
|
const initialWriteKeys = useMemo(() => parseKeys(initialWrite), [initialWrite]);
|
||||||
|
|
||||||
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>(() => {
|
const [readPermissions, setReadPermissions] = useState<AccessMode>(() => modeFromPermission(initialRead));
|
||||||
const initialState: Record<string, boolean> = {};
|
const [writePermissions, setWritePermissions] = useState<AccessMode>(() => modeFromPermission(initialWrite));
|
||||||
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
|
|
||||||
initialState[column.value] = true;
|
// 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<Record<string, boolean>>({});
|
||||||
|
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// 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;
|
setWriteSwitches((prev) => {
|
||||||
});
|
const next = { ...prev };
|
||||||
|
for (const column of allColumns) {
|
||||||
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>(() => {
|
if (!(column.value in next)) next[column.value] = defaultWrite(column.value);
|
||||||
const initialState: Record<string, boolean> = {};
|
}
|
||||||
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
|
return next;
|
||||||
initialState[column.value] = true;
|
|
||||||
});
|
});
|
||||||
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) => {
|
const handleReadModeChange = (value: AccessMode) => {
|
||||||
setReadPermissions(value);
|
setReadPermissions(value);
|
||||||
|
|
||||||
setReadSwitches((prevReadSwitches) => {
|
|
||||||
const updatedReadSwitches = { ...prevReadSwitches };
|
|
||||||
Object.keys(updatedReadSwitches).forEach((key) => {
|
|
||||||
updatedReadSwitches[key] = true;
|
|
||||||
});
|
|
||||||
return updatedReadSwitches;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWriteModeChange = (value: AccessMode) => {
|
const handleWriteModeChange = (value: AccessMode) => {
|
||||||
|
setWritePermissions(value);
|
||||||
|
// Full write implies full read
|
||||||
if (value === 'full') {
|
if (value === 'full') {
|
||||||
setReadPermissions('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) => {
|
const handleReadSwitch = (key: string, value: boolean) => {
|
||||||
if (type === 'read') {
|
setReadSwitches((prev) => ({ ...prev, [key]: value }));
|
||||||
setReadSwitches((prevReadSwitches) => {
|
// A column the recipient cannot read cannot be written either
|
||||||
const updatedReadSwitches = { ...prevReadSwitches, [key]: value };
|
if (!value) {
|
||||||
return updatedReadSwitches;
|
setWriteSwitches((prev) => ({ ...prev, [key]: false }));
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setWriteSwitches((prevWriteSwitches) => {
|
|
||||||
const updatedWriteSwitches = { ...prevWriteSwitches, [key]: value };
|
|
||||||
return updatedWriteSwitches;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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') {
|
if (readPermissions === 'full' || writePermissions === 'full') {
|
||||||
return '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)
|
const resolvedWrite = useMemo(() => {
|
||||||
.filter(([_, value]) => value)
|
|
||||||
.map(([key]) => key)
|
|
||||||
.join(',');
|
|
||||||
};
|
|
||||||
|
|
||||||
const getWritePermissions = () => {
|
|
||||||
if (writePermissions === 'full') {
|
if (writePermissions === 'full') {
|
||||||
return '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)
|
// Notify the parent of the resolved permissions. onChange is expected to be stable.
|
||||||
.filter(([_, value]) => value)
|
useEffect(() => {
|
||||||
.map(([key]) => key)
|
onChange({ read: resolvedRead, write: resolvedWrite });
|
||||||
.join(',');
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
};
|
}, [resolvedRead, resolvedWrite]);
|
||||||
|
|
||||||
|
const noReadAccess = resolvedRead === '-';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Indent>
|
<Panel.Indent>
|
||||||
<input name='read' hidden readOnly ref={readRef} value={getReadPermissions() || '-'} />
|
|
||||||
<input name='write' hidden readOnly ref={writeRef} value={getWritePermissions() || '-'} />
|
|
||||||
<div>
|
<div>
|
||||||
<Panel.Field title='Access mode' description='Which parts of the data will the link give access to' />
|
<Panel.Field title='Access mode' description='Which parts of the data the link gives access to' />
|
||||||
<div>
|
<div>
|
||||||
<RadioGroup
|
|
||||||
value={writePermissions}
|
|
||||||
onValueChange={handleWriteModeChange}
|
|
||||||
orientation='horizontal'
|
|
||||||
items={[
|
|
||||||
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
|
|
||||||
{ value: 'custom', label: 'Custom write' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={readPermissions}
|
value={readPermissions}
|
||||||
onValueChange={handleReadModeChange}
|
onValueChange={handleReadModeChange}
|
||||||
@@ -132,8 +155,18 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
|
|||||||
{ value: 'custom', label: 'Custom read' },
|
{ value: 'custom', label: 'Custom read' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<RadioGroup
|
||||||
|
value={writePermissions}
|
||||||
|
onValueChange={handleWriteModeChange}
|
||||||
|
orientation='horizontal'
|
||||||
|
items={[
|
||||||
|
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
|
||||||
|
{ value: 'custom', label: 'Custom write' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{noReadAccess && <Panel.Error>Links must contain at least one readable column.</Panel.Error>}
|
||||||
<div className={style.twoCols}>
|
<div className={style.twoCols}>
|
||||||
<div className={style.grid}>
|
<div className={style.grid}>
|
||||||
<Panel.Description>Ontime columns</Panel.Description>
|
<Panel.Description>Ontime columns</Panel.Description>
|
||||||
@@ -143,14 +176,14 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
|
|||||||
<Fragment key={column.value}>
|
<Fragment key={column.value}>
|
||||||
<div>{column.label}</div>
|
<div>{column.label}</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={Boolean(readSwitches[column.value])}
|
checked={isReadOn(column.value)}
|
||||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
|
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
|
||||||
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
||||||
data-testid={`read-${column.value}`}
|
data-testid={`read-${column.value}`}
|
||||||
/>
|
/>
|
||||||
<Switch
|
<Switch
|
||||||
checked={Boolean(writeSwitches[column.value])}
|
checked={isWriteOn(column.value)}
|
||||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
|
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
|
||||||
disabled={writePermissions === 'full'}
|
disabled={writePermissions === 'full'}
|
||||||
data-testid={`write-${column.value}`}
|
data-testid={`write-${column.value}`}
|
||||||
/>
|
/>
|
||||||
@@ -164,16 +197,16 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
|
|||||||
<Panel.Description>Write</Panel.Description>
|
<Panel.Description>Write</Panel.Description>
|
||||||
{customFieldColumns.map((column) => (
|
{customFieldColumns.map((column) => (
|
||||||
<Fragment key={column.value}>
|
<Fragment key={column.value}>
|
||||||
{column.label}
|
<div>{column.label}</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={Boolean(readSwitches[column.value])}
|
checked={isReadOn(column.value)}
|
||||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
|
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
|
||||||
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
||||||
data-testid={`read-${column.value}`}
|
data-testid={`read-${column.value}`}
|
||||||
/>
|
/>
|
||||||
<Switch
|
<Switch
|
||||||
checked={Boolean(writeSwitches[column.value])}
|
checked={isWriteOn(column.value)}
|
||||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
|
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
|
||||||
disabled={writePermissions === 'full'}
|
disabled={writePermissions === 'full'}
|
||||||
data-testid={`write-${column.value}`}
|
data-testid={`write-${column.value}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user