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:
2026-02-07 20:03:27 +02:00
parent 202a826642
commit f737b1d11d
22 changed files with 3098 additions and 488 deletions

View File

@@ -1,17 +1,17 @@
import { Combobox, Checkbox } from '@mantine/core';
import { Checkbox, Combobox } from '@mantine/core';
import { useMemo } from 'react';
import type { BoxerItem } from '../Boxer.types';
interface UseBoxerOptionsProps {
boxerData: Array<BoxerItem>;
value?: any | Array<any>;
multiSelect?: boolean;
onOptionSubmit: (index: number) => void;
value?: any | Array<any>;
}
const useBoxerOptions = (props: UseBoxerOptionsProps) => {
const { boxerData, value, multiSelect, onOptionSubmit } = props;
const { boxerData, multiSelect, onOptionSubmit, value } = props;
const options = useMemo(() => {
return boxerData.map((item, index) => {
@@ -21,15 +21,15 @@ const useBoxerOptions = (props: UseBoxerOptionsProps) => {
return (
<Combobox.Option
key={`${item.value}-${index}`}
value={String(index)}
active={isSelected}
key={`${item.value}-${index}`}
onClick={() => {
onOptionSubmit(index);
}}
value={String(index)}
>
{multiSelect ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ alignItems: 'center', display: 'flex', gap: '8px' }}>
<Checkbox checked={isSelected} onChange={() => {}} tabIndex={-1} />
<span>{item.label}</span>
</div>

View File

@@ -97,7 +97,7 @@ const FormerInner = forwardRef<FormerRef<any>, Partial<FormerProps<any>> & Props
return (
<FormProvider {...formMethods}>
{typeof wrapper === 'function' ? (
wrapper(<FormerLayout>{props.children}</FormerLayout>, opened, onClose, onOpen, getState)
wrapper(<FormerLayout>{props.children}</FormerLayout>, opened ??false, onClose ?? (() => {setState('opened', false)}), onOpen ?? (() => {setState('opened', true)}), getState)
) : (
<FormerLayout>{props.children || null}</FormerLayout>
)}

View File

@@ -62,9 +62,9 @@ export interface FormerRef<T extends FieldValues = any> {
export type FormerSectionRender<T extends FieldValues = any> = (
children: React.ReactNode,
opened: boolean | undefined,
onClose: ((data?: T) => void) | undefined,
onOpen: ((data?: T) => void) | undefined,
opened: boolean ,
onClose: ((data?: T) => void),
onOpen: ((data?: T) => void) ,
getState: FormerState<T>['getState']
) => React.ReactNode;

View File

@@ -2,19 +2,20 @@ import { useFormerStore } from './Former.store';
import { FormerButtonArea } from './FormerButtonArea';
export const FormerLayoutBottom = () => {
const { buttonArea, getState, opened, renderBottom } = useFormerStore((state) => ({
const { buttonArea, getState, opened, renderBottom ,setState} = useFormerStore((state) => ({
buttonArea: state.layout?.buttonArea,
getState: state.getState,
opened: state.opened,
renderBottom: state.layout?.renderBottom,
setState: state.setState,
}));
if (renderBottom) {
return renderBottom(
<FormerButtonArea />,
opened,
getState('onClose'),
getState('onOpen'),
opened ?? false,
getState('onClose') ?? (() => {setState('opened', false)}),
getState('onOpen') ?? (() => {setState('opened', true)}),
getState
);
}

View File

@@ -2,19 +2,20 @@ import { useFormerStore } from './Former.store';
import { FormerButtonArea } from './FormerButtonArea';
export const FormerLayoutTop = () => {
const { buttonArea, getState, opened, renderTop } = useFormerStore((state) => ({
const { buttonArea, getState, opened, renderTop,setState } = useFormerStore((state) => ({
buttonArea: state.layout?.buttonArea,
getState: state.getState,
opened: state.opened,
renderTop: state.layout?.renderTop,
setState: state.setState,
}));
if (renderTop) {
return renderTop(
<FormerButtonArea />,
opened,
getState('onClose'),
getState('onOpen'),
opened ?? false,
getState('onClose') ?? (() => {setState('opened', false)}),
getState('onOpen') ?? (() => {setState('opened', true)}),
getState
);
}

View File

@@ -0,0 +1,411 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button, Card, Group, Stack, Switch, Text, TextInput, Title } from '@mantine/core';
import { useEffect, useState } from 'react';
import {
GlobalStateStore,
GlobalStateStoreProvider,
useGlobalStateStore,
useGlobalStateStoreContext,
} from './';
// Basic State Display Component
const StateDisplay = () => {
const state = useGlobalStateStore();
return (
<Card>
<Stack gap="sm">
<Title order={3}>Current State</Title>
<div>
<Text fw={700}>Program:</Text>
<Text size="sm">Name: {state.program.name || '(empty)'}</Text>
<Text size="sm">Slug: {state.program.slug || '(empty)'}</Text>
</div>
<div>
<Text fw={700}>Session:</Text>
<Text size="sm">API URL: {state.session.apiURL || '(empty)'}</Text>
<Text size="sm">Connected: {state.session.connected ? 'Yes' : 'No'}</Text>
<Text size="sm">Auth Token: {state.session.authToken || '(empty)'}</Text>
</div>
<div>
<Text fw={700}>Owner:</Text>
<Text size="sm">Name: {state.owner.name || '(empty)'}</Text>
<Text size="sm">ID: {state.owner.id}</Text>
<Text size="sm">Theme: {state.owner.theme?.name || 'none'}</Text>
<Text size="sm">Dark Mode: {state.owner.theme?.darkMode ? 'Yes' : 'No'}</Text>
</div>
<div>
<Text fw={700}>User:</Text>
<Text size="sm">Username: {state.user.username || '(empty)'}</Text>
<Text size="sm">Email: {state.user.email || '(empty)'}</Text>
<Text size="sm">Theme: {state.user.theme?.name || 'none'}</Text>
<Text size="sm">Dark Mode: {state.user.theme?.darkMode ? 'Yes' : 'No'}</Text>
</div>
<div>
<Text fw={700}>Layout:</Text>
<Text size="sm">Left Bar: {state.layout.leftBar.open ? 'Open' : 'Closed'}</Text>
<Text size="sm">Right Bar: {state.layout.rightBar.open ? 'Open' : 'Closed'}</Text>
<Text size="sm">Top Bar: {state.layout.topBar.open ? 'Open' : 'Closed'}</Text>
<Text size="sm">Bottom Bar: {state.layout.bottomBar.open ? 'Open' : 'Closed'}</Text>
</div>
</Stack>
</Card>
);
};
// Interactive Controls Component
const InteractiveControls = () => {
const state = useGlobalStateStore();
const [programName, setProgramName] = useState('');
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
return (
<Card>
<Stack gap="md">
<Title order={3}>Controls</Title>
<div>
<Text fw={700} mb="xs">Program</Text>
<Group>
<TextInput
onChange={(e) => setProgramName(e.currentTarget.value)}
placeholder="Program name"
value={programName}
/>
<Button onClick={() => state.setProgram({ name: programName })}>
Set Program Name
</Button>
</Group>
</div>
<div>
<Text fw={700} mb="xs">User</Text>
<Stack gap="xs">
<Group>
<TextInput
onChange={(e) => setUsername(e.currentTarget.value)}
placeholder="Username"
value={username}
/>
<TextInput
onChange={(e) => setEmail(e.currentTarget.value)}
placeholder="Email"
value={email}
/>
<Button onClick={() => state.setUser({ email, username })}>
Set User Info
</Button>
</Group>
</Stack>
</div>
<div>
<Text fw={700} mb="xs">Theme</Text>
<Group>
<Switch
checked={state.user.theme?.darkMode || false}
label="User Dark Mode"
onChange={(e) =>
state.setUser({
theme: { ...state.user.theme, darkMode: e.currentTarget.checked },
})
}
/>
<Switch
checked={state.owner.theme?.darkMode || false}
label="Owner Dark Mode"
onChange={(e) =>
state.setOwner({
theme: { ...state.owner.theme, darkMode: e.currentTarget.checked },
})
}
/>
</Group>
</div>
<div>
<Text fw={700} mb="xs">Layout</Text>
<Group>
<Switch
checked={state.layout.leftBar.open}
label="Left Bar"
onChange={(e) => state.setLeftBar({ open: e.currentTarget.checked })}
/>
<Switch
checked={state.layout.rightBar.open}
label="Right Bar"
onChange={(e) => state.setRightBar({ open: e.currentTarget.checked })}
/>
<Switch
checked={state.layout.topBar.open}
label="Top Bar"
onChange={(e) => state.setTopBar({ open: e.currentTarget.checked })}
/>
<Switch
checked={state.layout.bottomBar.open}
label="Bottom Bar"
onChange={(e) => state.setBottomBar({ open: e.currentTarget.checked })}
/>
</Group>
</div>
<div>
<Text fw={700} mb="xs">Actions</Text>
<Group>
<Button
color="red"
onClick={() => {
state.setProgram({ name: '', slug: '' });
state.setUser({ email: '', username: '' });
state.setOwner({ id: 0, name: '' });
}}
>
Reset State
</Button>
</Group>
</div>
</Stack>
</Card>
);
};
// Provider Context Example
const ProviderExample = () => {
const { refetch } = useGlobalStateStoreContext();
const state = useGlobalStateStore();
return (
<Card>
<Stack gap="md">
<Title order={3}>Provider Context</Title>
<Text>API URL: {state.session.apiURL}</Text>
<Text>Loading: {state.session.loading ? 'Yes' : 'No'}</Text>
<Text>Connected: {state.session.connected ? 'Yes' : 'No'}</Text>
<Button onClick={refetch}>Refetch Data</Button>
</Stack>
</Card>
);
};
// Main Story Component
const BasicStory = () => {
useEffect(() => {
// Set initial state for demo
GlobalStateStore.getState().setProgram({
description: 'A demonstration application',
name: 'Demo App',
slug: 'demo-app',
});
GlobalStateStore.getState().setOwner({
id: 1,
name: 'Demo Organization',
theme: { darkMode: false, name: 'light' },
});
GlobalStateStore.getState().setUser({
email: 'demo@example.com',
theme: { darkMode: false, name: 'light' },
username: 'demo-user',
});
}, []);
return (
<Stack gap="lg" h={"100%"}>
<StateDisplay />
<InteractiveControls />
</Stack>
);
};
// Provider Story Component
const ProviderStory = () => {
return (
<GlobalStateStoreProvider
apiURL="https://api.example.com"
fetchOnMount={false}
throttleMs={1000}
>
<Stack gap="lg" h={"100%"}>
<StateDisplay />
<ProviderExample />
<InteractiveControls />
</Stack>
</GlobalStateStoreProvider>
);
};
// Layout Controls Story
const LayoutStory = () => {
const state = useGlobalStateStore();
return (
<Stack gap="lg" h={"100%"}>
<Card>
<Title order={3}>Layout Controls</Title>
<Stack gap="md" mt="md">
<Group>
<Stack gap="xs" style={{ flex: 1 }}>
<Text fw={700}>Left Sidebar</Text>
<Switch
checked={state.layout.leftBar.open}
label="Open"
onChange={(e) => state.setLeftBar({ open: e.currentTarget.checked })}
/>
<Switch
checked={state.layout.leftBar.pinned || false}
label="Pinned"
onChange={(e) => state.setLeftBar({ pinned: e.currentTarget.checked })}
/>
<Switch
checked={state.layout.leftBar.collapsed || false}
label="Collapsed"
onChange={(e) => state.setLeftBar({ collapsed: e.currentTarget.checked })}
/>
<TextInput
label="Size"
onChange={(e) =>
state.setLeftBar({ size: parseInt(e.currentTarget.value) || 0 })
}
type="number"
value={state.layout.leftBar.size || 0}
/>
</Stack>
<Stack gap="xs" style={{ flex: 1 }}>
<Text fw={700}>Right Sidebar</Text>
<Switch
checked={state.layout.rightBar.open}
label="Open"
onChange={(e) => state.setRightBar({ open: e.currentTarget.checked })}
/>
<Switch
checked={state.layout.rightBar.pinned || false}
label="Pinned"
onChange={(e) => state.setRightBar({ pinned: e.currentTarget.checked })}
/>
</Stack>
</Group>
</Stack>
</Card>
<StateDisplay />
</Stack>
);
};
// Theme Story
const ThemeStory = () => {
const state = useGlobalStateStore();
useEffect(() => {
GlobalStateStore.getState().setOwner({
id: 1,
name: 'Acme Corp',
theme: { darkMode: false, name: 'corporate' },
});
}, []);
return (
<Stack gap="lg" h={"100%"}>
<Card>
<Title order={3}>Theme Settings</Title>
<Stack gap="md" mt="md">
<div>
<Text fw={700} mb="xs">Owner Theme (Organization Default)</Text>
<Group>
<TextInput
label="Theme Name"
onChange={(e) =>
state.setOwner({
theme: { ...state.owner.theme, name: e.currentTarget.value },
})
}
value={state.owner.theme?.name || ''}
/>
<Switch
checked={state.owner.theme?.darkMode || false}
label="Dark Mode"
onChange={(e) =>
state.setOwner({
theme: { ...state.owner.theme, darkMode: e.currentTarget.checked },
})
}
/>
</Group>
</div>
<div>
<Text fw={700} mb="xs">User Theme (Personal Override)</Text>
<Group>
<TextInput
label="Theme Name"
onChange={(e) =>
state.setUser({
theme: { ...state.user.theme, name: e.currentTarget.value },
})
}
value={state.user.theme?.name || ''}
/>
<Switch
checked={state.user.theme?.darkMode || false}
label="Dark Mode"
onChange={(e) =>
state.setUser({
theme: { ...state.user.theme, darkMode: e.currentTarget.checked },
})
}
/>
</Group>
</div>
<div>
<Text fw={700} mb="xs">Effective Theme</Text>
<Text>
Name: {state.user.theme?.name || state.owner.theme?.name || 'default'}
</Text>
<Text>
Dark Mode:{' '}
{(state.user.theme?.darkMode ?? state.owner.theme?.darkMode) ? 'Yes' : 'No'}
</Text>
</div>
</Stack>
</Card>
<StateDisplay />
</Stack>
);
};
const meta = {
component: BasicStory,
parameters: {
layout: 'fullscreen',
},
tags: ['autodocs'],
title: 'State/GlobalStateStore',
} satisfies Meta<typeof BasicStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Basic: Story = {
render: () => <BasicStory />,
};
export const WithProvider: Story = {
render: () => <ProviderStory />,
};
export const LayoutControls: Story = {
render: () => <LayoutStory />,
};
export const ThemeControls: Story = {
render: () => <ThemeStory />,
};

View File

@@ -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 };

View File

@@ -1,4 +1,21 @@
import { type FunctionComponent } from 'react';
/* eslint-disable @typescript-eslint/no-explicit-any */
interface AppState {
controls?: Record<string, any>;
environment: 'development' | 'production';
globals?: Record<string, any>;
updatedAt?: string;
}
interface BarState {
collapsed?: boolean;
menuItems?: MenuItem[];
meta?: Record<string, any>;
open: boolean;
pinned?: boolean;
render?: () => React.ReactNode;
size?: number;
}
type DatabaseDetail = {
name?: string;
@@ -8,52 +25,108 @@ type DatabaseDetail = {
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;
app: AppState;
layout: LayoutState;
navigation: NavigationState;
owner: OwnerState;
program: ProgramState;
session: SessionState;
user: UserState;
}
interface GlobalStateStoreState extends GlobalState {
interface GlobalStateActions {
// Complex actions
fetchData: (url?: string) => Promise<void>;
login: (sessionData?: string) => Promise<void>;
login: (authToken?: string) => Promise<void>;
logout: () => Promise<void>;
onFetchSession?: (state: GlobalState) => Promise<GlobalState>;
// Callback for custom fetch logic
onFetchSession?: (state: GlobalState) => Promise<Partial<GlobalState>>;
setApiURL: (url: string) => void;
// App actions
setApp: (updates: Partial<AppState>) => void;
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;
setBottomBar: (updates: Partial<BarState>) => void;
setCurrentPage: (page: PageInfo) => void;
// Layout actions
setLayout: (updates: Partial<LayoutState>) => void;
setLeftBar: (updates: Partial<BarState>) => void;
setMenu: (menu: MenuItem[]) => void;
// Navigation actions
setNavigation: (updates: Partial<NavigationState>) => void;
// Owner actions
setOwner: (updates: Partial<OwnerState>) => void;
// Program actions
setProgram: (updates: Partial<ProgramState>) => void;
setRightBar: (updates: Partial<BarState>) => void;
// Session actions
setSession: (updates: Partial<SessionState>) => void;
setTopBar: (updates: Partial<BarState>) => void;
// User actions
setUser: (updates: Partial<UserState>) => 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 GlobalStateStoreType extends GlobalState, GlobalStateActions {}
interface LayoutState {
bottomBar: BarState;
leftBar: BarState;
rightBar: BarState;
topBar: BarState;
}
type MenuItem = {
[key: string]: any;
children?: MenuItem[];
icon?: string;
id?: number | string;
label: string;
path?: string;
};
interface ProgramMetaData {
[key: string]: any;
interface NavigationState {
currentPage?: PageInfo;
menu: MenuItem[];
}
interface OwnerState {
id: number;
logo?: string;
name: string;
settings?: Record<string, any>;
theme?: ThemeSettings;
}
type PageInfo = {
breadcrumbs?: string[];
meta?: Record<string, any>;
path?: string;
title?: string;
};
interface ProgramState {
backendVersion?: string;
bigLogo?: string;
database?: DatabaseDetail;
databaseVersion?: string;
description?: string;
logo?: string;
meta?: Record<string, any>;
name: string;
slug: string;
tags?: string[];
version?: string;
}
interface ProgramWrapperProps {
@@ -66,27 +139,50 @@ interface ProgramWrapperProps {
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;
interface SessionState {
apiURL: string;
authToken: string;
connected: boolean;
error?: string;
isSecurity?: boolean;
loading: boolean;
meta?: Record<string, any>;
parameters?: Record<string, any>;
rid_hub?: number;
rid_user?: number;
secuser?: Record<string, any>;
};
}
interface ThemeSettings {
darkMode?: boolean;
name?: string;
}
interface UserState {
avatarUrl?: string;
email?: string;
fullNames?: string;
guid?: string;
isAdmin?: boolean;
noticeMsg?: string;
parameters?: Record<string, any>;
rid?: number;
theme?: ThemeSettings;
username: string;
}
export type {
AppState,
BarState,
ExtractState,
GlobalState,
GlobalStateStoreState,
ProgramDetail,
GlobalStateActions,
GlobalStateStoreType,
LayoutState,
MenuItem,
NavigationState,
OwnerState,
PageInfo,
ProgramState,
ProgramWrapperProps,
UserDetail,
SessionState,
ThemeSettings,
UserState,
};

View File

@@ -1,109 +1,93 @@
import { createStore, entries, set, type UseStore } from 'idb-keyval';
import { get, set } from 'idb-keyval';
import type { GlobalState } from './GlobalStateStore.types';
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 SKIP_PATHS = new Set([
'app.controls',
'session.connected',
'session.error',
'session.loading',
]);
const shouldSkipPath = (path: string): boolean => {
return SKIP_PATHS.has(path);
};
const programDataIndexDBStore: null | UseStore = initilizeStore();
const skipKeysCallback = (dataKey: string, dataValue: any) => {
if (typeof dataValue === 'function') {
const filterState = (state: unknown, prefix = ''): unknown => {
if (typeof state === 'function') {
return undefined;
}
if (
dataKey === 'loading' ||
dataKey === 'error' ||
dataKey === 'security' ||
dataKey === 'meta' ||
dataKey === 'help'
) {
return undefined;
if (state === null || typeof state !== 'object') {
return state;
}
return dataValue;
if (Array.isArray(state)) {
return state.map((item, idx) => filterState(item, `${prefix}[${idx}]`));
}
const filtered: Record<string, unknown> = {};
for (const [key, value] of Object.entries(state)) {
const path = prefix ? `${prefix}.${key}` : key;
if (shouldSkipPath(path) || typeof value === 'function') {
continue;
}
filtered[key] = filterState(value, path);
}
return filtered;
};
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;
async function loadStorage(): Promise<Partial<GlobalState>> {
try {
if (typeof indexedDB !== 'undefined') {
const data = await get(STORAGE_KEY);
if (data) {
return JSON.parse(data) as Partial<GlobalState>;
}
return {} as T;
} catch (e) {
console.error('Failed to load storage: ', storageKey ?? STORAGE_KEY, e);
}
} catch (e) {
console.error('Failed to load from IndexedDB, falling back to localStorage:', e);
}
return {} as T;
try {
if (typeof localStorage !== 'undefined') {
const data = localStorage.getItem(STORAGE_KEY);
if (data) {
return JSON.parse(data) as Partial<GlobalState>;
}
}
} catch (e) {
console.error('Failed to load from localStorage:', e);
}
return {};
}
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);
async function saveStorage(state: GlobalState): Promise<void> {
const filtered = filterState(state);
const serialized = JSON.stringify(filtered);
localStorage.setItem(storageKey ?? STORAGE_KEY, dataString ?? '{}');
return data;
} catch (e) {
console.error('Failed to save localStorage storage: ', storageKey ?? STORAGE_KEY, e);
try {
if (typeof indexedDB !== 'undefined') {
await set(STORAGE_KEY, serialized);
return;
}
} catch (e) {
console.error('Failed to save to IndexedDB, falling back to localStorage:', e);
}
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY, serialized);
}
} catch (e) {
console.error('Failed to save to localStorage:', e);
}
return {} as T;
}
export { loadStorage, saveStorage };

View File

@@ -0,0 +1,107 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef } from 'react';
import type { GlobalStateStoreType } from './GlobalStateStore.types';
import { GetGlobalState, GlobalStateStore } from './GlobalStateStore';
interface GlobalStateStoreContextValue {
fetchData: (url?: string) => Promise<void>;
getState: () => GlobalStateStoreType;
refetch: () => Promise<void>;
}
const GlobalStateStoreContext = createContext<GlobalStateStoreContextValue | null>(null);
interface GlobalStateStoreProviderProps {
apiURL?: string;
autoFetch?: boolean;
children: ReactNode;
fetchOnMount?: boolean;
throttleMs?: number;
}
export function GlobalStateStoreProvider({
apiURL,
autoFetch = true,
children,
fetchOnMount = true,
throttleMs = 0,
}: GlobalStateStoreProviderProps) {
const lastFetchTime = useRef<number>(0);
const fetchInProgress = useRef<boolean>(false);
const mounted = useRef<boolean>(false);
const throttledFetch = useCallback(
async (url?: string) => {
const now = Date.now();
const timeSinceLastFetch = now - lastFetchTime.current;
if (fetchInProgress.current) {
return;
}
if (throttleMs > 0 && timeSinceLastFetch < throttleMs) {
return;
}
try {
fetchInProgress.current = true;
lastFetchTime.current = now;
await GlobalStateStore.getState().fetchData(url);
} finally {
fetchInProgress.current = false;
}
},
[throttleMs]
);
const refetch = useCallback(async () => {
await throttledFetch();
}, [throttledFetch]);
useEffect(() => {
if (apiURL) {
GlobalStateStore.getState().setApiURL(apiURL);
}
}, [apiURL]);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
if (autoFetch && fetchOnMount) {
throttledFetch(apiURL).catch((e) => {
console.error('Failed to fetch on mount:', e);
});
}
}
}, [apiURL, autoFetch, fetchOnMount, throttledFetch]);
const context = useMemo(() => {
return {
fetchData: throttledFetch,
getState: GetGlobalState,
refetch,
};
}, [throttledFetch, refetch]);
return (
<GlobalStateStoreContext.Provider value={context}>
{children}
</GlobalStateStoreContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useGlobalStateStoreContext(): GlobalStateStoreContextValue {
const context = useContext(GlobalStateStoreContext);
if (!context) {
throw new Error('useGlobalStateStoreContext must be used within GlobalStateStoreProvider');
}
return context;
}

View File

@@ -0,0 +1,105 @@
# GlobalStateStore
Zustand-based global state management with automatic persistence.
## Quick Start
```tsx
import {
GlobalStateStoreProvider,
useGlobalStateStore,
useGlobalStateStoreContext
} from './GlobalStateStore';
// Wrap app with provider
function App() {
return (
<GlobalStateStoreProvider
apiURL="https://api.example.com"
fetchOnMount={true}
throttleMs={5000}
>
<MyComponent />
</GlobalStateStoreProvider>
);
}
// Use in components
function MyComponent() {
const { program, session, user } = useGlobalStateStore();
const { refetch } = useGlobalStateStoreContext();
return (
<div>
{program.name}
<button onClick={refetch}>Refresh</button>
</div>
);
}
// Outside React
const apiURL = GlobalStateStore.getState().session.apiURL;
GlobalStateStore.getState().setAuthToken('token');
```
## Provider Props
- **apiURL** - Initial API URL (optional)
- **autoFetch** - Enable automatic fetching (default: `true`)
- **fetchOnMount** - Fetch data when provider mounts (default: `true`)
- **throttleMs** - Minimum time between fetch calls in milliseconds (default: `0`)
## Context Hook
`useGlobalStateStoreContext()` returns:
- **fetchData(url?)** - Throttled fetch function
- **refetch()** - Refetch with current URL
## State Slices
- **program** - name, logo, description, tags, version
- **session** - apiURL, authToken, connected, loading, error, parameters, meta
- **owner** - id, name, logo, settings, theme (darkMode, name)
- **user** - username, email, fullNames, isAdmin, avatarUrl, parameters, theme (darkMode, name)
- **layout** - leftBar, rightBar, topBar, bottomBar (each: open, collapsed, pinned, size, menuItems)
- **navigation** - menu, currentPage
- **app** - environment, updatedAt, controls, globals
## Actions
**Program:** `setProgram(updates)`
**Session:** `setSession(updates)`, `setAuthToken(token)`, `setApiURL(url)`
**Owner:** `setOwner(updates)`
**User:** `setUser(updates)`
**Layout:** `setLayout(updates)`, `setLeftBar(updates)`, `setRightBar(updates)`, `setTopBar(updates)`, `setBottomBar(updates)`
**Navigation:** `setNavigation(updates)`, `setMenu(items)`, `setCurrentPage(page)`
**App:** `setApp(updates)`
**Complex:** `fetchData(url?)`, `login(token?)`, `logout()`
## Custom Fetch
```ts
GlobalStateStore.getState().onFetchSession = async (state) => {
const response = await fetch(`${state.session.apiURL}/session`, {
headers: { Authorization: `Bearer ${state.session.authToken}` }
});
const data = await response.json();
return {
program: { name: data.appName, ... },
user: { id: data.userId, name: data.userName, ... },
navigation: { menu: data.menu },
};
};
```
## Persistence
Auto-saves to IndexedDB (localStorage fallback).
Auto-loads on initialization.
Skips transient data: loading states, errors, controls.
## TypeScript
Fully typed with exported types:
`GlobalState`, `ProgramState`, `SessionState`, `OwnerState`, `UserState`, `ThemeSettings`, `LayoutState`, `BarState`, `NavigationState`, `AppState`

View File

@@ -1,9 +1,16 @@
export { ProgramDataWrapper } from './src/ProgramDataWrapper'
export {
getApiURL,
getAuthToken,
GetGlobalState,
GlobalStateStore,
setApiURL,
programDataStore,
useProgramDataStore,
} from './src/store/ProgramDataStore.store'
setAuthToken,
useGlobalStateStore
} from './GlobalStateStore';
export type * from './src/types'
export type * from './GlobalStateStore.types';
export {
GlobalStateStoreProvider,
useGlobalStateStoreContext,
} from './GlobalStateStoreWrapper';

View File

@@ -4,6 +4,10 @@ import { useRef, useState } from 'react';
import type { GridlerColumns } from '../components/Column';
import { FormerDialog } from '../../Former';
import { NativeSelectCtrl, TextInputCtrl } from '../../FormerControllers';
import { InlineWrapper } from '../../FormerControllers/Inputs/InlineWrapper';
import NumberInputCtrl from '../../FormerControllers/Inputs/NumberInputCtrl';
import { GlidlerAPIAdaptorForGoLangv2 } from '../components/adaptors';
import { type GridlerRef } from '../components/GridlerStore';
import { Gridler } from '../Gridler';
@@ -18,12 +22,18 @@ export const GridlerGoAPIExampleEventlog = () => {
const [selectRow, setSelectRow] = useState<string | undefined>('');
const [values, setValues] = useState<Array<Record<string, any>>>([]);
const [search, setSearch] = useState<string>('');
const [formProps, setFormProps] = useState<{ onChange?: any; onClose?: any; opened: boolean; request: any; title?: string; values: any; } | null>({
onChange: (_request: string, data: any) => { ref.current?.refresh({ value: data }); },
onClose: () => { setFormProps((cv) => ({ ...cv, opened: false, request: null, values: null })) },
opened: false,
request: null,
values: null,
});
const [sections, setSections] = useState<Record<string, unknown> | undefined>(undefined);
const columns: GridlerColumns = [
{
Cell: (row) => {
const process = `${
row?.cql2?.length > 0
const process = `${row?.cql2?.length > 0
? '🔖'
: row?.cql1?.length > 0
? '📕'
@@ -32,7 +42,7 @@ export const GridlerGoAPIExampleEventlog = () => {
: row?.status === 2
? '🔒'
: '⚙️'
} ${String(row?.id_process ?? '0')}`;
} ${String(row?.id_process ?? '0')}`;
return {
data: process,
@@ -129,10 +139,29 @@ export const GridlerGoAPIExampleEventlog = () => {
changeOnActiveClick={true}
descriptionField={'process'}
onRequestForm={(request, data) => {
console.log('Form requested', request, data);
setFormProps((cv)=> {
return {...cv, opened: true, request: request as any, values: data as any}
})
}}
/>
</Gridler>
<FormerDialog
former={{
request: formProps?.request ?? "insert",
values: formProps?.values,
}}
onClose={formProps?.onClose}
opened={formProps?.opened ?? false}
title={formProps?.title ?? 'Process Form'}
>
<Stack>
<TextInputCtrl label="Process Name" name="process" />
<NumberInputCtrl label="Sequence" name="sequence" />
<InlineWrapper label="Type" promptWidth={200}>
<NativeSelectCtrl data={["trigger","function","view"]} name="type"/>
</InlineWrapper>
</Stack>
</FormerDialog>
<Divider />
<Group>
<TextInput
@@ -193,6 +222,6 @@ export const GridlerGoAPIExampleEventlog = () => {
Goto 2050
</Button>
</Group>
</Stack>
</Stack >
);
};

View File

@@ -2,6 +2,7 @@ export * from './Boxer';
export * from './ErrorBoundary';
export * from './Former';
export * from './FormerControllers';
export * from './GlobalStateStore';
export * from './Gridler';
export {