2 Commits

Author SHA1 Message Date
Hein
a81d59f3ba refactor(GlobalStateStore): 🔄 remove unused ProgramDataWrapper component 2026-02-02 13:18:40 +02:00
Hein
29d56980b2 feat(global-state-store): implement GlobalStateStore and utils
* Create GlobalStateStore for managing application state.
* Add utility functions for loading and saving state to storage.
* Define types for global state and session management.
* Implement ProgramDataWrapper for fetching and updating program data.
2026-02-02 13:18:33 +02:00
11 changed files with 1218 additions and 684 deletions

View File

@@ -1,11 +1,5 @@
# @warkypublic/zustandsyncstore # @warkypublic/zustandsyncstore
## 0.0.31
### Patch Changes
- ac6dcbf: Error Boundry
## 0.0.30 ## 0.0.30
### Patch Changes ### Patch Changes

View File

@@ -1,7 +1,7 @@
{ {
"name": "@warkypublic/oranguru", "name": "@warkypublic/oranguru",
"author": "Warky Devs", "author": "Warky Devs",
"version": "0.0.31", "version": "0.0.30",
"type": "module", "type": "module",
"types": "./dist/lib.d.ts", "types": "./dist/lib.d.ts",
"main": "./dist/lib.cjs.js", "main": "./dist/lib.cjs.js",
@@ -42,43 +42,40 @@
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.29.8", "@changesets/cli": "^2.29.8",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@microsoft/api-extractor": "^7.56.0", "@storybook/react-vite": "^10.2.1",
"@storybook/react-vite": "^10.2.3",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/jsdom": "~27.0.0", "@types/node": "^25.1.0",
"@types/node": "^25.2.0",
"@types/react": "^19.2.10", "@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/use-sync-external-store": "~1.5.0",
"@typescript-eslint/parser": "^8.54.0", "@typescript-eslint/parser": "^8.54.0",
"@vitejs/plugin-react-swc": "^4.2.3", "@vitejs/plugin-react-swc": "^4.2.2",
"eslint": "^9.39.2", "eslint": "^9.38.0",
"eslint-config-mantine": "^4.0.3", "eslint-config-mantine": "^4.0.3",
"eslint-plugin-perfectionist": "^5.4.0", "eslint-plugin-perfectionist": "^5.4.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.0", "eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-storybook": "^10.2.3", "eslint-plugin-storybook": "^9.1.15",
"global": "^4.4.0", "global": "^4.4.0",
"globals": "^17.3.0", "globals": "^17.2.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"jsdom": "^28.0.0", "jsdom": "^27.4.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "^3.8.1", "prettier": "^3.6.2",
"prettier-eslint": "^16.4.2", "prettier-eslint": "^16.4.2",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"storybook": "^10.2.3", "storybook": "^9.1.15",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.54.0", "typescript-eslint": "^8.46.2",
"vite": "^7.3.1", "vite": "^7.1.12",
"vite-plugin-dts": "^4.5.4", "vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^6.0.5", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.18" "vitest": "^4.0.3"
}, },
"peerDependencies": { "peerDependencies": {
"@glideapps/glide-data-grid": "^6.0.3", "@glideapps/glide-data-grid": "^6.0.3",
@@ -90,11 +87,11 @@
"@tanstack/react-query": "^5.90.5", "@tanstack/react-query": "^5.90.5",
"@warkypublic/artemis-kit": "^1.0.10", "@warkypublic/artemis-kit": "^1.0.10",
"@warkypublic/zustandsyncstore": "^0.0.4", "@warkypublic/zustandsyncstore": "^0.0.4",
"idb-keyval": "^6.2.2",
"immer": "^10.1.3", "immer": "^10.1.3",
"react": ">= 19.0.0", "react": ">= 19.0.0",
"react-dom": ">= 19.0.0", "react-dom": ">= 19.0.0",
"react-hook-form": "^7.71.0", "react-hook-form": "^7.71.0",
"idb-keyval": "^6.2.2",
"use-sync-external-store": ">= 1.4.0", "use-sync-external-store": ">= 1.4.0",
"zustand": ">= 5.0.0" "zustand": ">= 5.0.0"
} }

