Compare commits

..

28 Commits

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

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

test: update global ResizeObserver and IntersectionObserver mocks to use globalThis

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

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

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

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

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

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

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

[skip ci]
2026-01-14 22:44:27 +02:00
7cc09d6acb docs(changeset): Added form controllers - New button and input controller components for the FormerControllers module 2026-01-14 22:44:15 +02:00
9df2f3b504 feat(controllers): add new input and button components
* Introduced ButtonCtrl, IconButtonCtrl, NativeSelectCtrl, PasswordInputCtrl, SwitchCtrl, TextAreaCtrl, TextInputCtrl
* Updated FormerControllers.types.ts to include SpecialIDProps
* Enhanced lib.ts to export new components
2026-01-14 22:42:17 +02:00
e777e1fa3a chore(form): 🗑️ remove unused form components and types
* Refactor Former components to streamline functionality
* Update stories to reflect changes in form structure
2026-01-14 21:56:55 +02:00
cd2f6db880 feat(form): enhance form functionality and API integration
* Refactor key handling to use uniqueKeyField
* Add reset functionality to clear dirty state after save
* Introduce new API call specifications for REST and resolve
* Implement predefined wrappers for dialogs and popovers
* Update todo list to reflect completed tasks
2026-01-14 21:51:39 +02:00
e6507f44af feat(form): enhance form layout and functionality
* Add FormerButtonArea component for action buttons
* Introduce FormerLayoutTop and FormerLayoutBottom for structured layout
* Update Former types to include new properties
* Implement dynamic ID generation for forms
* Refactor example to demonstrate new layout features
* Mark tasks as completed in todo.md
2026-01-14 19:35:38 +02:00
400a193a58 feat(todo): planned ideas 2026-01-12 23:25:58 +02:00
73 changed files with 2948 additions and 2712 deletions

View File

@@ -1,5 +1,47 @@
# @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

View File

@@ -11,7 +11,7 @@ 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 },
},
@@ -20,7 +20,7 @@ const config = defineConfig([
tseslint.configs.recommended,
{
...pluginReact.configs.flat.recommended,
ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '*stories.tsx'],
ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '*stories.tsx','dist/**'],
rules: {
...pluginReact.configs.flat.recommended.rules,
'react/react-in-jsx-scope': 'off',
@@ -34,6 +34,7 @@ const config = defineConfig([
'@typescript-eslint/ban-ts-comment': 'off',
},
},
{ignores: ['dist/**','node_modules/**','vite.config.*','eslint.config.*' ]},
]);
export default config;

View File

@@ -1,8 +1,26 @@
{
"name": "@warkypublic/oranguru",
"author": "Warky Devs",
"version": "0.0.23",
"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,72 +35,40 @@
"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.15",
"@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.2.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.1",
"eslint-plugin-react-refresh": "^0.4.24",
"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",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"storybook": "^9.1.15",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.2",
@@ -95,17 +81,16 @@
"@glideapps/glide-data-grid": "^6.0.3",
"@mantine/core": "^8.3.1",
"@mantine/hooks": "^8.3.1",
"@mantine/notifications": "^8.3.5",
"@mantine/modals": "^8.3.5",
"@mantine/notifications": "^8.3.5",
"@tabler/icons-react": "^3.35.0",
"@tanstack/react-query": "^5.90.5",
"@warkypublic/artemis-kit": "^1.0.10",
"@warkypublic/zustandsyncstore": "^0.0.4",
"react-hook-form": "^7.71.0",
"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"
}

1107
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

159
src/Boxer/Boxer.store.tsx Normal file
View File

@@ -0,0 +1,159 @@
import { createSyncStore } from '@warkypublic/zustandsyncstore';
import { produce } from 'immer';
import type { BoxerProps, BoxerStoreState } from './Boxer.types';
const { Provider: BoxerProvider, useStore: useBoxerStore } = createSyncStore<
BoxerStoreState,
BoxerProps
>(
(set, get) => ({
boxerData: [],
// Data Actions
fetchData: async (search?: string, reset?: boolean) => {
const state = get();
// Handle local data
if (state.dataSource === 'local' || !state.onAPICall) {
const localData = state.data ?? [];
if (!search) {
set({ boxerData: localData, hasMore: false, total: localData.length });
return;
}
// Filter local data based on search
const filtered = localData.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())
);
set({ boxerData: filtered, hasMore: false, total: filtered.length });
return;
}
// Handle server-side data
if (state.onAPICall) {
try {
set({ isFetching: true });
const currentPage = reset ? 0 : state.page;
const result = await state.onAPICall({
page: currentPage,
pageSize: state.pageSize,
search,
});
set(
produce((draft) => {
if (reset) {
draft.boxerData = result.data;
draft.page = 0;
} else {
draft.boxerData = [...(draft.boxerData ?? []), ...result.data];
}
draft.total = result.total;
draft.hasMore = draft.boxerData.length < result.total;
draft.isFetching = false;
})
);
} catch (error) {
console.error('Boxer fetchData error:', error);
set({ isFetching: false });
}
}
},
fetchMoreOnBottomReached: (target: HTMLDivElement) => {
const state = get();
if (!state.hasMore || state.isFetching) {
return;
}
const scrollPercentage =
(target.scrollTop + target.clientHeight) / target.scrollHeight;
// Load more when scrolled past 80%
if (scrollPercentage > 0.8) {
state.loadMore();
}
},
// State Management
getState: (key) => {
const current = get();
return current?.[key];
},
hasMore: true,
input: '',
isFetching: false,
loadMore: async () => {
const state = get();
if (!state.hasMore || state.isFetching) {
return;
}
set(
produce((draft) => {
draft.page = draft.page + 1;
})
);
await state.fetchData(state.search);
},
// Initial State
opened: false,
page: 0,
pageSize: 50,
search: '',
selectedOptionIndex: -1,
setInput: (input: string) => {
set({ input });
},
// Actions
setOpened: (opened: boolean) => {
set({ opened });
},
setSearch: (search: string) => {
set({ search });
},
setSelectedOptionIndex: (index: number) => {
set({ selectedOptionIndex: index });
},
setState: (key, value) => {
set(
produce((state) => {
state[key] = value;
})
);
},
total: 0,
}),
({
data = [],
dataSource = 'local',
pageSize = 50,
...props
}) => {
return {
...props,
boxerData: data, // Initialize with local data if provided
data,
dataSource,
hasMore: dataSource === 'server',
pageSize,
total: data.length,
};
}
);
export { BoxerProvider };
export { useBoxerStore };

379
src/Boxer/Boxer.tsx Normal file
View File

