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.
This commit is contained in:
Hein
2026-02-02 13:18:33 +02:00
parent 63222f8f28
commit 29d56980b2
5 changed files with 564 additions and 0 deletions

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,117 @@
import React, { useEffect } from 'react'
import { setDefaultAPIOption } from '@bitechdev/utils'
import axios from 'axios'
import { Center, Notification } from '@mantine/core'
import { programDataStore, useProgramDataStore } from './store/ProgramDataStore.store'
import { ProgramDataStoreState, ProgramWrapperProps, SessionDetail } from './types'
/**
* Fetches program data and updates session details.
*
* @param {ProgramWrapperProps} props - Props for the ProgramDataWrapper component
* @return {ReactNode} The children of the component
*/
const ProgramDataWrapper = (props: ProgramWrapperProps) => {
const { fetchData, updateSession, connected, session, setState } = useProgramDataStore(
(state: ProgramDataStoreState) => ({
fetchData: state.fetchData,
updateSession: state.updateSession,
connected: state.connected,
session: state.session,
setState: state.setState,
})
)
useEffect(() => {
if (props.version) {
const program = programDataStore.getState()?.program
if (program) {
setState('program', { ...program, version: props.version })
}
}
}, [props.version])
useEffect(() => {
if (session?.apiURL && session?.apiURL !== '') {
fetchData(props.apiURL)
.catch((e) => {
console.error('Error fetching data:', e)
})
.finally(() => {
//Set the default API options
setDefaultAPIOption({
getAPIProvider: () => {
const s = programDataStore.getState()
if (s.session?.authtoken && s.session?.authtoken !== '') {
return { provider: undefined, providerKey: undefined }
}
return {
provider: s.session?.provider,
providerKey: s.session?.providerKey,
}
},
getAuthToken: () => {
const s = programDataStore.getState()
return s.session?.authtoken
},
})
})
}
}, [session?.apiURL, session?.authtoken])
useEffect(() => {
try {
updateSession?.((state: SessionDetail) => ({
apiURL: state.apiURL !== props.apiURL || !state.apiURL ? props.apiURL : state.apiURL,
debugMode: props.debugMode ?? state.debugMode,
testMode: props.testMode ?? state.testMode,
authtoken:
props.testMode && (!state.authtoken || state.authtoken === '') ? 'test' : state.authtoken,
provider:
props.testMode && (!state.provider || state.provider === '') ? 'bitech' : state.provider,
providerKey:
props.testMode && (!state.providerKey || state.providerKey === '')
? 'test'
: state.providerKey,
}))
axios.defaults.headers.common = {
'X-Program-API': 'bitechcore',
Authorization: `Bearer ${session?.authtoken}`,
}
if (session?.provider !== '') {
axios.defaults.headers.common['x-api-provider'] = session?.provider
}
if (session?.providerKey !== '') {
axios.defaults.headers.common['x-api-key'] = session?.providerKey
}
} catch (e) {
console.error('Error in ProgramDataWrapper useEffect:', e)
}
}, [
session.authtoken,
session.provider,
session.providerKey,
props.apiURL,
props.debugMode,
props.testMode,
])
if (props.renderFallback && !connected) {
return (
props.fallback ?? (
<Center>
<Notification withCloseButton={false} color='red' title='Program URL not set'>
Please make sure your app was correctly installed. If this problem persists, reset your
page cache and go to the login page.
</Notification>
</Center>
)
)
}
return props.children
}
export { ProgramDataWrapper }

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'