Compare commits
8 Commits
a81d59f3ba
...
dev-global
| Author | SHA1 | Date | |
|---|---|---|---|
| 0be5598655 | |||
| 53e6b7be62 | |||
| f5e31bd1f6 | |||
| f737b1d11d | |||
| 202a826642 | |||
|
|
812a5f4626 | ||
|
|
ac6dcbffec | ||
|
|
7257a86376 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"changelog": "@changesets/changelog-git",
|
||||
"commit": true,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
decorators: [
|
||||
PreviewDecorator,
|
||||
],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
@@ -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'} />
|
||||
{useGlobalStore ? (
|
||||
<GlobalStateStoreProvider fetchOnMount={false}>
|
||||
<div style={containerStyle}>
|
||||
<Story />
|
||||
</div>
|
||||
</GlobalStateStoreProvider>
|
||||
) : (
|
||||
<div style={containerStyle}>
|
||||
<Story />
|
||||
</div>
|
||||
)}
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
||||
# @warkypublic/zustandsyncstore
|
||||
|
||||
## 0.0.32
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 53e6b7b: Newest release
|
||||
|
||||
## 0.0.31
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ac6dcbf: Error Boundry
|
||||
|
||||
## 0.0.30
|
||||
|
||||
### Patch Changes
|
||||
|
||||
359
README.md
359
README.md
@@ -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>
|
||||
// Wrap app with provider
|
||||
<MantineBetterMenusProvider>
|
||||
{/* Your app content */}
|
||||
<App />
|
||||
</MantineBetterMenusProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Using the Menu Hook
|
||||
|
||||
```tsx
|
||||
import { useMantineBetterMenus } from '@warkypublic/oranguru';
|
||||
|
||||
function MyComponent() {
|
||||
// Use in components
|
||||
const { show, hide } = useMantineBetterMenus();
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
show('my-menu', {
|
||||
show('menu-id', {
|
||||
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');
|
||||
}
|
||||
}
|
||||
{ label: 'Edit', onClick: () => {} },
|
||||
{ isDivider: true },
|
||||
{ label: 'Async', onClickAsync: async () => {} }
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div onContextMenu={handleContextMenu}>
|
||||
Right-click me for a context menu
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Menu Items
|
||||
### Gridler
|
||||
|
||||
```tsx
|
||||
const customMenuItem = {
|
||||
renderer: ({ loading }: any) => (
|
||||
<div style={{ padding: '8px 12px' }}>
|
||||
{loading ? 'Loading...' : 'Custom Item'}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
import { Gridler } from '@warkypublic/oranguru';
|
||||
|
||||
show('custom-menu', {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
items: [customMenuItem]
|
||||
});
|
||||
// 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
|
||||
|
||||
@@ -192,6 +350,7 @@ See [LICENSE](LICENSE) file for details.
|
||||
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
86
mcp-server.json
Normal 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
102
mcp/README.md
Normal 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
953
mcp/server.js
Executable 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);
|
||||
51
package.json
51
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@warkypublic/oranguru",
|
||||
"author": "Warky Devs",
|
||||
"version": "0.0.30",
|
||||
"version": "0.0.32",
|
||||
"type": "module",
|
||||
"types": "./dist/lib.d.ts",
|
||||
"main": "./dist/lib.cjs.js",
|
||||
@@ -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,49 +40,55 @@
|
||||
"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": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"moment": "^2.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/changelog-git": "^0.2.1",
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@storybook/react-vite": "^10.2.1",
|
||||
"@microsoft/api-extractor": "^7.56.0",
|
||||
"@storybook/react-vite": "^10.2.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/jsdom": "~27.0.0",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/use-sync-external-store": "~1.5.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"eslint": "^9.38.0",
|
||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-mantine": "^4.0.3",
|
||||
"eslint-plugin-perfectionist": "^5.4.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"eslint-plugin-storybook": "^9.1.15",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"eslint-plugin-storybook": "^10.2.3",
|
||||
"global": "^4.4.0",
|
||||
"globals": "^17.2.0",
|
||||
"globals": "^17.3.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-eslint": "^16.4.2",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"storybook": "^9.1.15",
|
||||
"storybook": "^10.2.3",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"vite": "^7.1.12",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.0.3"
|
||||
"vite-tsconfig-paths": "^6.0.5",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@glideapps/glide-data-grid": "^6.0.3",
|
||||
@@ -87,11 +100,11 @@
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@warkypublic/artemis-kit": "^1.0.10",
|
||||
"@warkypublic/zustandsyncstore": "^0.0.4",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"immer": "^10.1.3",
|
||||
"react": ">= 19.0.0",
|
||||
"react-dom": ">= 19.0.0",
|
||||
"react-hook-form": "^7.71.0",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"use-sync-external-store": ">= 1.4.0",
|
||||
"zustand": ">= 5.0.0"
|
||||
}
|
||||
|
||||
1903
pnpm-lock.yaml
generated
1903
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
411
src/GlobalStateStore/GlobalStateStore.stories.tsx
Normal file
411
src/GlobalStateStore/GlobalStateStore.stories.tsx
Normal 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 />,
|
||||
};
|
||||
@@ -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
|
||||
const initialState: GlobalState = {
|
||||
app: {
|
||||
controls: {},
|
||||
environment: 'production',
|
||||
loading: false,
|
||||
meta: {},
|
||||
},
|
||||
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 },
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
const createSessionSlice = (set: SetState) => ({
|
||||
setApiURL: (url: string) =>
|
||||
set((state: GlobalState) => ({
|
||||
session: { ...state.session, apiURL: url },
|
||||
})),
|
||||
|
||||
setAuthToken: (token: string) =>
|
||||
set((state: GlobalState) => ({
|
||||
session: { ...state.session, authToken: token },
|
||||
})),
|
||||
|
||||
setSession: (updates: Partial<SessionState>) =>
|
||||
set((state: GlobalState) => ({
|
||||
session: { ...state.session, ...updates },
|
||||
})),
|
||||
});
|
||||
|
||||
const createOwnerSlice = (set: SetState) => ({
|
||||
setOwner: (updates: Partial<OwnerState>) =>
|
||||
set((state: GlobalState) => ({
|
||||
owner: { ...state.owner, ...updates },
|
||||
})),
|
||||
});
|
||||
|
||||
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((s) => ({
|
||||
...s,
|
||||
set((state: GlobalState) => ({
|
||||
session: {
|
||||
...state.session,
|
||||
apiURL: url ?? state.session.apiURL,
|
||||
loading: true,
|
||||
session: { ...s.session, apiURL: url ?? s.session.apiURL },
|
||||
},
|
||||
}));
|
||||
|
||||
const result = get().onFetchSession?.(get());
|
||||
const currentState = get();
|
||||
const result = await currentState.onFetchSession?.(currentState);
|
||||
|
||||
await setFetched((s) => ({
|
||||
...s,
|
||||
set((state: GlobalState) => ({
|
||||
...state,
|
||||
...result,
|
||||
app: {
|
||||
...state.app,
|
||||
...result?.app,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
session: {
|
||||
...state.session,
|
||||
...result?.session,
|
||||
connected: true,
|
||||
loading: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}));
|
||||
} catch (e) {
|
||||
await setFetched((s) => ({
|
||||
...s,
|
||||
set((state: GlobalState) => ({
|
||||
session: {
|
||||
...state.session,
|
||||
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 };
|
||||
});
|
||||
login: async (authToken?: string) => {
|
||||
set((state: GlobalState) => ({
|
||||
session: {
|
||||
...state.session,
|
||||
authToken: authToken ?? '',
|
||||
},
|
||||
}));
|
||||
await get().fetchData();
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const newstate = { ...get(), ...emptyStore };
|
||||
|
||||
set((state) => {
|
||||
return { ...state, ...newstate };
|
||||
});
|
||||
await get().fetchData();
|
||||
},
|
||||
|
||||
setAuthToken: (token: string) =>
|
||||
set(
|
||||
produce((state) => {
|
||||
state.session.authtoken = token;
|
||||
})
|
||||
),
|
||||
setIsSecurity: (issecurity: boolean) =>
|
||||
set(
|
||||
produce((state) => {
|
||||
state.session.jsonvalue.issecurity = issecurity;
|
||||
})
|
||||
),
|
||||
setState: (key, value) =>
|
||||
set(
|
||||
produce((state) => {
|
||||
state[key] = value;
|
||||
})
|
||||
),
|
||||
setStateFN: (key, value) => {
|
||||
set(
|
||||
produce((state) => {
|
||||
if (typeof value === 'function') {
|
||||
state[key] = (value as (value: any) => any)(state[key]);
|
||||
} else {
|
||||
console.error('value is not a function', value);
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
updateSession: (setter: UpdateSessionType) => {
|
||||
const curState = get();
|
||||
|
||||
const newSession: null | SessionDetail | void =
|
||||
typeof setter === 'function'
|
||||
? setter(curState?.session)
|
||||
: typeof setter === 'object'
|
||||
? (setter as SessionDetail)
|
||||
: null;
|
||||
if (newSession === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedState = {
|
||||
...curState,
|
||||
session: { ...curState.session, ...(newSession || {}) },
|
||||
};
|
||||
|
||||
set((state) => {
|
||||
state = {
|
||||
...state,
|
||||
session: { ...state.session, ...updatedState.session },
|
||||
};
|
||||
return state;
|
||||
});
|
||||
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,
|
||||
.then((state) => {
|
||||
GlobalStateStore.setState((current) => ({
|
||||
...current,
|
||||
...state,
|
||||
}))
|
||||
)
|
||||
session: {
|
||||
...current.session,
|
||||
...state.session,
|
||||
connected: true,
|
||||
loading: false,
|
||||
},
|
||||
}));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('Error loading storage:', e);
|
||||
});
|
||||
|
||||
GlobalStateStore.subscribe((state, previousState) => {
|
||||
//console.log('subscribe', state, previousState)
|
||||
GlobalStateStore.subscribe((state) => {
|
||||
saveStorage(state).catch((e) => {
|
||||
console.error('Error saving storage:', e);
|
||||
});
|
||||
if (state.session.authtoken !== previousState.session.authtoken) {
|
||||
setAuthTokenAPI(state.session.authtoken);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error loading storage:', e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-bounded version of useStore with shallow equality build in
|
||||
*/
|
||||
const createTypeBoundedUseStore = ((store) => (selector) =>
|
||||
useStoreWithEqualityFn(store, selector, shallow)) as <S extends StoreApi<unknown>>(
|
||||
store: S
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
async function loadStorage(): Promise<Partial<GlobalState>> {
|
||||
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);
|
||||
if (typeof indexedDB !== 'undefined') {
|
||||
const data = await get(STORAGE_KEY);
|
||||
if (data) {
|
||||
return JSON.parse(data) as Partial<GlobalState>;
|
||||
}
|
||||
} else if (localStorage) {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load from IndexedDB, falling back to localStorage:', e);
|
||||
}
|
||||
|
||||
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;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (data) {
|
||||
return JSON.parse(data) as Partial<GlobalState>;
|
||||
}
|
||||
return dataValue;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
return {} as T;
|
||||
} catch (e) {
|
||||
console.error('Failed to load storage: ', storageKey ?? STORAGE_KEY, e);
|
||||
}
|
||||
console.error('Failed to load from localStorage:', e);
|
||||
}
|
||||
|
||||
return {} as T;
|
||||
return {};
|
||||
}
|
||||
|
||||
async function saveStorage<T = any>(data: T, storageKey?: string): Promise<T> {
|
||||
if (indexedDB) {
|
||||
async function saveStorage(state: GlobalState): Promise<void> {
|
||||
const filtered = filterState(state);
|
||||
const serialized = JSON.stringify(filtered);
|
||||
|
||||
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);
|
||||
if (typeof indexedDB !== 'undefined') {
|
||||
await set(STORAGE_KEY, serialized);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to save to IndexedDB, falling back to localStorage:', e);
|
||||
}
|
||||
} else if (localStorage) {
|
||||
try {
|
||||
const dataString = JSON.stringify(data, skipKeysCallback);
|
||||
|
||||
localStorage.setItem(storageKey ?? STORAGE_KEY, dataString ?? '{}');
|
||||
return data;
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, serialized);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to save localStorage storage: ', storageKey ?? STORAGE_KEY, e);
|
||||
console.error('Failed to save to localStorage:', e);
|
||||
}
|
||||
}
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
export { loadStorage, saveStorage };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
105
src/GlobalStateStore/README.md
Normal file
105
src/GlobalStateStore/README.md
Normal 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`
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
? '📕'
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './Boxer';
|
||||
export * from './ErrorBoundary';
|
||||
export * from './Former';
|
||||
export * from './FormerControllers';
|
||||
export * from './GlobalStateStore';
|
||||
export * from './Gridler';
|
||||
|
||||
export {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"target": "es6",
|
||||
"useDefineForClassFields": true,
|
||||
"types": [
|
||||
"./global.d.ts"
|
||||
"./global.d.ts",
|
||||
"node"
|
||||
],
|
||||
"lib": [
|
||||
"ES2016",
|
||||
@@ -31,7 +32,8 @@
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
|
||||
@@ -21,10 +21,10 @@ export default defineConfig({
|
||||
tsconfigPath: './tsconfig.app.json',
|
||||
compilerOptions: {
|
||||
noEmit: false,
|
||||
skipLibCheck: true,
|
||||
emitDeclarationOnly: true,
|
||||
},
|
||||
}),
|
||||
|
||||
],
|
||||
publicDir: 'public',
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user