@@ -0,0 +1,379 @@
import { Combobox, ScrollArea, useVirtualizedCombobox } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import React, { useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import type { BoxerItem, BoxerProps, BoxerRef } from './Boxer.types';
import { BoxerProvider, useBoxerStore } from './Boxer.store';
import BoxerTarget from './BoxerTarget';
import useBoxerOptions from './hooks/useBoxerOptions';
const BoxerInner = React.forwardRef<BoxerRef>((_, ref) => {
// Component Refs
const parentRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const valueRef = useRef<any>(null);
const bufferRef = useRef<any>(null);
// Component store State
const {
boxerData,
clearable,
comboBoxProps,
dropDownProps,
error,
fetchData,
fetchMoreOnBottomReached,
input,
isFetching,
label,
mah,
multiSelect,
onBufferChange,
onChange,
opened,
openOnClear,
placeholder,
scrollAreaProps,
search,
selectedOptionIndex,
selectFirst,
setInput,
setOpened,
setSearch,
setSelectedOptionIndex,
showAll,
value,
} = useBoxerStore((state) => ({
boxerData: state.boxerData,
clearable: state.clearable,
comboBoxProps: state.comboBoxProps,
dropDownProps: state.dropDownProps,
error: state.error,
fetchData: state.fetchData,
fetchMoreOnBottomReached: state.fetchMoreOnBottomReached,
input: state.input,
isFetching: state.isFetching,
label: state.label,
mah: state.mah,
multiSelect: state.multiSelect,
onBufferChange: state.onBufferChange,
onChange: state.onChange,
opened: state.opened,
openOnClear: state.openOnClear,
placeholder: state.placeholder,
scrollAreaProps: state.scrollAreaProps,
search: state.search,
selectedOptionIndex: state.selectedOptionIndex,
selectFirst: state.selectFirst,
setInput: state.setInput,
setOpened: state.setOpened,
setSearch: state.setSearch,
setSelectedOptionIndex: state.setSelectedOptionIndex,
showAll: state.showAll,
value: state.value,
}));
// Virtualization setup
const count = boxerData.length;
const virtualizer = useVirtualizer({
count,
estimateSize: () => 36,
getScrollElement: () => parentRef.current,
});
const virtualItems = virtualizer.getVirtualItems();
// Component Callback Functions
const onOptionSubmit = useCallback(
(indexOrId: number | string) => {
const index = typeof indexOrId === 'string' ? parseInt(indexOrId, 10) : indexOrId;
const option = boxerData[index];
if (!option) {
return;
}
if (multiSelect) {
// Handle multi-select
const currentValues = Array.isArray(value) ? value : [];
const isSelected = currentValues.includes(option.value);
const newValues = isSelected
? currentValues.filter((v: any) => v !== option.value)
: [...currentValues, option.value];
onChange?.(newValues);
// Update buffer for multi-select
const newBuffer = boxerData.filter((item) => newValues.includes(item.value));
onBufferChange?.(newBuffer);
} else {
// Handle single select
onChange?.(option.value);
setSearch('');
setInput(option.label);
valueRef.current = option.value;
setOpened(false);
}
},
[boxerData, multiSelect, value, onChange, onBufferChange, setSearch, setInput, setOpened]
);
const onClear = useCallback(() => {
if (showAll && selectFirst) {
onOptionSubmit(0);
} else {
if (multiSelect) {
onChange?.([] as any);
} else {
onChange?.(null as any);
}
setSearch('');
setInput('');
inputRef.current?.focus();
}
if (openOnClear) {
setOpened(true);
}
}, [showAll, selectFirst, multiSelect, onChange, setSearch, setInput, openOnClear, setOpened, onOptionSubmit]);
// Component Hooks
const combobox = useVirtualizedCombobox({
getOptionId: (index) => String(index),
onDropdownClose: () => {
setOpened(false);
},
onDropdownOpen: () => {
if (!value || (multiSelect && (!Array.isArray(value) || value.length === 0))) {
setSearch('');
setInput('');
}
combobox.selectFirstOption();
},
onSelectedOptionSubmit: onOptionSubmit,
opened,
selectedOptionIndex,
setSelectedOptionIndex: (index) => {
setSelectedOptionIndex(index);
if (index !== -1) {
virtualizer.scrollToIndex(index);
}
},
totalOptionsCount: boxerData.length,
});
// Component variables
const { options } = useBoxerOptions({
boxerData,
multiSelect,
onOptionSubmit,
value,
});
// Component useEffects
useEffect(() => {
// Fetch initial data
fetchData('', true);
}, []);
useEffect(() => {
// Handle search changes
const delayDebounceFn = setTimeout(() => {
if (search !== undefined && opened) {
fetchData(search, true);
}
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [search, opened]);
useEffect(() => {
// Sync input with value
if (multiSelect) {
const labels = boxerData
.filter((item) => Array.isArray(value) && value.includes(item.value))
.map((item) => item.label)
.join(', ');
// When dropdown is closed, show selected labels. When open, allow searching
if (!opened && input !== labels) {
setInput(labels);
setSearch('');
}
} else {
const label = boxerData.find((item) => item.value === value)?.label;
// Only sync if we need to update the input to match the value
if (input !== label && (search ?? '') === '' && valueRef.current !== value && value) {
setInput(label ?? '');
} else if (!value && !valueRef.current && (search ?? '') === '') {
setSearch('');
setInput('');
}
}
// Handle buffer change
if (multiSelect) {
const buffer =
boxerData.filter((item: BoxerItem) => Array.isArray(value) && value.includes(item.value)) ??
[];
if (JSON.stringify(bufferRef.current) !== JSON.stringify(buffer)) {
onBufferChange?.(buffer);
bufferRef.current = buffer;
}
} else {
const buffer = boxerData?.find((item: BoxerItem) => item.value === value) ?? null;
if (bufferRef.current?.value !== buffer?.value) {
onBufferChange?.(buffer);
bufferRef.current = buffer;
}
}
}, [value, boxerData, input, search, multiSelect, opened, onBufferChange, setInput, setSearch]);
useEffect(() => {
// Select first option automatically
if (selectFirst && (boxerData?.length ?? 0) > 0 && !multiSelect) {
if (!value) {
onOptionSubmit?.(0);
}
}
}, [selectFirst, boxerData, multiSelect]);
// Expose ref methods
useImperativeHandle(ref, () => ({
clear: () => {
onClear();
},
close: () => {
setOpened(false);
combobox.closeDropdown();
},
focus: () => {
inputRef.current?.focus();
},
getValue: () => {
return value;
},
open: () => {
setOpened(true);
combobox.openDropdown();
},
setValue: (newValue: any) => {
onChange?.(newValue);
},
}));
return (
<Combobox
{...comboBoxProps}
resetSelectionOnOptionHover={false}
store={combobox}
withinPortal={true}
>
<Combobox.Target>
<Combobox.EventsTarget>
<BoxerTarget
clearable={clearable}
combobox={combobox}
error={error}
isFetching={isFetching}
label={label}
onBlur={() => {
if (!value && !multiSelect) {
setSearch('');
setInput('');
combobox.closeDropdown();
setOpened(false);
}
}}
onClear={onClear}
onSearch={(event) => {
setSearch(event.currentTarget.value);
setInput(event.currentTarget.value);
setOpened(true);
}}
placeholder={placeholder}
ref={inputRef}
search={input}
/>
</Combobox.EventsTarget>
</Combobox.Target>
<Combobox.Dropdown
onKeyDown={() => {
if (inputRef.current) {
inputRef.current.value = '';
inputRef.current?.focus();
}
}}
p={2}
{...dropDownProps}
>
{opened && options.length > 0 ? (
<Combobox.Options>
<ScrollArea.Autosize
{...scrollAreaProps}
mah={mah ?? 200}
viewportProps={{
...scrollAreaProps?.viewportProps,
onScroll: (event) => {
fetchMoreOnBottomReached(event.currentTarget as HTMLDivElement);
},
style: { border: '1px solid gray', borderRadius: 4 },
}}
viewportRef={parentRef}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
width: '100%',
}}
>
<div
style={{
left: 0,
position: 'absolute',
top: 0,
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
width: '100%',
}}
>
{virtualItems.map((virtualRow) => (
<div
data-index={virtualRow.index}
key={virtualRow.key}
ref={virtualizer.measureElement}
>
{options[virtualRow.index]}
</div>
))}
</div>
</div>
</ScrollArea.Autosize>
</Combobox.Options>
) : (
<Combobox.Empty>Nothing found</Combobox.Empty>
)}
</Combobox.Dropdown>
</Combobox>
);
});
BoxerInner.displayName = 'BoxerInner';
const Boxer = React.forwardRef<BoxerRef, BoxerProps>((props, ref) => {
return (
<BoxerProvider {...props}>
<BoxerInner ref={ref} />
</BoxerProvider>
);
});
Boxer.displayName = 'Boxer';
export { Boxer };
export default Boxer;

109
src/Boxer/Boxer.types.ts Normal file
View File

@@ -0,0 +1,109 @@
import type { ComboboxProps, ScrollAreaAutosizeProps, TextInputProps } from '@mantine/core';
import type { VirtualizerOptions } from '@tanstack/react-virtual';
export type BoxerDataSource =
| 'local' // Local array data
| 'server'; // Server-side with infinite loading
export type BoxerItem = {
[key: string]: any;
label: string;
value: any;
};
export interface BoxerProps {
clearable?: boolean;
// Component Props
comboBoxProps?: Partial<ComboboxProps>;
// Data Configuration
data?: Array<BoxerItem>;
dataSource?: BoxerDataSource;
disabled?: boolean;
dropDownProps?: React.ComponentPropsWithoutRef<'div'>;
error?: string;
// Advanced
id?: string;
inputProps?: Partial<TextInputProps>;
label?: string;
leftSection?: React.ReactNode;
mah?: number; // Max height for dropdown
// Component Configuration
multiSelect?: boolean;
name?: string;
// API Configuration (for server-side)
onAPICall?: (params: {
page: number;
pageSize: number;
search?: string;
}) => Promise<{ data: Array<BoxerItem>; total: number }>;
onBufferChange?: (buffer: Array<BoxerItem> | BoxerItem | null) => void;
onChange?: (value: any | Array<any>) => void;
openOnClear?: boolean;
pageSize?: number;
// UI Configuration
placeholder?: string;
// Styling
rightSection?: React.ReactNode;
scrollAreaProps?: Partial<ScrollAreaAutosizeProps>;
searchable?: boolean;
selectFirst?: boolean;
showAll?: boolean;
// Value Management
value?: any | Array<any>;
// Virtualization
virtualizer?: Partial<VirtualizerOptions<HTMLDivElement, Element>>;
}
export interface BoxerRef {
clear: () => void;
close: () => void;
focus: () => void;
getValue: () => any | Array<any>;
open: () => void;
setValue: (value: any | Array<any>) => void;
}
export interface BoxerState {
// Data State
boxerData: Array<BoxerItem>;
fetchData: (search?: string, reset?: boolean) => Promise<void>;
fetchMoreOnBottomReached: (target: HTMLDivElement) => void;
// State Management
getState: <K extends keyof BoxerStoreState>(key: K) => BoxerStoreState[K];
hasMore: boolean;
input: string;
isFetching: boolean;
// Data Actions
loadMore: () => Promise<void>;
// Internal State
opened: boolean;
page: number;
pageSize: number;
search: string;
selectedOptionIndex: number;
setInput: (input: string) => void;
// Actions
setOpened: (opened: boolean) => void;
setSearch: (search: string) => void;
setSelectedOptionIndex: (index: number) => void;
setState: <K extends keyof BoxerStoreState>(
key: K,
value: Partial<BoxerStoreState[K]>
) => void;
total: number;
}
export type BoxerStoreState = BoxerProps & BoxerState;

73
src/Boxer/BoxerTarget.tsx Normal file
View File

@@ -0,0 +1,73 @@
import type { ComboboxStore } from '@mantine/core';
import { ActionIcon, Loader, TextInput } from '@mantine/core';
import { IconX } from '@tabler/icons-react';
import React, { forwardRef } from 'react';
interface BoxerTargetProps {
clearable?: boolean;
combobox: ComboboxStore;
disabled?: boolean;
error?: string;
isFetching?: boolean;
label?: string;
leftSection?: React.ReactNode;
onBlur: () => void;
onClear: () => void;
onSearch: (event: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
search: string;
}
const BoxerTarget = forwardRef<HTMLInputElement, BoxerTargetProps>((props, ref) => {
const {
clearable = true,
combobox,
disabled,
error,
isFetching,
label,
leftSection,
onBlur,
onClear,
onSearch,
placeholder,
search,
} = props;
const rightSection = isFetching ? (
<Loader size="xs" />
) : search && clearable ? (
<ActionIcon
onClick={(e) => {
e.stopPropagation();
onClear();
}}
size="sm"
variant="subtle"
>
<IconX size={16} />
</ActionIcon>
) : null;
return (
<TextInput
disabled={disabled}
error={error}
label={label}
leftSection={leftSection}
onBlur={onBlur}
onChange={onSearch}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
placeholder={placeholder}
ref={ref}
rightSection={rightSection}
value={search}
/>
);
});
BoxerTarget.displayName = 'BoxerTarget';
export default BoxerTarget;

View File

@@ -0,0 +1,47 @@
import { Combobox, Checkbox } from '@mantine/core';
import { useMemo } from 'react';
import type { BoxerItem } from '../Boxer.types';
interface UseBoxerOptionsProps {
boxerData: Array<BoxerItem>;
value?: any | Array<any>;
multiSelect?: boolean;
onOptionSubmit: (index: number) => void;
}
const useBoxerOptions = (props: UseBoxerOptionsProps) => {
const { boxerData, value, multiSelect, onOptionSubmit } = props;
const options = useMemo(() => {
return boxerData.map((item, index) => {
const isSelected = multiSelect
? Array.isArray(value) && value.includes(item.value)
: value === item.value;
return (
<Combobox.Option
key={`${item.value}-${index}`}
value={String(index)}
active={isSelected}
onClick={() => {
onOptionSubmit(index);
}}
>
{multiSelect ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Checkbox checked={isSelected} onChange={() => {}} tabIndex={-1} />
<span>{item.label}</span>
</div>
) : (
item.label
)}
</Combobox.Option>
);
});
}, [boxerData, value, multiSelect, onOptionSubmit]);
return { options };
};
export default useBoxerOptions;

10
src/Boxer/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export { Boxer, default } from './Boxer';
export { BoxerProvider, useBoxerStore } from './Boxer.store';
export type {
BoxerDataSource,
BoxerItem,
BoxerProps,
BoxerRef,
BoxerState,
BoxerStoreState,
} from './Boxer.types';

View File

@@ -0,0 +1,218 @@
//@ts-ignore
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import type { BoxerItem } from '../Boxer.types';
import { Boxer } from '../Boxer';
const meta: Meta<typeof Boxer> = {
component: Boxer,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
title: 'Components/Boxer',
};
export default meta;
type Story = StoryObj<typeof Boxer>;
// Sample data
const sampleData: Array<BoxerItem> = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Date', value: 'date' },
{ label: 'Elderberry', value: 'elderberry' },
{ label: 'Fig', value: 'fig' },
{ label: 'Grape', value: 'grape' },
{ label: 'Honeydew', value: 'honeydew' },
{ label: 'Kiwi', value: 'kiwi' },
{ label: 'Lemon', value: 'lemon' },
{ label: 'Mango', value: 'mango' },
{ label: 'Nectarine', value: 'nectarine' },
{ label: 'Orange', value: 'orange' },
{ label: 'Papaya', value: 'papaya' },
{ label: 'Quince', value: 'quince' },
{ label: 'Raspberry', value: 'raspberry' },
{ label: 'Strawberry', value: 'strawberry' },
{ label: 'Tangerine', value: 'tangerine' },
{ label: 'Ugli Fruit', value: 'ugli' },
{ label: 'Watermelon', value: 'watermelon' },
];
// Local Data Example
export const LocalData: Story = {
render: () => {
const [value, setValue] = useState<null | string>(null);
return (
<div style={{ width: 300 }}>
<Boxer
clearable
data={sampleData}
dataSource="local"
label="Favorite Fruit"
onChange={setValue}
placeholder="Select a fruit"
searchable
value={value}
/>
<div style={{ marginTop: 20 }}>
<strong>Selected Value:</strong> {value ?? 'None'}
</div>
</div>
);
},
};
// Multi-Select Example
export const MultiSelect: Story = {
render: () => {
const [value, setValue] = useState<Array<string>>([]);
return (
<div style={{ width: 300 }}>
<Boxer
clearable
data={sampleData}
dataSource="local"
label="Favorite Fruits"
multiSelect
onChange={setValue}
placeholder="Select fruits"
searchable
value={value}
/>
<div style={{ marginTop: 20 }}>
<strong>Selected Values:</strong>{' '}
{value.length > 0 ? value.join(', ') : 'None'}
</div>
</div>
);
},
};
// Server-Side Example (Simulated)
export const ServerSide: Story = {
render: () => {
const [value, setValue] = useState<null | string>(null);
// Simulate server-side API call
const handleAPICall = async (params: {
page: number;
pageSize: number;
search?: string;
}): Promise<{ data: Array<BoxerItem>; total: number }> => {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 500));
// Filter based on search
let filteredData = [...sampleData];
if (params.search) {
filteredData = filteredData.filter((item) =>
item.label.toLowerCase().includes(params.search!.toLowerCase())
);
}
// Paginate
const start = params.page * params.pageSize;
const end = start + params.pageSize;
const paginatedData = filteredData.slice(start, end);
return {
data: paginatedData,
total: filteredData.length,
};
};
return (
<div style={{ width: 300 }}>
<Boxer
clearable
dataSource="server"
label="Favorite Fruit (Server-side)"
onAPICall={handleAPICall}
onChange={setValue}
pageSize={10}
placeholder="Select a fruit (Server-side)"
searchable
value={value}
/>
<div style={{ marginTop: 20 }}>
<strong>Selected Value:</strong> {value ?? 'None'}
</div>
</div>
);
},
};
// Select First Example
export const SelectFirst: Story = {
render: () => {
const [value, setValue] = useState<null | string>(null);
return (
<div style={{ width: 300 }}>
<Boxer
clearable
data={sampleData}
dataSource="local"
label="Auto-select First"
onChange={setValue}
placeholder="Select a fruit"
searchable
selectFirst
value={value}
/>
<div style={{ marginTop: 20 }}>
<strong>Selected Value:</strong> {value ?? 'None'}
</div>
</div>
);
},
};
// With Error
export const WithError: Story = {
render: () => {
const [value, setValue] = useState<null | string>(null);
return (
<div style={{ width: 300 }}>
<Boxer
clearable
data={sampleData}
dataSource="local"
error="Please select a fruit"
label="With Error"
onChange={setValue}
placeholder="Select a fruit"
searchable
value={value}
/>
</div>
);
},
};
// Disabled
export const Disabled: Story = {
render: () => {
return (
<div style={{ width: 300 }}>
<Boxer
data={sampleData}
dataSource="local"
disabled
label="Disabled"
onChange={() => {}}
placeholder="Select a fruit"
value="apple"
/>
</div>
);
},
};

15
src/Boxer/todo.md Normal file
View File

@@ -0,0 +1,15 @@
The plan and requirements:
Auto complete lookup with server side lookup support and infinite loading.
It must also have local array lookup and autocomplete.
When a users starts typing, it must start autocomplete list.
Exiting selected item must always be the first on the list and populated from the input in case the options does not exist anymore, it must not beak existing data.
- [ ] Auto Complete
- [ ] Multi Select
- [ ] Virtualize
- [ ] Search
- [ ] Clear, Menu buttons
- [ ] Headerspec API
- [ ] Relspec API
- [ ] SocketSpec API

View File

@@ -1,38 +0,0 @@
import React, { type ReactNode } from 'react'
import { Card, Stack, LoadingOverlay } from '@mantine/core'
import { FormSection } from './FormSection'
interface FormProps {
children: ReactNode
loading?: boolean
[key: string]: any
}
export const Form: React.FC<FormProps> & {
Section: typeof FormSection
} = ({ children, loading, ...others }) => {
return (
<Card
withBorder
component={Stack}
w="100%"
h="100%"
padding={0}
styles={{
root: {
height: '100%',
display: 'flex',
flexDirection: 'column',
},
}}
shadow="sm"
radius="md"
{...others}
>
<LoadingOverlay visible={loading || false} />
{children}
</Card>
)
}
Form.Section = FormSection

View File

@@ -1,55 +0,0 @@
import React, { type ReactNode } from 'react'
import { Modal } from '@mantine/core'
import { Form } from './Form'
import { FormLayoutStoreProvider, useFormLayoutStore } from '../store/FormLayout.store'
import type { RequestType } from '../types'
interface FormLayoutProps {
children: ReactNode
dirty?: boolean
loading?: boolean
onCancel?: () => void
onSubmit?: () => void
request?: RequestType
modal?: boolean
modalProps?: any
nested?: boolean
deleteFormProps?: any
[key: string]: any
}
const LayoutComponent: React.FC<FormLayoutProps> = ({
children,
modal,
modalProps,
...others
}) => {
const { request } = useFormLayoutStore((state) => ({
request: state.request,
}))
const modalWidth = request === 'delete' ? 400 : modalProps?.width
return modal === true ? (
<Modal
onClose={() => modalProps?.onClose?.()}
opened={modalProps?.opened || false}
size="auto"
withCloseButton={false}
centered={request !== 'delete'}
{...modalProps}
>
<div style={{ height: modalProps?.height, width: modalWidth }}>
<Form {...others}>{children}</Form>
</div>
</Modal>
) : (
<Form {...others}>{children}</Form>
)
}
export const FormLayout: React.FC<FormLayoutProps> = (props) => (
<FormLayoutStoreProvider {...props}>
<LayoutComponent {...props} />
</FormLayoutStoreProvider>
)

