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
working-directory: ${{ env.UI_DIRECTORY }}
- name: Unit Test
run: npm test
working-directory: ${{ env.UI_DIRECTORY }}
- uses: actions/upload-artifact@v2
with:
name: ${{ env.UI_ARTIFACT_NAME }}

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

@ -46,6 +46,12 @@ Open `http://localhost:3000` to view it in the browser.
The page will reload if you make edits.
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`
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-solid-svg-icons": "^5.15",
"@fortawesome/react-fontawesome": "^0.1.11",
"@reduxjs/toolkit": "^1.6",
"@types/bootstrap": "^5",
"@types/jest": "~26.0.24",
"@types/lodash": "^4",
"@types/node": "^15",
"@types/react": "^16",
@ -28,9 +30,6 @@
"@types/react-router-dom": "^5",
"@types/react-select": "^4.0.3",
"@types/react-table": "^7",
"@types/redux-actions": "^2",
"@types/redux-logger": "^3",
"@types/redux-promise": "^0.5",
"axios": "^0.21",
"bootstrap": "^4",
"http-proxy-middleware": "^0.19",
@ -46,10 +45,6 @@
"react-select": "^4",
"react-table": "^7",
"recharts": "^2.0.8",
"redux-actions": "^2",
"redux-logger": "^3",
"redux-promise": "^0.6",
"redux-thunk": "^2.3",
"rooks": "^5",
"sass": "^1",
"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 {
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(
MOVIES_UPDATE_LIST,
(id?: number[]) => MoviesApi.movies(id)
export const movieUpdateByRange = createAsyncThunk(
"movies/update/range",
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(
MOVIES_UPDATE_WANTED_LIST,
(radarrid: number[]) => MoviesApi.wantedBy(radarrid)
export const movieUpdateAll = createAsyncThunk(
"movies/update/all",
async () => {
const response = await MoviesApi.movies();
return response;
}
);
export const movieDeleteWantedItems = createDeleteAction(
MOVIES_DELETE_WANTED_ITEMS
export const movieRemoveById = createAction<number[]>("movies/remove");
export const movieMarkDirtyById = createAction<number[]>(
"movies/mark_dirty/id"
);
export const movieUpdateWantedByRange = createAsyncAction(
MOVIES_UPDATE_WANTED_LIST,
(start: number, length: number) => MoviesApi.wanted(start, length)
export const movieUpdateWantedById = createAsyncThunk(
"movies/wanted/update/id",
async (ids: number[]) => {
const response = await MoviesApi.wantedBy(ids);
return response;
}
);
export const movieUpdateHistoryList = createAsyncAction(
MOVIES_UPDATE_HISTORY_LIST,
() => MoviesApi.history()
export const movieRemoveWantedById = createAction<number[]>(
"movies/wanted/remove/id"
);
export const movieUpdateByRange = createAsyncAction(
MOVIES_UPDATE_LIST,
(start: number, length: number) => MoviesApi.moviesBy(start, length)
export const movieMarkWantedDirtyById = createAction<number[]>(
"movies/wanted/mark_dirty/id"
);
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(
MOVIES_UPDATE_BLACKLIST,
() => MoviesApi.blacklist()
export const movieMarkBlacklistDirty = createAction(
"movies/blacklist/mark_dirty"
);

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

@ -1,63 +1,45 @@
import { createAction } from "redux-actions";
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
import { BadgesApi } from "../../apis";
import {
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()
);
import { systemUpdateAllSettings } from "./system";
// TODO: Override error messages
export const siteInitializationFailed = createAction(SITE_INITIALIZE_FAILED);
export const siteBootstrap = createAsyncThunk(
"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, () =>
BadgesApi.all()
export const siteAddNotifications = createAction<Server.Notification[]>(
"site/notifications/add"
);
export const siteAddNotifications = createAction(
SITE_NOTIFICATIONS_ADD,
(notification: ReduxStore.Notification[]) => notification
export const siteRemoveNotifications = createAction<string>(
"site/notifications/remove"
);
export const siteRemoveNotifications = createAction(
SITE_NOTIFICATIONS_REMOVE,
(id: string) => id
export const siteAddProgress = createAction<Server.Progress[]>(
"site/progress/add"
);
export const siteAddProgress = createAction(
SITE_PROGRESS_ADD,
(progress: ReduxStore.Progress[]) => progress
);
export const siteRemoveProgress = createAction<string>("site/progress/remove");
export const siteRemoveProgress = createAction(
SITE_PROGRESS_REMOVE,
(id: string) => id
);
export const siteChangeSidebar = createAction<string>("site/sidebar/update");
export const siteChangeSidebar = createAction(
SITE_SIDEBAR_UPDATE,
(id: string) => id
);
export const siteUpdateOffline = createAction<boolean>("site/offline/update");
export const siteUpdateOffline = createAction(
SITE_OFFLINE_UPDATE,
(state: boolean) => state
export const siteUpdateBadges = createAsyncThunk(
"site/badges/update",
async () => {
const response = await BadgesApi.all();
return response;
}
);

@ -1,64 +1,87 @@
import { createAction, createAsyncThunk } from "@reduxjs/toolkit";
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(() => [
systemUpdateLanguages(),
systemUpdateLanguagesProfiles(),
]);
export const systemUpdateLanguages = createAsyncAction(
SYSTEM_UPDATE_LANGUAGES_LIST,
() => SystemApi.languages()
export const systemUpdateAllSettings = createAsyncThunk(
"system/update",
async (_: undefined, { dispatch }) => {
await Promise.all([
dispatch(systemUpdateSettings()),
dispatch(systemUpdateLanguages()),
dispatch(systemUpdateLanguagesProfiles()),
]);
}
);
export const systemUpdateLanguagesProfiles = createAsyncAction(
SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST,
() => SystemApi.languagesProfileList()
export const systemUpdateLanguages = createAsyncThunk(
"system/languages/update",
async () => {
const response = await SystemApi.languages();
return response;
}
);
export const systemUpdateStatus = createAsyncAction(SYSTEM_UPDATE_STATUS, () =>
SystemApi.status()
export const systemUpdateLanguagesProfiles = createAsyncThunk(
"system/languages/profile/update",
async () => {
const response = await SystemApi.languagesProfileList();
return response;
}
);
export const systemUpdateHealth = createAsyncAction(SYSTEM_UPDATE_HEALTH, () =>
SystemApi.health()
export const systemUpdateStatus = createAsyncThunk(
"system/status/update",
async () => {
const response = await SystemApi.status();
return response;
}
);
export const systemUpdateTasks = createAsyncAction(SYSTEM_UPDATE_TASKS, () =>
SystemApi.getTasks()
export const systemUpdateHealth = createAsyncThunk(
"system/health/update",
async () => {
const response = await SystemApi.health();
return response;
}
);
export const systemUpdateLogs = createAsyncAction(SYSTEM_UPDATE_LOGS, () =>
SystemApi.logs()
export const systemMarkTasksDirty = createAction("system/tasks/mark_dirty");
export const systemUpdateTasks = createAsyncThunk(
"system/tasks/update",
async () => {
const response = await SystemApi.tasks();
return response;
}
);
export const systemUpdateReleases = createAsyncAction(
SYSTEM_UPDATE_RELEASES,
() => SystemApi.releases()
export const systemUpdateLogs = createAsyncThunk(
"system/logs/update",
async () => {
const response = await SystemApi.logs();
return response;
}
);
export const systemUpdateSettings = createAsyncAction(
SYSTEM_UPDATE_SETTINGS,
() => SystemApi.settings()
export const systemUpdateReleases = createAsyncThunk(
"system/releases/update",
async () => {
const response = await SystemApi.releases();
return response;
}
);
export const providerUpdateList = createAsyncAction(
SYSTEM_UPDATE_PROVIDERS,
() => ProvidersApi.providers()
export const systemUpdateSettings = createAsyncThunk(
"system/settings/update",
async () => {
const response = await SystemApi.settings();
return response;
}
);
export const systemUpdateSettingsAll = createAsyncCombineAction(() => [
systemUpdateSettings(),
systemUpdateLanguagesAll(),
]);
export const providerUpdateList = createAsyncThunk(
"providers/update",
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 { useDispatch, useSelector } from "react-redux";
import { createCallbackAction } from "../actions/factory";
import { ActionCallback, AsyncActionDispatcher } from "../types";
import { AppDispatch, RootState } from "../store";
// function use
export function useReduxStore<T extends (store: ReduxStore) => any>(
export function useReduxStore<T extends (store: RootState) => any>(
selector: T
) {
return useSelector<ReduxStore, ReturnType<T>>(selector);
return useSelector<RootState, ReturnType<T>>(selector);
}
export function useReduxAction<T extends (...args: any[]) => void>(action: T) {
const dispatch = useDispatch();
return useCallback((...args: Parameters<T>) => dispatch(action(...args)), [
action,
dispatch,
]);
export function useAppDispatch() {
return useDispatch<AppDispatch>();
}
export function useReduxActionWith<
T extends (...args: any[]) => AsyncActionDispatcher<any>
>(action: T, success: ActionCallback) {
const dispatch = useDispatch();
// TODO: Fix type
export function useReduxAction<T extends ActionCreator<any>>(action: T) {
const dispatch = useAppDispatch();
return useCallback(
(...args: Parameters<T>) => {
const callbackAction = createCallbackAction(
() => [action(...args)],
success
);
dispatch(callbackAction());
},
[dispatch, action, success]
(...args: Parameters<T>) => dispatch(action(...args)),
[action, dispatch]
);
}

@ -1,393 +1,4 @@
import { useCallback, useEffect, useMemo } from "react";
import { useSocketIOReducer, useWrapToOptionalId } from "../../@socketio/hooks";
import { buildOrderList } from "../../utilites";
import {
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);
}
export * from "./movies";
export * from "./series";
export * from "./site";
export * from "./system";

@ -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);
return useCallback(
(msg: Omit<ReduxStore.Notification, "id" | "timeout">) => {
const notification: ReduxStore.Notification = {
(msg: Omit<Server.Notification, "id" | "timeout">) => {
const notification: Server.Notification = {
...msg,
id,
timeout,
@ -24,18 +24,18 @@ export function useIsOffline() {
}
export function useIsSonarrEnabled() {
const [settings] = useSystemSettings();
return settings.data?.general.use_sonarr ?? true;
const settings = useSystemSettings();
return settings.content?.general.use_sonarr ?? true;
}
export function useIsRadarrEnabled() {
const [settings] = useSystemSettings();
return settings.data?.general.use_radarr ?? true;
const settings = useSystemSettings();
return settings.content?.general.use_radarr ?? true;
}
export function useShowOnlyDesired() {
const [settings] = useSystemSettings();
return settings.data?.general.embedded_subs_show_desired ?? false;
const settings = useSystemSettings();
return settings.content?.general.embedded_subs_show_desired ?? false;
}
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 movie from "./movie";
import movies from "./movie";
import series from "./series";
import site from "./site";
import system from "./system";
export default combineReducers({
system,
const AllReducers = {
movies,
series,
movie,
site,
});
system,
};
export default AllReducers;

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

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

@ -1,100 +1,89 @@
import { createReducer } from "@reduxjs/toolkit";
import { remove, uniqBy } from "lodash";
import { Action, handleActions } from "redux-actions";
import apis from "../../apis";
import {
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 { AsyncAction } from "../types";
siteAddNotifications,
siteAddProgress,
siteBootstrap,
siteChangeSidebar,
siteRedirectToAuth,
siteRemoveNotifications,
siteRemoveProgress,
siteUpdateBadges,
siteUpdateInitialization,
siteUpdateOffline,
} from "../actions/site";
const reducer = handleActions<ReduxStore.Site, any>(
{
[SITE_NEED_AUTH]: (state) => {
if (process.env.NODE_ENV !== "development") {
interface Site {
// Initialization state or error message
initialized: boolean | string;
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");
}
return {
...state,
auth: false,
};
},
[SITE_INITIALIZED]: (state) => ({
...state,
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(
state.auth = false;
})
.addCase(siteUpdateInitialization, (state, action) => {
state.initialized = action.payload;
})
.addCase(siteAddNotifications, (state, action) => {
state.notifications = uniqBy(
[...action.payload.reverse(), ...state.notifications],
(n) => n.id
);
return { ...state, notifications };
},
[SITE_NOTIFICATIONS_REMOVE]: (state, action: Action<string>) => {
const notifications = [...state.notifications];
remove(notifications, (n) => n.id === action.payload);
return { ...state, notifications };
},
[SITE_PROGRESS_ADD]: (state, action: Action<ReduxStore.Progress[]>) => {
const progress = uniqBy(
})
.addCase(siteRemoveNotifications, (state, action) => {
remove(state.notifications, (n) => n.id === action.payload);
})
.addCase(siteAddProgress, (state, action) => {
state.progress = uniqBy(
[...action.payload.reverse(), ...state.progress],
(n) => n.id
);
return { ...state, progress };
},
[SITE_PROGRESS_REMOVE]: (state, action: Action<string>) => {
const progress = [...state.progress];
remove(progress, (n) => n.id === action.payload);
return { ...state, progress };
},
[SITE_SIDEBAR_UPDATE]: (state, action: Action<string>) => {
return {
...state,
sidebar: 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,
}
);
})
.addCase(siteRemoveProgress, (state, action) => {
remove(state.progress, (n) => n.id === action.payload);
})
.addCase(siteChangeSidebar, (state, action) => {
state.sidebar = action.payload;
})
.addCase(siteUpdateOffline, (state, action) => {
state.offline = action.payload;
})
.addCase(siteUpdateBadges.fulfilled, (state, action) => {
state.badges = action.payload;
});
});
export default reducer;

@ -1,121 +1,74 @@
import { handleActions } from "redux-actions";
import { createReducer } from "@reduxjs/toolkit";
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 { updateAsyncState } from "../utils/mapper";
providerUpdateList,
systemMarkTasksDirty,
systemUpdateHealth,
systemUpdateLanguages,
systemUpdateLanguagesProfiles,
systemUpdateLogs,
systemUpdateReleases,
systemUpdateSettings,
systemUpdateStatus,
systemUpdateTasks,
} from "../actions";
import { AsyncUtility } from "../utils/async";
import { createAsyncItemReducer } from "../utils/factory";
const reducer = handleActions<ReduxStore.System, any>(
{
[SYSTEM_UPDATE_LANGUAGES_LIST]: (state, action) => {
const languages = updateAsyncState<Array<ApiLanguage>>(action, []);
const enabledLanguage: AsyncState<ApiLanguage[]> = {
...languages,
data: languages.data.filter((v) => v.enabled),
};
const newState = {
...state,
languages,
enabledLanguage,
};
return newState;
},
[SYSTEM_UPDATE_LANGUAGES_PROFILE_LIST]: (state, action) => {
const newState = {
...state,
languagesProfiles: updateAsyncState<Array<Profile.Languages>>(
action,
[]
),
};
return newState;
},
[SYSTEM_UPDATE_STATUS]: (state, action) => {
return {
...state,
status: updateAsyncState<System.Status | undefined>(
action,
state.status.data
),
};
},
[SYSTEM_UPDATE_HEALTH]: (state, action) => {
return {
...state,
health: updateAsyncState(action, state.health.data),
};
},
[SYSTEM_UPDATE_TASKS]: (state, action) => {
return {
...state,
tasks: updateAsyncState<Array<System.Task>>(action, state.tasks.data),
};
},
[SYSTEM_UPDATE_PROVIDERS]: (state, action) => {
return {
...state,
providers: updateAsyncState(action, state.providers.data),
};
},
[SYSTEM_UPDATE_LOGS]: (state, action) => {
return {
...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,
},
}
);
interface System {
languages: Async.Item<Language.Server[]>;
languagesProfiles: Async.Item<Language.Profile[]>;
status: Async.Item<System.Status>;
health: Async.Item<System.Health[]>;
tasks: Async.Item<System.Task[]>;
providers: Async.Item<System.Provider[]>;
logs: Async.Item<System.Log[]>;
releases: Async.Item<ReleaseInfo[]>;
settings: Async.Item<Settings>;
}
const defaultSystem: System = {
languages: AsyncUtility.getDefaultItem(),
languagesProfiles: AsyncUtility.getDefaultItem(),
status: AsyncUtility.getDefaultItem(),
health: AsyncUtility.getDefaultItem(),
tasks: AsyncUtility.getDefaultItem(),
providers: AsyncUtility.getDefaultItem(),
logs: AsyncUtility.getDefaultItem(),
releases: AsyncUtility.getDefaultItem(),
settings: AsyncUtility.getDefaultItem(),
};
const reducer = createReducer(defaultSystem, (builder) => {
createAsyncItemReducer(builder, (s) => s.languages, {
all: systemUpdateLanguages,
});
createAsyncItemReducer(builder, (s) => s.languagesProfiles, {
all: systemUpdateLanguagesProfiles,
});
createAsyncItemReducer(builder, (s) => s.status, { all: systemUpdateStatus });
createAsyncItemReducer(builder, (s) => s.settings, {
all: systemUpdateSettings,
});
createAsyncItemReducer(builder, (s) => s.releases, {
all: systemUpdateReleases,
});
createAsyncItemReducer(builder, (s) => s.logs, {
all: systemUpdateLogs,
});
createAsyncItemReducer(builder, (s) => s.health, {
all: systemUpdateHealth,
});
createAsyncItemReducer(builder, (s) => s.tasks, {
all: systemUpdateTasks,
dirty: systemMarkTasksDirty,
});
createAsyncItemReducer(builder, (s) => s.providers, {
all: providerUpdateList,
});
});
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 logger from "redux-logger";
import promise from "redux-promise";
import trunk from "redux-thunk";
import rootReducer from "../reducers";
import { configureStore } from "@reduxjs/toolkit";
import apis from "../../apis";
import reducer from "../reducers";
const plugins = [promise, trunk];
const store = configureStore({
reducer,
});
if (
process.env.NODE_ENV === "development" &&
process.env["REACT_APP_LOG_REDUX_EVENT"] !== "false"
) {
plugins.push(logger);
}
// FIXME
apis.dispatch = store.dispatch;
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
const store = createStore(rootReducer, applyMiddleware(...plugins));
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 { log } from "../utilites/logger";
@ -11,16 +11,3 @@ export function useSocketIOReducer(reducer: SocketIO.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)) {
records[e.type] = {};
}
const record = records[e.type]!;
if (!(e.action in record)) {
record[e.action] = [];
}
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) => {
ids = uniq(ids);
const action = handler[key];
if (typeof action == "function") {
const action = handler[key as SocketIO.ActionType];
if (action) {
action(ids);
} 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 {
badgeUpdateAll,
bootstrap,
movieDeleteItems,
movieDeleteWantedItems,
movieUpdateList,
movieUpdateWantedList,
seriesDeleteItems,
seriesDeleteWantedItems,
seriesUpdateList,
seriesUpdateWantedList,
episodesMarkBlacklistDirty,
episodesMarkDirtyById,
episodesMarkHistoryDirty,
episodesRemoveById,
movieMarkBlacklistDirty,
movieMarkDirtyById,
movieMarkHistoryDirty,
movieMarkWantedDirtyById,
movieRemoveById,
movieRemoveWantedById,
seriesMarkDirtyById,
seriesMarkWantedDirtyById,
seriesRemoveById,
seriesRemoveWantedById,
siteAddNotifications,
siteAddProgress,
siteInitializationFailed,
siteBootstrap,
siteRemoveProgress,
siteUpdateBadges,
siteUpdateInitialization,
siteUpdateOffline,
systemUpdateLanguagesAll,
systemUpdateSettings,
systemMarkTasksDirty,
systemUpdateAllSettings,
systemUpdateLanguages,
} from "../@redux/actions";
import reduxStore from "../@redux/store";
function bindToReduxStore(
fn: (ids?: number[]) => any
): SocketIO.ActionFn<number> {
return (ids?: number[]) => reduxStore.dispatch(fn(ids));
function bindReduxAction<T extends ActionCreator<any>>(action: T) {
return (...args: Parameters<T>) => {
reduxStore.dispatch(action(...args));
};
}
export function createDeleteAction(type: string): SocketIO.ActionFn<number> {
return createAction(type, (id?: number[]) => id ?? []);
function bindReduxActionWithParam<T extends ActionCreator<any>>(
action: T,
...param: Parameters<T>
) {
return () => {
reduxStore.dispatch(action(...param));
};
}
export function createDefaultReducer(): SocketIO.Reducer[] {
return [
{
key: "connect",
any: () => reduxStore.dispatch(siteUpdateOffline(false)),
any: bindReduxActionWithParam(siteUpdateOffline, false),
},
{
key: "connect",
any: () => reduxStore.dispatch<any>(bootstrap()),
any: bindReduxAction(siteBootstrap),
},
{
key: "connect_error",
@ -47,19 +59,19 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
if (initialized === true) {
reduxStore.dispatch(siteUpdateOffline(true));
} else {
reduxStore.dispatch(siteInitializationFailed());
reduxStore.dispatch(siteUpdateInitialization("Socket.IO Error"));
}
},
},
{
key: "disconnect",
any: () => reduxStore.dispatch(siteUpdateOffline(true)),
any: bindReduxActionWithParam(siteUpdateOffline, true),
},
{
key: "message",
update: (msg) => {
if (msg) {
const notifications = msg.map<ReduxStore.Notification>((message) => ({
const notifications = msg.map<Server.Notification>((message) => ({
message,
type: "info",
id: "backend-message",
@ -72,14 +84,10 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
},
{
key: "progress",
update: (progress) => {
if (progress) {
reduxStore.dispatch(siteAddProgress(progress));
}
},
update: bindReduxAction(siteAddProgress),
delete: (ids) => {
setTimeout(() => {
ids?.forEach((id) => {
ids.forEach((id) => {
reduxStore.dispatch(siteRemoveProgress(id));
});
}, 3 * 1000);
@ -87,43 +95,60 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
},
{
key: "series",
update: bindToReduxStore(seriesUpdateList),
delete: bindToReduxStore(seriesDeleteItems),
update: bindReduxAction(seriesMarkDirtyById),
delete: bindReduxAction(seriesRemoveById),
},
{
key: "movie",
update: bindToReduxStore(movieUpdateList),
delete: bindToReduxStore(movieDeleteItems),
update: bindReduxAction(movieMarkDirtyById),
delete: bindReduxAction(movieRemoveById),
},
{
key: "episode",
update: bindReduxAction(episodesMarkDirtyById),
delete: bindReduxAction(episodesRemoveById),
},
{
key: "episode-wanted",
update: (ids: number[] | undefined) => {
if (ids) {
reduxStore.dispatch(seriesUpdateWantedList(ids) as any);
}
},
delete: bindToReduxStore(seriesDeleteWantedItems),
update: bindReduxAction(seriesMarkWantedDirtyById),
delete: bindReduxAction(seriesRemoveWantedById),
},
{
key: "movie-wanted",
update: (ids: number[] | undefined) => {
if (ids) {
reduxStore.dispatch(movieUpdateWantedList(ids) as any);
}
},
delete: bindToReduxStore(movieDeleteWantedItems),
update: bindReduxAction(movieMarkWantedDirtyById),
delete: bindReduxAction(movieRemoveWantedById),
},
{
key: "settings",
any: bindToReduxStore(systemUpdateSettings),
any: bindReduxAction(systemUpdateAllSettings),
},
{
key: "languages",
any: bindToReduxStore(systemUpdateLanguagesAll),
any: bindReduxAction(systemUpdateLanguages),
},
{
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 {
episodes: number;
movies: number;
@ -7,35 +5,40 @@ interface Badge {
status: number;
}
interface ApiLanguage {
code2: LanguageCodeType;
name: string;
enabled: boolean;
}
declare namespace Language {
type CodeType = string;
interface Server {
code2: CodeType;
name: string;
enabled: boolean;
}
type Language = Omit<ApiLanguage, "enabled"> & {
// TODO: Make things unify
hi?: boolean;
forced?: boolean;
};
interface Info {
code2: CodeType;
name: string;
hi?: boolean;
forced?: boolean;
}
namespace Profile {
interface Item {
interface ProfileItem {
id: number;
audio_exclude: PythonBoolean;
forced: PythonBoolean;
hi: PythonBoolean;
language: LanguageCodeType;
language: CodeType;
}
interface Languages {
interface Profile {
name: string;
profileId: number;
cutoff: number | null;
items: Item[];
items: ProfileItem[];
}
}
interface Subtitle extends Language {
interface Subtitle {
code2: Language.CodeType;
name: string;
forced: boolean;
hi: boolean;
path: string | null;
@ -91,15 +94,15 @@ interface TitleType {
}
interface AudioLanguageType {
audio_language: Language[];
audio_language: Language.Info[];
}
interface ItemHistoryType {
language: Language;
language: Language.Info;
provider: string;
}
namespace Item {
declare namespace Item {
type Base = PathType &
TitleType &
TagType &
@ -152,7 +155,7 @@ namespace Item {
};
}
namespace Wanted {
declare namespace Wanted {
type Base = MonitoredType &
TagType &
SceneNameType & {
@ -171,7 +174,7 @@ namespace Wanted {
type Movie = Base & MovieIdType & TitleType;
}
namespace Blacklist {
declare namespace Blacklist {
type Base = ItemHistoryType & {
parsed_timestamp: string;
timestamp: string;
@ -187,7 +190,7 @@ namespace Blacklist {
};
}
namespace History {
declare namespace History {
type Base = SubtitlePathType &
TagType &
MonitoredType &
@ -196,7 +199,7 @@ namespace History {
blacklisted: boolean;
score?: string;
subs_id?: string;
raw_timestamp: int;
raw_timestamp: number;
parsed_timestamp: string;
timestamp: string;
description: string;
@ -225,6 +228,13 @@ namespace History {
type ActionOptions = 1 | 2 | 3;
}
declare namespace Parameter {
interface Range {
start: number;
length: number;
}
}
interface SearchResultType {
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;
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> = {
label: string;
value: PAYLOAD;

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

@ -1,3 +1,6 @@
// @ts-nocheck
// TODO: Fine a better solution for this
import {
UseColumnOrderInstanceProps,
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;
subscene: Settings.Subscene;
betaseries: Settings.Betaseries;
titlovi: Settings.titlovi;
titlovi: Settings.Titlovi;
notifications: Settings.Notifications;
}
namespace Settings {
declare namespace Settings {
interface General {
adaptive_searching: boolean;
anti_captcha_provider?: string;

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

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

@ -17,6 +17,14 @@ type Pair<T = string> = {
value: T;
};
type EntityStruct<T> = {
keyName: keyof T;
ids: (string | null)[];
entities: {
[id: string]: T;
};
};
interface DataWrapper<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 KeysOfType<D, T> = NonNullable<
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 };
type OptionalRecord<T extends string | number, D> = { [P in T]?: D };
interface IdState<T> {
[key: number]: Readonly<T>;

@ -50,9 +50,9 @@ interface Props {}
const Header: FunctionComponent<Props> = () => {
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);

@ -1,3 +1,4 @@
import "@fontsource/roboto/300.css";
import React, {
FunctionComponent,
useCallback,
@ -5,14 +6,21 @@ import React, {
useState,
} from "react";
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 { 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 Sidebar from "../Sidebar";
import LaunchError from "../special-pages/LaunchError";
import UIError from "../special-pages/UIError";
import { useHasUpdateInject } from "../utilites";
import { useBaseUrl, useHasUpdateInject } from "../utilites";
import Header from "./Header";
import NotificationContainer from "./notifications";
import Router from "./Router";
@ -29,17 +37,15 @@ const App: FunctionComponent<Props> = () => {
// Has any update?
const hasUpdate = useHasUpdateInject();
useEffect(() => {
if (initialized) {
if (hasUpdate) {
notify({
type: "info",
message: "A new version of Bazarr is ready, restart is required",
// TODO: Restart action
});
}
useEffectOnceWhen(() => {
if (hasUpdate) {
notify({
type: "info",
message: "A new version of Bazarr is ready, restart is required",
// TODO: Restart action
});
}
}, [initialized, hasUpdate, notify]);
}, initialized === true);
const [sidebar, setSidebar] = useState(false);
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 {}
const NotificationContainer: FunctionComponent<NotificationContainerProps> = () => {
const { progress, notifications } = useReduxStore((s) => s.site);
const items = useMemo(() => {
const progressItems = progress.map((v) => (
<ProgressToast key={v.id} {...v}></ProgressToast>
));
const notificationItems = notifications.map((v) => (
<NotificationToast key={v.id} {...v}></NotificationToast>
));
return [...progressItems, ...notificationItems];
}, [notifications, progress]);
return (
<div className="alert-container">
<div className="toast-container">{items}</div>
</div>
);
};
type MessageHolderProps = ReduxStore.Notification & {};
const NotificationContainer: FunctionComponent<NotificationContainerProps> =
() => {
const { progress, notifications } = useReduxStore((s) => s.site);
const items = useMemo(() => {
const progressItems = progress.map((v) => (
<ProgressToast key={v.id} {...v}></ProgressToast>
));
const notificationItems = notifications.map((v) => (
<NotificationToast key={v.id} {...v}></NotificationToast>
));
return [...progressItems, ...notificationItems];
}, [notifications, progress]);
return (
<div className="alert-container">
<div className="toast-container">{items}</div>
</div>
);
};
type MessageHolderProps = Server.Notification & {};
const NotificationToast: FunctionComponent<MessageHolderProps> = (props) => {
const { message, type, id, timeout } = props;
const removeNotification = useReduxAction(siteRemoveNotifications);
const remove = useCallback(() => removeNotification(id), [
removeNotification,
id,
]);
const remove = useCallback(
() => removeNotification(id),
[removeNotification, id]
);
useEffect(() => {
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> = ({
id,

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

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

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

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

@ -12,6 +12,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import { useDidMount } from "rooks";
import {
HistoryApi,
ProvidersApi,
@ -49,16 +50,26 @@ const SelectorContainer: FunctionComponent = ({ children }) => (
);
const HistoryStats: FunctionComponent = () => {
const [languages] = useAsyncRequest(() => SystemApi.languages(true), []);
const [providerList] = useAsyncRequest(
() => ProvidersApi.providers(true),
const [languages, updateLanguages] = useAsyncRequest(
SystemApi.languages.bind(SystemApi),
[]
);
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 [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 promise = useCallback(() => {
@ -103,13 +114,14 @@ const HistoryStats: FunctionComponent = () => {
clearable
state={providerList}
label={providerLabel}
update={updateProvider}
onChange={setProvider}
></AsyncSelector>
</SelectorContainer>
<SelectorContainer>
<LanguageSelector
clearable
options={languages.data}
options={languages.content}
value={lang}
onChange={setLanguage}
></LanguageSelector>

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

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

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

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

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

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

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

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

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

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

@ -8,7 +8,7 @@ import React, {
} from "react";
import { Badge, Button, ButtonGroup } from "react-bootstrap";
import { Column, TableUpdater } from "react-table";
import { useEnabledLanguages, useProfiles } from ".";
import { useEnabledLanguagesContext, useProfilesContext } from ".";
import { ActionButton, SimpleTable, useShowModal } from "../../components";
import { useSingleUpdate } from "../components";
import { languageProfileKey } from "../keys";
@ -16,9 +16,9 @@ import Modal from "./modal";
import { anyCutoff } from "./options";
const Table: FunctionComponent = () => {
const originalProfiles = useProfiles();
const originalProfiles = useProfilesContext();
const languages = useEnabledLanguages();
const languages = useEnabledLanguagesContext();
const [profiles, setProfiles] = useState(() => cloneDeep(originalProfiles));
@ -34,7 +34,7 @@ const Table: FunctionComponent = () => {
const showModal = useShowModal();
const submitProfiles = useCallback(
(list: Profile.Languages[]) => {
(list: Language.Profile[]) => {
update(list, languageProfileKey);
setProfiles(list);
},
@ -42,7 +42,7 @@ const Table: FunctionComponent = () => {
);
const updateProfile = useCallback(
(profile: Profile.Languages) => {
(profile: Language.Profile) => {
const list = [...profiles];
const idx = list.findIndex((v) => v.profileId === profile.profileId);
@ -56,8 +56,8 @@ const Table: FunctionComponent = () => {
[profiles, submitProfiles]
);
const updateRow = useCallback<TableUpdater<Profile.Languages>>(
(row, item?: Profile.Languages) => {
const updateRow = useCallback<TableUpdater<Language.Profile>>(
(row, item?: Language.Profile) => {
if (item) {
showModal("profile", cloneDeep(item));
} else {
@ -69,7 +69,7 @@ const Table: FunctionComponent = () => {
[submitProfiles, showModal, profiles]
);
const columns = useMemo<Column<Profile.Languages>[]>(
const columns = useMemo<Column<Language.Profile>[]>(
() => [
{
Header: "Name",
@ -151,7 +151,7 @@ const Table: FunctionComponent = () => {
interface ItemProps {
className?: string;
item: Profile.Item;
item: Language.ProfileItem;
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 { systemUpdateSettings } from "../@redux/actions";
import { useReduxAction } from "../@redux/hooks/base";
import { useSetSidebar } from "../@redux/hooks/site";
import { RouterEmptyPath } from "../special-pages/404";
import General from "./General";
@ -17,9 +15,6 @@ import UI from "./UI";
interface Props {}
const Router: FunctionComponent<Props> = () => {
const update = useReduxAction(systemUpdateSettings);
useEffect(() => update, [update]);
useSetSidebar("Settings");
return (
<Switch>

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

@ -10,11 +10,11 @@ import React, {
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { Prompt } from "react-router";
import { useDidUpdate } from "rooks";
import { useSystemSettings } from "../../@redux/hooks";
import { useUpdateLocalStorage } from "../../@storage/local";
import { SystemApi } from "../../apis";
import { ContentHeader } from "../../components";
import { useOnLoadingFinish } from "../../utilites";
import { log } from "../../utilites/logger";
import {
enabledLanguageKey,
@ -35,7 +35,7 @@ function submitHooks(settings: LooseObject) {
}
if (enabledLanguageKey in settings) {
const item = settings[enabledLanguageKey] as Language[];
const item = settings[enabledLanguageKey] as Language.Info[];
settings[enabledLanguageKey] = item.map((v) => v.code2);
}
@ -59,13 +59,14 @@ const SettingsProvider: FunctionComponent<Props> = (props) => {
const [updating, setUpdating] = useState(false);
const [dispatcher, setDispatcher] = useState<SettingDispatcher>({});
const cleanup = useCallback(() => {
setChange({});
setUpdating(false);
}, []);
const [settings] = useSystemSettings();
useOnLoadingFinish(settings, cleanup);
const settings = useSystemSettings();
useDidUpdate(() => {
// Will be updated by websocket
if (settings.state !== "loading") {
setChange({});
setUpdating(false);
}
}, [settings.state]);
const saveSettings = useCallback((settings: LooseObject) => {
submitHooks(settings);
@ -90,9 +91,10 @@ const SettingsProvider: FunctionComponent<Props> = (props) => {
setDispatcher(newDispatch);
}, [saveSettings, saveLocalStorage]);
const defaultDispatcher = useMemo(() => dispatcher["__default__"], [
dispatcher,
]);
const defaultDispatcher = useMemo(
() => dispatcher["__default__"],
[dispatcher]
);
const submit = useCallback(() => {
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 { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import { systemUpdateLogs } from "../../@redux/actions";
import { useSystemLogs } from "../../@redux/hooks";
import { useReduxAction } from "../../@redux/hooks/base";
import { SystemApi } from "../../apis";
import { AsyncStateOverlay, ContentHeader } from "../../components";
import { AsyncOverlay, ContentHeader } from "../../components";
import { useBaseUrl } from "../../utilites";
import Table from "./table";
interface Props {}
const SystemLogsView: FunctionComponent<Props> = () => {
const [logs, update] = useSystemLogs();
const logs = useSystemLogs();
const update = useReduxAction(systemUpdateLogs);
const [resetting, setReset] = useState(false);
@ -22,15 +25,15 @@ const SystemLogsView: FunctionComponent<Props> = () => {
}, [baseUrl]);
return (
<AsyncStateOverlay state={logs}>
{({ data }) => (
<AsyncOverlay ctx={logs}>
{({ content, state }) => (
<Container fluid>
<Helmet>
<title>Logs - Bazarr (System)</title>
</Helmet>
<ContentHeader>
<ContentHeader.Button
updating={logs.updating}
updating={state === "loading"}
icon={faSync}
onClick={update}
>
@ -54,11 +57,11 @@ const SystemLogsView: FunctionComponent<Props> = () => {
</ContentHeader.Button>
</ContentHeader>
<Row>
<Table logs={data}></Table>
<Table logs={content ?? []}></Table>
</Row>
</Container>
)}
</AsyncStateOverlay>
</AsyncOverlay>
);
};

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

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

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

@ -6,7 +6,7 @@ interface Props {
health: readonly System.Health[];
}
const Table: FunctionComponent<Props> = (props) => {
const Table: FunctionComponent<Props> = ({ 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;

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

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

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

@ -9,8 +9,8 @@ import { AsyncPageTable, ContentHeader } from "../../components";
interface Props<T extends Wanted.Base> {
type: "movies" | "series";
columns: Column<T>[];
state: Readonly<AsyncOrderState<T>>;
loader: (start: number, length: number) => void;
state: Async.Entity<T>;
loader: (params: Parameter.Range) => void;
searchAll: () => Promise<void>;
}
@ -23,7 +23,7 @@ function GenericWantedView<T extends Wanted.Base>({
}: Props<T>) {
const typeName = capitalize(type);
const dataCount = Object.keys(state.data.items).length;
const dataCount = Object.keys(state.content.entities).length;
return (
<Container fluid>
@ -41,7 +41,7 @@ function GenericWantedView<T extends Wanted.Base>({
</ContentHeader>
<Row>
<AsyncPageTable
aos={state}
entity={state}
loader={loader}
emptyText={`No Missing ${typeName} Subtitles`}
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");
}
async bySeriesId(seriesid: number[]): Promise<Array<Item.Episode>> {
return new Promise<Array<Item.Episode>>((resolve, reject) => {
this.get<DataWrapper<Array<Item.Episode>>>("", { seriesid })
async bySeriesId(seriesid: number[]): Promise<Item.Episode[]> {
return new Promise<Item.Episode[]>((resolve, reject) => {
this.get<DataWrapper<Item.Episode[]>>("", { seriesid })
.then((result) => {
resolve(result.data.data);
})
@ -17,9 +17,9 @@ class EpisodeApi extends BaseApi {
});
}
async byEpisodeId(episodeid: number[]): Promise<Array<Item.Episode>> {
return new Promise<Array<Item.Episode>>((resolve, reject) => {
this.get<DataWrapper<Array<Item.Episode>>>("", { episodeid })
async byEpisodeId(episodeid: number[]): Promise<Item.Episode[]> {
return new Promise<Item.Episode[]>((resolve, reject) => {
this.get<DataWrapper<Item.Episode[]>>("", { episodeid })
.then((result) => {
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) => {
this.get<AsyncDataWrapper<Wanted.Episode>>("/wanted", { start, length })
this.get<AsyncDataWrapper<Wanted.Episode>>("/wanted", params)
.then((result) => {
resolve(result.data);
})
@ -53,9 +53,9 @@ class EpisodeApi extends BaseApi {
});
}
async history(episodeid?: number): Promise<Array<History.Episode>> {
return new Promise<Array<History.Episode>>((resolve, reject) => {
this.get<DataWrapper<Array<History.Episode>>>("/history", { episodeid })
async history(episodeid?: number): Promise<History.Episode[]> {
return new Promise<History.Episode[]>((resolve, reject) => {
this.get<DataWrapper<History.Episode[]>>("/history", { episodeid })
.then((result) => {
resolve(result.data.data);
})
@ -101,9 +101,9 @@ class EpisodeApi extends BaseApi {
});
}
async blacklist(): Promise<Array<Blacklist.Episode>> {
return new Promise<Array<Blacklist.Episode>>((resolve, reject) => {
this.get<DataWrapper<Array<Blacklist.Episode>>>("/blacklist")
async blacklist(): Promise<Blacklist.Episode[]> {
return new Promise<Blacklist.Episode[]>((resolve, reject) => {
this.get<DataWrapper<Blacklist.Episode[]>>("/blacklist")
.then((res) => {
resolve(res.data.data);
})

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

@ -1,24 +1,28 @@
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,
defaultData: RequestReturn<F>
): [AsyncState<RequestReturn<F>>, () => void] {
const [state, setState] = useState<AsyncState<RequestReturn<F>>>({
updating: true,
data: defaultData,
initial: Return<F>
): [Async.Base<Return<F>>, (...args: Parameters<F>) => void] {
const [state, setState] = useState<Async.Base<Return<F>>>({
state: "uninitialized",
content: initial,
error: null,
});
const update = useCallback(() => {
setState((s) => ({ ...s, updating: true }));
request()
.then((res) => setState({ updating: false, data: res }))
.catch((err) => setState((s) => ({ ...s, updating: false, err })));
}, [request]);
useDidMount(update);
const update = useCallback(
(...args: Parameters<F>) => {
setState((s) => ({ ...s, state: "loading" }));
request(...args)
.then((res) =>
setState({ state: "succeeded", content: res, error: null })
)
.catch((error) => setState((s) => ({ ...s, state: "failed", error })));
},
[request]
);
return [state, update];
}

@ -1,14 +1,15 @@
import Axios, { AxiosError, AxiosInstance, CancelTokenSource } from "axios";
import { siteRedirectToAuth, siteUpdateOffline } from "../@redux/actions";
import reduxStore from "../@redux/store";
import { siteRedirectToAuth } from "../@redux/actions";
import { AppDispatch } from "../@redux/store";
import { getBaseUrl } from "../utilites";
class Api {
axios!: AxiosInstance;
source!: CancelTokenSource;
dispatch!: AppDispatch;
constructor() {
const baseUrl = `${getBaseUrl()}/api/`;
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV !== "production") {
this.initialize(baseUrl, process.env["REACT_APP_APIKEY"]!);
} else {
this.initialize(baseUrl, window.Bazarr.apiKey);
@ -55,21 +56,10 @@ class Api {
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) {
switch (code) {
case 401:
reduxStore.dispatch(siteRedirectToAuth());
this.dispatch(siteRedirectToAuth());
break;
case 500:
break;

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

@ -6,8 +6,8 @@ class ProviderApi extends BaseApi {
}
async providers(history: boolean = false) {
return new Promise<Array<System.Provider>>((resolve, reject) => {
this.get<DataWrapper<Array<System.Provider>>>("", { history })
return new Promise<System.Provider[]>((resolve, reject) => {
this.get<DataWrapper<System.Provider[]>>("", { history })
.then((result) => {
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) => {
this.get<AsyncDataWrapper<Item.Series>>("", { start, length })
this.get<AsyncDataWrapper<Item.Series>>("", params)
.then((result) => {
resolve(result.data);
})

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

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

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

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

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

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

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

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

@ -7,7 +7,11 @@ import {
useCloseModal,
usePayload,
} from "..";
import { useLanguageBy, useLanguages, useProfileBy } from "../../@redux/hooks";
import {
useEnabledLanguages,
useLanguageBy,
useProfileBy,
} from "../../@redux/hooks";
import { MoviesApi } from "../../apis";
import BaseModal, { BaseModalProps } from "./BaseModal";
interface MovieProps {}
@ -17,7 +21,7 @@ const MovieUploadModal: FunctionComponent<MovieProps & BaseModalProps> = (
) => {
const modal = props;
const [availableLanguages] = useLanguages(true);
const availableLanguages = useEnabledLanguages();
const movie = usePayload<Item.Movie>(modal.modalKey);
@ -25,7 +29,7 @@ const MovieUploadModal: FunctionComponent<MovieProps & BaseModalProps> = (
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);

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

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

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

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

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

Loading…
Cancel
Save