1403
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,237 @@
import type { StoreApi } from 'zustand';
import { produce } from 'immer';
import { shallow } from 'zustand/shallow';
import { useStoreWithEqualityFn } from 'zustand/traditional';
import { createStore } from 'zustand/vanilla';
import type { ExtractState, GlobalState, GlobalStateStoreState } from './GlobalStateStore.types';
import { loadStorage, saveStorage } from './GlobalStateStore.utils';
const emptyStore: GlobalState = {
connected: true, //Only invalidate when a connection cannot be made
controls: {},
environment: 'production',
loading: false,
meta: {},
program: {
name: '',
slug: '',
},
user: {
access_control: false,
avatar_docid: 0,
id: 0,
login: '',
name: 'Guest',
},
};
//We use vanilla store because we must be able to get the API key and token outside a react render loop
//The storage is custom because zustand's vanilla stores persist API crashes.
//Also not using the other store because it's using outdated methods and give that warning
/**
* A zustand store function for managing program data and session information.
*
* @returns A zustand store state object.
*/
const GlobalStateStore = createStore<GlobalStateStoreState>((set, get) => ({
...emptyStore,
fetchData: async (url?: string) => {
const setFetched = async (
fn: (partial: GlobalState | Partial<GlobalState>) => Partial<GlobalStateStoreState>
) => {
const state = fn(get());
set((cur) => {
return { ...cur, ...state };
});
};
try {
set((s) => ({
...s,
loading: true,
session: { ...s.session, apiURL: url ?? s.session.apiURL },
}));
const result = get().onFetchSession?.(get());
await setFetched((s) => ({
...s,
...result,
connected: true,
loading: false,
updatedAt: new Date().toISOString(),
}));
} catch (e) {
await setFetched((s) => ({
...s,
connected: false,
error: `Load Exception: ${String(e)}`,
loading: false,
}));
}
},
login: async (sessionData?: string) => {
const state = get();
const newstate = {
...state,
session: { ...state.session, authtoken: sessionData ?? '' },
user: { ...state.user },
};
set((cur) => {
return { ...cur, ...newstate };
});
await get().fetchData();
},
logout: async () => {
const newstate = { ...get(), ...emptyStore };
set((state) => {
return { ...state, ...newstate };
});
await get().fetchData();
},
setAuthToken: (token: string) =>
set(
produce((state) => {
state.session.authtoken = token;
})
),
setIsSecurity: (issecurity: boolean) =>
set(
produce((state) => {
state.session.jsonvalue.issecurity = issecurity;
})
),
setState: (key, value) =>
set(
produce((state) => {
state[key] = value;
})
),
setStateFN: (key, value) => {
set(
produce((state) => {
if (typeof value === 'function') {
state[key] = (value as (value: any) => any)(state[key]);
} else {
console.error('value is not a function', value);
}
})
);
},
updateSession: (setter: UpdateSessionType) => {
const curState = get();
const newSession: null | SessionDetail | void =
typeof setter === 'function'
? setter(curState?.session)
: typeof setter === 'object'
? (setter as SessionDetail)
: null;
if (newSession === null) {
return;
}
const updatedState = {
...curState,
session: { ...curState.session, ...(newSession || {}) },
};
set((state) => {
state = {
...state,
session: { ...state.session, ...updatedState.session },
};
return state;
});
},
}));
//Load storage after the createStore function is executed.
try {
loadStorage()
.then((state) =>
GlobalStateStore.setState((s: GlobalStateStoreState) => ({
...s,
...state,
}))
)
.catch((e) => {
console.error('Error loading storage:', e);
});
GlobalStateStore.subscribe((state, previousState) => {
//console.log('subscribe', state, previousState)
saveStorage(state).catch((e) => {
console.error('Error saving storage:', e);
});
if (state.session.authtoken !== previousState.session.authtoken) {
setAuthTokenAPI(state.session.authtoken);
}
});
} catch (e) {
console.error('Error loading storage:', e);
}
/**
* Type-bounded version of useStore with shallow equality build in
*/
const createTypeBoundedUseStore = ((store) => (selector) =>
useStoreWithEqualityFn(store, selector, shallow)) as <S extends StoreApi<unknown>>(
store: S
) => {
(): ExtractState<S>;
<T>(selector: (state: ExtractState<S>) => T): T;
};
/**
* Creates a hook to access the state of the `GlobalStateStore` with shallow equality
* checking in the selector function.
*
* @typeParam S - The type of the store
* @param store - The store to be used
* @returns A function that returns the state of the store, or a selected part of it
*/
const useGlobalStateStore = createTypeBoundedUseStore(GlobalStateStore);
/**
* Sets the API URL in the program data store state.
*
* @param {string} url - The URL to set as the API URL.
* @return {void}
*/
const setApiURL = (url: string) => {
if (typeof GlobalStateStore?.setState !== 'function') {
return;
}
GlobalStateStore.setState((s: GlobalStateStoreState) => ({
...s,
session: {
...s.session,
apiURL: url,
},
}));
};
/**
* Retrieves the API URL from the session stored in the program data store.
*
* @return {string} The API URL from the session.
*/
const getApiURL = (): string => {
if (typeof GlobalStateStore?.setState !== 'function') {
return '';
}
const s = GlobalStateStore.getState();
return s.session?.apiURL;
};
export { getApiURL, GlobalStateStore, setApiURL, useGlobalStateStore };

