Compare commits

..

74 Commits

Author SHA1 Message Date
52a97f2a97 fix: update ESLint config to ignore additional directories and files 2026-01-28 21:10:06 +02:00
6c141b71da RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.30

[skip ci]
2026-01-28 21:05:16 +02:00
89fed20f70 docs(changeset): fix: update GridlerStore setState type to accept full state values 2026-01-28 21:05:11 +02:00
9414421430 fix: update GridlerStore setState type to accept full state values
fix: change defaultItems type in GlidlerFormAdaptor to use MantineBetterMenuInstanceItem[]

test: update global ResizeObserver and IntersectionObserver mocks to use globalThis

build: change moduleResolution to 'bundler' in tsconfig.app.json

build: add missing newline at end of file in tsconfig.node.json
2026-01-28 21:04:51 +02:00
c4f0fcc233 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.29

[skip ci]
2026-01-28 20:08:45 +02:00
5180f52698 docs(changeset): feat(Former): update layout to use buttonArea prop instead of buttonOnTop 2026-01-28 20:08:41 +02:00
ce7cf9435a feat(Former): update layout to use buttonArea prop instead of buttonOnTop 2026-01-28 20:07:30 +02:00
Hein
ad2252f5e4 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.28

[skip ci]
2026-01-23 11:11:40 +02:00
Hein
287dbcf4da docs(changeset): 1 2026-01-23 11:11:35 +02:00
Hein
f963b38339 fix(Gridler): 🔧 wrap filter value in parentheses 2026-01-23 11:11:12 +02:00
Hein
55cb9038ad RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.27

[skip ci]
2026-01-23 10:57:58 +02:00
Hein
9d907068a6 docs(changeset): feat(Gridler): add isValuesInPages method and update state handling 2026-01-23 10:57:50 +02:00
Hein
ecb90c69aa feat(Gridler): add isValuesInPages method and update state handling
* Introduce isValuesInPages method to check if values exist in paginated data.
* Update state management in GlidlerAPIAdaptorForGoLangv2 to clear values when no pages are found.
2026-01-23 10:57:32 +02:00
Hein
070e56e1af RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.26

[skip ci]
2026-01-23 10:13:11 +02:00
Hein
3e460ae46c docs(changeset): fixed Gridler selectFirstRow 2026-01-23 10:13:08 +02:00
Hein
9c64217b72 fix(Computer): update selectFirstRowOnMount logic
* Introduce selectFirstRowOnMount to manage row selection on component mount.
* Update useEffect dependencies to include selectFirstRowOnMount.
* Ensure first row selection logic handles cases where keyField is not defined.
2026-01-23 10:09:59 +02:00
1fb57d3454 feat(Boxer): add @tanstack/react-virtual dependency and enhance Boxer component with improved option handling and ref exposure 2026-01-17 19:33:19 +02:00
a8e9c50290 feat(Boxer): implement Boxer component with autocomplete and server-side support 2026-01-17 18:26:20 +02:00
31f2a0428f feat(components): add InlineWrapper and related styles for improved form handling 2026-01-17 17:29:08 +02:00
bc7262cede RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.25

[skip ci]
2026-01-14 22:49:39 +02:00
0825f739f4 docs(changeset): Bump 2026-01-14 22:49:36 +02:00
0bd642e2d2 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.24

[skip ci]
2026-01-14 22:44:27 +02:00
7cc09d6acb docs(changeset): Added form controllers - New button and input controller components for the FormerControllers module 2026-01-14 22:44:15 +02:00
9df2f3b504 feat(controllers): add new input and button components
* Introduced ButtonCtrl, IconButtonCtrl, NativeSelectCtrl, PasswordInputCtrl, SwitchCtrl, TextAreaCtrl, TextInputCtrl
* Updated FormerControllers.types.ts to include SpecialIDProps
* Enhanced lib.ts to export new components
2026-01-14 22:42:17 +02:00
e777e1fa3a chore(form): 🗑️ remove unused form components and types
* Refactor Former components to streamline functionality
* Update stories to reflect changes in form structure
2026-01-14 21:56:55 +02:00
cd2f6db880 feat(form): enhance form functionality and API integration
* Refactor key handling to use uniqueKeyField
* Add reset functionality to clear dirty state after save
* Introduce new API call specifications for REST and resolve
* Implement predefined wrappers for dialogs and popovers
* Update todo list to reflect completed tasks
2026-01-14 21:51:39 +02:00
e6507f44af feat(form): enhance form layout and functionality
* Add FormerButtonArea component for action buttons
* Introduce FormerLayoutTop and FormerLayoutBottom for structured layout
* Update Former types to include new properties
* Implement dynamic ID generation for forms
* Refactor example to demonstrate new layout features
* Mark tasks as completed in todo.md
2026-01-14 19:35:38 +02:00
400a193a58 feat(todo): planned ideas 2026-01-12 23:25:58 +02:00
d935c6cf28 Merge pull request 'Form is to complex, needed a rewrite before I try to use it' (#1) from rw into main
Reviewed-on: #1
2026-01-12 21:21:59 +00:00
9bac48d5dd Form prototype 2026-01-12 23:20:34 +02:00
fbb65afc94 Merge branch 'main' of git.warky.dev:wdevs/oranguru into rw 2026-01-12 23:20:02 +02:00
095ddf6162 refactor(former): 🔄 restructure form components and stores
* Remove unused FormLayout and SuperForm stores.
* Consolidate form logic into Former component.
* Implement new Former layout and types.
* Update stories for new Former component.
* Clean up unused styles and types across the project.
2026-01-12 23:19:25 +02:00
Hein
0d9511df77 fix(adaptor): 🐛 Handle undefined filter IDs in search 2026-01-12 11:00:58 +02:00
b2817f4233 Form prototype 2026-01-11 09:45:03 +02:00
Hein
71403289c2 Updated exports 2025-12-01 12:20:13 +02:00
Hein
7025f316de RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.23

[skip ci]
2025-12-01 11:48:08 +02:00
Hein
32054118de docs(changeset): Using the effect of array as feature. 2025-12-01 11:48:05 +02:00
Hein
017b6445fb RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.22

[skip ci]
2025-12-01 11:42:28 +02:00
Hein
7c1d47819a docs(changeset): Possible selection fixes 2025-12-01 11:42:26 +02:00
Hein
b514c906c8 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.21

[skip ci]
2025-12-01 11:14:25 +02:00
Hein
6664c988b7 docs(changeset): Calls onchange on cell click since selection does not change. 2025-12-01 11:14:22 +02:00
Hein
249c283819 Update buffer when onCellClick 2025-12-01 10:39:28 +02:00
Hein
1ce5c25098 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.20

[skip ci]
2025-10-30 14:54:52 +02:00
Hein
30581de17e docs(changeset): Version bump 2025-10-30 14:54:48 +02:00
Hein
8784a28a30 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.19

[skip ci]
2025-10-30 14:54:02 +02:00
Hein
1b2bf6282d docs(changeset): Fixed refresh bug 2025-10-30 14:54:00 +02:00
Hein
abcf08f98e Fixed the refresh bug 2025-10-30 14:53:42 +02:00
Hein
0ba8dca0b4 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.18

[skip ci]
2025-10-30 14:38:13 +02:00
Hein
7cfefa9e6d docs(changeset): API props change bug 2025-10-30 14:38:11 +02:00
Hein
e879abb43f Fixed api bug 2025-10-30 14:37:53 +02:00
Hein
9f04b36e7e RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.17

[skip ci]
2025-10-30 14:23:32 +02:00
Hein
03210a3a7a docs(changeset): Updated selected cols bug 2025-10-30 14:23:30 +02:00
Hein
e6560aa990 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.16

[skip ci]
2025-10-30 13:04:30 +02:00
Hein
5e922df97a docs(changeset): A Few fixes 2025-10-30 13:04:26 +02:00
Hein
abf9433c10 A few fixes 2025-10-30 13:04:13 +02:00
Hein
1284f46aa9 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.15

[skip ci]
2025-10-30 11:45:30 +02:00
Hein
a1202f9b6d docs(changeset): Hopefully fix the options not always loading 2025-10-30 11:45:26 +02:00
Hein
a8a172cbfe Hopefully fix the options not always loading 2025-10-30 11:45:08 +02:00
Hein
9f960a6729 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.14

[skip ci]
2025-10-30 10:47:21 +02:00
Hein
864188c599 docs(changeset): Added searchfields 2025-10-30 10:47:19 +02:00
Hein
5fc02f9671 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.13

[skip ci]
2025-10-30 10:42:11 +02:00
Hein
b4058f1ef3 docs(changeset): Fixed search and allow row selection for only rows with keys 2025-10-30 10:42:07 +02:00
Hein
0943ffc483 Search working 2025-10-30 10:20:25 +02:00
Hein
bd47e9d0ab RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.12

[skip ci]
2025-10-29 16:26:40 +02:00
Hein
57c72e656f docs(changeset): Search String and Better GoAPI functionality 2025-10-29 16:26:38 +02:00
Hein
b977308e54 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.11

[skip ci]
2025-10-29 14:57:03 +02:00
Hein
54deac6ccc docs(changeset): Added refs and exports, isEmpty 2025-10-29 14:56:59 +02:00
Hein
615b89360a RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.10

[skip ci]
2025-10-24 16:52:20 +02:00
Hein
64cfed8a67 docs(changeset): Scroll to and forwarded ref 2025-10-24 16:52:18 +02:00
Hein
d6b7fa4076 Forward Ref and selection/scrollto 2025-10-24 16:51:55 +02:00
Hein
ad5bc14d7c RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.9

[skip ci]
2025-10-23 16:31:48 +02:00
Hein
5d8388c2db docs(changeset): Added selectFirstRowOnMount and fixed selection of first row 2025-10-23 16:31:45 +02:00
Hein
1f5999b2d1 RELEASING: Releasing 1 package(s)
Releases:
  @warkypublic/oranguru@0.0.8

[skip ci]
2025-10-23 15:58:07 +02:00
Hein
cdcb5c2684 docs(changeset): Fixed memo of options in GridlerAPIAdaptor 2025-10-23 15:58:04 +02:00
64 changed files with 4515 additions and 927 deletions

View File

@@ -1,13 +1,16 @@
import { MantineProvider } from '@mantine/core'; 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) { export function PreviewDecorator(Story: any, { parameters }: any) {
console.log('Rendering decorator', parameters); console.log('Rendering decorator', parameters);
return ( return (
<MantineProvider> <MantineProvider>
<div style={{ height: 'calc(100vh - 64px)', width: 'calc(100vw - 64px)' }}> <ModalsProvider>
<Story key={'mainStory'} /> <div style={{ height: 'calc(100vh - 64px)', width: 'calc(100vw - 64px)' }}>
</div> <Story key={'mainStory'} />
</div>
</ModalsProvider>
</MantineProvider> </MantineProvider>
); );
} }

View File

@@ -1,5 +1,143 @@
# @warkypublic/zustandsyncstore # @warkypublic/zustandsyncstore
## 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
### Patch Changes
- 3205411: Using the effect of array as feature.
## 0.0.22
### Patch Changes
- 7c1d478: Possible selection fixes
## 0.0.21
### Patch Changes
- 6664c98: Calls onchange on cell click since selection does not change.
## 0.0.20
### Patch Changes
- 30581de: Version bump
## 0.0.19
### Patch Changes
- 1b2bf62: Fixed refresh bug
## 0.0.18
### Patch Changes
- 7cfefa9: API props change bug
## 0.0.17
### Patch Changes
- 03210a3: Updated selected cols bug
## 0.0.16
### Patch Changes
- 5e922df: A Few fixes
## 0.0.15
### Patch Changes
- a1202f9: Hopefully fix the options not always loading
## 0.0.14
### Patch Changes
- 864188c: Added searchfields
## 0.0.13
### Patch Changes
- b4058f1: Fixed search and allow row selection for only rows with keys
## 0.0.12
### Patch Changes
- 57c72e6: Search String and Better GoAPI functionality
## 0.0.11
### Patch Changes
- 54deac6: Added refs and exports, isEmpty
## 0.0.10
### Patch Changes
- 64cfed8: Scroll to and forwarded ref
## 0.0.9
### Patch Changes
- 5d8388c: Added selectFirstRowOnMount and fixed selection of first row
## 0.0.8
### Patch Changes
- cdcb5c2: Fixed memo of options in GridlerAPIAdaptor
## 0.0.7 ## 0.0.7
### Patch Changes ### Patch Changes

View File

