refactor(CustomFields): Implement UI-only reordering with Save button

This commit refactors the custom field ordering functionality to support
UI-only reordering with an explicit "Save Order" button. This replaces
the previous approach where each move operation updated the backend.

Changes include:

- **Frontend State Management:**
    - `CustomFields.tsx` now manages a local state (`displayedFields`) for the order of custom fields.
    - Reordering operations ("Move Up"/"Move Down") update this local state directly without immediate backend calls.
- **"Save Order" Button:**
    - A "Save Order" button has been added to the UI.
    - This button is enabled only when changes to the order have been made locally.
    - Clicking "Save Order" triggers API calls to persist the new order of all changed fields to the backend.
- **API Calls:**
    - `editCustomField` is now called in a batch when "Save Order" is pressed, only for fields whose order has effectively changed.
- **Local Order Integrity:**
    - The `onMove` handler now ensures that the local `order` properties are dense and sequential (0, 1, 2...) before saving.
- **Testing Plan:** Updated manual testing steps to reflect the new UI flow.

This approach improves UX by making reordering feel instantaneous and reduces the number of API calls compared to updating on every move.
This commit is contained in:
google-labs-jules[bot]
2025-07-04 17:28:29 +00:00
parent 5600235ba1
commit 342533e128
9 changed files with 224 additions and 60 deletions
@@ -1,47 +1,51 @@
import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
import { CustomField, ErrorResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import express from 'express';
import type { Request, Response } from 'express';
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
import { getProjectCustomFields, CustomFieldWithKey } from '../rundown/rundown.dao.js';
import { createCustomField, editCustomField, deleteCustomField } from '../rundown/rundown.service.js';
import { validateCustomField, validateDeleteCustomField, validateEditCustomField } from './customFields.validation.js';
export const router = express.Router();
router.get('/', async (_req: Request, res: Response<CustomFields>) => {
const customFields = getProjectCustomFields();
res.status(200).json(customFields);
// Response type changed to CustomFieldWithKey[]
router.get('/', async (_req: Request, res: Response<CustomFieldWithKey[]>) => {
const customFieldsArray = getProjectCustomFields();
res.status(200).json(customFieldsArray);
});
router.post('/', validateCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
// The POST, PUT, DELETE operations in this router return the entire updated CustomFields object.
// This will also need to change to return the sorted array if we want consistency.
router.post('/', validateCustomField, async (req: Request, res: Response<CustomFieldWithKey[] | ErrorResponse>) => {
try {
const newFields = await createCustomField(req.body as CustomField);
res.status(201).send(newFields);
// req.body will include label, type, colour, and optionally order
const newFieldsArray = await createCustomField(req.body as CustomField);
res.status(201).send(newFieldsArray);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.put('/:key', validateEditCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
router.put('/:key', validateEditCustomField, async (req: Request, res: Response<CustomFieldWithKey[] | ErrorResponse>) => {
try {
const currentKey = req.params.key;
const { colour, type, label } = req.body;
const newFields = await editCustomField(currentKey, { label, colour, type });
res.status(200).send(newFields);
const { colour, type, label, order } = req.body; // order is now included
const newFieldsArray = await editCustomField(currentKey, { label, colour, type, order });
res.status(200).send(newFieldsArray);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFieldWithKey[] | ErrorResponse>) => {
try {
const customFields = await deleteCustomField(req.params.key);
res.status(200).send(customFields);
const customFieldsArray = await deleteCustomField(req.params.key);
res.status(200).send(customFieldsArray);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
@@ -13,6 +13,7 @@ export const validateCustomField = [
}),
body('type').isIn(['string', 'image']),
body('colour').isString().trim(),
body('order').optional().isNumeric().toInt(),
requestValidationFunction,
];
@@ -28,6 +29,7 @@ export const validateEditCustomField = [
}),
body('type').isIn(['string', 'image']),
body('colour').isString().trim(),
body('order').optional().isNumeric().toInt(),
requestValidationFunction,
];
@@ -82,7 +82,21 @@ let projectCustomFields: CustomFields = {};
export const getCurrentRundown = (): Readonly<Rundown> => cachedRundown;
export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetadata;
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
export type CustomFieldWithKey = CustomField & { key: CustomFieldKey };
export const getProjectCustomFields = (): ReadonlyArray<CustomFieldWithKey> => {
return Object.entries(projectCustomFields)
.map(([key, value]): CustomFieldWithKey => ({ key, ...value }))
.sort((a, b) => {
const orderA = a.order ?? Infinity; // Fields without order go last
const orderB = b.order ?? Infinity;
if (orderA === orderB) {
// Fallback sort by key if order is the same or both undefined
return a.key.localeCompare(b.key);
}
return orderA - orderB;
});
};
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
type Transaction = {
@@ -563,9 +577,12 @@ function customFieldAdd(customFields: CustomFields, key: CustomFieldKey, newCust
label: newCustomField.label,
type: newCustomField.type,
colour: newCustomField.colour,
order: newCustomField.order, // Add order here
};
return { [key]: newCustomField };
// The return value of this function doesn't seem to be critically used for its content,
// but to be safe, let's include order here too.
return { [key]: { ...newCustomField } };
}
/**
@@ -19,7 +19,7 @@ import { customFieldLabelToKey } from 'ontime-utils';
import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
import { createTransaction, customFieldMutation, rundownCache, rundownMutation } from './rundown.dao.js';
import { createTransaction, customFieldMutation, rundownCache, rundownMutation, getProjectCustomFields, CustomFieldWithKey } from './rundown.dao.js';
import type { RundownMetadata } from './rundown.types.js';
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
@@ -412,7 +412,7 @@ export async function ungroupEntries(blockId: EntryId): Promise<Rundown> {
* Adds a new custom field to the project
* @throws if the label is missing or invalid
*/
export async function createCustomField(customField: CustomField): Promise<CustomFields> {
export async function createCustomField(customField: CustomField): Promise<ReadonlyArray<CustomFieldWithKey>> {
const key = customFieldLabelToKey(customField.label);
if (!key) {
@@ -426,16 +426,27 @@ export async function createCustomField(customField: CustomField): Promise<Custo
throw new Error('Label already exists');
}
// Assign order if not provided
if (customField.order === undefined) {
let maxOrder = -1;
Object.values(customFields).forEach(cf => {
if (cf.order !== undefined && cf.order > maxOrder) {
maxOrder = cf.order;
}
});
customField.order = maxOrder + 1;
}
customFieldMutation.add(customFields, key, customField);
// Adding a custom field has no immediate implications on the rundown
const { customFields: resultCustomFields } = commit(false);
commit(false); // Persist changes
setImmediate(() => {
sendRefetch(RefetchKey.CustomFields);
});
return resultCustomFields;
return getProjectCustomFields(); // Return sorted array
}
/**
@@ -446,7 +457,7 @@ export async function createCustomField(customField: CustomField): Promise<Custo
* @throws if the label is missing or invalid
* @throws if the new label already exists
*/
export async function editCustomField(key: CustomFieldKey, newField: Partial<CustomField>): Promise<CustomFields> {
export async function editCustomField(key: CustomFieldKey, newField: Partial<CustomField>): Promise<ReadonlyArray<CustomFieldWithKey>> {
const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({
mutableRundown: true,
mutableCustomFields: true,
@@ -470,26 +481,27 @@ export async function editCustomField(key: CustomFieldKey, newField: Partial<Cus
}
// the custom fields have been removed and there is no processing to be done
const { rundownMetadata, revision, customFields: resultCustomFields } = commit(false);
const { rundownMetadata, revision } = commit(false); // Persist changes
// schedule the side effects
setImmediate(() => {
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
sendRefetch(RefetchKey.CustomFields); // Also refetch custom fields explicitly
});
return resultCustomFields;
return getProjectCustomFields(); // Return sorted array
}
/**
* Deletes an existing custom field
*/
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
export async function deleteCustomField(key: CustomFieldKey): Promise<ReadonlyArray<CustomFieldWithKey>> {
const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({
mutableRundown: true,
mutableCustomFields: true,
});
if (!(key in customFields)) {
return customFields;
return getProjectCustomFields(); // Return sorted array even if key not found
}
customFieldMutation.remove(customFields, key);
@@ -498,14 +510,15 @@ export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFiel
}
// the custom fields have been removed and there is no processing to be done
const { rundownMetadata, revision, customFields: resultCustomFields } = commit(false);
const { rundownMetadata, revision } = commit(false); // Persist changes
// schedule the side effects
setImmediate(() => {
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
sendRefetch(RefetchKey.CustomFields); // Also refetch custom fields explicitly
});
return resultCustomFields;
return getProjectCustomFields(); // Return sorted array
}
/**