View File

@@ -0,0 +1,92 @@
import { type FunctionComponent } from 'react';
type DatabaseDetail = {
name?: string;
version?: string;
};
type ExtractState<S> = S extends { getState: () => infer X } ? X : never;
interface GlobalState {
[key: string]: any;
apiURL: string;
authtoken: string;
connected?: boolean;
environment?: 'development' | 'production';
error?: string;
globals?: Record<string, any>;
lastLoadTime?: string;
loading?: boolean;
menu?: Array<any>;
meta?: ProgramMetaData;
program: ProgramDetail;
updatedAt?: string;
user: UserDetail;
}
interface GlobalStateStoreState extends GlobalState {
fetchData: (url?: string) => Promise<void>;
login: (sessionData?: string) => Promise<void>;
logout: () => Promise<void>;
onFetchSession?: (state: GlobalState) => Promise<GlobalState>;
setAuthToken: (token: string) => void;
setIsSecurity: (isSecurity: boolean) => void;
setState: <K extends keyof GlobalState>(key: K, value: GlobalState[K]) => void;
setStateFN: <K extends keyof GlobalState>(
key: K,
value: (current: GlobalState[K]) => Partial<GlobalState[K]>
) => void;
}
type ProgramDetail = {
backend_version?: string;
biglogolink?: string;
database?: DatabaseDetail;
database_version?: string;
logolink?: string;
name: string;
programSummary?: string;
rid_owner?: number;
slug: string;
version?: string;
};
interface ProgramMetaData {
[key: string]: any;
}
interface ProgramWrapperProps {
apiURL?: string;
children: React.ReactNode | React.ReactNode[];
debugMode?: boolean;
fallback?: React.ReactNode | React.ReactNode[];
renderFallback?: boolean;
testMode?: boolean;
version?: string;
}
type UserDetail = {
access_control?: boolean;
avatar_docid?: number;
fullnames?: string;
guid?: string;
id?: number;
isadmin?: boolean;
login?: string;
name?: string;
notice_msg?: string;
parameters?: Record<string, any>;
rid_hub?: number;
rid_user?: number;
secuser?: Record<string, any>;
};
export type {
ExtractState,
GlobalState,
GlobalStateStoreState,
ProgramDetail,
ProgramWrapperProps,
UserDetail,
};

View File