@@ -11,26 +11,30 @@ 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 },
}, },
// reactHooks.configs['recommended-latest'], // reactHooks.configs['recommended-latest'],
{...reactRefresh.configs.vite, ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],}, { ...reactRefresh.configs.vite, ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'] },
tseslint.configs.recommended, tseslint.configs.recommended,
{ {
...pluginReact.configs.flat.recommended, ...pluginReact.configs.flat.recommended,
ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '*stories.tsx','dist/**'],
rules: {...pluginReact.configs.flat.recommended.rules, rules: {
...pluginReact.configs.flat.recommended.rules,
'react/react-in-jsx-scope': 'off', 'react/react-in-jsx-scope': 'off',
} 'react-refresh/only-export-components': 'warn',
},
}, },
perfectionist.configs['recommended-alphabetical'], perfectionist.configs['recommended-alphabetical'],
{ {
rules: { rules: {
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/ban-ts-comment': 'off',
}, },
}, },
{ignores: ['dist/**','node_modules/**','vite.config.*','eslint.config.*' ]},
]); ]);
export default config; export default config;

View File

@@ -1,8 +1,26 @@
{ {
"name": "@warkypublic/oranguru", "name": "@warkypublic/oranguru",
"author": "Warky Devs", "author": "Warky Devs",
"version": "0.0.7", "version": "0.0.30",
"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"
},
"files": [
"dist/**",
"assets/**",
"public/**",
"global.d.ts"
],
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
@@ -17,82 +35,53 @@
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },
"files": [
"dist/**",
"assets/**",
"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": {
"@tanstack/react-virtual": "^3.13.18",
"moment": "^2.30.1" "moment": "^2.30.1"
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.29.7", "@changesets/cli": "^2.29.8",
"@eslint/js": "^9.38.0", "@eslint/js": "^9.39.2",
"@storybook/react-vite": "^9.1.13", "@storybook/react-vite": "^10.2.1",
"@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/node": "^25.1.0",
"@types/react": "^19.2.2", "@types/react": "^19.2.10",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.3",
"@typescript-eslint/parser": "^8.46.2", "@typescript-eslint/parser": "^8.54.0",
"@vitejs/plugin-react-swc": "^4.1.0", "@vitejs/plugin-react-swc": "^4.2.2",
"eslint": "^9.38.0", "eslint": "^9.38.0",
"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.0", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-storybook": "^9.1.13", "eslint-plugin-storybook": "^9.1.15",
"global": "^4.4.0", "global": "^4.4.0",
"globals": "^16.4.0", "globals": "^17.2.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"jsdom": "^27.0.1", "jsdom": "^27.4.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.6.2",
"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.13", "storybook": "^9.1.15",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.2", "typescript-eslint": "^8.46.2",
"vite": "^7.1.11", "vite": "^7.1.12",
"vite-plugin-dts": "^4.5.4", "vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4" "vitest": "^4.0.3"
}, },
"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/modals": "^8.3.5",
"@mantine/notifications": "^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",
@@ -101,6 +90,7 @@
"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"
} }

1330
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

159
src/Boxer/Boxer.store.tsx Normal file
View 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
View 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
View 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
View 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;

View File

@@ -0,0 +1,47 @@
import { Combobox, Checkbox } from '@mantine/core';
import { useMemo } from 'react';
import type { BoxerItem } from '../Boxer.types';
interface UseBoxerOptionsProps {
boxerData: Array<BoxerItem>;
value?: any | Array<any>;
multiSelect?: boolean;
onOptionSubmit: (index: number) => void;
}
const useBoxerOptions = (props: UseBoxerOptionsProps) => {
const { boxerData, value, multiSelect, onOptionSubmit } = 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
key={`${item.value}-${index}`}
value={String(index)}
active={isSelected}
onClick={() => {
onOptionSubmit(index);
}}
>
{multiSelect ? (
<div style={{ display: 'flex', alignItems: 'center', 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
View 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';

View 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
View 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

212
src/Former/Former.store.tsx Normal file
View File

@@ -0,0 +1,212 @@
import { newUUID } from '@warkypublic/artemis-kit';
import { createSyncStore } from '@warkypublic/zustandsyncstore';
import { produce } from 'immer';
import type { FormerProps, FormerState } from './Former.types';
const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
FormerState<any> & Partial<FormerProps<any>>,
FormerProps<any>
>(
(set, get) => ({
getState: (key) => {
const current = get();
return current?.[key];
},
load: async (reset?: boolean) => {
try {
set({ loading: true });
const keyName = get()?.uniqueKeyField || 'id';
const keyValue = (get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName];
if (get().onAPICall && keyValue !== undefined) {
let data = await get().onAPICall!(
'read',
get().request || 'insert',
get().values,
keyValue
);
if (get().afterGet) {
data = await get().afterGet!({ ...data });
}
set({ loading: false, values: data });
get().onChange?.(data);
}
if (reset && get().getFormMethods) {
const formMethods = get().getFormMethods!();
formMethods.reset();
}
} catch (e) {
set({ error: (e as Error)?.message ?? e, loading: false });
}
set({ loading: false });
},
onChange: (values) => {
set({ values });
},
request: 'insert',
reset: async () => {
const state = get();
if (state.getFormMethods) {
if (state.request !== 'insert') {
await state.load(true);
}
const formMethods = state.getFormMethods!();
formMethods.reset({ ...state.values, ...state.primeData });
}
},
save: async (e?: React.BaseSyntheticEvent<object, any, any> | undefined) => {
try {
const keepOpen = get().keepOpen ?? false;
set({ loading: true });
if (get().getFormMethods) {
const formMethods = get().getFormMethods!();
let data = formMethods.getValues();
if (get().beforeSave) {
const newData = await get().beforeSave!(data);
data = newData;
}
let exit = false;
const handler = formMethods.handleSubmit(
(newdata) => {
data = newdata;
},
(errors) => {
set({ error: errors.root?.message || 'Validation errors', loading: false });
exit = true;
}
);
await handler(e);
//console.log('Former.store.tsx save called', success, e, data, get().getFormMethods);
if (exit) {
set({ loading: false });
return undefined;
}
if (get().request === 'delete' && !get().deleteConfirmed) {
const confirmed = (await get().onConfirmDelete?.(data)) ?? false;
if (!confirmed) {
set({ loading: false });
return undefined;
}
}
if (get().onAPICall) {
const keyName = get()?.uniqueKeyField || 'id';
const keyValue =
(get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName];
const savedData = await get().onAPICall!(
'mutate',
get().request || 'insert',
data,
keyValue
);
if (get().afterSave) {
await get().afterSave!(savedData);
}
set({ loading: false, values: savedData });
get().onChange?.(savedData);
formMethods.reset(savedData); //reset with saved data to clear dirty state
if (!keepOpen) {
get().onClose?.(savedData);
}
return savedData;
}
set({ loading: false, values: data });
formMethods.reset(data); //reset with saved data to clear dirty state
get().onChange?.(data);
if (!keepOpen) {
get().onClose?.(data);
}
return data;
}
} catch (e) {
set({ error: (e as Error)?.message ?? e, loading: false });
}
return undefined;
},
setRequest: (request) => {
set({ request });
},
setState: (key, value) => {
set(
produce((state) => {
state[key] = value;
})
);
},
setStateFN: (key, value) => {
const p = new Promise<void>((resolve, reject) => {
set(
produce((state) => {
if (typeof value === 'function') {
state[key] = (value as (value: unknown) => unknown)(state[key]);
} else {
reject(new Error(`Not a function ${value}`));
throw Error(`Not a function ${value}`);
}
})
);
resolve();
});
return p;
},
validate: async () => {
if (get().getFormMethods) {
const formMethods = get().getFormMethods!();
const isValid = await formMethods.trigger();
return isValid;
}
return true;
},
values: undefined,
}),
({ id, onClose, onConfirmDelete, primeData, request, useStoreApi, values }) => {
let _onConfirmDelete = onConfirmDelete;
if (!onConfirmDelete) {
_onConfirmDelete = async () => {
return confirm('Are you sure you want to delete this item?');
};
}
return {
id: !id ? newUUID() : id,
onClose: () => {
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();
} else {
setState('opened', false);
}
}
} else {
if (onClose) {
onClose();
} else {
setState('opened', false);
}
}
},
onConfirmDelete: _onConfirmDelete,
primeData,
request: (request || 'insert').replace('change', 'update'),
values: { ...primeData, ...values },
};
}
);
export { FormerProvider };
export { useFormerStore };

124
src/Former/Former.tsx Normal file
View File

@@ -0,0 +1,124 @@
import { forwardRef, type PropsWithChildren, useEffect, useImperativeHandle } from 'react';
import { type FieldValues, FormProvider, useForm } from 'react-hook-form';
import type { FormerProps, FormerRef } from './Former.types';
import { FormerProvider, useFormerStore } from './Former.store';
import { FormerLayout } from './FormerLayout';
const FormerInner = forwardRef<FormerRef<any>, Partial<FormerProps<any>> & PropsWithChildren>(
function FormerInner<T extends FieldValues>(
props: Partial<FormerProps<T>> & PropsWithChildren<T>,
ref: any
) {
const {
getState,
onChange,
onClose,
onOpen,
opened,
primeData,
reset,
save,
setState,
useFormProps,
validate,
values,
wrapper,
} = useFormerStore((state) => ({
getState: state.getState,
onChange: state.onChange,
onClose: state.onClose,
onOpen: state.onOpen,
opened: state.opened,
primeData: state.primeData,
reset: state.reset,
save: state.save,
setState: state.setState,
useFormProps: state.useFormProps,
validate: state.validate,
values: state.values,
wrapper: state.wrapper,
}));
const formMethods = useForm<T>({
defaultValues: primeData,
mode: 'all',
shouldUseNativeValidation: true,
values: values,
...useFormProps,
});
useImperativeHandle(
ref,
() => ({
close: async () => {
//console.log('close called');
onClose?.();
setState('opened', false);
},
getValue: () => {
return getState('values');
},
reset: () => {
reset();
},
save: async () => {
return await save();
},
setValue: (value: T) => {
onChange?.(value);
},
show: async () => {
//console.log('show called');
setState('opened', true);
onOpen?.();
},
validate: async () => {
return await validate();
},
}),
[getState, onChange, validate, save, reset, setState, onClose, onOpen]
);
useEffect(() => {
setState('getFormMethods', () => formMethods);
if (formMethods) {
formMethods.subscribe({
callback: ({ isDirty }) => {
setState('dirty', isDirty);
},
formState: { isDirty: true },
});
}
}, [formMethods]);
return (
<FormProvider {...formMethods}>
{typeof wrapper === 'function' ? (
wrapper(<FormerLayout>{props.children}</FormerLayout>, opened, onClose, onOpen, getState)
) : (
<FormerLayout>{props.children || null}</FormerLayout>
)}
</FormProvider>
);
}
);
export const Former = forwardRef<FormerRef<any>, FormerProps<any> & PropsWithChildren>(
function Former<T extends FieldValues = any>(
props: FormerProps<T> & PropsWithChildren<T>,
ref: any
) {
//if opened is false and wrapper is defined as function, do not render anything
if (!props.opened && typeof props.wrapper === 'function') {
return null;
}
return (
<FormerProvider {...props}>
<FormerInner ref={ref}>{props.children}</FormerInner>
</FormerProvider>
);
}
);

View File

@@ -0,0 +1,97 @@
import type {
ButtonProps,
GroupProps,
LoadingOverlayProps,
ScrollAreaAutosizeProps,
} from '@mantine/core';
import type React from 'react';
import type { FieldValues, UseFormProps, UseFormReturn } from 'react-hook-form';
export type FormerAPICallType<T extends FieldValues = any> = (
mode: 'mutate' | 'read',
request: RequestType,
value?: T,
key?: number | string
) => Promise<T>;
export interface FormerProps<T extends FieldValues = any> {
afterGet?: (data: T) => Promise<T> | void;
afterSave?: (data: T) => Promise<void> | void;
beforeSave?: (data: 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;
title?: string;
};
onAPICall?: FormerAPICallType<T>;
onCancel?: () => void;
onChange?: (value: T) => void;
onClose?: (data?: T) => void;
onConfirmDelete?: (values?: T) => Promise<boolean>;
onOpen?: (data?: T) => void;
opened?: boolean;
primeData?: T;
request: RequestType;
uniqueKeyField?: string;
useFormProps?: UseFormProps<T>;
values?: T;
wrapper?: FormerSectionRender<T>;
}
export interface FormerRef<T extends FieldValues = any> {
close: () => Promise<void>;
getValue: () => T | undefined;
reset: () => void;
save: () => Promise<T | undefined>;
setValue: (value: T) => void;
show: () => Promise<void>;
validate: () => Promise<boolean>;
}
export type FormerSectionRender<T extends FieldValues = any> = (
children: React.ReactNode,
opened: boolean | undefined,
onClose: ((data?: T) => void) | undefined,
onOpen: ((data?: T) => void) | undefined,
getState: FormerState<T>['getState']
) => React.ReactNode;
export interface FormerState<T extends FieldValues = any> {
deleteConfirmed?: boolean;
error?: string;
getFormMethods?: () => UseFormReturn<any, any>;
getState: <K extends keyof FormStateAndProps<T>>(key: K) => FormStateAndProps<T>[K];
load: (reset?: boolean) => Promise<void>;
loading?: boolean;
loadingOverlayProps?: LoadingOverlayProps;
reset: (e?: React.BaseSyntheticEvent<object, any, any> | undefined) => Promise<void>;
save: (e?: React.BaseSyntheticEvent<object, any, any> | undefined) => Promise<T | undefined>;
scrollAreaProps?: ScrollAreaAutosizeProps;
setRequest: (request: RequestType) => void;
setState: <K extends keyof FormStateAndProps<T>>(
key: K,
value: Partial<FormStateAndProps<T>>[K]
) => void;
setStateFN: <K extends keyof FormStateAndProps<T>>(
key: K,
value: (current: FormStateAndProps<T>[K]) => Partial<FormStateAndProps<T>[K]>
) => Promise<void>;
validate: () => Promise<boolean>;
}
export type FormStateAndProps<T extends FieldValues = any> = FormerProps<T> &
Partial<FormerState<T>>;
export type RequestType = 'delete' | 'insert' | 'select' | 'update' | 'view';

View File

@@ -0,0 +1,85 @@
import { Button, Group, Tooltip } from '@mantine/core';
import { IconDeviceFloppy, IconX } from '@tabler/icons-react';
import { useFormerStore } from './Former.store';
export const FormerButtonArea = () => {
const {
buttonAreaGroupProps,
closeButtonProps,
closeButtonTitle,
dirty,
onClose,
request,
save,
saveButtonProps,
saveButtonTitle,
} = useFormerStore((state) => ({
buttonAreaGroupProps: state.layout?.buttonAreaGroupProps,
closeButtonProps: state.layout?.closeButtonProps,
closeButtonTitle: state.layout?.closeButtonTitle,
dirty: state.dirty,
onClose: state.onClose,
request: state.request,
save: state.save,
saveButtonProps: state.layout?.saveButtonProps,
saveButtonTitle: state.layout?.saveButtonTitle,
}));
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();
}}
>
{closeButtonTitle || 'Close'}
</Button>
)}
<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>
);
};

