Update Redux Implementation with Redux-Toolkit

pull/1509/head v0.9.8-beta.0
Liang Yi 3 years ago committed by GitHub
parent 9b05a3a63a
commit 6f9c7f3da2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -47,6 +47,10 @@ jobs:
run: npm run build run: npm run build
working-directory: ${{ env.UI_DIRECTORY }} working-directory: ${{ env.UI_DIRECTORY }}
- name: Unit Test
run: npm test
working-directory: ${{ env.UI_DIRECTORY }}
- uses: actions/upload-artifact@v2 - uses: actions/upload-artifact@v2
with: with:
name: ${{ env.UI_ARTIFACT_NAME }} name: ${{ env.UI_ARTIFACT_NAME }}

@ -4,5 +4,4 @@ REACT_APP_APIKEY="YOUR_SERVER_API_KEY"
# Optional # Optional
REACT_APP_CAN_UPDATE=true REACT_APP_CAN_UPDATE=true
REACT_APP_HAS_UPDATE=false REACT_APP_HAS_UPDATE=false
REACT_APP_LOG_REDUX_EVENT=false

@ -46,6 +46,12 @@ Open `http://localhost:3000` to view it in the browser.
The page will reload if you make edits. The page will reload if you make edits.
You will also see any lint errors in the console. You will also see any lint errors in the console.
### `npm test`
Run the Unit Test to validate app state.
Please ensure all tests are passed before uploading the code
### `npm run build` ### `npm run build`
Builds the app for production to the `build` folder. Builds the app for production to the `build` folder.

File diff suppressed because it is too large Load Diff

@ -19,7 +19,9 @@
"@fortawesome/free-regular-svg-icons": "^5.15", "@fortawesome/free-regular-svg-icons": "^5.15",
"@fortawesome/free-solid-svg-icons": "^5.15", "@fortawesome/free-solid-svg-icons": "^5.15",
"@fortawesome/react-fontawesome": "^0.1.11", "@fortawesome/react-fontawesome": "^0.1.11",
"@reduxjs/toolkit": "^1.6",
"@types/bootstrap": "^5", "@types/bootstrap": "^5",
"@types/jest": "~26.0.24",
"@types/lodash": "^4", "@types/lodash": "^4",
"@types/node": "^15", "@types/node": "^15",
"@types/react": "^16", "@types/react": "^16",
@ -28,9 +30,6 @@
"@types/react-router-dom": "^5", "@types/react-router-dom": "^5",
"@types/react-select": "^4.0.3", "@types/react-select": "^4.0.3",
"@types/react-table": "^7", "@types/react-table": "^7",
"@types/redux-actions": "^2",
"@types/redux-logger": "^3",
"@types/redux-promise": "^0.5",
"axios": "^0.21", "axios": "^0.21",
"bootstrap": "^4", "bootstrap": "^4",
"http-proxy-middleware": "^0.19", "http-proxy-middleware": "^0.19",
@ -46,10 +45,6 @@
"react-select": "^4", "react-select": "^4",
"react-table": "^7", "react-table": "^7",
"recharts": "^2.0.8", "recharts": "^2.0.8",
"redux-actions": "^2",
"redux-logger": "^3",
"redux-promise": "^0.6",
"redux-thunk": "^2.3",
"rooks": "^5", "rooks": "^5",
"sass": "^1", "sass": "^1",
"socket.io-client": "^4", "socket.io-client": "^4",

@ -0,0 +1,379 @@
import {
configureStore,
createAction,
createAsyncThunk,
createReducer,
} from "@reduxjs/toolkit";
import {} from "jest";
import { differenceWith, intersectionWith, isString } from "lodash";
import { defaultList, defaultState, TestType } from "../tests/helper";
import { createAsyncEntityReducer } from "../utils/factory";
const newItem: TestType = {
id: 123,
name: "extended",
};
const longerList: TestType[] = [...defaultList, newItem];
const shorterList: TestType[] = defaultList.slice(0, defaultList.length - 1);
const allResolved = createAsyncThunk("all/resolved", () => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({ total: defaultList.length, data: defaultList });
});
});
const allResolvedLonger = createAsyncThunk("all/longer/resolved", () => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({ total: longerList.length, data: longerList });
});
});
const allResolvedShorter = createAsyncThunk("all/shorter/resolved", () => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({ total: shorterList.length, data: shorterList });
});
});
const idsResolved = createAsyncThunk("ids/resolved", (param: number[]) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: defaultList.length,
data: intersectionWith(defaultList, param, (l, r) => l.id === r),
});
});
});
const idsResolvedLonger = createAsyncThunk(
"ids/longer/resolved",
(param: number[]) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: longerList.length,
data: intersectionWith(longerList, param, (l, r) => l.id === r),
});
});
}
);
const idsResolvedShorter = createAsyncThunk(
"ids/shorter/resolved",
(param: number[]) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: shorterList.length,
data: intersectionWith(shorterList, param, (l, r) => l.id === r),
});
});
}
);
const rangeResolved = createAsyncThunk(
"range/resolved",
(param: Parameter.Range) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: defaultList.length,
data: defaultList.slice(param.start, param.start + param.length),
});
});
}
);
const rangeResolvedLonger = createAsyncThunk(
"range/longer/resolved",
(param: Parameter.Range) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: longerList.length,
data: longerList.slice(param.start, param.start + param.length),
});
});
}
);
const rangeResolvedShorter = createAsyncThunk(
"range/shorter/resolved",
(param: Parameter.Range) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve) => {
resolve({
total: shorterList.length,
data: shorterList.slice(param.start, param.start + param.length),
});
});
}
);
const allRejected = createAsyncThunk("all/rejected", () => {
return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
rejected("Error");
});
});
const idsRejected = createAsyncThunk("ids/rejected", (param: number[]) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
rejected("Error");
});
});
const rangeRejected = createAsyncThunk(
"range/rejected",
(param: Parameter.Range) => {
return new Promise<AsyncDataWrapper<TestType>>((resolve, rejected) => {
rejected("Error");
});
}
);
const removeIds = createAction<number[]>("remove/id");
const dirty = createAction<number[]>("dirty/id");
const reducer = createReducer(defaultState, (builder) => {
createAsyncEntityReducer(builder, (s) => s.entities, {
all: allResolved,
range: rangeResolved,
ids: idsResolved,
dirty,
removeIds,
});
createAsyncEntityReducer(builder, (s) => s.entities, {
all: allRejected,
range: rangeRejected,
ids: idsRejected,
});
createAsyncEntityReducer(builder, (s) => s.entities, {
all: allResolvedLonger,
range: rangeResolvedLonger,
ids: idsResolvedLonger,
});
createAsyncEntityReducer(builder, (s) => s.entities, {
all: allResolvedShorter,
range: rangeResolvedShorter,
ids: idsResolvedShorter,
});
});
function createStore() {
const store = configureStore({
reducer,
});
expect(store.getState()).toEqual(defaultState);
return store;
}
let store = createStore();
function use(callback: (entities: Async.Entity<TestType>) => void) {
const entities = store.getState().entities;
callback(entities);
}
beforeEach(() => {
store = createStore();
});
it("entity update all resolved", async () => {
await store.dispatch(allResolved());
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.error).toBeNull();
expect(entities.state).toBe("succeeded");
defaultList.forEach((v, index) => {
const id = v.id.toString();
expect(entities.content.ids[index]).toEqual(id);
expect(entities.content.entities[id]).toEqual(v);
});
});
});
it("entity update all rejected", async () => {
await store.dispatch(allRejected());
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.error).not.toBeNull();
expect(entities.state).toBe("failed");
expect(entities.content.ids).toHaveLength(0);
expect(entities.content.entities).toEqual({});
});
});
it("entity mark dirty", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty([1, 2, 3]));
use((entities) => {
expect(entities.error).toBeNull();
expect(entities.state).toBe("dirty");
defaultList.forEach((v, index) => {
const id = v.id.toString();
expect(entities.content.ids[index]).toEqual(id);
expect(entities.content.entities[id]).toEqual(v);
});
});
});
it("delete entity item", async () => {
await store.dispatch(allResolved());
const idsToRemove = [0, 1, 3, 5];
const expectResults = differenceWith(
defaultList,
idsToRemove,
(l, r) => l.id === r
);
store.dispatch(removeIds(idsToRemove));
use((entities) => {
expect(entities.state).toBe("succeeded");
expectResults.forEach((v, index) => {
const id = v.id.toString();
expect(entities.content.ids[index]).toEqual(id);
expect(entities.content.entities[id]).toEqual(v);
});
});
});
it("entity update by range", async () => {
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
await store.dispatch(rangeResolved({ start: 4, length: 2 }));
use((entities) => {
expect(entities.content.ids).toHaveLength(defaultList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(4);
[0, 1, 4, 5].forEach((v) => {
const id = v.toString();
expect(entities.content.ids).toContain(id);
expect(entities.content.entities[id].id).toEqual(v);
});
expect(entities.error).toBeNull();
expect(entities.state).toBe("succeeded");
});
});
it("entity update by duplicative range", async () => {
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
await store.dispatch(rangeResolved({ start: 1, length: 2 }));
use((entities) => {
expect(entities.content.ids).toHaveLength(defaultList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(3);
defaultList.slice(0, 3).forEach((v) => {
const id = v.id.toString();
expect(entities.content.ids).toContain(id);
expect(entities.content.entities[id]).toEqual(v);
});
expect(entities.error).toBeNull();
expect(entities.state).toBe("succeeded");
});
});
it("entity resolved by dirty", async () => {
await store.dispatch(rangeResolved({ start: 0, length: 2 }));
store.dispatch(dirty([1, 2, 3]));
await store.dispatch(rangeResolved({ start: 1, length: 2 }));
use((entities) => {
expect(entities.dirtyEntities).not.toContain("1");
expect(entities.dirtyEntities).not.toContain("2");
expect(entities.dirtyEntities).toContain("3");
expect(entities.state).toBe("dirty");
});
await store.dispatch(rangeResolved({ start: 1, length: 3 }));
use((entities) => {
expect(entities.dirtyEntities).not.toContain("1");
expect(entities.dirtyEntities).not.toContain("2");
expect(entities.dirtyEntities).not.toContain("3");
expect(entities.state).toBe("succeeded");
});
});
it("entity update by ids", async () => {
await store.dispatch(idsResolved([999]));
use((entities) => {
expect(entities.content.ids).toHaveLength(defaultList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(0);
expect(entities.content.entities).not.toHaveProperty("999");
expect(entities.error).toBeNull();
expect(entities.state).toBe("succeeded");
});
});
it("entity resolved dirty by ids", async () => {
await store.dispatch(idsResolved([0, 1, 2, 3, 4]));
store.dispatch(dirty([0, 1, 2, 3]));
await store.dispatch(idsResolved([0, 1]));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(2);
expect(entities.content.ids.filter(isString)).toHaveLength(5);
expect(entities.error).toBeNull();
expect(entities.state).toBe("dirty");
});
});
it("entity resolved non-exist by ids", async () => {
await store.dispatch(idsResolved([0, 1]));
store.dispatch(dirty([999]));
await store.dispatch(idsResolved([999]));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
});
});
it("entity update by variant range", async () => {
await store.dispatch(allResolved());
await store.dispatch(rangeResolvedLonger({ start: 0, length: 2 }));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
expect(entities.content.ids).toHaveLength(longerList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(2);
longerList.slice(0, 2).forEach((v) => {
const id = v.id.toString();
expect(entities.content.ids).toContain(id);
expect(entities.content.entities[id]).toEqual(v);
});
});
await store.dispatch(allResolved());
await store.dispatch(rangeResolvedShorter({ start: 0, length: 2 }));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
expect(entities.content.ids).toHaveLength(shorterList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(2);
shorterList.slice(0, 2).forEach((v) => {
const id = v.id.toString();
expect(entities.content.ids).toContain(id);
expect(entities.content.entities[id]).toEqual(v);
});
});
});
it("entity update by variant ids", async () => {
await store.dispatch(allResolved());
await store.dispatch(idsResolvedLonger([2, 3, 4]));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
expect(entities.content.ids).toHaveLength(longerList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(3);
Array(3)
.fill(undefined)
.forEach((v) => {
expect(entities.content.ids[v]).not.toBeNull();
});
});
await store.dispatch(allResolved());
await store.dispatch(idsResolvedShorter([2, 3, 4]));
use((entities) => {
expect(entities.dirtyEntities).toHaveLength(0);
expect(entities.state).toBe("succeeded");
expect(entities.content.ids).toHaveLength(shorterList.length);
expect(entities.content.ids.filter(isString)).toHaveLength(3);
Array(3)
.fill(undefined)
.forEach((v) => {
expect(entities.content.ids[v]).not.toBeNull();
});
});
});

@ -0,0 +1,155 @@
import {
configureStore,
createAction,
createAsyncThunk,
createReducer,
} from "@reduxjs/toolkit";
import {} from "jest";
import { defaultState, TestType } from "../tests/helper";
import { createAsyncItemReducer } from "../utils/factory";
// Item
const defaultItem: TestType = { id: 0, name: "test" };
const allResolved = createAsyncThunk("all/resolved", () => {
return new Promise<TestType>((resolve) => {
resolve(defaultItem);
});
});
const allRejected = createAsyncThunk("all/rejected", () => {
return new Promise<TestType>((resolve, rejected) => {
rejected("Error");
});
});
const dirty = createAction("dirty/ids");
const reducer = createReducer(defaultState, (builder) => {
createAsyncItemReducer(builder, (s) => s.item, { all: allResolved, dirty });
createAsyncItemReducer(builder, (s) => s.item, { all: allRejected });
});
function createStore() {
const store = configureStore({
reducer,
});
expect(store.getState()).toEqual(defaultState);
return store;
}
let store = createStore();
function use(callback: (entities: Async.Item<TestType>) => void) {
const item = store.getState().item;
callback(item);
}
// Begin Test Section
beforeEach(() => {
store = createStore();
});
it("item loading", async () => {
return new Promise<void>((done) => {
store.dispatch(allResolved()).finally(() => {
use((item) => {
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
done();
});
use((item) => {
expect(item.state).toBe("loading");
expect(item.error).toBeNull();
expect(item.content).toBeNull();
});
});
});
it("item uninitialized -> succeeded", async () => {
await store.dispatch(allResolved());
use((item) => {
expect(item.state).toBe("succeeded");
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item uninitialized -> failed", async () => {
await store.dispatch(allRejected());
use((item) => {
expect(item.state).toBe("failed");
expect(item.error).not.toBeNull();
expect(item.content).toBeNull();
});
});
it("item uninitialized -> dirty", () => {
store.dispatch(dirty());
use((item) => {
expect(item.state).toBe("uninitialized");
expect(item.error).toBeNull();
expect(item.content).toBeNull();
});
});
it("item succeeded -> failed", async () => {
await store.dispatch(allResolved());
await store.dispatch(allRejected());
use((item) => {
expect(item.state).toBe("failed");
expect(item.error).not.toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item failed -> succeeded", async () => {
await store.dispatch(allRejected());
await store.dispatch(allResolved());
use((item) => {
expect(item.state).toBe("succeeded");
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item succeeded -> dirty", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty());
use((item) => {
expect(item.state).toBe("dirty");
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item failed -> dirty", async () => {
await store.dispatch(allRejected());
store.dispatch(dirty());
use((item) => {
expect(item.state).toBe("dirty");
expect(item.error).not.toBeNull();
expect(item.content).toBeNull();
});
});
it("item dirty -> failed", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty());
await store.dispatch(allRejected());
use((item) => {
expect(item.state).toBe("failed");
expect(item.error).not.toBeNull();
expect(item.content).toEqual(defaultItem);
});
});
it("item dirty -> succeeded", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty());
await store.dispatch(allResolved());
use((item) => {
expect(item.state).toBe("succeeded");
expect(item.error).toBeNull();
expect(item.content).toEqual(defaultItem);
});
});

@ -0,0 +1,248 @@
import {
configureStore,
createAction,
createAsyncThunk,
createReducer,
} from "@reduxjs/toolkit";
import {} from "jest";
import { intersectionWith } from "lodash";
import { defaultList, defaultState, TestType } from "../tests/helper";
import { createAsyncListReducer } from "../utils/factory";
const allResolved = createAsyncThunk("all/resolved", () => {
return new Promise<TestType[]>((resolve) => {
resolve(defaultList);
});
});
const allRejected = createAsyncThunk("all/rejected", () => {
return new Promise<TestType[]>((resolve, rejected) => {
rejected("Error");
});
});
const idsResolved = createAsyncThunk("ids/resolved", (param: number[]) => {
return new Promise<TestType[]>((resolve) => {
resolve(intersectionWith(defaultList, param, (l, r) => l.id === r));
});
});
const idsRejected = createAsyncThunk("ids/rejected", (param: number[]) => {
return new Promise<TestType[]>((resolve, rejected) => {
rejected("Error");
});
});
const removeIds = createAction<number[]>("remove/id");
const dirty = createAction<number[]>("dirty/id");
const reducer = createReducer(defaultState, (builder) => {
createAsyncListReducer(builder, (s) => s.list, {
all: allResolved,
ids: idsResolved,
removeIds,
dirty,
});
createAsyncListReducer(builder, (s) => s.list, {
all: allRejected,
ids: idsRejected,
});
});
function createStore() {
const store = configureStore({
reducer,
});
expect(store.getState()).toEqual(defaultState);
return store;
}
let store = createStore();
function use(callback: (list: Async.List<TestType>) => void) {
const list = store.getState().list;
callback(list);
}
beforeEach(() => {
store = createStore();
});
it("list all uninitialized -> succeeded", async () => {
await store.dispatch(allResolved());
use((list) => {
expect(list.content).toEqual(defaultList);
expect(list.dirtyEntities).toHaveLength(0);
expect(list.error).toBeNull();
expect(list.state).toEqual("succeeded");
});
});
it("list all uninitialized -> failed", async () => {
await store.dispatch(allRejected());
use((list) => {
expect(list.content).toHaveLength(0);
expect(list.dirtyEntities).toHaveLength(0);
expect(list.error).not.toBeNull();
expect(list.state).toEqual("failed");
});
});
it("list uninitialized -> dirty", () => {
store.dispatch(dirty([0, 1]));
use((list) => {
expect(list.content).toHaveLength(0);
expect(list.dirtyEntities).toHaveLength(0);
expect(list.error).toBeNull();
expect(list.state).toEqual("uninitialized");
});
});
it("list succeeded -> dirty", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty([1, 2, 3]));
use((list) => {
expect(list.content).toEqual(defaultList);
expect(list.dirtyEntities).toHaveLength(3);
expect(list.error).toBeNull();
expect(list.state).toEqual("dirty");
});
});
it("list ids uninitialized -> succeeded", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
use((list) => {
expect(list.content).toHaveLength(3);
expect(list.dirtyEntities).toHaveLength(0);
expect(list.error).toBeNull();
expect(list.state).toEqual("succeeded");
});
});
it("list ids succeeded -> dirty", async () => {
await store.dispatch(idsResolved([0, 1]));
store.dispatch(dirty([2, 3]));
use((list) => {
expect(list.dirtyEntities).toHaveLength(2);
expect(list.state).toEqual("dirty");
});
});
it("list ids succeeded -> dirty", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
store.dispatch(dirty([2, 3]));
use((list) => {
expect(list.dirtyEntities).toHaveLength(2);
expect(list.state).toEqual("dirty");
});
});
it("list ids update data", async () => {
await store.dispatch(idsResolved([0, 1]));
await store.dispatch(idsResolved([3, 4]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.state).toEqual("succeeded");
});
});
it("list ids update duplicative data", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
await store.dispatch(idsResolved([2, 3]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.state).toEqual("succeeded");
});
});
it("list ids update new data", async () => {
await store.dispatch(idsResolved([0, 1]));
await store.dispatch(idsResolved([2, 3]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.content[1].id).toBe(2);
expect(list.content[0].id).toBe(3);
expect(list.state).toEqual("succeeded");
});
});
it("list ids empty data", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
await store.dispatch(idsResolved([999]));
use((list) => {
expect(list.content).toHaveLength(3);
expect(list.state).toEqual("succeeded");
});
});
it("list ids duplicative dirty", async () => {
await store.dispatch(idsResolved([0]));
store.dispatch(dirty([2, 2]));
use((list) => {
expect(list.dirtyEntities).toHaveLength(1);
expect(list.dirtyEntities).toContain("2");
expect(list.state).toEqual("dirty");
});
});
it("list ids resolved dirty", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
store.dispatch(dirty([2, 3]));
use((list) => {
expect(list.content).toHaveLength(3);
expect(list.dirtyEntities).toContain("2");
expect(list.dirtyEntities).toContain("3");
expect(list.state).toBe("dirty");
});
});
it("list ids resolved dirty", async () => {
await store.dispatch(idsResolved([0, 1, 2]));
store.dispatch(dirty([1, 2, 3, 999]));
await store.dispatch(idsResolved([1, 2]));
use((list) => {
expect(list.content).toHaveLength(3);
expect(list.dirtyEntities).not.toContain("1");
expect(list.dirtyEntities).not.toContain("2");
expect(list.state).toBe("dirty");
});
await store.dispatch(idsResolved([3]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.dirtyEntities).not.toContain("3");
expect(list.state).toBe("dirty");
});
await store.dispatch(idsResolved([999]));
use((list) => {
expect(list.content).toHaveLength(4);
expect(list.dirtyEntities).not.toContain("999");
expect(list.state).toBe("succeeded");
});
});
it("list remove ids", async () => {
await store.dispatch(allResolved());
const totalSize = store.getState().list.content.length;
store.dispatch(removeIds([1, 2]));
use((list) => {
expect(list.content).toHaveLength(totalSize - 2);
expect(list.content.map((v) => v.id)).not.toContain(1);
expect(list.content.map((v) => v.id)).not.toContain(2);
expect(list.state).toEqual("succeeded");
});
});
it("list remove dirty ids", async () => {
await store.dispatch(allResolved());
store.dispatch(dirty([1, 2, 3]));
store.dispatch(removeIds([1, 2]));
use((list) => {
expect(list.dirtyEntities).not.toContain("1");
expect(list.dirtyEntities).not.toContain("2");
expect(list.state).toEqual("dirty");
});
store.dispatch(removeIds([3]));
use((list) => {
expect(list.dirtyEntities).toHaveLength(0);
expect(list.state).toEqual("succeeded");
});
});

@ -1,123 +0,0 @@
import {
ActionCallback,
ActionDispatcher,
AsyncActionCreator,
AsyncActionDispatcher,
AvailableCreator,
AvailableType,
PromiseCreator,
} from "../types";
function asyncActionFactory<T extends PromiseCreator>(
type: string,
promise: T,
args: Parameters<T>
): AsyncActionDispatcher<PromiseType<ReturnType<T>>> {
return (dispatch) => {
dispatch({
type,
payload: {
loading: true,
parameters: args,
},
});
return new Promise((resolve, reject) => {
promise(...args)
.then((val) => {
dispatch({
type,
payload: {
loading: false,
item: val,
parameters: args,
},
});
resolve();
})
.catch((err) => {
dispatch({
type,
error: true,
payload: {
loading: false,
item: err,
parameters: args,
},
});
reject(err);
});
});
};
}
export function createAsyncAction<T extends PromiseCreator>(
type: string,
promise: T
) {
return (...args: Parameters<T>) => asyncActionFactory(type, promise, args);
}
// Create a action which combine multiple ActionDispatcher and execute them at once
function combineActionFactory(
dispatchers: AvailableType<any>[]
): ActionDispatcher {
return (dispatch) => {
dispatchers.forEach((fn) => {
if (typeof fn === "function") {
fn(dispatch);
} else {
dispatch(fn);
}
});
};
}
export function createCombineAction<T extends AvailableCreator>(fn: T) {
return (...args: Parameters<T>) => combineActionFactory(fn(...args));
}
function combineAsyncActionFactory(
dispatchers: AsyncActionDispatcher<any>[]
): AsyncActionDispatcher<any> {
return (dispatch) => {
const promises = dispatchers.map((v) => v(dispatch));
return Promise.all(promises) as Promise<any>;
};
}
export function createAsyncCombineAction<T extends AsyncActionCreator>(fn: T) {
return (...args: Parameters<T>) => combineAsyncActionFactory(fn(...args));
}
export function callbackActionFactory(
dispatchers: AsyncActionDispatcher<any>[],
success: ActionCallback,
error?: ActionCallback
): ActionDispatcher<any> {
return (dispatch) => {
const promises = dispatchers.map((v) => v(dispatch));
Promise.all(promises)
.then(() => {
const action = success();
if (action !== undefined) {
dispatch(action);
}
})
.catch(() => {
const action = error && error();
if (action !== undefined) {
dispatch(action);
}
});
};
}
export function createCallbackAction<T extends AsyncActionCreator>(
fn: T,
success: ActionCallback,
error?: ActionCallback
) {
return (...args: Parameters<T>) =>
callbackActionFactory(fn(args), success, error);
}

@ -1,47 +1,78 @@
import { createDeleteAction } from "../../@socketio/reducer"; import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { MoviesApi } from "../../apis"; import { MoviesApi } from "../../apis";
import {
MOVIES_DELETE_ITEMS,
MOVIES_DELETE_WANTED_ITEMS,
MOVIES_UPDATE_BLACKLIST,
MOVIES_UPDATE_HISTORY_LIST,
MOVIES_UPDATE_LIST,
MOVIES_UPDATE_WANTED_LIST,
} from "../constants";
import { createAsyncAction } from "./factory";
export const movieUpdateList = createAsyncAction( export const movieUpdateByRange = createAsyncThunk(
MOVIES_UPDATE_LIST, "movies/update/range",
(id?: number[]) => MoviesApi.movies(id) async (params: Parameter.Range) => {
const response = await MoviesApi.moviesBy(params);
return response;
}
); );
export const movieDeleteItems = createDeleteAction(MOVIES_DELETE_ITEMS); export const movieUpdateById = createAsyncThunk(
"movies/update/id",
async (ids: number[]) => {
const response = await MoviesApi.movies(ids);
return response;
}
);
export const movieUpdateWantedList = createAsyncAction( export const movieUpdateAll = createAsyncThunk(
MOVIES_UPDATE_WANTED_LIST, "movies/update/all",
(radarrid: number[]) => MoviesApi.wantedBy(radarrid) async () => {
const response = await MoviesApi.movies();
return response;
}
); );
export const movieDeleteWantedItems = createDeleteAction( export const movieRemoveById = createAction<number[]>("movies/remove");
MOVIES_DELETE_WANTED_ITEMS
export const movieMarkDirtyById = createAction<number[]>(
"movies/mark_dirty/id"
); );
export const movieUpdateWantedByRange = createAsyncAction( export const movieUpdateWantedById = createAsyncThunk(
MOVIES_UPDATE_WANTED_LIST, "movies/wanted/update/id",
(start: number, length: number) => MoviesApi.wanted(start, length) async (ids: number[]) => {
const response = await MoviesApi.wantedBy(ids);
return response;
}
); );
export const movieUpdateHistoryList = createAsyncAction( export const movieRemoveWantedById = createAction<number[]>(
MOVIES_UPDATE_HISTORY_LIST, "movies/wanted/remove/id"
() => MoviesApi.history()
); );
export const movieUpdateByRange = createAsyncAction( export const movieMarkWantedDirtyById = createAction<number[]>(
MOVIES_UPDATE_LIST, "movies/wanted/mark_dirty/id"
(start: number, length: number) => MoviesApi.moviesBy(start, length) );
export const movieUpdateWantedByRange = createAsyncThunk(
"movies/wanted/update/range",
async (params: Parameter.Range) => {
const response = await MoviesApi.wanted(params);
return response;
}
);
export const movieUpdateHistory = createAsyncThunk(
"movies/history/update",
async () => {
const response = await MoviesApi.history();
return response;
}
);
export const movieMarkHistoryDirty = createAction("movies/history/mark_dirty");
export const movieUpdateBlacklist = createAsyncThunk(
"movies/blacklist/update",
async () => {
const response = await MoviesApi.blacklist();
return response;
}
); );
export const movieUpdateBlacklist = createAsyncAction( export const movieMarkBlacklistDirty = createAction(
MOVIES_UPDATE_BLACKLIST, "movies/blacklist/mark_dirty"
() => MoviesApi.blacklist()
); );