View File

@@ -1,117 +0,0 @@
import React, { type ReactNode } from 'react'
import { Stack, Group, Paper, Button, Title, Box } from '@mantine/core'
import { useFormLayoutStore } from '../store/FormLayout.store'
interface FormSectionProps {
type: 'header' | 'body' | 'footer' | 'error'
title?: string
rightSection?: ReactNode
children?: ReactNode
buttonTitles?: { submit?: string; cancel?: string }
className?: string
[key: string]: any
}
export const FormSection: React.FC<FormSectionProps> = ({
type,
title,
rightSection,
children,
buttonTitles,
className,
...others
}) => {
const { onCancel, onSubmit, request, loading } = useFormLayoutStore((state) => ({
onCancel: state.onCancel,
onSubmit: state.onSubmit,
request: state.request,
loading: state.loading,
}))
if (type === 'header') {
return (
<Group
justify="space-between"
p="md"
style={{
borderBottom: '1px solid var(--mantine-color-gray-3)',
}}
className={className}
{...others}
>
<Title order={4} size="h5">
{title}
</Title>
{rightSection && <Box>{rightSection}</Box>}
</Group>
)
}
if (type === 'body') {
return (
<Stack
gap="md"
p="md"
style={{ flex: 1, overflow: 'auto' }}
className={className}
{...others}
>
{children}
</Stack>
)
}
if (type === 'footer') {
return (
<Group
justify="flex-end"
gap="xs"
p="md"
style={{
borderTop: '1px solid var(--mantine-color-gray-3)',
}}
className={className}
{...others}
>
{children}
{request !== 'view' && (
<>
<Button
variant="default"
onClick={onCancel}
disabled={loading}
>
{buttonTitles?.cancel ?? 'Cancel'}
</Button>
<Button
type="submit"
onClick={onSubmit}
loading={loading}
>
{buttonTitles?.submit ?? (request === 'delete' ? 'Delete' : 'Save')}
</Button>
</>
)}
</Group>
)
}
if (type === 'error') {
return (
<Paper
p="sm"
m="md"
style={{
backgroundColor: 'var(--mantine-color-red-0)',
border: '1px solid var(--mantine-color-red-3)',
}}
className={className}
{...others}
>
{children}
</Paper>
)
}
return null
}

View File

@@ -1,34 +0,0 @@
import React, { forwardRef, type ReactElement, type Ref } from 'react'
import { FormProvider, useForm, type FieldValues } from 'react-hook-form'
import { Provider } from '../store/SuperForm.store'
import type { SuperFormProps, SuperFormRef } from '../types'
import Layout from './SuperFormLayout'
import SuperFormPersist from './SuperFormPersist'
const SuperForm = <T extends FieldValues>(
{ useFormProps, gridRef, children, persist, ...others }: SuperFormProps<T>,
ref
) => {
const form = useForm<T>({ ...useFormProps })
return (
<Provider {...others}>
<FormProvider {...form}>
{persist && (
<SuperFormPersist storageKey={typeof persist === 'object' ? persist.storageKey : null} />
)}
<Layout<T> gridRef={gridRef} ref={ref}>
{children}
</Layout>
</FormProvider>
</Provider>
)
}
const FRSuperForm = forwardRef(SuperForm) as <T extends FieldValues>(
props: SuperFormProps<T> & {
ref?: Ref<SuperFormRef<T>>
}
) => ReactElement
export default FRSuperForm

View File

@@ -1,364 +0,0 @@
import React, {
forwardRef,
RefObject,
useEffect,
useImperativeHandle,
useMemo,
type MutableRefObject,
type ReactElement,
type ReactNode,
type Ref,
} from 'react'
import { useFormContext, useFormState, type FieldValues, type UseFormReturn } from 'react-hook-form'
import { v4 as uuid } from 'uuid'
import {
ActionIcon,
Group,
List,
LoadingOverlay,
Paper,
Spoiler,
Stack,
Title,
Tooltip,
Transition,
} from '@mantine/core'
import { IconChevronsLeft, IconChevronsRight } from '@tabler/icons-react'
import { useUncontrolled } from '@mantine/hooks'
import useRemote from '../hooks/useRemote'
import { useStore } from '../store/SuperForm.store'
import classes from '../styles/Form.module.css'
import { Form } from './Form'
import { FormLayout } from './FormLayout'
import type { GridRef, SuperFormRef } from '../types'
const SuperFormLayout = <T extends FieldValues>(
{
children,
gridRef,
}: {
children: React.ReactNode | ((props: UseFormReturn<T, any, undefined>) => React.ReactNode)
gridRef?: MutableRefObject<GridRef<any> | null>
},
ref
) => {
// Component store State
const {
layoutProps,
meta,
nested,
onBeforeSubmit,
onCancel,
onLayoutMounted,
onLayoutUnMounted,
onResetForm,
onSubmit,
primeData,
request,
tableName,
value,
} = useStore((state) => ({
extraButtons: state.extraButtons,
layoutProps: state.layoutProps,
meta: state.meta,
nested: state.nested,
onBeforeSubmit: state.onBeforeSubmit,
onCancel: state.onCancel,
onLayoutMounted: state.onLayoutMounted,
onLayoutUnMounted: state.onLayoutUnMounted,
onResetForm: state.onResetForm,
onSubmit: state.onSubmit,
primeData: state.primeData,
request: state.request,
tableName: state.remote?.tableName,
value: state.value,
}))
const [_opened, _setOpened] = useUncontrolled({
value: layoutProps?.bodyRightSection?.opened,
defaultValue: false,
onChange: layoutProps?.bodyRightSection?.setOpened,
})
// Component Hooks
const form = useFormContext<T, any, undefined>()
const formState = useFormState({ control: form.control })
const { isFetching, mutateAsync, error, queryKey } = useRemote(gridRef)
// Component variables
const formUID = useMemo(() => {
return meta?.id ?? uuid()
}, [])
const requestString = request?.charAt(0).toUpperCase() + request?.slice(1)
const renderRightSection = (
<>
<Tooltip label={`${_opened ? 'Close' : 'Open'} Right Section`} withArrow>
<ActionIcon
style={{
position: 'absolute',
right: 12,
zIndex: 5,
display: layoutProps?.bodyRightSection?.hideToggleButton ? 'none' : 'block',
}}
variant='filled'
size='sm'
onClick={() => _setOpened(!_opened)}
radius='6'
m={2}
>
{_opened ? <IconChevronsRight /> : <IconChevronsLeft />}
</ActionIcon>
</Tooltip>
<Group wrap='nowrap' h='100%' align='flex-start' gap={2} w={'100%'}>
<Stack gap={0} h='100%' style={{ flex: 1 }}>
{typeof children === 'function' ? children({ ...form }) : children}
</Stack>
<Transition transition='slide-left' mounted={_opened}>
{(transitionStyles) => (
<Paper
style={transitionStyles}
h='100%'
w={layoutProps?.bodyRightSection?.w}
shadow='xs'
radius='xs'
mr='xs'
mt='xs'
ml={0}
{...layoutProps?.bodyRightSection?.paperProps}
>
{layoutProps?.bodyRightSection?.render?.({
form,
formValue: form.getValues(),
isFetching,
opened: _opened,
queryKey,
setOpened: _setOpened,
})}
</Paper>
)}
</Transition>
</Group>
</>
)
// Component Callback Functions
const onFormSubmit = async (data: T | any, closeForm: boolean = true) => {
const res: any =
typeof onBeforeSubmit === 'function'
? await mutateAsync?.(await onBeforeSubmit(data, request, form))
: await mutateAsync?.(data)
if ((tableName?.length ?? 0) > 0) {
if (res?.ok || (res?.status >= 200 && res?.status < 300)) {
onSubmit?.(res?.data, request, data, form, closeForm)
} else {
form.setError('root', {
message: res.status === 401 ? 'Username or password is incorrect' : res?.error,
})
}
} else {
onSubmit?.(data, request, data, form, closeForm)
}
}
// Component use Effects
useEffect(() => {
if (request === 'insert') {
if (onResetForm) {
onResetForm(primeData, form).then((resetData) => {
form.reset(resetData)
})
} else {
form.reset(primeData)
}
} else if ((request === 'change' || request === 'delete') && (tableName?.length ?? 0) === 0) {
if (onResetForm) {
onResetForm(value, form).then((resetData) => {
form.reset(resetData)
})
} else {
form.reset(value)
}
}
onLayoutMounted?.()
return onLayoutUnMounted
}, [
request,
primeData,
tableName,
value,
form.reset,
onResetForm,
onLayoutMounted,
onLayoutUnMounted,
])
useEffect(() => {
if (
(Object.keys(formState.errors)?.length > 0 || error) &&
_opened === false &&
layoutProps?.showErrorList !== false
) {
_setOpened(true)
}
}, [Object.keys(formState.errors)?.length > 0, error, layoutProps?.showErrorList])
useImperativeHandle<SuperFormRef<T>, SuperFormRef<T>>(ref, () => ({
form,
mutation: { isFetching, mutateAsync, error },
submit: (closeForm: boolean = true, afterSubmit?: (data: T | any) => void) => {
return form.handleSubmit(async (data: T | any) => {
await onFormSubmit(data, closeForm)
afterSubmit?.(data)
})()
},
queryKey,
getFormState: () => formState,
}))
return (
<form
name={formUID}
onSubmit={(e) => {
e.stopPropagation()
e.preventDefault()
form.handleSubmit((data: T | any) => {
onFormSubmit(data)
})(e)
}}
style={{ height: '100%' }}
className={request === 'view' ? classes.disabled : ''}
>
{/* <LoadingOverlay
visible={isFetching}
overlayProps={{
backgroundOpacity: 0.5,
}}
/> */}
{layoutProps?.noLayout ? (
typeof layoutProps?.bodyRightSection?.render === 'function' ? (
renderRightSection
) : typeof children === 'function' ? (
<>
<LoadingOverlay
visible={isFetching}
overlayProps={{
backgroundOpacity: 0.5,
}}
/>
{children({ ...form })}
</>
) : (
<>
<LoadingOverlay
visible={isFetching}
overlayProps={{
backgroundOpacity: 0.5,
}}
/>
{children}
</>
)
) : (
<FormLayout
dirty={formState.isDirty}
loading={isFetching}
onCancel={() => onCancel?.(request)}
onSubmit={form.handleSubmit((data: T | any) => {
onFormSubmit(data)
})}
request={request}
modal={false}
nested={nested}
>
{!layoutProps?.noHeader && (
<Form.Section
type='header'
title={`${layoutProps?.title || requestString}`}
rightSection={layoutProps?.rightSection}
/>
)}
{(Object.keys(formState.errors)?.length > 0 || error) && (
<Form.Section
className={classes.sticky}
buttonTitles={layoutProps?.buttonTitles}
type='error'
>
<Title order={6} size='sm' c='red'>
{(error?.message?.length ?? 0) > 0
? 'Server Error'
: 'Required information is incomplete*'}
</Title>
{(error as any)?.response?.data?.msg ||
(error as any)?.response?.data?._error ||
error?.message}
{layoutProps?.showErrorList !== false && (
<Spoiler maxHeight={50} showLabel='Show more' hideLabel='Hide'>
<List
size='xs'
style={{
color: 'light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-2))',
}}
>
{getErrorMessages(formState.errors)}
</List>
</Spoiler>
)}
</Form.Section>
)}
{typeof layoutProps?.bodyRightSection?.render === 'function' ? (
<Form.Section type='body' {...layoutProps?.bodySectionProps}>
{renderRightSection}
</Form.Section>
) : (
<Form.Section type='body' {...layoutProps?.bodySectionProps}>
{typeof children === 'function' ? children({ ...form }) : children}
</Form.Section>
)}
{!layoutProps?.noFooter && (
<Form.Section
className={classes.sticky}
buttonTitles={layoutProps?.buttonTitles}
type='footer'
{...(typeof layoutProps?.footerSectionProps === 'function'
? layoutProps?.footerSectionProps(ref as RefObject<SuperFormRef<T>>)
: layoutProps?.footerSectionProps)}
>
{typeof layoutProps?.extraButtons === 'function'
? layoutProps?.extraButtons(form)
: layoutProps?.extraButtons}
</Form.Section>
)}
</FormLayout>
)}
</form>
)
}
const getErrorMessages = (errors: any): ReactNode | null => {
return Object.keys(errors ?? {}).map((key) => {
if (typeof errors[key] === 'object' && key !== 'ref') {
return getErrorMessages(errors[key])
}
if (key !== 'message') {
return null
}
return <List.Item key={key}>{errors[key]}</List.Item>
})
}
const FRSuperFormLayout = forwardRef(SuperFormLayout) as <T extends FieldValues>(
props: {
children: React.ReactNode | ((props: UseFormReturn<T, any, undefined>) => React.ReactNode)
gridRef?: MutableRefObject<GridRef | null>
} & {
ref?: Ref<SuperFormRef<T>>
}
) => ReactElement
export default FRSuperFormLayout

View File