View File

@@ -0,0 +1,90 @@
import { LoadingOverlay, ScrollAreaAutosize } from '@mantine/core';
import { type PropsWithChildren, useEffect } from 'react';
import { useFormerStore } from './Former.store';
import { FormerLayoutBottom } from './FormerLayoutBottom';
import { FormerLayoutTop } from './FormerLayoutTop';
export const FormerLayout = (props: PropsWithChildren) => {
const {
disableHTMlForm,
getFormMethods,
id,
load,
loading,
loadingOverlayProps,
opened,
request,
reset,
save,
scrollAreaProps,
} = useFormerStore((state) => ({
disableHTMlForm: state.disableHTMlForm,
getFormMethods: state.getFormMethods,
id: state.id,
load: state.load,
loading: state.loading,
loadingOverlayProps: state.loadingOverlayProps,
opened: state.opened,
request: state.request,
reset: state.reset,
save: state.save,
scrollAreaProps: state.scrollAreaProps,
}));
useEffect(() => {
if (getFormMethods) {
const formMethods = getFormMethods();
if (formMethods && request !== 'insert') {
load(true);
}
}
}, [getFormMethods, request, opened]);
return (
<>
<FormerLayoutTop />
<ScrollAreaAutosize
offsetScrollbars
scrollbarSize={4}
type="auto"
{...scrollAreaProps}
style={{
height: '100%',
padding: '0.25rem',
width: '100%',
...scrollAreaProps?.style,
}}
>
{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
loaderProps={{ type: 'bars' }}
overlayProps={{
backgroundOpacity: 0.5,
}}
{...loadingOverlayProps}
visible={loading}
/>
</ScrollAreaAutosize>
<FormerLayoutBottom />
</>
);
};

View File

@@ -0,0 +1,23 @@
import { useFormerStore } from './Former.store';
import { FormerButtonArea } from './FormerButtonArea';
export const FormerLayoutBottom = () => {
const { buttonArea, getState, opened, renderBottom } = useFormerStore((state) => ({
buttonArea: state.layout?.buttonArea,
getState: state.getState,
opened: state.opened,
renderBottom: state.layout?.renderBottom,
}));
if (renderBottom) {
return renderBottom(
<FormerButtonArea />,
opened,
getState('onClose'),
getState('onOpen'),
getState
);
}
return buttonArea === "bottom" ? <FormerButtonArea /> : <></>;
};

View File

@@ -0,0 +1,22 @@
import { useFormerStore } from './Former.store';
import { FormerButtonArea } from './FormerButtonArea';
export const FormerLayoutTop = () => {
const { buttonArea, getState, opened, renderTop } = useFormerStore((state) => ({
buttonArea: state.layout?.buttonArea,
getState: state.getState,
opened: state.opened,
renderTop: state.layout?.renderTop,
}));
if (renderTop) {
return renderTop(
<FormerButtonArea />,
opened,
getState('onClose'),
getState('onOpen'),
getState
);
}
return buttonArea === "top" ? <FormerButtonArea /> : <></>;
};

View File

@@ -0,0 +1,72 @@
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) {
throw new Error(`API request failed with status ${response.status}`);
}
const data = await response.json();
return data as any;
};
}
export { FormerResolveSpecAPI, type ResolveSpecRequest };

View 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) {
throw new Error(`API request failed with status ${response.status}`);
}
if (mode === 'read') {
const data = await response.json();
return data as any;
} else {
return value as any;
}
};
}
export { FormerRestHeadSpecAPI };

View 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>
);
};

6
src/Former/index.ts Normal file
View File

@@ -0,0 +1,6 @@
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';

View File

@@ -0,0 +1,42 @@
//@ts-nocheck
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Box } from '@mantine/core';
import { fn } from 'storybook/test';
import { FormTest } from './example';
const Renderable = (props: any) => {
return (
<Box h="100%" mih="400px" miw="400px" w="100%">
<FormTest {...props} />
</Box>
);
};
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/Former 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',
},
};

View 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>
);
};

View File

@@ -0,0 +1,182 @@
import { Button, Group, Select, Stack, Switch } from '@mantine/core';
import { useRef, useState } from 'react';
import { Controller } from 'react-hook-form';
import type { FormerAPICallType, FormerProps, FormerRef } from '../Former.types';
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 = () => {
const [request, setRequest] = useState<null | string>('insert');
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 [formData, setFormData] = useState({ a: 99, rid_usernote: 3047 });
//console.log('formData render', formData);
const ref = useRef<FormerRef>(null);
return (
<Stack h="100%" mih="400px" w="90%">
<Group>
<Select
data={['insert', 'update', 'delete', 'select', 'view']}
onChange={setRequest}
value={request}
/>
<Switch
checked={wrapped}
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>
<Group>
<Button
onClick={async () => {
const valid = await ref.current?.validate();
console.log('validate -> ', valid, ref.current);
}}
>
Test Ref Values. See console
</Button>
<Button
onClick={async () => {
setTimeout(() => {
ref.current?.close?.();
}, 3000);
}}
>
Test Show/Hide
</Button>
</Group>
<FormerModel former={{ request: 'insert' }} onClose={() => setOpen(false)} opened={open}>
<div>Test</div>
</FormerModel>
<Former
disableHTMlForm={disableHTML}
layout={layout}
onAPICall={
apiOptions.type === 'api'
? FormerRestHeadSpecAPI({
authToken: apiOptions.authToken,
url: apiOptions.url,
})
: StubAPI()
}
onChange={setFormData}
onClose={() => setOpen(false)}
opened={open}
primeData={{ a: '66', test: 'primed' }}
ref={ref}
request={request as any}
//wrapper={(children, getState) => <div>{children}</div>}
//opened={true}
uniqueKeyField="rid_usernote"
useFormProps={{ criteriaMode: 'all', shouldUseNativeValidation: false }}
values={formData}
// wrapper={
// wrapped
// ? (children, opened, onClose, _onOpen, getState) => {
// const values = getState('values');
// return (
// <Drawer
// h={'100%'}
// onClose={() => onClose?.()}
// opened={opened ?? false}
// title={`Drawer Former - Current A Value: ${values?.a}`}
// w={'50%'}
// >
// <Paper h="100%" shadow="sm" w="100%" withBorder>
// {children}
// </Paper>
// </Drawer>
// );
// }
// : undefined
// }
>
<Stack pb={'400px'}>
<Stack>
<Controller
name="test"
render={({ field }) => <input type="text" {...field} placeholder="A" />}
/>
<Controller
name="a"
render={({ field }) => <input type="text" {...field} placeholder="B" />}
rules={{ required: 'Field is required' }}
/>
<Controller
name="note"
render={({ field }) => <input type="text" {...field} placeholder="note" />}
rules={{ required: 'Field is required' }}
/>
</Stack>
{!disableHTML && (
<Stack>
<button type="submit">HTML Submit</button>
<button type="reset">HTML Reset</button>
</Stack>
)}
</Stack>
</Former>
{apiOptions.type === 'api' && (
<ApiFormData
onChange={(values) => {
setApiOptions({ ...apiOptions, ...values });
}}
values={apiOptions}
/>
)}
</Stack>
);
};

13
src/Former/todo.md Normal file
View File

@@ -0,0 +1,13 @@
- [x] Wrapper must receive button areas etc. Better scroll areas.
- [x] Predefined wrappers (Model,Dialog,notification,popover)
- [x] Headerspec API
- [x] Relspec API
- [ ] SocketSpec API
- [x] Layout Tool
- [x] Header Section
- [x] Button Section
- [x] Footer Section
- [ ] Different Loaded for saving vs loading
- [ ] Better Confirm Dialog
- [ ] Reset Confirm Dialog
- [ ] Request insert and save but keep open (must clear key from API, also add callback)

View 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;

View 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;

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

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

View 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 }

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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',
},
};

View File

@@ -1,12 +1,14 @@
import '@glideapps/glide-data-grid/dist/index.css'; import '@glideapps/glide-data-grid/dist/index.css';
import React, { type Ref } from 'react';
import { MantineBetterMenusProvider } from '../MantineBetterMenu'; import { MantineBetterMenusProvider } from '../MantineBetterMenu';
import { GlidlerFormAdaptor } from './components/adaptors/GlidlerFormAdaptor'; import { GlidlerFormAdaptor } from './components/adaptors/GlidlerFormAdaptor';
import { GlidlerLocalDataAdaptor } from './components/adaptors/GlidlerLocalDataAdaptor'; import { GlidlerLocalDataAdaptor } from './components/adaptors/GlidlerLocalDataAdaptor';
import { type GridlerProps, Provider } from './components/GridlerStore'; import { type GridlerProps, type GridlerRef, Provider } from './components/GridlerStore';
import { GridlerRefHandler } from './components/RefHandler';
import { GridlerDataGrid } from './GridlerDataGrid'; import { GridlerDataGrid } from './GridlerDataGrid';
const Gridler = (props: GridlerProps) => { const _Gridler = (props: GridlerProps, ref: Ref<GridlerRef> | undefined) => {
return ( return (
<MantineBetterMenusProvider> <MantineBetterMenusProvider>
<Provider <Provider
@@ -19,12 +21,20 @@ const Gridler = (props: GridlerProps) => {
}} }}
> >
<GridlerDataGrid /> <GridlerDataGrid />
<GridlerRefHandler ref={ref} />
{props.children} {props.children}
</Provider> </Provider>
</MantineBetterMenusProvider> </MantineBetterMenusProvider>
); );
}; };
type GridlerComponentType = {
FormAdaptor: typeof GlidlerFormAdaptor;
LocalDataAdaptor: typeof GlidlerLocalDataAdaptor;
} & React.ForwardRefExoticComponent<GridlerProps & React.RefAttributes<GridlerRef>>;
const Gridler = React.forwardRef(_Gridler) as GridlerComponentType;
Gridler.FormAdaptor = GlidlerFormAdaptor; Gridler.FormAdaptor = GlidlerFormAdaptor;
Gridler.LocalDataAdaptor = GlidlerLocalDataAdaptor; Gridler.LocalDataAdaptor = GlidlerLocalDataAdaptor;

View File

