Compare commits
72 Commits
1f5999b2d1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 52a97f2a97 | |||
| 6c141b71da | |||
| 89fed20f70 | |||
| 9414421430 | |||
| c4f0fcc233 | |||
| 5180f52698 | |||
| ce7cf9435a | |||
|
|
ad2252f5e4 | ||
|
|
287dbcf4da | ||
|
|
f963b38339 | ||
|
|
55cb9038ad | ||
|
|
9d907068a6 | ||
|
|
ecb90c69aa | ||
|
|
070e56e1af | ||
|
|
3e460ae46c | ||
|
|
9c64217b72 | ||
| 1fb57d3454 | |||
| a8e9c50290 | |||
| 31f2a0428f | |||
| bc7262cede | |||
| 0825f739f4 | |||
| 0bd642e2d2 | |||
| 7cc09d6acb | |||
| 9df2f3b504 | |||
| e777e1fa3a | |||
| cd2f6db880 | |||
| e6507f44af | |||
| 400a193a58 | |||
| d935c6cf28 | |||
| 9bac48d5dd | |||
| fbb65afc94 | |||
| 095ddf6162 | |||
|
|
0d9511df77 | ||
| b2817f4233 | |||
|
|
71403289c2 | ||
|
|
7025f316de | ||
|
|
32054118de | ||
|
|
017b6445fb | ||
|
|
7c1d47819a | ||
|
|
b514c906c8 | ||
|
|
6664c988b7 | ||
|
|
249c283819 | ||
|
|
1ce5c25098 | ||
|
|
30581de17e | ||
|
|
8784a28a30 | ||
|
|
1b2bf6282d | ||
|
|
abcf08f98e | ||
|
|
0ba8dca0b4 | ||
|
|
7cfefa9e6d | ||
|
|
e879abb43f | ||
|
|
9f04b36e7e | ||
|
|
03210a3a7a | ||
|
|
e6560aa990 | ||
|
|
5e922df97a | ||
|
|
abf9433c10 | ||
|
|
1284f46aa9 | ||
|
|
a1202f9b6d | ||
|
|
a8a172cbfe | ||
|
|
9f960a6729 | ||
|
|
864188c599 | ||
|
|
5fc02f9671 | ||
|
|
b4058f1ef3 | ||
|
|
0943ffc483 | ||
|
|
bd47e9d0ab | ||
|
|
57c72e656f | ||
|
|
b977308e54 | ||
|
|
54deac6ccc | ||
|
|
615b89360a | ||
|
|
64cfed8a67 | ||
|
|
d6b7fa4076 | ||
|
|
ad5bc14d7c | ||
|
|
5d8388c2db |
@@ -1,13 +1,16 @@
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import '@mantine/core/styles.css';
|
||||
|
||||
export function PreviewDecorator(Story: any, { parameters }: any) {
|
||||
console.log('Rendering decorator', parameters);
|
||||
return (
|
||||
<MantineProvider>
|
||||
<ModalsProvider>
|
||||
<div style={{ height: 'calc(100vh - 64px)', width: 'calc(100vw - 64px)' }}>
|
||||
<Story key={'mainStory'} />
|
||||
</div>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
132
CHANGELOG.md
132
CHANGELOG.md
@@ -1,5 +1,137 @@
|
||||
# @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
|
||||
|
||||
@@ -11,26 +11,30 @@ const config = defineConfig([
|
||||
{
|
||||
extends: ['js/recommended'],
|
||||
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 },
|
||||
plugins: { js },
|
||||
},
|
||||
// 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,
|
||||
{
|
||||
...pluginReact.configs.flat.recommended,
|
||||
ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
rules: {...pluginReact.configs.flat.recommended.rules,
|
||||
ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '*stories.tsx','dist/**'],
|
||||
rules: {
|
||||
...pluginReact.configs.flat.recommended.rules,
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
}
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
},
|
||||
},
|
||||
perfectionist.configs['recommended-alphabetical'],
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
},
|
||||
},
|
||||
{ignores: ['dist/**','node_modules/**','vite.config.*','eslint.config.*' ]},
|
||||
]);
|
||||
|
||||
export default config;
|
||||
|
||||
92
package.json
92
package.json
@@ -1,8 +1,26 @@
|
||||
{
|
||||
"name": "@warkypublic/oranguru",
|
||||
"author": "Warky Devs",
|
||||
"version": "0.0.8",
|
||||
"version": "0.0.30",
|
||||
"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": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
@@ -17,82 +35,53 @@
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"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": {
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"moment": "^2.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.29.7",
|
||||
"@eslint/js": "^9.38.0",
|
||||
"@storybook/react-vite": "^9.1.13",
|
||||
"@changesets/cli": "^2.29.8",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@storybook/react-vite": "^10.2.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",
|
||||
"@types/node": "^24.9.1",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@typescript-eslint/parser": "^8.46.2",
|
||||
"@vitejs/plugin-react-swc": "^4.1.0",
|
||||
"@types/node": "^25.1.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||
"eslint": "^9.38.0",
|
||||
"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-hooks": "^7.0.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"eslint-plugin-storybook": "^9.1.13",
|
||||
"eslint-plugin-storybook": "^9.1.15",
|
||||
"global": "^4.4.0",
|
||||
"globals": "^16.4.0",
|
||||
"globals": "^17.2.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^27.0.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-eslint": "^16.4.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"storybook": "^9.1.13",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"storybook": "^9.1.15",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"vite": "^7.1.11",
|
||||
"vite": "^7.1.12",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@glideapps/glide-data-grid": "^6.0.3",
|
||||
"@mantine/core": "^8.3.1",
|
||||
"@mantine/hooks": "^8.3.1",
|
||||
"@mantine/modals": "^8.3.5",
|
||||
"@mantine/notifications": "^8.3.5",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
@@ -101,6 +90,7 @@
|
||||
"immer": "^10.1.3",
|
||||
"react": ">= 19.0.0",
|
||||
"react-dom": ">= 19.0.0",
|
||||
"react-hook-form": "^7.71.0",
|
||||
"use-sync-external-store": ">= 1.4.0",
|
||||
"zustand": ">= 5.0.0"
|
||||
}
|
||||
|
||||
1330
pnpm-lock.yaml
generated
1330
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
159
src/Boxer/Boxer.store.tsx
Normal file
159
src/Boxer/Boxer.store.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { createSyncStore } from '@warkypublic/zustandsyncstore';
|
||||
import { produce } from 'immer';
|
||||
|
||||
import type { BoxerProps, BoxerStoreState } from './Boxer.types';
|
||||
|
||||
const { Provider: BoxerProvider, useStore: useBoxerStore } = createSyncStore<
|
||||
BoxerStoreState,
|
||||
BoxerProps
|
||||
>(
|
||||
(set, get) => ({
|
||||
boxerData: [],
|
||||
// Data Actions
|
||||
fetchData: async (search?: string, reset?: boolean) => {
|
||||
const state = get();
|
||||
|
||||
// Handle local data
|
||||
if (state.dataSource === 'local' || !state.onAPICall) {
|
||||
const localData = state.data ?? [];
|
||||
|
||||
if (!search) {
|
||||
set({ boxerData: localData, hasMore: false, total: localData.length });
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter local data based on search
|
||||
const filtered = localData.filter((item) =>
|
||||
item.label.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
set({ boxerData: filtered, hasMore: false, total: filtered.length });
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle server-side data
|
||||
if (state.onAPICall) {
|
||||
try {
|
||||
set({ isFetching: true });
|
||||
|
||||
const currentPage = reset ? 0 : state.page;
|
||||
|
||||
const result = await state.onAPICall({
|
||||
page: currentPage,
|
||||
pageSize: state.pageSize,
|
||||
search,
|
||||
});
|
||||
|
||||
set(
|
||||
produce((draft) => {
|
||||
if (reset) {
|
||||
draft.boxerData = result.data;
|
||||
draft.page = 0;
|
||||
} else {
|
||||
draft.boxerData = [...(draft.boxerData ?? []), ...result.data];
|
||||
}
|
||||
draft.total = result.total;
|
||||
draft.hasMore = draft.boxerData.length < result.total;
|
||||
draft.isFetching = false;
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Boxer fetchData error:', error);
|
||||
set({ isFetching: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
fetchMoreOnBottomReached: (target: HTMLDivElement) => {
|
||||
const state = get();
|
||||
|
||||
if (!state.hasMore || state.isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollPercentage =
|
||||
(target.scrollTop + target.clientHeight) / target.scrollHeight;
|
||||
|
||||
// Load more when scrolled past 80%
|
||||
if (scrollPercentage > 0.8) {
|
||||
state.loadMore();
|
||||
}
|
||||
},
|
||||
// State Management
|
||||
getState: (key) => {
|
||||
const current = get();
|
||||
return current?.[key];
|
||||
},
|
||||
hasMore: true,
|
||||
input: '',
|
||||
isFetching: false,
|
||||
loadMore: async () => {
|
||||
const state = get();
|
||||
|
||||
if (!state.hasMore || state.isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
set(
|
||||
produce((draft) => {
|
||||
draft.page = draft.page + 1;
|
||||
})
|
||||
);
|
||||
|
||||
await state.fetchData(state.search);
|
||||
},
|
||||
// Initial State
|
||||
opened: false,
|
||||
page: 0,
|
||||
|
||||
pageSize: 50,
|
||||
|
||||
search: '',
|
||||
|
||||
selectedOptionIndex: -1,
|
||||
|
||||
setInput: (input: string) => {
|
||||
set({ input });
|
||||
},
|
||||
|
||||
// Actions
|
||||
setOpened: (opened: boolean) => {
|
||||
set({ opened });
|
||||
},
|
||||
|
||||
setSearch: (search: string) => {
|
||||
set({ search });
|
||||
},
|
||||
|
||||
setSelectedOptionIndex: (index: number) => {
|
||||
set({ selectedOptionIndex: index });
|
||||
},
|
||||
|
||||
setState: (key, value) => {
|
||||
set(
|
||||
produce((state) => {
|
||||
state[key] = value;
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
total: 0,
|
||||
}),
|
||||
({
|
||||
data = [],
|
||||
dataSource = 'local',
|
||||
pageSize = 50,
|
||||
...props
|
||||
}) => {
|
||||
return {
|
||||
...props,
|
||||
boxerData: data, // Initialize with local data if provided
|
||||
data,
|
||||
dataSource,
|
||||
hasMore: dataSource === 'server',
|
||||
pageSize,
|
||||
total: data.length,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export { BoxerProvider };
|
||||
export { useBoxerStore };
|
||||
379
src/Boxer/Boxer.tsx
Normal file
379
src/Boxer/Boxer.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { Combobox, ScrollArea, useVirtualizedCombobox } from '@mantine/core';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import React, { useCallback, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
|
||||
import type { BoxerItem, BoxerProps, BoxerRef } from './Boxer.types';
|
||||
|
||||
import { BoxerProvider, useBoxerStore } from './Boxer.store';
|
||||
import BoxerTarget from './BoxerTarget';
|
||||
import useBoxerOptions from './hooks/useBoxerOptions';
|
||||
|
||||
const BoxerInner = React.forwardRef<BoxerRef>((_, ref) => {
|
||||
// Component Refs
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const valueRef = useRef<any>(null);
|
||||
const bufferRef = useRef<any>(null);
|
||||
|
||||
// Component store State
|
||||
const {
|
||||
boxerData,
|
||||
clearable,
|
||||
comboBoxProps,
|
||||
dropDownProps,
|
||||
error,
|
||||
fetchData,
|
||||
fetchMoreOnBottomReached,
|
||||
input,
|
||||
isFetching,
|
||||
label,
|
||||
mah,
|
||||
multiSelect,
|
||||
onBufferChange,
|
||||
onChange,
|
||||
opened,
|
||||
openOnClear,
|
||||
placeholder,
|
||||
scrollAreaProps,
|
||||
search,
|
||||
selectedOptionIndex,
|
||||
selectFirst,
|
||||
setInput,
|
||||
setOpened,
|
||||
setSearch,
|
||||
setSelectedOptionIndex,
|
||||
showAll,
|
||||
value,
|
||||
} = useBoxerStore((state) => ({
|
||||
boxerData: state.boxerData,
|
||||
clearable: state.clearable,
|
||||
comboBoxProps: state.comboBoxProps,
|
||||
dropDownProps: state.dropDownProps,
|
||||
error: state.error,
|
||||
fetchData: state.fetchData,
|
||||
fetchMoreOnBottomReached: state.fetchMoreOnBottomReached,
|
||||
input: state.input,
|
||||
isFetching: state.isFetching,
|
||||
label: state.label,
|
||||
mah: state.mah,
|
||||
multiSelect: state.multiSelect,
|
||||
onBufferChange: state.onBufferChange,
|
||||
onChange: state.onChange,
|
||||
opened: state.opened,
|
||||
openOnClear: state.openOnClear,
|
||||
placeholder: state.placeholder,
|
||||
scrollAreaProps: state.scrollAreaProps,
|
||||
search: state.search,
|
||||
selectedOptionIndex: state.selectedOptionIndex,
|
||||
selectFirst: state.selectFirst,
|
||||
setInput: state.setInput,
|
||||
setOpened: state.setOpened,
|
||||
setSearch: state.setSearch,
|
||||
setSelectedOptionIndex: state.setSelectedOptionIndex,
|
||||
showAll: state.showAll,
|
||||
value: state.value,
|
||||
}));
|
||||
|
||||
// Virtualization setup
|
||||
const count = boxerData.length;
|
||||
const virtualizer = useVirtualizer({
|
||||
count,
|
||||
estimateSize: () => 36,
|
||||
getScrollElement: () => parentRef.current,
|
||||
});
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
// Component Callback Functions
|
||||
const onOptionSubmit = useCallback(
|
||||
(indexOrId: number | string) => {
|
||||
const index = typeof indexOrId === 'string' ? parseInt(indexOrId, 10) : indexOrId;
|
||||
const option = boxerData[index];
|
||||
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (multiSelect) {
|
||||
// Handle multi-select
|
||||
const currentValues = Array.isArray(value) ? value : [];
|
||||
const isSelected = currentValues.includes(option.value);
|
||||
|
||||
const newValues = isSelected
|
||||
? currentValues.filter((v: any) => v !== option.value)
|
||||
: [...currentValues, option.value];
|
||||
|
||||
onChange?.(newValues);
|
||||
|
||||
// Update buffer for multi-select
|
||||
const newBuffer = boxerData.filter((item) => newValues.includes(item.value));
|
||||
onBufferChange?.(newBuffer);
|
||||
} else {
|
||||
// Handle single select
|
||||
onChange?.(option.value);
|
||||
setSearch('');
|
||||
setInput(option.label);
|
||||
valueRef.current = option.value;
|
||||
setOpened(false);
|
||||
}
|
||||
},
|
||||
[boxerData, multiSelect, value, onChange, onBufferChange, setSearch, setInput, setOpened]
|
||||
);
|
||||
|
||||
const onClear = useCallback(() => {
|
||||
if (showAll && selectFirst) {
|
||||
onOptionSubmit(0);
|
||||
} else {
|
||||
if (multiSelect) {
|
||||
onChange?.([] as any);
|
||||
} else {
|
||||
onChange?.(null as any);
|
||||
}
|
||||
setSearch('');
|
||||
setInput('');
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
if (openOnClear) {
|
||||
setOpened(true);
|
||||
}
|
||||
}, [showAll, selectFirst, multiSelect, onChange, setSearch, setInput, openOnClear, setOpened, onOptionSubmit]);
|
||||
|
||||
// Component Hooks
|
||||
const combobox = useVirtualizedCombobox({
|
||||
getOptionId: (index) => String(index),
|
||||
onDropdownClose: () => {
|
||||
setOpened(false);
|
||||
},
|
||||
onDropdownOpen: () => {
|
||||
if (!value || (multiSelect && (!Array.isArray(value) || value.length === 0))) {
|
||||
setSearch('');
|
||||
setInput('');
|
||||
}
|
||||
combobox.selectFirstOption();
|
||||
},
|
||||
onSelectedOptionSubmit: onOptionSubmit,
|
||||
opened,
|
||||
selectedOptionIndex,
|
||||
setSelectedOptionIndex: (index) => {
|
||||
setSelectedOptionIndex(index);
|
||||
if (index !== -1) {
|
||||
virtualizer.scrollToIndex(index);
|
||||
}
|
||||
},
|
||||
totalOptionsCount: boxerData.length,
|
||||
});
|
||||
|
||||
// Component variables
|
||||
const { options } = useBoxerOptions({
|
||||
boxerData,
|
||||
multiSelect,
|
||||
onOptionSubmit,
|
||||
value,
|
||||
});
|
||||
|
||||
// Component useEffects
|
||||
useEffect(() => {
|
||||
// Fetch initial data
|
||||
fetchData('', true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle search changes
|
||||
const delayDebounceFn = setTimeout(() => {
|
||||
if (search !== undefined && opened) {
|
||||
fetchData(search, true);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(delayDebounceFn);
|
||||
}, [search, opened]);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync input with value
|
||||
if (multiSelect) {
|
||||
const labels = boxerData
|
||||
.filter((item) => Array.isArray(value) && value.includes(item.value))
|
||||
.map((item) => item.label)
|
||||
.join(', ');
|
||||
|
||||
// When dropdown is closed, show selected labels. When open, allow searching
|
||||
if (!opened && input !== labels) {
|
||||
setInput(labels);
|
||||
setSearch('');
|
||||
}
|
||||
} else {
|
||||
const label = boxerData.find((item) => item.value === value)?.label;
|
||||
|
||||
// Only sync if we need to update the input to match the value
|
||||
if (input !== label && (search ?? '') === '' && valueRef.current !== value && value) {
|
||||
setInput(label ?? '');
|
||||
} else if (!value && !valueRef.current && (search ?? '') === '') {
|
||||
setSearch('');
|
||||
setInput('');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle buffer change
|
||||
if (multiSelect) {
|
||||
const buffer =
|
||||
boxerData.filter((item: BoxerItem) => Array.isArray(value) && value.includes(item.value)) ??
|
||||
[];
|
||||
|
||||
if (JSON.stringify(bufferRef.current) !== JSON.stringify(buffer)) {
|
||||
onBufferChange?.(buffer);
|
||||
bufferRef.current = buffer;
|
||||
}
|
||||
} else {
|
||||
const buffer = boxerData?.find((item: BoxerItem) => item.value === value) ?? null;
|
||||
|
||||
if (bufferRef.current?.value !== buffer?.value) {
|
||||
onBufferChange?.(buffer);
|
||||
bufferRef.current = buffer;
|
||||
}
|
||||
}
|
||||
}, [value, boxerData, input, search, multiSelect, opened, onBufferChange, setInput, setSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
// Select first option automatically
|
||||
if (selectFirst && (boxerData?.length ?? 0) > 0 && !multiSelect) {
|
||||
if (!value) {
|
||||
onOptionSubmit?.(0);
|
||||
}
|
||||
}
|
||||
}, [selectFirst, boxerData, multiSelect]);
|
||||
|
||||
// Expose ref methods
|
||||
useImperativeHandle(ref, () => ({
|
||||
clear: () => {
|
||||
onClear();
|
||||
},
|
||||
close: () => {
|
||||
setOpened(false);
|
||||
combobox.closeDropdown();
|
||||
},
|
||||
focus: () => {
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
getValue: () => {
|
||||
return value;
|
||||
},
|
||||
open: () => {
|
||||
setOpened(true);
|
||||
combobox.openDropdown();
|
||||
},
|
||||
setValue: (newValue: any) => {
|
||||
onChange?.(newValue);
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
{...comboBoxProps}
|
||||
resetSelectionOnOptionHover={false}
|
||||
store={combobox}
|
||||
withinPortal={true}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<Combobox.EventsTarget>
|
||||
<BoxerTarget
|
||||
clearable={clearable}
|
||||
combobox={combobox}
|
||||
error={error}
|
||||
isFetching={isFetching}
|
||||
label={label}
|
||||
onBlur={() => {
|
||||
if (!value && !multiSelect) {
|
||||
setSearch('');
|
||||
setInput('');
|
||||
combobox.closeDropdown();
|
||||
setOpened(false);
|
||||
}
|
||||
}}
|
||||
onClear={onClear}
|
||||
onSearch={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
setInput(event.currentTarget.value);
|
||||
setOpened(true);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
ref={inputRef}
|
||||
search={input}
|
||||
/>
|
||||
</Combobox.EventsTarget>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown
|
||||
onKeyDown={() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
p={2}
|
||||
{...dropDownProps}
|
||||
>
|
||||
{opened && options.length > 0 ? (
|
||||
<Combobox.Options>
|
||||
<ScrollArea.Autosize
|
||||
{...scrollAreaProps}
|
||||
mah={mah ?? 200}
|
||||
viewportProps={{
|
||||
...scrollAreaProps?.viewportProps,
|
||||
onScroll: (event) => {
|
||||
fetchMoreOnBottomReached(event.currentTarget as HTMLDivElement);
|
||||
},
|
||||
style: { border: '1px solid gray', borderRadius: 4 },
|
||||
}}
|
||||
viewportRef={parentRef}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => (
|
||||
<div
|
||||
data-index={virtualRow.index}
|
||||
key={virtualRow.key}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
{options[virtualRow.index]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea.Autosize>
|
||||
</Combobox.Options>
|
||||
) : (
|
||||
<Combobox.Empty>Nothing found</Combobox.Empty>
|
||||
)}
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
});
|
||||
|
||||
BoxerInner.displayName = 'BoxerInner';
|
||||
|
||||
const Boxer = React.forwardRef<BoxerRef, BoxerProps>((props, ref) => {
|
||||
return (
|
||||
<BoxerProvider {...props}>
|
||||
<BoxerInner ref={ref} />
|
||||
</BoxerProvider>
|
||||
);
|
||||
});
|
||||
|
||||
Boxer.displayName = 'Boxer';
|
||||
|
||||
export { Boxer };
|
||||
export default Boxer;
|
||||
109
src/Boxer/Boxer.types.ts
Normal file
109
src/Boxer/Boxer.types.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { ComboboxProps, ScrollAreaAutosizeProps, TextInputProps } from '@mantine/core';
|
||||
import type { VirtualizerOptions } from '@tanstack/react-virtual';
|
||||
|
||||
export type BoxerDataSource =
|
||||
| 'local' // Local array data
|
||||
| 'server'; // Server-side with infinite loading
|
||||
|
||||
export type BoxerItem = {
|
||||
[key: string]: any;
|
||||
label: string;
|
||||
value: any;
|
||||
};
|
||||
|
||||
export interface BoxerProps {
|
||||
clearable?: boolean;
|
||||
// Component Props
|
||||
comboBoxProps?: Partial<ComboboxProps>;
|
||||
|
||||
// Data Configuration
|
||||
data?: Array<BoxerItem>;
|
||||
|
||||
dataSource?: BoxerDataSource;
|
||||
disabled?: boolean;
|
||||
dropDownProps?: React.ComponentPropsWithoutRef<'div'>;
|
||||
|
||||
error?: string;
|
||||
// Advanced
|
||||
id?: string;
|
||||
inputProps?: Partial<TextInputProps>;
|
||||
label?: string;
|
||||
leftSection?: React.ReactNode;
|
||||
mah?: number; // Max height for dropdown
|
||||
|
||||
// Component Configuration
|
||||
multiSelect?: boolean;
|
||||
name?: string;
|
||||
// API Configuration (for server-side)
|
||||
onAPICall?: (params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
}) => Promise<{ data: Array<BoxerItem>; total: number }>;
|
||||
onBufferChange?: (buffer: Array<BoxerItem> | BoxerItem | null) => void;
|
||||
onChange?: (value: any | Array<any>) => void;
|
||||
|
||||
openOnClear?: boolean;
|
||||
|
||||
pageSize?: number;
|
||||
|
||||
// UI Configuration
|
||||
placeholder?: string;
|
||||
// Styling
|
||||
rightSection?: React.ReactNode;
|
||||
scrollAreaProps?: Partial<ScrollAreaAutosizeProps>;
|
||||
searchable?: boolean;
|
||||
|
||||
selectFirst?: boolean;
|
||||
showAll?: boolean;
|
||||
|
||||
// Value Management
|
||||
value?: any | Array<any>;
|
||||
// Virtualization
|
||||
virtualizer?: Partial<VirtualizerOptions<HTMLDivElement, Element>>;
|
||||
}
|
||||
|
||||
export interface BoxerRef {
|
||||
clear: () => void;
|
||||
close: () => void;
|
||||
focus: () => void;
|
||||
getValue: () => any | Array<any>;
|
||||
open: () => void;
|
||||
setValue: (value: any | Array<any>) => void;
|
||||
}
|
||||
|
||||
export interface BoxerState {
|
||||
// Data State
|
||||
boxerData: Array<BoxerItem>;
|
||||
fetchData: (search?: string, reset?: boolean) => Promise<void>;
|
||||
fetchMoreOnBottomReached: (target: HTMLDivElement) => void;
|
||||
// State Management
|
||||
getState: <K extends keyof BoxerStoreState>(key: K) => BoxerStoreState[K];
|
||||
hasMore: boolean;
|
||||
|
||||
input: string;
|
||||
isFetching: boolean;
|
||||
// Data Actions
|
||||
loadMore: () => Promise<void>;
|
||||
// Internal State
|
||||
opened: boolean;
|
||||
page: number;
|
||||
|
||||
pageSize: number;
|
||||
search: string;
|
||||
selectedOptionIndex: number;
|
||||
setInput: (input: string) => void;
|
||||
|
||||
// Actions
|
||||
setOpened: (opened: boolean) => void;
|
||||
setSearch: (search: string) => void;
|
||||
setSelectedOptionIndex: (index: number) => void;
|
||||
|
||||
setState: <K extends keyof BoxerStoreState>(
|
||||
key: K,
|
||||
value: Partial<BoxerStoreState[K]>
|
||||
) => void;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type BoxerStoreState = BoxerProps & BoxerState;
|
||||
73
src/Boxer/BoxerTarget.tsx
Normal file
73
src/Boxer/BoxerTarget.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { ComboboxStore } from '@mantine/core';
|
||||
|
||||
import { ActionIcon, Loader, TextInput } from '@mantine/core';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
interface BoxerTargetProps {
|
||||
clearable?: boolean;
|
||||
combobox: ComboboxStore;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
isFetching?: boolean;
|
||||
label?: string;
|
||||
leftSection?: React.ReactNode;
|
||||
onBlur: () => void;
|
||||
onClear: () => void;
|
||||
onSearch: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
placeholder?: string;
|
||||
search: string;
|
||||
}
|
||||
|
||||
const BoxerTarget = forwardRef<HTMLInputElement, BoxerTargetProps>((props, ref) => {
|
||||
const {
|
||||
clearable = true,
|
||||
combobox,
|
||||
disabled,
|
||||
error,
|
||||
isFetching,
|
||||
label,
|
||||
leftSection,
|
||||
onBlur,
|
||||
onClear,
|
||||
onSearch,
|
||||
placeholder,
|
||||
search,
|
||||
} = props;
|
||||
|
||||
const rightSection = isFetching ? (
|
||||
<Loader size="xs" />
|
||||
) : search && clearable ? (
|
||||
<ActionIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
}}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
disabled={disabled}
|
||||
error={error}
|
||||
label={label}
|
||||
leftSection={leftSection}
|
||||
onBlur={onBlur}
|
||||
onChange={onSearch}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
rightSection={rightSection}
|
||||
value={search}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
BoxerTarget.displayName = 'BoxerTarget';
|
||||
|
||||
export default BoxerTarget;
|
||||
47
src/Boxer/hooks/useBoxerOptions.tsx
Normal file
47
src/Boxer/hooks/useBoxerOptions.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { 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
10
src/Boxer/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { Boxer, default } from './Boxer';
|
||||
export { BoxerProvider, useBoxerStore } from './Boxer.store';
|
||||
export type {
|
||||
BoxerDataSource,
|
||||
BoxerItem,
|
||||
BoxerProps,
|
||||
BoxerRef,
|
||||
BoxerState,
|
||||
BoxerStoreState,
|
||||
} from './Boxer.types';
|
||||
218
src/Boxer/stories/Boxer.stories.tsx
Normal file
218
src/Boxer/stories/Boxer.stories.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
//@ts-ignore
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { BoxerItem } from '../Boxer.types';
|
||||
|
||||
import { Boxer } from '../Boxer';
|
||||
|
||||
const meta: Meta<typeof Boxer> = {
|
||||
component: Boxer,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
title: 'Components/Boxer',
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Boxer>;
|
||||
|
||||
// Sample data
|
||||
const sampleData: Array<BoxerItem> = [
|
||||
{ label: 'Apple', value: 'apple' },
|
||||
{ label: 'Banana', value: 'banana' },
|
||||
{ label: 'Cherry', value: 'cherry' },
|
||||
{ label: 'Date', value: 'date' },
|
||||
{ label: 'Elderberry', value: 'elderberry' },
|
||||
{ label: 'Fig', value: 'fig' },
|
||||
{ label: 'Grape', value: 'grape' },
|
||||
{ label: 'Honeydew', value: 'honeydew' },
|
||||
{ label: 'Kiwi', value: 'kiwi' },
|
||||
{ label: 'Lemon', value: 'lemon' },
|
||||
{ label: 'Mango', value: 'mango' },
|
||||
{ label: 'Nectarine', value: 'nectarine' },
|
||||
{ label: 'Orange', value: 'orange' },
|
||||
{ label: 'Papaya', value: 'papaya' },
|
||||
{ label: 'Quince', value: 'quince' },
|
||||
{ label: 'Raspberry', value: 'raspberry' },
|
||||
{ label: 'Strawberry', value: 'strawberry' },
|
||||
{ label: 'Tangerine', value: 'tangerine' },
|
||||
{ label: 'Ugli Fruit', value: 'ugli' },
|
||||
{ label: 'Watermelon', value: 'watermelon' },
|
||||
];
|
||||
|
||||
// Local Data Example
|
||||
export const LocalData: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState<null | string>(null);
|
||||
|
||||
return (
|
||||
<div style={{ width: 300 }}>
|
||||
<Boxer
|
||||
clearable
|
||||
data={sampleData}
|
||||
dataSource="local"
|
||||
label="Favorite Fruit"
|
||||
onChange={setValue}
|
||||
placeholder="Select a fruit"
|
||||
searchable
|
||||
value={value}
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<strong>Selected Value:</strong> {value ?? 'None'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Multi-Select Example
|
||||
export const MultiSelect: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState<Array<string>>([]);
|
||||
|
||||
return (
|
||||
<div style={{ width: 300 }}>
|
||||
<Boxer
|
||||
clearable
|
||||
data={sampleData}
|
||||
dataSource="local"
|
||||
label="Favorite Fruits"
|
||||
multiSelect
|
||||
onChange={setValue}
|
||||
placeholder="Select fruits"
|
||||
searchable
|
||||
value={value}
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<strong>Selected Values:</strong>{' '}
|
||||
{value.length > 0 ? value.join(', ') : 'None'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Server-Side Example (Simulated)
|
||||
export const ServerSide: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState<null | string>(null);
|
||||
|
||||
// Simulate server-side API call
|
||||
const handleAPICall = async (params: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
}): Promise<{ data: Array<BoxerItem>; total: number }> => {
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Filter based on search
|
||||
let filteredData = [...sampleData];
|
||||
if (params.search) {
|
||||
filteredData = filteredData.filter((item) =>
|
||||
item.label.toLowerCase().includes(params.search!.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const start = params.page * params.pageSize;
|
||||
const end = start + params.pageSize;
|
||||
const paginatedData = filteredData.slice(start, end);
|
||||
|
||||
return {
|
||||
data: paginatedData,
|
||||
total: filteredData.length,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: 300 }}>
|
||||
<Boxer
|
||||
clearable
|
||||
dataSource="server"
|
||||
label="Favorite Fruit (Server-side)"
|
||||
onAPICall={handleAPICall}
|
||||
onChange={setValue}
|
||||
pageSize={10}
|
||||
placeholder="Select a fruit (Server-side)"
|
||||
searchable
|
||||
value={value}
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<strong>Selected Value:</strong> {value ?? 'None'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Select First Example
|
||||
export const SelectFirst: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState<null | string>(null);
|
||||
|
||||
return (
|
||||
<div style={{ width: 300 }}>
|
||||
<Boxer
|
||||
clearable
|
||||
data={sampleData}
|
||||
dataSource="local"
|
||||
label="Auto-select First"
|
||||
onChange={setValue}
|
||||
placeholder="Select a fruit"
|
||||
searchable
|
||||
selectFirst
|
||||
value={value}
|
||||
/>
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<strong>Selected Value:</strong> {value ?? 'None'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// With Error
|
||||
export const WithError: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = useState<null | string>(null);
|
||||
|
||||
return (
|
||||
<div style={{ width: 300 }}>
|
||||
<Boxer
|
||||
clearable
|
||||
data={sampleData}
|
||||
dataSource="local"
|
||||
error="Please select a fruit"
|
||||
label="With Error"
|
||||
onChange={setValue}
|
||||
placeholder="Select a fruit"
|
||||
searchable
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Disabled
|
||||
export const Disabled: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ width: 300 }}>
|
||||
<Boxer
|
||||
data={sampleData}
|
||||
dataSource="local"
|
||||
disabled
|
||||
label="Disabled"
|
||||
onChange={() => {}}
|
||||
placeholder="Select a fruit"
|
||||
value="apple"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
15
src/Boxer/todo.md
Normal file
15
src/Boxer/todo.md
Normal file
@@ -0,0 +1,15 @@
|
||||
The plan and requirements:
|
||||
Auto complete lookup with server side lookup support and infinite loading.
|
||||
It must also have local array lookup and autocomplete.
|
||||
When a users starts typing, it must start autocomplete list.
|
||||
Exiting selected item must always be the first on the list and populated from the input in case the options does not exist anymore, it must not beak existing data.
|
||||
|
||||
|
||||
- [ ] Auto Complete
|
||||
- [ ] Multi Select
|
||||
- [ ] Virtualize
|
||||
- [ ] Search
|
||||
- [ ] Clear, Menu buttons
|
||||
- [ ] Headerspec API
|
||||
- [ ] Relspec API
|
||||
- [ ] SocketSpec API
|
||||
212
src/Former/Former.store.tsx
Normal file
212
src/Former/Former.store.tsx
Normal 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
124
src/Former/Former.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
97
src/Former/Former.types.ts
Normal file
97
src/Former/Former.types.ts
Normal 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';
|
||||
85
src/Former/FormerButtonArea.tsx
Normal file
85
src/Former/FormerButtonArea.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
src/Former/FormerLayout.tsx
Normal file
90
src/Former/FormerLayout.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
23
src/Former/FormerLayoutBottom.tsx
Normal file
23
src/Former/FormerLayoutBottom.tsx
Normal 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 /> : <></>;
|
||||
};
|
||||
22
src/Former/FormerLayoutTop.tsx
Normal file
22
src/Former/FormerLayoutTop.tsx
Normal 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 /> : <></>;
|
||||
};
|
||||
72
src/Former/FormerResolveSpecAPI.ts
Normal file
72
src/Former/FormerResolveSpecAPI.ts
Normal 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 };
|
||||
50
src/Former/FormerRestHeadSpecAPI.ts
Normal file
50
src/Former/FormerRestHeadSpecAPI.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { FormerAPICallType } from './Former.types';
|
||||
|
||||
function FormerRestHeadSpecAPI(options: {
|
||||
authToken: string;
|
||||
fetchOptions?: Partial<RequestInit>;
|
||||
signal?: AbortSignal;
|
||||
url: string;
|
||||
}): FormerAPICallType {
|
||||
return async (mode, request, value, key) => {
|
||||
const baseUrl = options.url ?? ''; // Remove trailing slashes
|
||||
let url = baseUrl;
|
||||
const fetchOptions: RequestInit = {
|
||||
cache: 'no-cache',
|
||||
signal: options.signal,
|
||||
...options.fetchOptions,
|
||||
body: mode === 'mutate' && request !== 'delete' ? JSON.stringify(value) : undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.authToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.fetchOptions?.headers,
|
||||
},
|
||||
method:
|
||||
mode === 'read'
|
||||
? 'GET'
|
||||
: request === 'delete'
|
||||
? 'DELETE'
|
||||
: request === 'update'
|
||||
? 'PUT'
|
||||
: 'POST',
|
||||
};
|
||||
|
||||
if (request !== 'insert') {
|
||||
url = `${baseUrl}/${key}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
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 };
|
||||
116
src/Former/FormerWrappers.tsx
Normal file
116
src/Former/FormerWrappers.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
Drawer,
|
||||
type DrawerProps,
|
||||
Modal,
|
||||
type ModalProps,
|
||||
Popover,
|
||||
type PopoverProps,
|
||||
} from '@mantine/core';
|
||||
|
||||
import type { FormerProps } from './Former.types';
|
||||
|
||||
import { Former } from './Former';
|
||||
|
||||
export const FormerDialog = (props: { former: FormerProps } & DrawerProps) => {
|
||||
const { children, former, onClose, opened, ...rest } = props;
|
||||
return (
|
||||
<Former
|
||||
{...former}
|
||||
onClose={onClose}
|
||||
opened={opened}
|
||||
wrapper={(children, opened, onClose, _onOpen, getState) => {
|
||||
const values = getState('values');
|
||||
const request = getState('request');
|
||||
const uniqueKeyField = getState('uniqueKeyField') ?? 'id';
|
||||
return (
|
||||
<Drawer
|
||||
closeOnClickOutside={false}
|
||||
h={'100%'}
|
||||
title={
|
||||
request === 'delete'
|
||||
? `Delete Record - ${values?.[uniqueKeyField]}`
|
||||
: request === 'insert'
|
||||
? 'New Record'
|
||||
: `Edit Record - ${values?.[uniqueKeyField]}`
|
||||
}
|
||||
{...rest}
|
||||
onClose={() => onClose?.()}
|
||||
opened={opened ?? false}
|
||||
>
|
||||
{children}
|
||||
</Drawer>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Former>
|
||||
);
|
||||
};
|
||||
|
||||
export const FormerModel = (props: { former: FormerProps } & ModalProps) => {
|
||||
const { children, former, onClose, opened, ...rest } = props;
|
||||
return (
|
||||
<Former
|
||||
{...former}
|
||||
onClose={onClose}
|
||||
opened={opened}
|
||||
wrapper={(children, opened, onClose, _onOpen, getState) => {
|
||||
const values = getState('values');
|
||||
const request = getState('request');
|
||||
const uniqueKeyField = getState('uniqueKeyField') ?? 'id';
|
||||
return (
|
||||
<Modal
|
||||
closeOnClickOutside={false}
|
||||
h={'100%'}
|
||||
title={
|
||||
request === 'delete'
|
||||
? `Delete Record - ${values?.[uniqueKeyField]}`
|
||||
: request === 'insert'
|
||||
? 'New Record'
|
||||
: `Edit Record - ${values?.[uniqueKeyField]}`
|
||||
}
|
||||
{...rest}
|
||||
onClose={() => onClose?.()}
|
||||
opened={opened ?? false}
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Former>
|
||||
);
|
||||
};
|
||||
|
||||
export const FormerPopover = (
|
||||
props: { former: FormerProps; target: React.ReactNode } & PopoverProps
|
||||
) => {
|
||||
const { children, former, onClose, opened, target, ...rest } = props;
|
||||
return (
|
||||
<Former
|
||||
{...former}
|
||||
onClose={onClose}
|
||||
opened={opened}
|
||||
wrapper={(children, opened, onClose) => {
|
||||
return (
|
||||
<Popover
|
||||
closeOnClickOutside={false}
|
||||
middlewares={{ inline: true }}
|
||||
trapFocus
|
||||
width={250}
|
||||
withArrow
|
||||
{...rest}
|
||||
onClose={() => onClose?.()}
|
||||
opened={opened ?? false}
|
||||
>
|
||||
<Popover.Target>{target}</Popover.Target>
|
||||
<Popover.Dropdown>{children}</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Former>
|
||||
);
|
||||
};
|
||||
6
src/Former/index.ts
Normal file
6
src/Former/index.ts
Normal 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';
|
||||
42
src/Former/stories/Former.goapi.stories.tsx
Normal file
42
src/Former/stories/Former.goapi.stories.tsx
Normal 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',
|
||||
},
|
||||
};
|
||||
40
src/Former/stories/apiFormData.tsx
Normal file
40
src/Former/stories/apiFormData.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { TextInput } from '@mantine/core';
|
||||
import { useUncontrolled } from '@mantine/hooks';
|
||||
import { Controller } from 'react-hook-form';
|
||||
|
||||
import { Former } from '../Former';
|
||||
|
||||
export const ApiFormData = (props: {
|
||||
onChange?: (values: Record<string, unknown>) => void;
|
||||
primeData?: Record<string, unknown>;
|
||||
values?: Record<string, unknown>;
|
||||
}) => {
|
||||
const [values, setValues] = useUncontrolled<Record<string, unknown>>({
|
||||
defaultValue: { authToken: '', url: '', ...props.primeData },
|
||||
finalValue: { authToken: '', url: '', ...props.primeData },
|
||||
onChange: props.onChange,
|
||||
value: props.values,
|
||||
});
|
||||
|
||||
return (
|
||||
<Former
|
||||
disableHTMlForm
|
||||
id="api-form-data"
|
||||
layout={{ saveButtonTitle: 'Save URL Parameters' }}
|
||||
onChange={setValues}
|
||||
primeData={props.primeData}
|
||||
request="update"
|
||||
uniqueKeyField="id"
|
||||
values={values}
|
||||
>
|
||||
<Controller
|
||||
name="url"
|
||||
render={({ field }) => <TextInput label="URL" type="url" {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
name="authToken"
|
||||
render={({ field }) => <TextInput label="Auth Token" type="password" {...field} />}
|
||||
/>
|
||||
</Former>
|
||||
);
|
||||
};
|
||||
182
src/Former/stories/example.tsx
Normal file
182
src/Former/stories/example.tsx
Normal 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
13
src/Former/todo.md
Normal 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)
|
||||
35
src/FormerControllers/Buttons/ButtonCtrl.tsx
Normal file
35
src/FormerControllers/Buttons/ButtonCtrl.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Button, type ButtonProps, Tooltip } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { SpecialIDProps } from '../FormerControllers.types';
|
||||
|
||||
const ButtonCtrl = (
|
||||
props: {
|
||||
onClick?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => Promise<void>;
|
||||
} & Omit<ButtonProps, 'onClick'> &
|
||||
SpecialIDProps
|
||||
) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
return (
|
||||
<Tooltip label={props.tooltip ?? ''} withArrow>
|
||||
<Button
|
||||
loaderProps={{
|
||||
type: 'bars',
|
||||
}}
|
||||
{...props}
|
||||
loading={loading || props.loading}
|
||||
onClick={(e) => {
|
||||
if (props.onClick) {
|
||||
setLoading(true);
|
||||
props.onClick(e).finally(() => setLoading(false));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export { ButtonCtrl };
|
||||
export default ButtonCtrl;
|
||||
36
src/FormerControllers/Buttons/IconButtonCtrl.tsx
Normal file
36
src/FormerControllers/Buttons/IconButtonCtrl.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ActionIcon, type ActionIconProps, Tooltip, VisuallyHidden } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { SpecialIDProps } from '../FormerControllers.types';
|
||||
|
||||
const IconButtonCtrl = (
|
||||
props: {
|
||||
onClick?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => Promise<void>;
|
||||
} & Omit<ActionIconProps, 'onClick'> &
|
||||
SpecialIDProps
|
||||
) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
return (
|
||||
<Tooltip label={props.tooltip ?? ''} withArrow>
|
||||
<ActionIcon
|
||||
loaderProps={{
|
||||
type: 'bars',
|
||||
}}
|
||||
{...props}
|
||||
loading={loading || props.loading}
|
||||
onClick={(e) => {
|
||||
if (props.onClick) {
|
||||
setLoading(true);
|
||||
props.onClick(e).finally(() => setLoading(false));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<VisuallyHidden>Action Button: {props.tooltip ?? props.sid ?? ''}</VisuallyHidden>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export { IconButtonCtrl };
|
||||
export default IconButtonCtrl;
|
||||
8
src/FormerControllers/FormerControllers.types.ts
Normal file
8
src/FormerControllers/FormerControllers.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ControllerProps } from 'react-hook-form';
|
||||
|
||||
export type FormerControllersProps = Omit<ControllerProps, 'render'>;
|
||||
|
||||
export interface SpecialIDProps {
|
||||
sid?: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
38
src/FormerControllers/Inputs/InlineWapper.module.css
Normal file
38
src/FormerControllers/Inputs/InlineWapper.module.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.prompt {
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
border: 1px solid #ced4da;
|
||||
border-right: 0px;
|
||||
|
||||
@mixin dark {
|
||||
border: 1px solid #373a40;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
border: 1px solid #ced4da;
|
||||
flex: 1;
|
||||
|
||||
&:not([data-promptArea]) {
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
|
||||
&[data-promptArea] {
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
color: black;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
border: 1px solid #373a40;
|
||||
}
|
||||
}
|
||||
|
||||
.root {
|
||||
flex: 1;
|
||||
}
|
||||
176
src/FormerControllers/Inputs/InlineWrapper.tsx
Normal file
176
src/FormerControllers/Inputs/InlineWrapper.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Flex,
|
||||
type FlexProps,
|
||||
Paper,
|
||||
Stack,
|
||||
Title,
|
||||
type TitleProps,
|
||||
Tooltip,
|
||||
useMantineColorScheme,
|
||||
} from '@mantine/core'
|
||||
import React from 'react'
|
||||
|
||||
import classes from './InlineWapper.module.css'
|
||||
|
||||
interface InlineWrapperCallbackProps extends Partial<InlineWrapperPropsOnly> {
|
||||
classNames: React.CSSProperties
|
||||
dataCssProps?: Record<string, any>
|
||||
size: string
|
||||
}
|
||||
|
||||
interface InlineWrapperProps extends InlineWrapperPropsOnly{
|
||||
children?: ((props: InlineWrapperCallbackProps) => ReactNode) | ReactNode
|
||||
}
|
||||
interface InlineWrapperPropsOnly {
|
||||
error?: ReactNode | string
|
||||
flexProps?: FlexProps
|
||||
label: ReactNode | string
|
||||
labelProps?: TitleProps
|
||||
promptArea?: ((props: InlineWrapperCallbackProps) => ReactNode) | ReactNode
|
||||
promptWidth?: FlexProps['w']
|
||||
required?: boolean
|
||||
rightSection?: ((props: InlineWrapperCallbackProps) => ReactNode) | ReactNode
|
||||
styles?: React.CSSProperties
|
||||
tooltip?: string
|
||||
value?: any
|
||||
}
|
||||
|
||||
function InlineWrapper(props: InlineWrapperProps) {
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Flex
|
||||
gap={0}
|
||||
h={undefined}
|
||||
m={0}
|
||||
mb={0}
|
||||
p={0}
|
||||
w={undefined}
|
||||
wrap='nowrap'
|
||||
{...props.flexProps}
|
||||
bg={'var(--input-background)'}
|
||||
>
|
||||
{props.promptWidth && props.promptWidth !== 0 ? <Prompt {...props} /> : null}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 0,
|
||||
flex: 10,
|
||||
}}
|
||||
>
|
||||
{typeof props.children === 'function' ? (
|
||||
props.children({ ...props, classNames: classes, size: 'xs' })
|
||||
) : typeof props.children === 'object' && React.isValidElement(props.children) ? (
|
||||
<props.children.type classNames={classes} size='xs' {...(typeof props.children.props === "object" ? props.children.props : {})} />
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!props.rightSection ? undefined : typeof props.rightSection === 'function' ? (
|
||||
props.rightSection({
|
||||
...props,
|
||||
classNames: classes,
|
||||
size: 'xs',
|
||||
})
|
||||
) : typeof props.rightSection === 'object' && React.isValidElement(props.rightSection) ? (
|
||||
<props.rightSection.type classNames={classes} size='xs' {...(typeof props.rightSection.props === "object" ? props.rightSection.props : {})} />
|
||||
) : (
|
||||
props.rightSection
|
||||
)}
|
||||
</Flex>
|
||||
{/* <ErrorComponent {...props} /> */}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
function isValueEmpty(inputValue: any) {
|
||||
if (inputValue === null || inputValue === undefined) return true
|
||||
if (typeof inputValue === 'number') {
|
||||
if (inputValue === 0) return false
|
||||
} else if (typeof inputValue === 'string' || inputValue === '') {
|
||||
return inputValue.trim() === ''
|
||||
} else if (inputValue instanceof File) {
|
||||
return inputValue.size === 0
|
||||
} else if (inputValue.target) {
|
||||
return isValueEmpty(inputValue.target?.value)
|
||||
} else if (inputValue.constructor?.name === 'Date') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function Prompt(props: Partial<InlineWrapperProps>) {
|
||||
return (
|
||||
<>
|
||||
{props.tooltip ? (
|
||||
<Tooltip label={props.tooltip}>
|
||||
<PromptDetail {...props} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<PromptDetail {...props} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptDetail(props: Partial<InlineWrapperProps>) {
|
||||
const colors = useColors(props)
|
||||
return props.promptArea ? (
|
||||
<Box maw={props.promptWidth} w={'100%'}>
|
||||
{!props.promptArea ? undefined : typeof props.promptArea === 'function' ? (
|
||||
props.promptArea({
|
||||
...props,
|
||||
classNames: classes,
|
||||
dataCssProps: { 'data-promptArea': true },
|
||||
size: 'xs',
|
||||
})
|
||||
) : typeof props.rightSection === 'object' && React.isValidElement(props.promptArea) ? (
|
||||
<props.promptArea.type
|
||||
classNames={classes}
|
||||
data-promptArea='true'
|
||||
size='xs'
|
||||
{...(typeof props.promptArea?.props === "object" ? props.promptArea.props : {})}
|
||||
/>
|
||||
) : (
|
||||
props.promptArea
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Paper
|
||||
bg={colors.paperColor}
|
||||
className={classes.prompt}
|
||||
px='md'
|
||||
w={props.promptWidth}
|
||||
withBorder
|
||||
>
|
||||
<Center h='100%' style={{ justifyContent: 'start' }} w='100%'>
|
||||
<Title c={colors.titleColor} fz='xs' order={6} {...props.labelProps}>
|
||||
{props.label}
|
||||
{props.required && isValueEmpty(props.value) && <span style={{ color: 'red' }}>*</span>}
|
||||
</Title>
|
||||
</Center>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
function useColors(props: Partial<InlineWrapperProps>) {
|
||||
const { colorScheme } = useMantineColorScheme()
|
||||
|
||||
let titleColor = colorScheme === 'dark' ? 'dark.0' : 'gray.8'
|
||||
let paperColor = colorScheme === 'dark' ? 'dark.7' : 'gray.1'
|
||||
|
||||
if (props.required && isValueEmpty(props.value)) {
|
||||
paperColor = colorScheme === 'dark' ? '#413012e7' : 'yellow.1'
|
||||
}
|
||||
|
||||
if (props.error) {
|
||||
paperColor = colorScheme === 'dark' ? 'red.7' : 'red.0'
|
||||
titleColor = colorScheme === 'dark' ? 'red.0' : 'red.9'
|
||||
}
|
||||
return { paperColor, titleColor }
|
||||
}
|
||||
|
||||
export { InlineWrapper }
|
||||
export type { InlineWrapperProps }
|
||||
30
src/FormerControllers/Inputs/NativeSelectCtrl.tsx
Normal file
30
src/FormerControllers/Inputs/NativeSelectCtrl.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NativeSelect, type NativeSelectProps, Tooltip } from '@mantine/core';
|
||||
import { Controller } from 'react-hook-form';
|
||||
|
||||
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
|
||||
|
||||
const NativeSelectCtrl = (props: FormerControllersProps & NativeSelectProps & SpecialIDProps) => {
|
||||
const { control, name, sid, tooltip, ...innerProps } = props;
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, formState }) => (
|
||||
<Tooltip label={tooltip ?? ''} withArrow>
|
||||
<NativeSelect
|
||||
{...innerProps}
|
||||
{...field}
|
||||
disabled={formState.disabled}
|
||||
id={`field_${name}_${sid ?? ''}`}
|
||||
key={`field_${name}_${sid ?? ''}`}
|
||||
>
|
||||
{props.children}
|
||||
</NativeSelect>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { NativeSelectCtrl };
|
||||
export default NativeSelectCtrl;
|
||||
36
src/FormerControllers/Inputs/NumberInputCtrl.tsx
Normal file
36
src/FormerControllers/Inputs/NumberInputCtrl.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NumberInput, type NumberInputProps, Tooltip } from '@mantine/core';
|
||||
import { Controller } from 'react-hook-form';
|
||||
|
||||
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
|
||||
|
||||
const NumberInputCtrl = (props: FormerControllersProps & NumberInputProps & SpecialIDProps) => {
|
||||
const { control, name, sid, tooltip, ...textProps } = props;
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, formState }) => (
|
||||
<Tooltip label={tooltip ?? ''} withArrow>
|
||||
<NumberInput
|
||||
{...textProps}
|
||||
{...field}
|
||||
disabled={formState.disabled}
|
||||
id={`field_${name}_${sid ?? ''}`}
|
||||
key={`field_${name}_${sid ?? ''}`}
|
||||
onChange={(num) =>
|
||||
field.onChange(num !== undefined && num !== null ? Number(num) : undefined)
|
||||
}
|
||||
value={
|
||||
field.value !== undefined && field.value !== null ? Number(field.value) : undefined
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</NumberInput>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { NumberInputCtrl };
|
||||
export default NumberInputCtrl;
|
||||
30
src/FormerControllers/Inputs/PasswordInputCtrl.tsx
Normal file
30
src/FormerControllers/Inputs/PasswordInputCtrl.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { PasswordInput, type PasswordInputProps, Tooltip } from '@mantine/core';
|
||||
import { Controller } from 'react-hook-form';
|
||||
|
||||
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
|
||||
|
||||
const PasswordInputCtrl = (props: FormerControllersProps & PasswordInputProps & SpecialIDProps) => {
|
||||
const { control, name, sid, tooltip, ...textProps } = props;
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, formState }) => (
|
||||
<Tooltip label={tooltip ?? ''} withArrow>
|
||||
<PasswordInput
|
||||
{...textProps}
|
||||
{...field}
|
||||
disabled={formState.disabled}
|
||||
id={`field_${name}_${sid ?? ''}`}
|
||||
key={`field_${name}_${sid ?? ''}`}
|
||||
>
|
||||
{props.children}
|
||||
</PasswordInput>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { PasswordInputCtrl };
|
||||
export default PasswordInputCtrl;
|
||||
32
src/FormerControllers/Inputs/SwitchCtrl.tsx
Normal file
32
src/FormerControllers/Inputs/SwitchCtrl.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Switch, type SwitchProps, Tooltip } from '@mantine/core';
|
||||
import { Controller } from 'react-hook-form';
|
||||
|
||||
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
|
||||
|
||||
const SwitchCtrl = (props: FormerControllersProps & SpecialIDProps & SwitchProps) => {
|
||||
const { control, name, sid, tooltip, ...innerProps } = props;
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, formState }) => (
|
||||
<Tooltip label={tooltip ?? ''} withArrow>
|
||||
<Switch
|
||||
{...innerProps}
|
||||
{...field}
|
||||
checked={!!field.value}
|
||||
disabled={formState.disabled}
|
||||
id={`field_${name}_${sid ?? ''}`}
|
||||
key={`field_${name}_${sid ?? ''}`}
|
||||
onChange={(e) => {
|
||||
field.onChange((e.currentTarget ?? e.target)?.checked);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { SwitchCtrl };
|
||||
export default SwitchCtrl;
|
||||
31
src/FormerControllers/Inputs/TextAreaCtrl.tsx
Normal file
31
src/FormerControllers/Inputs/TextAreaCtrl.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Textarea, type TextareaProps, Tooltip } from '@mantine/core';
|
||||
import { Controller } from 'react-hook-form';
|
||||
|
||||
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
|
||||
|
||||
const TextAreaCtrl = (props: FormerControllersProps & SpecialIDProps & TextareaProps) => {
|
||||
const { control, name, sid, tooltip, ...innerProps } = props;
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, formState }) => (
|
||||
<Tooltip label={tooltip ?? ''} withArrow>
|
||||
<Textarea
|
||||
minRows={4}
|
||||
{...innerProps}
|
||||
{...field}
|
||||
disabled={formState.disabled}
|
||||
id={`field_${name}_${sid ?? ''}`}
|
||||
key={`field_${name}_${sid ?? ''}`}
|
||||
>
|
||||
{props.children}
|
||||
</Textarea>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { TextAreaCtrl };
|
||||
export default TextAreaCtrl;
|
||||
30
src/FormerControllers/Inputs/TextInputCtrl.tsx
Normal file
30
src/FormerControllers/Inputs/TextInputCtrl.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { TextInput, type TextInputProps, Tooltip } from '@mantine/core';
|
||||
import { Controller } from 'react-hook-form';
|
||||
|
||||
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
|
||||
|
||||
const TextInputCtrl = (props: FormerControllersProps & SpecialIDProps & TextInputProps) => {
|
||||
const { control, name, sid, tooltip, ...textProps } = props;
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field, formState }) => (
|
||||
<Tooltip label={tooltip ?? ''} withArrow>
|
||||
<TextInput
|
||||
{...textProps}
|
||||
{...field}
|
||||
disabled={formState.disabled}
|
||||
id={`field_${name}_${sid ?? ''}`}
|
||||
key={`field_${name}_${sid ?? ''}`}
|
||||
>
|
||||
{props.children}
|
||||
</TextInput>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { TextInputCtrl };
|
||||
export default TextInputCtrl;
|
||||
7
src/FormerControllers/index.ts
Normal file
7
src/FormerControllers/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { ButtonCtrl } from './Buttons/ButtonCtrl';
|
||||
export { IconButtonCtrl } from './Buttons/IconButtonCtrl';
|
||||
export { NativeSelectCtrl } from './Inputs/NativeSelectCtrl';
|
||||
export { PasswordInputCtrl } from './Inputs/PasswordInputCtrl';
|
||||
export { SwitchCtrl } from './Inputs/SwitchCtrl';
|
||||
export { TextAreaCtrl } from './Inputs/TextAreaCtrl';
|
||||
export { TextInputCtrl } from './Inputs/TextInputCtrl';
|
||||
50
src/FormerControllers/stories/Formers.goapi.stories.tsx
Normal file
50
src/FormerControllers/stories/Formers.goapi.stories.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
//@ts-nocheck
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Stack } from '@mantine/core';
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import { Former, NativeSelectCtrl, TextInputCtrl } from '../../lib';
|
||||
import { InlineWrapper } from '../Inputs/InlineWrapper';
|
||||
import NumberInputCtrl from '../Inputs/NumberInputCtrl';
|
||||
|
||||
const Renderable = () => {
|
||||
return (
|
||||
<Former>
|
||||
<Stack h="100%" mih="400px" miw="400px" w="100%">
|
||||
<TextInputCtrl label="Test" name="test" />
|
||||
<NumberInputCtrl label="AgeTest" name="age" />
|
||||
<InlineWrapper label="Select One" promptWidth={200}>
|
||||
<NativeSelectCtrl data={["One","Two","Three"]} name="option1"/>
|
||||
</InlineWrapper>
|
||||
</Stack>
|
||||
</Former>
|
||||
);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
|
||||
args: { onClick: fn() },
|
||||
// More on argTypes: https://storybook.js.org/docs/api/argtypes
|
||||
argTypes: {
|
||||
backgroundColor: { control: 'color' },
|
||||
},
|
||||
component: Renderable,
|
||||
parameters: {
|
||||
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
|
||||
//layout: 'centered',
|
||||
},
|
||||
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
title: 'Former/Controls Basic',
|
||||
} satisfies Meta<typeof Renderable>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
|
||||
export const BasicExample: Story = {
|
||||
args: {
|
||||
label: 'Test',
|
||||
},
|
||||
};
|
||||
@@ -1,12 +1,14 @@
|
||||
import '@glideapps/glide-data-grid/dist/index.css';
|
||||
import React, { type Ref } from 'react';
|
||||
|
||||
import { MantineBetterMenusProvider } from '../MantineBetterMenu';
|
||||
import { GlidlerFormAdaptor } from './components/adaptors/GlidlerFormAdaptor';
|
||||
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';
|
||||
|
||||
const Gridler = (props: GridlerProps) => {
|
||||
const _Gridler = (props: GridlerProps, ref: Ref<GridlerRef> | undefined) => {
|
||||
return (
|
||||
<MantineBetterMenusProvider>
|
||||
<Provider
|
||||
@@ -19,12 +21,20 @@ const Gridler = (props: GridlerProps) => {
|
||||
}}
|
||||
>
|
||||
<GridlerDataGrid />
|
||||
<GridlerRefHandler ref={ref} />
|
||||
{props.children}
|
||||
</Provider>
|
||||
</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.LocalDataAdaptor = GlidlerLocalDataAdaptor;
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export const GridlerDataGrid = () => {
|
||||
|
||||
const {
|
||||
_gridSelection,
|
||||
allowMultiSelect,
|
||||
focused,
|
||||
getCellContent,
|
||||
getCellsForSelection,
|
||||
@@ -49,6 +50,7 @@ export const GridlerDataGrid = () => {
|
||||
headerHeight,
|
||||
heightProp,
|
||||
mounted,
|
||||
onCellActivated,
|
||||
onCellClicked,
|
||||
onCellEdited,
|
||||
onColumnMoved,
|
||||
@@ -69,6 +71,7 @@ export const GridlerDataGrid = () => {
|
||||
widthProp,
|
||||
} = useGridlerStore((s) => ({
|
||||
_gridSelection: s._gridSelection,
|
||||
allowMultiSelect: s.allowMultiSelect,
|
||||
focused: s.focused,
|
||||
getCellContent: s.getCellContent,
|
||||
getCellsForSelection: s.getCellsForSelection,
|
||||
@@ -78,6 +81,7 @@ export const GridlerDataGrid = () => {
|
||||
headerHeight: s.headerHeight,
|
||||
heightProp: s.height,
|
||||
mounted: s.mounted,
|
||||
onCellActivated: s.onCellActivated,
|
||||
onCellClicked: s.onCellClicked,
|
||||
onCellEdited: s.onCellEdited,
|
||||
onColumnMoved: s.onColumnMoved,
|
||||
@@ -157,16 +161,16 @@ export const GridlerDataGrid = () => {
|
||||
height={height ?? 400}
|
||||
overscrollX={16}
|
||||
overscrollY={32}
|
||||
rangeSelect="multi-rect"
|
||||
rangeSelect={allowMultiSelect ? 'multi-rect' : 'cell'}
|
||||
rightElementProps={{
|
||||
fill: false,
|
||||
sticky: true,
|
||||
}}
|
||||
rowMarkers={{
|
||||
checkboxStyle: 'square',
|
||||
kind: 'both',
|
||||
kind: allowMultiSelect ? 'both' : 'clickable-number',
|
||||
}}
|
||||
rowSelect="multi"
|
||||
rowSelect={allowMultiSelect ? 'multi' : 'single'}
|
||||
rowSelectionMode="auto"
|
||||
spanRangeBehavior="default"
|
||||
{...glideProps}
|
||||
@@ -176,6 +180,7 @@ export const GridlerDataGrid = () => {
|
||||
gridSelection={_gridSelection}
|
||||
headerHeight={headerHeight ?? 32}
|
||||
headerIcons={{ sort: SortSprite, sortdown: SortDownSprite, sortup: SortUpSprite }}
|
||||
onCellActivated={onCellActivated}
|
||||
onCellClicked={onCellClicked}
|
||||
onCellContextMenu={(cell, event) => {
|
||||
event.preventDefault();
|
||||
@@ -195,7 +200,13 @@ export const GridlerDataGrid = () => {
|
||||
onGridSelectionChange={(selection) => {
|
||||
let rows = CompactSelection.empty();
|
||||
const currentSelection = getState('_gridSelection');
|
||||
const keyField = getState('keyField') ?? 'id';
|
||||
const getRowBuffer = getState('getRowBuffer');
|
||||
for (const r of selection.rows) {
|
||||
const validRowID = getRowBuffer ? getRowBuffer(r)?.[keyField] : null;
|
||||
if (!validRowID) {
|
||||
continue;
|
||||
}
|
||||
rows = rows.hasIndex(r) ? rows : rows.add(r);
|
||||
}
|
||||
if (selectMode === 'row' && selection.current?.range) {
|
||||
@@ -204,19 +215,32 @@ export const GridlerDataGrid = () => {
|
||||
y < selection.current.range.y + selection.current.range.height;
|
||||
y++
|
||||
) {
|
||||
const validRowID = getRowBuffer ? getRowBuffer(y)?.[keyField] : null;
|
||||
if (!validRowID) {
|
||||
continue;
|
||||
}
|
||||
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 (
|
||||
JSON.stringify(currentSelection?.columns) !== JSON.stringify(selection.columns) ||
|
||||
JSON.stringify(currentSelection?.rows) !== JSON.stringify(rows) ||
|
||||
JSON.stringify(currentSelection?.current) !== JSON.stringify(selection.current)
|
||||
) {
|
||||
setState('_gridSelection', { ...selection, rows });
|
||||
if (JSON.stringify(currentSelection?.rows) !== JSON.stringify(rows)) {
|
||||
//if (JSON.stringify(currentSelection?.rows) !== JSON.stringify(rows)) {
|
||||
setState('_gridSelectionRows', rows);
|
||||
}
|
||||
//}
|
||||
}
|
||||
|
||||
//console.log('Selection', selection);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable react/react-in-jsx-scope */
|
||||
|
||||
import { useGridlerStore } from './GridlerStore';
|
||||
|
||||
export function BottomBar() {
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface GridlerColumn extends Partial<BaseGridColumn> {
|
||||
disableFilter?: boolean;
|
||||
disableMove?: boolean;
|
||||
disableResize?: boolean;
|
||||
disableSearch?: boolean;
|
||||
disableSort?: boolean;
|
||||
getMenuItems?: (
|
||||
id: string,
|
||||
@@ -35,6 +36,7 @@ export interface GridlerColumn extends Partial<BaseGridColumn> {
|
||||
maxWidth?: number;
|
||||
minWidth?: number;
|
||||
tooltip?: ((buffer: any, row: number, col: number) => ReactNode) | string;
|
||||
virtual?: boolean;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CompactSelection } from '@glideapps/glide-data-grid';
|
||||
import { useDebouncedCallback } from '@mantine/hooks';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
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.
|
||||
export const Computer = React.memo(() => {
|
||||
const refFirstRun = useRef(0);
|
||||
const refLastSearch = useRef('');
|
||||
const refLastFilters = useRef<unknown>(null);
|
||||
const {
|
||||
_glideref,
|
||||
_gridSelectionRows,
|
||||
askAPIRowNumber,
|
||||
colFilters,
|
||||
colOrder,
|
||||
colSize,
|
||||
colSort,
|
||||
columns,
|
||||
getRowIndexByKey,
|
||||
getState,
|
||||
loadPage,
|
||||
ready,
|
||||
|
||||
scrollToRowKey,
|
||||
searchStr,
|
||||
selectedRowKey,
|
||||
selectFirstRowOnMount,
|
||||
setState,
|
||||
setStateFN,
|
||||
values,
|
||||
values
|
||||
} = useGridlerStore((s) => ({
|
||||
_glideref: s._glideref,
|
||||
_gridSelectionRows: s._gridSelectionRows,
|
||||
askAPIRowNumber: s.askAPIRowNumber,
|
||||
colFilters: s.colFilters,
|
||||
colOrder: s.colOrder,
|
||||
colSize: s.colSize,
|
||||
colSort: s.colSort,
|
||||
columns: s.columns,
|
||||
getRowIndexByKey: s.getRowIndexByKey,
|
||||
getState: s.getState,
|
||||
loadPage: s.loadPage,
|
||||
ready: s.ready,
|
||||
|
||||
scrollToRowKey: s.scrollToRowKey,
|
||||
searchStr: s.searchStr,
|
||||
selectedRowKey: s.selectedRowKey,
|
||||
selectFirstRowOnMount:s.selectFirstRowOnMount,
|
||||
setState: s.setState,
|
||||
setStateFN: s.setStateFN,
|
||||
uniqueid: s.uniqueid,
|
||||
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(() => {
|
||||
const searchSelection = async () => {
|
||||
const page_data = getState('_page_data');
|
||||
@@ -72,8 +100,8 @@ export const Computer = React.memo(() => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!(rowIndex >= 0) && typeof askAPIRowNumber === 'function') {
|
||||
const idx = await askAPIRowNumber(key);
|
||||
if (!(rowIndex >= 0)) {
|
||||
const idx = await getRowIndexByKey(key);
|
||||
if (idx) {
|
||||
rowIndexes.push(idx);
|
||||
}
|
||||
@@ -103,40 +131,12 @@ export const Computer = React.memo(() => {
|
||||
}
|
||||
}, [values]);
|
||||
|
||||
//Fire onChange when selection changes
|
||||
useEffect(() => {
|
||||
const onChange = getState('onChange');
|
||||
if (onChange && typeof onChange === 'function') {
|
||||
const page_data = getState('_page_data');
|
||||
const pageSize = getState('pageSize');
|
||||
|
||||
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 getGridSelectedRows = getState('getGridSelectedRows');
|
||||
const buffers = getGridSelectedRows();
|
||||
|
||||
const _values = getState('values');
|
||||
|
||||
@@ -144,7 +144,7 @@ export const Computer = React.memo(() => {
|
||||
onChange(buffers);
|
||||
}
|
||||
}
|
||||
}, [JSON.stringify(_gridSelectionRows), getState]);
|
||||
}, [_gridSelectionRows, _gridSelectionRows?.length, getState]);
|
||||
|
||||
useEffect(() => {
|
||||
setState(
|
||||
@@ -157,6 +157,17 @@ export const Computer = React.memo(() => {
|
||||
);
|
||||
}, [columns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchStr === undefined || searchStr === null) {
|
||||
refLastSearch.current = '';
|
||||
return;
|
||||
}
|
||||
if (refLastSearch.current !== searchStr) {
|
||||
debouncedDoSearch(searchStr);
|
||||
refLastSearch.current = searchStr;
|
||||
}
|
||||
}, [searchStr]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!colSort) {
|
||||
return;
|
||||
@@ -180,13 +191,15 @@ export const Computer = React.memo(() => {
|
||||
: (c.defaultIcon ?? 'sort'),
|
||||
}));
|
||||
}).then(() => {
|
||||
loadPage(0, 'all');
|
||||
loadPage(0, 'all').then(() => {
|
||||
getState('refreshCells')?.();
|
||||
getState('_events')?.dispatchEvent?.(
|
||||
new CustomEvent('onColumnSorted', {
|
||||
detail: { cols: colSort },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}, [colSort]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -195,13 +208,15 @@ export const Computer = React.memo(() => {
|
||||
}
|
||||
|
||||
if (JSON.stringify(refLastFilters.current) !== JSON.stringify(colFilters)) {
|
||||
loadPage(0, 'all');
|
||||
refLastFilters.current = colFilters;
|
||||
loadPage(0, 'all').then(() => {
|
||||
getState('refreshCells')?.();
|
||||
getState('_events')?.dispatchEvent?.(
|
||||
new CustomEvent('onColumnFiltered', {
|
||||
detail: { filters: colFilters },
|
||||
})
|
||||
);
|
||||
});
|
||||
refLastFilters.current = colFilters;
|
||||
}
|
||||
}, [colFilters]);
|
||||
|
||||
@@ -214,6 +229,8 @@ export const Computer = React.memo(() => {
|
||||
...c,
|
||||
width: c.id && colSize?.[c.id] ? colSize?.[c.id] : c.width,
|
||||
}));
|
||||
}).then(() => {
|
||||
getState('refreshCells')?.();
|
||||
});
|
||||
}, [colSize]);
|
||||
|
||||
@@ -231,9 +248,12 @@ export const Computer = React.memo(() => {
|
||||
});
|
||||
|
||||
return result;
|
||||
}).then(() => {
|
||||
getState('refreshCells')?.();
|
||||
});
|
||||
}, [colOrder]);
|
||||
|
||||
//Initial Load
|
||||
useEffect(() => {
|
||||
if (!_glideref) {
|
||||
return;
|
||||
@@ -242,9 +262,123 @@ export const Computer = React.memo(() => {
|
||||
return;
|
||||
}
|
||||
refFirstRun.current = 1;
|
||||
loadPage(0);
|
||||
loadPage(0).then(() => {
|
||||
getState('refreshCells')?.();
|
||||
});
|
||||
}, [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', {
|
||||
// colFilters,
|
||||
// colOrder,
|
||||
|
||||
@@ -56,7 +56,7 @@ export type FilterOptionOperator =
|
||||
| 'startswith';
|
||||
|
||||
export interface GridlerProps extends PropsWithChildren {
|
||||
askAPIRowNumber?: (key: string) => Promise<number>;
|
||||
allowMultiSelect?: boolean;
|
||||
columns?: GridlerColumns;
|
||||
|
||||
defaultSort?: Array<SortOption>;
|
||||
@@ -89,6 +89,9 @@ export interface GridlerProps extends PropsWithChildren {
|
||||
) => GridCell;
|
||||
|
||||
rowHeight?: number;
|
||||
scrollToRowKey?: number;
|
||||
searchFields?: Array<string>;
|
||||
searchStr?: string;
|
||||
sections?: {
|
||||
bottom?: React.ReactNode;
|
||||
left?: React.ReactNode;
|
||||
@@ -98,18 +101,31 @@ export interface GridlerProps extends PropsWithChildren {
|
||||
rightElementStart?: React.ReactNode;
|
||||
top?: React.ReactNode;
|
||||
};
|
||||
selectedRow?: number;
|
||||
selectedRowKey?: number;
|
||||
selectFirstRowOnMount?: boolean;
|
||||
selectMode?: 'cell' | 'row';
|
||||
showMenu?: (id: string, options?: Partial<MantineBetterMenuInstance>) => void;
|
||||
title?: string;
|
||||
tooltipBarProps?: React.HTMLAttributes<HTMLDivElement>;
|
||||
total_rows?: number;
|
||||
|
||||
uniqueid: string;
|
||||
useAPIQuery?: (index: number) => Promise<Array<Record<string, any>>>;
|
||||
values?: Array<Record<string, any>>;
|
||||
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 {
|
||||
_active_requests?: Array<{ controller: AbortController; page: number }>;
|
||||
_activeTooltip?: ReactNode;
|
||||
@@ -119,32 +135,38 @@ export interface GridlerState {
|
||||
_gridSelectionRows?: GridSelection['rows'];
|
||||
_loadingList: CompactSelection;
|
||||
_page_data: Record<number, Array<any>>;
|
||||
_refresh: () => Promise<void>;
|
||||
_scrollTimeout?: any | number;
|
||||
_visibleArea: Rectangle;
|
||||
_visiblePages: Rectangle;
|
||||
|
||||
addError: (err: string, ...args: Array<any>) => void;
|
||||
askAPIRowNumber?: (key: string) => Promise<number>;
|
||||
colFilters?: Array<FilterOption>;
|
||||
colOrder?: Record<string, number>;
|
||||
colSize?: Record<string, number>;
|
||||
colSort?: Array<SortOption>;
|
||||
data?: Array<any>;
|
||||
|
||||
errors: Array<string>;
|
||||
focused?: boolean;
|
||||
|
||||
get: () => GridlerState;
|
||||
getCellContent: (cell: Item) => GridCell;
|
||||
getCellsForSelection: (
|
||||
selection: Rectangle,
|
||||
abortSignal: AbortSignal
|
||||
) => CellArray | GetCellsThunk;
|
||||
getGridSelectedRows: () => Array<any>;
|
||||
getRowBuffer: (row: number) => Record<string, any>;
|
||||
getRowIndexByKey: (key: number | string) => Promise<number | undefined>;
|
||||
getState: <K extends keyof GridlerStoreState>(key: K) => GridlerStoreState[K];
|
||||
|
||||
hasLocalData: boolean;
|
||||
isEmpty: boolean;
|
||||
|
||||
isValuesInPages: () => boolean
|
||||
loadingData?: boolean;
|
||||
loadPage: (page: number, clearMode?: 'all' | 'page') => Promise<void>;
|
||||
mounted: boolean;
|
||||
onCellActivated: (cell: Item) => void;
|
||||
onCellClicked: (cell: Item, event: CellClickedEventArgs) => void;
|
||||
onCellEdited: (cell: Item, newVal: EditableGridCell) => void;
|
||||
onColumnMoved: (from: number, to: number) => void;
|
||||
@@ -169,21 +191,25 @@ export interface GridlerState {
|
||||
freezeRegions?: readonly Rectangle[];
|
||||
selected?: Item;
|
||||
}
|
||||
|
||||
) => void;
|
||||
|
||||
pageSize: number;
|
||||
ready: boolean;
|
||||
refreshCells: (fromRow?: number, toRow?: number, col?: number) => void;
|
||||
|
||||
reload?: () => Promise<void>;
|
||||
renderColumns?: GridlerColumns;
|
||||
setState: <K extends keyof GridlerStoreState>(
|
||||
key: K,
|
||||
value: Partial<GridlerStoreState[K]>
|
||||
value: GridlerStoreState[K]
|
||||
) => void;
|
||||
setStateFN: <K extends keyof GridlerStoreState>(
|
||||
key: K,
|
||||
value: (current: GridlerStoreState[K]) => Partial<GridlerStoreState[K]>
|
||||
) => Promise<void>;
|
||||
toCell: <TRowType extends Record<string, string>>(row: TRowType, col: number) => GridCell;
|
||||
useAPIQuery?: (index: number) => Promise<Array<Record<string, any>>>;
|
||||
}
|
||||
|
||||
export type GridlerStoreState = GridlerProps & GridlerState;
|
||||
@@ -195,6 +221,12 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
||||
_events: new EventTarget(),
|
||||
_loadingList: CompactSelection.empty(),
|
||||
_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 },
|
||||
_visiblePages: { height: 0, width: 0, x: 0, y: 0 },
|
||||
addError: (err: string, ...args: Array<unknown>) => {
|
||||
@@ -248,6 +280,42 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
||||
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) => {
|
||||
const state = get();
|
||||
//Handle local data
|
||||
@@ -270,10 +338,73 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
||||
|
||||
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) => {
|
||||
return get()[key];
|
||||
},
|
||||
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',
|
||||
loadPage: async (pPage: number, clearMode?: 'all' | 'page') => {
|
||||
const state = get();
|
||||
@@ -332,7 +463,7 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('loadPage Error: ', page, e);
|
||||
console.error('loadPage Error: ', page, e);
|
||||
state._events.dispatchEvent(
|
||||
new CustomEvent('loadPage_error', {
|
||||
detail: { clearMode, error: e, page: pPage, state },
|
||||
@@ -343,10 +474,29 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
||||
},
|
||||
maxConcurrency: 1,
|
||||
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) => {
|
||||
const state = get();
|
||||
const [col, row] = cell;
|
||||
if (state.glideProps?.onCellClicked) {
|
||||
state.glideProps?.onCellClicked?.(cell, event);
|
||||
}
|
||||
if (state.values?.length) {
|
||||
if (state.onChange) {
|
||||
state.onChange(state.values);
|
||||
}
|
||||
}
|
||||
|
||||
state._events.dispatchEvent(
|
||||
new CustomEvent('onCellClicked', {
|
||||
detail: { cell, col, row, state },
|
||||
@@ -388,6 +538,7 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
||||
return { ...renderCols, [fromItem?.id]: to, [toItem?.id]: from };
|
||||
});
|
||||
},
|
||||
|
||||
onColumnProposeMove: (startIndex: number, endIndex: number) => {
|
||||
const s = get();
|
||||
const fromItem = s.renderColumns?.[startIndex];
|
||||
@@ -421,7 +572,6 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onContextClick: (area: string, event: CellClickedEventArgs, col?: number, row?: number) => {
|
||||
const s = get();
|
||||
const coldef = s.renderColumns?.[col ?? -1];
|
||||
@@ -601,9 +751,11 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
||||
isDivider: true,
|
||||
},
|
||||
{
|
||||
id: 'refesh',
|
||||
label: `Refresh`,
|
||||
onClickAsync: async () => {
|
||||
await s.reload?.();
|
||||
onClick: () => {
|
||||
const s = get();
|
||||
s._refresh?.();
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -700,6 +852,30 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
||||
},
|
||||
pageSize: 50,
|
||||
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) => {
|
||||
set(
|
||||
produce((state) => {
|
||||
@@ -756,8 +932,8 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
||||
|
||||
return {
|
||||
allowOverlay: true,
|
||||
data: val,
|
||||
displayData: String(val),
|
||||
data: val ?? '',
|
||||
displayData: String(val ?? ''),
|
||||
kind: GridCellKind.Text,
|
||||
};
|
||||
} catch (e) {
|
||||
@@ -773,7 +949,7 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
||||
}
|
||||
},
|
||||
total_rows: 1000,
|
||||
uniqueid: getUUID(),
|
||||
uniqueid: getUUID()
|
||||
}),
|
||||
(props) => {
|
||||
const [setState, getState] = props.useStore((s) => [s.setState, s.getState]);
|
||||
@@ -814,63 +990,18 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
||||
};
|
||||
}, [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('reload')?.();
|
||||
getState('_refresh')?.();
|
||||
});
|
||||
|
||||
return {
|
||||
...props,
|
||||
|
||||
colSort: props.defaultSort ?? getState('colSort') ?? [],
|
||||
hideMenu: props.hideMenu ?? menus.hide,
|
||||
scrollToRowKey: props.scrollToRowKey ?? props.selectedRowKey ?? getState('scrollToRowKey'),
|
||||
showMenu: props.showMenu ?? menus.show,
|
||||
total_rows: props.total_rows ?? getState('total_rows') ?? 0,
|
||||
total_rows: getState('total_rows') ?? props.total_rows,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useGridlerStore } from './GridlerStore';
|
||||
export const Pager = React.memo(() => {
|
||||
const [
|
||||
setState,
|
||||
getState,
|
||||
glideref,
|
||||
visiblePages,
|
||||
//_visibleArea,
|
||||
@@ -16,6 +17,7 @@ export const Pager = React.memo(() => {
|
||||
hasLocalData,
|
||||
] = useGridlerStore((s) => [
|
||||
s.setState,
|
||||
s.getState,
|
||||
s._glideref,
|
||||
s._visiblePages,
|
||||
//s._visibleArea,
|
||||
@@ -38,10 +40,10 @@ export const Pager = React.memo(() => {
|
||||
if (!glideref) {
|
||||
return;
|
||||
}
|
||||
if (hasLocalData) {
|
||||
//using local data, no need to load pages
|
||||
return;
|
||||
}
|
||||
// if (hasLocalData) {
|
||||
// //using local data, no need to load pages
|
||||
// return;
|
||||
// }
|
||||
const firstPage = Math.max(0, Math.floor(visiblePages.y / pageSize));
|
||||
const lastPage = Math.floor((visiblePages.y + visiblePages.height) / pageSize);
|
||||
//const upperPage = pageSize * firstPage;
|
||||
@@ -57,7 +59,10 @@ export const Pager = React.memo(() => {
|
||||
// );
|
||||
|
||||
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]);
|
||||
|
||||
|
||||
55
src/Gridler/components/RefHandler.tsx
Normal file
55
src/Gridler/components/RefHandler.tsx
Normal 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);
|
||||
@@ -2,11 +2,18 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
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';
|
||||
|
||||
export interface GlidlerAPIAdaptorForGoLangv2Props<T = unknown> extends APIOptions {
|
||||
filter?: string;
|
||||
hotfields?: Array<string>;
|
||||
initialData?: Array<T>;
|
||||
options?: Array<GoAPIOperation>;
|
||||
}
|
||||
@@ -22,42 +29,107 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
||||
|
||||
const useAPIQuery: (index: number) => Promise<any> = useCallback(
|
||||
async (index: number) => {
|
||||
const columns = getState('columns');
|
||||
const colSort = getState('colSort');
|
||||
const pageSize = getState('pageSize');
|
||||
const colFilters = getState('colFilters');
|
||||
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('x-limit', String(pageSize ?? 50));
|
||||
head.set('x-offset', String((pageSize ?? 50) * index));
|
||||
|
||||
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) {
|
||||
head.set(
|
||||
'x-sort',
|
||||
colSort
|
||||
ops.push({
|
||||
type: 'sort',
|
||||
value: colSort
|
||||
?.map((sort: any) => `${sort.id} ${sort.direction}`)
|
||||
.reduce((acc: any, val: any) => `${acc},${val}`)
|
||||
);
|
||||
.reduce((acc: any, val: any) => `${acc},${val}`),
|
||||
});
|
||||
}
|
||||
|
||||
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}`);
|
||||
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.options && props.options.length > 0) {
|
||||
const optionHeaders = GoAPIHeaders(props.options);
|
||||
for (const oh in optionHeaders) {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +140,7 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
||||
r.controller?.abort?.();
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
_active_requests &&
|
||||
currentRequestIndex >= 0 &&
|
||||
@@ -109,14 +182,24 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
||||
...(cv ?? []).filter((f) => f.page !== index),
|
||||
]);
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (_e) {
|
||||
//console.log('APIAdaptorGoLangv2 error', e);
|
||||
addError(`Error: ${e}`, 'api', props.url);
|
||||
//addError(`Error: ${e}`, 'api', props.url);
|
||||
}
|
||||
setState('loadingData', false);
|
||||
return [];
|
||||
},
|
||||
[getState, props.authtoken, props.url, props.options, setState, setStateFN, addError]
|
||||
[
|
||||
getState,
|
||||
props.authtoken,
|
||||
props.url,
|
||||
props.filter,
|
||||
JSON.stringify(props.options),
|
||||
setState,
|
||||
setStateFN,
|
||||
addError,
|
||||
]
|
||||
);
|
||||
|
||||
const askAPIRowNumber: (key: string) => Promise<number> = useCallback(
|
||||
@@ -126,8 +209,10 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
||||
//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));
|
||||
const ops: FetchAPIOperation[] = [
|
||||
{ type: 'limit', value: String(10) },
|
||||
{ type: 'fetch-rownumber', value: key },
|
||||
];
|
||||
|
||||
head.set('Authorization', `Token ${props.authtoken}`);
|
||||
|
||||
@@ -141,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) {
|
||||
const optionHeaders = GoAPIHeaders(props.options);
|
||||
for (const oh in optionHeaders) {
|
||||
@@ -148,6 +241,13 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
||||
}
|
||||
}
|
||||
|
||||
if (ops && ops.length > 0) {
|
||||
const optionHeaders = GoAPIHeaders(ops);
|
||||
for (const oh in GoAPIHeaders(ops)) {
|
||||
head.set(oh, optionHeaders[oh]);
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
const res = await fetch(`${props.url}?x-fetch-rownumber=${key}}`, {
|
||||
@@ -165,17 +265,36 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
||||
}
|
||||
return [];
|
||||
},
|
||||
[props.url, props.authtoken, props.options, getState, addError]
|
||||
[props.url, props.authtoken, props.filter, props.options, getState, addError]
|
||||
);
|
||||
|
||||
//Reset the function in the store.
|
||||
useEffect(() => {
|
||||
setState('useAPIQuery', useAPIQuery);
|
||||
setState('askAPIRowNumber', askAPIRowNumber);
|
||||
}, [props.url, props.authtoken, props.options, 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 <></>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
//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);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { GridlerColumn } from '../Column';
|
||||
import { type GridlerProps, type GridlerState, useGridlerStore } from '../GridlerStore';
|
||||
|
||||
export function GlidlerFormAdaptor(props: {
|
||||
changeOnActiveClick?: boolean;
|
||||
descriptionField?: ((data: Record<string, unknown>) => string) | string;
|
||||
getMenuItems?: GridlerProps['getMenuItems'];
|
||||
onReload?: () => void;
|
||||
@@ -23,21 +24,43 @@ export function GlidlerFormAdaptor(props: {
|
||||
) => void;
|
||||
showDescriptionInMenu?: boolean;
|
||||
}) {
|
||||
const [getState, mounted, setState, reload] = useGridlerStore((s) => [
|
||||
const [getState, mounted, setState, _events] = useGridlerStore((s) => [
|
||||
s.getState,
|
||||
s.mounted,
|
||||
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(
|
||||
(
|
||||
id: string,
|
||||
storeState: GridlerState,
|
||||
row?: Record<string, unknown>,
|
||||
col?: GridlerColumn,
|
||||
defaultItems?: Array<unknown>
|
||||
) => {
|
||||
defaultItems?: MantineBetterMenuInstanceItem[]
|
||||
): MantineBetterMenuInstanceItem[] => {
|
||||
//console.log('GlidlerFormInterface getMenuItems', id);
|
||||
|
||||
if (id === 'header-menu') {
|
||||
@@ -140,8 +163,9 @@ export function GlidlerFormAdaptor(props: {
|
||||
c: 'orange',
|
||||
label: 'Refresh',
|
||||
leftSection: <IconRefresh color="orange" size={16} />,
|
||||
onClick: () => {
|
||||
reload?.();
|
||||
onClickAsync: async () => {
|
||||
const _refresh = getState('_refresh');
|
||||
await _refresh?.();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -16,23 +16,30 @@ export interface GlidlerLocalDataAdaptorProps<T = unknown> {
|
||||
cols: GridlerColumns | undefined,
|
||||
data: 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.
|
||||
function _GlidlerLocalDataAdaptor<T = unknown>(props: GlidlerLocalDataAdaptorProps<T>) {
|
||||
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,
|
||||
colOrder: s.colOrder,
|
||||
colSize: s.colSize,
|
||||
colSort: s.colSort,
|
||||
columns: s.columns,
|
||||
searchStr: s.searchStr,
|
||||
}));
|
||||
|
||||
const refChanged = React.useRef({
|
||||
colFilters: colFilters,
|
||||
colSort: colSort,
|
||||
searchStr: searchStr,
|
||||
});
|
||||
|
||||
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('data', sortedData);
|
||||
refChanged.current.colSort = colSort;
|
||||
getState('refreshCells')?.();
|
||||
}
|
||||
}, [colSort, props.onColumnSort]);
|
||||
|
||||
@@ -65,9 +73,20 @@ function _GlidlerLocalDataAdaptor<T = unknown>(props: GlidlerLocalDataAdaptorPro
|
||||
setState('total_rows', filteredData.length);
|
||||
setState('data', filteredData);
|
||||
refChanged.current.colFilters = colFilters;
|
||||
getState('refreshCells')?.();
|
||||
}
|
||||
}, [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 <></>;
|
||||
}
|
||||
export const GlidlerLocalDataAdaptor = React.memo(_GlidlerLocalDataAdaptor);
|
||||
|
||||
@@ -102,8 +102,8 @@ export const useGridTheme = () => {
|
||||
// }[colorScheme];
|
||||
|
||||
|
||||
// for (const selectedRow of gridSelection?.rows) {
|
||||
// if (selectedRow === row) {
|
||||
// for (const scrollToRowKey of gridSelection?.rows) {
|
||||
// if (scrollToRowKey === row) {
|
||||
// return {
|
||||
// bgCell: rowColor.bgCell,
|
||||
// bgCellMedium: rowColor.bgCellMedium
|
||||
|
||||
@@ -2,7 +2,9 @@ export {GlidlerAPIAdaptorForGoLangv2 } from './components/adaptors/GlidlerAPIAda
|
||||
export {GlidlerFormAdaptor } from './components/adaptors/GlidlerFormAdaptor'
|
||||
export {GlidlerLocalDataAdaptor } from './components/adaptors/GlidlerLocalDataAdaptor'
|
||||
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 {Gridler} from './Gridler'
|
||||
export * from './utils'
|
||||
export {GoAPIHeaders} from './utils'
|
||||
export type {FetchAPIOperation} from './utils'
|
||||
export type {APIOptions} from './utils/types'
|
||||
@@ -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 { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import type { GridlerColumns } from '../components/Column';
|
||||
|
||||
import { GlidlerAPIAdaptorForGoLangv2 } from '../components/adaptors';
|
||||
import { type GridlerRef } from '../components/GridlerStore';
|
||||
import { Gridler } from '../Gridler';
|
||||
|
||||
export const GridlerGoAPIExampleEventlog = () => {
|
||||
@@ -12,9 +13,11 @@ export const GridlerGoAPIExampleEventlog = () => {
|
||||
defaultValue: 'http://localhost:8080/api',
|
||||
key: 'apiurl',
|
||||
});
|
||||
const ref = useRef<GridlerRef>(null);
|
||||
const [apiKey, setApiKey] = useLocalStorage({ defaultValue: '', key: 'apikey' });
|
||||
const [selectRow, setSelectRow] = useState<string | undefined>('');
|
||||
const [values, setValues] = useState<Array<Record<string, any>>>([]);
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [sections, setSections] = useState<Record<string, unknown> | undefined>(undefined);
|
||||
const columns: GridlerColumns = [
|
||||
{
|
||||
@@ -106,8 +109,11 @@ export const GridlerGoAPIExampleEventlog = () => {
|
||||
//console.log('GridlerGoAPIExampleEventlog onChange', v);
|
||||
setValues(v);
|
||||
}}
|
||||
ref={ref}
|
||||
scrollToRowKey={selectRow ? parseInt(selectRow, 10) : undefined}
|
||||
searchStr={search}
|
||||
sections={{ ...sections, rightElementDisabled: false }}
|
||||
selectedRow={selectRow ? parseInt(selectRow, 10) : undefined}
|
||||
selectFirstRowOnMount={true}
|
||||
selectMode="row"
|
||||
title="Go API Example"
|
||||
uniqueid="gridtest"
|
||||
@@ -115,10 +121,12 @@ export const GridlerGoAPIExampleEventlog = () => {
|
||||
>
|
||||
<GlidlerAPIAdaptorForGoLangv2
|
||||
authtoken={apiKey}
|
||||
options={[{ type: 'preload', value: 'PRO' }]}
|
||||
//options={[{ type: 'fieldfilter', name: 'process', value: 'test' }]}
|
||||
url={`${apiUrl}/public/process`}
|
||||
/>
|
||||
<Gridler.FormAdaptor
|
||||
changeOnActiveClick={true}
|
||||
descriptionField={'process'}
|
||||
onRequestForm={(request, data) => {
|
||||
console.log('Form requested', request, data);
|
||||
@@ -127,6 +135,13 @@ export const GridlerGoAPIExampleEventlog = () => {
|
||||
</Gridler>
|
||||
<Divider />
|
||||
<Group>
|
||||
<TextInput
|
||||
leftSection={<>S</>}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search"
|
||||
value={search}
|
||||
w="190px"
|
||||
/>
|
||||
<TextInput
|
||||
onChange={(e) => setSelectRow(e.target.value)}
|
||||
placeholder="row"
|
||||
@@ -141,6 +156,43 @@ export const GridlerGoAPIExampleEventlog = () => {
|
||||
/>
|
||||
;
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//@ts-nocheck
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Box } from '@mantine/core';
|
||||
@@ -6,7 +7,12 @@ import { fn } from 'storybook/test';
|
||||
import { GridlerGoAPIExampleEventlog } from './Examples.goapi';
|
||||
|
||||
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 = {
|
||||
@@ -19,7 +25,7 @@ const meta = {
|
||||
component: Renderable,
|
||||
parameters: {
|
||||
// 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
|
||||
tags: ['autodocs'],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//@ts-nocheck
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Box } from '@mantine/core';
|
||||
@@ -24,7 +25,7 @@ const meta = {
|
||||
component: Renderable,
|
||||
parameters: {
|
||||
// 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
|
||||
tags: ['autodocs'],
|
||||
|
||||
@@ -5,21 +5,20 @@ import { fn } from 'storybook/test';
|
||||
|
||||
import { MantineBetterMenusProvider, useMantineBetterMenus } from './';
|
||||
|
||||
|
||||
const Renderable = (props: Record<string,unknown>) => {
|
||||
const Renderable = (props: Record<string, unknown>) => {
|
||||
return (
|
||||
<MantineBetterMenusProvider providerID='test' {...props} >
|
||||
<Menu/>
|
||||
<MantineBetterMenusProvider providerID="test" {...props}>
|
||||
<Menu />
|
||||
</MantineBetterMenusProvider>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const Menu = () => {
|
||||
const menus = useMantineBetterMenus();
|
||||
//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 = {
|
||||
// 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,
|
||||
parameters: {
|
||||
// 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
|
||||
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
|
||||
export const BasicExample: Story = {
|
||||
args: {
|
||||
|
||||
label: 'Test',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ const MenuItemRenderer = ({ children, label, ...props }: MantineBetterMenuInstan
|
||||
props.onClick?.(e);
|
||||
if (props.onClickAsync) {
|
||||
setLoading(true);
|
||||
props.onClickAsync().finally(() => setLoading(false));
|
||||
props.onClickAsync(e).finally(() => setLoading(false));
|
||||
}
|
||||
}}
|
||||
styles={{
|
||||
@@ -120,7 +120,7 @@ const MenuItemRenderer = ({ children, label, ...props }: MantineBetterMenuInstan
|
||||
props.onClick?.(e);
|
||||
if (props.onClickAsync) {
|
||||
setLoading(true);
|
||||
props.onClickAsync().finally(() => setLoading(false));
|
||||
props.onClickAsync(e).finally(() => setLoading(false));
|
||||
}
|
||||
}}
|
||||
styles={{
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface MantineBetterMenuInstanceItem extends Partial<MenuItemProps> {
|
||||
items?: Array<MantineBetterMenuInstanceItem>;
|
||||
label?: string;
|
||||
onClick?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onClickAsync?: () => Promise<void>;
|
||||
onClickAsync?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => Promise<void>;
|
||||
renderer?:
|
||||
| ((props: MantineBetterMenuInstanceItem & Record<string, unknown>) => ReactNode)
|
||||
| ReactNode;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from './Gridler'
|
||||
|
||||
export * from './Boxer';
|
||||
export * from './Former';
|
||||
export * from './FormerControllers';
|
||||
export * from './Gridler';
|
||||
|
||||
export {
|
||||
type MantineBetterMenuInstance,
|
||||
@@ -7,4 +9,4 @@ export {
|
||||
MantineBetterMenusProvider,
|
||||
type MantineBetterMenuStoreState,
|
||||
useMantineBetterMenus,
|
||||
} from "./MantineBetterMenu";
|
||||
} from './MantineBetterMenu';
|
||||
|
||||
@@ -17,14 +17,14 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
})
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
disconnect: vi.fn(),
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
globalThis.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
disconnect: vi.fn(),
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "Node",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
@@ -37,5 +37,6 @@
|
||||
"src",
|
||||
"lib.ts",
|
||||
"*.d.ts",
|
||||
]
|
||||
],
|
||||
|
||||
}
|
||||
@@ -23,5 +23,5 @@
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user