From 86f34d8ffa8670ce7a48d68474cf642a4d242646 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sat, 27 Jun 2026 14:16:09 +0100 Subject: [PATCH 1/7] Fix cue and cue group display ordering on script lines (#1251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `line_position` column to `script_cue_association` to track the insertion order of individual cues and cue groups relative to each other on a line. Previously the frontend rendered individual cues first and groups second in two hardcoded loops, ignoring creation order entirely. - Backend: new `line_position` field on `CueAssociation`; POST handlers compute `max(existing) + 1` per line so each new cue/group gets the next position; GET includes `line_position` in the response - Migration: backfills existing data using `cue_id` as a creation-order proxy (individual cues get their cue_id; all members of a group share the `MIN(cue_id)` for that group) - Both clients: store getters now produce a merged sorted list; `ScriptLineViewer`, `ScriptLineViewerCompact`, and `ScriptLineCueEditor` in both Vue 3 and Vue 2 render a single loop over that list - 5 new backend tests covering I→G, G→I, G→I→G ordering, shared group position, and position stability after PATCH Co-authored-by: Claude Sonnet 4.6 --- .../show/config/cues/ScriptLineCueEditor.vue | 56 +++--- .../components/show/live/ScriptLineViewer.vue | 93 +++++---- .../show/live/ScriptLineViewerCompact.vue | 101 +++++----- client-v3/src/stores/script.ts | 35 +++- client-v3/src/types/api/cues.ts | 1 + client/src/store/modules/script.ts | 35 +++- client/src/types/api/cues.ts | 1 + .../show/config/cues/ScriptLineCueEditor.vue | 61 +++--- .../show/live/ScriptLineViewer.vue | 99 ++++----- .../show/live/ScriptLineViewerCompact.vue | 108 +++++----- ...4b_add_line_position_to_cue_association.py | 58 ++++++ server/controllers/api/v1/show/cues.py | 27 ++- server/models/cue.py | 1 + .../test/controllers/api/v1/show/test_cues.py | 188 ++++++++++++++++++ 14 files changed, 600 insertions(+), 264 deletions(-) create mode 100644 server/alembic_config/versions/d11cd9306d4b_add_line_position_to_cue_association.py diff --git a/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue b/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue index ed65da230..69871236a 100644 --- a/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue +++ b/client-v3/src/components/show/config/cues/ScriptLineCueEditor.vue @@ -22,32 +22,35 @@ isWholeLineCut(props.line, props.cuts)); -const individualCues = computed(() => props.cues.filter((c) => c.group_id == null)); -const lineGroups = computed(() => scriptStore.groupedCuesForLine(props.line.id).groups); +const mergedCueSlots = computed(() => scriptStore.groupedCuesForLine(props.line.id).merged); // RBAC-filtered cue type options: admins see all, others only see types they can write const cueTypeOptions = computed(() => { diff --git a/client-v3/src/components/show/live/ScriptLineViewer.vue b/client-v3/src/components/show/live/ScriptLineViewer.vue index 39b91d349..05e2017ac 100644 --- a/client-v3/src/components/show/live/ScriptLineViewer.vue +++ b/client-v3/src/components/show/live/ScriptLineViewer.vue @@ -130,28 +130,31 @@ - - {{ cueLabel(cue) }} - - - {{ cueGroupLabel(grp.group, grp.cues) }} - + + {{ cueLabel(slot.cue) }} + + + {{ cueGroupLabel(slot.group, slot.cues) }} + + - - {{ cueLabel(cue) }} - - - {{ cueGroupLabel(grp.group, grp.cues) }} - + + {{ cueLabel(slot.cue) }} + + + {{ cueGroupLabel(slot.group, slot.cues) }} + + props.cues.filter((c) => c.group_id == null)); -const lineGroups = computed(() => scriptStore.groupedCuesForLine(props.line.id).groups); +const mergedCueSlots = computed(() => scriptStore.groupedCuesForLine(props.line.id).merged); const lineContainer = ref(null); let observer: MutationObserver | null = null; diff --git a/client-v3/src/components/show/live/ScriptLineViewerCompact.vue b/client-v3/src/components/show/live/ScriptLineViewerCompact.vue index 74ef25fca..1a30f6938 100644 --- a/client-v3/src/components/show/live/ScriptLineViewerCompact.vue +++ b/client-v3/src/components/show/live/ScriptLineViewerCompact.vue @@ -32,52 +32,56 @@ - - - {{ cuePrefix(cue) }} - - - {{ cue.ident }} - - - - - - {{ cueGroupPrefix(grp.group) }} - - - {{ cueGroupIdentLabel(grp.group, grp.cues) }} - + + + @@ -212,8 +216,7 @@ const { cueGroupIdentLabel, } = useCueDisplay(); -const individualCues = computed(() => props.cues.filter((c) => c.group_id == null)); -const lineGroups = computed(() => scriptStore.groupedCuesForLine(props.line.id).groups); +const mergedCueSlots = computed(() => scriptStore.groupedCuesForLine(props.line.id).merged); const lineContainer = ref(null); let observer: MutationObserver | null = null; @@ -260,7 +263,7 @@ const isTaggedStageDirection = computed(() => { }); const isLineCut = computed(() => isWholeLineCut(props.line, props.cuts)); -const hasAnyCues = computed(() => individualCues.value.length > 0 || lineGroups.value.length > 0); +const hasAnyCues = computed(() => mergedCueSlots.value.length > 0); const isFirstRowActScene = computed(() => needsActSceneLabel.value); const isFirstRowCues = computed(() => !needsActSceneLabel.value && hasAnyCues.value); const isFirstRowContent = computed(() => !needsActSceneLabel.value && !hasAnyCues.value); diff --git a/client-v3/src/stores/script.ts b/client-v3/src/stores/script.ts index 3ae02b7ad..11cc42d31 100644 --- a/client-v3/src/stores/script.ts +++ b/client-v3/src/stores/script.ts @@ -43,8 +43,15 @@ export const useScriptStore = defineStore('script', { (state) => ( lineId: number | null - ): { individual: Cue[]; groups: { group: CueGroup; cues: Cue[] }[] } => { - if (lineId == null) return { individual: [], groups: [] }; + ): { + individual: Cue[]; + groups: { group: CueGroup; cues: Cue[] }[]; + merged: ( + | { type: 'individual'; cue: Cue; line_position: number | null } + | { type: 'group'; group: CueGroup; cues: Cue[]; line_position: number | null } + )[]; + } => { + if (lineId == null) return { individual: [], groups: [], merged: [] }; const allCues = state.cues[String(lineId)] ?? []; const individual = allCues.filter((c) => c.group_id == null); const groupMap = new Map(); @@ -62,7 +69,29 @@ export const useScriptStore = defineStore('script', { groups.push({ group, cues: sorted }); } } - return { individual, groups }; + const merged: ( + | { type: 'individual'; cue: Cue; line_position: number | null } + | { type: 'group'; group: CueGroup; cues: Cue[]; line_position: number | null } + )[] = [ + ...individual.map((c) => ({ + type: 'individual' as const, + cue: c, + line_position: c.line_position, + })), + ...groups.map((g) => ({ + type: 'group' as const, + group: g.group, + cues: g.cues, + line_position: g.cues[0]?.line_position ?? null, + })), + ]; + merged.sort((a, b) => { + if (a.line_position == null && b.line_position == null) return 0; + if (a.line_position == null) return 1; + if (b.line_position == null) return -1; + return a.line_position - b.line_position; + }); + return { individual, groups, merged }; }, }, diff --git a/client-v3/src/types/api/cues.ts b/client-v3/src/types/api/cues.ts index b2dbdb003..fe069ab18 100644 --- a/client-v3/src/types/api/cues.ts +++ b/client-v3/src/types/api/cues.ts @@ -18,4 +18,5 @@ export interface Cue { ident: string | null; group_id: number | null; sort_order: number | null; + line_position: number | null; } diff --git a/client/src/store/modules/script.ts b/client/src/store/modules/script.ts index 3196141af..97cef7642 100644 --- a/client/src/store/modules/script.ts +++ b/client/src/store/modules/script.ts @@ -436,8 +436,15 @@ const module: Module = { (state: ScriptState) => ( lineId: number | null - ): { individual: Cue[]; groups: { group: CueGroup; cues: Cue[] }[] } => { - if (lineId == null) return { individual: [], groups: [] }; + ): { + individual: Cue[]; + groups: { group: CueGroup; cues: Cue[] }[]; + merged: ( + | { type: 'individual'; cue: Cue; line_position: number | null } + | { type: 'group'; group: CueGroup; cues: Cue[]; line_position: number | null } + )[]; + } => { + if (lineId == null) return { individual: [], groups: [], merged: [] }; const allCues = state.cues[String(lineId)] ?? []; const individual = allCues.filter((c) => c.group_id == null); const groupMap = new Map(); @@ -455,7 +462,29 @@ const module: Module = { groups.push({ group, cues: sorted }); } } - return { individual, groups }; + const merged: ( + | { type: 'individual'; cue: Cue; line_position: number | null } + | { type: 'group'; group: CueGroup; cues: Cue[]; line_position: number | null } + )[] = [ + ...individual.map((c) => ({ + type: 'individual' as const, + cue: c, + line_position: c.line_position, + })), + ...groups.map((g) => ({ + type: 'group' as const, + group: g.group, + cues: g.cues, + line_position: g.cues[0]?.line_position ?? null, + })), + ]; + merged.sort((a, b) => { + if (a.line_position == null && b.line_position == null) return 0; + if (a.line_position == null) return 1; + if (b.line_position == null) return -1; + return a.line_position - b.line_position; + }); + return { individual, groups, merged }; }, SCRIPT_CUTS(state: ScriptState) { return state.cuts; diff --git a/client/src/types/api/cues.ts b/client/src/types/api/cues.ts index b2dbdb003..fe069ab18 100644 --- a/client/src/types/api/cues.ts +++ b/client/src/types/api/cues.ts @@ -18,4 +18,5 @@ export interface Cue { ident: string | null; group_id: number | null; sort_order: number | null; + line_position: number | null; } diff --git a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue index e7c244e09..3d1beaea5 100644 --- a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue +++ b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue @@ -12,32 +12,34 @@ > - - {{ cueLabel(cue) }} - - - {{ cueGroupLabel(grp.group, grp.cues) }} - + c.group_id == null); - }, - lineGroups(): { group: any; cues: any[] }[] { - return (this as any).GROUPED_CUES_FOR_LINE((this.line as any).id).groups; + mergedCueSlots(): any[] { + return (this as any).GROUPED_CUES_FOR_LINE((this.line as any).id).merged; }, cueTypeOptions(): unknown[] { if ((this as any).IS_ADMIN_USER) { diff --git a/client/src/vue_components/show/live/ScriptLineViewer.vue b/client/src/vue_components/show/live/ScriptLineViewer.vue index f0a312349..e8a4ac4d5 100644 --- a/client/src/vue_components/show/live/ScriptLineViewer.vue +++ b/client/src/vue_components/show/live/ScriptLineViewer.vue @@ -138,28 +138,30 @@ - - {{ cueLabel(cue) }} - - - {{ cueGroupLabel(grp.group, grp.cues) }} - + - - {{ cueLabel(cue) }} - - - {{ cueGroupLabel(grp.group, grp.cues) }} - + c.group_id == null); - }, - lineGroups(): { group: any; cues: any[] }[] { - return (this as any).GROUPED_CUES_FOR_LINE((this.line as any).id).groups; + mergedCueSlots(): any[] { + return (this as any).GROUPED_CUES_FOR_LINE((this.line as any).id).merged; }, ...mapGetters(['USER_SETTINGS', 'GROUPED_CUES_FOR_LINE']), }, diff --git a/client/src/vue_components/show/live/ScriptLineViewerCompact.vue b/client/src/vue_components/show/live/ScriptLineViewerCompact.vue index b5ced3581..7179775a6 100644 --- a/client/src/vue_components/show/live/ScriptLineViewerCompact.vue +++ b/client/src/vue_components/show/live/ScriptLineViewerCompact.vue @@ -40,57 +40,54 @@ - -