@@ -40,6 +40,7 @@ export const GridlerDataGrid = () => {
const { const {
_gridSelection, _gridSelection,
allowMultiSelect,
focused, focused,
getCellContent, getCellContent,
getCellsForSelection, getCellsForSelection,
@@ -49,6 +50,7 @@ export const GridlerDataGrid = () => {
headerHeight, headerHeight,
heightProp, heightProp,
mounted, mounted,
onCellActivated,
onCellClicked, onCellClicked,
onCellEdited, onCellEdited,
onColumnMoved, onColumnMoved,
@@ -69,6 +71,7 @@ export const GridlerDataGrid = () => {
widthProp, widthProp,
} = useGridlerStore((s) => ({ } = useGridlerStore((s) => ({
_gridSelection: s._gridSelection, _gridSelection: s._gridSelection,
allowMultiSelect: s.allowMultiSelect,
focused: s.focused, focused: s.focused,
getCellContent: s.getCellContent, getCellContent: s.getCellContent,
getCellsForSelection: s.getCellsForSelection, getCellsForSelection: s.getCellsForSelection,
@@ -78,6 +81,7 @@ export const GridlerDataGrid = () => {
headerHeight: s.headerHeight, headerHeight: s.headerHeight,
heightProp: s.height, heightProp: s.height,
mounted: s.mounted, mounted: s.mounted,
onCellActivated: s.onCellActivated,
onCellClicked: s.onCellClicked, onCellClicked: s.onCellClicked,
onCellEdited: s.onCellEdited, onCellEdited: s.onCellEdited,
onColumnMoved: s.onColumnMoved, onColumnMoved: s.onColumnMoved,
@@ -157,16 +161,16 @@ export const GridlerDataGrid = () => {
height={height ?? 400} height={height ?? 400}
overscrollX={16} overscrollX={16}
overscrollY={32} overscrollY={32}
rangeSelect="multi-rect" rangeSelect={allowMultiSelect ? 'multi-rect' : 'cell'}
rightElementProps={{ rightElementProps={{
fill: false, fill: false,
sticky: true, sticky: true,
}} }}
rowMarkers={{ rowMarkers={{
checkboxStyle: 'square', checkboxStyle: 'square',
kind: 'both', kind: allowMultiSelect ? 'both' : 'clickable-number',
}} }}
rowSelect="multi" rowSelect={allowMultiSelect ? 'multi' : 'single'}
rowSelectionMode="auto" rowSelectionMode="auto"
spanRangeBehavior="default" spanRangeBehavior="default"
{...glideProps} {...glideProps}
@@ -176,6 +180,7 @@ export const GridlerDataGrid = () => {
gridSelection={_gridSelection} gridSelection={_gridSelection}
headerHeight={headerHeight ?? 32} headerHeight={headerHeight ?? 32}
headerIcons={{ sort: SortSprite, sortdown: SortDownSprite, sortup: SortUpSprite }} headerIcons={{ sort: SortSprite, sortdown: SortDownSprite, sortup: SortUpSprite }}
onCellActivated={onCellActivated}
onCellClicked={onCellClicked} onCellClicked={onCellClicked}
onCellContextMenu={(cell, event) => { onCellContextMenu={(cell, event) => {
event.preventDefault(); event.preventDefault();
@@ -195,7 +200,13 @@ export const GridlerDataGrid = () => {
onGridSelectionChange={(selection) => { onGridSelectionChange={(selection) => {
let rows = CompactSelection.empty(); let rows = CompactSelection.empty();
const currentSelection = getState('_gridSelection'); const currentSelection = getState('_gridSelection');
const keyField = getState('keyField') ?? 'id';
const getRowBuffer = getState('getRowBuffer');
for (const r of selection.rows) { for (const r of selection.rows) {
const validRowID = getRowBuffer ? getRowBuffer(r)?.[keyField] : null;
if (!validRowID) {
continue;
}
rows = rows.hasIndex(r) ? rows : rows.add(r); rows = rows.hasIndex(r) ? rows : rows.add(r);
} }
if (selectMode === 'row' && selection.current?.range) { if (selectMode === 'row' && selection.current?.range) {
@@ -204,19 +215,32 @@ export const GridlerDataGrid = () => {
y < selection.current.range.y + selection.current.range.height; y < selection.current.range.y + selection.current.range.height;
y++ y++
) { ) {
const validRowID = getRowBuffer ? getRowBuffer(y)?.[keyField] : null;
if (!validRowID) {
continue;
}
rows = rows.hasIndex(y) ? rows : rows.add(y); rows = rows.hasIndex(y) ? rows : rows.add(y);
} }
} }
if (rows.length === 0) {
for (const r of currentSelection?.rows ?? []) {
const validRowID = getRowBuffer ? getRowBuffer(r)?.[keyField] : null;
if (!validRowID) {
continue;
}
rows = rows.hasIndex(r) ? rows : rows.add(r);
}
}
console.log('Debug:onGridSelectionChange', currentSelection, selection);
if ( if (
JSON.stringify(currentSelection?.columns) !== JSON.stringify(selection.columns) || JSON.stringify(currentSelection?.columns) !== JSON.stringify(selection.columns) ||
JSON.stringify(currentSelection?.rows) !== JSON.stringify(rows) || JSON.stringify(currentSelection?.rows) !== JSON.stringify(rows) ||
JSON.stringify(currentSelection?.current) !== JSON.stringify(selection.current) JSON.stringify(currentSelection?.current) !== JSON.stringify(selection.current)
) { ) {
setState('_gridSelection', { ...selection, rows }); setState('_gridSelection', { ...selection, rows });
if (JSON.stringify(currentSelection?.rows) !== JSON.stringify(rows)) { //if (JSON.stringify(currentSelection?.rows) !== JSON.stringify(rows)) {
setState('_gridSelectionRows', rows); setState('_gridSelectionRows', rows);
} //}
} }
//console.log('Selection', selection); //console.log('Selection', selection);

View File

@@ -1,4 +1,4 @@
/* eslint-disable react/react-in-jsx-scope */
import { useGridlerStore } from './GridlerStore'; import { useGridlerStore } from './GridlerStore';
export function BottomBar() { export function BottomBar() {

View File

@@ -23,6 +23,7 @@ export interface GridlerColumn extends Partial<BaseGridColumn> {
disableFilter?: boolean; disableFilter?: boolean;
disableMove?: boolean; disableMove?: boolean;
disableResize?: boolean; disableResize?: boolean;
disableSearch?: boolean;
disableSort?: boolean; disableSort?: boolean;
getMenuItems?: ( getMenuItems?: (
id: string, id: string,
@@ -35,6 +36,7 @@ export interface GridlerColumn extends Partial<BaseGridColumn> {
maxWidth?: number; maxWidth?: number;
minWidth?: number; minWidth?: number;
tooltip?: ((buffer: any, row: number, col: number) => ReactNode) | string; tooltip?: ((buffer: any, row: number, col: number) => ReactNode) | string;
virtual?: boolean;
width?: number; width?: number;
} }

View File

@@ -1,4 +1,5 @@
import { CompactSelection } from '@glideapps/glide-data-grid'; import { CompactSelection } from '@glideapps/glide-data-grid';
import { useDebouncedCallback } from '@mantine/hooks';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { useGridlerStore } from './GridlerStore'; import { useGridlerStore } from './GridlerStore';
@@ -6,42 +7,69 @@ import { useGridlerStore } from './GridlerStore';
//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 Computer = React.memo(() => { export const Computer = React.memo(() => {
const refFirstRun = useRef(0); const refFirstRun = useRef(0);
const refLastSearch = useRef('');
const refLastFilters = useRef<unknown>(null); const refLastFilters = useRef<unknown>(null);
const { const {
_glideref, _glideref,
_gridSelectionRows, _gridSelectionRows,
askAPIRowNumber,
colFilters, colFilters,
colOrder, colOrder,
colSize, colSize,
colSort, colSort,
columns, columns,
getRowIndexByKey,
getState, getState,
loadPage, loadPage,
ready, ready,
scrollToRowKey,
searchStr,
selectedRowKey,
selectFirstRowOnMount,
setState, setState,
setStateFN, setStateFN,
values, values
} = useGridlerStore((s) => ({ } = useGridlerStore((s) => ({
_glideref: s._glideref, _glideref: s._glideref,
_gridSelectionRows: s._gridSelectionRows, _gridSelectionRows: s._gridSelectionRows,
askAPIRowNumber: s.askAPIRowNumber,
colFilters: s.colFilters, colFilters: s.colFilters,
colOrder: s.colOrder, colOrder: s.colOrder,
colSize: s.colSize, colSize: s.colSize,
colSort: s.colSort, colSort: s.colSort,
columns: s.columns, columns: s.columns,
getRowIndexByKey: s.getRowIndexByKey,
getState: s.getState, getState: s.getState,
loadPage: s.loadPage, loadPage: s.loadPage,
ready: s.ready, ready: s.ready,
scrollToRowKey: s.scrollToRowKey,
searchStr: s.searchStr,
selectedRowKey: s.selectedRowKey,
selectFirstRowOnMount:s.selectFirstRowOnMount,
setState: s.setState, setState: s.setState,
setStateFN: s.setStateFN, setStateFN: s.setStateFN,
uniqueid: s.uniqueid, uniqueid: s.uniqueid,
values: s.values, values: s.values,
})); }));
const debouncedDoSearch = useDebouncedCallback(
(searchStr: string) => {
loadPage(0, 'all').then(() => {
getState('refreshCells')?.();
getState('_events')?.dispatchEvent?.(
new CustomEvent('onSearched', {
detail: { search: searchStr },
})
);
});
},
{
delay: 300,
leading: false,
}
);
//When values change, update selection
useEffect(() => { useEffect(() => {
const searchSelection = async () => { const searchSelection = async () => {
const page_data = getState('_page_data'); const page_data = getState('_page_data');
@@ -72,8 +100,8 @@ export const Computer = React.memo(() => {
break; break;
} }
} }
if (!(rowIndex >= 0) && typeof askAPIRowNumber === 'function') { if (!(rowIndex >= 0)) {
const idx = await askAPIRowNumber(key); const idx = await getRowIndexByKey(key);
if (idx) { if (idx) {
rowIndexes.push(idx); rowIndexes.push(idx);
} }
@@ -103,40 +131,12 @@ export const Computer = React.memo(() => {
} }
}, [values]); }, [values]);
//Fire onChange when selection changes
useEffect(() => { useEffect(() => {
const onChange = getState('onChange'); const onChange = getState('onChange');
if (onChange && typeof onChange === 'function') { if (onChange && typeof onChange === 'function') {
const page_data = getState('_page_data'); const getGridSelectedRows = getState('getGridSelectedRows');
const pageSize = getState('pageSize'); const buffers = getGridSelectedRows();
const buffers = [];
if (_gridSelectionRows) {
for (const range of _gridSelectionRows) {
let buffer = undefined;
for (const p in page_data) {
for (const r in page_data[p]) {
const idx = Number(p) * pageSize + Number(r);
if (isNaN(idx)) {
continue;
}
if (Number(page_data[p][r]?._rownumber) === range + 1) {
buffer = page_data[p][r];
//console.log('Found row', range, idx, page_data[p][r]?._rownumber);
break;
} else if (idx === range + 1) {
buffer = page_data[p][r];
//console.log('Found row 2', range, idx, page_data[p][r]?._rownumber);
break;
}
}
}
if (buffer !== undefined) {
buffers.push(buffer);
}
}
}
const _values = getState('values'); const _values = getState('values');
@@ -144,7 +144,7 @@ export const Computer = React.memo(() => {
onChange(buffers); onChange(buffers);
} }
} }
}, [JSON.stringify(_gridSelectionRows), getState]); }, [_gridSelectionRows, _gridSelectionRows?.length, getState]);
useEffect(() => { useEffect(() => {
setState( setState(
@@ -157,6 +157,17 @@ export const Computer = React.memo(() => {
); );
}, [columns]); }, [columns]);
useEffect(() => {
if (searchStr === undefined || searchStr === null) {
refLastSearch.current = '';
return;
}
if (refLastSearch.current !== searchStr) {
debouncedDoSearch(searchStr);
refLastSearch.current = searchStr;
}
}, [searchStr]);
useEffect(() => { useEffect(() => {
if (!colSort) { if (!colSort) {
return; return;
@@ -180,12 +191,14 @@ export const Computer = React.memo(() => {
: (c.defaultIcon ?? 'sort'), : (c.defaultIcon ?? 'sort'),
})); }));
}).then(() => { }).then(() => {
loadPage(0, 'all'); loadPage(0, 'all').then(() => {
getState('_events')?.dispatchEvent?.( getState('refreshCells')?.();
new CustomEvent('onColumnSorted', { getState('_events')?.dispatchEvent?.(
detail: { cols: colSort }, new CustomEvent('onColumnSorted', {
}) detail: { cols: colSort },
); })
);
});
}); });
}, [colSort]); }, [colSort]);
@@ -195,13 +208,15 @@ export const Computer = React.memo(() => {
} }
if (JSON.stringify(refLastFilters.current) !== JSON.stringify(colFilters)) { if (JSON.stringify(refLastFilters.current) !== JSON.stringify(colFilters)) {
loadPage(0, 'all'); loadPage(0, 'all').then(() => {
getState('refreshCells')?.();
getState('_events')?.dispatchEvent?.(
new CustomEvent('onColumnFiltered', {
detail: { filters: colFilters },
})
);
});
refLastFilters.current = colFilters; refLastFilters.current = colFilters;
getState('_events')?.dispatchEvent?.(
new CustomEvent('onColumnFiltered', {
detail: { filters: colFilters },
})
);
} }
}, [colFilters]); }, [colFilters]);
@@ -214,6 +229,8 @@ export const Computer = React.memo(() => {
...c, ...c,
width: c.id && colSize?.[c.id] ? colSize?.[c.id] : c.width, width: c.id && colSize?.[c.id] ? colSize?.[c.id] : c.width,
})); }));
}).then(() => {
getState('refreshCells')?.();
}); });
}, [colSize]); }, [colSize]);
@@ -231,9 +248,12 @@ export const Computer = React.memo(() => {
}); });
return result; return result;
}).then(() => {
getState('refreshCells')?.();
}); });
}, [colOrder]); }, [colOrder]);
//Initial Load
useEffect(() => { useEffect(() => {
if (!_glideref) { if (!_glideref) {
return; return;
@@ -242,9 +262,123 @@ export const Computer = React.memo(() => {
return; return;
} }
refFirstRun.current = 1; refFirstRun.current = 1;
loadPage(0); loadPage(0).then(() => {
getState('refreshCells')?.();
});
}, [ready, loadPage]); }, [ready, loadPage]);
//Logic to select first row on mount
useEffect(() => {
const _events = getState('_events');
const loadPage = () => {
const selectFirstRowOnMount = getState('selectFirstRowOnMount');
const ready = getState('ready');
if (ready && selectFirstRowOnMount) {
const scrollToRowKey = getState('scrollToRowKey');
if (scrollToRowKey && scrollToRowKey >= 0) {
return;
}
const keyField = getState('keyField') ?? 'id';
const page_data = getState('_page_data');
const firstBuffer = page_data?.[0]?.[0];
const firstRow = firstBuffer?.[keyField] ?? -1;
const currentValues = getState('values') ?? [];
if (
!(values && values.length > 0) &&
firstRow &&
firstRow > 0 &&
(currentValues.length ?? 0) === 0
) {
const values = [firstBuffer, ...(currentValues as Array<Record<string, unknown>>)];
const onChange = getState('onChange');
//console.log('Selecting first row:', firstRow, firstBuffer, values);
if (onChange) {
onChange(values);
} else {
setState('values', values);
}
setState('scrollToRowKey', firstRow);
}
}
};
_events?.addEventListener('loadPage', loadPage);
return () => {
_events?.removeEventListener('loadPage', loadPage);
};
}, [ready, selectFirstRowOnMount]);
/// logic to apply the selected row.
// useEffect(() => {
// const ready = getState('ready');
// const ref = getState('_glideref');
// const getRowIndexByKey = getState('getRowIndexByKey');
// if (scrollToRowKey && ref && ready) {
// getRowIndexByKey?.(scrollToRowKey).then((r) => {
// if (r !== undefined) {
// //console.log('Scrolling to selected row:', scrollToRowKey, r);
// ref.scrollTo(0, r);
// getState('_events').dispatchEvent(
// new CustomEvent('scrollToRowKeyFound', {
// detail: { rowNumber: r, scrollToRowKey: scrollToRowKey },
// })
// );
// }
// });
// }
// }, [scrollToRowKey]);
useEffect(() => {
const ready = getState('ready');
const ref = getState('_glideref');
const getRowIndexByKey = getState('getRowIndexByKey');
const key = selectedRowKey ?? scrollToRowKey;
if (key && ref && ready) {
getRowIndexByKey?.(key).then((r) => {
if (r !== undefined) {
//console.log('Scrolling to selected row:', r, selectedRowKey, scrollToRowKey);
if (selectedRowKey) {
const onChange = getState('onChange');
const selected = [{ [getState('keyField') ?? 'id']: selectedRowKey }];
if (JSON.stringify(getState('values')) !== JSON.stringify(selected)) {
if (onChange) {
onChange(selected);
} else {
setState('values', selected);
}
}
}
ref.scrollTo(0, r);
getState('_events').dispatchEvent(
new CustomEvent('scrollToRowKeyFound', {
detail: {
rowNumber: r,
scrollToRowKey: scrollToRowKey,
selectedRowKey: selectedRowKey,
},
})
);
}
});
}
}, [scrollToRowKey, selectedRowKey]);
// console.log('Gridler:Debug:Computer', { // console.log('Gridler:Debug:Computer', {
// colFilters, // colFilters,
// colOrder, // colOrder,

View File

@@ -56,7 +56,7 @@ export type FilterOptionOperator =
| 'startswith'; | 'startswith';
export interface GridlerProps extends PropsWithChildren { export interface GridlerProps extends PropsWithChildren {
askAPIRowNumber?: (key: string) => Promise<number>; allowMultiSelect?: boolean;
columns?: GridlerColumns; columns?: GridlerColumns;
defaultSort?: Array<SortOption>; defaultSort?: Array<SortOption>;
@@ -89,6 +89,9 @@ export interface GridlerProps extends PropsWithChildren {
) => GridCell; ) => GridCell;
rowHeight?: number; rowHeight?: number;
scrollToRowKey?: number;
searchFields?: Array<string>;
searchStr?: string;
sections?: { sections?: {
bottom?: React.ReactNode; bottom?: React.ReactNode;
left?: React.ReactNode; left?: React.ReactNode;
@@ -98,18 +101,31 @@ export interface GridlerProps extends PropsWithChildren {
rightElementStart?: React.ReactNode; rightElementStart?: React.ReactNode;
top?: React.ReactNode; top?: React.ReactNode;
}; };
selectedRow?: number; selectedRowKey?: number;
selectFirstRowOnMount?: boolean;
selectMode?: 'cell' | 'row'; selectMode?: 'cell' | 'row';
showMenu?: (id: string, options?: Partial<MantineBetterMenuInstance>) => void; showMenu?: (id: string, options?: Partial<MantineBetterMenuInstance>) => void;
title?: string; title?: string;
tooltipBarProps?: React.HTMLAttributes<HTMLDivElement>; tooltipBarProps?: React.HTMLAttributes<HTMLDivElement>;
total_rows?: number; total_rows?: number;
uniqueid: string; uniqueid: string;
useAPIQuery?: (index: number) => Promise<Array<Record<string, any>>>;
values?: Array<Record<string, any>>; values?: Array<Record<string, any>>;
width?: number | string; width?: number | string;
} }
export interface GridlerRef {
getGlideRef: () => DataEditorRef | undefined;
getState: GridlerState['getState'];
isEmpty: () => boolean;
refresh: (parms?: any) => Promise<void>;
reload: (parms?: any) => Promise<void>;
reloadRow: (key: number | string) => Promise<void>;
scrollToRow: (key: number | string) => Promise<void>;
selectRow: (key: number | string) => Promise<void>;
setStateFN: GridlerState['setStateFN'];
}
export interface GridlerState { export interface GridlerState {
_active_requests?: Array<{ controller: AbortController; page: number }>; _active_requests?: Array<{ controller: AbortController; page: number }>;
_activeTooltip?: ReactNode; _activeTooltip?: ReactNode;
@@ -119,32 +135,38 @@ export interface GridlerState {
_gridSelectionRows?: GridSelection['rows']; _gridSelectionRows?: GridSelection['rows'];
_loadingList: CompactSelection; _loadingList: CompactSelection;
_page_data: Record<number, Array<any>>; _page_data: Record<number, Array<any>>;
_refresh: () => Promise<void>;
_scrollTimeout?: any | number; _scrollTimeout?: any | number;
_visibleArea: Rectangle; _visibleArea: Rectangle;
_visiblePages: Rectangle; _visiblePages: Rectangle;
addError: (err: string, ...args: Array<any>) => void; addError: (err: string, ...args: Array<any>) => void;
askAPIRowNumber?: (key: string) => Promise<number>;
colFilters?: Array<FilterOption>; colFilters?: Array<FilterOption>;
colOrder?: Record<string, number>; colOrder?: Record<string, number>;
colSize?: Record<string, number>; colSize?: Record<string, number>;
colSort?: Array<SortOption>; colSort?: Array<SortOption>;
data?: Array<any>; data?: Array<any>;
errors: Array<string>; errors: Array<string>;
focused?: boolean; focused?: boolean;
get: () => GridlerState; get: () => GridlerState;
getCellContent: (cell: Item) => GridCell; getCellContent: (cell: Item) => GridCell;
getCellsForSelection: ( getCellsForSelection: (
selection: Rectangle, selection: Rectangle,
abortSignal: AbortSignal abortSignal: AbortSignal
) => CellArray | GetCellsThunk; ) => CellArray | GetCellsThunk;
getGridSelectedRows: () => Array<any>;
getRowBuffer: (row: number) => Record<string, any>; getRowBuffer: (row: number) => Record<string, any>;
getRowIndexByKey: (key: number | string) => Promise<number | undefined>;
getState: <K extends keyof GridlerStoreState>(key: K) => GridlerStoreState[K]; getState: <K extends keyof GridlerStoreState>(key: K) => GridlerStoreState[K];
hasLocalData: boolean; hasLocalData: 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;
onCellActivated: (cell: Item) => void;
onCellClicked: (cell: Item, event: CellClickedEventArgs) => void; onCellClicked: (cell: Item, event: CellClickedEventArgs) => void;
onCellEdited: (cell: Item, newVal: EditableGridCell) => void; onCellEdited: (cell: Item, newVal: EditableGridCell) => void;
onColumnMoved: (from: number, to: number) => void; onColumnMoved: (from: number, to: number) => void;
@@ -169,21 +191,25 @@ 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;
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,
value: (current: GridlerStoreState[K]) => Partial<GridlerStoreState[K]> value: (current: GridlerStoreState[K]) => Partial<GridlerStoreState[K]>
) => Promise<void>; ) => Promise<void>;
toCell: <TRowType extends Record<string, string>>(row: TRowType, col: number) => GridCell; toCell: <TRowType extends Record<string, string>>(row: TRowType, col: number) => GridCell;
useAPIQuery?: (index: number) => Promise<Array<Record<string, any>>>;
} }
export type GridlerStoreState = GridlerProps & GridlerState; export type GridlerStoreState = GridlerProps & GridlerState;
@@ -195,6 +221,12 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
_events: new EventTarget(), _events: new EventTarget(),
_loadingList: CompactSelection.empty(), _loadingList: CompactSelection.empty(),
_page_data: {}, _page_data: {},
_refresh: async () => {
const s = get();
await s.loadPage(0, 'all');
await s.refreshCells();
await s.reload?.();
},
_visibleArea: { height: 10000, width: 1000, x: 0, y: 0 }, _visibleArea: { height: 10000, width: 1000, x: 0, y: 0 },
_visiblePages: { height: 0, width: 0, x: 0, y: 0 }, _visiblePages: { height: 0, width: 0, x: 0, y: 0 },
addError: (err: string, ...args: Array<unknown>) => { addError: (err: string, ...args: Array<unknown>) => {
@@ -248,6 +280,42 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
return result as CellArray; return result as CellArray;
}; };
}, },
getGridSelectedRows: () => {
const state = get();
const buffers: Array<any> = [];
const page_data = state._page_data;
const pageSize = state.pageSize;
if (state._gridSelectionRows) {
for (const range of state._gridSelectionRows) {
let buffer = undefined;
for (const p in page_data) {
for (const r in page_data[p]) {
const idx = Number(p) * pageSize + Number(r);
if (isNaN(idx)) {
continue;
}
if (Number(page_data[p][r]?._rownumber) === range + 1) {
buffer = page_data[p][r];
//console.log('Found row', range, idx, page_data[p][r]?._rownumber);
break;
} else if (idx === range + 1) {
buffer = page_data[p][r];
//console.log('Found row 2', range, idx, page_data[p][r]?._rownumber);
break;
}
}
}
if (buffer !== undefined) {
buffers.push(buffer);
}
}
}
return buffers;
},
getRowBuffer: (row: number) => { getRowBuffer: (row: number) => {
const state = get(); const state = get();
//Handle local data //Handle local data
@@ -270,10 +338,73 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
return rowData; return rowData;
}, },
getRowIndexByKey: async (key: number | string) => {
const state = get();
let rowIndex = -1;
if (state.ready) {
const page_data = state._page_data;
const pageSize = state.pageSize;
const keyField = state.keyField ?? 'id';
for (const p in page_data) {
for (const r in page_data[p]) {
const idx = Number(p) * pageSize + Number(r);
//console.log('Found row', idx, page_data[p][r]?.[keyField], scrollToRowKey);
if (String(page_data[p][r]?.[keyField]) === String(key)) {
rowIndex =
page_data[p][r]?._rownumber > 0 ? page_data[p][r]?._rownumber : idx > 0 ? idx : -1;
break;
}
}
if (rowIndex > 0) {
console.log('Local row index', rowIndex, key);
return rowIndex;
}
}
if (rowIndex > 0) {
return rowIndex;
} else if (typeof state.askAPIRowNumber === 'function') {
const rn = await state.askAPIRowNumber(String(key));
if (rn && rn >= 0) {
console.log('Remote row index', rowIndex, key);
return rn;
}
}
}
return undefined;
},
getState: (key) => { getState: (key) => {
return get()[key]; return get()[key];
}, },
hasLocalData: false, hasLocalData: false,
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();
@@ -332,7 +463,7 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
); );
}) })
.catch((e) => { .catch((e) => {
console.warn('loadPage Error: ', page, e); console.error('loadPage Error: ', page, e);
state._events.dispatchEvent( state._events.dispatchEvent(
new CustomEvent('loadPage_error', { new CustomEvent('loadPage_error', {
detail: { clearMode, error: e, page: pPage, state }, detail: { clearMode, error: e, page: pPage, state },
@@ -343,10 +474,29 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
}, },
maxConcurrency: 1, maxConcurrency: 1,
mounted: false, mounted: false,
onCellActivated: (cell: Item): void => {
const state = get();
const [col, row] = cell;
state._events.dispatchEvent(
new CustomEvent('onCellActivated', {
detail: { cell, col, row, state },
})
);
state.glideProps?.onCellActivated?.(cell);
},
onCellClicked: (cell: Item, event: CellClickedEventArgs) => { onCellClicked: (cell: Item, event: CellClickedEventArgs) => {
const state = get(); const state = get();
const [col, row] = cell; const [col, row] = cell;
state.glideProps?.onCellClicked?.(cell, event); if (state.glideProps?.onCellClicked) {
state.glideProps?.onCellClicked?.(cell, event);
}
if (state.values?.length) {
if (state.onChange) {
state.onChange(state.values);
}
}
state._events.dispatchEvent( state._events.dispatchEvent(
new CustomEvent('onCellClicked', { new CustomEvent('onCellClicked', {
detail: { cell, col, row, state }, detail: { cell, col, row, state },
@@ -388,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];
@@ -421,7 +572,6 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
}); });
} }
}, },
onContextClick: (area: string, event: CellClickedEventArgs, col?: number, row?: number) => { onContextClick: (area: string, event: CellClickedEventArgs, col?: number, row?: number) => {
const s = get(); const s = get();
const coldef = s.renderColumns?.[col ?? -1]; const coldef = s.renderColumns?.[col ?? -1];
@@ -601,9 +751,11 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
isDivider: true, isDivider: true,
}, },
{ {
id: 'refesh',
label: `Refresh`, label: `Refresh`,
onClickAsync: async () => { onClick: () => {
await s.reload?.(); const s = get();
s._refresh?.();
}, },
}, },
]; ];
@@ -700,6 +852,30 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
}, },
pageSize: 50, pageSize: 50,
ready: false, ready: false,
refreshCells: (fromRow?: number, toRow?: number, col?: number) => {
const state = get();
const damageList: { cell: [number, number] }[] = [];
const colLen = Object.keys(state.renderColumns ?? [1, 2, 3]).length;
const from = fromRow && fromRow > 0 ? fromRow : 0;
const to = toRow && toRow >= from ? toRow : from + state.pageSize;
for (let row = from; row <= to; row++) {
if (col && col > 0) {
damageList.push({
cell: [col, row],
});
} else {
for (let c = 0; c <= colLen; c++) {
damageList.push({
cell: [c, row],
});
}
}
}
state._glideref?.updateCells(damageList);
},
setState: (key, value) => { setState: (key, value) => {
set( set(
produce((state) => { produce((state) => {
@@ -756,8 +932,8 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
return { return {
allowOverlay: true, allowOverlay: true,
data: val, data: val ?? '',
displayData: String(val), displayData: String(val ?? ''),
kind: GridCellKind.Text, kind: GridCellKind.Text,
}; };
} catch (e) { } catch (e) {
@@ -773,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]);
@@ -814,63 +990,18 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
}; };
}, [setState, getState]); }, [setState, getState]);
/// logic to apply the selected row.
useEffect(() => {
const ready = getState('ready');
const ref = getState('_glideref');
const keyField = getState('keyField') ?? 'id';
const selectedRow = getState('selectedRow') ?? props.selectedRow;
const askAPIRowNumber = getState('askAPIRowNumber');
let rowIndex = -1;
if (selectedRow && ref && ready) {
const page_data = getState('_page_data');
const pageSize = getState('pageSize');
for (const p in page_data) {
for (const r in page_data[p]) {
const idx = Number(p) * pageSize + Number(r);
//console.log('Found row', idx, page_data[p][r]?.[keyField], selectedRow);
if (String(page_data[p][r]?.[keyField]) === String(selectedRow)) {
rowIndex =
page_data[p][r]?._rownumber > 0 ? page_data[p][r]?._rownumber : idx > 0 ? idx : -1;
break;
}
}
if (rowIndex > 0) {
break;
}
}
if (rowIndex > 0) {
ref.scrollTo(0, rowIndex);
} else if (typeof askAPIRowNumber === 'function') {
askAPIRowNumber(String(selectedRow))
.then((r) => {
if (r >= 0) {
ref.scrollTo(0, r);
getState('_events').dispatchEvent(
new CustomEvent('selectedRowFound', {
detail: { rowNumber: r, selectedRow: selectedRow },
})
);
}
})
.catch((e) => {
console.warn('Error in askAPIRowNumber', e);
});
}
}
}, [props.selectedRow]);
getState('_events').addEventListener('reload', (_e: Event) => { getState('_events').addEventListener('reload', (_e: Event) => {
getState('reload')?.(); getState('_refresh')?.();
}); });
return { return {
...props, ...props,
colSort: props.defaultSort ?? getState('colSort') ?? [],
hideMenu: props.hideMenu ?? menus.hide, hideMenu: props.hideMenu ?? menus.hide,
scrollToRowKey: props.scrollToRowKey ?? props.selectedRowKey ?? getState('scrollToRowKey'),
showMenu: props.showMenu ?? menus.show, showMenu: props.showMenu ?? menus.show,
total_rows: props.total_rows ?? getState('total_rows') ?? 0, total_rows: getState('total_rows') ?? props.total_rows,
}; };
} }
); );

