diff --git a/src/GlobalStateStore/GlobalStateStore.ts b/src/GlobalStateStore/GlobalStateStore.ts new file mode 100644 index 0000000..dfe1fe9 --- /dev/null +++ b/src/GlobalStateStore/GlobalStateStore.ts @@ -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((set, get) => ({ + ...emptyStore, + fetchData: async (url?: string) => { + const setFetched = async ( + fn: (partial: GlobalState | Partial) => Partial + ) => { + 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 >( + store: S +) => { + (): ExtractState; + (selector: (state: ExtractState) => 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 }; diff --git a/src/GlobalStateStore/GlobalStateStore.types.ts b/src/GlobalStateStore/GlobalStateStore.types.ts new file mode 100644 index 0000000..eb4e63c --- /dev/null +++ b/src/GlobalStateStore/GlobalStateStore.types.ts @@ -0,0 +1,92 @@ +import { type FunctionComponent } from 'react'; + +type DatabaseDetail = { + name?: string; + version?: string; +}; + +type ExtractState = 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; + lastLoadTime?: string; + loading?: boolean; + menu?: Array; + meta?: ProgramMetaData; + program: ProgramDetail; + + updatedAt?: string; + user: UserDetail; +} + +interface GlobalStateStoreState extends GlobalState { + fetchData: (url?: string) => Promise; + login: (sessionData?: string) => Promise; + logout: () => Promise; + onFetchSession?: (state: GlobalState) => Promise; + setAuthToken: (token: string) => void; + setIsSecurity: (isSecurity: boolean) => void; + setState: (key: K, value: GlobalState[K]) => void; + setStateFN: ( + key: K, + value: (current: GlobalState[K]) => Partial + ) => 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; + rid_hub?: number; + rid_user?: number; + secuser?: Record; +}; + +export type { + ExtractState, + GlobalState, + GlobalStateStoreState, + ProgramDetail, + ProgramWrapperProps, + UserDetail, +}; diff --git a/src/GlobalStateStore/GlobalStateStore.utils.ts b/src/GlobalStateStore/GlobalStateStore.utils.ts new file mode 100644 index 0000000..045df57 --- /dev/null +++ b/src/GlobalStateStore/GlobalStateStore.utils.ts @@ -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(storageKey?: string): Promise { + 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(data: T, storageKey?: string): Promise { + 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 }; diff --git a/src/GlobalStateStore/GlobalStateStoreWrapper.tsx b/src/GlobalStateStore/GlobalStateStoreWrapper.tsx new file mode 100644 index 0000000..9660ee4 --- /dev/null +++ b/src/GlobalStateStore/GlobalStateStoreWrapper.tsx @@ -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 ?? ( +
+ + Please make sure your app was correctly installed. If this problem persists, reset your + page cache and go to the login page. + +
+ ) + ) + } + + return props.children +} + +export { ProgramDataWrapper } diff --git a/src/GlobalStateStore/index.ts b/src/GlobalStateStore/index.ts new file mode 100644 index 0000000..acbf2b7 --- /dev/null +++ b/src/GlobalStateStore/index.ts @@ -0,0 +1,9 @@ +export { ProgramDataWrapper } from './src/ProgramDataWrapper' +export { + getApiURL, + setApiURL, + programDataStore, + useProgramDataStore, +} from './src/store/ProgramDataStore.store' + +export type * from './src/types'