@@ -1,66 +0,0 @@
import { useEffect, useState } from 'react'
import { useFormContext, useFormState } from 'react-hook-form'
import { useDebouncedCallback } from '@mantine/hooks'
import useSubscribe from '../hooks/use-subscribe'
import { useSuperFormStore } from '../store/SuperForm.store'
import { openConfirmModal } from '../utils/openConfirmModal'
const SuperFormPersist = ({ storageKey }: { storageKey?: string | null }) => {
// Component store State
const [persistKey, setPersistKey] = useState<string>('')
const { isDirty, isReady, isSubmitted } = useFormState()
const { remote, request } = useSuperFormStore((state) => ({
request: state.request,
remote: state.remote,
}))
// Component Hooks
const { reset, setValue } = useFormContext()
const handleFormChange = useDebouncedCallback(({ values }) => {
setPersistKey(() => {
const key = `superform-persist-${storageKey?.length > 0 ? storageKey : `${remote?.tableName || 'local'}-${request}-${values[remote?.primaryKey] ?? ''}`}`
if (!isDirty) {
return key
}
window.localStorage.setItem(key, JSON.stringify(values))
return key
})
}, 250)
useSubscribe('', handleFormChange)
// Component use Effects
useEffect(() => {
if (isReady && persistKey) {
const data = window.localStorage.getItem(persistKey)
if (!data) {
return
}
if (isSubmitted) {
window.localStorage.removeItem(persistKey)
return
}
openConfirmModal(
() => {
reset(JSON.parse(data))
setValue('_dirty', true, { shouldDirty: true })
},
() => {
window.localStorage.removeItem(persistKey)
},
'Do you want to restore the previous data that was not submitted?'
)
}
}, [isReady, isSubmitted, persistKey])
return null
}
export default SuperFormPersist

View File

@@ -1,43 +0,0 @@
import React, { createContext, useContext, ReactNode, useState } from 'react'
interface ApiConfigContextValue {
apiURL: string
setApiURL: (url: string) => void
}
const ApiConfigContext = createContext<ApiConfigContextValue | null>(null)
interface ApiConfigProviderProps {
children: ReactNode
defaultApiURL?: string
}
export const ApiConfigProvider: React.FC<ApiConfigProviderProps> = ({
children,
defaultApiURL = '',
}) => {
const [apiURL, setApiURL] = useState(defaultApiURL)
return (
<ApiConfigContext.Provider value={{ apiURL, setApiURL }}>
{children}
</ApiConfigContext.Provider>
)
}
export const useApiConfig = (): ApiConfigContextValue => {
const context = useContext(ApiConfigContext)
if (!context) {
throw new Error('useApiConfig must be used within ApiConfigProvider')
}
return context
}
/**
* Hook to get API URL with optional override
* @param overrideURL - Optional URL to use instead of context value
*/
export const useApiURL = (overrideURL?: string): string => {
const { apiURL } = useApiConfig()
return overrideURL ?? apiURL
}

View File

@@ -1,146 +0,0 @@
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useRef,
useState,
type ReactElement,
type Ref,
} from 'react'
import { IconX } from '@tabler/icons-react'
import type { FieldValues } from 'react-hook-form'
import { ActionIcon, Drawer } from '@mantine/core'
import type { SuperFormDrawerProps, SuperFormDrawerRef, SuperFormRef } from '../../types'
import SuperForm from '../../components/SuperForm'
import { openConfirmModal } from '../../utils/openConfirmModal'
const SuperFormDrawer = <T extends FieldValues>(
{ drawerProps, noCloseOnSubmit, ...formProps }: SuperFormDrawerProps<T>,
ref: Ref<SuperFormDrawerRef<T>>
) => {
// Component Refs
const formRef = useRef<SuperFormRef<T>>(null)
const drawerRef = useRef<HTMLDivElement>(null)
// Component store State
// Tell drawer that form layout mounted to fix refs
const [layoutMounted, setLayoutMounted] = useState(false)
// Component Callback Functions
const onSubmit = (data: T, request, formData, form, closeForm: boolean = true) => {
formProps?.onSubmit?.(data, request, formData, form, closeForm)
if (request === 'delete') {
drawerProps?.onClose()
}
if (!noCloseOnSubmit) {
if (closeForm) {
drawerProps?.onClose()
}
}
}
const onCancel = (request) => {
if (formRef?.current?.getFormState().isDirty) {
openConfirmModal(() => {
drawerProps?.onClose()
formProps?.onCancel?.(request)
})
} else {
drawerProps?.onClose()
formProps?.onCancel?.(request)
}
}
const onLayoutMounted = useCallback(() => {
setLayoutMounted(true)
formProps?.onLayoutMounted?.()
}, [formProps?.onLayoutMounted])
const onLayoutUnMounted = useCallback(() => {
setLayoutMounted(false)
formProps?.onLayoutUnMounted?.()
}, [formProps?.onLayoutUnMounted])
// Component use Effects
useImperativeHandle<SuperFormDrawerRef<T>, SuperFormDrawerRef<T>>(
ref,
() => ({
...formRef.current,
drawer: drawerRef.current,
} as SuperFormDrawerRef<T>),
[layoutMounted]
)
return (
<Drawer
ref={drawerRef}
onClose={onCancel}
closeOnClickOutside={false}
onKeyDown={(e) => {
if (e.key === 'Escape' && drawerProps.closeOnEscape !== false) {
e.stopPropagation()
onCancel(formProps.request)
}
}}
overlayProps={{ backgroundOpacity: 0.5, blur: 0.5 }}
padding={6}
position='right'
transitionProps={{
transition: 'slide-left',
duration: 150,
timingFunction: 'linear',
}}
size={500}
styles={{
content: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'stretch',
},
body: {
minHeight: '100px',
flexGrow: 1,
},
}}
keepMounted={false}
{...drawerProps}
closeOnEscape={false}
withCloseButton={false}
title={null}
>
<SuperForm<T>
{...formProps}
onCancel={onCancel}
onSubmit={onSubmit}
onLayoutMounted={onLayoutMounted}
onLayoutUnMounted={onLayoutUnMounted}
ref={formRef}
layoutProps={{
...formProps?.layoutProps,
rightSection: (
<ActionIcon
size='xs'
onClick={() => {
onCancel(formProps?.request)
}}
>
<IconX size={18} />
</ActionIcon>
),
title:
(drawerProps.title as string) ??
formProps?.layoutProps?.title ??
(formProps?.request as string),
}}
/>
</Drawer>
)
}
const FRSuperFormDrawer = forwardRef(SuperFormDrawer) as <T extends FieldValues>(
props: SuperFormDrawerProps<T> & { ref?: Ref<SuperFormDrawerRef<T>> }
) => ReactElement
export default FRSuperFormDrawer

View File

@@ -1,105 +0,0 @@
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useRef,
useState,
type ReactElement,
type Ref,
} from 'react'
import type { FieldValues } from 'react-hook-form'
import { Modal, ScrollArea } from '@mantine/core'
import type { SuperFormModalProps, SuperFormModalRef, SuperFormRef } from '../../types'
import SuperForm from '../../components/SuperForm'
import { openConfirmModal } from '../../utils/openConfirmModal'
const SuperFormModal = <T extends FieldValues>(
{ modalProps, noCloseOnSubmit, ...formProps }: SuperFormModalProps<T>,
ref: Ref<SuperFormModalRef<T>>
) => {
// Component Refs
const modalRef = useRef<HTMLDivElement>(null)
const formRef = useRef<SuperFormRef<T>>(null)
// Component store State
// Tell drawer that form layout mounted to fix refs
const [layoutMounted, setLayoutMounted] = useState(false)
// Component Callback Functions
const onSubmit = (data: T, request, formData, form, closeForm: boolean = true) => {
formProps?.onSubmit?.(data, request, formData, form, closeForm)
if (request === 'delete') {
modalProps?.onClose()
}
if (!noCloseOnSubmit) {
if (closeForm) {
modalProps?.onClose()
}
}
}
const onCancel = (request) => {
if (formRef?.current?.getFormState().isDirty) {
openConfirmModal(() => {
modalProps?.onClose()
formProps?.onCancel?.(request)
})
} else {
modalProps?.onClose()
formProps?.onCancel?.(request)
}
}
const onLayoutMounted = useCallback(() => {
setLayoutMounted(true)
formProps?.onLayoutMounted?.()
}, [formProps?.onLayoutMounted])
const onLayoutUnMounted = useCallback(() => {
setLayoutMounted(false)
formProps?.onLayoutUnMounted?.()
}, [formProps?.onLayoutUnMounted])
// Component use Effects
useImperativeHandle<SuperFormModalRef<T>, SuperFormModalRef<T>>(
ref,
() => ({
...formRef.current,
modal: modalRef.current,
} as SuperFormModalRef<T>),
[layoutMounted]
)
return (
<Modal
ref={modalRef}
closeOnClickOutside={false}
overlayProps={{
backgroundOpacity: 0.5,
blur: 4,
}}
padding='sm'
scrollAreaComponent={ScrollArea.Autosize}
size={500}
keepMounted={false}
{...modalProps}
>
<SuperForm<T>
{...formProps}
onCancel={onCancel}
onSubmit={onSubmit}
onLayoutMounted={onLayoutMounted}
onLayoutUnMounted={onLayoutUnMounted}
ref={formRef}
/>
</Modal>
)
}
const FRSuperFormModal = forwardRef(SuperFormModal) as <T extends FieldValues>(
props: SuperFormModalProps<T> & { ref?: Ref<SuperFormModalRef<T>> }
) => ReactElement
export default FRSuperFormModal

View File

@@ -1,116 +0,0 @@
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useRef,
useState,
type ReactElement,
type Ref,
} from 'react'
import type { FieldValues } from 'react-hook-form'
import { Box, Popover } from '@mantine/core'
import { useUncontrolled } from '@mantine/hooks'
import type { SuperFormPopoverProps, SuperFormPopoverRef, SuperFormRef } from '../../types'
import SuperForm from '../../components/SuperForm'
import { openConfirmModal } from '../../utils/openConfirmModal'
const SuperFormPopover = <T extends FieldValues>(
{ popoverProps, target, noCloseOnSubmit, ...formProps }: SuperFormPopoverProps<T>,
ref: Ref<SuperFormPopoverRef<T>>
) => {
// Component Refs
const popoverRef = useRef<HTMLDivElement>(null)
const formRef = useRef<SuperFormRef<T>>(null)
// Component store State
// Tell drawer that form layout mounted to fix refs
const [layoutMounted, setLayoutMounted] = useState(false)
// Component Hooks
const [_value, _onChange] = useUncontrolled({
value: popoverProps?.opened,
onChange: popoverProps?.onChange,
})
// Component Callback Functions
const onSubmit = (data: T, request, formData, form, closeForm: boolean = true) => {
formProps?.onSubmit?.(data, request, formData, form, closeForm)
if (request === 'delete') {
_onChange(false)
}
if (!noCloseOnSubmit) {
if (closeForm) {
_onChange(false)
}
}
}
const onCancel = (request) => {
if (formRef?.current?.getFormState().isDirty) {
openConfirmModal(() => {
_onChange(false)
formProps?.onCancel?.(request)
})
} else {
_onChange(false)
formProps?.onCancel?.(request)
}
}
const onLayoutMounted = useCallback(() => {
setLayoutMounted(true)
formProps?.onLayoutMounted?.()
}, [formProps?.onLayoutMounted])
const onLayoutUnMounted = useCallback(() => {
setLayoutMounted(false)
formProps?.onLayoutUnMounted?.()
}, [formProps?.onLayoutUnMounted])
// Component use Effects
useImperativeHandle<SuperFormPopoverRef<T>, SuperFormPopoverRef<T>>(
ref,
() => ({
...formRef.current,
popover: popoverRef.current,
} as SuperFormPopoverRef<T>),
[layoutMounted]
)
return (
<Popover
closeOnClickOutside={false}
onClose={() => _onChange(false)}
opened={_value}
position='left'
radius='md'
withArrow
withinPortal
zIndex={200}
keepMounted={false}
{...popoverProps}
>
<Popover.Target>
<Box onClick={() => _onChange(true)}>{target}</Box>
</Popover.Target>
<Popover.Dropdown p={0} m={0} ref={popoverRef}>
<SuperForm
{...formProps}
onCancel={onCancel}
onSubmit={onSubmit}
onLayoutMounted={onLayoutMounted}
onLayoutUnMounted={onLayoutUnMounted}
ref={formRef}
/>
</Popover.Dropdown>
</Popover>
)
}
const FRSuperFormPopover = forwardRef(SuperFormPopover) as <T extends FieldValues>(
props: SuperFormPopoverProps<T> & { ref?: Ref<SuperFormPopoverRef<T>> }
) => ReactElement
export default FRSuperFormPopover

View File

@@ -1,96 +0,0 @@
import { useState } from 'react'
import { FieldValues } from 'react-hook-form'
import { SuperFormProps, RequestType, ExtendedDrawerProps } from '../types'
interface UseDrawerFormState<T extends FieldValues> extends Partial<SuperFormProps<T>> {
drawerProps: Partial<ExtendedDrawerProps>
opened?: boolean
onClose?: () => void
request: RequestType
[key: string]: any
}
type AskFunction = (request: RequestType, buffer: any) => void
const useDrawerFormState = <T extends FieldValues>(
props?: Partial<UseDrawerFormState<T>>
): {
formProps: UseDrawerFormState<T>
setFormProps: React.Dispatch<React.SetStateAction<UseDrawerFormState<T>>>
open: (props: Partial<UseDrawerFormState<T>>) => void
close: () => void
ask: AskFunction
} => {
const [formProps, setFormProps] = useState<UseDrawerFormState<T>>({
opened: false,
request: 'insert',
...props,
onClose: () =>
setFormProps((curr) => ({
...curr,
opened: false,
drawerProps: { ...curr.drawerProps, opened: false },
})),
drawerProps: { opened: false, onClose: () => {}, ...props?.drawerProps },
})
return {
formProps,
setFormProps,
open: (props?: Partial<UseDrawerFormState<T>>) => {
setFormProps((curr) => {
return {
...curr,
...props,
request: props.request ?? curr.request,
opened: true,
drawerProps: {
...curr.drawerProps,
...props?.drawerProps,
opened: true,
onClose: curr.onClose,
},
primeData: props?.primeData,
useFormProps: {
...curr.useFormProps,
...props?.useFormProps,
},
layoutProps: {
...curr.layoutProps,
...props?.layoutProps,
},
useQueryOptions: {
...curr.useQueryOptions,
...props?.useQueryOptions,
},
meta: {
...curr.meta,
...props?.meta,
},
useMutationOptions: {
...curr.useMutationOptions,
...props?.useMutationOptions,
},
}
})
},
close: () =>
setFormProps((curr) => ({
...curr,
opened: false,
drawerProps: { ...curr.drawerProps, opened: false, onClose: curr.onClose },
})),
ask: (request: RequestType, buffer: any) => {
setFormProps((curr) => ({
...curr,
request,
value: buffer,
opened: true,
drawerProps: { ...curr.drawerProps, opened: true, onClose: curr.onClose },
}))
},
}
}
export default useDrawerFormState
export type { UseDrawerFormState }