View File

@@ -7,6 +7,7 @@ import { useGridlerStore } from './GridlerStore';
export const Pager = React.memo(() => { export const Pager = React.memo(() => {
const [ const [
setState, setState,
getState,
glideref, glideref,
visiblePages, visiblePages,
//_visibleArea, //_visibleArea,
@@ -16,6 +17,7 @@ export const Pager = React.memo(() => {
hasLocalData, hasLocalData,
] = useGridlerStore((s) => [ ] = useGridlerStore((s) => [
s.setState, s.setState,
s.getState,
s._glideref, s._glideref,
s._visiblePages, s._visiblePages,
//s._visibleArea, //s._visibleArea,
@@ -38,10 +40,10 @@ export const Pager = React.memo(() => {
if (!glideref) { if (!glideref) {
return; return;
} }
if (hasLocalData) { // if (hasLocalData) {
//using local data, no need to load pages // //using local data, no need to load pages
return; // return;
} // }
const firstPage = Math.max(0, Math.floor(visiblePages.y / pageSize)); const firstPage = Math.max(0, Math.floor(visiblePages.y / pageSize));
const lastPage = Math.floor((visiblePages.y + visiblePages.height) / pageSize); const lastPage = Math.floor((visiblePages.y + visiblePages.height) / pageSize);
//const upperPage = pageSize * firstPage; //const upperPage = pageSize * firstPage;
@@ -57,7 +59,10 @@ export const Pager = React.memo(() => {
// ); // );
for (const page of range(firstPage, lastPage + 1, 1)) { for (const page of range(firstPage, lastPage + 1, 1)) {
loadPage(page); loadPage(page).then(() => {
const pg = getState('_page_data')?.[0] ?? {};
setState('isEmpty', pg && pg.length > 0);
});
} }
}, [loadPage, pageSize, visiblePages, glideref, _loadingList, hasLocalData]); }, [loadPage, pageSize, visiblePages, glideref, _loadingList, hasLocalData]);

View File

@@ -0,0 +1,55 @@
import React, { type PropsWithChildren, type Ref, useImperativeHandle } from 'react';
import { type GridlerRef, useGridlerStore } from './GridlerStore';
function _GridlerRefHandler(props: PropsWithChildren, ref: Ref<GridlerRef> | undefined) {
const [setStateFN, getstate] = useGridlerStore((s) => [s.setStateFN, s.getState]);
useImperativeHandle<GridlerRef, GridlerRef>(ref, () => {
return {
getGlideRef: () => {
return getstate('_glideref');
},
getState: getstate,
isEmpty: () => getstate('isEmpty'),
refresh: async (parms?: any) => {
const refreshCells = getstate('refreshCells');
const loadPage = getstate('loadPage');
loadPage?.(parms?.pageIndex ?? 0, 'all').then(() => {
refreshCells?.();
});
},
reload: async (parms?: any) => {
const refreshCells = getstate('refreshCells');
const loadPage = getstate('loadPage');
loadPage?.(parms?.pageIndex ?? 0, 'all').then(() => {
refreshCells?.();
});
},
reloadRow: async (key: number | string) => {
const refreshCells = getstate('refreshCells');
//const loadPage = getstate('loadPage');
const getRowIndexByKey = getstate('getRowIndexByKey');
const rn = await getRowIndexByKey?.(String(key));
if (rn && rn >= 0) {
refreshCells?.(rn, rn + 1);
//todo loadpage or row from server
}
},
scrollToRow: async (key: number | string) => {
if (key && Number(key) >= 0) {
setStateFN('scrollToRowKey', (cv) => Number(key ?? cv));
}
},
selectRow: async (key: number | string) => {
if (key && Number(key) >= 0) {
setStateFN('selectedRowKey', (cv) => Number(key ?? cv));
}
},
setStateFN: setStateFN,
};
}, []);
return <>{props.children}</>;
}
export const GridlerRefHandler = React.forwardRef(_GridlerRefHandler);

View File

@@ -1,12 +1,19 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import type { APIOptions } from '../../utils/types'; import type { APIOptions } from '../../utils/types';
import type { GridlerColumn } from '../Column';
import { GoAPIHeaders, type GoAPIOperation } from '../../utils/golang-restapi-v2'; import {
type FetchAPIOperation,
GoAPIHeaders,
type GoAPIOperation,
} from '../../utils/golang-restapi-v2';
import { useGridlerStore } from '../GridlerStore'; import { useGridlerStore } from '../GridlerStore';
export interface GlidlerAPIAdaptorForGoLangv2Props<T = unknown> extends APIOptions { export interface GlidlerAPIAdaptorForGoLangv2Props<T = unknown> extends APIOptions {
filter?: string;
hotfields?: Array<string>;
initialData?: Array<T>; initialData?: Array<T>;
options?: Array<GoAPIOperation>; options?: Array<GoAPIOperation>;
} }
@@ -20,30 +27,195 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
s.mounted, s.mounted,
]); ]);
const useAPIQuery: (index: number) => Promise<any> = async (index: number) => { const useAPIQuery: (index: number) => Promise<any> = useCallback(
const colSort = getState('colSort'); async (index: number) => {
const pageSize = getState('pageSize'); const columns = getState('columns');
const colFilters = getState('colFilters'); const colSort = getState('colSort');
const _active_requests = getState('_active_requests'); const pageSize = getState('pageSize');
setState('loadingData', true); const colFilters = getState('colFilters');
try { const searchStr = getState('searchStr');
const searchFields = getState('searchFields');
const _active_requests = getState('_active_requests');
const keyField = getState('keyField');
setState('loadingData', true);
try {
//console.log('APIAdaptorGoLangv2', { _active_requests, index, pageSize, props });
if (props && props.url) {
const head = new Headers();
head.set('Authorization', `Token ${props.authtoken}`);
const ops: FetchAPIOperation[] = [
{ type: 'limit', value: String(pageSize ?? 50) },
{ type: 'offset', value: String((pageSize ?? 50) * index) },
];
if (colSort?.length && colSort.length > 0) {
ops.push({
type: 'sort',
value: colSort
?.map((sort: any) => `${sort.id} ${sort.direction}`)
.reduce((acc: any, val: any) => `${acc},${val}`),
});
}
colFilters
?.filter((f) => f.value?.length > 0)
?.forEach((filter: any) => {
if (filter.value && filter.value !== '') {
ops.push({
name: `${filter.id}`,
op: filter.operator,
type: 'searchop',
value: filter.value,
});
}
});
if (searchStr && searchStr !== '') {
columns
?.filter(
(f) =>
!f.disableFilter &&
!f.disableSearch &&
!f.virtual &&
f.id &&
((searchFields ?? []).length == 0 || searchFields?.includes(f.id))
)
?.forEach((filter: any) => {
ops.push({
name: `${filter.id ?? ""}`,
op: 'contains',
type: 'searchor',
value: searchStr,
});
});
}
if (props.filter && props.filter !== '') {
ops.push({
name: 'sql_filter',
type: 'custom-sql-w',
value: props.filter,
});
}
if ((props.options ?? []).length > 0) {
ops.push(...(props.options ?? []));
}
const col_ids =
columns
?.filter((col) => !col.virtual)
?.map((col: GridlerColumn) => {
return col.id;
}) ?? [];
if (props.hotfields && props.hotfields.length > 0) {
col_ids?.push(props.hotfields.join(','));
}
if (keyField) {
if (!col_ids.includes(keyField)) {
col_ids.push(keyField);
}
}
if (col_ids && col_ids.length > 0) {
ops.push({
type: 'select-fields',
value: col_ids.join(','),
});
}
if (ops && ops.length > 0) {
const optionHeaders = GoAPIHeaders(ops);
for (const oh in GoAPIHeaders(ops)) {
head.set(oh, optionHeaders[oh]);
}
}
const currentRequestIndex = _active_requests?.findIndex((f) => f.page === index) ?? -1;
_active_requests?.forEach((r) => {
if ((r.page >= 0 && r.page < index - 2) || (index >= 0 && r.page > index + 2)) {
r.controller?.abort?.();
}
});
if (
_active_requests &&
currentRequestIndex >= 0 &&
_active_requests[currentRequestIndex]
) {
//console.log(`Already queued ${index}`, index, s._active_requests);
setState('loadingData', false);
return undefined;
}
const controller = new AbortController();
await setStateFN('_active_requests', (cv) => [
...(cv ?? []),
{ controller, page: index },
]);
const res = await fetch(
`${props.url}?x-limit=${String(pageSize ?? 50)}&x-offset=${String((pageSize ?? 50) * index)}`,
{
headers: head,
method: 'GET',
signal: controller?.signal,
}
);
if (res.ok) {
const cr = res.headers.get('Content-Range')?.split('/');
if (cr?.[1] && parseInt(cr[1], 10) > 0) {
setState('total_rows', parseInt(cr[1], 10));
}
const data = await res.json();
setState('loadingData', false);
return data ?? [];
}
addError(`${res.status} ${res.statusText}`, 'api', props.url);
await setStateFN('_active_requests', (cv) => [
...(cv ?? []).filter((f) => f.page !== index),
]);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_e) {
//console.log('APIAdaptorGoLangv2 error', e);
//addError(`Error: ${e}`, 'api', props.url);
}
setState('loadingData', false);
return [];
},
[
getState,
props.authtoken,
props.url,
props.filter,
JSON.stringify(props.options),
setState,
setStateFN,
addError,
]
);
const askAPIRowNumber: (key: string) => Promise<number> = useCallback(
async (key: string) => {
const colFilters = getState('colFilters');
//console.log('APIAdaptorGoLangv2', { _active_requests, index, pageSize, props }); //console.log('APIAdaptorGoLangv2', { _active_requests, index, pageSize, props });
if (props && props.url) { if (props && props.url) {
const head = new Headers(); const head = new Headers();
head.set('x-limit', String(pageSize ?? 50)); const ops: FetchAPIOperation[] = [
head.set('x-offset', String((pageSize ?? 50) * index)); { type: 'limit', value: String(10) },
{ type: 'fetch-rownumber', value: key },
];
head.set('Authorization', `Token ${props.authtoken}`); head.set('Authorization', `Token ${props.authtoken}`);
if (colSort?.length && colSort.length > 0) {
head.set(
'x-sort',
colSort
?.map((sort: any) => `${sort.id} ${sort.direction}`)
.reduce((acc: any, val: any) => `${acc},${val}`)
);
}
if (colFilters?.length && colFilters.length > 0) { if (colFilters?.length && colFilters.length > 0) {
colFilters colFilters
?.filter((f) => f.value?.length > 0) ?.filter((f) => f.value?.length > 0)
@@ -54,6 +226,14 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
}); });
} }
if (props.filter && props.filter !== '') {
ops.push({
name: 'sql_filter',
type: 'custom-sql-w',
value: `(${props.filter})`,
});
}
if (props.options && props.options.length > 0) { if (props.options && props.options.length > 0) {
const optionHeaders = GoAPIHeaders(props.options); const optionHeaders = GoAPIHeaders(props.options);
for (const oh in optionHeaders) { for (const oh in optionHeaders) {
@@ -61,101 +241,60 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
} }
} }
const currentRequestIndex = _active_requests?.findIndex((f) => f.page === index) ?? -1; if (ops && ops.length > 0) {
_active_requests?.forEach((r) => { const optionHeaders = GoAPIHeaders(ops);
if ((r.page >= 0 && r.page < index - 2) || (index >= 0 && r.page > index + 2)) { for (const oh in GoAPIHeaders(ops)) {
r.controller?.abort?.(); head.set(oh, optionHeaders[oh]);
} }
});
if (_active_requests && currentRequestIndex >= 0 && _active_requests[currentRequestIndex]) {
//console.log(`Already queued ${index}`, index, s._active_requests);
setState('loadingData', false);
return undefined;
} }
const controller = new AbortController(); const controller = new AbortController();
await setStateFN('_active_requests', (cv) => [...(cv ?? []), { controller, page: index }]);
const res = await fetch( const res = await fetch(`${props.url}?x-fetch-rownumber=${key}}`, {
`${props.url}?x-limit=${String(pageSize ?? 50)}&x-offset=${String((pageSize ?? 50) * index)}`, headers: head,
{ method: 'GET',
headers: head, signal: controller?.signal,
method: 'GET', });
signal: controller?.signal,
}
);
if (res.ok) { if (res.ok) {
const cr = res.headers.get('Content-Range')?.split('/');
if (cr?.[1] && parseInt(cr[1], 10) > 0) {
setState('total_rows', parseInt(cr[1], 10));
}
const data = await res.json(); const data = await res.json();
setState('loadingData', false);
return data ?? []; return data?.[0]?._rownumber ?? data?._rownumber ?? 0;
} }
addError(`${res.status} ${res.statusText}`, 'api', props.url); addError(`${res.status} ${res.statusText}`, 'api', props.url);
await setStateFN('_active_requests', (cv) => [
...(cv ?? []).filter((f) => f.page !== index),
]);
} }
} catch (e) { return [];
//console.log('APIAdaptorGoLangv2 error', e); },
addError(`Error: ${e}`, 'api', props.url); [props.url, props.authtoken, props.filter, props.options, getState, addError]
} );
setState('loadingData', false);
return [];
};
const askAPIRowNumber: (key: string) => Promise<number> = async (key: string) => {
const colFilters = getState('colFilters');
//console.log('APIAdaptorGoLangv2', { _active_requests, index, pageSize, props });
if (props && props.url) {
const head = new Headers();
head.set('x-limit', '10');
head.set('x-fetch-rownumber', String(key));
head.set('Authorization', `Token ${props.authtoken}`);
if (colFilters?.length && colFilters.length > 0) {
colFilters
?.filter((f) => f.value?.length > 0)
?.forEach((filter: any) => {
if (filter.value && filter.value !== '') {
head.set(`x-searchop-${filter.operator}-${filter.id}`, `${filter.value}`);
}
});
}
const controller = new AbortController();
const res = await fetch(`${props.url}?x-fetch-rownumber=${key}}`, {
headers: head,
method: 'GET',
signal: controller?.signal,
});
if (res.ok) {
const data = await res.json();
return data?.[0]?._rownumber ?? data?._rownumber ?? 0;
}
addError(`${res.status} ${res.statusText}`, 'api', props.url);
}
return [];
};
//Reset the function in the store.
useEffect(() => { useEffect(() => {
setState('useAPIQuery', useAPIQuery); setState('useAPIQuery', useAPIQuery);
setState('askAPIRowNumber', askAPIRowNumber); setState('askAPIRowNumber', askAPIRowNumber);
}, [props.url, props.authtoken, mounted, setState]); const isValuesInPages = getState('isValuesInPages');
const _refresh = getState('_refresh');
if (!isValuesInPages) {
setState('values', []);
}
//Reset the loaded pages to new rules
_refresh?.().then(() => {
const onChange = getState('onChange');
const getGridSelectedRows = getState('getGridSelectedRows');
if (onChange && typeof onChange === 'function') {
const buffers = getGridSelectedRows?.();
onChange(buffers);
}
});
}, [props.url, props.authtoken, props.filter, JSON.stringify(props.options), mounted, setState]);
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);

View File

@@ -14,6 +14,7 @@ import type { GridlerColumn } from '../Column';
import { type GridlerProps, type GridlerState, useGridlerStore } from '../GridlerStore'; import { type GridlerProps, type GridlerState, useGridlerStore } from '../GridlerStore';
export function GlidlerFormAdaptor(props: { export function GlidlerFormAdaptor(props: {
changeOnActiveClick?: boolean;
descriptionField?: ((data: Record<string, unknown>) => string) | string; descriptionField?: ((data: Record<string, unknown>) => string) | string;
getMenuItems?: GridlerProps['getMenuItems']; getMenuItems?: GridlerProps['getMenuItems'];
onReload?: () => void; onReload?: () => void;
@@ -23,21 +24,43 @@ export function GlidlerFormAdaptor(props: {
) => void; ) => void;
showDescriptionInMenu?: boolean; showDescriptionInMenu?: boolean;
}) { }) {
const [getState, mounted, setState, reload] = useGridlerStore((s) => [ const [getState, mounted, setState, _events] = useGridlerStore((s) => [
s.getState, s.getState,
s.mounted, s.mounted,
s.setState, s.setState,
s.reload, s._events,
]); ]);
useEffect(() => {
if (mounted && props.changeOnActiveClick) {
const evf = (event: CustomEvent<any>) => {
const { row, state } = event.detail;
const getRowBuffer = state.getRowBuffer as (row: number) => Record<string, unknown>;
if (getRowBuffer) {
const rowData = getRowBuffer(row);
if (!rowData) {
return;
}
props.onRequestForm('change', rowData);
}
};
_events?.addEventListener('onCellActivated', evf as any);
return () => {
if (evf) {
_events?.removeEventListener('onCellActivated', evf as any);
}
};
}
}, [props.changeOnActiveClick, mounted, _events]);
const getMenuItems = useCallback( const getMenuItems = useCallback(
( (
id: string, id: string,
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') {
@@ -140,8 +163,9 @@ export function GlidlerFormAdaptor(props: {
c: 'orange', c: 'orange',
label: 'Refresh', label: 'Refresh',
leftSection: <IconRefresh color="orange" size={16} />, leftSection: <IconRefresh color="orange" size={16} />,
onClick: () => { onClickAsync: async () => {
reload?.(); const _refresh = getState('_refresh');
await _refresh?.();
}, },
}); });

View File

@@ -16,23 +16,30 @@ export interface GlidlerLocalDataAdaptorProps<T = unknown> {
cols: GridlerColumns | undefined, cols: GridlerColumns | undefined,
data: Array<T> data: Array<T>
) => Array<T>; ) => Array<T>;
onSearch?: (
searchField: string | undefined,
cols: GridlerColumns | undefined,
data: Array<T>
) => Array<T>;
} }
//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.
function _GlidlerLocalDataAdaptor<T = unknown>(props: GlidlerLocalDataAdaptorProps<T>) { function _GlidlerLocalDataAdaptor<T = unknown>(props: GlidlerLocalDataAdaptorProps<T>) {
const [setState, getState, mounted] = useGridlerStore((s) => [s.setState, s.getState, s.mounted]); const [setState, getState, mounted] = useGridlerStore((s) => [s.setState, s.getState, s.mounted]);
const { colFilters, colSort, columns } = useGridlerStore((s) => ({ const { colFilters, colSort, columns, searchStr } = useGridlerStore((s) => ({
colFilters: s.colFilters, colFilters: s.colFilters,
colOrder: s.colOrder, colOrder: s.colOrder,
colSize: s.colSize, colSize: s.colSize,
colSort: s.colSort, colSort: s.colSort,
columns: s.columns, columns: s.columns,
searchStr: s.searchStr,
})); }));
const refChanged = React.useRef({ const refChanged = React.useRef({
colFilters: colFilters, colFilters: colFilters,
colSort: colSort, colSort: colSort,
searchStr: searchStr,
}); });
const useAPIQuery: (index: number) => Promise<any> = async (index: number) => { const useAPIQuery: (index: number) => Promise<any> = async (index: number) => {
@@ -56,6 +63,7 @@ function _GlidlerLocalDataAdaptor<T = unknown>(props: GlidlerLocalDataAdaptorPro
setState('total_rows', sortedData.length); setState('total_rows', sortedData.length);
setState('data', sortedData); setState('data', sortedData);
refChanged.current.colSort = colSort; refChanged.current.colSort = colSort;
getState('refreshCells')?.();
} }
}, [colSort, props.onColumnSort]); }, [colSort, props.onColumnSort]);
@@ -65,9 +73,20 @@ function _GlidlerLocalDataAdaptor<T = unknown>(props: GlidlerLocalDataAdaptorPro
setState('total_rows', filteredData.length); setState('total_rows', filteredData.length);
setState('data', filteredData); setState('data', filteredData);
refChanged.current.colFilters = colFilters; refChanged.current.colFilters = colFilters;
getState('refreshCells')?.();
} }
}, [colFilters, props.onColumnFilter]); }, [colFilters, props.onColumnFilter]);
useEffect(() => {
if (props.onSearch && searchStr !== refChanged?.current?.searchStr) {
const filteredData = props.onSearch(searchStr, columns, props.data as Array<T>);
setState('total_rows', filteredData.length);
setState('data', filteredData);
refChanged.current.colFilters = colFilters;
getState('refreshCells')?.();
}
}, [searchStr, props.onSearch]);
return <></>; return <></>;
} }
export const GlidlerLocalDataAdaptor = React.memo(_GlidlerLocalDataAdaptor); export const GlidlerLocalDataAdaptor = React.memo(_GlidlerLocalDataAdaptor);

