@donotdev/ui 0.0.10 → 0.0.11
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/crud/components/EntityCardList.d.ts +16 -0
- package/dist/crud/components/EntityCardList.d.ts.map +1 -0
- package/dist/crud/components/EntityCardList.js +175 -0
- package/dist/crud/components/EntityDisplayRenderer.d.ts +13 -21
- package/dist/crud/components/EntityDisplayRenderer.d.ts.map +1 -1
- package/dist/crud/components/EntityDisplayRenderer.js +138 -23
- package/dist/crud/components/EntityFormRenderer.d.ts +18 -0
- package/dist/crud/components/EntityFormRenderer.d.ts.map +1 -0
- package/dist/crud/components/EntityFormRenderer.js +275 -0
- package/dist/crud/components/EntityList.d.ts +14 -0
- package/dist/crud/components/EntityList.d.ts.map +1 -0
- package/dist/crud/components/EntityList.js +201 -0
- package/dist/crud/components/index.d.ts +7 -5
- package/dist/crud/components/index.d.ts.map +1 -1
- package/dist/crud/components/index.js +6 -5
- package/dist/dndev.css +179 -0
- package/dist/index.js +4 -64
- package/dist/internal/layout/components/AutoMetaTags.d.ts.map +1 -1
- package/dist/internal/layout/components/AutoMetaTags.js +36 -6
- package/dist/internal/layout/components/NextJsAutoMetaTags.d.ts.map +1 -1
- package/dist/internal/layout/components/NextJsAutoMetaTags.js +38 -10
- package/dist/internal/layout/components/footer/FooterBranding.js +2 -2
- package/dist/styles/index.css +179 -0
- package/package.json +12 -12
- package/dist/crud/components/DisplayFieldRenderer.d.ts +0 -26
- package/dist/crud/components/DisplayFieldRenderer.d.ts.map +0 -1
- package/dist/crud/components/DisplayFieldRenderer.js +0 -107
- package/dist/crud/components/fields/display/AvatarFieldDisplay.d.ts +0 -23
- package/dist/crud/components/fields/display/AvatarFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/AvatarFieldDisplay.js +0 -38
- package/dist/crud/components/fields/display/BadgeFieldDisplay.d.ts +0 -21
- package/dist/crud/components/fields/display/BadgeFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/BadgeFieldDisplay.js +0 -31
- package/dist/crud/components/fields/display/ButtonFieldDisplay.d.ts +0 -29
- package/dist/crud/components/fields/display/ButtonFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/ButtonFieldDisplay.js +0 -12
- package/dist/crud/components/fields/display/CheckboxFieldDisplay.d.ts +0 -21
- package/dist/crud/components/fields/display/CheckboxFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/CheckboxFieldDisplay.js +0 -27
- package/dist/crud/components/fields/display/DateFieldDisplay.d.ts +0 -24
- package/dist/crud/components/fields/display/DateFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/DateFieldDisplay.js +0 -41
- package/dist/crud/components/fields/display/DropdownDisplay.d.ts +0 -21
- package/dist/crud/components/fields/display/DropdownDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/DropdownDisplay.js +0 -25
- package/dist/crud/components/fields/display/FileFieldDisplay.d.ts +0 -21
- package/dist/crud/components/fields/display/FileFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/FileFieldDisplay.js +0 -25
- package/dist/crud/components/fields/display/GeoPointFieldDisplay.d.ts +0 -25
- package/dist/crud/components/fields/display/GeoPointFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/GeoPointFieldDisplay.js +0 -25
- package/dist/crud/components/fields/display/HiddenFieldDisplay.d.ts +0 -30
- package/dist/crud/components/fields/display/HiddenFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/HiddenFieldDisplay.js +0 -12
- package/dist/crud/components/fields/display/ImageFieldDisplay.d.ts +0 -24
- package/dist/crud/components/fields/display/ImageFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/ImageFieldDisplay.js +0 -38
- package/dist/crud/components/fields/display/LinkFieldDisplay.d.ts +0 -22
- package/dist/crud/components/fields/display/LinkFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/LinkFieldDisplay.js +0 -48
- package/dist/crud/components/fields/display/MapFieldDisplay.d.ts +0 -25
- package/dist/crud/components/fields/display/MapFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/MapFieldDisplay.js +0 -25
- package/dist/crud/components/fields/display/MultiDropdownDisplay.d.ts +0 -22
- package/dist/crud/components/fields/display/MultiDropdownDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/MultiDropdownDisplay.js +0 -25
- package/dist/crud/components/fields/display/MultiInputTextFieldDisplay.d.ts +0 -22
- package/dist/crud/components/fields/display/MultiInputTextFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/MultiInputTextFieldDisplay.js +0 -25
- package/dist/crud/components/fields/display/NumberFieldDisplay.d.ts +0 -24
- package/dist/crud/components/fields/display/NumberFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/NumberFieldDisplay.js +0 -28
- package/dist/crud/components/fields/display/PasswordFieldDisplay.d.ts +0 -24
- package/dist/crud/components/fields/display/PasswordFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/PasswordFieldDisplay.js +0 -31
- package/dist/crud/components/fields/display/PhoneNumberDisplay.d.ts +0 -22
- package/dist/crud/components/fields/display/PhoneNumberDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/PhoneNumberDisplay.js +0 -25
- package/dist/crud/components/fields/display/RadioFieldDisplay.d.ts +0 -22
- package/dist/crud/components/fields/display/RadioFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/RadioFieldDisplay.js +0 -25
- package/dist/crud/components/fields/display/RangeFieldDisplay.d.ts +0 -22
- package/dist/crud/components/fields/display/RangeFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/RangeFieldDisplay.js +0 -25
- package/dist/crud/components/fields/display/ReferenceFieldDisplay.d.ts +0 -22
- package/dist/crud/components/fields/display/ReferenceFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/ReferenceFieldDisplay.js +0 -26
- package/dist/crud/components/fields/display/RichTextDisplay.d.ts +0 -25
- package/dist/crud/components/fields/display/RichTextDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/RichTextDisplay.js +0 -104
- package/dist/crud/components/fields/display/TextAreaDisplay.d.ts +0 -22
- package/dist/crud/components/fields/display/TextAreaDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/TextAreaDisplay.js +0 -25
- package/dist/crud/components/fields/display/TextFieldDisplay.d.ts +0 -42
- package/dist/crud/components/fields/display/TextFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/TextFieldDisplay.js +0 -97
- package/dist/crud/components/fields/display/TimestampFieldDisplay.d.ts +0 -22
- package/dist/crud/components/fields/display/TimestampFieldDisplay.d.ts.map +0 -1
- package/dist/crud/components/fields/display/TimestampFieldDisplay.js +0 -33
- package/dist/crud/components/fields/display/index.d.ts +0 -32
- package/dist/crud/components/fields/display/index.d.ts.map +0 -1
- package/dist/crud/components/fields/display/index.js +0 -32
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// packages/ui/src/crud/components/EntityFormRenderer.tsx
|
|
4
|
+
/**
|
|
5
|
+
* @fileoverview EntityFormRenderer component
|
|
6
|
+
* @description Dumb renderer that composes form from entity definition.
|
|
7
|
+
* All orchestration logic lives in useEntityForm.
|
|
8
|
+
*
|
|
9
|
+
* @version 0.0.7
|
|
10
|
+
* @since 0.0.1
|
|
11
|
+
* @author AMBROISE PARK Consulting
|
|
12
|
+
*/
|
|
13
|
+
import { useEffect, useId, useMemo, useState } from 'react';
|
|
14
|
+
import { FormProvider } from 'react-hook-form';
|
|
15
|
+
import { Badge, Button, DropdownMenu, Grid, Stack, Spinner, } from '@donotdev/components';
|
|
16
|
+
import { useTranslation } from '@donotdev/core';
|
|
17
|
+
import { useNavigate } from '../../routing';
|
|
18
|
+
import { DisplayFieldRenderer, FormFieldRenderer, UploadProvider, useEntityForm, useUnsavedChangesWarning, useConfirmNavigation, useFormStore, } from '@donotdev/crud';
|
|
19
|
+
/**
|
|
20
|
+
* EntityFormRenderer - Dumb component that renders a form from entity definition.
|
|
21
|
+
*
|
|
22
|
+
* All orchestration (uploads, validation, status tracking) is handled by useEntityForm.
|
|
23
|
+
* This component just:
|
|
24
|
+
* - Generates formId
|
|
25
|
+
* - Renders fields
|
|
26
|
+
* - Renders submit button with status from useEntityForm
|
|
27
|
+
*
|
|
28
|
+
* @version 0.0.7
|
|
29
|
+
* @since 0.0.1
|
|
30
|
+
* @author AMBROISE PARK Consulting
|
|
31
|
+
*/
|
|
32
|
+
export function EntityFormRenderer({ entity, onSubmit, t, className = '', submitText, loading = false, defaultValues, submitVariant = 'primary', secondaryButtonText, secondaryButtonVariant = 'outline', onSecondarySubmit, viewerRole, operation = defaultValues ? 'edit' : 'create', autoSave = operation === 'create', formId: externalFormId, cancelText, cancelPath, successPath, onCancel, warnOnUnsavedChanges = true, unsavedChangesMessage, hideVisibilityInfo = false, }) {
|
|
33
|
+
const navigate = useNavigate();
|
|
34
|
+
// Generate stable form ID
|
|
35
|
+
const generatedFormId = useId();
|
|
36
|
+
const formId = externalFormId ?? `entity-form-${entity.name}-${generatedFormId}`;
|
|
37
|
+
// Use entity name as i18n namespace
|
|
38
|
+
const { t: translationFn } = useTranslation(entity.namespace);
|
|
39
|
+
const { t: tCrud } = useTranslation('crud');
|
|
40
|
+
const translate = t || translationFn;
|
|
41
|
+
// Preview role state for View As toggle (only used when visibility info is shown)
|
|
42
|
+
const [previewRole, setPreviewRole] = useState('super');
|
|
43
|
+
// Visibility → Badge variant mapping (inline, no separate file needed)
|
|
44
|
+
const visibilityBadgeVariant = {
|
|
45
|
+
guest: 'muted',
|
|
46
|
+
user: 'secondary',
|
|
47
|
+
admin: 'warning',
|
|
48
|
+
super: 'destructive',
|
|
49
|
+
technical: 'accent',
|
|
50
|
+
};
|
|
51
|
+
// Role hierarchy for filtering (higher index = more access)
|
|
52
|
+
const roleHierarchy = ['guest', 'user', 'admin', 'super'];
|
|
53
|
+
// Role display labels (hardcoded English - universal technical terms)
|
|
54
|
+
const roleLabels = {
|
|
55
|
+
guest: 'Guest',
|
|
56
|
+
user: 'User',
|
|
57
|
+
admin: 'Admin',
|
|
58
|
+
super: 'Super',
|
|
59
|
+
technical: 'System',
|
|
60
|
+
};
|
|
61
|
+
// Preview dropdown menu items
|
|
62
|
+
const previewMenuItems = useMemo(() => roleHierarchy.map((role) => ({
|
|
63
|
+
label: roleLabels[role],
|
|
64
|
+
checked: previewRole === role,
|
|
65
|
+
onClick: () => setPreviewRole(role),
|
|
66
|
+
})), [previewRole]);
|
|
67
|
+
// useEntityForm handles all orchestration
|
|
68
|
+
const form = useEntityForm(entity, {
|
|
69
|
+
formId,
|
|
70
|
+
operation,
|
|
71
|
+
defaultValues,
|
|
72
|
+
viewerRole,
|
|
73
|
+
t: translate,
|
|
74
|
+
autoSave,
|
|
75
|
+
});
|
|
76
|
+
const { control, handleSubmit, formState: { errors }, fields: rawFields, formStatus, uploadProgress, cleanup, isDirty, resetForm, } = form;
|
|
77
|
+
// Filter fields based on previewRole when visibility info is shown
|
|
78
|
+
const renderableFields = useMemo(() => {
|
|
79
|
+
if (hideVisibilityInfo)
|
|
80
|
+
return rawFields;
|
|
81
|
+
const previewRoleIndex = roleHierarchy.indexOf(previewRole);
|
|
82
|
+
return rawFields.filter(({ config }) => {
|
|
83
|
+
const fieldVisibility = config.visibility;
|
|
84
|
+
// 'hidden' fields are never shown, 'technical' always shown as read-only
|
|
85
|
+
if (fieldVisibility === 'hidden')
|
|
86
|
+
return false;
|
|
87
|
+
if (fieldVisibility === 'technical')
|
|
88
|
+
return true;
|
|
89
|
+
const fieldRoleIndex = roleHierarchy.indexOf(fieldVisibility);
|
|
90
|
+
return fieldRoleIndex <= previewRoleIndex;
|
|
91
|
+
});
|
|
92
|
+
}, [rawFields, hideVisibilityInfo, previewRole]);
|
|
93
|
+
// Sync isDirty to FormStore (single source of truth)
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (formId) {
|
|
96
|
+
useFormStore.getState().setIsDirty(formId, isDirty);
|
|
97
|
+
}
|
|
98
|
+
}, [formId, isDirty]);
|
|
99
|
+
// Cleanup on unmount
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
return cleanup;
|
|
102
|
+
}, [cleanup]);
|
|
103
|
+
// Compute i18n messages once (DRY, performance)
|
|
104
|
+
const unsavedChangesLeaveMessage = useMemo(() => unsavedChangesMessage ||
|
|
105
|
+
tCrud('messages.unsavedChangesLeave', {
|
|
106
|
+
defaultValue: 'You have unsaved changes. Are you sure you want to leave?',
|
|
107
|
+
}), [unsavedChangesMessage, tCrud]);
|
|
108
|
+
const unsavedChangesDiscardMessage = useMemo(() => unsavedChangesMessage ||
|
|
109
|
+
tCrud('messages.unsavedChangesDiscard', {
|
|
110
|
+
defaultValue: 'You have unsaved changes. Discard them?',
|
|
111
|
+
}), [unsavedChangesMessage, tCrud]);
|
|
112
|
+
// Warn about unsaved changes when navigating away (browser refresh/close)
|
|
113
|
+
// SPA navigation blocking is handled by router hooks via FormStore
|
|
114
|
+
useUnsavedChangesWarning({
|
|
115
|
+
isDirty,
|
|
116
|
+
enabled: warnOnUnsavedChanges,
|
|
117
|
+
message: unsavedChangesLeaveMessage,
|
|
118
|
+
});
|
|
119
|
+
// Use framework helper for cancel confirmation (DRY, SSR-safe)
|
|
120
|
+
const confirmNavigation = useConfirmNavigation(isDirty, unsavedChangesDiscardMessage);
|
|
121
|
+
// Handle cancel with confirmation
|
|
122
|
+
const handleCancel = async () => {
|
|
123
|
+
if (isDirty) {
|
|
124
|
+
const confirmed = await confirmNavigation();
|
|
125
|
+
if (!confirmed)
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
resetForm();
|
|
129
|
+
// If onCancel callback provided, use it (takes precedence)
|
|
130
|
+
if (onCancel) {
|
|
131
|
+
onCancel();
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// Otherwise, navigate back or to cancelPath if provided
|
|
135
|
+
if (cancelPath) {
|
|
136
|
+
navigate(cancelPath);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
navigate('back');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
// Wrap onSubmit to navigate optimistically (fire and forget)
|
|
144
|
+
// handleSubmit from react-hook-form expects: (data: EntityData) => void | Promise<void>
|
|
145
|
+
// Our onSubmit prop is (data: T) => void | Promise<void>, where T extends EntityRecord
|
|
146
|
+
// EntityData should be compatible with T, so we can safely pass it
|
|
147
|
+
const handleFormSubmit = (data) => {
|
|
148
|
+
// Call onSubmit (don't await - optimistic update pattern)
|
|
149
|
+
// Type assertion is safe: EntityData is the validated form data, T is EntityRecord
|
|
150
|
+
onSubmit(data);
|
|
151
|
+
// Navigate immediately (optimistic navigation - feels fast)
|
|
152
|
+
// If onSubmit is async, navigation happens while it's processing in background
|
|
153
|
+
if (successPath) {
|
|
154
|
+
navigate(successPath);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// Default: navigate back (optimistic - user came from list, go back there)
|
|
158
|
+
navigate('back');
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
// Determine if cancel button should show
|
|
162
|
+
// Show by default, hide only if cancelText is explicitly null
|
|
163
|
+
const showCancelButton = cancelText !== null;
|
|
164
|
+
const cancelButtonText = cancelText ?? tCrud('form.cancel', { defaultValue: 'Cancel' });
|
|
165
|
+
// Common button container style (sticky footer)
|
|
166
|
+
const buttonContainerStyle = {
|
|
167
|
+
gridColumn: '1 / -1',
|
|
168
|
+
position: 'sticky',
|
|
169
|
+
bottom: 0,
|
|
170
|
+
backgroundColor: 'var(--background)',
|
|
171
|
+
borderTop: '1px solid var(--border)',
|
|
172
|
+
paddingTop: 'var(--gap-md)',
|
|
173
|
+
paddingBottom: 'var(--gap-md)',
|
|
174
|
+
zIndex: 10,
|
|
175
|
+
marginTop: 'var(--gap-lg)',
|
|
176
|
+
};
|
|
177
|
+
// Auto-detect loading for edit forms waiting for data
|
|
178
|
+
const isLoading = loading || (operation === 'edit' && !defaultValues);
|
|
179
|
+
// Compute button state from form status
|
|
180
|
+
const buttonState = useMemo(() => {
|
|
181
|
+
if (formStatus === 'uploading') {
|
|
182
|
+
return {
|
|
183
|
+
loading: true,
|
|
184
|
+
loadingText: uploadProgress < 100
|
|
185
|
+
? tCrud('form.uploading', {
|
|
186
|
+
progress: Math.round(uploadProgress),
|
|
187
|
+
defaultValue: `Uploading ${Math.round(uploadProgress)}%...`,
|
|
188
|
+
})
|
|
189
|
+
: tCrud('form.processing', { defaultValue: 'Processing...' }),
|
|
190
|
+
progress: uploadProgress,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (formStatus === 'validating') {
|
|
194
|
+
return {
|
|
195
|
+
loading: true,
|
|
196
|
+
loadingText: tCrud('form.validating', {
|
|
197
|
+
defaultValue: 'Validating...',
|
|
198
|
+
}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (formStatus === 'submitting') {
|
|
202
|
+
return {
|
|
203
|
+
loading: true,
|
|
204
|
+
loadingText: tCrud('form.saving', { defaultValue: 'Saving...' }),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return { loading: false };
|
|
208
|
+
}, [formStatus, uploadProgress, tCrud]);
|
|
209
|
+
return (_jsx(FormProvider, { ...form, children: _jsx(UploadProvider, { formId: formId, children: _jsxs("div", { style: {
|
|
210
|
+
position: 'relative',
|
|
211
|
+
width: '100%',
|
|
212
|
+
gridColumn: '1 / -1',
|
|
213
|
+
display: 'contents',
|
|
214
|
+
}, children: [isLoading && _jsx(Spinner, { overlay: true }), _jsxs("form", { onSubmit: handleSubmit(handleFormSubmit), noValidate: true, className: className, style: { width: '100%', gridColumn: '1 / -1', display: 'contents' }, children: [renderableFields.map(({ name, config, editable }) => {
|
|
215
|
+
// Show visibility badge for non-guest fields when visibility info is enabled
|
|
216
|
+
const showBadge = !hideVisibilityInfo && config.visibility !== 'guest';
|
|
217
|
+
const badgeVariant = config.visibility !== 'hidden'
|
|
218
|
+
? visibilityBadgeVariant[config.visibility]
|
|
219
|
+
: undefined;
|
|
220
|
+
const badgeLabel = roleLabels[config.visibility] ??
|
|
221
|
+
config.visibility;
|
|
222
|
+
// Wrap field with visibility badge when enabled
|
|
223
|
+
const fieldElement = !editable ? (_jsx(DisplayFieldRenderer, { name: name, config: config, value: defaultValues?.[name], t: translate }, name)) : (_jsx(FormFieldRenderer, { name: name, config: config, control: control, errors: errors, t: translate }, name));
|
|
224
|
+
// If no badge needed, return field directly
|
|
225
|
+
if (!showBadge)
|
|
226
|
+
return fieldElement;
|
|
227
|
+
// Wrap field with badge indicator
|
|
228
|
+
return (_jsxs("div", { style: { position: 'relative' }, children: [_jsx(Badge, { as: "span", variant: badgeVariant, style: {
|
|
229
|
+
position: 'absolute',
|
|
230
|
+
top: 0,
|
|
231
|
+
insetInlineEnd: 0,
|
|
232
|
+
fontSize: 'var(--font-size-xs)',
|
|
233
|
+
zIndex: 1,
|
|
234
|
+
}, children: badgeLabel }), fieldElement] }, name));
|
|
235
|
+
}), (() => {
|
|
236
|
+
// Define all possible buttons (conditionally included)
|
|
237
|
+
const buttons = [];
|
|
238
|
+
// Cancel button
|
|
239
|
+
if (showCancelButton && cancelButtonText) {
|
|
240
|
+
buttons.push(_jsx(Button, { type: "button", onClick: handleCancel, disabled: buttonState.loading, variant: "outline", className: "dndev-w-full", children: cancelButtonText }, "cancel"));
|
|
241
|
+
}
|
|
242
|
+
// Preview dropdown
|
|
243
|
+
if (!hideVisibilityInfo) {
|
|
244
|
+
const previewVariant = visibilityBadgeVariant[previewRole] || 'muted';
|
|
245
|
+
buttons.push(_jsx(DropdownMenu, { trigger: _jsxs(Button, { type: "button", variant: previewVariant, disabled: buttonState.loading, className: "dndev-w-full", children: [tCrud('visibility.preview', {
|
|
246
|
+
defaultValue: 'Preview',
|
|
247
|
+
}), ": ", roleLabels[previewRole]] }), items: previewMenuItems }, "preview"));
|
|
248
|
+
}
|
|
249
|
+
// Secondary submit button
|
|
250
|
+
if (secondaryButtonText) {
|
|
251
|
+
buttons.push(_jsx(Button, { type: "button", onClick: () => {
|
|
252
|
+
if (onSecondarySubmit) {
|
|
253
|
+
const currentValues = form.getValues();
|
|
254
|
+
onSecondarySubmit(currentValues);
|
|
255
|
+
}
|
|
256
|
+
}, loading: buttonState.loading, loadingText: buttonState.loadingText, progress: buttonState.progress, variant: secondaryButtonVariant, className: "dndev-w-full", children: secondaryButtonText }, "secondary"));
|
|
257
|
+
}
|
|
258
|
+
// Primary submit button (always shown)
|
|
259
|
+
buttons.push(_jsx(Button, { type: "submit", loading: buttonState.loading, loadingText: buttonState.loadingText, progress: buttonState.progress, variant: submitVariant, className: "dndev-w-full", children: submitText ||
|
|
260
|
+
tCrud('form.submit', { defaultValue: 'Submit' }) }, "submit"));
|
|
261
|
+
const buttonCount = buttons.length;
|
|
262
|
+
// Single button: use Stack
|
|
263
|
+
if (buttonCount === 1) {
|
|
264
|
+
return (_jsx(Stack, { direction: "column", gap: "tight", style: buttonContainerStyle, children: buttons[0] }));
|
|
265
|
+
}
|
|
266
|
+
// Multiple buttons: use Grid with proportional columns (Submit gets 2fr)
|
|
267
|
+
const templateColumns = buttonCount === 2
|
|
268
|
+
? '1fr 2fr' // Cancel/Preview + Submit
|
|
269
|
+
: buttonCount === 3
|
|
270
|
+
? '1fr 1fr 2fr' // Cancel + Preview + Submit
|
|
271
|
+
: '1fr 1fr 1fr 2fr'; // Cancel + Preview + Secondary + Submit
|
|
272
|
+
return (_jsx(Grid, { cols: [1, buttonCount, buttonCount, buttonCount], templateColumns: templateColumns, gap: "tight", style: buttonContainerStyle, children: buttons }));
|
|
273
|
+
})()] })] }) }) }));
|
|
274
|
+
}
|
|
275
|
+
export default EntityFormRenderer;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EntityListProps } from '@donotdev/core';
|
|
2
|
+
export type { EntityListProps };
|
|
3
|
+
/**
|
|
4
|
+
* Entity List Component - Table view for admin/internal operations
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Filters section (collapsible) with actions and filter inputs
|
|
8
|
+
* - Results section (collapsible) with DataTable
|
|
9
|
+
* - Excel-like table display with formatted values
|
|
10
|
+
* - Edit and Delete actions (admin only)
|
|
11
|
+
* - Auto-routing when handlers not provided
|
|
12
|
+
*/
|
|
13
|
+
export declare function EntityList({ entity, userRole, basePath, onClick, hideFilters, pagination, pageSize: pageSizeProp, queryOptions, }: EntityListProps): import("react/jsx-runtime").JSX.Element;
|
|
14
|
+
//# sourceMappingURL=EntityList.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EntityList.d.ts","sourceRoot":"","sources":["../../../src/crud/components/EntityList.tsx"],"names":[],"mappings":"AAyCA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAEtD,YAAY,EAAE,eAAe,EAAE,CAAC;AAEhC;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,EACzB,MAAM,EACN,QAAkB,EAClB,QAAQ,EACR,OAAO,EACP,WAAmB,EACnB,UAAqB,EACrB,QAAQ,EAAE,YAAY,EACtB,YAAY,GACb,EAAE,eAAe,2CAsTjB"}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// packages/ui/src/crud/components/EntityList.tsx
|
|
4
|
+
/**
|
|
5
|
+
* @fileoverview Entity List Component
|
|
6
|
+
* @description Table view for admin/internal CRUD operations.
|
|
7
|
+
* Features: Filters section + Results section with DataTable
|
|
8
|
+
*
|
|
9
|
+
* **Routing:** Convention basePath = `/${collection}`. View/Edit = basePath/:id, Create = basePath/new.
|
|
10
|
+
* Override basePath for nested routes; use onClick(id) to open sheet instead of navigating.
|
|
11
|
+
*
|
|
12
|
+
* @version 0.3.2
|
|
13
|
+
* @since 0.0.1
|
|
14
|
+
* @author AMBROISE PARK Consulting
|
|
15
|
+
*/
|
|
16
|
+
import { RefreshCw, Plus, Trash2, Edit, Search } from 'lucide-react';
|
|
17
|
+
import { useMemo, useCallback, useState } from 'react';
|
|
18
|
+
import { DataTable, Button, Stack, ActionButton, Section, Input, } from '@donotdev/components';
|
|
19
|
+
import { useTranslation } from '@donotdev/core';
|
|
20
|
+
import { useNavigate } from '../../routing';
|
|
21
|
+
import { translateFieldLabel, useCrud, useCrudList, EntityFilters, matchesFilter, formatValue, } from '@donotdev/crud';
|
|
22
|
+
/**
|
|
23
|
+
* Entity List Component - Table view for admin/internal operations
|
|
24
|
+
*
|
|
25
|
+
* Features:
|
|
26
|
+
* - Filters section (collapsible) with actions and filter inputs
|
|
27
|
+
* - Results section (collapsible) with DataTable
|
|
28
|
+
* - Excel-like table display with formatted values
|
|
29
|
+
* - Edit and Delete actions (admin only)
|
|
30
|
+
* - Auto-routing when handlers not provided
|
|
31
|
+
*/
|
|
32
|
+
export function EntityList({ entity, userRole = 'guest', basePath, onClick, hideFilters = false, pagination = 'client', pageSize: pageSizeProp, queryOptions, }) {
|
|
33
|
+
const navigate = useNavigate();
|
|
34
|
+
const base = basePath ?? `/${entity.collection}`;
|
|
35
|
+
// Server-side pagination state (only used when pagination='server')
|
|
36
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
37
|
+
// For server-side, we need to track pageSize state (DataTable will call onPageSizeChange)
|
|
38
|
+
// For client-side, we just pass pageSizeProp to DataTable and it handles its own state
|
|
39
|
+
const [serverPageSize, setServerPageSize] = useState(pageSizeProp);
|
|
40
|
+
// Separation of Concerns:
|
|
41
|
+
// Client mode: fetches ALL items, DataTable handles pagination
|
|
42
|
+
// Server mode: fetches per page, controlled pagination
|
|
43
|
+
const { data: listData, loading, mutate: refreshList, } = useCrudList(entity, {
|
|
44
|
+
pagination,
|
|
45
|
+
...(queryOptions && { queryOptions }),
|
|
46
|
+
...(pagination === 'server' && {
|
|
47
|
+
page: currentPage,
|
|
48
|
+
pageSize: serverPageSize,
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
// useCrud -> handles actions (delete)
|
|
52
|
+
const { delete: deleteItem } = useCrud(entity);
|
|
53
|
+
const { t: tCrud } = useTranslation('crud');
|
|
54
|
+
const data = listData?.items || [];
|
|
55
|
+
// Entity + crud namespaces so formatValue can resolve crud:price.* etc.
|
|
56
|
+
const { t } = useTranslation([entity.namespace, 'crud']);
|
|
57
|
+
// Filter state - supports string, number range {min, max}, date range {min, max}
|
|
58
|
+
const [filters, setFilters] = useState({});
|
|
59
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
60
|
+
// Refresh handler - triggers manual refetch in useList
|
|
61
|
+
const handleRefresh = useCallback(async () => {
|
|
62
|
+
await refreshList();
|
|
63
|
+
}, [refreshList]);
|
|
64
|
+
// Edit button: always navigate to basePath/:id
|
|
65
|
+
const handleEdit = useCallback((id) => {
|
|
66
|
+
navigate(`${base}/${id}`);
|
|
67
|
+
}, [base, navigate]);
|
|
68
|
+
// Row click: onClick(id) if provided, else navigate to basePath/:id
|
|
69
|
+
const handleView = useCallback((id) => {
|
|
70
|
+
if (onClick) {
|
|
71
|
+
onClick(id);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
navigate(`${base}/${id}`);
|
|
75
|
+
}
|
|
76
|
+
}, [base, navigate, onClick]);
|
|
77
|
+
// Add New: navigate to basePath/new
|
|
78
|
+
const handleCreate = useCallback(() => {
|
|
79
|
+
navigate(`${base}/new`);
|
|
80
|
+
}, [base, navigate]);
|
|
81
|
+
// Delete handler - store handles optimistic removal automatically
|
|
82
|
+
const handleDelete = useCallback(async (itemId) => {
|
|
83
|
+
await deleteItem(itemId);
|
|
84
|
+
}, [deleteItem]);
|
|
85
|
+
// Update filters (for EntityFilters component)
|
|
86
|
+
const handleFiltersChange = useCallback((newFilters) => {
|
|
87
|
+
setFilters(newFilters);
|
|
88
|
+
}, []);
|
|
89
|
+
// Apply search and filters to data
|
|
90
|
+
// @todo Server-side filtering: Currently only handles client-side filtering.
|
|
91
|
+
// When pagination='server', filters should be sent to the server via useCrudList options.
|
|
92
|
+
// Until then, filters are applied client-side after fetching.
|
|
93
|
+
const filteredData = useMemo(() => {
|
|
94
|
+
let result = data;
|
|
95
|
+
// Apply search query (searches all fields)
|
|
96
|
+
if (searchQuery) {
|
|
97
|
+
const searchLower = searchQuery.toLowerCase();
|
|
98
|
+
result = result.filter((item) => {
|
|
99
|
+
return Object.values(item).some((value) => {
|
|
100
|
+
if (value === null || value === undefined)
|
|
101
|
+
return false;
|
|
102
|
+
return String(value).toLowerCase().includes(searchLower);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// Apply column filters
|
|
107
|
+
if (Object.keys(filters).length > 0) {
|
|
108
|
+
result = result.filter((item) => {
|
|
109
|
+
return Object.entries(filters).every(([fieldName, filterValue]) => {
|
|
110
|
+
const itemValue = item[fieldName];
|
|
111
|
+
const fieldConfig = entity.fields[fieldName];
|
|
112
|
+
const fieldType = fieldConfig?.type || 'text';
|
|
113
|
+
return matchesFilter(itemValue, filterValue, fieldType);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}, [data, searchQuery, filters, entity.fields]);
|
|
119
|
+
// Generate columns from entity.listFields or entity.fields
|
|
120
|
+
const columns = useMemo(() => {
|
|
121
|
+
const fieldsToShow = entity.listFields || Object.keys(entity.fields);
|
|
122
|
+
const baseColumns = fieldsToShow
|
|
123
|
+
.map((fieldName) => {
|
|
124
|
+
const fieldConfig = entity.fields[fieldName];
|
|
125
|
+
if (!fieldConfig)
|
|
126
|
+
return null;
|
|
127
|
+
const label = translateFieldLabel(fieldName, fieldConfig, t);
|
|
128
|
+
const fieldType = fieldConfig.type || 'text';
|
|
129
|
+
const isNumeric = fieldType === 'number' || fieldType === 'range';
|
|
130
|
+
const align = isNumeric ? 'end' : 'start';
|
|
131
|
+
return {
|
|
132
|
+
key: fieldName,
|
|
133
|
+
title: label,
|
|
134
|
+
dataIndex: fieldName,
|
|
135
|
+
sortable: true,
|
|
136
|
+
filterable: true,
|
|
137
|
+
align,
|
|
138
|
+
render: (value, record) => formatValue(value, fieldConfig, t, { compact: true }),
|
|
139
|
+
};
|
|
140
|
+
})
|
|
141
|
+
.filter(Boolean);
|
|
142
|
+
// Add actions column at the front (for mobile accessibility)
|
|
143
|
+
baseColumns.unshift({
|
|
144
|
+
key: '_actions',
|
|
145
|
+
title: tCrud('actions.label', { defaultValue: 'Actions' }),
|
|
146
|
+
dataIndex: undefined,
|
|
147
|
+
sortable: false,
|
|
148
|
+
width: 120,
|
|
149
|
+
align: 'center',
|
|
150
|
+
render: (_, record) => (_jsxs(Stack, { direction: "row", gap: "tight", align: "center", justify: "center", children: [_jsx(Button, { variant: "outline", icon: Edit, onClick: (e) => {
|
|
151
|
+
e.stopPropagation();
|
|
152
|
+
handleEdit(record.id);
|
|
153
|
+
}, "aria-label": tCrud('edit', { defaultValue: 'Edit' }) }), _jsx(ActionButton, { action: async () => {
|
|
154
|
+
await handleDelete(record.id);
|
|
155
|
+
}, confirmText: tCrud('delete.confirm', {
|
|
156
|
+
defaultValue: 'Are you sure you want to delete this item?',
|
|
157
|
+
}), confirmTitle: tCrud('delete.title', {
|
|
158
|
+
defaultValue: 'Delete Item',
|
|
159
|
+
}), loadingText: tCrud('delete.loading', {
|
|
160
|
+
defaultValue: 'Deleting...',
|
|
161
|
+
}), variant: "destructive", icon: Trash2, "aria-label": tCrud('delete', { defaultValue: 'Delete' }), children: tCrud('delete', { defaultValue: 'Delete' }) })] })),
|
|
162
|
+
});
|
|
163
|
+
return baseColumns;
|
|
164
|
+
}, [entity, t, tCrud, handleEdit, handleDelete]);
|
|
165
|
+
// Entity name for section title
|
|
166
|
+
const entityName = t('name', { defaultValue: entity.name });
|
|
167
|
+
// Result count
|
|
168
|
+
const resultCount = filteredData.length;
|
|
169
|
+
// Always show table structure - DataTable will show skeleton rows when loading
|
|
170
|
+
return (_jsxs(_Fragment, { children: [_jsx(Section, { title: tCrud('filters.title', {
|
|
171
|
+
entity: entityName,
|
|
172
|
+
defaultValue: `Browse ${entityName} - Filters`,
|
|
173
|
+
}), collapsible: true, defaultOpen: true, children: _jsxs(Stack, { gap: "medium", children: [_jsxs(Stack, { direction: "row", gap: "tight", align: "center", className: "dndev-w-full", style: { display: 'grid', gridTemplateColumns: '1fr auto auto' }, children: [_jsx(Input, { placeholder: tCrud('search.placeholder', {
|
|
174
|
+
defaultValue: 'Search...',
|
|
175
|
+
}), value: searchQuery, onChange: (e) => setSearchQuery(e.target.value), icon: Search, className: "dndev-w-full" }), _jsx(Button, { icon: RefreshCw, variant: "outline", onClick: handleRefresh, disabled: loading, display: "compact", "aria-label": tCrud('refresh', { defaultValue: 'Refresh' }) }), _jsx(Button, { icon: Plus, onClick: handleCreate, display: "compact", children: tCrud('addNew', { defaultValue: 'Add New' }) })] }), !hideFilters && (_jsx(EntityFilters, { entity: entity, data: data, filters: filters, onFiltersChange: handleFiltersChange, fieldsToFilter: entity.listFields }))] }) }), _jsx(Section, { title: loading
|
|
176
|
+
? tCrud('results.title.fetching', {
|
|
177
|
+
defaultValue: 'Fetching...',
|
|
178
|
+
})
|
|
179
|
+
: tCrud('results.title.count', {
|
|
180
|
+
count: resultCount,
|
|
181
|
+
defaultValue: resultCount === 1
|
|
182
|
+
? 'Found 1 occurrence'
|
|
183
|
+
: `Found ${resultCount} occurrences`,
|
|
184
|
+
}), collapsible: true, defaultOpen: true, children: _jsx(DataTable, { data: filteredData, columns: columns, sortable: true, searchable: false, pagination: true, loading: loading, onRowClick: (item) => handleView(item.id),
|
|
185
|
+
// Pagination labels (translated)
|
|
186
|
+
showingLabel: tCrud('pagination.showing', {
|
|
187
|
+
defaultValue: 'Showing {{from}} to {{to}} of {{total}} entries',
|
|
188
|
+
}), paginationPreviousLabel: tCrud('pagination.previous', {
|
|
189
|
+
defaultValue: 'Previous',
|
|
190
|
+
}), paginationNextLabel: tCrud('pagination.next', {
|
|
191
|
+
defaultValue: 'Next',
|
|
192
|
+
}), paginationItemsPerPagePlaceholder: tCrud('pagination.itemsPerPagePlaceholder', {
|
|
193
|
+
defaultValue: 'Items per page',
|
|
194
|
+
}), ...(pageSizeProp && { pageSize: pageSizeProp }), ...(pagination === 'server' && {
|
|
195
|
+
currentPage,
|
|
196
|
+
pageSize: serverPageSize,
|
|
197
|
+
total: listData?.total,
|
|
198
|
+
onPageChange: setCurrentPage,
|
|
199
|
+
onPageSizeChange: setServerPageSize,
|
|
200
|
+
}) }) })] }));
|
|
201
|
+
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview CRUD
|
|
3
|
-
* @description
|
|
2
|
+
* @fileoverview CRUD components
|
|
3
|
+
* @description CRUD components with routing support (moved from @donotdev/crud)
|
|
4
4
|
*
|
|
5
|
-
* @version 0.0.
|
|
5
|
+
* @version 0.0.3
|
|
6
6
|
* @since 0.0.1
|
|
7
7
|
* @author AMBROISE PARK Consulting
|
|
8
8
|
*/
|
|
9
|
-
export { DisplayFieldRenderer } from './DisplayFieldRenderer';
|
|
10
9
|
export { EntityDisplayRenderer } from './EntityDisplayRenderer';
|
|
10
|
+
export { EntityList } from './EntityList';
|
|
11
|
+
export { EntityCardList } from './EntityCardList';
|
|
12
|
+
export { EntityFormRenderer } from './EntityFormRenderer';
|
|
11
13
|
export * from './Form';
|
|
12
|
-
export
|
|
14
|
+
export type { EntityListProps, EntityCardListProps, EntityFormRendererProps, EntityDisplayRendererProps, } from '@donotdev/core';
|
|
13
15
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/crud/components/index.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AAEH,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/crud/components/index.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AAEH,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAChE,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC1D,cAAc,QAAQ,CAAC;AAEvB,YAAY,EACV,eAAe,EACf,mBAAmB,EACnB,uBAAuB,EACvB,0BAA0B,GAC3B,MAAM,gBAAgB,CAAC"}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// packages/ui/src/crud/components/index.ts
|
|
2
2
|
/**
|
|
3
|
-
* @fileoverview CRUD
|
|
4
|
-
* @description
|
|
3
|
+
* @fileoverview CRUD components
|
|
4
|
+
* @description CRUD components with routing support (moved from @donotdev/crud)
|
|
5
5
|
*
|
|
6
|
-
* @version 0.0.
|
|
6
|
+
* @version 0.0.3
|
|
7
7
|
* @since 0.0.1
|
|
8
8
|
* @author AMBROISE PARK Consulting
|
|
9
9
|
*/
|
|
10
|
-
export { DisplayFieldRenderer } from './DisplayFieldRenderer';
|
|
11
10
|
export { EntityDisplayRenderer } from './EntityDisplayRenderer';
|
|
11
|
+
export { EntityList } from './EntityList';
|
|
12
|
+
export { EntityCardList } from './EntityCardList';
|
|
13
|
+
export { EntityFormRenderer } from './EntityFormRenderer';
|
|
12
14
|
export * from './Form';
|
|
13
|
-
export * from './fields/display';
|