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,18 +1,19 @@
import type { Preview } from '@storybook/react-vite'
import type { Preview } from '@storybook/react-vite';
import { PreviewDecorator } from './previewDecorator';
const preview: Preview = {
decorators: [PreviewDecorator],
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
color: /(background|color)$/i,
date: /Date$/i,
},
},
layout: 'fullscreen',
},
decorators: [
PreviewDecorator,
],
};
export default preview;

View File

@@ -1,16 +1,40 @@
import { MantineProvider } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import '@mantine/core/styles.css';
export function PreviewDecorator(Story: any, { parameters }: any) {
console.log('Rendering decorator', parameters);
import type { Decorator } from '@storybook/react-vite';
import { MantineProvider } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
import { GlobalStateStoreProvider } from '../src/GlobalStateStore';
export const PreviewDecorator: Decorator = (Story, context) => {
const { parameters } = context;
// Allow stories to opt-out of GlobalStateStore provider
const useGlobalStore = parameters.globalStore !== false;
// Use 100% for fullscreen layout, 100vh otherwise
const isFullscreen = parameters.layout === 'fullscreen';
const containerStyle = {
height: isFullscreen ? '100%' : '100vh',
width: isFullscreen ? '100%' : '100vw',
};
return (
<MantineProvider>
<ModalsProvider>
<div style={{ height: 'calc(100vh - 64px)', width: 'calc(100vw - 64px)' }}>
<Story key={'mainStory'} />
</div>
{useGlobalStore ? (
<GlobalStateStoreProvider fetchOnMount={false}>
<div style={containerStyle}>
<Story />
</div>
</GlobalStateStoreProvider>
) : (
<div style={containerStyle}>
<Story />
</div>
)}
</ModalsProvider>
</MantineProvider>
);
}
};

381
README.md
View File