View File

@@ -1,97 +0,0 @@
import { useState } from 'react'
import { FieldValues } from 'react-hook-form'
import { ModalProps } from '@mantine/core'
import { SuperFormProps, RequestType } from '../types'
interface UseModalFormState<T extends FieldValues> extends Partial<SuperFormProps<T>> {
modalProps: ModalProps
opened?: boolean
onClose?: () => void
request: RequestType
[key: string]: any
}
type AskFunction = (request: RequestType, buffer: any) => void
const useModalFormState = <T extends FieldValues>(
props?: Partial<UseModalFormState<T>>
): {
formProps: UseModalFormState<T>
setFormProps: React.Dispatch<React.SetStateAction<UseModalFormState<T>>>
open: (props: Partial<UseModalFormState<T>>) => void
close: () => void
ask: AskFunction
} => {
const [formProps, setFormProps] = useState<UseModalFormState<T>>({
opened: false,
request: 'insert',
...props,
onClose: () =>
setFormProps((curr) => ({
...curr,
opened: false,
modalProps: { ...curr.modalProps, opened: false },
})),
modalProps: { opened: false, onClose: () => {}, ...props?.modalProps },
})
return {
formProps,
setFormProps,
open: (props?: Partial<UseModalFormState<T>>) => {
setFormProps((curr) => {
return {
...curr,
...props,
request: props.request ?? curr.request,
opened: true,
modalProps: {
...curr.modalProps,
...props?.modalProps,
opened: true,
onClose: curr.onClose,
},
primeData: props?.primeData,
useFormProps: {
...curr.useFormProps,
...props?.useFormProps,
},
layoutProps: {
...curr.layoutProps,
...props?.layoutProps,
},
useQueryOptions: {
...curr.useQueryOptions,
...props?.useQueryOptions,
},
meta: {
...curr.meta,
...props?.meta,
},
useMutationOptions: {
...curr.useMutationOptions,
...props?.useMutationOptions,
},
}
})
},
close: () =>
setFormProps((curr) => ({
...curr,
opened: false,
modalProps: { ...curr.modalProps, opened: false, onClose: curr.onClose },
})),
ask: (request: RequestType, buffer: any) => {
setFormProps((curr) => ({
...curr,
request,
value: buffer,
opened: true,
modalProps: { ...curr.modalProps, opened: true, onClose: curr.onClose },
}))
},
}
}
export default useModalFormState
export type { UseModalFormState }

View File

@@ -1,97 +0,0 @@
import { useState } from 'react'
import { FieldValues } from 'react-hook-form'
import { PopoverProps } from '@mantine/core'
import { SuperFormProps, RequestType } from '../types'
interface UsePopoverFormState<T extends FieldValues> extends Partial<SuperFormProps<T>> {
popoverProps: Omit<PopoverProps, 'children'>
opened?: boolean
onClose?: () => void
request: RequestType
[key: string]: any
}
type AskFunction = (request: RequestType, buffer: any) => void
const usePopoverFormState = <T extends FieldValues>(
props?: Partial<UsePopoverFormState<T>>
): {
formProps: UsePopoverFormState<T>
setFormProps: React.Dispatch<React.SetStateAction<UsePopoverFormState<T>>>
open: (props: Partial<UsePopoverFormState<T>>) => void
close: () => void
ask: AskFunction
} => {
const [formProps, setFormProps] = useState<UsePopoverFormState<T>>({
opened: false,
request: 'insert',
...props,
popoverProps: { opened: false, onClose: () => {}, ...props?.popoverProps },
onClose: () =>
setFormProps((curr) => ({
...curr,
opened: false,
popoverProps: { ...curr.popoverProps, opened: false },
})),
})
return {
formProps,
setFormProps,
open: (props?: Partial<UsePopoverFormState<T>>) => {
setFormProps((curr) => {
return {
...curr,
...props,
request: props.request ?? curr.request,
opened: true,
popoverProps: {
...curr.popoverProps,
...props?.popoverProps,
opened: true,
onClose: curr.onClose,
},
primeData: props?.primeData,
useFormProps: {
...curr.useFormProps,
...props?.useFormProps,
},
layoutProps: {
...curr.layoutProps,
...props?.layoutProps,
},
useQueryOptions: {
...curr.useQueryOptions,
...props?.useQueryOptions,
},
meta: {
...curr.meta,
...props?.meta,
},
useMutationOptions: {
...curr.useMutationOptions,
...props?.useMutationOptions,
},
}
})
},
close: () =>
setFormProps((curr) => ({
...curr,
opened: false,
popoverProps: { ...curr.popoverProps, opened: false, onClose: curr.onClose },
})),
ask: (request: RequestType, buffer: any) => {
setFormProps((curr) => ({
...curr,
request,
value: buffer,
opened: true,
popoverProps: { ...curr.popoverProps, opened: true, onClose: curr.onClose },
}))
},
}
}
export default usePopoverFormState
export type { UsePopoverFormState }

View File

@@ -1,40 +0,0 @@
import { useEffect } from 'react'
import {
EventType,
FieldValues,
FormState,
InternalFieldName,
Path,
ReadFormState,
useFormContext,
UseFormReturn,
} from 'react-hook-form'
const useSubscribe = <T extends FieldValues>(
name: Path<T> | readonly Path<T>[] | undefined,
callback: (
data: Partial<FormState<T>> & {
values: FieldValues
name?: InternalFieldName
type?: EventType
},
form?: UseFormReturn<T, any, T>
) => void,
formState?: ReadFormState,
deps?: unknown[]
) => {
const form = useFormContext<T>()
return useEffect(() => {
const unsubscribe = form.subscribe({
name,
callback: (data) => callback(data, form),
formState: { values: true, ...formState },
exact: true,
})
return unsubscribe
}, [form.subscribe, ...(deps || [])])
}
export default useSubscribe

View File

@@ -1,38 +0,0 @@
import { useState } from 'react'
import { FieldValues } from 'react-hook-form'
import { SuperFormProps, RequestType } from '../types'
interface UseSuperFormState<T extends FieldValues> extends Partial<SuperFormProps<T>> {
request: RequestType
[key: string]: any
}
type AskFunction = (request: RequestType, buffer: any) => void
const useSuperFormState = <T extends FieldValues>(
props?: UseSuperFormState<T>
): {
formProps: UseSuperFormState<any>
setFormProps: React.Dispatch<React.SetStateAction<UseSuperFormState<T>>>
ask: AskFunction
} => {
const [formProps, setFormProps] = useState<UseSuperFormState<T>>({
request: 'insert',
...props,
})
return {
formProps,
setFormProps,
ask: (request: RequestType, buffer: any) => {
setFormProps((curr) => ({
...curr,
request,
value: buffer,
}))
},
}
}
export default useSuperFormState
export type { UseSuperFormState }

View File

@@ -1,123 +0,0 @@
import { useEffect, type MutableRefObject } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useFormContext, useFormState, type FieldValues } from 'react-hook-form'
import { useStore } from '../store/SuperForm.store'
import { useApiURL } from '../config/ApiConfig'
import { getNestedValue } from '../utils/getNestedValue'
import { fetchClient, type FetchResponse, FetchError } from '../utils/fetchClient'
import type { GridRef } from '../types'
const useRemote = <T extends FieldValues>(gridRef?: MutableRefObject<GridRef<any> | null>) => {
// Component store State
const { onResetForm, remote, request, useMutationOptions, useQueryOptions, value } = useStore(
(state) => ({
onResetForm: state.onResetForm,
remote: state.remote,
request: state.request,
useMutationOptions: state.useMutationOptions,
useQueryOptions: state.useQueryOptions,
value: state.value,
})
)
// Component Hooks
const form = useFormContext<T>()
const { isDirty } = useFormState({ control: form.control })
// Component use Effects
const qc = useQueryClient()
// Get API URL from context or override
const contextApiURL = useApiURL()
const id = remote?.primaryKey?.includes('.')
? getNestedValue(remote?.primaryKey, value)
: value?.[remote?.primaryKey ?? '']
const queryKey = useQueryOptions?.queryKey || [remote?.tableName, id]
const enabled =
useQueryOptions?.enabled ||
!!(
(remote?.enabled ?? true) &&
(remote?.tableName ?? '').length > 0 &&
(request === 'change' || request === 'delete') &&
String(id) !== 'undefined' &&
(String(id)?.length ?? 0) > 0
)
let url = remote?.apiURL ?? `${contextApiURL}/${remote?.tableName}`
url = url?.endsWith('/') ? url.substring(0, url.length - 1) : url
const { isSuccess, status, data, isFetching } = useQuery<FetchResponse<T>>({
queryKey,
queryFn: () => fetchClient.get<T>(`${url}/${id}`, remote?.apiOptions),
enabled,
refetchOnMount: 'always',
refetchOnReconnect: !isDirty,
refetchOnWindowFocus: !isDirty,
staleTime: 0,
gcTime: 0,
...useQueryOptions,
})
const changeMut = useMutation({
// @ts-ignore
mutationFn: (mutVal: T) => {
if (!remote?.tableName || !remote?.primaryKey) {
return Promise.resolve(null)
}
return request === 'insert'
? fetchClient.post(url, mutVal, remote?.apiOptions)
: request === 'change'
? fetchClient.post(`${url}/${id}`, mutVal, remote?.apiOptions)
: request === 'delete'
? fetchClient.delete(`${url}/${id}`, remote?.apiOptions)
: Promise.resolve(null)
},
onSettled: (response: FetchResponse | null) => {
qc?.invalidateQueries({ queryKey: [remote?.tableName] })
if (request !== 'delete' && response) {
if (onResetForm) {
onResetForm(response?.data, form).then(() => {
form.reset(response?.data, { keepDirty: false })
})
} else {
form.reset(response?.data, { keepDirty: false })
}
}
gridRef?.current?.refresh?.()
// @ts-ignore
gridRef?.current?.selectRow?.(response?.data?.[remote?.primaryKey ?? ''])
},
...useMutationOptions,
})
useEffect(() => {
if (isSuccess && status === 'success' && enabled && !isFetching) {
if (!Object.keys(data?.data ?? {}).includes(remote?.primaryKey ?? '')) {
throw new Error('Primary key not found in remote data')
}
if (onResetForm) {
onResetForm(data?.data, form).then((resetData) => {
form.reset(resetData)
})
} else {
form.reset(data?.data)
}
}
}, [isSuccess, status, enabled, isFetching])
return {
error: changeMut.error as FetchError,
isFetching: (enabled ? isFetching : false) || changeMut?.isPending,
mutateAsync: changeMut.mutateAsync,
queryKey,
}
}
export default useRemote

View File

@@ -1,48 +0,0 @@
import React, { createContext, useContext, type ReactNode } from 'react'
import { create } from 'zustand'
import type { RequestType } from '../types'
interface FormLayoutState {
request: RequestType
loading: boolean
dirty: boolean
onCancel?: () => void
onSubmit?: () => void
setState: (key: string, value: any) => void
}
const createFormLayoutStore = (initialProps: any) =>
create<FormLayoutState>((set) => ({
request: initialProps.request || 'insert',
loading: initialProps.loading || false,
dirty: initialProps.dirty || false,
onCancel: initialProps.onCancel,
onSubmit: initialProps.onSubmit,
setState: (key, value) => set({ [key]: value }),
}))
const FormLayoutStoreContext = createContext<ReturnType<typeof createFormLayoutStore> | null>(null)
export const FormLayoutStoreProvider: React.FC<{ children: ReactNode; [key: string]: any }> = ({
children,
...props
}) => {
const storeRef = React.useRef<ReturnType<typeof createFormLayoutStore>>()
if (!storeRef.current) {
storeRef.current = createFormLayoutStore(props)
}
return (
<FormLayoutStoreContext.Provider value={storeRef.current}>
{children}
</FormLayoutStoreContext.Provider>
)
}
export const useFormLayoutStore = <T,>(selector: (state: FormLayoutState) => T): T => {
const store = useContext(FormLayoutStoreContext)
if (!store) {
throw new Error('useFormLayoutStore must be used within FormLayoutStoreProvider')
}
return store(selector)
}

View File

@@ -1,22 +0,0 @@
import type { SuperFormProviderProps } from '../types'
import { createSyncStore } from '@warkypublic/zustandsyncstore'
const { Provider, useStore } = createSyncStore<any, SuperFormProviderProps>((set) => ({
request: 'insert',
setRequest: (request) => {
set({ request })
},
value: undefined,
setValue: (value) => {
set({ value })
},
noCloseOnSubmit: false,
setNoCloseOnSubmit: (noCloseOnSubmit) => {
set({ noCloseOnSubmit })
},
}))
export { Provider, useStore }
export const useSuperFormStore = useStore

View File

@@ -1,10 +0,0 @@
.disabled {
pointer-events: none;
opacity: 0.9;
}
.sticky {
position: -webkit-sticky;
position: sticky;
bottom: 0;
}

View File

