mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-01 20:39:18 +00:00
feat: add custom field type of image
This commit is contained in:
committed by
Carlos Valente
parent
d0b4c6a279
commit
570ff59cfc
@@ -0,0 +1,45 @@
|
|||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,11 +5,17 @@ import type { ParamField } from './types';
|
|||||||
export const makeOptionsFromCustomFields = (
|
export const makeOptionsFromCustomFields = (
|
||||||
customFields: CustomFields,
|
customFields: CustomFields,
|
||||||
additionalOptions: Record<string, string> = {},
|
additionalOptions: Record<string, string> = {},
|
||||||
|
filterImageType = true,
|
||||||
) => {
|
) => {
|
||||||
return Object.entries(customFields).reduce((options, [key, value]) => {
|
const options = structuredClone(additionalOptions);
|
||||||
|
for (const [key, value] of Object.entries(customFields)) {
|
||||||
|
if (filterImageType && value.type === 'image') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
options[`custom-${key}`] = `Custom: ${value.label}`;
|
options[`custom-${key}`] = `Custom: ${value.label}`;
|
||||||
return options;
|
}
|
||||||
}, additionalOptions);
|
return options;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTimeOption = (timeFormat: string): ParamField => {
|
export const getTimeOption = (timeFormat: string): ParamField => {
|
||||||
|
|||||||
+12
-7
@@ -6,6 +6,7 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
|
|||||||
|
|
||||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||||
|
import Tag from '../../../../../common/components/tag/Tag';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import CustomFieldForm from './CustomFieldForm';
|
import CustomFieldForm from './CustomFieldForm';
|
||||||
@@ -13,19 +14,20 @@ import CustomFieldForm from './CustomFieldForm';
|
|||||||
import style from '../FeatureSettings.module.scss';
|
import style from '../FeatureSettings.module.scss';
|
||||||
|
|
||||||
interface CustomFieldEntryProps {
|
interface CustomFieldEntryProps {
|
||||||
field: string;
|
|
||||||
colour: string;
|
colour: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
fieldKey: string;
|
||||||
|
type: 'string' | 'image';
|
||||||
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
|
onEdit: (label: CustomFieldLabel, patch: CustomField) => Promise<void>;
|
||||||
onDelete: (label: CustomFieldLabel) => Promise<void>;
|
onDelete: (label: CustomFieldLabel) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||||
const { colour, label, onEdit, onDelete, field } = props;
|
const { colour, label, fieldKey, type, onEdit, onDelete } = props;
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
const handleEdit = async (patch: CustomField) => {
|
const handleEdit = async (patch: CustomField) => {
|
||||||
await onEdit(field, patch);
|
await onEdit(fieldKey, patch);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
|||||||
onSubmit={handleEdit}
|
onSubmit={handleEdit}
|
||||||
initialColour={colour}
|
initialColour={colour}
|
||||||
initialLabel={label}
|
initialLabel={label}
|
||||||
initialKey={field}
|
initialKey={fieldKey}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -50,10 +52,13 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
|||||||
<td>
|
<td>
|
||||||
<Swatch color={colour} />
|
<Swatch color={colour} />
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<Tag>{type}</Tag>
|
||||||
|
</td>
|
||||||
<td className={style.halfWidth}>{label}</td>
|
<td className={style.halfWidth}>{label}</td>
|
||||||
<td className={style.fullWidth}>
|
<td className={style.fullWidth}>
|
||||||
<CopyTag label='Copy key to use in integrations' copyValue={field}>
|
<CopyTag label='Copy key to use in integrations' copyValue={fieldKey}>
|
||||||
{field}
|
{fieldKey}
|
||||||
</CopyTag>
|
</CopyTag>
|
||||||
</td>
|
</td>
|
||||||
<Panel.InlineElements relation='inner' as='td'>
|
<Panel.InlineElements relation='inner' as='td'>
|
||||||
@@ -71,7 +76,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
|||||||
color='#FA5656' // $red-500
|
color='#FA5656' // $red-500
|
||||||
icon={<IoTrash />}
|
icon={<IoTrash />}
|
||||||
aria-label='Delete entry'
|
aria-label='Delete entry'
|
||||||
onClick={() => onDelete(field)}
|
onClick={() => onDelete(fieldKey)}
|
||||||
/>
|
/>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+31
-9
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { Button, Input } from '@chakra-ui/react';
|
import { Button, Input, Radio, RadioGroup } from '@chakra-ui/react';
|
||||||
import { CustomField } from 'ontime-types';
|
import { CustomField } from 'ontime-types';
|
||||||
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||||
|
|
||||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||||
|
import Info from '../../../../../common/components/info/Info';
|
||||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||||
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||||
@@ -20,6 +21,8 @@ interface CustomFieldsFormProps {
|
|||||||
initialKey?: string;
|
initialKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CustomFieldFormData = CustomField & { key: string };
|
||||||
|
|
||||||
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||||
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props;
|
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props;
|
||||||
const { data } = useCustomFields();
|
const { data } = useCustomFields();
|
||||||
@@ -28,6 +31,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
|||||||
const [_, setColour] = useState(initialColour || '');
|
const [_, setColour] = useState(initialColour || '');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
setFocus,
|
setFocus,
|
||||||
@@ -35,17 +39,17 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
|||||||
setValue,
|
setValue,
|
||||||
getValues,
|
getValues,
|
||||||
formState: { errors, isSubmitting, isValid, isDirty },
|
formState: { errors, isSubmitting, isValid, isDirty },
|
||||||
} = useForm({
|
} = useForm<CustomFieldFormData>({
|
||||||
defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' },
|
defaultValues: { type: 'string', label: initialLabel || '', colour: initialColour || '' },
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDirtyValues: true,
|
keepDirtyValues: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const setupSubmit = async (values: { label: string; colour: string }) => {
|
const setupSubmit = async (values: CustomFieldFormData) => {
|
||||||
const { label, colour } = values;
|
const { type, label, colour } = values;
|
||||||
const newField: CustomField = {
|
const newField: CustomField = {
|
||||||
type: 'string', // type is not user definable yet
|
type,
|
||||||
colour,
|
colour,
|
||||||
label,
|
label,
|
||||||
};
|
};
|
||||||
@@ -77,6 +81,26 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
|||||||
className={style.fieldForm}
|
className={style.fieldForm}
|
||||||
onKeyDown={(event) => preventEscape(event, onCancel)}
|
onKeyDown={(event) => preventEscape(event, onCancel)}
|
||||||
>
|
>
|
||||||
|
<Info>
|
||||||
|
Please note that images can quickly deteriorate your app's performance.
|
||||||
|
<br />
|
||||||
|
Prefer using small, and compressed images.
|
||||||
|
</Info>
|
||||||
|
<div>
|
||||||
|
<Panel.Description>Type</Panel.Description>
|
||||||
|
<Controller
|
||||||
|
name='type'
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<RadioGroup {...field} size='sm' isDisabled={isEditMode} variant='ontime'>
|
||||||
|
<Panel.InlineElements relation='component'>
|
||||||
|
<Radio value='string'>Text</Radio>
|
||||||
|
<Radio value='image'>Image</Radio>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className={style.twoCols}>
|
<div className={style.twoCols}>
|
||||||
<div>
|
<div>
|
||||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||||
@@ -105,12 +129,10 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
|||||||
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
|
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Panel.Description>Colour</Panel.Description>
|
<Panel.Description>Colour</Panel.Description>
|
||||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
<Panel.InlineElements relation='inner' align='end'>
|
<Panel.InlineElements relation='inner' align='end'>
|
||||||
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
|
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
|
||||||
|
|||||||
+4
-2
@@ -74,19 +74,21 @@ export default function CustomFields() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Colour</th>
|
<th>Colour</th>
|
||||||
|
<th>Type</th>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Key (used in Integrations)</th>
|
<th>Key (used in Integrations)</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{Object.entries(data).map(([key, { colour, label }]) => {
|
{Object.entries(data).map(([key, { colour, label, type }]) => {
|
||||||
return (
|
return (
|
||||||
<CustomFieldEntry
|
<CustomFieldEntry
|
||||||
key={key}
|
key={key}
|
||||||
field={key}
|
fieldKey={key}
|
||||||
colour={colour}
|
colour={colour}
|
||||||
label={label}
|
label={label}
|
||||||
|
type={type}
|
||||||
onEdit={handleEditField}
|
onEdit={handleEditField}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -78,3 +78,9 @@
|
|||||||
font-size: 1.25em;
|
font-size: 1.25em;
|
||||||
margin-left: 0.25em;
|
margin-left: 0.25em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.customImage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 72px;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
|||||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
import * as Editor from '../../editors/editor-utils/EditorUtils';
|
||||||
|
|
||||||
|
import EventEditorImage from './composite/EventEditorImage';
|
||||||
import EventEditorTimes from './composite/EventEditorTimes';
|
import EventEditorTimes from './composite/EventEditorTimes';
|
||||||
import EventEditorTitles from './composite/EventEditorTitles';
|
import EventEditorTitles from './composite/EventEditorTitles';
|
||||||
import EventTextArea from './composite/EventTextArea';
|
import EventTextArea from './composite/EventTextArea';
|
||||||
|
import EventTextInput from './composite/EventTextInput';
|
||||||
import EventEditorEmpty from './EventEditorEmpty';
|
import EventEditorEmpty from './EventEditorEmpty';
|
||||||
|
|
||||||
import style from './EventEditor.module.scss';
|
import style from './EventEditor.module.scss';
|
||||||
@@ -87,6 +89,7 @@ export default function EventEditor(props: EventEditorProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Editor.Title>
|
</Editor.Title>
|
||||||
|
|
||||||
{Object.keys(customFields).map((fieldKey) => {
|
{Object.keys(customFields).map((fieldKey) => {
|
||||||
const key = `${event.id}-${fieldKey}`;
|
const key = `${event.id}-${fieldKey}`;
|
||||||
const fieldName = `custom-${fieldKey}`;
|
const fieldName = `custom-${fieldKey}`;
|
||||||
@@ -94,17 +97,41 @@ export default function EventEditor(props: EventEditorProps) {
|
|||||||
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
const { backgroundColor, color } = getAccessibleColour(customFields[fieldKey].colour);
|
||||||
const labelText = customFields[fieldKey].label;
|
const labelText = customFields[fieldKey].label;
|
||||||
|
|
||||||
return (
|
if (customFields[fieldKey].type === 'string') {
|
||||||
<EventTextArea
|
return (
|
||||||
key={key}
|
<EventTextArea
|
||||||
field={fieldName}
|
key={key}
|
||||||
label={labelText}
|
field={fieldName}
|
||||||
initialValue={initialValue}
|
label={labelText}
|
||||||
submitHandler={handleSubmit}
|
initialValue={initialValue}
|
||||||
className={style.decorated}
|
submitHandler={handleSubmit}
|
||||||
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
className={style.decorated}
|
||||||
/>
|
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customFields[fieldKey].type === 'image') {
|
||||||
|
return (
|
||||||
|
<div key={key} className={style.customImage}>
|
||||||
|
<EventTextInput
|
||||||
|
key={key}
|
||||||
|
field={fieldName}
|
||||||
|
label={labelText}
|
||||||
|
initialValue={initialValue}
|
||||||
|
placeholder='Paste image URL'
|
||||||
|
submitHandler={handleSubmit}
|
||||||
|
className={style.decorated}
|
||||||
|
maxLength={255}
|
||||||
|
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
|
||||||
|
/>
|
||||||
|
<EventEditorImage src={initialValue} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// we should have exhausted all types by now
|
||||||
|
return null;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.imageContainer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: $gray-1250;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageOverlay {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import style from './EventEditorImage.module.scss';
|
||||||
|
|
||||||
|
interface EventEditorImageProps {
|
||||||
|
src: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventEditorImage(props: EventEditorImageProps) {
|
||||||
|
const { src } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.imageContainer}>
|
||||||
|
<img loading='lazy' src={src} />
|
||||||
|
<div className={style.imageOverlay} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,11 +9,12 @@ interface EventTextInputProps extends InputProps {
|
|||||||
field: EditorUpdateFields;
|
field: EditorUpdateFields;
|
||||||
label: string;
|
label: string;
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
|
placeholder?: string;
|
||||||
submitHandler: (field: EditorUpdateFields, value: string) => void;
|
submitHandler: (field: EditorUpdateFields, value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventTextInput(props: EventTextInputProps) {
|
export default function EventTextInput(props: EventTextInputProps) {
|
||||||
const { field, label, initialValue, submitHandler, maxLength } = props;
|
const { className, field, label, initialValue, style: givenStyles, submitHandler, maxLength, placeholder } = props;
|
||||||
const ref = useRef<HTMLInputElement | null>(null);
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
|
||||||
|
|
||||||
@@ -23,7 +24,9 @@ export default function EventTextInput(props: EventTextInputProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Editor.Label htmlFor={field}>{label}</Editor.Label>
|
<Editor.Label className={className} htmlFor={field} style={givenStyles}>
|
||||||
|
{label}
|
||||||
|
</Editor.Label>
|
||||||
<Input
|
<Input
|
||||||
id={field}
|
id={field}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -32,6 +35,7 @@ export default function EventTextInput(props: EventTextInputProps) {
|
|||||||
data-testid='input-textfield'
|
data-testid='input-textfield'
|
||||||
value={value}
|
value={value}
|
||||||
maxLength={maxLength || 100}
|
maxLength={maxLength || 100}
|
||||||
|
placeholder={placeholder}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ $white-60: rgba(255, 255, 255, 0.60);
|
|||||||
$white-90: rgba(255, 255, 255, 0.90);
|
$white-90: rgba(255, 255, 255, 0.90);
|
||||||
|
|
||||||
$black-10: rgba(0, 0, 0, 0.10);
|
$black-10: rgba(0, 0, 0, 0.10);
|
||||||
|
$black-60: rgba(0, 0, 0, 0.60);
|
||||||
|
|
||||||
$gray-50: #f6f6f6;
|
$gray-50: #f6f6f6;
|
||||||
$gray-100: #ececec;
|
$gray-100: #ececec;
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
.imageCell {
|
||||||
|
position: relative;
|
||||||
|
min-height: 2rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.overlay {
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
background-color: $black-60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
background-color: $gray-1350;
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { Input } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import style from './EditableImage.module.scss';
|
||||||
|
|
||||||
|
interface EditableImageProps {
|
||||||
|
initialValue: string;
|
||||||
|
updateValue: (newValue: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(EditableImage);
|
||||||
|
|
||||||
|
function EditableImage(props: EditableImageProps) {
|
||||||
|
const { initialValue, updateValue } = props;
|
||||||
|
|
||||||
|
const handleUpdate = (newValue: string) => {
|
||||||
|
if (newValue === initialValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newValue !== '' && !newValue.startsWith('http')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!initialValue) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-transparent'
|
||||||
|
padding={0}
|
||||||
|
fontSize='md'
|
||||||
|
placeholder='Paste image URL'
|
||||||
|
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
handleUpdate(event.currentTarget.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
defaultValue={initialValue}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.imageCell}>
|
||||||
|
<div className={style.overlay}>
|
||||||
|
<button onClick={() => handleUpdate('')}>Delete</button>
|
||||||
|
</div>
|
||||||
|
<img loading='lazy' src={initialValue} className={style.image} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+21
-3
@@ -6,6 +6,7 @@ import { millisToString, removeSeconds } from 'ontime-utils';
|
|||||||
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
||||||
import { formatDuration } from '../../../../common/utils/time';
|
import { formatDuration } from '../../../../common/utils/time';
|
||||||
|
|
||||||
|
import EditableImage from './EditableImage';
|
||||||
import MultiLineCell from './MultiLineCell';
|
import MultiLineCell from './MultiLineCell';
|
||||||
import SingleLineCell from './SingleLineCell';
|
import SingleLineCell from './SingleLineCell';
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
@@ -105,6 +106,24 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEnt
|
|||||||
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LazyImage({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
|
const update = useCallback(
|
||||||
|
(newValue: string) => {
|
||||||
|
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
|
||||||
|
[column.id, row.index],
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = row.original;
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValue = event.custom[column.id];
|
||||||
|
return <EditableImage initialValue={initialValue} updateValue={update} />;
|
||||||
|
}
|
||||||
|
|
||||||
function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
@@ -139,7 +158,6 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialValue = event.custom[column.id] ?? '';
|
const initialValue = event.custom[column.id] ?? '';
|
||||||
|
|
||||||
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,8 +166,8 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
|||||||
accessorKey: key,
|
accessorKey: key,
|
||||||
id: key,
|
id: key,
|
||||||
header: customFields[key].label,
|
header: customFields[key].label,
|
||||||
meta: { colour: customFields[key].colour },
|
meta: { colour: customFields[key].colour, type: customFields[key].type },
|
||||||
cell: MakeCustomField,
|
cell: customFields[key].type === 'string' ? MakeCustomField : LazyImage,
|
||||||
size: 250,
|
size: 250,
|
||||||
minSize: 75,
|
minSize: 75,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const validateCustomField = [
|
|||||||
.custom((value) => {
|
.custom((value) => {
|
||||||
return isAlphanumericWithSpace(value);
|
return isAlphanumericWithSpace(value);
|
||||||
}),
|
}),
|
||||||
body('type').exists().isString().trim(),
|
body('type').exists().isIn(['string', 'image']),
|
||||||
body('colour').exists().isString().trim(),
|
body('colour').exists().isString().trim(),
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -23,8 +23,14 @@ export const validateCustomField = [
|
|||||||
|
|
||||||
export const validateEditCustomField = [
|
export const validateEditCustomField = [
|
||||||
param('label').exists().isString().trim(),
|
param('label').exists().isString().trim(),
|
||||||
body('label').exists().isString().trim(),
|
body('label')
|
||||||
body('type').exists().isString().trim(),
|
.exists()
|
||||||
|
.isString()
|
||||||
|
.trim()
|
||||||
|
.custom((value) => {
|
||||||
|
return isAlphanumericWithSpace(value);
|
||||||
|
}),
|
||||||
|
body('type').exists().isIn(['string', 'image']),
|
||||||
body('colour').exists().isString().trim(),
|
body('colour').exists().isString().trim(),
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ describe('parseCustomFields()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('sanitiseCustomFields()', () => {
|
describe('sanitiseCustomFields()', () => {
|
||||||
it('returns an empty array if not an array', () => {
|
it('returns an empty object the type is incorrect', () => {
|
||||||
expect(sanitiseCustomFields({})).toEqual({});
|
expect(sanitiseCustomFields({})).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,16 +170,16 @@ describe('sanitiseCustomFields()', () => {
|
|||||||
expect(sanitationResult).toStrictEqual(customFields);
|
expect(sanitationResult).toStrictEqual(customFields);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('type is forced to be string', () => {
|
it('type should be one of (image | string)', () => {
|
||||||
const customFields: CustomFields = {
|
const testTypes = sanitiseCustomFields({
|
||||||
// @ts-expect-error intentional bad data
|
test1: { label: 'test1', type: 'another', colour: 'red' },
|
||||||
test: { label: 'test', type: 'another', colour: 'red' },
|
test2: { label: 'test2', type: 'image', colour: 'red' },
|
||||||
};
|
test3: { label: 'test3', type: 'string', colour: 'red' },
|
||||||
const expectedCustomFields: CustomFields = {
|
});
|
||||||
test: { label: 'test', type: 'string', colour: 'red' },
|
expect(testTypes).toMatchObject({
|
||||||
};
|
test2: { label: 'test2', type: 'image', colour: 'red' },
|
||||||
const sanitationResult = sanitiseCustomFields(customFields);
|
test3: { label: 'test3', type: 'string', colour: 'red' },
|
||||||
expect(sanitationResult).toStrictEqual(expectedCustomFields);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('colour must be a string', () => {
|
it('colour must be a string', () => {
|
||||||
|
|||||||
@@ -217,15 +217,15 @@ export function sanitiseCustomFields(data: object): CustomFields {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const keyFromLabel = customFieldLabelToKey(field.label);
|
const keyFromLabel = customFieldLabelToKey(field.label);
|
||||||
//Test label and key cohesion, but allow old lowercased keys to stay
|
// Test label and key cohesion, but allow old lowercased keys to stay
|
||||||
//TODO: the `toLocaleLowerCase` part here is to conserve keys from old projects and could be removed at some point (okt. 2024)
|
// TODO: the `toLocaleLowerCase` part here is to conserve keys from old projects and could be removed at some point (okt. 2024)
|
||||||
const key = originalKey.toLocaleLowerCase() === keyFromLabel.toLocaleLowerCase() ? originalKey : keyFromLabel;
|
const key = originalKey.toLocaleLowerCase() === keyFromLabel.toLocaleLowerCase() ? originalKey : keyFromLabel;
|
||||||
if (key in newCustomFields) {
|
if (key in newCustomFields) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
newCustomFields[key] = {
|
newCustomFields[key] = {
|
||||||
type: 'string',
|
type: field.type,
|
||||||
colour: field.colour,
|
colour: field.colour,
|
||||||
label: field.label,
|
label: field.label,
|
||||||
};
|
};
|
||||||
@@ -238,7 +238,9 @@ export function sanitiseCustomFields(data: object): CustomFields {
|
|||||||
'label' in data &&
|
'label' in data &&
|
||||||
data.label !== '' &&
|
data.label !== '' &&
|
||||||
'colour' in data &&
|
'colour' in data &&
|
||||||
typeof data.colour === 'string'
|
typeof data.colour === 'string' &&
|
||||||
|
'type' in data &&
|
||||||
|
(data.type === 'string' || data.type === 'image')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export type CustomFieldLabel = string;
|
export type CustomFieldLabel = string;
|
||||||
|
|
||||||
export type CustomField = {
|
export type CustomField = {
|
||||||
type: 'string';
|
type: 'string' | 'image';
|
||||||
colour: string;
|
colour: string;
|
||||||
label: CustomFieldLabel;
|
label: CustomFieldLabel;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user