@ -1,61 +1,100 @@
import { createDeleteAction } from "../../@socketio/reducer"; import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { EpisodesApi, SeriesApi } from "../../apis"; import { EpisodesApi, SeriesApi } from "../../apis";
import {
SERIES_DELETE_EPISODES,
SERIES_DELETE_ITEMS,
SERIES_DELETE_WANTED_ITEMS,
SERIES_UPDATE_BLACKLIST,
SERIES_UPDATE_EPISODE_LIST,
SERIES_UPDATE_HISTORY_LIST,
SERIES_UPDATE_LIST,
SERIES_UPDATE_WANTED_LIST,
} from "../constants";
import { createAsyncAction } from "./factory";
export const seriesUpdateWantedList = createAsyncAction( export const seriesUpdateWantedById = createAsyncThunk(
SERIES_UPDATE_WANTED_LIST, "series/wanted/update/id",
(episodeid: number[]) => EpisodesApi.wantedBy(episodeid) async (episodeid: number[]) => {
const response = await EpisodesApi.wantedBy(episodeid);
return response;
}
); );
export const seriesDeleteWantedItems = createDeleteAction( export const seriesUpdateWantedByRange = createAsyncThunk(
SERIES_DELETE_WANTED_ITEMS "series/wanted/update/range",
async (params: Parameter.Range) => {
const response = await EpisodesApi.wanted(params);
return response;
}
); );
export const seriesUpdateWantedByRange = createAsyncAction( export const seriesRemoveWantedById = createAction<number[]>(
SERIES_UPDATE_WANTED_LIST, "series/wanted/remove/id"
(start: number, length: number) => EpisodesApi.wanted(start, length)
); );
export const seriesUpdateList = createAsyncAction( export const seriesMarkWantedDirtyById = createAction<number[]>(
SERIES_UPDATE_LIST, "series/wanted/mark_dirty/episode_id"
(id?: number[]) => SeriesApi.series(id)
); );
export const seriesDeleteItems = createDeleteAction(SERIES_DELETE_ITEMS); export const seriesRemoveById = createAction<number[]>("series/remove");
export const episodeUpdateBy = createAsyncAction( export const seriesMarkDirtyById = createAction<number[]>(
SERIES_UPDATE_EPISODE_LIST, "series/mark_dirty/id"
(seriesid: number[]) => EpisodesApi.bySeriesId(seriesid)
); );
export const episodeDeleteItems = createDeleteAction(SERIES_DELETE_EPISODES); export const seriesUpdateById = createAsyncThunk(
"series/update/id",
async (ids: number[]) => {
const response = await SeriesApi.series(ids);
return response;
}
);
export const seriesUpdateAll = createAsyncThunk(
"series/update/all",
async () => {
const response = await SeriesApi.series();
return response;
}
);
export const seriesUpdateByRange = createAsyncThunk(
"series/update/range",
async (params: Parameter.Range) => {
const response = await SeriesApi.seriesBy(params);
return response;
}
);
export const episodesRemoveById = createAction<number[]>("episodes/remove");
export const episodeUpdateById = createAsyncAction( export const episodesMarkDirtyById = createAction<number[]>(
SERIES_UPDATE_EPISODE_LIST, "episodes/mark_dirty/id"
(episodeid: number[]) => EpisodesApi.byEpisodeId(episodeid)
); );
export const seriesUpdateByRange = createAsyncAction( export const episodeUpdateBySeriesId = createAsyncThunk(
SERIES_UPDATE_LIST, "episodes/update/series_id",
(start: number, length: number) => SeriesApi.seriesBy(start, length) async (seriesid: number[]) => {
const response = await EpisodesApi.bySeriesId(seriesid);
return response;
}
); );
export const seriesUpdateHistoryList = createAsyncAction( export const episodeUpdateById = createAsyncThunk(
SERIES_UPDATE_HISTORY_LIST, "episodes/update/episodes_id",
() => EpisodesApi.history() async (episodeid: number[]) => {
const response = await EpisodesApi.byEpisodeId(episodeid);
return response;
}
);
export const episodesUpdateHistory = createAsyncThunk(
"episodes/history/update",
async () => {
const response = await EpisodesApi.history();
return response;
}
);
export const episodesMarkHistoryDirty = createAction("episodes/history/update");
export const episodesUpdateBlacklist = createAsyncThunk(
"episodes/blacklist/update",
async () => {
const response = await EpisodesApi.blacklist();
return response;
}
); );
export const seriesUpdateBlacklist = createAsyncAction( export const episodesMarkBlacklistDirty = createAction(
SERIES_UPDATE_BLACKLIST, "episodes/blacklist/update"
() => EpisodesApi.blacklist()
); );

@ -1,63 +1,45 @@
import { createAction } from "redux-actions"; import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { BadgesApi } from "../../apis"; import { BadgesApi } from "../../apis";
import { import { systemUpdateAllSettings } from "./system";
SITE_BADGE_UPDATE,
SITE_INITIALIZED,
SITE_INITIALIZE_FAILED,
SITE_NEED_AUTH,
SITE_NOTIFICATIONS_ADD,
SITE_NOTIFICATIONS_REMOVE,
SITE_OFFLINE_UPDATE,
SITE_PROGRESS_ADD,
SITE_PROGRESS_REMOVE,
SITE_SIDEBAR_UPDATE,
} from "../constants";
import { createAsyncAction, createCallbackAction } from "./factory";
import { systemUpdateLanguagesAll, systemUpdateSettings } from "./system";
export const bootstrap = createCallbackAction(
() => [systemUpdateLanguagesAll(), systemUpdateSettings(), badgeUpdateAll()],
() => siteInitialized(),
() => siteInitializationFailed()
);
// TODO: Override error messages export const siteBootstrap = createAsyncThunk(
export const siteInitializationFailed = createAction(SITE_INITIALIZE_FAILED); "site/bootstrap",
(_: undefined, { dispatch }) => {
return Promise.all([
dispatch(systemUpdateAllSettings()),
dispatch(siteUpdateBadges()),
]);
}
);
const siteInitialized = createAction(SITE_INITIALIZED); export const siteUpdateInitialization = createAction<string | true>(
"site/initialization/update"
);
export const siteRedirectToAuth = createAction(SITE_NEED_AUTH); export const siteRedirectToAuth = createAction("site/redirect_auth");
export const badgeUpdateAll = createAsyncAction(SITE_BADGE_UPDATE, () => export const siteAddNotifications = createAction<Server.Notification[]>(
BadgesApi.all() "site/notifications/add"
); );
export const siteAddNotifications = createAction( export const siteRemoveNotifications = createAction<string>(
SITE_NOTIFICATIONS_ADD, "site/notifications/remove"
(notification: ReduxStore.Notification[]) => notification
); );
export const siteRemoveNotifications = createAction( export const siteAddProgress = createAction<Server.Progress[]>(
SITE_NOTIFICATIONS_REMOVE, "site/progress/add"
(id: string) => id
); );
export const siteAddProgress = createAction( export const siteRemoveProgress = createAction<string>("site/progress/remove");
SITE_PROGRESS_ADD,
(progress: ReduxStore.Progress[]) => progress
);
export const siteRemoveProgress = createAction( export const siteChangeSidebar = createAction<string>("site/sidebar/update");
SITE_PROGRESS_REMOVE,
(id: string) => id
);
export const siteChangeSidebar = createAction( export const siteUpdateOffline = createAction<boolean>("site/offline/update");
SITE_SIDEBAR_UPDATE,
(id: string) => id
);
export const siteUpdateOffline = createAction( export const siteUpdateBadges = createAsyncThunk(
SITE_OFFLINE_UPDATE, "site/badges/update",
(state: boolean) => state async () => {
const response = await BadgesApi.all();
return response;
}
); );

@ -1,64 +1,87 @@
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { ProvidersApi, SystemApi } from "../../apis"; import { ProvidersApi, SystemApi } from "../../apis";
import {
SYSTEM_UPDATE_HEALTH,
SYSTEM_UPDATE_LANGUAGES_LIST,
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST,
SYSTEM_UPDATE_LOGS,
SYSTEM_UPDATE_PROVIDERS,
SYSTEM_UPDATE_RELEASES,
SYSTEM_UPDATE_SETTINGS,
SYSTEM_UPDATE_STATUS,
SYSTEM_UPDATE_TASKS,
} from "../constants";
import { createAsyncAction, createAsyncCombineAction } from "./factory";
export const systemUpdateLanguagesAll = createAsyncCombineAction(() => [ export const systemUpdateAllSettings = createAsyncThunk(
systemUpdateLanguages(), "system/update",
systemUpdateLanguagesProfiles(), async (_: undefined, { dispatch }) => {
]); await Promise.all([
dispatch(systemUpdateSettings()),
export const systemUpdateLanguages = createAsyncAction( dispatch(systemUpdateLanguages()),
SYSTEM_UPDATE_LANGUAGES_LIST, dispatch(systemUpdateLanguagesProfiles()),
() => SystemApi.languages() ]);
}
); );
export const systemUpdateLanguagesProfiles = createAsyncAction( export const systemUpdateLanguages = createAsyncThunk(
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST, "system/languages/update",
() => SystemApi.languagesProfileList() async () => {
const response = await SystemApi.languages();
return response;
}
); );
export const systemUpdateStatus = createAsyncAction(SYSTEM_UPDATE_STATUS, () => export const systemUpdateLanguagesProfiles = createAsyncThunk(
SystemApi.status() "system/languages/profile/update",
async () => {
const response = await SystemApi.languagesProfileList();
return response;
}
); );
export const systemUpdateHealth = createAsyncAction(SYSTEM_UPDATE_HEALTH, () => export const systemUpdateStatus = createAsyncThunk(
SystemApi.health() "system/status/update",
async () => {
const response = await SystemApi.status();
return response;
}
); );
export const systemUpdateTasks = createAsyncAction(SYSTEM_UPDATE_TASKS, () => export const systemUpdateHealth = createAsyncThunk(
SystemApi.getTasks() "system/health/update",
async () => {
const response = await SystemApi.health();
return response;
}
); );
export const systemUpdateLogs = createAsyncAction(SYSTEM_UPDATE_LOGS, () => export const systemMarkTasksDirty = createAction("system/tasks/mark_dirty");
SystemApi.logs()
export const systemUpdateTasks = createAsyncThunk(
"system/tasks/update",
async () => {
const response = await SystemApi.tasks();
return response;
}
); );
export const systemUpdateReleases = createAsyncAction( export const systemUpdateLogs = createAsyncThunk(
SYSTEM_UPDATE_RELEASES, "system/logs/update",
() => SystemApi.releases() async () => {
const response = await SystemApi.logs();
return response;
}
); );
export const systemUpdateSettings = createAsyncAction( export const systemUpdateReleases = createAsyncThunk(
SYSTEM_UPDATE_SETTINGS, "system/releases/update",
() => SystemApi.settings() async () => {
const response = await SystemApi.releases();
return response;
}
); );
export const providerUpdateList = createAsyncAction( export const systemUpdateSettings = createAsyncThunk(
SYSTEM_UPDATE_PROVIDERS, "system/settings/update",
() => ProvidersApi.providers() async () => {
const response = await SystemApi.settings();
return response;
}
); );
export const systemUpdateSettingsAll = createAsyncCombineAction(() => [ export const providerUpdateList = createAsyncThunk(
systemUpdateSettings(), "providers/update",
systemUpdateLanguagesAll(), async () => {
]); const response = await ProvidersApi.providers();
return response;
}
);

@ -1,43 +0,0 @@
// Provider action
// System action
export const SYSTEM_UPDATE_LANGUAGES_LIST = "UPDATE_ALL_LANGUAGES_LIST";
export const SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST =
"UPDATE_LANGUAGES_PROFILE_LIST";
export const SYSTEM_UPDATE_STATUS = "UPDATE_SYSTEM_STATUS";
export const SYSTEM_UPDATE_HEALTH = "UPDATE_SYSTEM_HEALTH";
export const SYSTEM_UPDATE_TASKS = "UPDATE_SYSTEM_TASKS";
export const SYSTEM_UPDATE_LOGS = "UPDATE_SYSTEM_LOGS";
export const SYSTEM_UPDATE_RELEASES = "SYSTEM_UPDATE_RELEASES";
export const SYSTEM_UPDATE_SETTINGS = "UPDATE_SYSTEM_SETTINGS";
export const SYSTEM_UPDATE_PROVIDERS = "SYSTEM_UPDATE_PROVIDERS";
// Series action
export const SERIES_UPDATE_WANTED_LIST = "UPDATE_SERIES_WANTED_LIST";
export const SERIES_DELETE_WANTED_ITEMS = "SERIES_DELETE_WANTED_ITEMS";
export const SERIES_UPDATE_EPISODE_LIST = "UPDATE_SERIES_EPISODE_LIST";
export const SERIES_DELETE_EPISODES = "SERIES_DELETE_EPISODES";
export const SERIES_UPDATE_HISTORY_LIST = "UPDATE_SERIES_HISTORY_LIST";
export const SERIES_UPDATE_LIST = "UPDATE_SEIRES_LIST";
export const SERIES_DELETE_ITEMS = "SERIES_DELETE_ITEMS";
export const SERIES_UPDATE_BLACKLIST = "UPDATE_SERIES_BLACKLIST";
// Movie action
export const MOVIES_UPDATE_LIST = "UPDATE_MOVIE_LIST";
export const MOVIES_DELETE_ITEMS = "MOVIES_DELETE_ITEMS";
export const MOVIES_UPDATE_WANTED_LIST = "UPDATE_MOVIE_WANTED_LIST";
export const MOVIES_DELETE_WANTED_ITEMS = "MOVIES_DELETE_WANTED_ITEMS";
export const MOVIES_UPDATE_HISTORY_LIST = "UPDATE_MOVIE_HISTORY_LIST";
export const MOVIES_UPDATE_BLACKLIST = "UPDATE_MOVIES_BLACKLIST";
// Site Action
export const SITE_NEED_AUTH = "SITE_NEED_AUTH";
export const SITE_INITIALIZED = "SITE_SYSTEM_INITIALIZED";
export const SITE_INITIALIZE_FAILED = "SITE_INITIALIZE_FAILED";
export const SITE_NOTIFICATIONS_ADD = "SITE_NOTIFICATIONS_ADD";
export const SITE_NOTIFICATIONS_REMOVE = "SITE_NOTIFICATIONS_REMOVE";
export const SITE_PROGRESS_ADD = "SITE_PROGRESS_ADD";
export const SITE_PROGRESS_REMOVE = "SITE_PROGRESS_REMOVE";
export const SITE_SIDEBAR_UPDATE = "SITE_SIDEBAR_UPDATE";
export const SITE_BADGE_UPDATE = "SITE_BADGE_UPDATE";
export const SITE_OFFLINE_UPDATE = "SITE_OFFLINE_UPDATE";

@ -0,0 +1,29 @@
import { AsyncThunk } from "@reduxjs/toolkit";
import { useEffect } from "react";
import { log } from "../../utilites/logger";
import { useReduxAction } from "./base";
export function useAutoUpdate(item: Async.Item<any>, update: () => void) {
useEffect(() => {
if (item.state === "uninitialized" || item.state === "dirty") {
update();
}
}, [item.state, update]);
}
export function useAutoDirtyUpdate(
item: Async.List<any> | Async.Entity<any>,
updateAction: AsyncThunk<any, number[], {}>
) {
const { state, dirtyEntities } = item;
const hasDirty = dirtyEntities.length > 0 && state === "dirty";
const update = useReduxAction(updateAction);
useEffect(() => {
if (hasDirty) {
log("info", "updating dirty entities...");
update(dirtyEntities.map(Number));
}
}, [hasDirty, dirtyEntities, update]);
}

@ -1,36 +1,24 @@
import { ActionCreator } from "@reduxjs/toolkit";
import { useCallback } from "react"; import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { createCallbackAction } from "../actions/factory"; import { AppDispatch, RootState } from "../store";
import { ActionCallback, AsyncActionDispatcher } from "../types";
// function use // function use
export function useReduxStore<T extends (store: ReduxStore) => any>( export function useReduxStore<T extends (store: RootState) => any>(
selector: T selector: T
) { ) {
return useSelector<ReduxStore, ReturnType<T>>(selector); return useSelector<RootState, ReturnType<T>>(selector);
} }
export function useReduxAction<T extends (...args: any[]) => void>(action: T) { export function useAppDispatch() {
const dispatch = useDispatch(); return useDispatch<AppDispatch>();
return useCallback((...args: Parameters<T>) => dispatch(action(...args)), [
action,
dispatch,
]);
} }
export function useReduxActionWith< // TODO: Fix type
T extends (...args: any[]) => AsyncActionDispatcher<any> export function useReduxAction<T extends ActionCreator<any>>(action: T) {
>(action: T, success: ActionCallback) { const dispatch = useAppDispatch();
const dispatch = useDispatch();
return useCallback( return useCallback(
(...args: Parameters<T>) => { (...args: Parameters<T>) => dispatch(action(...args)),
const callbackAction = createCallbackAction( [action, dispatch]
() => [action(...args)],
success
);
dispatch(callbackAction());
},
[dispatch, action, success]
); );
} }

