diff --git a/src/components/pages/ProductionSchedule.vue b/src/components/pages/ProductionSchedule.vue index f7a9c69954..6598918830 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,12 @@ const loadData = async () => { } } +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(() => { @@ -1161,10 +1171,29 @@ 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 +// 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 = ( @@ -1228,6 +1257,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 +1289,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 +1298,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 +1356,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 +1828,7 @@ const selectTaskTypeElement = async ( return } + const generation = scheduleGeneration selectedTaskType.value = taskType if (resetAssignments) { @@ -1820,14 +1845,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 +1887,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 +1913,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 +1947,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 +1971,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 +2384,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 +2586,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 +2613,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 +2906,7 @@ onMounted(() => { }) onBeforeUnmount(() => { + scheduleGeneration++ if (resetTimeout.value) clearTimeout(resetTimeout.value) }) 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.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) + } } } } diff --git a/tests/unit/pages/productionschedule.spec.js b/tests/unit/pages/productionschedule.spec.js index f2517b0c17..0de4050d4a 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' }) @@ -655,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() @@ -668,6 +675,224 @@ describe('ProductionSchedule page', () => { 'Props' ]) }) + + // 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 + // 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('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({ + 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', () => { 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.