From c92161ccbc9f74f62128d9b367ef81f2b5f8c206 Mon Sep 17 00:00:00 2001 From: Viktor Podzigun Date: Fri, 6 Dec 2024 23:14:50 +0100 Subject: [PATCH] Added PanelStack --- src/stack/PanelStack.mjs | 127 +++++++++++++ src/stack/PanelStackItem.mjs | 8 +- test/all.mjs | 1 + test/stack/PanelStack.test.mjs | 287 +++++++++++++++++++++++++++++ test/stack/PanelStackItem.test.mjs | 30 +-- types/stack/PanelStack.d.mts | 58 ++++++ types/stack/PanelStackItem.d.mts | 12 +- 7 files changed, 502 insertions(+), 21 deletions(-) create mode 100644 src/stack/PanelStack.mjs create mode 100644 test/stack/PanelStack.test.mjs create mode 100644 types/stack/PanelStack.d.mts diff --git a/src/stack/PanelStack.mjs b/src/stack/PanelStack.mjs new file mode 100644 index 0000000..e41ad6b --- /dev/null +++ b/src/stack/PanelStack.mjs @@ -0,0 +1,127 @@ +/** + * @typedef {import("./PanelStackItem.mjs").ReactComponent} ReactComponent + */ +import PanelStackItem from "./PanelStackItem.mjs"; + +class PanelStack { + /** + * @param {boolean} isActive + * @param {PanelStackItem[]} data + * @param {(f: (data: PanelStackItem[]) => PanelStackItem[]) => void} updater + */ + constructor(isActive, data, updater) { + /** @readonly @type {boolean} */ + this.isActive = isActive; + + /** @private @readonly @type {PanelStackItem[]} */ + this._data = data; + + /** @private @readonly @type {(f: (data: PanelStackItem[]) => PanelStackItem[]) => void} */ + this._updater = updater; + } + + /** + * @param {PanelStackItem} item + * @returns {void} + */ + push(item) { + this._updater((data) => [item, ...data]); + } + + /** + * @template T + * @param {(data: PanelStackItem) => PanelStackItem} f + * @returns {void} + */ + update(f) { + this._updater((data) => { + if (data.length === 0) { + return data; + } + + const [head, ...tail] = data; + return [f(head), ...tail]; + }); + } + + /** + * @template T + * @param {ReactComponent} component + * @param {(data: PanelStackItem) => PanelStackItem} f + * @returns {void} + */ + updateFor(component, f) { + this._updater((data) => { + return data.map((item) => { + return item.component === component ? f(item) : item; + }); + }); + } + + /** + * @returns {void} + */ + pop() { + this._updater((data) => { + if (data.length > 1) { + const [_, ...tail] = data; + return tail; + } + + return data; + }); + } + + /** + * @returns {void} + */ + clear() { + this._updater((data) => { + if (data.length > 1) { + const last = data[data.length - 1]; + return [last]; + } + + return data; + }); + } + + /** + * @template T + * @returns {PanelStackItem} + */ + peek() { + ensureNonEmpty(this._data); + + return this._data[0]; + } + + /** + * @template T + * @returns {PanelStackItem} + */ + peekLast() { + ensureNonEmpty(this._data); + + return this._data[this._data.length - 1]; + } + + /** + * @template T + * @returns {T} + */ + params() { + return this.peek().state; + } +} + +/** + * @param {PanelStackItem[]} data + */ +function ensureNonEmpty(data) { + if (data.length === 0) { + throw Error("PanelStack is empty!"); + } +} + +export default PanelStack; diff --git a/src/stack/PanelStackItem.mjs b/src/stack/PanelStackItem.mjs index e8a56b5..9521803 100644 --- a/src/stack/PanelStackItem.mjs +++ b/src/stack/PanelStackItem.mjs @@ -7,18 +7,22 @@ import FileListData from "../FileListData.mjs"; import FileListState from "../FileListState.mjs"; import FileListActions from "../FileListActions.mjs"; +/** + * @typedef {React.FunctionComponent | React.ComponentClass} ReactComponent + */ + /** * @template T */ class PanelStackItem { /** - * @param {React.FunctionComponent | React.ComponentClass} component + * @param {ReactComponent} component * @param {Dispatch} [dispatch] * @param {FileListActions} [actions] * @param {T} [state] */ constructor(component, dispatch, actions, state) { - /** @readonly @type {React.FunctionComponent | React.ComponentClass} */ + /** @readonly @type {ReactComponent} */ this.component = component; /** @readonly @type {Dispatch | undefined} */ diff --git a/test/all.mjs b/test/all.mjs index 1dca222..61a4790 100644 --- a/test/all.mjs +++ b/test/all.mjs @@ -12,6 +12,7 @@ await import("./history/HistoryProvider.test.mjs"); await import("./sort/FileListSort.test.mjs"); +await import("./stack/PanelStack.test.mjs"); await import("./stack/PanelStackItem.test.mjs"); await import("./theme/FileListTheme.test.mjs"); diff --git a/test/stack/PanelStack.test.mjs b/test/stack/PanelStack.test.mjs new file mode 100644 index 0000000..bde88d2 --- /dev/null +++ b/test/stack/PanelStack.test.mjs @@ -0,0 +1,287 @@ +import assert from "node:assert/strict"; +import mockFunction from "mock-fn"; +import PanelStackItem from "../../src/stack/PanelStackItem.mjs"; +import PanelStack from "../../src/stack/PanelStack.mjs"; + +const { describe, it } = await (async () => { + // @ts-ignore + const module = process.isBun ? "bun:test" : "node:test"; + // @ts-ignore + return process.isBun // @ts-ignore + ? Promise.resolve({ describe: (_, fn) => fn(), it: test }) + : import(module); +})(); + +const Component = () => { + return null; +}; +const OtherComponent = () => { + return null; +}; + +describe("PanelStack.test.mjs", () => { + it("should create new stack and set isActive", () => { + //when & then + assert.deepEqual(new PanelStack(true, [], mockFunction()).isActive, true); + assert.deepEqual(new PanelStack(false, [], mockFunction()).isActive, false); + }); + + it("should push new item when push", () => { + //given + let result = null; + const updater = mockFunction((updateFn) => { + result = updateFn(data); + }); + const stack = new PanelStack(false, [], updater); + const data = [new PanelStackItem(Component), new PanelStackItem(Component)]; + const newItem = new PanelStackItem(OtherComponent); + + //when + stack.push(newItem); + + //then + assert.deepEqual(updater.times, 1); + assert.deepEqual(result, [newItem, ...data]); + }); + + it("should do nothing if empty stack data when update", () => { + //given + let result = null; + const updater = mockFunction((updateFn) => { + result = updateFn(data); + }); + const stack = new PanelStack(false, [], updater); + const data = /** @type {PanelStackItem[]} */ ([]); + + //when + stack.update((_) => _.withState({})); + + //then + assert.deepEqual(updater.times, 1); + assert.deepEqual(result === data, true); + }); + + it("should update single item when update", () => { + //given + let result = null; + const updater = mockFunction((updateFn) => { + result = updateFn(data); + }); + const stack = new PanelStack(false, [], updater); + const data = [new PanelStackItem(Component)]; + const params = { name: "test" }; + + //when + stack.update((_) => _.withState(params)); + + //then + assert.deepEqual(updater.times, 1); + assert.deepEqual(result, [ + new PanelStackItem(Component, undefined, undefined, params), + ]); + }); + + it("should update top item when update", () => { + //given + let result = null; + const updater = mockFunction((updateFn) => { + result = updateFn(data); + }); + const stack = new PanelStack(false, [], updater); + const top = new PanelStackItem(Component); + const other1 = new PanelStackItem(OtherComponent); + const other2 = new PanelStackItem(OtherComponent); + const data = [top, other1, other2]; + const params = { name: "test" }; + + //when + stack.update((_) => _.withState(params)); + + //then + assert.deepEqual(updater.times, 1); + assert.deepEqual(result, [ + new PanelStackItem(Component, undefined, undefined, params), + other1, + other2, + ]); + }); + + it("should update item when updateFor", () => { + //given + let result = null; + const updater = mockFunction((updateFn) => { + result = updateFn(data); + }); + const stack = new PanelStack(false, [], updater); + const top = new PanelStackItem(Component); + const other = new PanelStackItem(OtherComponent); + const data = [top, other]; + const params = { name: "test" }; + + //when + stack.updateFor(OtherComponent, (_) => _.withState(params)); + + //then + assert.deepEqual(updater.times, 1); + assert.deepEqual(result, [ + top, + new PanelStackItem(OtherComponent, undefined, undefined, params), + ]); + }); + + it("should remove top component when pop", () => { + //given + let result = null; + const updater = mockFunction((updateFn) => { + result = updateFn(data); + }); + const stack = new PanelStack(false, [], updater); + const other = new PanelStackItem(OtherComponent); + const data = [new PanelStackItem(Component), other]; + + //when + stack.pop(); + + //then + assert.deepEqual(updater.times, 1); + assert.deepEqual(result, [other]); + }); + + it("should not remove last item when pop", () => { + //given + let result = null; + const updater = mockFunction((updateFn) => { + result = updateFn(data); + }); + const stack = new PanelStack(false, [], updater); + const data = [new PanelStackItem(Component)]; + + //when + stack.pop(); + + //then + assert.deepEqual(updater.times, 1); + assert.deepEqual(result === data, true); + }); + + it("should remove all except last item when clear", () => { + //given + let result = null; + const updater = mockFunction((updateFn) => { + result = updateFn(data); + }); + const stack = new PanelStack(false, [], updater); + const other = new PanelStackItem(OtherComponent); + const data = [ + new PanelStackItem(Component), + new PanelStackItem(Component), + other, + ]; + + //when + stack.clear(); + + //then + assert.deepEqual(updater.times, 1); + assert.deepEqual(result, [other]); + }); + + it("should not remove last item when clear", () => { + //given + let result = null; + const updater = mockFunction((updateFn) => { + result = updateFn(data); + }); + const stack = new PanelStack(false, [], updater); + const data = [new PanelStackItem(Component)]; + + //when + stack.clear(); + + //then + assert.deepEqual(updater.times, 1); + assert.deepEqual(result === data, true); + }); + + it("should return top item when peek", () => { + //given + const top = new PanelStackItem(Component); + const other = new PanelStackItem(OtherComponent); + const stack = new PanelStack(false, [top, other], mockFunction()); + + //when & then + assert.deepEqual(stack.peek() === top, true); + assert.deepEqual(stack.peek() === top, true); + }); + + it("should throw error if empty stack when peek", () => { + //given + const stack = new PanelStack(false, [], mockFunction()); + let error = null; + + try { + //when + stack.peek(); + } catch (e) { + error = e; + } + + //then + assert.deepEqual(error, Error("PanelStack is empty!")); + }); + + it("should return last item when peekLast", () => { + //given + const top = new PanelStackItem(Component); + const other = new PanelStackItem(OtherComponent); + const stack = new PanelStack(false, [top, other], mockFunction()); + + //when & then + assert.deepEqual(stack.peekLast() === other, true); + assert.deepEqual(stack.peekLast() === other, true); + }); + + it("should throw error if empty stack when peekLast", () => { + //given + const stack = new PanelStack(false, [], mockFunction()); + let error = null; + + try { + //when + stack.peekLast(); + } catch (e) { + error = e; + } + + //then + assert.deepEqual(error, Error("PanelStack is empty!")); + }); + + it("should return top item state when params", () => { + //given + const params = { name: "test" }; + const top = new PanelStackItem(Component, undefined, undefined, params); + const other = new PanelStackItem(OtherComponent, undefined, undefined, {}); + const stack = new PanelStack(false, [top, other], mockFunction()); + + //when & then + assert.deepEqual(stack.params() === params, true); + assert.deepEqual(stack.params() === params, true); + }); + + it("should throw error if empty stack when params", () => { + //given + const stack = new PanelStack(false, [], mockFunction()); + let error = null; + + try { + //when + stack.params(); + } catch (e) { + error = e; + } + + //then + assert.deepEqual(error, Error("PanelStack is empty!")); + }); +}); diff --git a/test/stack/PanelStackItem.test.mjs b/test/stack/PanelStackItem.test.mjs index e4d57ec..57b854c 100644 --- a/test/stack/PanelStackItem.test.mjs +++ b/test/stack/PanelStackItem.test.mjs @@ -27,7 +27,7 @@ describe("PanelStackItem.test.mjs", () => { const result = new PanelStackItem(Component); //then - assert.deepEqual(result.component == Component, true); + assert.deepEqual(result.component === Component, true); assert.deepEqual(result.dispatch, undefined); assert.deepEqual(result.actions, undefined); assert.deepEqual(result.state, undefined); @@ -38,10 +38,10 @@ describe("PanelStackItem.test.mjs", () => { const result = new PanelStackItem(Component, dispatch, actions, state); //then - assert.deepEqual(result.component == Component, true); - assert.deepEqual(result.dispatch == dispatch, true); - assert.deepEqual(result.actions == actions, true); - assert.deepEqual(result.state == state, true); + assert.deepEqual(result.component === Component, true); + assert.deepEqual(result.dispatch === dispatch, true); + assert.deepEqual(result.actions === actions, true); + assert.deepEqual(result.state === state, true); }); it("should return new item with updated state when withState", () => { @@ -55,9 +55,9 @@ describe("PanelStackItem.test.mjs", () => { //then assert.deepEqual(item.state, undefined); assert.deepEqual(result !== item, true); - assert.deepEqual(result.dispatch == dispatch, true); - assert.deepEqual(result.actions == actions, true); - assert.deepEqual(result.state == state, true); + assert.deepEqual(result.dispatch === dispatch, true); + assert.deepEqual(result.actions === actions, true); + assert.deepEqual(result.state === state, true); }); it("should return new item with undefined state when updateState", () => { @@ -73,8 +73,8 @@ describe("PanelStackItem.test.mjs", () => { assert.deepEqual(onState.times, 0); assert.deepEqual(item.state, undefined); assert.deepEqual(result !== item, true); - assert.deepEqual(result.dispatch == dispatch, true); - assert.deepEqual(result.actions == actions, true); + assert.deepEqual(result.dispatch === dispatch, true); + assert.deepEqual(result.actions === actions, true); assert.deepEqual(result.state, undefined); }); @@ -93,12 +93,12 @@ describe("PanelStackItem.test.mjs", () => { //then assert.deepEqual(onState.times, 1); - assert.deepEqual(capturedState == state, true); - assert.deepEqual(item.state == state, true); + assert.deepEqual(capturedState === state, true); + assert.deepEqual(item.state === state, true); assert.deepEqual(result !== item, true); - assert.deepEqual(result.dispatch == dispatch, true); - assert.deepEqual(result.actions == actions, true); - assert.deepEqual(result.state == newState, true); + assert.deepEqual(result.dispatch === dispatch, true); + assert.deepEqual(result.actions === actions, true); + assert.deepEqual(result.state === newState, true); }); it("should return undefined when getData", () => { diff --git a/types/stack/PanelStack.d.mts b/types/stack/PanelStack.d.mts new file mode 100644 index 0000000..ff7d3de --- /dev/null +++ b/types/stack/PanelStack.d.mts @@ -0,0 +1,58 @@ +export default PanelStack; +export type ReactComponent = import("./PanelStackItem.mjs").ReactComponent; +declare class PanelStack { + /** + * @param {boolean} isActive + * @param {PanelStackItem[]} data + * @param {(f: (data: PanelStackItem[]) => PanelStackItem[]) => void} updater + */ + constructor(isActive: boolean, data: PanelStackItem[], updater: (f: (data: PanelStackItem[]) => PanelStackItem[]) => void); + /** @readonly @type {boolean} */ + readonly isActive: boolean; + /** @private @readonly @type {PanelStackItem[]} */ + private readonly _data; + /** @private @readonly @type {(f: (data: PanelStackItem[]) => PanelStackItem[]) => void} */ + private readonly _updater; + /** + * @param {PanelStackItem} item + * @returns {void} + */ + push(item: PanelStackItem): void; + /** + * @template T + * @param {(data: PanelStackItem) => PanelStackItem} f + * @returns {void} + */ + update(f: (data: PanelStackItem) => PanelStackItem): void; + /** + * @template T + * @param {ReactComponent} component + * @param {(data: PanelStackItem) => PanelStackItem} f + * @returns {void} + */ + updateFor(component: ReactComponent, f: (data: PanelStackItem) => PanelStackItem): void; + /** + * @returns {void} + */ + pop(): void; + /** + * @returns {void} + */ + clear(): void; + /** + * @template T + * @returns {PanelStackItem} + */ + peek(): PanelStackItem; + /** + * @template T + * @returns {PanelStackItem} + */ + peekLast(): PanelStackItem; + /** + * @template T + * @returns {T} + */ + params(): T_4; +} +import PanelStackItem from "./PanelStackItem.mjs"; diff --git a/types/stack/PanelStackItem.d.mts b/types/stack/PanelStackItem.d.mts index c57c1ee..5a97f5d 100644 --- a/types/stack/PanelStackItem.d.mts +++ b/types/stack/PanelStackItem.d.mts @@ -1,19 +1,23 @@ export default PanelStackItem; export type Dispatch = import("../FileListData.mjs").Dispatch; export type FileListData = import("../FileListData.mjs").FileListData; +export type ReactComponent = React.FunctionComponent | React.ComponentClass; +/** + * @typedef {React.FunctionComponent | React.ComponentClass} ReactComponent + */ /** * @template T */ declare class PanelStackItem { /** - * @param {React.FunctionComponent | React.ComponentClass} component + * @param {ReactComponent} component * @param {Dispatch} [dispatch] * @param {FileListActions} [actions] * @param {T} [state] */ - constructor(component: React.FunctionComponent | React.ComponentClass, dispatch?: import("../FileListData.mjs").Dispatch | undefined, actions?: FileListActions | undefined, state?: T | undefined); - /** @readonly @type {React.FunctionComponent | React.ComponentClass} */ - readonly component: React.FunctionComponent | React.ComponentClass; + constructor(component: ReactComponent, dispatch?: import("../FileListData.mjs").Dispatch | undefined, actions?: FileListActions | undefined, state?: T | undefined); + /** @readonly @type {ReactComponent} */ + readonly component: ReactComponent; /** @readonly @type {Dispatch | undefined} */ readonly dispatch: Dispatch | undefined; /** @readonly @type {FileListActions | undefined} */