@@ -1,135 +0,0 @@
import type { UseMutationOptions, UseMutationResult, UseQueryOptions } from '@tanstack/react-query'
import type { FieldValues, UseFormProps, UseFormReturn, UseFormStateReturn } from 'react-hook-form'
import type { ModalProps, PaperProps, PopoverProps, DrawerProps } from '@mantine/core'
import type { RemoteConfig } from './remote.types'
export type RequestType = 'insert' | 'change' | 'view' | 'select' | 'delete' | 'get' | 'set'
// Grid integration types (simplified - removes BTGlideRef dependency)
export interface GridRef<T = any> {
refresh?: () => void
selectRow?: (id: any) => void
}
export interface FormSectionBodyProps {
// Add properties as needed from original FormLayout
[key: string]: any
}
export interface FormSectionFooterProps {
// Add properties as needed from original FormLayout
[key: string]: any
}
export interface BodyRightSection<T extends FieldValues> {
opened?: boolean
setOpened?: (opened: boolean) => void
w: number | string
hideToggleButton?: boolean
paperProps?: PaperProps
render: (props: {
form: UseFormReturn<any, any, undefined>
formValue: T
isFetching: boolean
opened: boolean
queryKey: any
setOpened: (opened: boolean) => void
}) => React.ReactNode
}
export interface SuperFormLayoutProps<T extends FieldValues> {
buttonTitles?: { submit?: string; cancel?: string }
extraButtons?: React.ReactNode | ((form: UseFormReturn<any, any, undefined>) => React.ReactNode)
noFooter?: boolean
noHeader?: boolean
noLayout?: boolean
bodySectionProps?: Partial<FormSectionBodyProps>
footerSectionProps?:
| Partial<FormSectionFooterProps>
| ((ref: React.RefObject<SuperFormRef<T>>) => Partial<FormSectionFooterProps>)
rightSection?: React.ReactNode
bodyRightSection?: BodyRightSection<T>
title?: string
showErrorList?: boolean
}
export interface CommonFormProps<T extends FieldValues> {
gridRef?: React.MutableRefObject<GridRef<any> | null>
layoutProps?: SuperFormLayoutProps<T>
meta?: { [key: string]: any }
nested?: boolean
onCancel?: (request: RequestType) => void
onLayoutMounted?: () => void
onLayoutUnMounted?: () => void
onResetForm?: (data: T, form?: UseFormReturn<any, any, undefined>) => Promise<T>
onBeforeSubmit?: (
data: T,
request: RequestType,
form?: UseFormReturn<any, any, undefined>
) => Promise<T>
onSubmit?: (
data: T,
request: RequestType,
formData?: T,
form?: UseFormReturn<any, any, undefined>,
closeForm?: boolean
) => void
primeData?: any
readonly?: boolean
remote?: RemoteConfig
request: RequestType
persist?: boolean | { storageKey?: string }
useMutationOptions?: UseMutationOptions<any, Error, T, unknown>
useQueryOptions?: Partial<UseQueryOptions<any, Error, T>>
value?: T | null
}
export interface SuperFormProps<T extends FieldValues> extends CommonFormProps<T> {
children: React.ReactNode | ((props: UseFormReturn<T, any, undefined>) => React.ReactNode)
useFormProps?: UseFormProps<T>
}
export interface SuperFormProviderProps extends Omit<SuperFormProps<any>, 'children'> {
children?: React.ReactNode
}
export interface SuperFormModalProps<T extends FieldValues> extends SuperFormProps<T> {
modalProps: ModalProps
noCloseOnSubmit?: boolean
}
export interface SuperFormPopoverProps<T extends FieldValues> extends SuperFormProps<T> {
popoverProps?: Omit<PopoverProps, 'children'>
target: any
noCloseOnSubmit?: boolean
}
export interface ExtendedDrawerProps extends DrawerProps {
// Add any extended drawer props needed
[key: string]: any
}
export interface SuperFormDrawerProps<T extends FieldValues> extends SuperFormProps<T> {
drawerProps: ExtendedDrawerProps
noCloseOnSubmit?: boolean
}
export interface SuperFormRef<T extends FieldValues> {
form: UseFormReturn<T, any, undefined>
mutation: Partial<UseMutationResult<any, Error, any, unknown>>
submit: (closeForm?: boolean, afterSubmit?: (data: T | any) => void) => Promise<void>
queryKey?: any
getFormState: () => UseFormStateReturn<T>
}
export interface SuperFormDrawerRef<T extends FieldValues> extends SuperFormRef<T> {
drawer: HTMLDivElement | null
}
export interface SuperFormModalRef<T extends FieldValues> extends SuperFormRef<T> {
modal: HTMLDivElement | null
}
export interface SuperFormPopoverRef<T extends FieldValues> extends SuperFormRef<T> {
popover: HTMLDivElement | null
}

View File

@@ -1,2 +0,0 @@
export * from './form.types'
export * from './remote.types'

View File

@@ -1,11 +0,0 @@
export interface RemoteConfig {
apiOptions?: RequestInit
apiURL?: string
enabled?: boolean
fetchSize?: number
hotFields?: string[]
primaryKey?: string
sqlFilter?: string
tableName: string
uniqueKeys?: string[]
}

View File

@@ -1,161 +0,0 @@
export interface FetchOptions extends RequestInit {
params?: Record<string, any>
timeout?: number
}
export interface FetchResponse<T = any> {
data: T
status: number
statusText: string
ok: boolean
error?: string
}
export class FetchError extends Error {
constructor(
public message: string,
public status?: number,
public response?: any
) {
super(message)
this.name = 'FetchError'
}
}
/**
* Fetch wrapper with timeout support and axios-like interface
*/
async function fetchWithTimeout(
url: string,
options: FetchOptions = {}
): Promise<Response> {
const { timeout = 30000, ...fetchOptions } = options
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
})
clearTimeout(timeoutId)
return response
} catch (error) {
clearTimeout(timeoutId)
throw error
}
}
/**
* GET request
*/
export async function get<T = any>(
url: string,
options?: FetchOptions
): Promise<FetchResponse<T>> {
try {
const response = await fetchWithTimeout(url, {
...options,
method: 'GET',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
const data = await response.json()
return {
data,
status: response.status,
statusText: response.statusText,
ok: response.ok,
error: response.ok ? undefined : data?.message || data?.error || response.statusText,
}
} catch (error) {
throw new FetchError(
error instanceof Error ? error.message : 'Network request failed',
undefined,
error
)
}
}
/**
* POST request
*/
export async function post<T = any>(
url: string,
data?: any,
options?: FetchOptions
): Promise<FetchResponse<T>> {
try {
const response = await fetchWithTimeout(url, {
...options,
method: 'POST',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
body: JSON.stringify(data),
})
const responseData = await response.json()
return {
data: responseData,
status: response.status,
statusText: response.statusText,
ok: response.ok,
error: response.ok ? undefined : responseData?.message || responseData?.error || response.statusText,
}
} catch (error) {
throw new FetchError(
error instanceof Error ? error.message : 'Network request failed',
undefined,
error
)
}
}
/**
* DELETE request
*/
export async function del<T = any>(
url: string,
options?: FetchOptions
): Promise<FetchResponse<T>> {
try {
const response = await fetchWithTimeout(url, {
...options,
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
})
const data = await response.json().catch(() => ({}))
return {
data,
status: response.status,
statusText: response.statusText,
ok: response.ok,
error: response.ok ? undefined : data?.message || data?.error || response.statusText,
}
} catch (error) {
throw new FetchError(
error instanceof Error ? error.message : 'Network request failed',
undefined,
error
)
}
}
export const fetchClient = {
get,
post,
delete: del,
}

View File

@@ -1,9 +0,0 @@
/**
* Retrieves a nested value from an object using dot notation path
* @param path - Dot-separated path (e.g., "user.address.city")
* @param obj - Object to extract value from
* @returns The value at the specified path, or undefined if not found
*/
export const getNestedValue = (path: string, obj: any): any => {
return path.split('.').reduce((prev, curr) => prev?.[curr], obj)
}

View File

@@ -1,30 +0,0 @@
import React from 'react'
import { Stack, Text } from '@mantine/core'
import { modals } from '@mantine/modals'
export const openConfirmModal = (
onConfirm: () => void,
onCancel?: (() => void) | null,
description?: string | null
) =>
modals.openConfirmModal({
size: 'xs',
children: (
<Stack gap={4}>
<Text size='xs' c={description ? 'blue' : 'red'} fw='bold'>
You have unsaved changes in this form.
</Text>
<Text size='xs'>
{description ??
'Closing now will discard any modifications you have made. Are you sure you want to continue?'}
</Text>
</Stack>
),
labels: { confirm: description ? 'Restore' : 'Confirm', cancel: 'Cancel' },
confirmProps: { color: description ? 'blue' : 'red', size: 'compact-xs' },
cancelProps: { size: 'compact-xs' },
groupProps: { gap: 'xs' },
withCloseButton: false,
onConfirm,
onCancel,
})

View File

@@ -1,3 +1,4 @@
import { newUUID } from '@warkypublic/artemis-kit';
import { createSyncStore } from '@warkypublic/zustandsyncstore';
import { produce } from 'immer';
@@ -15,7 +16,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
load: async (reset?: boolean) => {
try {
set({ loading: true });
const keyName = get()?.apiKeyField || 'id';
const keyName = get()?.uniqueKeyField || 'id';
const keyValue = (get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName];
if (get().onAPICall && keyValue !== undefined) {
let data = await get().onAPICall!(
@@ -97,7 +98,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
}
if (get().onAPICall) {
const keyName = get()?.apiKeyField || 'id';
const keyName = get()?.uniqueKeyField || 'id';
const keyValue =
(get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName];
const savedData = await get().onAPICall!(
@@ -111,6 +112,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
}
set({ loading: false, values: savedData });
get().onChange?.(savedData);
formMethods.reset(savedData); //reset with saved data to clear dirty state
if (!keepOpen) {
get().onClose?.(savedData);
}
@@ -118,6 +120,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
}
set({ loading: false, values: data });
formMethods.reset(data); //reset with saved data to clear dirty state
get().onChange?.(data);
if (!keepOpen) {
get().onClose?.(data);
@@ -168,17 +171,38 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
},
values: undefined,
}),
({ onConfirmDelete, primeData, request, values }) => {
({ 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',
request: (request || 'insert').replace('change', 'update'),
values: { ...primeData, ...values },
};
}

View File

@@ -78,11 +78,20 @@ const FormerInner = forwardRef<FormerRef<any>, Partial<FormerProps<any>> & Props
return await validate();
},
}),
[getState, onChange]
[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 (

View File

@@ -1,37 +1,53 @@
import type { LoadingOverlayProps, ScrollAreaAutosizeProps } from '@mantine/core';
import type {
ButtonProps,
GroupProps,
LoadingOverlayProps,
ScrollAreaAutosizeProps,
} from '@mantine/core';
import type React from 'react';
import type { FieldValues, UseFormProps, UseFormReturn } from 'react-hook-form';
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;
apiKeyField?: string;
beforeSave?: (data: T) => Promise<T> | T;
dirty?: boolean;
disableHTMlForm?: boolean;
id?: string;
keepOpen?: boolean;
onAPICall?: (
mode: 'mutate' | 'read',
request: RequestType,
value?: T,
key?: number | string
) => Promise<T>;
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;
onOpen?: (data?: T) => void;
opened?: boolean;
primeData?: T;
request: RequestType;
uniqueKeyField?: string;
useFormProps?: UseFormProps<T>;
values?: T;
wrapper?: (
children: React.ReactNode,
opened: boolean | undefined,
onClose: ((data?: T) => void) | undefined,
onOpen: ((data?: T) => void) | undefined,
getState: <K extends keyof FormStateAndProps<T>>(key: K) => FormStateAndProps<T>[K]
) => React.ReactNode;
wrapper?: FormerSectionRender<T>;
}
export interface FormerRef<T extends FieldValues = any> {
@@ -44,6 +60,14 @@ export interface FormerRef<T extends FieldValues = any> {
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;

View File

@@ -0,0 +1,85 @@
import { Button, Group, Tooltip } from '@mantine/core';
import { IconDeviceFloppy, IconX } from '@tabler/icons-react';
import { useFormerStore } from './Former.store';
export const FormerButtonArea = () => {
const {
buttonAreaGroupProps,
closeButtonProps,
closeButtonTitle,
dirty,
onClose,
request,
save,
saveButtonProps,
saveButtonTitle,
} = useFormerStore((state) => ({
buttonAreaGroupProps: state.layout?.buttonAreaGroupProps,
closeButtonProps: state.layout?.closeButtonProps,
closeButtonTitle: state.layout?.closeButtonTitle,
dirty: state.dirty,
onClose: state.onClose,
request: state.request,
save: state.save,
saveButtonProps: state.layout?.saveButtonProps,
saveButtonTitle: state.layout?.saveButtonTitle,
}));
const disabledSave =
['select', 'view'].includes(request || '') || (['update'].includes(request || '') && !dirty);
return (
<Group
justify="center"
p="xs"
style={{ boxShadow: '2px 2px 5px rgba(47, 47, 47, 0.1)' }}
w="100%"
{...buttonAreaGroupProps}
>
<Group grow justify="space-evenly">
{typeof onClose === 'function' && (
<Button
color="orange"
leftSection={<IconX />}
miw={'8rem'}
px="md"
size="sm"
{...closeButtonProps}
onClick={() => {
onClose();
}}
>
{closeButtonTitle || 'Close'}
</Button>
)}
<Tooltip
label={
disabledSave ? (
<p>
Cannot save in view or select mode, or no changes made. <br />
Try changing some values.
</p>
) : (
<p>Save the current record</p>
)
}
>
<Button
bg={request === 'delete' ? 'red' : undefined}
color="green"
leftSection={<IconDeviceFloppy />}
miw={'8rem'}
px="md"
size="sm"
{...saveButtonProps}
disabled={disabledSave}
onClick={() => save()}
>
{saveButtonTitle || 'Save'}
</Button>
</Tooltip>
</Group>
</Group>
);
};

View File

@@ -2,14 +2,18 @@ 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,
@@ -17,12 +21,15 @@ export const FormerLayout = (props: PropsWithChildren) => {
} = 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,
}));
@@ -33,10 +40,11 @@ export const FormerLayout = (props: PropsWithChildren) => {
load(true);
}
}
}, [getFormMethods, request]);
}, [getFormMethods, request, opened]);
if (disableHTMlForm) {
return (
return (
<>
<FormerLayoutTop />
<ScrollAreaAutosize
offsetScrollbars
scrollbarSize={4}
@@ -44,13 +52,29 @@ export const FormerLayout = (props: PropsWithChildren) => {
{...scrollAreaProps}
style={{
height: '100%',
maxHeight: '89vh',
padding: '0.25rem',
width: '100%',
...scrollAreaProps?.style,
}}
>
{props.children}
{disableHTMlForm ? (
// eslint-disable-next-line react/no-unknown-property
<div key={`former_d${id}`} x-data-request={request}>
{props.children}
</div>
) : (
<form
id={`former_f${id}`}
key={`former_${id}`}
onReset={(e) => reset(e)}
onSubmit={(e) => save(e)}
// eslint-disable-next-line react/no-unknown-property
x-data-request={request}
>
{props.children}
</form>
)}
<LoadingOverlay
loaderProps={{ type: 'bars' }}
overlayProps={{
@@ -60,34 +84,7 @@ export const FormerLayout = (props: PropsWithChildren) => {
visible={loading}
/>
</ScrollAreaAutosize>
);
}
return (
<ScrollAreaAutosize
offsetScrollbars
scrollbarSize={4}
type="auto"
{...scrollAreaProps}
style={{
height: '100%',
maxHeight: '89vh',
padding: '0.25rem',
width: '100%',
...scrollAreaProps?.style,
}}
>
<form onReset={(e) => reset(e)} onSubmit={(e) => save(e)}>
{props.children}
<LoadingOverlay
loaderProps={{ type: 'bars' }}
overlayProps={{
backgroundOpacity: 0.5,
}}
{...loadingOverlayProps}
visible={loading}
/>
</form>
</ScrollAreaAutosize>
<FormerLayoutBottom />
</>
);
};

View File

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

View File

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

View File

@@ -0,0 +1,72 @@
import type { FormerAPICallType } from './Former.types';
interface ResolveSpecRequest {
data?: Record<string, any>;
operation: 'create' | 'delete' | 'read' | 'update';
options?: {
columns?: string[];
computedColumns?: any[];
customOperators?: any[];
filters?: Array<{ column: string; operator: string; value: any }>;
limit?: number;
offset?: number;
preload?: string[];
sort?: string[];
};
}
function FormerResolveSpecAPI(options: {
authToken: string;
fetchOptions?: Partial<RequestInit>;
signal?: AbortSignal;
url: string;
}): FormerAPICallType {
return async (mode, request, value, key) => {
const baseUrl = options.url.replace(/\/$/, '');
// Build URL: /[schema]/[table_or_entity]/[id]
let url = `${baseUrl}`;
if (request !== 'insert' && key) {
url = `${url}/${key}`;
}
// Build ResolveSpec request body
const resolveSpecRequest: ResolveSpecRequest = {
operation:
mode === 'read'
? 'read'
: request === 'delete'
? 'delete'
: request === 'update'
? 'update'
: 'create',
};
if (mode === 'mutate') {
resolveSpecRequest.data = value;
}
const fetchOptions: RequestInit = {
cache: 'no-cache',
signal: options.signal,
...options.fetchOptions,
body: JSON.stringify(resolveSpecRequest),
headers: {
Authorization: `Bearer ${options.authToken}`,
'Content-Type': 'application/json',
...options.fetchOptions?.headers,
},
method: 'POST',
};
const response = await fetch(url, fetchOptions);
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
const data = await response.json();
return data as any;
};
}
export { FormerResolveSpecAPI, type ResolveSpecRequest };

View File

@@ -0,0 +1,50 @@
import type { FormerAPICallType } from './Former.types';
function FormerRestHeadSpecAPI(options: {
authToken: string;
fetchOptions?: Partial<RequestInit>;
signal?: AbortSignal;
url: string;
}): FormerAPICallType {
return async (mode, request, value, key) => {
const baseUrl = options.url ?? ''; // Remove trailing slashes
let url = baseUrl;
const fetchOptions: RequestInit = {
cache: 'no-cache',
signal: options.signal,
...options.fetchOptions,
body: mode === 'mutate' && request !== 'delete' ? JSON.stringify(value) : undefined,
headers: {
Authorization: `Bearer ${options.authToken}`,
'Content-Type': 'application/json',
...options.fetchOptions?.headers,
},
method:
mode === 'read'
? 'GET'
: request === 'delete'
? 'DELETE'
: request === 'update'
? 'PUT'
: 'POST',
};
if (request !== 'insert') {
url = `${baseUrl}/${key}`;
}
const response = await fetch(url, fetchOptions);
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
if (mode === 'read') {
const data = await response.json();
return data as any;
} else {
return value as any;
}
};
}
export { FormerRestHeadSpecAPI };

View File

@@ -0,0 +1,116 @@
import {
Drawer,
type DrawerProps,
Modal,
type ModalProps,
Popover,
type PopoverProps,
} from '@mantine/core';
import type { FormerProps } from './Former.types';
import { Former } from './Former';
export const FormerDialog = (props: { former: FormerProps } & DrawerProps) => {
const { children, former, onClose, opened, ...rest } = props;
return (
<Former
{...former}
onClose={onClose}
opened={opened}
wrapper={(children, opened, onClose, _onOpen, getState) => {
const values = getState('values');
const request = getState('request');
const uniqueKeyField = getState('uniqueKeyField') ?? 'id';
return (
<Drawer
closeOnClickOutside={false}
h={'100%'}
title={
request === 'delete'
? `Delete Record - ${values?.[uniqueKeyField]}`
: request === 'insert'
? 'New Record'
: `Edit Record - ${values?.[uniqueKeyField]}`
}
{...rest}
onClose={() => onClose?.()}
opened={opened ?? false}
>
{children}
</Drawer>
);
}}
>
{children}
</Former>
);
};
export const FormerModel = (props: { former: FormerProps } & ModalProps) => {
const { children, former, onClose, opened, ...rest } = props;
return (
<Former
{...former}
onClose={onClose}
opened={opened}
wrapper={(children, opened, onClose, _onOpen, getState) => {
const values = getState('values');
const request = getState('request');
const uniqueKeyField = getState('uniqueKeyField') ?? 'id';
return (
<Modal
closeOnClickOutside={false}
h={'100%'}
title={
request === 'delete'
? `Delete Record - ${values?.[uniqueKeyField]}`
: request === 'insert'
? 'New Record'
: `Edit Record - ${values?.[uniqueKeyField]}`
}
{...rest}
onClose={() => onClose?.()}
opened={opened ?? false}
>
{children}
</Modal>
);
}}
>
{children}
</Former>
);
};
export const FormerPopover = (
props: { former: FormerProps; target: React.ReactNode } & PopoverProps
) => {
const { children, former, onClose, opened, target, ...rest } = props;
return (
<Former
{...former}
onClose={onClose}
opened={opened}
wrapper={(children, opened, onClose) => {
return (
<Popover
closeOnClickOutside={false}
middlewares={{ inline: true }}
trapFocus
width={250}
withArrow
{...rest}
onClose={() => onClose?.()}
opened={opened ?? false}
>
<Popover.Target>{target}</Popover.Target>
<Popover.Dropdown>{children}</Popover.Dropdown>
</Popover>
);
}}
>
{children}
</Former>
);
};

View File

@@ -0,0 +1,6 @@
export { Former } from './Former';
export type * from './Former.types';
export { FormerButtonArea } from './FormerButtonArea';
export { FormerResolveSpecAPI } from './FormerResolveSpecAPI';
export { FormerRestHeadSpecAPI } from './FormerRestHeadSpecAPI';
export { FormerDialog, FormerModel, FormerPopover } from './FormerWrappers';

View File

@@ -0,0 +1,40 @@
import { TextInput } from '@mantine/core';
import { useUncontrolled } from '@mantine/hooks';
import { Controller } from 'react-hook-form';
import { Former } from '../Former';
export const ApiFormData = (props: {
onChange?: (values: Record<string, unknown>) => void;
primeData?: Record<string, unknown>;
values?: Record<string, unknown>;
}) => {
const [values, setValues] = useUncontrolled<Record<string, unknown>>({
defaultValue: { authToken: '', url: '', ...props.primeData },
finalValue: { authToken: '', url: '', ...props.primeData },
onChange: props.onChange,
value: props.values,
});
return (
<Former
disableHTMlForm
id="api-form-data"
layout={{ saveButtonTitle: 'Save URL Parameters' }}
onChange={setValues}
primeData={props.primeData}
request="update"
uniqueKeyField="id"
values={values}
>
<Controller
name="url"
render={({ field }) => <TextInput label="URL" type="url" {...field} />}
/>
<Controller
name="authToken"
render={({ field }) => <TextInput label="Auth Token" type="password" {...field} />}
/>
</Former>
);
};

View File

@@ -1,31 +1,83 @@
import { Button, Drawer, Group, Paper, Select, Stack, Switch } from '@mantine/core';
import { Button, Group, Select, Stack, Switch } from '@mantine/core';
import { useRef, useState } from 'react';
import { Controller } from 'react-hook-form';
import type { FormerRef } from '../Former.types';
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 });
console.log('formData', formData);
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%">
<Select
data={['insert', 'update', 'delete', 'select', 'view']}
onChange={setRequest}
value={request}
/>
<Switch
checked={wrapped}
label="Wrapped in Drawer"
onChange={(event) => setWrapped(event.currentTarget.checked)}
/>
<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
@@ -46,56 +98,53 @@ export const FormTest = () => {
Test Show/Hide
</Button>
</Group>
<FormerModel former={{ request: 'insert' }} onClose={() => setOpen(false)} opened={open}>
<div>Test</div>
</FormerModel>
<Former
//wrapper={(children, getState) => <div>{children}</div>}
//opened={true}
apiKeyField="a"
onAPICall={(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);
});
}}
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}
<Button>Test Save</Button>
</Paper>
</Drawer>
);
}
: undefined
}
// 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 h="1200px">
<Stack pb={'400px'}>
<Stack>
<Controller
name="test"
@@ -106,13 +155,28 @@ export const FormTest = () => {
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>
<Stack>
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</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>
);
};