View File

@@ -102,8 +102,8 @@ export const useGridTheme = () => {
// }[colorScheme]; // }[colorScheme];
// for (const selectedRow of gridSelection?.rows) { // for (const scrollToRowKey of gridSelection?.rows) {
// if (selectedRow === row) { // if (scrollToRowKey === row) {
// return { // return {
// bgCell: rowColor.bgCell, // bgCell: rowColor.bgCell,
// bgCellMedium: rowColor.bgCellMedium // bgCellMedium: rowColor.bgCellMedium

View File

@@ -2,7 +2,9 @@ export {GlidlerAPIAdaptorForGoLangv2 } from './components/adaptors/GlidlerAPIAda
export {GlidlerFormAdaptor } from './components/adaptors/GlidlerFormAdaptor' export {GlidlerFormAdaptor } from './components/adaptors/GlidlerFormAdaptor'
export {GlidlerLocalDataAdaptor } from './components/adaptors/GlidlerLocalDataAdaptor' export {GlidlerLocalDataAdaptor } from './components/adaptors/GlidlerLocalDataAdaptor'
export * from './components/Column' export * from './components/Column'
export {useGridlerStore } from './components/GridlerStore' export {type GridlerProps,type GridlerRef,type GridlerState, useGridlerStore } from './components/GridlerStore'
export { GridlerRightMenuIcon } from './components/RightMenuIcon' export { GridlerRightMenuIcon } from './components/RightMenuIcon'
export {Gridler} from './Gridler' export {Gridler} from './Gridler'
export * from './utils' export {GoAPIHeaders} from './utils'
export type {FetchAPIOperation} from './utils'
export type {APIOptions} from './utils/types'

View File

@@ -1,10 +1,11 @@
import { 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 { useState } from 'react'; import { useRef, useState } from 'react';
import type { GridlerColumns } from '../components/Column'; import type { GridlerColumns } from '../components/Column';
import { GlidlerAPIAdaptorForGoLangv2 } from '../components/adaptors'; import { GlidlerAPIAdaptorForGoLangv2 } from '../components/adaptors';
import { type GridlerRef } from '../components/GridlerStore';
import { Gridler } from '../Gridler'; import { Gridler } from '../Gridler';
export const GridlerGoAPIExampleEventlog = () => { export const GridlerGoAPIExampleEventlog = () => {
@@ -12,9 +13,11 @@ export const GridlerGoAPIExampleEventlog = () => {
defaultValue: 'http://localhost:8080/api', defaultValue: 'http://localhost:8080/api',
key: 'apiurl', key: 'apiurl',
}); });
const ref = useRef<GridlerRef>(null);
const [apiKey, setApiKey] = useLocalStorage({ defaultValue: '', key: 'apikey' }); const [apiKey, setApiKey] = useLocalStorage({ defaultValue: '', key: 'apikey' });
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 [sections, setSections] = useState<Record<string, unknown> | undefined>(undefined); const [sections, setSections] = useState<Record<string, unknown> | undefined>(undefined);
const columns: GridlerColumns = [ const columns: GridlerColumns = [
{ {
@@ -106,8 +109,11 @@ export const GridlerGoAPIExampleEventlog = () => {
//console.log('GridlerGoAPIExampleEventlog onChange', v); //console.log('GridlerGoAPIExampleEventlog onChange', v);
setValues(v); setValues(v);
}} }}
ref={ref}
scrollToRowKey={selectRow ? parseInt(selectRow, 10) : undefined}
searchStr={search}
sections={{ ...sections, rightElementDisabled: false }} sections={{ ...sections, rightElementDisabled: false }}
selectedRow={selectRow ? parseInt(selectRow, 10) : undefined} selectFirstRowOnMount={true}
selectMode="row" selectMode="row"
title="Go API Example" title="Go API Example"
uniqueid="gridtest" uniqueid="gridtest"
@@ -115,10 +121,12 @@ export const GridlerGoAPIExampleEventlog = () => {
> >
<GlidlerAPIAdaptorForGoLangv2 <GlidlerAPIAdaptorForGoLangv2
authtoken={apiKey} authtoken={apiKey}
options={[{ type: 'preload', value: 'PRO' }]}
//options={[{ type: 'fieldfilter', name: 'process', value: 'test' }]} //options={[{ type: 'fieldfilter', name: 'process', value: 'test' }]}
url={`${apiUrl}/public/process`} url={`${apiUrl}/public/process`}
/> />
<Gridler.FormAdaptor <Gridler.FormAdaptor
changeOnActiveClick={true}
descriptionField={'process'} descriptionField={'process'}
onRequestForm={(request, data) => { onRequestForm={(request, data) => {
console.log('Form requested', request, data); console.log('Form requested', request, data);
@@ -127,6 +135,13 @@ export const GridlerGoAPIExampleEventlog = () => {
</Gridler> </Gridler>
<Divider /> <Divider />
<Group> <Group>
<TextInput
leftSection={<>S</>}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search"
value={search}
w="190px"
/>
<TextInput <TextInput
onChange={(e) => setSelectRow(e.target.value)} onChange={(e) => setSelectRow(e.target.value)}
placeholder="row" placeholder="row"
@@ -141,6 +156,43 @@ export const GridlerGoAPIExampleEventlog = () => {
/> />
; ;
</Group> </Group>
<Group>
<Button
onClick={() => {
ref.current?.refresh();
}}
>
Refresh
</Button>
<Button
onClick={() => {
ref.current?.selectRow(20523);
}}
>
Select 20523
</Button>
<Button
onClick={() => {
ref.current?.selectRow(4);
}}
>
Select 4
</Button>
<Button
onClick={() => {
ref.current?.reloadRow(20523);
}}
>
Reload 20523
</Button>
<Button
onClick={() => {
ref.current?.scrollToRow(16272);
}}
>
Goto 2050
</Button>
</Group>
</Stack> </Stack>
); );
}; };

View File

@@ -1,3 +1,4 @@
//@ts-nocheck
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { Box } from '@mantine/core'; import { Box } from '@mantine/core';
@@ -6,7 +7,12 @@ import { fn } from 'storybook/test';
import { GridlerGoAPIExampleEventlog } from './Examples.goapi'; import { GridlerGoAPIExampleEventlog } from './Examples.goapi';
const Renderable = (props: any) => { const Renderable = (props: any) => {
return <Box h="100%" mih="400px" miw="400px" w='100%' > <GridlerGoAPIExampleEventlog {...props} /></Box>; return (
<Box h="100%" mih="400px" miw="400px" w="100%">
{' '}
<GridlerGoAPIExampleEventlog {...props} />
</Box>
);
}; };
const meta = { const meta = {
@@ -19,7 +25,7 @@ const meta = {
component: Renderable, component: Renderable,
parameters: { parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: 'centered', //layout: 'centered',
}, },
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'], tags: ['autodocs'],

View File

@@ -1,3 +1,4 @@
//@ts-nocheck
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { Box } from '@mantine/core'; import { Box } from '@mantine/core';
@@ -24,7 +25,7 @@ const meta = {
component: Renderable, component: Renderable,
parameters: { parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: 'centered', // layout: 'centered',
}, },
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'], tags: ['autodocs'],

View File

@@ -5,21 +5,20 @@ import { fn } from 'storybook/test';
import { MantineBetterMenusProvider, useMantineBetterMenus } from './'; import { MantineBetterMenusProvider, useMantineBetterMenus } from './';
const Renderable = (props: Record<string, unknown>) => {
const Renderable = (props: Record<string,unknown>) => {
return ( return (
<MantineBetterMenusProvider providerID='test' {...props} > <MantineBetterMenusProvider providerID="test" {...props}>
<Menu/> <Menu />
</MantineBetterMenusProvider> </MantineBetterMenusProvider>
); );
} };
const Menu = () => { const Menu = () => {
const menus = useMantineBetterMenus(); const menus = useMantineBetterMenus();
//menus.setState("menus",[{id:"test",items:[{id:"1",label:"Test",onClick:()=>{console.log("Clicked")}}]}]) //menus.setState("menus",[{id:"test",items:[{id:"1",label:"Test",onClick:()=>{console.log("Clicked")}}]}])
return <Button onClick={()=> menus.show("test",{})}>Menu</Button>; return <Button onClick={() => menus.show('test', {})}>Menu</Button>;
} };
const meta = { 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 // 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
@@ -31,7 +30,7 @@ const meta = {
component: Renderable, component: Renderable,
parameters: { parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: 'centered', //layout: 'centered',
}, },
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'], tags: ['autodocs'],
@@ -44,8 +43,6 @@ type Story = StoryObj<typeof meta>;
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const BasicExample: Story = { export const BasicExample: Story = {
args: { args: {
label: 'Test', label: 'Test',
}, },
}; };

View File

@@ -80,7 +80,7 @@ const MenuItemRenderer = ({ children, label, ...props }: MantineBetterMenuInstan
props.onClick?.(e); props.onClick?.(e);
if (props.onClickAsync) { if (props.onClickAsync) {
setLoading(true); setLoading(true);
props.onClickAsync().finally(() => setLoading(false)); props.onClickAsync(e).finally(() => setLoading(false));
} }
}} }}
styles={{ styles={{
@@ -120,7 +120,7 @@ const MenuItemRenderer = ({ children, label, ...props }: MantineBetterMenuInstan
props.onClick?.(e); props.onClick?.(e);
if (props.onClickAsync) { if (props.onClickAsync) {
setLoading(true); setLoading(true);
props.onClickAsync().finally(() => setLoading(false)); props.onClickAsync(e).finally(() => setLoading(false));
} }
}} }}
styles={{ styles={{

View File

@@ -22,7 +22,7 @@ export interface MantineBetterMenuInstanceItem extends Partial<MenuItemProps> {
items?: Array<MantineBetterMenuInstanceItem>; items?: Array<MantineBetterMenuInstanceItem>;
label?: string; label?: string;
onClick?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; onClick?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
onClickAsync?: () => Promise<void>; onClickAsync?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => Promise<void>;
renderer?: renderer?:
| ((props: MantineBetterMenuInstanceItem & Record<string, unknown>) => ReactNode) | ((props: MantineBetterMenuInstanceItem & Record<string, unknown>) => ReactNode)
| ReactNode; | ReactNode;

View File

@@ -1,5 +1,7 @@
export * from './Gridler' export * from './Boxer';
export * from './Former';
export * from './FormerControllers';
export * from './Gridler';
export { export {
type MantineBetterMenuInstance, type MantineBetterMenuInstance,
@@ -7,4 +9,4 @@ export {
MantineBetterMenusProvider, MantineBetterMenusProvider,
type MantineBetterMenuStoreState, type MantineBetterMenuStoreState,
useMantineBetterMenus, useMantineBetterMenus,
} from "./MantineBetterMenu"; } from './MantineBetterMenu';

View File

@@ -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(),

View File

@@ -15,7 +15,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,
@@ -37,5 +37,6 @@
"src", "src",
"lib.ts", "lib.ts",
"*.d.ts", "*.d.ts",
] ],
} }

View File

@@ -23,5 +23,5 @@
}, },
"include": [ "include": [
"vite.config.ts" "vite.config.ts"
] ],
} }