feat(globalStateStore): implement global state management with persistence
- refactor state structure to include app, layout, navigation, owner, program, session, and user - add slices for managing program, session, owner, user, layout, navigation, and app states - create context provider for global state with automatic fetching and throttling - implement persistence using IndexedDB with localStorage fallback - add comprehensive README documentation for usage and API
This commit is contained in:
@@ -1,189 +1,250 @@
|
||||
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 type {
|
||||
AppState,
|
||||
BarState,
|
||||
ExtractState,
|
||||
GlobalState,
|
||||
GlobalStateStoreType,
|
||||
LayoutState,
|
||||
NavigationState,
|
||||
OwnerState,
|
||||
ProgramState,
|
||||
SessionState,
|
||||
UserState,
|
||||
} 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: {},
|
||||
const initialState: GlobalState = {
|
||||
app: {
|
||||
controls: {},
|
||||
environment: 'production',
|
||||
},
|
||||
layout: {
|
||||
bottomBar: { open: false },
|
||||
leftBar: { open: false },
|
||||
rightBar: { open: false },
|
||||
topBar: { open: false },
|
||||
},
|
||||
navigation: {
|
||||
menu: [],
|
||||
},
|
||||
owner: {
|
||||
id: 0,
|
||||
name: '',
|
||||
},
|
||||
program: {
|
||||
name: '',
|
||||
slug: '',
|
||||
},
|
||||
|
||||
session: {
|
||||
apiURL: '',
|
||||
authToken: '',
|
||||
connected: true,
|
||||
loading: false,
|
||||
},
|
||||
user: {
|
||||
access_control: false,
|
||||
avatar_docid: 0,
|
||||
id: 0,
|
||||
login: '',
|
||||
name: 'Guest',
|
||||
username: '',
|
||||
},
|
||||
};
|
||||
|
||||
//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
|
||||
type GetState = () => GlobalStateStoreType;
|
||||
type SetState = (
|
||||
partial: ((state: GlobalState) => Partial<GlobalState>) | Partial<GlobalState>
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
});
|
||||
};
|
||||
const createProgramSlice = (set: SetState) => ({
|
||||
setProgram: (updates: Partial<ProgramState>) =>
|
||||
set((state: GlobalState) => ({
|
||||
program: { ...state.program, ...updates },
|
||||
})),
|
||||
});
|
||||
|
||||
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();
|
||||
},
|
||||
const createSessionSlice = (set: SetState) => ({
|
||||
setApiURL: (url: string) =>
|
||||
set((state: GlobalState) => ({
|
||||
session: { ...state.session, apiURL: url },
|
||||
})),
|
||||
|
||||
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();
|
||||
set((state: GlobalState) => ({
|
||||
session: { ...state.session, authToken: token },
|
||||
})),
|
||||
|
||||
const newSession: null | SessionDetail | void =
|
||||
typeof setter === 'function'
|
||||
? setter(curState?.session)
|
||||
: typeof setter === 'object'
|
||||
? (setter as SessionDetail)
|
||||
: null;
|
||||
if (newSession === null) {
|
||||
return;
|
||||
}
|
||||
setSession: (updates: Partial<SessionState>) =>
|
||||
set((state: GlobalState) => ({
|
||||
session: { ...state.session, ...updates },
|
||||
})),
|
||||
});
|
||||
|
||||
const updatedState = {
|
||||
...curState,
|
||||
session: { ...curState.session, ...(newSession || {}) },
|
||||
};
|
||||
const createOwnerSlice = (set: SetState) => ({
|
||||
setOwner: (updates: Partial<OwnerState>) =>
|
||||
set((state: GlobalState) => ({
|
||||
owner: { ...state.owner, ...updates },
|
||||
})),
|
||||
});
|
||||
|
||||
set((state) => {
|
||||
state = {
|
||||
const createUserSlice = (set: SetState) => ({
|
||||
setUser: (updates: Partial<UserState>) =>
|
||||
set((state: GlobalState) => ({
|
||||
user: { ...state.user, ...updates },
|
||||
})),
|
||||
});
|
||||
|
||||
const createLayoutSlice = (set: SetState) => ({
|
||||
setBottomBar: (updates: Partial<BarState>) =>
|
||||
set((state: GlobalState) => ({
|
||||
layout: { ...state.layout, bottomBar: { ...state.layout.bottomBar, ...updates } },
|
||||
})),
|
||||
|
||||
setLayout: (updates: Partial<LayoutState>) =>
|
||||
set((state: GlobalState) => ({
|
||||
layout: { ...state.layout, ...updates },
|
||||
})),
|
||||
|
||||
setLeftBar: (updates: Partial<BarState>) =>
|
||||
set((state: GlobalState) => ({
|
||||
layout: { ...state.layout, leftBar: { ...state.layout.leftBar, ...updates } },
|
||||
})),
|
||||
|
||||
setRightBar: (updates: Partial<BarState>) =>
|
||||
set((state: GlobalState) => ({
|
||||
layout: { ...state.layout, rightBar: { ...state.layout.rightBar, ...updates } },
|
||||
})),
|
||||
|
||||
setTopBar: (updates: Partial<BarState>) =>
|
||||
set((state: GlobalState) => ({
|
||||
layout: { ...state.layout, topBar: { ...state.layout.topBar, ...updates } },
|
||||
})),
|
||||
});
|
||||
|
||||
const createNavigationSlice = (set: SetState) => ({
|
||||
setCurrentPage: (page: NavigationState['currentPage']) =>
|
||||
set((state: GlobalState) => ({
|
||||
navigation: { ...state.navigation, currentPage: page },
|
||||
})),
|
||||
|
||||
setMenu: (menu: NavigationState['menu']) =>
|
||||
set((state: GlobalState) => ({
|
||||
navigation: { ...state.navigation, menu },
|
||||
})),
|
||||
|
||||
setNavigation: (updates: Partial<NavigationState>) =>
|
||||
set((state: GlobalState) => ({
|
||||
navigation: { ...state.navigation, ...updates },
|
||||
})),
|
||||
});
|
||||
|
||||
const createAppSlice = (set: SetState) => ({
|
||||
setApp: (updates: Partial<AppState>) =>
|
||||
set((state: GlobalState) => ({
|
||||
app: { ...state.app, ...updates },
|
||||
})),
|
||||
});
|
||||
|
||||
const createComplexActions = (set: SetState, get: GetState) => ({
|
||||
fetchData: async (url?: string) => {
|
||||
try {
|
||||
set((state: GlobalState) => ({
|
||||
session: {
|
||||
...state.session,
|
||||
apiURL: url ?? state.session.apiURL,
|
||||
loading: true,
|
||||
},
|
||||
}));
|
||||
|
||||
const currentState = get();
|
||||
const result = await currentState.onFetchSession?.(currentState);
|
||||
|
||||
set((state: GlobalState) => ({
|
||||
...state,
|
||||
session: { ...state.session, ...updatedState.session },
|
||||
};
|
||||
return state;
|
||||
});
|
||||
...result,
|
||||
app: {
|
||||
...state.app,
|
||||
...result?.app,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
session: {
|
||||
...state.session,
|
||||
...result?.session,
|
||||
connected: true,
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
} catch (e) {
|
||||
set((state: GlobalState) => ({
|
||||
session: {
|
||||
...state.session,
|
||||
connected: false,
|
||||
error: `Load Exception: ${String(e)}`,
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
login: async (authToken?: string) => {
|
||||
set((state: GlobalState) => ({
|
||||
session: {
|
||||
...state.session,
|
||||
authToken: authToken ?? '',
|
||||
},
|
||||
}));
|
||||
await get().fetchData();
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
set((state: GlobalState) => ({
|
||||
...initialState,
|
||||
session: {
|
||||
...initialState.session,
|
||||
apiURL: state.session.apiURL,
|
||||
},
|
||||
}));
|
||||
await get().fetchData();
|
||||
},
|
||||
});
|
||||
|
||||
const GlobalStateStore = createStore<GlobalStateStoreType>((set, get) => ({
|
||||
...initialState,
|
||||
...createProgramSlice(set),
|
||||
...createSessionSlice(set),
|
||||
...createOwnerSlice(set),
|
||||
...createUserSlice(set),
|
||||
...createLayoutSlice(set),
|
||||
...createNavigationSlice(set),
|
||||
...createAppSlice(set),
|
||||
...createComplexActions(set, get),
|
||||
}));
|
||||
|
||||
//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);
|
||||
}
|
||||
loadStorage()
|
||||
.then((state) => {
|
||||
GlobalStateStore.setState((current) => ({
|
||||
...current,
|
||||
...state,
|
||||
session: {
|
||||
...current.session,
|
||||
...state.session,
|
||||
connected: true,
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Error loading storage:', e);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error loading storage:', e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-bounded version of useStore with shallow equality build in
|
||||
*/
|
||||
GlobalStateStore.subscribe((state) => {
|
||||
saveStorage(state).catch((e) => {
|
||||
console.error('Error saving storage:', e);
|
||||
});
|
||||
});
|
||||
|
||||
const createTypeBoundedUseStore = ((store) => (selector) =>
|
||||
useStoreWithEqualityFn(store, selector, shallow)) as <S extends StoreApi<unknown>>(
|
||||
store: S
|
||||
@@ -192,46 +253,26 @@ const createTypeBoundedUseStore = ((store) => (selector) =>
|
||||
<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,
|
||||
},
|
||||
}));
|
||||
GlobalStateStore.getState().setApiURL(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;
|
||||
return GlobalStateStore.getState().session.apiURL;
|
||||
};
|
||||
|
||||
export { getApiURL, GlobalStateStore, setApiURL, useGlobalStateStore };
|
||||
const getAuthToken = (): string => {
|
||||
return GlobalStateStore.getState().session.authToken;
|
||||
};
|
||||
|
||||
const setAuthToken = (token: string) => {
|
||||
GlobalStateStore.getState().setAuthToken(token);
|
||||
};
|
||||
|
||||
const GetGlobalState = (): GlobalStateStoreType => {
|
||||
return GlobalStateStore.getState();
|
||||
}
|
||||
|
||||
export { getApiURL, getAuthToken, GetGlobalState, GlobalStateStore, setApiURL, setAuthToken, useGlobalStateStore };
|
||||
|
||||
Reference in New Issue
Block a user