diff --git a/packages/client-hooks/jest.config.js b/packages/client-hooks/jest.config.js new file mode 100644 index 000000000..5073898e9 --- /dev/null +++ b/packages/client-hooks/jest.config.js @@ -0,0 +1,186 @@ +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +export default { + displayName: "client-hooks", + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: process.env.LAYERCI ? "/tmp/jest-cache" : undefined, + + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: undefined, + + // The directory where Jest should output its coverage files + // coverageDirectory: undefined, + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: undefined, + + // A path to a custom dependency extractor + // dependencyExtractor: undefined, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + extensionsToTreatAsEsm: [".ts", ".tsx"], + + // Force coverage collection from ignored files using an array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: "/../api/spec/jest.globalsetup.ts", + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: undefined, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. + // maxWorkers: "50%", + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "ts", + // "tsx", + // "node" + // ], + + // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: "ts-jest", + + // Run tests from one or more projects + // projects: undefined, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: undefined, + + // Automatically restore mock state between every test + restoreMocks: true, + + // The root directory that Jest should scan for tests and modules within + // rootDir: undefined, + + // A list of paths to directories that Jest should use to search for files in + roots: [""], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: ["./spec/setup.ts"], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: ["/spec/jest.setup.ts"], + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "setup-polly-jest/jest-environment-jsdom", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [path.join(__dirname, "spec/(*.)+(spec|test).[tj]s?(x)")], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + testPathIgnorePatterns: ["/node_modules/"], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + + // This option allows the use of a custom results processor + // testResultsProcessor: undefined, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + testRunner: "jest-circus/runner", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + // transform: undefined, + transform: { "^.+\\.(t|j)sx?$": ["@swc/jest"] }, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/" + // ], + // transformIgnorePatterns: ["/node_modules/(?!lodash)"], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: undefined, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/packages/client-hooks/package.json b/packages/client-hooks/package.json new file mode 100644 index 000000000..8caf49376 --- /dev/null +++ b/packages/client-hooks/package.json @@ -0,0 +1,45 @@ +{ + "name": "@gadgetinc/client-hooks", + "version": "0.1.0", + "files": [ + "README.md", + "dist/**/*" + ], + "license": "MIT", + "repository": "github:gadget-inc/js-clients", + "homepage": "https://github.com/gadget-inc/js-clients/tree/main/packages/client-hooks", + "type": "module", + "exports": { + "./package.json": "./package.json", + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "default": "./dist/esm/index.js" + } + }, + "source": "src/index.ts", + "main": "dist/cjs/index.js", + "sideEffects": false, + "scripts": { + "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch", + "typecheck:main": "tsc --noEmit", + "typecheck": "tsc --noEmit", + "clean": "rimraf dist/ *.tsbuildinfo **/*.tsbuildinfo", + "prebuild": "mkdir -p dist/cjs dist/esm && echo '{\"type\": \"commonjs\"}' > dist/cjs/package.json && echo '{\"type\": \"module\"}' > dist/esm/package.json", + "build": "pnpm clean && pnpm prebuild && tsc -b tsconfig.cjs.json tsconfig.esm.json", + "prepublishOnly": "pnpm build", + "prerelease": "gitpkg publish" + }, + "peerDependencies": { + "@gadgetinc/core": ">=0.2.0" + }, + "devDependencies": { + "@gadgetinc/core": "workspace:*", + "@urql/core": "*", + "@jest/globals": "*", + "@swc/jest": "*", + "@types/jest": "*", + "jest": "*" + } +} diff --git a/packages/client-hooks/spec/README.md b/packages/client-hooks/spec/README.md new file mode 100644 index 000000000..b82dbcdeb --- /dev/null +++ b/packages/client-hooks/spec/README.md @@ -0,0 +1,61 @@ +# Client Hooks Test Suite + +This directory contains tests for the `@gadgetinc/client-hooks` package. These tests are designed to be framework-agnostic and test the core logic of each hook without depending on specific framework implementations or concrete API clients. + +## Test Architecture + +### Mock Adapter (`mockAdapter.ts`) + +The tests use a **mock adapter** that implements the `RuntimeAdapter` interface. This adapter simulates the behavior of framework-specific adapters (like React or Preact) without actually depending on those frameworks. + +Key components: + +- **Framework bindings**: Mock implementations of `useState`, `useEffect`, `useMemo`, etc. +- **URQL bindings**: Mock implementations of `useQuery` and `useMutation` +- **Context**: Mock Gadget API context for providing the client and connection + +### Test Approach + +Each hook test file follows a similar pattern: + +1. **Setup**: Create a mock adapter, API client, and connection +2. **Initialize hooks**: Call `createHooks(adapter)` to initialize the hook system +3. **Test hook behavior**: Call the hook and verify it: + - Returns the expected state shape + - Calls the appropriate connection methods with correct parameters + - Uses adapter hooks correctly (useMemo, useCallback, etc.) + - Handles options and edge cases properly + +### What These Tests DO Cover + +- ✅ Hook initialization and state shape +- ✅ Correct parameters passed to connection operations +- ✅ Proper use of adapter framework hooks +- ✅ Options handling (pause, suspense, select, etc.) +- ✅ Namespace support +- ✅ Edge cases (stubbed actions, null data, etc.) + +### What These Tests DON'T Cover + +- ❌ Actual network requests and responses +- ❌ Framework-specific behavior (React rendering, Preact lifecycle, etc.) +- ❌ Real URQL client behavior +- ❌ Integration with concrete Gadget API clients + +These integration tests will live in the framework-specific packages: + +- `@gadgetinc/react` - Full integration tests with React, real URQL, and mocked network responses +- `@gadgetinc/preact` - Full integration tests with Preact + +## Running Tests + +```bash +# From the package root +pnpm test + +# Watch mode +pnpm test --watch + +# With coverage +pnpm test --coverage +``` diff --git a/packages/client-hooks/spec/mockAdapter.ts b/packages/client-hooks/spec/mockAdapter.ts new file mode 100644 index 000000000..e706e50d5 --- /dev/null +++ b/packages/client-hooks/spec/mockAdapter.ts @@ -0,0 +1,159 @@ +import type { AnyClient, GadgetConnection } from "@gadgetinc/api-client-core"; +import { jest } from "@jest/globals"; +import type { GadgetApiContext, RuntimeAdapter, UseMutationResponse, UseQueryArgs, UseQueryResponse } from "../src/adapter.js"; + +/** + * Creates a mock RuntimeAdapter for testing + * This simulates the behavior of a framework adapter (React, Preact, etc.) + * without depending on any specific framework + */ +export const createMockAdapter = (api: AnyClient, connection: GadgetConnection): RuntimeAdapter => { + // Track hook state across calls using a simple state store + const stateStore = new Map(); + let stateCounter = 0; + + // Mock context + const contextValue: GadgetApiContext = { api, connection }; + const GadgetApiContext: any = { + Provider: null, + Consumer: null, + _currentValue: contextValue, + }; + + // Mock framework bindings + const framework = { + deepEqual: (a: A, b: B): boolean => { + return JSON.stringify(a) === JSON.stringify(b); + }, + + useEffect: jest.fn((fn: () => void | (() => void), _deps?: any[]) => { + const cleanup = fn(); + return cleanup; + }), + + useMemo: jest.fn((factory: () => T, _deps: any[]): T => { + return factory(); + }), + + useRef: jest.fn((initial: T): { current: T } => { + return { current: initial }; + }), + + useState: jest.fn((initial: T | (() => T)): [T, (next: T) => void] => { + const id = stateCounter++; + if (!stateStore.has(id)) { + stateStore.set(id, typeof initial === "function" ? (initial as () => T)() : initial); + } + const setState = (next: T) => { + stateStore.set(id, next); + }; + return [stateStore.get(id), setState]; + }), + + useContext: jest.fn((_ctx: any): T => { + return contextValue as any; + }), + + createContext: jest.fn((defaultValue: T): any => { + return { + Provider: null, + Consumer: null, + _currentValue: defaultValue, + }; + }), + + useCallback: jest.fn( any>(fn: T, _deps: any[]): T => { + return fn; + }), + + useReducer: jest.fn( + (reducer: (prevState: S, ...args: A) => S, initialArg: I, init?: (i: I) => S): [S, (...args: A) => void] => { + const id = stateCounter++; + if (!stateStore.has(id)) { + stateStore.set(id, init ? init(initialArg) : initialArg); + } + const dispatch = (...args: A) => { + const currentState = stateStore.get(id); + const newState = reducer(currentState, ...args); + stateStore.set(id, newState); + }; + return [stateStore.get(id), dispatch]; + } + ), + + Fragment: null as any, + }; + + // Mock urql bindings + const urql = { + Provider: jest.fn((props: { client: any; children: any }) => props.children), + + useQuery: jest.fn((_args: UseQueryArgs): UseQueryResponse => { + return [ + { + fetching: false, + stale: false, + data: undefined, + error: undefined, + }, + jest.fn(), + ] as any; + }), + + useMutation: jest.fn((_query: any): UseMutationResponse => { + return [ + { + fetching: false, + stale: false, + data: undefined, + error: undefined, + }, + jest.fn(async () => ({ + fetching: false, + stale: false, + data: undefined, + error: undefined, + operation: undefined, + })), + ] as any; + }), + }; + + return { + GadgetApiContext, + framework, + urql, + }; +}; + +/** + * Mock API client builder + */ +export const createMockApiClient = (): AnyClient => { + return { + connection: createMockConnection(), + } as any; +}; + +/** + * Mock GadgetConnection builder + */ +export const createMockConnection = (): GadgetConnection => { + return { + currentClient: {} as any, + endpoint: "https://test.gadget.app/api", + actionOperation: jest.fn(), + findOneOperation: jest.fn(), + findManyOperation: jest.fn(), + findFirstOperation: jest.fn(), + getOperation: jest.fn(), + viewOperation: jest.fn(), + hydrateRecord: jest.fn((result, data) => data), + processActionResponse: jest.fn((defaultSelection, result, mutationData, modelSelectionField, hasReturnType) => { + if (hasReturnType) { + return mutationData.result; + } + return mutationData[modelSelectionField]; + }), + } as any; +}; diff --git a/packages/client-hooks/spec/useAction.spec.ts b/packages/client-hooks/spec/useAction.spec.ts new file mode 100644 index 000000000..f1480f194 --- /dev/null +++ b/packages/client-hooks/spec/useAction.spec.ts @@ -0,0 +1,194 @@ +import { jest } from "@jest/globals"; +import { createHooks } from "../src/createHooks.js"; +import { useAction } from "../src/useAction.js"; +import { createMockAdapter, createMockApiClient, createMockConnection } from "./mockAdapter.js"; + +describe("useAction", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the hook correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + // Initialize the hooks with the adapter + createHooks(adapter); + + // Mock action function + const mockAction: any = { + type: "action", + operationName: "updateUser", + namespace: [], + modelApiIdentifier: "user", + modelSelectionField: "user", + defaultSelection: { id: true }, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: false, + isBulk: false, + }; + + // Mock connection.actionOperation to return a valid plan + connection.actionOperation = jest.fn(() => ({ + query: "mutation updateUser($id: ID!) { updateUser(id: $id) { success user { id } } }", + variables: { id: "123" }, + })); + + // Call the hook + const [state, mutate] = useAction(mockAction); + + // Verify initial state + expect(state.fetching).toBe(false); + expect(state.data).toBeUndefined(); + expect(state.error).toBeUndefined(); + + // Verify mutate function is returned + expect(typeof mutate).toBe("function"); + + // Verify adapter methods were called + expect(adapter.framework.useMemo).toHaveBeenCalled(); + }); + + it("should call connection.actionOperation with correct parameters", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockAction: any = { + type: "action", + operationName: "updateUser", + namespace: [], + modelApiIdentifier: "user", + modelSelectionField: "user", + defaultSelection: { id: true, email: true }, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: false, + isBulk: false, + }; + + const mockOptions = { select: { id: true } }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation updateUser { updateUser { success } }", + variables: {}, + })); + + useAction(mockAction, mockOptions); + + expect(connection.actionOperation).toHaveBeenCalledWith( + "updateUser", + { id: true, email: true }, + "user", + "user", + {}, + mockOptions, + [], + false, + false + ); + }); + + it("should handle stubbed actions and dispatch event", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockStubbedAction: any = { + type: "stubbedAction", + reason: "MissingApiTrigger", + dataPath: "fakePath", + functionName: "fakeAction", + actionApiIdentifier: "fakeAction", + modelApiIdentifier: "fakeModel", + variables: {}, + }; + + let eventDispatched: CustomEvent | undefined; + globalThis.addEventListener("gadget:devharness:stubbedActionError", (event) => { + eventDispatched = event as CustomEvent; + }); + + connection.actionOperation = jest.fn(() => ({ + query: "mutation fake { fake { success } }", + variables: {}, + })); + + useAction(mockStubbedAction); + + // Verify useEffect was called (which dispatches the event) + expect(adapter.framework.useEffect).toHaveBeenCalled(); + + // Call the effect function to simulate it running + const effectCall = (adapter.framework.useEffect as jest.Mock).mock.calls[0]; + if (effectCall && effectCall[0]) { + effectCall[0](); + } + + expect(eventDispatched).toBeTruthy(); + expect(eventDispatched?.detail).toEqual({ + reason: "MissingApiTrigger", + action: { + functionName: "fakeAction", + actionApiIdentifier: "fakeAction", + dataPath: "fakePath", + modelApiIdentifier: "fakeModel", + }, + }); + }); + + it("should use adapter hooks correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockAction: any = { + type: "action", + operationName: "createUser", + namespace: [], + modelApiIdentifier: "user", + modelSelectionField: "user", + defaultSelection: { id: true }, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: false, + isBulk: false, + }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation createUser { createUser { success } }", + variables: {}, + })); + + useAction(mockAction); + + // Verify framework adapter methods were called + expect(adapter.framework.useEffect).toHaveBeenCalled(); + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.framework.useCallback).toHaveBeenCalled(); + + // Verify urql methods were called + expect(adapter.urql.useMutation).toHaveBeenCalled(); + }); +}); diff --git a/packages/client-hooks/spec/useBulkAction.spec.ts b/packages/client-hooks/spec/useBulkAction.spec.ts new file mode 100644 index 000000000..9864e7bb0 --- /dev/null +++ b/packages/client-hooks/spec/useBulkAction.spec.ts @@ -0,0 +1,174 @@ +import { jest } from "@jest/globals"; +import { createHooks } from "../src/createHooks.js"; +import { useBulkAction } from "../src/useBulkAction.js"; +import { createMockAdapter, createMockApiClient, createMockConnection } from "./mockAdapter.js"; + +describe("useBulkAction", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the hook correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockAction: any = { + type: "action", + operationName: "bulkUpdateUsers", + namespace: [], + modelApiIdentifier: "user", + modelSelectionField: "users", + defaultSelection: { id: true }, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: false, + isBulk: true, + }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation bulkUpdateUsers { bulkUpdateUsers { success users { id } } }", + variables: {}, + })); + + const [state, mutate] = useBulkAction(mockAction); + + expect(state.fetching).toBe(false); + expect(state.data).toBeUndefined(); + expect(state.error).toBeUndefined(); + expect(typeof mutate).toBe("function"); + + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.urql.useMutation).toHaveBeenCalled(); + }); + + it("should call connection.actionOperation with isBulk=true", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockAction: any = { + type: "action", + operationName: "bulkCreateWidgets", + namespace: [], + modelApiIdentifier: "widget", + modelSelectionField: "widgets", + defaultSelection: { id: true, name: true }, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: false, + isBulk: true, + }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation bulkCreateWidgets { bulkCreateWidgets { success } }", + variables: {}, + })); + + useBulkAction(mockAction); + + expect(connection.actionOperation).toHaveBeenCalledWith( + "bulkCreateWidgets", + { id: true, name: true }, + "widget", + "widgets", + {}, + undefined, + [], + true, + false + ); + }); + + it("should handle namespaced bulk actions", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockAction: any = { + type: "action", + operationName: "bulkCreatePlayers", + namespace: ["game"], + modelApiIdentifier: "game.player", + modelSelectionField: "players", + defaultSelection: { id: true }, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: false, + isBulk: true, + }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation bulkCreatePlayers { game { bulkCreatePlayers { success } } }", + variables: {}, + })); + + useBulkAction(mockAction); + + expect(connection.actionOperation).toHaveBeenCalledWith( + "bulkCreatePlayers", + { id: true }, + "game.player", + "players", + {}, + undefined, + ["game"], + true, + false + ); + }); + + it("should use adapter hooks correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockAction: any = { + type: "action", + operationName: "bulkDeleteWidgets", + namespace: [], + modelApiIdentifier: "widget", + modelSelectionField: "widgets", + defaultSelection: { id: true }, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: false, + isBulk: true, + }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation bulkDeleteWidgets { bulkDeleteWidgets { success } }", + variables: {}, + })); + + useBulkAction(mockAction); + + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.framework.useCallback).toHaveBeenCalled(); + expect(adapter.urql.useMutation).toHaveBeenCalled(); + }); +}); diff --git a/packages/client-hooks/spec/useEnqueue.spec.ts b/packages/client-hooks/spec/useEnqueue.spec.ts new file mode 100644 index 000000000..fb9009852 --- /dev/null +++ b/packages/client-hooks/spec/useEnqueue.spec.ts @@ -0,0 +1,206 @@ +import { jest } from "@jest/globals"; +import { createHooks } from "../src/createHooks.js"; +import { useEnqueue } from "../src/useEnqueue.js"; +import { createMockAdapter, createMockApiClient, createMockConnection } from "./mockAdapter.js"; + +describe("useEnqueue", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the hook correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockAction: any = { + type: "action", + operationName: "createUser", + namespace: [], + modelApiIdentifier: "user", + modelSelectionField: "user", + defaultSelection: { id: true }, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: false, + isBulk: false, + }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation enqueueCreateUser { enqueueCreateUser { success backgroundAction { id } } }", + variables: {}, + })); + + const [state, enqueue] = useEnqueue(mockAction); + + expect(state.fetching).toBe(false); + expect(state.handle).toBeNull(); + expect(state.error).toBeUndefined(); + expect(typeof enqueue).toBe("function"); + + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.urql.useMutation).toHaveBeenCalled(); + }); + + it("should call connection.actionOperation for background action", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockAction: any = { + type: "action", + operationName: "sendEmail", + namespace: [], + modelApiIdentifier: "email", + modelSelectionField: "email", + defaultSelection: { id: true }, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: false, + isBulk: false, + }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation enqueueSendEmail { enqueueSendEmail { success backgroundAction { id } } }", + variables: {}, + })); + + useEnqueue(mockAction); + + // The enqueue hook should call actionOperation with the action + expect(connection.actionOperation).toHaveBeenCalled(); + }); + + it("should handle base background options", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockAction: any = { + type: "action", + operationName: "processJob", + namespace: [], + modelApiIdentifier: "job", + modelSelectionField: "job", + defaultSelection: { id: true }, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: false, + isBulk: false, + }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation enqueueProcessJob { enqueueProcessJob { success backgroundAction { id } } }", + variables: {}, + })); + + const baseBackgroundOptions = { + id: "custom-id", + priority: "high", + retries: 3, + }; + + useEnqueue(mockAction, baseBackgroundOptions); + + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.urql.useMutation).toHaveBeenCalled(); + }); + + it("should handle bulk actions", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockBulkAction: any = { + type: "action", + operationName: "bulkCreateUsers", + namespace: [], + modelApiIdentifier: "user", + modelSelectionField: "users", + defaultSelection: { id: true }, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: false, + isBulk: true, + }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation enqueueBulkCreateUsers { enqueueBulkCreateUsers { success backgroundAction { id } } }", + variables: {}, + })); + + const [state] = useEnqueue(mockBulkAction); + + // For bulk actions, state should have 'handles' instead of 'handle' + expect(state.handles).toBeNull(); + expect(adapter.urql.useMutation).toHaveBeenCalled(); + }); + + it("should handle namespaced actions", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockAction: any = { + type: "action", + operationName: "createPlayer", + namespace: ["game"], + modelApiIdentifier: "game.player", + modelSelectionField: "player", + defaultSelection: { id: true }, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: false, + isBulk: false, + }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation enqueueCreatePlayer { game { enqueueCreatePlayer { success backgroundAction { id } } } }", + variables: {}, + })); + + useEnqueue(mockAction); + + expect(connection.actionOperation).toHaveBeenCalledWith( + "createPlayer", + { id: true }, + "game.player", + "player", + {}, + undefined, + ["game"], + false, + false + ); + }); +}); diff --git a/packages/client-hooks/spec/useFetch.spec.ts b/packages/client-hooks/spec/useFetch.spec.ts new file mode 100644 index 000000000..bde3ccfac --- /dev/null +++ b/packages/client-hooks/spec/useFetch.spec.ts @@ -0,0 +1,140 @@ +import { jest } from "@jest/globals"; +import { createHooks } from "../src/createHooks.js"; +import { useFetch } from "../src/useFetch.js"; +import { createMockAdapter, createMockApiClient, createMockConnection } from "./mockAdapter.js"; + +describe("useFetch", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the hook correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const [state, execute] = useFetch("/api/custom"); + + expect(state.fetching).toBe(false); + expect(state.streaming).toBe(false); + expect(state.data).toBeUndefined(); + expect(state.error).toBeUndefined(); + expect(typeof execute).toBe("function"); + + expect(adapter.framework.useState).toHaveBeenCalled(); + }); + + it("should accept fetch options", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const options = { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ foo: "bar" }), + }; + + const [state] = useFetch("/api/custom", options); + + expect(state.options).toMatchObject(options); + }); + + it("should support json option", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const [state] = useFetch("/api/custom", { json: true }); + + expect(state.options.json).toBe(true); + }); + + it("should support stream option", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const [state] = useFetch("/api/custom", { stream: true }); + + expect(state.options.stream).toBe(true); + }); + + it("should support sendImmediately option for GET requests", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const [state] = useFetch("/api/custom", { sendImmediately: true }); + + expect(state.options.sendImmediately).toBe(true); + // For GET requests with sendImmediately, useEffect should be called to trigger fetch + expect(adapter.framework.useEffect).toHaveBeenCalled(); + }); + + it("should support onStreamComplete callback", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const onStreamComplete = jest.fn(); + const [state] = useFetch("/api/custom", { stream: "string", onStreamComplete }); + + expect(state.options.stream).toBe("string"); + expect(state.options.onStreamComplete).toBe(onStreamComplete); + }); + + it("should use adapter hooks correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + useFetch("/api/custom"); + + expect(adapter.framework.useState).toHaveBeenCalled(); + expect(adapter.framework.useCallback).toHaveBeenCalled(); + expect(adapter.framework.useEffect).toHaveBeenCalled(); + }); + + it("should handle different HTTP methods", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + // POST request + const [statePost] = useFetch("/api/create", { method: "POST" }); + expect(statePost.options.method).toBe("POST"); + + // PUT request + const [statePut] = useFetch("/api/update", { method: "PUT" }); + expect(statePut.options.method).toBe("PUT"); + + // DELETE request + const [stateDelete] = useFetch("/api/delete", { method: "DELETE" }); + expect(stateDelete.options.method).toBe("DELETE"); + }); +}); diff --git a/packages/client-hooks/spec/useFindBy.spec.ts b/packages/client-hooks/spec/useFindBy.spec.ts new file mode 100644 index 000000000..c595fc75c --- /dev/null +++ b/packages/client-hooks/spec/useFindBy.spec.ts @@ -0,0 +1,137 @@ +import { jest } from "@jest/globals"; +import { createHooks } from "../src/createHooks.js"; +import { useFindBy } from "../src/useFindBy.js"; +import { createMockAdapter, createMockApiClient, createMockConnection } from "./mockAdapter.js"; + +describe("useFindBy", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the hook correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockFinder: any = { + type: "findOne", + operationName: "user", + findByVariableName: "email", + modelApiIdentifier: "user", + defaultSelection: { id: true, email: true }, + namespace: [], + }; + + connection.findOneOperation = jest.fn(() => ({ + query: "query user($email: String!) { user(email: $email) { id email } }", + variables: { email: "test@test.com" }, + })); + + const [state, refetch] = useFindBy(mockFinder, "test@test.com"); + + expect(state.fetching).toBe(false); + expect(state.data).toBeUndefined(); + expect(state.error).toBeUndefined(); + expect(typeof refetch).toBe("function"); + + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.urql.useQuery).toHaveBeenCalled(); + }); + + it("should call connection.findOneOperation with the field value", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockFinder: any = { + type: "findOne", + operationName: "user", + findByVariableName: "email", + modelApiIdentifier: "user", + defaultSelection: { id: true, email: true }, + namespace: [], + }; + + connection.findOneOperation = jest.fn(() => ({ + query: "query user($email: String!) { user(email: $email) { id email } }", + variables: { email: "alice@example.com" }, + })); + + useFindBy(mockFinder, "alice@example.com"); + + expect(connection.findOneOperation).toHaveBeenCalledWith("user", "alice@example.com", { id: true, email: true }, "user", undefined, []); + }); + + it("should handle namespaced models", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockFinder: any = { + type: "findOne", + operationName: "player", + findByVariableName: "number", + modelApiIdentifier: "game.player", + defaultSelection: { id: true, number: true }, + namespace: ["game"], + }; + + connection.findOneOperation = jest.fn(() => ({ + query: "query player($number: Int!) { game { player(number: $number) { id number } } }", + variables: { number: "23" }, + })); + + useFindBy(mockFinder, "23"); + + expect(connection.findOneOperation).toHaveBeenCalledWith("player", "23", { id: true, number: true }, "game.player", undefined, [ + "game", + ]); + }); + + it("should pass options to the query", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockFinder: any = { + type: "findOne", + operationName: "user", + findByVariableName: "email", + modelApiIdentifier: "user", + defaultSelection: { id: true, email: true }, + namespace: [], + }; + + connection.findOneOperation = jest.fn(() => ({ + query: "query user($email: String!) { user(email: $email) { id } }", + variables: { email: "test@test.com" }, + })); + + useFindBy(mockFinder, "test@test.com", { select: { id: true }, pause: true }); + + const useQueryCall = (adapter.urql.useQuery as jest.Mock).mock.calls[0]; + expect(useQueryCall).toBeDefined(); + expect(useQueryCall[0]).toMatchObject({ pause: true }); + + expect(connection.findOneOperation).toHaveBeenCalledWith( + "user", + "test@test.com", + { id: true, email: true }, + "user", + { select: { id: true }, pause: true }, + [] + ); + }); +}); diff --git a/packages/client-hooks/spec/useFindFirst.spec.ts b/packages/client-hooks/spec/useFindFirst.spec.ts new file mode 100644 index 000000000..13bff3213 --- /dev/null +++ b/packages/client-hooks/spec/useFindFirst.spec.ts @@ -0,0 +1,139 @@ +import { jest } from "@jest/globals"; +import { createHooks } from "../src/createHooks.js"; +import { useFindFirst } from "../src/useFindFirst.js"; +import { createMockAdapter, createMockApiClient, createMockConnection } from "./mockAdapter.js"; + +describe("useFindFirst", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the hook correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findFirst: { + type: "findFirst", + operationName: "users", + modelApiIdentifier: "user", + defaultSelection: { id: true, email: true }, + namespace: [], + }, + }; + + connection.findFirstOperation = jest.fn(() => ({ + query: "query users { users(first: 1) { edges { node { id email } } } }", + variables: {}, + })); + + const [state, refetch] = useFindFirst(mockManager); + + expect(state.fetching).toBe(false); + expect(state.data).toBeUndefined(); + expect(state.error).toBeUndefined(); + expect(typeof refetch).toBe("function"); + + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.urql.useQuery).toHaveBeenCalled(); + }); + + it("should call connection.findFirstOperation with correct parameters", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findFirst: { + type: "findFirst", + operationName: "users", + modelApiIdentifier: "user", + defaultSelection: { id: true, email: true }, + namespace: [], + }, + }; + + const mockOptions = { + select: { id: true }, + filter: { email: { equals: "test@test.com" } }, + sort: { createdAt: "Descending" }, + }; + + connection.findFirstOperation = jest.fn(() => ({ + query: "query users { users(first: 1) { edges { node { id } } } }", + variables: {}, + })); + + useFindFirst(mockManager, mockOptions); + + expect(connection.findFirstOperation).toHaveBeenCalledWith("users", { id: true, email: true }, "user", mockOptions, []); + }); + + it("should handle namespaced models", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findFirst: { + type: "findFirst", + operationName: "players", + modelApiIdentifier: "game.player", + defaultSelection: { id: true, name: true }, + namespace: ["game"], + }, + }; + + connection.findFirstOperation = jest.fn(() => ({ + query: "query players { game { players(first: 1) { edges { node { id name } } } } }", + variables: {}, + })); + + useFindFirst(mockManager); + + expect(connection.findFirstOperation).toHaveBeenCalledWith("players", { id: true, name: true }, "game.player", undefined, ["game"]); + }); + + it("should pass query options correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findFirst: { + type: "findFirst", + operationName: "users", + modelApiIdentifier: "user", + defaultSelection: { id: true }, + namespace: [], + }, + }; + + connection.findFirstOperation = jest.fn(() => ({ + query: "query users { users(first: 1) { edges { node { id } } } }", + variables: {}, + })); + + useFindFirst(mockManager, { pause: true, suspense: true }); + + const useQueryCall = (adapter.urql.useQuery as jest.Mock).mock.calls[0]; + expect(useQueryCall).toBeDefined(); + expect(useQueryCall[0]).toMatchObject({ + pause: true, + suspense: true, + }); + }); +}); diff --git a/packages/client-hooks/spec/useFindMany.spec.ts b/packages/client-hooks/spec/useFindMany.spec.ts new file mode 100644 index 000000000..9fa2042e3 --- /dev/null +++ b/packages/client-hooks/spec/useFindMany.spec.ts @@ -0,0 +1,169 @@ +import { jest } from "@jest/globals"; +import { createHooks } from "../src/createHooks.js"; +import { useFindMany } from "../src/useFindMany.js"; +import { createMockAdapter, createMockApiClient, createMockConnection } from "./mockAdapter.js"; + +describe("useFindMany", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the hook correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findMany: { + type: "findMany", + operationName: "users", + modelApiIdentifier: "user", + defaultSelection: { id: true, email: true }, + namespace: [], + }, + }; + + connection.findManyOperation = jest.fn(() => ({ + query: "query users { users { edges { node { id email } } } }", + variables: {}, + })); + + const [state, refetch] = useFindMany(mockManager); + + expect(state.fetching).toBe(false); + expect(state.data).toBeUndefined(); + expect(state.error).toBeUndefined(); + expect(typeof refetch).toBe("function"); + + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.urql.useQuery).toHaveBeenCalled(); + }); + + it("should call connection.findManyOperation with correct parameters", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findMany: { + type: "findMany", + operationName: "users", + modelApiIdentifier: "user", + defaultSelection: { id: true, email: true }, + namespace: [], + }, + }; + + const mockOptions = { + select: { id: true }, + first: 10, + filter: { email: { equals: "test@test.com" } }, + }; + + connection.findManyOperation = jest.fn(() => ({ + query: "query users { users { edges { node { id } } } }", + variables: { first: 10 }, + })); + + useFindMany(mockManager, mockOptions); + + expect(connection.findManyOperation).toHaveBeenCalledWith("users", { id: true, email: true }, "user", mockOptions, []); + }); + + it("should handle namespaced models", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findMany: { + type: "findMany", + operationName: "players", + modelApiIdentifier: "game.player", + defaultSelection: { id: true, name: true }, + namespace: ["game"], + }, + }; + + connection.findManyOperation = jest.fn(() => ({ + query: "query players { game { players { edges { node { id name } } } } }", + variables: {}, + })); + + useFindMany(mockManager); + + expect(connection.findManyOperation).toHaveBeenCalledWith("players", { id: true, name: true }, "game.player", undefined, ["game"]); + }); + + it("should pass pause and other query options", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findMany: { + type: "findMany", + operationName: "users", + modelApiIdentifier: "user", + defaultSelection: { id: true }, + namespace: [], + }, + }; + + connection.findManyOperation = jest.fn(() => ({ + query: "query users { users { edges { node { id } } } }", + variables: {}, + })); + + useFindMany(mockManager, { pause: true, requestPolicy: "cache-first" }); + + const useQueryCall = (adapter.urql.useQuery as jest.Mock).mock.calls[0]; + expect(useQueryCall).toBeDefined(); + expect(useQueryCall[0]).toMatchObject({ + pause: true, + requestPolicy: "cache-first", + }); + }); + + it("should support live queries", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findMany: { + type: "findMany", + operationName: "users", + modelApiIdentifier: "user", + defaultSelection: { id: true }, + namespace: [], + }, + }; + + connection.findManyOperation = jest.fn(() => ({ + query: "query users { users { edges { node { id } } } }", + variables: {}, + })); + + useFindMany(mockManager, { live: true }); + + const useQueryCall = (adapter.urql.useQuery as jest.Mock).mock.calls[0]; + expect(useQueryCall).toBeDefined(); + expect(useQueryCall[0]).toMatchObject({ live: true }); + }); +}); diff --git a/packages/client-hooks/spec/useFindOne.spec.ts b/packages/client-hooks/spec/useFindOne.spec.ts new file mode 100644 index 000000000..c95837286 --- /dev/null +++ b/packages/client-hooks/spec/useFindOne.spec.ts @@ -0,0 +1,133 @@ +import { jest } from "@jest/globals"; +import { createHooks } from "../src/createHooks.js"; +import { useFindOne } from "../src/useFindOne.js"; +import { createMockAdapter, createMockApiClient, createMockConnection } from "./mockAdapter.js"; + +describe("useFindOne", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the hook correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findOne: { + type: "findOne", + operationName: "user", + modelApiIdentifier: "user", + defaultSelection: { id: true, email: true }, + namespace: [], + }, + }; + + connection.findOneOperation = jest.fn(() => ({ + query: "query user($id: ID!) { user(id: $id) { id email } }", + variables: { id: "123" }, + })); + + const [state, refetch] = useFindOne(mockManager, "123"); + + expect(state.fetching).toBe(false); + expect(state.data).toBeUndefined(); + expect(state.error).toBeUndefined(); + expect(typeof refetch).toBe("function"); + + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.urql.useQuery).toHaveBeenCalled(); + }); + + it("should call connection.findOneOperation with correct parameters", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findOne: { + type: "findOne", + operationName: "user", + modelApiIdentifier: "user", + defaultSelection: { id: true, email: true }, + namespace: [], + }, + }; + + const mockOptions = { select: { id: true } }; + + connection.findOneOperation = jest.fn(() => ({ + query: "query user($id: ID!) { user(id: $id) { id } }", + variables: { id: "456" }, + })); + + useFindOne(mockManager, "456", mockOptions); + + expect(connection.findOneOperation).toHaveBeenCalledWith("user", "456", { id: true, email: true }, "user", mockOptions, []); + }); + + it("should handle namespaced models", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findOne: { + type: "findOne", + operationName: "player", + modelApiIdentifier: "game.player", + defaultSelection: { id: true, name: true }, + namespace: ["game"], + }, + }; + + connection.findOneOperation = jest.fn(() => ({ + query: "query player($id: ID!) { game { player(id: $id) { id name } } }", + variables: { id: "123" }, + })); + + useFindOne(mockManager, "123"); + + expect(connection.findOneOperation).toHaveBeenCalledWith("player", "123", { id: true, name: true }, "game.player", undefined, ["game"]); + }); + + it("should pass pause option to useQuery", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findOne: { + type: "findOne", + operationName: "user", + modelApiIdentifier: "user", + defaultSelection: { id: true }, + namespace: [], + }, + }; + + connection.findOneOperation = jest.fn(() => ({ + query: "query user($id: ID!) { user(id: $id) { id } }", + variables: { id: "123" }, + })); + + useFindOne(mockManager, "123", { pause: true }); + + // Verify useQuery was called with pause option + const useQueryCall = (adapter.urql.useQuery as jest.Mock).mock.calls[0]; + expect(useQueryCall).toBeDefined(); + expect(useQueryCall[0]).toMatchObject({ pause: true }); + }); +}); diff --git a/packages/client-hooks/spec/useGet.spec.ts b/packages/client-hooks/spec/useGet.spec.ts new file mode 100644 index 000000000..712f98af0 --- /dev/null +++ b/packages/client-hooks/spec/useGet.spec.ts @@ -0,0 +1,171 @@ +import { jest } from "@jest/globals"; +import { createHooks } from "../src/createHooks.js"; +import { useGet } from "../src/useGet.js"; +import { createMockAdapter, createMockApiClient, createMockConnection } from "./mockAdapter.js"; + +describe("useGet", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the hook correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + get: { + type: "get", + operationName: "currentSession", + modelApiIdentifier: "session", + defaultSelection: { id: true, userId: true }, + namespace: [], + }, + }; + + connection.getOperation = jest.fn(() => ({ + query: "query currentSession { currentSession { id userId } }", + variables: {}, + })); + + const [state, refetch] = useGet(mockManager); + + expect(state.fetching).toBe(false); + expect(state.data).toBeUndefined(); + expect(state.error).toBeUndefined(); + expect(typeof refetch).toBe("function"); + + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.urql.useQuery).toHaveBeenCalled(); + }); + + it("should call connection.getOperation with correct parameters", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + get: { + type: "get", + operationName: "currentSession", + modelApiIdentifier: "session", + defaultSelection: { id: true, userId: true, createdAt: true }, + namespace: [], + }, + }; + + const mockOptions = { select: { id: true, userId: true } }; + + connection.getOperation = jest.fn(() => ({ + query: "query currentSession { currentSession { id userId } }", + variables: {}, + })); + + useGet(mockManager, mockOptions); + + expect(connection.getOperation).toHaveBeenCalledWith( + "currentSession", + { id: true, userId: true, createdAt: true }, + "session", + mockOptions, + [] + ); + }); + + it("should handle namespaced models", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + get: { + type: "get", + operationName: "currentPlayer", + modelApiIdentifier: "game.player", + defaultSelection: { id: true, name: true }, + namespace: ["game"], + }, + }; + + connection.getOperation = jest.fn(() => ({ + query: "query currentPlayer { game { currentPlayer { id name } } }", + variables: {}, + })); + + useGet(mockManager); + + expect(connection.getOperation).toHaveBeenCalledWith("currentPlayer", { id: true, name: true }, "game.player", undefined, ["game"]); + }); + + it("should pass query options correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + get: { + type: "get", + operationName: "currentSession", + modelApiIdentifier: "session", + defaultSelection: { id: true }, + namespace: [], + }, + }; + + connection.getOperation = jest.fn(() => ({ + query: "query currentSession { currentSession { id } }", + variables: {}, + })); + + useGet(mockManager, { pause: true, requestPolicy: "network-only" }); + + const useQueryCall = (adapter.urql.useQuery as jest.Mock).mock.calls[0]; + expect(useQueryCall).toBeDefined(); + expect(useQueryCall[0]).toMatchObject({ + pause: true, + requestPolicy: "network-only", + }); + }); + + it("should support suspense mode", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + get: { + type: "get", + operationName: "currentSession", + modelApiIdentifier: "session", + defaultSelection: { id: true }, + namespace: [], + }, + }; + + connection.getOperation = jest.fn(() => ({ + query: "query currentSession { currentSession { id } }", + variables: {}, + })); + + useGet(mockManager, { suspense: true }); + + const useQueryCall = (adapter.urql.useQuery as jest.Mock).mock.calls[0]; + expect(useQueryCall).toBeDefined(); + expect(useQueryCall[0]).toMatchObject({ suspense: true }); + }); +}); diff --git a/packages/client-hooks/spec/useGlobalAction.spec.ts b/packages/client-hooks/spec/useGlobalAction.spec.ts new file mode 100644 index 000000000..9e89cec60 --- /dev/null +++ b/packages/client-hooks/spec/useGlobalAction.spec.ts @@ -0,0 +1,184 @@ +import { jest } from "@jest/globals"; +import { createHooks } from "../src/createHooks.js"; +import { useGlobalAction } from "../src/useGlobalAction.js"; +import { createMockAdapter, createMockApiClient, createMockConnection } from "./mockAdapter.js"; + +describe("useGlobalAction", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the hook correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockGlobalAction: any = { + type: "globalAction", + operationName: "flipAll", + namespace: [], + modelApiIdentifier: undefined, + modelSelectionField: "result", + defaultSelection: undefined, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: true, + isBulk: false, + }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation flipAll { flipAll { success result } }", + variables: {}, + })); + + const [state, mutate] = useGlobalAction(mockGlobalAction); + + expect(state.fetching).toBe(false); + expect(state.data).toBeUndefined(); + expect(state.error).toBeUndefined(); + expect(typeof mutate).toBe("function"); + + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.urql.useMutation).toHaveBeenCalled(); + }); + + it("should call connection.actionOperation with hasReturnType=true", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockGlobalAction: any = { + type: "globalAction", + operationName: "calculateTotal", + namespace: [], + modelApiIdentifier: undefined, + modelSelectionField: "result", + defaultSelection: undefined, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: true, + isBulk: false, + }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation calculateTotal { calculateTotal { success result } }", + variables: {}, + })); + + useGlobalAction(mockGlobalAction); + + expect(connection.actionOperation).toHaveBeenCalledWith( + "calculateTotal", + undefined, + undefined, + "result", + {}, + undefined, + [], + false, + true + ); + }); + + it("should handle namespaced global actions", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockGlobalAction: any = { + type: "globalAction", + operationName: "calculateScore", + namespace: ["game"], + modelApiIdentifier: undefined, + modelSelectionField: "result", + defaultSelection: undefined, + selectionType: {}, + optionsType: {}, + schemaType: {}, + variablesType: {}, + variables: {}, + hasReturnType: true, + isBulk: false, + }; + + connection.actionOperation = jest.fn(() => ({ + query: "mutation calculateScore { game { calculateScore { success result } } }", + variables: {}, + })); + + useGlobalAction(mockGlobalAction); + + expect(connection.actionOperation).toHaveBeenCalledWith( + "calculateScore", + undefined, + undefined, + "result", + {}, + undefined, + ["game"], + false, + true + ); + }); + + it("should dispatch stubbed action event for global actions", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockStubbedGlobalAction: any = { + type: "stubbedAction", + reason: "MissingApiTrigger", + dataPath: "fakePath", + functionName: "fakeGlobalAction", + actionApiIdentifier: "fakeGlobalAction", + variables: {}, + }; + + let eventDispatched: CustomEvent | undefined; + globalThis.addEventListener("gadget:devharness:stubbedActionError", (event) => { + eventDispatched = event as CustomEvent; + }); + + connection.actionOperation = jest.fn(() => ({ + query: "mutation fake { fake { success } }", + variables: {}, + })); + + useGlobalAction(mockStubbedGlobalAction); + + // Call the effect function to simulate it running + const effectCall = (adapter.framework.useEffect as jest.Mock).mock.calls[0]; + if (effectCall && effectCall[0]) { + effectCall[0](); + } + + expect(eventDispatched).toBeTruthy(); + expect(eventDispatched?.detail).toEqual({ + reason: "MissingApiTrigger", + action: { + functionName: "fakeGlobalAction", + actionApiIdentifier: "fakeGlobalAction", + dataPath: "fakePath", + }, + }); + }); +}); diff --git a/packages/client-hooks/spec/useMaybeFindFirst.spec.ts b/packages/client-hooks/spec/useMaybeFindFirst.spec.ts new file mode 100644 index 000000000..ac1c57d33 --- /dev/null +++ b/packages/client-hooks/spec/useMaybeFindFirst.spec.ts @@ -0,0 +1,149 @@ +import { jest } from "@jest/globals"; +import { createHooks } from "../src/createHooks.js"; +import { useMaybeFindFirst } from "../src/useMaybeFindFirst.js"; +import { createMockAdapter, createMockApiClient, createMockConnection } from "./mockAdapter.js"; + +describe("useMaybeFindFirst", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the hook correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findFirst: { + type: "findFirst", + operationName: "users", + modelApiIdentifier: "user", + defaultSelection: { id: true, email: true }, + namespace: [], + }, + }; + + connection.findFirstOperation = jest.fn(() => ({ + query: "query users { users(first: 1) { edges { node { id email } } } }", + variables: {}, + })); + + const [state, refetch] = useMaybeFindFirst(mockManager); + + expect(state.fetching).toBe(false); + expect(state.data).toBeUndefined(); + expect(state.error).toBeUndefined(); + expect(typeof refetch).toBe("function"); + + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.urql.useQuery).toHaveBeenCalled(); + }); + + it("should not throw error when no data is found", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findFirst: { + type: "findFirst", + operationName: "users", + modelApiIdentifier: "user", + defaultSelection: { id: true }, + namespace: [], + }, + }; + + connection.findFirstOperation = jest.fn(() => ({ + query: "query users { users(first: 1) { edges { node { id } } } }", + variables: {}, + })); + + // Mock useQuery to return empty result + adapter.urql.useQuery = jest.fn(() => [ + { + fetching: false, + stale: false, + data: { users: { edges: [] } }, + error: undefined, + }, + jest.fn(), + ]) as any; + + const [state] = useMaybeFindFirst(mockManager); + + // Should not throw error, data should be null/undefined + expect(state.error).toBeUndefined(); + }); + + it("should handle namespaced models", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findFirst: { + type: "findFirst", + operationName: "players", + modelApiIdentifier: "game.player", + defaultSelection: { id: true, name: true }, + namespace: ["game"], + }, + }; + + connection.findFirstOperation = jest.fn(() => ({ + query: "query players { game { players(first: 1) { edges { node { id name } } } } }", + variables: {}, + })); + + useMaybeFindFirst(mockManager); + + expect(connection.findFirstOperation).toHaveBeenCalledWith("players", { id: true, name: true }, "game.player", undefined, ["game"]); + }); + + it("should pass options correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findFirst: { + type: "findFirst", + operationName: "users", + modelApiIdentifier: "user", + defaultSelection: { id: true }, + namespace: [], + }, + }; + + connection.findFirstOperation = jest.fn(() => ({ + query: "query users { users(first: 1) { edges { node { id } } } }", + variables: {}, + })); + + useMaybeFindFirst(mockManager, { + filter: { email: { equals: "test@test.com" } }, + pause: true, + }); + + expect(connection.findFirstOperation).toHaveBeenCalledWith( + "users", + { id: true }, + "user", + { filter: { email: { equals: "test@test.com" } }, pause: true }, + [] + ); + }); +}); diff --git a/packages/client-hooks/spec/useMaybeFindOne.spec.ts b/packages/client-hooks/spec/useMaybeFindOne.spec.ts new file mode 100644 index 000000000..0c6b8f100 --- /dev/null +++ b/packages/client-hooks/spec/useMaybeFindOne.spec.ts @@ -0,0 +1,148 @@ +import { jest } from "@jest/globals"; +import { createHooks } from "../src/createHooks.js"; +import { useMaybeFindOne } from "../src/useMaybeFindOne.js"; +import { createMockAdapter, createMockApiClient, createMockConnection } from "./mockAdapter.js"; + +describe("useMaybeFindOne", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the hook correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findOne: { + type: "findOne", + operationName: "user", + modelApiIdentifier: "user", + defaultSelection: { id: true, email: true }, + namespace: [], + }, + }; + + connection.findOneOperation = jest.fn(() => ({ + query: "query user($id: ID!) { user(id: $id) { id email } }", + variables: { id: "123" }, + })); + + const [state, refetch] = useMaybeFindOne(mockManager, "123"); + + expect(state.fetching).toBe(false); + expect(state.data).toBeUndefined(); + expect(state.error).toBeUndefined(); + expect(typeof refetch).toBe("function"); + + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.urql.useQuery).toHaveBeenCalled(); + }); + + it("should not throw error when data is null", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findOne: { + type: "findOne", + operationName: "user", + modelApiIdentifier: "user", + defaultSelection: { id: true, email: true }, + namespace: [], + }, + }; + + connection.findOneOperation = jest.fn(() => ({ + query: "query user($id: ID!) { user(id: $id) { id email } }", + variables: { id: "123" }, + })); + + // Mock useQuery to return data: null + adapter.urql.useQuery = jest.fn(() => [ + { + fetching: false, + stale: false, + data: { user: null }, + error: undefined, + }, + jest.fn(), + ]) as any; + + const [state] = useMaybeFindOne(mockManager, "123"); + + // Should not throw error, data should be null/undefined + expect(state.error).toBeUndefined(); + expect(state.data).toBeNull(); + }); + + it("should handle namespaced models", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findOne: { + type: "findOne", + operationName: "player", + modelApiIdentifier: "game.player", + defaultSelection: { id: true, name: true }, + namespace: ["game"], + }, + }; + + connection.findOneOperation = jest.fn(() => ({ + query: "query player($id: ID!) { game { player(id: $id) { id name } } }", + variables: { id: "123" }, + })); + + useMaybeFindOne(mockManager, "123"); + + expect(connection.findOneOperation).toHaveBeenCalledWith("player", "123", { id: true, name: true }, "game.player", undefined, ["game"]); + }); + + it("should pass options correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockManager: any = { + findOne: { + type: "findOne", + operationName: "user", + modelApiIdentifier: "user", + defaultSelection: { id: true, email: true }, + namespace: [], + }, + }; + + connection.findOneOperation = jest.fn(() => ({ + query: "query user($id: ID!) { user(id: $id) { id } }", + variables: { id: "123" }, + })); + + useMaybeFindOne(mockManager, "123", { select: { id: true }, pause: true }); + + expect(connection.findOneOperation).toHaveBeenCalledWith( + "user", + "123", + { id: true, email: true }, + "user", + { select: { id: true }, pause: true }, + [] + ); + }); +}); diff --git a/packages/client-hooks/spec/useView.spec.ts b/packages/client-hooks/spec/useView.spec.ts new file mode 100644 index 000000000..b9ef483bd --- /dev/null +++ b/packages/client-hooks/spec/useView.spec.ts @@ -0,0 +1,215 @@ +import { jest } from "@jest/globals"; +import { createHooks } from "../src/createHooks.js"; +import { useView } from "../src/useView.js"; +import { createMockAdapter, createMockApiClient, createMockConnection } from "./mockAdapter.js"; + +describe("useView", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should initialize the hook correctly for view without variables", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockView: any = { + type: "view", + operationName: "leaderboard", + hasVariables: false, + namespace: [], + }; + + connection.viewOperation = jest.fn(() => ({ + query: "query leaderboard { leaderboard { name score } }", + variables: {}, + })); + + const [state, refetch] = useView(mockView); + + expect(state.fetching).toBe(false); + expect(state.data).toBeUndefined(); + expect(state.error).toBeUndefined(); + expect(typeof refetch).toBe("function"); + + expect(adapter.framework.useMemo).toHaveBeenCalled(); + expect(adapter.urql.useQuery).toHaveBeenCalled(); + }); + + it("should initialize the hook correctly for view with variables", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockView: any = { + type: "view", + operationName: "topScores", + hasVariables: true, + namespace: [], + }; + + const variables = { limit: 10, minScore: 100 }; + + connection.viewOperation = jest.fn(() => ({ + query: "query topScores($limit: Int!, $minScore: Int!) { topScores(limit: $limit, minScore: $minScore) { name score } }", + variables, + })); + + const [state, refetch] = useView(mockView, variables); + + expect(state.fetching).toBe(false); + expect(state.data).toBeUndefined(); + expect(state.error).toBeUndefined(); + expect(typeof refetch).toBe("function"); + + expect(connection.viewOperation).toHaveBeenCalledWith("topScores", variables, undefined, []); + }); + + it("should handle inline gelly query strings", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const gellyQuery = "{ count(todos) }"; + const variables = { filter: { completed: true } }; + + connection.viewOperation = jest.fn(() => ({ + query: "query InlineView($filter: JSON) { view(filter: $filter) }", + variables, + })); + + useView(gellyQuery, variables); + + expect(connection.viewOperation).toHaveBeenCalledWith(gellyQuery, variables, undefined, []); + }); + + it("should handle namespaced views", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockView: any = { + type: "view", + operationName: "playerStats", + hasVariables: false, + namespace: ["game"], + }; + + connection.viewOperation = jest.fn(() => ({ + query: "query playerStats { game { playerStats { name totalScore } } }", + variables: {}, + })); + + useView(mockView); + + expect(connection.viewOperation).toHaveBeenCalledWith("playerStats", undefined, undefined, ["game"]); + }); + + it("should pass query options correctly", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockView: any = { + type: "view", + operationName: "summary", + hasVariables: false, + namespace: [], + }; + + connection.viewOperation = jest.fn(() => ({ + query: "query summary { summary { total } }", + variables: {}, + })); + + useView(mockView, { pause: true, requestPolicy: "network-only" }); + + const useQueryCall = (adapter.urql.useQuery as jest.Mock).mock.calls[0]; + expect(useQueryCall).toBeDefined(); + expect(useQueryCall[0]).toMatchObject({ + pause: true, + requestPolicy: "network-only", + }); + }); + + it("should support views with complex variable types", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockView: any = { + type: "view", + operationName: "filteredData", + hasVariables: true, + namespace: [], + }; + + const complexVariables = { + filters: { + startDate: "2024-01-01", + endDate: "2024-12-31", + categories: ["A", "B", "C"], + }, + sorting: { field: "createdAt", direction: "DESC" }, + pagination: { first: 20, after: "cursor123" }, + }; + + connection.viewOperation = jest.fn(() => ({ + query: + "query filteredData($filters: FilterInput, $sorting: SortInput, $pagination: PaginationInput) { filteredData(filters: $filters, sorting: $sorting, pagination: $pagination) { results } }", + variables: complexVariables, + })); + + useView(mockView, complexVariables); + + expect(connection.viewOperation).toHaveBeenCalledWith("filteredData", complexVariables, undefined, []); + }); + + it("should handle options for view with variables", () => { + const connection = createMockConnection(); + const api = createMockApiClient(); + api.connection = connection; + const adapter = createMockAdapter(api, connection); + + createHooks(adapter); + + const mockView: any = { + type: "view", + operationName: "report", + hasVariables: true, + namespace: [], + }; + + const variables = { year: 2024 }; + const options = { suspense: true }; + + connection.viewOperation = jest.fn(() => ({ + query: "query report($year: Int!) { report(year: $year) { data } }", + variables, + })); + + useView(mockView, variables, options); + + const useQueryCall = (adapter.urql.useQuery as jest.Mock).mock.calls[0]; + expect(useQueryCall).toBeDefined(); + expect(useQueryCall[0]).toMatchObject({ suspense: true }); + }); +}); diff --git a/packages/client-hooks/src/adapter.ts b/packages/client-hooks/src/adapter.ts new file mode 100644 index 000000000..f1edde9ae --- /dev/null +++ b/packages/client-hooks/src/adapter.ts @@ -0,0 +1,97 @@ +import type { AnyClient, AnyConnection } from "@gadgetinc/core"; +import type { + AnyVariables, + Client, + CombinedError, + DocumentInput, + GraphQLRequestParams, + Operation, + OperationContext, + OperationResult, + RequestPolicy, +} from "@urql/core"; + +type Dispose = void | (() => void); +type AnyActionArg = [] | [any]; +type ActionDispatch = (...args: ActionArg) => void; + +type Context<_T> = { + Provider: unknown; + Consumer: unknown; +}; + +interface FrameworkBindings { + deepEqual: (a: A, b: B) => boolean; + useEffect: (fn: () => Dispose, deps?: any[]) => void; + useMemo: (factory: () => T, deps: any[]) => T; + useRef: (initial: T) => { current: T }; + useState: (initial: T | (() => T)) => [T, (next: T) => void]; + useContext: (ctx: any) => T; + createContext: (defaultValue: T) => Context; + useCallback: any>(fn: T, deps: any[]) => T; + useReducer: ( + reducer: (prevState: S, ...args: A) => S, + initialArg: I, + init?: (i: I) => S + ) => [S, ActionDispatch]; + Fragment: unknown; +} + +export type UseQueryArgs = { + requestPolicy?: RequestPolicy; + context?: Partial; + pause?: boolean; +} & GraphQLRequestParams; + +export interface UseQueryState { + fetching: boolean; + stale: boolean; + data?: Data; + error?: CombinedError; + extensions?: Record; + operation?: Operation; +} + +type UseQueryExecute = (opts?: Partial) => void; + +export type UseQueryResponse = [UseQueryState, UseQueryExecute]; + +export interface UseMutationState { + fetching: boolean; + stale: boolean; + data?: Data; + error?: CombinedError; + extensions?: Record; + operation?: Operation; +} + +type UseMutationExecute = ( + variables: Variables, + context?: Partial +) => Promise>; + +export type UseMutationResponse = [ + UseMutationState, + UseMutationExecute +]; + +interface UrqlBindings { + Provider: (props: { client: Client; children: any }) => any; + useQuery: ( + args: UseQueryArgs + ) => UseQueryResponse; + useMutation: ( + query: DocumentInput + ) => UseMutationResponse; +} + +export interface GadgetApiContext { + api: AnyClient; + connection: AnyConnection; +} + +export interface RuntimeAdapter { + GadgetApiContext: Context; + framework: FrameworkBindings; + urql: UrqlBindings; +} diff --git a/packages/client-hooks/src/createHooks.ts b/packages/client-hooks/src/createHooks.ts new file mode 100644 index 000000000..8f5ce4ca1 --- /dev/null +++ b/packages/client-hooks/src/createHooks.ts @@ -0,0 +1,119 @@ +import type { AnyVariables, DocumentInput } from "@urql/core"; +import type { GadgetApiContext, RuntimeAdapter } from "./adapter.js"; +import { CoreHooks, UseApi, UseConnection, UseGadgetMutation, UseGadgetQuery, UseGadgetQueryArgs, type QueryOptions } from "./types.js"; +import { noProviderErrorMessage } from "./utils.js"; + +const RegisteredHooks: ((adapter: RuntimeAdapter, coreHooks: CoreHooks) => void)[] = []; + +export const createHookStub = (hook: string, registerFn?: (adapter: RuntimeAdapter, coreHooks: CoreHooks) => void) => { + if (registerFn) { + RegisteredHooks.push(registerFn); + } + return () => { + throw new Error( + `You are attempting to use the ${hook} hook, but you are not calling it from a component that is wrapped in a Gadget component. Please ensure you are wrapping this hook with the component from either @gadgetinc/react or @gadgetinc/preact.` + ); + }; +}; + +export let useApi: UseApi = createHookStub("useApi"); +export let useConnection: UseConnection = createHookStub("useConnection"); +export let useQuery: UseGadgetQuery = createHookStub("useQuery"); +export let useMutation: UseGadgetMutation = createHookStub("useMutation"); + +export const createHooks = (adapter: RuntimeAdapter) => { + const coreHooks = createCoreHooks(adapter); + + useQuery = coreHooks.useGadgetQuery; + useMutation = coreHooks.useGadgetMutation; + useApi = coreHooks.useApi; + useConnection = coreHooks.useConnection; + + for (const registration of RegisteredHooks) { + registration(adapter, coreHooks); + } +}; + +const createCoreHooks = (adapter: RuntimeAdapter): CoreHooks => { + const { GadgetApiContext } = adapter; + + const useConnection: UseConnection = () => { + const { connection } = adapter.framework.useContext(GadgetApiContext); + + if (!connection) { + throw new Error( + `urql client found in context was not set up by the Gadget API client. Please ensure you are wrapping this hook with the component from either @gadgetinc/react or @gadgetinc/preact. + + Possible remedies: + - ensuring you have the component wrapped around your hook invocation + - ensuring you are passing a value to the provider, usually + - ensuring your @gadget-client/ package and your @gadgetinc/react or @gadgetinc/preact package are up to date` + ); + } + + return connection; + }; + + const useApi: UseApi = () => { + const { api } = adapter.framework.useContext(GadgetApiContext); + + if (!api) { + throw new Error( + `useApi hook called in context where no Gadget API client is available. Please ensure you are wrapping this hook with the component from @gadgetinc/react. + + Possible remedies: + - ensuring you have the component wrapped around your hook invocation + - ensuring you are passing an api client instance to the provider, usually + - ensuring your @gadget-client/ package and your @gadgetinc/react package are up to date` + ); + } + + return api; + }; + + const useMemoizedQueryOptions = (options?: Options): Options => { + const { context: _context, suspense: _suspense, ...rest } = options ?? {}; + + // use a memo as urql rerenders on context identity changes + const context = adapter.framework.useMemo(() => { + return { + suspense: !!options?.suspense, + ...options?.context, + }; + }, [options?.suspense, options?.context]); + + return { + ...rest, + context, + } as unknown as Options; + }; + + const useStructuralMemo = (value: T) => { + const ref = adapter.framework.useRef(value); + + if (!adapter.framework.deepEqual(value, ref.current)) { + ref.current = value; + } + + return ref.current; + }; + + const useGadgetQuery = (args: UseGadgetQueryArgs) => { + if (!adapter.framework.useContext(GadgetApiContext)) throw new Error(noProviderErrorMessage); + const options = useMemoizedQueryOptions(args); + return adapter.urql.useQuery(options); + }; + + const useGadgetMutation = (query: DocumentInput) => { + if (!adapter.framework.useContext(GadgetApiContext)) throw new Error(noProviderErrorMessage); + return adapter.urql.useMutation(query); + }; + + return { + useConnection, + useApi, + useStructuralMemo, + useGadgetQuery, + useGadgetMutation, + }; +}; diff --git a/packages/client-hooks/src/index.ts b/packages/client-hooks/src/index.ts new file mode 100644 index 000000000..b2998faf1 --- /dev/null +++ b/packages/client-hooks/src/index.ts @@ -0,0 +1,17 @@ +export * from "./adapter.js"; +export { useApi, useConnection, useMutation, useQuery } from "./createHooks.js"; +export { registerClientHooks } from "./provider.js"; +export * from "./types.js"; +export { useAction } from "./useAction.js"; +export { useBulkAction } from "./useBulkAction.js"; +export { useEnqueue } from "./useEnqueue.js"; +export { useFetch } from "./useFetch.js"; +export { useFindBy } from "./useFindBy.js"; +export { useFindFirst } from "./useFindFirst.js"; +export { useFindMany } from "./useFindMany.js"; +export { useFindOne } from "./useFindOne.js"; +export { useGet } from "./useGet.js"; +export { useGlobalAction } from "./useGlobalAction.js"; +export { useMaybeFindFirst } from "./useMaybeFindFirst.js"; +export { useMaybeFindOne } from "./useMaybeFindOne.js"; +export { useView } from "./useView.js"; diff --git a/packages/client-hooks/src/provider.ts b/packages/client-hooks/src/provider.ts new file mode 100644 index 000000000..bae288899 --- /dev/null +++ b/packages/client-hooks/src/provider.ts @@ -0,0 +1,36 @@ +import type { AnyClient, AnyConnection } from "@gadgetinc/core"; +import type { Client as UrqlClient } from "@urql/core"; +import { RuntimeAdapter } from "./adapter.js"; +import { createHooks } from "./createHooks.js"; + +const isGadgetClient = (client: any): client is AnyClient => { + return client && "connection" in client && client.connection && "endpoint" in client.connection; +}; + +export const registerClientHooks = (api: AnyClient, adapter: RuntimeAdapter) => { + let gadgetClient: AnyClient; + let gadgetConnection: AnyConnection; + let urqlClient: UrqlClient; + + if (!api) { + throw new Error( + "No Gadget API client passed to component -- please pass an instance of your generated client, like !" + ); + } + + if (!isGadgetClient(api)) { + throw new Error( + "Invalid Gadget API client passed to component -- please pass an instance of your generated client, like !" + ); + } + gadgetClient = api; + urqlClient = api.connection.currentClient; + gadgetConnection = api.connection; + createHooks(adapter); + + return { + gadgetClient, + gadgetConnection, + urqlClient, + }; +}; diff --git a/packages/client-hooks/src/types.ts b/packages/client-hooks/src/types.ts new file mode 100644 index 000000000..5f28885e1 --- /dev/null +++ b/packages/client-hooks/src/types.ts @@ -0,0 +1,800 @@ +import type { + ActionFunction, + AnyActionFunction, + AnyBackgroundActionHandle, + AnyBulkActionFunction, + AnyClient, + AnyConnection, + BulkActionFunction, + DefaultSelection, + EnqueueBackgroundActionOptions, + ErrorWrapper, + FieldSelection, + FindFirstFunction, + FindManyFunction, + FindOneFunction, + GadgetRecord, + GadgetRecordList, + GetFunction, + GlobalActionFunction, + LimitToKnownKeys, + Select, + ViewFunction, + ViewFunctionWithVariables, + ViewFunctionWithoutVariables, + ViewResult, +} from "@gadgetinc/core"; +import type { AnyVariables, DocumentInput, Operation, OperationContext, RequestPolicy } from "@urql/core"; +import type { UseMutationResponse, UseQueryArgs, UseQueryResponse } from "./adapter.js"; + +export interface QueryOptions { + context?: Partial; + pause?: boolean; + requestPolicy?: RequestPolicy; + suspense?: boolean; +} + +/** + * All the options controlling how this query will be managed by urql + * */ +export declare type ReadOperationOptions = { + /** Updates the {@link RequestPolicy} for the executed GraphQL query operation. + * + * @remarks + * `requestPolicy` modifies the {@link RequestPolicy} of the GraphQL query operation + * that `useQuery` executes, and indicates a caching strategy for cache exchanges. + * + * For example, when set to `'cache-and-network'`, {@link useQuery} will + * receive a cached result with `stale: true` and an API request will be + * sent in the background. + * + * @see {@link OperationContext.requestPolicy} for where this value is set. + */ + requestPolicy?: RequestPolicy; + /** Updates the {@link OperationContext} for the executed GraphQL query operation. + * + * @remarks + * `context` may be passed to {@link useQuery}, to update the {@link OperationContext} + * of a query operation. This may be used to update the `context` that exchanges + * will receive for a single hook. + * + * Hint: This should be wrapped in a `useMemo` hook, to make sure that your + * component doesn’t infinitely update. + * + * @example + * ```ts + * const [result, reexecute] = useQuery({ + * query, + * context: useMemo(() => ({ + * additionalTypenames: ['Item'], + * }), []) + * }); + * ``` + */ + context?: Partial; + /** Prevents {@link useQuery} from automatically executing GraphQL query operations. + * + * @remarks + * `pause` may be set to `true` to stop {@link useQuery} from executing + * automatically. The hook will stop receiving updates from the {@link Client} + * and won’t execute the query operation, until either it’s set to `false` + * or the {@link UseQueryExecute} function is called. + * + * @see {@link https://urql.dev/goto/docs/basics/react-preact/#pausing-usequery} for + * documentation on the `pause` option. + */ + pause?: boolean; + /** + * Marks this query as one that should suspend the react component rendering while executing, instead of returning `{fetching: true}` to the caller. + * Useful if you want to allow components higher in the tree to show spinners instead of having every component manage its own loading state. + */ + suspense?: boolean; + /** + * Marks this query as a live query that will subscribe to changes from the backend and re-render when backend data changes with the newest data. + */ + live?: boolean; +}; + +export type OptionsType = { + [key: string]: any; + /** What fields to select from the resulting object */ + select?: FieldSelection; + /** Subscribe to changes from the backend and return a new result as it changes */ + live?: boolean; +}; + +/** + * The inner result object returned from a query result + **/ +export interface ReadHookState> { + fetching: boolean; + stale: boolean; + data?: Data; + error?: ErrorWrapper; + extensions?: Record; + operation?: Operation; +} + +/** + * The return value of a `useGet`, `useFindMany`, `useFindOne` etc hook. + * Includes the data result object and a refetch function. + **/ +export declare type ReadHookResult = [ + ReadHookState, + (opts?: Partial) => void +]; + +export type RequiredKeysOf = Exclude< + { + [Key in keyof BaseType]: BaseType extends Record ? Key : never; + }[keyof BaseType], + undefined +>; + +/** + * The inner result object returned from a mutation result + */ +export interface ActionHookState> { + fetching: boolean; + stale: boolean; + data?: Data; + error?: ErrorWrapper; + extensions?: Record; + operation?: Operation; +} + +/** + * The return value of a `useAction`, `useGlobalAction`, `useBulkAction` etc hook. + * Includes the data result object and a function for running the mutation. + **/ +export type ActionHookResult = RequiredKeysOf extends never + ? ActionHookResultWithOptionalCallbackVariables + : ActionHookResultWithRequiredCallbackVariables; + +export type ActionHookResultWithOptionalCallbackVariables = [ + ActionHookState, + (variables?: Variables, context?: Partial) => Promise> +]; + +export type ActionHookResultWithRequiredCallbackVariables = [ + ActionHookState, + (variables: Variables, context?: Partial) => Promise> +]; + +export type UseGadgetQueryArgs = UseQueryArgs & { + /** + * Marks this query as one that should suspend the react component rendering while executing, instead of returning `{fetching: true}` to the caller. + * Useful if you want to allow components higher in the tree to show spinners instead of having every component manage its own loading state. + */ + suspense?: boolean; +}; + +/** + * Get the current `GadgetConnection` object from context. + * Must be called within a component wrapped by ``. + **/ +export type UseConnection = () => AnyConnection; +/** + * Get the current `api` object from context + * Must be called within a component wrapped by the `` component. + **/ +export type UseApi = () => AnyClient; +/** + * Memoize and ensure a stable identity on a given value as long as it remains the same, structurally. + */ +export type UseStructuralMemo = (value: T) => T; +export type UseGadgetQuery = ( + args: UseGadgetQueryArgs +) => UseQueryResponse; +export type UseGadgetMutation = ( + query: DocumentInput +) => UseMutationResponse; + +export type CoreHooks = { + useConnection: UseConnection; + useApi: UseApi; + useStructuralMemo: UseStructuralMemo; + useGadgetQuery: UseGadgetQuery; + useGadgetMutation: UseGadgetMutation; +}; + +/** + * The inner result object returned from a mutation result + */ +export type EnqueueHookState = Action extends AnyBulkActionFunction + ? { + fetching: boolean; + stale: boolean; + handles: AnyBackgroundActionHandle[] | null; + error?: ErrorWrapper; + extensions?: Record; + operation?: Operation<{ backgroundAction: { id: string } }, Action["variablesType"]>; + } + : { + fetching: boolean; + stale: boolean; + handle: AnyBackgroundActionHandle | null; + error?: ErrorWrapper; + extensions?: Record; + operation?: Operation<{ backgroundAction: { id: string } }, Action["variablesType"]>; + }; + +/** + * The return value of a `useEnqueue` hook. + * Returns a two-element array: + * - the result object, with the keys like `handle`, `fetching`, and `error` + * - and a function for running the enqueue mutation. + **/ +export type EnqueueHookResult = RequiredKeysOf< + Exclude +> extends never + ? [ + EnqueueHookState, + ( + variables?: Action["variablesType"], + backgroundOptions?: EnqueueBackgroundActionOptions, + context?: Partial + ) => Promise> + ] + : [ + EnqueueHookState, + ( + variables: Action["variablesType"], + backgroundOptions?: EnqueueBackgroundActionOptions, + context?: Partial + ) => Promise> + ]; + +/** + * Hook to run a Gadget model action. `useAction` must be passed an action function from an instance of your generated API client library, like `api.user.create` or `api.blogPost.publish`. `useAction` doesn't actually run the action when invoked, but instead returns an action function as the second result for running the action in response to an event. + * + * @param action an action function from a model manager in your application's client, like `api.user.create` + * @param options action options, like selecting the fields in the result + * + * @example + * export function CreateUserButton(props: { name: string; email: string }) { + * const [{error, fetching, data}, createUser] = useAction(api.user.create, { + * select: { + * id: true, + * }, + * }); + * + * const onClick = () => createUser({ + * name: props.name, + * email: props.email, + * }); + * + * return ( + * <> + * {error && <>Failed to create user: {error.toString()}} + * {fetching && <>Creating user...} + * {data && <>Created user with id={data.id}} + * + * + * ); + * } + */ +export type UseAction = < + GivenOptions extends OptionsType, + SchemaT, + F extends ActionFunction, + Options extends F["optionsType"] +>( + action: F, + options?: LimitToKnownKeys +) => ActionHookResult< + F["hasReturnType"] extends true + ? any + : GadgetRecord< + Select, DefaultSelection> + >, + Exclude +>; + +/** + * Hook to run a Gadget model bulk action. + * + * @param action any bulk action function from a Gadget manager + * @param options action options, like selecting the fields in the result + * + * @example + * ``` + * export function BulkFinish(props: { ids: string[]; }) { + * const [result, bulkFinish] = useBulkAction(Client.todo.bulkFinish, { + * select: { + * id: true, + * }, + * }); + * + * const onClick = () => ; + * + * return ( + * <> + * {result.error && <>Failed to create user: {result.error.toString()}} + * {result.fetching && <>Creating user...} + * {result.data && <>Finished TODOs with ids={props.ids}} + * + * + * ); + * } + */ +export type UseBulkAction = < + GivenOptions extends OptionsType, + SchemaT, + F extends BulkActionFunction, + Options extends F["optionsType"] +>( + action: F, + options?: LimitToKnownKeys +) => ActionHookResult< + F["hasReturnType"] extends true + ? any[] + : GadgetRecord< + Select, DefaultSelection> + >[], + Exclude +>; + +/** + * Hook to enqueue a Gadget action in the background. `useEnqueue` must be passed an action function from an instance of your generated API client library, like `useEnqueue(api.user.create)` or `useEnqueue(api.someGlobalAction)`. `useEnqueue` doesn't actually submit the background action when invoked, but instead returns a function for enqueuing the action in response to an event. + * + * @param action a model action or global action in your application's client, like `api.user.create` or `api.someGlobalAction` + * @param options action options, like selecting the fields in the result + * + * @example + * export function CreateUserButton(props: { name: string; email: string }) { + * const [{error, fetching, handle}, enqueue] = useEnqueue(api.user.create)); + * + * const onClick = () => enqueue( + * { + * name: props.name, + * email: props.email, + * }, { + * id: `send-email-action-${props.email}` + * } + * ); + * + * return ( + * <> + * {error && <>Failed to enqueue user create: {error.toString()}} + * {fetching && <>Enqueuing action...} + * {data && <>Enqueued action with background action id={handle.id}} + * + * + * ); + * } + */ +export type UseEnqueue = ( + action: Action, + baseBackgroundOptions?: EnqueueBackgroundActionOptions +) => EnqueueHookResult; + +export interface FetchHookOptions extends RequestInit { + stream?: boolean | string; + json?: boolean; + sendImmediately?: boolean; + onStreamComplete?: (value: string) => void; +} + +export interface FetchHookState { + data?: T; + response?: Response; + error?: ErrorWrapper; + fetching: boolean; + streaming: boolean; + options: FetchHookOptions; +} + +export type FetchHookResult = [FetchHookState, (opts?: Partial) => Promise]; + +/** + * Hook to make an HTTP request to a Gadget backend HTTP route. Preserves client side session information and ensures it's passed along to the backend. + * + * Returns a tuple with the current state of the request and a function to send or re-send the request. The state is an object with the following fields: + * - `data`: the response data, if the request was successful + * - `fetching`: a boolean describing if the fetch request is currently in progress + * - `streaming`: a boolean describing if the fetch request is currently streaming. This is only set when the option `{ stream: "string" }` is passed + * - `error`: an error object if the request failed in any way + * + * The second return value is a function for executing the fetch request. It returns a promise for the response body. + * + * By default, `GET` requests are sent as soon as the hook executes. Any other request methods are not sent automatically, and must be triggered by calling the `execute` function returned in the second argument. + * + * Pass the `{ json: true }` option to expect a JSON response from the server, and to automatically parse the response as JSON. Otherwise, the response will be returned as a `string` object. + * + * Pass the `{ stream: true }` to get a `ReadableStream` object as a response from the server, allowing you to work with the response as it arrives. + * + * Pass the `{ stream: "string" }` to decode the `ReadableStream` as a string and update data as it arrives. If the stream is in an encoding other than utf8 use i.e. `{ stream: "utf-16" }`. + * + * When `{ stream: "string" }` is used, the `streaming` field in the state will be set to `true` while the stream is active, and `false` when the stream is complete. You can use this to show a loading indicator while the stream is active. + * You can also pass an `onStreamComplete` callback that will be called with the value of the streamed string once it has completed. + * + * If you want to read model data, see the `useFindMany` function and similar. If you want to invoke a backend Action, use the `useAction` hook instead. + * + * @param path the backend path to fetch + * @param options the `fetch` options for the request + * + * @example + * ``` + * export function UserByEmail(props: { email: string }) { + * const [{data, fetching, error}, refresh] = useFetch("/users/get", { + * method: "GET", + * body: JSON.stringify({ email: props.email }}) + * headers: { + * "content-type": "application/json", + * } + * json: true, + * }); + * + * if (result.error) return <>Error: {result.error.toString()}; + * if (result.fetching && !result.data) return <>Fetching...; + * if (!result.data) return <>No user found with id={props.id}; + * + * return
{result.data.name}
; + * } + */ +export interface UseFetch { + (path: string, options: { stream: string } & FetchHookOptions): FetchHookResult>; + (path: string, options: { stream: true } & FetchHookOptions): FetchHookResult>; + >(url: string, options: { json: true } & FetchHookOptions): FetchHookResult; + (path: string, options?: FetchHookOptions): FetchHookResult; +} + +/** + * Hook to fetch a Gadget record using the `findByXYZ` method of a given model manager. Useful for finding records by key fields which are used for looking up records by. Gadget autogenerates the `findByXYZ` methods on your model managers, and `useFindBy` can only be used with models that have these generated finder functions. + * + * @param finder `findByXYZ` function from a Gadget manager that will be used + * @param value field value of the record to fetch + * @param options options for selecting the fields in the result + * + * @example + * ``` + * export function UserByEmail(props: { email: string }) { + * const [result, refresh] = useFindBy(api.user.findByEmail, props.email, { + * select: { + * name: true, + * }, + * }); + * + * if (result.error) return <>Error: {result.error.toString()}; + * if (result.fetching && !result.data) return <>Fetching...; + * if (!result.data) return <>No user found with id={props.id}; + * + * return
{result.data.name}
; + * } + */ +export type UseFindBy = < + GivenOptions extends OptionsType, + SchemaT, + F extends FindOneFunction, + Options extends F["optionsType"] & ReadOperationOptions +>( + finder: F, + value: string, + options?: LimitToKnownKeys +) => ReadHookResult< + GadgetRecord, DefaultSelection>> +>; + +/** + * Hook to fetch the first backend record matching a given filter and sort. Returns a standard hook result set with a tuple of the result object with `data`, `fetching`, and `error` keys, and a `refetch` function. `data` will be the first record found if there is one, and null otherwise. + * + * @param manager Gadget model manager to use + * @param options options for filtering and searching records, and selecting the fields in each record of the result + * + * @example + * + * ``` + * export function Users() { + * const [result, refresh] = useFindFirst(api.user, { + * select: { + * name: true, + * }, + * }); + * + * if (result.error) return <>Error: {result.error.toString()}; + * if (result.fetching && !result.data) return <>Fetching...; + * if (!result.data) return <>No user found; + * + * return
{result.data.name}
; + * } + * ``` + */ +export type UseFindFirst = < + GivenOptions extends OptionsType, + SchemaT, + F extends FindFirstFunction, + Options extends F["optionsType"] & ReadOperationOptions +>( + manager: { findFirst: F }, + options?: LimitToKnownKeys +) => ReadHookResult< + GadgetRecord, DefaultSelection>> +>; + +/** + * Hook to fetch a page of Gadget records from the backend, optionally sorted, filtered, searched, and selected from. Returns a standard hook result set with a tuple of the result object with `data`, `fetching`, and `error` keys, and a `refetch` function. `data` will be a `GadgetRecordList` object holding the list of returned records and pagination info. + * + * @param manager Gadget model manager to use + * @param options options for filtering and searching records, and selecting the fields in each record of the result + * + * @example + * + * ``` + * export function Users() { + * const [result, refresh] = useFindMany(api.user, { + * select: { + * name: true, + * }, + * }); + * + * if (result.error) return <>Error: {result.error.toString()}; + * if (result.fetching && !result.data) return <>Fetching...; + * if (!result.data) return <>No users found; + * + * return <>{result.data.map((user) =>
{user.name}
)}; + * } + * ``` + */ +export type UseFindMany = < + GivenOptions extends OptionsType, + SchemaT, + F extends FindManyFunction, + Options extends F["optionsType"] & ReadOperationOptions +>( + manager: { findMany: F }, + options?: LimitToKnownKeys +) => ReadHookResult< + GadgetRecordList, DefaultSelection>> +>; + +/** + * Hook to fetch one Gadget record by `id` from the backend. Returns a standard hook result set with a tuple of the result object with `data`, `fetching`, and `error` keys, and a `refetch` function. `data` will be the record if it was found, and `null` otherwise. + * + * @param manager Gadget model manager to use + * @param id id of the record to fetch + * @param options options for selecting the fields in the result + * + * @example + * ``` + * export function User(props: { id: string }) { + * const [result, refresh] = useFindOne(api.user, props.id, { + * select: { + * name: true, + * }, + * }); + * + * if (result.error) return <>Error: {result.error.toString()}; + * if (result.fetching && !result.data) return <>Fetching...; + * if (!result.data) return <>No user found with id={props.id}; + * + * return
{result.data.name}
; + * } + * ``` + */ +export type UseFindOne = < + GivenOptions extends OptionsType, + SchemaT, + F extends FindOneFunction, + Options extends F["optionsType"] & ReadOperationOptions +>( + manager: { findOne: F }, + id: string, + options?: LimitToKnownKeys +) => ReadHookResult< + GadgetRecord, DefaultSelection>> +>; + +/** + * Hook that fetches a singleton record for an `api.currentSomething` style model manager. `useGet` fetches one global record, which is most often the current session. `useGet` doesn't require knowing the record's ID in order to fetch it, and instead returns the one current record for the current context. + * + * @param manager Gadget model manager to use, like `api.currentSomething` + * @param options options for selecting the fields in the result + * + * @example + * ``` + * export function CurrentSession() { + * const [{error, data, fetching}, refresh] = useGet(api.currentSession, { + * select: { + * id: true, + * userId: true, + * }, + * }); + * + * if (error) return <>Error: {error.toString()}; + * if (fetching && !data) return <>Fetching...; + * if (!data) return <>No current session found; + * + * return
Current session ID={data.id} and userId={data.userId}
; + * } + * ``` + */ +export type UseGet = < + GivenOptions extends OptionsType, + SchemaT, + F extends GetFunction, + Options extends F["optionsType"] & ReadOperationOptions +>( + manager: { get: F }, + options?: LimitToKnownKeys +) => ReadHookResult< + GadgetRecord, DefaultSelection>> +>; + +/** + * Hook to run a Gadget model action. + * + * @param action any action function from a Gadget manager + * + * @example + * ``` + * export function FlipAllWidgets(props: { name: string; email: string }) { + * const [result, flipAllWidgets] = useGlobalAction(Client.flipAllWidgets); + * + * return ( + * <> + * {result.error && <>Failed to flip all widgets: {result.error.toString()}} + * {result.fetching && <>Flipping all widgets...} + * {result.data && <>Flipped all widgets} + * + * + * ); + * } + */ +export type UseGlobalAction = >( + action: F +) => ActionHookResultWithOptionalCallbackVariables>; + +/** + * Hook to fetch many Gadget records using the `maybeFindFirst` method of a given manager. + * + * @param manager Gadget model manager to use + * @param options options for filtering and searching records, and selecting the fields in each record of the result + * + * @example + * + * ``` + * export function Users() { + * const [result, refresh] = useMaybeFindFirst(Client.user, { + * select: { + * name: true, + * }, + * }); + * + * if (result.error) return <>Error: {result.error.toString()}; + * if (result.fetching && !result.data) return <>Fetching...; + * if (!result.data) return <>No user found; + * + * return
{result.data.name}
; + * } + * ``` + */ +export type UseMaybeFindFirst = < + GivenOptions extends OptionsType, + SchemaT, + F extends FindFirstFunction, + Options extends F["optionsType"] & ReadOperationOptions +>( + manager: { findFirst: F }, + options?: LimitToKnownKeys +) => ReadHookResult< + GadgetRecord, DefaultSelection>> +>; + +/** + * Hook to fetch a Gadget record using the `maybeFindOne` method of a given manager. + * + * @param manager Gadget model manager to use + * @param id id of the record to fetch + * @param options options for selecting the fields in the result + * + * @example + * ``` + * export function User(props: { id: string }) { + * const [result, refresh] = useMaybeFindOne(Client.user, props.id, { + * select: { + * name: true, + * }, + * }); + * + * if (result.error) return <>Error: {result.error.toString()}; + * if (result.fetching && !result.data) return <>Fetching...; + * if (!result.data) return <>No user found with id={props.id}; + * + * return
{result.data.name}
; + * } + * ``` + */ +export type UseMaybeFindOne = < + GivenOptions extends OptionsType, + SchemaT, + F extends FindOneFunction, + Options extends F["optionsType"] & ReadOperationOptions +>( + manager: { findOne: F }, + id: string, + options?: LimitToKnownKeys +) => ReadHookResult< + GadgetRecord, DefaultSelection>> +>; + +export interface UseView { + /** + * Hook to fetch the result of a computed view from the backend. Returns a standard hook result set with a tuple of the result object with `data`, `fetching`, and `error` keys, and a `refetch` function. `data` will be the shape of the computed view's result. + * + * @param view Gadget view function to run, like `api.leaderboard` or `api.todos.summary` + * @param options options for controlling client side execution + * + * @example + * + * ``` + * export function Leaderboard() { + * const [result, refresh] = useView(api.leaderboard); + * + * if (result.error) return <>Error: {result.error.toString()}; + * if (result.fetching && !result.data) return <>Fetching...; + * if (!result.data) return <>No data found; + * + * return <>{result.data.map((leaderboard) =>
{leaderboard.name}: {leaderboard.score}
)}; + * } + * ``` + */ + >(view: F, options?: Omit): ReadHookResult>; + /** + * Hook to fetch the result of a computed view with variables from the backend. Returns a standard hook result set with a tuple of the result object with `data`, `fetching`, and `error` keys, and a `refetch` function. `data` will be the shape of the computed view's result. + * + * @param manager Gadget view function to run + * @param variables variables to pass to the backend view + * @param options options for controlling client side execution + * + * @example + * + * ``` + * export function Leaderboard() { + * const [result, refresh] = useView(api.leaderboard, { + * first: 10, + * }); + * + * if (result.error) return <>Error: {result.error.toString()}; + * if (result.fetching && !result.data) return <>Fetching...; + * if (!result.data) return <>No data found; + * + * return <>{result.data.map((leaderboard) =>
{leaderboard.name}: {leaderboard.score}
)}; + * } + * ``` + */ + >( + view: F, + variables: F["variablesType"], + options?: Omit + ): ReadHookResult>; + + /** + * Hook to fetch the result of an inline computed view with variables from the backend. Returns a standard hook result set with a tuple of the result object with `data`, `fetching`, and `error` keys, and a `refetch` function. `data` will be the shape of the computed view's result. + * + * Does not know the type of the result from the input string -- for type safety, use a named view defined in a .gelly file in the backend. + * + * @param view Gelly query string to run, like `{ count(todos) }` + * @param variables variables to pass to the backend view + * @param options options for controlling client side execution + * + * @example + * + * ``` + * export function Leaderboard() { + * const [result, refresh] = useView("{ count(todos) }", { + * first: 10, + * }); + * + * if (result.error) return <>Error: {result.error.toString()}; + * if (result.fetching && !result.data) return <>Fetching...; + * if (!result.data) return <>No data found; + * + * return <>{result.data.map((leaderboard) =>
{leaderboard.name}: {leaderboard.score}
)}; + * } + * ``` + */ + (gellyQuery: string, variables?: Record, options?: Omit): ReadHookResult< + ViewResult> + >; +} diff --git a/packages/client-hooks/src/useAction.ts b/packages/client-hooks/src/useAction.ts new file mode 100644 index 000000000..d34858142 --- /dev/null +++ b/packages/client-hooks/src/useAction.ts @@ -0,0 +1,85 @@ +import type { ActionFunction, StubbedActionFunction } from "@gadgetinc/core"; +import { ErrorWrapper, capitalizeIdentifier, disambiguateActionVariables, get, namespaceDataPath } from "@gadgetinc/core"; +import { AnyVariables, OperationContext } from "@urql/core"; +import { UseMutationState } from "./adapter.js"; +import { createHookStub } from "./createHooks.js"; +import type { ActionHookState, UseAction } from "./types.js"; + +export let useAction: UseAction = createHookStub("useAction", (adapter, coreHooks) => { + useAction = (action, options) => { + adapter.framework.useEffect(() => { + if (action.type === ("stubbedAction" as string)) { + const stubbedAction = action as unknown as StubbedActionFunction; + if (!("reason" in stubbedAction) || !("dataPath" in stubbedAction) || !("actionApiIdentifier" in stubbedAction)) { + // Don't dispatch an event if the generated client has not yet been updated with the updated parameters + return; + } + + const event = new CustomEvent("gadget:devharness:stubbedActionError", { + detail: { + reason: stubbedAction.reason, + action: { + functionName: stubbedAction.functionName, + actionApiIdentifier: stubbedAction.actionApiIdentifier, + modelApiIdentifier: stubbedAction.modelApiIdentifier, + dataPath: stubbedAction.dataPath, + }, + }, + }); + globalThis.dispatchEvent(event); + } + }, []); + + const memoizedOptions = coreHooks.useStructuralMemo(options); + const plan = adapter.framework.useMemo(() => { + if (!action.plan) { + throw new Error(`Action ${action.operationName} does not have a plan method, is your client up to date?`); + } + return action.plan(memoizedOptions); + }, [action, memoizedOptions]); + + const [result, runMutation] = coreHooks.useGadgetMutation(plan.query); + + const transformedResult = adapter.framework.useMemo(() => processResult(result, action), [result, action]); + + return [ + transformedResult, + adapter.framework.useCallback( + async (input: (typeof action)["variablesType"], context?: Partial) => { + const variables = disambiguateActionVariables(action, input); + + const result = await runMutation(variables, { + ...context, + // Adding the model's additional typename ensures document cache will properly refresh, regardless of whether __typename was selected (and sometimes we can't even select it, like delete actions!) + additionalTypenames: [...(context?.additionalTypenames ?? []), capitalizeIdentifier(action.modelApiIdentifier)], + }); + + return processResult({ fetching: false, ...result }, action); + }, + [action, runMutation] + ), + ]; + }; +}); + +const processResult = ( + result: UseMutationState, + action: ActionFunction +): ActionHookState => { + let error = ErrorWrapper.forMaybeCombinedError(result.error); + let data = null; + if (result.data) { + const dataPath = namespaceDataPath([action.operationName], action.namespace); + const mutationData = get(result.data, dataPath); + if (mutationData) { + const errors = mutationData["errors"]; + if (errors && errors[0]) { + error = ErrorWrapper.forErrorsResponse(errors, error?.response); + } else { + data = action.processResult(action.defaultSelection, result, mutationData, action.modelSelectionField, action.hasReturnType); + } + } + } + + return { ...result, error, data }; +}; diff --git a/packages/client-hooks/src/useBulkAction.ts b/packages/client-hooks/src/useBulkAction.ts new file mode 100644 index 000000000..c5e735a6f --- /dev/null +++ b/packages/client-hooks/src/useBulkAction.ts @@ -0,0 +1,95 @@ +import type { BulkActionFunction, StubbedActionFunction } from "@gadgetinc/core"; +import { + ErrorWrapper, + capitalizeIdentifier, + disambiguateBulkActionVariables, + get, + hydrateRecordArray, + namespaceDataPath, +} from "@gadgetinc/core"; +import type { OperationContext } from "@urql/core"; +import { UseMutationState } from "./adapter.js"; +import { createHookStub } from "./createHooks.js"; +import type { UseBulkAction } from "./types.js"; + +export let useBulkAction: UseBulkAction = createHookStub("useBulkAction", (adapter, coreHooks) => { + useBulkAction = (action, options) => { + adapter.framework.useEffect(() => { + if (action.type === ("stubbedAction" as string)) { + const stubbedAction = action as unknown as StubbedActionFunction; + if (!("reason" in stubbedAction) || !("dataPath" in stubbedAction) || !("actionApiIdentifier" in stubbedAction)) { + // Don't dispatch an event if the generated client has not yet been updated with the updated parameters + return; + } + + const event = new CustomEvent("gadget:devharness:stubbedActionError", { + detail: { + reason: stubbedAction.reason, + action: { + functionName: stubbedAction.functionName, + actionApiIdentifier: stubbedAction.actionApiIdentifier, + modelApiIdentifier: stubbedAction.modelApiIdentifier, + dataPath: stubbedAction.dataPath, + }, + }, + }); + globalThis.dispatchEvent(event); + } + }, []); + + const memoizedOptions = coreHooks.useStructuralMemo(options); + const plan = adapter.framework.useMemo(() => { + if (!action.plan) { + throw new Error(`Action ${action.operationName} does not have a plan method, is your client up to date?`); + } + return action.plan(memoizedOptions); + }, [action, memoizedOptions]); + + const [result, runMutation] = coreHooks.useGadgetMutation(plan.query); + + const transformedResult = adapter.framework.useMemo(() => processResult(result, action), [result, action]); + + return [ + transformedResult, + adapter.framework.useCallback( + async (inputs: (typeof action)["variablesType"], context?: Partial) => { + const variables = disambiguateBulkActionVariables(action, inputs); + + const result = await runMutation(variables, { + ...context, + // Adding the model's additional typename ensures document cache will properly refresh, regardless of whether __typename was selected (and sometimes we can't even select it, like delete actions!) + additionalTypenames: [...(context?.additionalTypenames ?? []), capitalizeIdentifier(action.modelApiIdentifier)], + }); + return processResult({ fetching: false, ...result }, action); + }, + [action, runMutation] + ), + ]; + }; +}); + +const processResult = (result: UseMutationState, action: BulkActionFunction) => { + let error = ErrorWrapper.forMaybeCombinedError(result.error); + let data = undefined; + + if (result.data && !error) { + const dataPath = namespaceDataPath([action.operationName], action.namespace); + const mutationData = get(result.data, dataPath); + + if (mutationData) { + const isDeleteAction = (action as any).isDeleter; + if (!isDeleteAction) { + const errors = mutationData["errors"]; + if (errors && errors[0]) { + error = ErrorWrapper.forErrorsResponse(errors, (error as any)?.response); + } else { + data = action.hasReturnType ? mutationData.results : hydrateRecordArray(result, mutationData[action.modelSelectionField]); + } + } else { + // Delete action + data = mutationData; + } + } + } + return { ...result, error, data }; +}; diff --git a/packages/client-hooks/src/useEnqueue.ts b/packages/client-hooks/src/useEnqueue.ts new file mode 100644 index 000000000..3db7e47ff --- /dev/null +++ b/packages/client-hooks/src/useEnqueue.ts @@ -0,0 +1,73 @@ +import type { AnyActionFunction, AnyBackgroundActionHandle, AnyConnection, EnqueueBackgroundActionOptions } from "@gadgetinc/core"; +import { ErrorWrapper, disambiguateActionVariables, disambiguateBulkActionVariables, get, namespaceDataPath } from "@gadgetinc/core"; +import type { UseMutationState } from "./adapter.js"; +import { createHookStub } from "./createHooks.js"; +import type { EnqueueHookState, UseEnqueue } from "./types.js"; + +export let useEnqueue: UseEnqueue = createHookStub("useEnqueue", (adapter, coreHooks) => { + useEnqueue = (action, baseBackgroundOptions) => { + const connection = coreHooks.useConnection(); + const plan = adapter.framework.useMemo( + () => connection.enqueue.plan(action.operationName, action.variables, action.namespace, null, action.isBulk), + [action] + ); + + const [rawState, runMutation] = coreHooks.useGadgetMutation(plan.query); + + const state = adapter.framework.useMemo(() => processResult(connection, rawState, action), [rawState, action]); + + return [ + state, + adapter.framework.useCallback( + async (input: (typeof action)["variablesType"], options?: EnqueueBackgroundActionOptions) => { + const variables = action.isBulk ? disambiguateBulkActionVariables(action, input) : disambiguateActionVariables(action, input); + + const fullContext = { ...baseBackgroundOptions, ...options }; + variables.backgroundOptions = connection.enqueue.processOptions(fullContext); + + const rawState = await runMutation(variables, fullContext); + + return processResult(connection, { fetching: false, ...rawState }, action); + }, + [action, connection, runMutation] + ), + ]; + }; +}); + +// /** Processes urql's result object into the fancier Gadget result object */ +const processResult = ( + connection: AnyConnection, + rawResult: UseMutationState, + action: Action +): EnqueueHookState => { + const { data, ...result } = rawResult; + let error = ErrorWrapper.forMaybeCombinedError(result.error); + let handle: AnyBackgroundActionHandle | null = null; + let handles: AnyBackgroundActionHandle[] | null = null; + const isBulk = action.isBulk; + + if (data) { + const dataPath = ["background", ...namespaceDataPath([action.operationName], action.namespace)]; + + const mutationData = get(data, dataPath); + if (mutationData) { + const errors = mutationData["errors"]; + if (errors && errors[0]) { + error = ErrorWrapper.forErrorsResponse(errors, error?.response); + } else { + if (isBulk) { + handles = mutationData.backgroundActions.map((result: { id: string }) => connection.enqueue.createHandle(action, result.id)); + } else { + handle = connection.enqueue.createHandle(action, mutationData.backgroundAction.id); + } + } + } + } + + if (isBulk) { + return { ...result, error, handles } as EnqueueHookState; + } else { + return { ...result, error, handle } as EnqueueHookState; + } +}; diff --git a/packages/client-hooks/src/useFetch.ts b/packages/client-hooks/src/useFetch.ts new file mode 100644 index 000000000..20e1928ba --- /dev/null +++ b/packages/client-hooks/src/useFetch.ts @@ -0,0 +1,198 @@ +import { ErrorWrapper } from "@gadgetinc/core"; +import { RuntimeAdapter } from "./adapter.js"; +import { createHookStub } from "./createHooks.js"; +import type { CoreHooks, FetchHookOptions, FetchHookResult, FetchHookState, UseFetch } from "./types.js"; + +type FetchAction = + | { type: "fetching" } + | { type: "streaming" } + | { type: "fetched"; payload: T } + | { type: "streamed" } + | { type: "update"; payload: T } + | { type: "error"; payload: ErrorWrapper }; + +const reducer = (state: FetchHookState, action: FetchAction): FetchHookState => { + switch (action.type) { + case "fetching": + return { ...state, fetching: true, streaming: false, error: undefined }; + case "streaming": + return { ...state, streaming: true }; + case "streamed": + return { ...state, streaming: false }; + case "fetched": + return { ...state, fetching: false, data: action.payload, error: undefined }; + case "update": + return { ...state, data: action.payload }; + case "error": + return { ...state, fetching: false, error: action.payload }; + default: + return state; + } +}; + +const startRequestByDefault = (options?: FetchHookOptions) => { + if (typeof options?.sendImmediately != "undefined") { + return options.sendImmediately; + } else { + return !options?.method || options.method === "GET"; + } +}; + +const dispatchError = ( + mounted: { current: boolean }, + dispatch: (action: FetchAction) => void, + abortController: AbortController, + error: any, + response?: Response +) => { + if (!mounted.current || abortController.signal.aborted) return null; + + const wrapped = ErrorWrapper.forClientSideError(error, response); + dispatch({ type: "error", payload: wrapped }); + + return wrapped; +}; + +export let useFetch: UseFetch = createHookStub("useFetch", (adapter: RuntimeAdapter, coreHooks: CoreHooks) => { + useFetch = (path: string, options?: FetchHookOptions): FetchHookResult => { + // Used to prevent state update if the component is unmounted + const mounted = adapter.framework.useRef(true); + const { onStreamComplete, ...optionsToMemoize } = options ?? {}; + const memoizedOptions = coreHooks.useStructuralMemo(optionsToMemoize); + const connection = coreHooks.useConnection(); + const startRequestOnMount = startRequestByDefault(memoizedOptions); + const controller = adapter.framework.useRef(null); + + const [state, dispatch] = adapter.framework.useReducer, FetchHookOptions, [FetchAction]>( + reducer, + memoizedOptions, + (memoizedOptions) => { + return { fetching: startRequestOnMount, streaming: false, options: memoizedOptions }; + } + ); + + const send = adapter.framework.useCallback( + async (sendOptions?: Partial): Promise => { + if (controller.current && !controller.current.signal.aborted) { + controller.current.abort("useFetch is starting a new request, aborting the previous one"); + } + + const abortContoller = new AbortController(); + controller.current = abortContoller; + + dispatch({ type: "fetching" }); + + let data: any; + let response: Response | undefined = undefined; + + const mergedOptions = { ...memoizedOptions, onStreamComplete, ...sendOptions }; + + // add implicit headers from options, being careful not to mutate any inputs + if (mergedOptions.json) { + mergedOptions.headers = { ...mergedOptions.headers }; + (mergedOptions.headers as any)["accept"] ??= "application/json"; + } + + try { + const { json: _json, stream: _stream, onStreamComplete: _onStreamComplete, ...fetchOptions } = mergedOptions; + // make the fetch call using GadgetConnection to pass along auth and other headers + response = await connection.fetch(path, { signal: abortContoller.signal, ...fetchOptions }); + if (!response.ok) { + throw new Error(response.statusText); + } + + let dispatchData = true; + + if (mergedOptions.json) { + data = await response.json(); + } else if (typeof mergedOptions.stream === "string") { + dispatchData = false; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const decodedStream = response.body!.pipeThrough( + new TextDecoderStream(mergedOptions.stream === "string" ? "utf8" : mergedOptions.stream) + ); + + const [responseStream, updateStream] = decodedStream.tee(); + + data = responseStream; + const decodedStreamReader = updateStream.getReader(); + + decodedStreamReader.closed.catch((error) => { + dispatchError(mounted, dispatch, abortContoller, error, response); + }); + + dispatch({ type: "fetched", payload: "" as any }); + + (async () => { + let responseText = ""; + let done = false; + + dispatch({ type: "streaming" }); + + while (!done) { + const { value, done: _done } = await decodedStreamReader.read(); + done = _done; + + if (value) { + responseText += value; + + if (!abortContoller.signal.aborted) { + dispatch({ type: "update", payload: responseText as any }); + } + } + } + + mergedOptions.onStreamComplete?.(responseText); + })() + .catch((error) => { + dispatchError(mounted, dispatch, abortContoller, error, response); + }) + .finally(() => { + dispatch({ type: "streamed" }); + }); + } else if (mergedOptions.stream) { + data = response.body; + } else { + data = await response.text(); + } + + if (!mounted.current || !dispatchData) return data; + + dispatch({ type: "fetched", payload: data }); + } catch (error: any) { + const wrapped = dispatchError(mounted, dispatch, abortContoller, error, response); + if (!wrapped) return null as any; + throw wrapped; + } + return data; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [connection, memoizedOptions, path] + ); + + // track if we're mounted or not + adapter.framework.useEffect(() => { + mounted.current = true; + + return () => { + mounted.current = false; + }; + }, []); + + // execute the initial request on mount if needed + adapter.framework.useEffect(() => { + if (startRequestOnMount) { + void send().catch(() => { + // error will be reported via the return value of the hook + }); + } + + // abort if the component is unmounted, or if one of the key elements of the request changes such that we don't want an outstanding request's result anymore + return () => { + controller.current?.abort(); + }; + }, [path, startRequestOnMount, send]); + + return [state, send]; + }; +}); diff --git a/packages/client-hooks/src/useFindBy.ts b/packages/client-hooks/src/useFindBy.ts new file mode 100644 index 000000000..f4cff942a --- /dev/null +++ b/packages/client-hooks/src/useFindBy.ts @@ -0,0 +1,44 @@ +import { ErrorWrapper, GadgetNotFoundError, get, getNonUniqueDataError, hydrateConnection, namespaceDataPath } from "@gadgetinc/core"; +import { RuntimeAdapter } from "./adapter.js"; +import { createHookStub } from "./createHooks.js"; +import type { CoreHooks, UseFindBy } from "./types.js"; +import { useQueryArgs } from "./utils.js"; + +export let useFindBy: UseFindBy = createHookStub("useFindBy", (adapter: RuntimeAdapter, coreHooks: CoreHooks) => { + useFindBy = (finder, value, options) => { + const memoizedOptions = coreHooks.useStructuralMemo(options); + const plan = adapter.framework.useMemo(() => { + return finder.plan(value, memoizedOptions); + }, [finder, value, memoizedOptions]); + const [rawResult, refresh] = coreHooks.useGadgetQuery(useQueryArgs(plan, options)); + + const result = adapter.framework.useMemo(() => { + const dataPath = namespaceDataPath([finder.operationName], finder.namespace); + + let data = rawResult.data; + let records = []; + if (data) { + const gqlConnection = get(rawResult.data, dataPath); + if (gqlConnection) { + records = hydrateConnection(rawResult, gqlConnection); + data = records[0]; + } + } + + let error = ErrorWrapper.forMaybeCombinedError(rawResult.error); + if (!error) { + if (records.length > 1) { + error = ErrorWrapper.forClientSideError(getNonUniqueDataError(finder.modelApiIdentifier, finder.findByVariableName, value)); + } else if (rawResult.data && !records[0]) { + error = ErrorWrapper.forClientSideError( + new GadgetNotFoundError(`${finder.modelApiIdentifier} record with ${finder.findByVariableName}=${value} not found`) + ); + } + } + + return { ...rawResult, data, error }; + }, [rawResult, finder, value]); + + return [result, refresh]; + }; +}); diff --git a/packages/client-hooks/src/useFindFirst.ts b/packages/client-hooks/src/useFindFirst.ts new file mode 100644 index 000000000..17dea0a66 --- /dev/null +++ b/packages/client-hooks/src/useFindFirst.ts @@ -0,0 +1,30 @@ +import { ErrorWrapper, get, hydrateConnection, namespaceDataPath } from "@gadgetinc/core"; +import { RuntimeAdapter } from "./adapter.js"; +import { createHookStub } from "./createHooks.js"; +import type { CoreHooks, UseFindFirst } from "./types.js"; +import { useQueryArgs } from "./utils.js"; + +export let useFindFirst: UseFindFirst = createHookStub("useFindFirst", (adapter: RuntimeAdapter, coreHooks: CoreHooks) => { + useFindFirst = (manager, options) => { + const firstOptions = { ...options, first: 1 } as typeof options; + const memoizedOptions = coreHooks.useStructuralMemo(firstOptions); + const plan = adapter.framework.useMemo(() => { + return manager.findFirst.plan(memoizedOptions); + }, [manager, memoizedOptions]); + const [rawResult, refresh] = coreHooks.useGadgetQuery(useQueryArgs(plan, firstOptions)); + + const result = adapter.framework.useMemo(() => { + const dataPath = namespaceDataPath([manager.findFirst.operationName], manager.findFirst.namespace); + let data = rawResult.data && get(rawResult.data, dataPath); + if (data) { + data = hydrateConnection(rawResult, data)[0]; + } + + const error = ErrorWrapper.errorIfDataAbsent(rawResult, dataPath, options?.pause); + + return { ...rawResult, data, error }; + }, [manager.findFirst.operationName, options?.pause, rawResult]); + + return [result, refresh]; + }; +}); diff --git a/packages/client-hooks/src/useFindMany.ts b/packages/client-hooks/src/useFindMany.ts new file mode 100644 index 000000000..bfaf19b4b --- /dev/null +++ b/packages/client-hooks/src/useFindMany.ts @@ -0,0 +1,33 @@ +import type { AnyModelManager } from "@gadgetinc/core"; +import { ErrorWrapper, GadgetRecordList, get, hydrateConnection, namespaceDataPath } from "@gadgetinc/core"; +import { RuntimeAdapter } from "./adapter.js"; +import { createHookStub } from "./createHooks.js"; +import type { CoreHooks, UseFindMany } from "./types.js"; +import { useQueryArgs } from "./utils.js"; + +export let useFindMany: UseFindMany = createHookStub("useFindMany", (adapter: RuntimeAdapter, coreHooks: CoreHooks) => { + useFindMany = (manager, options) => { + const memoizedOptions = coreHooks.useStructuralMemo(options); + const plan = adapter.framework.useMemo(() => { + return manager.findMany.plan(memoizedOptions); + }, [manager, memoizedOptions]); + + const [rawResult, refresh] = coreHooks.useGadgetQuery(useQueryArgs(plan, options)); + + const result = adapter.framework.useMemo(() => { + const dataPath = namespaceDataPath([manager.findMany.operationName], manager.findMany.namespace); + let data = rawResult.data; + let gqlConnection = rawResult.data && get(rawResult.data, dataPath); + if (gqlConnection) { + const records = hydrateConnection(rawResult, gqlConnection); + data = GadgetRecordList.boot(manager as unknown as AnyModelManager, records, gqlConnection); + } + + const error = ErrorWrapper.errorIfDataAbsent(rawResult, dataPath, options?.pause); + + return { ...rawResult, data, error }; + }, [manager, options?.pause, rawResult]); + + return [result, refresh]; + }; +}); diff --git a/packages/client-hooks/src/useFindOne.ts b/packages/client-hooks/src/useFindOne.ts new file mode 100644 index 000000000..54be0a7bd --- /dev/null +++ b/packages/client-hooks/src/useFindOne.ts @@ -0,0 +1,29 @@ +import { ErrorWrapper, get, hydrateRecord, namespaceDataPath } from "@gadgetinc/core"; +import type { RuntimeAdapter } from "./adapter.js"; +import { createHookStub } from "./createHooks.js"; +import type { CoreHooks, UseFindOne } from "./types.js"; +import { useQueryArgs } from "./utils.js"; + +export let useFindOne: UseFindOne = createHookStub("useFindOne", (adapter: RuntimeAdapter, coreHooks: CoreHooks) => { + useFindOne = (manager, id, options) => { + const memoizedOptions = coreHooks.useStructuralMemo(options); + const plan = adapter.framework.useMemo(() => { + return manager.findOne.plan(id); + }, [manager, id, memoizedOptions]); + const [rawResult, refresh] = coreHooks.useGadgetQuery(useQueryArgs(plan, options)); + + const result = adapter.framework.useMemo(() => { + const dataPath = namespaceDataPath([manager.findOne.operationName], manager.findOne.namespace); + + let data = rawResult.data && get(rawResult.data, dataPath); + if (data) { + data = hydrateRecord(rawResult, data); + } + const error = ErrorWrapper.errorIfDataAbsent(rawResult, dataPath, options?.pause); + + return { ...rawResult, data, error }; + }, [manager.findOne.operationName, rawResult, options?.pause]); + + return [result, refresh]; + }; +}); diff --git a/packages/client-hooks/src/useGet.ts b/packages/client-hooks/src/useGet.ts new file mode 100644 index 000000000..ac1b4f2ac --- /dev/null +++ b/packages/client-hooks/src/useGet.ts @@ -0,0 +1,34 @@ +import { ErrorWrapper, get, hydrateRecord, namespaceDataPath } from "@gadgetinc/core"; +import { RuntimeAdapter } from "./adapter.js"; +import { createHookStub } from "./createHooks.js"; +import type { CoreHooks, UseGet } from "./types.js"; +import { useQueryArgs } from "./utils.js"; + +export let useGet: UseGet = createHookStub("useGet", (adapter: RuntimeAdapter, coreHooks: CoreHooks) => { + useGet = (manager, options) => { + const memoizedOptions = coreHooks.useStructuralMemo(options); + const plan = adapter.framework.useMemo(() => { + return manager.get.plan(memoizedOptions); + }, [manager, memoizedOptions]); + + const [rawResult, refresh] = coreHooks.useGadgetQuery(useQueryArgs(plan, options)); + const dataPath = namespaceDataPath([manager.get.operationName], manager.get.namespace); + + const result = adapter.framework.useMemo(() => { + let data = null; + const rawRecord = rawResult.data && get(rawResult.data, dataPath); + if (rawRecord) { + data = hydrateRecord(rawResult, rawRecord); + } + const error = ErrorWrapper.forMaybeCombinedError(rawResult.error); + + return { + ...rawResult, + error, + data, + }; + }, [rawResult, manager]); + + return [result, refresh]; + }; +}); diff --git a/packages/client-hooks/src/useGlobalAction.ts b/packages/client-hooks/src/useGlobalAction.ts new file mode 100644 index 000000000..93a70dc10 --- /dev/null +++ b/packages/client-hooks/src/useGlobalAction.ts @@ -0,0 +1,68 @@ +import type { GlobalActionFunction, StubbedActionFunction } from "@gadgetinc/core"; +import { ErrorWrapper, get, namespaceDataPath } from "@gadgetinc/core"; +import { OperationContext } from "@urql/core"; +import { RuntimeAdapter, UseMutationState } from "./adapter.js"; +import { createHookStub } from "./createHooks.js"; +import type { CoreHooks, UseGlobalAction } from "./types.js"; + +export let useGlobalAction: UseGlobalAction = createHookStub("useGlobalAction", (adapter: RuntimeAdapter, coreHooks: CoreHooks) => { + useGlobalAction = (action) => { + adapter.framework.useEffect(() => { + if (action.type === ("stubbedAction" as string)) { + const stubbedAction = action as unknown as StubbedActionFunction; + if (!("reason" in stubbedAction) || !("dataPath" in stubbedAction)) { + // Don't dispatch an event if the generated client has not yet been updated with the updated parameters + return; + } + + const event = new CustomEvent("gadget:devharness:stubbedActionError", { + detail: { + reason: stubbedAction.reason, + action: { + functionName: stubbedAction.functionName, + actionApiIdentifier: stubbedAction.actionApiIdentifier, + dataPath: stubbedAction.dataPath, + }, + }, + }); + globalThis.dispatchEvent(event); + } + }, []); + + const plan = adapter.framework.useMemo(() => { + return action.plan(); + }, [action]); + + const [result, runMutation] = coreHooks.useGadgetMutation(plan.query); + + const transformedResult = adapter.framework.useMemo(() => processResult(result, action), [result, action]); + + return [ + transformedResult, + adapter.framework.useCallback( + async (variables?: (typeof action)["variablesType"], context?: Partial) => { + const result = await runMutation(variables ?? {}, context); + return processResult({ fetching: false, ...result }, action); + }, + [action, runMutation] + ), + ]; + }; +}); + +const processResult = (result: UseMutationState, action: GlobalActionFunction) => { + let error = ErrorWrapper.forMaybeCombinedError(result.error); + let data = undefined; + if (result.data) { + const dataPath = namespaceDataPath([action.operationName], action.namespace); + data = get(result.data, dataPath); + if (data) { + const errors = data.errors; + data = data.result; + if (errors && errors[0]) { + error = ErrorWrapper.forErrorsResponse(errors); + } + } + } + return { ...result, error, data }; +}; diff --git a/packages/client-hooks/src/useMaybeFindFirst.ts b/packages/client-hooks/src/useMaybeFindFirst.ts new file mode 100644 index 000000000..f2d46e9f2 --- /dev/null +++ b/packages/client-hooks/src/useMaybeFindFirst.ts @@ -0,0 +1,35 @@ +import { ErrorWrapper, get, hydrateConnection, namespaceDataPath } from "@gadgetinc/core"; +import { RuntimeAdapter } from "./adapter.js"; +import { createHookStub } from "./createHooks.js"; +import type { CoreHooks, UseMaybeFindFirst } from "./types.js"; +import { useQueryArgs } from "./utils.js"; + +export let useMaybeFindFirst: UseMaybeFindFirst = createHookStub("useMaybeFindFirst", (adapter: RuntimeAdapter, coreHooks: CoreHooks) => { + useMaybeFindFirst = (manager, options) => { + const firstOptions = { ...options, first: 1 } as typeof options; + const memoizedOptions = coreHooks.useStructuralMemo(firstOptions); + const plan = adapter.framework.useMemo(() => { + return manager.findFirst.plan(memoizedOptions); + }, [manager, memoizedOptions]); + + const [rawResult, refresh] = coreHooks.useGadgetQuery(useQueryArgs(plan, firstOptions)); + + const result = adapter.framework.useMemo(() => { + const dataPath = namespaceDataPath([manager.findFirst.operationName], manager.findFirst.namespace); + let data = (rawResult.data && get(rawResult.data, dataPath)) ?? null; + if (data) { + data = hydrateConnection(rawResult, data)[0] ?? null; + } + + const error = ErrorWrapper.forMaybeCombinedError(rawResult.error); + + return { + ...rawResult, + error, + data, + }; + }, [rawResult, manager]); + + return [result, refresh]; + }; +}); diff --git a/packages/client-hooks/src/useMaybeFindOne.ts b/packages/client-hooks/src/useMaybeFindOne.ts new file mode 100644 index 000000000..42b5a45c6 --- /dev/null +++ b/packages/client-hooks/src/useMaybeFindOne.ts @@ -0,0 +1,32 @@ +import { ErrorWrapper, get, hydrateRecord, namespaceDataPath } from "@gadgetinc/core"; +import { RuntimeAdapter } from "./adapter.js"; +import { createHookStub } from "./createHooks.js"; +import type { CoreHooks, UseMaybeFindOne } from "./types.js"; +import { useQueryArgs } from "./utils.js"; + +export let useMaybeFindOne: UseMaybeFindOne = createHookStub("useMaybeFindOne", (adapter: RuntimeAdapter, coreHooks: CoreHooks) => { + useMaybeFindOne = (manager, id, options) => { + const memoizedOptions = coreHooks.useStructuralMemo(options); + const plan = adapter.framework.useMemo(() => { + return manager.findOne.plan(id); + }, [manager, id, memoizedOptions]); + + const [rawResult, refresh] = coreHooks.useGadgetQuery(useQueryArgs(plan, options)); + + const result = adapter.framework.useMemo(() => { + const dataPath = namespaceDataPath([manager.findOne.operationName], manager.findOne.namespace); + let data = (rawResult.data && get(rawResult.data, dataPath)) ?? null; + if (data) { + data = data && "id" in data ? hydrateRecord(rawResult, data) : null; + } + const error = ErrorWrapper.forMaybeCombinedError(rawResult.error); + return { + ...rawResult, + error, + data, + }; + }, [rawResult, manager]); + + return [result, refresh]; + }; +}); diff --git a/packages/client-hooks/src/useView.ts b/packages/client-hooks/src/useView.ts new file mode 100644 index 000000000..875b5066f --- /dev/null +++ b/packages/client-hooks/src/useView.ts @@ -0,0 +1,73 @@ +import type { GQLBuilderResult, VariablesOptions, ViewFunction, ViewResult } from "@gadgetinc/core"; +import { ErrorWrapper, get, namespaceDataPath } from "@gadgetinc/core"; +import { RuntimeAdapter } from "./adapter.js"; +import { createHookStub } from "./createHooks.js"; +import type { CoreHooks, ReadHookResult, ReadOperationOptions, UseView } from "./types.js"; +import { useQueryArgs } from "./utils.js"; + +export let useView: UseView = createHookStub("useView", (adapter: RuntimeAdapter, coreHooks: CoreHooks) => { + useView = >( + view: F | string, + variablesOrOptions?: VariablesT | Omit, + maybeOptions?: Omit + ): ReadHookResult> => { + let variables: VariablesT | undefined; + let options: Omit | undefined; + + if (typeof view == "string" || "variables" in view) { + variables = variablesOrOptions as VariablesT; + options = maybeOptions; + } else if (variablesOrOptions) { + options = variablesOrOptions as Omit; + } + + const memoizedVariables = coreHooks.useStructuralMemo(variables); + const memoizedOptions = coreHooks.useStructuralMemo({ + ...options, + context: { + ...options?.context, + // if the view exports the typenames it references, add them to the context so urql will refresh the view when mutations are made against these typenames + additionalTypenames: [ + ...(options?.context?.additionalTypenames ?? []), + ...(typeof view == "string" ? [] : view.referencedTypenames ?? []), + ], + }, + }); + + const [plan, dataPath] = adapter.framework.useMemo((): [plan: GQLBuilderResult, dataPath: string[]] => { + if (typeof view == "string") { + return [{ query: inlineViewQuery, variables: { query: view, variables: memoizedVariables } }, ["gellyView"]]; + } else { + const variablesOptions: VariablesOptions = {}; + if ("variables" in view && memoizedVariables) { + for (const [name, variable] of Object.entries(view.variables)) { + const value = memoizedVariables[name as keyof typeof memoizedVariables] as unknown; + if (typeof value != "undefined" && value !== null) { + variablesOptions[name] = { + value, + ...variable, + }; + } + } + } + + return [view.plan(variablesOptions), namespaceDataPath([view.gqlFieldName], view.namespace)]; + } + }, [view, memoizedVariables]); + + const [rawResult, refresh] = coreHooks.useGadgetQuery(useQueryArgs(plan, memoizedOptions)); + + const result = adapter.framework.useMemo(() => { + const data = get(rawResult.data, dataPath); + const error = ErrorWrapper.errorIfDataAbsent(rawResult, dataPath, options?.pause); + + return { ...rawResult, data, error }; + }, [dataPath, options?.pause, rawResult]); + + return [result, refresh]; + }; +}); + +const inlineViewQuery = `query InlineView($query: String!, $variables: JSONObject) { + gellyView(query: $query, variables: $variables) +}`; diff --git a/packages/client-hooks/src/utils.ts b/packages/client-hooks/src/utils.ts new file mode 100644 index 000000000..feaab5655 --- /dev/null +++ b/packages/client-hooks/src/utils.ts @@ -0,0 +1,24 @@ +import type { OperationContext, RequestPolicy } from "@urql/core"; + +interface QueryPlan { + variables: any; + query: string; +} + +interface QueryOptions { + context?: Partial; + pause?: boolean; + requestPolicy?: RequestPolicy; + suspense?: boolean; +} + +/** + * Given a plan from a gadget query plan generator, create the query options object to pass to `urql`'s `useQuery` hook + **/ +export const useQueryArgs = (plan: Plan, options?: Options): any => ({ + query: plan.query, + variables: plan.variables, + ...options, +}); + +export const noProviderErrorMessage = `Could not find a client in the context of Provider. Please ensure you wrap the root component in a `; diff --git a/packages/client-hooks/tsconfig.base.json b/packages/client-hooks/tsconfig.base.json new file mode 100644 index 000000000..37dd6956f --- /dev/null +++ b/packages/client-hooks/tsconfig.base.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["es2020", "DOM"], + "jsx": "react", + "baseUrl": "./", + "target": "es2020", + "types": ["jest", "node"], + "importHelpers": true + } +} diff --git a/packages/client-hooks/tsconfig.cjs.json b/packages/client-hooks/tsconfig.cjs.json new file mode 100644 index 000000000..25c2e42ab --- /dev/null +++ b/packages/client-hooks/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist/cjs", + "module": "CommonJS", + "moduleResolution": "node" + }, + "include": ["./src"] +} diff --git a/packages/client-hooks/tsconfig.esm.json b/packages/client-hooks/tsconfig.esm.json new file mode 100644 index 000000000..06e3c57bb --- /dev/null +++ b/packages/client-hooks/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist/esm", + "module": "nodenext", + "moduleResolution": "nodenext" + }, + "include": ["./src"] +} diff --git a/packages/client-hooks/tsconfig.json b/packages/client-hooks/tsconfig.json new file mode 100644 index 000000000..ac15ade5f --- /dev/null +++ b/packages/client-hooks/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["es2020", "DOM"], + "jsx": "react", + "baseUrl": "./", + "moduleResolution": "nodenext", + "module": "nodenext", + "target": "es2020", + "types": ["jest", "node"], + "outDir": "./dist", + "resolveJsonModule": true, + "rootDir": "../../" + }, + "include": ["./src", "./spec"] +} diff --git a/packages/core/package.json b/packages/core/package.json index 45e1a17d9..bab98d292 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@gadgetinc/core", - "version": "0.2.0", + "version": "0.3.0", "files": [ "README.md", "dist/**/*", diff --git a/packages/core/src/AnyConnection.ts b/packages/core/src/AnyConnection.ts index e732b0bc4..f42cc0288 100644 --- a/packages/core/src/AnyConnection.ts +++ b/packages/core/src/AnyConnection.ts @@ -1,7 +1,9 @@ import type { Client, ClientOptions } from "@urql/core"; import type { ClientOptions as SubscriptionClientOptions, createClient as createSubscriptionClient } from "graphql-ws"; import type { AuthenticationModeOptions, Exchanges } from "./ClientOptions.js"; +import { AnyActionFunction } from "./GadgetFunctions.js"; import { GadgetTransaction } from "./GadgetTransaction.js"; +import { AnyBackgroundActionHandle, BuildOperationResult, EnqueueBackgroundActionOptions, VariablesOptions } from "./types.js"; export interface GadgetSubscriptionClientOptions extends Partial { urlParams?: Record; @@ -53,4 +55,15 @@ export interface AnyConnection { }; close(): void; fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; + enqueue: { + plan: ( + operation: string, + variables: VariablesOptions, + namespace?: string | string[] | null, + options?: EnqueueBackgroundActionOptions | null, + isBulk?: boolean + ) => BuildOperationResult; + processOptions: (options: EnqueueBackgroundActionOptions) => Record; + createHandle: (action: Action, id: string) => AnyBackgroundActionHandle; + }; } diff --git a/packages/core/src/GadgetFunctions.ts b/packages/core/src/GadgetFunctions.ts index 2e90c2353..1647ccc80 100644 --- a/packages/core/src/GadgetFunctions.ts +++ b/packages/core/src/GadgetFunctions.ts @@ -1,3 +1,4 @@ +import { FieldSelection } from "./FieldSelection.js"; import type { GadgetRecord, RecordShape } from "./GadgetRecord.js"; import type { GadgetRecordList } from "./GadgetRecordList.js"; import type { LimitToKnownKeys, VariablesOptions } from "./types.js"; @@ -23,7 +24,7 @@ export interface FindOneFunction { selectionType: SelectionT; optionsType: OptionsT; schemaType: SchemaT | null; - plan?: (fieldValue: string, options?: LimitToKnownKeys) => GQLBuilderResult; + plan: (fieldValue: string, options?: LimitToKnownKeys) => GQLBuilderResult; } export interface MaybeFindOneFunction { @@ -38,7 +39,7 @@ export interface MaybeFindOneFunction selectionType: SelectionT; optionsType: OptionsT; schemaType: SchemaT | null; - plan?: (fieldValue: string, options?: LimitToKnownKeys) => GQLBuilderResult; + plan: (fieldValue: string, options?: LimitToKnownKeys) => GQLBuilderResult; } export interface FindManyFunction { @@ -52,7 +53,7 @@ export interface FindManyFunction { selectionType: SelectionT; optionsType: OptionsT; schemaType: SchemaT | null; - plan?: (options?: LimitToKnownKeys) => GQLBuilderResult; + plan: (options?: LimitToKnownKeys) => GQLBuilderResult; } export interface FindFirstFunction { @@ -66,7 +67,7 @@ export interface FindFirstFunction { selectionType: SelectionT; optionsType: OptionsT; schemaType: SchemaT | null; - plan?: (options?: LimitToKnownKeys) => GQLBuilderResult; + plan: (options?: LimitToKnownKeys) => GQLBuilderResult; } export interface MaybeFindFirstFunction { @@ -80,7 +81,7 @@ export interface MaybeFindFirstFunction(options?: LimitToKnownKeys) => GQLBuilderResult; + plan: (options?: LimitToKnownKeys) => GQLBuilderResult; } export interface ViewFunctionWithoutVariables { @@ -158,7 +159,14 @@ export interface ActionFunctionMetadata : never; - plan?: (options?: LimitToKnownKeys) => GQLBuilderResult; + plan: (options?: LimitToKnownKeys) => GQLBuilderResult; + processResult: ( + defaultSelection: FieldSelection | null, + response: any, + record: any, + modelSelectionField: string, + hasReturnType?: HasReturnType | null + ) => any; /** @deprecated */ hasCreateOrUpdateEffect?: boolean; } @@ -215,7 +223,7 @@ export interface GetFunction { selectionType: SelectionT; optionsType: OptionsT; schemaType: SchemaT | null; - plan?: (options?: LimitToKnownKeys) => GQLBuilderResult; + plan: (options?: LimitToKnownKeys) => GQLBuilderResult; } export interface GlobalActionFunction { @@ -228,7 +236,7 @@ export interface GlobalActionFunction { variables: VariablesOptions; variablesType: VariablesT; isBulk?: undefined; - plan?: (variables?: VariablesOptions) => GQLBuilderResult; + plan: (variables?: VariablesOptions) => GQLBuilderResult; } export type AnyActionFunction = diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4911c8c45..b0361466c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3,6 +3,7 @@ import type { VariableOptions } from "tiny-graphql-query-compiler"; import type { FieldSelection } from "./FieldSelection.js"; import type { ActionFunction, + ActionFunctionMetadata, AnyActionFunction, BulkActionFunction, GlobalActionFunction, @@ -10,6 +11,7 @@ import type { ViewFunctionWithoutVariables, ViewFunctionWithVariables, } from "./GadgetFunctions.js"; +import type { GadgetRecord } from "./GadgetRecord.js"; /** * Allows detecting an any type, this is rather tricky: @@ -927,6 +929,45 @@ export type EnqueueBackgroundActionOptions = { startAt?: Date | string; } & Partial; +export type BackgroundActionResultData< + F extends ActionFunctionMetadata | GlobalActionFunction, + Selection +> = F extends ActionFunction + ? F["hasReturnType"] extends true + ? any + : GadgetRecord< + Select< + Exclude, + DefaultSelection< + F["selectionType"], + Selection extends { select?: F["selectionType"] | null | undefined } ? Selection : never, + F["defaultSelection"] + > + > + > + : any; + +export type BuildOperationResult = { + query: string; + variables: Record; +}; + +export type BackgroundActionResult = { + id: string; + outcome: string | null; + result: Data | null; +}; + +export interface AnyBackgroundActionHandle< + SchemaT, + Action extends ActionFunctionMetadata | GlobalActionFunction +> { + result, ResultData = BackgroundActionResultData>( + options?: Options + ): Promise; + cancel(): Promise; +} + export type ActionFunctionOptions = Action extends ActionFunction ? Options : Action extends BulkActionFunction diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6026240a..511009f5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,27 @@ importers: specifier: ^7.1.4 version: 7.1.4(@types/node@22.18.1)(tsx@4.9.3) + packages/client-hooks: + devDependencies: + '@gadgetinc/core': + specifier: workspace:* + version: link:../core + '@jest/globals': + specifier: '*' + version: 29.7.0 + '@swc/jest': + specifier: '*' + version: 0.2.36(@swc/core@1.13.5) + '@types/jest': + specifier: '*' + version: 29.5.12 + '@urql/core': + specifier: '*' + version: 4.0.10 + jest: + specifier: '*' + version: 29.7.0(@types/node@22.18.1) + packages/core: dependencies: '@urql/core': @@ -682,6 +703,17 @@ packages: graphql: 16.11.0 dev: false + /@0no-co/graphql.web@1.2.0(graphql@16.8.1): + resolution: {integrity: sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==} + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 + peerDependenciesMeta: + graphql: + optional: true + dependencies: + graphql: 16.8.1 + dev: true + /@aashutoshrathi/word-wrap@1.2.6: resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} engines: {node: '>=0.10.0'} @@ -1336,21 +1368,21 @@ packages: '@babel/plugin-transform-parameters': 7.24.7(@babel/core@7.19.0) dev: true - /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.19.0): + /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.3): resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.18.9 dev: true - /@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.19.0): + /@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.3): resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.18.9 dev: true @@ -1363,6 +1395,15 @@ packages: '@babel/helper-plugin-utils': 7.18.9 dev: true + /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.3): + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.28.3 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + /@babel/plugin-syntax-flow@7.24.7(@babel/core@7.19.0): resolution: {integrity: sha512-9G8GYT/dxn/D1IIKOUBmGX0mnmj46mGH9NnZyJLwtCpgh5f7D2VbuKodb+2s9m1Yavh1s7ASQN8lf0eqrb1LTw==} engines: {node: '>=6.9.0'} @@ -1383,21 +1424,21 @@ packages: '@babel/helper-plugin-utils': 7.24.7 dev: true - /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.19.0): + /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.3): resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.18.9 dev: true - /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.19.0): + /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.3): resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.18.9 dev: true @@ -1421,30 +1462,40 @@ packages: '@babel/helper-plugin-utils': 7.24.7 dev: true - /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.19.0): + /@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.28.3): + resolution: {integrity: sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.28.3 + '@babel/helper-plugin-utils': 7.24.7 + dev: true + + /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.3): resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.18.9 dev: true - /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.19.0): + /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.3): resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.18.9 dev: true - /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.19.0): + /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.3): resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.18.9 dev: true @@ -1457,41 +1508,50 @@ packages: '@babel/helper-plugin-utils': 7.18.9 dev: true - /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.19.0): + /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.3): + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.28.3 + '@babel/helper-plugin-utils': 7.18.9 + dev: true + + /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.3): resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.18.9 dev: true - /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.19.0): + /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.3): resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.18.9 dev: true - /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.19.0): + /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.3): resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.18.9 dev: true - /@babel/plugin-syntax-typescript@7.17.12(@babel/core@7.19.0): + /@babel/plugin-syntax-typescript@7.17.12(@babel/core@7.28.3): resolution: {integrity: sha512-TYY0SXFiO31YXtNg3HtFwNJHjLsAyIIhAhNWkQ5whPPS7HWUFlg9z0Ta4qAQNjQbP1wsSt/oKkmZ/4/WWdMUpw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.18.9 dev: true @@ -4105,9 +4165,9 @@ packages: '@types/node': 22.18.1 ansi-escapes: 4.3.2 chalk: 4.1.2 - ci-info: 3.3.1 + ci-info: 3.9.0 exit: 0.1.2 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 jest-changed-files: 29.7.0 jest-config: 29.7.0(@types/node@22.18.1) jest-haste-map: 29.7.0 @@ -4121,7 +4181,7 @@ packages: jest-util: 29.7.0 jest-validate: 29.7.0 jest-watcher: 29.7.0 - micromatch: 4.0.5 + micromatch: 4.0.8 pretty-format: 29.7.0 slash: 3.0.0 strip-ansi: 6.0.1 @@ -4203,13 +4263,13 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@jridgewell/trace-mapping': 0.3.19 + '@jridgewell/trace-mapping': 0.3.30 '@types/node': 22.18.1 chalk: 4.1.2 collect-v8-coverage: 1.0.1 exit: 0.1.2 glob: 7.2.3 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 istanbul-lib-coverage: 3.2.0 istanbul-lib-instrument: 6.0.0 istanbul-lib-report: 3.0.0 @@ -7581,6 +7641,18 @@ packages: /@swc/counter@0.1.3: resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + /@swc/jest@0.2.36(@swc/core@1.13.5): + resolution: {integrity: sha512-8X80dp81ugxs4a11z1ka43FPhP+/e+mJNXJSxiNYk8gIX/jPBtY4gQTrKu/KIoco8bzKuPI5lUxjfLiGsfvnlw==} + engines: {npm: '>= 7.0.0'} + peerDependencies: + '@swc/core': '*' + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@swc/core': 1.13.5 + '@swc/counter': 0.1.3 + jsonc-parser: 3.2.0 + dev: true + /@swc/jest@0.2.36(@swc/core@1.3.90): resolution: {integrity: sha512-8X80dp81ugxs4a11z1ka43FPhP+/e+mJNXJSxiNYk8gIX/jPBtY4gQTrKu/KIoco8bzKuPI5lUxjfLiGsfvnlw==} engines: {npm: '>= 7.0.0'} @@ -7727,16 +7799,6 @@ packages: resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} dev: true - /@types/babel__core@7.1.19: - resolution: {integrity: sha512-WEOTgRsbYkvA/KCsDwVEGkd7WAr1e3g31VHQ8zy5gul/V1qKullU/BU5I68X5v7V3GnB9eotmom4v5a5gjxorw==} - dependencies: - '@babel/parser': 7.19.0 - '@babel/types': 7.24.7 - '@types/babel__generator': 7.6.4 - '@types/babel__template': 7.4.1 - '@types/babel__traverse': 7.20.6 - dev: true - /@types/babel__core@7.20.5: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} dependencies: @@ -8166,6 +8228,15 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true + /@urql/core@4.0.10: + resolution: {integrity: sha512-Vs3nOSAnYftqOCg034Ostp/uSqWlQg5ryLIzcOrm8+O43s4M+Ew4GQAuemIH7ZDB8dek6h61zzWI3ujd8FH3NA==} + dependencies: + '@0no-co/graphql.web': 1.2.0(graphql@16.8.1) + wonka: 6.3.2 + transitivePeerDependencies: + - graphql + dev: true + /@urql/core@4.0.10(graphql@16.11.0): resolution: {integrity: sha512-Vs3nOSAnYftqOCg034Ostp/uSqWlQg5ryLIzcOrm8+O43s4M+Ew4GQAuemIH7ZDB8dek6h61zzWI3ujd8FH3NA==} dependencies: @@ -8638,17 +8709,17 @@ packages: resolution: {integrity: sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g==} dev: true - /babel-jest@29.7.0(@babel/core@7.19.0): + /babel-jest@29.7.0(@babel/core@7.28.3): resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: '@babel/core': ^7.8.0 dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 '@jest/transform': 29.7.0 - '@types/babel__core': 7.1.19 + '@types/babel__core': 7.20.5 babel-plugin-istanbul: 6.1.1 - babel-preset-jest: 29.6.3(@babel/core@7.19.0) + babel-preset-jest: 29.6.3(@babel/core@7.28.3) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 @@ -8674,7 +8745,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@babel/template': 7.18.10 - '@babel/types': 7.24.7 + '@babel/types': 7.28.2 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.6 dev: true @@ -8692,24 +8763,24 @@ packages: resolution: {integrity: sha512-Xj9XuRuz3nTSbaTXWv3itLOcxyF4oPD8douBBmj7U9BBC6nEBYfyOJYQMf/8PJAFotC62UY5dFfIGEPr7WswzQ==} dev: true - /babel-preset-current-node-syntax@1.0.1(@babel/core@7.19.0): + /babel-preset-current-node-syntax@1.0.1(@babel/core@7.28.3): resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==} peerDependencies: '@babel/core': ^7.0.0 dependencies: - '@babel/core': 7.19.0 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.19.0) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.19.0) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.19.0) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.19.0) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.19.0) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.19.0) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.19.0) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.19.0) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.19.0) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.19.0) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.19.0) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.19.0) + '@babel/core': 7.28.3 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.3) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.3) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.3) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.3) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.3) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.3) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.3) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.3) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.3) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.3) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.3) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.3) dev: true /babel-preset-fbjs@3.4.0(@babel/core@7.19.0): @@ -8749,15 +8820,15 @@ packages: - supports-color dev: true - /babel-preset-jest@29.6.3(@babel/core@7.19.0): + /babel-preset-jest@29.6.3(@babel/core@7.28.3): resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: '@babel/core': ^7.0.0 dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 babel-plugin-jest-hoist: 29.6.3 - babel-preset-current-node-syntax: 1.0.1(@babel/core@7.19.0) + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.28.3) dev: true /balanced-match@1.0.2: @@ -9582,7 +9653,7 @@ packages: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 jest-config: 29.7.0(@types/node@22.18.1) jest-util: 29.7.0 prompts: 2.4.2 @@ -12511,16 +12582,16 @@ packages: ts-node: optional: true dependencies: - '@babel/core': 7.19.0 + '@babel/core': 7.28.3 '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 '@types/node': 22.18.1 - babel-jest: 29.7.0(@babel/core@7.19.0) + babel-jest: 29.7.0(@babel/core@7.28.3) chalk: 4.1.2 - ci-info: 3.3.1 + ci-info: 3.9.0 deepmerge: 4.3.1 glob: 7.2.3 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 jest-circus: 29.7.0 jest-environment-node: 29.7.0 jest-get-type: 29.6.3 @@ -12529,7 +12600,7 @@ packages: jest-runner: 29.7.0 jest-util: 29.7.0 jest-validate: 29.7.0 - micromatch: 4.0.5 + micromatch: 4.0.8 parse-json: 5.2.0 pretty-format: 29.7.0 slash: 3.0.0 @@ -12616,11 +12687,11 @@ packages: '@types/node': 22.18.1 anymatch: 3.1.2 fb-watchman: 2.0.1 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 jest-regex-util: 29.6.3 jest-util: 29.7.0 jest-worker: 29.7.0 - micromatch: 4.0.5 + micromatch: 4.0.8 walker: 1.0.8 optionalDependencies: fsevents: 2.3.3 @@ -12732,7 +12803,7 @@ packages: '@types/node': 22.18.1 chalk: 4.1.2 emittery: 0.13.1 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 jest-docblock: 29.7.0 jest-environment-node: 29.7.0 jest-haste-map: 29.7.0 @@ -12783,18 +12854,18 @@ packages: resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@babel/core': 7.19.0 - '@babel/generator': 7.19.0 - '@babel/plugin-syntax-jsx': 7.18.6(@babel/core@7.19.0) - '@babel/plugin-syntax-typescript': 7.17.12(@babel/core@7.19.0) - '@babel/types': 7.19.0 + '@babel/core': 7.28.3 + '@babel/generator': 7.28.3 + '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.28.3) + '@babel/plugin-syntax-typescript': 7.17.12(@babel/core@7.28.3) + '@babel/types': 7.28.2 '@jest/expect-utils': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - babel-preset-current-node-syntax: 1.0.1(@babel/core@7.19.0) + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.28.3) chalk: 4.1.2 expect: 29.7.0 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 jest-diff: 29.7.0 jest-get-type: 29.6.3 jest-matcher-utils: 29.7.0 @@ -12802,7 +12873,7 @@ packages: jest-util: 29.7.0 natural-compare: 1.4.0 pretty-format: 29.7.0 - semver: 7.6.2 + semver: 7.7.2 transitivePeerDependencies: - supports-color dev: true