@pagamio/frontend-commons-lib 0.8.351 → 0.8.353

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,19 @@
1
1
  import * as React from 'react';
2
2
  import type { UsePagamioComboboxReturn } from '../../shared/hooks/usePagamioCombobox';
3
+ /**
4
+ * Optional in-popover action — replaces the old "+ Create New X" sentinel
5
+ * option pattern that some `<Select>` callers used to inline a "create a
6
+ * new item" affordance. Rendered as a button row pinned to the top of the
7
+ * popover, above the options list. Clicking it closes the popover and
8
+ * fires `onClick` (typically opening a quick-create modal). The handler
9
+ * is responsible for the side effect; the combobox does NOT update its
10
+ * `value` automatically.
11
+ */
12
+ export interface SearchableComboboxActionButton {
13
+ /** Label shown on the action button (e.g. "+ New category"). */
14
+ label: React.ReactNode;
15
+ onClick: () => void;
16
+ }
3
17
  export interface PagamioSearchableComboboxProps<T extends {
4
18
  id: string;
5
19
  }> {
@@ -8,6 +22,12 @@ export interface PagamioSearchableComboboxProps<T extends {
8
22
  /** Controlled selected id, or null when nothing is selected. */
9
23
  value: string | null;
10
24
  onChange: (id: string | null) => void;
25
+ /**
26
+ * Optional action button rendered at the top of the popover. Used to
27
+ * surface a "+ Create new …" affordance inside the picker itself,
28
+ * matching where the old static-select sentinel option lived.
29
+ */
30
+ actionButton?: SearchableComboboxActionButton;
11
31
  /**
12
32
  * Optional pre-resolved selected option. When set, the trigger shows its
13
33
  * label without needing to fetch via `restoreIds`. If both this and the
@@ -39,4 +59,4 @@ export interface PagamioSearchableComboboxProps<T extends {
39
59
  }
40
60
  export declare function PagamioSearchableCombobox<T extends {
41
61
  id: string;
42
- }>({ combobox, value, onChange, selectedOption, placeholder, searchPlaceholder, emptyMessage, cappedMessage, loadingMessage, disabled, required, name, getLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }: PagamioSearchableComboboxProps<T>): import("react/jsx-runtime").JSX.Element;
62
+ }>({ combobox, value, onChange, actionButton, selectedOption, placeholder, searchPlaceholder, emptyMessage, cappedMessage, loadingMessage, disabled, required, name, getLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }: PagamioSearchableComboboxProps<T>): import("react/jsx-runtime").JSX.Element;
@@ -19,7 +19,7 @@ function defaultGetLabel(option) {
19
19
  const o = option;
20
20
  return o.name ?? option.id;
21
21
  }
22
- export function PagamioSearchableCombobox({ combobox, value, onChange, selectedOption, placeholder = 'Select…', searchPlaceholder = 'Search...', emptyMessage = 'No options found', cappedMessage = 'Showing top results — refine your search.', loadingMessage = 'Loading…', disabled, required, name, getLabel = defaultGetLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }) {
22
+ export function PagamioSearchableCombobox({ combobox, value, onChange, actionButton, selectedOption, placeholder = 'Select…', searchPlaceholder = 'Search...', emptyMessage = 'No options found', cappedMessage = 'Showing top results — refine your search.', loadingMessage = 'Loading…', disabled, required, name, getLabel = defaultGetLabel, renderOption, renderSelected, className, triggerClassName, contentClassName, }) {
23
23
  const [open, setOpen] = React.useState(false);
24
24
  const inputRef = React.useRef(null);
25
25
  // Focus the search input when the popover opens — matches the Select's
@@ -52,11 +52,18 @@ export function PagamioSearchableCombobox({ combobox, value, onChange, selectedO
52
52
  : placeholder }), _jsx(ChevronDownIcon, { className: "ml-2 h-4 w-4 opacity-50 shrink-0" })] }) }), _jsxs(PopoverContent, { align: "start", sideOffset: 4,
53
53
  // Mirrors SelectContent: same shadow + animation classes, popover
54
54
  // background, popover-foreground text. Width matches trigger.
55
- className: cn('p-0 w-[var(--radix-popover-trigger-width)] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md', contentClassName), children: [_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { ref: inputRef, type: "text", placeholder: searchPlaceholder, value: combobox.search, onChange: (e) => combobox.setSearch(e.target.value), onKeyDown: (e) => e.stopPropagation(), className: "w-full px-2 py-1 text-sm border border-input rounded bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" }) }), _jsx("div", { className: "p-1 max-h-[200px] overflow-y-auto", children: combobox.isLoading && combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: loadingMessage })) : combobox.error ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-destructive", children: combobox.error.message || 'Failed to load options.' })) : combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: emptyMessage })) : (_jsxs(_Fragment, { children: [combobox.options.map((option) => {
56
- const isSelected = option.id === value;
57
- return (_jsx("button", { type: "button", role: "option", "aria-selected": isSelected, onClick: () => handleSelect(option.id), className: cn(
58
- // Mirrors SelectItem styling: padding, hover via
59
- // focus:bg-muted, selected via bg-accent.
60
- 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none text-left', 'hover:bg-muted focus:bg-muted focus:text-foreground', isSelected && 'bg-accent text-foreground'), children: renderOption ? (renderOption(option, isSelected)) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "line-clamp-1", children: getLabel(option) }), isSelected ? (_jsx("span", { className: "absolute right-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(CheckIcon, { className: "h-4 w-4" }) })) : null] })) }, option.id));
61
- }), combobox.isCapped ? (_jsx("div", { className: "mt-1 border-t px-2 py-1.5 text-xs text-muted-foreground", children: cappedMessage })) : null] })) })] })] }), name ? _jsx("input", { type: "hidden", name: name, value: value ?? '' }) : null] }));
55
+ className: cn('p-0 w-[var(--radix-popover-trigger-width)] min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md', contentClassName), children: [_jsx("div", { className: "p-2 border-b border-border", children: _jsx("input", { ref: inputRef, type: "text", placeholder: searchPlaceholder, value: combobox.search, onChange: (e) => combobox.setSearch(e.target.value), onKeyDown: (e) => e.stopPropagation(), className: "w-full px-2 py-1 text-sm border border-input rounded bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" }) }), _jsxs("div", { className: "p-1 max-h-[200px] overflow-y-auto", children: [actionButton ? (_jsx("button", { type: "button", onClick: () => {
56
+ setOpen(false);
57
+ actionButton.onClick();
58
+ }, className: cn(
59
+ // Same row geometry as an option row so the picker reads
60
+ // as one consistent list. Primary-coloured label keeps it
61
+ // visually distinct from real options.
62
+ 'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none text-left font-medium text-primary', 'hover:bg-muted focus:bg-muted'), children: actionButton.label }, "__action__")) : null, combobox.isLoading && combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: loadingMessage })) : combobox.error ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-destructive", children: combobox.error.message || 'Failed to load options.' })) : combobox.options.length === 0 ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-muted-foreground", children: emptyMessage })) : (_jsxs(_Fragment, { children: [combobox.options.map((option) => {
63
+ const isSelected = option.id === value;
64
+ return (_jsx("button", { type: "button", role: "option", "aria-selected": isSelected, onClick: () => handleSelect(option.id), className: cn(
65
+ // Mirrors SelectItem styling: padding, hover via
66
+ // focus:bg-muted, selected via bg-accent.
67
+ 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none text-left', 'hover:bg-muted focus:bg-muted focus:text-foreground', isSelected && 'bg-accent text-foreground'), children: renderOption ? (renderOption(option, isSelected)) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "line-clamp-1", children: getLabel(option) }), isSelected ? (_jsx("span", { className: "absolute right-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(CheckIcon, { className: "h-4 w-4" }) })) : null] })) }, option.id));
68
+ }), combobox.isCapped ? (_jsx("div", { className: "mt-1 border-t px-2 py-1.5 text-xs text-muted-foreground", children: cappedMessage })) : null] }))] })] })] }), name ? _jsx("input", { type: "hidden", name: name, value: value ?? '' }) : null] }));
62
69
  }
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /* eslint-disable @typescript-eslint/no-explicit-any */
3
3
  import { Dropdown, DropdownItem } from 'flowbite-react';
4
4
  import { jsPDF } from 'jspdf';
5
- import { autoTable } from 'jspdf-autotable';
5
+ import autoTable from 'jspdf-autotable';
6
6
  import { FiDownload } from 'react-icons/fi';
7
7
  import * as XLSX from 'xlsx';
8
8
  const TableDownload = ({ columns, data }) => {
@@ -47,31 +47,36 @@ const FormEngine = ({ fields, onSubmit, initialValues, layout = 'vertical', isNo
47
47
  }
48
48
  return initialValues;
49
49
  };
50
- const { control, handleSubmit, watch, formState: { errors, isSubmitting }, reset, setValue, trigger, } = useForm({ mode: 'onBlur', defaultValues: getEffectiveInitialValues() });
51
- const allFields = watch();
52
- const password = watch('password');
53
- const confirmPassword = watch('confirmPassword');
54
- //re-validate confirmPassword when password changes
50
+ const { control, handleSubmit, watch, formState: { errors, isSubmitting }, reset, setValue, trigger, getValues, } = useForm({ mode: 'onBlur', defaultValues: getEffectiveInitialValues() });
51
+ // Subscribe to field changes via the callback form of `watch` so this
52
+ // component does NOT re-render on every keystroke / select change. The
53
+ // previous `const allFields = watch()` (no args) caused the entire
54
+ // FormEngine subtree including all Radix Select popovers — to re-render
55
+ // synchronously inside the field's onChange. That race produced the
56
+ // "click twice to commit" symptom on Select inputs: the first click's
57
+ // outside-dismiss fired during the parent re-render and the popover lost
58
+ // its open state. Subscribing via callback keeps RHF state updates
59
+ // isolated to the affected Controller subtree.
55
60
  useEffect(() => {
56
- if (password && confirmPassword) {
57
- trigger('confirmPassword');
58
- }
59
- }, [password, trigger, confirmPassword]);
60
- useEffect(() => {
61
- if (getFieldValues) {
62
- getFieldValues(allFields);
63
- }
64
- }, [allFields, getFieldValues]);
65
- // Save form data when fields change (for persistence)
66
- useEffect(() => {
67
- if (persistenceKey && allFields && Object.keys(allFields).length > 0) {
68
- // Only save if there are actual field values (not just empty object)
69
- const hasValues = Object.values(allFields).some((value) => value !== undefined && value !== null && value !== '');
70
- if (hasValues) {
71
- saveFormData(allFields, true);
61
+ const subscription = watch((values, info) => {
62
+ if (info?.name === 'password') {
63
+ const cp = values?.confirmPassword;
64
+ if (values?.password && cp) {
65
+ trigger('confirmPassword');
66
+ }
72
67
  }
73
- }
74
- }, [allFields, persistenceKey, saveFormData]);
68
+ if (getFieldValues) {
69
+ getFieldValues(values);
70
+ }
71
+ if (persistenceKey) {
72
+ const hasValues = Object.values(values ?? {}).some((value) => value !== undefined && value !== null && value !== '');
73
+ if (hasValues) {
74
+ saveFormData(values, true);
75
+ }
76
+ }
77
+ });
78
+ return () => subscription.unsubscribe();
79
+ }, [watch, trigger, getFieldValues, persistenceKey, saveFormData]);
75
80
  // Expose form control methods via ref
76
81
  useEffect(() => {
77
82
  if (formRef && 'current' in formRef) {
@@ -109,11 +114,12 @@ const FormEngine = ({ fields, onSubmit, initialValues, layout = 'vertical', isNo
109
114
  const validateConfirmPassword = (value) => {
110
115
  if (!value)
111
116
  return 'Please confirm your password';
112
- if (!password)
117
+ const currentPassword = getValues('password');
118
+ if (!currentPassword)
113
119
  return 'Please enter a password first';
114
- if (password.length < 8)
120
+ if (currentPassword.length < 8)
115
121
  return 'Please enter a valid password first (minimum 8 characters)';
116
- if (value !== password)
122
+ if (value !== currentPassword)
117
123
  return 'Passwords do not match';
118
124
  return true;
119
125
  };
@@ -34,7 +34,7 @@ const SearchableComboboxInput = forwardRef(({ field, value, onChange }, ref) =>
34
34
  debounceMs: source.debounceMs,
35
35
  restoreIds,
36
36
  });
37
- return (_jsxs("div", { ref: ref, children: [field.label && (_jsx("label", { htmlFor: field.name, className: "block text-sm font-medium text-foreground mb-1", children: field.label })), _jsx(PagamioSearchableCombobox, { combobox: combobox, value: value ?? null, onChange: (id) => onChange?.(id), placeholder: field.placeholder, disabled: field.disabled, required: field.validation?.required ? true : undefined, name: field.name, getLabel: source.getLabel })] }));
37
+ return (_jsxs("div", { ref: ref, children: [field.label && (_jsx("label", { htmlFor: field.name, className: "block text-sm font-medium text-foreground mb-1", children: field.label })), _jsx(PagamioSearchableCombobox, { combobox: combobox, value: value ?? null, onChange: (id) => onChange?.(id), actionButton: field.actionButton, placeholder: field.placeholder, disabled: field.disabled, required: field.validation?.required ? true : undefined, name: field.name, getLabel: source.getLabel })] }));
38
38
  });
39
39
  SearchableComboboxInput.displayName = 'SearchableComboboxInput';
40
40
  export default SearchableComboboxInput;
@@ -215,6 +215,17 @@ export interface Field {
215
215
  * Tells the form engine how to fetch lookup results.
216
216
  */
217
217
  source?: ComboboxSourceSpec;
218
+ /**
219
+ * Optional in-picker action for `searchable-combobox`. Renders as the
220
+ * first row of the popover (above the options) — the same place a
221
+ * "+ Create new …" sentinel option used to live in the old static
222
+ * `<Select>`. Clicking it closes the popover and fires `onClick`; the
223
+ * handler owns the side effect (e.g. opens a quick-create modal).
224
+ */
225
+ actionButton?: {
226
+ label: string;
227
+ onClick: () => void;
228
+ };
218
229
  }
219
230
  export interface DependentFieldUpdate {
220
231
  field: string;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Dropdown, DropdownItem } from 'flowbite-react';
3
3
  import { jsPDF } from 'jspdf';
4
- import { autoTable } from 'jspdf-autotable';
4
+ import autoTable from 'jspdf-autotable';
5
5
  import { FiDownload } from 'react-icons/fi';
6
6
  import * as XLSX from 'xlsx';
7
7
  const TableDownload = ({ columns, data }) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pagamio/frontend-commons-lib",
3
3
  "description": "Pagamio library for Frontend reusable components like the form engine and table container",
4
- "version": "0.8.351",
4
+ "version": "0.8.353",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "provenance": false