From a30cf9cd1ea0a5b2dec52cfbcb4fcb137ab2b9d8 Mon Sep 17 00:00:00 2001 From: Nicolas Pennec Date: Wed, 28 Oct 2026 13:39:08 +0100 Subject: [PATCH 1/4] [tests] Load Vue Test Utils in the jsdom specs only The setup file loaded Vue Test Utils, with the full Vue build, its template compiler, and its server renderer, in every spec file, the node specs included, which never mount. It also loaded moment to set the locale moment already starts with. On 3 workers, the setup files now take 9 s of the summed spec time instead of 16 to 17 s, about 5% of the run. --- tests/unit.setup.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/unit.setup.js b/tests/unit.setup.js index 5a808d9f3a..4337ee21eb 100644 --- a/tests/unit.setup.js +++ b/tests/unit.setup.js @@ -1,18 +1,18 @@ -import { config } from "@vue/test-utils" -import moment from 'moment' - -moment.locale('en') -config.global.mocks = { - $t: tKey => tKey // just return translation key -} +// Node specs never mount: they skip loading Vue Test Utils. +if (typeof document !== 'undefined') { + const { config } = await import('@vue/test-utils') + config.global.mocks = { + $t: tKey => tKey // just return translation key + } -// v-focus only exists on the real app built by main.js, which the specs -// never load: mirror it here so mounted components resolve it instead of -// logging a warning. -config.global.directives = { - focus: { - mounted(el, binding) { - el.focus(binding.value) + // v-focus only exists on the real app built by main.js, which the specs + // never load: mirror it here so mounted components resolve it instead of + // logging a warning. + config.global.directives = { + focus: { + mounted(el, binding) { + el.focus(binding.value) + } } } } From b5f1c8ef06abf81ac7f208d2160ffbfcf6696a4e Mon Sep 17 00:00:00 2001 From: Nicolas Pennec Date: Wed, 28 Oct 2026 14:40:06 +0100 Subject: [PATCH 2/4] [schedule] Stop expanding the rows of a production left Expand all, the export, a row being expanded, and the assignment panel kept loading after a switch to another production or episode, or after the page was left. Each step read the current production again, so the rows left loaded the assets, shots, and tasks of the new one. On the Assets page, the list was replaced by assets without tasks, once per asset row left to expand. These loads now stop at their next answer once the production or the episode changes or the page is left. An Expand all stopped that way no longer flips its button. --- src/components/pages/ProductionSchedule.vue | 74 +++++---- tests/unit/pages/productionschedule.spec.js | 166 +++++++++++++++++++- 2 files changed, 205 insertions(+), 35 deletions(-) diff --git a/src/components/pages/ProductionSchedule.vue b/src/components/pages/ProductionSchedule.vue index f7a9c69954..6bb0ba7aab 100644 --- a/src/components/pages/ProductionSchedule.vue +++ b/src/components/pages/ProductionSchedule.vue @@ -715,6 +715,11 @@ const modals = ref({ const pendingParentChange = ref(null) const scheduleRef = useTemplateRef('schedule') +// Bumped when the production or the episode changes and when the page is +// left: an expansion still running then stops, rather than loading the +// stores of the new scope for the rows of the one left. +let scheduleGeneration = 0 + // Computed // -------------------------------------------------------------------------- const currentEpisode = computed(() => store.getters.currentEpisode) @@ -1032,7 +1037,10 @@ const loadData = async () => { } } +const isStaleSchedule = generation => generation !== scheduleGeneration + const reset = () => { + scheduleGeneration++ // Debounce: cross-prod navigation triggers two close currentEpisode changes (transient 'main' then 'all'). if (resetTimeout.value) clearTimeout(resetTimeout.value) resetTimeout.value = setTimeout(() => { @@ -1167,6 +1175,19 @@ const assetInScope = asset => { return !isMainPack.value || !asset.source_id } +// Loads the entities, scoped to the current episode for TV shows, that the +// rows of a task type stand for. +const loadTaskTypeEntities = forEntity => { + const loaders = { + Asset: loadScopedAssets, + Shot: () => store.dispatch('loadShots'), + Sequence: () => store.dispatch('loadSequences'), + Episode: () => store.dispatch('loadEpisodes'), + Edit: () => store.dispatch('loadEdits') + } + return loaders[forEntity]?.() +} + const expandTaskTypeElement = ( taskTypeElement, refreshScheduleCallBack = null, @@ -1228,6 +1249,7 @@ const expandTaskTypeDrillDown = async ( taskTypeElement.expanded = expanded || !taskTypeElement.expanded if (taskTypeElement.expanded) { + const generation = scheduleGeneration // unversioned task list shared with the side panel to avoid a reload let rawTasks = null try { @@ -1259,6 +1281,7 @@ const expandTaskTypeDrillDown = async ( episodeId: currentEpisodeId.value } const entityRows = await loadEntityRows(parameters) + if (isStaleSchedule(generation)) return let children = convertScheduleItems(taskTypeElement, entityRows) const childrenById = new Map( @@ -1267,22 +1290,14 @@ const expandTaskTypeDrillDown = async ( // load entities (scoped to the current episode for TV shows) that // back the row grouping, the entity name and the episode filter - if (taskTypeElement.for_entity === 'Asset') { - await loadScopedAssets() - } else if (taskTypeElement.for_entity === 'Shot') { - await store.dispatch('loadShots') - } else if (taskTypeElement.for_entity === 'Sequence') { - await store.dispatch('loadSequences') - } else if (taskTypeElement.for_entity === 'Episode') { - await store.dispatch('loadEpisodes') - } else if (taskTypeElement.for_entity === 'Edit') { - await store.dispatch('loadEdits') - } + await loadTaskTypeEntities(taskTypeElement.for_entity) + if (isStaleSchedule(generation)) return let tasks = await store.dispatch( 'loadTasks', buildTaskFilters(taskTypeElement) ) + if (isStaleSchedule(generation)) return rawTasks = tasks // Update tasks for versioned schedules @@ -1333,6 +1348,7 @@ const expandTaskTypeDrillDown = async ( ) daysOffRangeKey.value = daysOffKey } + if (isStaleSchedule(generation)) return // Read the entity maps fresh from the store cache. They are plain, // non-reactive Maps replaced on each episode-scoped load, so a cached @@ -1804,6 +1820,7 @@ const selectTaskTypeElement = async ( return } + const generation = scheduleGeneration selectedTaskType.value = taskType if (resetAssignments) { @@ -1820,14 +1837,15 @@ const selectTaskTypeElement = async ( 'loadTasks', buildTaskFilters(selectedTaskType.value) )) + if (isStaleSchedule(generation)) return + if (!preloadedTasks) { + await loadTaskTypeEntities(taskType.for_entity) + if (isStaleSchedule(generation)) return + } const taskEntityIds = new Set(tasks.map(task => task.entity_id)) // load entity types if (taskType.for_entity === 'Asset') { - if (!preloadedTasks) { - await loadScopedAssets() - } - assignments.value.entityTypes = productionAssetTypes.value .filter(assetType => { // filtering following custom asset types workflow @@ -1861,10 +1879,6 @@ const selectTaskTypeElement = async ( } }) } else if (taskType.for_entity === 'Shot') { - if (!preloadedTasks) { - await store.dispatch('loadShots') - } - const shotsBySequence = shotStore.cache.shots .filter(shot => taskEntityIds.has(shot.id)) .reduce((acc, shot) => { @@ -1891,10 +1905,6 @@ const selectTaskTypeElement = async ( } ) } else if (taskType.for_entity === 'Sequence') { - if (!preloadedTasks) { - await store.dispatch('loadSequences') - } - // sequences are the assignable entities, grouped by episode const sequencesByEpisode = [...sequenceStore.cache.sequenceMap.values()] .filter( @@ -1929,10 +1939,6 @@ const selectTaskTypeElement = async ( } ) } else if (taskType.for_entity === 'Episode') { - if (!preloadedTasks) { - await store.dispatch('loadEpisodes') - } - // episodes are the assignable entities, under a single production group const episodes = [...episodeStore.cache.episodeMap.values()] .filter( @@ -1957,10 +1963,6 @@ const selectTaskTypeElement = async ( } ] } else if (taskType.for_entity === 'Edit') { - if (!preloadedTasks) { - await store.dispatch('loadEdits') - } - // edits are the assignable entities, grouped by episode const editsByEpisode = [...editStore.cache.editMap.values()] .filter( @@ -2374,13 +2376,16 @@ const saveTask = async () => { const onScheduleExpandAll = async () => { if (loading.value.expandSchedule) return + const generation = scheduleGeneration loading.value.expandSchedule = true if (!expandAll.value) { await expandAllScheduleItems() } else { collapseAllScheduleItems() } - expandAll.value = !expandAll.value + // Stopped by a production or episode change: the rows shown now are + // collapsed. + if (!isStaleSchedule(generation)) expandAll.value = !expandAll.value loading.value.expandSchedule = false } @@ -2573,11 +2578,13 @@ const applyToProduction = async () => { } const expandAllScheduleItems = async () => { + const generation = scheduleGeneration // scopedScheduleItems keeps the main pack to its in-scope Asset rows: // drilling an out-of-scope row would forward episode_id=main to an // endpoint that rejects it. Same array reference in every other mode. // run sequentially to avoid overloading the server for (const element of scopedScheduleItems.value) { + if (isStaleSchedule(generation)) return if (!element.expanded) { await expandTaskTypeElement( element, @@ -2598,12 +2605,14 @@ const collapseAllScheduleItems = () => { } const exportSchedule = async (withAllRows = true) => { + const generation = scheduleGeneration loading.value.exportSchedule = true try { if (withAllRows) { await expandAllScheduleItems() } + if (isStaleSchedule(generation)) return const data = scheduleRef.value?.exportData() @@ -2889,6 +2898,7 @@ onMounted(() => { }) onBeforeUnmount(() => { + scheduleGeneration++ if (resetTimeout.value) clearTimeout(resetTimeout.value) }) diff --git a/tests/unit/pages/productionschedule.spec.js b/tests/unit/pages/productionschedule.spec.js index f2517b0c17..b6700a0f40 100644 --- a/tests/unit/pages/productionschedule.spec.js +++ b/tests/unit/pages/productionschedule.spec.js @@ -173,15 +173,18 @@ const mountPage = async ({ ...actions } const store = createStore({ - state: { scheduleVersions: versions }, + state: { production, scheduleVersions: versions }, mutations: { + SET_PRODUCTION(state, newProduction) { + state.production = newProduction + }, SET_VERSIONS(state, scheduleVersions) { state.scheduleVersions = scheduleVersions } }, getters: { currentEpisode: () => null, - currentProduction: () => production, + currentProduction: state => state.production, dateFormat: () => 'YYYY-MM-DD', isCurrentUserProductionManager: () => true, isCurrentUserProductionSupervisor: () => false, @@ -229,7 +232,7 @@ const mountPage = async ({ }) mountedPage = wrapper await waitForLoad() - return { router, scheduleWidget, storeActions, wrapper } + return { router, scheduleWidget, store, storeActions, wrapper } } const findSchedule = wrapper => wrapper.findComponent({ name: 'Schedule' }) @@ -668,6 +671,163 @@ describe('ProductionSchedule page', () => { 'Props' ]) }) + + }) + + // An expansion outlives the production it started on: once the schedule + // shows another one, it must load nothing more, or it fills the stores of + // the new production for the rows of the one left. + describe('production switch', () => { + const otherProduction = { + ...production, + id: 'production-2', + name: 'Sprite Fright' + } + + const drillDownActions = [ + 'loadAssets', + 'loadShots', + 'loadTasks', + 'loadProductionDaysOff' + ] + + // The first call waits for the test to release it, the next ones answer + // at once. + const holdFirstCall = answer => { + let release = () => {} + const action = vi.fn(answer).mockImplementationOnce( + () => + new Promise(resolve => { + release = () => resolve(answer()) + }) + ) + return { action, release: () => release() } + } + + const switchProduction = async store => { + store.commit('SET_PRODUCTION', otherProduction) + await flushPromises() + } + + it.each([ + ['its asset type rows', 'loadAssetTypeScheduleItems', buildAssetTypeBars], + ['its assets', 'loadAssets', () => undefined], + ['its tasks', 'loadTasks', () => []], + ['the days off', 'loadProductionDaysOff', () => ({})] + ])('stops an expand all waiting for %s', async (_, name, answer) => { + const held = holdFirstCall(answer) + const { scheduleWidget, store, storeActions, wrapper } = await mountPage({ + actions: { [name]: held.action } + }) + findSchedule(wrapper).vm.$emit('expand-all') + await flushPromises() + drillDownActions.forEach(action => storeActions[action].mockClear()) + scheduleWidget.methods.refreshItemPositions.mockClear() + + await switchProduction(store) + held.release() + await waitForLoad() + + drillDownActions.forEach(action => { + expect(storeActions[action]).not.toHaveBeenCalled() + }) + expect(scheduleWidget.methods.refreshItemPositions).not.toHaveBeenCalled() + }) + + it('expands the new production after an expand all it interrupted', async () => { + const held = holdFirstCall(buildAssetTypeBars) + const { store, storeActions, wrapper } = await mountPage({ + actions: { loadAssetTypeScheduleItems: held.action } + }) + findSchedule(wrapper).vm.$emit('expand-all') + await flushPromises() + await switchProduction(store) + held.release() + await waitForLoad() + + findSchedule(wrapper).vm.$emit('expand-all') + await waitForLoad() + + expect( + payloadsOf(storeActions.loadAssetTypeScheduleItems).map( + payload => payload.production.id + ) + ).toEqual(['production-1', 'production-2']) + }) + + it('stops an expand all when the page is left', async () => { + const held = holdFirstCall(buildAssetTypeBars) + const { storeActions, wrapper } = await mountPage({ + actions: { loadAssetTypeScheduleItems: held.action } + }) + findSchedule(wrapper).vm.$emit('expand-all') + await flushPromises() + + wrapper.unmount() + mountedPage = null + held.release() + await flushPromises() + + expect(storeActions.loadAssets).not.toHaveBeenCalled() + expect(storeActions.loadShots).not.toHaveBeenCalled() + }) + + it('exports nothing once the production changed', async () => { + const held = holdFirstCall(buildAssetTypeBars) + const { scheduleWidget, store, wrapper } = await mountPage({ + actions: { loadAssetTypeScheduleItems: held.action } + }) + wrapper + .findAllComponents(ButtonSimple) + .find(button => button.props('title') === 'schedule.export') + .vm.$emit('click') + await flushPromises() + + await switchProduction(store) + held.release() + await waitForLoad() + + expect(scheduleWidget.methods.exportData).not.toHaveBeenCalled() + }) + + it('stops loading the side panel entities once the production changed', async () => { + const held = holdFirstCall(() => []) + const { store, storeActions, wrapper } = await mountPage({ + actions: { loadTasks: held.action } + }) + findSchedule(wrapper).vm.$emit('item-selected', rowsOf(wrapper)[0]) + await flushPromises() + + await switchProduction(store) + held.release() + await waitForLoad() + + expect(storeActions.loadAssets).not.toHaveBeenCalled() + }) + + it('leaves the side panel of the new production empty', async () => { + const held = holdFirstCall(() => undefined) + const { store, wrapper } = await mountPage({ + actions: { loadAssets: held.action }, + getters: { + productionAssetTypes: () => [ + { id: 'asset-type-props', name: 'Props', task_types: [] } + ] + } + }) + findSchedule(wrapper).vm.$emit('item-selected', rowsOf(wrapper)[0]) + await flushPromises() + + // The new production loads, its side panel closed, before the assets + // of the one left arrive. + await switchProduction(store) + await waitForLoad() + held.release() + await flushPromises() + await toggleSidePanel(wrapper) + + expect(wrapper.findAll('.side-column .assignment-item')).toHaveLength(0) + }) }) describe('route', () => { From 15b7f180a2f51609f28446d2b27249645af3cd31 Mon Sep 17 00:00:00 2001 From: Nicolas Pennec Date: Wed, 28 Oct 2026 14:40:14 +0100 Subject: [PATCH 3/4] [assets] Keep the full list of a scope over a load without tasks The schedule loads the assets without their tasks. That load replaced the list the Assets page had loaded with its tasks for the same production and episode, and the page then fetched it again. A load without tasks now takes that list, loaded or on its way. The schedule reads the episode of an asset from episode_id, as that list names it, as well as from source_id, and leaves out the assets other productions share with this one, which only that list holds. --- src/components/pages/ProductionSchedule.vue | 12 ++- src/store/modules/assets.js | 8 +- tests/unit/pages/productionschedule.spec.js | 48 +++++++++ tests/unit/store/assets.spec.js | 103 ++++++++++++++++++++ 4 files changed, 167 insertions(+), 4 deletions(-) diff --git a/src/components/pages/ProductionSchedule.vue b/src/components/pages/ProductionSchedule.vue index 6bb0ba7aab..eda08f8174 100644 --- a/src/components/pages/ProductionSchedule.vue +++ b/src/components/pages/ProductionSchedule.vue @@ -1169,10 +1169,16 @@ const loadScopedAssets = () => { } // Whether an asset falls in the current scope: for the main pack, only the -// assets attached to no episode (source_id null); otherwise the assetMap is -// already scoped and every asset it holds belongs. +// assets attached to no episode; otherwise the assetMap is already scoped +// and every asset it holds belongs. The store may hand over the list of the +// Assets page instead of a list without tasks: that list names the episode +// episode_id rather than source_id, and holds the assets other productions +// share with this one, which never belong. const assetInScope = asset => { - return !isMainPack.value || !asset.source_id + return ( + !asset.shared && + (!isMainPack.value || !(asset.episode_id || asset.source_id)) + ) } // Loads the entities, scoped to the current episode for TV shows, that the diff --git a/src/store/modules/assets.js b/src/store/modules/assets.js index c45a78ec4c..7729b5e9e9 100644 --- a/src/store/modules/assets.js +++ b/src/store/modules/assets.js @@ -458,9 +458,14 @@ const actions = { const marker = isPartial ? '#partial' : all ? '#shared' : '' const scope = all ? 'all' : (episode?.id ?? '') const loadingKey = `${production.id}/${scope}${marker}` + // The full list of the same scope, loaded or on its way, holds every + // asset a partial load would fetch: a partial load takes it rather than + // replace the list a page displays with assets without their tasks. + const isFullListOfScope = + isPartial && state.assetsLoadingKey === `${production.id}/${scope}` if (state.isAssetsLoading) { - if (state.assetsLoadingKey === loadingKey) { + if (state.assetsLoadingKey === loadingKey || isFullListOfScope) { return cache.assetsLoadingPromise || cache.assets } // Another scope is in flight (the user switched episode mid-load): wait @@ -472,6 +477,7 @@ const actions = { () => dispatch('loadAssets', { all, withShared, withTasks }) ) } + if (isFullListOfScope) return cache.assets if (all || episode?.id === 'all') { episode = null // Do not filter by episode diff --git a/tests/unit/pages/productionschedule.spec.js b/tests/unit/pages/productionschedule.spec.js index b6700a0f40..f850cf99da 100644 --- a/tests/unit/pages/productionschedule.spec.js +++ b/tests/unit/pages/productionschedule.spec.js @@ -658,6 +658,10 @@ describe('ProductionSchedule page', () => { }) describe('drill-down', () => { + afterEach(() => { + assetStore.cache.assetMap.clear() + }) + it('loads the days off of the schedule range when a row expands', async () => { const { storeActions, wrapper } = await mountPage() @@ -672,6 +676,50 @@ describe('ProductionSchedule page', () => { ]) }) + // The list the Assets page loads under All, which the main pack may take + // over, gives the episode of an asset as episode_id, not source_id, and + // also holds the assets other productions share with this one. + it.each([ + ['an asset of another production', { source_id: null, shared: true }], + ['an episode asset of the list with tasks', { episode_id: 'episode-1' }] + ])('keeps %s out of the main pack rows', async (_, fields) => { + assetTypeStore.cache.assetTypeMap.set('asset-type-sets', { + id: 'asset-type-sets', + name: 'Sets', + task_types: [] + }) + const assets = [ + { id: 'asset-1', asset_type_id: 'asset-type-props', source_id: null }, + { id: 'asset-9', asset_type_id: 'asset-type-sets', ...fields } + ] + assets.forEach(asset => assetStore.cache.assetMap.set(asset.id, asset)) + const { wrapper } = await mountPage({ + actions: { + loadAssetTypeScheduleItems: vi.fn(() => [ + ...buildAssetTypeBars(), + { + id: 'bar-sets', + task_type_id: 'tt-modeling', + object_id: 'asset-type-sets', + name: 'Sets', + start_date: '2026-02-01', + end_date: '2026-02-20' + } + ]) + }, + getters: { + currentEpisode: () => ({ id: 'main', name: 'Main Pack' }), + isTVShow: () => true + } + }) + + findSchedule(wrapper).vm.$emit('root-element-expanded', rowsOf(wrapper)[0]) + await waitForLoad() + + expect(rowsOf(wrapper)[0].children.map(row => row.name)).toEqual([ + 'Props' + ]) + }) }) // An expansion outlives the production it started on: once the schedule diff --git a/tests/unit/store/assets.spec.js b/tests/unit/store/assets.spec.js index 22c433f5ec..420127c41d 100644 --- a/tests/unit/store/assets.spec.js +++ b/tests/unit/store/assets.spec.js @@ -1071,6 +1071,109 @@ describe('Assets store, partial loads', () => { }) }) +describe('Assets store, partial load over the full list', () => { + // The full list of a scope, the one the Assets page displays, holds every + // asset a partial load of that scope would fetch, with their tasks: a + // partial load (the schedule) must not replace it with a poorer dataset. + const buildAsset = id => ({ + id, + name: id, + asset_type_id: 't1', + asset_type_name: 'Char', + canceled: false, + tasks: [], + data: {} + }) + + afterEach(() => { + vi.restoreAllMocks() + assetsStore.cache.assetsLoadingPromise = null + assetsStore.cache.assets = [] + }) + + test('takes the full list of its scope', async () => { + const getAssets = vi.spyOn(assetsApi, 'getAssets').mockResolvedValue([]) + const assets = [buildAsset('a1')] + assetsStore.cache.assets = assets + const state = { + isAssetsLoading: false, + isAssetsLoadingError: false, + assetsLoadingKey: 'p1/' + } + const commit = realCommit(state) + + const result = await assetsStore.actions.loadAssets( + { commit, dispatch: vi.fn(), state, rootGetters: baseRootGetters() }, + { withTasks: false, withShared: false } + ) + + expect(getAssets).not.toHaveBeenCalled() + expect(commit).not.toHaveBeenCalled() + expect(state.assetsLoadingKey).toBe('p1/') + expect(result).toBe(assets) + }) + + test('joins the full list of its scope on its way', async () => { + let release = () => {} + const getAssets = vi.spyOn(assetsApi, 'getAssets').mockImplementation( + () => + new Promise(resolve => { + release = () => resolve([buildAsset('a1')]) + }) + ) + vi.spyOn(assetsApi, 'getUsedSharedAssets').mockResolvedValue([]) + const state = { + isAssetsLoading: false, + isAssetsLoadingError: false, + assetsLoadingKey: null, + assetSearchText: '' + } + const ctx = { + commit: realCommit(state), + dispatch: vi.fn(), + state, + rootGetters: baseRootGetters() + } + + const full = assetsStore.actions.loadAssets(ctx) + const partial = assetsStore.actions.loadAssets(ctx, { + withTasks: false, + withShared: false + }) + release() + const [, result] = await Promise.all([full, partial]) + + expect(getAssets).toHaveBeenCalledTimes(1) + expect(ctx.dispatch).not.toHaveBeenCalled() + expect(state.assetsLoadingKey).toBe('p1/') + expect(result.map(asset => asset.id)).toEqual(['a1']) + }) + + test('still loads the episode the full list does not cover', async () => { + const getAssets = vi.spyOn(assetsApi, 'getAssets').mockResolvedValue([]) + const state = { + isAssetsLoading: false, + isAssetsLoadingError: false, + assetsLoadingKey: 'p1/ep-a', + assetSearchText: '' + } + const rootGetters = { + ...baseRootGetters(), + isTVShow: true, + currentEpisode: { id: 'ep-b' }, + episodes: [{ id: 'ep-a' }, { id: 'ep-b' }] + } + + await assetsStore.actions.loadAssets( + { commit: realCommit(state), dispatch: vi.fn(), state, rootGetters }, + { withTasks: false, withShared: false } + ) + + expect(getAssets).toHaveBeenCalledTimes(1) + expect(state.assetsLoadingKey).toBe('p1/ep-b#partial') + }) +}) + describe('Assets store, LOAD_ASSETS_ERROR', () => { // The store logs the failed load: keep that expected error out of the // test output. From 1fb3d1a30fa080c11352f804b009880c0dc5fbab Mon Sep 17 00:00:00 2001 From: Nicolas Pennec Date: Wed, 28 Oct 2026 14:40:21 +0100 Subject: [PATCH 4/4] [schedule] Expand all rows at the first click after a switch The rows of another production or episode load collapsed, but after an Expand all the button stayed on collapse: its first click did nothing. The button now starts over with the rows of the new production or episode. --- src/components/pages/ProductionSchedule.vue | 2 ++ tests/unit/pages/productionschedule.spec.js | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/components/pages/ProductionSchedule.vue b/src/components/pages/ProductionSchedule.vue index eda08f8174..6598918830 100644 --- a/src/components/pages/ProductionSchedule.vue +++ b/src/components/pages/ProductionSchedule.vue @@ -1041,6 +1041,8 @@ const isStaleSchedule = generation => generation !== scheduleGeneration const reset = () => { scheduleGeneration++ + // the rows of the new production or episode load collapsed + expandAll.value = false // Debounce: cross-prod navigation triggers two close currentEpisode changes (transient 'main' then 'all'). if (resetTimeout.value) clearTimeout(resetTimeout.value) resetTimeout.value = setTimeout(() => { diff --git a/tests/unit/pages/productionschedule.spec.js b/tests/unit/pages/productionschedule.spec.js index f850cf99da..0de4050d4a 100644 --- a/tests/unit/pages/productionschedule.spec.js +++ b/tests/unit/pages/productionschedule.spec.js @@ -803,6 +803,23 @@ describe('ProductionSchedule page', () => { ).toEqual(['production-1', 'production-2']) }) + it('expands the new production after an expand all that finished', async () => { + const { store, storeActions, wrapper } = await mountPage() + findSchedule(wrapper).vm.$emit('expand-all') + await waitForLoad() + await switchProduction(store) + await waitForLoad() + + findSchedule(wrapper).vm.$emit('expand-all') + await waitForLoad() + + expect( + payloadsOf(storeActions.loadAssetTypeScheduleItems).map( + payload => payload.production.id + ) + ).toEqual(['production-1', 'production-2']) + }) + it('stops an expand all when the page is left', async () => { const held = holdFirstCall(buildAssetTypeBars) const { storeActions, wrapper } = await mountPage({