@jmruthers/pace-core 0.5.106 → 0.5.108
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.
- package/dist/{DataTable-BE0OXZKQ.d.ts → DataTable-D5cBRca8.d.ts} +1 -1
- package/dist/{DataTable-LWHFLTEW.js → DataTable-WFCHVWTY.js} +3 -3
- package/dist/{PublicLoadingSpinner-48ewSMKK.d.ts → PublicLoadingSpinner-DgDWTFqn.d.ts} +4 -2
- package/dist/{chunk-QPCAGLUS.js → chunk-4OX5PXHX.js} +5 -2
- package/dist/chunk-4OX5PXHX.js.map +1 -0
- package/dist/{chunk-IBZBNBTE.js → chunk-B3QX32P5.js} +177 -54
- package/dist/chunk-B3QX32P5.js.map +1 -0
- package/dist/{chunk-75G3NZWN.js → chunk-IMZGJ2X7.js} +373 -95
- package/dist/chunk-IMZGJ2X7.js.map +1 -0
- package/dist/{chunk-4BWGRQBG.js → chunk-NFPV7MRN.js} +22 -2
- package/dist/chunk-NFPV7MRN.js.map +1 -0
- package/dist/components.d.ts +4 -4
- package/dist/components.js +3 -3
- package/dist/{formatting-BfDeV-ja.d.ts → formatting-BiEv5oEk.d.ts} +32 -2
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/{types-BDg1mAGG.d.ts → types-D4TVpDa1.d.ts} +24 -1
- package/dist/{useToast-Bm6TnSK-.d.ts → useToast-DRah6K-g.d.ts} +5 -2
- package/dist/utils.d.ts +3 -3
- package/dist/utils.js +2 -2
- package/docs/api/classes/ColumnFactory.md +1 -1
- package/docs/api/classes/ErrorBoundary.md +1 -1
- package/docs/api/classes/InvalidScopeError.md +1 -1
- package/docs/api/classes/MissingUserContextError.md +1 -1
- package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
- package/docs/api/classes/PermissionDeniedError.md +1 -1
- package/docs/api/classes/PublicErrorBoundary.md +1 -1
- package/docs/api/classes/RBACAuditManager.md +1 -1
- package/docs/api/classes/RBACCache.md +1 -1
- package/docs/api/classes/RBACEngine.md +1 -1
- package/docs/api/classes/RBACError.md +1 -1
- package/docs/api/classes/RBACNotInitializedError.md +1 -1
- package/docs/api/classes/SecureSupabaseClient.md +1 -1
- package/docs/api/classes/StorageUtils.md +1 -1
- package/docs/api/enums/FileCategory.md +1 -1
- package/docs/api/interfaces/AggregateConfig.md +4 -4
- package/docs/api/interfaces/ButtonProps.md +1 -1
- package/docs/api/interfaces/CardProps.md +1 -1
- package/docs/api/interfaces/ColorPalette.md +1 -1
- package/docs/api/interfaces/ColorShade.md +1 -1
- package/docs/api/interfaces/DataAccessRecord.md +1 -1
- package/docs/api/interfaces/DataRecord.md +1 -1
- package/docs/api/interfaces/DataTableAction.md +18 -18
- package/docs/api/interfaces/DataTableColumn.md +115 -10
- package/docs/api/interfaces/DataTableProps.md +38 -38
- package/docs/api/interfaces/DataTableToolbarButton.md +7 -7
- package/docs/api/interfaces/EmptyStateConfig.md +5 -5
- package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
- package/docs/api/interfaces/FileDisplayProps.md +1 -1
- package/docs/api/interfaces/FileMetadata.md +1 -1
- package/docs/api/interfaces/FileReference.md +1 -1
- package/docs/api/interfaces/FileSizeLimits.md +1 -1
- package/docs/api/interfaces/FileUploadOptions.md +1 -1
- package/docs/api/interfaces/FileUploadProps.md +1 -1
- package/docs/api/interfaces/FooterProps.md +1 -1
- package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
- package/docs/api/interfaces/InputProps.md +1 -1
- package/docs/api/interfaces/LabelProps.md +1 -1
- package/docs/api/interfaces/LoginFormProps.md +1 -1
- package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
- package/docs/api/interfaces/NavigationContextType.md +1 -1
- package/docs/api/interfaces/NavigationGuardProps.md +1 -1
- package/docs/api/interfaces/NavigationItem.md +1 -1
- package/docs/api/interfaces/NavigationMenuProps.md +1 -1
- package/docs/api/interfaces/NavigationProviderProps.md +1 -1
- package/docs/api/interfaces/Organisation.md +1 -1
- package/docs/api/interfaces/OrganisationContextType.md +1 -1
- package/docs/api/interfaces/OrganisationMembership.md +1 -1
- package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
- package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
- package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
- package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
- package/docs/api/interfaces/PageAccessRecord.md +1 -1
- package/docs/api/interfaces/PagePermissionContextType.md +1 -1
- package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
- package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
- package/docs/api/interfaces/PaletteData.md +1 -1
- package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
- package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
- package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
- package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
- package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
- package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
- package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
- package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
- package/docs/api/interfaces/RBACConfig.md +1 -1
- package/docs/api/interfaces/RBACLogger.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
- package/docs/api/interfaces/RouteAccessRecord.md +1 -1
- package/docs/api/interfaces/RouteConfig.md +1 -1
- package/docs/api/interfaces/SecureDataContextType.md +1 -1
- package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
- package/docs/api/interfaces/StorageConfig.md +1 -1
- package/docs/api/interfaces/StorageFileInfo.md +1 -1
- package/docs/api/interfaces/StorageFileMetadata.md +1 -1
- package/docs/api/interfaces/StorageListOptions.md +1 -1
- package/docs/api/interfaces/StorageListResult.md +1 -1
- package/docs/api/interfaces/StorageUploadOptions.md +1 -1
- package/docs/api/interfaces/StorageUploadResult.md +1 -1
- package/docs/api/interfaces/StorageUrlOptions.md +1 -1
- package/docs/api/interfaces/StyleImport.md +1 -1
- package/docs/api/interfaces/SwitchProps.md +1 -1
- package/docs/api/interfaces/ToastActionElement.md +1 -1
- package/docs/api/interfaces/ToastProps.md +1 -1
- package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
- package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
- package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
- package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
- package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
- package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
- package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
- package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
- package/docs/api/interfaces/UserEventAccess.md +1 -1
- package/docs/api/interfaces/UserMenuProps.md +1 -1
- package/docs/api/interfaces/UserProfile.md +1 -1
- package/docs/api/modules.md +42 -19
- package/docs/api-reference/utilities.md +26 -3
- package/docs/implementation-guides/data-tables.md +390 -0
- package/package.json +1 -1
- package/src/components/DataTable/DataTable.tsx +4 -0
- package/src/components/DataTable/__tests__/DataTableCore.test.tsx +25 -10
- package/src/components/DataTable/components/ColumnFilter.tsx +2 -1
- package/src/components/DataTable/components/EditableRow.tsx +179 -16
- package/src/components/DataTable/components/FilterRow.tsx +22 -11
- package/src/components/DataTable/components/PaginationControls.tsx +1 -1
- package/src/components/DataTable/components/UnifiedTableBody.tsx +231 -32
- package/src/components/DataTable/types.ts +34 -4
- package/src/components/FileDisplay/FileDisplay.test.tsx +184 -201
- package/src/components/FileDisplay/FileDisplay.tsx +40 -39
- package/src/components/NavigationMenu/NavigationMenu.test.tsx +189 -13
- package/src/components/NavigationMenu/NavigationMenu.tsx +142 -35
- package/src/components/PaceAppLayout/PaceAppLayout.tsx +79 -10
- package/src/components/PublicLayout/__tests__/PublicPageHeader.test.tsx +4 -4
- package/src/components/Toast/Toast.tsx +1 -1
- package/src/hooks/useEventTheme.test.ts +11 -0
- package/src/hooks/useSecureDataAccess.test.ts +22 -5
- package/src/hooks/useToast.ts +11 -2
- package/src/providers/UnifiedAuthProvider.smoke.test.tsx +67 -3
- package/src/providers/__tests__/ProviderLifecycle.test.tsx +72 -4
- package/src/services/__tests__/OrganisationService.pagination.test.ts +10 -2
- package/src/styles/core.css +11 -0
- package/src/utils/__tests__/formatting.unit.test.ts +33 -0
- package/src/utils/file-reference.test.ts +44 -5
- package/src/utils/formatting.ts +57 -2
- package/src/validation/__tests__/passwordSchema.unit.test.ts +3 -3
- package/dist/chunk-4BWGRQBG.js.map +0 -1
- package/dist/chunk-75G3NZWN.js.map +0 -1
- package/dist/chunk-IBZBNBTE.js.map +0 -1
- package/dist/chunk-QPCAGLUS.js.map +0 -1
- /package/dist/{DataTable-LWHFLTEW.js.map → DataTable-WFCHVWTY.js.map} +0 -0
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
SelectItem,
|
|
11
11
|
SelectTrigger,
|
|
12
12
|
SelectValue,
|
|
13
|
+
SelectGroup,
|
|
14
|
+
SelectLabel,
|
|
15
|
+
SelectSeparator,
|
|
13
16
|
} from '../../Select/Select';
|
|
14
17
|
import type { CellValue, DataRecord, DataTableAction, EditableColumnDef } from '../types';
|
|
15
18
|
|
|
@@ -25,6 +28,170 @@ interface EditableRowProps<TData extends DataRecord> {
|
|
|
25
28
|
hierarchical?: boolean;
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
// Component for select fields with searchable and creatable support
|
|
32
|
+
function SelectEditField<TData extends DataRecord>({
|
|
33
|
+
columnDef,
|
|
34
|
+
accessorKey,
|
|
35
|
+
currentValue,
|
|
36
|
+
placeholder,
|
|
37
|
+
onChange,
|
|
38
|
+
className,
|
|
39
|
+
}: {
|
|
40
|
+
columnDef: EditableColumnDef<TData>;
|
|
41
|
+
accessorKey: string;
|
|
42
|
+
currentValue: CellValue;
|
|
43
|
+
placeholder?: string;
|
|
44
|
+
onChange: (value: CellValue) => void;
|
|
45
|
+
className?: string;
|
|
46
|
+
}) {
|
|
47
|
+
// Determine if searchable - explicitly check for true to ensure visible search input appears
|
|
48
|
+
// When selectSearchable is true or undefined, show the visible search input box
|
|
49
|
+
// When selectSearchable is false, hide the search input (type-to-search still works via SelectContent internals)
|
|
50
|
+
const isSearchable = columnDef.selectSearchable !== false;
|
|
51
|
+
const isCreatable = columnDef.creatable === true;
|
|
52
|
+
const selectRef = React.useRef<HTMLFormElement>(null);
|
|
53
|
+
const [searchTerm, setSearchTerm] = React.useState('');
|
|
54
|
+
const [isOpen, setIsOpen] = React.useState(false);
|
|
55
|
+
const [showCreateOption, setShowCreateOption] = React.useState(false);
|
|
56
|
+
|
|
57
|
+
// Monitor search input value via DOM events to detect when user types
|
|
58
|
+
React.useEffect(() => {
|
|
59
|
+
if (!isOpen || !isSearchable || !isCreatable || !selectRef.current) return;
|
|
60
|
+
|
|
61
|
+
const searchInput = selectRef.current.querySelector<HTMLInputElement>('[data-testid="select-search-input"]');
|
|
62
|
+
if (!searchInput) return;
|
|
63
|
+
|
|
64
|
+
const handleInput = (e: Event) => {
|
|
65
|
+
const target = e.target as HTMLInputElement;
|
|
66
|
+
const currentSearch = target.value;
|
|
67
|
+
setSearchTerm(currentSearch);
|
|
68
|
+
|
|
69
|
+
// Check if search doesn't match any option (including items in groups)
|
|
70
|
+
if (currentSearch.trim()) {
|
|
71
|
+
const searchLower = currentSearch.toLowerCase().trim();
|
|
72
|
+
|
|
73
|
+
// Helper to check if an option matches
|
|
74
|
+
// Use explicit union type instead of typeof to avoid Babel parsing issues
|
|
75
|
+
type FieldOption =
|
|
76
|
+
| { value: string | number; label: string }
|
|
77
|
+
| { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> }
|
|
78
|
+
| { type: 'separator' };
|
|
79
|
+
|
|
80
|
+
const checkMatch = (opt: FieldOption): boolean => {
|
|
81
|
+
// Simple option
|
|
82
|
+
if ('value' in opt && !('type' in opt)) {
|
|
83
|
+
return opt.label.toLowerCase().includes(searchLower);
|
|
84
|
+
}
|
|
85
|
+
// Group - check items within the group
|
|
86
|
+
if ('type' in opt && opt.type === 'group') {
|
|
87
|
+
return (opt as { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> }).items.some((item: { value: string | number; label: string }) => item.label.toLowerCase().includes(searchLower));
|
|
88
|
+
}
|
|
89
|
+
// Separator - doesn't match
|
|
90
|
+
return false;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const hasMatch = (columnDef.fieldOptions || []).some(checkMatch);
|
|
94
|
+
setShowCreateOption(!hasMatch);
|
|
95
|
+
} else {
|
|
96
|
+
setShowCreateOption(false);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
searchInput.addEventListener('input', handleInput);
|
|
101
|
+
|
|
102
|
+
return () => {
|
|
103
|
+
searchInput.removeEventListener('input', handleInput);
|
|
104
|
+
};
|
|
105
|
+
}, [isOpen, isSearchable, isCreatable, columnDef.fieldOptions]);
|
|
106
|
+
|
|
107
|
+
const handleCreateNew = React.useCallback(async () => {
|
|
108
|
+
if (!isCreatable || !columnDef.onCreateNew || !searchTerm.trim()) return;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const newValue = await columnDef.onCreateNew(searchTerm.trim());
|
|
112
|
+
onChange(newValue);
|
|
113
|
+
setSearchTerm('');
|
|
114
|
+
setShowCreateOption(false);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error('Error creating new item:', error);
|
|
117
|
+
}
|
|
118
|
+
}, [isCreatable, columnDef.onCreateNew, searchTerm, onChange]);
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Select
|
|
122
|
+
ref={selectRef}
|
|
123
|
+
value={String(currentValue)}
|
|
124
|
+
onValueChange={(newValue) => {
|
|
125
|
+
if (newValue.startsWith('__create_new__')) {
|
|
126
|
+
handleCreateNew();
|
|
127
|
+
} else {
|
|
128
|
+
onChange(newValue as CellValue);
|
|
129
|
+
}
|
|
130
|
+
}}
|
|
131
|
+
onOpenChange={(open) => {
|
|
132
|
+
setIsOpen(open);
|
|
133
|
+
if (!open) {
|
|
134
|
+
setSearchTerm('');
|
|
135
|
+
setShowCreateOption(false);
|
|
136
|
+
}
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<SelectTrigger className={className || "w-full h-7"}>
|
|
140
|
+
<SelectValue placeholder={placeholder || `Select ${columnDef.header || 'option'}...`} />
|
|
141
|
+
</SelectTrigger>
|
|
142
|
+
<SelectContent
|
|
143
|
+
searchable={Boolean(isSearchable)}
|
|
144
|
+
searchPlaceholder={`Search ${columnDef.header || 'options'}...`}
|
|
145
|
+
maxHeight={columnDef.selectMaxHeight}
|
|
146
|
+
className={columnDef.selectContentClassName}
|
|
147
|
+
style={columnDef.selectContentStyle}
|
|
148
|
+
>
|
|
149
|
+
{columnDef.fieldOptions?.map((option, index) => {
|
|
150
|
+
// Simple option item
|
|
151
|
+
if ('value' in option && !('type' in option)) {
|
|
152
|
+
return (
|
|
153
|
+
<SelectItem key={`${option.value}-${index}`} value={String(option.value)}>
|
|
154
|
+
{option.label}
|
|
155
|
+
</SelectItem>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Separator
|
|
160
|
+
if ('type' in option && option.type === 'separator') {
|
|
161
|
+
return <SelectSeparator key={`separator-${index}`} />;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Group with label
|
|
165
|
+
if ('type' in option && option.type === 'group') {
|
|
166
|
+
const groupOption = option as { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> };
|
|
167
|
+
return (
|
|
168
|
+
<SelectGroup key={`group-${groupOption.label}-${index}`}>
|
|
169
|
+
<SelectLabel>{groupOption.label}</SelectLabel>
|
|
170
|
+
{groupOption.items.map((item: { value: string | number; label: string }) => (
|
|
171
|
+
<SelectItem key={`${item.value}-${index}`} value={String(item.value)}>
|
|
172
|
+
{item.label}
|
|
173
|
+
</SelectItem>
|
|
174
|
+
))}
|
|
175
|
+
</SelectGroup>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return null;
|
|
180
|
+
})}
|
|
181
|
+
{showCreateOption && isCreatable && searchTerm.trim() && (
|
|
182
|
+
<SelectItem
|
|
183
|
+
key="__create_new__"
|
|
184
|
+
value={`__create_new__${searchTerm}`}
|
|
185
|
+
className="bg-main-100 font-medium border-t border-main-200"
|
|
186
|
+
>
|
|
187
|
+
Create "{searchTerm}"
|
|
188
|
+
</SelectItem>
|
|
189
|
+
)}
|
|
190
|
+
</SelectContent>
|
|
191
|
+
</Select>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
28
195
|
const renderEditField = <TData extends DataRecord>(
|
|
29
196
|
column: Column<TData, unknown>,
|
|
30
197
|
value: CellValue,
|
|
@@ -44,21 +211,14 @@ const renderEditField = <TData extends DataRecord>(
|
|
|
44
211
|
const currentValue = editingData[accessorKey] ?? value ?? '';
|
|
45
212
|
|
|
46
213
|
return (
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
{columnDef.fieldOptions.map(option => (
|
|
56
|
-
<SelectItem key={option.value} value={String(option.value)}>
|
|
57
|
-
{option.label}
|
|
58
|
-
</SelectItem>
|
|
59
|
-
))}
|
|
60
|
-
</SelectContent>
|
|
61
|
-
</Select>
|
|
214
|
+
<SelectEditField
|
|
215
|
+
columnDef={columnDef}
|
|
216
|
+
accessorKey={accessorKey}
|
|
217
|
+
currentValue={currentValue}
|
|
218
|
+
placeholder={placeholder}
|
|
219
|
+
onChange={(newValue) => onChange({ [accessorKey]: newValue })}
|
|
220
|
+
className="w-full h-7"
|
|
221
|
+
/>
|
|
62
222
|
);
|
|
63
223
|
}
|
|
64
224
|
|
|
@@ -75,13 +235,16 @@ const renderEditField = <TData extends DataRecord>(
|
|
|
75
235
|
}
|
|
76
236
|
|
|
77
237
|
if (columnDef.fieldType === 'number') {
|
|
238
|
+
// Hide spinner arrows by default for number, currency, and percentage fields
|
|
239
|
+
// Only show spinners if explicitly set to false
|
|
240
|
+
const hideSpinners = columnDef.hideNumberSpinners !== false; // Default to true
|
|
78
241
|
return (
|
|
79
242
|
<Input
|
|
80
243
|
ref={inputRef}
|
|
81
244
|
type="number"
|
|
82
245
|
value={String(value ?? '')}
|
|
83
246
|
onChange={(e) => onChange(e.target.value as unknown as CellValue)}
|
|
84
|
-
className=
|
|
247
|
+
className={`w-full h-7 ${hideSpinners ? 'datatable-number-no-spinners' : ''}`}
|
|
85
248
|
/>
|
|
86
249
|
);
|
|
87
250
|
}
|
|
@@ -12,7 +12,7 @@ export function FilterRow<TData>({ table, visibleColumns }: FilterRowProps<TData
|
|
|
12
12
|
const { columnFilters } = getState();
|
|
13
13
|
|
|
14
14
|
// Get unique values for select filters
|
|
15
|
-
const getColumnOptions = (columnId: string) => {
|
|
15
|
+
const getColumnOptions = React.useCallback((columnId: string) => {
|
|
16
16
|
const column = table.getColumn(columnId);
|
|
17
17
|
if (!column) return [];
|
|
18
18
|
|
|
@@ -40,44 +40,55 @@ export function FilterRow<TData>({ table, visibleColumns }: FilterRowProps<TData
|
|
|
40
40
|
return Array.from(uniqueValues)
|
|
41
41
|
.sort()
|
|
42
42
|
.map((value) => ({ value, label: value }));
|
|
43
|
-
};
|
|
43
|
+
}, [table]);
|
|
44
44
|
|
|
45
45
|
// Determine filter type based on column data
|
|
46
|
-
|
|
46
|
+
// IMPORTANT: Explicit filterType always takes priority - auto-detection only runs if filterType is not set
|
|
47
|
+
const getFilterType = React.useCallback((columnId: string) => {
|
|
47
48
|
const column = table.getColumn(columnId);
|
|
48
49
|
if (!column) return 'text';
|
|
49
50
|
|
|
50
51
|
const columnDef = column.columnDef as any;
|
|
51
52
|
|
|
52
|
-
// Check if column has explicit filter type configuration
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// PRIORITY 1: Check if column has explicit filter type configuration
|
|
54
|
+
// This MUST be checked first and must respect any explicit value, including 'text'
|
|
55
|
+
// Use explicit !== undefined && !== null check to ensure 'text' is not treated as falsy
|
|
56
|
+
// This prevents auto-detection from overriding explicit filterType settings
|
|
57
|
+
const explicitFilterType = columnDef.filterType;
|
|
58
|
+
if (explicitFilterType !== undefined && explicitFilterType !== null && explicitFilterType !== '') {
|
|
59
|
+
// Explicit filterType set - return it immediately (no auto-detection)
|
|
60
|
+
// This ensures filterType: 'text' is always respected, even for columns with ≤10 unique values
|
|
61
|
+
return explicitFilterType as 'text' | 'select' | 'number' | 'date';
|
|
55
62
|
}
|
|
56
63
|
|
|
57
|
-
//
|
|
64
|
+
// Only proceed with auto-detection if filterType was NOT explicitly set
|
|
65
|
+
|
|
66
|
+
// PRIORITY 2: Auto-detect select filter if filterSelectOptions is explicitly provided
|
|
58
67
|
if (columnDef.filterSelectOptions && Array.isArray(columnDef.filterSelectOptions)) {
|
|
59
68
|
return 'select';
|
|
60
69
|
}
|
|
61
70
|
|
|
62
|
-
// Check if it's a date column
|
|
71
|
+
// PRIORITY 3: Check if it's a date column (by column ID pattern)
|
|
63
72
|
if (columnId.toLowerCase().includes('date') || columnId.toLowerCase().includes('time')) {
|
|
64
73
|
return 'date';
|
|
65
74
|
}
|
|
66
75
|
|
|
67
|
-
// Check if it's a number column
|
|
76
|
+
// PRIORITY 4: Check if it's a number column (by data type)
|
|
68
77
|
const firstValue = table.getRowModel().rows[0]?.getValue(columnId);
|
|
69
78
|
if (typeof firstValue === 'number') {
|
|
70
79
|
return 'number';
|
|
71
80
|
}
|
|
72
81
|
|
|
73
|
-
//
|
|
82
|
+
// PRIORITY 5: Auto-detect select filter if limited unique values (≤10)
|
|
83
|
+
// Only runs if filterType was NOT explicitly set (checked above)
|
|
74
84
|
const uniqueValues = getColumnOptions(columnId);
|
|
75
85
|
if (uniqueValues.length <= 10 && uniqueValues.length > 1) {
|
|
76
86
|
return 'select';
|
|
77
87
|
}
|
|
78
88
|
|
|
89
|
+
// Default to text filter
|
|
79
90
|
return 'text';
|
|
80
|
-
};
|
|
91
|
+
}, [table, getColumnOptions]);
|
|
81
92
|
|
|
82
93
|
return (
|
|
83
94
|
<tr className="border-b bg-sec-50/50">
|
|
@@ -259,7 +259,7 @@ export function EnhancedPaginationControls<TData extends DataRecord>({
|
|
|
259
259
|
max={pageCount}
|
|
260
260
|
value={jumpToPage}
|
|
261
261
|
onChange={(e) => setJumpToPage(e.target.value)}
|
|
262
|
-
className="w-16 h-6 px-2 border rounded text-xs"
|
|
262
|
+
className="w-16 h-6 px-2 border rounded text-xs datatable-number-no-spinners"
|
|
263
263
|
placeholder="1"
|
|
264
264
|
/>
|
|
265
265
|
<Button type="submit" size="sm" variant="outline" className="h-6 px-2 text-xs">
|
|
@@ -21,7 +21,7 @@ import { ActionButtons } from './ActionButtons';
|
|
|
21
21
|
import { EditableRow } from './EditableRow';
|
|
22
22
|
import { getTableCellClasses, getTableHeadClasses, getTableRowClasses } from '../styles';
|
|
23
23
|
import { Input } from '../../Input/Input';
|
|
24
|
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../Select/Select';
|
|
24
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup, SelectLabel, SelectSeparator } from '../../Select/Select';
|
|
25
25
|
import type {
|
|
26
26
|
AggregateConfig,
|
|
27
27
|
DataRecord,
|
|
@@ -120,6 +120,168 @@ interface UnifiedTableBodyProps<TData extends DataRecord> {
|
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// Component for select fields with searchable and creatable support
|
|
124
|
+
function SelectEditField<TData extends DataRecord>({
|
|
125
|
+
columnDef,
|
|
126
|
+
accessorKey,
|
|
127
|
+
currentValue,
|
|
128
|
+
placeholder,
|
|
129
|
+
onChange,
|
|
130
|
+
}: {
|
|
131
|
+
columnDef: EditableColumnDef<TData>;
|
|
132
|
+
accessorKey: string;
|
|
133
|
+
currentValue: CellValue;
|
|
134
|
+
placeholder?: string;
|
|
135
|
+
onChange: (value: CellValue) => void;
|
|
136
|
+
}) {
|
|
137
|
+
// Determine if searchable - explicitly check for true to ensure visible search input appears
|
|
138
|
+
// When selectSearchable is true or undefined, show the visible search input box
|
|
139
|
+
// When selectSearchable is false, hide the search input (type-to-search still works via SelectContent internals)
|
|
140
|
+
const isSearchable = columnDef.selectSearchable !== false;
|
|
141
|
+
const isCreatable = columnDef.creatable === true;
|
|
142
|
+
const selectRef = React.useRef<HTMLFormElement>(null);
|
|
143
|
+
const [searchTerm, setSearchTerm] = React.useState('');
|
|
144
|
+
const [isOpen, setIsOpen] = React.useState(false);
|
|
145
|
+
const [showCreateOption, setShowCreateOption] = React.useState(false);
|
|
146
|
+
|
|
147
|
+
// Monitor search input value via DOM events to detect when user types
|
|
148
|
+
React.useEffect(() => {
|
|
149
|
+
if (!isOpen || !isSearchable || !isCreatable || !selectRef.current) return;
|
|
150
|
+
|
|
151
|
+
const searchInput = selectRef.current.querySelector<HTMLInputElement>('[data-testid="select-search-input"]');
|
|
152
|
+
if (!searchInput) return;
|
|
153
|
+
|
|
154
|
+
const handleInput = (e: Event) => {
|
|
155
|
+
const target = e.target as HTMLInputElement;
|
|
156
|
+
const currentSearch = target.value;
|
|
157
|
+
setSearchTerm(currentSearch);
|
|
158
|
+
|
|
159
|
+
// Check if search doesn't match any option (including items in groups)
|
|
160
|
+
if (currentSearch.trim()) {
|
|
161
|
+
const searchLower = currentSearch.toLowerCase().trim();
|
|
162
|
+
|
|
163
|
+
// Helper to check if an option matches
|
|
164
|
+
// Use explicit union type instead of typeof to avoid Babel parsing issues
|
|
165
|
+
type FieldOption =
|
|
166
|
+
| { value: string | number; label: string }
|
|
167
|
+
| { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> }
|
|
168
|
+
| { type: 'separator' };
|
|
169
|
+
|
|
170
|
+
const checkMatch = (opt: FieldOption): boolean => {
|
|
171
|
+
// Simple option
|
|
172
|
+
if ('value' in opt && !('type' in opt)) {
|
|
173
|
+
return opt.label.toLowerCase().includes(searchLower);
|
|
174
|
+
}
|
|
175
|
+
// Group - check items within the group
|
|
176
|
+
if ('type' in opt && opt.type === 'group') {
|
|
177
|
+
return (opt as { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> }).items.some((item: { value: string | number; label: string }) => item.label.toLowerCase().includes(searchLower));
|
|
178
|
+
}
|
|
179
|
+
// Separator - doesn't match
|
|
180
|
+
return false;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const hasMatch = (columnDef.fieldOptions || []).some(checkMatch);
|
|
184
|
+
setShowCreateOption(!hasMatch);
|
|
185
|
+
} else {
|
|
186
|
+
setShowCreateOption(false);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
searchInput.addEventListener('input', handleInput);
|
|
191
|
+
|
|
192
|
+
return () => {
|
|
193
|
+
searchInput.removeEventListener('input', handleInput);
|
|
194
|
+
};
|
|
195
|
+
}, [isOpen, isSearchable, isCreatable, columnDef.fieldOptions]);
|
|
196
|
+
|
|
197
|
+
const handleCreateNew = React.useCallback(async () => {
|
|
198
|
+
if (!isCreatable || !columnDef.onCreateNew || !searchTerm.trim()) return;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const newValue = await columnDef.onCreateNew(searchTerm.trim());
|
|
202
|
+
onChange(newValue);
|
|
203
|
+
setSearchTerm('');
|
|
204
|
+
setShowCreateOption(false);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
console.error('Error creating new item:', error);
|
|
207
|
+
}
|
|
208
|
+
}, [isCreatable, columnDef.onCreateNew, searchTerm, onChange]);
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<Select
|
|
212
|
+
ref={selectRef}
|
|
213
|
+
value={String(currentValue)}
|
|
214
|
+
onValueChange={(newValue) => {
|
|
215
|
+
if (newValue.startsWith('__create_new__')) {
|
|
216
|
+
handleCreateNew();
|
|
217
|
+
} else {
|
|
218
|
+
onChange(newValue as CellValue);
|
|
219
|
+
}
|
|
220
|
+
}}
|
|
221
|
+
onOpenChange={(open) => {
|
|
222
|
+
setIsOpen(open);
|
|
223
|
+
if (!open) {
|
|
224
|
+
setSearchTerm('');
|
|
225
|
+
setShowCreateOption(false);
|
|
226
|
+
}
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
<SelectTrigger className="h-8">
|
|
230
|
+
<SelectValue placeholder={placeholder || `Select ${columnDef.header || 'option'}...`} />
|
|
231
|
+
</SelectTrigger>
|
|
232
|
+
<SelectContent
|
|
233
|
+
searchable={Boolean(isSearchable)}
|
|
234
|
+
searchPlaceholder={`Search ${columnDef.header || 'options'}...`}
|
|
235
|
+
maxHeight={columnDef.selectMaxHeight}
|
|
236
|
+
className={columnDef.selectContentClassName}
|
|
237
|
+
style={columnDef.selectContentStyle}
|
|
238
|
+
>
|
|
239
|
+
{columnDef.fieldOptions?.map((option, index) => {
|
|
240
|
+
// Simple option item
|
|
241
|
+
if ('value' in option && !('type' in option)) {
|
|
242
|
+
return (
|
|
243
|
+
<SelectItem key={`${option.value}-${index}`} value={String(option.value)}>
|
|
244
|
+
{option.label}
|
|
245
|
+
</SelectItem>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Separator
|
|
250
|
+
if ('type' in option && option.type === 'separator') {
|
|
251
|
+
return <SelectSeparator key={`separator-${index}`} />;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Group with label
|
|
255
|
+
if ('type' in option && option.type === 'group') {
|
|
256
|
+
const groupOption = option as { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> };
|
|
257
|
+
return (
|
|
258
|
+
<SelectGroup key={`group-${groupOption.label}-${index}`}>
|
|
259
|
+
<SelectLabel>{groupOption.label}</SelectLabel>
|
|
260
|
+
{groupOption.items.map((item: { value: string | number; label: string }) => (
|
|
261
|
+
<SelectItem key={`${item.value}-${index}`} value={String(item.value)}>
|
|
262
|
+
{item.label}
|
|
263
|
+
</SelectItem>
|
|
264
|
+
))}
|
|
265
|
+
</SelectGroup>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return null;
|
|
270
|
+
})}
|
|
271
|
+
{showCreateOption && isCreatable && searchTerm.trim() && (
|
|
272
|
+
<SelectItem
|
|
273
|
+
key="__create_new__"
|
|
274
|
+
value={`__create_new__${searchTerm}`}
|
|
275
|
+
className="bg-main-100 font-medium border-t border-main-200"
|
|
276
|
+
>
|
|
277
|
+
Create "{searchTerm}"
|
|
278
|
+
</SelectItem>
|
|
279
|
+
)}
|
|
280
|
+
</SelectContent>
|
|
281
|
+
</Select>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
123
285
|
// Helper function to render the appropriate input type based on column configuration
|
|
124
286
|
const renderEditField = <TData extends DataRecord>(
|
|
125
287
|
column: Column<TData, unknown>,
|
|
@@ -143,33 +305,29 @@ const renderEditField = <TData extends DataRecord>(
|
|
|
143
305
|
const currentValue = editingData[accessorKey] ?? value ?? '';
|
|
144
306
|
|
|
145
307
|
return (
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<SelectContent>
|
|
154
|
-
{columnDef.fieldOptions.map(option => (
|
|
155
|
-
<SelectItem key={option.value} value={String(option.value)}>
|
|
156
|
-
{option.label}
|
|
157
|
-
</SelectItem>
|
|
158
|
-
))}
|
|
159
|
-
</SelectContent>
|
|
160
|
-
</Select>
|
|
308
|
+
<SelectEditField
|
|
309
|
+
columnDef={columnDef}
|
|
310
|
+
accessorKey={accessorKey}
|
|
311
|
+
currentValue={currentValue}
|
|
312
|
+
placeholder={placeholder}
|
|
313
|
+
onChange={(newValue) => onChange({ [accessorKey]: newValue })}
|
|
314
|
+
/>
|
|
161
315
|
);
|
|
162
316
|
}
|
|
163
317
|
|
|
164
|
-
// Check for number type
|
|
318
|
+
// Check for number type (applies to number, currency, and percentage fields)
|
|
165
319
|
if (columnDef.fieldType === 'number') {
|
|
320
|
+
// Hide spinner arrows by default for all number-related fields
|
|
321
|
+
// Currency and percentage columns use fieldType: 'number' with formatting in cell renderer
|
|
322
|
+
// Only show spinners if explicitly set to false
|
|
323
|
+
const hideSpinners = columnDef.hideNumberSpinners !== false; // Default to true
|
|
166
324
|
return (
|
|
167
325
|
<Input
|
|
168
326
|
type="number"
|
|
169
327
|
value={String(value ?? '')}
|
|
170
328
|
onChange={(e) => onChange(e.target.value as unknown as CellValue)}
|
|
171
329
|
placeholder={placeholder || `Enter ${columnDef.header || column.id}...`}
|
|
172
|
-
className=
|
|
330
|
+
className={`h-8 ${hideSpinners ? 'datatable-number-no-spinners' : ''}`}
|
|
173
331
|
/>
|
|
174
332
|
);
|
|
175
333
|
}
|
|
@@ -709,24 +867,65 @@ export function UnifiedTableBody<TData extends Record<string, any>>({
|
|
|
709
867
|
? header.column.getIsVisible()
|
|
710
868
|
: true;
|
|
711
869
|
})
|
|
712
|
-
?.filter(header => header.column.id !== 'actions'
|
|
713
|
-
?.map((header) =>
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
870
|
+
?.filter(header => header.column.id !== 'actions') // Only exclude actions column
|
|
871
|
+
?.map((header) => {
|
|
872
|
+
// Handle select column separately - render empty checkbox cell for alignment
|
|
873
|
+
if (header.column.id === 'select') {
|
|
874
|
+
return (
|
|
875
|
+
<td
|
|
876
|
+
key={header.column.id}
|
|
877
|
+
className={getTableCellClasses({
|
|
878
|
+
isCompact: true,
|
|
879
|
+
className: "px-3 py-2"
|
|
880
|
+
})}
|
|
881
|
+
>
|
|
882
|
+
{/* Empty cell for selection checkbox to maintain alignment */}
|
|
883
|
+
</td>
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Render edit fields for data columns
|
|
888
|
+
// Determine the correct key to use for creationData
|
|
889
|
+
// Priority: editAccessorKey > accessorKey > column.id
|
|
890
|
+
const columnDef = header.column.columnDef as EditableColumnDef<TData>;
|
|
891
|
+
const dataKey = columnDef.editAccessorKey || columnDef.accessorKey || header.column.id;
|
|
892
|
+
|
|
893
|
+
// Always render a cell to maintain alignment - renderEditField always returns something
|
|
894
|
+
const editField = renderEditField(
|
|
895
|
+
header.column,
|
|
896
|
+
creationData[dataKey] ?? creationData[header.column.id] ?? '',
|
|
897
|
+
(value) => {
|
|
722
898
|
if (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
|
|
723
899
|
onCreationDataChange({ ...creationData, ...(value as Record<string, CellValue>) });
|
|
724
900
|
} else {
|
|
725
|
-
|
|
901
|
+
// Use the determined dataKey for consistent data access
|
|
902
|
+
onCreationDataChange({ ...creationData, [dataKey]: value as CellValue });
|
|
726
903
|
}
|
|
727
|
-
},
|
|
728
|
-
|
|
729
|
-
|
|
904
|
+
},
|
|
905
|
+
creationData
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
return (
|
|
909
|
+
<td
|
|
910
|
+
key={header.column.id}
|
|
911
|
+
className={getTableCellClasses({
|
|
912
|
+
isCompact: true,
|
|
913
|
+
className: "px-3 py-2"
|
|
914
|
+
})}
|
|
915
|
+
>
|
|
916
|
+
{editField || (
|
|
917
|
+
// Fallback: render a text input if renderEditField somehow returns nothing
|
|
918
|
+
<Input
|
|
919
|
+
type="text"
|
|
920
|
+
value={String(creationData[dataKey] ?? creationData[header.column.id] ?? '')}
|
|
921
|
+
onChange={(e) => onCreationDataChange({ ...creationData, [dataKey]: e.target.value as CellValue })}
|
|
922
|
+
placeholder={`Enter ${columnDef.header || header.column.id}...`}
|
|
923
|
+
className="h-8"
|
|
924
|
+
/>
|
|
925
|
+
)}
|
|
926
|
+
</td>
|
|
927
|
+
);
|
|
928
|
+
})}
|
|
730
929
|
<td
|
|
731
930
|
className={getTableCellClasses({
|
|
732
931
|
isCompact: true,
|
|
@@ -292,8 +292,26 @@ export interface DataTableColumn<TData extends DataRecord = DataRecord> extends
|
|
|
292
292
|
memoizedCell?: React.ComponentType<{ row: TData }>;
|
|
293
293
|
/** Field type for editing (text, select, date, etc.) */
|
|
294
294
|
fieldType?: 'text' | 'select' | 'date' | 'number' | 'boolean';
|
|
295
|
-
/** Options for select fields */
|
|
296
|
-
fieldOptions?: Array<
|
|
295
|
+
/** Options for select fields - can be simple items or grouped with labels and separators */
|
|
296
|
+
fieldOptions?: Array<
|
|
297
|
+
| { value: string | number; label: string } // Simple option
|
|
298
|
+
| { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> } // Group with label
|
|
299
|
+
| { type: 'separator' } // Visual separator
|
|
300
|
+
>;
|
|
301
|
+
/** Enable keyboard search/filtering in select dropdowns within editable columns (default: true). When fieldType is 'select', this controls dropdown searchability, not global search. */
|
|
302
|
+
selectSearchable?: boolean;
|
|
303
|
+
/** Enable creating new items in select dropdowns (default: false) */
|
|
304
|
+
creatable?: boolean;
|
|
305
|
+
/** Callback to create a new item when user types non-matching text in select dropdown */
|
|
306
|
+
onCreateNew?: (inputValue: string) => Promise<string | number> | string | number;
|
|
307
|
+
/** Maximum height for select dropdown content (default: "20rem") */
|
|
308
|
+
selectMaxHeight?: string;
|
|
309
|
+
/** Custom className for select content dropdown */
|
|
310
|
+
selectContentClassName?: string;
|
|
311
|
+
/** Custom style for select content dropdown */
|
|
312
|
+
selectContentStyle?: React.CSSProperties;
|
|
313
|
+
/** Hide spinner arrows on number input fields (default: true for DataTable) */
|
|
314
|
+
hideNumberSpinners?: boolean;
|
|
297
315
|
/** Filter type for column filtering (text, select, number, date) */
|
|
298
316
|
filterType?: 'text' | 'select' | 'number' | 'date';
|
|
299
317
|
/** Options for select filters (alternative to fieldOptions) */
|
|
@@ -320,8 +338,20 @@ export interface EditableColumnDef<TData extends DataRecord = DataRecord> extend
|
|
|
320
338
|
editable?: boolean;
|
|
321
339
|
/** Field type used to determine edit control */
|
|
322
340
|
fieldType?: 'text' | 'select' | 'date' | 'number' | 'boolean';
|
|
323
|
-
/** Options for select based editors */
|
|
324
|
-
fieldOptions?: Array<
|
|
341
|
+
/** Options for select based editors - can be simple items or grouped with labels and separators */
|
|
342
|
+
fieldOptions?: Array<
|
|
343
|
+
| { value: string | number; label: string } // Simple option
|
|
344
|
+
| { type: 'group'; label: string; items: Array<{ value: string | number; label: string }> } // Group with label
|
|
345
|
+
| { type: 'separator' } // Visual separator
|
|
346
|
+
>;
|
|
347
|
+
/** Enable keyboard search/filtering in select dropdowns (inherits from DataTableColumn.selectSearchable) */
|
|
348
|
+
selectSearchable?: boolean;
|
|
349
|
+
/** Enable creating new items in select dropdowns */
|
|
350
|
+
creatable?: boolean;
|
|
351
|
+
/** Callback to create a new item when user types non-matching text */
|
|
352
|
+
onCreateNew?: (inputValue: string) => Promise<string | number> | string | number;
|
|
353
|
+
/** Hide spinner arrows on number input fields */
|
|
354
|
+
hideNumberSpinners?: boolean;
|
|
325
355
|
}
|
|
326
356
|
|
|
327
357
|
/**
|