From c81cbdef114f91bad30ec7cc154be1187a1be957 Mon Sep 17 00:00:00 2001 From: Ashley Neaves Date: Thu, 7 May 2026 14:01:33 +0100 Subject: [PATCH 1/8] Added details to package.json for publishing --- lib/components/WithEndpoint/index.tsx | 4 +++- lib/main.ts | 1 + package.json | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/components/WithEndpoint/index.tsx b/lib/components/WithEndpoint/index.tsx index 164fe4f..4dfb44d 100644 --- a/lib/components/WithEndpoint/index.tsx +++ b/lib/components/WithEndpoint/index.tsx @@ -8,6 +8,7 @@ import { EndpointDoubleSlider } from "./EndpointDoubleSlider"; import { EndpointDropdown } from "./EndpointDropdown"; import { EndpointInput } from "./EndpointInput"; import { EndpointSlider } from "./EndpointSlider"; +import { EndpointMultipliedInput } from "./EndpointRangeInput"; import type { EndpointProps } from "./util"; import { useRequestHandler } from "./util"; // import { isEqual } from 'lodash'; @@ -245,7 +246,8 @@ export { EndpointDoubleSlider, EndpointDropdown, EndpointInput, - EndpointSlider + EndpointSlider, + EndpointMultipliedInput as EndpointRangeInput }; export { WithEndpoint }; diff --git a/lib/main.ts b/lib/main.ts index 701ff8c..30cedac 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -9,6 +9,7 @@ export { OdinTable, OdinTableRow } from './components/OdinTable'; export { ParamController } from './components/ParamController'; export { TitleCard } from './components/TitleCard'; export { EndpointButton, EndpointCheckbox, EndpointDropdown, EndpointInput, EndpointSlider, EndpointDoubleSlider, WithEndpoint } from './components/WithEndpoint'; +export { EndpointRangeInput } from './components/WithEndpoint' export type { AdapterEndpoint, ParamNode, ParamTree } from "./components/AdapterEndpoint"; export type { AdapterEndpoint_t } from "./components/AdapterEndpoint/AdapterEndpoint.types"; diff --git a/package.json b/package.json index aa2ea92..9c9b5ac 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,20 @@ "private": false, "version": "2.1.2", "type": "module", + "description": "Component library designed to work with the Odin Detector Control API", + "author": { + "name": "Ashley Neaves", + "email": "asley.neaves@stfc.ac.uk" + }, + "repository": { + "type": "git", + "url": "https://github.com/stfc-aeg/odin-react" + }, + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/stfc-aeg/odin-react/issues" + }, + "homepage": "https://stfc-aeg.github.io/odin-react", "main": "./dist/odin-react.umd.cjs.js", "module": "./dist/odin-react.js", "types": "./dist/main.d.ts", From ac687b0b7646d98233380eefbd5701b4bc859818 Mon Sep 17 00:00:00 2001 From: Ashley Neaves Date: Tue, 12 May 2026 15:34:21 +0100 Subject: [PATCH 2/8] Added Tanstack Query based AdapterEndpoint --- .../AdapterEndpoint/QueryEndpoint.ts | 120 ++++++++++++++ package-lock.json | 147 ++++-------------- package.json | 1 + 3 files changed, 151 insertions(+), 117 deletions(-) create mode 100644 lib/components/AdapterEndpoint/QueryEndpoint.ts diff --git a/lib/components/AdapterEndpoint/QueryEndpoint.ts b/lib/components/AdapterEndpoint/QueryEndpoint.ts new file mode 100644 index 0000000..cdc073c --- /dev/null +++ b/lib/components/AdapterEndpoint/QueryEndpoint.ts @@ -0,0 +1,120 @@ +import { useMutation, useQuery, useQueryClient, type QueryFunctionContext } from "@tanstack/react-query"; +import type { Metadata, ParamTree, AdapterEndpoint, ParamNode, getConfig } from "./AdapterEndpoint.types"; +import { useError } from "../OdinErrorContext"; +import axios, { AxiosRequestConfig, ResponseType } from "axios"; + +const smartPathSplit = (path: string) => { + return path.split("/").filter(Boolean); +} + +function useAdapterEndpoint = ParamNode>( + adapter: string, endpoint_url: string, interval?: number, timeout?: number +) { + + const client = useQueryClient(); + const errCtx = useError(); + + //TODO: this does not check for Odin Control Version (needing "0.1" or not) + const base_url = [endpoint_url, "api", adapter].join("/"); + const queryKey = [endpoint_url, ...smartPathSplit(adapter)]; + + const axiosInstance = axios.create({ + baseURL: base_url, + timeout: timeout, + headers: { + "Content-Type": "application/json" + } + }) + + const handleError = (err: unknown) => { + let errorMsg: string = ""; + if (axios.isAxiosError(err)) { + if (err.response) { + const method = err.response.config.method ? err.response.config.method.toUpperCase() : "UNDEFINED"; + const reason = err.response.data?.error || ""; + // const address = err.response + errorMsg = `${method} request failed with status ${err.response.status} : ${reason}`; + } + else if (err.request) { + errorMsg = `Network error sending request to ${base_url}: ${err.message}`; + } + } + else { + errorMsg = `Unknown error sending request to ${base_url}`; + } + const error = new Error(errorMsg); + errCtx.setError(error); + + return error; // return error so it can be caught/thrown + } + + const queryGet = async ({ queryKey, signal }: QueryFunctionContext<[ResponseType | "metadata", ...string[]]>) => { + const [wants_metadata, addr, ...adapter] = queryKey; + console.debug(`Query GET, addr ${addr}, adapter path ${adapter}`); + const responseType = wants_metadata === "metadata" ? "json" : wants_metadata; + const request_config: AxiosRequestConfig = { signal: signal, responseType: responseType } + if (wants_metadata === "metadata") { + request_config.headers = { Accept: "application/json;metadata=true" }; + } + try { + const response = await axiosInstance.get("", request_config); + return response.data; + } catch (err) { + throw handleError(err); + } + } + + const queryPut = async ({ path = "", data }: { path?: string, data: ParamNode }) => { + const response = await axiosInstance.put(path, data); + return response.data; + } + + const query = useQuery({ + queryKey: ["json", ...queryKey], + queryFn: queryGet, + staleTime: interval || Infinity, + refetchInterval: interval + }); + + const { data: metadata } = useQuery({ + queryKey: ["metadata", ...queryKey], + queryFn: queryGet>, + staleTime: Infinity + }); + + const mutation = useMutation({ + mutationKey: queryKey, + mutationFn: queryPut, + onError: handleError + }); + + const put = async (data: T, param_path?: string) => { + console.debug(`PUT: ${base_url}/${param_path}, data: `, data); + mutation.mutate( + { path: param_path, data: data }, + { onSuccess: async () => { await client.invalidateQueries({ queryKey: ["json", ...queryKey] }) } } + ) + } + + const get = async (param_path = "", config?: getConfig) => { + console.debug(`GET: ${base_url}/${param_path}`); + + const { wants_metadata = false, responseType = 'json' } = config ?? {}; + const key: [ResponseType | "metadata", ...string[]] = [ + wants_metadata ? "metadata" : responseType, + endpoint_url, + ...smartPathSplit(adapter), + ...smartPathSplit(param_path) + ] + const data = await client.fetchQuery({queryKey: key, queryFn: queryGet}); + return data; + + } + + return { + data: query.data as Tree, + metadata: metadata as Metadata, + loading: mutation.isPending, + get, put + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 65f13e2..cda417a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,15 @@ { - "name": "odin-react", + "name": "@dssg/odin-react", "version": "2.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "odin-react", + "name": "@dssg/odin-react", "version": "2.1.2", + "license": "Apache-2.0", "dependencies": { + "@tanstack/react-query": "^5.100.9", "axios": "^1.8.1", "react": "^19.1.0", "react-bootstrap-icons": "^1.11.5", @@ -35,7 +37,6 @@ "eslint-plugin-react-hooks": "^7.0.0", "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.2.17", - "glob": "^11.0.1", "globals": "^17.0.0", "msw": "^2.12.10", "msw-storybook-addon": "^2.0.6", @@ -1346,16 +1347,6 @@ "@swc/helpers": "^0.5.0" } }, - "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.7.0.tgz", @@ -2743,6 +2734,32 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.100.9", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz", + "integrity": "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz", + "integrity": "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.9" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -5856,23 +5873,6 @@ "css-font": "^1.2.0" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -6079,31 +6079,6 @@ "weak-map": "^1.0.5" } }, - "node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -6117,45 +6092,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/global-prefix": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", @@ -6929,22 +6865,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jju": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", @@ -8106,13 +8026,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", diff --git a/package.json b/package.json index 9c9b5ac..73e10d4 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "vitest": "^4.1.0" }, "dependencies": { + "@tanstack/react-query": "^5.100.9", "axios": "^1.8.1", "react": "^19.1.0", "react-bootstrap-icons": "^1.11.5", From 0eca2518854ff38c7720666b5d97b1c785e8abae Mon Sep 17 00:00:00 2001 From: Ashley Neaves Date: Wed, 13 May 2026 14:35:09 +0100 Subject: [PATCH 3/8] New Tanstack Query Based Adapter Endpoint can handle both Odin Control 1.6 and 2.0 Updated tests Modified the RequestHandler to work with new Endpoint --- .storybook/preview.tsx | 5 ++ .../AdapterEndpoint/AdapterEndpoint.types.ts | 26 +++---- .../AdapterEndpoint/QueryEndpoint.ts | 78 ++++++++++++++----- .../AdapterEndpoint/index.stories.tsx | 10 +-- lib/components/AdapterEndpoint/index.tsx | 14 ++-- lib/components/WithEndpoint/util.ts | 21 ++--- src/EndpointPage.tsx | 20 ++--- src/main.tsx | 22 +++++- 8 files changed, 129 insertions(+), 67 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index af27d2d..08312d9 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -10,6 +10,9 @@ import { apiVersionHandler, getHandler, getHandlerUpdate, putHandler, putHandler import 'bootstrap/dist/css/bootstrap.min.css'; import type { ParamNode } from '../lib/components/AdapterEndpoint'; import { useEffect } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); /* * Initializes MSW @@ -113,9 +116,11 @@ const preview: Preview = { "data-bs-theme", isDarkMode ? "dark" : "light" )) return ( + + ) } ], diff --git a/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts b/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts index 4b4e4d8..2bf7760 100644 --- a/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts +++ b/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts @@ -7,16 +7,16 @@ interface AdapterEndpoint { * Recursive Nested dictionary structure representing the adapter Param Tree. Should be read only * from this interface */ - data: Readonly; + data?: Readonly; /** * Dictionary structure containing the adapter Metadata, if its implimented by the adapter */ - metadata: Readonly>; + metadata?: Readonly>; /** * Any Errors that occur during http methods or otherwise will be accessible here */ - error: null | Error; + // error: null | Error; /** * State of the http client connection to the adapter. * If true, the AdapterEndpoint is awaiting a response from the adapter. @@ -26,18 +26,18 @@ interface AdapterEndpoint { /** * Flag token that will change whenever the data has changed, to alert WithEndpoint components */ - updateFlag: symbol; + // updateFlag: symbol; /** * Connection status for the AdapterEndpoint. */ - status: status; + // status: status; /** * Api String. Used to differentiate between Odin Control versions. * If blank, Odin Control is v2.* */ - apiVersion: string; + apiVersion: "" | "0.1"; /** * Async http GET method. Request the provided value(s) from the parameter tree. * It is worth noting that this method does NOT automatically merge the response into the Endpoint.Data object. @@ -53,7 +53,7 @@ interface AdapterEndpoint { * @param {string} param_path - the path you want to PUT to. Defaults to an empty string for a top level PUT * @returns An Async promise, that when resolved will return the data within the HTTP response */ - put: (data: {[key: string]: T}, param_path?: string) => Promise<{[Property in keyof typeof data]: T}>; + put: (data: T, param_path?: string) => Promise; /** * Async http POST method. Not often implemented by Adapters, but potentially used to post data @@ -62,7 +62,7 @@ interface AdapterEndpoint { * @param param_path - the path you want to POST to. defaults to an empty string, for a top level POST * @returns An Async promise, that when resolved will return the data within the HTTP response */ - post: (data: ParamNode, param_path?: string) => Promise; + // post: (data: ParamNode, param_path?: string) => Promise; /** * Async http DELETE method. Renamed because 'delete' is a reserved word in javascript. Not often @@ -70,12 +70,12 @@ interface AdapterEndpoint { * @param param_path the path to the data you want to DELETE. Defaults to an empty string * @returns An Async promise, that when resolved will return the data within the HTTP response */ - remove: (param_path?: string) => Promise; + // remove: (param_path?: string) => Promise; /** * A method to automatically perform a top level GET request and refresh the AdapterEndpoint's current view of the data * @returns */ - refreshData: () => void; + // refreshData: () => void; /** * A method to merge one chunk of (potentially nested) data into the AdapterEndpoint's current view of the data, * to update the data without having to GET from the entire adapter @@ -83,18 +83,18 @@ interface AdapterEndpoint { * @param {string} param_path - the location that data within the ParamTree to merge it * @returns */ - mergeData: (newData: ParamNode, param_path: string) => void; + // mergeData: (newData: ParamNode, param_path: string) => void; } -/** Defines allowed values for paramater primatives. */ +/** Defines allowed values for parameter primitives. */ type Parameter = string | number | boolean | null | undefined; /** Dict structure for the Parameters. */ type ParamNode = {[key:string]: ParamTree}; /** Any possible value from the Param Tree (Basically any possible JSON value) - * This could be a primative value, a dict structure, or an array of any of these values. + * This could be a primitive value, a dict structure, or an array of any of these values. * This flexibility allows for the recursive nested structure of the Parameter Tree */ type ParamTree = Parameter | ParamTree[] | ParamNode; diff --git a/lib/components/AdapterEndpoint/QueryEndpoint.ts b/lib/components/AdapterEndpoint/QueryEndpoint.ts index cdc073c..930bfe4 100644 --- a/lib/components/AdapterEndpoint/QueryEndpoint.ts +++ b/lib/components/AdapterEndpoint/QueryEndpoint.ts @@ -2,20 +2,26 @@ import { useMutation, useQuery, useQueryClient, type QueryFunctionContext } from import type { Metadata, ParamTree, AdapterEndpoint, ParamNode, getConfig } from "./AdapterEndpoint.types"; import { useError } from "../OdinErrorContext"; import axios, { AxiosRequestConfig, ResponseType } from "axios"; +import { useState } from "react"; const smartPathSplit = (path: string) => { return path.split("/").filter(Boolean); } +const smartPathJoin = (path: string[]) => { + return path.filter(Boolean).join("/"); +} + function useAdapterEndpoint = ParamNode>( adapter: string, endpoint_url: string, interval?: number, timeout?: number -) { +): AdapterEndpoint { const client = useQueryClient(); const errCtx = useError(); + const [apiVersion, setApiVersion] = useState<"" | "0.1">(""); //TODO: this does not check for Odin Control Version (needing "0.1" or not) - const base_url = [endpoint_url, "api", adapter].join("/"); + const base_url = smartPathJoin([endpoint_url, "api", apiVersion]); const queryKey = [endpoint_url, ...smartPathSplit(adapter)]; const axiosInstance = axios.create({ @@ -29,14 +35,19 @@ function useAdapterEndpoint = ParamNode>( const handleError = (err: unknown) => { let errorMsg: string = ""; if (axios.isAxiosError(err)) { + if (err.code == "ERR_CANCELED") { + // request was cancelled. Does not need to be reported + return null; + } + const addr = err.config?.url ?? ""; if (err.response) { const method = err.response.config.method ? err.response.config.method.toUpperCase() : "UNDEFINED"; const reason = err.response.data?.error || ""; // const address = err.response - errorMsg = `${method} request failed with status ${err.response.status} : ${reason}`; + errorMsg = `${method} request to addr ${addr} failed with status ${err.response.status} : ${reason}`; } else if (err.request) { - errorMsg = `Network error sending request to ${base_url}: ${err.message}`; + errorMsg = `Network error sending request to ${addr}: ${err.message}`; } } else { @@ -49,23 +60,45 @@ function useAdapterEndpoint = ParamNode>( } const queryGet = async ({ queryKey, signal }: QueryFunctionContext<[ResponseType | "metadata", ...string[]]>) => { - const [wants_metadata, addr, ...adapter] = queryKey; - console.debug(`Query GET, addr ${addr}, adapter path ${adapter}`); + const [wants_metadata, addr, ...path] = queryKey; + const adapter = smartPathJoin(path); + console.debug(`Query GET, addr "${addr}", adapter path "${adapter}"`); + const responseType = wants_metadata === "metadata" ? "json" : wants_metadata; const request_config: AxiosRequestConfig = { signal: signal, responseType: responseType } if (wants_metadata === "metadata") { request_config.headers = { Accept: "application/json;metadata=true" }; } + try { - const response = await axiosInstance.get("", request_config); + const response = await axiosInstance.get(adapter, request_config); return response.data; } catch (err) { - throw handleError(err); + if (axios.isAxiosError(err) && err.code == "ERR_NETWORK") { + console.warn(`Network Error for ${err.config?.baseURL}/${err.config?.url}`); + // Network error. could be caused by the wrong API type (Odin 1.6.* vs 2.*) + // try adding/removing the 0.1 from the URL and try again + const version = apiVersion ? "" : "0.1"; + setApiVersion(version); + + try { + const response = await axiosInstance.get(smartPathJoin([version, adapter]), request_config); + return response.data; + } catch (err) { + console.warn("Retry Failed"); + + throw handleError(err); + } + + } else { + throw handleError(err); + } } } const queryPut = async ({ path = "", data }: { path?: string, data: ParamNode }) => { - const response = await axiosInstance.put(path, data); + const request_path = smartPathJoin([adapter, path]); + const response = await axiosInstance.put(request_path, data); return response.data; } @@ -84,21 +117,24 @@ function useAdapterEndpoint = ParamNode>( const mutation = useMutation({ mutationKey: queryKey, - mutationFn: queryPut, - onError: handleError + mutationFn: queryPut }); - const put = async (data: T, param_path?: string) => { + const put = async (data: T, param_path?: string) => { console.debug(`PUT: ${base_url}/${param_path}, data: `, data); - mutation.mutate( - { path: param_path, data: data }, - { onSuccess: async () => { await client.invalidateQueries({ queryKey: ["json", ...queryKey] }) } } - ) + try { + return await mutation.mutateAsync( + { path: param_path, data: data }, + { onSuccess: async () => { await client.invalidateQueries({ queryKey: ["json", ...queryKey] }) } } + ) as T; + } catch (err) { + throw handleError(err); + } } const get = async (param_path = "", config?: getConfig) => { console.debug(`GET: ${base_url}/${param_path}`); - + const { wants_metadata = false, responseType = 'json' } = config ?? {}; const key: [ResponseType | "metadata", ...string[]] = [ wants_metadata ? "metadata" : responseType, @@ -106,7 +142,7 @@ function useAdapterEndpoint = ParamNode>( ...smartPathSplit(adapter), ...smartPathSplit(param_path) ] - const data = await client.fetchQuery({queryKey: key, queryFn: queryGet}); + const data = await client.fetchQuery({ queryKey: key, queryFn: queryGet }); return data; } @@ -115,6 +151,8 @@ function useAdapterEndpoint = ParamNode>( data: query.data as Tree, metadata: metadata as Metadata, loading: mutation.isPending, - get, put + get, put, apiVersion } -} \ No newline at end of file +} + +export { useAdapterEndpoint } \ No newline at end of file diff --git a/lib/components/AdapterEndpoint/index.stories.tsx b/lib/components/AdapterEndpoint/index.stories.tsx index 4846303..f40a681 100644 --- a/lib/components/AdapterEndpoint/index.stories.tsx +++ b/lib/components/AdapterEndpoint/index.stories.tsx @@ -222,7 +222,7 @@ export const Default: Story = { await userEvent.type(pathTextbox, path); await userEvent.click(getButton); await expect(args.endpoint.get(path), `get: ${path}`) - .rejects.toThrow(`GET request failed with status 400 : Invalid Path: ${path}`); + .rejects.toThrow(`GET request to addr ${args.adapter}/${path} failed with status 400 : Invalid Path: ${path}`); path = "data/set_data"; await userEvent.clear(pathTextbox) @@ -269,8 +269,8 @@ export const Default: Story = { await userEvent.clear(putDataTextBox); await userEvent.type(putDataTextBox, "{{\"rand_num\": 55}"); await userEvent.click(putButton); - await expect(args.endpoint.put({ "rand_num": 55 })) - .rejects.toThrow("PUT request failed with status 400 : Parameter rand_num is read-only"); + await expect(args.endpoint.put({ "rand_num": 55 }), "Put: Invalid Option") + .rejects.toThrow(`PUT request to addr ${args.adapter} failed with status 400 : Parameter rand_num is read-only`); }); } } @@ -317,7 +317,7 @@ export const OlderOdinVersion: Story = { await userEvent.type(pathTextbox, path); await userEvent.click(getButton); await expect(args.endpoint.get(path), `get: ${path}`) - .rejects.toThrow(`GET request failed with status 400 : Invalid Path: ${path}`); + .rejects.toThrow(`GET request to addr ${args.adapter}/${path} failed with status 400 : Invalid Path: ${path}`); path = "data/set_data"; await userEvent.clear(pathTextbox) @@ -356,7 +356,7 @@ export const OlderOdinVersion: Story = { await userEvent.type(putDataTextBox, "{{\"rand_num\": 55}"); await userEvent.click(putButton); await expect(args.endpoint.put({"rand_num": 55})) - .rejects.toThrow("PUT request failed with status 400 : Parameter rand_num is read-only"); + .rejects.toThrow(`PUT request to addr ${args.adapter} failed with status 400 : Parameter rand_num is read-only`); }); } }; diff --git a/lib/components/AdapterEndpoint/index.tsx b/lib/components/AdapterEndpoint/index.tsx index 80196da..d13d964 100644 --- a/lib/components/AdapterEndpoint/index.tsx +++ b/lib/components/AdapterEndpoint/index.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react"; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; import type { AdapterEndpoint, Metadata, Parameter, ParamNode, ParamTree, getConfig, status, MetadataValue } from "./AdapterEndpoint.types"; import { useError } from "../OdinErrorContext"; - +import { useAdapterEndpoint } from "./QueryEndpoint"; // odin control 2.0 no longer uses an API Version. enum updateFlag_enum { @@ -55,7 +55,7 @@ function getValueFromPath(data: ParamTree, path: string): T | und * @param timeout An option Timeout for API requests, in ms. * @returns */ -function useAdapterEndpoint( +function useAdapterEndpointDEAD( adapter: string, endpoint_url: string, interval?: number, timeout?: number ): AdapterEndpoint { @@ -136,17 +136,17 @@ function useAdapterEndpoint( } }; - const put = async (data: {[key: string]: T}, param_path='') => { + const put = async (data: T, param_path='') => { // const url = [base_url, param_path].join("/"); // assumes param_path does not start with a slash console.debug(`PUT: ${base_url}/${param_path}, data: `, data); - let result: typeof data = {}; - let response: AxiosResponse; + // let result: typeof data = {}; + // let response: AxiosResponse; try { changeAwaiting(true); - response = await axiosInstance.put(param_path, data); - result = response.data; + const response = await axiosInstance.put(param_path, data); + const result = response.data; console.debug("Response: ", result); setStatusFlag(curStatus => curStatus != "init" ? "connected" : curStatus); diff --git a/lib/components/WithEndpoint/util.ts b/lib/components/WithEndpoint/util.ts index 6ec983b..faa7035 100644 --- a/lib/components/WithEndpoint/util.ts +++ b/lib/components/WithEndpoint/util.ts @@ -31,7 +31,11 @@ const getLastPathPart = (path: string): [string, string] => { * @param endpoint AdapterEndpoint to handle the PUT request * @param path Path to the parameter */ -async function sendRequest(val: T, endpoint: AdapterEndpoint, path: string): Promise { +async function sendRequest( + val: T, + endpoint: AdapterEndpoint, + path: string +): Promise { const [sendVal, sendPath] = (function () { if (isParamNode(val)) { @@ -47,11 +51,10 @@ async function sendRequest(val: T, endpoint: AdapterEndpoin })(); try { const response = await endpoint.put(sendVal, sendPath); - endpoint.mergeData(response, sendPath); return response; } catch (err) { console.debug("Error in PUT occurred in WithEndpoint component", err); - return {"error": "PUT Failed"} + return { "error": "PUT Failed" } } } @@ -172,7 +175,7 @@ function useRequestHandler( // as a key and see that the pre_args object doesn't include it, // but I need to find a way to make the PreArgs type accessible // at runtime to do that - if(pre_args && Object.keys(pre_args).includes("value")) { + if (pre_args && Object.keys(pre_args).includes("value")) { if (pre_args.value == undefined || pre_args.value == null) { // if so, overwrite the value with the param to be put pre_args.value = val; @@ -182,9 +185,9 @@ function useRequestHandler( sendRequest(modVal ?? val ?? data, endpoint, fullpath) .then((value) => { - - if(post_args && Object.keys(post_args).includes("value")) { - if(post_args.value == undefined || post_args.value == null) { + + if (post_args && Object.keys(post_args).includes("value")) { + if (post_args.value == undefined || post_args.value == null) { // depending on Odin Control version, and how // many Params we are PUTing, the returned response from sendRequest // can be of of these shapes: @@ -192,7 +195,7 @@ function useRequestHandler( // {[param_name: param]} - single param, Odin 1.6 // {[param_name]: {param_1: x, param_2: y...}} - multiple param, Odin 1.6 // {param_1: x, param_2: y...} - multiple param, Odin 2.0 - if(Object.keys(value).length == 1){ + if (Object.keys(value).length == 1) { // either Odin 1.6 with any number params, or 2.0 with single post_args.value = value[Object.keys(value)[0]] } else { @@ -214,4 +217,4 @@ function useRequestHandler( } export { sendRequest, useRequestHandler }; -export type { EndpointProps, ArgType}; +export type { EndpointProps, ArgType }; diff --git a/src/EndpointPage.tsx b/src/EndpointPage.tsx index 77be83b..6f8fd9f 100644 --- a/src/EndpointPage.tsx +++ b/src/EndpointPage.tsx @@ -56,7 +56,7 @@ export const EndpointPage: React.FC<{endpoint: AdapterEndpoint}> = const [input, changeInput] = useState(0); const [formData, changeFormData] = useState({first_name: "", last_name: "", age: 0}); - const testFunc = (comment: string) => { + const testFunc = ({comment}: {comment: string}) => { console.log(comment); } @@ -71,12 +71,12 @@ export const EndpointPage: React.FC<{endpoint: AdapterEndpoint}> = Trigger New Button - {`Slider Val: ${endpoint.data.num_val ?? "Unknown"}`} + {`Slider Val: ${endpoint.data?.num_val ?? "Unknown"}`} @@ -90,9 +90,9 @@ export const EndpointPage: React.FC<{endpoint: AdapterEndpoint}> = Enter Value: - Half: {endpoint.data.data?.dict?.half} - Is Even: {endpoint.data.data?.dict?.is_even?.toString()} - + Half: {endpoint.data?.data.dict.half} + Is Even: {endpoint.data?.data.dict.is_even.toString()} + Disabled on Even @@ -104,7 +104,7 @@ export const EndpointPage: React.FC<{endpoint: AdapterEndpoint}> = + title={endpoint.data?.selected || "Unknown"}> {endpoint.metadata?.selected?.allowed_values ? endpoint.metadata.selected.allowed_values.map( (selection, index) => ( {selection} @@ -164,19 +164,19 @@ export const EndpointPage: React.FC<{endpoint: AdapterEndpoint}> = - changeFormData(oldForm => Object.assign(oldForm, {first_name: e.target.value}))}/> - changeFormData(oldForm => Object.assign(oldForm, {last_name: e.target.value}))}/> - changeFormData(oldForm => Object.assign(oldForm, {age: Number(e.target.value)}))}/> diff --git a/src/main.tsx b/src/main.tsx index f385c0f..7c93ff4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,11 +2,27 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App.tsx' import { OdinErrorContext } from "odin-react" +import { QueryClientProvider, QueryClient } from '@tanstack/react-query' + +const queryClient = new QueryClient(); + +//Code required to allow the Tanstack Query Dev Tools to function +// This code is only for TypeScript +declare global { + interface Window { + __TANSTACK_QUERY_CLIENT__: + import('@tanstack/query-core').QueryClient + } +} +// This code is for all users +window.__TANSTACK_QUERY_CLIENT__ = queryClient createRoot(document.getElementById('root')!).render( - - - + + + + + , ) From cc12e990af3ef78dd19c74c57b2b8f0ba1e6bc48 Mon Sep 17 00:00:00 2001 From: Ashley Neaves Date: Wed, 13 May 2026 15:51:07 +0100 Subject: [PATCH 4/8] Modified WithEndpoint components to work with new Adapter Endpoint --- .../AdapterEndpoint/AdapterEndpoint.types.ts | 9 +- .../AdapterEndpoint/QueryEndpoint.ts | 158 -------- .../AdapterEndpoint/index.stories.tsx | 2 +- lib/components/AdapterEndpoint/index.tsx | 344 ++++++------------ .../WithEndpoint/EndpointButton.tsx | 4 +- .../WithEndpoint/EndpointDoubleSlider.tsx | 2 +- lib/components/WithEndpoint/EndpointInput.tsx | 15 +- .../EndpointRangeInput.stories.tsx | 8 +- .../WithEndpoint/EndpointRangeInput.tsx | 6 +- .../WithEndpoint/EndpointSlider.tsx | 2 +- lib/components/WithEndpoint/index.tsx | 6 +- lib/components/WithEndpoint/utils.types.ts | 20 - lib/main.ts | 7 +- 13 files changed, 141 insertions(+), 442 deletions(-) delete mode 100644 lib/components/AdapterEndpoint/QueryEndpoint.ts diff --git a/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts b/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts index 2bf7760..5c4eb4a 100644 --- a/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts +++ b/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts @@ -1,7 +1,5 @@ import type { AxiosRequestConfig } from "axios"; -type status = "init" | "connected" | "error"; - interface AdapterEndpoint { /** * Recursive Nested dictionary structure representing the adapter Param Tree. Should be read only @@ -144,9 +142,4 @@ interface getConfig { responseType?: AxiosRequestConfig['responseType']; } -export type { AdapterEndpoint, Metadata, MetadataValue, Parameter, ParamNode, ParamTree, getConfig, status}; - -/** - * @deprecated This is the old name for this type and should be replaced with "AdapterEndpoint" - */ -export type AdapterEndpoint_t = AdapterEndpoint; \ No newline at end of file +export type { AdapterEndpoint, Metadata, MetadataValue, Parameter, ParamNode, ParamTree, getConfig}; \ No newline at end of file diff --git a/lib/components/AdapterEndpoint/QueryEndpoint.ts b/lib/components/AdapterEndpoint/QueryEndpoint.ts deleted file mode 100644 index 930bfe4..0000000 --- a/lib/components/AdapterEndpoint/QueryEndpoint.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { useMutation, useQuery, useQueryClient, type QueryFunctionContext } from "@tanstack/react-query"; -import type { Metadata, ParamTree, AdapterEndpoint, ParamNode, getConfig } from "./AdapterEndpoint.types"; -import { useError } from "../OdinErrorContext"; -import axios, { AxiosRequestConfig, ResponseType } from "axios"; -import { useState } from "react"; - -const smartPathSplit = (path: string) => { - return path.split("/").filter(Boolean); -} - -const smartPathJoin = (path: string[]) => { - return path.filter(Boolean).join("/"); -} - -function useAdapterEndpoint = ParamNode>( - adapter: string, endpoint_url: string, interval?: number, timeout?: number -): AdapterEndpoint { - - const client = useQueryClient(); - const errCtx = useError(); - - const [apiVersion, setApiVersion] = useState<"" | "0.1">(""); - //TODO: this does not check for Odin Control Version (needing "0.1" or not) - const base_url = smartPathJoin([endpoint_url, "api", apiVersion]); - const queryKey = [endpoint_url, ...smartPathSplit(adapter)]; - - const axiosInstance = axios.create({ - baseURL: base_url, - timeout: timeout, - headers: { - "Content-Type": "application/json" - } - }) - - const handleError = (err: unknown) => { - let errorMsg: string = ""; - if (axios.isAxiosError(err)) { - if (err.code == "ERR_CANCELED") { - // request was cancelled. Does not need to be reported - return null; - } - const addr = err.config?.url ?? ""; - if (err.response) { - const method = err.response.config.method ? err.response.config.method.toUpperCase() : "UNDEFINED"; - const reason = err.response.data?.error || ""; - // const address = err.response - errorMsg = `${method} request to addr ${addr} failed with status ${err.response.status} : ${reason}`; - } - else if (err.request) { - errorMsg = `Network error sending request to ${addr}: ${err.message}`; - } - } - else { - errorMsg = `Unknown error sending request to ${base_url}`; - } - const error = new Error(errorMsg); - errCtx.setError(error); - - return error; // return error so it can be caught/thrown - } - - const queryGet = async ({ queryKey, signal }: QueryFunctionContext<[ResponseType | "metadata", ...string[]]>) => { - const [wants_metadata, addr, ...path] = queryKey; - const adapter = smartPathJoin(path); - console.debug(`Query GET, addr "${addr}", adapter path "${adapter}"`); - - const responseType = wants_metadata === "metadata" ? "json" : wants_metadata; - const request_config: AxiosRequestConfig = { signal: signal, responseType: responseType } - if (wants_metadata === "metadata") { - request_config.headers = { Accept: "application/json;metadata=true" }; - } - - try { - const response = await axiosInstance.get(adapter, request_config); - return response.data; - } catch (err) { - if (axios.isAxiosError(err) && err.code == "ERR_NETWORK") { - console.warn(`Network Error for ${err.config?.baseURL}/${err.config?.url}`); - // Network error. could be caused by the wrong API type (Odin 1.6.* vs 2.*) - // try adding/removing the 0.1 from the URL and try again - const version = apiVersion ? "" : "0.1"; - setApiVersion(version); - - try { - const response = await axiosInstance.get(smartPathJoin([version, adapter]), request_config); - return response.data; - } catch (err) { - console.warn("Retry Failed"); - - throw handleError(err); - } - - } else { - throw handleError(err); - } - } - } - - const queryPut = async ({ path = "", data }: { path?: string, data: ParamNode }) => { - const request_path = smartPathJoin([adapter, path]); - const response = await axiosInstance.put(request_path, data); - return response.data; - } - - const query = useQuery({ - queryKey: ["json", ...queryKey], - queryFn: queryGet, - staleTime: interval || Infinity, - refetchInterval: interval - }); - - const { data: metadata } = useQuery({ - queryKey: ["metadata", ...queryKey], - queryFn: queryGet>, - staleTime: Infinity - }); - - const mutation = useMutation({ - mutationKey: queryKey, - mutationFn: queryPut - }); - - const put = async (data: T, param_path?: string) => { - console.debug(`PUT: ${base_url}/${param_path}, data: `, data); - try { - return await mutation.mutateAsync( - { path: param_path, data: data }, - { onSuccess: async () => { await client.invalidateQueries({ queryKey: ["json", ...queryKey] }) } } - ) as T; - } catch (err) { - throw handleError(err); - } - } - - const get = async (param_path = "", config?: getConfig) => { - console.debug(`GET: ${base_url}/${param_path}`); - - const { wants_metadata = false, responseType = 'json' } = config ?? {}; - const key: [ResponseType | "metadata", ...string[]] = [ - wants_metadata ? "metadata" : responseType, - endpoint_url, - ...smartPathSplit(adapter), - ...smartPathSplit(param_path) - ] - const data = await client.fetchQuery({ queryKey: key, queryFn: queryGet }); - return data; - - } - - return { - data: query.data as Tree, - metadata: metadata as Metadata, - loading: mutation.isPending, - get, put, apiVersion - } -} - -export { useAdapterEndpoint } \ No newline at end of file diff --git a/lib/components/AdapterEndpoint/index.stories.tsx b/lib/components/AdapterEndpoint/index.stories.tsx index f40a681..074df50 100644 --- a/lib/components/AdapterEndpoint/index.stories.tsx +++ b/lib/components/AdapterEndpoint/index.stories.tsx @@ -49,7 +49,7 @@ const EndpointDisplay = ({ endpoint }: Endpoint) => { }); } - const displayFullTree = useMemo(() => { return endpoint.data }, [endpoint.updateFlag]); + const displayFullTree = endpoint.data; return ( diff --git a/lib/components/AdapterEndpoint/index.tsx b/lib/components/AdapterEndpoint/index.tsx index d13d964..472ddcc 100644 --- a/lib/components/AdapterEndpoint/index.tsx +++ b/lib/components/AdapterEndpoint/index.tsx @@ -1,17 +1,8 @@ -import { useState, useEffect, useRef } from "react"; -import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; -import type { AdapterEndpoint, Metadata, Parameter, ParamNode, ParamTree, getConfig, status, MetadataValue } from "./AdapterEndpoint.types"; +import { useMutation, useQuery, useQueryClient, type QueryFunctionContext } from "@tanstack/react-query"; +import axios, { AxiosRequestConfig, ResponseType } from "axios"; +import { useState } from "react"; import { useError } from "../OdinErrorContext"; -import { useAdapterEndpoint } from "./QueryEndpoint"; -// odin control 2.0 no longer uses an API Version. - -enum updateFlag_enum { - INIT = "init", - FIRST = "first", - REFRESH = "refreshed", - MERGED = "merged", - ERROR = "error" -} +import type { AdapterEndpoint, getConfig, Metadata, MetadataValue, Parameter, ParamNode, ParamTree } from "./AdapterEndpoint.types"; const isParamNode = (x: ParamTree): x is ParamNode => { return x !== null && typeof x === "object" && !Array.isArray(x); @@ -21,9 +12,17 @@ const isMetadataValue = (x: Metadata): x is MetadataValue => { return isParamNode(x) && "writeable" in x } +const smartPathSplit = (path: string) => { + return path.split("/").filter(Boolean); +} + +const smartPathJoin = (path: string[]) => { + return path.filter(Boolean).join("/"); +} + /** - * Nagivates down a nested dict-like structure to get the value at the path - * @param data the nested JSON dict-like structure to naviagate down + * Navigates down a nested dict-like structure to get the value at the path + * @param data the nested JSON dict-like structure to navigate down * @param path the path, separated by slashes, to navigate down * @returns the value at the specified path (which is either a single value, or a JSON Node with Key/Val pair(s)) * or Undefined if the value was not found at that path @@ -55,267 +54,150 @@ function getValueFromPath(data: ParamTree, path: string): T | und * @param timeout An option Timeout for API requests, in ms. * @returns */ -function useAdapterEndpointDEAD( +function useAdapterEndpoint = ParamNode>( adapter: string, endpoint_url: string, interval?: number, timeout?: number -): AdapterEndpoint { +): AdapterEndpoint { - const data = useRef({} as T); - const [metadata, setMetadata] = useState>({} as Metadata); - const [error, setError] = useState(null); - const [updateFlag, setUpdateFlag] = useState(Symbol(updateFlag_enum.INIT)); - const [statusFlag, setStatusFlag] = useState("init"); + const client = useQueryClient(); + const errCtx = useError(); - const [apiVersion, setApiVersion] = useState(""); - const [base_url, setBaseUrl] = useState([endpoint_url, - "api", - apiVersion, - adapter - ].filter(Boolean).join("/")); - - const [awaiting, changeAwaiting] = useState(false); + const [apiVersion, setApiVersion] = useState<"" | "0.1">(""); + const base_url = smartPathJoin([endpoint_url, "api", apiVersion]); + const queryKey = [endpoint_url, ...smartPathSplit(adapter)]; - const ctx = useError(); - const axiosInstance: AxiosInstance = axios.create({ + const axiosInstance = axios.create({ baseURL: base_url, timeout: timeout, headers: { - "Content-Type": "application/json", - // "Accept": "application/json,image/*" + "Content-Type": "application/json" } }) - //** handles errors thrown by the http requests, setting it in the Error State*/ const handleError = (err: unknown) => { let errorMsg: string = ""; - if(axios.isAxiosError(err)){ - if(err.response) { + if (axios.isAxiosError(err)) { + if (err.code == "ERR_CANCELED") { + // request was cancelled. Does not need to be reported + return null; + } + const addr = err.config?.url ?? ""; + if (err.response) { const method = err.response.config.method ? err.response.config.method.toUpperCase() : "UNDEFINED"; const reason = err.response.data?.error || ""; // const address = err.response - errorMsg = `${method} request failed with status ${err.response.status} : ${reason}`; + errorMsg = `${method} request to addr ${addr} failed with status ${err.response.status} : ${reason}`; } else if (err.request) { - errorMsg = `Network error sending request to ${base_url}: ${err.message}`; + errorMsg = `Network error sending request to ${addr}: ${err.message}`; } } else { errorMsg = `Unknown error sending request to ${base_url}`; } const error = new Error(errorMsg); - setError(error); - setStatusFlag("error"); - ctx.setError(error); - - return error; // rethrow error so it doesn't dissapear - }; + errCtx.setError(error); - const get = async (param_path='', config?: getConfig) => { - console.debug(`GET: ${base_url}/${param_path}`); - - const {wants_metadata=false, responseType='json'} = config ?? {}; + return error; // return error so it can be caught/thrown + } - let result: T; - let response: AxiosResponse; + const queryGet = async ({ queryKey, signal }: QueryFunctionContext<[ResponseType | "metadata", ...string[]]>) => { + const [wants_metadata, addr, ...path] = queryKey; + const adapter = smartPathJoin(path); + console.debug(`Query GET, addr "${addr}", adapter path "${adapter}"`); - const request_config: AxiosRequestConfig = {responseType: responseType}; - if(wants_metadata){ - request_config.headers = {Accept: "application/json;metadata=true"}; - } - - try { - //intentionally not setting the awaiting state here to avoid - //issues with the WithEndpoint component - response = await axiosInstance.get(param_path, request_config); - result = response.data; - console.debug("Response Data: ", result); - setStatusFlag(curStatus => curStatus != "init" ? "connected" : curStatus); // modified to ensure the init for metadata and data runs no matter what - return result; - } - catch (err) { - throw handleError(err); + const responseType = wants_metadata === "metadata" ? "json" : wants_metadata; + const request_config: AxiosRequestConfig = { signal: signal, responseType: responseType } + if (wants_metadata === "metadata") { + request_config.headers = { Accept: "application/json;metadata=true" }; } - }; - - const put = async (data: T, param_path='') => { - // const url = [base_url, param_path].join("/"); // assumes param_path does not start with a slash - console.debug(`PUT: ${base_url}/${param_path}, data: `, data); - - // let result: typeof data = {}; - // let response: AxiosResponse; try { - changeAwaiting(true); - const response = await axiosInstance.put(param_path, data); - const result = response.data; - - console.debug("Response: ", result); - setStatusFlag(curStatus => curStatus != "init" ? "connected" : curStatus); - return result; - } - catch (err: unknown) { - throw handleError(err); - } - finally { - changeAwaiting(false); - } - }; - - const post = async (data: ParamNode, param_path="") => { - console.debug(`POST: ${base_url}/${param_path}, data: `, data); - - let result: ParamNode = {}; - let response: AxiosResponse; + const response = await axiosInstance.get(adapter, request_config); + return response.data; + } catch (err) { + if (axios.isAxiosError(err) && err.code == "ERR_NETWORK") { + console.warn(`Network Error for ${err.config?.baseURL}/${err.config?.url}`); + // Network error. could be caused by the wrong API type (Odin 1.6.* vs 2.*) + // try adding/removing the 0.1 from the URL and try again + const version = apiVersion ? "" : "0.1"; + setApiVersion(version); + + try { + const response = await axiosInstance.get(smartPathJoin([version, adapter]), request_config); + return response.data; + } catch (err) { + console.warn("Retry Failed"); + + throw handleError(err); + } - try { - changeAwaiting(true); - response = await axiosInstance.post(param_path, data); - result = response.data; - setStatusFlag(curStatus => curStatus != "init" ? "connected" : curStatus); - } - catch (err: unknown) { - throw handleError(err); - } - finally { - changeAwaiting(false); + } else { + throw handleError(err); + } } - console.debug("Response: ", result); - - return result; - }; - - const remove = async (param_path="") => { - console.debug(`DELETE: ${base_url}/${param_path}`); + } - let result: ParamNode = {}; - let response: AxiosResponse; + const queryPut = async ({ path = "", data }: { path?: string, data: ParamNode }) => { + const request_path = smartPathJoin([adapter, path]); + const response = await axiosInstance.put(request_path, data); + return response.data; + } + const query = useQuery({ + queryKey: ["json", ...queryKey], + queryFn: queryGet, + staleTime: interval || Infinity, + refetchInterval: interval + }); + + const { data: metadata } = useQuery({ + queryKey: ["metadata", ...queryKey], + queryFn: queryGet>, + staleTime: Infinity + }); + + const mutation = useMutation({ + mutationKey: queryKey, + mutationFn: queryPut + }); + + const put = async (data: T, param_path?: string) => { + console.debug(`PUT: ${base_url}/${param_path}, data: `, data); try { - changeAwaiting(true); - response = await axiosInstance.delete(param_path); - result = response.data; - setStatusFlag(curStatus => curStatus != "init" ? "connected" : curStatus); - } - catch (err: unknown) { + return await mutation.mutateAsync( + { path: param_path, data: data }, + { onSuccess: async () => { await client.invalidateQueries({ queryKey: ["json", ...queryKey] }) } } + ) as T; + } catch (err) { throw handleError(err); } - finally { - changeAwaiting(false); - } - console.debug("Response: ", result); - - return result; } - // some sort of looping attempt at first connection until we get a response? so if the adapter - // isn't running at first for whatever reason, we keep trying until it is? - const getInitialData = () => { - // discover Odin Control version (1.* or 2.*, changes what the full URL needs to be) - let url = [endpoint_url, "api"].filter(Boolean).join("/"); - console.debug("Checking Odin Control Version"); - let api_version = ""; - //can't use the get function of the endpoint yet, because we don't know if the base_url is correct - // until we've worked out if we need the api "0.1" yet. - axios.get<{api?: number}>(url) - .then(result => { - api_version = result.data?.api ? result.data.api.toString() : ""; - }) - .catch(() => { - // might just be CORS error from previous Odin Control version - //try and do the get stuff with the api_version 0.1? - // throw handleError(err); - console.debug("No response from checking version. Assuming <2.0.0"); - api_version = "0.1"; - }) - .finally(() => { - setApiVersion(api_version); - url = [url, api_version, adapter].filter(Boolean).join("/"); - setBaseUrl(url); - - const request_config: AxiosRequestConfig = {headers: {Accept: "application/json;metadata=true"}}; - // using Promise.all to ensure we set the trees and flags only when - // both requests have returned - Promise.all([ - axios.get(url), - axios.get>(url, request_config) - ]).then(([dataResult, metadataResult]) => { - data.current = dataResult.data; - setMetadata(metadataResult.data); - setStatusFlag("connected"); - setUpdateFlag(Symbol(updateFlag_enum.FIRST)); - }) - .catch(err => { - throw handleError(err); - }); - - }); - } - - useEffect(() => { - let init_timer: NodeJS.Timeout; - - if(statusFlag !== "connected"){ - init_timer = setInterval(getInitialData, interval ?? 1000); - } - - return () => clearInterval(init_timer); - - }, [statusFlag, interval]); - - useEffect(() => { // interval effect. refreshes data if the interval is set - let timer_id: NodeJS.Timeout; - if(interval && statusFlag == "connected") { - timer_id = setInterval(refreshData, interval); - } - - return () => clearInterval(timer_id); + const get = async (param_path = "", config?: getConfig) => { + console.debug(`GET: ${base_url}/${param_path}`); - }, [interval, statusFlag]); + const { wants_metadata = false, responseType = 'json' } = config ?? {}; + const key: [ResponseType | "metadata", ...string[]] = [ + wants_metadata ? "metadata" : responseType, + endpoint_url, + ...smartPathSplit(adapter), + ...smartPathSplit(param_path) + ] + const data = await client.fetchQuery({ queryKey: key, queryFn: queryGet }); + return data; - const refreshData = () => { - get("") - .then(result => { - data.current = result as T; - setUpdateFlag(Symbol(updateFlag_enum.REFRESH)); - }) - .catch(() => { - return // error already handled by GET, so we can catch it here to avoid needless console messaging. - }); } - - const mergeData = (newData: ParamTree, param_path: string) => { - const splitPath = param_path.replace(/\/$/, "").split("/"); - const paramName = splitPath.pop()!; - - const tmpData = data.current; // use tmpData as a copy of the Data that we can modify - let pointer: ParamTree = tmpData; // pointer that can traverse down the nested data - if(splitPath[0]) { - splitPath.forEach((part_path) => { - if(isParamNode(pointer)){ - pointer = pointer[part_path]; - } - }); - } - // becasue pointer was a copy of tmpData, changes made to it will also be made to tmpData - if(isParamNode(newData)){ - // const keys = Object.keys(newData); - - if("value" in newData && Object.keys(newData).length == 1){ - // test if merge data has only one key of "value". This likely means we're - // using Odin Control 2.0 and need to adjust to merge it into the larger tree - newData = {[paramName]: newData["value"]} - } - } - Object.assign(pointer, newData); - data.current = tmpData; - setUpdateFlag(Symbol(updateFlag_enum.MERGED)); + return { + data: query.data as Tree, + metadata: metadata as Metadata, + loading: mutation.isPending, + get, put, apiVersion } - - return { data: data.current, metadata, error, loading: awaiting, updateFlag, status: statusFlag, apiVersion, get, put, post, remove, refreshData, mergeData} } -export { useAdapterEndpoint, isParamNode, isMetadataValue, getValueFromPath }; -export type { AdapterEndpoint, Parameter, ParamNode, Metadata, ParamTree }; +export { getValueFromPath, isMetadataValue, isParamNode, useAdapterEndpoint }; +export type { AdapterEndpoint, Metadata, Parameter, ParamNode, ParamTree }; /** * @deprecated This is the old name for this type and should be replaced with {@link ParamTree} diff --git a/lib/components/WithEndpoint/EndpointButton.tsx b/lib/components/WithEndpoint/EndpointButton.tsx index 2cf8e84..fbf5177 100644 --- a/lib/components/WithEndpoint/EndpointButton.tsx +++ b/lib/components/WithEndpoint/EndpointButton.tsx @@ -27,7 +27,7 @@ const EndpointButton = ( ...rest }: EndpointButtonProps ) => { - const { requestHandler, disable } = useRequestHandler({ + const { requestHandler, data, disable } = useRequestHandler({ endpoint, fullpath, value, disabled, pre_method, pre_args, post_method, post_args @@ -36,7 +36,7 @@ const EndpointButton = ( const onClickHandler: ButtonProps["onClick"] = (event) => { console.debug(fullpath, event); - requestHandler(value); + requestHandler(data); } return ( diff --git a/lib/components/WithEndpoint/EndpointDoubleSlider.tsx b/lib/components/WithEndpoint/EndpointDoubleSlider.tsx index e0bad0c..f3c778d 100644 --- a/lib/components/WithEndpoint/EndpointDoubleSlider.tsx +++ b/lib/components/WithEndpoint/EndpointDoubleSlider.tsx @@ -54,7 +54,7 @@ const EndpointDoubleSlider = , PostArgs if (typeof newVal !== "undefined" && !component.current?.contains(document.activeElement)) { changeCompVal(newVal); } - }, [endpoint.updateFlag, data]); + }, [endpoint.data, fullpath, data]); diff --git a/lib/components/WithEndpoint/EndpointInput.tsx b/lib/components/WithEndpoint/EndpointInput.tsx index d2be3e5..fe79bf1 100644 --- a/lib/components/WithEndpoint/EndpointInput.tsx +++ b/lib/components/WithEndpoint/EndpointInput.tsx @@ -35,7 +35,6 @@ const EndpointInput = , PostArgs extends }); const metaData: MetadataValue | undefined = getValueFromPath(endpoint.metadata, fullpath); - const [compVal, changeCompVal] = useState(""); const [editing, setEditing] = useState(false); const compMin = min ?? metaData?.min; @@ -53,14 +52,14 @@ const EndpointInput = , PostArgs extends const target = event.target; const val = type == "number" ? Number(target.value) : target.value; - changeCompVal(val); setEditing(!(val == endVal)); } const onEnterHandler: FormControlProps["onKeyUp"] = (event) => { if (event.key === "Enter" && !event.shiftKey) { - console.debug(fullpath, event); - requestHandler(compVal); + const target = event.target as HTMLInputElement; + const val = type == "number" ? target.valueAsNumber : target.value; + requestHandler(val); setEditing(false); } } @@ -71,13 +70,15 @@ const EndpointInput = , PostArgs extends // check if the component is not currently active if (document.activeElement !== component.current && !editing && typeof newVal !== "undefined") { - changeCompVal(newVal); + if(component.current) { + component.current.value = newVal.toString(); + } } - }, [endpoint.updateFlag, endVal]); + }, [endpoint.data, fullpath, editing, endVal]); return ( - ) } diff --git a/lib/components/WithEndpoint/EndpointRangeInput.stories.tsx b/lib/components/WithEndpoint/EndpointRangeInput.stories.tsx index 4ccb320..5851831 100644 --- a/lib/components/WithEndpoint/EndpointRangeInput.stories.tsx +++ b/lib/components/WithEndpoint/EndpointRangeInput.stories.tsx @@ -1,11 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { EndpointMultipliedInput } from './EndpointRangeInput'; +import { EndpointRangeInput } from './EndpointRangeInput'; import { useAdapterEndpoint, resetMockData, transformMockCode } from '../AdapterEndpoint/index.mock'; import { expect, spyOn } from 'storybook/test'; const meta = { - component: EndpointMultipliedInput, + component: EndpointRangeInput, args: { endpoint: undefined, fullpath: "volt", @@ -35,13 +35,13 @@ const meta = { }, render: (args) => { args.endpoint = useAdapterEndpoint("test", "http://localhost:1338"); - return + return }, beforeEach: async () => { resetMockData(); }, -} satisfies Meta; +} satisfies Meta; export default meta; diff --git a/lib/components/WithEndpoint/EndpointRangeInput.tsx b/lib/components/WithEndpoint/EndpointRangeInput.tsx index 4d50a9c..2f29a68 100644 --- a/lib/components/WithEndpoint/EndpointRangeInput.tsx +++ b/lib/components/WithEndpoint/EndpointRangeInput.tsx @@ -45,7 +45,7 @@ type MultipliedInputProps, PostArgs exte * the user can more easily view and enter values that might have wide range * options. */ -const EndpointMultipliedInput = , PostArgs extends Record>( +const EndpointRangeInput = , PostArgs extends Record>( { endpoint, fullpath, value, disabled, min, max, pre_method, pre_args, post_method, post_args, @@ -107,7 +107,7 @@ const EndpointMultipliedInput = , PostAr changeCompVal(newVal); } - }, [endpoint.updateFlag, endVal]); + }, [endpoint.data, fullpath, endVal]); return ( @@ -133,4 +133,4 @@ const EndpointMultipliedInput = , PostAr } -export { EndpointMultipliedInput }; +export { EndpointRangeInput }; diff --git a/lib/components/WithEndpoint/EndpointSlider.tsx b/lib/components/WithEndpoint/EndpointSlider.tsx index 30ecca2..3e74640 100644 --- a/lib/components/WithEndpoint/EndpointSlider.tsx +++ b/lib/components/WithEndpoint/EndpointSlider.tsx @@ -67,7 +67,7 @@ const EndpointSlider = , PostArgs extend if(document.activeElement !== component.current && typeof newVal !== "undefined"){ changeCompVal(newVal); } - }, [endpoint.updateFlag, data]); + }, [endpoint.data, fullpath, data]); const tooltip = ( diff --git a/lib/components/WithEndpoint/index.tsx b/lib/components/WithEndpoint/index.tsx index 4dfb44d..6f02359 100644 --- a/lib/components/WithEndpoint/index.tsx +++ b/lib/components/WithEndpoint/index.tsx @@ -8,7 +8,7 @@ import { EndpointDoubleSlider } from "./EndpointDoubleSlider"; import { EndpointDropdown } from "./EndpointDropdown"; import { EndpointInput } from "./EndpointInput"; import { EndpointSlider } from "./EndpointSlider"; -import { EndpointMultipliedInput } from "./EndpointRangeInput"; +import { EndpointRangeInput } from "./EndpointRangeInput"; import type { EndpointProps } from "./util"; import { useRequestHandler } from "./util"; // import { isEqual } from 'lodash'; @@ -113,7 +113,7 @@ const WithEndpoint =

