Skip to content

Commit

Permalink
feat(Models): refresh Models by tags (URLs per default)
Browse files Browse the repository at this point in the history
  • Loading branch information
mfal committed Sep 20, 2024
1 parent 653a431 commit 8661d86
Show file tree
Hide file tree
Showing 21 changed files with 1,632 additions and 81 deletions.
2 changes: 1 addition & 1 deletion .idea/.name

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

File renamed without changes.
2 changes: 1 addition & 1 deletion .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions .idea/nx-angular-config.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

777 changes: 756 additions & 21 deletions .pnp.cjs

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/commons/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@
},
"dependencies": {
"@types/parse-path": "^7.0.3",
"axios": "^1.7.4",
"axios": "^1.7.7",
"parse-path": "^7.0.0",
"path-to-regexp": "^8.1.0",
"type-fest": "^4.23.0"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@mittwald/react-use-promise": "^2.3.13",
"@mittwald/react-use-promise": "^2.5.0",
"@types/jest": "^29.5.12",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/generator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"@types/prettier": "^3.0.0",
"@types/verror": "^1.10.10",
"@types/yieldable-json": "^2.0.2",
"axios": "^1.7.4",
"axios": "^1.7.7",
"camelcase": "^8.0.0",
"clone-deep": "^4.0.1",
"dot-prop": "^8.0.2",
Expand Down
2 changes: 1 addition & 1 deletion packages/mittwald/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
},
"devDependencies": {
"@mittwald/api-code-generator": "workspace:^",
"@mittwald/react-use-promise": "^2.3.13",
"@mittwald/react-use-promise": "^2.5.0",
"@types/node": "^20.14.14",
"@types/react": "^18.3.3",
"@typescript-eslint/eslint-plugin": "^7.18.0",
Expand Down
9 changes: 8 additions & 1 deletion packages/models/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,31 @@
"dependencies": {
"@mittwald/api-client": "workspace:^",
"another-deep-freeze": "^1.0.0",
"context": "^3.0.31",
"object-code": "^1.3.3",
"polytype": "^0.17.0",
"type-fest": "^4.23.0"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@mittwald/react-use-promise": "^2.3.13",
"@mittwald/react-use-promise": "^2.5.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@types/jest": "^29.5.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-json": "^3.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"prettier": "^3.3.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"rimraf": "^5.0.10",
"ts-jest": "^29.2.4",
"typescript": "^5.5.4"
Expand Down
3 changes: 3 additions & 0 deletions packages/models/src/config/behaviors/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { apiServerBehaviors } from "../../server/Server/behaviors/index.js";
import { apiCustomerBehaviors } from "../../customer/Customer/behaviors/index.js";
import { apiIngressBehaviors } from "../../domain/Ingress/behaviors/index.js";
import { apiAppInstallationBehaviors } from "../../app/AppInstallation/behaviors/index.js";
import { updateCacheTagsBeforeRequest } from "../../react/asyncResourceInvalidation.js";

class ApiSetupState {
private _client: MittwaldAPIV2Client | undefined;
Expand All @@ -16,6 +17,8 @@ class ApiSetupState {
);
}
this._client = client;
this._client.defaultRequestOptions.onBeforeRequest =
updateCacheTagsBeforeRequest;

config.behaviors.project = apiProjectBehaviors(client);
config.behaviors.server = apiServerBehaviors(client);
Expand Down
2 changes: 1 addition & 1 deletion packages/models/src/project/Project/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export class Project extends ReferenceModel {
},
);

public query(query: ProjectListQueryModelData = {}) {
public static query(query: ProjectListQueryModelData = {}) {
return new ProjectListQuery(query);
}

Expand Down
16 changes: 0 additions & 16 deletions packages/models/src/react/MittwaldApiModelProvider.ts

This file was deleted.

25 changes: 25 additions & 0 deletions packages/models/src/react/MittwaldApiModelProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { FC, PropsWithChildren, ReactNode } from "react";
import { usePromise } from "@mittwald/react-use-promise";
import { setModule } from "./reactUsePromise.js";

interface Props extends PropsWithChildren {
fallback?: ReactNode;
}

export const MittwaldApiModelProvider: FC<Props> = (props) => {
const { fallback, children } = props;

const module = usePromise(
() => import("@mittwald/react-use-promise").then(setModule),
[],
{
useSuspense: false,
},
);

if (!module.hasValue) {
return fallback;
}

return children;
};
38 changes: 38 additions & 0 deletions packages/models/src/react/asyncResourceInvalidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Commons } from "@mittwald/api-client";
import { reactProvisionContext } from "./reactProvisionContext.js";
import { refresh } from "@mittwald/react-use-promise";
import { Store } from "@mittwald/react-use-promise/store";

const cacheTagStore = new Store<Set<number>>();

export const refreshModels = (tag: string) => {
cacheTagStore.getAll(tag).forEach((ids) => {
ids.forEach((id) => {
refresh({
tag: String(id),
});
});
});
};

export const addCacheTag = (tag: string) => {
const context = reactProvisionContext.use();

if (context) {
const ids = cacheTagStore.get(tag) ?? new Set<number>();
ids.add(context.id);

cacheTagStore.set(tag, () => ids, {
tags: [tag],
});
}
};

export const updateCacheTagsBeforeRequest: Commons.RequestOptions["onBeforeRequest"] =
(request) => {
const url = request.requestConfig.url;

if (request.requestConfig.method === "GET" && url) {
addCacheTag(url);
}
};
1 change: 1 addition & 0 deletions packages/models/src/react/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { refreshModels, addCacheTag } from "./asyncResourceInvalidation.js";
export * from "./MittwaldApiModelProvider.js";
export * from "./reactUsePromise.js";
export { type AsyncResourceVariant } from "./provideReact.js";
Expand Down
90 changes: 90 additions & 0 deletions packages/models/src/react/provideReact.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/** @jest-environment jsdom */

import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { ReferenceModel } from "../base/index.js";
import { provideReact } from "./provideReact.js";
import React, { act, FC, PropsWithChildren, Suspense } from "react";
import { beforeEach, jest } from "@jest/globals";
import { MittwaldApiModelProvider } from "./MittwaldApiModelProvider.js";
import { addCacheTag, refreshModels } from "./asyncResourceInvalidation.js";
import { asyncResourceStore } from "@mittwald/react-use-promise";

const simulatedDataLoad = jest.fn();
let rerender: ReturnType<typeof render>["rerender"] | undefined;

beforeEach(() => {
jest.resetAllMocks();
jest.useFakeTimers();
asyncResourceStore.clear();
rerender = undefined;

simulatedDataLoad.mockImplementation(() => {
return new Promise((res) => setTimeout(res, 100));
});
});

class TestModel extends ReferenceModel {
public static ofId(id: number) {
return new TestModel(String(id));
}

public getDetailed = provideReact(async () => {
addCacheTag(`test/get/${this.id}`);
await simulatedDataLoad();
return {
id: this.id,
foo: true,
};
}, [this.id]);
}

const TestComponent: FC<{ id: number }> = (props) => {
const model = TestModel.ofId(props.id).getDetailed.use();
return <span>{model.id}</span>;
};

const TestWrapper: FC<PropsWithChildren> = (props) => (
<MittwaldApiModelProvider>
<Suspense>{props.children}</Suspense>
</MittwaldApiModelProvider>
);

const runTest = async (id: number, expectedDataLoadingCount: number) => {
const ui = rerender
? rerender(<TestComponent id={id} />)
: render(<TestComponent id={id} />, {
wrapper: TestWrapper,
});

if (ui) {
rerender = ui.rerender;
}
expect(await screen.findByText(id)).toBeInTheDocument();
expect(simulatedDataLoad).toHaveBeenCalledTimes(expectedDataLoadingCount);
};

test("Model data can be used", async () => {
await runTest(42, 1);
await runTest(43, 2);
});

test("Model caches data", async () => {
await runTest(42, 1);
await runTest(43, 2);
await runTest(42, 2);
await runTest(43, 2);
});

test("Model cache can be refreshed", async () => {
await runTest(42, 1);
// Tag does not exist
act(() => refreshModels("foo"));
await runTest(42, 1);
// Tag exist
act(() => refreshModels("test/get/42"));
await runTest(42, 2);
// Tag exist
act(() => refreshModels("test/**/*"));
await runTest(42, 3);
});
23 changes: 20 additions & 3 deletions packages/models/src/react/provideReact.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { AsyncResource, reactUsePromise } from "./reactUsePromise.js";
import { AsyncReturnType } from "type-fest";
import { hash } from "object-code";
import { reactProvisionContext } from "./reactProvisionContext.js";

type AsyncFn = (...args: any[]) => Promise<unknown>;

Expand All @@ -8,13 +10,28 @@ export const provideReact = <T extends AsyncFn>(
dependencies?: string[],
) => {
type P = Parameters<T>;
const provisionId = hash({
loader,
dependencies,
});

const getAsyncResource = (params: P) =>
reactUsePromise.getAsyncResource(loader, params, {
const getAsyncResource = (params: P) => {
const contextId = provisionId + hash(params);

const loaderWithContext = reactProvisionContext.bind(
{
id: contextId,
},
loader,
);

return reactUsePromise.getAsyncResource(loaderWithContext, params, {
// "stringify" dependencies to be used as loaderId
// see https://github.com/mittwald/react-use-promise?tab=readme-ov-file#loaderid
loaderId: dependencies ? dependencies.join("|") : undefined,
loaderId: String(provisionId),
tags: [String(contextId)],
});
};

return Object.assign(loader, {
asResource: (...params: P) => getAsyncResource(params),
Expand Down
5 changes: 5 additions & 0 deletions packages/models/src/react/reactProvisionContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createCascade } from "context";

export const reactProvisionContext = createCascade<{
id: number;
}>();
3 changes: 2 additions & 1 deletion packages/models/src/react/reactUsePromise.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
type ReactUsePromiseModule = typeof import("@mittwald/react-use-promise");
export type ReactUsePromiseModule =
typeof import("@mittwald/react-use-promise");

export type {
AsyncResource,
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"skipLibCheck": true,
"strict": true,
"stripInternal": true,
"target": "ES2022"
"target": "ES2022",
"jsx": "react"
}
}
Loading

0 comments on commit 8661d86

Please sign in to comment.