@ -1,393 +1,4 @@
import { useCallback, useEffect, useMemo } from "react"; export * from "./movies";
import { useSocketIOReducer, useWrapToOptionalId } from "../../@socketio/hooks"; export * from "./series";
import { buildOrderList } from "../../utilites"; export * from "./site";
import { export * from "./system";
episodeDeleteItems,
episodeUpdateBy,
episodeUpdateById,
movieUpdateBlacklist,
movieUpdateHistoryList,
movieUpdateList,
movieUpdateWantedList,
providerUpdateList,
seriesUpdateBlacklist,
seriesUpdateHistoryList,
seriesUpdateList,
seriesUpdateWantedList,
systemUpdateHealth,
systemUpdateLanguages,
systemUpdateLanguagesProfiles,
systemUpdateLogs,
systemUpdateReleases,
systemUpdateSettingsAll,
systemUpdateStatus,
systemUpdateTasks,
} from "../actions";
import { useReduxAction, useReduxStore } from "./base";
function stateBuilder<T, D extends (...args: any[]) => any>(
t: T,
d: D
): [Readonly<T>, D] {
return [t, d];
}
export function useSystemSettings() {
const update = useReduxAction(systemUpdateSettingsAll);
const items = useReduxStore((s) => s.system.settings);
return stateBuilder(items, update);
}
export function useSystemLogs() {
const items = useReduxStore(({ system }) => system.logs);
const update = useReduxAction(systemUpdateLogs);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useSystemTasks() {
const items = useReduxStore((s) => s.system.tasks);
const update = useReduxAction(systemUpdateTasks);
const reducer = useMemo<SocketIO.Reducer>(() => ({ key: "task", update }), [
update,
]);
useSocketIOReducer(reducer);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useSystemStatus() {
const items = useReduxStore((s) => s.system.status.data);
const update = useReduxAction(systemUpdateStatus);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useSystemHealth() {
const update = useReduxAction(systemUpdateHealth);
const items = useReduxStore((s) => s.system.health);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useSystemProviders() {
const update = useReduxAction(providerUpdateList);
const items = useReduxStore((d) => d.system.providers);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useSystemReleases() {
const items = useReduxStore(({ system }) => system.releases);
const update = useReduxAction(systemUpdateReleases);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useLanguageProfiles() {
const action = useReduxAction(systemUpdateLanguagesProfiles);
const items = useReduxStore((s) => s.system.languagesProfiles.data);
return stateBuilder(items, action);
}
export function useProfileBy(id: number | null | undefined) {
const [profiles] = useLanguageProfiles();
return useMemo(() => profiles.find((v) => v.profileId === id), [
id,
profiles,
]);
}
export function useLanguages(enabled: boolean = false) {
const action = useReduxAction(systemUpdateLanguages);
const items = useReduxStore((s) =>
enabled ? s.system.enabledLanguage.data : s.system.languages.data
);
return stateBuilder(items, action);
}
function useLanguageGetter(enabled: boolean = false) {
const [languages] = useLanguages(enabled);
return useCallback(
(code?: string) => {
if (code === undefined) {
return undefined;
} else {
return languages.find((v) => v.code2 === code);
}
},
[languages]
);
}
export function useLanguageBy(code?: string) {
const getter = useLanguageGetter();
return useMemo(() => getter(code), [code, getter]);
}
// Convert languageprofile items to language
export function useProfileItems(profile?: Profile.Languages) {
const getter = useLanguageGetter(true);
return useMemo(
() =>
profile?.items.map<Language>(({ language, hi, forced }) => {
const name = getter(language)?.name ?? "";
return {
hi: hi === "True",
forced: forced === "True",
code2: language,
name,
};
}) ?? [],
[getter, profile?.items]
);
}
export function useRawSeries() {
const update = useReduxAction(seriesUpdateList);
const items = useReduxStore((d) => d.series.seriesList);
return stateBuilder(items, update);
}
export function useSeries(order = true) {
const [rawSeries, action] = useRawSeries();
const series = useMemo<AsyncState<Item.Series[]>>(() => {
const state = rawSeries.data;
if (order) {
return {
...rawSeries,
data: buildOrderList(state),
};
} else {
return {
...rawSeries,
data: Object.values(state.items),
};
}
}, [rawSeries, order]);
return stateBuilder(series, action);
}
export function useSerieBy(id?: number) {
const [series, updateSerie] = useRawSeries();
const serie = useMemo<AsyncState<Item.Series | null>>(() => {
const items = series.data.items;
let item: Item.Series | null = null;
if (id && !isNaN(id) && id in items) {
item = items[id];
}
return {
...series,
data: item,
};
}, [id, series]);
const update = useCallback(() => {
if (id && !isNaN(id)) {
updateSerie([id]);
}
}, [id, updateSerie]);
useEffect(() => {
if (serie.data === null) {
update();
}
}, [serie.data, update]);
return stateBuilder(serie, update);
}
export function useEpisodesBy(seriesId?: number) {
const action = useReduxAction(episodeUpdateBy);
const update = useCallback(() => {
if (seriesId !== undefined && !isNaN(seriesId)) {
action([seriesId]);
}
}, [action, seriesId]);
const list = useReduxStore((d) => d.series.episodeList);
const items = useMemo(() => {
if (seriesId !== undefined && !isNaN(seriesId)) {
return list.data.filter((v) => v.sonarrSeriesId === seriesId);
} else {
return [];
}
}, [seriesId, list.data]);
const state: AsyncState<Item.Episode[]> = useMemo(
() => ({
...list,
data: items,
}),
[list, items]
);
const actionById = useReduxAction(episodeUpdateById);
const wrapActionById = useWrapToOptionalId(actionById);
const deleteAction = useReduxAction(episodeDeleteItems);
const episodeReducer = useMemo<SocketIO.Reducer>(
() => ({ key: "episode", update: wrapActionById, delete: deleteAction }),
[wrapActionById, deleteAction]
);
useSocketIOReducer(episodeReducer);
const wrapAction = useWrapToOptionalId(action);
const seriesReducer = useMemo<SocketIO.Reducer>(
() => ({ key: "series", update: wrapAction }),
[wrapAction]
);
useSocketIOReducer(seriesReducer);
useEffect(() => {
update();
}, [update]);
return stateBuilder(state, update);
}
export function useRawMovies() {
const update = useReduxAction(movieUpdateList);
const items = useReduxStore((d) => d.movie.movieList);
return stateBuilder(items, update);
}
export function useMovies(order = true) {
const [rawMovies, action] = useRawMovies();
const movies = useMemo<AsyncState<Item.Movie[]>>(() => {
const state = rawMovies.data;
if (order) {
return {
...rawMovies,
data: buildOrderList(state),
};
} else {
return {
...rawMovies,
data: Object.values(state.items),
};
}
}, [rawMovies, order]);
return stateBuilder(movies, action);
}
export function useMovieBy(id?: number) {
const [movies, updateMovies] = useRawMovies();
const movie = useMemo<AsyncState<Item.Movie | null>>(() => {
const items = movies.data.items;
let item: Item.Movie | null = null;
if (id && !isNaN(id) && id in items) {
item = items[id];
}
return {
...movies,
data: item,
};
}, [id, movies]);
const update = useCallback(() => {
if (id && !isNaN(id)) {
updateMovies([id]);
}
}, [id, updateMovies]);
useEffect(() => {
if (movie.data === null) {
update();
}
}, [movie.data, update]);
return stateBuilder(movie, update);
}
export function useWantedSeries() {
const update = useReduxAction(seriesUpdateWantedList);
const items = useReduxStore((d) => d.series.wantedEpisodesList);
return stateBuilder(items, update);
}
export function useWantedMovies() {
const update = useReduxAction(movieUpdateWantedList);
const items = useReduxStore((d) => d.movie.wantedMovieList);
return stateBuilder(items, update);
}
export function useBlacklistMovies() {
const update = useReduxAction(movieUpdateBlacklist);
const items = useReduxStore((d) => d.movie.blacklist);
const reducer = useMemo<SocketIO.Reducer>(
() => ({ key: "movie-blacklist", any: update }),
[update]
);
useSocketIOReducer(reducer);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useBlacklistSeries() {
const update = useReduxAction(seriesUpdateBlacklist);
const items = useReduxStore((d) => d.series.blacklist);
const reducer = useMemo<SocketIO.Reducer>(
() => ({ key: "episode-blacklist", any: update }),
[update]
);
useSocketIOReducer(reducer);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useMoviesHistory() {
const update = useReduxAction(movieUpdateHistoryList);
const items = useReduxStore((s) => s.movie.historyList);
const reducer = useMemo<SocketIO.Reducer>(
() => ({ key: "movie-history", update }),
[update]
);
useSocketIOReducer(reducer);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}
export function useSeriesHistory() {
const update = useReduxAction(seriesUpdateHistoryList);
const items = useReduxStore((s) => s.series.historyList);
const reducer = useMemo<SocketIO.Reducer>(
() => ({ key: "episode-history", update }),
[update]
);
useSocketIOReducer(reducer);
useEffect(() => {
update();
}, [update]);
return stateBuilder(items, update);
}

@ -0,0 +1,70 @@
import { useCallback, useMemo } from "react";
import { useEntityItemById, useEntityToList } from "../../utilites";
import {
movieUpdateBlacklist,
movieUpdateById,
movieUpdateHistory,
movieUpdateWantedById,
} from "../actions";
import { useAutoDirtyUpdate, useAutoUpdate } from "./async";
import { useReduxAction, useReduxStore } from "./base";
export function useMovieEntities() {
const entities = useReduxStore((d) => d.movies.movieList);
useAutoDirtyUpdate(entities, movieUpdateById);
return entities;
}
export function useMovies() {
const rawMovies = useMovieEntities();
const content = useEntityToList(rawMovies.content);
const movies = useMemo<Async.List<Item.Movie>>(() => {
return {
...rawMovies,
keyName: rawMovies.content.keyName,
content,
};
}, [rawMovies, content]);
return movies;
}
export function useMovieBy(id: number) {
const movies = useMovieEntities();
const action = useReduxAction(movieUpdateById);
const update = useCallback(() => {
if (!isNaN(id)) {
action([id]);
}
}, [id, action]);
const movie = useEntityItemById(movies, id.toString());
useAutoUpdate(movie, update);
return movie;
}
export function useWantedMovies() {
const items = useReduxStore((d) => d.movies.wantedMovieList);
useAutoDirtyUpdate(items, movieUpdateWantedById);
return items;
}
export function useBlacklistMovies() {
const update = useReduxAction(movieUpdateBlacklist);
const items = useReduxStore((d) => d.movies.blacklist);
useAutoUpdate(items, update);
return items;
}
export function useMoviesHistory() {
const update = useReduxAction(movieUpdateHistory);
const items = useReduxStore((s) => s.movies.historyList);
useAutoUpdate(items, update);
return items;
}

@ -0,0 +1,102 @@
import { useCallback, useEffect, useMemo } from "react";
import { useEntityItemById, useEntityToList } from "../../utilites";
import {
episodesUpdateBlacklist,
episodesUpdateHistory,
episodeUpdateById,
episodeUpdateBySeriesId,
seriesUpdateById,
seriesUpdateWantedById,
} from "../actions";
import { useAutoDirtyUpdate, useAutoUpdate } from "./async";
import { useReduxAction, useReduxStore } from "./base";
export function useSerieEntities() {
const items = useReduxStore((d) => d.series.seriesList);
useAutoDirtyUpdate(items, seriesUpdateById);
return items;
}
export function useSeries() {
const rawSeries = useSerieEntities();
const content = useEntityToList(rawSeries.content);
const series = useMemo<Async.List<Item.Series>>(() => {
return {
...rawSeries,
keyName: rawSeries.content.keyName,
content,
};
}, [rawSeries, content]);
return series;
}
export function useSerieBy(id: number) {
const series = useSerieEntities();
const action = useReduxAction(seriesUpdateById);
const serie = useEntityItemById(series, id.toString());
const update = useCallback(() => {
if (!isNaN(id)) {
action([id]);
}
}, [id, action]);
useAutoUpdate(serie, update);
return serie;
}
export function useEpisodesBy(seriesId: number) {
const action = useReduxAction(episodeUpdateBySeriesId);
const update = useCallback(() => {
if (!isNaN(seriesId)) {
action([seriesId]);
}
}, [action, seriesId]);
const episodes = useReduxStore((d) => d.series.episodeList);
const newContent = useMemo(() => {
return episodes.content.filter((v) => v.sonarrSeriesId === seriesId);
}, [seriesId, episodes.content]);
const newList: Async.List<Item.Episode> = useMemo(
() => ({
...episodes,
content: newContent,
}),
[episodes, newContent]
);
// FIXME
useEffect(() => {
update();
}, [update]);
useAutoDirtyUpdate(episodes, episodeUpdateById);
return newList;
}
export function useWantedSeries() {
const items = useReduxStore((d) => d.series.wantedEpisodesList);
useAutoDirtyUpdate(items, seriesUpdateWantedById);
return items;
}
export function useBlacklistSeries() {
const update = useReduxAction(episodesUpdateBlacklist);
const items = useReduxStore((d) => d.series.blacklist);
useAutoUpdate(items, update);
return items;
}
export function useSeriesHistory() {
const update = useReduxAction(episodesUpdateHistory);
const items = useReduxStore((s) => s.series.historyList);
useAutoUpdate(items, update);
return items;
}

@ -7,8 +7,8 @@ export function useNotification(id: string, timeout: number = 5000) {
const add = useReduxAction(siteAddNotifications); const add = useReduxAction(siteAddNotifications);
return useCallback( return useCallback(
(msg: Omit<ReduxStore.Notification, "id" | "timeout">) => { (msg: Omit<Server.Notification, "id" | "timeout">) => {
const notification: ReduxStore.Notification = { const notification: Server.Notification = {
...msg, ...msg,
id, id,
timeout, timeout,
@ -24,18 +24,18 @@ export function useIsOffline() {
} }
export function useIsSonarrEnabled() { export function useIsSonarrEnabled() {
const [settings] = useSystemSettings(); const settings = useSystemSettings();
return settings.data?.general.use_sonarr ?? true; return settings.content?.general.use_sonarr ?? true;
} }
export function useIsRadarrEnabled() { export function useIsRadarrEnabled() {
const [settings] = useSystemSettings(); const settings = useSystemSettings();
return settings.data?.general.use_radarr ?? true; return settings.content?.general.use_radarr ?? true;
} }
export function useShowOnlyDesired() { export function useShowOnlyDesired() {
const [settings] = useSystemSettings(); const settings = useSystemSettings();
return settings.data?.general.embedded_subs_show_desired ?? false; return settings.content?.general.embedded_subs_show_desired ?? false;
} }
export function useSetSidebar(key: string) { export function useSetSidebar(key: string) {

@ -0,0 +1,131 @@
import { useMemo } from "react";
import {
providerUpdateList,
systemUpdateHealth,
systemUpdateLogs,
systemUpdateReleases,
systemUpdateStatus,
systemUpdateTasks,
} from "../actions";
import { useAutoUpdate } from "./async";
import { useReduxAction, useReduxStore } from "./base";
export function useSystemSettings() {
const items = useReduxStore((s) => s.system.settings);
return items;
}
export function useSystemLogs() {
const items = useReduxStore(({ system }) => system.logs);
const update = useReduxAction(systemUpdateLogs);
useAutoUpdate(items, update);
return items;
}
export function useSystemTasks() {
const items = useReduxStore((s) => s.system.tasks);
const update = useReduxAction(systemUpdateTasks);
useAutoUpdate(items, update);
return items;
}
export function useSystemStatus() {
const items = useReduxStore((s) => s.system.status);
const update = useReduxAction(systemUpdateStatus);
useAutoUpdate(items, update);
return items.content;
}
export function useSystemHealth() {
const items = useReduxStore((s) => s.system.health);
const update = useReduxAction(systemUpdateHealth);
useAutoUpdate(items, update);
return items;
}
export function useSystemProviders() {
const update = useReduxAction(providerUpdateList);
const items = useReduxStore((d) => d.system.providers);
useAutoUpdate(items, update);
return items;
}
export function useSystemReleases() {
const items = useReduxStore(({ system }) => system.releases);
const update = useReduxAction(systemUpdateReleases);
useAutoUpdate(items, update);
return items;
}
export function useLanguageProfiles() {
const items = useReduxStore((s) => s.system.languagesProfiles);
return items.content;
}
export function useProfileBy(id: number | null | undefined) {
const profiles = useLanguageProfiles();
return useMemo(
() => profiles?.find((v) => v.profileId === id),
[id, profiles]
);
}
export function useLanguages() {
const data = useReduxStore((s) => s.system.languages);
const languages = useMemo<Language.Info[]>(
() => data.content?.map((v) => ({ code2: v.code2, name: v.name })) ?? [],
[data.content]
);
return languages;
}
export function useEnabledLanguages() {
const data = useReduxStore((s) => s.system.languages);
const enabled = useMemo<Language.Info[]>(
() =>
data.content
?.filter((v) => v.enabled)
.map((v) => ({ code2: v.code2, name: v.name })) ?? [],
[data.content]
);
return enabled;
}
export function useLanguageBy(code?: string) {
const languages = useLanguages();
return useMemo(
() => languages.find((v) => v.code2 === code),
[languages, code]
);
}
// Convert languageprofile items to language
export function useProfileItemsToLanguages(profile?: Language.Profile) {
const languages = useLanguages();
return useMemo(
() =>
profile?.items.map<Language.Info>(({ language: code, hi, forced }) => {
const name = languages.find((v) => v.code2 === code)?.name ?? "";
return {
hi: hi === "True",
forced: forced === "True",
code2: code,
name,
};
}) ?? [],
[languages, profile?.items]
);
}

@ -1,12 +1,13 @@
import { combineReducers } from "redux"; import movies from "./movie";
import movie from "./movie";
import series from "./series"; import series from "./series";
import site from "./site"; import site from "./site";
import system from "./system"; import system from "./system";
export default combineReducers({ const AllReducers = {
system, movies,
series, series,
movie,
site, site,
}); system,
};
export default AllReducers;

@ -1,81 +1,64 @@
import { Action, handleActions } from "redux-actions"; import { createReducer } from "@reduxjs/toolkit";
import { import {
MOVIES_DELETE_ITEMS, movieMarkBlacklistDirty,
MOVIES_DELETE_WANTED_ITEMS, movieMarkDirtyById,
MOVIES_UPDATE_BLACKLIST, movieMarkHistoryDirty,
MOVIES_UPDATE_HISTORY_LIST, movieMarkWantedDirtyById,
MOVIES_UPDATE_LIST, movieRemoveById,
MOVIES_UPDATE_WANTED_LIST, movieRemoveWantedById,
} from "../constants"; movieUpdateAll,
import { AsyncAction } from "../types"; movieUpdateBlacklist,
import { defaultAOS } from "../utils"; movieUpdateById,
movieUpdateByRange,
movieUpdateHistory,
movieUpdateWantedById,
movieUpdateWantedByRange,
} from "../actions";
import { AsyncUtility } from "../utils/async";
import { import {
deleteOrderListItemBy, createAsyncEntityReducer,
updateAsyncState, createAsyncItemReducer,
updateOrderIdState, } from "../utils/factory";
} from "../utils/mapper";
const reducer = handleActions<ReduxStore.Movie, any>( interface Movie {
{ movieList: Async.Entity<Item.Movie>;
[MOVIES_UPDATE_WANTED_LIST]: ( wantedMovieList: Async.Entity<Wanted.Movie>;
state, historyList: Async.Item<History.Movie[]>;
action: AsyncAction<AsyncDataWrapper<Wanted.Movie>> blacklist: Async.Item<Blacklist.Movie[]>;
) => { }
return {
...state, const defaultMovie: Movie = {
wantedMovieList: updateOrderIdState( movieList: AsyncUtility.getDefaultEntity("radarrId"),
action, wantedMovieList: AsyncUtility.getDefaultEntity("radarrId"),
state.wantedMovieList, historyList: AsyncUtility.getDefaultItem(),
"radarrId" blacklist: AsyncUtility.getDefaultItem(),
), };
};
}, const reducer = createReducer(defaultMovie, (builder) => {
[MOVIES_DELETE_WANTED_ITEMS]: (state, action: Action<number[]>) => { createAsyncEntityReducer(builder, (s) => s.movieList, {
return { range: movieUpdateByRange,
...state, ids: movieUpdateById,
wantedMovieList: deleteOrderListItemBy(action, state.wantedMovieList), removeIds: movieRemoveById,
}; all: movieUpdateAll,
}, dirty: movieMarkDirtyById,
[MOVIES_UPDATE_HISTORY_LIST]: ( });
state,
action: AsyncAction<History.Movie[]> createAsyncEntityReducer(builder, (s) => s.wantedMovieList, {
) => { range: movieUpdateWantedByRange,
return { ids: movieUpdateWantedById,
...state, removeIds: movieRemoveWantedById,
historyList: updateAsyncState(action, state.historyList.data), dirty: movieMarkWantedDirtyById,
}; });
},
[MOVIES_UPDATE_LIST]: ( createAsyncItemReducer(builder, (s) => s.historyList, {
state, all: movieUpdateHistory,
action: AsyncAction<AsyncDataWrapper<Item.Movie>> dirty: movieMarkHistoryDirty,
) => { });
return {
...state, createAsyncItemReducer(builder, (s) => s.blacklist, {
movieList: updateOrderIdState(action, state.movieList, "radarrId"), all: movieUpdateBlacklist,
}; dirty: movieMarkBlacklistDirty,
}, });
[MOVIES_DELETE_ITEMS]: (state, action: Action<number[]>) => { });
return {
...state,
movieList: deleteOrderListItemBy(action, state.movieList),
};
},
[MOVIES_UPDATE_BLACKLIST]: (
state,
action: AsyncAction<Blacklist.Movie[]>
) => {
return {
...state,
blacklist: updateAsyncState(action, state.blacklist.data),
};
},
},
{
movieList: defaultAOS(),
wantedMovieList: defaultAOS(),
historyList: { updating: true, data: [] },
blacklist: { updating: true, data: [] },
}
);
export default reducer; export default reducer;

@ -1,116 +1,96 @@
import { Action, handleActions } from "redux-actions"; import { createReducer } from "@reduxjs/toolkit";
import { import {
SERIES_DELETE_EPISODES, episodesMarkBlacklistDirty,
SERIES_DELETE_ITEMS, episodesMarkDirtyById,
SERIES_DELETE_WANTED_ITEMS, episodesMarkHistoryDirty,
SERIES_UPDATE_BLACKLIST, episodesRemoveById,
SERIES_UPDATE_EPISODE_LIST, episodesUpdateBlacklist,
SERIES_UPDATE_HISTORY_LIST, episodesUpdateHistory,
SERIES_UPDATE_LIST, episodeUpdateById,
SERIES_UPDATE_WANTED_LIST, episodeUpdateBySeriesId,
} from "../constants"; seriesMarkDirtyById,
import { AsyncAction } from "../types"; seriesMarkWantedDirtyById,
import { defaultAOS } from "../utils"; seriesRemoveById,
seriesRemoveWantedById,
seriesUpdateAll,
seriesUpdateById,
seriesUpdateByRange,
seriesUpdateWantedById,
seriesUpdateWantedByRange,
} from "../actions";
import { AsyncReducer, AsyncUtility } from "../utils/async";
import { import {
deleteAsyncListItemBy, createAsyncEntityReducer,
deleteOrderListItemBy, createAsyncItemReducer,
updateAsyncList, createAsyncListReducer,
updateAsyncState, } from "../utils/factory";
updateOrderIdState,
} from "../utils/mapper";
const reducer = handleActions<ReduxStore.Series, any>( interface Series {
{ seriesList: Async.Entity<Item.Series>;
[SERIES_UPDATE_WANTED_LIST]: ( wantedEpisodesList: Async.Entity<Wanted.Episode>;
state, episodeList: Async.List<Item.Episode>;
action: AsyncAction<AsyncDataWrapper<Wanted.Episode>> historyList: Async.Item<History.Episode[]>;
) => { blacklist: Async.Item<Blacklist.Episode[]>;
return { }
...state,
wantedEpisodesList: updateOrderIdState( const defaultSeries: Series = {
action, seriesList: AsyncUtility.getDefaultEntity("sonarrSeriesId"),
state.wantedEpisodesList, wantedEpisodesList: AsyncUtility.getDefaultEntity("sonarrEpisodeId"),
"sonarrEpisodeId" episodeList: AsyncUtility.getDefaultList("sonarrEpisodeId"),
), historyList: AsyncUtility.getDefaultItem(),
}; blacklist: AsyncUtility.getDefaultItem(),
}, };
[SERIES_DELETE_WANTED_ITEMS]: (state, action: Action<number[]>) => {
return { const reducer = createReducer(defaultSeries, (builder) => {
...state, createAsyncEntityReducer(builder, (s) => s.seriesList, {
wantedEpisodesList: deleteOrderListItemBy( range: seriesUpdateByRange,
action, ids: seriesUpdateById,
state.wantedEpisodesList removeIds: seriesRemoveById,
), all: seriesUpdateAll,
}; });
},
[SERIES_UPDATE_EPISODE_LIST]: ( builder.addCase(seriesMarkDirtyById, (state, action) => {
state, const series = state.seriesList;
action: AsyncAction<Item.Episode[]> const dirtyIds = action.payload.map(String);
) => {
return { AsyncReducer.markDirty(series, dirtyIds);
...state,
episodeList: updateAsyncList( // Update episode list
action, const episodes = state.episodeList;
state.episodeList, const dirtyIdsSet = new Set(dirtyIds);
"sonarrEpisodeId" const dirtyEpisodeIds = episodes.content
), .filter((v) => dirtyIdsSet.has(v.sonarrSeriesId.toString()))
}; .map((v) => String(v.sonarrEpisodeId));
},
[SERIES_DELETE_EPISODES]: (state, action: Action<number[]>) => { AsyncReducer.markDirty(episodes, dirtyEpisodeIds);
return { });
...state,
episodeList: deleteAsyncListItemBy( createAsyncEntityReducer(builder, (s) => s.wantedEpisodesList, {
action, range: seriesUpdateWantedByRange,
state.episodeList, ids: seriesUpdateWantedById,
"sonarrEpisodeId" removeIds: seriesRemoveWantedById,
), dirty: seriesMarkWantedDirtyById,
}; });
},
[SERIES_UPDATE_HISTORY_LIST]: ( createAsyncItemReducer(builder, (s) => s.historyList, {
state, all: episodesUpdateHistory,
action: AsyncAction<History.Episode[]> dirty: episodesMarkHistoryDirty,
) => { });
return {
...state, createAsyncItemReducer(builder, (s) => s.blacklist, {
historyList: updateAsyncState(action, state.historyList.data), all: episodesUpdateBlacklist,
}; dirty: episodesMarkBlacklistDirty,
}, });
[SERIES_UPDATE_LIST]: (
state, createAsyncListReducer(builder, (s) => s.episodeList, {
action: AsyncAction<AsyncDataWrapper<Item.Series>> ids: episodeUpdateBySeriesId,
) => { });
return {
...state, createAsyncListReducer(builder, (s) => s.episodeList, {
seriesList: updateOrderIdState( ids: episodeUpdateById,
action, removeIds: episodesRemoveById,
state.seriesList, dirty: episodesMarkDirtyById,
"sonarrSeriesId" });
), });
};
},
[SERIES_DELETE_ITEMS]: (state, action: Action<number[]>) => {
return {
...state,
seriesList: deleteOrderListItemBy(action, state.seriesList),
};
},
[SERIES_UPDATE_BLACKLIST]: (
state,
action: AsyncAction<Blacklist.Episode[]>
) => {
return {
...state,
blacklist: updateAsyncState(action, state.blacklist.data),
};
},
},
{
seriesList: defaultAOS(),
wantedEpisodesList: defaultAOS(),
episodeList: { updating: true, data: [] },
historyList: { updating: true, data: [] },
blacklist: { updating: true, data: [] },
}
);
export default reducer; export default reducer;

@ -1,100 +1,89 @@
import { createReducer } from "@reduxjs/toolkit";
import { remove, uniqBy } from "lodash"; import { remove, uniqBy } from "lodash";
import { Action, handleActions } from "redux-actions";
import apis from "../../apis"; import apis from "../../apis";
import { import {
SITE_BADGE_UPDATE, siteAddNotifications,
SITE_INITIALIZED, siteAddProgress,
SITE_INITIALIZE_FAILED, siteBootstrap,
SITE_NEED_AUTH, siteChangeSidebar,
SITE_NOTIFICATIONS_ADD, siteRedirectToAuth,
SITE_NOTIFICATIONS_REMOVE, siteRemoveNotifications,
SITE_OFFLINE_UPDATE, siteRemoveProgress,
SITE_PROGRESS_ADD, siteUpdateBadges,
SITE_PROGRESS_REMOVE, siteUpdateInitialization,
SITE_SIDEBAR_UPDATE, siteUpdateOffline,
} from "../constants"; } from "../actions/site";
import { AsyncAction } from "../types";
const reducer = handleActions<ReduxStore.Site, any>( interface Site {
{ // Initialization state or error message
[SITE_NEED_AUTH]: (state) => { initialized: boolean | string;
if (process.env.NODE_ENV !== "development") { auth: boolean;
progress: Server.Progress[];
notifications: Server.Notification[];
sidebar: string;
badges: Badge;
offline: boolean;
}
const defaultSite: Site = {
initialized: false,
auth: true,
progress: [],
notifications: [],
sidebar: "",
badges: {
movies: 0,
episodes: 0,
providers: 0,
status: 0,
},
offline: false,
};
const reducer = createReducer(defaultSite, (builder) => {
builder
.addCase(siteBootstrap.fulfilled, (state) => {
state.initialized = true;
})
.addCase(siteBootstrap.rejected, (state) => {
state.initialized = "An Error Occurred When Initializing Bazarr UI";
})
.addCase(siteRedirectToAuth, (state) => {
if (process.env.NODE_ENV !== "production") {
apis.danger_resetApi("NEED_AUTH"); apis.danger_resetApi("NEED_AUTH");
} }
return { state.auth = false;
...state, })
auth: false, .addCase(siteUpdateInitialization, (state, action) => {
}; state.initialized = action.payload;
}, })
[SITE_INITIALIZED]: (state) => ({ .addCase(siteAddNotifications, (state, action) => {
...state, state.notifications = uniqBy(
initialized: true,
}),
[SITE_INITIALIZE_FAILED]: (state) => ({
...state,
initialized: "An Error Occurred When Initializing Bazarr UI",
}),
[SITE_NOTIFICATIONS_ADD]: (
state,
action: Action<ReduxStore.Notification[]>
) => {
const notifications = uniqBy(
[...action.payload.reverse(), ...state.notifications], [...action.payload.reverse(), ...state.notifications],
(n) => n.id (n) => n.id
); );
return { ...state, notifications }; })
}, .addCase(siteRemoveNotifications, (state, action) => {
[SITE_NOTIFICATIONS_REMOVE]: (state, action: Action<string>) => { remove(state.notifications, (n) => n.id === action.payload);
const notifications = [...state.notifications]; })
remove(notifications, (n) => n.id === action.payload); .addCase(siteAddProgress, (state, action) => {
return { ...state, notifications }; state.progress = uniqBy(
},
[SITE_PROGRESS_ADD]: (state, action: Action<ReduxStore.Progress[]>) => {
const progress = uniqBy(
[...action.payload.reverse(), ...state.progress], [...action.payload.reverse(), ...state.progress],
(n) => n.id (n) => n.id
); );
return { ...state, progress }; })
}, .addCase(siteRemoveProgress, (state, action) => {
[SITE_PROGRESS_REMOVE]: (state, action: Action<string>) => { remove(state.progress, (n) => n.id === action.payload);
const progress = [...state.progress]; })
remove(progress, (n) => n.id === action.payload); .addCase(siteChangeSidebar, (state, action) => {
return { ...state, progress }; state.sidebar = action.payload;
}, })
[SITE_SIDEBAR_UPDATE]: (state, action: Action<string>) => { .addCase(siteUpdateOffline, (state, action) => {
return { state.offline = action.payload;
...state, })
sidebar: action.payload, .addCase(siteUpdateBadges.fulfilled, (state, action) => {
}; state.badges = action.payload;
}, });
[SITE_BADGE_UPDATE]: { });
next: (state, action: AsyncAction<Badge>) => {
const badges = action.payload.item;
if (badges && action.error !== true) {
return { ...state, badges: badges as Badge };
}
return state;
},
throw: (state) => state,
},
[SITE_OFFLINE_UPDATE]: (state, action: Action<boolean>) => {
return { ...state, offline: action.payload };
},
},
{
initialized: false,
auth: true,
progress: [],
notifications: [],
sidebar: "",
badges: {
movies: 0,
episodes: 0,
providers: 0,
status: 0,
},
offline: false,
}
);
export default reducer; export default reducer;

@ -1,121 +1,74 @@
import { handleActions } from "redux-actions"; import { createReducer } from "@reduxjs/toolkit";
import { import {
SYSTEM_UPDATE_HEALTH, providerUpdateList,
SYSTEM_UPDATE_LANGUAGES_LIST, systemMarkTasksDirty,
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST, systemUpdateHealth,
SYSTEM_UPDATE_LOGS, systemUpdateLanguages,
SYSTEM_UPDATE_PROVIDERS, systemUpdateLanguagesProfiles,
SYSTEM_UPDATE_RELEASES, systemUpdateLogs,
SYSTEM_UPDATE_SETTINGS, systemUpdateReleases,
SYSTEM_UPDATE_STATUS, systemUpdateSettings,
SYSTEM_UPDATE_TASKS, systemUpdateStatus,
} from "../constants"; systemUpdateTasks,
import { updateAsyncState } from "../utils/mapper"; } from "../actions";
import { AsyncUtility } from "../utils/async";
import { createAsyncItemReducer } from "../utils/factory";
const reducer = handleActions<ReduxStore.System, any>( interface System {
{ languages: Async.Item<Language.Server[]>;
[SYSTEM_UPDATE_LANGUAGES_LIST]: (state, action) => { languagesProfiles: Async.Item<Language.Profile[]>;
const languages = updateAsyncState<Array<ApiLanguage>>(action, []); status: Async.Item<System.Status>;
const enabledLanguage: AsyncState<ApiLanguage[]> = { health: Async.Item<System.Health[]>;
...languages, tasks: Async.Item<System.Task[]>;
data: languages.data.filter((v) => v.enabled), providers: Async.Item<System.Provider[]>;
}; logs: Async.Item<System.Log[]>;
const newState = { releases: Async.Item<ReleaseInfo[]>;
...state, settings: Async.Item<Settings>;
languages, }
enabledLanguage,
}; const defaultSystem: System = {
return newState; languages: AsyncUtility.getDefaultItem(),
}, languagesProfiles: AsyncUtility.getDefaultItem(),
[SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST]: (state, action) => { status: AsyncUtility.getDefaultItem(),
const newState = { health: AsyncUtility.getDefaultItem(),
...state, tasks: AsyncUtility.getDefaultItem(),
languagesProfiles: updateAsyncState<Array<Profile.Languages>>( providers: AsyncUtility.getDefaultItem(),
action, logs: AsyncUtility.getDefaultItem(),
[] releases: AsyncUtility.getDefaultItem(),
), settings: AsyncUtility.getDefaultItem(),
}; };
return newState;
}, const reducer = createReducer(defaultSystem, (builder) => {
[SYSTEM_UPDATE_STATUS]: (state, action) => { createAsyncItemReducer(builder, (s) => s.languages, {
return { all: systemUpdateLanguages,
...state, });
status: updateAsyncState<System.Status | undefined>(
action, createAsyncItemReducer(builder, (s) => s.languagesProfiles, {
state.status.data all: systemUpdateLanguagesProfiles,
), });
}; createAsyncItemReducer(builder, (s) => s.status, { all: systemUpdateStatus });
}, createAsyncItemReducer(builder, (s) => s.settings, {
[SYSTEM_UPDATE_HEALTH]: (state, action) => { all: systemUpdateSettings,
return { });
...state, createAsyncItemReducer(builder, (s) => s.releases, {
health: updateAsyncState(action, state.health.data), all: systemUpdateReleases,
}; });
}, createAsyncItemReducer(builder, (s) => s.logs, {
[SYSTEM_UPDATE_TASKS]: (state, action) => { all: systemUpdateLogs,
return { });
...state,
tasks: updateAsyncState<Array<System.Task>>(action, state.tasks.data), createAsyncItemReducer(builder, (s) => s.health, {
}; all: systemUpdateHealth,
}, });
[SYSTEM_UPDATE_PROVIDERS]: (state, action) => {
return { createAsyncItemReducer(builder, (s) => s.tasks, {
...state, all: systemUpdateTasks,
providers: updateAsyncState(action, state.providers.data), dirty: systemMarkTasksDirty,
}; });
},
[SYSTEM_UPDATE_LOGS]: (state, action) => { createAsyncItemReducer(builder, (s) => s.providers, {
return { all: providerUpdateList,
...state, });
logs: updateAsyncState(action, state.logs.data), });
};
},
[SYSTEM_UPDATE_RELEASES]: (state, action) => {
return {
...state,
releases: updateAsyncState(action, state.releases.data),
};
},
[SYSTEM_UPDATE_SETTINGS]: (state, action) => {
return {
...state,
settings: updateAsyncState(action, state.settings.data),
};
},
},
{
languages: { updating: true, data: [] },
enabledLanguage: { updating: true, data: [] },
languagesProfiles: { updating: true, data: [] },
status: {
updating: true,
data: undefined,
},
health: {
updating: true,
data: [],
},
tasks: {
updating: true,
data: [],
},
providers: {
updating: true,
data: [],
},
logs: {
updating: true,
data: [],
},
releases: {
updating: true,
data: [],
},
settings: {
updating: true,
data: undefined,
},
}
);
export default reducer; export default reducer;

@ -1,62 +0,0 @@
interface ReduxStore {
system: ReduxStore.System;
series: ReduxStore.Series;
movie: ReduxStore.Movie;
site: ReduxStore.Site;
}
namespace ReduxStore {
interface Notification {
type: "error" | "warning" | "info";
id: string;
message: string;
timeout: number;
}
interface Progress {
id: string;
header: string;
name: string;
value: number;
count: number;
}
interface Site {
// Initialization state or error message
initialized: boolean | string;
auth: boolean;
progress: Progress[];
notifications: Notification[];
sidebar: string;
badges: Badge;
offline: boolean;
}
interface System {
languages: AsyncState<Array<Language>>;
enabledLanguage: AsyncState<Array<Language>>;
languagesProfiles: AsyncState<Array<Profile.Languages>>;
status: AsyncState<System.Status | undefined>;
health: AsyncState<Array<System.Health>>;
tasks: AsyncState<Array<System.Task>>;
providers: AsyncState<Array<System.Provider>>;
logs: AsyncState<Array<System.Log>>;
releases: AsyncState<Array<ReleaseInfo>>;
settings: AsyncState<Settings | undefined>;
}
interface Series {
seriesList: AsyncOrderState<Item.Series>;
wantedEpisodesList: AsyncOrderState<Wanted.Episode>;
episodeList: AsyncState<Item.Episode[]>;
historyList: AsyncState<Array<History.Episode>>;
blacklist: AsyncState<Array<Blacklist.Episode>>;
}
interface Movie {
movieList: AsyncOrderState<Item.Movie>;
wantedMovieList: AsyncOrderState<Wanted.Movie>;
historyList: AsyncState<Array<History.Movie>>;
blacklist: AsyncState<Array<Blacklist.Movie>>;
}
}

@ -1,17 +1,15 @@
import { applyMiddleware, createStore } from "redux"; import { configureStore } from "@reduxjs/toolkit";
import logger from "redux-logger"; import apis from "../../apis";
import promise from "redux-promise"; import reducer from "../reducers";
import trunk from "redux-thunk";
import rootReducer from "../reducers";
const plugins = [promise, trunk]; const store = configureStore({
reducer,
});
if ( // FIXME
process.env.NODE_ENV === "development" && apis.dispatch = store.dispatch;
process.env["REACT_APP_LOG_REDUX_EVENT"] !== "false"
) { export type AppDispatch = typeof store.dispatch;
plugins.push(logger); export type RootState = ReturnType<typeof store.getState>;
}
const store = createStore(rootReducer, applyMiddleware(...plugins));
export default store; export default store;

@ -0,0 +1,31 @@
import { AsyncUtility } from "../utils/async";
export interface TestType {
id: number;
name: string;
}
export interface Reducer {
item: Async.Item<TestType>;
list: Async.List<TestType>;
entities: Async.Entity<TestType>;
}
export const defaultState: Reducer = {
item: AsyncUtility.getDefaultItem(),
list: AsyncUtility.getDefaultList("id"),
entities: AsyncUtility.getDefaultEntity("id"),
};
export const defaultItem: TestType = { id: 0, name: "test" };
export const defaultList: TestType[] = [
{ id: 0, name: "test" },
{ id: 1, name: "test_1" },
{ id: 2, name: "test_2" },
{ id: 3, name: "test_3" },
{ id: 4, name: "test_4" },
{ id: 5, name: "test_5" },
{ id: 6, name: "test_6" },
{ id: 7, name: "test_6" },
];

@ -1,22 +0,0 @@
import { Dispatch } from "redux";
import { Action } from "redux-actions";
interface AsyncPayload<Payload> {
loading: boolean;
item?: Payload | Error;
parameters: any[];
}
type AvailableType<T> = Action<T> | ActionDispatcher<T>;
type AsyncAction<Payload> = Action<AsyncPayload<Payload>>;
type ActionDispatcher<T = any> = (dispatch: Dispatch<Action<T>>) => void;
type AsyncActionDispatcher<T> = (
dispatch: Dispatch<AsyncAction<T>>
) => Promise<void>;
type PromiseCreator = (...args: any[]) => Promise<any>;
type AvailableCreator = (...args: any[]) => AvailableType<any>[];
type AsyncActionCreator = (...args: any[]) => AsyncActionDispatcher<any>[];
type ActionCallback = () => Action<any> | void;

@ -0,0 +1,32 @@
import {} from "jest";
import { AsyncUtility } from "../async";
interface AsyncTest {
id: string;
name: string;
}
it("Item Init", () => {
const item = AsyncUtility.getDefaultItem<AsyncTest>();
expect(item.state).toEqual("uninitialized");
expect(item.error).toBeNull();
expect(item.content).toBeNull();
});
it("List Init", () => {
const list = AsyncUtility.getDefaultList<AsyncTest>("id");
expect(list.state).toEqual("uninitialized");
expect(list.dirtyEntities).toHaveLength(0);
expect(list.error).toBeNull();
expect(list.content).toHaveLength(0);
});
it("Entity Init", () => {
const entity = AsyncUtility.getDefaultEntity<AsyncTest>("id");
expect(entity.state).toEqual("uninitialized");
expect(entity.dirtyEntities).toHaveLength(0);
expect(entity.error).toBeNull();
expect(entity.content.ids).toHaveLength(0);
expect(entity.content.keyName).toBe("id");
expect(entity.content.entities).toMatchObject({});
});

@ -0,0 +1,71 @@
import { Draft } from "@reduxjs/toolkit";
import { difference, uniq } from "lodash";
export namespace AsyncUtility {
export function getDefaultItem<T>(): Async.Item<T> {
return {
state: "uninitialized",
content: null,
error: null,
};
}
export function getDefaultList<T>(key: keyof T): Async.List<T> {
return {
state: "uninitialized",
keyName: key,
dirtyEntities: [],
content: [],
error: null,
};
}
export function getDefaultEntity<T>(key: keyof T): Async.Entity<T> {
return {
state: "uninitialized",
dirtyEntities: [],
content: {
keyName: key,
ids: [],
entities: {},
},
error: null,
};
}
}
export namespace AsyncReducer {
type DirtyType = Draft<Async.Entity<any>> | Draft<Async.List<any>>;
export function markDirty<T extends DirtyType>(
entity: T,
dirtyIds: string[]
) {
if (entity.state !== "uninitialized" && entity.state !== "loading") {
entity.state = "dirty";
entity.dirtyEntities.push(...dirtyIds);
entity.dirtyEntities = uniq(entity.dirtyEntities);
}
}
export function updateDirty<T extends DirtyType>(
entity: T,
updatedIds: string[]
) {
entity.dirtyEntities = difference(entity.dirtyEntities, updatedIds);
if (entity.dirtyEntities.length > 0) {
entity.state = "dirty";
} else {
entity.state = "succeeded";
}
}
export function removeDirty<T extends DirtyType>(
entity: T,
removedIds: string[]
) {
entity.dirtyEntities = difference(entity.dirtyEntities, removedIds);
if (entity.dirtyEntities.length === 0 && entity.state === "dirty") {
entity.state = "succeeded";
}
}
}

@ -0,0 +1,303 @@
import {
ActionCreatorWithoutPayload,
ActionCreatorWithPayload,
ActionReducerMapBuilder,
AsyncThunk,
Draft,
} from "@reduxjs/toolkit";
import {
difference,
findIndex,
isNull,
isString,
omit,
pullAll,
pullAllWith,
} from "lodash";
import { conditionalLog } from "../../utilites/logger";
import { AsyncReducer } from "./async";
interface ActionParam<T, ID = null> {
range?: AsyncThunk<T, Parameter.Range, {}>;
all?: AsyncThunk<T, void, {}>;
ids?: AsyncThunk<T, ID[], {}>;
removeIds?: ActionCreatorWithPayload<ID[]>;
dirty?: ID extends null
? ActionCreatorWithoutPayload
: ActionCreatorWithPayload<ID[]>;
}
export function createAsyncItemReducer<S, T>(
builder: ActionReducerMapBuilder<S>,
getItem: (state: Draft<S>) => Draft<Async.Item<T>>,
actions: Pick<ActionParam<T>, "all" | "dirty">
) {
const { all, dirty } = actions;
all &&
builder
.addCase(all.pending, (state) => {
const item = getItem(state);
item.state = "loading";
item.error = null;
})
.addCase(all.fulfilled, (state, action) => {
const item = getItem(state);
item.state = "succeeded";
item.content = action.payload as Draft<T>;
})
.addCase(all.rejected, (state, action) => {
const item = getItem(state);
item.state = "failed";
item.error = action.error.message ?? null;
});
dirty &&
builder.addCase(dirty, (state) => {
const item = getItem(state);
if (item.state !== "uninitialized") {
item.state = "dirty";
}
});
}
export function createAsyncListReducer<S, T, ID extends Async.IdType>(
builder: ActionReducerMapBuilder<S>,
getList: (state: Draft<S>) => Draft<Async.List<T>>,
actions: ActionParam<T[], ID>
) {
const { ids, removeIds, all, dirty } = actions;
ids &&
builder
.addCase(ids.pending, (state) => {
const list = getList(state);
list.state = "loading";
list.error = null;
})
.addCase(ids.fulfilled, (state, action) => {
const list = getList(state);
const {
meta: { arg },
} = action;
const keyName = list.keyName as keyof T;
action.payload.forEach((v) => {
const idx = findIndex(list.content, [keyName, v[keyName]]);
if (idx !== -1) {
list.content.splice(idx, 1, v as Draft<T>);
} else {
list.content.unshift(v as Draft<T>);
}
});
AsyncReducer.updateDirty(list, arg.map(String));
})
.addCase(ids.rejected, (state, action) => {
const list = getList(state);
list.state = "failed";
list.error = action.error.message ?? null;
});
removeIds &&
builder.addCase(removeIds, (state, action) => {
const list = getList(state);
const keyName = list.keyName as keyof T;
const removeIds = action.payload.map(String);
pullAllWith(list.content, removeIds, (lhs, rhs) => {
return String((lhs as T)[keyName]) === rhs;
});
AsyncReducer.removeDirty(list, removeIds);
});
all &&
builder
.addCase(all.pending, (state) => {
const list = getList(state);
list.state = "loading";
list.error = null;
})
.addCase(all.fulfilled, (state, action) => {
const list = getList(state);
list.state = "succeeded";
list.content = action.payload as Draft<T[]>;
list.dirtyEntities = [];
})
.addCase(all.rejected, (state, action) => {
const list = getList(state);
list.state = "failed";
list.error = action.error.message ?? null;
});
dirty &&
builder.addCase(dirty, (state, action) => {
const list = getList(state);
AsyncReducer.markDirty(list, action.payload.map(String));
});
}
export function createAsyncEntityReducer<S, T, ID extends Async.IdType>(
builder: ActionReducerMapBuilder<S>,
getEntity: (state: Draft<S>) => Draft<Async.Entity<T>>,
actions: ActionParam<AsyncDataWrapper<T>, ID>
) {
const { all, removeIds, ids, range, dirty } = actions;
const checkSizeUpdate = (entity: Draft<Async.Entity<T>>, newSize: number) => {
if (entity.content.ids.length !== newSize) {
// Reset Entity State
entity.dirtyEntities = [];
entity.content.ids = Array(newSize).fill(null);
entity.content.entities = {};
}
};
range &&
builder
.addCase(range.pending, (state) => {
const entity = getEntity(state);
entity.state = "loading";
entity.error = null;
})
.addCase(range.fulfilled, (state, action) => {
const entity = getEntity(state);
const {
meta: {
arg: { start, length },
},
payload: { data, total },
} = action;
const keyName = entity.content.keyName as keyof T;
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<T>;
});
AsyncReducer.updateDirty(entity, idsToUpdate);
})
.addCase(range.rejected, (state, action) => {
const entity = getEntity(state);
entity.state = "failed";
entity.error = action.error.message ?? null;
});
ids &&
builder
.addCase(ids.pending, (state) => {
const entity = getEntity(state);
entity.state = "loading";
entity.error = null;
})
.addCase(ids.fulfilled, (state, action) => {
const entity = getEntity(state);
const {
meta: { arg },
payload: { data, total },
} = action;
const keyName = entity.content.keyName as keyof T;
checkSizeUpdate(entity, total);
const idsToAdd = data.map((v) => String(v[keyName]));
// For new ids, remove null from list and add them
const newIds = difference(
idsToAdd,
entity.content.ids.filter(isString)
);
const newSize = entity.content.ids.unshift(...newIds);
Array(newSize - total)
.fill(undefined)
.forEach(() => {
const idx = entity.content.ids.findIndex(isNull);
conditionalLog(idx === -1, "Error when deleting ids from entity");
entity.content.ids.splice(idx, 1);
});
data.forEach((v) => {
const key = String(v[keyName]);
entity.content.entities[key] = v as Draft<T>;
});
AsyncReducer.updateDirty(entity, arg.map(String));
})
.addCase(ids.rejected, (state, action) => {
const entity = getEntity(state);
entity.state = "failed";
entity.error = action.error.message ?? null;
});
removeIds &&
builder.addCase(removeIds, (state, action) => {
const entity = getEntity(state);
conditionalLog(
entity.state === "loading",
"Try to delete async entity when it's now loading"
);
const idsToDelete = action.payload.map(String);
pullAll(entity.content.ids, idsToDelete);
AsyncReducer.removeDirty(entity, idsToDelete);
omit(entity.content.entities, idsToDelete);
});
all &&
builder
.addCase(all.pending, (state) => {
const entity = getEntity(state);
entity.state = "loading";
entity.error = null;
})
.addCase(all.fulfilled, (state, action) => {
const entity = getEntity(state);
const {
payload: { data, total },
} = action;
conditionalLog(
data.length !== total,
"Length of data is mismatch with total length"
);
const keyName = entity.content.keyName as keyof T;
entity.state = "succeeded";
entity.dirtyEntities = [];
entity.content.ids = data.map((v) => String(v[keyName]));
entity.content.entities = data.reduce<
Draft<{
[id: string]: T;
}>
>((prev, curr) => {
const id = String(curr[keyName]);
prev[id] = curr as Draft<T>;
return prev;
}, {});
})
.addCase(all.rejected, (state, action) => {
const entity = getEntity(state);
entity.state = "failed";
entity.error = action.error.message ?? null;
});
dirty &&
builder.addCase(dirty, (state, action) => {
const entity = getEntity(state);
AsyncReducer.markDirty(entity, action.payload.map(String));
});
}

@ -1,10 +0,0 @@
export function defaultAOS(): AsyncOrderState<any> {
return {
updating: true,
data: {
items: [],
order: [],
dirty: false,
},
};
}

@ -1,181 +0,0 @@
import { difference, has, isArray, isNull, isNumber, uniqBy } from "lodash";
import { Action } from "redux-actions";
import { conditionalLog } from "../../utilites/logger";
import { AsyncAction } from "../types";
export function updateAsyncState<Payload>(
action: AsyncAction<Payload>,
defVal: Readonly<Payload>
): AsyncState<Payload> {
if (action.payload.loading) {
return {
updating: true,
data: defVal,
};
} else if (action.error !== undefined) {
return {
updating: false,
error: action.payload.item as Error,
data: defVal,
};
} else {
return {
updating: false,
error: undefined,
data: action.payload.item as Payload,
};
}
}
export function updateOrderIdState<T extends LooseObject>(
action: AsyncAction<AsyncDataWrapper<T>>,
state: AsyncOrderState<T>,
id: ItemIdType<T>
): AsyncOrderState<T> {
if (action.payload.loading) {
return {
data: {
...state.data,
dirty: true,
},
updating: true,
};
} else if (action.error !== undefined) {
return {
data: {
...state.data,
dirty: true,
},
updating: false,
error: action.payload.item as Error,
};
} else {
const { data, total } = action.payload.item as AsyncDataWrapper<T>;
const { parameters } = action.payload;
const [start, length] = parameters;
// Convert item list to object
const newItems = data.reduce<IdState<T>>(
(prev, curr) => {
const tid = curr[id];
prev[tid] = curr;
return prev;
},
{ ...state.data.items }
);
let newOrder = [...state.data.order];
const countDist = total - newOrder.length;
if (countDist > 0) {
newOrder = Array(countDist).fill(null).concat(newOrder);
} else if (countDist < 0) {
// Completely drop old data if list has shrinked
newOrder = Array(total).fill(null);
}
const idList = newOrder.filter(isNumber);
const dataOrder: number[] = data.map((v) => v[id]);
if (typeof start === "number" && typeof length === "number") {
newOrder.splice(start, length, ...dataOrder);
} else if (isArray(start)) {
// Find the null values and delete them, insert new values to the front of array
const addition = difference(dataOrder, idList);
let addCount = addition.length;
newOrder.unshift(...addition);
newOrder = newOrder.flatMap((v) => {
if (isNull(v) && addCount > 0) {
--addCount;
return [];
} else {
return [v];
}
}, []);
conditionalLog(
addCount !== 0,
"Error when replacing item in OrderIdState"
);
} else if (parameters.length === 0) {
// TODO: Delete me -> Full Update
newOrder = dataOrder;
}
return {
updating: false,
data: {
dirty: true,
items: newItems,
order: newOrder,
},
};
}
}
export function deleteOrderListItemBy<T extends LooseObject>(
action: Action<number[]>,
state: AsyncOrderState<T>
): AsyncOrderState<T> {
const ids = action.payload;
const { items, order } = state.data;
const newItems = { ...items };
ids.forEach((v) => {
if (has(newItems, v)) {
delete newItems[v];
}
});
const newOrder = difference(order, ids);
return {
...state,
data: {
dirty: true,
items: newItems,
order: newOrder,
},
};
}
export function deleteAsyncListItemBy<T extends LooseObject>(
action: Action<number[]>,
state: AsyncState<T[]>,
match: ItemIdType<T>
): AsyncState<T[]> {
const ids = new Set(action.payload);
const data = [...state.data].filter((v) => !ids.has(v[match]));
return {
...state,
data,
};
}
export function updateAsyncList<T, ID extends keyof T>(
action: AsyncAction<T[]>,
state: AsyncState<T[]>,
match: ID
): AsyncState<T[]> {
if (action.payload.loading) {
return {
...state,
updating: true,
};
} else if (action.error !== undefined) {
return {
...state,
updating: false,
error: action.payload.item as Error,
};
} else {
const olds = state.data as T[];
const news = action.payload.item as T[];
const result = uniqBy([...news, ...olds], match);
return {
updating: false,
data: result,
};
}
}

@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react"; import { useEffect } from "react";
import Socketio from "."; import Socketio from ".";
import { log } from "../utilites/logger"; import { log } from "../utilites/logger";
@ -11,16 +11,3 @@ export function useSocketIOReducer(reducer: SocketIO.Reducer) {
}; };
}, [reducer]); }, [reducer]);
} }
export function useWrapToOptionalId(
fn: (id: number[]) => void
): SocketIO.ActionFn<number> {
return useCallback(
(id?: number[]) => {
if (id) {
fn(id);
}
},
[fn]
);
}

@ -65,12 +65,13 @@ class SocketIOClient {
if (!(e.type in records)) { if (!(e.type in records)) {
records[e.type] = {}; records[e.type] = {};
} }
const record = records[e.type]!; const record = records[e.type]!;
if (!(e.action in record)) { if (!(e.action in record)) {
record[e.action] = []; record[e.action] = [];
} }
if (e.payload) { if (e.payload) {
record[e.action]?.push(e.payload); record[e.action]!.push(e.payload);
} }
}); });
@ -91,11 +92,11 @@ class SocketIOClient {
forIn(element, (ids, key) => { forIn(element, (ids, key) => {
ids = uniq(ids); ids = uniq(ids);
const action = handler[key]; const action = handler[key as SocketIO.ActionType];
if (typeof action == "function") { if (action) {
action(ids); action(ids);
} else if (anyAction === undefined) { } else if (anyAction === undefined) {
log("warning", "Unhandle action of SocketIO event", key, type); log("warning", "Unhandled SocketIO event", key, type);
} }
}); });
}); });

@ -1,44 +1,56 @@
import { createAction } from "redux-actions"; import { ActionCreator } from "@reduxjs/toolkit";
import { import {
badgeUpdateAll, episodesMarkBlacklistDirty,
bootstrap, episodesMarkDirtyById,
movieDeleteItems, episodesMarkHistoryDirty,
movieDeleteWantedItems, episodesRemoveById,
movieUpdateList, movieMarkBlacklistDirty,
movieUpdateWantedList, movieMarkDirtyById,
seriesDeleteItems, movieMarkHistoryDirty,
seriesDeleteWantedItems, movieMarkWantedDirtyById,
seriesUpdateList, movieRemoveById,
seriesUpdateWantedList, movieRemoveWantedById,
seriesMarkDirtyById,
seriesMarkWantedDirtyById,
seriesRemoveById,
seriesRemoveWantedById,
siteAddNotifications, siteAddNotifications,
siteAddProgress, siteAddProgress,
siteInitializationFailed, siteBootstrap,
siteRemoveProgress, siteRemoveProgress,
siteUpdateBadges,
siteUpdateInitialization,
siteUpdateOffline, siteUpdateOffline,
systemUpdateLanguagesAll, systemMarkTasksDirty,
systemUpdateSettings, systemUpdateAllSettings,
systemUpdateLanguages,
} from "../@redux/actions"; } from "../@redux/actions";
import reduxStore from "../@redux/store"; import reduxStore from "../@redux/store";
function bindToReduxStore( function bindReduxAction<T extends ActionCreator<any>>(action: T) {
fn: (ids?: number[]) => any return (...args: Parameters<T>) => {
): SocketIO.ActionFn<number> { reduxStore.dispatch(action(...args));
return (ids?: number[]) => reduxStore.dispatch(fn(ids)); };
} }
export function createDeleteAction(type: string): SocketIO.ActionFn<number> { function bindReduxActionWithParam<T extends ActionCreator<any>>(
return createAction(type, (id?: number[]) => id ?? []); action: T,
...param: Parameters<T>
) {
return () => {
reduxStore.dispatch(action(...param));
};
} }
export function createDefaultReducer(): SocketIO.Reducer[] { export function createDefaultReducer(): SocketIO.Reducer[] {
return [ return [
{ {
key: "connect", key: "connect",
any: () => reduxStore.dispatch(siteUpdateOffline(false)), any: bindReduxActionWithParam(siteUpdateOffline, false),
}, },
{ {
key: "connect", key: "connect",
any: () => reduxStore.dispatch<any>(bootstrap()), any: bindReduxAction(siteBootstrap),
}, },
{ {
key: "connect_error", key: "connect_error",
@ -47,19 +59,19 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
if (initialized === true) { if (initialized === true) {
reduxStore.dispatch(siteUpdateOffline(true)); reduxStore.dispatch(siteUpdateOffline(true));
} else { } else {
reduxStore.dispatch(siteInitializationFailed()); reduxStore.dispatch(siteUpdateInitialization("Socket.IO Error"));
} }
}, },
}, },
{ {
key: "disconnect", key: "disconnect",
any: () => reduxStore.dispatch(siteUpdateOffline(true)), any: bindReduxActionWithParam(siteUpdateOffline, true),
}, },
{ {
key: "message", key: "message",
update: (msg) => { update: (msg) => {
if (msg) { if (msg) {
const notifications = msg.map<ReduxStore.Notification>((message) => ({ const notifications = msg.map<Server.Notification>((message) => ({
message, message,
type: "info", type: "info",
id: "backend-message", id: "backend-message",
@ -72,14 +84,10 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
}, },
{ {
key: "progress", key: "progress",
update: (progress) => { update: bindReduxAction(siteAddProgress),
if (progress) {
reduxStore.dispatch(siteAddProgress(progress));
}
},
delete: (ids) => { delete: (ids) => {
setTimeout(() => { setTimeout(() => {
ids?.forEach((id) => { ids.forEach((id) => {
reduxStore.dispatch(siteRemoveProgress(id)); reduxStore.dispatch(siteRemoveProgress(id));
}); });
}, 3 * 1000); }, 3 * 1000);
@ -87,43 +95,60 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
}, },
{ {
key: "series", key: "series",
update: bindToReduxStore(seriesUpdateList), update: bindReduxAction(seriesMarkDirtyById),
delete: bindToReduxStore(seriesDeleteItems), delete: bindReduxAction(seriesRemoveById),
}, },
{ {
key: "movie", key: "movie",
update: bindToReduxStore(movieUpdateList), update: bindReduxAction(movieMarkDirtyById),
delete: bindToReduxStore(movieDeleteItems), delete: bindReduxAction(movieRemoveById),
},
{
key: "episode",
update: bindReduxAction(episodesMarkDirtyById),
delete: bindReduxAction(episodesRemoveById),
}, },
{ {
key: "episode-wanted", key: "episode-wanted",
update: (ids: number[] | undefined) => { update: bindReduxAction(seriesMarkWantedDirtyById),
if (ids) { delete: bindReduxAction(seriesRemoveWantedById),
reduxStore.dispatch(seriesUpdateWantedList(ids) as any);
}
},
delete: bindToReduxStore(seriesDeleteWantedItems),
}, },
{ {
key: "movie-wanted", key: "movie-wanted",
update: (ids: number[] | undefined) => { update: bindReduxAction(movieMarkWantedDirtyById),
if (ids) { delete: bindReduxAction(movieRemoveWantedById),
reduxStore.dispatch(movieUpdateWantedList(ids) as any);
}
},
delete: bindToReduxStore(movieDeleteWantedItems),
}, },
{ {
key: "settings", key: "settings",
any: bindToReduxStore(systemUpdateSettings), any: bindReduxAction(systemUpdateAllSettings),
}, },
{ {
key: "languages", key: "languages",
any: bindToReduxStore(systemUpdateLanguagesAll), any: bindReduxAction(systemUpdateLanguages),
}, },
{ {
key: "badges", key: "badges",
any: bindToReduxStore(badgeUpdateAll), any: bindReduxAction(siteUpdateBadges),
},
{
key: "movie-history",
any: bindReduxAction(movieMarkHistoryDirty),
},
{
key: "movie-blacklist",
any: bindReduxAction(movieMarkBlacklistDirty),
},
{
key: "episode-history",
any: bindReduxAction(episodesMarkHistoryDirty),
},
{
key: "episode-blacklist",
any: bindReduxAction(episodesMarkBlacklistDirty),
},
{
key: "task",
any: bindReduxAction(systemMarkTasksDirty),
}, },
]; ];
} }

@ -1,5 +1,3 @@
type LanguageCodeType = string;
interface Badge { interface Badge {
episodes: number; episodes: number;
movies: number; movies: number;
@ -7,35 +5,40 @@ interface Badge {
status: number; status: number;
} }
interface ApiLanguage { declare namespace Language {
code2: LanguageCodeType; type CodeType = string;
name: string; interface Server {
enabled: boolean; code2: CodeType;
} name: string;
enabled: boolean;
}
type Language = Omit<ApiLanguage, "enabled"> & { interface Info {
// TODO: Make things unify code2: CodeType;
hi?: boolean; name: string;
forced?: boolean; hi?: boolean;
}; forced?: boolean;
}
namespace Profile { interface ProfileItem {
interface Item {
id: number; id: number;
audio_exclude: PythonBoolean; audio_exclude: PythonBoolean;
forced: PythonBoolean; forced: PythonBoolean;
hi: PythonBoolean; hi: PythonBoolean;
language: LanguageCodeType; language: CodeType;
} }
interface Languages {
interface Profile {
name: string; name: string;
profileId: number; profileId: number;
cutoff: number | null; cutoff: number | null;
items: Item[]; items: ProfileItem[];
} }
} }
interface Subtitle extends Language { interface Subtitle {
code2: Language.CodeType;
name: string;
forced: boolean; forced: boolean;
hi: boolean; hi: boolean;
path: string | null; path: string | null;
@ -91,15 +94,15 @@ interface TitleType {
} }
interface AudioLanguageType { interface AudioLanguageType {
audio_language: Language[]; audio_language: Language.Info[];
} }
interface ItemHistoryType { interface ItemHistoryType {
language: Language; language: Language.Info;
provider: string; provider: string;
} }
namespace Item { declare namespace Item {
type Base = PathType & type Base = PathType &
TitleType & TitleType &
TagType & TagType &
@ -152,7 +155,7 @@ namespace Item {
}; };
} }
namespace Wanted { declare namespace Wanted {
type Base = MonitoredType & type Base = MonitoredType &
TagType & TagType &
SceneNameType & { SceneNameType & {
@ -171,7 +174,7 @@ namespace Wanted {
type Movie = Base & MovieIdType & TitleType; type Movie = Base & MovieIdType & TitleType;
} }
namespace Blacklist { declare namespace Blacklist {
type Base = ItemHistoryType & { type Base = ItemHistoryType & {
parsed_timestamp: string; parsed_timestamp: string;
timestamp: string; timestamp: string;
@ -187,7 +190,7 @@ namespace Blacklist {
}; };
} }
namespace History { declare namespace History {
type Base = SubtitlePathType & type Base = SubtitlePathType &
TagType & TagType &
MonitoredType & MonitoredType &
@ -196,7 +199,7 @@ namespace History {
blacklisted: boolean; blacklisted: boolean;
score?: string; score?: string;
subs_id?: string; subs_id?: string;
raw_timestamp: int; raw_timestamp: number;
parsed_timestamp: string; parsed_timestamp: string;
timestamp: string; timestamp: string;
description: string; description: string;
@ -225,6 +228,13 @@ namespace History {
type ActionOptions = 1 | 2 | 3; type ActionOptions = 1 | 2 | 3;
} }
declare namespace Parameter {
interface Range {
start: number;
length: number;
}
}
interface SearchResultType { interface SearchResultType {
matches: string[]; matches: string[];
dont_matches: string[]; dont_matches: string[];

@ -0,0 +1,22 @@
declare namespace Async {
type State = "loading" | "succeeded" | "failed" | "dirty" | "uninitialized";
type IdType = number | string;
type Base<T> = {
state: State;
content: T;
error: string | null;
};
type List<T> = Base<T[]> & {
keyName: keyof T;
dirtyEntities: string[];
};
type Item<T> = Base<T | null>;
type Entity<T> = Base<EntityStruct<T>> & {
dirtyEntities: string[];
};
}

@ -11,22 +11,6 @@ type FileTree = {
type StorageType = string | null; type StorageType = string | null;
interface OrderIdState<T> {
items: IdState<T>;
order: (number | null)[];
dirty: boolean;
}
interface AsyncState<T> {
updating: boolean;
error?: Error;
data: Readonly<T>;
}
type AsyncOrderState<T> = AsyncState<OrderIdState<T>>;
type AsyncPayload<T> = T extends AsyncState<infer D> ? D : never;
type SelectorOption<PAYLOAD> = { type SelectorOption<PAYLOAD> = {
label: string; label: string;
value: PAYLOAD; value: PAYLOAD;

@ -1,4 +1,4 @@
namespace FormType { declare namespace FormType {
interface ModifyItem { interface ModifyItem {
id: number[]; id: number[];
profileid: (number | null)[]; profileid: (number | null)[];
@ -57,7 +57,7 @@ namespace FormType {
interface AddBlacklist { interface AddBlacklist {
provider: string; provider: string;
subs_id: string; subs_id: string;
language: LanguageCodeType; language: Language.CodeType;
subtitles_path: string; subtitles_path: string;
} }

@ -1,3 +1,6 @@
// @ts-nocheck
// TODO: Fine a better solution for this
import { import {
UseColumnOrderInstanceProps, UseColumnOrderInstanceProps,
UseColumnOrderState, UseColumnOrderState,

@ -0,0 +1,16 @@
declare namespace Server {
interface Notification {
type: "error" | "warning" | "info";
id: string;
message: string;
timeout: number;
}
interface Progress {
id: string;
header: string;
name: string;
value: number;
count: number;
}
}

@ -20,11 +20,11 @@ interface Settings {
napisy24: Settings.Napisy24; napisy24: Settings.Napisy24;
subscene: Settings.Subscene; subscene: Settings.Subscene;
betaseries: Settings.Betaseries; betaseries: Settings.Betaseries;
titlovi: Settings.titlovi; titlovi: Settings.Titlovi;
notifications: Settings.Notifications; notifications: Settings.Notifications;
} }
namespace Settings { declare namespace Settings {
interface General { interface General {
adaptive_searching: boolean; adaptive_searching: boolean;
anti_captcha_provider?: string; anti_captcha_provider?: string;

@ -1,4 +1,4 @@
namespace SocketIO { declare namespace SocketIO {
type EventType = NumEventType | NullEventType | SpecialEventType; type EventType = NumEventType | NullEventType | SpecialEventType;
type NumEventType = type NumEventType =
@ -23,25 +23,26 @@ namespace SocketIO {
type SpecialEventType = "message" | "progress"; type SpecialEventType = "message" | "progress";
type ReducerCreator<E extends EventType, U, D = never> = ValueOf< type ActionType = "update" | "delete";
type ReducerCreator<E extends EventType, U, D = U> = ValueOf<
{ {
[P in E]: { [P in E]: {
key: P; key: P;
any?: () => void; any?: ActionHandler<null>;
update?: ActionFn<T>; update?: ActionHandler<U>;
delete?: ActionFn<D extends never ? T : D>; delete?: ActionHandler<D>;
} & LooseObject; };
// TODO: Typing
} }
>; >;
type Event = { type Event = {
type: EventType; type: EventType;
action: string; action: ActionType;
payload: any; payload: any;
}; };
type ActionFn<T> = (payload?: T[]) => void; type ActionHandler<T> = T extends null ? () => void : (payload: T[]) => void;
type Reducer = type Reducer =
| ReducerCreator<NumEventType, number> | ReducerCreator<NumEventType, number>
@ -49,9 +50,13 @@ namespace SocketIO {
| ReducerCreator<"message", string> | ReducerCreator<"message", string>
| ReducerCreator<"progress", CustomEvent.Progress, string>; | ReducerCreator<"progress", CustomEvent.Progress, string>;
type ActionRecord = OptionalRecord<EventType, StrictObject<any[]>>; type ActionRecord = {
[P in EventType]?: {
[R in ActionType]?: any[];
};
};
namespace CustomEvent { namespace CustomEvent {
type Progress = ReduxStore.Progress; type Progress = Server.Progress;
} }
} }

@ -1,4 +1,4 @@
namespace System { declare namespace System {
interface Task { interface Task {
interval: string; interval: string;
job_id: string; job_id: string;

@ -17,6 +17,14 @@ type Pair<T = string> = {
value: T; value: T;
}; };
type EntityStruct<T> = {
keyName: keyof T;
ids: (string | null)[];
entities: {
[id: string]: T;
};
};
interface DataWrapper<T> { interface DataWrapper<T> {
data: T; data: T;
} }
@ -32,13 +40,7 @@ type Override<T, U> = T & Omit<U, keyof T>;
type Comparer<T> = (lhs: T, rhs: T) => boolean; type Comparer<T> = (lhs: T, rhs: T) => boolean;
type KeysOfType<D, T> = NonNullable< type OptionalRecord<T extends string | number, D> = { [P in T]?: D };
ValueOf<{ [P in keyof D]: D[P] extends T ? P : never }>
>;
type ItemIdType<T> = KeysOfType<T, number>;
type OptionalRecord<T, D> = { [P in T]?: D };
interface IdState<T> { interface IdState<T> {
[key: number]: Readonly<T>; [key: number]: Readonly<T>;

@ -50,9 +50,9 @@ interface Props {}
const Header: FunctionComponent<Props> = () => { const Header: FunctionComponent<Props> = () => {
const setNeedAuth = useReduxAction(siteRedirectToAuth); const setNeedAuth = useReduxAction(siteRedirectToAuth);
const [settings] = useSystemSettings(); const settings = useSystemSettings();
const canLogout = (settings.data?.auth.type ?? "none") === "form"; const canLogout = (settings.content?.auth.type ?? "none") === "form";
const toggleSidebar = useContext(SidebarToggleContext); const toggleSidebar = useContext(SidebarToggleContext);

@ -1,3 +1,4 @@
import "@fontsource/roboto/300.css";
import React, { import React, {
FunctionComponent, FunctionComponent,
useCallback, useCallback,
@ -5,14 +6,21 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { Row } from "react-bootstrap"; import { Row } from "react-bootstrap";
import { Redirect } from "react-router-dom"; import { Provider } from "react-redux";
import { Route, Switch } from "react-router";
import { BrowserRouter, Redirect } from "react-router-dom";
import { useEffectOnceWhen } from "rooks";
import { useReduxStore } from "../@redux/hooks/base"; import { useReduxStore } from "../@redux/hooks/base";
import { useNotification } from "../@redux/hooks/site"; import { useNotification } from "../@redux/hooks/site";
import store from "../@redux/store";
import "../@scss/index.scss";
import Socketio from "../@socketio";
import Auth from "../Auth";
import { LoadingIndicator, ModalProvider } from "../components"; import { LoadingIndicator, ModalProvider } from "../components";
import Sidebar from "../Sidebar"; import Sidebar from "../Sidebar";
import LaunchError from "../special-pages/LaunchError"; import LaunchError from "../special-pages/LaunchError";
import UIError from "../special-pages/UIError"; import UIError from "../special-pages/UIError";
import { useHasUpdateInject } from "../utilites"; import { useBaseUrl, useHasUpdateInject } from "../utilites";
import Header from "./Header"; import Header from "./Header";
import NotificationContainer from "./notifications"; import NotificationContainer from "./notifications";
import Router from "./Router"; import Router from "./Router";
@ -29,17 +37,15 @@ const App: FunctionComponent<Props> = () => {
// Has any update? // Has any update?
const hasUpdate = useHasUpdateInject(); const hasUpdate = useHasUpdateInject();
useEffect(() => { useEffectOnceWhen(() => {
if (initialized) { if (hasUpdate) {
if (hasUpdate) { notify({
notify({ type: "info",
type: "info", message: "A new version of Bazarr is ready, restart is required",
message: "A new version of Bazarr is ready, restart is required", // TODO: Restart action
// TODO: Restart action });
});
}
} }
}, [initialized, hasUpdate, notify]); }, initialized === true);
const [sidebar, setSidebar] = useState(false); const [sidebar, setSidebar] = useState(false);
const toggleSidebar = useCallback(() => setSidebar((s) => !s), []); const toggleSidebar = useCallback(() => setSidebar((s) => !s), []);
@ -77,4 +83,36 @@ const App: FunctionComponent<Props> = () => {
} }
}; };
export default App; const MainRouter: FunctionComponent = () => {
const baseUrl = useBaseUrl();
useEffect(() => {
Socketio.initialize();
}, []);
return (
<BrowserRouter basename={baseUrl}>
<Switch>
<Route exact path="/login">
<Auth></Auth>
</Route>
<Route path="/">
<App></App>
</Route>
</Switch>
</BrowserRouter>
);
};
const Main: FunctionComponent = () => {
return (
<Provider store={store}>
{/* TODO: Enabled Strict Mode after react-bootstrap upgrade to bootstrap 5 */}
{/* <React.StrictMode> */}
<MainRouter></MainRouter>
{/* </React.StrictMode> */}
</Provider>
);
};
export default Main;

@ -20,37 +20,38 @@ import "./style.scss";
export interface NotificationContainerProps {} export interface NotificationContainerProps {}
const NotificationContainer: FunctionComponent<NotificationContainerProps> = () => { const NotificationContainer: FunctionComponent<NotificationContainerProps> =
const { progress, notifications } = useReduxStore((s) => s.site); () => {
const { progress, notifications } = useReduxStore((s) => s.site);
const items = useMemo(() => {
const progressItems = progress.map((v) => ( const items = useMemo(() => {
<ProgressToast key={v.id} {...v}></ProgressToast> const progressItems = progress.map((v) => (
)); <ProgressToast key={v.id} {...v}></ProgressToast>
));
const notificationItems = notifications.map((v) => (
<NotificationToast key={v.id} {...v}></NotificationToast> const notificationItems = notifications.map((v) => (
)); <NotificationToast key={v.id} {...v}></NotificationToast>
));
return [...progressItems, ...notificationItems];
}, [notifications, progress]); return [...progressItems, ...notificationItems];
return ( }, [notifications, progress]);
<div className="alert-container"> return (
<div className="toast-container">{items}</div> <div className="alert-container">
</div> <div className="toast-container">{items}</div>
); </div>
}; );
};
type MessageHolderProps = ReduxStore.Notification & {};
type MessageHolderProps = Server.Notification & {};
const NotificationToast: FunctionComponent<MessageHolderProps> = (props) => { const NotificationToast: FunctionComponent<MessageHolderProps> = (props) => {
const { message, type, id, timeout } = props; const { message, type, id, timeout } = props;
const removeNotification = useReduxAction(siteRemoveNotifications); const removeNotification = useReduxAction(siteRemoveNotifications);
const remove = useCallback(() => removeNotification(id), [ const remove = useCallback(
removeNotification, () => removeNotification(id),
id, [removeNotification, id]
]); );
useEffect(() => { useEffect(() => {
const handle = setTimeout(remove, timeout); const handle = setTimeout(remove, timeout);
@ -73,7 +74,7 @@ const NotificationToast: FunctionComponent<MessageHolderProps> = (props) => {
); );
}; };
type ProgressHolderProps = ReduxStore.Progress & {}; type ProgressHolderProps = Server.Progress & {};
const ProgressToast: FunctionComponent<ProgressHolderProps> = ({ const ProgressToast: FunctionComponent<ProgressHolderProps> = ({
id, id,

@ -4,16 +4,16 @@ import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useBlacklistMovies } from "../../@redux/hooks"; import { useBlacklistMovies } from "../../@redux/hooks";
import { MoviesApi } from "../../apis"; import { MoviesApi } from "../../apis";
import { AsyncStateOverlay, ContentHeader } from "../../components"; import { AsyncOverlay, ContentHeader } from "../../components";
import Table from "./table"; import Table from "./table";
interface Props {} interface Props {}
const BlacklistMoviesView: FunctionComponent<Props> = () => { const BlacklistMoviesView: FunctionComponent<Props> = () => {
const [blacklist] = useBlacklistMovies(); const blacklist = useBlacklistMovies();
return ( return (
<AsyncStateOverlay state={blacklist}> <AsyncOverlay ctx={blacklist}>
{({ data }) => ( {({ content }) => (
<Container fluid> <Container fluid>
<Helmet> <Helmet>
<title>Movies Blacklist - Bazarr</title> <title>Movies Blacklist - Bazarr</title>
@ -21,18 +21,18 @@ const BlacklistMoviesView: FunctionComponent<Props> = () => {
<ContentHeader> <ContentHeader>
<ContentHeader.AsyncButton <ContentHeader.AsyncButton
icon={faTrash} icon={faTrash}
disabled={data.length === 0} disabled={content?.length === 0}
promise={() => MoviesApi.deleteBlacklist(true)} promise={() => MoviesApi.deleteBlacklist(true)}
> >
Remove All Remove All
</ContentHeader.AsyncButton> </ContentHeader.AsyncButton>
</ContentHeader> </ContentHeader>
<Row> <Row>
<Table blacklist={data}></Table> <Table blacklist={content ?? []}></Table>
</Row> </Row>
</Container> </Container>
)} )}
</AsyncStateOverlay> </AsyncOverlay>
); );
}; };

@ -4,16 +4,16 @@ import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useBlacklistSeries } from "../../@redux/hooks"; import { useBlacklistSeries } from "../../@redux/hooks";
import { EpisodesApi } from "../../apis"; import { EpisodesApi } from "../../apis";
import { AsyncStateOverlay, ContentHeader } from "../../components"; import { AsyncOverlay, ContentHeader } from "../../components";
import Table from "./table"; import Table from "./table";
interface Props {} interface Props {}
const BlacklistSeriesView: FunctionComponent<Props> = () => { const BlacklistSeriesView: FunctionComponent<Props> = () => {
const [blacklist] = useBlacklistSeries(); const blacklist = useBlacklistSeries();
return ( return (
<AsyncStateOverlay state={blacklist}> <AsyncOverlay ctx={blacklist}>
{({ data }) => ( {({ content }) => (
<Container fluid> <Container fluid>
<Helmet> <Helmet>
<title>Series Blacklist - Bazarr</title> <title>Series Blacklist - Bazarr</title>
@ -21,18 +21,18 @@ const BlacklistSeriesView: FunctionComponent<Props> = () => {
<ContentHeader> <ContentHeader>
<ContentHeader.AsyncButton <ContentHeader.AsyncButton
icon={faTrash} icon={faTrash}
disabled={data.length === 0} disabled={content?.length === 0}
promise={() => EpisodesApi.deleteBlacklist(true)} promise={() => EpisodesApi.deleteBlacklist(true)}
> >
Remove All Remove All
</ContentHeader.AsyncButton> </ContentHeader.AsyncButton>
</ContentHeader> </ContentHeader>
<Row> <Row>
<Table blacklist={data}></Table> <Table blacklist={content ?? []}></Table>
</Row> </Row>
</Container> </Container>
)} )}
</AsyncStateOverlay> </AsyncOverlay>
); );
}; };

@ -13,7 +13,7 @@ import HistoryGenericView from "../generic";
interface Props {} interface Props {}
const MoviesHistoryView: FunctionComponent<Props> = () => { const MoviesHistoryView: FunctionComponent<Props> = () => {
const [movies] = useMoviesHistory(); const movies = useMoviesHistory();
const columns: Column<History.Movie>[] = useMemo<Column<History.Movie>[]>( const columns: Column<History.Movie>[] = useMemo<Column<History.Movie>[]>(
() => [ () => [

@ -13,7 +13,7 @@ import HistoryGenericView from "../generic";
interface Props {} interface Props {}
const SeriesHistoryView: FunctionComponent<Props> = () => { const SeriesHistoryView: FunctionComponent<Props> = () => {
const [series] = useSeriesHistory(); const series = useSeriesHistory();
const columns: Column<History.Episode>[] = useMemo<Column<History.Episode>[]>( const columns: Column<History.Episode>[] = useMemo<Column<History.Episode>[]>(
() => [ () => [

@ -12,6 +12,7 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { useDidMount } from "rooks";
import { import {
HistoryApi, HistoryApi,
ProvidersApi, ProvidersApi,
@ -49,16 +50,26 @@ const SelectorContainer: FunctionComponent = ({ children }) => (
); );
const HistoryStats: FunctionComponent = () => { const HistoryStats: FunctionComponent = () => {
const [languages] = useAsyncRequest(() => SystemApi.languages(true), []); const [languages, updateLanguages] = useAsyncRequest(
SystemApi.languages.bind(SystemApi),
const [providerList] = useAsyncRequest( []
() => ProvidersApi.providers(true), );
const [providerList, updateProviderParam] = useAsyncRequest(
ProvidersApi.providers.bind(ProvidersApi),
[] []
); );
const updateProvider = useCallback(() => updateProviderParam(true), [
updateProviderParam,
]);
useDidMount(() => {
updateLanguages(true);
});
const [timeframe, setTimeframe] = useState<History.TimeframeOptions>("month"); const [timeframe, setTimeframe] = useState<History.TimeframeOptions>("month");
const [action, setAction] = useState<Nullable<History.ActionOptions>>(null); const [action, setAction] = useState<Nullable<History.ActionOptions>>(null);
const [lang, setLanguage] = useState<Nullable<Language>>(null); const [lang, setLanguage] = useState<Nullable<Language.Info>>(null);
const [provider, setProvider] = useState<Nullable<System.Provider>>(null); const [provider, setProvider] = useState<Nullable<System.Provider>>(null);
const promise = useCallback(() => { const promise = useCallback(() => {
@ -103,13 +114,14 @@ const HistoryStats: FunctionComponent = () => {
clearable clearable
state={providerList} state={providerList}
label={providerLabel} label={providerLabel}
update={updateProvider}
onChange={setProvider} onChange={setProvider}
></AsyncSelector> ></AsyncSelector>
</SelectorContainer> </SelectorContainer>
<SelectorContainer> <SelectorContainer>
<LanguageSelector <LanguageSelector
clearable clearable
options={languages.data} options={languages.content}
value={lang} value={lang}
onChange={setLanguage} onChange={setLanguage}
></LanguageSelector> ></LanguageSelector>

@ -3,11 +3,11 @@ import React, { FunctionComponent } from "react";
import { Container, Row } from "react-bootstrap"; import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Column } from "react-table"; import { Column } from "react-table";
import { AsyncStateOverlay, PageTable } from "../../components"; import { AsyncOverlay, PageTable } from "../../components";
interface Props { interface Props {
type: "movies" | "series"; type: "movies" | "series";
state: Readonly<AsyncState<History.Base[]>>; state: Readonly<Async.Item<History.Base[]>>;
columns: Column<History.Base>[]; columns: Column<History.Base>[];
} }
@ -23,15 +23,15 @@ const HistoryGenericView: FunctionComponent<Props> = ({
<title>{typeName} History - Bazarr</title> <title>{typeName} History - Bazarr</title>
</Helmet> </Helmet>
<Row> <Row>
<AsyncStateOverlay state={state}> <AsyncOverlay ctx={state}>
{({ data }) => ( {({ content }) => (
<PageTable <PageTable
emptyText={`Nothing Found in ${typeName} History`} emptyText={`Nothing Found in ${typeName} History`}
columns={columns} columns={columns}
data={data} data={content ?? []}
></PageTable> ></PageTable>
)} )}
</AsyncStateOverlay> </AsyncOverlay>
</Row> </Row>
</Container> </Container>
); );

@ -7,7 +7,7 @@ import {
faUser, faUser,
faWrench, faWrench,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent, useCallback, useState } from "react"; import React, { FunctionComponent, useState } from "react";
import { Container, Row } from "react-bootstrap"; import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Redirect, RouteComponentProps, withRouter } from "react-router-dom"; import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
@ -25,7 +25,7 @@ import {
import { ManualSearchModal } from "../../components/modals/ManualSearchModal"; import { ManualSearchModal } from "../../components/modals/ManualSearchModal";
import ItemOverview from "../../generic/ItemOverview"; import ItemOverview from "../../generic/ItemOverview";
import { RouterEmptyPath } from "../../special-pages/404"; import { RouterEmptyPath } from "../../special-pages/404";
import { useOnLoadingFinish } from "../../utilites"; import { useOnLoadedOnce } from "../../utilites";
import Table from "./table"; import Table from "./table";
const download = (item: any, result: SearchResultType) => { const download = (item: any, result: SearchResultType) => {
@ -48,22 +48,20 @@ interface Props extends RouteComponentProps<Params> {}
const MovieDetailView: FunctionComponent<Props> = ({ match }) => { const MovieDetailView: FunctionComponent<Props> = ({ match }) => {
const id = Number.parseInt(match.params.id); const id = Number.parseInt(match.params.id);
const [movie] = useMovieBy(id); const movie = useMovieBy(id);
const item = movie.data; const item = movie.content;
const profile = useProfileBy(movie.data?.profileId); const profile = useProfileBy(movie.content?.profileId);
const showModal = useShowModal(); const showModal = useShowModal();
const [valid, setValid] = useState(true); const [valid, setValid] = useState(true);
const validator = useCallback(() => { useOnLoadedOnce(() => {
if (movie.data === null) { if (movie.content === null) {
setValid(false); setValid(false);
} }
}, [movie.data]); }, movie);
useOnLoadingFinish(movie, validator);
if (isNaN(id) || !valid) { if (isNaN(id) || !valid) {
return <Redirect to={RouterEmptyPath}></Redirect>; return <Redirect to={RouterEmptyPath}></Redirect>;

@ -3,7 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useMemo } from "react"; import React, { FunctionComponent, useMemo } from "react";
import { Badge } from "react-bootstrap"; import { Badge } from "react-bootstrap";
import { Column } from "react-table"; import { Column } from "react-table";
import { useProfileItems } from "../../@redux/hooks"; import { useProfileItemsToLanguages } from "../../@redux/hooks";
import { useShowOnlyDesired } from "../../@redux/hooks/site"; import { useShowOnlyDesired } from "../../@redux/hooks/site";
import { MoviesApi } from "../../apis"; import { MoviesApi } from "../../apis";
import { AsyncButton, LanguageText, SimpleTable } from "../../components"; import { AsyncButton, LanguageText, SimpleTable } from "../../components";
@ -13,13 +13,13 @@ const missingText = "Missing Subtitles";
interface Props { interface Props {
movie: Item.Movie; movie: Item.Movie;
profile?: Profile.Languages; profile?: Language.Profile;
} }
const Table: FunctionComponent<Props> = ({ movie, profile }) => { const Table: FunctionComponent<Props> = ({ movie, profile }) => {
const onlyDesired = useShowOnlyDesired(); const onlyDesired = useShowOnlyDesired();
const profileItems = useProfileItems(profile); const profileItems = useProfileItemsToLanguages(profile);
const columns: Column<Subtitle>[] = useMemo<Column<Subtitle>[]>( const columns: Column<Subtitle>[] = useMemo<Column<Subtitle>[]>(
() => [ () => [

@ -5,8 +5,8 @@ import React, { FunctionComponent, useMemo } from "react";
import { Badge } from "react-bootstrap"; import { Badge } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Column } from "react-table"; import { Column } from "react-table";
import { movieUpdateByRange, movieUpdateList } from "../@redux/actions"; import { movieUpdateAll, movieUpdateByRange } from "../@redux/actions";
import { useRawMovies } from "../@redux/hooks"; import { useMovieEntities } from "../@redux/hooks";
import { useReduxAction } from "../@redux/hooks/base"; import { useReduxAction } from "../@redux/hooks/base";
import { MoviesApi } from "../apis"; import { MoviesApi } from "../apis";
import { ActionBadge, LanguageText, TextPopover } from "../components"; import { ActionBadge, LanguageText, TextPopover } from "../components";
@ -16,8 +16,8 @@ import { BuildKey } from "../utilites";
interface Props {} interface Props {}
const MovieView: FunctionComponent<Props> = () => { const MovieView: FunctionComponent<Props> = () => {
const [movies] = useRawMovies(); const movies = useMovieEntities();
const load = useReduxAction(movieUpdateByRange); const loader = useReduxAction(movieUpdateByRange);
const columns: Column<Item.Movie>[] = useMemo<Column<Item.Movie>[]>( const columns: Column<Item.Movie>[] = useMemo<Column<Item.Movie>[]>(
() => [ () => [
{ {
@ -70,7 +70,7 @@ const MovieView: FunctionComponent<Props> = () => {
Cell: ({ value, loose }) => { Cell: ({ value, loose }) => {
if (loose) { if (loose) {
// Define in generic/BaseItemView/table.tsx // Define in generic/BaseItemView/table.tsx
const profiles = loose[0] as Profile.Languages[]; const profiles = loose[0] as Language.Profile[];
return profiles.find((v) => v.profileId === value)?.name ?? null; return profiles.find((v) => v.profileId === value)?.name ?? null;
} else { } else {
return null; return null;
@ -112,8 +112,8 @@ const MovieView: FunctionComponent<Props> = () => {
<BaseItemView <BaseItemView
state={movies} state={movies}
name="Movies" name="Movies"
loader={load} loader={loader}
updateAction={movieUpdateList} updateAction={movieUpdateAll}
columns={columns} columns={columns}
modify={(form) => MoviesApi.modify(form)} modify={(form) => MoviesApi.modify(form)}
></BaseItemView> ></BaseItemView>

@ -7,12 +7,7 @@ import {
faSync, faSync,
faWrench, faWrench,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import React, { import React, { FunctionComponent, useMemo, useState } from "react";
FunctionComponent,
useCallback,
useMemo,
useState,
} from "react";
import { Container, Row } from "react-bootstrap"; import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Redirect, RouteComponentProps, withRouter } from "react-router-dom"; import { Redirect, RouteComponentProps, withRouter } from "react-router-dom";
@ -27,7 +22,7 @@ import {
} from "../../components"; } from "../../components";
import ItemOverview from "../../generic/ItemOverview"; import ItemOverview from "../../generic/ItemOverview";
import { RouterEmptyPath } from "../../special-pages/404"; import { RouterEmptyPath } from "../../special-pages/404";
import { useOnLoadingFinish } from "../../utilites"; import { useOnLoadedOnce } from "../../utilites";
import Table from "./table"; import Table from "./table";
interface Params { interface Params {
@ -39,53 +34,50 @@ interface Props extends RouteComponentProps<Params> {}
const SeriesEpisodesView: FunctionComponent<Props> = (props) => { const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
const { match } = props; const { match } = props;
const id = Number.parseInt(match.params.id); const id = Number.parseInt(match.params.id);
const [serie] = useSerieBy(id); const series = useSerieBy(id);
const item = serie.data; const episodes = useEpisodesBy(id);
const serie = series.content;
const [episodes] = useEpisodesBy(serie.data?.sonarrSeriesId); const available = episodes.content.length !== 0;
const available = episodes.data.length !== 0;
const details = useMemo( const details = useMemo(
() => [ () => [
{ {
icon: faHdd, icon: faHdd,
text: `${item?.episodeFileCount} files`, text: `${serie?.episodeFileCount} files`,
}, },
{ {
icon: faAdjust, icon: faAdjust,
text: item?.seriesType ?? "", text: serie?.seriesType ?? "",
}, },
], ],
[item] [serie]
); );
const showModal = useShowModal(); const showModal = useShowModal();
const [valid, setValid] = useState(true); const [valid, setValid] = useState(true);
const validator = useCallback(() => { useOnLoadedOnce(() => {
if (serie.data === null) { if (series.content === null) {
setValid(false); setValid(false);
} }
}, [serie.data]); }, series);
useOnLoadingFinish(serie, validator);
const profile = useProfileBy(serie.data?.profileId); const profile = useProfileBy(series.content?.profileId);
if (isNaN(id) || !valid) { if (isNaN(id) || !valid) {
return <Redirect to={RouterEmptyPath}></Redirect>; return <Redirect to={RouterEmptyPath}></Redirect>;
} }
if (!item) { if (!serie) {
return <LoadingIndicator></LoadingIndicator>; return <LoadingIndicator></LoadingIndicator>;
} }
return ( return (
<Container fluid> <Container fluid>
<Helmet> <Helmet>
<title>{item.title} - Bazarr (Series)</title> <title>{serie.title} - Bazarr (Series)</title>
</Helmet> </Helmet>
<ContentHeader> <ContentHeader>
<ContentHeader.Group pos="start"> <ContentHeader.Group pos="start">
@ -104,8 +96,8 @@ const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
SeriesApi.action({ action: "search-missing", seriesid: id }) SeriesApi.action({ action: "search-missing", seriesid: id })
} }
disabled={ disabled={
item.episodeFileCount === 0 || serie.episodeFileCount === 0 ||
item.profileId === null || serie.profileId === null ||
!available !available
} }
> >
@ -114,36 +106,36 @@ const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
</ContentHeader.Group> </ContentHeader.Group>
<ContentHeader.Group pos="end"> <ContentHeader.Group pos="end">
<ContentHeader.Button <ContentHeader.Button
disabled={item.episodeFileCount === 0 || !available} disabled={serie.episodeFileCount === 0 || !available}
icon={faBriefcase} icon={faBriefcase}
onClick={() => showModal("tools", episodes.data)} onClick={() => showModal("tools", episodes.content)}
> >
Tools Tools
</ContentHeader.Button> </ContentHeader.Button>
<ContentHeader.Button <ContentHeader.Button
disabled={ disabled={
item.episodeFileCount === 0 || serie.episodeFileCount === 0 ||
item.profileId === null || serie.profileId === null ||
!available !available
} }
icon={faCloudUploadAlt} icon={faCloudUploadAlt}
onClick={() => showModal("upload", item)} onClick={() => showModal("upload", serie)}
> >
Upload Upload
</ContentHeader.Button> </ContentHeader.Button>
<ContentHeader.Button <ContentHeader.Button
icon={faWrench} icon={faWrench}
onClick={() => showModal("edit", item)} onClick={() => showModal("edit", serie)}
> >
Edit Series Edit Series
</ContentHeader.Button> </ContentHeader.Button>
</ContentHeader.Group> </ContentHeader.Group>
</ContentHeader> </ContentHeader>
<Row> <Row>
<ItemOverview item={item} details={details}></ItemOverview> <ItemOverview item={serie} details={details}></ItemOverview>
</Row> </Row>
<Row> <Row>
<Table episodes={episodes} profile={profile}></Table> <Table serie={series} episodes={episodes} profile={profile}></Table>
</Row> </Row>
<ItemEditorModal <ItemEditorModal
modalKey="edit" modalKey="edit"
@ -151,7 +143,7 @@ const SeriesEpisodesView: FunctionComponent<Props> = (props) => {
></ItemEditorModal> ></ItemEditorModal>
<SeriesUploadModal <SeriesUploadModal
modalKey="upload" modalKey="upload"
episodes={episodes.data} episodes={episodes.content}
></SeriesUploadModal> ></SeriesUploadModal>
</Container> </Container>
); );

@ -9,12 +9,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React, { FunctionComponent, useCallback, useMemo } from "react"; import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Badge, ButtonGroup } from "react-bootstrap"; import { Badge, ButtonGroup } from "react-bootstrap";
import { Column, TableUpdater } from "react-table"; import { Column, TableUpdater } from "react-table";
import { useProfileItems, useSerieBy } from "../../@redux/hooks"; import { useProfileItemsToLanguages } from "../../@redux/hooks";
import { useShowOnlyDesired } from "../../@redux/hooks/site"; import { useShowOnlyDesired } from "../../@redux/hooks/site";
import { ProvidersApi } from "../../apis"; import { ProvidersApi } from "../../apis";
import { import {
ActionButton, ActionButton,
AsyncStateOverlay, AsyncOverlay,
EpisodeHistoryModal, EpisodeHistoryModal,
GroupTable, GroupTable,
SubtitleToolModal, SubtitleToolModal,
@ -26,8 +26,9 @@ import { BuildKey, filterSubtitleBy } from "../../utilites";
import { SubtitleAction } from "./components"; import { SubtitleAction } from "./components";
interface Props { interface Props {
episodes: AsyncState<Item.Episode[]>; serie: Async.Item<Item.Series>;
profile?: Profile.Languages; episodes: Async.Base<Item.Episode[]>;
profile?: Language.Profile;
} }
const download = (item: any, result: SearchResultType) => { const download = (item: any, result: SearchResultType) => {
@ -46,12 +47,12 @@ const download = (item: any, result: SearchResultType) => {
); );
}; };
const Table: FunctionComponent<Props> = ({ episodes, profile }) => { const Table: FunctionComponent<Props> = ({ serie, episodes, profile }) => {
const showModal = useShowModal(); const showModal = useShowModal();
const onlyDesired = useShowOnlyDesired(); const onlyDesired = useShowOnlyDesired();
const profileItems = useProfileItems(profile); const profileItems = useProfileItemsToLanguages(profile);
const columns: Column<Item.Episode>[] = useMemo<Column<Item.Episode>[]>( const columns: Column<Item.Episode>[] = useMemo<Column<Item.Episode>[]>(
() => [ () => [
@ -142,13 +143,11 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
Header: "Actions", Header: "Actions",
accessor: "sonarrEpisodeId", accessor: "sonarrEpisodeId",
Cell: ({ row, externalUpdate }) => { Cell: ({ row, externalUpdate }) => {
const [serie] = useSerieBy(row.original.sonarrSeriesId);
return ( return (
<ButtonGroup> <ButtonGroup>
<ActionButton <ActionButton
icon={faUser} icon={faUser}
disabled={serie.data?.profileId === null} disabled={serie.content?.profileId === null}
onClick={() => { onClick={() => {
externalUpdate && externalUpdate(row, "manual-search"); externalUpdate && externalUpdate(row, "manual-search");
}} }}
@ -170,7 +169,7 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
}, },
}, },
], ],
[onlyDesired, profileItems] [onlyDesired, profileItems, serie]
); );
const updateRow = useCallback<TableUpdater<Item.Episode>>( const updateRow = useCallback<TableUpdater<Item.Episode>>(
@ -186,7 +185,7 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
const maxSeason = useMemo( const maxSeason = useMemo(
() => () =>
episodes.data.reduce<number>( episodes.content.reduce<number>(
(prev, curr) => Math.max(prev, curr.season), (prev, curr) => Math.max(prev, curr.season),
0 0
), ),
@ -195,11 +194,11 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
return ( return (
<React.Fragment> <React.Fragment>
<AsyncStateOverlay state={episodes}> <AsyncOverlay ctx={episodes}>
{({ data }) => ( {({ content }) => (
<GroupTable <GroupTable
columns={columns} columns={columns}
data={data} data={content}
externalUpdate={updateRow} externalUpdate={updateRow}
initialState={{ initialState={{
sortBy: [ sortBy: [
@ -214,7 +213,7 @@ const Table: FunctionComponent<Props> = ({ episodes, profile }) => {
emptyText="No Episode Found For This Series" emptyText="No Episode Found For This Series"
></GroupTable> ></GroupTable>
)} )}
</AsyncStateOverlay> </AsyncOverlay>
<SubtitleToolModal modalKey="tools" size="lg"></SubtitleToolModal> <SubtitleToolModal modalKey="tools" size="lg"></SubtitleToolModal>
<EpisodeHistoryModal modalKey="history" size="lg"></EpisodeHistoryModal> <EpisodeHistoryModal modalKey="history" size="lg"></EpisodeHistoryModal>
<ManualSearchModal <ManualSearchModal

@ -3,8 +3,8 @@ import React, { FunctionComponent, useMemo } from "react";
import { Badge, ProgressBar } from "react-bootstrap"; import { Badge, ProgressBar } from "react-bootstrap";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Column } from "react-table"; import { Column } from "react-table";
import { seriesUpdateByRange, seriesUpdateList } from "../@redux/actions"; import { seriesUpdateAll, seriesUpdateByRange } from "../@redux/actions";
import { useRawSeries } from "../@redux/hooks"; import { useSerieEntities } from "../@redux/hooks";
import { useReduxAction } from "../@redux/hooks/base"; import { useReduxAction } from "../@redux/hooks/base";
import { SeriesApi } from "../apis"; import { SeriesApi } from "../apis";
import { ActionBadge } from "../components"; import { ActionBadge } from "../components";
@ -14,8 +14,8 @@ import { BuildKey } from "../utilites";
interface Props {} interface Props {}
const SeriesView: FunctionComponent<Props> = () => { const SeriesView: FunctionComponent<Props> = () => {
const [series] = useRawSeries(); const series = useSerieEntities();
const load = useReduxAction(seriesUpdateByRange); const loader = useReduxAction(seriesUpdateByRange);
const columns: Column<Item.Series>[] = useMemo<Column<Item.Series>[]>( const columns: Column<Item.Series>[] = useMemo<Column<Item.Series>[]>(
() => [ () => [
{ {
@ -56,7 +56,7 @@ const SeriesView: FunctionComponent<Props> = () => {
Cell: ({ value, loose }) => { Cell: ({ value, loose }) => {
if (loose) { if (loose) {
// Define in generic/BaseItemView/table.tsx // Define in generic/BaseItemView/table.tsx
const profiles = loose[0] as Profile.Languages[]; const profiles = loose[0] as Language.Profile[];
return profiles.find((v) => v.profileId === value)?.name ?? null; return profiles.find((v) => v.profileId === value)?.name ?? null;
} else { } else {
return null; return null;
@ -68,11 +68,8 @@ const SeriesView: FunctionComponent<Props> = () => {
accessor: "episodeFileCount", accessor: "episodeFileCount",
selectHide: true, selectHide: true,
Cell: (row) => { Cell: (row) => {
const { const { episodeFileCount, episodeMissingCount, profileId } =
episodeFileCount, row.row.original;
episodeMissingCount,
profileId,
} = row.row.original;
let progress = 0; let progress = 0;
let label = ""; let label = "";
if (episodeFileCount === 0 || !profileId) { if (episodeFileCount === 0 || !profileId) {
@ -118,8 +115,8 @@ const SeriesView: FunctionComponent<Props> = () => {
<BaseItemView <BaseItemView
state={series} state={series}
name="Series" name="Series"
updateAction={seriesUpdateList} updateAction={seriesUpdateAll}
loader={load} loader={loader}
columns={columns} columns={columns}
modify={(form) => SeriesApi.modify(form)} modify={(form) => SeriesApi.modify(form)}
></BaseItemView> ></BaseItemView>

@ -1,16 +1,16 @@
import React, { FunctionComponent, useMemo } from "react"; import React, { FunctionComponent, useMemo } from "react";
import { useEnabledLanguages, useProfiles } from "."; import { useEnabledLanguagesContext, useProfilesContext } from ".";
import { LanguageSelector as CLanguageSelector } from "../../components"; import { LanguageSelector as CLanguageSelector } from "../../components";
import { BaseInput, Selector, useSingleUpdate } from "../components"; import { BaseInput, Selector, useSingleUpdate } from "../components";
interface LanguageSelectorProps { interface LanguageSelectorProps {
options: readonly Language[]; options: readonly Language.Info[];
} }
export const LanguageSelector: FunctionComponent< export const LanguageSelector: FunctionComponent<
LanguageSelectorProps & BaseInput<string[]> LanguageSelectorProps & BaseInput<string[]>
> = ({ settingKey, options }) => { > = ({ settingKey, options }) => {
const enabled = useEnabledLanguages(); const enabled = useEnabledLanguagesContext();
const update = useSingleUpdate(); const update = useSingleUpdate();
return ( return (
@ -28,9 +28,9 @@ export const LanguageSelector: FunctionComponent<
interface ProfileSelectorProps {} interface ProfileSelectorProps {}
export const ProfileSelector: FunctionComponent< export const ProfileSelector: FunctionComponent<
ProfileSelectorProps & BaseInput<Profile.Languages> ProfileSelectorProps & BaseInput<Language.Profile>
> = ({ settingKey }) => { > = ({ settingKey }) => {
const profiles = useProfiles(); const profiles = useProfilesContext();
const profileOptions = useMemo<SelectorOption<number>[]>( const profileOptions = useMemo<SelectorOption<number>[]>(
() => () =>

@ -1,6 +1,10 @@
import { isArray } from "lodash"; import { isArray } from "lodash";
import React, { FunctionComponent, useContext } from "react"; import React, { FunctionComponent, useContext } from "react";
import { useLanguageProfiles, useLanguages } from "../../@redux/hooks"; import {
useEnabledLanguages,
useLanguageProfiles,
useLanguages,
} from "../../@redux/hooks";
import { import {
Check, Check,
CollapseBox, CollapseBox,
@ -14,14 +18,16 @@ import { enabledLanguageKey, languageProfileKey } from "../keys";
import { LanguageSelector, ProfileSelector } from "./components"; import { LanguageSelector, ProfileSelector } from "./components";
import Table from "./table"; import Table from "./table";
const EnabledLanguageContext = React.createContext<readonly Language[]>([]); const EnabledLanguageContext = React.createContext<readonly Language.Info[]>(
[]
);
const LanguagesProfileContext = React.createContext< const LanguagesProfileContext = React.createContext<
readonly Profile.Languages[] readonly Language.Profile[]
>([]); >([]);
export function useEnabledLanguages() { export function useEnabledLanguagesContext() {
const list = useContext(EnabledLanguageContext); const list = useContext(EnabledLanguageContext);
const latest = useLatest<Language[]>(enabledLanguageKey, isArray); const latest = useLatest<Language.Info[]>(enabledLanguageKey, isArray);
if (latest) { if (latest) {
return latest; return latest;
@ -30,9 +36,9 @@ export function useEnabledLanguages() {
} }
} }
export function useProfiles() { export function useProfilesContext() {
const list = useContext(LanguagesProfileContext); const list = useContext(LanguagesProfileContext);
const latest = useLatest<Profile.Languages[]>(languageProfileKey, isArray); const latest = useLatest<Language.Profile[]>(languageProfileKey, isArray);
if (latest) { if (latest) {
return latest; return latest;
@ -44,14 +50,14 @@ export function useProfiles() {
interface Props {} interface Props {}
const SettingsLanguagesView: FunctionComponent<Props> = () => { const SettingsLanguagesView: FunctionComponent<Props> = () => {
const [languages] = useLanguages(false); const languages = useLanguages();
const [enabled] = useLanguages(true); const enabled = useEnabledLanguages();
const [profiles] = useLanguageProfiles(); const profiles = useLanguageProfiles();
return ( return (
<SettingsProvider title="Languages - Bazarr (Settings)"> <SettingsProvider title="Languages - Bazarr (Settings)">
<EnabledLanguageContext.Provider value={enabled}> <EnabledLanguageContext.Provider value={enabled}>
<LanguagesProfileContext.Provider value={profiles}> <LanguagesProfileContext.Provider value={profiles ?? []}>
<Group header="Subtitles Language"> <Group header="Subtitles Language">
<Input> <Input>
<Check <Check

@ -8,7 +8,7 @@ import React, {
} from "react"; } from "react";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { Column, TableUpdater } from "react-table"; import { Column, TableUpdater } from "react-table";
import { useEnabledLanguages } from "."; import { useEnabledLanguagesContext } from ".";
import { import {
ActionButton, ActionButton,
BaseModal, BaseModal,
@ -23,10 +23,10 @@ import { BuildKey } from "../../utilites";
import { Input, Message } from "../components"; import { Input, Message } from "../components";
import { cutoffOptions } from "./options"; import { cutoffOptions } from "./options";
interface Props { interface Props {
update: (profile: Profile.Languages) => void; update: (profile: Language.Profile) => void;
} }
function createDefaultProfile(): Profile.Languages { function createDefaultProfile(): Language.Profile {
return { return {
profileId: -1, profileId: -1,
name: "", name: "",
@ -40,11 +40,11 @@ const LanguagesProfileModal: FunctionComponent<Props & BaseModalProps> = (
) => { ) => {
const { update, ...modal } = props; const { update, ...modal } = props;
const profile = usePayload<Profile.Languages>(modal.modalKey); const profile = usePayload<Language.Profile>(modal.modalKey);
const closeModal = useCloseModal(); const closeModal = useCloseModal();
const languages = useEnabledLanguages(); const languages = useEnabledLanguagesContext();
const [current, setProfile] = useState(createDefaultProfile); const [current, setProfile] = useState(createDefaultProfile);
@ -70,10 +70,7 @@ const LanguagesProfileModal: FunctionComponent<Props & BaseModalProps> = (
}, [current.items]); }, [current.items]);
const updateProfile = useCallback( const updateProfile = useCallback(
<K extends keyof Profile.Languages>( <K extends keyof Language.Profile>(key: K, value: Language.Profile[K]) => {
key: K,
value: Profile.Languages[K]
) => {
const newProfile = { ...current }; const newProfile = { ...current };
newProfile[key] = value; newProfile[key] = value;
setProfile(newProfile); setProfile(newProfile);
@ -81,8 +78,8 @@ const LanguagesProfileModal: FunctionComponent<Props & BaseModalProps> = (
[current] [current]
); );
const updateRow = useCallback<TableUpdater<Profile.Item>>( const updateRow = useCallback<TableUpdater<Language.ProfileItem>>(
(row, item: Profile.Item) => { (row, item: Language.ProfileItem) => {
const list = [...current.items]; const list = [...current.items];
if (item) { if (item) {
list[row.index] = item; list[row.index] = item;
@ -102,7 +99,7 @@ const LanguagesProfileModal: FunctionComponent<Props & BaseModalProps> = (
if (languages.length > 0) { if (languages.length > 0) {
const language = languages[0].code2; const language = languages[0].code2;
const item: Profile.Item = { const item: Language.ProfileItem = {
id, id,
language, language,
audio_exclude: "False", audio_exclude: "False",
@ -131,7 +128,7 @@ const LanguagesProfileModal: FunctionComponent<Props & BaseModalProps> = (
</Button> </Button>
); );
const columns = useMemo<Column<Profile.Item>[]>( const columns = useMemo<Column<Language.ProfileItem>[]>(
() => [ () => [
{ {
Header: "ID", Header: "ID",

@ -8,7 +8,7 @@ import React, {
} from "react"; } from "react";
import { Badge, Button, ButtonGroup } from "react-bootstrap"; import { Badge, Button, ButtonGroup } from "react-bootstrap";
import { Column, TableUpdater } from "react-table"; import { Column, TableUpdater } from "react-table";
import { useEnabledLanguages, useProfiles } from "."; import { useEnabledLanguagesContext, useProfilesContext } from ".";
import { ActionButton, SimpleTable, useShowModal } from "../../components"; import { ActionButton, SimpleTable, useShowModal } from "../../components";
import { useSingleUpdate } from "../components"; import { useSingleUpdate } from "../components";
import { languageProfileKey } from "../keys"; import { languageProfileKey } from "../keys";
@ -16,9 +16,9 @@ import Modal from "./modal";
import { anyCutoff } from "./options"; import { anyCutoff } from "./options";
const Table: FunctionComponent = () => { const Table: FunctionComponent = () => {
const originalProfiles = useProfiles(); const originalProfiles = useProfilesContext();
const languages = useEnabledLanguages(); const languages = useEnabledLanguagesContext();
const [profiles, setProfiles] = useState(() => cloneDeep(originalProfiles)); const [profiles, setProfiles] = useState(() => cloneDeep(originalProfiles));
@ -34,7 +34,7 @@ const Table: FunctionComponent = () => {
const showModal = useShowModal(); const showModal = useShowModal();
const submitProfiles = useCallback( const submitProfiles = useCallback(
(list: Profile.Languages[]) => { (list: Language.Profile[]) => {
update(list, languageProfileKey); update(list, languageProfileKey);
setProfiles(list); setProfiles(list);
}, },
@ -42,7 +42,7 @@ const Table: FunctionComponent = () => {
); );
const updateProfile = useCallback( const updateProfile = useCallback(
(profile: Profile.Languages) => { (profile: Language.Profile) => {
const list = [...profiles]; const list = [...profiles];
const idx = list.findIndex((v) => v.profileId === profile.profileId); const idx = list.findIndex((v) => v.profileId === profile.profileId);
@ -56,8 +56,8 @@ const Table: FunctionComponent = () => {
[profiles, submitProfiles] [profiles, submitProfiles]
); );
const updateRow = useCallback<TableUpdater<Profile.Languages>>( const updateRow = useCallback<TableUpdater<Language.Profile>>(
(row, item?: Profile.Languages) => { (row, item?: Language.Profile) => {
if (item) { if (item) {
showModal("profile", cloneDeep(item)); showModal("profile", cloneDeep(item));
} else { } else {
@ -69,7 +69,7 @@ const Table: FunctionComponent = () => {
[submitProfiles, showModal, profiles] [submitProfiles, showModal, profiles]
); );
const columns = useMemo<Column<Profile.Languages>[]>( const columns = useMemo<Column<Language.Profile>[]>(
() => [ () => [
{ {
Header: "Name", Header: "Name",
@ -151,7 +151,7 @@ const Table: FunctionComponent = () => {
interface ItemProps { interface ItemProps {
className?: string; className?: string;
item: Profile.Item; item: Language.ProfileItem;
cutoff: boolean; cutoff: boolean;
} }

@ -1,7 +1,5 @@
import React, { FunctionComponent, useEffect } from "react"; import React, { FunctionComponent } from "react";
import { Redirect, Route, Switch } from "react-router-dom"; import { Redirect, Route, Switch } from "react-router-dom";
import { systemUpdateSettings } from "../@redux/actions";
import { useReduxAction } from "../@redux/hooks/base";
import { useSetSidebar } from "../@redux/hooks/site"; import { useSetSidebar } from "../@redux/hooks/site";
import { RouterEmptyPath } from "../special-pages/404"; import { RouterEmptyPath } from "../special-pages/404";
import General from "./General"; import General from "./General";
@ -17,9 +15,6 @@ import UI from "./UI";
interface Props {} interface Props {}
const Router: FunctionComponent<Props> = () => { const Router: FunctionComponent<Props> = () => {
const update = useReduxAction(systemUpdateSettings);
useEffect(() => update, [update]);
useSetSidebar("Settings"); useSetSidebar("Settings");
return ( return (
<Switch> <Switch>

@ -51,8 +51,7 @@ export function useExtract<T>(
validate: ValidateFuncType<T>, validate: ValidateFuncType<T>,
override?: OverrideFuncType<T> override?: OverrideFuncType<T>
): Readonly<Nullable<T>> { ): Readonly<Nullable<T>> {
const [systemSettings] = useSystemSettings(); const settings = useSystemSettings().content!;
const settings = systemSettings.data;
const extractValue = useMemo(() => { const extractValue = useMemo(() => {
let value: Nullable<T> = null; let value: Nullable<T> = null;

@ -10,11 +10,11 @@ import React, {
import { Container, Row } from "react-bootstrap"; import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Prompt } from "react-router"; import { Prompt } from "react-router";
import { useDidUpdate } from "rooks";
import { useSystemSettings } from "../../@redux/hooks"; import { useSystemSettings } from "../../@redux/hooks";
import { useUpdateLocalStorage } from "../../@storage/local"; import { useUpdateLocalStorage } from "../../@storage/local";
import { SystemApi } from "../../apis"; import { SystemApi } from "../../apis";
import { ContentHeader } from "../../components"; import { ContentHeader } from "../../components";
import { useOnLoadingFinish } from "../../utilites";
import { log } from "../../utilites/logger"; import { log } from "../../utilites/logger";
import { import {
enabledLanguageKey, enabledLanguageKey,
@ -35,7 +35,7 @@ function submitHooks(settings: LooseObject) {
} }
if (enabledLanguageKey in settings) { if (enabledLanguageKey in settings) {
const item = settings[enabledLanguageKey] as Language[]; const item = settings[enabledLanguageKey] as Language.Info[];
settings[enabledLanguageKey] = item.map((v) => v.code2); settings[enabledLanguageKey] = item.map((v) => v.code2);
} }
@ -59,13 +59,14 @@ const SettingsProvider: FunctionComponent<Props> = (props) => {
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);
const [dispatcher, setDispatcher] = useState<SettingDispatcher>({}); const [dispatcher, setDispatcher] = useState<SettingDispatcher>({});
const cleanup = useCallback(() => { const settings = useSystemSettings();
setChange({}); useDidUpdate(() => {
setUpdating(false); // Will be updated by websocket
}, []); if (settings.state !== "loading") {
setChange({});
const [settings] = useSystemSettings(); setUpdating(false);
useOnLoadingFinish(settings, cleanup); }
}, [settings.state]);
const saveSettings = useCallback((settings: LooseObject) => { const saveSettings = useCallback((settings: LooseObject) => {
submitHooks(settings); submitHooks(settings);
@ -90,9 +91,10 @@ const SettingsProvider: FunctionComponent<Props> = (props) => {
setDispatcher(newDispatch); setDispatcher(newDispatch);
}, [saveSettings, saveLocalStorage]); }, [saveSettings, saveLocalStorage]);
const defaultDispatcher = useMemo(() => dispatcher["__default__"], [ const defaultDispatcher = useMemo(
dispatcher, () => dispatcher["__default__"],
]); [dispatcher]
);
const submit = useCallback(() => { const submit = useCallback(() => {
const dispatchMaps = new Map<string, LooseObject>(); const dispatchMaps = new Map<string, LooseObject>();

@ -2,16 +2,19 @@ import { faDownload, faSync, faTrash } from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent, useCallback, useState } from "react"; import React, { FunctionComponent, useCallback, useState } from "react";
import { Container, Row } from "react-bootstrap"; import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { systemUpdateLogs } from "../../@redux/actions";
import { useSystemLogs } from "../../@redux/hooks"; import { useSystemLogs } from "../../@redux/hooks";
import { useReduxAction } from "../../@redux/hooks/base";
import { SystemApi } from "../../apis"; import { SystemApi } from "../../apis";
import { AsyncStateOverlay, ContentHeader } from "../../components"; import { AsyncOverlay, ContentHeader } from "../../components";
import { useBaseUrl } from "../../utilites"; import { useBaseUrl } from "../../utilites";
import Table from "./table"; import Table from "./table";
interface Props {} interface Props {}
const SystemLogsView: FunctionComponent<Props> = () => { const SystemLogsView: FunctionComponent<Props> = () => {
const [logs, update] = useSystemLogs(); const logs = useSystemLogs();
const update = useReduxAction(systemUpdateLogs);
const [resetting, setReset] = useState(false); const [resetting, setReset] = useState(false);
@ -22,15 +25,15 @@ const SystemLogsView: FunctionComponent<Props> = () => {
}, [baseUrl]); }, [baseUrl]);
return ( return (
<AsyncStateOverlay state={logs}> <AsyncOverlay ctx={logs}>
{({ data }) => ( {({ content, state }) => (
<Container fluid> <Container fluid>
<Helmet> <Helmet>
<title>Logs - Bazarr (System)</title> <title>Logs - Bazarr (System)</title>
</Helmet> </Helmet>
<ContentHeader> <ContentHeader>
<ContentHeader.Button <ContentHeader.Button
updating={logs.updating} updating={state === "loading"}
icon={faSync} icon={faSync}
onClick={update} onClick={update}
> >
@ -54,11 +57,11 @@ const SystemLogsView: FunctionComponent<Props> = () => {
</ContentHeader.Button> </ContentHeader.Button>
</ContentHeader> </ContentHeader>
<Row> <Row>
<Table logs={data}></Table> <Table logs={content ?? []}></Table>
</Row> </Row>
</Container> </Container>
)} )}
</AsyncStateOverlay> </AsyncOverlay>
); );
}; };

@ -2,26 +2,29 @@ import { faSync, faTrash } from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent } from "react"; import React, { FunctionComponent } from "react";
import { Container, Row } from "react-bootstrap"; import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { providerUpdateList } from "../../@redux/actions";
import { useSystemProviders } from "../../@redux/hooks"; import { useSystemProviders } from "../../@redux/hooks";
import { useReduxAction } from "../../@redux/hooks/base";
import { ProvidersApi } from "../../apis"; import { ProvidersApi } from "../../apis";
import { AsyncStateOverlay, ContentHeader } from "../../components"; import { AsyncOverlay, ContentHeader } from "../../components";
import Table from "./table"; import Table from "./table";
interface Props {} interface Props {}
const SystemProvidersView: FunctionComponent<Props> = () => { const SystemProvidersView: FunctionComponent<Props> = () => {
const [providers, update] = useSystemProviders(); const providers = useSystemProviders();
const update = useReduxAction(providerUpdateList);
return ( return (
<AsyncStateOverlay state={providers}> <AsyncOverlay ctx={providers}>
{({ data }) => ( {({ content, state }) => (
<Container fluid> <Container fluid>
<Helmet> <Helmet>
<title>Providers - Bazarr (System)</title> <title>Providers - Bazarr (System)</title>
</Helmet> </Helmet>
<ContentHeader> <ContentHeader>
<ContentHeader.Button <ContentHeader.Button
updating={providers.updating} updating={state === "loading"}
icon={faSync} icon={faSync}
onClick={update} onClick={update}
> >
@ -36,11 +39,11 @@ const SystemProvidersView: FunctionComponent<Props> = () => {
</ContentHeader.AsyncButton> </ContentHeader.AsyncButton>
</ContentHeader> </ContentHeader>
<Row> <Row>
<Table providers={data}></Table> <Table providers={content ?? []}></Table>
</Row> </Row>
</Container> </Container>
)} )}
</AsyncStateOverlay> </AsyncOverlay>
); );
}; };

@ -2,32 +2,55 @@ import React, { FunctionComponent, useMemo } from "react";
import { Badge, Card, Col, Container, Row } from "react-bootstrap"; import { Badge, Card, Col, Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useSystemReleases } from "../../@redux/hooks"; import { useSystemReleases } from "../../@redux/hooks";
import { AsyncStateOverlay } from "../../components"; import { AsyncOverlay } from "../../components";
import { BuildKey } from "../../utilites"; import { BuildKey } from "../../utilites";
interface Props {} interface Props {}
const ReleasesView: FunctionComponent<Props> = () => { const ReleasesView: FunctionComponent<Props> = () => {
const [releases] = useSystemReleases(); const releases = useSystemReleases();
return ( return (
<AsyncStateOverlay state={releases}> <Container fluid className="px-5 py-4 bg-light">
{({ data }) => ( <Helmet>
<Container fluid className="px-5 py-4 bg-light"> <title>Releases - Bazarr (System)</title>
<Helmet> </Helmet>
<title>Releases - Bazarr (System)</title> <Row>
</Helmet> <AsyncOverlay ctx={releases}>
<Row> {({ content }) => {
{data.map((v, idx) => ( return (
<Col xs={12} key={BuildKey(idx, v.date)}> <React.Fragment>
<InfoElement {...v}></InfoElement> {content?.map((v, idx) => (
</Col> <Col xs={12} key={BuildKey(idx, v.date)}>
))} <InfoElement {...v}></InfoElement>
</Row> </Col>
</Container> ))}
)} </React.Fragment>
</AsyncStateOverlay> );
}}
</AsyncOverlay>
</Row>
</Container>
); );
// return (
// <AsyncStateOverlay state={releases}>
// {({ data }) => (
// <Container fluid className="px-5 py-4 bg-light">
// <Helmet>
// <title>Releases - Bazarr (System)</title>
// </Helmet>
// <Row>
// {data.map((v, idx) => (
// <Col xs={12} key={BuildKey(idx, v.date)}>
// <InfoElement {...v}></InfoElement>
// </Col>
// ))}
// </Row>
// </Container>
// )}
// </AsyncStateOverlay>
// );
}; };
const headerBadgeCls = "mr-2"; const headerBadgeCls = "mr-2";

@ -10,7 +10,7 @@ import React, { FunctionComponent } from "react";
import { Col, Container, Row } from "react-bootstrap"; import { Col, Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useSystemHealth, useSystemStatus } from "../../@redux/hooks"; import { useSystemHealth, useSystemStatus } from "../../@redux/hooks";
import { AsyncStateOverlay } from "../../components"; import { AsyncOverlay } from "../../components";
import { GithubRepoRoot } from "../../constants"; import { GithubRepoRoot } from "../../constants";
import Table from "./table"; import Table from "./table";
@ -65,19 +65,8 @@ const InfoContainer: FunctionComponent<{ title: string }> = ({
interface Props {} interface Props {}
const SystemStatusView: FunctionComponent<Props> = () => { const SystemStatusView: FunctionComponent<Props> = () => {
const [health] = useSystemHealth(); const health = useSystemHealth();
const [status] = useSystemStatus(); const status = useSystemStatus();
let health_table;
if (health.data.length) {
health_table = (
<AsyncStateOverlay state={health}>
{({ data }) => <Table health={data}></Table>}
</AsyncStateOverlay>
);
} else {
health_table = "No issues with your configuration";
}
return ( return (
<Container className="p-5"> <Container className="p-5">
@ -85,7 +74,13 @@ const SystemStatusView: FunctionComponent<Props> = () => {
<title>Status - Bazarr (System)</title> <title>Status - Bazarr (System)</title>
</Helmet> </Helmet>
<Row> <Row>
<InfoContainer title="Health">{health_table}</InfoContainer> <InfoContainer title="Health">
<AsyncOverlay ctx={health}>
{({ content }) => {
return <Table health={content ?? []}></Table>;
}}
</AsyncOverlay>
</InfoContainer>
</Row> </Row>
<Row> <Row>
<InfoContainer title="About"> <InfoContainer title="About">

@ -6,7 +6,7 @@ interface Props {
health: readonly System.Health[]; health: readonly System.Health[];
} }
const Table: FunctionComponent<Props> = (props) => { const Table: FunctionComponent<Props> = ({ health }) => {
const columns: Column<System.Health>[] = useMemo<Column<System.Health>[]>( const columns: Column<System.Health>[] = useMemo<Column<System.Health>[]>(
() => [ () => [
{ {
@ -21,7 +21,13 @@ const Table: FunctionComponent<Props> = (props) => {
[] []
); );
return <SimpleTable columns={columns} data={props.health}></SimpleTable>; return (
<SimpleTable
columns={columns}
data={health}
emptyText="No issues with your configuration"
></SimpleTable>
);
}; };
export default Table; export default Table;

@ -2,25 +2,28 @@ import { faSync } from "@fortawesome/free-solid-svg-icons";
import React, { FunctionComponent } from "react"; import React, { FunctionComponent } from "react";
import { Container, Row } from "react-bootstrap"; import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { systemMarkTasksDirty } from "../../@redux/actions";
import { useSystemTasks } from "../../@redux/hooks"; import { useSystemTasks } from "../../@redux/hooks";
import { AsyncStateOverlay, ContentHeader } from "../../components"; import { useReduxAction } from "../../@redux/hooks/base";
import { AsyncOverlay, ContentHeader } from "../../components";
import Table from "./table"; import Table from "./table";
interface Props {} interface Props {}
const SystemTasksView: FunctionComponent<Props> = () => { const SystemTasksView: FunctionComponent<Props> = () => {
const [tasks, update] = useSystemTasks(); const tasks = useSystemTasks();
const update = useReduxAction(systemMarkTasksDirty);
return ( return (
<AsyncStateOverlay state={tasks}> <AsyncOverlay ctx={tasks}>
{({ data }) => ( {({ content, state }) => (
<Container fluid> <Container fluid>
<Helmet> <Helmet>
<title>Tasks - Bazarr (System)</title> <title>Tasks - Bazarr (System)</title>
</Helmet> </Helmet>
<ContentHeader> <ContentHeader>
<ContentHeader.Button <ContentHeader.Button
updating={tasks.updating} updating={state === "loading"}
icon={faSync} icon={faSync}
onClick={update} onClick={update}
> >
@ -28,11 +31,11 @@ const SystemTasksView: FunctionComponent<Props> = () => {
</ContentHeader.Button> </ContentHeader.Button>
</ContentHeader> </ContentHeader>
<Row> <Row>
<Table tasks={data}></Table> <Table tasks={content ?? []}></Table>
</Row> </Row>
</Container> </Container>
)} )}
</AsyncStateOverlay> </AsyncOverlay>
); );
}; };

@ -15,7 +15,7 @@ import GenericWantedView from "../generic";
interface Props {} interface Props {}
const WantedMoviesView: FunctionComponent<Props> = () => { const WantedMoviesView: FunctionComponent<Props> = () => {
const [movies] = useWantedMovies(); const wantedMovies = useWantedMovies();
const loader = useReduxAction(movieUpdateWantedByRange); const loader = useReduxAction(movieUpdateWantedByRange);
@ -75,7 +75,7 @@ const WantedMoviesView: FunctionComponent<Props> = () => {
<GenericWantedView <GenericWantedView
type="movies" type="movies"
columns={columns} columns={columns}
state={movies} state={wantedMovies}
loader={loader} loader={loader}
searchAll={searchAll} searchAll={searchAll}
></GenericWantedView> ></GenericWantedView>

@ -15,7 +15,7 @@ import GenericWantedView from "../generic";
interface Props {} interface Props {}
const WantedSeriesView: FunctionComponent<Props> = () => { const WantedSeriesView: FunctionComponent<Props> = () => {
const [series] = useWantedSeries(); const series = useWantedSeries();
const loader = useReduxAction(seriesUpdateWantedByRange); const loader = useReduxAction(seriesUpdateWantedByRange);

@ -9,8 +9,8 @@ import { AsyncPageTable, ContentHeader } from "../../components";
interface Props<T extends Wanted.Base> { interface Props<T extends Wanted.Base> {
type: "movies" | "series"; type: "movies" | "series";
columns: Column<T>[]; columns: Column<T>[];
state: Readonly<AsyncOrderState<T>>; state: Async.Entity<T>;
loader: (start: number, length: number) => void; loader: (params: Parameter.Range) => void;
searchAll: () => Promise<void>; searchAll: () => Promise<void>;
} }
@ -23,7 +23,7 @@ function GenericWantedView<T extends Wanted.Base>({
}: Props<T>) { }: Props<T>) {
const typeName = capitalize(type); const typeName = capitalize(type);
const dataCount = Object.keys(state.data.items).length; const dataCount = Object.keys(state.content.entities).length;
return ( return (
<Container fluid> <Container fluid>
@ -41,7 +41,7 @@ function GenericWantedView<T extends Wanted.Base>({
</ContentHeader> </ContentHeader>
<Row> <Row>
<AsyncPageTable <AsyncPageTable
aos={state} entity={state}
loader={loader} loader={loader}
emptyText={`No Missing ${typeName} Subtitles`} emptyText={`No Missing ${typeName} Subtitles`}
columns={columns} columns={columns}

@ -0,0 +1,9 @@
import {} from "jest";
import ReactDOM from "react-dom";
import App from "../App";
it("renders", () => {
const div = document.createElement("div");
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

@ -5,9 +5,9 @@ class EpisodeApi extends BaseApi {
super("/episodes"); super("/episodes");
} }
async bySeriesId(seriesid: number[]): Promise<Array<Item.Episode>> { async bySeriesId(seriesid: number[]): Promise<Item.Episode[]> {
return new Promise<Array<Item.Episode>>((resolve, reject) => { return new Promise<Item.Episode[]>((resolve, reject) => {
this.get<DataWrapper<Array<Item.Episode>>>("", { seriesid }) this.get<DataWrapper<Item.Episode[]>>("", { seriesid })
.then((result) => { .then((result) => {
resolve(result.data.data); resolve(result.data.data);
}) })
@ -17,9 +17,9 @@ class EpisodeApi extends BaseApi {
}); });
} }
async byEpisodeId(episodeid: number[]): Promise<Array<Item.Episode>> { async byEpisodeId(episodeid: number[]): Promise<Item.Episode[]> {
return new Promise<Array<Item.Episode>>((resolve, reject) => { return new Promise<Item.Episode[]>((resolve, reject) => {
this.get<DataWrapper<Array<Item.Episode>>>("", { episodeid }) this.get<DataWrapper<Item.Episode[]>>("", { episodeid })
.then((result) => { .then((result) => {
resolve(result.data.data); resolve(result.data.data);
}) })
@ -29,9 +29,9 @@ class EpisodeApi extends BaseApi {
}); });
} }
async wanted(start: number, length: number) { async wanted(params: Parameter.Range) {
return new Promise<AsyncDataWrapper<Wanted.Episode>>((resolve, reject) => { return new Promise<AsyncDataWrapper<Wanted.Episode>>((resolve, reject) => {
this.get<AsyncDataWrapper<Wanted.Episode>>("/wanted", { start, length }) this.get<AsyncDataWrapper<Wanted.Episode>>("/wanted", params)
.then((result) => { .then((result) => {
resolve(result.data); resolve(result.data);
}) })
@ -53,9 +53,9 @@ class EpisodeApi extends BaseApi {
}); });
} }
async history(episodeid?: number): Promise<Array<History.Episode>> { async history(episodeid?: number): Promise<History.Episode[]> {
return new Promise<Array<History.Episode>>((resolve, reject) => { return new Promise<History.Episode[]>((resolve, reject) => {
this.get<DataWrapper<Array<History.Episode>>>("/history", { episodeid }) this.get<DataWrapper<History.Episode[]>>("/history", { episodeid })
.then((result) => { .then((result) => {
resolve(result.data.data); resolve(result.data.data);
}) })
@ -101,9 +101,9 @@ class EpisodeApi extends BaseApi {
}); });
} }
async blacklist(): Promise<Array<Blacklist.Episode>> { async blacklist(): Promise<Blacklist.Episode[]> {
return new Promise<Array<Blacklist.Episode>>((resolve, reject) => { return new Promise<Blacklist.Episode[]>((resolve, reject) => {
this.get<DataWrapper<Array<Blacklist.Episode>>>("/blacklist") this.get<DataWrapper<Blacklist.Episode[]>>("/blacklist")
.then((res) => { .then((res) => {
resolve(res.data.data); resolve(res.data.data);
}) })

@ -9,7 +9,7 @@ class HistoryApi extends BaseApi {
timeframe?: History.TimeframeOptions, timeframe?: History.TimeframeOptions,
action?: History.ActionOptions, action?: History.ActionOptions,
provider?: string, provider?: string,
language?: LanguageCodeType language?: Language.CodeType
): Promise<History.Stat> { ): Promise<History.Stat> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.get<History.Stat>("/stats", { this.get<History.Stat>("/stats", {

@ -1,24 +1,28 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useDidMount } from "rooks";
type RequestReturn<F extends () => Promise<any>> = PromiseType<ReturnType<F>>; type Request = (...args: any[]) => Promise<any>;
type Return<T extends Request> = PromiseType<ReturnType<T>>;
export function useAsyncRequest<F extends () => Promise<any>>( export function useAsyncRequest<F extends Request>(
request: F, request: F,
defaultData: RequestReturn<F> initial: Return<F>
): [AsyncState<RequestReturn<F>>, () => void] { ): [Async.Base<Return<F>>, (...args: Parameters<F>) => void] {
const [state, setState] = useState<AsyncState<RequestReturn<F>>>({ const [state, setState] = useState<Async.Base<Return<F>>>({
updating: true, state: "uninitialized",
data: defaultData, content: initial,
error: null,
}); });
const update = useCallback(() => { const update = useCallback(
setState((s) => ({ ...s, updating: true })); (...args: Parameters<F>) => {
request() setState((s) => ({ ...s, state: "loading" }));
.then((res) => setState({ updating: false, data: res })) request(...args)
.catch((err) => setState((s) => ({ ...s, updating: false, err }))); .then((res) =>
}, [request]); setState({ state: "succeeded", content: res, error: null })
)
useDidMount(update); .catch((error) => setState((s) => ({ ...s, state: "failed", error })));
},
[request]
);
return [state, update]; return [state, update];
} }

@ -1,14 +1,15 @@
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios"; import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
import { siteRedirectToAuth, siteUpdateOffline } from "../@redux/actions"; import { siteRedirectToAuth } from "../@redux/actions";
import reduxStore from "../@redux/store"; import { AppDispatch } from "../@redux/store";
import { getBaseUrl } from "../utilites"; import { getBaseUrl } from "../utilites";
class Api { class Api {
axios!: AxiosInstance; axios!: AxiosInstance;
source!: CancelTokenSource; source!: CancelTokenSource;
dispatch!: AppDispatch;
constructor() { constructor() {
const baseUrl = `${getBaseUrl()}/api/`; const baseUrl = `${getBaseUrl()}/api/`;
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV !== "production") {
this.initialize(baseUrl, process.env["REACT_APP_APIKEY"]!); this.initialize(baseUrl, process.env["REACT_APP_APIKEY"]!);
} else { } else {
this.initialize(baseUrl, window.Bazarr.apiKey); this.initialize(baseUrl, window.Bazarr.apiKey);
@ -55,21 +56,10 @@ class Api {
this.axios.defaults.headers.common["X-API-KEY"] = apikey; this.axios.defaults.headers.common["X-API-KEY"] = apikey;
} }
onOnline() {
const offline = reduxStore.getState().site.offline;
if (offline) {
reduxStore.dispatch(siteUpdateOffline(false));
}
}
onOffline() {
reduxStore.dispatch(siteUpdateOffline(true));
}
handleError(code: number) { handleError(code: number) {
switch (code) { switch (code) {
case 401: case 401:
reduxStore.dispatch(siteRedirectToAuth()); this.dispatch(siteRedirectToAuth());
break; break;
case 500: case 500:
break; break;

@ -5,9 +5,9 @@ class MovieApi extends BaseApi {
super("/movies"); super("/movies");
} }
async blacklist(): Promise<Array<Blacklist.Movie>> { async blacklist(): Promise<Blacklist.Movie[]> {
return new Promise<Array<Blacklist.Movie>>((resolve, reject) => { return new Promise<Blacklist.Movie[]>((resolve, reject) => {
this.get<DataWrapper<Array<Blacklist.Movie>>>("/blacklist") this.get<DataWrapper<Blacklist.Movie[]>>("/blacklist")
.then((res) => { .then((res) => {
resolve(res.data.data); resolve(res.data.data);
}) })
@ -49,9 +49,9 @@ class MovieApi extends BaseApi {
}); });
} }
async moviesBy(start: number, length: number) { async moviesBy(params: Parameter.Range) {
return new Promise<AsyncDataWrapper<Item.Movie>>((resolve, reject) => { return new Promise<AsyncDataWrapper<Item.Movie>>((resolve, reject) => {
this.get<AsyncDataWrapper<Item.Movie>>("", { start, length }) this.get<AsyncDataWrapper<Item.Movie>>("", params)
.then((result) => { .then((result) => {
resolve(result.data); resolve(result.data);
}) })
@ -69,9 +69,9 @@ class MovieApi extends BaseApi {
}); });
} }
async wanted(start: number, length: number) { async wanted(params: Parameter.Range) {
return new Promise<AsyncDataWrapper<Wanted.Movie>>((resolve, reject) => { return new Promise<AsyncDataWrapper<Wanted.Movie>>((resolve, reject) => {
this.get<AsyncDataWrapper<Wanted.Movie>>("/wanted", { start, length }) this.get<AsyncDataWrapper<Wanted.Movie>>("/wanted", params)
.then((result) => { .then((result) => {
resolve(result.data); resolve(result.data);
}) })
@ -93,9 +93,9 @@ class MovieApi extends BaseApi {
}); });
} }
async history(id?: number): Promise<Array<History.Movie>> { async history(id?: number): Promise<History.Movie[]> {
return new Promise<Array<History.Movie>>((resolve, reject) => { return new Promise<History.Movie[]>((resolve, reject) => {
this.get<DataWrapper<Array<History.Movie>>>("/history", { this.get<DataWrapper<History.Movie[]>>("/history", {
radarrid: id, radarrid: id,
}) })
.then((result) => { .then((result) => {

@ -6,8 +6,8 @@ class ProviderApi extends BaseApi {
} }
async providers(history: boolean = false) { async providers(history: boolean = false) {
return new Promise<Array<System.Provider>>((resolve, reject) => { return new Promise<System.Provider[]>((resolve, reject) => {
this.get<DataWrapper<Array<System.Provider>>>("", { history }) this.get<DataWrapper<System.Provider[]>>("", { history })
.then((result) => { .then((result) => {
resolve(result.data.data); resolve(result.data.data);
}) })

@ -17,9 +17,9 @@ class SeriesApi extends BaseApi {
}); });
} }
async seriesBy(start: number, length: number) { async seriesBy(params: Parameter.Range) {
return new Promise<AsyncDataWrapper<Item.Series>>((resolve, reject) => { return new Promise<AsyncDataWrapper<Item.Series>>((resolve, reject) => {
this.get<AsyncDataWrapper<Item.Series>>("", { start, length }) this.get<AsyncDataWrapper<Item.Series>>("", params)
.then((result) => { .then((result) => {
resolve(result.data); resolve(result.data);
}) })

@ -58,8 +58,8 @@ class SystemApi extends BaseApi {
} }
async languages(history: boolean = false) { async languages(history: boolean = false) {
return new Promise<Array<ApiLanguage>>((resolve, reject) => { return new Promise<Language.Server[]>((resolve, reject) => {
this.get<Array<ApiLanguage>>("/languages", { history }) this.get<Language.Server[]>("/languages", { history })
.then((result) => { .then((result) => {
resolve(result.data); resolve(result.data);
}) })
@ -70,8 +70,8 @@ class SystemApi extends BaseApi {
} }
async languagesProfileList() { async languagesProfileList() {
return new Promise<Array<Profile.Languages>>((resolve, reject) => { return new Promise<Language.Profile[]>((resolve, reject) => {
this.get<Array<Profile.Languages>>("/languages/profiles") this.get<Language.Profile[]>("/languages/profiles")
.then((result) => resolve(result.data)) .then((result) => resolve(result.data))
.catch(reject); .catch(reject);
}); });
@ -90,8 +90,8 @@ class SystemApi extends BaseApi {
} }
async health() { async health() {
return new Promise<System.Health>((resolve, reject) => { return new Promise<System.Health[]>((resolve, reject) => {
this.get<DataWrapper<System.Health>>("/health") this.get<DataWrapper<System.Health[]>>("/health")
.then((result) => { .then((result) => {
resolve(result.data.data); resolve(result.data.data);
}) })
@ -102,16 +102,16 @@ class SystemApi extends BaseApi {
} }
async logs() { async logs() {
return new Promise<Array<System.Log>>((resolve, reject) => { return new Promise<System.Log[]>((resolve, reject) => {
this.get<DataWrapper<Array<System.Log>>>("/logs") this.get<DataWrapper<System.Log[]>>("/logs")
.then((result) => resolve(result.data.data)) .then((result) => resolve(result.data.data))
.catch((err) => reject(err)); .catch((err) => reject(err));
}); });
} }
async releases() { async releases() {
return new Promise<Array<ReleaseInfo>>((resolve, reject) => { return new Promise<ReleaseInfo[]>((resolve, reject) => {
this.get<DataWrapper<Array<ReleaseInfo>>>("/releases") this.get<DataWrapper<ReleaseInfo[]>>("/releases")
.then((result) => resolve(result.data.data)) .then((result) => resolve(result.data.data))
.catch(reject); .catch(reject);
}); });
@ -125,9 +125,9 @@ class SystemApi extends BaseApi {
}); });
} }
async getTasks() { async tasks() {
return new Promise<System.Task>((resolve, reject) => { return new Promise<System.Task[]>((resolve, reject) => {
this.get<DataWrapper<System.Task>>("/tasks") this.get<DataWrapper<System.Task[]>>("/tasks")
.then((result) => { .then((result) => {
resolve(result.data.data); resolve(result.data.data);
}) })

@ -2,11 +2,11 @@ import React, { useMemo } from "react";
import { Selector, SelectorProps } from "../components"; import { Selector, SelectorProps } from "../components";
interface Props { interface Props {
options: readonly Language[]; options: readonly Language.Info[];
} }
type RemovedSelectorProps<M extends boolean> = Omit< type RemovedSelectorProps<M extends boolean> = Omit<
SelectorProps<Language, M>, SelectorProps<Language.Info, M>,
"label" | "placeholder" "label" | "placeholder"
>; >;
@ -15,7 +15,7 @@ export type LanguageSelectorProps<M extends boolean> = Override<
RemovedSelectorProps<M> RemovedSelectorProps<M>
>; >;
function getLabel(lang: Language) { function getLabel(lang: Language.Info) {
return lang.name; return lang.name;
} }
@ -24,7 +24,7 @@ export function LanguageSelector<M extends boolean = false>(
) { ) {
const { options, ...selector } = props; const { options, ...selector } = props;
const items = useMemo<SelectorOption<Language>[]>( const items = useMemo<SelectorOption<Language.Info>[]>(
() => () =>
options.map((v) => ({ options.map((v) => ({
label: v.name, label: v.name,

@ -1,10 +1,10 @@
import { import {
faCheck, faCheck,
faCircleNotch, faCircleNotch,
faExclamationTriangle,
faTimes, faTimes,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { isEmpty } from "lodash";
import React, { import React, {
FunctionComponent, FunctionComponent,
PropsWithChildren, PropsWithChildren,
@ -13,82 +13,28 @@ import React, {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { Alert, Button, ButtonProps, Container } from "react-bootstrap"; import { Button, ButtonProps } from "react-bootstrap";
import { useTimeoutWhen } from "rooks";
import { LoadingIndicator } from "."; import { LoadingIndicator } from ".";
import { useNotification } from "../@redux/hooks/site";
import { Reload } from "../utilites";
import { Selector, SelectorProps } from "./inputs"; import { Selector, SelectorProps } from "./inputs";
enum RequestState { interface Props<T extends Async.Base<any>> {
Success, ctx: T;
Error, children: FunctionComponent<T>;
Invalid,
}
interface ChildProps<T> {
data: NonNullable<Readonly<T>>;
error?: Error;
}
interface AsyncStateOverlayProps<T> {
state: AsyncState<T>;
exist?: (item: T) => boolean;
children?: FunctionComponent<ChildProps<T>>;
}
function defaultExist(item: any) {
if (item instanceof Array) {
return item.length !== 0;
} else {
return item !== null && item !== undefined;
}
} }
export function AsyncStateOverlay<T>(props: AsyncStateOverlayProps<T>) { export function AsyncOverlay<T extends Async.Base<any>>(props: Props<T>) {
const { exist, state, children } = props; const { ctx, children } = props;
const missing = exist ? !exist(state.data) : !defaultExist(state.data); if (
ctx.state === "uninitialized" ||
const onError = useNotification("async-loading"); (ctx.state === "loading" && isEmpty(ctx.content))
) {
useEffect(() => { return <LoadingIndicator></LoadingIndicator>;
if (!state.updating && state.error !== undefined && !missing) { } else if (ctx.state === "failed") {
onError({ return <p>{ctx.error}</p>;
type: "error",
message: state.error.message,
});
}
}, [state, onError, missing]);
if (state.updating) {
if (missing) {
return <LoadingIndicator></LoadingIndicator>;
}
} else { } else {
if (state.error && missing) { return children(ctx);
return (
<Container>
<Alert variant="danger" className="my-4">
<Alert.Heading>
<FontAwesomeIcon
className="mr-2"
icon={faExclamationTriangle}
></FontAwesomeIcon>
<span>Ouch! You got an error</span>
</Alert.Heading>
<p>{state.error.message}</p>
<hr></hr>
<div className="d-flex justify-content-end">
<Button variant="outline-danger" onClick={Reload}>
Reload
</Button>
</div>
</Alert>
</Container>
);
}
} }
return children ? children({ data: state.data!, error: state.error }) : null;
} }
interface PromiseProps<T> { interface PromiseProps<T> {
@ -101,7 +47,7 @@ export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) {
useEffect(() => { useEffect(() => {
promise() promise()
.then((result) => setItem(result)) .then(setItem)
.catch(() => {}); .catch(() => {});
}, [promise]); }, [promise]);
@ -112,29 +58,27 @@ export function PromiseOverlay<T>({ promise, children }: PromiseProps<T>) {
} }
} }
type ExtractAS<T extends AsyncState<any[]>> = Unpacked<AsyncPayload<T>>; type AsyncSelectorProps<V, T extends Async.Base<V[]>> = {
type AsyncSelectorProps<T extends AsyncState<any[]>> = {
state: T; state: T;
label: (item: ExtractAS<T>) => string; update: () => void;
label: (item: V) => string;
}; };
type RemovedSelectorProps<T, M extends boolean> = Omit< type RemovedSelectorProps<T, M extends boolean> = Omit<
SelectorProps<T, M>, SelectorProps<T, M>,
"loading" | "options" "loading" | "options" | "onFocus"
>; >;
export function AsyncSelector< export function AsyncSelector<
T extends AsyncState<any[]>, V,
T extends Async.Base<V[]>,
M extends boolean = false M extends boolean = false
>( >(props: Override<AsyncSelectorProps<V, T>, RemovedSelectorProps<V, M>>) {
props: Override<AsyncSelectorProps<T>, RemovedSelectorProps<ExtractAS<T>, M>> const { label, state, update, ...selector } = props;
) {
const { label, state, ...selector } = props;
const options = useMemo<SelectorOption<ExtractAS<T>>[]>( const options = useMemo<SelectorOption<V>[]>(
() => () =>
state.data.map((v) => ({ state.content.map((v) => ({
label: label(v), label: label(v),
value: v, value: v,
})), })),
@ -143,9 +87,14 @@ export function AsyncSelector<
return ( return (
<Selector <Selector
loading={state.updating} loading={state.state === "loading"}
options={options} options={options}
label={label} label={label}
onFocus={() => {
if (state.state === "uninitialized") {
update();
}
}}
{...selector} {...selector}
></Selector> ></Selector>
); );
@ -168,6 +117,12 @@ interface AsyncButtonProps<T> {
error?: () => void; error?: () => void;
} }
enum RequestState {
Success,
Error,
Invalid,
}
export function AsyncButton<T>( export function AsyncButton<T>(
props: PropsWithChildren<AsyncButtonProps<T>> props: PropsWithChildren<AsyncButtonProps<T>>
): JSX.Element { ): JSX.Element {
@ -188,28 +143,15 @@ export function AsyncButton<T>(
const [state, setState] = useState(RequestState.Invalid); const [state, setState] = useState(RequestState.Invalid);
const [, setHandle] = useState<Nullable<NodeJS.Timeout>>(null); const needFire = state !== RequestState.Invalid && !noReset;
useEffect(() => {
if (noReset) {
return;
}
if (state === RequestState.Error || state === RequestState.Success) { useTimeoutWhen(
const handle = setTimeout(() => setState(RequestState.Invalid), 2 * 1000); () => {
setHandle(handle); setState(RequestState.Invalid);
} },
2 * 1000,
// Clear timeout handle so we wont leak memory needFire
return () => { );
setHandle((handle) => {
if (handle) {
clearTimeout(handle);
}
return null;
});
};
}, [state, noReset]);
const click = useCallback(() => { const click = useCallback(() => {
if (state !== RequestState.Invalid) { if (state !== RequestState.Invalid) {

@ -98,7 +98,7 @@ export const LoadingIndicator: FunctionComponent<{
}; };
interface LanguageTextProps { interface LanguageTextProps {
text: Language; text: Language.Info;
className?: string; className?: string;
long?: boolean; long?: boolean;
} }

@ -13,6 +13,7 @@ export interface SelectorProps<T, M extends boolean> {
loading?: boolean; loading?: boolean;
multiple?: M; multiple?: M;
onChange?: (k: SelectorValueType<T, M>) => void; onChange?: (k: SelectorValueType<T, M>) => void;
onFocus?: (e: React.FocusEvent<HTMLElement>) => void;
label?: (item: T) => string; label?: (item: T) => string;
defaultValue?: SelectorValueType<T, M>; defaultValue?: SelectorValueType<T, M>;
value?: SelectorValueType<T, M>; value?: SelectorValueType<T, M>;
@ -32,6 +33,7 @@ export function Selector<T = string, M extends boolean = false>(
options, options,
multiple, multiple,
onChange, onChange,
onFocus,
defaultValue, defaultValue,
components, components,
value, value,
@ -89,6 +91,7 @@ export function Selector<T = string, M extends boolean = false>(
components={components} components={components}
className={`custom-selector w-100 ${className ?? ""}`} className={`custom-selector w-100 ${className ?? ""}`}
classNamePrefix="selector" classNamePrefix="selector"
onFocus={onFocus}
onChange={(v) => { onChange={(v) => {
if (onChange) { if (onChange) {
let res: T | T[] | null = null; let res: T | T[] | null = null;

@ -1,21 +1,10 @@
import React, { import React, { FunctionComponent, useCallback, useMemo } from "react";
FunctionComponent,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Column } from "react-table"; import { Column } from "react-table";
import { import { useDidUpdate } from "rooks";
AsyncStateOverlay, import { HistoryIcon, LanguageText, PageTable, TextPopover } from "..";
HistoryIcon, import { EpisodesApi, MoviesApi, useAsyncRequest } from "../../apis";
LanguageText,
PageTable,
TextPopover,
} from "..";
import { EpisodesApi, MoviesApi } from "../../apis";
import { BlacklistButton } from "../../generic/blacklist"; import { BlacklistButton } from "../../generic/blacklist";
import { updateAsyncState } from "../../utilites"; import { AsyncOverlay } from "../async";
import BaseModal, { BaseModalProps } from "./BaseModal"; import BaseModal, { BaseModalProps } from "./BaseModal";
import { usePayload } from "./provider"; import { usePayload } from "./provider";
@ -24,20 +13,20 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
const movie = usePayload<Item.Movie>(modal.modalKey); const movie = usePayload<Item.Movie>(modal.modalKey);
const [history, setHistory] = useState<AsyncState<History.Movie[]>>({ const [history, updateHistory] = useAsyncRequest(
updating: false, MoviesApi.history.bind(MoviesApi),
data: [], []
}); );
const update = useCallback(() => { const update = useCallback(() => {
if (movie) { if (movie) {
updateAsyncState(MoviesApi.history(movie.radarrId), setHistory, []); updateHistory(movie.radarrId);
} }
}, [movie]); }, [movie, updateHistory]);
useEffect(() => { useDidUpdate(() => {
update(); update();
}, [update]); }, [movie?.radarrId]);
const columns = useMemo<Column<History.Movie>[]>( const columns = useMemo<Column<History.Movie>[]>(
() => [ () => [
@ -104,15 +93,15 @@ export const MovieHistoryModal: FunctionComponent<BaseModalProps> = (props) => {
return ( return (
<BaseModal title={`History - ${movie?.title ?? ""}`} {...modal}> <BaseModal title={`History - ${movie?.title ?? ""}`} {...modal}>
<AsyncStateOverlay state={history}> <AsyncOverlay ctx={history}>
{({ data }) => ( {({ content }) => (
<PageTable <PageTable
emptyText="No History Found" emptyText="No History Found"
columns={columns} columns={columns}
data={data} data={content}
></PageTable> ></PageTable>
)} )}
</AsyncStateOverlay> </AsyncOverlay>
</BaseModal> </BaseModal>
); );
}; };
@ -124,22 +113,20 @@ export const EpisodeHistoryModal: FunctionComponent<
> = (props) => { > = (props) => {
const episode = usePayload<Item.Episode>(props.modalKey); const episode = usePayload<Item.Episode>(props.modalKey);
const [history, setHistory] = useState<AsyncState<History.Episode[]>>({ const [history, updateHistory] = useAsyncRequest(
updating: false, EpisodesApi.history.bind(EpisodesApi),
data: [], []
}); );
const update = useCallback(() => { const update = useCallback(() => {
if (episode) { if (episode) {
updateAsyncState( updateHistory(episode.sonarrEpisodeId);
EpisodesApi.history(episode.sonarrEpisodeId),
setHistory,
[]
);
} }
}, [episode]); }, [episode, updateHistory]);
useEffect(() => update(), [update]); useDidUpdate(() => {
update();
}, [episode?.sonarrEpisodeId]);
const columns = useMemo<Column<History.Episode>[]>( const columns = useMemo<Column<History.Episode>[]>(
() => [ () => [
@ -207,15 +194,15 @@ export const EpisodeHistoryModal: FunctionComponent<
return ( return (
<BaseModal title={`History - ${episode?.title ?? ""}`} {...props}> <BaseModal title={`History - ${episode?.title ?? ""}`} {...props}>
<AsyncStateOverlay state={history}> <AsyncOverlay ctx={history}>
{({ data }) => ( {({ content }) => (
<PageTable <PageTable
emptyText="No History Found" emptyText="No History Found"
columns={columns} columns={columns}
data={data} data={content}
></PageTable> ></PageTable>
)} )}
</AsyncStateOverlay> </AsyncOverlay>
</BaseModal> </BaseModal>
); );
}; };

@ -14,7 +14,7 @@ interface Props {
const Editor: FunctionComponent<Props & BaseModalProps> = (props) => { const Editor: FunctionComponent<Props & BaseModalProps> = (props) => {
const { onSuccess, submit, ...modal } = props; const { onSuccess, submit, ...modal } = props;
const [profiles] = useLanguageProfiles(); const profiles = useLanguageProfiles();
const item = usePayload<Item.Base>(modal.modalKey); const item = usePayload<Item.Base>(modal.modalKey);
@ -22,9 +22,9 @@ const Editor: FunctionComponent<Props & BaseModalProps> = (props) => {
const profileOptions = useMemo<SelectorOption<number>[]>( const profileOptions = useMemo<SelectorOption<number>[]>(
() => () =>
profiles.map((v) => { profiles?.map((v) => {
return { label: v.name, value: v.profileId }; return { label: v.name, value: v.profileId };
}), }) ?? [],
[profiles] [profiles]
); );
const [id, setId] = useState<Nullable<number>>(null); const [id, setId] = useState<Nullable<number>>(null);

@ -89,7 +89,7 @@ export const ManualSearchModal: FunctionComponent<Props & BaseModalProps> = (
{ {
accessor: "language", accessor: "language",
Cell: ({ row: { original }, value }) => { Cell: ({ row: { original }, value }) => {
const lang: Language = { const lang: Language.Info = {
code2: value, code2: value,
hi: original.hearing_impaired === "True", hi: original.hearing_impaired === "True",
forced: original.forced === "True", forced: original.forced === "True",

@ -7,7 +7,11 @@ import {
useCloseModal, useCloseModal,
usePayload, usePayload,
} from ".."; } from "..";
import { useLanguageBy, useLanguages, useProfileBy } from "../../@redux/hooks"; import {
useEnabledLanguages,
useLanguageBy,
useProfileBy,
} from "../../@redux/hooks";
import { MoviesApi } from "../../apis"; import { MoviesApi } from "../../apis";
import BaseModal, { BaseModalProps } from "./BaseModal"; import BaseModal, { BaseModalProps } from "./BaseModal";
interface MovieProps {} interface MovieProps {}
@ -17,7 +21,7 @@ const MovieUploadModal: FunctionComponent<MovieProps & BaseModalProps> = (
) => { ) => {
const modal = props; const modal = props;
const [availableLanguages] = useLanguages(true); const availableLanguages = useEnabledLanguages();
const movie = usePayload<Item.Movie>(modal.modalKey); const movie = usePayload<Item.Movie>(modal.modalKey);
@ -25,7 +29,7 @@ const MovieUploadModal: FunctionComponent<MovieProps & BaseModalProps> = (
const [uploading, setUpload] = useState(false); const [uploading, setUpload] = useState(false);
const [language, setLanguage] = useState<Nullable<Language>>(null); const [language, setLanguage] = useState<Nullable<Language.Info>>(null);
const profile = useProfileBy(movie?.profileId); const profile = useProfileBy(movie?.profileId);

@ -24,7 +24,7 @@ import {
useCloseModal, useCloseModal,
usePayload, usePayload,
} from ".."; } from "..";
import { useProfileBy, useProfileItems } from "../../@redux/hooks"; import { useProfileBy, useProfileItemsToLanguages } from "../../@redux/hooks";
import { EpisodesApi, SubtitlesApi } from "../../apis"; import { EpisodesApi, SubtitlesApi } from "../../apis";
import { Selector } from "../inputs"; import { Selector } from "../inputs";
import BaseModal, { BaseModalProps } from "./BaseModal"; import BaseModal, { BaseModalProps } from "./BaseModal";
@ -75,7 +75,7 @@ const SeriesUploadModal: FunctionComponent<SerieProps & BaseModalProps> = ({
const profile = useProfileBy(series?.profileId); const profile = useProfileBy(series?.profileId);
const languages = useProfileItems(profile); const languages = useProfileItemsToLanguages(profile);
const filelist = useMemo(() => pending.map((v) => v.form.file), [pending]); const filelist = useMemo(() => pending.map((v) => v.form.file), [pending]);
@ -141,7 +141,7 @@ const SeriesUploadModal: FunctionComponent<SerieProps & BaseModalProps> = ({
); );
const updateLanguage = useCallback( const updateLanguage = useCallback(
(lang: Nullable<Language>) => { (lang: Nullable<Language.Info>) => {
if (lang) { if (lang) {
const list = pending.map((v) => { const list = pending.map((v) => {
const form = v.form; const form = v.form;

@ -42,7 +42,7 @@ import {
usePayload, usePayload,
useShowModal, useShowModal,
} from ".."; } from "..";
import { useLanguages } from "../../@redux/hooks"; import { useEnabledLanguages } from "../../@redux/hooks";
import { SubtitlesApi } from "../../apis"; import { SubtitlesApi } from "../../apis";
import { isMovie, submodProcessColor } from "../../utilites"; import { isMovie, submodProcessColor } from "../../utilites";
import { log } from "../../utilites/logger"; import { log } from "../../utilites/logger";
@ -54,7 +54,7 @@ import { availableTranslation, colorOptions } from "./toolOptions";
type SupportType = Item.Episode | Item.Movie; type SupportType = Item.Episode | Item.Movie;
type TableColumnType = FormType.ModifySubtitle & { type TableColumnType = FormType.ModifySubtitle & {
_language: Language; _language: Language.Info;
}; };
enum State { enum State {
@ -207,10 +207,7 @@ const AdjustTimesModal: FunctionComponent<BaseModalProps & ToolModalProps> = (
const [isPlus, setPlus] = useState(true); const [isPlus, setPlus] = useState(true);
const [offset, setOffset] = useState<[number, number, number, number]>([ const [offset, setOffset] = useState<[number, number, number, number]>([
0, 0, 0, 0, 0,
0,
0,
0,
]); ]);
const updateOffset = useCallback( const updateOffset = useCallback(
@ -293,14 +290,15 @@ const TranslateModal: FunctionComponent<BaseModalProps & ToolModalProps> = ({
process, process,
...modal ...modal
}) => { }) => {
const [languages] = useLanguages(true); const languages = useEnabledLanguages();
const available = useMemo( const available = useMemo(
() => languages.filter((v) => v.code2 in availableTranslation), () => languages.filter((v) => v.code2 in availableTranslation),
[languages] [languages]
); );
const [selectedLanguage, setLanguage] = useState<Nullable<Language>>(null); const [selectedLanguage, setLanguage] =
useState<Nullable<Language.Info>>(null);
const submit = useCallback(() => { const submit = useCallback(() => {
if (selectedLanguage) { if (selectedLanguage) {

@ -1,41 +1,76 @@
import { isNull } from "lodash"; import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { PluginHook, TableOptions, useTable } from "react-table"; import { PluginHook, TableOptions, useTable } from "react-table";
import { LoadingIndicator } from ".."; import { LoadingIndicator } from "..";
import { usePageSize } from "../../@storage/local"; import { usePageSize } from "../../@storage/local";
import { buildOrderListFrom, isNonNullable, ScrollToTop } from "../../utilites"; import {
ScrollToTop,
useEntityByRange,
useIsEntityLoaded,
} from "../../utilites";
import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable"; import BaseTable, { TableStyleProps, useStyleAndOptions } from "./BaseTable";
import PageControl from "./PageControl"; import PageControl from "./PageControl";
import { useDefaultSettings } from "./plugins"; import { useDefaultSettings } from "./plugins";
function useEntityPagination<T>(
entity: Async.Entity<T>,
loader: (range: Parameter.Range) => void,
start: number,
end: number
): T[] {
const { state, content } = entity;
const needInit = state === "uninitialized";
const hasEmpty = useIsEntityLoaded(content, start, end) === false;
useEffect(() => {
if (needInit || hasEmpty) {
const length = end - start;
loader({ start, length });
}
});
return useEntityByRange(content, start, end);
}
type Props<T extends object> = TableOptions<T> & type Props<T extends object> = TableOptions<T> &
TableStyleProps<T> & { TableStyleProps<T> & {
plugins?: PluginHook<T>[]; plugins?: PluginHook<T>[];
aos: AsyncOrderState<T>; entity: Async.Entity<T>;
loader: (start: number, length: number) => void; loader: (params: Parameter.Range) => void;
}; };
export default function AsyncPageTable<T extends object>(props: Props<T>) { export default function AsyncPageTable<T extends object>(props: Props<T>) {
const { aos, plugins, loader, ...remain } = props; const { entity, plugins, loader, ...remain } = props;
const { style, options } = useStyleAndOptions(remain); const { style, options } = useStyleAndOptions(remain);
const { const {
updating, state,
data: { order, items, dirty }, content: { ids },
} = aos; } = entity;
const allPlugins: PluginHook<T>[] = [useDefaultSettings];
if (plugins) { // Impl a new pagination system instead of hacking into existing one
allPlugins.push(...plugins);
}
// Impl a new pagination system instead of hooking into the existing one
const [pageIndex, setIndex] = useState(0); const [pageIndex, setIndex] = useState(0);
const [pageSize] = usePageSize(); const [pageSize] = usePageSize();
const totalRows = order.length; const totalRows = ids.length;
const pageCount = Math.ceil(totalRows / pageSize); const pageCount = Math.ceil(totalRows / pageSize);
const pageStart = pageIndex * pageSize;
const pageEnd = pageStart + pageSize;
const data = useEntityPagination(entity, loader, pageStart, pageEnd);
const instance = useTable(
{
...options,
data,
},
useDefaultSettings,
...(plugins ?? [])
);
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
instance;
const previous = useCallback(() => { const previous = useCallback(() => {
setIndex((idx) => idx - 1); setIndex((idx) => idx - 1);
}, []); }, []);
@ -48,57 +83,22 @@ export default function AsyncPageTable<T extends object>(props: Props<T>) {
setIndex(idx); setIndex(idx);
}, []); }, []);
const pageStart = pageIndex * pageSize;
const pageEnd = pageStart + pageSize;
const visibleItemIds = useMemo(() => order.slice(pageStart, pageEnd), [
pageStart,
pageEnd,
order,
]);
const newData = useMemo(() => buildOrderListFrom(items, visibleItemIds), [
items,
visibleItemIds,
]);
const newOptions = useMemo<TableOptions<T>>(
() => ({
...options,
data: newData,
}),
[options, newData]
);
const instance = useTable(newOptions, ...allPlugins);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
} = instance;
useEffect(() => { useEffect(() => {
ScrollToTop(); ScrollToTop();
}, [pageIndex]); }, [pageIndex]);
// Reset page index if we out of bound
useEffect(() => { useEffect(() => {
const needFetch = visibleItemIds.length === 0 && dirty === false; if (pageCount === 0) return;
const needRefresh = !visibleItemIds.every(isNonNullable);
if (needFetch || needRefresh) {
loader(pageStart, pageSize);
}
}, [visibleItemIds, pageStart, pageSize, loader, dirty]);
const showLoading = useMemo( if (pageIndex >= pageCount) {
() => setIndex(pageCount - 1);
updating && (visibleItemIds.every(isNull) || visibleItemIds.length === 0), } else if (pageIndex < 0) {
[visibleItemIds, updating] setIndex(0);
); }
}, [pageIndex, pageCount]);
if (showLoading) { if ((state === "loading" && data.length === 0) || state === "uninitialized") {
return <LoadingIndicator></LoadingIndicator>; return <LoadingIndicator></LoadingIndicator>;
} }

@ -1,26 +1,26 @@
import { faCheck, faList, faUndo } from "@fortawesome/free-solid-svg-icons"; import { faCheck, faList, faUndo } from "@fortawesome/free-solid-svg-icons";
import { AsyncThunk } from "@reduxjs/toolkit";
import { uniqBy } from "lodash"; import { uniqBy } from "lodash";
import React, { useCallback, useMemo, useState } from "react"; import React, { useCallback, useMemo, useState } from "react";
import { Container, Dropdown, Row } from "react-bootstrap"; import { Container, Dropdown, Row } from "react-bootstrap";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { Column } from "react-table"; import { Column } from "react-table";
import { useLanguageProfiles } from "../../@redux/hooks"; import { useLanguageProfiles } from "../../@redux/hooks";
import { useReduxActionWith } from "../../@redux/hooks/base"; import { useAppDispatch } from "../../@redux/hooks/base";
import { AsyncActionDispatcher } from "../../@redux/types";
import { ContentHeader } from "../../components"; import { ContentHeader } from "../../components";
import { GetItemId, isNonNullable } from "../../utilites"; import { GetItemId, isNonNullable } from "../../utilites";
import Table from "./table"; import Table from "./table";
export interface SharedProps<T extends Item.Base> { export interface SharedProps<T extends Item.Base> {
name: string; name: string;
loader: (start: number, length: number) => void; loader: (params: Parameter.Range) => void;
columns: Column<T>[]; columns: Column<T>[];
modify: (form: FormType.ModifyItem) => Promise<void>; modify: (form: FormType.ModifyItem) => Promise<void>;
state: AsyncOrderState<T>; state: Async.Entity<T>;
} }
interface Props<T extends Item.Base = Item.Base> extends SharedProps<T> { interface Props<T extends Item.Base = Item.Base> extends SharedProps<T> {
updateAction: (id?: number[]) => AsyncActionDispatcher<any>; updateAction: AsyncThunk<AsyncDataWrapper<T>, void, {}>;
} }
function BaseItemView<T extends Item.Base>({ function BaseItemView<T extends Item.Base>({
@ -32,35 +32,39 @@ function BaseItemView<T extends Item.Base>({
const [pendingEditMode, setPendingEdit] = useState(false); const [pendingEditMode, setPendingEdit] = useState(false);
const [editMode, setEdit] = useState(false); const [editMode, setEdit] = useState(false);
const onUpdated = useCallback(() => { const dispatch = useAppDispatch();
setPendingEdit((edit) => { const update = useCallback(() => {
// Hack to remove all dependencies dispatch(updateAction()).then(() => {
setEdit(edit); setPendingEdit((edit) => {
return edit; // Hack to remove all dependencies
setEdit(edit);
return edit;
});
setDirty([]);
}); });
setDirty([]); }, [dispatch, updateAction]);
}, []);
const update = useReduxActionWith(updateAction, onUpdated);
const [selections, setSelections] = useState<T[]>([]); const [selections, setSelections] = useState<T[]>([]);
const [dirtyItems, setDirty] = useState<T[]>([]); const [dirtyItems, setDirty] = useState<T[]>([]);
const [profiles] = useLanguageProfiles(); const profiles = useLanguageProfiles();
const profileOptions = useMemo<JSX.Element[]>(() => { const profileOptions = useMemo<JSX.Element[]>(() => {
const items: JSX.Element[] = []; const items: JSX.Element[] = [];
items.push( if (profiles) {
<Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item> items.push(
); <Dropdown.Item key="clear-profile">Clear Profile</Dropdown.Item>
items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>); );
items.push( items.push(<Dropdown.Divider key="dropdown-divider"></Dropdown.Divider>);
...profiles.map((v) => ( items.push(
<Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}> ...profiles.map((v) => (
{v.name} <Dropdown.Item key={v.profileId} eventKey={v.profileId.toString()}>
</Dropdown.Item> {v.name}
)) </Dropdown.Item>
); ))
);
}
return items; return items;
}, [profiles]); }, [profiles]);
@ -79,13 +83,13 @@ function BaseItemView<T extends Item.Base>({
); );
const startEdit = useCallback(() => { const startEdit = useCallback(() => {
if (shared.state.data.order.every(isNonNullable)) { if (shared.state.content.ids.every(isNonNullable)) {
setEdit(true); setEdit(true);
} else { } else {
update(); update();
} }
setPendingEdit(true); setPendingEdit(true);
}, [shared.state.data.order, update]); }, [shared.state.content.ids, update]);
const endEdit = useCallback(() => { const endEdit = useCallback(() => {
setEdit(false); setEdit(false);
@ -143,7 +147,9 @@ function BaseItemView<T extends Item.Base>({
) : ( ) : (
<ContentHeader.Button <ContentHeader.Button
updating={pendingEditMode !== editMode} updating={pendingEditMode !== editMode}
disabled={state.data.order.length === 0 && state.updating} disabled={
state.content.ids.length === 0 && state.state === "loading"
}
icon={faList} icon={faList}
onClick={startEdit} onClick={startEdit}
> >

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save