View File

@@ -1,10 +1,12 @@
- [ ] Headerspec API
- [ ] Relspec API
- [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
- [ ] Layout Tool
- [ ] Header Section
- [ ] Button Section
- [ ] Footer Section
- [x] Layout Tool
- [x] Header Section
- [x] Button Section
- [x] Footer Section
- [ ] Different Loaded for saving vs loading
- [ ] Better Confirm Dialog
- [ ] Reset Confirm Dialog

View File

@@ -0,0 +1,35 @@
import { Button, type ButtonProps, Tooltip } from '@mantine/core';
import { useState } from 'react';
import type { SpecialIDProps } from '../FormerControllers.types';
const ButtonCtrl = (
props: {
onClick?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => Promise<void>;
} & Omit<ButtonProps, 'onClick'> &
SpecialIDProps
) => {
const [loading, setLoading] = useState(false);
return (
<Tooltip label={props.tooltip ?? ''} withArrow>
<Button
loaderProps={{
type: 'bars',
}}
{...props}
loading={loading || props.loading}
onClick={(e) => {
if (props.onClick) {
setLoading(true);
props.onClick(e).finally(() => setLoading(false));
}
}}
>
{props.children}
</Button>
</Tooltip>
);
};
export { ButtonCtrl };
export default ButtonCtrl;

View File

@@ -0,0 +1,36 @@
import { ActionIcon, type ActionIconProps, Tooltip, VisuallyHidden } from '@mantine/core';
import { useState } from 'react';
import type { SpecialIDProps } from '../FormerControllers.types';
const IconButtonCtrl = (
props: {
onClick?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => Promise<void>;
} & Omit<ActionIconProps, 'onClick'> &
SpecialIDProps
) => {
const [loading, setLoading] = useState(false);
return (
<Tooltip label={props.tooltip ?? ''} withArrow>
<ActionIcon
loaderProps={{
type: 'bars',
}}
{...props}
loading={loading || props.loading}
onClick={(e) => {
if (props.onClick) {
setLoading(true);
props.onClick(e).finally(() => setLoading(false));
}
}}
>
{props.children}
<VisuallyHidden>Action Button: {props.tooltip ?? props.sid ?? ''}</VisuallyHidden>
</ActionIcon>
</Tooltip>
);
};
export { IconButtonCtrl };
export default IconButtonCtrl;

View File

@@ -0,0 +1,8 @@
import type { ControllerProps } from 'react-hook-form';
export type FormerControllersProps = Omit<ControllerProps, 'render'>;
export interface SpecialIDProps {
sid?: string;
tooltip?: string;
}

View File

@@ -0,0 +1,38 @@
.prompt {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border: 1px solid #ced4da;
border-right: 0px;
@mixin dark {
border: 1px solid #373a40;
}
}
.input {
border: 1px solid #ced4da;
flex: 1;
&:not([data-promptArea]) {
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
&[data-promptArea] {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
&[data-disabled] {
color: black;
background-color: #fff;
}
@mixin dark {
border: 1px solid #373a40;
}
}
.root {
flex: 1;
}

View File

@@ -0,0 +1,176 @@
import type { ReactNode } from 'react'
import {
Box,
Center,
Flex,
type FlexProps,
Paper,
Stack,
Title,
type TitleProps,
Tooltip,
useMantineColorScheme,
} from '@mantine/core'
import React from 'react'
import classes from './InlineWapper.module.css'
interface InlineWrapperCallbackProps extends Partial<InlineWrapperPropsOnly> {
classNames: React.CSSProperties
dataCssProps?: Record<string, any>
size: string
}
interface InlineWrapperProps extends InlineWrapperPropsOnly{
children?: ((props: InlineWrapperCallbackProps) => ReactNode) | ReactNode
}
interface InlineWrapperPropsOnly {
error?: ReactNode | string
flexProps?: FlexProps
label: ReactNode | string
labelProps?: TitleProps
promptArea?: ((props: InlineWrapperCallbackProps) => ReactNode) | ReactNode
promptWidth?: FlexProps['w']
required?: boolean
rightSection?: ((props: InlineWrapperCallbackProps) => ReactNode) | ReactNode
styles?: React.CSSProperties
tooltip?: string
value?: any
}
function InlineWrapper(props: InlineWrapperProps) {
return (
<Stack gap={0}>
<Flex
gap={0}
h={undefined}
m={0}
mb={0}
p={0}
w={undefined}
wrap='nowrap'
{...props.flexProps}
bg={'var(--input-background)'}
>
{props.promptWidth && props.promptWidth !== 0 ? <Prompt {...props} /> : null}
<div
style={{
borderRadius: 0,
flex: 10,
}}
>
{typeof props.children === 'function' ? (
props.children({ ...props, classNames: classes, size: 'xs' })
) : typeof props.children === 'object' && React.isValidElement(props.children) ? (
<props.children.type classNames={classes} size='xs' {...(typeof props.children.props === "object" ? props.children.props : {})} />
) : (
props.children
)}
</div>
{!props.rightSection ? undefined : typeof props.rightSection === 'function' ? (
props.rightSection({
...props,
classNames: classes,
size: 'xs',
})
) : typeof props.rightSection === 'object' && React.isValidElement(props.rightSection) ? (
<props.rightSection.type classNames={classes} size='xs' {...(typeof props.rightSection.props === "object" ? props.rightSection.props : {})} />
) : (
props.rightSection
)}
</Flex>
{/* <ErrorComponent {...props} /> */}
</Stack>
)
}
function isValueEmpty(inputValue: any) {
if (inputValue === null || inputValue === undefined) return true
if (typeof inputValue === 'number') {
if (inputValue === 0) return false
} else if (typeof inputValue === 'string' || inputValue === '') {
return inputValue.trim() === ''
} else if (inputValue instanceof File) {
return inputValue.size === 0
} else if (inputValue.target) {
return isValueEmpty(inputValue.target?.value)
} else if (inputValue.constructor?.name === 'Date') {
return false
}
}
function Prompt(props: Partial<InlineWrapperProps>) {
return (
<>
{props.tooltip ? (
<Tooltip label={props.tooltip}>
<PromptDetail {...props} />
</Tooltip>
) : (
<PromptDetail {...props} />
)}
</>
)
}
function PromptDetail(props: Partial<InlineWrapperProps>) {
const colors = useColors(props)
return props.promptArea ? (
<Box maw={props.promptWidth} w={'100%'}>
{!props.promptArea ? undefined : typeof props.promptArea === 'function' ? (
props.promptArea({
...props,
classNames: classes,
dataCssProps: { 'data-promptArea': true },
size: 'xs',
})
) : typeof props.rightSection === 'object' && React.isValidElement(props.promptArea) ? (
<props.promptArea.type
classNames={classes}
data-promptArea='true'
size='xs'
{...(typeof props.promptArea?.props === "object" ? props.promptArea.props : {})}
/>
) : (
props.promptArea
)}
</Box>
) : (
<Paper
bg={colors.paperColor}
className={classes.prompt}
px='md'
w={props.promptWidth}
withBorder
>
<Center h='100%' style={{ justifyContent: 'start' }} w='100%'>
<Title c={colors.titleColor} fz='xs' order={6} {...props.labelProps}>
{props.label}
{props.required && isValueEmpty(props.value) && <span style={{ color: 'red' }}>*</span>}
</Title>
</Center>
</Paper>
)
}
function useColors(props: Partial<InlineWrapperProps>) {
const { colorScheme } = useMantineColorScheme()
let titleColor = colorScheme === 'dark' ? 'dark.0' : 'gray.8'
let paperColor = colorScheme === 'dark' ? 'dark.7' : 'gray.1'
if (props.required && isValueEmpty(props.value)) {
paperColor = colorScheme === 'dark' ? '#413012e7' : 'yellow.1'
}
if (props.error) {
paperColor = colorScheme === 'dark' ? 'red.7' : 'red.0'
titleColor = colorScheme === 'dark' ? 'red.0' : 'red.9'
}
return { paperColor, titleColor }
}
export { InlineWrapper }
export type { InlineWrapperProps }

View File

@@ -0,0 +1,30 @@
import { NativeSelect, type NativeSelectProps, Tooltip } from '@mantine/core';
import { Controller } from 'react-hook-form';
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
const NativeSelectCtrl = (props: FormerControllersProps & NativeSelectProps & SpecialIDProps) => {
const { control, name, sid, tooltip, ...innerProps } = props;
return (
<Controller
control={control}
name={name}
render={({ field, formState }) => (
<Tooltip label={tooltip ?? ''} withArrow>
<NativeSelect
{...innerProps}
{...field}
disabled={formState.disabled}
id={`field_${name}_${sid ?? ''}`}
key={`field_${name}_${sid ?? ''}`}
>
{props.children}
</NativeSelect>
</Tooltip>
)}
/>
);
};
export { NativeSelectCtrl };
export default NativeSelectCtrl;

View File

@@ -0,0 +1,36 @@
import { NumberInput, type NumberInputProps, Tooltip } from '@mantine/core';
import { Controller } from 'react-hook-form';
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
const NumberInputCtrl = (props: FormerControllersProps & NumberInputProps & SpecialIDProps) => {
const { control, name, sid, tooltip, ...textProps } = props;
return (
<Controller
control={control}
name={name}
render={({ field, formState }) => (
<Tooltip label={tooltip ?? ''} withArrow>
<NumberInput
{...textProps}
{...field}
disabled={formState.disabled}
id={`field_${name}_${sid ?? ''}`}
key={`field_${name}_${sid ?? ''}`}
onChange={(num) =>
field.onChange(num !== undefined && num !== null ? Number(num) : undefined)
}
value={
field.value !== undefined && field.value !== null ? Number(field.value) : undefined
}
>
{props.children}
</NumberInput>
</Tooltip>
)}
/>
);
};
export { NumberInputCtrl };
export default NumberInputCtrl;

View File

@@ -0,0 +1,30 @@
import { PasswordInput, type PasswordInputProps, Tooltip } from '@mantine/core';
import { Controller } from 'react-hook-form';
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
const PasswordInputCtrl = (props: FormerControllersProps & PasswordInputProps & SpecialIDProps) => {
const { control, name, sid, tooltip, ...textProps } = props;
return (
<Controller
control={control}
name={name}
render={({ field, formState }) => (
<Tooltip label={tooltip ?? ''} withArrow>
<PasswordInput
{...textProps}
{...field}
disabled={formState.disabled}
id={`field_${name}_${sid ?? ''}`}
key={`field_${name}_${sid ?? ''}`}
>
{props.children}
</PasswordInput>
</Tooltip>
)}
/>
);
};
export { PasswordInputCtrl };
export default PasswordInputCtrl;

View File

@@ -0,0 +1,32 @@
import { Switch, type SwitchProps, Tooltip } from '@mantine/core';
import { Controller } from 'react-hook-form';
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
const SwitchCtrl = (props: FormerControllersProps & SpecialIDProps & SwitchProps) => {
const { control, name, sid, tooltip, ...innerProps } = props;
return (
<Controller
control={control}
name={name}
render={({ field, formState }) => (
<Tooltip label={tooltip ?? ''} withArrow>
<Switch
{...innerProps}
{...field}
checked={!!field.value}
disabled={formState.disabled}
id={`field_${name}_${sid ?? ''}`}
key={`field_${name}_${sid ?? ''}`}
onChange={(e) => {
field.onChange((e.currentTarget ?? e.target)?.checked);
}}
/>
</Tooltip>
)}
/>
);
};
export { SwitchCtrl };
export default SwitchCtrl;

View File

@@ -0,0 +1,31 @@
import { Textarea, type TextareaProps, Tooltip } from '@mantine/core';
import { Controller } from 'react-hook-form';
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
const TextAreaCtrl = (props: FormerControllersProps & SpecialIDProps & TextareaProps) => {
const { control, name, sid, tooltip, ...innerProps } = props;
return (
<Controller
control={control}
name={name}
render={({ field, formState }) => (
<Tooltip label={tooltip ?? ''} withArrow>
<Textarea
minRows={4}
{...innerProps}
{...field}
disabled={formState.disabled}
id={`field_${name}_${sid ?? ''}`}
key={`field_${name}_${sid ?? ''}`}
>
{props.children}
</Textarea>
</Tooltip>
)}
/>
);
};
export { TextAreaCtrl };
export default TextAreaCtrl;

View File

@@ -0,0 +1,30 @@
import { TextInput, type TextInputProps, Tooltip } from '@mantine/core';
import { Controller } from 'react-hook-form';
import type { FormerControllersProps, SpecialIDProps } from '../FormerControllers.types';
const TextInputCtrl = (props: FormerControllersProps & SpecialIDProps & TextInputProps) => {
const { control, name, sid, tooltip, ...textProps } = props;
return (
<Controller
control={control}
name={name}
render={({ field, formState }) => (
<Tooltip label={tooltip ?? ''} withArrow>
<TextInput
{...textProps}
{...field}
disabled={formState.disabled}
id={`field_${name}_${sid ?? ''}`}
key={`field_${name}_${sid ?? ''}`}
>
{props.children}
</TextInput>
</Tooltip>
)}
/>
);
};
export { TextInputCtrl };
export default TextInputCtrl;

View File

@@ -0,0 +1,7 @@
export { ButtonCtrl } from './Buttons/ButtonCtrl';
export { IconButtonCtrl } from './Buttons/IconButtonCtrl';
export { NativeSelectCtrl } from './Inputs/NativeSelectCtrl';
export { PasswordInputCtrl } from './Inputs/PasswordInputCtrl';
export { SwitchCtrl } from './Inputs/SwitchCtrl';
export { TextAreaCtrl } from './Inputs/TextAreaCtrl';
export { TextInputCtrl } from './Inputs/TextInputCtrl';

View File

@@ -0,0 +1,50 @@
//@ts-nocheck
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Stack } from '@mantine/core';
import { fn } from 'storybook/test';
import { Former, NativeSelectCtrl, TextInputCtrl } from '../../lib';
import { InlineWrapper } from '../Inputs/InlineWrapper';
import NumberInputCtrl from '../Inputs/NumberInputCtrl';
const Renderable = () => {
return (
<Former>
<Stack h="100%" mih="400px" miw="400px" w="100%">
<TextInputCtrl label="Test" name="test" />
<NumberInputCtrl label="AgeTest" name="age" />
<InlineWrapper label="Select One" promptWidth={200}>
<NativeSelectCtrl data={["One","Two","Three"]} name="option1"/>
</InlineWrapper>
</Stack>
</Former>
);
};
const meta = {
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
args: { onClick: fn() },
// More on argTypes: https://storybook.js.org/docs/api/argtypes
argTypes: {
backgroundColor: { control: 'color' },
},
component: Renderable,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
//layout: 'centered',
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
title: 'Former/Controls Basic',
} satisfies Meta<typeof Renderable>;
export default meta;
type Story = StoryObj<typeof meta>;
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const BasicExample: Story = {
args: {
label: 'Test',
},
};

View File

@@ -25,9 +25,10 @@ export const Computer = React.memo(() => {
scrollToRowKey,
searchStr,
selectedRowKey,
selectFirstRowOnMount,
setState,
setStateFN,
values,
values
} = useGridlerStore((s) => ({
_glideref: s._glideref,
_gridSelectionRows: s._gridSelectionRows,
@@ -44,6 +45,7 @@ export const Computer = React.memo(() => {
scrollToRowKey: s.scrollToRowKey,
searchStr: s.searchStr,
selectedRowKey: s.selectedRowKey,
selectFirstRowOnMount:s.selectFirstRowOnMount,
setState: s.setState,
setStateFN: s.setStateFN,
uniqueid: s.uniqueid,
@@ -268,18 +270,25 @@ export const Computer = React.memo(() => {
//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];
const firstRow = firstBuffer?.[keyField] ?? -1;
const currentValues = getState('values') ?? [];
if (
@@ -290,6 +299,7 @@ export const Computer = React.memo(() => {
) {
const values = [firstBuffer, ...(currentValues as Array<Record<string, unknown>>)];
const onChange = getState('onChange');
//console.log('Selecting first row:', firstRow, firstBuffer, values);
if (onChange) {
@@ -308,7 +318,7 @@ export const Computer = React.memo(() => {
return () => {
_events?.removeEventListener('loadPage', loadPage);
};
}, [ready]);
}, [ready, selectFirstRowOnMount]);
/// logic to apply the selected row.
// useEffect(() => {

View File

@@ -162,6 +162,7 @@ export interface GridlerState {
hasLocalData: boolean;
isEmpty: boolean;
isValuesInPages: () => boolean
loadingData?: boolean;
loadPage: (page: number, clearMode?: 'all' | 'page') => Promise<void>;
mounted: boolean;
@@ -180,6 +181,7 @@ export interface GridlerState {
onHeaderClicked: (colIndex: number, event: HeaderClickedEventArgs) => void;
onHeaderMenuClick: (col: number, screenPosition: Rectangle) => void;
onItemHovered: (args: GridMouseEventArgs) => void;
onVisibleRegionChanged: (
r: Rectangle,
tx: number,
@@ -189,18 +191,18 @@ export interface GridlerState {
freezeRegions?: readonly Rectangle[];
selected?: Item;
}
) => void;
pageSize: number;
ready: boolean;
refreshCells: (fromRow?: number, toRow?: number, col?: number) => void;
reload?: () => Promise<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,
@@ -378,6 +380,31 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
},
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();
@@ -511,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];
@@ -520,7 +548,6 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
}
return true;
},
onColumnResize: (
column: GridColumn,
newSize: number,
@@ -922,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]);

View File

@@ -36,6 +36,7 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
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 });
@@ -113,6 +114,12 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
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',
@@ -223,7 +230,7 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
ops.push({
name: 'sql_filter',
type: 'custom-sql-w',
value: props.filter,
value: `(${props.filter})`,
});
}
@@ -265,8 +272,12 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
useEffect(() => {
setState('useAPIQuery', useAPIQuery);
setState('askAPIRowNumber', askAPIRowNumber);
const isValuesInPages = getState('isValuesInPages');
const _refresh = getState('_refresh');
if (!isValuesInPages) {
setState('values', []);
}
//Reset the loaded pages to new rules
_refresh?.().then(() => {
@@ -282,6 +293,8 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
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);

View File

@@ -59,8 +59,8 @@ export function GlidlerFormAdaptor(props: {
storeState: GridlerState,
row?: Record<string, unknown>,
col?: GridlerColumn,
defaultItems?: Array<unknown>
) => {
defaultItems?: MantineBetterMenuInstanceItem[]
): MantineBetterMenuInstanceItem[] => {
//console.log('GlidlerFormInterface getMenuItems', id);
if (id === 'header-menu') {

View File

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

View File

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

View File

@@ -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",
]
],
}

View File

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