@@ -0,0 +1,109 @@
import { createStore, entries, set, type UseStore } from 'idb-keyval';
const STORAGE_KEY = 'app-data';
const initilizeStore = () => {
if (indexedDB) {
try {
return createStore('programdata', 'programdata');
} catch (e) {
console.error('Failed to initialize indexedDB store: ', STORAGE_KEY, e);
}
}
return null;
};
const programDataIndexDBStore: null | UseStore = initilizeStore();
const skipKeysCallback = (dataKey: string, dataValue: any) => {
if (typeof dataValue === 'function') {
return undefined;
}
if (
dataKey === 'loading' ||
dataKey === 'error' ||
dataKey === 'security' ||
dataKey === 'meta' ||
dataKey === 'help'
) {
return undefined;
}
return dataValue;
};
async function loadStorage<T = any>(storageKey?: string): Promise<T> {
if (indexedDB) {
try {
const storeValues = await entries(programDataIndexDBStore);
const obj: any = {};
storeValues.forEach((arr: string[]) => {
const k = String(arr[0]);
obj[k] = JSON.parse(arr[1]);
});
return obj;
} catch (e) {
console.error('Failed to load storage: ', storageKey ?? STORAGE_KEY, e);
}
} else if (localStorage) {
try {
const storagedata = localStorage.getItem(storageKey ?? STORAGE_KEY);
if (storagedata && storagedata.length > 0) {
const obj = JSON.parse(storagedata, (_dataKey, dataValue) => {
if (typeof dataValue === 'string' && dataValue.startsWith('function')) {
return undefined;
}
return dataValue;
});
return obj;
}
return {} as T;
} catch (e) {
console.error('Failed to load storage: ', storageKey ?? STORAGE_KEY, e);
}
}
return {} as T;
}
async function saveStorage<T = any>(data: T, storageKey?: string): Promise<T> {
if (indexedDB) {
try {
const keys = Object.keys(data as object).filter(
(key) =>
key !== 'loading' &&
key !== 'error' &&
key !== 'help' &&
key !== 'meta' &&
key !== 'security' &&
typeof data[key as keyof T] !== 'function'
);
const promises = keys.map((key) => {
return set(
key,
JSON.stringify((data as any)[key], skipKeysCallback) ?? '{}',
programDataIndexDBStore
);
});
await Promise.all(promises);
return data;
} catch (e) {
console.error('Failed to save indexedDB storage: ', storageKey ?? STORAGE_KEY, e);
}
} else if (localStorage) {
try {
const dataString = JSON.stringify(data, skipKeysCallback);
localStorage.setItem(storageKey ?? STORAGE_KEY, dataString ?? '{}');
return data;
} catch (e) {
console.error('Failed to save localStorage storage: ', storageKey ?? STORAGE_KEY, e);
}
}
return {} as T;
}
export { loadStorage, saveStorage };

View File

@@ -0,0 +1,9 @@
export { ProgramDataWrapper } from './src/ProgramDataWrapper'
export {
getApiURL,
setApiURL,
programDataStore,
useProgramDataStore,
} from './src/store/ProgramDataStore.store'
export type * from './src/types'

View File

@@ -4,8 +4,7 @@
"target": "es6", "target": "es6",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"types": [ "types": [
"./global.d.ts", "./global.d.ts"
"node"
], ],
"lib": [ "lib": [
"ES2016", "ES2016",
@@ -32,8 +31,7 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true
}, },
"include": [ "include": [
"src", "src",

View File

@@ -19,8 +19,7 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true
"types": ["node"]
}, },
"include": [ "include": [
"vite.config.ts" "vite.config.ts"

View File

@@ -21,10 +21,10 @@ export default defineConfig({
tsconfigPath: './tsconfig.app.json', tsconfigPath: './tsconfig.app.json',
compilerOptions: { compilerOptions: {
noEmit: false, noEmit: false,
skipLibCheck: true,
emitDeclarationOnly: true, emitDeclarationOnly: true,
}, },
}), }),
], ],
publicDir: 'public', publicDir: 'public',
build: { build: {