diff --git a/CHANGELOG.md b/CHANGELOG.md index fa9b87f9..bda52646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - Added `ISyncExternalStore` and `SyncExternalStore` to make creating external stores for `React.useSyncExternalStore` easier (Requires React@18 or higher) +- Add `StatefulSyncExternalStore` to provide structured extension of `SyncExternalStore`. ## [1.1.1] - 2022-10-13 diff --git a/src/index.tsx b/src/index.tsx index ba0b2294..0264e6c8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,6 +6,9 @@ import { } from "./context-store--stateful-indexable/index.js"; import { getNotImplementedPromise } from "./shared/index.js"; export * from "./sync-external-store/index.js"; +export * from "./sync-external-store--indexable/index.js"; +export * from "./sync-external-store--stateful/index.js"; +export * from "./sync-external-store--stateful-indexable/index.js"; export { ContextStore, diff --git a/src/shared/index.ts b/src/shared/index.ts index a06d8c1e..8d76a136 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -16,3 +16,23 @@ export const errorMessages = { }; export const getNotImplementedPromise = () => Promise.reject("Not Implemented"); + +export function normalizeError(error: unknown): null | string { + if (error == null) { + return null; + } + + if (typeof error === "string") { + return error; + } + + if (error instanceof Error) { + return error.message; + } + + return JSON.stringify(error); +} + +export type CreateActionParams = { + action: (params: TParams) => Promise; +}; diff --git a/src/sync-external-store--indexable/create-snapshot.ts b/src/sync-external-store--indexable/create-snapshot.ts new file mode 100644 index 00000000..036060af --- /dev/null +++ b/src/sync-external-store--indexable/create-snapshot.ts @@ -0,0 +1,46 @@ +import { normalizeError } from "../shared/index.js"; +import type { IndexableStore, IndexableStoreStructureKey } from "./types.js"; + +export function createIndexableLoadingSnapshot>() { + return (snapshot: TStore) => { + return { + ...snapshot, + state: "loading", + }; + }; +} + +export function createIndexableErrorSnapshot>(error: unknown) { + const errorMessage = normalizeError(error); + return (snapshot: TStore) => { + return { + ...snapshot, + error: errorMessage, + state: "error", + }; + }; +} + +export function createIndexableSuccessSnapshot, TData>( + key: IndexableStoreStructureKey, + data: TData, +) { + return (snapshot: TStore) => { + if (Array.isArray(snapshot.data) && typeof key === "number") { + return { + ...snapshot, + data: [...snapshot.data.slice(0, key), data, ...snapshot.data.slice(key + 1)], + state: "success", + }; + } else { + return { + ...snapshot, + data: { + ...snapshot.data, + [key]: data, + }, + state: "success", + }; + } + }; +} diff --git a/src/sync-external-store--indexable/index.ts b/src/sync-external-store--indexable/index.ts new file mode 100644 index 00000000..3d216634 --- /dev/null +++ b/src/sync-external-store--indexable/index.ts @@ -0,0 +1,3 @@ +export * from "./create-snapshot.js"; +export * from "./store.js"; +export * from "./types.js"; diff --git a/src/sync-external-store--indexable/store.test.ts b/src/sync-external-store--indexable/store.test.ts new file mode 100644 index 00000000..c49acb2d --- /dev/null +++ b/src/sync-external-store--indexable/store.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, afterEach, describe, it, jest } from "@jest/globals"; +import { IndexableSyncExternalStore } from "./store.js"; +import { IndexableStore } from "./types.js"; + +describe("IndexableSyncExternalStore", () => { + describe("array data", () => { + type Item = { id: number; value: string }; + const mockAction = jest.fn(async (value: Item) => value); + const mockStoreInitialState: IndexableStore = { + data: [ + { id: 2112, value: "hello world" }, + { id: 13, value: "hola mundo" }, + ], + error: null, + state: "unsent", + }; + + class MockStore extends IndexableSyncExternalStore> { + constructor() { + super(mockStoreInitialState); + } + + private getIndex(item: Item) { + const snapshot = this.getSnapshot(); + if (!Array.isArray(snapshot.data)) { + throw new Error("IndexableSyncExternalStore: data is not an array"); + } + const index = snapshot.data.findIndex((data) => data.id === item.id); + + if (index === -1) { + return snapshot.data.length; + } + + return index; + } + + private async updateDataAction(value: Item) { + const action = this.createAction({ + action: mockAction, + key: this.getIndex(value), + }); + return action(value); + } + public async updateData(value: Item) { + await this.updateDataAction(value); + } + } + + let store: MockStore; + + beforeEach(() => { + store = new MockStore(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("updateData", () => { + it("will update the store", async () => { + //* Arrange + const item = { id: 2112, value: "goodbye world" }; + + //* Act + await store.updateData(item); + const updatedSnapshot = store.getSnapshot(); + + //* Assert + expect(mockAction).toHaveBeenCalledTimes(1); + expect(updatedSnapshot.data[0]).toMatchObject(item); + expect(updatedSnapshot.data[1]).toMatchObject(mockStoreInitialState.data[1]); + }); + }); + }); + + describe("object data", () => { + type Item = { id: number; value: string }; + const mockAction = jest.fn(async (value: Item) => value); + const mockStoreInitialState: IndexableStore = { + data: { + 2112: { id: 2112, value: "hello world" }, + 13: { id: 13, value: "hola mundo" }, + }, + error: null, + state: "unsent", + }; + + class MockStore extends IndexableSyncExternalStore> { + constructor() { + super(mockStoreInitialState); + } + + private getIndex(item: Item) { + return item.id; + } + + private async updateDataAction(value: Item) { + const action = this.createAction({ + action: mockAction, + key: this.getIndex(value), + }); + return action(value); + } + public async updateData(value: Item) { + await this.updateDataAction(value); + } + } + + let store: MockStore; + + beforeEach(() => { + store = new MockStore(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("updateData", () => { + it("will update the store", async () => { + //* Arrange + const item = { id: 2112, value: "goodbye world" }; + + //* Act + await store.updateData(item); + const updatedSnapshot = store.getSnapshot(); + + //* Assert + expect(mockAction).toHaveBeenCalledTimes(1); + expect(updatedSnapshot.data[2112]).toMatchObject(item); + expect(updatedSnapshot.data[13]).toMatchObject(mockStoreInitialState.data[13]); + }); + }); + }); +}); diff --git a/src/sync-external-store--indexable/store.ts b/src/sync-external-store--indexable/store.ts new file mode 100644 index 00000000..97705779 --- /dev/null +++ b/src/sync-external-store--indexable/store.ts @@ -0,0 +1,35 @@ +import { SyncExternalStore } from "../sync-external-store/index.js"; +import { + createIndexableErrorSnapshot, + createIndexableLoadingSnapshot, + createIndexableSuccessSnapshot, +} from "./create-snapshot.js"; +import type { CreateIndexableActionParams, IndexableStore } from "./types.js"; + +export abstract class IndexableSyncExternalStore< + TStore extends IndexableStore, +> extends SyncExternalStore { + protected createAction( + params: CreateIndexableActionParams, + ) { + const { + action, + key, + updateSnapshot = { + loading: createIndexableLoadingSnapshot, + success: createIndexableSuccessSnapshot, + error: createIndexableErrorSnapshot, + }, + } = params; + + return async (actionParams: TParams) => { + this.updateSnapshot(updateSnapshot.loading()); + try { + const data = await action(actionParams); + this.updateSnapshot(updateSnapshot.success(key, data)); + } catch (error) { + this.updateSnapshot(updateSnapshot.error(error)); + } + }; + } +} diff --git a/src/sync-external-store--indexable/types.ts b/src/sync-external-store--indexable/types.ts new file mode 100644 index 00000000..2cb2775d --- /dev/null +++ b/src/sync-external-store--indexable/types.ts @@ -0,0 +1,33 @@ +import { CreateActionParams, statefulStates } from "../shared/index.js"; +import { + createIndexableErrorSnapshot, + createIndexableLoadingSnapshot, + createIndexableSuccessSnapshot, +} from "./create-snapshot.js"; + +export type CreateIndexableErrorSnapshot = typeof createIndexableErrorSnapshot; +export type CreateIndexableLoadingSnapshot = typeof createIndexableLoadingSnapshot; +export type CreateIndexableSuccessSnapshot = typeof createIndexableSuccessSnapshot; + +export type IndexableStoreStructureKey> = TStore["data"] extends Array + ? number + : keyof TStore["data"]; + +export type IndexableStore = { + data: Record | Array; + error: null | string; + state: keyof typeof statefulStates; +}; + +export type CreateIndexableActionParams< + TStore extends IndexableStore, + TParams, + TResponse, +> = CreateActionParams & { + key: IndexableStoreStructureKey; + updateSnapshot?: { + loading: CreateIndexableLoadingSnapshot; + success: CreateIndexableSuccessSnapshot; + error: CreateIndexableErrorSnapshot; + }; +}; diff --git a/src/sync-external-store--stateful-indexable/create-snapshot.ts b/src/sync-external-store--stateful-indexable/create-snapshot.ts new file mode 100644 index 00000000..e8adc27c --- /dev/null +++ b/src/sync-external-store--stateful-indexable/create-snapshot.ts @@ -0,0 +1,49 @@ +import { normalizeError } from "../shared/index.js"; +import type { StatefulIndexableStore } from "./types.js"; + +export function createStatefulIndexableLoadingSnapshot>( + key: keyof TStore, +) { + return (snapshot: TStore) => { + return { + ...snapshot, + [key]: { + ...snapshot[key], + state: "loading", + }, + }; + }; +} + +export function createStatefulIndexableErrorSnapshot>( + key: keyof TStore, + error: unknown, +) { + const errorMessage = normalizeError(error); + return (snapshot: TStore) => { + return { + ...snapshot, + [key]: { + ...snapshot[key], + error: errorMessage, + state: "error", + }, + }; + }; +} + +export function createStatefulIndexableSuccessSnapshot, TData = unknown>( + key: keyof TStore, + data: TData, +) { + return (snapshot: TStore) => { + return { + ...snapshot, + [key]: { + ...snapshot[key], + data, + state: "success", + }, + }; + }; +} diff --git a/src/sync-external-store--stateful-indexable/index.ts b/src/sync-external-store--stateful-indexable/index.ts new file mode 100644 index 00000000..3d216634 --- /dev/null +++ b/src/sync-external-store--stateful-indexable/index.ts @@ -0,0 +1,3 @@ +export * from "./create-snapshot.js"; +export * from "./store.js"; +export * from "./types.js"; diff --git a/src/sync-external-store--stateful-indexable/store.test.ts b/src/sync-external-store--stateful-indexable/store.test.ts new file mode 100644 index 00000000..96020101 --- /dev/null +++ b/src/sync-external-store--stateful-indexable/store.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, afterEach, describe, it, jest } from "@jest/globals"; +import { StatefulIndexableStore } from "./types.js"; +import { StatefulIndexableSyncExternalStore } from "./store.js"; + +describe("StatefulIndexableSyncExternalStore", () => { + type Item = { id: number; value: string }; + const mockAction = jest.fn(async (value) => value); + const mockStoreInitialState: StatefulIndexableStore = { + hello: { + data: null, + error: null, + state: "unsent", + }, + goodbye: { + data: null, + error: null, + state: "unsent", + }, + }; + + class MockStore extends StatefulIndexableSyncExternalStore> { + constructor() { + super(mockStoreInitialState); + } + private async updateDataAction(key: string, value: Item) { + const action = this.createAction({ + action: mockAction, + key, + }); + return action(value); + } + public async updateHello(value: Item) { + await this.updateDataAction("hello", value); + } + public async updateGoodbye(value: Item) { + await this.updateDataAction("goodbye", value); + } + } + + let store: MockStore; + + beforeEach(() => { + store = new MockStore(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("will update the store", async () => { + //* Arrange + const mockItem = { id: 2112, value: "hello world" }; + + //* Act + await store.updateHello(mockItem); + const updatedSnapshot = store.getSnapshot(); + + //* Assert + expect(mockAction).toHaveBeenCalledTimes(1); + expect(updatedSnapshot.hello.data).toMatchObject(mockItem); + }); +}); diff --git a/src/sync-external-store--stateful-indexable/store.ts b/src/sync-external-store--stateful-indexable/store.ts new file mode 100644 index 00000000..c616701b --- /dev/null +++ b/src/sync-external-store--stateful-indexable/store.ts @@ -0,0 +1,35 @@ +import { SyncExternalStore } from "../sync-external-store/index.js"; +import { + createStatefulIndexableErrorSnapshot, + createStatefulIndexableLoadingSnapshot, + createStatefulIndexableSuccessSnapshot, +} from "./create-snapshot.js"; +import type { CreateStatefulIndexableActionParams, StatefulIndexableStore } from "./types.js"; + +export abstract class StatefulIndexableSyncExternalStore< + TStore extends StatefulIndexableStore, +> extends SyncExternalStore { + protected createAction( + params: CreateStatefulIndexableActionParams, + ) { + const { + action, + key, + updateSnapshot = { + loading: createStatefulIndexableLoadingSnapshot, + success: createStatefulIndexableSuccessSnapshot, + error: createStatefulIndexableErrorSnapshot, + }, + } = params; + + return async (actionParams: TParams) => { + this.updateSnapshot(updateSnapshot.loading(key)); + try { + const data = await action(actionParams); + this.updateSnapshot(updateSnapshot.success(key, data)); + } catch (error) { + this.updateSnapshot(updateSnapshot.error(key, error)); + } + }; + } +} diff --git a/src/sync-external-store--stateful-indexable/types.ts b/src/sync-external-store--stateful-indexable/types.ts new file mode 100644 index 00000000..e9b3b633 --- /dev/null +++ b/src/sync-external-store--stateful-indexable/types.ts @@ -0,0 +1,31 @@ +import { CreateActionParams, statefulStates } from "../shared/index.js"; +import { + createStatefulIndexableErrorSnapshot, + createStatefulIndexableLoadingSnapshot, + createStatefulIndexableSuccessSnapshot, +} from "./create-snapshot.js"; + +export type CreateStatefulIndexableErrorSnapshot = typeof createStatefulIndexableErrorSnapshot; +export type CreateStatefulIndexableLoadingSnapshot = typeof createStatefulIndexableLoadingSnapshot; +export type CreateStatefulIndexableSuccessSnapshot = typeof createStatefulIndexableSuccessSnapshot; + +export type StatefulIndexableStore = Record>; + +export type StoreItem = { + data: TData; + error: null | string; + state: keyof typeof statefulStates; +}; + +export type CreateStatefulIndexableActionParams< + TStore extends StatefulIndexableStore, + TParams, + TResponse, +> = CreateActionParams & { + key: keyof TStore; + updateSnapshot?: { + loading: CreateStatefulIndexableLoadingSnapshot; + success: CreateStatefulIndexableSuccessSnapshot; + error: CreateStatefulIndexableErrorSnapshot; + }; +}; diff --git a/src/sync-external-store--stateful/create-snapshot.ts b/src/sync-external-store--stateful/create-snapshot.ts new file mode 100644 index 00000000..375e3c9d --- /dev/null +++ b/src/sync-external-store--stateful/create-snapshot.ts @@ -0,0 +1,32 @@ +import { normalizeError } from "../shared/index.js"; +import type { StatefulStore } from "./types.js"; + +export function createStatefulLoadingSnapshot>() { + return (snapshot: TStore) => { + return { + ...snapshot, + state: "loading", + }; + }; +} + +export function createStatefulErrorSnapshot>(error: unknown) { + const errorMessage = normalizeError(error); + return (snapshot: TStore) => { + return { + ...snapshot, + error: errorMessage, + state: "error", + }; + }; +} + +export function createStatefulSuccessSnapshot>(data: TStore["data"]) { + return (snapshot: TStore) => { + return { + ...snapshot, + data, + state: "success", + }; + }; +} diff --git a/src/sync-external-store--stateful/index.ts b/src/sync-external-store--stateful/index.ts new file mode 100644 index 00000000..3d216634 --- /dev/null +++ b/src/sync-external-store--stateful/index.ts @@ -0,0 +1,3 @@ +export * from "./create-snapshot.js"; +export * from "./store.js"; +export * from "./types.js"; diff --git a/src/sync-external-store--stateful/store.test.ts b/src/sync-external-store--stateful/store.test.ts new file mode 100644 index 00000000..1063cf75 --- /dev/null +++ b/src/sync-external-store--stateful/store.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, afterEach, describe, it, jest } from "@jest/globals"; +import { StatefulSyncExternalStore } from "./store.js"; +import { StatefulStore } from "./types.js"; + +describe("StatefulSyncExternalStore", () => { + type Item = { id: number; value: string }; + const mockAction = jest.fn(async (value: Item) => value); + const mockStoreInitialState: StatefulStore = { + data: null, + error: null, + state: "unsent", + }; + + class MockStore extends StatefulSyncExternalStore> { + constructor() { + super(mockStoreInitialState); + } + + private async updateDataAction(value: Item) { + const action = this.createAction({ + action: mockAction, + }); + return action(value); + } + public async updateData(value: Item) { + await this.updateDataAction(value); + } + } + + let store: MockStore; + + beforeEach(() => { + store = new MockStore(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("will update the store", async () => { + //* Arrange + const mockItem: Item = { id: 2112, value: "hello world" }; + + //* Act + await store.updateData(mockItem); + const updatedSnapshot = store.getSnapshot(); + + //* Assert + expect(mockAction).toHaveBeenCalledTimes(1); + expect(updatedSnapshot.data).toMatchObject(mockItem); + }); +}); diff --git a/src/sync-external-store--stateful/store.ts b/src/sync-external-store--stateful/store.ts new file mode 100644 index 00000000..b4a720db --- /dev/null +++ b/src/sync-external-store--stateful/store.ts @@ -0,0 +1,30 @@ +import { SyncExternalStore } from "../sync-external-store/index.js"; +import { + createStatefulErrorSnapshot, + createStatefulLoadingSnapshot, + createStatefulSuccessSnapshot, +} from "./create-snapshot.js"; +import type { CreateStatefulActionParams, StatefulStore } from "./types.js"; + +export class StatefulSyncExternalStore> extends SyncExternalStore { + protected createAction(params: CreateStatefulActionParams) { + const { + action, + updateSnapshot = { + error: createStatefulErrorSnapshot, + loading: createStatefulLoadingSnapshot, + success: createStatefulSuccessSnapshot, + }, + } = params; + + return async (actionParams: TParams) => { + this.updateSnapshot(updateSnapshot.loading()); + try { + const data = await action(actionParams); + this.updateSnapshot(updateSnapshot.success(data)); + } catch (error) { + this.updateSnapshot(updateSnapshot.error(error)); + } + }; + } +} diff --git a/src/sync-external-store--stateful/types.ts b/src/sync-external-store--stateful/types.ts new file mode 100644 index 00000000..8f95b3de --- /dev/null +++ b/src/sync-external-store--stateful/types.ts @@ -0,0 +1,24 @@ +import { CreateActionParams, statefulStates } from "../shared/index.js"; +import { + createStatefulErrorSnapshot, + createStatefulLoadingSnapshot, + createStatefulSuccessSnapshot, +} from "./create-snapshot.js"; + +export type CreateStatefulErrorSnapshot = typeof createStatefulErrorSnapshot; +export type CreateStatefulLoadingSnapshot = typeof createStatefulLoadingSnapshot; +export type CreateStatefulSuccessSnapshot = typeof createStatefulSuccessSnapshot; + +export type StatefulStore = { + data: TData; + error: null | string; + state: keyof typeof statefulStates; +}; + +export type CreateStatefulActionParams = CreateActionParams & { + updateSnapshot?: { + loading: CreateStatefulLoadingSnapshot; + success: CreateStatefulSuccessSnapshot; + error: CreateStatefulErrorSnapshot; + }; +};