(WrappedComponent: React.FC

) => { setComponentValue(newVal); } } - }, [endpoint.updateFlag, endpointValue]); + }, [endpoint.data, fullpath, endpointValue]); const handleRequest = (val: typeof value) => { @@ -247,7 +247,7 @@ export { EndpointDropdown, EndpointInput, EndpointSlider, - EndpointMultipliedInput as EndpointRangeInput + EndpointRangeInput }; export { WithEndpoint }; diff --git a/lib/components/WithEndpoint/utils.types.ts b/lib/components/WithEndpoint/utils.types.ts index 946ae3b..ff3de44 100644 --- a/lib/components/WithEndpoint/utils.types.ts +++ b/lib/components/WithEndpoint/utils.types.ts @@ -13,26 +13,6 @@ interface BasicEndpointProps { disabled?: boolean; } -interface PreMethodWithArgs extends BasicEndpointProps { - pre_method: (args: Args) => void; - pre_args: Args; -} - -interface PreMethodNoArgs extends BasicEndpointProps { - pre_method?: () => void; - pre_args?: undefined; -} - -interface PostMethodWithArgs extends BasicEndpointProps { - post_method: (args: Args) => void; - post_args: Args; -} - -interface PostMethodNoArgs extends BasicEndpointProps { - post_method?: () => void; - post_args?: undefined; -} - // export type EndpointProps = // (PreMethodNoArgs | PreMethodWithArgs>) & // (PostMethodNoArgs | PostMethodWithArgs>) diff --git a/lib/main.ts b/lib/main.ts index 30cedac..14d3cd9 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -8,11 +8,12 @@ export { OdinLiveView, ZoomableImage } from './components/OdinLiveView'; export { OdinTable, OdinTableRow } from './components/OdinTable'; export { ParamController } from './components/ParamController'; export { TitleCard } from './components/TitleCard'; -export { EndpointButton, EndpointCheckbox, EndpointDropdown, EndpointInput, EndpointSlider, EndpointDoubleSlider, WithEndpoint } from './components/WithEndpoint'; -export { EndpointRangeInput } from './components/WithEndpoint' +export { EndpointButton, EndpointCheckbox, EndpointDropdown, EndpointInput } from './components/WithEndpoint'; +export { EndpointRangeInput, EndpointSlider, EndpointDoubleSlider } from './components/WithEndpoint'; +export { WithEndpoint } from './components/WithEndpoint'; + export type { AdapterEndpoint, ParamNode, ParamTree } from "./components/AdapterEndpoint"; -export type { AdapterEndpoint_t } from "./components/AdapterEndpoint/AdapterEndpoint.types"; export type { Log } from './components/OdinEventLog'; export type { Axis, GraphData } from './components/OdinGraph'; From d061925bdb0ce2331e5e16f09e131a0ae1917e44 Mon Sep 17 00:00:00 2001 From: Ashley Neaves Date: Thu, 14 May 2026 13:52:35 +0100 Subject: [PATCH 5/8] Added post/delete HTTP methods back to new Adapter Endpoint Cleared up some of the network Mocking Fixed build issue with Live View still trying to use props from the Endpoint that no longer exist --- .storybook/api.mock.ts | 3 +- .storybook/preview.tsx | 14 ++-- .../AdapterEndpoint/AdapterEndpoint.types.ts | 22 ++---- lib/components/AdapterEndpoint/index.mock.tsx | 24 +----- .../AdapterEndpoint/index.stories.tsx | 25 +++++- .../AdapterEndpoint/{index.tsx => index.ts} | 78 ++++++++++++------- lib/components/OdinLiveView/index.tsx | 8 +- .../WithEndpoint/EndpointInput.stories.tsx | 6 +- 8 files changed, 96 insertions(+), 84 deletions(-) rename lib/components/AdapterEndpoint/{index.tsx => index.ts} (81%) diff --git a/.storybook/api.mock.ts b/.storybook/api.mock.ts index cd0a9dd..1aa1bd7 100644 --- a/.storybook/api.mock.ts +++ b/.storybook/api.mock.ts @@ -161,6 +161,7 @@ class BaseMockAdapter implements BaseAdapter { console.log(this.getValueFromMetadata(path)); let metadata = this.getValueFromMetadata(path)[paramName] as MetadataValue; console.log(metadata); + console.groupEnd() // VALIDATE if (!(metadata?.writeable ?? true)) { throw new AdapterError(`Parameter ${paramName} is read-only`, "read_only"); } @@ -193,8 +194,6 @@ class BaseMockAdapter implements BaseAdapter { `Type mismatch setting ${paramName}: got ${typeof val} expected ${metadata?.type ?? "dict"}`, "type_mismatch" ) - } finally { - console.groupEnd() // VALIDATE } if (metadata?.allowed_values != undefined && !metadata.allowed_values.includes(val)) { diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 08312d9..32db740 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -2,7 +2,7 @@ import type { Preview } from '@storybook/react-vite'; import { themes } from 'storybook/theming'; import { useDarkMode } from 'storybook-dark-mode'; import { DocsContainer } from '@storybook/addon-docs/blocks'; -import { http, passthrough } from 'msw'; +import { http, HttpResponse, passthrough } from 'msw'; import { initialize, mswLoader } from 'msw-storybook-addon'; import { OdinErrorContext } from '../lib/components/OdinErrorContext'; import { apiVersionHandler, getHandler, getHandlerUpdate, putHandler, putHandlerUpdate } from './stories.mock'; @@ -12,7 +12,6 @@ import type { ParamNode } from '../lib/components/AdapterEndpoint'; import { useEffect } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -const queryClient = new QueryClient(); /* * Initializes MSW @@ -57,8 +56,9 @@ const preview: Preview = { http.get<{ adapter: string, path: string[] }>("http://localhost:1337/api/0.1/:adapter/:path+", getHandler), http.put<{ adapter: string }, ParamNode>("http://localhost:1337/api/0.1/:adapter", putHandler), http.put<{ adapter: string, path: string[] }, ParamNode>("http://localhost:1337/api/0.1/:adapter/:path+", putHandler), + http.get<{ adapter: string }>("http://localhost:1337/api/:adapter", () => { return HttpResponse.error() }), - // Odin 2.0 GET/PUT requests. Note differnt path (no API version) + // Odin 2.0 GET/PUT requests. Note different path (no API version) http.get<{ adapter: string }>("http://localhost:1338/api/:adapter", getHandlerUpdate), http.get<{ adapter: string, path: string[] }>("http://localhost:1338/api/:adapter/:path+", getHandlerUpdate), http.put<{ adapter: string }, ParamNode>("http://localhost:1338/api/:adapter", putHandlerUpdate), @@ -114,13 +114,11 @@ const preview: Preview = { const isDarkMode = useDarkMode(); useEffect(() => document.documentElement?.setAttribute( "data-bs-theme", isDarkMode ? "dark" : "light" - )) + )) return ( - - + - - + ) } ], diff --git a/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts b/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts index 5c4eb4a..2af32a5 100644 --- a/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts +++ b/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts @@ -44,6 +44,7 @@ interface AdapterEndpoint { * @returns An Async promise, that when resolved will return the data within the HTTP response */ get: (param_path?: string, config?: getConfig) => Promise; + /** * Async http PUT method. Modify the data in the param tree at the provided path * It is worth noting that this method does NOT automatically merge the response into the Endpoint.Data object. @@ -60,28 +61,15 @@ interface AdapterEndpoint { * @param param_path - the path you want to POST to. defaults to an empty string, for a top level POST * @returns An Async promise, that when resolved will return the data within the HTTP response */ - // post: (data: ParamNode, param_path?: string) => Promise; + post: (data: ParamNode, param_path?: string) => Promise; /** - * Async http DELETE method. Renamed because 'delete' is a reserved word in javascript. Not often - * implemented by adapters. + * Async http DELETE method. Not often implemented by adapters, but potentially used to remove + * some part of a mutable Parameter Tree. * @param param_path the path to the data you want to DELETE. Defaults to an empty string * @returns An Async promise, that when resolved will return the data within the HTTP response */ - // remove: (param_path?: string) => Promise; - /** - * A method to automatically perform a top level GET request and refresh the AdapterEndpoint's current view of the data - * @returns - */ - // refreshData: () => void; - /** - * A method to merge one chunk of (potentially nested) data into the AdapterEndpoint's current view of the data, - * to update the data without having to GET from the entire adapter - * @param {ParamNode} newData - The new data to be merged in - * @param {string} param_path - the location that data within the ParamTree to merge it - * @returns - */ - // mergeData: (newData: ParamNode, param_path: string) => void; + delete: (param_path?: string) => Promise; } diff --git a/lib/components/AdapterEndpoint/index.mock.tsx b/lib/components/AdapterEndpoint/index.mock.tsx index 6bbc425..47c74d7 100644 --- a/lib/components/AdapterEndpoint/index.mock.tsx +++ b/lib/components/AdapterEndpoint/index.mock.tsx @@ -171,17 +171,10 @@ const MockedEndpoint: AdapterEndpoint = { get: fn(mockGet).mockName("get"), // @ts-ignore put: fn(mockPut).mockName("put"), - post: fn().mockName("post"), - remove: fn(), - refreshData: fn(), - mergeData: fn(), data: testAdapterData, metadata: testMetadata, - status: "connected", apiVersion: "", - error: null, loading: false, - updateFlag: Symbol("mocked") } const MockedLiveEndpoint: AdapterEndpoint = { @@ -189,19 +182,11 @@ const MockedLiveEndpoint: AdapterEndpoint = { get: fn(mockGet).mockName("get"), // @ts-ignore put: fn(mockPut).mockName("put"), - post: fn().mockName("post"), - remove: fn(), - refreshData: fn(), - mergeData: fn(), data: testLiveData, // @ts-ignore - metadata: testLiveData, - status: "connected", + metadata: testLiveData, apiVersion: "", - error: null, loading: false, - updateFlag: Symbol("mocked") - } const MockedLiveEndpointNoControls: AdapterEndpoint<{}> = { @@ -209,18 +194,11 @@ const MockedLiveEndpointNoControls: AdapterEndpoint<{}> = { get: fn(mockGet).mockName("get"), // @ts-ignore put: fn(mockPut).mockName("put"), - post: fn().mockName("post"), - remove: fn(), - refreshData: fn(), - mergeData: fn(), data: {}, // @ts-ignore metadata: {}, - status: "connected", apiVersion: "", - error: null, loading: false, - updateFlag: Symbol("mocked") } const useAdapterEndpoint = fn( diff --git a/lib/components/AdapterEndpoint/index.stories.tsx b/lib/components/AdapterEndpoint/index.stories.tsx index 074df50..bf6b002 100644 --- a/lib/components/AdapterEndpoint/index.stories.tsx +++ b/lib/components/AdapterEndpoint/index.stories.tsx @@ -9,6 +9,7 @@ import { adapters, update_adapters } from '../../../.storybook/stories.mock'; import { FloatingLabel, InputGroup, Form, Card, ButtonGroup, Button, Row, Col } from 'react-bootstrap'; import { expect } from 'storybook/test'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; interface Endpoint { adapter: string; @@ -19,6 +20,19 @@ interface Endpoint { } +const queryClient = new QueryClient() + +// TypeScript only: +declare global { + interface Window { + __TANSTACK_QUERY_CLIENT__: + import('@tanstack/query-core') + .QueryClient + } +} + +window.__TANSTACK_QUERY_CLIENT__ = queryClient + const EndpointDisplay = ({ endpoint }: Endpoint) => { // const endpoint = useAdapterEndpoint(adapter, endpoint_url, interval, timeout); @@ -167,7 +181,16 @@ const meta = { for (const [_, adapter] of Object.entries(update_adapters)) { adapter.reset() } - } + }, + decorators: [ + (Story) => { + return ( + + + + ) + } + ] } satisfies Meta; export default meta; diff --git a/lib/components/AdapterEndpoint/index.tsx b/lib/components/AdapterEndpoint/index.ts similarity index 81% rename from lib/components/AdapterEndpoint/index.tsx rename to lib/components/AdapterEndpoint/index.ts index 472ddcc..3a94132 100644 --- a/lib/components/AdapterEndpoint/index.tsx +++ b/lib/components/AdapterEndpoint/index.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient, type QueryFunctionContext } from "@tanstack/react-query"; -import axios, { AxiosRequestConfig, ResponseType } from "axios"; +import axios, { AxiosRequestConfig, AxiosResponse, ResponseType } from "axios"; import { useState } from "react"; import { useError } from "../OdinErrorContext"; import type { AdapterEndpoint, getConfig, Metadata, MetadataValue, Parameter, ParamNode, ParamTree } from "./AdapterEndpoint.types"; @@ -137,9 +137,21 @@ function useAdapterEndpoint = ParamNode>( } } - const queryPut = async ({ path = "", data }: { path?: string, data: ParamNode }) => { + const mutateFunc = async ({ path = "", data, method = "PUT" }: { path?: string, data: ParamNode, method?: "PUT" | "POST" | "DELETE" }) => { const request_path = smartPathJoin([adapter, path]); - const response = await axiosInstance.put(request_path, data); + let response: AxiosResponse; + switch(method) { + case "POST": + response = await axiosInstance.post(request_path, data); + break; + case "DELETE": + response = await axiosInstance.delete(request_path); + break; + case "PUT": + default: + response = await axiosInstance.put(request_path, data); + break; + } return response.data; } @@ -158,9 +170,23 @@ function useAdapterEndpoint = ParamNode>( const mutation = useMutation({ mutationKey: queryKey, - mutationFn: queryPut + mutationFn: mutateFunc }); + const get = async (param_path = "", config?: getConfig) => { + console.debug(`GET: ${base_url}/${param_path}`); + + const { wants_metadata = false, responseType = 'json' } = config ?? {}; + const key: [ResponseType | "metadata", ...string[]] = [ + wants_metadata ? "metadata" : responseType, + endpoint_url, + ...smartPathSplit(adapter), + ...smartPathSplit(param_path) + ] + const data = await client.fetchQuery({ queryKey: key, queryFn: queryGet }); + return data; + } + const put = async (data: T, param_path?: string) => { console.debug(`PUT: ${base_url}/${param_path}, data: `, data); try { @@ -173,38 +199,38 @@ function useAdapterEndpoint = ParamNode>( } } - const get = async (param_path = "", config?: getConfig) => { - console.debug(`GET: ${base_url}/${param_path}`); + const post = async (data: T, param_path?: string) => { + console.debug(`POST: ${base_url}/${param_path}, data:`, data); + try { + return await mutation.mutateAsync( + {path: param_path, data: data, method: "POST"}, + { onSuccess: async () => { await client.invalidateQueries({ queryKey: ["json", ...queryKey] }) } } + ) as T; + } catch (err) { + throw handleError(err); + } + } - const { wants_metadata = false, responseType = 'json' } = config ?? {}; - const key: [ResponseType | "metadata", ...string[]] = [ - wants_metadata ? "metadata" : responseType, - endpoint_url, - ...smartPathSplit(adapter), - ...smartPathSplit(param_path) - ] - const data = await client.fetchQuery({ queryKey: key, queryFn: queryGet }); - return data; + const remove = async (param_path?: string) => { + console.debug(`DELETE: ${base_url}/${param_path}`); + try { + return await mutation.mutateAsync( + {path: param_path, method: "DELETE", data: {}}, + { onSuccess: async () => { await client.invalidateQueries({ queryKey: ["json", ...queryKey] }) } } + ) + } catch (err) { + throw handleError(err); + } } return { data: query.data as Tree, metadata: metadata as Metadata, loading: mutation.isPending, - get, put, apiVersion + get, put, post, delete: remove, apiVersion } } export { getValueFromPath, isMetadataValue, isParamNode, useAdapterEndpoint }; export type { AdapterEndpoint, Metadata, Parameter, ParamNode, ParamTree }; - -/** - * @deprecated This is the old name for this type and should be replaced with {@link ParamTree} - */ -export type JSON = ParamTree - -/** - * @deprecated This is the old name for this type, and should be replaced with {@link ParamNode} - */ -export type NodeJSON = ParamNode diff --git a/lib/components/OdinLiveView/index.tsx b/lib/components/OdinLiveView/index.tsx index e70ac7e..781bfab 100644 --- a/lib/components/OdinLiveView/index.tsx +++ b/lib/components/OdinLiveView/index.tsx @@ -194,18 +194,18 @@ const OdinLiveView = ( const div = useRef(null); - const refreshImage = useCallback(() => { + const refreshImage = () => { endpoint.get(img_path, { responseType: "blob" }) .then(result => { URL.revokeObjectURL(imgPath); // memory management const img_url = URL.createObjectURL(result); setImgPath(img_url); - endpoint.refreshData(); + endpoint.get(""); }).catch((error) => { console.error("IMAGE GET FAILED: ", error); setImgPath(defaultImg); }) - }, [endpoint.updateFlag]); + } useEffect(() => { let timer_id: NodeJS.Timeout; @@ -224,7 +224,7 @@ const OdinLiveView = ( setFrameNum(getValueFromPath(endpoint.data, frame_num_addr) ?? -1); - }, [endpoint.updateFlag]); + }, [endpoint.data]); const renderOptions = ( diff --git a/lib/components/WithEndpoint/EndpointInput.stories.tsx b/lib/components/WithEndpoint/EndpointInput.stories.tsx index 6845d51..0faab63 100644 --- a/lib/components/WithEndpoint/EndpointInput.stories.tsx +++ b/lib/components/WithEndpoint/EndpointInput.stories.tsx @@ -50,7 +50,7 @@ export const Default: Story = { const put = spyOn(args.endpoint, "put").mockName("endpoint.put"); const input = canvas.getByRole("textbox"); - await expect(input).toHaveDisplayValue(args.endpoint.data.string_val as string); + await expect(input).toHaveDisplayValue(args.endpoint.data?.string_val as string); await userEvent.clear(input); await userEvent.type(input, "New Value"); @@ -74,7 +74,7 @@ export const Number: Story = { const put = spyOn(args.endpoint, "put").mockName("endpoint.put"); const input = canvas.getByRole("spinbutton"); - await expect(input).toHaveDisplayValue(args.endpoint.data.num_val as string); + await expect(input).toHaveDisplayValue(args.endpoint.data?.num_val as string); await userEvent.clear(input); await userEvent.type(input, "52"); @@ -101,7 +101,7 @@ export const PreCapitalise: Story = { const input = canvas.getByRole("textbox"); const put_string = "new value"; - await expect(input).toHaveDisplayValue(args.endpoint.data.string_val as string); + await expect(input).toHaveDisplayValue(args.endpoint.data?.string_val as string); await userEvent.clear(input); await userEvent.type(input, put_string); From 6c38ba04e3aad7235a7ca235a34eeed15fe7ea32 Mon Sep 17 00:00:00 2001 From: Ashley Neaves Date: Mon, 18 May 2026 11:20:53 +0100 Subject: [PATCH 6/8] Modified Documentation pages to reflect changes Added Tanstack Devtools to the AdapterEndpoint Stories --- docs/Endpoint.mdx | 40 +++------------ docs/FAQ.mdx | 4 +- .../AdapterEndpoint/AdapterEndpoint.types.ts | 4 +- .../AdapterEndpoint/index.stories.tsx | 4 +- package-lock.json | 50 +++++++++++++++---- package.json | 3 +- 6 files changed, 54 insertions(+), 51 deletions(-) diff --git a/docs/Endpoint.mdx b/docs/Endpoint.mdx index 9a4bed0..edbf0e1 100644 --- a/docs/Endpoint.mdx +++ b/docs/Endpoint.mdx @@ -54,7 +54,8 @@ using typescript to specify what Parameters we expect to get from the Adapter ```typescript -import type { ParamNode } from "odin-react"; +import { type ParamNode } from "odin-react"; +import { useAdapterEndpoint } from "odin-react"; // We extend the type "ParamNode" so that the AdapterEndpoint knows // that we are specifying a nested Dictionary shaped oject @@ -122,18 +123,11 @@ The object returned by the `useAdapterEndpoint` hook has the following relevant - updateFlag - symbol + loading + boolean - Flag token that will change whenever the data has changed, - to alert components using the data to re-render. - - - - status - "init" | "connected" | "error" - - Connection status for the AdapterEndpoint. + State of the http connection. If `true`, the Endpoint is awaiting + a response to a PUT request @@ -141,9 +135,6 @@ The object returned by the `useAdapterEndpoint` hook has the following relevant async \(param_path, config) => Promise\ Async http GET method. Request the provided value(s) from the parameter tree. - - This method does NOT automatically merge the response into the - `Endpoint.Data` object. - `param_path`: the path of the data desired. defaults to an Empty String to get the entire param tree. @@ -170,25 +161,6 @@ The object returned by the `useAdapterEndpoint` hook has the following relevant Returns a Promise that will resolve to the data within the HTTP response. - - - refreshData - () => void - - Function that performs a top level GET request to refresh the - `endpoint.data` object. - - - - mergeData - (newData, param_path) => void - - A method to merge one chunk of (potentially nested) data into the - `endpoint.data` object, to apply any changes. - - Often used after a PUT or GET request to update the `endpoint.data` - object with the response. - diff --git a/docs/FAQ.mdx b/docs/FAQ.mdx index 7db9ff8..1504946 100644 --- a/docs/FAQ.mdx +++ b/docs/FAQ.mdx @@ -15,7 +15,7 @@ the port number. - Ensure that Odin Control has [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) enabled in the config file. This allows Odin React to access the API resources from another server. - - This is only required when running Odin React in development mode. When + - This is only required when running Odin React in development mode. If served statically, it is running on the same server interface and thus does not need CORS. @@ -25,7 +25,7 @@ from another server. Odin React uses Bootstrap's components and for a lot of it's styling. - Ensure that you have not imported a custom CSS file that may be overwriting the normal styling for components. - - If you want to apply custom CSS styling to a page or component, refer to + - If you want to apply custom CSS styling to a page or component, refer to the [Page on Styling](./?path=/docs/layout-and-styling--docs) ## The AdapterEndpoints in my GUI are running twice when a page is loaded. diff --git a/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts b/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts index 2af32a5..2b1bb1e 100644 --- a/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts +++ b/lib/components/AdapterEndpoint/AdapterEndpoint.types.ts @@ -1,6 +1,6 @@ import type { AxiosRequestConfig } from "axios"; -interface AdapterEndpoint { +interface AdapterEndpoint = ParamNode> { /** * Recursive Nested dictionary structure representing the adapter Param Tree. Should be read only * from this interface @@ -8,7 +8,7 @@ interface AdapterEndpoint { data?: Readonly; /** - * Dictionary structure containing the adapter Metadata, if its implimented by the adapter + * Dictionary structure containing the adapter Metadata, if its implemented by the adapter */ metadata?: Readonly>; /** diff --git a/lib/components/AdapterEndpoint/index.stories.tsx b/lib/components/AdapterEndpoint/index.stories.tsx index bf6b002..a4d7a6e 100644 --- a/lib/components/AdapterEndpoint/index.stories.tsx +++ b/lib/components/AdapterEndpoint/index.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useAdapterEndpoint } from './index'; -import { ComponentProps, useMemo, useState } from 'react'; +import { ComponentProps, useState } from 'react'; import type { AdapterEndpoint, ParamNode } from './index'; import { adapters, update_adapters } from '../../../.storybook/stories.mock'; @@ -10,6 +10,7 @@ import { adapters, update_adapters } from '../../../.storybook/stories.mock'; import { FloatingLabel, InputGroup, Form, Card, ButtonGroup, Button, Row, Col } from 'react-bootstrap'; import { expect } from 'storybook/test'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; interface Endpoint { adapter: string; @@ -186,6 +187,7 @@ const meta = { (Story) => { return ( + ) diff --git a/package-lock.json b/package-lock.json index cda417a..029f81e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@storybook/addon-themes": "^10.2.17", "@storybook/addon-vitest": "^10.2.17", "@storybook/react-vite": "^10.2.17", + "@tanstack/react-query-devtools": "^5.100.10", "@types/node": "^24.0.10", "@types/plotly.js": "^3.0.2", "@types/react": "^19.0.8", @@ -2735,9 +2736,19 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.100.9", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz", - "integrity": "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==", + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.10.tgz", + "integrity": "sha512-3DmJf25hDPus5IpVvp6ujXv6bKV2zPzI9vpbAmpJigsL/H6DPvPjmf7/Q9yVKEke//8fgeQ45abjgnLuyYxAiw==", "license": "MIT", "funding": { "type": "github", @@ -2745,12 +2756,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.100.9", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz", - "integrity": "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==", + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", + "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.100.9" + "@tanstack/query-core": "5.100.10" }, "funding": { "type": "github", @@ -2760,6 +2771,23 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.10.tgz", + "integrity": "sha512-zes0+o9ef5rAZXJ9f/SeaLs2nufJaeVkZkl/Or9NGrWVF41kL9Od9ED9nCwtQlgiF2VGtrzhEw5AU/igAO+aAg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.100.10", + "react": "^18 || ^19" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -5728,9 +5756,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -10538,4 +10566,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 73e10d4..b347e70 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@storybook/addon-themes": "^10.2.17", "@storybook/addon-vitest": "^10.2.17", "@storybook/react-vite": "^10.2.17", + "@tanstack/react-query-devtools": "^5.100.10", "@types/node": "^24.0.10", "@types/plotly.js": "^3.0.2", "@types/react": "^19.0.8", @@ -102,4 +103,4 @@ "public" ] } -} +} \ No newline at end of file From 3f61b708e6e4fe8cc7fb53a7c7a67f46e734ca8b Mon Sep 17 00:00:00 2001 From: Ashley Neaves Date: Mon, 18 May 2026 12:53:52 +0100 Subject: [PATCH 7/8] Added QueryClient Provider to the OdinErrorContext that wraps all Apps, to ensure it's included --- lib/components/OdinApp/index.tsx | 5 ++- lib/components/OdinErrorContext/index.tsx | 43 ++++++++++++++++++----- src/main.tsx | 21 ++--------- 3 files changed, 41 insertions(+), 28 deletions(-) diff --git a/lib/components/OdinApp/index.tsx b/lib/components/OdinApp/index.tsx index 5749c82..98085c9 100644 --- a/lib/components/OdinApp/index.tsx +++ b/lib/components/OdinApp/index.tsx @@ -249,13 +249,16 @@ const ScrollableNavList = ({ navLinks }: ScrollableNavListProps) => { * Component designed to encapsulate the entire GUI. Provides a Nav Bar * with routing for GUIs with multiple pages,that also displays * an Icon, App title, a button to toggle between dark and light mode, - * and a button to display any errors that may have occured. + * and a button to display any errors that may have occurred. * This Nav Bar is pinned to the top of the screen and so is always visible. * * An error boundary is included in this component, so feedback for any * rendering errors caused by React is displayed, rather than a blank * page. * + * Each individual child of this component is treated as a separate Page, + * and connected to each of the provided navLinks in order. + * */ const OdinApp = ( diff --git a/lib/components/OdinErrorContext/index.tsx b/lib/components/OdinErrorContext/index.tsx index 08e33e3..0b126f3 100644 --- a/lib/components/OdinErrorContext/index.tsx +++ b/lib/components/OdinErrorContext/index.tsx @@ -3,6 +3,7 @@ import { useContext, createContext, useState, PropsWithChildren, CSSProperties, import { Alert, AlertProps, Badge } from "react-bootstrap"; import Style from './styles.module.css'; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; interface ErrorContext_t { /** @@ -20,6 +21,9 @@ interface ErrorContext_t { * Removes all errors from the ErrorList, resetting it to an empty array. */ clearAllError: () => void; + + /** List of all errors. */ + errors: OdinError[]; } /** @@ -51,6 +55,30 @@ interface ErrorAction { error?: Error | OdinError; } +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // as we are usually talking to a local Odin Control API, we + // don't care about the network status + networkMode: "always" + }, + mutations: { + networkMode: "always" + } + } +}); + +// TypeScript only: +declare global { + interface Window { + __TANSTACK_QUERY_CLIENT__: + import('@tanstack/query-core') + .QueryClient + } +} + +window.__TANSTACK_QUERY_CLIENT__ = queryClient + const errorsReducer = (errors: OdinError[], action: ErrorAction) => { const err = action.error; switch (action.type) { @@ -91,8 +119,6 @@ const errorsReducer = (errors: OdinError[], action: ErrorAction) => { } } - -const ErrorContext = createContext([]); const ErrorDispatchContext = createContext(null); /** @@ -120,14 +146,14 @@ const OdinErrorContext = (props: PropsWithChildren) => { type: ErrorActionType.CLEAR }); - const context = useMemo(() => ({ setError, clearError, clearAllError }), [errors]); + const context = useMemo(() => ({ setError, clearError, clearAllError, errors }), [errors]); return ( - - + + {props.children} - - + + ) } @@ -246,7 +272,6 @@ const SingleErrorOutlet = ({ delay = 5000 }: SingleErrorOutletProps) => { * Custom Hook, providing access to the error list and the methods from the context. */ const useError = () => { - const errors = useContext(ErrorContext); const methods = useContext(ErrorDispatchContext); if (methods == null) { @@ -255,7 +280,7 @@ const useError = () => { ); } - return { errors, ...methods }; + return { ...methods }; } export { OdinErrorContext, OdinErrorOutlet, SingleErrorOutlet, useError }; \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 7c93ff4..593e127 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,27 +2,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App.tsx' import { OdinErrorContext } from "odin-react" -import { QueryClientProvider, QueryClient } from '@tanstack/react-query' -const queryClient = new QueryClient(); - -//Code required to allow the Tanstack Query Dev Tools to function -// This code is only for TypeScript -declare global { - interface Window { - __TANSTACK_QUERY_CLIENT__: - import('@tanstack/query-core').QueryClient - } -} -// This code is for all users -window.__TANSTACK_QUERY_CLIENT__ = queryClient createRoot(document.getElementById('root')!).render( - - - - - + + + , ) From d34c889f91c72ca096972ab629665b84d9e0f0ae Mon Sep 17 00:00:00 2001 From: Ashley Neaves Date: Mon, 18 May 2026 13:40:21 +0100 Subject: [PATCH 8/8] 2.2.0 --- package-lock.json | 6 +++--- package.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 029f81e..cc4be36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dssg/odin-react", - "version": "2.1.2", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dssg/odin-react", - "version": "2.1.2", + "version": "2.2.0", "license": "Apache-2.0", "dependencies": { "@tanstack/react-query": "^5.100.9", @@ -10566,4 +10566,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index b347e70..86ef5de 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@dssg/odin-react", "private": false, - "version": "2.1.2", + "version": "2.2.0", "type": "module", "description": "Component library designed to work with the Odin Detector Control API", "author": { @@ -103,4 +103,4 @@ "public" ] } -} \ No newline at end of file +}