diff --git a/frontend/src/@redux/__tests__/entity-reducer.test.ts b/frontend/src/@redux/__tests__/entity-reducer.test.ts index ec3080ee4..b380dff98 100644 --- a/frontend/src/@redux/__tests__/entity-reducer.test.ts +++ b/frontend/src/@redux/__tests__/entity-reducer.test.ts @@ -5,7 +5,7 @@ import { createReducer, } from "@reduxjs/toolkit"; import {} from "jest"; -import { differenceWith, intersectionWith, isString } from "lodash"; +import { differenceWith, intersectionWith, isString, uniq } from "lodash"; import { defaultList, defaultState, TestType } from "../tests/helper"; import { createAsyncEntityReducer } from "../utils/factory"; @@ -181,6 +181,7 @@ it("entity update all resolved", async () => { const id = v.id.toString(); expect(entities.content.ids[index]).toEqual(id); expect(entities.content.entities[id]).toEqual(v); + expect(entities.didLoaded).toContain(id); }); }); }); @@ -224,6 +225,9 @@ it("delete entity item", async () => { store.dispatch(removeIds(idsToRemove)); use((entities) => { expect(entities.state).toBe("succeeded"); + idsToRemove.map(String).forEach((v) => { + expect(entities.didLoaded).not.toContain(v); + }); expectResults.forEach((v, index) => { const id = v.id.toString(); expect(entities.content.ids[index]).toEqual(id); @@ -242,6 +246,7 @@ it("entity update by range", async () => { const id = v.toString(); expect(entities.content.ids).toContain(id); expect(entities.content.entities[id].id).toEqual(v); + expect(entities.didLoaded).toContain(id); }); expect(entities.error).toBeNull(); expect(entities.state).toBe("succeeded"); @@ -258,12 +263,24 @@ it("entity update by duplicative range", async () => { const id = v.id.toString(); expect(entities.content.ids).toContain(id); expect(entities.content.entities[id]).toEqual(v); + expect(entities.didLoaded.filter((v) => v === id)).toHaveLength(1); }); expect(entities.error).toBeNull(); expect(entities.state).toBe("succeeded"); }); }); +it("entity update by range and ids", async () => { + await store.dispatch(rangeResolved({ start: 0, length: 2 })); + await store.dispatch(idsResolved([3])); + await store.dispatch(rangeResolved({ start: 2, length: 2 })); + use((entries) => { + const ids = entries.content.ids.filter(isString); + const dedupIds = uniq(ids); + expect(ids.length).toBe(dedupIds.length); + }); +}); + it("entity resolved by dirty", async () => { await store.dispatch(rangeResolved({ start: 0, length: 2 })); store.dispatch(dirty([1, 2, 3])); diff --git a/frontend/src/@redux/__tests__/list-reducer.test.ts b/frontend/src/@redux/__tests__/list-reducer.test.ts index 5bda3c51d..d94bfd164 100644 --- a/frontend/src/@redux/__tests__/list-reducer.test.ts +++ b/frontend/src/@redux/__tests__/list-reducer.test.ts @@ -69,6 +69,7 @@ it("list all uninitialized -> succeeded", async () => { use((list) => { expect(list.content).toEqual(defaultList); expect(list.dirtyEntities).toHaveLength(0); + expect(list.didLoaded).toHaveLength(defaultList.length); expect(list.error).toBeNull(); expect(list.state).toEqual("succeeded"); }); @@ -109,6 +110,7 @@ it("list ids uninitialized -> succeeded", async () => { await store.dispatch(idsResolved([0, 1, 2])); use((list) => { expect(list.content).toHaveLength(3); + expect(list.didLoaded).toHaveLength(3); expect(list.dirtyEntities).toHaveLength(0); expect(list.error).toBeNull(); expect(list.state).toEqual("succeeded"); @@ -147,6 +149,7 @@ it("list ids update duplicative data", async () => { await store.dispatch(idsResolved([2, 3])); use((list) => { expect(list.content).toHaveLength(4); + expect(list.didLoaded).toHaveLength(4); expect(list.state).toEqual("succeeded"); }); }); @@ -156,6 +159,7 @@ it("list ids update new data", async () => { await store.dispatch(idsResolved([2, 3])); use((list) => { expect(list.content).toHaveLength(4); + expect(list.didLoaded).toHaveLength(4); expect(list.content[1].id).toBe(2); expect(list.content[0].id).toBe(3); expect(list.state).toEqual("succeeded"); diff --git a/frontend/src/@redux/hooks/series.ts b/frontend/src/@redux/hooks/series.ts index 893c5eef6..77bfb74f5 100644 --- a/frontend/src/@redux/hooks/series.ts +++ b/frontend/src/@redux/hooks/series.ts @@ -34,9 +34,10 @@ export function useSeries() { export function useSerieBy(id: number) { const series = useSerieEntities(); const action = useReduxAction(seriesUpdateById); - const serie = useEntityItemById(series, id.toString()); + const serie = useEntityItemById(series, String(id)); const update = useCallback(() => { + console.log("try loading", id); if (!isNaN(id)) { action([id]); } diff --git a/frontend/src/@redux/reducers/movie.ts b/frontend/src/@redux/reducers/movie.ts index ae139452a..7de902801 100644 --- a/frontend/src/@redux/reducers/movie.ts +++ b/frontend/src/@redux/reducers/movie.ts @@ -14,7 +14,7 @@ import { movieUpdateWantedById, movieUpdateWantedByRange, } from "../actions"; -import { AsyncUtility } from "../utils/async"; +import { AsyncUtility } from "../utils"; import { createAsyncEntityReducer, createAsyncItemReducer, diff --git a/frontend/src/@redux/reducers/series.ts b/frontend/src/@redux/reducers/series.ts index 86394c071..1facde9f1 100644 --- a/frontend/src/@redux/reducers/series.ts +++ b/frontend/src/@redux/reducers/series.ts @@ -18,7 +18,7 @@ import { seriesUpdateWantedById, seriesUpdateWantedByRange, } from "../actions"; -import { AsyncReducer, AsyncUtility } from "../utils/async"; +import { AsyncUtility, ReducerUtility } from "../utils"; import { createAsyncEntityReducer, createAsyncItemReducer, @@ -53,7 +53,7 @@ const reducer = createReducer(defaultSeries, (builder) => { const series = state.seriesList; const dirtyIds = action.payload.map(String); - AsyncReducer.markDirty(series, dirtyIds); + ReducerUtility.markDirty(series, dirtyIds); // Update episode list const episodes = state.episodeList; @@ -62,7 +62,7 @@ const reducer = createReducer(defaultSeries, (builder) => { .filter((v) => dirtyIdsSet.has(v.sonarrSeriesId.toString())) .map((v) => String(v.sonarrEpisodeId)); - AsyncReducer.markDirty(episodes, dirtyEpisodeIds); + ReducerUtility.markDirty(episodes, dirtyEpisodeIds); }); createAsyncEntityReducer(builder, (s) => s.wantedEpisodesList, { diff --git a/frontend/src/@redux/reducers/system.ts b/frontend/src/@redux/reducers/system.ts index 1c3efdf93..77e60330d 100644 --- a/frontend/src/@redux/reducers/system.ts +++ b/frontend/src/@redux/reducers/system.ts @@ -11,7 +11,7 @@ import { systemUpdateStatus, systemUpdateTasks, } from "../actions"; -import { AsyncUtility } from "../utils/async"; +import { AsyncUtility } from "../utils"; import { createAsyncItemReducer } from "../utils/factory"; interface System { diff --git a/frontend/src/@redux/tests/helper.ts b/frontend/src/@redux/tests/helper.ts index 8adfed91d..37ca830c2 100644 --- a/frontend/src/@redux/tests/helper.ts +++ b/frontend/src/@redux/tests/helper.ts @@ -1,4 +1,4 @@ -import { AsyncUtility } from "../utils/async"; +import { AsyncUtility } from "../utils"; export interface TestType { id: number; diff --git a/frontend/src/@redux/utils/__tests__/async-test.ts b/frontend/src/@redux/utils/__tests__/async-test.ts index 052d0a1eb..631204141 100644 --- a/frontend/src/@redux/utils/__tests__/async-test.ts +++ b/frontend/src/@redux/utils/__tests__/async-test.ts @@ -1,5 +1,5 @@ import {} from "jest"; -import { AsyncUtility } from "../async"; +import { AsyncUtility } from ".."; interface AsyncTest { id: string; diff --git a/frontend/src/@redux/utils/factory.ts b/frontend/src/@redux/utils/factory.ts index 37cee5c5b..41ab339b7 100644 --- a/frontend/src/@redux/utils/factory.ts +++ b/frontend/src/@redux/utils/factory.ts @@ -14,8 +14,8 @@ import { pullAll, pullAllWith, } from "lodash"; +import { ReducerUtility } from "."; import { conditionalLog } from "../../utilites/logger"; -import { AsyncReducer } from "./async"; interface ActionParam { range?: AsyncThunk; @@ -81,6 +81,8 @@ export function createAsyncListReducer( meta: { arg }, } = action; + const strIds = arg.map(String); + const keyName = list.keyName as keyof T; action.payload.forEach((v) => { @@ -92,7 +94,8 @@ export function createAsyncListReducer( } }); - AsyncReducer.updateDirty(list, arg.map(String)); + ReducerUtility.updateDirty(list, strIds); + ReducerUtility.updateDidLoaded(list, strIds); }) .addCase(ids.rejected, (state, action) => { const list = getList(state); @@ -111,7 +114,8 @@ export function createAsyncListReducer( return String((lhs as T)[keyName]) === rhs; }); - AsyncReducer.removeDirty(list, removeIds); + ReducerUtility.removeDirty(list, removeIds); + ReducerUtility.removeDidLoaded(list, removeIds); }); all && @@ -126,6 +130,11 @@ export function createAsyncListReducer( list.state = "succeeded"; list.content = action.payload as Draft; list.dirtyEntities = []; + + const ids = action.payload.map((v) => + String(v[list.keyName as keyof T]) + ); + ReducerUtility.updateDidLoaded(list, ids); }) .addCase(all.rejected, (state, action) => { const list = getList(state); @@ -136,7 +145,7 @@ export function createAsyncListReducer( dirty && builder.addCase(dirty, (state, action) => { const list = getList(state); - AsyncReducer.markDirty(list, action.payload.map(String)); + ReducerUtility.markDirty(list, action.payload.map(String)); }); } @@ -177,15 +186,22 @@ export function createAsyncEntityReducer( checkSizeUpdate(entity, total); - const idsToUpdate = data.map((v) => String(v[keyName])); - - entity.content.ids.splice(start, length, ...idsToUpdate); data.forEach((v) => { const key = String(v[keyName]); entity.content.entities[key] = v as Draft; }); - AsyncReducer.updateDirty(entity, idsToUpdate); + const idsToUpdate = data.map((v) => String(v[keyName])); + + // Remove duplicated ids + const pulledSize = + total - pullAll(entity.content.ids, idsToUpdate).length; + entity.content.ids.push(...Array(pulledSize).fill(null)); + + entity.content.ids.splice(start, length, ...idsToUpdate); + + ReducerUtility.updateDirty(entity, idsToUpdate); + ReducerUtility.updateDidLoaded(entity, idsToUpdate); }) .addCase(range.rejected, (state, action) => { const entity = getEntity(state); @@ -233,7 +249,10 @@ export function createAsyncEntityReducer( entity.content.entities[key] = v as Draft; }); - AsyncReducer.updateDirty(entity, arg.map(String)); + const allIds = arg.map(String); + + ReducerUtility.updateDirty(entity, allIds); + ReducerUtility.updateDidLoaded(entity, allIds); }) .addCase(ids.rejected, (state, action) => { const entity = getEntity(state); @@ -251,7 +270,9 @@ export function createAsyncEntityReducer( const idsToDelete = action.payload.map(String); pullAll(entity.content.ids, idsToDelete); - AsyncReducer.removeDirty(entity, idsToDelete); + ReducerUtility.removeDirty(entity, idsToDelete); + ReducerUtility.removeDidLoaded(entity, idsToDelete); + omit(entity.content.entities, idsToDelete); }); @@ -288,6 +309,9 @@ export function createAsyncEntityReducer( prev[id] = curr as Draft; return prev; }, {}); + + const allIds = entity.content.ids.filter(isString); + ReducerUtility.updateDidLoaded(entity, allIds); }) .addCase(all.rejected, (state, action) => { const entity = getEntity(state); @@ -298,6 +322,6 @@ export function createAsyncEntityReducer( dirty && builder.addCase(dirty, (state, action) => { const entity = getEntity(state); - AsyncReducer.markDirty(entity, action.payload.map(String)); + ReducerUtility.markDirty(entity, action.payload.map(String)); }); } diff --git a/frontend/src/@redux/utils/async.ts b/frontend/src/@redux/utils/index.ts similarity index 75% rename from frontend/src/@redux/utils/async.ts rename to frontend/src/@redux/utils/index.ts index 9b173f064..7ffe0b2e7 100644 --- a/frontend/src/@redux/utils/async.ts +++ b/frontend/src/@redux/utils/index.ts @@ -1,5 +1,5 @@ import { Draft } from "@reduxjs/toolkit"; -import { difference, uniq } from "lodash"; +import { difference, pullAll, uniq } from "lodash"; export namespace AsyncUtility { export function getDefaultItem(): Async.Item { @@ -15,6 +15,7 @@ export namespace AsyncUtility { state: "uninitialized", keyName: key, dirtyEntities: [], + didLoaded: [], content: [], error: null, }; @@ -24,6 +25,7 @@ export namespace AsyncUtility { return { state: "uninitialized", dirtyEntities: [], + didLoaded: [], content: { keyName: key, ids: [], @@ -34,7 +36,7 @@ export namespace AsyncUtility { } } -export namespace AsyncReducer { +export namespace ReducerUtility { type DirtyType = Draft> | Draft>; export function markDirty( entity: T, @@ -63,9 +65,24 @@ export namespace AsyncReducer { entity: T, removedIds: string[] ) { - entity.dirtyEntities = difference(entity.dirtyEntities, removedIds); + pullAll(entity.dirtyEntities, removedIds); if (entity.dirtyEntities.length === 0 && entity.state === "dirty") { entity.state = "succeeded"; } } + + export function updateDidLoaded( + entity: T, + loadedIds: string[] + ) { + entity.didLoaded.push(...loadedIds); + entity.didLoaded = uniq(entity.didLoaded); + } + + export function removeDidLoaded( + entity: T, + removedIds: string[] + ) { + pullAll(entity.didLoaded, removedIds); + } } diff --git a/frontend/src/@types/async.d.ts b/frontend/src/@types/async.d.ts index d72cc0ef2..8d60accaa 100644 --- a/frontend/src/@types/async.d.ts +++ b/frontend/src/@types/async.d.ts @@ -12,11 +12,13 @@ declare namespace Async { type List = Base & { keyName: keyof T; dirtyEntities: string[]; + didLoaded: string[]; }; type Item = Base; type Entity = Base> & { dirtyEntities: string[]; + didLoaded: string[]; }; } diff --git a/frontend/src/components/modals/SeriesUploadModal.tsx b/frontend/src/components/modals/SeriesUploadModal.tsx index 1a4ca4dbc..a150a1f48 100644 --- a/frontend/src/components/modals/SeriesUploadModal.tsx +++ b/frontend/src/components/modals/SeriesUploadModal.tsx @@ -140,26 +140,20 @@ const SeriesUploadModal: FunctionComponent = ({ [episodes] ); - const updateLanguage = useCallback( - (lang: Nullable) => { - if (lang) { - const list = pending.map((v) => { - const form = v.form; - return { - ...v, - form: { - ...form, - language: lang.code2, - hi: lang.hi ?? false, - forced: lang.forced ?? false, - }, - }; + const updateLanguage = useCallback((lang: Nullable) => { + if (lang) { + const { code2, hi, forced } = lang; + setPending((pending) => { + return pending.map((v) => { + const newValue = { ...v }; + newValue.form.language = code2; + newValue.form.hi = hi ?? false; + newValue.form.forced = forced ?? false; + return newValue; }); - setPending(list); - } - }, - [pending] - ); + }); + } + }, []); const setFiles = useCallback( (files: File[]) => { diff --git a/frontend/src/utilites/async.ts b/frontend/src/utilites/async.ts index 2ec4a080e..97eaeb42a 100644 --- a/frontend/src/utilites/async.ts +++ b/frontend/src/utilites/async.ts @@ -31,20 +31,23 @@ export function useEntityItemById( entity: Async.Entity, id: string ): Async.Item { - const { content, dirtyEntities, error, state } = entity; + const { content, dirtyEntities, didLoaded, error, state } = entity; const item = useEntityToItem(content, id); const newState = useMemo(() => { - if (state === "dirty") { - if (dirtyEntities.find((v) => v === id)) { - return "dirty"; - } else { - return "succeeded"; - } - } else { - return state; + switch (state) { + case "loading": + return state; + default: + if (dirtyEntities.find((v) => v === id)) { + return "dirty"; + } else if (!didLoaded.find((v) => v === id)) { + return "uninitialized"; + } else { + return state; + } } - }, [dirtyEntities, id, state]); + }, [dirtyEntities, id, state, didLoaded]); return useMemo( () => ({ content: item, state: newState, error }), @@ -52,32 +55,6 @@ export function useEntityItemById( ); } -// export function useListItemById( -// list: Async.List, -// id: string -// ): Async.Item { -// const { content, dirtyEntities, error, state, keyName } = list; -// const item = useMemo( -// () => content.find((v) => String(v[keyName]) === id) ?? null, -// [content, id, keyName] -// ); - -// const newState = useMemo(() => { -// if (state === "loading" || state === "uninitialized") { -// return state; -// } else if (dirtyEntities.find((v) => v === id)) { -// return "dirty"; -// } else { -// return "succeeded"; -// } -// }, [dirtyEntities, error, id, state]); - -// return useMemo( -// () => ({ content: item, state: newState, error }), -// [item, newState, error] -// ); -// } - export function useOnLoadedOnce(callback: () => void, entity: Async.Base) { const [didLoaded, setLoaded] = useState(false);