@@ -8,14 +8,38 @@ Oranguru is a comprehensive component library that extends Mantine's component e
Currently featuring advanced menu components, Oranguru is designed to grow into a full suite of enhanced Mantine components that offer more flexibility and power than their standard counterparts.
## Features
## Components
### Current Components
- **Enhanced Context Menus**: Better menu positioning and visibility control
- **Custom Rendering**: Support for custom menu item renderers and complete menu rendering
- **Async Actions**: Built-in support for async menu item actions with loading states
### MantineBetterMenu
Enhanced context menus with better positioning and visibility control
### Gridler
Powerful data grid component with sorting, filtering, and pagination
### Former
Form component with React Hook Form integration and validation
### FormerControllers
Pre-built form input controls for use with Former
### Boxer
Advanced combobox/select with virtualization and server-side data support
### ErrorBoundary
React error boundary components for graceful error handling
### GlobalStateStore
Zustand-based global state management with automatic persistence
## Core Features
### Core Features
- **State Management**: Zustand-based store for component state management
- **TypeScript Support**: Full TypeScript definitions included
- **Portal-based Rendering**: Proper z-index handling through React portals
@@ -37,133 +61,266 @@ npm install react@">= 19.0.0" zustand@">= 5.0.0" @mantine/core@"^8.3.1" @mantine
## Usage
### Basic Setup
### MantineBetterMenu
```tsx
import { MantineBetterMenusProvider } from '@warkypublic/oranguru';
import { MantineProvider } from '@mantine/core';
import { MantineBetterMenusProvider, useMantineBetterMenus } from '@warkypublic/oranguru';
function App() {
return (
<MantineProvider>
<MantineBetterMenusProvider>
{/* Your app content */}
</MantineBetterMenusProvider>
</MantineProvider>
);
}
```
// Wrap app with provider
<MantineBetterMenusProvider>
<App />
</MantineBetterMenusProvider>
### Using the Menu Hook
```tsx
import { useMantineBetterMenus } from '@warkypublic/oranguru';
function MyComponent() {
const { show, hide } = useMantineBetterMenus();
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
show('my-menu', {
x: e.clientX,
y: e.clientY,
items: [
{
label: 'Edit',
onClick: () => console.log('Edit clicked')
},
{
label: 'Delete',
onClick: () => console.log('Delete clicked')
},
{
isDivider: true
},
{
label: 'Async Action',
onClickAsync: async () => {
await new Promise(resolve => setTimeout(resolve, 2000));
console.log('Async action completed');
}
}
]
});
};
return (
<div onContextMenu={handleContextMenu}>
Right-click me for a context menu
</div>
);
}
```
### Custom Menu Items
```tsx
const customMenuItem = {
renderer: ({ loading }: any) => (
<div style={{ padding: '8px 12px' }}>
{loading ? 'Loading...' : 'Custom Item'}
</div>
)
};
show('custom-menu', {
// Use in components
const { show, hide } = useMantineBetterMenus();
show('menu-id', {
x: e.clientX,
y: e.clientY,
items: [customMenuItem]
items: [
{ label: 'Edit', onClick: () => {} },
{ isDivider: true },
{ label: 'Async', onClickAsync: async () => {} }
]
});
```
### Gridler
```tsx
import { Gridler } from '@warkypublic/oranguru';
// Local data
<Gridler columns={columns} uniqueid="my-grid">
<Gridler.LocalDataAdaptor data={data} />
</Gridler>
// API data
<Gridler columns={columns} uniqueid="my-grid">
<Gridler.APIAdaptorForGoLangv2 apiURL="/api/data" />
</Gridler>
// With inline editing form
<Gridler columns={columns} uniqueid="editable-grid" ref={gridRef}>
<Gridler.APIAdaptorForGoLangv2 url="/api/data" />
<Gridler.FormAdaptor
changeOnActiveClick={true}
descriptionField="name"
onRequestForm={(request, data) => {
setFormProps({ opened: true, request, values: data });
}}
/>
</Gridler>
<FormerDialog
former={{ request: formProps.request, values: formProps.values }}
opened={formProps.opened}
onClose={() => setFormProps({ opened: false })}
>
<TextInputCtrl label="Name" name="name" />
<NativeSelectCtrl label="Type" name="type" data={["A", "B"]} />
</FormerDialog>
// Columns definition
const columns = [
{ id: 'name', title: 'Name', width: 200 },
{ id: 'email', title: 'Email', width: 250 }
];
```
### Former
```tsx
import { Former, FormerDialog } from '@warkypublic/oranguru';
const formRef = useRef<FormerRef>(null);
<Former
ref={formRef}
onSave={async (data) => { /* save logic */ }}
primeData={{ name: '', email: '' }}
wrapper={FormerDialog}
>
{/* Form content */}
</Former>
// Methods: formRef.current.show(), .save(), .reset()
```
### FormerControllers
```tsx
import {
TextInputCtrl,
PasswordInputCtrl,
NativeSelectCtrl,
TextAreaCtrl,
SwitchCtrl,
ButtonCtrl
} from '@warkypublic/oranguru';
<Former>
<TextInputCtrl name="username" label="Username" />
<PasswordInputCtrl name="password" label="Password" />
<NativeSelectCtrl name="role" data={['Admin', 'User']} />
<SwitchCtrl name="active" label="Active" />
<ButtonCtrl type="submit">Save</ButtonCtrl>
</Former>
```
### Boxer
```tsx
import { Boxer } from '@warkypublic/oranguru';
// Local data
<Boxer
data={[{ label: 'Apple', value: 'apple' }]}
dataSource="local"
value={value}
onChange={setValue}
searchable
clearable
/>
// Server-side data
<Boxer
dataSource="server"
onAPICall={async ({ page, pageSize, search }) => ({
data: [...],
total: 100
})}
value={value}
onChange={setValue}
/>
// Multi-select
<Boxer multiSelect value={values} onChange={setValues} />
```
### ErrorBoundary
```tsx
import { ReactErrorBoundary, ReactBasicErrorBoundary } from '@warkypublic/oranguru';
// Full-featured error boundary
<ReactErrorBoundary
namespace="my-component"
reportAPI="/api/errors"
onResetClick={() => {}}
>
<App />
</ReactErrorBoundary>
// Basic error boundary
<ReactBasicErrorBoundary>
<App />
</ReactBasicErrorBoundary>
```
### GlobalStateStore
```tsx
import {
GlobalStateStoreProvider,
useGlobalStateStore,
GlobalStateStore
} from '@warkypublic/oranguru';
// Wrap app
<GlobalStateStoreProvider
apiURL="https://api.example.com"
fetchOnMount={true}
throttleMs={5000}
>
<App />
</GlobalStateStoreProvider>
// Use in components
const { program, session, user, layout } = useGlobalStateStore();
const { refetch } = useGlobalStateStoreContext();
// Outside React
GlobalStateStore.getState().setAuthToken('token');
const apiURL = GlobalStateStore.getState().session.apiURL;
```
## API Reference
### MantineBetterMenusProvider
**MantineBetterMenu**
The main provider component that wraps your application.
- Provider: `MantineBetterMenusProvider`
- Hook: `useMantineBetterMenus()` returns `{ show, hide, menus, setInstanceState }`
- Key Props: `items[]`, `x`, `y`, `visible`, `menuProps`, `renderer`
**Props:**
- `providerID?`: Optional unique identifier for the provider instance
**Gridler**
### useMantineBetterMenus
- Main Component: `Gridler`
- Adaptors: `LocalDataAdaptor`, `APIAdaptorForGoLangv2`, `FormAdaptor`
- Store Hook: `useGridlerStore()`
- Key Props: `uniqueid`, `columns[]`, `data`
Hook to access menu functionality.
**Former**
**Returns:**
- `show(id: string, options?: Partial<MantineBetterMenuInstance>)`: Show a menu
- `hide(id: string)`: Hide a menu
- `menus`: Array of current menu instances
- `setInstanceState`: Update specific menu instance properties
- Main Component: `Former`
- Wrappers: `FormerDialog`, `FormerModel`, `FormerPopover`
- Ref Methods: `show()`, `close()`, `save()`, `reset()`, `validate()`
- Key Props: `primeData`, `onSave`, `wrapper`
### MantineBetterMenuInstance
**FormerControllers**
Interface for menu instances:
- Controls: `TextInputCtrl`, `PasswordInputCtrl`, `TextAreaCtrl`, `NativeSelectCtrl`, `SwitchCtrl`, `ButtonCtrl`, `IconButtonCtrl`
- Common Props: `name` (required), `label`, `disabled`
```typescript
interface MantineBetterMenuInstance {
id: string;
items?: Array<MantineBetterMenuInstanceItem>;
menuProps?: MenuProps;
renderer?: ReactNode;
visible: boolean;
x: number;
y: number;
**Boxer**
- Provider: `BoxerProvider`
- Store Hook: `useBoxerStore()`
- Data Sources: `local`, `server`
- Key Props: `data`, `dataSource`, `onAPICall`, `multiSelect`, `searchable`, `clearable`
**ErrorBoundary**
- Components: `ReactErrorBoundary`, `ReactBasicErrorBoundary`
- Key Props: `namespace`, `reportAPI`, `onResetClick`, `onRetryClick`
**GlobalStateStore**
- Provider: `GlobalStateStoreProvider`
- Hook: `useGlobalStateStore()` returns `{ program, session, owner, user, layout, navigation, app }`
- Store Methods: `setAuthToken()`, `setApiURL()`, `fetchData()`, `login()`, `logout()`
- Key Props: `apiURL`, `autoFetch`, `fetchOnMount`, `throttleMs`
## MCP Server
Oranguru includes a Model Context Protocol (MCP) server for AI-assisted development.
**Configuration:**
Add to `~/.claude/mcp_settings.json`:
```json
{
"mcpServers": {
"oranguru-docs": {
"command": "npx",
"args": ["-y", "@warkypublic/oranguru", "mcp"]
}
}
}
```
### MantineBetterMenuInstanceItem
**Tools:**
Interface for menu items:
- `list_components` - List all components
- `get_component_docs` - Get component documentation
- `get_component_example` - Get code examples
```typescript
interface MantineBetterMenuInstanceItem extends Partial<MenuItemProps> {
isDivider?: boolean;
label?: string;
onClick?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onClickAsync?: () => Promise<void>;
renderer?: ((props: MantineBetterMenuInstanceItem & Record<string, unknown>) => ReactNode) | ReactNode;
}
```
**Resources:**
- `oranguru://docs/readme` - Full documentation
- `oranguru://docs/components` - Component list
See `mcp/README.md` for details.
## Development
@@ -174,6 +331,7 @@ interface MantineBetterMenuInstanceItem extends Partial<MenuItemProps> {
- `pnpm lint`: Run ESLint
- `pnpm typecheck`: Run TypeScript type checking
- `pnpm clean`: Clean node_modules and dist folders
- `pnpm mcp`: Run MCP server
### Building
@@ -189,9 +347,10 @@ See [LICENSE](LICENSE) file for details.
## About the Name
Oranguru is named after the Orangutan Pokémon (オランガ Oranga), a Normal/Psychic-type Pokémon introduced in Generation VII. Known as the "Sage Pokémon," Oranguru is characterized by its wisdom, intelligence, and ability to use tools strategically.
Oranguru is named after the Orangutan Pokémon (オランガ Oranga), a Normal/Psychic-type Pokémon introduced in Generation VII. Known as the "Sage Pokémon," Oranguru is characterized by its wisdom, intelligence, and ability to use tools strategically.
In the Pokémon world, Oranguru is known for:
- Its exceptional intelligence and strategic thinking
- Living deep in forests and rarely showing itself to humans
- Using its psychic powers to control other Pokémon with its fan
@@ -201,4 +360,4 @@ Just as Oranguru the Pokémon enhances and controls its environment with wisdom
## Author
**Warky Devs**
Warky Devs

86
mcp-server.json Normal file
View File

@@ -0,0 +1,86 @@
{
"name": "@warkypublic/oranguru-mcp",
"version": "0.0.31",
"description": "MCP server for Oranguru component library documentation and code generation",
"mcpServers": {
"oranguru-docs": {
"command": "node",
"args": ["mcp/server.js"],
"env": {}
}
},
"tools": [
{
"name": "get_component_docs",
"description": "Get documentation for a specific Oranguru component",
"inputSchema": {
"type": "object",
"properties": {
"component": {
"type": "string",
"enum": [
"MantineBetterMenu",
"Gridler",
"Former",
"FormerControllers",
"Boxer",
"ErrorBoundary",
"GlobalStateStore"
],
"description": "The component name"
}
},
"required": ["component"]
}
},
{
"name": "get_component_example",
"description": "Generate code example for a specific Oranguru component",
"inputSchema": {
"type": "object",
"properties": {
"component": {
"type": "string",
"enum": [
"MantineBetterMenu",
"Gridler",
"Former",
"FormerControllers",
"Boxer",
"ErrorBoundary",
"GlobalStateStore"
],
"description": "The component name"
},
"variant": {
"type": "string",
"description": "Example variant (e.g., 'basic', 'advanced', 'with-api')"
}
},
"required": ["component"]
}
},
{
"name": "list_components",
"description": "List all available Oranguru components",
"inputSchema": {
"type": "object",
"properties": {}
}
}
],
"resources": [
{
"uri": "oranguru://docs/readme",
"name": "Oranguru Documentation",
"description": "Main documentation for the Oranguru library",
"mimeType": "text/markdown"
},
{
"uri": "oranguru://docs/components",
"name": "Component List",
"description": "List of all available components",
"mimeType": "application/json"
}
]
}

102
mcp/README.md Normal file
View File

@@ -0,0 +1,102 @@
# Oranguru MCP Server
Model Context Protocol server for Oranguru component library documentation and code generation.
## Installation
```bash
npm install @warkypublic/oranguru
```
## Configuration
Add to your Claude Code MCP settings (`~/.claude/mcp_settings.json`):
```json
{
"mcpServers": {
"oranguru-docs": {
"command": "node",
"args": ["./node_modules/@warkypublic/oranguru/mcp/server.js"]
}
}
}
```
Or use npx:
```json
{
"mcpServers": {
"oranguru-docs": {
"command": "npx",
"args": ["-y", "@warkypublic/oranguru", "mcp"]
}
}
}
```
## Available Tools
### `list_components`
List all available Oranguru components
**Returns:** JSON array of components with name, description, and exports
### `get_component_docs`
Get detailed documentation for a specific component
**Parameters:**
- `component` (required): Component name (MantineBetterMenu, Gridler, Former, etc.)
**Returns:** JSON object with component details, exports, and usage information
### `get_component_example`
Get code examples for a specific component
**Parameters:**
- `component` (required): Component name
- `variant` (optional): Example variant ('basic', 'local', 'server', etc.)
**Returns:** Code example string
## Available Resources
### `oranguru://docs/readme`
Full README documentation
**MIME Type:** text/markdown
### `oranguru://docs/components`
Component list in JSON format
**MIME Type:** application/json
## Components
- **MantineBetterMenu** - Enhanced context menus
- **Gridler** - Data grid component
- **Former** - Form component with React Hook Form
- **FormerControllers** - Form input controls
- **Boxer** - Advanced combobox/select
- **ErrorBoundary** - Error boundary components
- **GlobalStateStore** - Global state management
## Usage in Claude Code
Once configured, you can ask Claude Code:
- "Show me examples of the Gridler component"
- "Get documentation for the Former component"
- "List all Oranguru components"
- "Generate a code example for Boxer with server-side data"
## Running Locally
```bash
npm run mcp
```
## License
See main package LICENSE file

953
mcp/server.js Executable file
View File

@@ -0,0 +1,953 @@
#!/usr/bin/env node
/**
* Oranguru MCP Server
* Provides documentation and code generation for Oranguru components
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Component documentation data
const COMPONENTS = {
Boxer: {
component: 'Boxer',
description: 'Advanced combobox/select with virtualization and server-side data support',
examples: {
basic: `import { Boxer } from '@warkypublic/oranguru';
import { useState } from 'react';
const sampleData = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' }
];
function MyComponent() {
const [value, setValue] = useState(null);
return (
<Boxer
data={sampleData}
dataSource="local"
label="Favorite Fruit"
value={value}
onChange={setValue}
searchable
clearable
placeholder="Select a fruit"
/>
);
}`,
multiSelect: `import { Boxer } from '@warkypublic/oranguru';
import { useState } from 'react';
function MultiSelectExample() {
const [values, setValues] = useState([]);
return (
<Boxer
data={sampleData}
dataSource="local"
label="Favorite Fruits"
multiSelect
value={values}
onChange={setValues}
searchable
clearable
/>
);
}`,
server: `import { Boxer } from '@warkypublic/oranguru';
function ServerSideExample() {
const [value, setValue] = useState(null);
const handleAPICall = async ({ page, pageSize, search }) => {
const response = await fetch(\`/api/items?page=\${page}&size=\${pageSize}&search=\${search}\`);
const result = await response.json();
return {
data: result.items,
total: result.total
};
};
return (
<Boxer
dataSource="server"
label="Server-side Data"
onAPICall={handleAPICall}
value={value}
onChange={setValue}
pageSize={10}
searchable
/>
);
}`
},
exports: ['Boxer', 'BoxerProvider', 'useBoxerStore'],
hook: 'useBoxerStore()',
name: 'Boxer',
provider: 'BoxerProvider'
},
ErrorBoundary: {
components: ['ReactErrorBoundary', 'ReactBasicErrorBoundary'],
description: 'React error boundary components for graceful error handling',
examples: {
basic: `import { ReactBasicErrorBoundary } from '@warkypublic/oranguru';
function App() {
return (
<ReactBasicErrorBoundary>
<MyComponent />
</ReactBasicErrorBoundary>
);
}`,
full: `import { ReactErrorBoundary } from '@warkypublic/oranguru';
function App() {
const handleReportError = () => {
console.log('Report error to support');
};
const handleReset = () => {
console.log('Reset application state');
window.location.reload();
};
const handleRetry = () => {
console.log('Retry failed operation');
};
return (
<ReactErrorBoundary
namespace="main-app"
reportAPI="/api/errors/report"
onReportClick={handleReportError}
onResetClick={handleReset}
onRetryClick={handleRetry}
>
<MyApp />
</ReactErrorBoundary>
);
}`,
nested: `import { ReactErrorBoundary } from '@warkypublic/oranguru';
// Multiple error boundaries for granular error handling
function App() {
return (
<ReactErrorBoundary namespace="app">
<Header />
<ReactErrorBoundary namespace="sidebar">
<Sidebar />
</ReactErrorBoundary>
<ReactErrorBoundary namespace="main-content">
<MainContent />
</ReactErrorBoundary>
</ReactErrorBoundary>
);
}`,
globalConfig: `import { SetErrorBoundaryOptions } from '@warkypublic/oranguru';
// Configure error boundary globally
SetErrorBoundaryOptions({
disabled: false, // Set to true to pass through errors (dev mode)
onError: (error, errorInfo) => {
console.error('Global error handler:', error);
// Send to analytics service
analytics.trackError(error, errorInfo);
}
});`
},
exports: ['ReactErrorBoundary', 'ReactBasicErrorBoundary', 'SetErrorBoundaryOptions', 'GetErrorBoundaryOptions'],
name: 'ErrorBoundary'
},
Former: {
component: 'Former',
description: 'Form component with React Hook Form integration and validation',
examples: {
basic: `import { Former } from '@warkypublic/oranguru';
import { useRef } from 'react';
import { Controller } from 'react-hook-form';
function BasicForm() {
const formRef = useRef(null);
return (
<Former
ref={formRef}
primeData={{ name: '', email: '' }}
onSave={async (data) => {
console.log('Saving:', data);
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify(data)
});
}}
>
<Controller
name="name"
render={({ field }) => (
<input {...field} placeholder="Name" />
)}
rules={{ required: 'Name is required' }}
/>
<Controller
name="email"
render={({ field }) => (
<input {...field} type="email" placeholder="Email" />
)}
rules={{ required: 'Email is required' }}
/>
<button type="submit">Save</button>
</Former>
);
}`,
withWrapper: `import { Former, FormerModel } from '@warkypublic/oranguru';
import { useState } from 'react';
function ModalForm() {
const [opened, setOpened] = useState(false);
return (
<>
<button onClick={() => setOpened(true)}>Open Form</button>
<FormerModel
former={{ request: 'insert' }}
opened={opened}
onClose={() => setOpened(false)}
>
<Controller
name="title"
render={({ field }) => <input {...field} />}
/>
</FormerModel>
</>
);
}`,
withAPI: `import { Former, FormerRestHeadSpecAPI } from '@warkypublic/oranguru';
function APIForm() {
return (
<Former
request="update"
uniqueKeyField="id"
primeData={{ id: 123 }}
onAPICall={FormerRestHeadSpecAPI({
url: 'https://api.example.com/items',
authToken: 'your-token'
})}
>
{/* Form fields */}
</Former>
);
}`,
customLayout: `import { Former } from '@warkypublic/oranguru';
function CustomLayoutForm() {
return (
<Former
layout={{
title: 'Edit User Profile',
buttonArea: 'bottom',
buttonAreaGroupProps: { justify: 'space-between' }
}}
primeData={{ username: '', bio: '' }}
>
{/* Form fields */}
</Former>
);
}`,
refMethods: `import { Former } from '@warkypublic/oranguru';
import { useRef } from 'react';
function FormWithRef() {
const formRef = useRef(null);
const handleValidate = async () => {
const isValid = await formRef.current?.validate();
console.log('Form valid:', isValid);
};
const handleSave = async () => {
const result = await formRef.current?.save();
console.log('Save result:', result);
};
const handleReset = () => {
formRef.current?.reset();
};
return (
<div>
<Former ref={formRef} primeData={{}}>
{/* Form fields */}
</Former>
<button onClick={handleValidate}>Validate</button>
<button onClick={handleSave}>Save</button>
<button onClick={handleReset}>Reset</button>
</div>
);
}`
},
exports: ['Former', 'FormerDialog', 'FormerModel', 'FormerPopover', 'FormerRestHeadSpecAPI'],
name: 'Former',
wrappers: ['FormerDialog', 'FormerModel', 'FormerPopover']
},
FormerControllers: {
controls: ['TextInputCtrl', 'PasswordInputCtrl', 'NativeSelectCtrl', 'TextAreaCtrl', 'SwitchCtrl', 'ButtonCtrl', 'IconButtonCtrl'],
description: 'Pre-built form input controls for use with Former',
examples: {
basic: `import { TextInputCtrl, PasswordInputCtrl, NativeSelectCtrl, ButtonCtrl } from '@warkypublic/oranguru';
<Former>
<TextInputCtrl name="username" label="Username" />
<PasswordInputCtrl name="password" label="Password" />
<NativeSelectCtrl name="role" data={['Admin', 'User']} />
<ButtonCtrl type="submit">Save</ButtonCtrl>
</Former>`
},
exports: ['TextInputCtrl', 'PasswordInputCtrl', 'NativeSelectCtrl', 'TextAreaCtrl', 'SwitchCtrl', 'ButtonCtrl', 'IconButtonCtrl'],
name: 'FormerControllers'
},
GlobalStateStore: {
description: 'Zustand-based global state management with automatic persistence',
examples: {
basic: `import { useGlobalStateStore } from '@warkypublic/oranguru';
function MyComponent() {
const state = useGlobalStateStore();
return (
<div>
<h1>{state.program.name}</h1>
<p>User: {state.user.username}</p>
<p>Email: {state.user.email}</p>
<p>Connected: {state.session.connected ? 'Yes' : 'No'}</p>
</div>
);
}`,
provider: `import { GlobalStateStoreProvider } from '@warkypublic/oranguru';
function App() {
return (
<GlobalStateStoreProvider
apiURL="https://api.example.com"
fetchOnMount={true}
throttleMs={5000}
>
<MyApp />
</GlobalStateStoreProvider>
);
}`,
stateUpdates: `import { useGlobalStateStore } from '@warkypublic/oranguru';
function StateControls() {
const state = useGlobalStateStore();
const handleUpdateProgram = () => {
state.setProgram({
name: 'My App',
slug: 'my-app',
description: 'A great application'
});
};
const handleUpdateUser = () => {
state.setUser({
username: 'john_doe',
email: 'john@example.com',
fullNames: 'John Doe'
});
};
const handleToggleTheme = () => {
state.setUser({
theme: {
...state.user.theme,
darkMode: !state.user.theme?.darkMode
}
});
};
return (
<div>
<button onClick={handleUpdateProgram}>Update Program</button>
<button onClick={handleUpdateUser}>Update User</button>
<button onClick={handleToggleTheme}>Toggle Dark Mode</button>
</div>
);
}`,
layout: `import { useGlobalStateStore } from '@warkypublic/oranguru';
function LayoutControls() {
const state = useGlobalStateStore();
return (
<div>
<button onClick={() => state.setLeftBar({ open: !state.layout.leftBar.open })}>
Toggle Left Bar
</button>
<button onClick={() => state.setRightBar({ open: !state.layout.rightBar.open })}>
Toggle Right Bar
</button>
<button onClick={() => state.setLeftBar({ pinned: true, size: 250 })}>
Pin & Resize Left Bar
</button>
</div>
);
}`,
outsideReact: `import { GlobalStateStore } from '@warkypublic/oranguru';
// Access state outside React components
const currentState = GlobalStateStore.getState();
console.log('API URL:', currentState.session.apiURL);
// Update state outside React
GlobalStateStore.getState().setAuthToken('new-token');
GlobalStateStore.getState().setApiURL('https://new-api.com');
// Subscribe to changes
const unsubscribe = GlobalStateStore.subscribe(
(state) => state.session.connected,
(connected) => console.log('Connected:', connected)
);`
},
exports: ['GlobalStateStore', 'GlobalStateStoreProvider', 'useGlobalStateStore', 'useGlobalStateStoreContext'],
hook: 'useGlobalStateStore()',
name: 'GlobalStateStore',
provider: 'GlobalStateStoreProvider',
store: 'GlobalStateStore'
},
Gridler: {
adaptors: ['LocalDataAdaptor', 'APIAdaptorForGoLangv2', 'FormAdaptor'],
component: 'Gridler',
description: 'Powerful data grid component with sorting, filtering, and pagination',
examples: {
basic: `import { Gridler } from '@warkypublic/oranguru';
const columns = [
{ id: 'name', title: 'Name', width: 200 },
{ id: 'email', title: 'Email', width: 250 },
{ id: 'role', title: 'Role', width: 150 }
];
const data = [
{ name: 'John Doe', email: 'john@example.com', role: 'Admin' },
{ name: 'Jane Smith', email: 'jane@example.com', role: 'User' }
];
function GridExample() {
return (
<Gridler columns={columns} uniqueid="my-grid">
<Gridler.LocalDataAdaptor data={data} />
</Gridler>
);
}`,
customCell: `// Custom cell rendering with icons
const columns = [
{
id: 'status',
title: 'Status',
width: 100,
Cell: (row) => {
const icon = row?.active ? '✅' : '❌';
return {
data: \`\${icon} \${row?.status}\`,
displayData: \`\${icon} \${row?.status}\`
};
}
},
{ id: 'name', title: 'Name', width: 200 }
];`,
api: `import { Gridler, GlidlerAPIAdaptorForGoLangv2 } from '@warkypublic/oranguru';
import { useRef } from 'react';
function APIGridExample() {
const gridRef = useRef(null);
const columns = [
{ id: 'id', title: 'ID', width: 100 },
{ id: 'name', title: 'Name', width: 200 },
{ id: 'status', title: 'Status', width: 150 }
];
return (
<Gridler
columns={columns}
uniqueid="api-grid"
ref={gridRef}
selectMode="row"
searchStr={searchTerm}
onChange={(values) => console.log('Selected:', values)}
>
<GlidlerAPIAdaptorForGoLangv2
url="https://api.example.com/data"
authtoken="your-api-key"
/>
</Gridler>
);
}`,
withForm: `// Gridler with Former integration for inline editing
import { Gridler, GlidlerAPIAdaptorForGoLangv2 } from '@warkypublic/oranguru';
import { FormerDialog } from '@warkypublic/oranguru';
import { TextInputCtrl, NativeSelectCtrl } from '@warkypublic/oranguru';
import { useState, useRef } from 'react';
function EditableGrid() {
const gridRef = useRef(null);
const [formProps, setFormProps] = useState({
opened: false,
request: null,
values: null,
onClose: () => setFormProps(prev => ({
...prev,
opened: false,
request: null,
values: null
})),
onChange: (request, data) => {
gridRef.current?.refresh({ value: data });
}
});
const columns = [
{ id: 'id', title: 'ID', width: 100 },
{ id: 'name', title: 'Name', width: 200 },
{ id: 'type', title: 'Type', width: 150 }
];
return (
<>
<Gridler
ref={gridRef}
columns={columns}
uniqueid="editable-grid"
selectMode="row"
>
<GlidlerAPIAdaptorForGoLangv2
url="https://api.example.com/items"
authtoken="your-token"
/>
<Gridler.FormAdaptor
changeOnActiveClick={true}
descriptionField="name"
onRequestForm={(request, data) => {
setFormProps(prev => ({
...prev,
opened: true,
request: request,
values: data
}));
}}
/>
</Gridler>
<FormerDialog
former={{
request: formProps.request ?? "insert",
values: formProps.values
}}
opened={formProps.opened}
onClose={formProps.onClose}
title="Edit Item"
>
<TextInputCtrl label="Name" name="name" />
<TextInputCtrl label="Description" name="description" />
<NativeSelectCtrl
label="Type"
name="type"
data={["Type A", "Type B", "Type C"]}
/>
</FormerDialog>
</>
);
}`,
refMethods: `// Using Gridler ref methods for programmatic control
import { Gridler } from '@warkypublic/oranguru';
import { useRef } from 'react';
function GridWithControls() {
const gridRef = useRef(null);
return (
<>
<Gridler ref={gridRef} columns={columns} uniqueid="controlled-grid">
<Gridler.LocalDataAdaptor data={data} />
</Gridler>
<div>
<button onClick={() => gridRef.current?.refresh()}>
Refresh Grid
</button>
<button onClick={() => gridRef.current?.selectRow(123)}>
Select Row 123
</button>
<button onClick={() => gridRef.current?.scrollToRow(456)}>
Scroll to Row 456
</button>
<button onClick={() => gridRef.current?.reloadRow(789)}>
Reload Row 789
</button>
</div>
</>
);
}`,
sections: `// Gridler with custom side sections
import { Gridler } from '@warkypublic/oranguru';
import { useState } from 'react';
function GridWithSections() {
const [sections, setSections] = useState({
top: <div style={{ backgroundColor: 'purple', height: '20px' }}>Top Bar</div>,
bottom: <div style={{ backgroundColor: 'teal', height: '25px' }}>Bottom Bar</div>,
left: <div style={{ backgroundColor: 'orange', width: '20px' }}>L</div>,
right: <div style={{ backgroundColor: 'green', width: '20px' }}>R</div>
});
return (
<Gridler
columns={columns}
uniqueid="sections-grid"
sections={{ ...sections, rightElementDisabled: false }}
>
<Gridler.LocalDataAdaptor data={data} />
</Gridler>
);
}`
},
exports: ['Gridler', 'GlidlerLocalDataAdaptor', 'GlidlerAPIAdaptorForGoLangv2', 'GlidlerFormAdaptor'],
hook: 'useGridlerStore()',
name: 'Gridler'
},
MantineBetterMenu: {
description: 'Enhanced context menus with better positioning and visibility control',
examples: {
provider: `import { MantineBetterMenusProvider } from '@warkypublic/oranguru';
import { MantineProvider } from '@mantine/core';
function App() {
return (
<MantineProvider>
<MantineBetterMenusProvider providerID="main">
<YourApp />
</MantineBetterMenusProvider>
</MantineProvider>
);
}`,
contextMenu: `import { useMantineBetterMenus } from '@warkypublic/oranguru';
function MyComponent() {
const { show, hide } = useMantineBetterMenus();
const handleContextMenu = (e) => {
e.preventDefault();
show('context-menu', {
x: e.clientX,
y: e.clientY,
items: [
{
label: 'Edit',
onClick: () => console.log('Edit clicked')
},
{
label: 'Copy',
onClick: () => console.log('Copy clicked')
},
{
isDivider: true
},
{
label: 'Delete',
onClick: () => console.log('Delete clicked'),
color: 'red'
}
]
});
};
return (
<div onContextMenu={handleContextMenu}>
Right-click me for a context menu
</div>
);
}`,
asyncActions: `import { useMantineBetterMenus } from '@warkypublic/oranguru';
function AsyncMenuExample() {
const { show } = useMantineBetterMenus();
const handleClick = (e) => {
show('async-menu', {
x: e.clientX,
y: e.clientY,
items: [
{
label: 'Save',
onClickAsync: async () => {
await fetch('/api/save', { method: 'POST' });
console.log('Saved successfully');
}
},
{
label: 'Load Data',
onClickAsync: async () => {
const data = await fetch('/api/data').then(r => r.json());
console.log('Data loaded:', data);
}
}
]
});
};
return <button onClick={handleClick}>Show Menu</button>;
}`,
customRenderer: `import { useMantineBetterMenus } from '@warkypublic/oranguru';
function CustomMenuExample() {
const { show } = useMantineBetterMenus();
const handleClick = (e) => {
show('custom-menu', {
x: e.clientX,
y: e.clientY,
items: [
{
renderer: ({ loading }) => (
<div style={{ padding: '8px 12px', color: 'blue' }}>
{loading ? 'Processing...' : 'Custom Item'}
</div>
)
}
]
});
};
return <button onClick={handleClick}>Custom Menu</button>;
}`
},
exports: ['MantineBetterMenusProvider', 'useMantineBetterMenus'],
hook: 'useMantineBetterMenus()',
name: 'MantineBetterMenu',
provider: 'MantineBetterMenusProvider'
}
};
class OranguruMCPServer {
constructor() {
this.server = new Server(
{
name: 'oranguru-docs',
version: '0.0.31',
},
{
capabilities: {
resources: {},
tools: {},
},
}
);
this.setupHandlers();
this.server.onerror = (error) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Oranguru MCP server running on stdio');
}
setupHandlers() {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
description: 'Get documentation for a specific Oranguru component',
inputSchema: {
properties: {
component: {
description: 'The component name',
enum: Object.keys(COMPONENTS),
type: 'string',
},
},
required: ['component'],
type: 'object',
},
name: 'get_component_docs',
},
{
description: 'Generate code example for a specific Oranguru component',
inputSchema: {
properties: {
component: {
description: 'The component name',
enum: Object.keys(COMPONENTS),
type: 'string',
},
variant: {
description: "Example variant (e.g., 'basic', 'local', 'server')",
type: 'string',
},
},
required: ['component'],
type: 'object',
},
name: 'get_component_example',
},
{
description: 'List all available Oranguru components',
inputSchema: {
properties: {},
type: 'object',
},
name: 'list_components',
},
],
}));
// Handle tool calls
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { arguments: args, name } = request.params;
switch (name) {
case 'get_component_docs':
if (!args.component || !COMPONENTS[args.component]) {
throw new Error(`Component ${args.component} not found`);
}
return {
content: [
{
text: JSON.stringify(COMPONENTS[args.component], null, 2),
type: 'text',
},
],
};
case 'get_component_example':
if (!args.component || !COMPONENTS[args.component]) {
throw new Error(`Component ${args.component} not found`);
}
const component = COMPONENTS[args.component];
const variant = args.variant || Object.keys(component.examples)[0];
const example = component.examples[variant];
if (!example) {
throw new Error(`Variant ${variant} not found for ${args.component}`);
}
return {
content: [
{
text: example,
type: 'text',
},
],
};
case 'list_components':
return {
content: [
{
text: JSON.stringify(
Object.entries(COMPONENTS).map(([key, comp]) => ({
description: comp.description,
exports: comp.exports,
name: key,
})),
null,
2
),
type: 'text',
},
],
};
default:
throw new Error(`Unknown tool: ${name}`);
}
});
// List resources
this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
description: 'Main documentation for the Oranguru library',
mimeType: 'text/markdown',
name: 'Oranguru Documentation',
uri: 'oranguru://docs/readme',
},
{
description: 'List of all available components',
mimeType: 'application/json',
name: 'Component List',
uri: 'oranguru://docs/components',
},
],
}));
// Read resources
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (uri === 'oranguru://docs/readme') {
const readmePath = join(__dirname, '..', 'README.md');
const readme = readFileSync(readmePath, 'utf-8');
return {
contents: [
{
mimeType: 'text/markdown',
text: readme,
uri,
},
],
};
}
if (uri === 'oranguru://docs/components') {
return {
contents: [
{
mimeType: 'application/json',
text: JSON.stringify(
Object.entries(COMPONENTS).map(([key, comp]) => ({
description: comp.description,
exports: comp.exports,
name: key,
})),
null,
2
),
uri,
},
],
};
}
throw new Error(`Resource not found: ${uri}`);
});
}
}
const server = new OranguruMCPServer();
server.run().catch(console.error);

View File

@@ -13,13 +13,20 @@
"require": "./dist/lib.cjs.js"
},
"./oranguru.css": "./dist/oranguru.css",
"./package.json": "./package.json"
"./package.json": "./package.json",
"./mcp": "./mcp-server.json"
},
"mcp": {
"server": "./mcp/server.js",
"config": "./mcp-server.json"
},
"files": [
"dist/**",
"assets/**",
"public/**",
"global.d.ts"
"global.d.ts",
"mcp/**",
"mcp-server.json"
],
"scripts": {
"dev": "vite",
@@ -33,10 +40,12 @@
"clean": "rm -rf node_modules && rm -rf dist ",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"mcp": "node mcp/server.js"
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.18",
"@modelcontextprotocol/sdk": "^1.0.4",
"moment": "^2.30.1"
},
"devDependencies": {
@@ -98,4 +107,4 @@
"use-sync-external-store": ">= 1.4.0",
"zustand": ">= 5.0.0"
}
}
}

493
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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 {