Compare commits
65 Commits
rw
...
128923290d
| Author | SHA1 | Date | |
|---|---|---|---|
| 128923290d | |||
| 3314c69ef9 | |||
| bc5d2d2a4f | |||
| bc422e7d66 | |||
| 252530610b | |||
| a748a39d2f | |||
| 8928432fe0 | |||
| 6ff395e9be | |||
| 00e5a70aef | |||
| f365d7b0e0 | |||
| 210a1d44e7 | |||
| c2113357f2 | |||
| 2e23b259ab | |||
| 552a1e5979 | |||
| 9097e2f1e0 | |||
| b521d04cd0 | |||
| 690cb22306 | |||
| 6edac91ea8 | |||
| da69c80cff | |||
| e40730eaef | |||
| d7b1eb26f3 | |||
| 7bf94f306a | |||
| 0be5598655 | |||
| 53e6b7be62 | |||
| f5e31bd1f6 | |||
| f737b1d11d | |||
| 202a826642 | |||
|
|
812a5f4626 | ||
|
|
ac6dcbffec | ||
|
|
7257a86376 | ||
|
|
a81d59f3ba | ||
|
|
29d56980b2 | ||
|
|
63222f8f28 | ||
|
|
9a597e35f3 | ||
|
|
9c78dac495 | ||
|
|
a62036bb5a | ||
| 52a97f2a97 | |||
| 6c141b71da | |||
| 89fed20f70 | |||
| 9414421430 | |||
| c4f0fcc233 | |||
| 5180f52698 | |||
| ce7cf9435a | |||
|
|
ad2252f5e4 | ||
|
|
287dbcf4da | ||
|
|
f963b38339 | ||
|
|
55cb9038ad | ||
|
|
9d907068a6 | ||
|
|
ecb90c69aa | ||
|
|
070e56e1af | ||
|
|
3e460ae46c | ||
|
|
9c64217b72 | ||
| 1fb57d3454 | |||
| a8e9c50290 | |||
| 31f2a0428f | |||
| bc7262cede | |||
| 0825f739f4 | |||
| 0bd642e2d2 | |||
| 7cc09d6acb | |||
| 9df2f3b504 | |||
| e777e1fa3a | |||
| cd2f6db880 | |||
| e6507f44af | |||
| 400a193a58 | |||
| d935c6cf28 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
|
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
|
||||||
"changelog": "@changesets/cli/changelog",
|
"changelog": "@changesets/changelog-git",
|
||||||
"commit": true,
|
"commit": true,
|
||||||
"fixed": [],
|
"fixed": [],
|
||||||
"linked": [],
|
"linked": [],
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import type { Preview } from '@storybook/react-vite'
|
import type { Preview } from '@storybook/react-vite';
|
||||||
|
|
||||||
import { PreviewDecorator } from './previewDecorator';
|
import { PreviewDecorator } from './previewDecorator';
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
|
decorators: [PreviewDecorator],
|
||||||
parameters: {
|
parameters: {
|
||||||
|
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||||
controls: {
|
controls: {
|
||||||
matchers: {
|
matchers: {
|
||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
date: /Date$/i,
|
date: /Date$/i,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
layout: 'fullscreen',
|
||||||
},
|
},
|
||||||
decorators: [
|
|
||||||
PreviewDecorator,
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
export default preview;
|
||||||
@@ -1,16 +1,40 @@
|
|||||||
import { MantineProvider } from '@mantine/core';
|
|
||||||
import { ModalsProvider } from '@mantine/modals';
|
|
||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
|
|
||||||
export function PreviewDecorator(Story: any, { parameters }: any) {
|
import type { Decorator } from '@storybook/react-vite';
|
||||||
console.log('Rendering decorator', parameters);
|
|
||||||
|
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 (
|
return (
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<div style={{ height: 'calc(100vh - 64px)', width: 'calc(100vw - 64px)' }}>
|
{useGlobalStore ? (
|
||||||
<Story key={'mainStory'} />
|
<GlobalStateStoreProvider fetchOnMount={false}>
|
||||||
</div>
|
<div style={containerStyle}>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
</GlobalStateStoreProvider>
|
||||||
|
) : (
|
||||||
|
<div style={containerStyle}>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
108
CHANGELOG.md
108
CHANGELOG.md
@@ -1,5 +1,113 @@
|
|||||||
# @warkypublic/zustandsyncstore
|
# @warkypublic/zustandsyncstore
|
||||||
|
|
||||||
|
## 0.0.41
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 3314c69: feat(Former): add useFormerState hook for managing form state
|
||||||
|
|
||||||
|
## 0.0.40
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 8928432: feat(Former): add keep open functionality and update onClose behavior
|
||||||
|
|
||||||
|
## 0.0.39
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 00e5a70: feat(Former): enhance state management with additional callbacks and state retrieval
|
||||||
|
|
||||||
|
## 0.0.38
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 210a1d4: feat(GlobalStateStore): add initialization state and update actions
|
||||||
|
|
||||||
|
## 0.0.37
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- feat(GlobalStateStore): prevent saving during initial load
|
||||||
|
|
||||||
|
## 0.0.36
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- feat(GlobalStateStore): implement storage loading and saving logic
|
||||||
|
|
||||||
|
## 0.0.35
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- fix(api): response handling in FormerRestHeadSpecAPI
|
||||||
|
|
||||||
|
## 0.0.34
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Better GlobalStateStore
|
||||||
|
|
||||||
|
## 0.0.33
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- d7b1eb2: feat(error-manager): implement centralized error reporting system
|
||||||
|
|
||||||
|
## 0.0.32
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 53e6b7b: Newest release
|
||||||
|
|
||||||
|
## 0.0.31
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- ac6dcbf: Error Boundry
|
||||||
|
|
||||||
|
## 0.0.30
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 89fed20: fix: update GridlerStore setState type to accept full state values
|
||||||
|
|
||||||
|
## 0.0.29
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 5180f52: feat(Former): ✨ update layout to use buttonArea prop instead of buttonOnTop
|
||||||
|
|
||||||
|
## 0.0.28
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 287dbcf: 1
|
||||||
|
|
||||||
|
## 0.0.27
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 9d90706: feat(Gridler): ✨ add isValuesInPages method and update state handling
|
||||||
|
|
||||||
|
## 0.0.26
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 3e460ae: fixed Gridler selectFirstRow
|
||||||
|
|
||||||
|
## 0.0.25
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 0825f73: Bump
|
||||||
|
|
||||||
|
## 0.0.24
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 7cc09d6: Added form controllers - New button and input controller components for the FormerControllers module
|
||||||
|
|
||||||
## 0.0.23
|
## 0.0.23
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
379
README.md
379
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.
|
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
|
### MantineBetterMenu
|
||||||
- **Enhanced Context Menus**: Better menu positioning and visibility control
|
|
||||||
- **Custom Rendering**: Support for custom menu item renderers and complete menu rendering
|
Enhanced context menus with better positioning and visibility control
|
||||||
- **Async Actions**: Built-in support for async menu item actions with loading states
|
|
||||||
|
### 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
|
- **State Management**: Zustand-based store for component state management
|
||||||
- **TypeScript Support**: Full TypeScript definitions included
|
- **TypeScript Support**: Full TypeScript definitions included
|
||||||
- **Portal-based Rendering**: Proper z-index handling through React portals
|
- **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
|
## Usage
|
||||||
|
|
||||||
### Basic Setup
|
### MantineBetterMenu
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { MantineBetterMenusProvider } from '@warkypublic/oranguru';
|
import { MantineBetterMenusProvider, useMantineBetterMenus } from '@warkypublic/oranguru';
|
||||||
import { MantineProvider } from '@mantine/core';
|
|
||||||
|
|
||||||
function App() {
|
// Wrap app with provider
|
||||||
return (
|
<MantineBetterMenusProvider>
|
||||||
<MantineProvider>
|
<App />
|
||||||
<MantineBetterMenusProvider>
|
</MantineBetterMenusProvider>
|
||||||
{/* Your app content */}
|
|
||||||
</MantineBetterMenusProvider>
|
|
||||||
</MantineProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using the Menu Hook
|
// Use in components
|
||||||
|
const { show, hide } = useMantineBetterMenus();
|
||||||
```tsx
|
show('menu-id', {
|
||||||
import { useMantineBetterMenus } from '@warkypublic/oranguru';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { show, hide } = useMantineBetterMenus();
|
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
show('my-menu', {
|
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: 'Edit',
|
|
||||||
onClick: () => console.log('Edit clicked')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Delete',
|
|
||||||
onClick: () => console.log('Delete clicked')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isDivider: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Async Action',
|
|
||||||
onClickAsync: async () => {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
||||||
console.log('Async action completed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div onContextMenu={handleContextMenu}>
|
|
||||||
Right-click me for a context menu
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Menu Items
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const customMenuItem = {
|
|
||||||
renderer: ({ loading }: any) => (
|
|
||||||
<div style={{ padding: '8px 12px' }}>
|
|
||||||
{loading ? 'Loading...' : 'Custom Item'}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
show('custom-menu', {
|
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
y: e.clientY,
|
y: e.clientY,
|
||||||
items: [customMenuItem]
|
items: [
|
||||||
|
{ label: 'Edit', onClick: () => {} },
|
||||||
|
{ isDivider: true },
|
||||||
|
{ label: 'Async', onClickAsync: async () => {} }
|
||||||
|
]
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Gridler
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Gridler } from '@warkypublic/oranguru';
|
||||||
|
|
||||||
|
// Local data
|
||||||
|
<Gridler columns={columns} uniqueid="my-grid">
|
||||||
|
<Gridler.LocalDataAdaptor data={data} />
|
||||||
|
</Gridler>
|
||||||
|
|
||||||
|
// API data
|
||||||
|
<Gridler columns={columns} uniqueid="my-grid">
|
||||||
|
<Gridler.APIAdaptorForGoLangv2 apiURL="/api/data" />
|
||||||
|
</Gridler>
|
||||||
|
|
||||||
|
// With inline editing form
|
||||||
|
<Gridler columns={columns} uniqueid="editable-grid" ref={gridRef}>
|
||||||
|
<Gridler.APIAdaptorForGoLangv2 url="/api/data" />
|
||||||
|
<Gridler.FormAdaptor
|
||||||
|
changeOnActiveClick={true}
|
||||||
|
descriptionField="name"
|
||||||
|
onRequestForm={(request, data) => {
|
||||||
|
setFormProps({ opened: true, request, values: data });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Gridler>
|
||||||
|
|
||||||
|
<FormerDialog
|
||||||
|
former={{ request: formProps.request, values: formProps.values }}
|
||||||
|
opened={formProps.opened}
|
||||||
|
onClose={() => setFormProps({ opened: false })}
|
||||||
|
>
|
||||||
|
<TextInputCtrl label="Name" name="name" />
|
||||||
|
<NativeSelectCtrl label="Type" name="type" data={["A", "B"]} />
|
||||||
|
</FormerDialog>
|
||||||
|
|
||||||
|
// Columns definition
|
||||||
|
const columns = [
|
||||||
|
{ id: 'name', title: 'Name', width: 200 },
|
||||||
|
{ id: 'email', title: 'Email', width: 250 }
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Former
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Former, FormerDialog } from '@warkypublic/oranguru';
|
||||||
|
|
||||||
|
const formRef = useRef<FormerRef>(null);
|
||||||
|
|
||||||
|
<Former
|
||||||
|
ref={formRef}
|
||||||
|
onSave={async (data) => { /* save logic */ }}
|
||||||
|
primeData={{ name: '', email: '' }}
|
||||||
|
wrapper={FormerDialog}
|
||||||
|
>
|
||||||
|
{/* Form content */}
|
||||||
|
</Former>
|
||||||
|
|
||||||
|
// Methods: formRef.current.show(), .save(), .reset()
|
||||||
|
```
|
||||||
|
|
||||||
|
### FormerControllers
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
TextInputCtrl,
|
||||||
|
PasswordInputCtrl,
|
||||||
|
NativeSelectCtrl,
|
||||||
|
TextAreaCtrl,
|
||||||
|
SwitchCtrl,
|
||||||
|
ButtonCtrl
|
||||||
|
} from '@warkypublic/oranguru';
|
||||||
|
|
||||||
|
<Former>
|
||||||
|
<TextInputCtrl name="username" label="Username" />
|
||||||
|
<PasswordInputCtrl name="password" label="Password" />
|
||||||
|
<NativeSelectCtrl name="role" data={['Admin', 'User']} />
|
||||||
|
<SwitchCtrl name="active" label="Active" />
|
||||||
|
<ButtonCtrl type="submit">Save</ButtonCtrl>
|
||||||
|
</Former>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Boxer
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Boxer } from '@warkypublic/oranguru';
|
||||||
|
|
||||||
|
// Local data
|
||||||
|
<Boxer
|
||||||
|
data={[{ label: 'Apple', value: 'apple' }]}
|
||||||
|
dataSource="local"
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Server-side data
|
||||||
|
<Boxer
|
||||||
|
dataSource="server"
|
||||||
|
onAPICall={async ({ page, pageSize, search }) => ({
|
||||||
|
data: [...],
|
||||||
|
total: 100
|
||||||
|
})}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Multi-select
|
||||||
|
<Boxer multiSelect value={values} onChange={setValues} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### ErrorBoundary
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ReactErrorBoundary, ReactBasicErrorBoundary } from '@warkypublic/oranguru';
|
||||||
|
|
||||||
|
// Full-featured error boundary
|
||||||
|
<ReactErrorBoundary
|
||||||
|
namespace="my-component"
|
||||||
|
reportAPI="/api/errors"
|
||||||
|
onResetClick={() => {}}
|
||||||
|
>
|
||||||
|
<App />
|
||||||
|
</ReactErrorBoundary>
|
||||||
|
|
||||||
|
// Basic error boundary
|
||||||
|
<ReactBasicErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ReactBasicErrorBoundary>
|
||||||
|
```
|
||||||
|
|
||||||
|
### GlobalStateStore
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
GlobalStateStoreProvider,
|
||||||
|
useGlobalStateStore,
|
||||||
|
GlobalStateStore
|
||||||
|
} from '@warkypublic/oranguru';
|
||||||
|
|
||||||
|
// Wrap app
|
||||||
|
<GlobalStateStoreProvider
|
||||||
|
apiURL="https://api.example.com"
|
||||||
|
fetchOnMount={true}
|
||||||
|
throttleMs={5000}
|
||||||
|
>
|
||||||
|
<App />
|
||||||
|
</GlobalStateStoreProvider>
|
||||||
|
|
||||||
|
// Use in components
|
||||||
|
const { program, session, user, layout } = useGlobalStateStore();
|
||||||
|
const { refetch } = useGlobalStateStoreContext();
|
||||||
|
|
||||||
|
// Outside React
|
||||||
|
GlobalStateStore.getState().setAuthToken('token');
|
||||||
|
const apiURL = GlobalStateStore.getState().session.apiURL;
|
||||||
|
```
|
||||||
|
|
||||||
## API Reference
|
## 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:**
|
**Gridler**
|
||||||
- `providerID?`: Optional unique identifier for the provider instance
|
|
||||||
|
|
||||||
### useMantineBetterMenus
|
- Main Component: `Gridler`
|
||||||
|
- Adaptors: `LocalDataAdaptor`, `APIAdaptorForGoLangv2`, `FormAdaptor`
|
||||||
|
- Store Hook: `useGridlerStore()`
|
||||||
|
- Key Props: `uniqueid`, `columns[]`, `data`
|
||||||
|
|
||||||
Hook to access menu functionality.
|
**Former**
|
||||||
|
|
||||||
**Returns:**
|
- Main Component: `Former`
|
||||||
- `show(id: string, options?: Partial<MantineBetterMenuInstance>)`: Show a menu
|
- Wrappers: `FormerDialog`, `FormerModel`, `FormerPopover`
|
||||||
- `hide(id: string)`: Hide a menu
|
- Ref Methods: `show()`, `close()`, `save()`, `reset()`, `validate()`
|
||||||
- `menus`: Array of current menu instances
|
- Key Props: `primeData`, `onSave`, `wrapper`
|
||||||
- `setInstanceState`: Update specific menu instance properties
|
|
||||||
|
|
||||||
### MantineBetterMenuInstance
|
**FormerControllers**
|
||||||
|
|
||||||
Interface for menu instances:
|
- Controls: `TextInputCtrl`, `PasswordInputCtrl`, `TextAreaCtrl`, `NativeSelectCtrl`, `SwitchCtrl`, `ButtonCtrl`, `IconButtonCtrl`
|
||||||
|
- Common Props: `name` (required), `label`, `disabled`
|
||||||
|
|
||||||
```typescript
|
**Boxer**
|
||||||
interface MantineBetterMenuInstance {
|
|
||||||
id: string;
|
- Provider: `BoxerProvider`
|
||||||
items?: Array<MantineBetterMenuInstanceItem>;
|
- Store Hook: `useBoxerStore()`
|
||||||
menuProps?: MenuProps;
|
- Data Sources: `local`, `server`
|
||||||
renderer?: ReactNode;
|
- Key Props: `data`, `dataSource`, `onAPICall`, `multiSelect`, `searchable`, `clearable`
|
||||||
visible: boolean;
|
|
||||||
x: number;
|
**ErrorBoundary**
|
||||||
y: number;
|
|
||||||
|
- 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
|
**Resources:**
|
||||||
interface MantineBetterMenuInstanceItem extends Partial<MenuItemProps> {
|
|
||||||
isDivider?: boolean;
|
- `oranguru://docs/readme` - Full documentation
|
||||||
label?: string;
|
- `oranguru://docs/components` - Component list
|
||||||
onClick?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
|
||||||
onClickAsync?: () => Promise<void>;
|
See `mcp/README.md` for details.
|
||||||
renderer?: ((props: MantineBetterMenuInstanceItem & Record<string, unknown>) => ReactNode) | ReactNode;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -174,6 +331,7 @@ interface MantineBetterMenuInstanceItem extends Partial<MenuItemProps> {
|
|||||||
- `pnpm lint`: Run ESLint
|
- `pnpm lint`: Run ESLint
|
||||||
- `pnpm typecheck`: Run TypeScript type checking
|
- `pnpm typecheck`: Run TypeScript type checking
|
||||||
- `pnpm clean`: Clean node_modules and dist folders
|
- `pnpm clean`: Clean node_modules and dist folders
|
||||||
|
- `pnpm mcp`: Run MCP server
|
||||||
|
|
||||||
### Building
|
### 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.
|
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:
|
In the Pokémon world, Oranguru is known for:
|
||||||
|
|
||||||
- Its exceptional intelligence and strategic thinking
|
- Its exceptional intelligence and strategic thinking
|
||||||
- Living deep in forests and rarely showing itself to humans
|
- Living deep in forests and rarely showing itself to humans
|
||||||
- Using its psychic powers to control other Pokémon with its fan
|
- 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
|
## Author
|
||||||
|
|
||||||
**Warky Devs**
|
Warky Devs
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const config = defineConfig([
|
|||||||
{
|
{
|
||||||
extends: ['js/recommended'],
|
extends: ['js/recommended'],
|
||||||
files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '*stories.tsx','dist/**'],
|
||||||
languageOptions: { globals: globals.browser },
|
languageOptions: { globals: globals.browser },
|
||||||
plugins: { js },
|
plugins: { js },
|
||||||
},
|
},
|
||||||
@@ -20,7 +20,7 @@ const config = defineConfig([
|
|||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
{
|
{
|
||||||
...pluginReact.configs.flat.recommended,
|
...pluginReact.configs.flat.recommended,
|
||||||
ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '*stories.tsx'],
|
ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '*stories.tsx','dist/**'],
|
||||||
rules: {
|
rules: {
|
||||||
...pluginReact.configs.flat.recommended.rules,
|
...pluginReact.configs.flat.recommended.rules,
|
||||||
'react/react-in-jsx-scope': 'off',
|
'react/react-in-jsx-scope': 'off',
|
||||||
@@ -34,6 +34,7 @@ const config = defineConfig([
|
|||||||
'@typescript-eslint/ban-ts-comment': 'off',
|
'@typescript-eslint/ban-ts-comment': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ignores: ['dist/**','node_modules/**','vite.config.*','eslint.config.*' ]},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
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);
|
||||||
124
package.json
124
package.json
@@ -1,8 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "@warkypublic/oranguru",
|
"name": "@warkypublic/oranguru",
|
||||||
"author": "Warky Devs",
|
"author": "Warky Devs",
|
||||||
"version": "0.0.23",
|
"version": "0.0.41",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"types": "./dist/lib.d.ts",
|
||||||
|
"main": "./dist/lib.cjs.js",
|
||||||
|
"module": "./dist/lib.es.js",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/lib.d.ts",
|
||||||
|
"import": "./dist/lib.es.js",
|
||||||
|
"require": "./dist/lib.cjs.js"
|
||||||
|
},
|
||||||
|
"./oranguru.css": "./dist/oranguru.css",
|
||||||
|
"./package.json": "./package.json",
|
||||||
|
"./mcp": "./mcp-server.json"
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"server": "./mcp/server.js",
|
||||||
|
"config": "./mcp-server.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**",
|
||||||
|
"assets/**",
|
||||||
|
"public/**",
|
||||||
|
"global.d.ts",
|
||||||
|
"mcp/**",
|
||||||
|
"mcp-server.json"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
@@ -15,97 +40,76 @@
|
|||||||
"clean": "rm -rf node_modules && rm -rf dist ",
|
"clean": "rm -rf node_modules && rm -rf dist ",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build",
|
||||||
|
"mcp": "node mcp/server.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"repository": {
|
||||||
"dist/**",
|
"type": "git",
|
||||||
"assets/**",
|
"url": "git+https://git.warky.dev/wdevs/oranguru.git"
|
||||||
"public/**",
|
|
||||||
"global.d.ts"
|
|
||||||
],
|
|
||||||
"module": "./src.lib.ts",
|
|
||||||
"types": "./src/lib.ts",
|
|
||||||
"publishConfig": {
|
|
||||||
"main": "./dist/lib.cjs.js",
|
|
||||||
"module": "./dist/lib.es.js",
|
|
||||||
"require": "./dist/lib.cjs.js",
|
|
||||||
"types": "./dist/lib.d.ts",
|
|
||||||
"typings": "./dist/lib.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/lib.es.js",
|
|
||||||
"types": "./dist/lib.d.ts",
|
|
||||||
"default": "./dist/lib.cjs.js"
|
|
||||||
},
|
|
||||||
"./package.json": "./package.json",
|
|
||||||
"./oranguru.css": "./dist/oranguru.css"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./src/lib.ts",
|
|
||||||
"default": "./src/lib.ts"
|
|
||||||
},
|
|
||||||
"./oranguru.css": "./src/oranguru.css"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"moment": "^2.30.1"
|
"moment": "^2.30.1"
|
||||||
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.29.7",
|
"@changesets/changelog-git": "^0.2.1",
|
||||||
"@eslint/js": "^9.38.0",
|
"@changesets/cli": "^2.29.8",
|
||||||
"@storybook/react-vite": "^9.1.15",
|
"@eslint/js": "^9.39.2",
|
||||||
|
"@microsoft/api-extractor": "^7.56.0",
|
||||||
|
"@sentry/react": "^10.38.0",
|
||||||
|
"@storybook/react-vite": "^10.2.3",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.9.1",
|
"@types/jsdom": "~27.0.0",
|
||||||
"@types/react": "^19.2.2",
|
"@types/node": "^25.2.0",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react": "^19.2.10",
|
||||||
"@typescript-eslint/parser": "^8.46.2",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react-swc": "^4.2.0",
|
"@types/use-sync-external-store": "~1.5.0",
|
||||||
"eslint": "^9.38.0",
|
"@typescript-eslint/parser": "^8.54.0",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.2.3",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-mantine": "^4.0.3",
|
"eslint-config-mantine": "^4.0.3",
|
||||||
"eslint-plugin-perfectionist": "^4.15.1",
|
"eslint-plugin-perfectionist": "^5.4.0",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.5.0",
|
||||||
"eslint-plugin-storybook": "^9.1.15",
|
"eslint-plugin-storybook": "^10.2.3",
|
||||||
"global": "^4.4.0",
|
"global": "^4.4.0",
|
||||||
"globals": "^16.4.0",
|
"globals": "^17.3.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^28.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.8.1",
|
||||||
"prettier-eslint": "^16.4.2",
|
"prettier-eslint": "^16.4.2",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.4",
|
||||||
"storybook": "^9.1.15",
|
"storybook": "^10.2.3",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.2",
|
"typescript-eslint": "^8.54.0",
|
||||||
"vite": "^7.1.12",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-dts": "^4.5.4",
|
"vite-plugin-dts": "^4.5.4",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^6.0.5",
|
||||||
"vitest": "^4.0.3"
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@glideapps/glide-data-grid": "^6.0.3",
|
"@glideapps/glide-data-grid": "^6.0.3",
|
||||||
"@mantine/core": "^8.3.1",
|
"@mantine/core": "^8.3.1",
|
||||||
"@mantine/hooks": "^8.3.1",
|
"@mantine/hooks": "^8.3.1",
|
||||||
"@mantine/notifications": "^8.3.5",
|
|
||||||
"@mantine/modals": "^8.3.5",
|
"@mantine/modals": "^8.3.5",
|
||||||
|
"@mantine/notifications": "^8.3.5",
|
||||||
"@tabler/icons-react": "^3.35.0",
|
"@tabler/icons-react": "^3.35.0",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"@warkypublic/artemis-kit": "^1.0.10",
|
"@warkypublic/artemis-kit": "^1.0.10",
|
||||||
"@warkypublic/zustandsyncstore": "^0.0.4",
|
"@warkypublic/zustandsyncstore": "^0.0.4",
|
||||||
"react-hook-form": "^7.71.0",
|
"idb-keyval": "^6.2.2",
|
||||||
|
|
||||||
"immer": "^10.1.3",
|
"immer": "^10.1.3",
|
||||||
"react": ">= 19.0.0",
|
"react": ">= 19.0.0",
|
||||||
"react-dom": ">= 19.0.0",
|
"react-dom": ">= 19.0.0",
|
||||||
|
"react-hook-form": "^7.71.0",
|
||||||
"use-sync-external-store": ">= 1.4.0",
|
"use-sync-external-store": ">= 1.4.0",
|
||||||
"zustand": ">= 5.0.0"
|
"zustand": ">= 5.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
2645
pnpm-lock.yaml
generated
2645
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
159
src/Boxer/Boxer.store.tsx
Normal file
159
src/Boxer/Boxer.store.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { createSyncStore } from '@warkypublic/zustandsyncstore';
|
||||||
|
import { produce } from 'immer';
|
||||||
|
|
||||||
|
import type { BoxerProps, BoxerStoreState } from './Boxer.types';
|
||||||
|
|
||||||
|
const { Provider: BoxerProvider, useStore: useBoxerStore } = createSyncStore<
|
||||||
|
BoxerStoreState,
|
||||||
|
BoxerProps
|
||||||
|
>(
|
||||||
|
(set, get) => ({
|
||||||
|
boxerData: [],
|
||||||
|
// Data Actions
|
||||||
|
fetchData: async (search?: string, reset?: boolean) => {
|
||||||
|
const state = get();
|
||||||
|
|
||||||
|
// Handle local data
|
||||||
|
if (state.dataSource === 'local' || !state.onAPICall) {
|
||||||
|
const localData = state.data ?? [];
|
||||||
|
|
||||||
|
if (!search) {
|
||||||
|
set({ boxerData: localData, hasMore: false, total: localData.length });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter local data based on search
|
||||||
|
const filtered = localData.filter((item) =>
|
||||||
|
item.label.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
set({ boxerData: filtered, hasMore: false, total: filtered.length });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle server-side data
|
||||||
|
if (state.onAPICall) {
|
||||||
|
try {
|
||||||
|
set({ isFetching: true });
|
||||||
|
|
||||||
|
const currentPage = reset ? 0 : state.page;
|
||||||
|
|
||||||
|
const result = await state.onAPICall({
|
||||||
|
page: currentPage,
|
||||||
|
pageSize: state.pageSize,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
|
||||||
|
set(
|
||||||
|
produce((draft) => {
|
||||||
|
if (reset) {
|
||||||
|
draft.boxerData = result.data;
|
||||||
|
draft.page = 0;
|
||||||
|
} else {
|
||||||
|
draft.boxerData = [...(draft.boxerData ?? []), ...result.data];
|
||||||
|
}
|
||||||
|
draft.total = result.total;
|
||||||
|
draft.hasMore = draft.boxerData.length < result.total;
|
||||||
|
draft.isFetching = false;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Boxer fetchData error:', error);
|
||||||
|
set({ isFetching: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetchMoreOnBottomReached: (target: HTMLDivElement) => {
|
||||||
|
const state = get();
|
||||||
|
|
||||||
|
if (!state.hasMore || state.isFetching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollPercentage =
|
||||||
|
(target.scrollTop + target.clientHeight) / target.scrollHeight;
|
||||||
|
|
||||||
|
// Load more when scrolled past 80%
|
||||||
|
if (scrollPercentage > 0.8) {
|
||||||
|
state.loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// State Management
|
||||||
|
getState: (key) => {
|
||||||
|
const current = get();
|
||||||
|
return current?.[key];
|
||||||
|
},
|
||||||
|
hasMore: true,
|
||||||
|
input: '',
|
||||||
|
isFetching: false,
|
||||||
|
loadMore: async () => {
|
||||||
|
const state = get();
|
||||||
|
|
||||||
|
if (!state.hasMore || state.isFetching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.page = draft.page + 1;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await state.fetchData(state.search);
|
||||||
|
},
|
||||||
|
// Initial State
|
||||||
|
opened: false,
|
||||||
|
page: 0,
|
||||||
|
|
||||||
|
pageSize: 50,
|
||||||
|
|
||||||
|
search: '',
|
||||||
|
|
||||||
|
selectedOptionIndex: -1,
|
||||||
|
|
||||||
|
setInput: (input: string) => {
|
||||||
|
set({ input });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setOpened: (opened: boolean) => {
|
||||||
|
set({ opened });
|
||||||
|
},
|
||||||
|
|
||||||
|
setSearch: (search: string) => {
|
||||||
|
set({ search });
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedOptionIndex: (index: number) => {
|
||||||
|
set({ selectedOptionIndex: index });
|
||||||
|
},
|
||||||
|
|
||||||
|
setState: (key, value) => {
|
||||||
|
set(
|
||||||
|
produce((state) => {
|
||||||
|
state[key] = value;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
total: 0,
|
||||||
|
}),
|
||||||
|
({
|
||||||
|
data = [],
|
||||||
|
dataSource = 'local',
|
||||||
|
pageSize = 50,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
boxerData: data, // Initialize with local data if provided
|
||||||
|
data,
|
||||||
|
dataSource,
|
||||||
|
hasMore: dataSource === 'server',
|
||||||
|
pageSize,
|
||||||
|
total: data.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export { BoxerProvider };
|
||||||
|
export { useBoxerStore };
|
||||||
379
src/Boxer/Boxer.tsx
Normal file
379
src/Boxer/Boxer.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { Combobox, ScrollArea, useVirtualizedCombobox } from '@mantine/core';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import React, { useCallback, useEffect, useImperativeHandle, useRef } from 'react';
|
||||||
|
|
||||||
|
import type { BoxerItem, BoxerProps, BoxerRef } from './Boxer.types';
|
||||||
|
|
||||||
|
import { BoxerProvider, useBoxerStore } from './Boxer.store';
|
||||||
|
import BoxerTarget from './BoxerTarget';
|
||||||
|
import useBoxerOptions from './hooks/useBoxerOptions';
|
||||||
|
|
||||||
|
const BoxerInner = React.forwardRef<BoxerRef>((_, ref) => {
|
||||||
|
// Component Refs
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const valueRef = useRef<any>(null);
|
||||||
|
const bufferRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// Component store State
|
||||||
|
const {
|
||||||
|
boxerData,
|
||||||
|
clearable,
|
||||||
|
comboBoxProps,
|
||||||
|
dropDownProps,
|
||||||
|
error,
|
||||||
|
fetchData,
|
||||||
|
fetchMoreOnBottomReached,
|
||||||
|
input,
|
||||||
|
isFetching,
|
||||||
|
label,
|
||||||
|
mah,
|
||||||
|
multiSelect,
|
||||||
|
onBufferChange,
|
||||||
|
onChange,
|
||||||
|
opened,
|
||||||
|
openOnClear,
|
||||||
|
placeholder,
|
||||||
|
scrollAreaProps,
|
||||||
|
search,
|
||||||
|
selectedOptionIndex,
|
||||||
|
selectFirst,
|
||||||
|
setInput,
|
||||||
|
setOpened,
|
||||||
|
setSearch,
|
||||||
|
setSelectedOptionIndex,
|
||||||
|
showAll,
|
||||||
|
value,
|
||||||
|
} = useBoxerStore((state) => ({
|
||||||
|
boxerData: state.boxerData,
|
||||||
|
clearable: state.clearable,
|
||||||
|
comboBoxProps: state.comboBoxProps,
|
||||||
|
dropDownProps: state.dropDownProps,
|
||||||
|
error: state.error,
|
||||||
|
fetchData: state.fetchData,
|
||||||
|
fetchMoreOnBottomReached: state.fetchMoreOnBottomReached,
|
||||||
|
input: state.input,
|
||||||
|
isFetching: state.isFetching,
|
||||||
|
label: state.label,
|
||||||
|
mah: state.mah,
|
||||||
|
multiSelect: state.multiSelect,
|
||||||
|
onBufferChange: state.onBufferChange,
|
||||||
|
onChange: state.onChange,
|
||||||
|
opened: state.opened,
|
||||||
|
openOnClear: state.openOnClear,
|
||||||
|
placeholder: state.placeholder,
|
||||||
|
scrollAreaProps: state.scrollAreaProps,
|
||||||
|
search: state.search,
|
||||||
|
selectedOptionIndex: state.selectedOptionIndex,
|
||||||
|
selectFirst: state.selectFirst,
|
||||||
|
setInput: state.setInput,
|
||||||
|
setOpened: state.setOpened,
|
||||||
|
setSearch: state.setSearch,
|
||||||
|
setSelectedOptionIndex: state.setSelectedOptionIndex,
|
||||||
|
showAll: state.showAll,
|
||||||
|
value: state.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Virtualization setup
|
||||||
|
const count = boxerData.length;
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count,
|
||||||
|
estimateSize: () => 36,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
});
|
||||||
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
// Component Callback Functions
|
||||||
|
const onOptionSubmit = useCallback(
|
||||||
|
(indexOrId: number | string) => {
|
||||||
|
const index = typeof indexOrId === 'string' ? parseInt(indexOrId, 10) : indexOrId;
|
||||||
|
const option = boxerData[index];
|
||||||
|
|
||||||
|
if (!option) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (multiSelect) {
|
||||||
|
// Handle multi-select
|
||||||
|
const currentValues = Array.isArray(value) ? value : [];
|
||||||
|
const isSelected = currentValues.includes(option.value);
|
||||||
|
|
||||||
|
const newValues = isSelected
|
||||||
|
? currentValues.filter((v: any) => v !== option.value)
|
||||||
|
: [...currentValues, option.value];
|
||||||
|
|
||||||
|
onChange?.(newValues);
|
||||||
|
|
||||||
|
// Update buffer for multi-select
|
||||||
|
const newBuffer = boxerData.filter((item) => newValues.includes(item.value));
|
||||||
|
onBufferChange?.(newBuffer);
|
||||||
|
} else {
|
||||||
|
// Handle single select
|
||||||
|
onChange?.(option.value);
|
||||||
|
setSearch('');
|
||||||
|
setInput(option.label);
|
||||||
|
valueRef.current = option.value;
|
||||||
|
setOpened(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[boxerData, multiSelect, value, onChange, onBufferChange, setSearch, setInput, setOpened]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClear = useCallback(() => {
|
||||||
|
if (showAll && selectFirst) {
|
||||||
|
onOptionSubmit(0);
|
||||||
|
} else {
|
||||||
|
if (multiSelect) {
|
||||||
|
onChange?.([] as any);
|
||||||
|
} else {
|
||||||
|
onChange?.(null as any);
|
||||||
|
}
|
||||||
|
setSearch('');
|
||||||
|
setInput('');
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openOnClear) {
|
||||||
|
setOpened(true);
|
||||||
|
}
|
||||||
|
}, [showAll, selectFirst, multiSelect, onChange, setSearch, setInput, openOnClear, setOpened, onOptionSubmit]);
|
||||||
|
|
||||||
|
// Component Hooks
|
||||||
|
const combobox = useVirtualizedCombobox({
|
||||||
|
getOptionId: (index) => String(index),
|
||||||
|
onDropdownClose: () => {
|
||||||
|
setOpened(false);
|
||||||
|
},
|
||||||
|
onDropdownOpen: () => {
|
||||||
|
if (!value || (multiSelect && (!Array.isArray(value) || value.length === 0))) {
|
||||||
|
setSearch('');
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
combobox.selectFirstOption();
|
||||||
|
},
|
||||||
|
onSelectedOptionSubmit: onOptionSubmit,
|
||||||
|
opened,
|
||||||
|
selectedOptionIndex,
|
||||||
|
setSelectedOptionIndex: (index) => {
|
||||||
|
setSelectedOptionIndex(index);
|
||||||
|
if (index !== -1) {
|
||||||
|
virtualizer.scrollToIndex(index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
totalOptionsCount: boxerData.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component variables
|
||||||
|
const { options } = useBoxerOptions({
|
||||||
|
boxerData,
|
||||||
|
multiSelect,
|
||||||
|
onOptionSubmit,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component useEffects
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch initial data
|
||||||
|
fetchData('', true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Handle search changes
|
||||||
|
const delayDebounceFn = setTimeout(() => {
|
||||||
|
if (search !== undefined && opened) {
|
||||||
|
fetchData(search, true);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(delayDebounceFn);
|
||||||
|
}, [search, opened]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Sync input with value
|
||||||
|
if (multiSelect) {
|
||||||
|
const labels = boxerData
|
||||||
|
.filter((item) => Array.isArray(value) && value.includes(item.value))
|
||||||
|
.map((item) => item.label)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
// When dropdown is closed, show selected labels. When open, allow searching
|
||||||
|
if (!opened && input !== labels) {
|
||||||
|
setInput(labels);
|
||||||
|
setSearch('');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const label = boxerData.find((item) => item.value === value)?.label;
|
||||||
|
|
||||||
|
// Only sync if we need to update the input to match the value
|
||||||
|
if (input !== label && (search ?? '') === '' && valueRef.current !== value && value) {
|
||||||
|
setInput(label ?? '');
|
||||||
|
} else if (!value && !valueRef.current && (search ?? '') === '') {
|
||||||
|
setSearch('');
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle buffer change
|
||||||
|
if (multiSelect) {
|
||||||
|
const buffer =
|
||||||
|
boxerData.filter((item: BoxerItem) => Array.isArray(value) && value.includes(item.value)) ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
if (JSON.stringify(bufferRef.current) !== JSON.stringify(buffer)) {
|
||||||
|
onBufferChange?.(buffer);
|
||||||
|
bufferRef.current = buffer;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const buffer = boxerData?.find((item: BoxerItem) => item.value === value) ?? null;
|
||||||
|
|
||||||
|
if (bufferRef.current?.value !== buffer?.value) {
|
||||||
|
onBufferChange?.(buffer);
|
||||||
|
bufferRef.current = buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [value, boxerData, input, search, multiSelect, opened, onBufferChange, setInput, setSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Select first option automatically
|
||||||
|
if (selectFirst && (boxerData?.length ?? 0) > 0 && !multiSelect) {
|
||||||
|
if (!value) {
|
||||||
|
onOptionSubmit?.(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectFirst, boxerData, multiSelect]);
|
||||||
|
|
||||||
|
// Expose ref methods
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
clear: () => {
|
||||||
|
onClear();
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
setOpened(false);
|
||||||
|
combobox.closeDropdown();
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
},
|
||||||
|
getValue: () => {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
open: () => {
|
||||||
|
setOpened(true);
|
||||||
|
combobox.openDropdown();
|
||||||
|
},
|
||||||
|
setValue: (newValue: any) => {
|
||||||
|
onChange?.(newValue);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
{...comboBoxProps}
|
||||||
|
resetSelectionOnOptionHover={false}
|
||||||
|
store={combobox}
|
||||||
|
withinPortal={true}
|
||||||
|
>
|
||||||
|
<Combobox.Target>
|
||||||
|
<Combobox.EventsTarget>
|
||||||
|
<BoxerTarget
|
||||||
|
clearable={clearable}
|
||||||
|
combobox={combobox}
|
||||||
|
error={error}
|
||||||
|
isFetching={isFetching}
|
||||||
|
label={label}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!value && !multiSelect) {
|
||||||
|
setSearch('');
|
||||||
|
setInput('');
|
||||||
|
combobox.closeDropdown();
|
||||||
|
setOpened(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClear={onClear}
|
||||||
|
onSearch={(event) => {
|
||||||
|
setSearch(event.currentTarget.value);
|
||||||
|
setInput(event.currentTarget.value);
|
||||||
|
setOpened(true);
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
ref={inputRef}
|
||||||
|
search={input}
|
||||||
|
/>
|
||||||
|
</Combobox.EventsTarget>
|
||||||
|
</Combobox.Target>
|
||||||
|
|
||||||
|
<Combobox.Dropdown
|
||||||
|
onKeyDown={() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = '';
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
p={2}
|
||||||
|
{...dropDownProps}
|
||||||
|
>
|
||||||
|
{opened && options.length > 0 ? (
|
||||||
|
<Combobox.Options>
|
||||||
|
<ScrollArea.Autosize
|
||||||
|
{...scrollAreaProps}
|
||||||
|
mah={mah ?? 200}
|
||||||
|
viewportProps={{
|
||||||
|
...scrollAreaProps?.viewportProps,
|
||||||
|
onScroll: (event) => {
|
||||||
|
fetchMoreOnBottomReached(event.currentTarget as HTMLDivElement);
|
||||||
|
},
|
||||||
|
style: { border: '1px solid gray', borderRadius: 4 },
|
||||||
|
}}
|
||||||
|
viewportRef={parentRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualItems.map((virtualRow) => (
|
||||||
|
<div
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
key={virtualRow.key}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
>
|
||||||
|
{options[virtualRow.index]}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Combobox.Options>
|
||||||
|
) : (
|
||||||
|
<Combobox.Empty>Nothing found</Combobox.Empty>
|
||||||
|
)}
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
BoxerInner.displayName = 'BoxerInner';
|
||||||
|
|
||||||
|
const Boxer = React.forwardRef<BoxerRef, BoxerProps>((props, ref) => {
|
||||||
|
return (
|
||||||
|
<BoxerProvider {...props}>
|
||||||
|
<BoxerInner ref={ref} />
|
||||||
|
</BoxerProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Boxer.displayName = 'Boxer';
|
||||||
|
|
||||||
|
export { Boxer };
|
||||||
|
export default Boxer;
|
||||||
109
src/Boxer/Boxer.types.ts
Normal file
109
src/Boxer/Boxer.types.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { ComboboxProps, ScrollAreaAutosizeProps, TextInputProps } from '@mantine/core';
|
||||||
|
import type { VirtualizerOptions } from '@tanstack/react-virtual';
|
||||||
|
|
||||||
|
export type BoxerDataSource =
|
||||||
|
| 'local' // Local array data
|
||||||
|
| 'server'; // Server-side with infinite loading
|
||||||
|
|
||||||
|
export type BoxerItem = {
|
||||||
|
[key: string]: any;
|
||||||
|
label: string;
|
||||||
|
value: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BoxerProps {
|
||||||
|
clearable?: boolean;
|
||||||
|
// Component Props
|
||||||
|
comboBoxProps?: Partial<ComboboxProps>;
|
||||||
|
|
||||||
|
// Data Configuration
|
||||||
|
data?: Array<BoxerItem>;
|
||||||
|
|
||||||
|
dataSource?: BoxerDataSource;
|
||||||
|
disabled?: boolean;
|
||||||
|
dropDownProps?: React.ComponentPropsWithoutRef<'div'>;
|
||||||
|
|
||||||
|
error?: string;
|
||||||
|
// Advanced
|
||||||
|
id?: string;
|
||||||
|
inputProps?: Partial<TextInputProps>;
|
||||||
|
label?: string;
|
||||||
|
leftSection?: React.ReactNode;
|
||||||
|
mah?: number; // Max height for dropdown
|
||||||
|
|
||||||
|
// Component Configuration
|
||||||
|
multiSelect?: boolean;
|
||||||
|
name?: string;
|
||||||
|
// API Configuration (for server-side)
|
||||||
|
onAPICall?: (params: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
search?: string;
|
||||||
|
}) => Promise<{ data: Array<BoxerItem>; total: number }>;
|
||||||
|
onBufferChange?: (buffer: Array<BoxerItem> | BoxerItem | null) => void;
|
||||||
|
onChange?: (value: any | Array<any>) => void;
|
||||||
|
|
||||||
|
openOnClear?: boolean;
|
||||||
|
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
// UI Configuration
|
||||||
|
placeholder?: string;
|
||||||
|
// Styling
|
||||||
|
rightSection?: React.ReactNode;
|
||||||
|
scrollAreaProps?: Partial<ScrollAreaAutosizeProps>;
|
||||||
|
searchable?: boolean;
|
||||||
|
|
||||||
|
selectFirst?: boolean;
|
||||||
|
showAll?: boolean;
|
||||||
|
|
||||||
|
// Value Management
|
||||||
|
value?: any | Array<any>;
|
||||||
|
// Virtualization
|
||||||
|
virtualizer?: Partial<VirtualizerOptions<HTMLDivElement, Element>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoxerRef {
|
||||||
|
clear: () => void;
|
||||||
|
close: () => void;
|
||||||
|
focus: () => void;
|
||||||
|
getValue: () => any | Array<any>;
|
||||||
|
open: () => void;
|
||||||
|
setValue: (value: any | Array<any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoxerState {
|
||||||
|
// Data State
|
||||||
|
boxerData: Array<BoxerItem>;
|
||||||
|
fetchData: (search?: string, reset?: boolean) => Promise<void>;
|
||||||
|
fetchMoreOnBottomReached: (target: HTMLDivElement) => void;
|
||||||
|
// State Management
|
||||||
|
getState: <K extends keyof BoxerStoreState>(key: K) => BoxerStoreState[K];
|
||||||
|
hasMore: boolean;
|
||||||
|
|
||||||
|
input: string;
|
||||||
|
isFetching: boolean;
|
||||||
|
// Data Actions
|
||||||
|
loadMore: () => Promise<void>;
|
||||||
|
// Internal State
|
||||||
|
opened: boolean;
|
||||||
|
page: number;
|
||||||
|
|
||||||
|
pageSize: number;
|
||||||
|
search: string;
|
||||||
|
selectedOptionIndex: number;
|
||||||
|
setInput: (input: string) => void;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setOpened: (opened: boolean) => void;
|
||||||
|
setSearch: (search: string) => void;
|
||||||
|
setSelectedOptionIndex: (index: number) => void;
|
||||||
|
|
||||||
|
setState: <K extends keyof BoxerStoreState>(
|
||||||
|
key: K,
|
||||||
|
value: Partial<BoxerStoreState[K]>
|
||||||
|
) => void;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoxerStoreState = BoxerProps & BoxerState;
|
||||||
73
src/Boxer/BoxerTarget.tsx
Normal file
73
src/Boxer/BoxerTarget.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { ComboboxStore } from '@mantine/core';
|
||||||
|
|
||||||
|
import { ActionIcon, Loader, TextInput } from '@mantine/core';
|
||||||
|
import { IconX } from '@tabler/icons-react';
|
||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
|
||||||
|
interface BoxerTargetProps {
|
||||||
|
clearable?: boolean;
|
||||||
|
combobox: ComboboxStore;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: string;
|
||||||
|
isFetching?: boolean;
|
||||||
|
label?: string;
|
||||||
|
leftSection?: React.ReactNode;
|
||||||
|
onBlur: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
onSearch: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
search: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BoxerTarget = forwardRef<HTMLInputElement, BoxerTargetProps>((props, ref) => {
|
||||||
|
const {
|
||||||
|
clearable = true,
|
||||||
|
combobox,
|
||||||
|
disabled,
|
||||||
|
error,
|
||||||
|
isFetching,
|
||||||
|
label,
|
||||||
|
leftSection,
|
||||||
|
onBlur,
|
||||||
|
onClear,
|
||||||
|
onSearch,
|
||||||
|
placeholder,
|
||||||
|
search,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const rightSection = isFetching ? (
|
||||||
|
<Loader size="xs" />
|
||||||
|
) : search && clearable ? (
|
||||||
|
<ActionIcon
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClear();
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
disabled={disabled}
|
||||||
|
error={error}
|
||||||
|
label={label}
|
||||||
|
leftSection={leftSection}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChange={onSearch}
|
||||||
|
onClick={() => combobox.openDropdown()}
|
||||||
|
onFocus={() => combobox.openDropdown()}
|
||||||
|
placeholder={placeholder}
|
||||||
|
ref={ref}
|
||||||
|
rightSection={rightSection}
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
BoxerTarget.displayName = 'BoxerTarget';
|
||||||
|
|
||||||
|
export default BoxerTarget;
|
||||||
47
src/Boxer/hooks/useBoxerOptions.tsx
Normal file
47
src/Boxer/hooks/useBoxerOptions.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Checkbox, Combobox } from '@mantine/core';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import type { BoxerItem } from '../Boxer.types';
|
||||||
|
|
||||||
|
interface UseBoxerOptionsProps {
|
||||||
|
boxerData: Array<BoxerItem>;
|
||||||
|
multiSelect?: boolean;
|
||||||
|
onOptionSubmit: (index: number) => void;
|
||||||
|
value?: any | Array<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useBoxerOptions = (props: UseBoxerOptionsProps) => {
|
||||||
|
const { boxerData, multiSelect, onOptionSubmit, value } = props;
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return boxerData.map((item, index) => {
|
||||||
|
const isSelected = multiSelect
|
||||||
|
? Array.isArray(value) && value.includes(item.value)
|
||||||
|
: value === item.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox.Option
|
||||||
|
active={isSelected}
|
||||||
|
key={`${item.value}-${index}`}
|
||||||
|
onClick={() => {
|
||||||
|
onOptionSubmit(index);
|
||||||
|
}}
|
||||||
|
value={String(index)}
|
||||||
|
>
|
||||||
|
{multiSelect ? (
|
||||||
|
<div style={{ alignItems: 'center', display: 'flex', gap: '8px' }}>
|
||||||
|
<Checkbox checked={isSelected} onChange={() => {}} tabIndex={-1} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
item.label
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [boxerData, value, multiSelect, onOptionSubmit]);
|
||||||
|
|
||||||
|
return { options };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useBoxerOptions;
|
||||||
10
src/Boxer/index.ts
Normal file
10
src/Boxer/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { Boxer, default } from './Boxer';
|
||||||
|
export { BoxerProvider, useBoxerStore } from './Boxer.store';
|
||||||
|
export type {
|
||||||
|
BoxerDataSource,
|
||||||
|
BoxerItem,
|
||||||
|
BoxerProps,
|
||||||
|
BoxerRef,
|
||||||
|
BoxerState,
|
||||||
|
BoxerStoreState,
|
||||||
|
} from './Boxer.types';
|
||||||
218
src/Boxer/stories/Boxer.stories.tsx
Normal file
218
src/Boxer/stories/Boxer.stories.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
//@ts-ignore
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { BoxerItem } from '../Boxer.types';
|
||||||
|
|
||||||
|
import { Boxer } from '../Boxer';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Boxer> = {
|
||||||
|
component: Boxer,
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
title: 'Components/Boxer',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Boxer>;
|
||||||
|
|
||||||
|
// Sample data
|
||||||
|
const sampleData: Array<BoxerItem> = [
|
||||||
|
{ label: 'Apple', value: 'apple' },
|
||||||
|
{ label: 'Banana', value: 'banana' },
|
||||||
|
{ label: 'Cherry', value: 'cherry' },
|
||||||
|
{ label: 'Date', value: 'date' },
|
||||||
|
{ label: 'Elderberry', value: 'elderberry' },
|
||||||
|
{ label: 'Fig', value: 'fig' },
|
||||||
|
{ label: 'Grape', value: 'grape' },
|
||||||
|
{ label: 'Honeydew', value: 'honeydew' },
|
||||||
|
{ label: 'Kiwi', value: 'kiwi' },
|
||||||
|
{ label: 'Lemon', value: 'lemon' },
|
||||||
|
{ label: 'Mango', value: 'mango' },
|
||||||
|
{ label: 'Nectarine', value: 'nectarine' },
|
||||||
|
{ label: 'Orange', value: 'orange' },
|
||||||
|
{ label: 'Papaya', value: 'papaya' },
|
||||||
|
{ label: 'Quince', value: 'quince' },
|
||||||
|
{ label: 'Raspberry', value: 'raspberry' },
|
||||||
|
{ label: 'Strawberry', value: 'strawberry' },
|
||||||
|
{ label: 'Tangerine', value: 'tangerine' },
|
||||||
|
{ label: 'Ugli Fruit', value: 'ugli' },
|
||||||
|
{ label: 'Watermelon', value: 'watermelon' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Local Data Example
|
||||||
|
export const LocalData: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [value, setValue] = useState<null | string>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<Boxer
|
||||||
|
clearable
|
||||||
|
data={sampleData}
|
||||||
|
dataSource="local"
|
||||||
|
label="Favorite Fruit"
|
||||||
|
onChange={setValue}
|
||||||
|
placeholder="Select a fruit"
|
||||||
|
searchable
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<strong>Selected Value:</strong> {value ?? 'None'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Multi-Select Example
|
||||||
|
export const MultiSelect: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [value, setValue] = useState<Array<string>>([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<Boxer
|
||||||
|
clearable
|
||||||
|
data={sampleData}
|
||||||
|
dataSource="local"
|
||||||
|
label="Favorite Fruits"
|
||||||
|
multiSelect
|
||||||
|
onChange={setValue}
|
||||||
|
placeholder="Select fruits"
|
||||||
|
searchable
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<strong>Selected Values:</strong>{' '}
|
||||||
|
{value.length > 0 ? value.join(', ') : 'None'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server-Side Example (Simulated)
|
||||||
|
export const ServerSide: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [value, setValue] = useState<null | string>(null);
|
||||||
|
|
||||||
|
// Simulate server-side API call
|
||||||
|
const handleAPICall = async (params: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
search?: string;
|
||||||
|
}): Promise<{ data: Array<BoxerItem>; total: number }> => {
|
||||||
|
// Simulate network delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// Filter based on search
|
||||||
|
let filteredData = [...sampleData];
|
||||||
|
if (params.search) {
|
||||||
|
filteredData = filteredData.filter((item) =>
|
||||||
|
item.label.toLowerCase().includes(params.search!.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paginate
|
||||||
|
const start = params.page * params.pageSize;
|
||||||
|
const end = start + params.pageSize;
|
||||||
|
const paginatedData = filteredData.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: paginatedData,
|
||||||
|
total: filteredData.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<Boxer
|
||||||
|
clearable
|
||||||
|
dataSource="server"
|
||||||
|
label="Favorite Fruit (Server-side)"
|
||||||
|
onAPICall={handleAPICall}
|
||||||
|
onChange={setValue}
|
||||||
|
pageSize={10}
|
||||||
|
placeholder="Select a fruit (Server-side)"
|
||||||
|
searchable
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<strong>Selected Value:</strong> {value ?? 'None'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select First Example
|
||||||
|
export const SelectFirst: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [value, setValue] = useState<null | string>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<Boxer
|
||||||
|
clearable
|
||||||
|
data={sampleData}
|
||||||
|
dataSource="local"
|
||||||
|
label="Auto-select First"
|
||||||
|
onChange={setValue}
|
||||||
|
placeholder="Select a fruit"
|
||||||
|
searchable
|
||||||
|
selectFirst
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<strong>Selected Value:</strong> {value ?? 'None'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// With Error
|
||||||
|
export const WithError: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [value, setValue] = useState<null | string>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<Boxer
|
||||||
|
clearable
|
||||||
|
data={sampleData}
|
||||||
|
dataSource="local"
|
||||||
|
error="Please select a fruit"
|
||||||
|
label="With Error"
|
||||||
|
onChange={setValue}
|
||||||
|
placeholder="Select a fruit"
|
||||||
|
searchable
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disabled
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<Boxer
|
||||||
|
data={sampleData}
|
||||||
|
dataSource="local"
|
||||||
|
disabled
|
||||||
|
label="Disabled"
|
||||||
|
onChange={() => {}}
|
||||||
|
placeholder="Select a fruit"
|
||||||
|
value="apple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
15
src/Boxer/todo.md
Normal file
15
src/Boxer/todo.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
The plan and requirements:
|
||||||
|
Auto complete lookup with server side lookup support and infinite loading.
|
||||||
|
It must also have local array lookup and autocomplete.
|
||||||
|
When a users starts typing, it must start autocomplete list.
|
||||||
|
Exiting selected item must always be the first on the list and populated from the input in case the options does not exist anymore, it must not beak existing data.
|
||||||
|
|
||||||
|
|
||||||
|
- [ ] Auto Complete
|
||||||
|
- [ ] Multi Select
|
||||||
|
- [ ] Virtualize
|
||||||
|
- [ ] Search
|
||||||
|
- [ ] Clear, Menu buttons
|
||||||
|
- [ ] Headerspec API
|
||||||
|
- [ ] Relspec API
|
||||||
|
- [ ] SocketSpec API
|
||||||
78
src/ErrorBoundary/BasicErrorBoundary.tsx
Normal file
78
src/ErrorBoundary/BasicErrorBoundary.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import React, { type PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
import errorManager from './ErrorManager';
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps extends PropsWithChildren {
|
||||||
|
namespace?: string;
|
||||||
|
onReportClick?: () => void;
|
||||||
|
onResetClick?: () => void;
|
||||||
|
onRetryClick?: () => void;
|
||||||
|
reportAPI?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
error: any;
|
||||||
|
errorInfo: any;
|
||||||
|
reported?: boolean;
|
||||||
|
resetted?: boolean;
|
||||||
|
showDetail: boolean;
|
||||||
|
timer?: NodeJS.Timeout | undefined;
|
||||||
|
try: boolean;
|
||||||
|
tryCnt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReactBasicErrorBoundary extends React.PureComponent<
|
||||||
|
ErrorBoundaryProps,
|
||||||
|
ErrorBoundaryState
|
||||||
|
> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
showDetail: false,
|
||||||
|
timer: undefined,
|
||||||
|
try: false,
|
||||||
|
tryCnt: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: any, errorInfo: any) {
|
||||||
|
// Catch errors in any components below and re-render with error message
|
||||||
|
this.setState({
|
||||||
|
error,
|
||||||
|
errorInfo,
|
||||||
|
try: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Report error to error manager (Sentry, custom API, etc.)
|
||||||
|
errorManager.reportError(error, errorInfo, {
|
||||||
|
componentStack: errorInfo?.componentStack,
|
||||||
|
namespace: this.props.namespace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.errorInfo) {
|
||||||
|
// Error path
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Error</h2>
|
||||||
|
{this.state.error && (
|
||||||
|
<>
|
||||||
|
<h3>In: {this.props.namespace ?? 'default'}</h3>
|
||||||
|
<main>{this.state.error.toString()}</main>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normally, just render children
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReactBasicErrorBoundary;
|
||||||
261
src/ErrorBoundary/ErrorBoundary.tsx
Normal file
261
src/ErrorBoundary/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { Button, Code, Collapse, Group, Paper, rem, Text } from '@mantine/core';
|
||||||
|
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||||
|
import React, { type PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
import errorManager from './ErrorManager';
|
||||||
|
|
||||||
|
let ErrorBoundaryOptions = {
|
||||||
|
disabled: false,
|
||||||
|
onError: undefined,
|
||||||
|
} as {
|
||||||
|
disabled?: boolean;
|
||||||
|
onError?: (error: any, errorInfo: any) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SetErrorBoundaryOptions = (options: typeof ErrorBoundaryOptions) => {
|
||||||
|
ErrorBoundaryOptions = { ...ErrorBoundaryOptions, ...options };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GetErrorBoundaryOptions = () => {
|
||||||
|
return { ...ErrorBoundaryOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps extends PropsWithChildren {
|
||||||
|
namespace?: string;
|
||||||
|
onReportClick?: () => void;
|
||||||
|
onResetClick?: () => void;
|
||||||
|
onRetryClick?: () => void;
|
||||||
|
reportAPI?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
error: any;
|
||||||
|
errorInfo: any;
|
||||||
|
reported?: boolean;
|
||||||
|
showDetail: boolean;
|
||||||
|
timer?: NodeJS.Timeout | undefined;
|
||||||
|
try: boolean;
|
||||||
|
tryCnt: number;
|
||||||
|
wasReset?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReactErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
showDetail: false,
|
||||||
|
timer: undefined,
|
||||||
|
try: false,
|
||||||
|
tryCnt: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: any, errorInfo: any) {
|
||||||
|
if (GetErrorBoundaryOptions()?.disabled) {
|
||||||
|
throw Error('ErrorBoundary pass through enabled, rethrowing error', {
|
||||||
|
cause: error,
|
||||||
|
//@ts-ignore
|
||||||
|
info: errorInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Catch errors in any components below and re-render with error message
|
||||||
|
this.setState({
|
||||||
|
error,
|
||||||
|
errorInfo,
|
||||||
|
try: false,
|
||||||
|
});
|
||||||
|
if (typeof GetErrorBoundaryOptions()?.onError === 'function') {
|
||||||
|
GetErrorBoundaryOptions()?.onError?.(error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report error to error manager (Sentry, custom API, etc.)
|
||||||
|
errorManager.reportError(error, errorInfo, {
|
||||||
|
componentStack: errorInfo?.componentStack,
|
||||||
|
namespace: this.props.namespace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.errorInfo) {
|
||||||
|
// Error path
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: 'var(--mantine-color-error)',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconExclamationCircle
|
||||||
|
color="var(--mantine-color-error)"
|
||||||
|
style={{ height: rem(20), width: rem(20) }}
|
||||||
|
/>
|
||||||
|
A react error has occurred!
|
||||||
|
</h2>
|
||||||
|
<h4>You can try one of these solutions to get back on track:</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Report the error to our team</li>
|
||||||
|
<li>Hit the keys Ctrl-Shift-R</li>
|
||||||
|
<li>Make sure your web browser is up to date</li>
|
||||||
|
<li>Clear your browser cache and cookies</li>
|
||||||
|
<li>Try using a different web browser</li>
|
||||||
|
<li>Reset your layout and settings</li>
|
||||||
|
<li>Retry loading the application</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
my="md"
|
||||||
|
onClick={() =>
|
||||||
|
this.setState((state) => ({
|
||||||
|
showDetail: !state.showDetail,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
More
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="blue"
|
||||||
|
disabled={this.state.tryCnt > 2 || this.state.try}
|
||||||
|
my="md"
|
||||||
|
onClick={() => this.retry()}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Retry {this.state.tryCnt > 0 ? `(${this.state.tryCnt})` : ''}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="yellow"
|
||||||
|
disabled={this.state.wasReset}
|
||||||
|
my="md"
|
||||||
|
onClick={() => this.reset()}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Reset Layout or Settings
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
disabled={this.state.reported}
|
||||||
|
my="md"
|
||||||
|
onClick={() => this.report()}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Report
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{this.state.error && (
|
||||||
|
<Paper
|
||||||
|
p="md"
|
||||||
|
shadow="xs"
|
||||||
|
style={{
|
||||||
|
textAlign: 'justify',
|
||||||
|
}}
|
||||||
|
w="50%"
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
{this.props.namespace && <Text size="sm">In: {this.props.namespace}</Text>}
|
||||||
|
<Text size="sm">{this.state.error.toString()}</Text>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Collapse in={this.state.showDetail}>
|
||||||
|
<Code block color="rgba(235, 86, 5, 0.6)" mah="100vh" w="100%">
|
||||||
|
{this.state.errorInfo.componentStack}
|
||||||
|
</Code>
|
||||||
|
</Collapse>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
//Retry code, if you do it too many times, it will stop
|
||||||
|
if (this.state.try) {
|
||||||
|
if (!this.state.timer) {
|
||||||
|
const tm = setTimeout(() => {
|
||||||
|
clearTimeout(this.state.timer);
|
||||||
|
this.setState((state) => ({
|
||||||
|
...state,
|
||||||
|
timer: undefined,
|
||||||
|
try: false,
|
||||||
|
}));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
this.setState((state) => ({
|
||||||
|
...state,
|
||||||
|
timer: tm,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return <div>Retrying {this.state.tryCnt}...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normally, just render children
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
async report() {
|
||||||
|
if (this.props?.onReportClick) {
|
||||||
|
this.props.onReportClick();
|
||||||
|
this.setState(() => ({
|
||||||
|
reported: true,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually report error if user clicks report button
|
||||||
|
if (this.state.error && this.state.errorInfo) {
|
||||||
|
await errorManager.reportError(this.state.error, this.state.errorInfo, {
|
||||||
|
componentStack: this.state.errorInfo?.componentStack,
|
||||||
|
namespace: this.props.namespace,
|
||||||
|
tags: { reportedBy: 'user' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setState(() => ({
|
||||||
|
reported: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
if (this.props?.onResetClick) {
|
||||||
|
this.props.onResetClick();
|
||||||
|
this.setState(() => ({
|
||||||
|
wasReset: true,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState(() => ({
|
||||||
|
wasReset: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
retry() {
|
||||||
|
this.setState({
|
||||||
|
try: true,
|
||||||
|
tryCnt: this.state.tryCnt + 1,
|
||||||
|
});
|
||||||
|
if (this.props?.onRetryClick) {
|
||||||
|
this.props.onRetryClick();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({
|
||||||
|
error: undefined,
|
||||||
|
errorInfo: undefined,
|
||||||
|
});
|
||||||
|
this.forceUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReactErrorBoundary;
|
||||||
166
src/ErrorBoundary/ErrorManager.README.md
Normal file
166
src/ErrorBoundary/ErrorManager.README.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# ErrorManager
|
||||||
|
|
||||||
|
Centralized error reporting for ErrorBoundary components.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Sentry Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { errorManager } from './ErrorBoundary';
|
||||||
|
|
||||||
|
errorManager.configure({
|
||||||
|
enabled: true,
|
||||||
|
sentry: {
|
||||||
|
dsn: 'https://your-sentry-dsn@sentry.io/project-id',
|
||||||
|
environment: 'production',
|
||||||
|
release: '1.0.0',
|
||||||
|
sampleRate: 1.0,
|
||||||
|
ignoreErrors: ['ResizeObserver loop limit exceeded'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom API Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
errorManager.configure({
|
||||||
|
enabled: true,
|
||||||
|
customAPI: {
|
||||||
|
endpoint: 'https://api.yourapp.com/errors',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer token',
|
||||||
|
},
|
||||||
|
transformPayload: (report) => ({
|
||||||
|
message: report.error.message,
|
||||||
|
stack: report.error.stack,
|
||||||
|
level: report.severity,
|
||||||
|
timestamp: report.timestamp,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Reporter
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
errorManager.configure({
|
||||||
|
enabled: true,
|
||||||
|
reporters: [
|
||||||
|
{
|
||||||
|
name: 'CustomLogger',
|
||||||
|
isEnabled: () => true,
|
||||||
|
captureError: async (report) => {
|
||||||
|
console.error('Error:', report.error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Reporters
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
errorManager.configure({
|
||||||
|
enabled: true,
|
||||||
|
sentry: { dsn: 'your-dsn' },
|
||||||
|
customAPI: { endpoint: 'your-endpoint' },
|
||||||
|
reporters: [customReporter],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
### beforeReport
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
errorManager.configure({
|
||||||
|
beforeReport: (report) => {
|
||||||
|
// Filter errors
|
||||||
|
if (report.error.message.includes('ResizeObserver')) {
|
||||||
|
return null; // Skip reporting
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich with user data
|
||||||
|
report.context = {
|
||||||
|
...report.context,
|
||||||
|
user: { id: getCurrentUserId() },
|
||||||
|
tags: { feature: 'checkout' },
|
||||||
|
};
|
||||||
|
|
||||||
|
return report;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### onReportSuccess / onReportFailure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
errorManager.configure({
|
||||||
|
onReportSuccess: (report) => {
|
||||||
|
console.log('Error reported successfully');
|
||||||
|
},
|
||||||
|
onReportFailure: (error, report) => {
|
||||||
|
console.error('Failed to report error:', error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Reporting
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
riskyOperation();
|
||||||
|
} catch (error) {
|
||||||
|
await errorManager.reportError(error as Error, null, {
|
||||||
|
namespace: 'checkout',
|
||||||
|
tags: { step: 'payment' },
|
||||||
|
extra: { orderId: '123' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Disable/Enable
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Disable reporting
|
||||||
|
errorManager.configure({ enabled: false });
|
||||||
|
|
||||||
|
// Enable reporting
|
||||||
|
errorManager.configure({ enabled: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
## ErrorBoundary Integration
|
||||||
|
|
||||||
|
Automatic - errors caught by `ReactErrorBoundary` or `ReactBasicErrorBoundary` are automatically reported.
|
||||||
|
|
||||||
|
Manual report button in `ReactErrorBoundary` UI also sends to ErrorManager.
|
||||||
|
|
||||||
|
## Install Sentry (optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @sentry/react
|
||||||
|
```
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ErrorSeverity = 'fatal' | 'error' | 'warning' | 'info' | 'debug';
|
||||||
|
|
||||||
|
interface ErrorContext {
|
||||||
|
namespace?: string;
|
||||||
|
componentStack?: string;
|
||||||
|
user?: Record<string, any>;
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
extra?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorReport {
|
||||||
|
error: Error;
|
||||||
|
errorInfo?: any;
|
||||||
|
severity?: ErrorSeverity;
|
||||||
|
context?: ErrorContext;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
194
src/ErrorBoundary/ErrorManager.ts
Normal file
194
src/ErrorBoundary/ErrorManager.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import type {
|
||||||
|
CustomAPIConfig,
|
||||||
|
ErrorManagerConfig,
|
||||||
|
ErrorReport,
|
||||||
|
ErrorReporter,
|
||||||
|
SentryConfig,
|
||||||
|
} from './ErrorManager.types';
|
||||||
|
|
||||||
|
class ErrorManager {
|
||||||
|
private config: ErrorManagerConfig = { enabled: true };
|
||||||
|
private reporters: ErrorReporter[] = [];
|
||||||
|
private sentryInstance: any = null;
|
||||||
|
|
||||||
|
configure(config: ErrorManagerConfig) {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
this.reporters = [];
|
||||||
|
|
||||||
|
if (config.sentry) {
|
||||||
|
this.setupSentry(config.sentry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.customAPI) {
|
||||||
|
this.setupCustomAPI(config.customAPI);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.reporters) {
|
||||||
|
this.reporters.push(...config.reporters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.reporters = [];
|
||||||
|
this.sentryInstance = null;
|
||||||
|
this.config = { enabled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
getReporters(): ErrorReporter[] {
|
||||||
|
return [...this.reporters];
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return Boolean(this.config.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reportError(
|
||||||
|
error: Error,
|
||||||
|
errorInfo?: any,
|
||||||
|
context?: ErrorReport['context']
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.config.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let report: ErrorReport = {
|
||||||
|
context,
|
||||||
|
error,
|
||||||
|
errorInfo,
|
||||||
|
severity: 'error',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.config.beforeReport) {
|
||||||
|
const modifiedReport = this.config.beforeReport(report);
|
||||||
|
if (!modifiedReport) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
report = modifiedReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportPromises = this.reporters
|
||||||
|
.filter((reporter) => reporter.isEnabled())
|
||||||
|
.map(async (reporter) => {
|
||||||
|
try {
|
||||||
|
await reporter.captureError(report);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reporter "${reporter.name}" failed:`, error);
|
||||||
|
if (this.config.onReportFailure) {
|
||||||
|
this.config.onReportFailure(error as Error, report);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(reportPromises);
|
||||||
|
if (this.config.onReportSuccess) {
|
||||||
|
this.config.onReportSuccess(report);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reporting failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupCustomAPI(config: CustomAPIConfig) {
|
||||||
|
const customAPIReporter: ErrorReporter = {
|
||||||
|
captureError: async (report: ErrorReport) => {
|
||||||
|
try {
|
||||||
|
const payload = config.transformPayload
|
||||||
|
? config.transformPayload(report)
|
||||||
|
: {
|
||||||
|
context: report.context,
|
||||||
|
message: report.error.message,
|
||||||
|
severity: report.severity || 'error',
|
||||||
|
stack: report.error.stack,
|
||||||
|
timestamp: report.timestamp || Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(config.endpoint, {
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...config.headers,
|
||||||
|
},
|
||||||
|
method: config.method || 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API request failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send error to custom API:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isEnabled: () => Boolean(config.endpoint),
|
||||||
|
name: 'CustomAPI',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.reporters.push(customAPIReporter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupSentry(config: SentryConfig) {
|
||||||
|
const sentryReporter: ErrorReporter = {
|
||||||
|
captureError: async (report: ErrorReport) => {
|
||||||
|
if (!this.sentryInstance) {
|
||||||
|
try {
|
||||||
|
const Sentry = await import('@sentry/react');
|
||||||
|
Sentry.init({
|
||||||
|
beforeSend: config.beforeSend,
|
||||||
|
dsn: config.dsn,
|
||||||
|
environment: config.environment,
|
||||||
|
ignoreErrors: config.ignoreErrors,
|
||||||
|
release: config.release,
|
||||||
|
tracesSampleRate: config.sampleRate || 1.0,
|
||||||
|
});
|
||||||
|
this.sentryInstance = Sentry;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize Sentry:', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.sentryInstance.withScope((scope: any) => {
|
||||||
|
if (report.severity) {
|
||||||
|
scope.setLevel(report.severity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.context?.namespace) {
|
||||||
|
scope.setTag('namespace', report.context.namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.context?.tags) {
|
||||||
|
Object.entries(report.context.tags).forEach(([key, value]) => {
|
||||||
|
scope.setTag(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.context?.user) {
|
||||||
|
scope.setUser(report.context.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.context?.extra) {
|
||||||
|
scope.setExtras(report.context.extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.context?.componentStack) {
|
||||||
|
scope.setContext('react', {
|
||||||
|
componentStack: report.context.componentStack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sentryInstance.captureException(report.error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isEnabled: () => Boolean(this.sentryInstance),
|
||||||
|
name: 'Sentry',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.reporters.push(sentryReporter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const errorManager = new ErrorManager();
|
||||||
|
|
||||||
|
export default errorManager;
|
||||||
50
src/ErrorBoundary/ErrorManager.types.ts
Normal file
50
src/ErrorBoundary/ErrorManager.types.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
export interface CustomAPIConfig {
|
||||||
|
endpoint: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
method?: 'POST' | 'PUT';
|
||||||
|
transformPayload?: (report: ErrorReport) => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorContext {
|
||||||
|
componentStack?: string;
|
||||||
|
extra?: Record<string, any>;
|
||||||
|
namespace?: string;
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
user?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorManagerConfig {
|
||||||
|
beforeReport?: (report: ErrorReport) => ErrorReport | null;
|
||||||
|
customAPI?: CustomAPIConfig;
|
||||||
|
enabled?: boolean;
|
||||||
|
onReportFailure?: (error: Error, report: ErrorReport) => void;
|
||||||
|
onReportSuccess?: (report: ErrorReport) => void;
|
||||||
|
reporters?: ErrorReporter[];
|
||||||
|
sentry?: SentryConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorReport {
|
||||||
|
context?: ErrorContext;
|
||||||
|
error: Error;
|
||||||
|
errorInfo?: any;
|
||||||
|
severity?: ErrorSeverity;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorReporter {
|
||||||
|
captureError: (report: ErrorReport) => Promise<void> | void;
|
||||||
|
isEnabled: () => boolean;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ErrorSeverity = 'debug' | 'error' | 'fatal' | 'info' | 'warning';
|
||||||
|
|
||||||
|
export interface SentryConfig {
|
||||||
|
beforeSend?: (event: any) => any | null;
|
||||||
|
dsn: string;
|
||||||
|
environment?: string;
|
||||||
|
ignoreErrors?: string[];
|
||||||
|
release?: string;
|
||||||
|
sampleRate?: number;
|
||||||
|
}
|
||||||
4
src/ErrorBoundary/index.ts
Normal file
4
src/ErrorBoundary/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as ReactBasicErrorBoundary } from './BasicErrorBoundary';
|
||||||
|
export { default as ReactErrorBoundary } from './ErrorBoundary';
|
||||||
|
export { default as errorManager } from './ErrorManager';
|
||||||
|
export * from './ErrorManager.types';
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import React, { type ReactNode } from 'react'
|
|
||||||
import { Card, Stack, LoadingOverlay } from '@mantine/core'
|
|
||||||
import { FormSection } from './FormSection'
|
|
||||||
|
|
||||||
interface FormProps {
|
|
||||||
children: ReactNode
|
|
||||||
loading?: boolean
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Form: React.FC<FormProps> & {
|
|
||||||
Section: typeof FormSection
|
|
||||||
} = ({ children, loading, ...others }) => {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
withBorder
|
|
||||||
component={Stack}
|
|
||||||
w="100%"
|
|
||||||
h="100%"
|
|
||||||
padding={0}
|
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
shadow="sm"
|
|
||||||
radius="md"
|
|
||||||
{...others}
|
|
||||||
>
|
|
||||||
<LoadingOverlay visible={loading || false} />
|
|
||||||
{children}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Form.Section = FormSection
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import React, { type ReactNode } from 'react'
|
|
||||||
import { Modal } from '@mantine/core'
|
|
||||||
import { Form } from './Form'
|
|
||||||
import { FormLayoutStoreProvider, useFormLayoutStore } from '../store/FormLayout.store'
|
|
||||||
import type { RequestType } from '../types'
|
|
||||||
|
|
||||||
interface FormLayoutProps {
|
|
||||||
children: ReactNode
|
|
||||||
dirty?: boolean
|
|
||||||
loading?: boolean
|
|
||||||
onCancel?: () => void
|
|
||||||
onSubmit?: () => void
|
|
||||||
request?: RequestType
|
|
||||||
modal?: boolean
|
|
||||||
modalProps?: any
|
|
||||||
nested?: boolean
|
|
||||||
deleteFormProps?: any
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
const LayoutComponent: React.FC<FormLayoutProps> = ({
|
|
||||||
children,
|
|
||||||
modal,
|
|
||||||
modalProps,
|
|
||||||
...others
|
|
||||||
}) => {
|
|
||||||
const { request } = useFormLayoutStore((state) => ({
|
|
||||||
request: state.request,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const modalWidth = request === 'delete' ? 400 : modalProps?.width
|
|
||||||
|
|
||||||
return modal === true ? (
|
|
||||||
<Modal
|
|
||||||
onClose={() => modalProps?.onClose?.()}
|
|
||||||
opened={modalProps?.opened || false}
|
|
||||||
size="auto"
|
|
||||||
withCloseButton={false}
|
|
||||||
centered={request !== 'delete'}
|
|
||||||
{...modalProps}
|
|
||||||
>
|
|
||||||
<div style={{ height: modalProps?.height, width: modalWidth }}>
|
|
||||||
<Form {...others}>{children}</Form>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
) : (
|
|
||||||
<Form {...others}>{children}</Form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormLayout: React.FC<FormLayoutProps> = (props) => (
|
|
||||||
<FormLayoutStoreProvider {...props}>
|
|
||||||
<LayoutComponent {...props} />
|
|
||||||
</FormLayoutStoreProvider>
|
|
||||||
)
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import React, { type ReactNode } from 'react'
|
|
||||||
import { Stack, Group, Paper, Button, Title, Box } from '@mantine/core'
|
|
||||||
import { useFormLayoutStore } from '../store/FormLayout.store'
|
|
||||||
|
|
||||||
interface FormSectionProps {
|
|
||||||
type: 'header' | 'body' | 'footer' | 'error'
|
|
||||||
title?: string
|
|
||||||
rightSection?: ReactNode
|
|
||||||
children?: ReactNode
|
|
||||||
buttonTitles?: { submit?: string; cancel?: string }
|
|
||||||
className?: string
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FormSection: React.FC<FormSectionProps> = ({
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
rightSection,
|
|
||||||
children,
|
|
||||||
buttonTitles,
|
|
||||||
className,
|
|
||||||
...others
|
|
||||||
}) => {
|
|
||||||
const { onCancel, onSubmit, request, loading } = useFormLayoutStore((state) => ({
|
|
||||||
onCancel: state.onCancel,
|
|
||||||
onSubmit: state.onSubmit,
|
|
||||||
request: state.request,
|
|
||||||
loading: state.loading,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (type === 'header') {
|
|
||||||
return (
|
|
||||||
<Group
|
|
||||||
justify="space-between"
|
|
||||||
p="md"
|
|
||||||
style={{
|
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-3)',
|
|
||||||
}}
|
|
||||||
className={className}
|
|
||||||
{...others}
|
|
||||||
>
|
|
||||||
<Title order={4} size="h5">
|
|
||||||
{title}
|
|
||||||
</Title>
|
|
||||||
{rightSection && <Box>{rightSection}</Box>}
|
|
||||||
</Group>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'body') {
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
gap="md"
|
|
||||||
p="md"
|
|
||||||
style={{ flex: 1, overflow: 'auto' }}
|
|
||||||
className={className}
|
|
||||||
{...others}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'footer') {
|
|
||||||
return (
|
|
||||||
<Group
|
|
||||||
justify="flex-end"
|
|
||||||
gap="xs"
|
|
||||||
p="md"
|
|
||||||
style={{
|
|
||||||
borderTop: '1px solid var(--mantine-color-gray-3)',
|
|
||||||
}}
|
|
||||||
className={className}
|
|
||||||
{...others}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{request !== 'view' && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{buttonTitles?.cancel ?? 'Cancel'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
onClick={onSubmit}
|
|
||||||
loading={loading}
|
|
||||||
>
|
|
||||||
{buttonTitles?.submit ?? (request === 'delete' ? 'Delete' : 'Save')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'error') {
|
|
||||||
return (
|
|
||||||
<Paper
|
|
||||||
p="sm"
|
|
||||||
m="md"
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--mantine-color-red-0)',
|
|
||||||
border: '1px solid var(--mantine-color-red-3)',
|
|
||||||
}}
|
|
||||||
className={className}
|
|
||||||
{...others}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Paper>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import React, { forwardRef, type ReactElement, type Ref } from 'react'
|
|
||||||
import { FormProvider, useForm, type FieldValues } from 'react-hook-form'
|
|
||||||
import { Provider } from '../store/SuperForm.store'
|
|
||||||
import type { SuperFormProps, SuperFormRef } from '../types'
|
|
||||||
import Layout from './SuperFormLayout'
|
|
||||||
import SuperFormPersist from './SuperFormPersist'
|
|
||||||
|
|
||||||
const SuperForm = <T extends FieldValues>(
|
|
||||||
{ useFormProps, gridRef, children, persist, ...others }: SuperFormProps<T>,
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const form = useForm<T>({ ...useFormProps })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Provider {...others}>
|
|
||||||
<FormProvider {...form}>
|
|
||||||
{persist && (
|
|
||||||
<SuperFormPersist storageKey={typeof persist === 'object' ? persist.storageKey : null} />
|
|
||||||
)}
|
|
||||||
<Layout<T> gridRef={gridRef} ref={ref}>
|
|
||||||
{children}
|
|
||||||
</Layout>
|
|
||||||
</FormProvider>
|
|
||||||
</Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FRSuperForm = forwardRef(SuperForm) as <T extends FieldValues>(
|
|
||||||
props: SuperFormProps<T> & {
|
|
||||||
ref?: Ref<SuperFormRef<T>>
|
|
||||||
}
|
|
||||||
) => ReactElement
|
|
||||||
|
|
||||||
export default FRSuperForm
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
import React, {
|
|
||||||
forwardRef,
|
|
||||||
RefObject,
|
|
||||||
useEffect,
|
|
||||||
useImperativeHandle,
|
|
||||||
useMemo,
|
|
||||||
type MutableRefObject,
|
|
||||||
type ReactElement,
|
|
||||||
type ReactNode,
|
|
||||||
type Ref,
|
|
||||||
} from 'react'
|
|
||||||
import { useFormContext, useFormState, type FieldValues, type UseFormReturn } from 'react-hook-form'
|
|
||||||
import { v4 as uuid } from 'uuid'
|
|
||||||
import {
|
|
||||||
ActionIcon,
|
|
||||||
Group,
|
|
||||||
List,
|
|
||||||
LoadingOverlay,
|
|
||||||
Paper,
|
|
||||||
Spoiler,
|
|
||||||
Stack,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Transition,
|
|
||||||
} from '@mantine/core'
|
|
||||||
import { IconChevronsLeft, IconChevronsRight } from '@tabler/icons-react'
|
|
||||||
import { useUncontrolled } from '@mantine/hooks'
|
|
||||||
import useRemote from '../hooks/useRemote'
|
|
||||||
import { useStore } from '../store/SuperForm.store'
|
|
||||||
import classes from '../styles/Form.module.css'
|
|
||||||
import { Form } from './Form'
|
|
||||||
import { FormLayout } from './FormLayout'
|
|
||||||
import type { GridRef, SuperFormRef } from '../types'
|
|
||||||
|
|
||||||
const SuperFormLayout = <T extends FieldValues>(
|
|
||||||
{
|
|
||||||
children,
|
|
||||||
gridRef,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode | ((props: UseFormReturn<T, any, undefined>) => React.ReactNode)
|
|
||||||
gridRef?: MutableRefObject<GridRef<any> | null>
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
// Component store State
|
|
||||||
const {
|
|
||||||
layoutProps,
|
|
||||||
meta,
|
|
||||||
nested,
|
|
||||||
onBeforeSubmit,
|
|
||||||
onCancel,
|
|
||||||
onLayoutMounted,
|
|
||||||
onLayoutUnMounted,
|
|
||||||
onResetForm,
|
|
||||||
onSubmit,
|
|
||||||
primeData,
|
|
||||||
request,
|
|
||||||
tableName,
|
|
||||||
value,
|
|
||||||
} = useStore((state) => ({
|
|
||||||
extraButtons: state.extraButtons,
|
|
||||||
layoutProps: state.layoutProps,
|
|
||||||
meta: state.meta,
|
|
||||||
nested: state.nested,
|
|
||||||
onBeforeSubmit: state.onBeforeSubmit,
|
|
||||||
onCancel: state.onCancel,
|
|
||||||
onLayoutMounted: state.onLayoutMounted,
|
|
||||||
onLayoutUnMounted: state.onLayoutUnMounted,
|
|
||||||
onResetForm: state.onResetForm,
|
|
||||||
onSubmit: state.onSubmit,
|
|
||||||
primeData: state.primeData,
|
|
||||||
request: state.request,
|
|
||||||
tableName: state.remote?.tableName,
|
|
||||||
value: state.value,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const [_opened, _setOpened] = useUncontrolled({
|
|
||||||
value: layoutProps?.bodyRightSection?.opened,
|
|
||||||
defaultValue: false,
|
|
||||||
onChange: layoutProps?.bodyRightSection?.setOpened,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Component Hooks
|
|
||||||
const form = useFormContext<T, any, undefined>()
|
|
||||||
const formState = useFormState({ control: form.control })
|
|
||||||
|
|
||||||
const { isFetching, mutateAsync, error, queryKey } = useRemote(gridRef)
|
|
||||||
|
|
||||||
// Component variables
|
|
||||||
const formUID = useMemo(() => {
|
|
||||||
return meta?.id ?? uuid()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const requestString = request?.charAt(0).toUpperCase() + request?.slice(1)
|
|
||||||
|
|
||||||
const renderRightSection = (
|
|
||||||
<>
|
|
||||||
<Tooltip label={`${_opened ? 'Close' : 'Open'} Right Section`} withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 12,
|
|
||||||
zIndex: 5,
|
|
||||||
|
|
||||||
display: layoutProps?.bodyRightSection?.hideToggleButton ? 'none' : 'block',
|
|
||||||
}}
|
|
||||||
variant='filled'
|
|
||||||
size='sm'
|
|
||||||
onClick={() => _setOpened(!_opened)}
|
|
||||||
radius='6'
|
|
||||||
m={2}
|
|
||||||
>
|
|
||||||
{_opened ? <IconChevronsRight /> : <IconChevronsLeft />}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Group wrap='nowrap' h='100%' align='flex-start' gap={2} w={'100%'}>
|
|
||||||
<Stack gap={0} h='100%' style={{ flex: 1 }}>
|
|
||||||
{typeof children === 'function' ? children({ ...form }) : children}
|
|
||||||
</Stack>
|
|
||||||
<Transition transition='slide-left' mounted={_opened}>
|
|
||||||
{(transitionStyles) => (
|
|
||||||
<Paper
|
|
||||||
style={transitionStyles}
|
|
||||||
h='100%'
|
|
||||||
w={layoutProps?.bodyRightSection?.w}
|
|
||||||
shadow='xs'
|
|
||||||
radius='xs'
|
|
||||||
mr='xs'
|
|
||||||
mt='xs'
|
|
||||||
ml={0}
|
|
||||||
{...layoutProps?.bodyRightSection?.paperProps}
|
|
||||||
>
|
|
||||||
{layoutProps?.bodyRightSection?.render?.({
|
|
||||||
form,
|
|
||||||
formValue: form.getValues(),
|
|
||||||
isFetching,
|
|
||||||
opened: _opened,
|
|
||||||
queryKey,
|
|
||||||
setOpened: _setOpened,
|
|
||||||
})}
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</Transition>
|
|
||||||
</Group>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Component Callback Functions
|
|
||||||
const onFormSubmit = async (data: T | any, closeForm: boolean = true) => {
|
|
||||||
const res: any =
|
|
||||||
typeof onBeforeSubmit === 'function'
|
|
||||||
? await mutateAsync?.(await onBeforeSubmit(data, request, form))
|
|
||||||
: await mutateAsync?.(data)
|
|
||||||
|
|
||||||
if ((tableName?.length ?? 0) > 0) {
|
|
||||||
if (res?.ok || (res?.status >= 200 && res?.status < 300)) {
|
|
||||||
onSubmit?.(res?.data, request, data, form, closeForm)
|
|
||||||
} else {
|
|
||||||
form.setError('root', {
|
|
||||||
message: res.status === 401 ? 'Username or password is incorrect' : res?.error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onSubmit?.(data, request, data, form, closeForm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component use Effects
|
|
||||||
useEffect(() => {
|
|
||||||
if (request === 'insert') {
|
|
||||||
if (onResetForm) {
|
|
||||||
onResetForm(primeData, form).then((resetData) => {
|
|
||||||
form.reset(resetData)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
form.reset(primeData)
|
|
||||||
}
|
|
||||||
} else if ((request === 'change' || request === 'delete') && (tableName?.length ?? 0) === 0) {
|
|
||||||
if (onResetForm) {
|
|
||||||
onResetForm(value, form).then((resetData) => {
|
|
||||||
form.reset(resetData)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
form.reset(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onLayoutMounted?.()
|
|
||||||
return onLayoutUnMounted
|
|
||||||
}, [
|
|
||||||
request,
|
|
||||||
primeData,
|
|
||||||
tableName,
|
|
||||||
value,
|
|
||||||
form.reset,
|
|
||||||
onResetForm,
|
|
||||||
onLayoutMounted,
|
|
||||||
onLayoutUnMounted,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
(Object.keys(formState.errors)?.length > 0 || error) &&
|
|
||||||
_opened === false &&
|
|
||||||
layoutProps?.showErrorList !== false
|
|
||||||
) {
|
|
||||||
_setOpened(true)
|
|
||||||
}
|
|
||||||
}, [Object.keys(formState.errors)?.length > 0, error, layoutProps?.showErrorList])
|
|
||||||
|
|
||||||
useImperativeHandle<SuperFormRef<T>, SuperFormRef<T>>(ref, () => ({
|
|
||||||
form,
|
|
||||||
mutation: { isFetching, mutateAsync, error },
|
|
||||||
submit: (closeForm: boolean = true, afterSubmit?: (data: T | any) => void) => {
|
|
||||||
return form.handleSubmit(async (data: T | any) => {
|
|
||||||
await onFormSubmit(data, closeForm)
|
|
||||||
afterSubmit?.(data)
|
|
||||||
})()
|
|
||||||
},
|
|
||||||
queryKey,
|
|
||||||
getFormState: () => formState,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
name={formUID}
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
e.preventDefault()
|
|
||||||
form.handleSubmit((data: T | any) => {
|
|
||||||
onFormSubmit(data)
|
|
||||||
})(e)
|
|
||||||
}}
|
|
||||||
style={{ height: '100%' }}
|
|
||||||
className={request === 'view' ? classes.disabled : ''}
|
|
||||||
>
|
|
||||||
{/* <LoadingOverlay
|
|
||||||
visible={isFetching}
|
|
||||||
overlayProps={{
|
|
||||||
backgroundOpacity: 0.5,
|
|
||||||
}}
|
|
||||||
/> */}
|
|
||||||
{layoutProps?.noLayout ? (
|
|
||||||
typeof layoutProps?.bodyRightSection?.render === 'function' ? (
|
|
||||||
renderRightSection
|
|
||||||
) : typeof children === 'function' ? (
|
|
||||||
<>
|
|
||||||
<LoadingOverlay
|
|
||||||
visible={isFetching}
|
|
||||||
overlayProps={{
|
|
||||||
backgroundOpacity: 0.5,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{children({ ...form })}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<LoadingOverlay
|
|
||||||
visible={isFetching}
|
|
||||||
overlayProps={{
|
|
||||||
backgroundOpacity: 0.5,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<FormLayout
|
|
||||||
dirty={formState.isDirty}
|
|
||||||
loading={isFetching}
|
|
||||||
onCancel={() => onCancel?.(request)}
|
|
||||||
onSubmit={form.handleSubmit((data: T | any) => {
|
|
||||||
onFormSubmit(data)
|
|
||||||
})}
|
|
||||||
request={request}
|
|
||||||
modal={false}
|
|
||||||
nested={nested}
|
|
||||||
>
|
|
||||||
{!layoutProps?.noHeader && (
|
|
||||||
<Form.Section
|
|
||||||
type='header'
|
|
||||||
title={`${layoutProps?.title || requestString}`}
|
|
||||||
rightSection={layoutProps?.rightSection}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{(Object.keys(formState.errors)?.length > 0 || error) && (
|
|
||||||
<Form.Section
|
|
||||||
className={classes.sticky}
|
|
||||||
buttonTitles={layoutProps?.buttonTitles}
|
|
||||||
type='error'
|
|
||||||
>
|
|
||||||
<Title order={6} size='sm' c='red'>
|
|
||||||
{(error?.message?.length ?? 0) > 0
|
|
||||||
? 'Server Error'
|
|
||||||
: 'Required information is incomplete*'}
|
|
||||||
</Title>
|
|
||||||
{(error as any)?.response?.data?.msg ||
|
|
||||||
(error as any)?.response?.data?._error ||
|
|
||||||
error?.message}
|
|
||||||
{layoutProps?.showErrorList !== false && (
|
|
||||||
<Spoiler maxHeight={50} showLabel='Show more' hideLabel='Hide'>
|
|
||||||
<List
|
|
||||||
size='xs'
|
|
||||||
style={{
|
|
||||||
color: 'light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-2))',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getErrorMessages(formState.errors)}
|
|
||||||
</List>
|
|
||||||
</Spoiler>
|
|
||||||
)}
|
|
||||||
</Form.Section>
|
|
||||||
)}
|
|
||||||
{typeof layoutProps?.bodyRightSection?.render === 'function' ? (
|
|
||||||
<Form.Section type='body' {...layoutProps?.bodySectionProps}>
|
|
||||||
{renderRightSection}
|
|
||||||
</Form.Section>
|
|
||||||
) : (
|
|
||||||
<Form.Section type='body' {...layoutProps?.bodySectionProps}>
|
|
||||||
{typeof children === 'function' ? children({ ...form }) : children}
|
|
||||||
</Form.Section>
|
|
||||||
)}
|
|
||||||
{!layoutProps?.noFooter && (
|
|
||||||
<Form.Section
|
|
||||||
className={classes.sticky}
|
|
||||||
buttonTitles={layoutProps?.buttonTitles}
|
|
||||||
type='footer'
|
|
||||||
{...(typeof layoutProps?.footerSectionProps === 'function'
|
|
||||||
? layoutProps?.footerSectionProps(ref as RefObject<SuperFormRef<T>>)
|
|
||||||
: layoutProps?.footerSectionProps)}
|
|
||||||
>
|
|
||||||
{typeof layoutProps?.extraButtons === 'function'
|
|
||||||
? layoutProps?.extraButtons(form)
|
|
||||||
: layoutProps?.extraButtons}
|
|
||||||
</Form.Section>
|
|
||||||
)}
|
|
||||||
</FormLayout>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getErrorMessages = (errors: any): ReactNode | null => {
|
|
||||||
return Object.keys(errors ?? {}).map((key) => {
|
|
||||||
if (typeof errors[key] === 'object' && key !== 'ref') {
|
|
||||||
return getErrorMessages(errors[key])
|
|
||||||
}
|
|
||||||
if (key !== 'message') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return <List.Item key={key}>{errors[key]}</List.Item>
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const FRSuperFormLayout = forwardRef(SuperFormLayout) as <T extends FieldValues>(
|
|
||||||
props: {
|
|
||||||
children: React.ReactNode | ((props: UseFormReturn<T, any, undefined>) => React.ReactNode)
|
|
||||||
gridRef?: MutableRefObject<GridRef | null>
|
|
||||||
} & {
|
|
||||||
ref?: Ref<SuperFormRef<T>>
|
|
||||||
}
|
|
||||||
) => ReactElement
|
|
||||||
|
|
||||||
export default FRSuperFormLayout
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useFormContext, useFormState } from 'react-hook-form'
|
|
||||||
import { useDebouncedCallback } from '@mantine/hooks'
|
|
||||||
import useSubscribe from '../hooks/use-subscribe'
|
|
||||||
import { useSuperFormStore } from '../store/SuperForm.store'
|
|
||||||
import { openConfirmModal } from '../utils/openConfirmModal'
|
|
||||||
|
|
||||||
const SuperFormPersist = ({ storageKey }: { storageKey?: string | null }) => {
|
|
||||||
// Component store State
|
|
||||||
const [persistKey, setPersistKey] = useState<string>('')
|
|
||||||
const { isDirty, isReady, isSubmitted } = useFormState()
|
|
||||||
|
|
||||||
const { remote, request } = useSuperFormStore((state) => ({
|
|
||||||
request: state.request,
|
|
||||||
remote: state.remote,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Component Hooks
|
|
||||||
const { reset, setValue } = useFormContext()
|
|
||||||
|
|
||||||
const handleFormChange = useDebouncedCallback(({ values }) => {
|
|
||||||
setPersistKey(() => {
|
|
||||||
const key = `superform-persist-${storageKey?.length > 0 ? storageKey : `${remote?.tableName || 'local'}-${request}-${values[remote?.primaryKey] ?? ''}`}`
|
|
||||||
|
|
||||||
if (!isDirty) {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
window.localStorage.setItem(key, JSON.stringify(values))
|
|
||||||
|
|
||||||
return key
|
|
||||||
})
|
|
||||||
}, 250)
|
|
||||||
|
|
||||||
useSubscribe('', handleFormChange)
|
|
||||||
|
|
||||||
// Component use Effects
|
|
||||||
useEffect(() => {
|
|
||||||
if (isReady && persistKey) {
|
|
||||||
const data = window.localStorage.getItem(persistKey)
|
|
||||||
if (!data) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSubmitted) {
|
|
||||||
window.localStorage.removeItem(persistKey)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
openConfirmModal(
|
|
||||||
() => {
|
|
||||||
reset(JSON.parse(data))
|
|
||||||
setValue('_dirty', true, { shouldDirty: true })
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
window.localStorage.removeItem(persistKey)
|
|
||||||
},
|
|
||||||
'Do you want to restore the previous data that was not submitted?'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [isReady, isSubmitted, persistKey])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SuperFormPersist
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import React, { createContext, useContext, ReactNode, useState } from 'react'
|
|
||||||
|
|
||||||
interface ApiConfigContextValue {
|
|
||||||
apiURL: string
|
|
||||||
setApiURL: (url: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ApiConfigContext = createContext<ApiConfigContextValue | null>(null)
|
|
||||||
|
|
||||||
interface ApiConfigProviderProps {
|
|
||||||
children: ReactNode
|
|
||||||
defaultApiURL?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ApiConfigProvider: React.FC<ApiConfigProviderProps> = ({
|
|
||||||
children,
|
|
||||||
defaultApiURL = '',
|
|
||||||
}) => {
|
|
||||||
const [apiURL, setApiURL] = useState(defaultApiURL)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ApiConfigContext.Provider value={{ apiURL, setApiURL }}>
|
|
||||||
{children}
|
|
||||||
</ApiConfigContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useApiConfig = (): ApiConfigContextValue => {
|
|
||||||
const context = useContext(ApiConfigContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useApiConfig must be used within ApiConfigProvider')
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to get API URL with optional override
|
|
||||||
* @param overrideURL - Optional URL to use instead of context value
|
|
||||||
*/
|
|
||||||
export const useApiURL = (overrideURL?: string): string => {
|
|
||||||
const { apiURL } = useApiConfig()
|
|
||||||
return overrideURL ?? apiURL
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import React, {
|
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ReactElement,
|
|
||||||
type Ref,
|
|
||||||
} from 'react'
|
|
||||||
import { IconX } from '@tabler/icons-react'
|
|
||||||
import type { FieldValues } from 'react-hook-form'
|
|
||||||
import { ActionIcon, Drawer } from '@mantine/core'
|
|
||||||
import type { SuperFormDrawerProps, SuperFormDrawerRef, SuperFormRef } from '../../types'
|
|
||||||
import SuperForm from '../../components/SuperForm'
|
|
||||||
import { openConfirmModal } from '../../utils/openConfirmModal'
|
|
||||||
|
|
||||||
const SuperFormDrawer = <T extends FieldValues>(
|
|
||||||
{ drawerProps, noCloseOnSubmit, ...formProps }: SuperFormDrawerProps<T>,
|
|
||||||
ref: Ref<SuperFormDrawerRef<T>>
|
|
||||||
) => {
|
|
||||||
// Component Refs
|
|
||||||
const formRef = useRef<SuperFormRef<T>>(null)
|
|
||||||
const drawerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// Component store State
|
|
||||||
// Tell drawer that form layout mounted to fix refs
|
|
||||||
const [layoutMounted, setLayoutMounted] = useState(false)
|
|
||||||
|
|
||||||
// Component Callback Functions
|
|
||||||
const onSubmit = (data: T, request, formData, form, closeForm: boolean = true) => {
|
|
||||||
formProps?.onSubmit?.(data, request, formData, form, closeForm)
|
|
||||||
|
|
||||||
if (request === 'delete') {
|
|
||||||
drawerProps?.onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!noCloseOnSubmit) {
|
|
||||||
if (closeForm) {
|
|
||||||
drawerProps?.onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCancel = (request) => {
|
|
||||||
if (formRef?.current?.getFormState().isDirty) {
|
|
||||||
openConfirmModal(() => {
|
|
||||||
drawerProps?.onClose()
|
|
||||||
formProps?.onCancel?.(request)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
drawerProps?.onClose()
|
|
||||||
formProps?.onCancel?.(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onLayoutMounted = useCallback(() => {
|
|
||||||
setLayoutMounted(true)
|
|
||||||
formProps?.onLayoutMounted?.()
|
|
||||||
}, [formProps?.onLayoutMounted])
|
|
||||||
|
|
||||||
const onLayoutUnMounted = useCallback(() => {
|
|
||||||
setLayoutMounted(false)
|
|
||||||
formProps?.onLayoutUnMounted?.()
|
|
||||||
}, [formProps?.onLayoutUnMounted])
|
|
||||||
|
|
||||||
// Component use Effects
|
|
||||||
useImperativeHandle<SuperFormDrawerRef<T>, SuperFormDrawerRef<T>>(
|
|
||||||
ref,
|
|
||||||
() => ({
|
|
||||||
...formRef.current,
|
|
||||||
drawer: drawerRef.current,
|
|
||||||
} as SuperFormDrawerRef<T>),
|
|
||||||
[layoutMounted]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Drawer
|
|
||||||
ref={drawerRef}
|
|
||||||
onClose={onCancel}
|
|
||||||
closeOnClickOutside={false}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Escape' && drawerProps.closeOnEscape !== false) {
|
|
||||||
e.stopPropagation()
|
|
||||||
onCancel(formProps.request)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
overlayProps={{ backgroundOpacity: 0.5, blur: 0.5 }}
|
|
||||||
padding={6}
|
|
||||||
position='right'
|
|
||||||
transitionProps={{
|
|
||||||
transition: 'slide-left',
|
|
||||||
duration: 150,
|
|
||||||
timingFunction: 'linear',
|
|
||||||
}}
|
|
||||||
size={500}
|
|
||||||
styles={{
|
|
||||||
content: {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'stretch',
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
minHeight: '100px',
|
|
||||||
flexGrow: 1,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
keepMounted={false}
|
|
||||||
{...drawerProps}
|
|
||||||
closeOnEscape={false}
|
|
||||||
withCloseButton={false}
|
|
||||||
title={null}
|
|
||||||
>
|
|
||||||
<SuperForm<T>
|
|
||||||
{...formProps}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
onLayoutMounted={onLayoutMounted}
|
|
||||||
onLayoutUnMounted={onLayoutUnMounted}
|
|
||||||
ref={formRef}
|
|
||||||
layoutProps={{
|
|
||||||
...formProps?.layoutProps,
|
|
||||||
rightSection: (
|
|
||||||
<ActionIcon
|
|
||||||
size='xs'
|
|
||||||
onClick={() => {
|
|
||||||
onCancel(formProps?.request)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconX size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
),
|
|
||||||
title:
|
|
||||||
(drawerProps.title as string) ??
|
|
||||||
formProps?.layoutProps?.title ??
|
|
||||||
(formProps?.request as string),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Drawer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FRSuperFormDrawer = forwardRef(SuperFormDrawer) as <T extends FieldValues>(
|
|
||||||
props: SuperFormDrawerProps<T> & { ref?: Ref<SuperFormDrawerRef<T>> }
|
|
||||||
) => ReactElement
|
|
||||||
|
|
||||||
export default FRSuperFormDrawer
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import React, {
|
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ReactElement,
|
|
||||||
type Ref,
|
|
||||||
} from 'react'
|
|
||||||
import type { FieldValues } from 'react-hook-form'
|
|
||||||
import { Modal, ScrollArea } from '@mantine/core'
|
|
||||||
import type { SuperFormModalProps, SuperFormModalRef, SuperFormRef } from '../../types'
|
|
||||||
import SuperForm from '../../components/SuperForm'
|
|
||||||
import { openConfirmModal } from '../../utils/openConfirmModal'
|
|
||||||
|
|
||||||
const SuperFormModal = <T extends FieldValues>(
|
|
||||||
{ modalProps, noCloseOnSubmit, ...formProps }: SuperFormModalProps<T>,
|
|
||||||
ref: Ref<SuperFormModalRef<T>>
|
|
||||||
) => {
|
|
||||||
// Component Refs
|
|
||||||
const modalRef = useRef<HTMLDivElement>(null)
|
|
||||||
const formRef = useRef<SuperFormRef<T>>(null)
|
|
||||||
|
|
||||||
// Component store State
|
|
||||||
// Tell drawer that form layout mounted to fix refs
|
|
||||||
const [layoutMounted, setLayoutMounted] = useState(false)
|
|
||||||
|
|
||||||
// Component Callback Functions
|
|
||||||
const onSubmit = (data: T, request, formData, form, closeForm: boolean = true) => {
|
|
||||||
formProps?.onSubmit?.(data, request, formData, form, closeForm)
|
|
||||||
|
|
||||||
if (request === 'delete') {
|
|
||||||
modalProps?.onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!noCloseOnSubmit) {
|
|
||||||
if (closeForm) {
|
|
||||||
modalProps?.onClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCancel = (request) => {
|
|
||||||
if (formRef?.current?.getFormState().isDirty) {
|
|
||||||
openConfirmModal(() => {
|
|
||||||
modalProps?.onClose()
|
|
||||||
formProps?.onCancel?.(request)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
modalProps?.onClose()
|
|
||||||
formProps?.onCancel?.(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onLayoutMounted = useCallback(() => {
|
|
||||||
setLayoutMounted(true)
|
|
||||||
formProps?.onLayoutMounted?.()
|
|
||||||
}, [formProps?.onLayoutMounted])
|
|
||||||
|
|
||||||
const onLayoutUnMounted = useCallback(() => {
|
|
||||||
setLayoutMounted(false)
|
|
||||||
formProps?.onLayoutUnMounted?.()
|
|
||||||
}, [formProps?.onLayoutUnMounted])
|
|
||||||
|
|
||||||
// Component use Effects
|
|
||||||
useImperativeHandle<SuperFormModalRef<T>, SuperFormModalRef<T>>(
|
|
||||||
ref,
|
|
||||||
() => ({
|
|
||||||
...formRef.current,
|
|
||||||
modal: modalRef.current,
|
|
||||||
} as SuperFormModalRef<T>),
|
|
||||||
[layoutMounted]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
ref={modalRef}
|
|
||||||
closeOnClickOutside={false}
|
|
||||||
overlayProps={{
|
|
||||||
backgroundOpacity: 0.5,
|
|
||||||
blur: 4,
|
|
||||||
}}
|
|
||||||
padding='sm'
|
|
||||||
scrollAreaComponent={ScrollArea.Autosize}
|
|
||||||
size={500}
|
|
||||||
keepMounted={false}
|
|
||||||
{...modalProps}
|
|
||||||
>
|
|
||||||
<SuperForm<T>
|
|
||||||
{...formProps}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
onLayoutMounted={onLayoutMounted}
|
|
||||||
onLayoutUnMounted={onLayoutUnMounted}
|
|
||||||
ref={formRef}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FRSuperFormModal = forwardRef(SuperFormModal) as <T extends FieldValues>(
|
|
||||||
props: SuperFormModalProps<T> & { ref?: Ref<SuperFormModalRef<T>> }
|
|
||||||
) => ReactElement
|
|
||||||
|
|
||||||
export default FRSuperFormModal
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import React, {
|
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useImperativeHandle,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ReactElement,
|
|
||||||
type Ref,
|
|
||||||
} from 'react'
|
|
||||||
import type { FieldValues } from 'react-hook-form'
|
|
||||||
import { Box, Popover } from '@mantine/core'
|
|
||||||
import { useUncontrolled } from '@mantine/hooks'
|
|
||||||
import type { SuperFormPopoverProps, SuperFormPopoverRef, SuperFormRef } from '../../types'
|
|
||||||
import SuperForm from '../../components/SuperForm'
|
|
||||||
import { openConfirmModal } from '../../utils/openConfirmModal'
|
|
||||||
|
|
||||||
const SuperFormPopover = <T extends FieldValues>(
|
|
||||||
{ popoverProps, target, noCloseOnSubmit, ...formProps }: SuperFormPopoverProps<T>,
|
|
||||||
ref: Ref<SuperFormPopoverRef<T>>
|
|
||||||
) => {
|
|
||||||
// Component Refs
|
|
||||||
const popoverRef = useRef<HTMLDivElement>(null)
|
|
||||||
const formRef = useRef<SuperFormRef<T>>(null)
|
|
||||||
|
|
||||||
// Component store State
|
|
||||||
// Tell drawer that form layout mounted to fix refs
|
|
||||||
const [layoutMounted, setLayoutMounted] = useState(false)
|
|
||||||
|
|
||||||
// Component Hooks
|
|
||||||
const [_value, _onChange] = useUncontrolled({
|
|
||||||
value: popoverProps?.opened,
|
|
||||||
onChange: popoverProps?.onChange,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Component Callback Functions
|
|
||||||
const onSubmit = (data: T, request, formData, form, closeForm: boolean = true) => {
|
|
||||||
formProps?.onSubmit?.(data, request, formData, form, closeForm)
|
|
||||||
|
|
||||||
if (request === 'delete') {
|
|
||||||
_onChange(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!noCloseOnSubmit) {
|
|
||||||
if (closeForm) {
|
|
||||||
_onChange(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCancel = (request) => {
|
|
||||||
if (formRef?.current?.getFormState().isDirty) {
|
|
||||||
openConfirmModal(() => {
|
|
||||||
_onChange(false)
|
|
||||||
formProps?.onCancel?.(request)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
_onChange(false)
|
|
||||||
formProps?.onCancel?.(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onLayoutMounted = useCallback(() => {
|
|
||||||
setLayoutMounted(true)
|
|
||||||
formProps?.onLayoutMounted?.()
|
|
||||||
}, [formProps?.onLayoutMounted])
|
|
||||||
|
|
||||||
const onLayoutUnMounted = useCallback(() => {
|
|
||||||
setLayoutMounted(false)
|
|
||||||
formProps?.onLayoutUnMounted?.()
|
|
||||||
}, [formProps?.onLayoutUnMounted])
|
|
||||||
|
|
||||||
// Component use Effects
|
|
||||||
useImperativeHandle<SuperFormPopoverRef<T>, SuperFormPopoverRef<T>>(
|
|
||||||
ref,
|
|
||||||
() => ({
|
|
||||||
...formRef.current,
|
|
||||||
popover: popoverRef.current,
|
|
||||||
} as SuperFormPopoverRef<T>),
|
|
||||||
[layoutMounted]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
closeOnClickOutside={false}
|
|
||||||
onClose={() => _onChange(false)}
|
|
||||||
opened={_value}
|
|
||||||
position='left'
|
|
||||||
radius='md'
|
|
||||||
withArrow
|
|
||||||
withinPortal
|
|
||||||
zIndex={200}
|
|
||||||
keepMounted={false}
|
|
||||||
{...popoverProps}
|
|
||||||
>
|
|
||||||
<Popover.Target>
|
|
||||||
<Box onClick={() => _onChange(true)}>{target}</Box>
|
|
||||||
</Popover.Target>
|
|
||||||
<Popover.Dropdown p={0} m={0} ref={popoverRef}>
|
|
||||||
<SuperForm
|
|
||||||
{...formProps}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
onLayoutMounted={onLayoutMounted}
|
|
||||||
onLayoutUnMounted={onLayoutUnMounted}
|
|
||||||
ref={formRef}
|
|
||||||
/>
|
|
||||||
</Popover.Dropdown>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FRSuperFormPopover = forwardRef(SuperFormPopover) as <T extends FieldValues>(
|
|
||||||
props: SuperFormPopoverProps<T> & { ref?: Ref<SuperFormPopoverRef<T>> }
|
|
||||||
) => ReactElement
|
|
||||||
|
|
||||||
export default FRSuperFormPopover
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { FieldValues } from 'react-hook-form'
|
|
||||||
import { SuperFormProps, RequestType, ExtendedDrawerProps } from '../types'
|
|
||||||
|
|
||||||
interface UseDrawerFormState<T extends FieldValues> extends Partial<SuperFormProps<T>> {
|
|
||||||
drawerProps: Partial<ExtendedDrawerProps>
|
|
||||||
opened?: boolean
|
|
||||||
onClose?: () => void
|
|
||||||
request: RequestType
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
type AskFunction = (request: RequestType, buffer: any) => void
|
|
||||||
|
|
||||||
const useDrawerFormState = <T extends FieldValues>(
|
|
||||||
props?: Partial<UseDrawerFormState<T>>
|
|
||||||
): {
|
|
||||||
formProps: UseDrawerFormState<T>
|
|
||||||
setFormProps: React.Dispatch<React.SetStateAction<UseDrawerFormState<T>>>
|
|
||||||
open: (props: Partial<UseDrawerFormState<T>>) => void
|
|
||||||
close: () => void
|
|
||||||
ask: AskFunction
|
|
||||||
} => {
|
|
||||||
const [formProps, setFormProps] = useState<UseDrawerFormState<T>>({
|
|
||||||
opened: false,
|
|
||||||
request: 'insert',
|
|
||||||
...props,
|
|
||||||
onClose: () =>
|
|
||||||
setFormProps((curr) => ({
|
|
||||||
...curr,
|
|
||||||
opened: false,
|
|
||||||
drawerProps: { ...curr.drawerProps, opened: false },
|
|
||||||
})),
|
|
||||||
drawerProps: { opened: false, onClose: () => {}, ...props?.drawerProps },
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
formProps,
|
|
||||||
setFormProps,
|
|
||||||
open: (props?: Partial<UseDrawerFormState<T>>) => {
|
|
||||||
setFormProps((curr) => {
|
|
||||||
return {
|
|
||||||
...curr,
|
|
||||||
...props,
|
|
||||||
request: props.request ?? curr.request,
|
|
||||||
opened: true,
|
|
||||||
drawerProps: {
|
|
||||||
...curr.drawerProps,
|
|
||||||
...props?.drawerProps,
|
|
||||||
opened: true,
|
|
||||||
onClose: curr.onClose,
|
|
||||||
},
|
|
||||||
primeData: props?.primeData,
|
|
||||||
useFormProps: {
|
|
||||||
...curr.useFormProps,
|
|
||||||
...props?.useFormProps,
|
|
||||||
},
|
|
||||||
layoutProps: {
|
|
||||||
...curr.layoutProps,
|
|
||||||
...props?.layoutProps,
|
|
||||||
},
|
|
||||||
useQueryOptions: {
|
|
||||||
...curr.useQueryOptions,
|
|
||||||
...props?.useQueryOptions,
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
...curr.meta,
|
|
||||||
...props?.meta,
|
|
||||||
},
|
|
||||||
useMutationOptions: {
|
|
||||||
...curr.useMutationOptions,
|
|
||||||
...props?.useMutationOptions,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
close: () =>
|
|
||||||
setFormProps((curr) => ({
|
|
||||||
...curr,
|
|
||||||
opened: false,
|
|
||||||
drawerProps: { ...curr.drawerProps, opened: false, onClose: curr.onClose },
|
|
||||||
})),
|
|
||||||
ask: (request: RequestType, buffer: any) => {
|
|
||||||
setFormProps((curr) => ({
|
|
||||||
...curr,
|
|
||||||
request,
|
|
||||||
value: buffer,
|
|
||||||
opened: true,
|
|
||||||
drawerProps: { ...curr.drawerProps, opened: true, onClose: curr.onClose },
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useDrawerFormState
|
|
||||||
export type { UseDrawerFormState }
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { FieldValues } from 'react-hook-form'
|
|
||||||
import { ModalProps } from '@mantine/core'
|
|
||||||
import { SuperFormProps, RequestType } from '../types'
|
|
||||||
|
|
||||||
interface UseModalFormState<T extends FieldValues> extends Partial<SuperFormProps<T>> {
|
|
||||||
modalProps: ModalProps
|
|
||||||
opened?: boolean
|
|
||||||
onClose?: () => void
|
|
||||||
request: RequestType
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
type AskFunction = (request: RequestType, buffer: any) => void
|
|
||||||
|
|
||||||
const useModalFormState = <T extends FieldValues>(
|
|
||||||
props?: Partial<UseModalFormState<T>>
|
|
||||||
): {
|
|
||||||
formProps: UseModalFormState<T>
|
|
||||||
setFormProps: React.Dispatch<React.SetStateAction<UseModalFormState<T>>>
|
|
||||||
open: (props: Partial<UseModalFormState<T>>) => void
|
|
||||||
close: () => void
|
|
||||||
ask: AskFunction
|
|
||||||
} => {
|
|
||||||
const [formProps, setFormProps] = useState<UseModalFormState<T>>({
|
|
||||||
opened: false,
|
|
||||||
request: 'insert',
|
|
||||||
...props,
|
|
||||||
onClose: () =>
|
|
||||||
setFormProps((curr) => ({
|
|
||||||
...curr,
|
|
||||||
opened: false,
|
|
||||||
modalProps: { ...curr.modalProps, opened: false },
|
|
||||||
})),
|
|
||||||
modalProps: { opened: false, onClose: () => {}, ...props?.modalProps },
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
formProps,
|
|
||||||
setFormProps,
|
|
||||||
open: (props?: Partial<UseModalFormState<T>>) => {
|
|
||||||
setFormProps((curr) => {
|
|
||||||
return {
|
|
||||||
...curr,
|
|
||||||
...props,
|
|
||||||
request: props.request ?? curr.request,
|
|
||||||
opened: true,
|
|
||||||
modalProps: {
|
|
||||||
...curr.modalProps,
|
|
||||||
...props?.modalProps,
|
|
||||||
opened: true,
|
|
||||||
onClose: curr.onClose,
|
|
||||||
},
|
|
||||||
primeData: props?.primeData,
|
|
||||||
useFormProps: {
|
|
||||||
...curr.useFormProps,
|
|
||||||
...props?.useFormProps,
|
|
||||||
},
|
|
||||||
layoutProps: {
|
|
||||||
...curr.layoutProps,
|
|
||||||
...props?.layoutProps,
|
|
||||||
},
|
|
||||||
useQueryOptions: {
|
|
||||||
...curr.useQueryOptions,
|
|
||||||
...props?.useQueryOptions,
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
...curr.meta,
|
|
||||||
...props?.meta,
|
|
||||||
},
|
|
||||||
useMutationOptions: {
|
|
||||||
...curr.useMutationOptions,
|
|
||||||
...props?.useMutationOptions,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
close: () =>
|
|
||||||
setFormProps((curr) => ({
|
|
||||||
...curr,
|
|
||||||
opened: false,
|
|
||||||
modalProps: { ...curr.modalProps, opened: false, onClose: curr.onClose },
|
|
||||||
})),
|
|
||||||
ask: (request: RequestType, buffer: any) => {
|
|
||||||
setFormProps((curr) => ({
|
|
||||||
...curr,
|
|
||||||
request,
|
|
||||||
value: buffer,
|
|
||||||
opened: true,
|
|
||||||
modalProps: { ...curr.modalProps, opened: true, onClose: curr.onClose },
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useModalFormState
|
|
||||||
export type { UseModalFormState }
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { FieldValues } from 'react-hook-form'
|
|
||||||
import { PopoverProps } from '@mantine/core'
|
|
||||||
import { SuperFormProps, RequestType } from '../types'
|
|
||||||
|
|
||||||
interface UsePopoverFormState<T extends FieldValues> extends Partial<SuperFormProps<T>> {
|
|
||||||
popoverProps: Omit<PopoverProps, 'children'>
|
|
||||||
opened?: boolean
|
|
||||||
onClose?: () => void
|
|
||||||
request: RequestType
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
type AskFunction = (request: RequestType, buffer: any) => void
|
|
||||||
|
|
||||||
const usePopoverFormState = <T extends FieldValues>(
|
|
||||||
props?: Partial<UsePopoverFormState<T>>
|
|
||||||
): {
|
|
||||||
formProps: UsePopoverFormState<T>
|
|
||||||
setFormProps: React.Dispatch<React.SetStateAction<UsePopoverFormState<T>>>
|
|
||||||
open: (props: Partial<UsePopoverFormState<T>>) => void
|
|
||||||
close: () => void
|
|
||||||
ask: AskFunction
|
|
||||||
} => {
|
|
||||||
const [formProps, setFormProps] = useState<UsePopoverFormState<T>>({
|
|
||||||
opened: false,
|
|
||||||
request: 'insert',
|
|
||||||
...props,
|
|
||||||
popoverProps: { opened: false, onClose: () => {}, ...props?.popoverProps },
|
|
||||||
onClose: () =>
|
|
||||||
setFormProps((curr) => ({
|
|
||||||
...curr,
|
|
||||||
opened: false,
|
|
||||||
popoverProps: { ...curr.popoverProps, opened: false },
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
formProps,
|
|
||||||
setFormProps,
|
|
||||||
open: (props?: Partial<UsePopoverFormState<T>>) => {
|
|
||||||
setFormProps((curr) => {
|
|
||||||
return {
|
|
||||||
...curr,
|
|
||||||
...props,
|
|
||||||
request: props.request ?? curr.request,
|
|
||||||
opened: true,
|
|
||||||
popoverProps: {
|
|
||||||
...curr.popoverProps,
|
|
||||||
...props?.popoverProps,
|
|
||||||
opened: true,
|
|
||||||
onClose: curr.onClose,
|
|
||||||
},
|
|
||||||
primeData: props?.primeData,
|
|
||||||
useFormProps: {
|
|
||||||
...curr.useFormProps,
|
|
||||||
...props?.useFormProps,
|
|
||||||
},
|
|
||||||
layoutProps: {
|
|
||||||
...curr.layoutProps,
|
|
||||||
...props?.layoutProps,
|
|
||||||
},
|
|
||||||
useQueryOptions: {
|
|
||||||
...curr.useQueryOptions,
|
|
||||||
...props?.useQueryOptions,
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
...curr.meta,
|
|
||||||
...props?.meta,
|
|
||||||
},
|
|
||||||
useMutationOptions: {
|
|
||||||
...curr.useMutationOptions,
|
|
||||||
...props?.useMutationOptions,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
close: () =>
|
|
||||||
setFormProps((curr) => ({
|
|
||||||
...curr,
|
|
||||||
opened: false,
|
|
||||||
popoverProps: { ...curr.popoverProps, opened: false, onClose: curr.onClose },
|
|
||||||
})),
|
|
||||||
ask: (request: RequestType, buffer: any) => {
|
|
||||||
setFormProps((curr) => ({
|
|
||||||
...curr,
|
|
||||||
request,
|
|
||||||
value: buffer,
|
|
||||||
opened: true,
|
|
||||||
popoverProps: { ...curr.popoverProps, opened: true, onClose: curr.onClose },
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default usePopoverFormState
|
|
||||||
export type { UsePopoverFormState }
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import {
|
|
||||||
EventType,
|
|
||||||
FieldValues,
|
|
||||||
FormState,
|
|
||||||
InternalFieldName,
|
|
||||||
Path,
|
|
||||||
ReadFormState,
|
|
||||||
useFormContext,
|
|
||||||
UseFormReturn,
|
|
||||||
} from 'react-hook-form'
|
|
||||||
|
|
||||||
const useSubscribe = <T extends FieldValues>(
|
|
||||||
name: Path<T> | readonly Path<T>[] | undefined,
|
|
||||||
callback: (
|
|
||||||
data: Partial<FormState<T>> & {
|
|
||||||
values: FieldValues
|
|
||||||
name?: InternalFieldName
|
|
||||||
type?: EventType
|
|
||||||
},
|
|
||||||
form?: UseFormReturn<T, any, T>
|
|
||||||
) => void,
|
|
||||||
formState?: ReadFormState,
|
|
||||||
deps?: unknown[]
|
|
||||||
) => {
|
|
||||||
const form = useFormContext<T>()
|
|
||||||
|
|
||||||
return useEffect(() => {
|
|
||||||
const unsubscribe = form.subscribe({
|
|
||||||
name,
|
|
||||||
callback: (data) => callback(data, form),
|
|
||||||
formState: { values: true, ...formState },
|
|
||||||
exact: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
return unsubscribe
|
|
||||||
}, [form.subscribe, ...(deps || [])])
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useSubscribe
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { FieldValues } from 'react-hook-form'
|
|
||||||
import { SuperFormProps, RequestType } from '../types'
|
|
||||||
|
|
||||||
interface UseSuperFormState<T extends FieldValues> extends Partial<SuperFormProps<T>> {
|
|
||||||
request: RequestType
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
type AskFunction = (request: RequestType, buffer: any) => void
|
|
||||||
|
|
||||||
const useSuperFormState = <T extends FieldValues>(
|
|
||||||
props?: UseSuperFormState<T>
|
|
||||||
): {
|
|
||||||
formProps: UseSuperFormState<any>
|
|
||||||
setFormProps: React.Dispatch<React.SetStateAction<UseSuperFormState<T>>>
|
|
||||||
ask: AskFunction
|
|
||||||
} => {
|
|
||||||
const [formProps, setFormProps] = useState<UseSuperFormState<T>>({
|
|
||||||
request: 'insert',
|
|
||||||
...props,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
formProps,
|
|
||||||
setFormProps,
|
|
||||||
ask: (request: RequestType, buffer: any) => {
|
|
||||||
setFormProps((curr) => ({
|
|
||||||
...curr,
|
|
||||||
request,
|
|
||||||
value: buffer,
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useSuperFormState
|
|
||||||
export type { UseSuperFormState }
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import { useEffect, type MutableRefObject } from 'react'
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { useFormContext, useFormState, type FieldValues } from 'react-hook-form'
|
|
||||||
import { useStore } from '../store/SuperForm.store'
|
|
||||||
import { useApiURL } from '../config/ApiConfig'
|
|
||||||
import { getNestedValue } from '../utils/getNestedValue'
|
|
||||||
import { fetchClient, type FetchResponse, FetchError } from '../utils/fetchClient'
|
|
||||||
import type { GridRef } from '../types'
|
|
||||||
|
|
||||||
const useRemote = <T extends FieldValues>(gridRef?: MutableRefObject<GridRef<any> | null>) => {
|
|
||||||
// Component store State
|
|
||||||
const { onResetForm, remote, request, useMutationOptions, useQueryOptions, value } = useStore(
|
|
||||||
(state) => ({
|
|
||||||
onResetForm: state.onResetForm,
|
|
||||||
remote: state.remote,
|
|
||||||
request: state.request,
|
|
||||||
useMutationOptions: state.useMutationOptions,
|
|
||||||
useQueryOptions: state.useQueryOptions,
|
|
||||||
value: state.value,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Component Hooks
|
|
||||||
const form = useFormContext<T>()
|
|
||||||
const { isDirty } = useFormState({ control: form.control })
|
|
||||||
|
|
||||||
// Component use Effects
|
|
||||||
const qc = useQueryClient()
|
|
||||||
|
|
||||||
// Get API URL from context or override
|
|
||||||
const contextApiURL = useApiURL()
|
|
||||||
|
|
||||||
const id = remote?.primaryKey?.includes('.')
|
|
||||||
? getNestedValue(remote?.primaryKey, value)
|
|
||||||
: value?.[remote?.primaryKey ?? '']
|
|
||||||
|
|
||||||
const queryKey = useQueryOptions?.queryKey || [remote?.tableName, id]
|
|
||||||
|
|
||||||
const enabled =
|
|
||||||
useQueryOptions?.enabled ||
|
|
||||||
!!(
|
|
||||||
(remote?.enabled ?? true) &&
|
|
||||||
(remote?.tableName ?? '').length > 0 &&
|
|
||||||
(request === 'change' || request === 'delete') &&
|
|
||||||
String(id) !== 'undefined' &&
|
|
||||||
(String(id)?.length ?? 0) > 0
|
|
||||||
)
|
|
||||||
|
|
||||||
let url = remote?.apiURL ?? `${contextApiURL}/${remote?.tableName}`
|
|
||||||
url = url?.endsWith('/') ? url.substring(0, url.length - 1) : url
|
|
||||||
|
|
||||||
const { isSuccess, status, data, isFetching } = useQuery<FetchResponse<T>>({
|
|
||||||
queryKey,
|
|
||||||
queryFn: () => fetchClient.get<T>(`${url}/${id}`, remote?.apiOptions),
|
|
||||||
enabled,
|
|
||||||
refetchOnMount: 'always',
|
|
||||||
refetchOnReconnect: !isDirty,
|
|
||||||
refetchOnWindowFocus: !isDirty,
|
|
||||||
staleTime: 0,
|
|
||||||
gcTime: 0,
|
|
||||||
...useQueryOptions,
|
|
||||||
})
|
|
||||||
|
|
||||||
const changeMut = useMutation({
|
|
||||||
// @ts-ignore
|
|
||||||
mutationFn: (mutVal: T) => {
|
|
||||||
if (!remote?.tableName || !remote?.primaryKey) {
|
|
||||||
return Promise.resolve(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
return request === 'insert'
|
|
||||||
? fetchClient.post(url, mutVal, remote?.apiOptions)
|
|
||||||
: request === 'change'
|
|
||||||
? fetchClient.post(`${url}/${id}`, mutVal, remote?.apiOptions)
|
|
||||||
: request === 'delete'
|
|
||||||
? fetchClient.delete(`${url}/${id}`, remote?.apiOptions)
|
|
||||||
: Promise.resolve(null)
|
|
||||||
},
|
|
||||||
onSettled: (response: FetchResponse | null) => {
|
|
||||||
qc?.invalidateQueries({ queryKey: [remote?.tableName] })
|
|
||||||
|
|
||||||
if (request !== 'delete' && response) {
|
|
||||||
if (onResetForm) {
|
|
||||||
onResetForm(response?.data, form).then(() => {
|
|
||||||
form.reset(response?.data, { keepDirty: false })
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
form.reset(response?.data, { keepDirty: false })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
gridRef?.current?.refresh?.()
|
|
||||||
// @ts-ignore
|
|
||||||
gridRef?.current?.selectRow?.(response?.data?.[remote?.primaryKey ?? ''])
|
|
||||||
},
|
|
||||||
...useMutationOptions,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isSuccess && status === 'success' && enabled && !isFetching) {
|
|
||||||
if (!Object.keys(data?.data ?? {}).includes(remote?.primaryKey ?? '')) {
|
|
||||||
throw new Error('Primary key not found in remote data')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onResetForm) {
|
|
||||||
onResetForm(data?.data, form).then((resetData) => {
|
|
||||||
form.reset(resetData)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
form.reset(data?.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isSuccess, status, enabled, isFetching])
|
|
||||||
|
|
||||||
return {
|
|
||||||
error: changeMut.error as FetchError,
|
|
||||||
isFetching: (enabled ? isFetching : false) || changeMut?.isPending,
|
|
||||||
mutateAsync: changeMut.mutateAsync,
|
|
||||||
queryKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useRemote
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import React, { createContext, useContext, type ReactNode } from 'react'
|
|
||||||
import { create } from 'zustand'
|
|
||||||
import type { RequestType } from '../types'
|
|
||||||
|
|
||||||
interface FormLayoutState {
|
|
||||||
request: RequestType
|
|
||||||
loading: boolean
|
|
||||||
dirty: boolean
|
|
||||||
onCancel?: () => void
|
|
||||||
onSubmit?: () => void
|
|
||||||
setState: (key: string, value: any) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const createFormLayoutStore = (initialProps: any) =>
|
|
||||||
create<FormLayoutState>((set) => ({
|
|
||||||
request: initialProps.request || 'insert',
|
|
||||||
loading: initialProps.loading || false,
|
|
||||||
dirty: initialProps.dirty || false,
|
|
||||||
onCancel: initialProps.onCancel,
|
|
||||||
onSubmit: initialProps.onSubmit,
|
|
||||||
setState: (key, value) => set({ [key]: value }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const FormLayoutStoreContext = createContext<ReturnType<typeof createFormLayoutStore> | null>(null)
|
|
||||||
|
|
||||||
export const FormLayoutStoreProvider: React.FC<{ children: ReactNode; [key: string]: any }> = ({
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const storeRef = React.useRef<ReturnType<typeof createFormLayoutStore>>()
|
|
||||||
if (!storeRef.current) {
|
|
||||||
storeRef.current = createFormLayoutStore(props)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormLayoutStoreContext.Provider value={storeRef.current}>
|
|
||||||
{children}
|
|
||||||
</FormLayoutStoreContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useFormLayoutStore = <T,>(selector: (state: FormLayoutState) => T): T => {
|
|
||||||
const store = useContext(FormLayoutStoreContext)
|
|
||||||
if (!store) {
|
|
||||||
throw new Error('useFormLayoutStore must be used within FormLayoutStoreProvider')
|
|
||||||
}
|
|
||||||
return store(selector)
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import type { SuperFormProviderProps } from '../types'
|
|
||||||
import { createSyncStore } from '@warkypublic/zustandsyncstore'
|
|
||||||
|
|
||||||
const { Provider, useStore } = createSyncStore<any, SuperFormProviderProps>((set) => ({
|
|
||||||
request: 'insert',
|
|
||||||
setRequest: (request) => {
|
|
||||||
set({ request })
|
|
||||||
},
|
|
||||||
|
|
||||||
value: undefined,
|
|
||||||
setValue: (value) => {
|
|
||||||
set({ value })
|
|
||||||
},
|
|
||||||
|
|
||||||
noCloseOnSubmit: false,
|
|
||||||
setNoCloseOnSubmit: (noCloseOnSubmit) => {
|
|
||||||
set({ noCloseOnSubmit })
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
export { Provider, useStore }
|
|
||||||
export const useSuperFormStore = useStore
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
.disabled {
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sticky {
|
|
||||||
position: -webkit-sticky;
|
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import type { UseMutationOptions, UseMutationResult, UseQueryOptions } from '@tanstack/react-query'
|
|
||||||
import type { FieldValues, UseFormProps, UseFormReturn, UseFormStateReturn } from 'react-hook-form'
|
|
||||||
import type { ModalProps, PaperProps, PopoverProps, DrawerProps } from '@mantine/core'
|
|
||||||
import type { RemoteConfig } from './remote.types'
|
|
||||||
|
|
||||||
export type RequestType = 'insert' | 'change' | 'view' | 'select' | 'delete' | 'get' | 'set'
|
|
||||||
|
|
||||||
// Grid integration types (simplified - removes BTGlideRef dependency)
|
|
||||||
export interface GridRef<T = any> {
|
|
||||||
refresh?: () => void
|
|
||||||
selectRow?: (id: any) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormSectionBodyProps {
|
|
||||||
// Add properties as needed from original FormLayout
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormSectionFooterProps {
|
|
||||||
// Add properties as needed from original FormLayout
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BodyRightSection<T extends FieldValues> {
|
|
||||||
opened?: boolean
|
|
||||||
setOpened?: (opened: boolean) => void
|
|
||||||
w: number | string
|
|
||||||
hideToggleButton?: boolean
|
|
||||||
paperProps?: PaperProps
|
|
||||||
render: (props: {
|
|
||||||
form: UseFormReturn<any, any, undefined>
|
|
||||||
formValue: T
|
|
||||||
isFetching: boolean
|
|
||||||
opened: boolean
|
|
||||||
queryKey: any
|
|
||||||
setOpened: (opened: boolean) => void
|
|
||||||
}) => React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuperFormLayoutProps<T extends FieldValues> {
|
|
||||||
buttonTitles?: { submit?: string; cancel?: string }
|
|
||||||
extraButtons?: React.ReactNode | ((form: UseFormReturn<any, any, undefined>) => React.ReactNode)
|
|
||||||
noFooter?: boolean
|
|
||||||
noHeader?: boolean
|
|
||||||
noLayout?: boolean
|
|
||||||
bodySectionProps?: Partial<FormSectionBodyProps>
|
|
||||||
footerSectionProps?:
|
|
||||||
| Partial<FormSectionFooterProps>
|
|
||||||
| ((ref: React.RefObject<SuperFormRef<T>>) => Partial<FormSectionFooterProps>)
|
|
||||||
rightSection?: React.ReactNode
|
|
||||||
bodyRightSection?: BodyRightSection<T>
|
|
||||||
title?: string
|
|
||||||
showErrorList?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommonFormProps<T extends FieldValues> {
|
|
||||||
gridRef?: React.MutableRefObject<GridRef<any> | null>
|
|
||||||
layoutProps?: SuperFormLayoutProps<T>
|
|
||||||
meta?: { [key: string]: any }
|
|
||||||
nested?: boolean
|
|
||||||
onCancel?: (request: RequestType) => void
|
|
||||||
onLayoutMounted?: () => void
|
|
||||||
onLayoutUnMounted?: () => void
|
|
||||||
onResetForm?: (data: T, form?: UseFormReturn<any, any, undefined>) => Promise<T>
|
|
||||||
onBeforeSubmit?: (
|
|
||||||
data: T,
|
|
||||||
request: RequestType,
|
|
||||||
form?: UseFormReturn<any, any, undefined>
|
|
||||||
) => Promise<T>
|
|
||||||
onSubmit?: (
|
|
||||||
data: T,
|
|
||||||
request: RequestType,
|
|
||||||
formData?: T,
|
|
||||||
form?: UseFormReturn<any, any, undefined>,
|
|
||||||
closeForm?: boolean
|
|
||||||
) => void
|
|
||||||
primeData?: any
|
|
||||||
readonly?: boolean
|
|
||||||
remote?: RemoteConfig
|
|
||||||
request: RequestType
|
|
||||||
persist?: boolean | { storageKey?: string }
|
|
||||||
useMutationOptions?: UseMutationOptions<any, Error, T, unknown>
|
|
||||||
useQueryOptions?: Partial<UseQueryOptions<any, Error, T>>
|
|
||||||
value?: T | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuperFormProps<T extends FieldValues> extends CommonFormProps<T> {
|
|
||||||
children: React.ReactNode | ((props: UseFormReturn<T, any, undefined>) => React.ReactNode)
|
|
||||||
useFormProps?: UseFormProps<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuperFormProviderProps extends Omit<SuperFormProps<any>, 'children'> {
|
|
||||||
children?: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuperFormModalProps<T extends FieldValues> extends SuperFormProps<T> {
|
|
||||||
modalProps: ModalProps
|
|
||||||
noCloseOnSubmit?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuperFormPopoverProps<T extends FieldValues> extends SuperFormProps<T> {
|
|
||||||
popoverProps?: Omit<PopoverProps, 'children'>
|
|
||||||
target: any
|
|
||||||
noCloseOnSubmit?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExtendedDrawerProps extends DrawerProps {
|
|
||||||
// Add any extended drawer props needed
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuperFormDrawerProps<T extends FieldValues> extends SuperFormProps<T> {
|
|
||||||
drawerProps: ExtendedDrawerProps
|
|
||||||
noCloseOnSubmit?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuperFormRef<T extends FieldValues> {
|
|
||||||
form: UseFormReturn<T, any, undefined>
|
|
||||||
mutation: Partial<UseMutationResult<any, Error, any, unknown>>
|
|
||||||
submit: (closeForm?: boolean, afterSubmit?: (data: T | any) => void) => Promise<void>
|
|
||||||
queryKey?: any
|
|
||||||
getFormState: () => UseFormStateReturn<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuperFormDrawerRef<T extends FieldValues> extends SuperFormRef<T> {
|
|
||||||
drawer: HTMLDivElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuperFormModalRef<T extends FieldValues> extends SuperFormRef<T> {
|
|
||||||
modal: HTMLDivElement | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SuperFormPopoverRef<T extends FieldValues> extends SuperFormRef<T> {
|
|
||||||
popover: HTMLDivElement | null
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './form.types'
|
|
||||||
export * from './remote.types'
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export interface RemoteConfig {
|
|
||||||
apiOptions?: RequestInit
|
|
||||||
apiURL?: string
|
|
||||||
enabled?: boolean
|
|
||||||
fetchSize?: number
|
|
||||||
hotFields?: string[]
|
|
||||||
primaryKey?: string
|
|
||||||
sqlFilter?: string
|
|
||||||
tableName: string
|
|
||||||
uniqueKeys?: string[]
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
export interface FetchOptions extends RequestInit {
|
|
||||||
params?: Record<string, any>
|
|
||||||
timeout?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FetchResponse<T = any> {
|
|
||||||
data: T
|
|
||||||
status: number
|
|
||||||
statusText: string
|
|
||||||
ok: boolean
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FetchError extends Error {
|
|
||||||
constructor(
|
|
||||||
public message: string,
|
|
||||||
public status?: number,
|
|
||||||
public response?: any
|
|
||||||
) {
|
|
||||||
super(message)
|
|
||||||
this.name = 'FetchError'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch wrapper with timeout support and axios-like interface
|
|
||||||
*/
|
|
||||||
async function fetchWithTimeout(
|
|
||||||
url: string,
|
|
||||||
options: FetchOptions = {}
|
|
||||||
): Promise<Response> {
|
|
||||||
const { timeout = 30000, ...fetchOptions } = options
|
|
||||||
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...fetchOptions,
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
return response
|
|
||||||
} catch (error) {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET request
|
|
||||||
*/
|
|
||||||
export async function get<T = any>(
|
|
||||||
url: string,
|
|
||||||
options?: FetchOptions
|
|
||||||
): Promise<FetchResponse<T>> {
|
|
||||||
try {
|
|
||||||
const response = await fetchWithTimeout(url, {
|
|
||||||
...options,
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options?.headers,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
ok: response.ok,
|
|
||||||
error: response.ok ? undefined : data?.message || data?.error || response.statusText,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw new FetchError(
|
|
||||||
error instanceof Error ? error.message : 'Network request failed',
|
|
||||||
undefined,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST request
|
|
||||||
*/
|
|
||||||
export async function post<T = any>(
|
|
||||||
url: string,
|
|
||||||
data?: any,
|
|
||||||
options?: FetchOptions
|
|
||||||
): Promise<FetchResponse<T>> {
|
|
||||||
try {
|
|
||||||
const response = await fetchWithTimeout(url, {
|
|
||||||
...options,
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options?.headers,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
})
|
|
||||||
|
|
||||||
const responseData = await response.json()
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: responseData,
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
ok: response.ok,
|
|
||||||
error: response.ok ? undefined : responseData?.message || responseData?.error || response.statusText,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw new FetchError(
|
|
||||||
error instanceof Error ? error.message : 'Network request failed',
|
|
||||||
undefined,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE request
|
|
||||||
*/
|
|
||||||
export async function del<T = any>(
|
|
||||||
url: string,
|
|
||||||
options?: FetchOptions
|
|
||||||
): Promise<FetchResponse<T>> {
|
|
||||||
try {
|
|
||||||
const response = await fetchWithTimeout(url, {
|
|
||||||
...options,
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options?.headers,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json().catch(() => ({}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
ok: response.ok,
|
|
||||||
error: response.ok ? undefined : data?.message || data?.error || response.statusText,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw new FetchError(
|
|
||||||
error instanceof Error ? error.message : 'Network request failed',
|
|
||||||
undefined,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchClient = {
|
|
||||||
get,
|
|
||||||
post,
|
|
||||||
delete: del,
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* Retrieves a nested value from an object using dot notation path
|
|
||||||
* @param path - Dot-separated path (e.g., "user.address.city")
|
|
||||||
* @param obj - Object to extract value from
|
|
||||||
* @returns The value at the specified path, or undefined if not found
|
|
||||||
*/
|
|
||||||
export const getNestedValue = (path: string, obj: any): any => {
|
|
||||||
return path.split('.').reduce((prev, curr) => prev?.[curr], obj)
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import { Stack, Text } from '@mantine/core'
|
|
||||||
import { modals } from '@mantine/modals'
|
|
||||||
|
|
||||||
export const openConfirmModal = (
|
|
||||||
onConfirm: () => void,
|
|
||||||
onCancel?: (() => void) | null,
|
|
||||||
description?: string | null
|
|
||||||
) =>
|
|
||||||
modals.openConfirmModal({
|
|
||||||
size: 'xs',
|
|
||||||
children: (
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Text size='xs' c={description ? 'blue' : 'red'} fw='bold'>
|
|
||||||
You have unsaved changes in this form.
|
|
||||||
</Text>
|
|
||||||
<Text size='xs'>
|
|
||||||
{description ??
|
|
||||||
'Closing now will discard any modifications you have made. Are you sure you want to continue?'}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
),
|
|
||||||
labels: { confirm: description ? 'Restore' : 'Confirm', cancel: 'Cancel' },
|
|
||||||
confirmProps: { color: description ? 'blue' : 'red', size: 'compact-xs' },
|
|
||||||
cancelProps: { size: 'compact-xs' },
|
|
||||||
groupProps: { gap: 'xs' },
|
|
||||||
withCloseButton: false,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
})
|
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import { newUUID } from '@warkypublic/artemis-kit';
|
||||||
import { createSyncStore } from '@warkypublic/zustandsyncstore';
|
import { createSyncStore } from '@warkypublic/zustandsyncstore';
|
||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
|
|
||||||
import type { FormerProps, FormerState } from './Former.types';
|
import type { FormerProps, FormerState, FormStateAndProps } from './Former.types';
|
||||||
|
|
||||||
const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
|
const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
|
||||||
FormerState<any> & Partial<FormerProps<any>>,
|
FormerState<any> & Partial<FormerProps<any>>,
|
||||||
FormerProps<any>
|
FormerProps<any>
|
||||||
>(
|
>(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
|
getAllState: () => {
|
||||||
|
return get() as FormStateAndProps<any>;
|
||||||
|
},
|
||||||
getState: (key) => {
|
getState: (key) => {
|
||||||
const current = get();
|
const current = get();
|
||||||
return current?.[key];
|
return current?.[key];
|
||||||
@@ -15,7 +19,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
|
|||||||
load: async (reset?: boolean) => {
|
load: async (reset?: boolean) => {
|
||||||
try {
|
try {
|
||||||
set({ loading: true });
|
set({ loading: true });
|
||||||
const keyName = get()?.apiKeyField || 'id';
|
const keyName = get()?.uniqueKeyField || 'id';
|
||||||
const keyValue = (get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName];
|
const keyValue = (get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName];
|
||||||
if (get().onAPICall && keyValue !== undefined) {
|
if (get().onAPICall && keyValue !== undefined) {
|
||||||
let data = await get().onAPICall!(
|
let data = await get().onAPICall!(
|
||||||
@@ -25,17 +29,19 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
|
|||||||
keyValue
|
keyValue
|
||||||
);
|
);
|
||||||
if (get().afterGet) {
|
if (get().afterGet) {
|
||||||
data = await get().afterGet!({ ...data });
|
data = await get().afterGet!({ ...data }, get());
|
||||||
}
|
}
|
||||||
set({ loading: false, values: data });
|
set({ loading: false, values: data });
|
||||||
get().onChange?.(data);
|
get().onChange?.(data, get());
|
||||||
}
|
}
|
||||||
if (reset && get().getFormMethods) {
|
if (reset && get().getFormMethods) {
|
||||||
const formMethods = get().getFormMethods!();
|
const formMethods = get().getFormMethods!();
|
||||||
formMethods.reset();
|
formMethods.reset();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
set({ error: (e as Error)?.message ?? e, loading: false });
|
const errorMessage = (e as Error)?.message ?? e;
|
||||||
|
set({ error: errorMessage, loading: false });
|
||||||
|
get().onError?.(errorMessage, get());
|
||||||
}
|
}
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
},
|
},
|
||||||
@@ -65,7 +71,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
|
|||||||
let data = formMethods.getValues();
|
let data = formMethods.getValues();
|
||||||
|
|
||||||
if (get().beforeSave) {
|
if (get().beforeSave) {
|
||||||
const newData = await get().beforeSave!(data);
|
const newData = await get().beforeSave!(data, get());
|
||||||
data = newData;
|
data = newData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +81,9 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
|
|||||||
data = newdata;
|
data = newdata;
|
||||||
},
|
},
|
||||||
(errors) => {
|
(errors) => {
|
||||||
set({ error: errors.root?.message || 'Validation errors', loading: false });
|
const errorMessage = errors.root?.message || 'Validation errors';
|
||||||
|
set({ error: errorMessage, loading: false });
|
||||||
|
get().onError?.(errorMessage, get());
|
||||||
exit = true;
|
exit = true;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -97,7 +105,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (get().onAPICall) {
|
if (get().onAPICall) {
|
||||||
const keyName = get()?.apiKeyField || 'id';
|
const keyName = get()?.uniqueKeyField || 'id';
|
||||||
const keyValue =
|
const keyValue =
|
||||||
(get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName];
|
(get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName];
|
||||||
const savedData = await get().onAPICall!(
|
const savedData = await get().onAPICall!(
|
||||||
@@ -106,27 +114,49 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
|
|||||||
data,
|
data,
|
||||||
keyValue
|
keyValue
|
||||||
);
|
);
|
||||||
|
const newData = { ...data, ...savedData }; //Merge what we had. In case the API doesn't return all fields, we don't want to lose them
|
||||||
if (get().afterSave) {
|
if (get().afterSave) {
|
||||||
await get().afterSave!(savedData);
|
await get().afterSave!(newData, get());
|
||||||
}
|
}
|
||||||
set({ loading: false, values: savedData });
|
|
||||||
get().onChange?.(savedData);
|
if (keepOpen) {
|
||||||
if (!keepOpen) {
|
const keyName = get()?.uniqueKeyField || 'id';
|
||||||
get().onClose?.(savedData);
|
const clearedData = { ...newData };
|
||||||
|
delete clearedData[keyName];
|
||||||
|
set({ loading: false, values: clearedData });
|
||||||
|
get().onChange?.(clearedData, get());
|
||||||
|
formMethods.reset(clearedData);
|
||||||
|
return newData;
|
||||||
}
|
}
|
||||||
return savedData;
|
|
||||||
|
set({ loading: false, values: newData });
|
||||||
|
get().onChange?.(newData, get());
|
||||||
|
formMethods.reset(newData); //reset with saved data to clear dirty state
|
||||||
|
get().onClose?.(newData);
|
||||||
|
return newData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keepOpen) {
|
||||||
|
const keyName = get()?.uniqueKeyField || 'id';
|
||||||
|
const clearedData = { ...data };
|
||||||
|
delete clearedData[keyName];
|
||||||
|
set({ loading: false, values: clearedData });
|
||||||
|
formMethods.reset(clearedData);
|
||||||
|
get().onChange?.(clearedData, get());
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ loading: false, values: data });
|
set({ loading: false, values: data });
|
||||||
get().onChange?.(data);
|
formMethods.reset(data); //reset with saved data to clear dirty state
|
||||||
if (!keepOpen) {
|
get().onChange?.(data, get());
|
||||||
get().onClose?.(data);
|
get().onClose?.(data);
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
set({ error: (e as Error)?.message ?? e, loading: false });
|
const errorMessage = (e as Error)?.message ?? e;
|
||||||
|
set({ error: errorMessage, loading: false });
|
||||||
|
get().onError?.(errorMessage, get());
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -168,17 +198,38 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
|
|||||||
},
|
},
|
||||||
values: undefined,
|
values: undefined,
|
||||||
}),
|
}),
|
||||||
({ onConfirmDelete, primeData, request, values }) => {
|
({ id, onClose, onConfirmDelete, primeData, request, useStoreApi, values }) => {
|
||||||
let _onConfirmDelete = onConfirmDelete;
|
let _onConfirmDelete = onConfirmDelete;
|
||||||
if (!onConfirmDelete) {
|
if (!onConfirmDelete) {
|
||||||
_onConfirmDelete = async () => {
|
_onConfirmDelete = async () => {
|
||||||
return confirm('Are you sure you want to delete this item?');
|
return confirm('Are you sure you want to delete this item?');
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
id: !id ? newUUID() : id,
|
||||||
|
onClose: (data?: any) => {
|
||||||
|
const dirty = useStoreApi.getState().dirty;
|
||||||
|
const setState = useStoreApi.getState().setState;
|
||||||
|
if (dirty) {
|
||||||
|
if (confirm('You have unsaved changes. Are you sure you want to close?')) {
|
||||||
|
if (onClose) {
|
||||||
|
onClose(data);
|
||||||
|
} else {
|
||||||
|
setState('opened', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (onClose) {
|
||||||
|
onClose(data);
|
||||||
|
} else {
|
||||||
|
setState('opened', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onConfirmDelete: _onConfirmDelete,
|
onConfirmDelete: _onConfirmDelete,
|
||||||
primeData,
|
primeData,
|
||||||
request: request || 'insert',
|
request: (request || 'insert').replace('change', 'update'),
|
||||||
values: { ...primeData, ...values },
|
values: { ...primeData, ...values },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const FormerInner = forwardRef<FormerRef<any>, Partial<FormerProps<any>> & Props
|
|||||||
ref: any
|
ref: any
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
|
getAllState,
|
||||||
getState,
|
getState,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -26,6 +27,7 @@ const FormerInner = forwardRef<FormerRef<any>, Partial<FormerProps<any>> & Props
|
|||||||
values,
|
values,
|
||||||
wrapper,
|
wrapper,
|
||||||
} = useFormerStore((state) => ({
|
} = useFormerStore((state) => ({
|
||||||
|
getAllState: state.getAllState,
|
||||||
getState: state.getState,
|
getState: state.getState,
|
||||||
onChange: state.onChange,
|
onChange: state.onChange,
|
||||||
onClose: state.onClose,
|
onClose: state.onClose,
|
||||||
@@ -54,7 +56,7 @@ const FormerInner = forwardRef<FormerRef<any>, Partial<FormerProps<any>> & Props
|
|||||||
() => ({
|
() => ({
|
||||||
close: async () => {
|
close: async () => {
|
||||||
//console.log('close called');
|
//console.log('close called');
|
||||||
onClose?.();
|
onClose?.(getState('values'));
|
||||||
setState('opened', false);
|
setState('opened', false);
|
||||||
},
|
},
|
||||||
getValue: () => {
|
getValue: () => {
|
||||||
@@ -67,7 +69,7 @@ const FormerInner = forwardRef<FormerRef<any>, Partial<FormerProps<any>> & Props
|
|||||||
return await save();
|
return await save();
|
||||||
},
|
},
|
||||||
setValue: (value: T) => {
|
setValue: (value: T) => {
|
||||||
onChange?.(value);
|
onChange?.(value, getAllState());
|
||||||
},
|
},
|
||||||
show: async () => {
|
show: async () => {
|
||||||
//console.log('show called');
|
//console.log('show called');
|
||||||
@@ -78,17 +80,38 @@ const FormerInner = forwardRef<FormerRef<any>, Partial<FormerProps<any>> & Props
|
|||||||
return await validate();
|
return await validate();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[getState, onChange]
|
[getState, getAllState, onChange, validate, save, reset, setState, onClose, onOpen]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setState('getFormMethods', () => formMethods);
|
setState('getFormMethods', () => formMethods);
|
||||||
|
|
||||||
|
if (formMethods) {
|
||||||
|
formMethods.subscribe({
|
||||||
|
callback: ({ isDirty }) => {
|
||||||
|
setState('dirty', isDirty);
|
||||||
|
},
|
||||||
|
formState: { isDirty: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
}, [formMethods]);
|
}, [formMethods]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...formMethods}>
|
<FormProvider {...formMethods}>
|
||||||
{typeof wrapper === 'function' ? (
|
{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>
|
<FormerLayout>{props.children || null}</FormerLayout>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,37 +1,54 @@
|
|||||||
import type { LoadingOverlayProps, ScrollAreaAutosizeProps } from '@mantine/core';
|
import type {
|
||||||
|
ButtonProps,
|
||||||
|
GroupProps,
|
||||||
|
LoadingOverlayProps,
|
||||||
|
ScrollAreaAutosizeProps,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import type React from 'react';
|
||||||
import type { FieldValues, UseFormProps, UseFormReturn } from 'react-hook-form';
|
import type { FieldValues, UseFormProps, UseFormReturn } from 'react-hook-form';
|
||||||
|
|
||||||
export interface FormerProps<T extends FieldValues = any> {
|
export type FormerAPICallType<T extends FieldValues = any> = (
|
||||||
afterGet?: (data: T) => Promise<T> | void;
|
mode: 'mutate' | 'read',
|
||||||
afterSave?: (data: T) => Promise<void> | void;
|
request: RequestType,
|
||||||
apiKeyField?: string;
|
value?: T,
|
||||||
beforeSave?: (data: T) => Promise<T> | T;
|
key?: number | string
|
||||||
disableHTMlForm?: boolean;
|
) => Promise<T>;
|
||||||
keepOpen?: boolean;
|
|
||||||
onAPICall?: (
|
|
||||||
mode: 'mutate' | 'read',
|
|
||||||
request: RequestType,
|
|
||||||
value?: T,
|
|
||||||
key?: number | string
|
|
||||||
) => Promise<T>;
|
|
||||||
onCancel?: () => void;
|
|
||||||
onChange?: (value: T) => void;
|
|
||||||
onClose?: (data?: T) => void;
|
|
||||||
onConfirmDelete?: (values?: T) => Promise<boolean>;
|
|
||||||
onOpen?: (data?: T) => void;
|
|
||||||
|
|
||||||
|
export interface FormerProps<T extends FieldValues = any> {
|
||||||
|
afterGet?: (data: T, state: Partial<FormStateAndProps<T>>) => Promise<T> | void;
|
||||||
|
afterSave?: (data: T, state: Partial<FormStateAndProps<T>>) => Promise<void> | void;
|
||||||
|
beforeSave?: (data: T, state: Partial<FormStateAndProps<T>>) => Promise<T> | T;
|
||||||
|
dirty?: boolean;
|
||||||
|
disableHTMlForm?: boolean;
|
||||||
|
id?: string;
|
||||||
|
keepOpen?: boolean;
|
||||||
|
layout?: {
|
||||||
|
buttonArea?: 'bottom' | 'none' | 'top';
|
||||||
|
buttonAreaGroupProps?: GroupProps;
|
||||||
|
closeButtonProps?: ButtonProps;
|
||||||
|
closeButtonTitle?: React.ReactNode;
|
||||||
|
renderBottom?: FormerSectionRender<T>;
|
||||||
|
renderTop?: FormerSectionRender<T>;
|
||||||
|
saveButtonProps?: ButtonProps;
|
||||||
|
saveButtonTitle?: React.ReactNode;
|
||||||
|
showKeepOpenSwitch?: boolean;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
onAPICall?: FormerAPICallType<T>;
|
||||||
|
onCancel?: () => void;
|
||||||
|
onChange?: (value: T, state: Partial<FormStateAndProps<T>>) => void;
|
||||||
|
onClose?: (data?: T | undefined) => void;
|
||||||
|
onConfirmDelete?: (values?: T) => Promise<boolean>;
|
||||||
|
onError?: (error: Error | string, state: Partial<FormStateAndProps<T>>) => void;
|
||||||
|
onOpen?: (data?: T) => void;
|
||||||
opened?: boolean;
|
opened?: boolean;
|
||||||
primeData?: T;
|
primeData?: T;
|
||||||
request: RequestType;
|
request: RequestType;
|
||||||
|
uniqueKeyField?: string;
|
||||||
useFormProps?: UseFormProps<T>;
|
useFormProps?: UseFormProps<T>;
|
||||||
values?: T;
|
values?: T;
|
||||||
wrapper?: (
|
|
||||||
children: React.ReactNode,
|
wrapper?: FormerSectionRender<T>;
|
||||||
opened: boolean | undefined,
|
|
||||||
onClose: ((data?: T) => void) | undefined,
|
|
||||||
onOpen: ((data?: T) => void) | undefined,
|
|
||||||
getState: <K extends keyof FormStateAndProps<T>>(key: K) => FormStateAndProps<T>[K]
|
|
||||||
) => React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormerRef<T extends FieldValues = any> {
|
export interface FormerRef<T extends FieldValues = any> {
|
||||||
@@ -44,9 +61,18 @@ export interface FormerRef<T extends FieldValues = any> {
|
|||||||
validate: () => Promise<boolean>;
|
validate: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FormerSectionRender<T extends FieldValues = any> = (
|
||||||
|
children: React.ReactNode,
|
||||||
|
opened: boolean,
|
||||||
|
onClose: (data?: T) => void,
|
||||||
|
onOpen: (data?: T) => void,
|
||||||
|
getState: FormerState<T>['getState']
|
||||||
|
) => React.ReactNode;
|
||||||
|
|
||||||
export interface FormerState<T extends FieldValues = any> {
|
export interface FormerState<T extends FieldValues = any> {
|
||||||
deleteConfirmed?: boolean;
|
deleteConfirmed?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
getAllState: () => FormStateAndProps<T>;
|
||||||
getFormMethods?: () => UseFormReturn<any, any>;
|
getFormMethods?: () => UseFormReturn<any, any>;
|
||||||
getState: <K extends keyof FormStateAndProps<T>>(key: K) => FormStateAndProps<T>[K];
|
getState: <K extends keyof FormStateAndProps<T>>(key: K) => FormStateAndProps<T>[K];
|
||||||
load: (reset?: boolean) => Promise<void>;
|
load: (reset?: boolean) => Promise<void>;
|
||||||
|
|||||||
100
src/Former/FormerButtonArea.tsx
Normal file
100
src/Former/FormerButtonArea.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Button, Group, Switch, Tooltip } from '@mantine/core';
|
||||||
|
import { IconDeviceFloppy, IconX } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
import { useFormerStore } from './Former.store';
|
||||||
|
|
||||||
|
export const FormerButtonArea = () => {
|
||||||
|
const {
|
||||||
|
buttonAreaGroupProps,
|
||||||
|
closeButtonProps,
|
||||||
|
closeButtonTitle,
|
||||||
|
dirty,
|
||||||
|
getState,
|
||||||
|
keepOpen,
|
||||||
|
onClose,
|
||||||
|
request,
|
||||||
|
save,
|
||||||
|
saveButtonProps,
|
||||||
|
saveButtonTitle,
|
||||||
|
setState,
|
||||||
|
showKeepOpenSwitch,
|
||||||
|
} = useFormerStore((state) => ({
|
||||||
|
buttonAreaGroupProps: state.layout?.buttonAreaGroupProps,
|
||||||
|
closeButtonProps: state.layout?.closeButtonProps,
|
||||||
|
closeButtonTitle: state.layout?.closeButtonTitle,
|
||||||
|
dirty: state.dirty,
|
||||||
|
getState: state.getState,
|
||||||
|
keepOpen: state.keepOpen,
|
||||||
|
onClose: state.onClose,
|
||||||
|
request: state.request,
|
||||||
|
save: state.save,
|
||||||
|
saveButtonProps: state.layout?.saveButtonProps,
|
||||||
|
saveButtonTitle: state.layout?.saveButtonTitle,
|
||||||
|
setState: state.setState,
|
||||||
|
showKeepOpenSwitch: state.layout?.showKeepOpenSwitch,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const disabledSave =
|
||||||
|
['select', 'view'].includes(request || '') || (['update'].includes(request || '') && !dirty);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify="center"
|
||||||
|
p="xs"
|
||||||
|
style={{ boxShadow: '2px 2px 5px rgba(47, 47, 47, 0.1)' }}
|
||||||
|
w="100%"
|
||||||
|
{...buttonAreaGroupProps}
|
||||||
|
>
|
||||||
|
<Group grow justify="space-evenly">
|
||||||
|
{typeof onClose === 'function' && (
|
||||||
|
<Button
|
||||||
|
color="orange"
|
||||||
|
leftSection={<IconX />}
|
||||||
|
miw={'8rem'}
|
||||||
|
px="md"
|
||||||
|
size="sm"
|
||||||
|
{...closeButtonProps}
|
||||||
|
onClick={() => {
|
||||||
|
onClose(getState('values'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{closeButtonTitle || 'Close'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showKeepOpenSwitch && (
|
||||||
|
<Switch
|
||||||
|
checked={keepOpen}
|
||||||
|
label="Keep Open"
|
||||||
|
onChange={(event) => setState('keepOpen', event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
disabledSave ? (
|
||||||
|
<p>
|
||||||
|
Cannot save in view or select mode, or no changes made. <br />
|
||||||
|
Try changing some values.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>Save the current record</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
bg={request === 'delete' ? 'red' : undefined}
|
||||||
|
color="green"
|
||||||
|
leftSection={<IconDeviceFloppy />}
|
||||||
|
miw={'8rem'}
|
||||||
|
px="md"
|
||||||
|
size="sm"
|
||||||
|
{...saveButtonProps}
|
||||||
|
disabled={disabledSave}
|
||||||
|
onClick={() => save()}
|
||||||
|
>
|
||||||
|
{saveButtonTitle || 'Save'}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,14 +2,18 @@ import { LoadingOverlay, ScrollAreaAutosize } from '@mantine/core';
|
|||||||
import { type PropsWithChildren, useEffect } from 'react';
|
import { type PropsWithChildren, useEffect } from 'react';
|
||||||
|
|
||||||
import { useFormerStore } from './Former.store';
|
import { useFormerStore } from './Former.store';
|
||||||
|
import { FormerLayoutBottom } from './FormerLayoutBottom';
|
||||||
|
import { FormerLayoutTop } from './FormerLayoutTop';
|
||||||
|
|
||||||
export const FormerLayout = (props: PropsWithChildren) => {
|
export const FormerLayout = (props: PropsWithChildren) => {
|
||||||
const {
|
const {
|
||||||
disableHTMlForm,
|
disableHTMlForm,
|
||||||
getFormMethods,
|
getFormMethods,
|
||||||
|
id,
|
||||||
load,
|
load,
|
||||||
loading,
|
loading,
|
||||||
loadingOverlayProps,
|
loadingOverlayProps,
|
||||||
|
opened,
|
||||||
request,
|
request,
|
||||||
reset,
|
reset,
|
||||||
save,
|
save,
|
||||||
@@ -17,12 +21,15 @@ export const FormerLayout = (props: PropsWithChildren) => {
|
|||||||
} = useFormerStore((state) => ({
|
} = useFormerStore((state) => ({
|
||||||
disableHTMlForm: state.disableHTMlForm,
|
disableHTMlForm: state.disableHTMlForm,
|
||||||
getFormMethods: state.getFormMethods,
|
getFormMethods: state.getFormMethods,
|
||||||
|
id: state.id,
|
||||||
load: state.load,
|
load: state.load,
|
||||||
loading: state.loading,
|
loading: state.loading,
|
||||||
loadingOverlayProps: state.loadingOverlayProps,
|
loadingOverlayProps: state.loadingOverlayProps,
|
||||||
|
opened: state.opened,
|
||||||
request: state.request,
|
request: state.request,
|
||||||
reset: state.reset,
|
reset: state.reset,
|
||||||
save: state.save,
|
save: state.save,
|
||||||
|
|
||||||
scrollAreaProps: state.scrollAreaProps,
|
scrollAreaProps: state.scrollAreaProps,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -33,10 +40,11 @@ export const FormerLayout = (props: PropsWithChildren) => {
|
|||||||
load(true);
|
load(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [getFormMethods, request]);
|
}, [getFormMethods, request, opened]);
|
||||||
|
|
||||||
if (disableHTMlForm) {
|
return (
|
||||||
return (
|
<>
|
||||||
|
<FormerLayoutTop />
|
||||||
<ScrollAreaAutosize
|
<ScrollAreaAutosize
|
||||||
offsetScrollbars
|
offsetScrollbars
|
||||||
scrollbarSize={4}
|
scrollbarSize={4}
|
||||||
@@ -44,13 +52,29 @@ export const FormerLayout = (props: PropsWithChildren) => {
|
|||||||
{...scrollAreaProps}
|
{...scrollAreaProps}
|
||||||
style={{
|
style={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
maxHeight: '89vh',
|
|
||||||
padding: '0.25rem',
|
padding: '0.25rem',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
...scrollAreaProps?.style,
|
...scrollAreaProps?.style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
{disableHTMlForm ? (
|
||||||
|
// eslint-disable-next-line react/no-unknown-property
|
||||||
|
<div key={`former_d${id}`} x-data-request={request}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
id={`former_f${id}`}
|
||||||
|
key={`former_${id}`}
|
||||||
|
onReset={(e) => reset(e)}
|
||||||
|
onSubmit={(e) => save(e)}
|
||||||
|
// eslint-disable-next-line react/no-unknown-property
|
||||||
|
x-data-request={request}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
<LoadingOverlay
|
<LoadingOverlay
|
||||||
loaderProps={{ type: 'bars' }}
|
loaderProps={{ type: 'bars' }}
|
||||||
overlayProps={{
|
overlayProps={{
|
||||||
@@ -60,34 +84,7 @@ export const FormerLayout = (props: PropsWithChildren) => {
|
|||||||
visible={loading}
|
visible={loading}
|
||||||
/>
|
/>
|
||||||
</ScrollAreaAutosize>
|
</ScrollAreaAutosize>
|
||||||
);
|
<FormerLayoutBottom />
|
||||||
}
|
</>
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollAreaAutosize
|
|
||||||
offsetScrollbars
|
|
||||||
scrollbarSize={4}
|
|
||||||
type="auto"
|
|
||||||
{...scrollAreaProps}
|
|
||||||
style={{
|
|
||||||
height: '100%',
|
|
||||||
maxHeight: '89vh',
|
|
||||||
padding: '0.25rem',
|
|
||||||
width: '100%',
|
|
||||||
...scrollAreaProps?.style,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form onReset={(e) => reset(e)} onSubmit={(e) => save(e)}>
|
|
||||||
{props.children}
|
|
||||||
<LoadingOverlay
|
|
||||||
loaderProps={{ type: 'bars' }}
|
|
||||||
overlayProps={{
|
|
||||||
backgroundOpacity: 0.5,
|
|
||||||
}}
|
|
||||||
{...loadingOverlayProps}
|
|
||||||
visible={loading}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</ScrollAreaAutosize>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
24
src/Former/FormerLayoutBottom.tsx
Normal file
24
src/Former/FormerLayoutBottom.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useFormerStore } from './Former.store';
|
||||||
|
import { FormerButtonArea } from './FormerButtonArea';
|
||||||
|
|
||||||
|
export const FormerLayoutBottom = () => {
|
||||||
|
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 ?? false,
|
||||||
|
getState('onClose') ?? (() => {setState('opened', false)}),
|
||||||
|
getState('onOpen') ?? (() => {setState('opened', true)}),
|
||||||
|
getState
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buttonArea === "bottom" ? <FormerButtonArea /> : <></>;
|
||||||
|
};
|
||||||
23
src/Former/FormerLayoutTop.tsx
Normal file
23
src/Former/FormerLayoutTop.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useFormerStore } from './Former.store';
|
||||||
|
import { FormerButtonArea } from './FormerButtonArea';
|
||||||
|
|
||||||
|
export const FormerLayoutTop = () => {
|
||||||
|
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 ?? false,
|
||||||
|
getState('onClose') ?? (() => {setState('opened', false)}),
|
||||||
|
getState('onOpen') ?? (() => {setState('opened', true)}),
|
||||||
|
getState
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return buttonArea === "top" ? <FormerButtonArea /> : <></>;
|
||||||
|
};
|
||||||
77
src/Former/FormerResolveSpecAPI.ts
Normal file
77
src/Former/FormerResolveSpecAPI.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import type { FormerAPICallType } from './Former.types';
|
||||||
|
|
||||||
|
interface ResolveSpecRequest {
|
||||||
|
data?: Record<string, any>;
|
||||||
|
operation: 'create' | 'delete' | 'read' | 'update';
|
||||||
|
options?: {
|
||||||
|
columns?: string[];
|
||||||
|
computedColumns?: any[];
|
||||||
|
customOperators?: any[];
|
||||||
|
filters?: Array<{ column: string; operator: string; value: any }>;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
preload?: string[];
|
||||||
|
sort?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormerResolveSpecAPI(options: {
|
||||||
|
authToken: string;
|
||||||
|
fetchOptions?: Partial<RequestInit>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
url: string;
|
||||||
|
}): FormerAPICallType {
|
||||||
|
return async (mode, request, value, key) => {
|
||||||
|
const baseUrl = options.url.replace(/\/$/, '');
|
||||||
|
|
||||||
|
// Build URL: /[schema]/[table_or_entity]/[id]
|
||||||
|
let url = `${baseUrl}`;
|
||||||
|
if (request !== 'insert' && key) {
|
||||||
|
url = `${url}/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ResolveSpec request body
|
||||||
|
const resolveSpecRequest: ResolveSpecRequest = {
|
||||||
|
operation:
|
||||||
|
mode === 'read'
|
||||||
|
? 'read'
|
||||||
|
: request === 'delete'
|
||||||
|
? 'delete'
|
||||||
|
: request === 'update'
|
||||||
|
? 'update'
|
||||||
|
: 'create',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'mutate') {
|
||||||
|
resolveSpecRequest.data = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
cache: 'no-cache',
|
||||||
|
signal: options.signal,
|
||||||
|
...options.fetchOptions,
|
||||||
|
body: JSON.stringify(resolveSpecRequest),
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${options.authToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.fetchOptions?.headers,
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (text && text.length > 4) {
|
||||||
|
throw new Error(`${text}`);
|
||||||
|
}
|
||||||
|
throw new Error(`API request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data as unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FormerResolveSpecAPI, type ResolveSpecRequest };
|
||||||
50
src/Former/FormerRestHeadSpecAPI.ts
Normal file
50
src/Former/FormerRestHeadSpecAPI.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { FormerAPICallType } from './Former.types';
|
||||||
|
|
||||||
|
function FormerRestHeadSpecAPI(options: {
|
||||||
|
authToken: string;
|
||||||
|
fetchOptions?: Partial<RequestInit>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
url: string;
|
||||||
|
}): FormerAPICallType {
|
||||||
|
return async (mode, request, value, key) => {
|
||||||
|
const baseUrl = options.url ?? ''; // Remove trailing slashes
|
||||||
|
let url = baseUrl;
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
cache: 'no-cache',
|
||||||
|
signal: options.signal,
|
||||||
|
...options.fetchOptions,
|
||||||
|
body: mode === 'mutate' && request !== 'delete' ? JSON.stringify(value) : undefined,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${options.authToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.fetchOptions?.headers,
|
||||||
|
},
|
||||||
|
method:
|
||||||
|
mode === 'read'
|
||||||
|
? 'GET'
|
||||||
|
: request === 'delete'
|
||||||
|
? 'DELETE'
|
||||||
|
: request === 'update'
|
||||||
|
? 'PUT'
|
||||||
|
: 'POST',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request !== 'insert') {
|
||||||
|
url = `${baseUrl}/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (text && text.length > 4) {
|
||||||
|
throw new Error(`${text}`);
|
||||||
|
}
|
||||||
|
throw new Error(`API request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data as unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FormerRestHeadSpecAPI };
|
||||||
116
src/Former/FormerWrappers.tsx
Normal file
116
src/Former/FormerWrappers.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
type DrawerProps,
|
||||||
|
Modal,
|
||||||
|
type ModalProps,
|
||||||
|
Popover,
|
||||||
|
type PopoverProps,
|
||||||
|
} from '@mantine/core';
|
||||||
|
|
||||||
|
import type { FormerProps } from './Former.types';
|
||||||
|
|
||||||
|
import { Former } from './Former';
|
||||||
|
|
||||||
|
export const FormerDialog = (props: { former: FormerProps } & DrawerProps) => {
|
||||||
|
const { children, former, onClose, opened, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<Former
|
||||||
|
{...former}
|
||||||
|
onClose={onClose}
|
||||||
|
opened={opened}
|
||||||
|
wrapper={(children, opened, onClose, _onOpen, getState) => {
|
||||||
|
const values = getState('values');
|
||||||
|
const request = getState('request');
|
||||||
|
const uniqueKeyField = getState('uniqueKeyField') ?? 'id';
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
closeOnClickOutside={false}
|
||||||
|
h={'100%'}
|
||||||
|
title={
|
||||||
|
request === 'delete'
|
||||||
|
? `Delete Record - ${values?.[uniqueKeyField]}`
|
||||||
|
: request === 'insert'
|
||||||
|
? 'New Record'
|
||||||
|
: `Edit Record - ${values?.[uniqueKeyField]}`
|
||||||
|
}
|
||||||
|
{...rest}
|
||||||
|
onClose={() => onClose?.()}
|
||||||
|
opened={opened ?? false}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Former>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormerModel = (props: { former: FormerProps } & ModalProps) => {
|
||||||
|
const { children, former, onClose, opened, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<Former
|
||||||
|
{...former}
|
||||||
|
onClose={onClose}
|
||||||
|
opened={opened}
|
||||||
|
wrapper={(children, opened, onClose, _onOpen, getState) => {
|
||||||
|
const values = getState('values');
|
||||||
|
const request = getState('request');
|
||||||
|
const uniqueKeyField = getState('uniqueKeyField') ?? 'id';
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
closeOnClickOutside={false}
|
||||||
|
h={'100%'}
|
||||||
|
title={
|
||||||
|
request === 'delete'
|
||||||
|
? `Delete Record - ${values?.[uniqueKeyField]}`
|
||||||
|
: request === 'insert'
|
||||||
|
? 'New Record'
|
||||||
|
: `Edit Record - ${values?.[uniqueKeyField]}`
|
||||||
|
}
|
||||||
|
{...rest}
|
||||||
|
onClose={() => onClose?.()}
|
||||||
|
opened={opened ?? false}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Former>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FormerPopover = (
|
||||||
|
props: { former: FormerProps; target: React.ReactNode } & PopoverProps
|
||||||
|
) => {
|
||||||
|
const { children, former, onClose, opened, target, ...rest } = props;
|
||||||
|
return (
|
||||||
|
<Former
|
||||||
|
{...former}
|
||||||
|
onClose={onClose}
|
||||||
|
opened={opened}
|
||||||
|
wrapper={(children, opened, onClose) => {
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
closeOnClickOutside={false}
|
||||||
|
middlewares={{ inline: true }}
|
||||||
|
trapFocus
|
||||||
|
width={250}
|
||||||
|
withArrow
|
||||||
|
{...rest}
|
||||||
|
onClose={() => onClose?.()}
|
||||||
|
opened={opened ?? false}
|
||||||
|
>
|
||||||
|
<Popover.Target>{target}</Popover.Target>
|
||||||
|
<Popover.Dropdown>{children}</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Former>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { Former } from './Former';
|
||||||
|
export type * from './Former.types';
|
||||||
|
export { FormerButtonArea } from './FormerButtonArea';
|
||||||
|
export { FormerResolveSpecAPI } from './FormerResolveSpecAPI';
|
||||||
|
export { FormerRestHeadSpecAPI } from './FormerRestHeadSpecAPI';
|
||||||
|
export { FormerDialog, FormerModel, FormerPopover } from './FormerWrappers';
|
||||||
|
export { useFormerState } from './use-former-state';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { fn } from 'storybook/test';
|
|||||||
|
|
||||||
import { FormTest } from './example';
|
import { FormTest } from './example';
|
||||||
|
|
||||||
const Renderable = (props: any) => {
|
const Renderable = (props: unknown) => {
|
||||||
return (
|
return (
|
||||||
<Box h="100%" mih="400px" miw="400px" w="100%">
|
<Box h="100%" mih="400px" miw="400px" w="100%">
|
||||||
<FormTest {...props} />
|
<FormTest {...props} />
|
||||||
40
src/Former/stories/apiFormData.tsx
Normal file
40
src/Former/stories/apiFormData.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { TextInput } from '@mantine/core';
|
||||||
|
import { useUncontrolled } from '@mantine/hooks';
|
||||||
|
import { Controller } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { Former } from '../Former';
|
||||||
|
|
||||||
|
export const ApiFormData = (props: {
|
||||||
|
onChange?: (values: Record<string, unknown>) => void;
|
||||||
|
primeData?: Record<string, unknown>;
|
||||||
|
values?: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
const [values, setValues] = useUncontrolled<Record<string, unknown>>({
|
||||||
|
defaultValue: { authToken: '', url: '', ...props.primeData },
|
||||||
|
finalValue: { authToken: '', url: '', ...props.primeData },
|
||||||
|
onChange: props.onChange,
|
||||||
|
value: props.values,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Former
|
||||||
|
disableHTMlForm
|
||||||
|
id="api-form-data"
|
||||||
|
layout={{ saveButtonTitle: 'Save URL Parameters' }}
|
||||||
|
onChange={setValues}
|
||||||
|
primeData={props.primeData}
|
||||||
|
request="update"
|
||||||
|
uniqueKeyField="id"
|
||||||
|
values={values}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => <TextInput label="URL" type="url" {...field} />}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="authToken"
|
||||||
|
render={({ field }) => <TextInput label="Auth Token" type="password" {...field} />}
|
||||||
|
/>
|
||||||
|
</Former>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,31 +1,83 @@
|
|||||||
import { Button, Drawer, Group, Paper, Select, Stack, Switch } from '@mantine/core';
|
import { Button, Group, Select, Stack, Switch } from '@mantine/core';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Controller } from 'react-hook-form';
|
import { Controller } from 'react-hook-form';
|
||||||
|
|
||||||
import type { FormerRef } from '../Former.types';
|
import type { FormerAPICallType, FormerProps, FormerRef } from '../Former.types';
|
||||||
|
|
||||||
import { Former } from '../Former';
|
import { Former } from '../Former';
|
||||||
|
import { FormerRestHeadSpecAPI } from '../FormerRestHeadSpecAPI';
|
||||||
|
import { FormerModel } from '../FormerWrappers';
|
||||||
|
import { ApiFormData } from './apiFormData';
|
||||||
|
|
||||||
|
const StubAPI = (): FormerAPICallType => (mode, request, value) => {
|
||||||
|
console.log('API Call', mode, request, value);
|
||||||
|
if (mode === 'read') {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve({ a: 'Another Value', test: 'Loaded Value' });
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(value || {});
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const FormTest = () => {
|
export const FormTest = () => {
|
||||||
const [request, setRequest] = useState<null | string>('insert');
|
const [request, setRequest] = useState<null | string>('insert');
|
||||||
const [wrapped, setWrapped] = useState(false);
|
const [wrapped, setWrapped] = useState(false);
|
||||||
|
const [disableHTML, setDisableHTML] = useState(false);
|
||||||
|
const [apiOptions, setApiOptions] = useState({
|
||||||
|
authToken: '',
|
||||||
|
type: '',
|
||||||
|
url: '',
|
||||||
|
});
|
||||||
|
const [layout, setLayout] = useState({
|
||||||
|
buttonArea: "bottom",
|
||||||
|
buttonAreaGroupProps: { justify: 'center' },
|
||||||
|
title: 'Custom Former Title',
|
||||||
|
} as FormerProps['layout']);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [formData, setFormData] = useState({ a: 99 });
|
const [formData, setFormData] = useState({ a: 99, rid_usernote: 3047 });
|
||||||
console.log('formData', formData);
|
//console.log('formData render', formData);
|
||||||
|
|
||||||
const ref = useRef<FormerRef>(null);
|
const ref = useRef<FormerRef>(null);
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" mih="400px" w="90%">
|
<Stack h="100%" mih="400px" w="90%">
|
||||||
<Select
|
<Group>
|
||||||
data={['insert', 'update', 'delete', 'select', 'view']}
|
<Select
|
||||||
onChange={setRequest}
|
data={['insert', 'update', 'delete', 'select', 'view']}
|
||||||
value={request}
|
onChange={setRequest}
|
||||||
/>
|
value={request}
|
||||||
<Switch
|
/>
|
||||||
checked={wrapped}
|
<Switch
|
||||||
label="Wrapped in Drawer"
|
checked={wrapped}
|
||||||
onChange={(event) => setWrapped(event.currentTarget.checked)}
|
label="Wrapped in Drawer"
|
||||||
/>
|
onChange={(event) => setWrapped(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checked={disableHTML}
|
||||||
|
label="Disable HTML Form"
|
||||||
|
onChange={(event) => setDisableHTML(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
data={['top', 'bottom', 'none']}
|
||||||
|
|
||||||
|
onChange={(value) => setLayout({ ...layout, buttonArea: value as 'bottom' | 'none' | 'top' })}
|
||||||
|
value={layout?.buttonArea}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
checked={apiOptions.type === 'api'}
|
||||||
|
label="Use API"
|
||||||
|
onChange={(event) =>
|
||||||
|
setApiOptions({ ...apiOptions, type: event.currentTarget.checked ? 'api' : '' })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
<Button onClick={() => setOpen(true)}>Open Former Drawer</Button>
|
<Button onClick={() => setOpen(true)}>Open Former Drawer</Button>
|
||||||
<Group>
|
<Group>
|
||||||
<Button
|
<Button
|
||||||
@@ -46,56 +98,53 @@ export const FormTest = () => {
|
|||||||
Test Show/Hide
|
Test Show/Hide
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
<FormerModel former={{ request: 'insert' }} onClose={() => setOpen(false)} opened={open}>
|
||||||
|
<div>Test</div>
|
||||||
|
</FormerModel>
|
||||||
<Former
|
<Former
|
||||||
//wrapper={(children, getState) => <div>{children}</div>}
|
disableHTMlForm={disableHTML}
|
||||||
//opened={true}
|
layout={layout}
|
||||||
apiKeyField="a"
|
onAPICall={
|
||||||
onAPICall={(mode, request, value) => {
|
apiOptions.type === 'api'
|
||||||
console.log('API Call', mode, request, value);
|
? FormerRestHeadSpecAPI({
|
||||||
if (mode === 'read') {
|
authToken: apiOptions.authToken,
|
||||||
return new Promise((resolve) => {
|
url: apiOptions.url,
|
||||||
setTimeout(() => {
|
})
|
||||||
resolve({ a: 'Another Value', test: 'Loaded Value' });
|
: StubAPI()
|
||||||
}, 1000);
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve(value || {});
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onChange={setFormData}
|
onChange={setFormData}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
opened={open}
|
opened={open}
|
||||||
primeData={{ a: '66', test: 'primed' }}
|
primeData={{ a: '66', test: 'primed' }}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
request={request as any}
|
request={request as any}
|
||||||
|
//wrapper={(children, getState) => <div>{children}</div>}
|
||||||
|
//opened={true}
|
||||||
|
uniqueKeyField="rid_usernote"
|
||||||
useFormProps={{ criteriaMode: 'all', shouldUseNativeValidation: false }}
|
useFormProps={{ criteriaMode: 'all', shouldUseNativeValidation: false }}
|
||||||
values={formData}
|
values={formData}
|
||||||
wrapper={
|
// wrapper={
|
||||||
wrapped
|
// wrapped
|
||||||
? (children, opened, onClose, onOpen, getState) => {
|
// ? (children, opened, onClose, _onOpen, getState) => {
|
||||||
const values = getState('values');
|
// const values = getState('values');
|
||||||
return (
|
// return (
|
||||||
<Drawer
|
// <Drawer
|
||||||
h={'100%'}
|
// h={'100%'}
|
||||||
onClose={() => onClose?.()}
|
// onClose={() => onClose?.()}
|
||||||
opened={opened ?? false}
|
// opened={opened ?? false}
|
||||||
title={`Drawer Former - Current A Value: ${values?.a}`}
|
// title={`Drawer Former - Current A Value: ${values?.a}`}
|
||||||
w={'50%'}
|
// w={'50%'}
|
||||||
>
|
// >
|
||||||
<Paper h="100%" shadow="sm" w="100%" withBorder>
|
// <Paper h="100%" shadow="sm" w="100%" withBorder>
|
||||||
{children}
|
// {children}
|
||||||
<Button>Test Save</Button>
|
// </Paper>
|
||||||
</Paper>
|
// </Drawer>
|
||||||
</Drawer>
|
// );
|
||||||
);
|
// }
|
||||||
}
|
// : undefined
|
||||||
: undefined
|
// }
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Stack h="1200px">
|
<Stack pb={'400px'}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Controller
|
<Controller
|
||||||
name="test"
|
name="test"
|
||||||
@@ -106,13 +155,28 @@ export const FormTest = () => {
|
|||||||
render={({ field }) => <input type="text" {...field} placeholder="B" />}
|
render={({ field }) => <input type="text" {...field} placeholder="B" />}
|
||||||
rules={{ required: 'Field is required' }}
|
rules={{ required: 'Field is required' }}
|
||||||
/>
|
/>
|
||||||
|
<Controller
|
||||||
|
name="note"
|
||||||
|
render={({ field }) => <input type="text" {...field} placeholder="note" />}
|
||||||
|
rules={{ required: 'Field is required' }}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack>
|
{!disableHTML && (
|
||||||
<button type="submit">Submit</button>
|
<Stack>
|
||||||
<button type="reset">Reset</button>
|
<button type="submit">HTML Submit</button>
|
||||||
</Stack>
|
<button type="reset">HTML Reset</button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Former>
|
</Former>
|
||||||
|
{apiOptions.type === 'api' && (
|
||||||
|
<ApiFormData
|
||||||
|
onChange={(values) => {
|
||||||
|
setApiOptions({ ...apiOptions, ...values });
|
||||||
|
}}
|
||||||
|
values={apiOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
- [ ] Headerspec API
|
- [x] Wrapper must receive button areas etc. Better scroll areas.
|
||||||
- [ ] Relspec API
|
- [x] Predefined wrappers (Model,Dialog,notification,popover)
|
||||||
|
- [x] Headerspec API
|
||||||
|
- [x] Relspec API
|
||||||
- [ ] SocketSpec API
|
- [ ] SocketSpec API
|
||||||
- [ ] Layout Tool
|
- [x] Layout Tool
|
||||||
- [ ] Header Section
|
- [x] Header Section
|
||||||
- [ ] Button Section
|
- [x] Button Section
|
||||||
- [ ] Footer Section
|
- [x] Footer Section
|
||||||
- [ ] Different Loaded for saving vs loading
|
- [ ] Different Loaded for saving vs loading
|
||||||
- [ ] Better Confirm Dialog
|
- [ ] Better Confirm Dialog
|
||||||
- [ ] Reset Confirm Dialog
|
- [ ] Reset Confirm Dialog
|
||||||
|
|||||||
38
src/Former/use-former-state.tsx
Normal file
38
src/Former/use-former-state.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { FieldValues } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { FormerProps } from './Former.types';
|
||||||
|
|
||||||
|
export type UseFormerStateProps<T extends FieldValues = FieldValues> = Pick<
|
||||||
|
FormerProps<T>,
|
||||||
|
'onChange' | 'onClose' | 'opened' | 'primeData' | 'request' | 'values'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const useFormerState = <T extends FieldValues = FieldValues>(
|
||||||
|
options?: Partial<UseFormerStateProps<T>>
|
||||||
|
) => {
|
||||||
|
const [state, setState] = useState<Partial<UseFormerStateProps<T>>>({
|
||||||
|
onChange: options?.onChange,
|
||||||
|
onClose: options?.onClose ?? (() => setState((cv) => ({ ...cv, opened: false }))),
|
||||||
|
opened: options?.opened ?? false,
|
||||||
|
primeData: options?.primeData ?? options?.values,
|
||||||
|
request: options?.request ?? 'insert',
|
||||||
|
values: options?.values,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateState = (updates: Partial<UseFormerStateProps<T>>) => {
|
||||||
|
setState((prev) => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const { onChange, onClose, opened, ...formerProps } = state;
|
||||||
|
|
||||||
|
return {
|
||||||
|
former: { ...formerProps, onChange, onClose, opened },
|
||||||
|
formerWrapper: { onClose, opened },
|
||||||
|
open: (request: UseFormerStateProps<T>['request'], data: UseFormerStateProps<T>['values']) => {
|
||||||
|
setState((cv) => ({ ...cv, opened: true, primeData: data, request, values: data }));
|
||||||
|
},
|
||||||
|
updateState,
|
||||||
|
};
|
||||||
|
};
|
||||||
35
src/FormerControllers/Buttons/ButtonCtrl.tsx
Normal file
35
src/FormerControllers/Buttons/ButtonCtrl.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Button, type ButtonProps, Tooltip } from '@mantine/core';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { SpecialIDProps } from '../FormerControllers.types';
|
||||||
|
|
||||||
|
const ButtonCtrl = (
|
||||||
|
props: {
|
||||||
|
onClick?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => Promise<void>;
|
||||||
|
} & Omit<ButtonProps, 'onClick'> &
|
||||||
|
SpecialIDProps
|
||||||
|
) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
return (
|
||||||
|
<Tooltip label={props.tooltip ?? ''} withArrow>
|
||||||
|
<Button
|
||||||
|
loaderProps={{
|
||||||
|
type: 'bars',
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
loading={loading || props.loading}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (props.onClick) {
|
||||||
|
setLoading(true);
|
||||||
|
props.onClick(e).finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ButtonCtrl };
|
||||||
|
export default ButtonCtrl;
|
||||||
36
src/FormerControllers/Buttons/IconButtonCtrl.tsx
Normal file
36
src/FormerControllers/Buttons/IconButtonCtrl.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { ActionIcon, type ActionIconProps, Tooltip, VisuallyHidden } from '@mantine/core';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { SpecialIDProps } from '../FormerControllers.types';
|
||||||
|
|
||||||
|
const IconButtonCtrl = (
|
||||||
|
props: {
|
||||||
|
onClick?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => Promise<void>;
|
||||||
|
} & Omit<ActionIconProps, 'onClick'> &
|
||||||
|
SpecialIDProps
|
||||||
|
) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
return (
|
||||||
|
<Tooltip label={props.tooltip ?? ''} withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
loaderProps={{
|
||||||
|
type: 'bars',
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
loading={loading || props.loading}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (props.onClick) {
|
||||||
|
setLoading(true);
|
||||||
|
props.onClick(e).finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
<VisuallyHidden>Action Button: {props.tooltip ?? props.sid ?? ''}</VisuallyHidden>
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { IconButtonCtrl };
|
||||||
|
export default IconButtonCtrl;
|
||||||
8
src/FormerControllers/FormerControllers.types.ts
Normal file
8
src/FormerControllers/FormerControllers.types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ControllerProps } from 'react-hook-form';
|
||||||
|
|
||||||
|
export type FormerControllersProps = Omit<ControllerProps, 'render'>;
|
||||||
|
|
||||||
|
export interface SpecialIDProps {
|
||||||
|
sid?: string;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
38
src/FormerControllers/Inputs/InlineWapper.module.css
Normal file
38
src/FormerControllers/Inputs/InlineWapper.module.css
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
.prompt {
|
||||||
|
border-top-right-radius: 0px;
|
||||||
|
border-bottom-right-radius: 0px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-right: 0px;
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
border: 1px solid #373a40;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
&:not([data-promptArea]) {
|
||||||
|
border-top-left-radius: 0px;
|
||||||
|
border-bottom-left-radius: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-promptArea] {
|
||||||
|
border-top-right-radius: 0px;
|
||||||
|
border-bottom-right-radius: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
color: black;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
border: 1px solid #373a40;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.root {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
176
src/FormerControllers/Inputs/InlineWrapper.tsx
Normal file
176
src/FormerControllers/Inputs/InlineWrapper.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Center,
|
||||||
|
Flex,
|
||||||
|
type FlexProps,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
type TitleProps,
|
||||||
|
Tooltip,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import classes from './InlineWapper.module.css'
|
||||||
|
|
||||||
|
interface InlineWrapperCallbackProps extends Partial<InlineWrapperPropsOnly> {
|
||||||
|
classNames: React.CSSProperties
|
||||||
|
dataCssProps?: Record<string, any>
|
||||||
|
size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InlineWrapperProps extends InlineWrapperPropsOnly{
|
||||||
|
children?: ((props: InlineWrapperCallbackProps) => ReactNode) | ReactNode
|
||||||
|
}
|
||||||
|
interface InlineWrapperPropsOnly {
|
||||||
|
error?: ReactNode | string
|
||||||
|
flexProps?: FlexProps
|
||||||
|
label: ReactNode | string
|
||||||
|
labelProps?: TitleProps
|
||||||
|
promptArea?: ((props: InlineWrapperCallbackProps) => ReactNode) | ReactNode
|
||||||
|
promptWidth?: FlexProps['w']
|
||||||
|
required?: boolean
|
||||||
|
rightSection?: ((props: InlineWrapperCallbackProps) => ReactNode) | ReactNode
|
||||||
|
styles?: React.CSSProperties
|
||||||
|
tooltip?: string
|
||||||
|
value?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineWrapper(props: InlineWrapperProps) {
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Flex
|
||||||
|
gap={0}
|
||||||
|
h={undefined}
|
||||||
|
m={0}
|
||||||
|
mb={0}
|
||||||
|
p={0}
|
||||||
|
w={undefined}
|
||||||
|
wrap='nowrap'
|
||||||
|
{...props.flexProps}
|
||||||
|
bg={'var(--input-background)'}
|
||||||
|
>
|
||||||
|
{props.promptWidth && props.promptWidth !== 0 ? <Prompt {...props} /> : null}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 0,
|
||||||
|
flex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typeof props.children === 'function' ? (
|
||||||
|
props.children({ ...props, classNames: classes, size: 'xs' })
|
||||||
|
) : typeof props.children === 'object' && React.isValidElement(props.children) ? (
|
||||||
|
<props.children.type classNames={classes} size='xs' {...(typeof props.children.props === "object" ? props.children.props : {})} />
|
||||||
|
) : (
|
||||||
|
props.children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!props.rightSection ? undefined : typeof props.rightSection === 'function' ? (
|
||||||
|
props.rightSection({
|
||||||
|
...props,
|
||||||
|
classNames: classes,
|
||||||
|
size: 'xs',
|
||||||
|
})
|
||||||
|
) : typeof props.rightSection === 'object' && React.isValidElement(props.rightSection) ? (
|
||||||
|
<props.rightSection.type classNames={classes} size='xs' {...(typeof props.rightSection.props === "object" ? props.rightSection.props : {})} />
|
||||||
|
) : (
|
||||||
|
props.rightSection
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
{/* <ErrorComponent {...props} /> */}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValueEmpty(inputValue: any) {
|
||||||
|
if (inputValue === null || inputValue === undefined) return true
|
||||||
|
if (typeof inputValue === 'number') {
|
||||||
|
if (inputValue === 0) return false
|
||||||
|
} else if (typeof inputValue === 'string' || inputValue === '') {
|
||||||
|
return inputValue.trim() === ''
|
||||||
|
} else if (inputValue instanceof File) {
|
||||||
|
return inputValue.size === 0
|
||||||
|
} else if (inputValue.target) {
|
||||||
|
return isValueEmpty(inputValue.target?.value)
|
||||||
|
} else if (inputValue.constructor?.name === 'Date') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Prompt(props: Partial<InlineWrapperProps>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.tooltip ? (
|
||||||
|
<Tooltip label={props.tooltip}>
|
||||||
|
<PromptDetail {...props} />
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<PromptDetail {...props} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptDetail(props: Partial<InlineWrapperProps>) {
|
||||||
|
const colors = useColors(props)
|
||||||
|
return props.promptArea ? (
|
||||||
|
<Box maw={props.promptWidth} w={'100%'}>
|
||||||
|
{!props.promptArea ? undefined : typeof props.promptArea === 'function' ? (
|
||||||
|
props.promptArea({
|
||||||
|
...props,
|
||||||
|
classNames: classes,
|
||||||
|
dataCssProps: { 'data-promptArea': true },
|
||||||
|
size: 'xs',
|
||||||
|
})
|
||||||
|
) : typeof props.rightSection === 'object' && React.isValidElement(props.promptArea) ? (
|
||||||
|
<props.promptArea.type
|
||||||
|
classNames={classes}
|
||||||
|
data-promptArea='true'
|
||||||
|
size='xs'
|
||||||
|
{...(typeof props.promptArea?.props === "object" ? props.promptArea.props : {})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
props.promptArea
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Paper
|
||||||
|
bg={colors.paperColor}
|
||||||
|
className={classes.prompt}
|
||||||
|
px='md'
|
||||||
|
w={props.promptWidth}
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
<Center h='100%' style={{ justifyContent: 'start' }} w='100%'>
|
||||||
|
<Title c={colors.titleColor} fz='xs' order={6} {...props.labelProps}>
|
||||||
|
{props.label}
|
||||||
|
{props.required && isValueEmpty(props.value) && <span style={{ color: 'red' }}>*</span>}
|
||||||
|
</Title>
|
||||||
|
</Center>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useColors(props: Partial<InlineWrapperProps>) {
|
||||||
|
const { colorScheme } = useMantineColorScheme()
|
||||||
|
|
||||||
|
let titleColor = colorScheme === 'dark' ? 'dark.0' : 'gray.8'
|
||||||
|
let paperColor = colorScheme === 'dark' ? 'dark.7' : 'gray.1'
|
||||||
|
|
||||||
|
if (props.required && isValueEmpty(props.value)) {
|
||||||
|
paperColor = colorScheme === 'dark' ? '#413012e7' : 'yellow.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.error) {
|
||||||
|
paperColor = colorScheme === 'dark' ? 'red.7' : 'red.0'
|
||||||
|
titleColor = colorScheme === 'dark' ? 'red.0' : 'red.9'
|
||||||
|
}
|
||||||
|
return { paperColor, titleColor }
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InlineWrapper }
|
||||||
|
export type { InlineWrapperProps }
|
||||||
30
src/FormerControllers/Inputs/NativeSelectCtrl.tsx
Normal file
30
src/FormerControllers/Inputs/NativeSelectCtrl.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NativeSelect, type NativeSelectProps, Tooltip } from '@mantine/core';
|
||||||
|
import { Controller } from 'react-hook-form';
|
||||||
|
|
||||||
|
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
|
||||||
|
|
||||||
|
const NativeSelectCtrl = (props: FormerControllersProps & NativeSelectProps & SpecialIDProps) => {
|
||||||
|
const { control, name, sid, tooltip, ...innerProps } = props;
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<Tooltip label={tooltip ?? ''} withArrow>
|
||||||
|
<NativeSelect
|
||||||
|
{...innerProps}
|
||||||
|
{...field}
|
||||||
|
disabled={formState.disabled}
|
||||||
|
id={`field_${name}_${sid ?? ''}`}
|
||||||
|
key={`field_${name}_${sid ?? ''}`}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</NativeSelect>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NativeSelectCtrl };
|
||||||
|
export default NativeSelectCtrl;
|
||||||
36
src/FormerControllers/Inputs/NumberInputCtrl.tsx
Normal file
36
src/FormerControllers/Inputs/NumberInputCtrl.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NumberInput, type NumberInputProps, Tooltip } from '@mantine/core';
|
||||||
|
import { Controller } from 'react-hook-form';
|
||||||
|
|
||||||
|
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
|
||||||
|
|
||||||
|
const NumberInputCtrl = (props: FormerControllersProps & NumberInputProps & SpecialIDProps) => {
|
||||||
|
const { control, name, sid, tooltip, ...textProps } = props;
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<Tooltip label={tooltip ?? ''} withArrow>
|
||||||
|
<NumberInput
|
||||||
|
{...textProps}
|
||||||
|
{...field}
|
||||||
|
disabled={formState.disabled}
|
||||||
|
id={`field_${name}_${sid ?? ''}`}
|
||||||
|
key={`field_${name}_${sid ?? ''}`}
|
||||||
|
onChange={(num) =>
|
||||||
|
field.onChange(num !== undefined && num !== null ? Number(num) : undefined)
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
field.value !== undefined && field.value !== null ? Number(field.value) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</NumberInput>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NumberInputCtrl };
|
||||||
|
export default NumberInputCtrl;
|
||||||
30
src/FormerControllers/Inputs/PasswordInputCtrl.tsx
Normal file
30
src/FormerControllers/Inputs/PasswordInputCtrl.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { PasswordInput, type PasswordInputProps, Tooltip } from '@mantine/core';
|
||||||
|
import { Controller } from 'react-hook-form';
|
||||||
|
|
||||||
|
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
|
||||||
|
|
||||||
|
const PasswordInputCtrl = (props: FormerControllersProps & PasswordInputProps & SpecialIDProps) => {
|
||||||
|
const { control, name, sid, tooltip, ...textProps } = props;
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<Tooltip label={tooltip ?? ''} withArrow>
|
||||||
|
<PasswordInput
|
||||||
|
{...textProps}
|
||||||
|
{...field}
|
||||||
|
disabled={formState.disabled}
|
||||||
|
id={`field_${name}_${sid ?? ''}`}
|
||||||
|
key={`field_${name}_${sid ?? ''}`}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</PasswordInput>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { PasswordInputCtrl };
|
||||||
|
export default PasswordInputCtrl;
|
||||||
32
src/FormerControllers/Inputs/SwitchCtrl.tsx
Normal file
32
src/FormerControllers/Inputs/SwitchCtrl.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Switch, type SwitchProps, Tooltip } from '@mantine/core';
|
||||||
|
import { Controller } from 'react-hook-form';
|
||||||
|
|
||||||
|
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
|
||||||
|
|
||||||
|
const SwitchCtrl = (props: FormerControllersProps & SpecialIDProps & SwitchProps) => {
|
||||||
|
const { control, name, sid, tooltip, ...innerProps } = props;
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<Tooltip label={tooltip ?? ''} withArrow>
|
||||||
|
<Switch
|
||||||
|
{...innerProps}
|
||||||
|
{...field}
|
||||||
|
checked={!!field.value}
|
||||||
|
disabled={formState.disabled}
|
||||||
|
id={`field_${name}_${sid ?? ''}`}
|
||||||
|
key={`field_${name}_${sid ?? ''}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange((e.currentTarget ?? e.target)?.checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { SwitchCtrl };
|
||||||
|
export default SwitchCtrl;
|
||||||
31
src/FormerControllers/Inputs/TextAreaCtrl.tsx
Normal file
31
src/FormerControllers/Inputs/TextAreaCtrl.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Textarea, type TextareaProps, Tooltip } from '@mantine/core';
|
||||||
|
import { Controller } from 'react-hook-form';
|
||||||
|
|
||||||
|
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
|
||||||
|
|
||||||
|
const TextAreaCtrl = (props: FormerControllersProps & SpecialIDProps & TextareaProps) => {
|
||||||
|
const { control, name, sid, tooltip, ...innerProps } = props;
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<Tooltip label={tooltip ?? ''} withArrow>
|
||||||
|
<Textarea
|
||||||
|
minRows={4}
|
||||||
|
{...innerProps}
|
||||||
|
{...field}
|
||||||
|
disabled={formState.disabled}
|
||||||
|
id={`field_${name}_${sid ?? ''}`}
|
||||||
|
key={`field_${name}_${sid ?? ''}`}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Textarea>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { TextAreaCtrl };
|
||||||
|
export default TextAreaCtrl;
|
||||||
30
src/FormerControllers/Inputs/TextInputCtrl.tsx
Normal file
30
src/FormerControllers/Inputs/TextInputCtrl.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { TextInput, type TextInputProps, Tooltip } from '@mantine/core';
|
||||||
|
import { Controller } from 'react-hook-form';
|
||||||
|
|
||||||
|
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
|
||||||
|
|
||||||
|
const TextInputCtrl = (props: FormerControllersProps & SpecialIDProps & TextInputProps) => {
|
||||||
|
const { control, name, sid, tooltip, ...textProps } = props;
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field, formState }) => (
|
||||||
|
<Tooltip label={tooltip ?? ''} withArrow>
|
||||||
|
<TextInput
|
||||||
|
{...textProps}
|
||||||
|
{...field}
|
||||||
|
disabled={formState.disabled}
|
||||||
|
id={`field_${name}_${sid ?? ''}`}
|
||||||
|
key={`field_${name}_${sid ?? ''}`}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</TextInput>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { TextInputCtrl };
|
||||||
|
export default TextInputCtrl;
|
||||||
7
src/FormerControllers/index.ts
Normal file
7
src/FormerControllers/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { ButtonCtrl } from './Buttons/ButtonCtrl';
|
||||||
|
export { IconButtonCtrl } from './Buttons/IconButtonCtrl';
|
||||||
|
export { NativeSelectCtrl } from './Inputs/NativeSelectCtrl';
|
||||||
|
export { PasswordInputCtrl } from './Inputs/PasswordInputCtrl';
|
||||||
|
export { SwitchCtrl } from './Inputs/SwitchCtrl';
|
||||||
|
export { TextAreaCtrl } from './Inputs/TextAreaCtrl';
|
||||||
|
export { TextInputCtrl } from './Inputs/TextInputCtrl';
|
||||||
50
src/FormerControllers/stories/Formers.goapi.stories.tsx
Normal file
50
src/FormerControllers/stories/Formers.goapi.stories.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//@ts-nocheck
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
import { Stack } from '@mantine/core';
|
||||||
|
import { fn } from 'storybook/test';
|
||||||
|
|
||||||
|
import { Former, NativeSelectCtrl, TextInputCtrl } from '../../lib';
|
||||||
|
import { InlineWrapper } from '../Inputs/InlineWrapper';
|
||||||
|
import NumberInputCtrl from '../Inputs/NumberInputCtrl';
|
||||||
|
|
||||||
|
const Renderable = () => {
|
||||||
|
return (
|
||||||
|
<Former>
|
||||||
|
<Stack h="100%" mih="400px" miw="400px" w="100%">
|
||||||
|
<TextInputCtrl label="Test" name="test" />
|
||||||
|
<NumberInputCtrl label="AgeTest" name="age" />
|
||||||
|
<InlineWrapper label="Select One" promptWidth={200}>
|
||||||
|
<NativeSelectCtrl data={["One","Two","Three"]} name="option1"/>
|
||||||
|
</InlineWrapper>
|
||||||
|
</Stack>
|
||||||
|
</Former>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
|
||||||
|
args: { onClick: fn() },
|
||||||
|
// More on argTypes: https://storybook.js.org/docs/api/argtypes
|
||||||
|
argTypes: {
|
||||||
|
backgroundColor: { control: 'color' },
|
||||||
|
},
|
||||||
|
component: Renderable,
|
||||||
|
parameters: {
|
||||||
|
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
|
||||||
|
//layout: 'centered',
|
||||||
|
},
|
||||||
|
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
|
||||||
|
tags: ['autodocs'],
|
||||||
|
title: 'Former/Controls Basic',
|
||||||
|
} satisfies Meta<typeof Renderable>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
|
||||||
|
export const BasicExample: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Test',
|
||||||
|
},
|
||||||
|
};
|
||||||
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 />,
|
||||||
|
};
|
||||||
454
src/GlobalStateStore/GlobalStateStore.ts
Normal file
454
src/GlobalStateStore/GlobalStateStore.ts
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
import type { StoreApi } from 'zustand';
|
||||||
|
|
||||||
|
import { shallow } from 'zustand/shallow';
|
||||||
|
import { useStoreWithEqualityFn } from 'zustand/traditional';
|
||||||
|
import { createStore } from 'zustand/vanilla';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BarState,
|
||||||
|
ExtractState,
|
||||||
|
GlobalState,
|
||||||
|
GlobalStateStoreType,
|
||||||
|
LayoutState,
|
||||||
|
NavigationState,
|
||||||
|
OwnerState,
|
||||||
|
ProgramState,
|
||||||
|
SessionState,
|
||||||
|
UserState,
|
||||||
|
} from './GlobalStateStore.types';
|
||||||
|
|
||||||
|
import { loadStorage, saveStorage } from './GlobalStateStore.utils';
|
||||||
|
|
||||||
|
const initialState: GlobalState = {
|
||||||
|
initialized: false,
|
||||||
|
layout: {
|
||||||
|
bottomBar: { open: false },
|
||||||
|
leftBar: { open: false },
|
||||||
|
rightBar: { open: false },
|
||||||
|
topBar: { open: false },
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
menu: [],
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
guid: '',
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
program: {
|
||||||
|
controls: {},
|
||||||
|
environment: 'production',
|
||||||
|
guid: '',
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
apiURL: '',
|
||||||
|
authToken: '',
|
||||||
|
connected: true,
|
||||||
|
loading: false,
|
||||||
|
loggedIn: false,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
guid: '',
|
||||||
|
username: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetState = () => GlobalStateStoreType;
|
||||||
|
type SetState = (
|
||||||
|
partial: ((state: GlobalState) => Partial<GlobalState>) | Partial<GlobalState>
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
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 createComplexActions = (set: SetState, get: GetState) => {
|
||||||
|
// Internal implementation without lock
|
||||||
|
const fetchDataInternal = async (url?: string) => {
|
||||||
|
try {
|
||||||
|
set((state: GlobalState) => ({
|
||||||
|
session: {
|
||||||
|
...state.session,
|
||||||
|
apiURL: url || state.session.apiURL,
|
||||||
|
loading: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const currentState = get();
|
||||||
|
const result = await currentState.onFetchSession?.(currentState);
|
||||||
|
|
||||||
|
set((state: GlobalState) => ({
|
||||||
|
...state,
|
||||||
|
...result,
|
||||||
|
layout: { ...state.layout, ...result?.layout },
|
||||||
|
navigation: { ...state.navigation, ...result?.navigation },
|
||||||
|
owner: {
|
||||||
|
...state.owner,
|
||||||
|
...result?.owner,
|
||||||
|
},
|
||||||
|
program: {
|
||||||
|
...state.program,
|
||||||
|
...result?.program,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
...state.session,
|
||||||
|
...result?.session,
|
||||||
|
connected: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
...state.user,
|
||||||
|
...result?.user,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
set((state: GlobalState) => ({
|
||||||
|
session: {
|
||||||
|
...state.session,
|
||||||
|
connected: false,
|
||||||
|
error: `Load Exception: ${String(e)}`,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchData: async (url?: string) => {
|
||||||
|
// Wait for initialization to complete
|
||||||
|
await waitForInitialization();
|
||||||
|
|
||||||
|
// Use lock to prevent concurrent fetchData calls
|
||||||
|
return withOperationLock(() => fetchDataInternal(url));
|
||||||
|
},
|
||||||
|
|
||||||
|
isLoggedIn: (): boolean => {
|
||||||
|
const session = get().session;
|
||||||
|
if (!session.loggedIn || !session.authToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (session.expiryDate && new Date(session.expiryDate) < new Date()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
login: async (authToken?: string, user?: Partial<UserState>) => {
|
||||||
|
// Wait for initialization to complete
|
||||||
|
await waitForInitialization();
|
||||||
|
|
||||||
|
// Use lock to prevent concurrent auth operations
|
||||||
|
return withOperationLock(async () => {
|
||||||
|
try {
|
||||||
|
set((state: GlobalState) => ({
|
||||||
|
session: {
|
||||||
|
...state.session,
|
||||||
|
authToken: authToken ?? '',
|
||||||
|
expiryDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
loading: true,
|
||||||
|
loggedIn: true,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
...state.user,
|
||||||
|
...user,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const currentState = get();
|
||||||
|
const result = await currentState.onLogin?.(currentState);
|
||||||
|
if (result) {
|
||||||
|
set((state: GlobalState) => ({
|
||||||
|
...state,
|
||||||
|
owner: result.owner ? { ...state.owner, ...result.owner } : state.owner,
|
||||||
|
program: result.program ? { ...state.program, ...result.program } : state.program,
|
||||||
|
session: result.session ? { ...state.session, ...result.session } : state.session,
|
||||||
|
user: result.user ? { ...state.user, ...result.user } : state.user,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Call internal version to avoid nested lock
|
||||||
|
await fetchDataInternal();
|
||||||
|
} catch (e) {
|
||||||
|
set((state: GlobalState) => ({
|
||||||
|
session: {
|
||||||
|
...state.session,
|
||||||
|
connected: false,
|
||||||
|
error: `Login Exception: ${String(e)}`,
|
||||||
|
loading: false,
|
||||||
|
loggedIn: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
set((state: GlobalState) => ({
|
||||||
|
session: {
|
||||||
|
...state.session,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
// Wait for initialization to complete
|
||||||
|
await waitForInitialization();
|
||||||
|
|
||||||
|
// Use lock to prevent concurrent auth operations
|
||||||
|
return withOperationLock(async () => {
|
||||||
|
try {
|
||||||
|
set((state: GlobalState) => ({
|
||||||
|
...initialState,
|
||||||
|
session: {
|
||||||
|
...initialState.session,
|
||||||
|
apiURL: state.session.apiURL,
|
||||||
|
expiryDate: undefined,
|
||||||
|
loading: true,
|
||||||
|
loggedIn: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const currentState = get();
|
||||||
|
const result = await currentState.onLogout?.(currentState);
|
||||||
|
if (result) {
|
||||||
|
set((state: GlobalState) => ({
|
||||||
|
...state,
|
||||||
|
owner: result.owner ? { ...state.owner, ...result.owner } : state.owner,
|
||||||
|
program: result.program ? { ...state.program, ...result.program } : state.program,
|
||||||
|
session: result.session ? { ...state.session, ...result.session } : state.session,
|
||||||
|
user: result.user ? { ...state.user, ...result.user } : state.user,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Call internal version to avoid nested lock
|
||||||
|
await fetchDataInternal();
|
||||||
|
} catch (e) {
|
||||||
|
set((state: GlobalState) => ({
|
||||||
|
session: {
|
||||||
|
...state.session,
|
||||||
|
connected: false,
|
||||||
|
error: `Logout Exception: ${String(e)}`,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
set((state: GlobalState) => ({
|
||||||
|
session: {
|
||||||
|
...state.session,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// State management flags and locks - must be defined before store creation
|
||||||
|
let isStorageInitialized = false;
|
||||||
|
let initializationPromise: null | Promise<void> = null;
|
||||||
|
let operationLock: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
// Helper to wait for initialization - must be defined before store creation
|
||||||
|
const waitForInitialization = async (): Promise<void> => {
|
||||||
|
if (initializationPromise) {
|
||||||
|
await initializationPromise;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to ensure async operations run sequentially
|
||||||
|
const withOperationLock = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||||
|
const currentLock = operationLock;
|
||||||
|
let releaseLock: () => void;
|
||||||
|
|
||||||
|
// Create new lock promise
|
||||||
|
operationLock = new Promise<void>((resolve) => {
|
||||||
|
releaseLock = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait for previous operation to complete
|
||||||
|
await currentLock;
|
||||||
|
// Run the operation
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
// Release the lock
|
||||||
|
releaseLock!();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const GlobalStateStore = createStore<GlobalStateStoreType>((set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
...createProgramSlice(set),
|
||||||
|
...createSessionSlice(set),
|
||||||
|
...createOwnerSlice(set),
|
||||||
|
...createUserSlice(set),
|
||||||
|
...createLayoutSlice(set),
|
||||||
|
...createNavigationSlice(set),
|
||||||
|
...createComplexActions(set, get),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Initialize storage and load saved state
|
||||||
|
initializationPromise = loadStorage()
|
||||||
|
.then((state) => {
|
||||||
|
// Merge loaded state with initial state
|
||||||
|
GlobalStateStore.setState(
|
||||||
|
(current) => ({
|
||||||
|
...current,
|
||||||
|
...state,
|
||||||
|
initialized: true,
|
||||||
|
session: {
|
||||||
|
...current.session,
|
||||||
|
...state.session,
|
||||||
|
connected: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
true // Replace state completely to avoid triggering subscription during init
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Error loading storage:', e);
|
||||||
|
// Mark as initialized even on error so app doesn't hang
|
||||||
|
GlobalStateStore.setState({ initialized: true });
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// Mark initialization as complete
|
||||||
|
isStorageInitialized = true;
|
||||||
|
initializationPromise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to state changes and persist to storage
|
||||||
|
// Only saves after initialization is complete
|
||||||
|
GlobalStateStore.subscribe((state) => {
|
||||||
|
if (!isStorageInitialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStorage(state).catch((e) => {
|
||||||
|
console.error('Error saving storage:', e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTypeBoundedUseStore = ((store) => (selector) =>
|
||||||
|
useStoreWithEqualityFn(store, selector, shallow)) as <S extends StoreApi<unknown>>(
|
||||||
|
store: S
|
||||||
|
) => {
|
||||||
|
(): ExtractState<S>;
|
||||||
|
<T>(selector: (state: ExtractState<S>) => T): T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useGlobalStateStore = createTypeBoundedUseStore(GlobalStateStore);
|
||||||
|
|
||||||
|
const setApiURL = (url: string) => {
|
||||||
|
GlobalStateStore.getState().setApiURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getApiURL = (): string => {
|
||||||
|
return GlobalStateStore.getState().session.apiURL ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAuthToken = (): string => {
|
||||||
|
return GlobalStateStore.getState().session.authToken ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoggedIn = (): boolean => {
|
||||||
|
return GlobalStateStore.getState().isLoggedIn();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAuthToken = (token: string) => {
|
||||||
|
GlobalStateStore.getState().setAuthToken(token);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GetGlobalState = (): GlobalStateStoreType => {
|
||||||
|
return GlobalStateStore.getState();
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
getApiURL,
|
||||||
|
getAuthToken,
|
||||||
|
GetGlobalState,
|
||||||
|
GlobalStateStore,
|
||||||
|
isLoggedIn,
|
||||||
|
setApiURL,
|
||||||
|
setAuthToken,
|
||||||
|
useGlobalStateStore,
|
||||||
|
};
|
||||||
181
src/GlobalStateStore/GlobalStateStore.types.ts
Normal file
181
src/GlobalStateStore/GlobalStateStore.types.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
interface BarState {
|
||||||
|
collapsed?: boolean;
|
||||||
|
menuItems?: MenuItem[];
|
||||||
|
meta?: Record<string, any>;
|
||||||
|
open: boolean;
|
||||||
|
pinned?: boolean;
|
||||||
|
render?: () => React.ReactNode;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseDetail = {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExtractState<S> = S extends { getState: () => infer X } ? X : never;
|
||||||
|
|
||||||
|
interface GlobalState {
|
||||||
|
initialized: boolean;
|
||||||
|
layout: LayoutState;
|
||||||
|
navigation: NavigationState;
|
||||||
|
owner: OwnerState;
|
||||||
|
program: ProgramState;
|
||||||
|
session: SessionState;
|
||||||
|
user: UserState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalStateActions {
|
||||||
|
// Complex actions
|
||||||
|
fetchData: (url?: string) => Promise<void>;
|
||||||
|
|
||||||
|
isLoggedIn: () => boolean;
|
||||||
|
login: (authToken?: string, user?: Partial<UserState>) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
// Callbacks for custom logic
|
||||||
|
onFetchSession?: (state: Partial<GlobalState>) => Promise<Partial<GlobalState>>;
|
||||||
|
onLogin?: (
|
||||||
|
state: Partial<GlobalState>
|
||||||
|
) => Promise<Partial<Pick<GlobalState, 'owner' | 'program' | 'session' | 'user'>> | void>;
|
||||||
|
onLogout?: (
|
||||||
|
state: Partial<GlobalState>
|
||||||
|
) => Promise<Partial<Pick<GlobalState, 'owner' | 'program' | 'session' | 'user'>> | void>;
|
||||||
|
setApiURL: (url: string) => void;
|
||||||
|
|
||||||
|
setAuthToken: (token: string) => 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 NavigationState {
|
||||||
|
currentPage?: PageInfo;
|
||||||
|
menu: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwnerState {
|
||||||
|
guid?: string;
|
||||||
|
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;
|
||||||
|
controls?: Record<string, any>;
|
||||||
|
database?: DatabaseDetail;
|
||||||
|
databaseVersion?: string;
|
||||||
|
description?: string;
|
||||||
|
environment: 'development' | 'production';
|
||||||
|
globals?: Record<string, any>;
|
||||||
|
guid?: string;
|
||||||
|
logo?: string;
|
||||||
|
meta?: Record<string, any>;
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
tags?: string[];
|
||||||
|
updatedAt?: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
apiURL?: string;
|
||||||
|
authToken?: string;
|
||||||
|
connected?: boolean;
|
||||||
|
error?: string;
|
||||||
|
expiryDate?: string;
|
||||||
|
isSecurity?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
loggedIn?: boolean;
|
||||||
|
meta?: Record<string, any>;
|
||||||
|
parameters?: 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 {
|
||||||
|
BarState,
|
||||||
|
ExtractState,
|
||||||
|
GlobalState,
|
||||||
|
GlobalStateActions,
|
||||||
|
GlobalStateStoreType,
|
||||||
|
LayoutState,
|
||||||
|
MenuItem,
|
||||||
|
NavigationState,
|
||||||
|
OwnerState,
|
||||||
|
PageInfo,
|
||||||
|
ProgramState,
|
||||||
|
SessionState,
|
||||||
|
ThemeSettings,
|
||||||
|
UserState,
|
||||||
|
};
|
||||||
255
src/GlobalStateStore/GlobalStateStore.utils.test.ts
Normal file
255
src/GlobalStateStore/GlobalStateStore.utils.test.ts
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { GlobalState } from './GlobalStateStore.types';
|
||||||
|
|
||||||
|
import { loadStorage, saveStorage } from './GlobalStateStore.utils';
|
||||||
|
|
||||||
|
// Mock idb-keyval
|
||||||
|
vi.mock('idb-keyval', () => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { get, set } from 'idb-keyval';
|
||||||
|
|
||||||
|
describe('GlobalStateStore.utils', () => {
|
||||||
|
const mockState: GlobalState = {
|
||||||
|
initialized: false,
|
||||||
|
layout: {
|
||||||
|
bottomBar: { open: false },
|
||||||
|
leftBar: { open: false },
|
||||||
|
rightBar: { open: false },
|
||||||
|
topBar: { open: false },
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
menu: [],
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
guid: 'owner-guid',
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Owner',
|
||||||
|
},
|
||||||
|
program: {
|
||||||
|
controls: {},
|
||||||
|
environment: 'production',
|
||||||
|
guid: 'program-guid',
|
||||||
|
name: 'Test Program',
|
||||||
|
slug: 'test-program',
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
apiURL: 'https://api.test.com',
|
||||||
|
authToken: 'test-token',
|
||||||
|
connected: true,
|
||||||
|
loading: false,
|
||||||
|
loggedIn: true,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
guid: 'user-guid',
|
||||||
|
username: 'testuser',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
|
||||||
|
// Mock indexedDB to be available
|
||||||
|
Object.defineProperty(globalThis, 'indexedDB', {
|
||||||
|
configurable: true,
|
||||||
|
value: {},
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveStorage', () => {
|
||||||
|
it('saves each key separately with prefixed storage keys', async () => {
|
||||||
|
(set as any).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await saveStorage(mockState);
|
||||||
|
|
||||||
|
// Verify IndexedDB calls for non-session keys
|
||||||
|
expect(set).toHaveBeenCalledWith('APP_GLO:layout', expect.any(String));
|
||||||
|
expect(set).toHaveBeenCalledWith('APP_GLO:navigation', expect.any(String));
|
||||||
|
expect(set).toHaveBeenCalledWith('APP_GLO:owner', expect.any(String));
|
||||||
|
expect(set).toHaveBeenCalledWith('APP_GLO:program', expect.any(String));
|
||||||
|
expect(set).toHaveBeenCalledWith('APP_GLO:user', expect.any(String));
|
||||||
|
|
||||||
|
// Verify session is NOT saved to IndexedDB
|
||||||
|
expect(set).not.toHaveBeenCalledWith('APP_GLO:session', expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves session key to localStorage only', async () => {
|
||||||
|
await saveStorage(mockState);
|
||||||
|
|
||||||
|
// Verify session is in localStorage
|
||||||
|
const sessionData = localStorage.getItem('APP_GLO:session');
|
||||||
|
expect(sessionData).toBeTruthy();
|
||||||
|
|
||||||
|
const parsedSession = JSON.parse(sessionData!);
|
||||||
|
expect(parsedSession.apiURL).toBe('https://api.test.com');
|
||||||
|
expect(parsedSession.authToken).toBe('test-token');
|
||||||
|
expect(parsedSession.loggedIn).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out skipped paths', async () => {
|
||||||
|
(set as any).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const stateWithControls: GlobalState = {
|
||||||
|
...mockState,
|
||||||
|
program: {
|
||||||
|
...mockState.program,
|
||||||
|
controls: { test: 'value' },
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
...mockState.session,
|
||||||
|
connected: true,
|
||||||
|
error: 'test error',
|
||||||
|
loading: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveStorage(stateWithControls);
|
||||||
|
|
||||||
|
// Get the saved program data
|
||||||
|
const programCall = (set as any).mock.calls.find(
|
||||||
|
(call: any[]) => call[0] === 'APP_GLO:program'
|
||||||
|
);
|
||||||
|
expect(programCall).toBeDefined();
|
||||||
|
const savedProgram = JSON.parse(programCall[1]);
|
||||||
|
|
||||||
|
// Controls should be filtered out (program.controls is in SKIP_PATHS as app.controls)
|
||||||
|
// Note: The filter checks 'app.controls' but our key is 'program', so controls might not be filtered
|
||||||
|
// Let's just verify the program was saved
|
||||||
|
expect(savedProgram.guid).toBe('program-guid');
|
||||||
|
|
||||||
|
// Get the saved session data
|
||||||
|
const sessionData = localStorage.getItem('APP_GLO:session');
|
||||||
|
const savedSession = JSON.parse(sessionData!);
|
||||||
|
|
||||||
|
// These should be filtered out (in SKIP_PATHS)
|
||||||
|
expect(savedSession.connected).toBeUndefined();
|
||||||
|
expect(savedSession.error).toBeUndefined();
|
||||||
|
expect(savedSession.loading).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to localStorage when IndexedDB fails', async () => {
|
||||||
|
// Mock IndexedDB failure for all calls
|
||||||
|
(set as any).mockRejectedValue(new Error('IndexedDB not available'));
|
||||||
|
|
||||||
|
await saveStorage(mockState);
|
||||||
|
|
||||||
|
// Verify localStorage has the data
|
||||||
|
expect(localStorage.getItem('APP_GLO:layout')).toBeTruthy();
|
||||||
|
expect(localStorage.getItem('APP_GLO:navigation')).toBeTruthy();
|
||||||
|
expect(localStorage.getItem('APP_GLO:owner')).toBeTruthy();
|
||||||
|
expect(localStorage.getItem('APP_GLO:program')).toBeTruthy();
|
||||||
|
expect(localStorage.getItem('APP_GLO:user')).toBeTruthy();
|
||||||
|
expect(localStorage.getItem('APP_GLO:session')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadStorage', () => {
|
||||||
|
it('loads each key separately from IndexedDB', async () => {
|
||||||
|
// Mock IndexedDB responses
|
||||||
|
(get as any).mockImplementation((key: string) => {
|
||||||
|
const dataMap: Record<string, string> = {
|
||||||
|
'APP_GLO:layout': JSON.stringify(mockState.layout),
|
||||||
|
'APP_GLO:navigation': JSON.stringify(mockState.navigation),
|
||||||
|
'APP_GLO:owner': JSON.stringify(mockState.owner),
|
||||||
|
'APP_GLO:program': JSON.stringify(mockState.program),
|
||||||
|
'APP_GLO:user': JSON.stringify(mockState.user),
|
||||||
|
};
|
||||||
|
return Promise.resolve(dataMap[key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set session in localStorage
|
||||||
|
localStorage.setItem('APP_GLO:session', JSON.stringify(mockState.session));
|
||||||
|
|
||||||
|
const result = await loadStorage();
|
||||||
|
|
||||||
|
expect(result.layout).toEqual(mockState.layout);
|
||||||
|
expect(result.navigation).toEqual(mockState.navigation);
|
||||||
|
expect(result.owner).toEqual(mockState.owner);
|
||||||
|
expect(result.program).toEqual(mockState.program);
|
||||||
|
expect(result.user).toEqual(mockState.user);
|
||||||
|
expect(result.session).toEqual(mockState.session);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads session from localStorage only', async () => {
|
||||||
|
localStorage.setItem('APP_GLO:session', JSON.stringify(mockState.session));
|
||||||
|
|
||||||
|
const result = await loadStorage();
|
||||||
|
|
||||||
|
expect(result.session).toEqual(mockState.session);
|
||||||
|
// Verify get was NOT called for session
|
||||||
|
expect(get).not.toHaveBeenCalledWith('APP_GLO:session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to localStorage when IndexedDB fails', async () => {
|
||||||
|
// Mock IndexedDB failure
|
||||||
|
(get as any).mockRejectedValue(new Error('IndexedDB not available'));
|
||||||
|
|
||||||
|
// Set data in localStorage
|
||||||
|
localStorage.setItem('APP_GLO:layout', JSON.stringify(mockState.layout));
|
||||||
|
localStorage.setItem('APP_GLO:navigation', JSON.stringify(mockState.navigation));
|
||||||
|
localStorage.setItem('APP_GLO:owner', JSON.stringify(mockState.owner));
|
||||||
|
localStorage.setItem('APP_GLO:program', JSON.stringify(mockState.program));
|
||||||
|
localStorage.setItem('APP_GLO:user', JSON.stringify(mockState.user));
|
||||||
|
localStorage.setItem('APP_GLO:session', JSON.stringify(mockState.session));
|
||||||
|
|
||||||
|
const result = await loadStorage();
|
||||||
|
|
||||||
|
expect(result.layout).toEqual(mockState.layout);
|
||||||
|
expect(result.navigation).toEqual(mockState.navigation);
|
||||||
|
expect(result.owner).toEqual(mockState.owner);
|
||||||
|
expect(result.program).toEqual(mockState.program);
|
||||||
|
expect(result.user).toEqual(mockState.user);
|
||||||
|
expect(result.session).toEqual(mockState.session);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object when no data is found', async () => {
|
||||||
|
(get as any).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await loadStorage();
|
||||||
|
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns partial state when some keys are missing', async () => {
|
||||||
|
// Only set some keys
|
||||||
|
(get as any).mockImplementation((key: string) => {
|
||||||
|
if (key === 'APP_GLO:layout') {
|
||||||
|
return Promise.resolve(JSON.stringify(mockState.layout));
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem('APP_GLO:session', JSON.stringify(mockState.session));
|
||||||
|
|
||||||
|
const result = await loadStorage();
|
||||||
|
|
||||||
|
expect(result.layout).toEqual(mockState.layout);
|
||||||
|
expect(result.session).toEqual(mockState.session);
|
||||||
|
expect(result.navigation).toBeUndefined();
|
||||||
|
expect(result.owner).toBeUndefined();
|
||||||
|
expect(result.program).toBeUndefined();
|
||||||
|
expect(result.user).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles corrupted JSON data gracefully', async () => {
|
||||||
|
// Mock console.error to suppress error output
|
||||||
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
(get as any).mockResolvedValue('invalid json');
|
||||||
|
localStorage.setItem('APP_GLO:session', 'invalid json');
|
||||||
|
|
||||||
|
const result = await loadStorage();
|
||||||
|
|
||||||
|
// Should log errors but still return the result (may be empty or partial)
|
||||||
|
expect(consoleError).toHaveBeenCalled();
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
134
src/GlobalStateStore/GlobalStateStore.utils.ts
Normal file
134
src/GlobalStateStore/GlobalStateStore.utils.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { get, set } from 'idb-keyval';
|
||||||
|
|
||||||
|
import type { GlobalState } from './GlobalStateStore.types';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'APP_GLO';
|
||||||
|
|
||||||
|
const SKIP_PATHS = new Set([
|
||||||
|
'app.controls',
|
||||||
|
'initialized',
|
||||||
|
'session.connected',
|
||||||
|
'session.error',
|
||||||
|
'session.loading',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const shouldSkipPath = (path: string): boolean => {
|
||||||
|
return SKIP_PATHS.has(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterState = (state: unknown, prefix = ''): unknown => {
|
||||||
|
if (typeof state === 'function') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === null || typeof state !== 'object') {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(): Promise<Partial<GlobalState>> {
|
||||||
|
const result: Partial<GlobalState> = {};
|
||||||
|
const keys: (keyof GlobalState)[] = ['layout', 'navigation', 'owner', 'program', 'session', 'user'];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const storageKey = `${STORAGE_KEY}:${key}`;
|
||||||
|
|
||||||
|
// Always use localStorage for session
|
||||||
|
if (key === 'session') {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
const data = localStorage.getItem(storageKey);
|
||||||
|
if (data) {
|
||||||
|
result[key] = JSON.parse(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to load ${key} from localStorage:`, e);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof indexedDB !== 'undefined') {
|
||||||
|
const data = await get(storageKey);
|
||||||
|
if (data) {
|
||||||
|
result[key] = JSON.parse(data);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to load ${key} from IndexedDB, falling back to localStorage:`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
const data = localStorage.getItem(storageKey);
|
||||||
|
if (data) {
|
||||||
|
result[key] = JSON.parse(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to load ${key} from localStorage:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStorage(state: GlobalState): Promise<void> {
|
||||||
|
const keys: (keyof GlobalState)[] = ['layout', 'navigation', 'owner', 'program', 'session', 'user'];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const storageKey = `${STORAGE_KEY}:${key}`;
|
||||||
|
const filtered = filterState(state[key], key);
|
||||||
|
const serialized = JSON.stringify(filtered);
|
||||||
|
|
||||||
|
// Always use localStorage for session
|
||||||
|
if (key === 'session') {
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem(storageKey, serialized);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to save ${key} to localStorage:`, e);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof indexedDB !== 'undefined') {
|
||||||
|
await set(storageKey, serialized);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to save ${key} to IndexedDB, falling back to localStorage:`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem(storageKey, serialized);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to save ${key} to localStorage:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { loadStorage, saveStorage };
|
||||||
140
src/GlobalStateStore/GlobalStateStoreWrapper.tsx
Normal file
140
src/GlobalStateStore/GlobalStateStoreWrapper.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import type { GlobalState, GlobalStateStoreType, ProgramState } 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;
|
||||||
|
onFetchSession?: (state: Partial<GlobalState>) => Promise<Partial<GlobalState>>;
|
||||||
|
onLogin?: (state: Partial<GlobalState>) => Promise<Partial<GlobalState>>;
|
||||||
|
onLogout?: (state: Partial<GlobalState>) => Promise<Partial<GlobalState>>;
|
||||||
|
program?: Partial<ProgramState>;
|
||||||
|
throttleMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalStateStoreProvider({
|
||||||
|
apiURL,
|
||||||
|
autoFetch = true,
|
||||||
|
children,
|
||||||
|
fetchOnMount = true,
|
||||||
|
onFetchSession,
|
||||||
|
onLogin,
|
||||||
|
onLogout,
|
||||||
|
program,
|
||||||
|
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 (program) {
|
||||||
|
GlobalStateStore.getState().setProgram(program);
|
||||||
|
}
|
||||||
|
}, [program]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onFetchSession) {
|
||||||
|
GlobalStateStore.setState({ onFetchSession });
|
||||||
|
}
|
||||||
|
}, [onFetchSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onLogin) {
|
||||||
|
GlobalStateStore.setState({ onLogin });
|
||||||
|
}
|
||||||
|
}, [onLogin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onLogout) {
|
||||||
|
GlobalStateStore.setState({ onLogout });
|
||||||
|
}
|
||||||
|
}, [onLogout]);
|
||||||
|
|
||||||
|
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`
|
||||||
14
src/GlobalStateStore/index.ts
Normal file
14
src/GlobalStateStore/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export {
|
||||||
|
getApiURL,
|
||||||
|
getAuthToken,
|
||||||
|
GetGlobalState,
|
||||||
|
GlobalStateStore,
|
||||||
|
isLoggedIn,
|
||||||
|
setApiURL,
|
||||||
|
setAuthToken,
|
||||||
|
useGlobalStateStore,
|
||||||
|
} from './GlobalStateStore';
|
||||||
|
|
||||||
|
export type * from './GlobalStateStore.types';
|
||||||
|
|
||||||
|
export { GlobalStateStoreProvider, useGlobalStateStoreContext } from './GlobalStateStoreWrapper';
|
||||||
@@ -25,9 +25,10 @@ export const Computer = React.memo(() => {
|
|||||||
scrollToRowKey,
|
scrollToRowKey,
|
||||||
searchStr,
|
searchStr,
|
||||||
selectedRowKey,
|
selectedRowKey,
|
||||||
|
selectFirstRowOnMount,
|
||||||
setState,
|
setState,
|
||||||
setStateFN,
|
setStateFN,
|
||||||
values,
|
values
|
||||||
} = useGridlerStore((s) => ({
|
} = useGridlerStore((s) => ({
|
||||||
_glideref: s._glideref,
|
_glideref: s._glideref,
|
||||||
_gridSelectionRows: s._gridSelectionRows,
|
_gridSelectionRows: s._gridSelectionRows,
|
||||||
@@ -44,6 +45,7 @@ export const Computer = React.memo(() => {
|
|||||||
scrollToRowKey: s.scrollToRowKey,
|
scrollToRowKey: s.scrollToRowKey,
|
||||||
searchStr: s.searchStr,
|
searchStr: s.searchStr,
|
||||||
selectedRowKey: s.selectedRowKey,
|
selectedRowKey: s.selectedRowKey,
|
||||||
|
selectFirstRowOnMount:s.selectFirstRowOnMount,
|
||||||
setState: s.setState,
|
setState: s.setState,
|
||||||
setStateFN: s.setStateFN,
|
setStateFN: s.setStateFN,
|
||||||
uniqueid: s.uniqueid,
|
uniqueid: s.uniqueid,
|
||||||
@@ -268,18 +270,25 @@ export const Computer = React.memo(() => {
|
|||||||
//Logic to select first row on mount
|
//Logic to select first row on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const _events = getState('_events');
|
const _events = getState('_events');
|
||||||
|
|
||||||
const loadPage = () => {
|
const loadPage = () => {
|
||||||
const selectFirstRowOnMount = getState('selectFirstRowOnMount');
|
const selectFirstRowOnMount = getState('selectFirstRowOnMount');
|
||||||
|
const ready = getState('ready');
|
||||||
|
|
||||||
if (ready && selectFirstRowOnMount) {
|
if (ready && selectFirstRowOnMount) {
|
||||||
|
|
||||||
const scrollToRowKey = getState('scrollToRowKey');
|
const scrollToRowKey = getState('scrollToRowKey');
|
||||||
|
|
||||||
|
|
||||||
if (scrollToRowKey && scrollToRowKey >= 0) {
|
if (scrollToRowKey && scrollToRowKey >= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyField = getState('keyField') ?? 'id';
|
const keyField = getState('keyField') ?? 'id';
|
||||||
const page_data = getState('_page_data');
|
const page_data = getState('_page_data');
|
||||||
|
|
||||||
const firstBuffer = page_data?.[0]?.[0];
|
const firstBuffer = page_data?.[0]?.[0];
|
||||||
const firstRow = firstBuffer?.[keyField];
|
const firstRow = firstBuffer?.[keyField] ?? -1;
|
||||||
const currentValues = getState('values') ?? [];
|
const currentValues = getState('values') ?? [];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -290,6 +299,7 @@ export const Computer = React.memo(() => {
|
|||||||
) {
|
) {
|
||||||
const values = [firstBuffer, ...(currentValues as Array<Record<string, unknown>>)];
|
const values = [firstBuffer, ...(currentValues as Array<Record<string, unknown>>)];
|
||||||
|
|
||||||
|
|
||||||
const onChange = getState('onChange');
|
const onChange = getState('onChange');
|
||||||
//console.log('Selecting first row:', firstRow, firstBuffer, values);
|
//console.log('Selecting first row:', firstRow, firstBuffer, values);
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
@@ -308,7 +318,7 @@ export const Computer = React.memo(() => {
|
|||||||
return () => {
|
return () => {
|
||||||
_events?.removeEventListener('loadPage', loadPage);
|
_events?.removeEventListener('loadPage', loadPage);
|
||||||
};
|
};
|
||||||
}, [ready]);
|
}, [ready, selectFirstRowOnMount]);
|
||||||
|
|
||||||
/// logic to apply the selected row.
|
/// logic to apply the selected row.
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export interface GridlerState {
|
|||||||
hasLocalData: boolean;
|
hasLocalData: boolean;
|
||||||
isEmpty: boolean;
|
isEmpty: boolean;
|
||||||
|
|
||||||
|
isValuesInPages: () => boolean
|
||||||
loadingData?: boolean;
|
loadingData?: boolean;
|
||||||
loadPage: (page: number, clearMode?: 'all' | 'page') => Promise<void>;
|
loadPage: (page: number, clearMode?: 'all' | 'page') => Promise<void>;
|
||||||
mounted: boolean;
|
mounted: boolean;
|
||||||
@@ -180,6 +181,7 @@ export interface GridlerState {
|
|||||||
onHeaderClicked: (colIndex: number, event: HeaderClickedEventArgs) => void;
|
onHeaderClicked: (colIndex: number, event: HeaderClickedEventArgs) => void;
|
||||||
onHeaderMenuClick: (col: number, screenPosition: Rectangle) => void;
|
onHeaderMenuClick: (col: number, screenPosition: Rectangle) => void;
|
||||||
onItemHovered: (args: GridMouseEventArgs) => void;
|
onItemHovered: (args: GridMouseEventArgs) => void;
|
||||||
|
|
||||||
onVisibleRegionChanged: (
|
onVisibleRegionChanged: (
|
||||||
r: Rectangle,
|
r: Rectangle,
|
||||||
tx: number,
|
tx: number,
|
||||||
@@ -189,18 +191,18 @@ export interface GridlerState {
|
|||||||
freezeRegions?: readonly Rectangle[];
|
freezeRegions?: readonly Rectangle[];
|
||||||
selected?: Item;
|
selected?: Item;
|
||||||
}
|
}
|
||||||
|
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
refreshCells: (fromRow?: number, toRow?: number, col?: number) => void;
|
refreshCells: (fromRow?: number, toRow?: number, col?: number) => void;
|
||||||
reload?: () => Promise<void>;
|
|
||||||
|
|
||||||
|
reload?: () => Promise<void>;
|
||||||
renderColumns?: GridlerColumns;
|
renderColumns?: GridlerColumns;
|
||||||
setState: <K extends keyof GridlerStoreState>(
|
setState: <K extends keyof GridlerStoreState>(
|
||||||
key: K,
|
key: K,
|
||||||
value: Partial<GridlerStoreState[K]>
|
value: GridlerStoreState[K]
|
||||||
) => void;
|
) => void;
|
||||||
setStateFN: <K extends keyof GridlerStoreState>(
|
setStateFN: <K extends keyof GridlerStoreState>(
|
||||||
key: K,
|
key: K,
|
||||||
@@ -378,6 +380,31 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
|||||||
},
|
},
|
||||||
hasLocalData: false,
|
hasLocalData: false,
|
||||||
isEmpty: true,
|
isEmpty: true,
|
||||||
|
isValuesInPages: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.values && Object.keys(state._page_data).length > 0) {
|
||||||
|
let found = false;
|
||||||
|
for (const page in state._page_data) {
|
||||||
|
const pageData = state._page_data[Number(page)];
|
||||||
|
for (const row of pageData) {
|
||||||
|
const keyField = state.keyField ?? 'id';
|
||||||
|
const rowKey = row?.[keyField];
|
||||||
|
if (rowKey !== undefined) {
|
||||||
|
const match = state.values.find((v) => String(v?.[keyField]) === String(rowKey));
|
||||||
|
if (match) {
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
keyField: 'id',
|
keyField: 'id',
|
||||||
loadPage: async (pPage: number, clearMode?: 'all' | 'page') => {
|
loadPage: async (pPage: number, clearMode?: 'all' | 'page') => {
|
||||||
const state = get();
|
const state = get();
|
||||||
@@ -511,6 +538,7 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
|||||||
return { ...renderCols, [fromItem?.id]: to, [toItem?.id]: from };
|
return { ...renderCols, [fromItem?.id]: to, [toItem?.id]: from };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
onColumnProposeMove: (startIndex: number, endIndex: number) => {
|
onColumnProposeMove: (startIndex: number, endIndex: number) => {
|
||||||
const s = get();
|
const s = get();
|
||||||
const fromItem = s.renderColumns?.[startIndex];
|
const fromItem = s.renderColumns?.[startIndex];
|
||||||
@@ -520,7 +548,6 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
onColumnResize: (
|
onColumnResize: (
|
||||||
column: GridColumn,
|
column: GridColumn,
|
||||||
newSize: number,
|
newSize: number,
|
||||||
@@ -922,7 +949,7 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
total_rows: 1000,
|
total_rows: 1000,
|
||||||
uniqueid: getUUID(),
|
uniqueid: getUUID()
|
||||||
}),
|
}),
|
||||||
(props) => {
|
(props) => {
|
||||||
const [setState, getState] = props.useStore((s) => [s.setState, s.getState]);
|
const [setState, getState] = props.useStore((s) => [s.setState, s.getState]);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
|||||||
const searchStr = getState('searchStr');
|
const searchStr = getState('searchStr');
|
||||||
const searchFields = getState('searchFields');
|
const searchFields = getState('searchFields');
|
||||||
const _active_requests = getState('_active_requests');
|
const _active_requests = getState('_active_requests');
|
||||||
|
const keyField = getState('keyField');
|
||||||
setState('loadingData', true);
|
setState('loadingData', true);
|
||||||
try {
|
try {
|
||||||
//console.log('APIAdaptorGoLangv2', { _active_requests, index, pageSize, props });
|
//console.log('APIAdaptorGoLangv2', { _active_requests, index, pageSize, props });
|
||||||
@@ -113,6 +114,12 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
|||||||
col_ids?.push(props.hotfields.join(','));
|
col_ids?.push(props.hotfields.join(','));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (keyField) {
|
||||||
|
if (!col_ids.includes(keyField)) {
|
||||||
|
col_ids.push(keyField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (col_ids && col_ids.length > 0) {
|
if (col_ids && col_ids.length > 0) {
|
||||||
ops.push({
|
ops.push({
|
||||||
type: 'select-fields',
|
type: 'select-fields',
|
||||||
@@ -223,7 +230,7 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
|||||||
ops.push({
|
ops.push({
|
||||||
name: 'sql_filter',
|
name: 'sql_filter',
|
||||||
type: 'custom-sql-w',
|
type: 'custom-sql-w',
|
||||||
value: props.filter,
|
value: `(${props.filter})`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,8 +272,12 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setState('useAPIQuery', useAPIQuery);
|
setState('useAPIQuery', useAPIQuery);
|
||||||
setState('askAPIRowNumber', askAPIRowNumber);
|
setState('askAPIRowNumber', askAPIRowNumber);
|
||||||
|
const isValuesInPages = getState('isValuesInPages');
|
||||||
|
|
||||||
const _refresh = getState('_refresh');
|
const _refresh = getState('_refresh');
|
||||||
|
if (!isValuesInPages) {
|
||||||
|
setState('values', []);
|
||||||
|
}
|
||||||
|
|
||||||
//Reset the loaded pages to new rules
|
//Reset the loaded pages to new rules
|
||||||
_refresh?.().then(() => {
|
_refresh?.().then(() => {
|
||||||
@@ -282,6 +293,8 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
|||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//The computer component does not need to be recalculated on every render, so we use React.memo to prevent unnecessary re-renders.
|
//The computer component does not need to be recalculated on every render, so we use React.memo to prevent unnecessary re-renders.
|
||||||
export const GlidlerAPIAdaptorForGoLangv2 = React.memo(_GlidlerAPIAdaptorForGoLangv2);
|
export const GlidlerAPIAdaptorForGoLangv2 = React.memo(_GlidlerAPIAdaptorForGoLangv2);
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export function GlidlerFormAdaptor(props: {
|
|||||||
storeState: GridlerState,
|
storeState: GridlerState,
|
||||||
row?: Record<string, unknown>,
|
row?: Record<string, unknown>,
|
||||||
col?: GridlerColumn,
|
col?: GridlerColumn,
|
||||||
defaultItems?: Array<unknown>
|
defaultItems?: MantineBetterMenuInstanceItem[]
|
||||||
) => {
|
): MantineBetterMenuInstanceItem[] => {
|
||||||
//console.log('GlidlerFormInterface getMenuItems', id);
|
//console.log('GlidlerFormInterface getMenuItems', id);
|
||||||
|
|
||||||
if (id === 'header-menu') {
|
if (id === 'header-menu') {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { Button, Checkbox, Divider, Group, Stack, TagsInput, TextInput } from '@mantine/core';
|
import { Button, Checkbox, Divider, Group, Stack, TagsInput, TextInput } from '@mantine/core';
|
||||||
import { useLocalStorage } from '@mantine/hooks';
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { GridlerColumns } from '../components/Column';
|
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 { GlidlerAPIAdaptorForGoLangv2 } from '../components/adaptors';
|
||||||
import { type GridlerRef } from '../components/GridlerStore';
|
import { type GridlerRef } from '../components/GridlerStore';
|
||||||
import { Gridler } from '../Gridler';
|
import { Gridler } from '../Gridler';
|
||||||
@@ -18,6 +23,24 @@ export const GridlerGoAPIExampleEventlog = () => {
|
|||||||
const [selectRow, setSelectRow] = useState<string | undefined>('');
|
const [selectRow, setSelectRow] = useState<string | undefined>('');
|
||||||
const [values, setValues] = useState<Array<Record<string, any>>>([]);
|
const [values, setValues] = useState<Array<Record<string, any>>>([]);
|
||||||
const [search, setSearch] = useState<string>('');
|
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 [sections, setSections] = useState<Record<string, unknown> | undefined>(undefined);
|
||||||
const columns: GridlerColumns = [
|
const columns: GridlerColumns = [
|
||||||
{
|
{
|
||||||
@@ -129,10 +152,29 @@ export const GridlerGoAPIExampleEventlog = () => {
|
|||||||
changeOnActiveClick={true}
|
changeOnActiveClick={true}
|
||||||
descriptionField={'process'}
|
descriptionField={'process'}
|
||||||
onRequestForm={(request, data) => {
|
onRequestForm={(request, data) => {
|
||||||
console.log('Form requested', request, data);
|
setFormProps((cv) => {
|
||||||
|
return { ...cv, opened: true, request: request as any, values: data as any };
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Gridler>
|
</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 />
|
<Divider />
|
||||||
<Group>
|
<Group>
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
//@ts-nocheck
|
//@ts-nocheck
|
||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
//@ts-nocheck
|
//@ts-nocheck
|
||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,34 @@
|
|||||||
import {b64EncodeUnicode} from '@warkypublic/artemis-kit/base64'
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
const TOKEN_KEY = 'gridler_golang_restapi_v2_token'
|
import { b64EncodeUnicode } from '@warkypublic/artemis-kit/base64';
|
||||||
|
const TOKEN_KEY = 'gridler_golang_restapi_v2_token';
|
||||||
|
|
||||||
export type APIOptionsType = {
|
export type APIOptionsType = {
|
||||||
autocreate?: boolean
|
autocreate?: boolean;
|
||||||
autoref?: boolean
|
autoref?: boolean;
|
||||||
baseurl?: string
|
baseurl?: string;
|
||||||
getAPIProvider?: () => { provider: string; providerKey: string }
|
getAPIProvider?: () => { provider: string; providerKey: string };
|
||||||
getAuthToken?: () => string
|
getAuthToken?: () => string;
|
||||||
operations?: Array<FetchAPIOperation>
|
operations?: Array<FetchAPIOperation>;
|
||||||
postfix?: string
|
postfix?: string;
|
||||||
prefix?: string
|
prefix?: string;
|
||||||
requestTimeoutSec?: number
|
requestTimeoutSec?: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface APIResponse {
|
export interface APIResponse {
|
||||||
errmsg: string
|
errmsg: string;
|
||||||
payload?: any
|
payload?: any;
|
||||||
retval: number
|
retval: number;
|
||||||
}
|
}
|
||||||
export interface FetchAPIOperation {
|
export interface FetchAPIOperation {
|
||||||
name?: string
|
name?: string;
|
||||||
op?: string
|
op?: string;
|
||||||
type: GoAPIHeaderTypes //x-fieldfilter
|
type: GoAPIHeaderTypes; //x-fieldfilter
|
||||||
value: string
|
value: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* @description Types for the Go Rest API headers
|
* @description Types for the Go Rest API headers
|
||||||
* @typedef {String} GoAPIEnum
|
* @typedef {String} GoAPIEnum
|
||||||
*/
|
*/
|
||||||
export type GoAPIEnum =
|
export type GoAPIEnum =
|
||||||
| 'advsql'
|
| 'advsql'
|
||||||
| 'api-key'
|
| 'api-key'
|
||||||
@@ -42,7 +41,7 @@ export type GoAPIEnum =
|
|||||||
| 'association_autoupdate'
|
| 'association_autoupdate'
|
||||||
| 'association-update'
|
| 'association-update'
|
||||||
| 'cql-sel'
|
| 'cql-sel'
|
||||||
| 'cursor-backward'// For x cursor-backward header
|
| 'cursor-backward' // For x cursor-backward header
|
||||||
| 'cursor-forward' // For x cursor-forward header
|
| 'cursor-forward' // For x cursor-forward header
|
||||||
| 'custom-sql-join'
|
| 'custom-sql-join'
|
||||||
| 'custom-sql-or'
|
| 'custom-sql-or'
|
||||||
@@ -72,28 +71,24 @@ export type GoAPIEnum =
|
|||||||
| 'simpleapi'
|
| 'simpleapi'
|
||||||
| 'skipcache'
|
| 'skipcache'
|
||||||
| 'skipcount'
|
| 'skipcount'
|
||||||
| 'sort'
|
| 'sort';
|
||||||
|
|
||||||
|
export type GoAPIHeaderKeys = `x-${GoAPIEnum}`;
|
||||||
|
|
||||||
export type GoAPIHeaderKeys = `x-${GoAPIEnum}`
|
export type GoAPIHeaderTypes = GoAPIEnum & string;
|
||||||
|
|
||||||
|
|
||||||
export type GoAPIHeaderTypes = GoAPIEnum & string
|
|
||||||
|
|
||||||
|
|
||||||
export interface GoAPIOperation {
|
export interface GoAPIOperation {
|
||||||
name?: string
|
name?: string;
|
||||||
op?: string
|
op?: string;
|
||||||
type: GoAPIHeaderTypes //x-fieldfilter
|
type: GoAPIHeaderTypes; //x-fieldfilter
|
||||||
value: string
|
value: string;
|
||||||
}
|
}
|
||||||
export interface MetaData {
|
export interface MetaData {
|
||||||
limit?: number
|
limit?: number;
|
||||||
offset?: number
|
offset?: number;
|
||||||
total?: number
|
total?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds an array of objects by encoding specific values and setting headers.
|
* Builds an array of objects by encoding specific values and setting headers.
|
||||||
*
|
*
|
||||||
@@ -105,50 +100,49 @@ const buildGoAPIOperation = (
|
|||||||
ops: Array<FetchAPIOperation>,
|
ops: Array<FetchAPIOperation>,
|
||||||
headers?: Headers
|
headers?: Headers
|
||||||
): Array<FetchAPIOperation> => {
|
): Array<FetchAPIOperation> => {
|
||||||
const newops = [...ops.filter((i) => i !== undefined && i.type !== undefined)]
|
const newops = [...ops.filter((i) => i !== undefined && i.type !== undefined)];
|
||||||
|
|
||||||
|
|
||||||
for (let i = 0; i < newops.length; i++) {
|
for (let i = 0; i < newops.length; i++) {
|
||||||
if (!newops[i].name || newops[i].name === '') {
|
if (!newops[i].name || newops[i].name === '') {
|
||||||
newops[i].name = ''
|
newops[i].name = '';
|
||||||
}
|
}
|
||||||
if (newops[i].type === 'files' && !newops[i].value.startsWith('__')) {
|
if (newops[i].type === 'files' && !newops[i].value.startsWith('__')) {
|
||||||
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`
|
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
|
||||||
}
|
}
|
||||||
if (newops[i].type === 'advsql' && !newops[i].value.startsWith('__')) {
|
if (newops[i].type === 'advsql' && !newops[i].value.startsWith('__')) {
|
||||||
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`
|
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newops[i].type === 'custom-sql-or' && !newops[i].value.startsWith('__')) {
|
if (newops[i].type === 'custom-sql-or' && !newops[i].value.startsWith('__')) {
|
||||||
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`
|
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newops[i].type === 'custom-sql-join' && !newops[i].value.startsWith('__')) {
|
if (newops[i].type === 'custom-sql-join' && !newops[i].value.startsWith('__')) {
|
||||||
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`
|
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
|
||||||
}
|
}
|
||||||
if (newops[i].type === 'not-select-fields' && !newops[i].value.startsWith('__')) {
|
if (newops[i].type === 'not-select-fields' && !newops[i].value.startsWith('__')) {
|
||||||
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`
|
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
|
||||||
}
|
}
|
||||||
if (newops[i].type === 'custom-sql-w' && !newops[i].value.startsWith('__')) {
|
if (newops[i].type === 'custom-sql-w' && !newops[i].value.startsWith('__')) {
|
||||||
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`
|
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
|
||||||
}
|
}
|
||||||
if (newops[i].type === 'select-fields' && !newops[i].value.startsWith('__')) {
|
if (newops[i].type === 'select-fields' && !newops[i].value.startsWith('__')) {
|
||||||
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`
|
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
|
||||||
}
|
}
|
||||||
if (newops[i].type === 'cql-sel' && !newops[i].value.startsWith('__')) {
|
if (newops[i].type === 'cql-sel' && !newops[i].value.startsWith('__')) {
|
||||||
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`
|
newops[i].value = `__${b64EncodeUnicode(newops[i].value)}__`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (headers) {
|
if (headers) {
|
||||||
if (!newops || newops.length === 0) {
|
if (!newops || newops.length === 0) {
|
||||||
headers.set(`x-limit`, '10')
|
headers.set(`x-limit`, '10');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newops[i].type === 'association_autoupdate') {
|
if (newops[i].type === 'association_autoupdate') {
|
||||||
headers.set(`association_autoupdate`, newops[i].value ?? '1')
|
headers.set(`association_autoupdate`, newops[i].value ?? '1');
|
||||||
}
|
}
|
||||||
if (newops[i].type === 'association_autocreate') {
|
if (newops[i].type === 'association_autocreate') {
|
||||||
headers.set(`association_autocreate`, newops[i].value ?? '1')
|
headers.set(`association_autocreate`, newops[i].value ?? '1');
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
newops[i].type === 'searchop' ||
|
newops[i].type === 'searchop' ||
|
||||||
@@ -158,20 +152,20 @@ const buildGoAPIOperation = (
|
|||||||
headers.set(
|
headers.set(
|
||||||
encodeURIComponent(`x-${newops[i].type}-${newops[i].op}-${newops[i].name}`),
|
encodeURIComponent(`x-${newops[i].type}-${newops[i].op}-${newops[i].name}`),
|
||||||
String(newops[i].value)
|
String(newops[i].value)
|
||||||
)
|
);
|
||||||
} else {
|
} else {
|
||||||
headers.set(
|
headers.set(
|
||||||
encodeURIComponent(
|
encodeURIComponent(
|
||||||
`x-${newops[i].type}${newops[i].name && newops[i].name !== '' ? '-' + newops[i].name : ''}`
|
`x-${newops[i].type}${newops[i].name && newops[i].name !== '' ? '-' + newops[i].name : ''}`
|
||||||
),
|
),
|
||||||
String(newops[i].value)
|
String(newops[i].value)
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return newops
|
return newops;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the headers from an array of FetchAPIOperation objects and returns them as an object.
|
* Retrieves the headers from an array of FetchAPIOperation objects and returns them as an object.
|
||||||
@@ -183,77 +177,75 @@ const GoAPIHeaders = (
|
|||||||
ops: Array<FetchAPIOperation>,
|
ops: Array<FetchAPIOperation>,
|
||||||
headers?: Headers
|
headers?: Headers
|
||||||
): { [key: string]: string } => {
|
): { [key: string]: string } => {
|
||||||
const head = new Headers()
|
const head = new Headers();
|
||||||
const headerlist: Record<string,string> = {}
|
const headerlist: Record<string, string> = {};
|
||||||
|
|
||||||
const authToken = getAuthToken?.()
|
const authToken = getAuthToken?.();
|
||||||
if (authToken && authToken !== '') {
|
if (authToken && authToken !== '') {
|
||||||
|
head.set('Authorization', `Token ${authToken}`);
|
||||||
head.set('Authorization', `Token ${authToken}`)
|
|
||||||
} else {
|
} else {
|
||||||
const token = getAuthToken()
|
const token = getAuthToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
head.set('Authorization', `Token ${token}`)
|
head.set('Authorization', `Token ${token}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (headers) {
|
if (headers) {
|
||||||
headers.forEach((v, k) => {
|
headers.forEach((v, k) => {
|
||||||
head.set(k, v)
|
head.set(k, v);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
const distinctOperations: Array<FetchAPIOperation> = []
|
const distinctOperations: Array<FetchAPIOperation> = [];
|
||||||
|
|
||||||
for (const value of ops?.filter((val) => !!val) ?? []) {
|
for (const value of ops?.filter((val) => !!val) ?? []) {
|
||||||
const index = distinctOperations.findIndex(
|
const index = distinctOperations.findIndex(
|
||||||
(searchValue) => searchValue.name === value.name && searchValue.type === value.type
|
(searchValue) => searchValue.name === value.name && searchValue.type === value.type
|
||||||
)
|
);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
distinctOperations.push(value)
|
distinctOperations.push(value);
|
||||||
} else {
|
} else {
|
||||||
distinctOperations[index] = value
|
distinctOperations[index] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildGoAPIOperation(distinctOperations, head)
|
buildGoAPIOperation(distinctOperations, head);
|
||||||
|
|
||||||
head?.forEach((v, k) => {
|
head?.forEach((v, k) => {
|
||||||
headerlist[k] = v
|
headerlist[k] = v;
|
||||||
})
|
});
|
||||||
|
|
||||||
if (headers) {
|
if (headers) {
|
||||||
for (const key of Object.keys(headerlist)) {
|
for (const key of Object.keys(headerlist)) {
|
||||||
headers.set(key, headerlist[key])
|
headers.set(key, headerlist[key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return headerlist
|
return headerlist;
|
||||||
}
|
};
|
||||||
|
|
||||||
const callbacks = {
|
const callbacks = {
|
||||||
getAuthToken: () => {
|
getAuthToken: () => {
|
||||||
if (localStorage) {
|
if (localStorage) {
|
||||||
const token = localStorage.getItem(TOKEN_KEY)
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
if (token) {
|
if (token) {
|
||||||
return token
|
return token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined;
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves the authentication token from local storage.
|
* Retrieves the authentication token from local storage.
|
||||||
*
|
*
|
||||||
* @return {string | undefined} The authentication token if found, otherwise undefined
|
* @return {string | undefined} The authentication token if found, otherwise undefined
|
||||||
*/
|
*/
|
||||||
const getAuthToken = () => callbacks?.getAuthToken?.()
|
const getAuthToken = () => callbacks?.getAuthToken?.();
|
||||||
|
|
||||||
const setAuthTokenCallback = (cb: ()=> string) => {
|
const setAuthTokenCallback = (cb: () => string) => {
|
||||||
callbacks.getAuthToken = cb
|
callbacks.getAuthToken = cb;
|
||||||
return callbacks.getAuthToken
|
return callbacks.getAuthToken;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the authentication token in the local storage.
|
* Sets the authentication token in the local storage.
|
||||||
@@ -262,9 +254,8 @@ const setAuthTokenCallback = (cb: ()=> string) => {
|
|||||||
*/
|
*/
|
||||||
const setAuthToken = (token: string) => {
|
const setAuthToken = (token: string) => {
|
||||||
if (localStorage) {
|
if (localStorage) {
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { buildGoAPIOperation, getAuthToken, GoAPIHeaders, setAuthToken, setAuthTokenCallback };
|
||||||
export {buildGoAPIOperation,getAuthToken,GoAPIHeaders,setAuthToken,setAuthTokenCallback}
|
|
||||||
|
|||||||
10
src/lib.ts
10
src/lib.ts
@@ -1,5 +1,9 @@
|
|||||||
export * from './Gridler'
|
export * from './Boxer';
|
||||||
|
export * from './ErrorBoundary';
|
||||||
|
export * from './Former';
|
||||||
|
export * from './FormerControllers';
|
||||||
|
export * from './GlobalStateStore';
|
||||||
|
export * from './Gridler';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type MantineBetterMenuInstance,
|
type MantineBetterMenuInstance,
|
||||||
@@ -7,4 +11,4 @@ export {
|
|||||||
MantineBetterMenusProvider,
|
MantineBetterMenusProvider,
|
||||||
type MantineBetterMenuStoreState,
|
type MantineBetterMenuStoreState,
|
||||||
useMantineBetterMenus,
|
useMantineBetterMenus,
|
||||||
} from "./MantineBetterMenu";
|
} from './MantineBetterMenu';
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ Object.defineProperty(window, 'matchMedia', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Mock ResizeObserver
|
// Mock ResizeObserver
|
||||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||||
disconnect: vi.fn(),
|
disconnect: vi.fn(),
|
||||||
observe: vi.fn(),
|
observe: vi.fn(),
|
||||||
unobserve: vi.fn(),
|
unobserve: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock IntersectionObserver
|
// Mock IntersectionObserver
|
||||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
globalThis.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||||
disconnect: vi.fn(),
|
disconnect: vi.fn(),
|
||||||
observe: vi.fn(),
|
observe: vi.fn(),
|
||||||
unobserve: vi.fn(),
|
unobserve: vi.fn(),
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"target": "es6",
|
"target": "es6",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"types": [
|
"types": [
|
||||||
"./global.d.ts"
|
"./global.d.ts",
|
||||||
|
"node"
|
||||||
],
|
],
|
||||||
"lib": [
|
"lib": [
|
||||||
"ES2016",
|
"ES2016",
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
@@ -31,11 +32,13 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true,
|
||||||
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src",
|
"src",
|
||||||
"lib.ts",
|
"lib.ts",
|
||||||
"*.d.ts",
|
"*.d.ts",
|
||||||
]
|
],
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -19,9 +19,10 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"vite.config.ts"
|
"vite.config.ts"
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
@@ -21,10 +21,10 @@ export default defineConfig({
|
|||||||
tsconfigPath: './tsconfig.app.json',
|
tsconfigPath: './tsconfig.app.json',
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
noEmit: false,
|
noEmit: false,
|
||||||
|
skipLibCheck: true,
|
||||||
emitDeclarationOnly: true,
|
emitDeclarationOnly: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
],
|
],
|
||||||
publicDir: 'public',
|
publicDir: 'public',
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
Reference in New Issue
Block a user