@qwickapps/react-framework 1.5.6 → 1.5.8
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/components/AccessibilityChecker.d.ts.map +1 -1
- package/dist/components/Html.d.ts +1 -1
- package/dist/components/Html.d.ts.map +1 -1
- package/dist/components/Logo.d.ts.map +1 -1
- package/dist/components/Markdown.d.ts +2 -2
- package/dist/components/Markdown.d.ts.map +1 -1
- package/dist/components/QwickApp.d.ts.map +1 -1
- package/dist/components/SafeSpan.d.ts +1 -1
- package/dist/components/SafeSpan.d.ts.map +1 -1
- package/dist/components/base/ModelView.d.ts +1 -1
- package/dist/components/base/ModelView.d.ts.map +1 -1
- package/dist/components/blocks/Article.d.ts +1 -1
- package/dist/components/blocks/Article.d.ts.map +1 -1
- package/dist/components/blocks/CardListGrid.d.ts.map +1 -1
- package/dist/components/blocks/Code.d.ts.map +1 -1
- package/dist/components/blocks/Content.d.ts.map +1 -1
- package/dist/components/blocks/CoverImageHeader.d.ts.map +1 -1
- package/dist/components/blocks/FeatureCard.d.ts.map +1 -1
- package/dist/components/blocks/FeatureGrid.d.ts.map +1 -1
- package/dist/components/blocks/Footer.d.ts.map +1 -1
- package/dist/components/blocks/Image.d.ts.map +1 -1
- package/dist/components/blocks/PageBannerHeader.d.ts.map +1 -1
- package/dist/components/blocks/ProductCard.d.ts.map +1 -1
- package/dist/components/blocks/Section.d.ts.map +1 -1
- package/dist/components/blocks/Text.d.ts +8 -1
- package/dist/components/blocks/Text.d.ts.map +1 -1
- package/dist/components/buttons/Button.d.ts.map +1 -1
- package/dist/components/buttons/PaletteSwitcher.d.ts.map +1 -1
- package/dist/components/buttons/ThemeSwitcher.d.ts.map +1 -1
- package/dist/components/forms/FormBlock.d.ts +1 -1
- package/dist/components/forms/FormBlock.d.ts.map +1 -1
- package/dist/components/forms/SchemaFormRenderer.d.ts +28 -0
- package/dist/components/forms/SchemaFormRenderer.d.ts.map +1 -0
- package/dist/components/forms/index.d.ts +2 -0
- package/dist/components/forms/index.d.ts.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/input/ChoiceInputField.d.ts.map +1 -1
- package/dist/components/input/HtmlInputField.d.ts.map +1 -1
- package/dist/components/layout/CollapsibleLayout/CollapsibleLayout.d.ts.map +1 -1
- package/dist/components/layout/GridLayout.d.ts +5 -0
- package/dist/components/layout/GridLayout.d.ts.map +1 -1
- package/dist/components/plugins/DataTable.d.ts +57 -0
- package/dist/components/plugins/DataTable.d.ts.map +1 -0
- package/dist/components/plugins/StatCard.d.ts +44 -0
- package/dist/components/plugins/StatCard.d.ts.map +1 -0
- package/dist/components/plugins/index.d.ts +13 -0
- package/dist/components/plugins/index.d.ts.map +1 -0
- package/dist/components/shared/createSerializableView.d.ts.map +1 -1
- package/dist/contexts/NavigationContext.d.ts.map +1 -1
- package/dist/hooks/useBaseProps.d.ts +1 -1
- package/dist/hooks/useBaseProps.d.ts.map +1 -1
- package/dist/index.esm.js +5939 -5532
- package/dist/index.js +6028 -5618
- package/dist/palettes/manifest.json +19 -19
- package/dist/schemas/transformers/ReactNodeTransformer.d.ts.map +1 -1
- package/dist/utils/iconMap.d.ts +21 -8
- package/dist/utils/iconMap.d.ts.map +1 -1
- package/package.json +1 -2
- package/src/__tests__/utils/iconMap.test.tsx +197 -0
- package/src/components/AccessibilityChecker.tsx +10 -7
- package/src/components/ErrorBoundary.tsx +3 -3
- package/src/components/Html.tsx +17 -12
- package/src/components/Logo.tsx +1 -8
- package/src/components/Markdown.tsx +10 -10
- package/src/components/QwickApp.tsx +8 -1
- package/src/components/ResponsiveMenu.tsx +1 -1
- package/src/components/SafeSpan.tsx +9 -9
- package/src/components/Scaffold.tsx +4 -4
- package/src/components/base/ModelView.tsx +2 -2
- package/src/components/blocks/Article.tsx +7 -7
- package/src/components/blocks/CardListGrid.tsx +1 -3
- package/src/components/blocks/Code.tsx +10 -8
- package/src/components/blocks/Content.tsx +2 -4
- package/src/components/blocks/CoverImageHeader.tsx +3 -4
- package/src/components/blocks/FeatureCard.tsx +2 -4
- package/src/components/blocks/FeatureGrid.tsx +2 -4
- package/src/components/blocks/Footer.tsx +2 -4
- package/src/components/blocks/Image.tsx +8 -5
- package/src/components/blocks/PageBannerHeader.tsx +3 -4
- package/src/components/blocks/ProductCard.tsx +8 -5
- package/src/components/blocks/Section.tsx +6 -4
- package/src/components/blocks/Text.tsx +15 -7
- package/src/components/buttons/Button.tsx +8 -6
- package/src/components/buttons/PaletteSwitcher.tsx +6 -8
- package/src/components/buttons/ThemeSwitcher.tsx +8 -9
- package/src/components/forms/Captcha.tsx +1 -1
- package/src/components/forms/FormBlock.tsx +3 -5
- package/src/components/forms/FormCheckbox.tsx +1 -1
- package/src/components/forms/FormField.tsx +1 -1
- package/src/components/forms/FormSelect.tsx +1 -1
- package/src/components/forms/SchemaFormRenderer.tsx +268 -0
- package/src/components/forms/__tests__/SchemaFormRenderer.test.tsx +212 -0
- package/src/components/forms/index.ts +3 -0
- package/src/components/index.ts +1 -0
- package/src/components/input/ChoiceInputField.tsx +2 -1
- package/src/components/input/HtmlInputField.tsx +14 -9
- package/src/components/input/TextField.tsx +1 -1
- package/src/components/layout/CollapsibleLayout/CollapsibleLayout.tsx +6 -8
- package/src/components/layout/GridLayout.tsx +4 -0
- package/src/components/plugins/DataTable.tsx +259 -0
- package/src/components/plugins/StatCard.tsx +122 -0
- package/src/components/plugins/__tests__/DataTable.test.tsx +158 -0
- package/src/components/plugins/index.ts +14 -0
- package/src/components/shared/createSerializableView.tsx +8 -6
- package/src/contexts/NavigationContext.tsx +21 -15
- package/src/hooks/useBaseProps.ts +1 -1
- package/src/schemas/transformers/ReactNodeTransformer.ts +13 -10
- package/src/utils/iconMap.tsx +290 -174
- /package/dist/palettes/{palette-autumn.1.5.6.css → palette-autumn.1.5.8.css} +0 -0
- /package/dist/palettes/{palette-autumn.1.5.6.min.css → palette-autumn.1.5.8.min.css} +0 -0
- /package/dist/palettes/{palette-cosmic.1.5.6.css → palette-cosmic.1.5.8.css} +0 -0
- /package/dist/palettes/{palette-cosmic.1.5.6.min.css → palette-cosmic.1.5.8.min.css} +0 -0
- /package/dist/palettes/{palette-default.1.5.6.css → palette-default.1.5.8.css} +0 -0
- /package/dist/palettes/{palette-default.1.5.6.min.css → palette-default.1.5.8.min.css} +0 -0
- /package/dist/palettes/{palette-ocean.1.5.6.css → palette-ocean.1.5.8.css} +0 -0
- /package/dist/palettes/{palette-ocean.1.5.6.min.css → palette-ocean.1.5.8.min.css} +0 -0
- /package/dist/palettes/{palette-spring.1.5.6.css → palette-spring.1.5.8.css} +0 -0
- /package/dist/palettes/{palette-spring.1.5.6.min.css → palette-spring.1.5.8.min.css} +0 -0
- /package/dist/palettes/{palette-winter.1.5.6.css → palette-winter.1.5.8.css} +0 -0
- /package/dist/palettes/{palette-winter.1.5.6.min.css → palette-winter.1.5.8.min.css} +0 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTable - Sortable, filterable table component for plugin management pages
|
|
3
|
+
*
|
|
4
|
+
* Provides consistent table UI with sorting, filtering, pagination, and bulk actions.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <DataTable
|
|
9
|
+
* columns={[
|
|
10
|
+
* { key: 'email', label: 'Email', sortable: true },
|
|
11
|
+
* { key: 'status', label: 'Status', render: (val) => <Badge>{val}</Badge> }
|
|
12
|
+
* ]}
|
|
13
|
+
* data={users}
|
|
14
|
+
* onRowClick={(user) => navigate(`/users/${user.id}`)}
|
|
15
|
+
* bulkActions={[
|
|
16
|
+
* { label: 'Delete', onClick: (rows) => handleDelete(rows), variant: 'danger' }
|
|
17
|
+
* ]}
|
|
18
|
+
* />
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import React, { useState, useMemo } from 'react';
|
|
23
|
+
|
|
24
|
+
export interface Column<T> {
|
|
25
|
+
/** Column key (must match data property) */
|
|
26
|
+
key: keyof T;
|
|
27
|
+
|
|
28
|
+
/** Display label */
|
|
29
|
+
label: string;
|
|
30
|
+
|
|
31
|
+
/** Enable sorting for this column */
|
|
32
|
+
sortable?: boolean;
|
|
33
|
+
|
|
34
|
+
/** Custom render function */
|
|
35
|
+
render?: (value: unknown, row: T) => React.ReactNode;
|
|
36
|
+
|
|
37
|
+
/** Column width (CSS value) */
|
|
38
|
+
width?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DataTableProps<T> {
|
|
42
|
+
/** Column definitions */
|
|
43
|
+
columns: Column<T>[];
|
|
44
|
+
|
|
45
|
+
/** Table data */
|
|
46
|
+
data: T[];
|
|
47
|
+
|
|
48
|
+
/** Row click handler */
|
|
49
|
+
onRowClick?: (row: T) => void;
|
|
50
|
+
|
|
51
|
+
/** Bulk action buttons */
|
|
52
|
+
bulkActions?: Array<{
|
|
53
|
+
label: string;
|
|
54
|
+
onClick: (selectedRows: T[]) => void;
|
|
55
|
+
variant?: 'primary' | 'secondary' | 'danger';
|
|
56
|
+
}>;
|
|
57
|
+
|
|
58
|
+
/** Enable row selection */
|
|
59
|
+
selectable?: boolean;
|
|
60
|
+
|
|
61
|
+
/** Empty state message */
|
|
62
|
+
emptyMessage?: string;
|
|
63
|
+
|
|
64
|
+
/** Loading state */
|
|
65
|
+
loading?: boolean;
|
|
66
|
+
|
|
67
|
+
/** Row key extractor */
|
|
68
|
+
getRowKey?: (row: T, index: number) => string | number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function DataTable<T = Record<string, unknown>>({
|
|
72
|
+
columns,
|
|
73
|
+
data,
|
|
74
|
+
onRowClick,
|
|
75
|
+
bulkActions = [],
|
|
76
|
+
selectable = bulkActions.length > 0,
|
|
77
|
+
emptyMessage = 'No data available',
|
|
78
|
+
loading = false,
|
|
79
|
+
getRowKey = (row: T, index: number) => {
|
|
80
|
+
// Type-safe check for id property
|
|
81
|
+
const rowObj = row as Record<string, unknown>;
|
|
82
|
+
const hasValidId = 'id' in rowObj &&
|
|
83
|
+
(typeof rowObj.id === 'string' || typeof rowObj.id === 'number');
|
|
84
|
+
return hasValidId ? (rowObj.id as string | number) : index;
|
|
85
|
+
},
|
|
86
|
+
}: DataTableProps<T>) {
|
|
87
|
+
const [selectedRows, setSelectedRows] = useState<Set<string | number>>(new Set());
|
|
88
|
+
const [sortColumn, setSortColumn] = useState<keyof T | null>(null);
|
|
89
|
+
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
|
90
|
+
|
|
91
|
+
// Sorted data
|
|
92
|
+
const sortedData = useMemo(() => {
|
|
93
|
+
if (!sortColumn) return data;
|
|
94
|
+
|
|
95
|
+
return [...data].sort((a, b) => {
|
|
96
|
+
const aVal = a[sortColumn];
|
|
97
|
+
const bVal = b[sortColumn];
|
|
98
|
+
|
|
99
|
+
if (aVal === bVal) return 0;
|
|
100
|
+
|
|
101
|
+
const comparison = aVal < bVal ? -1 : 1;
|
|
102
|
+
return sortDirection === 'asc' ? comparison : -comparison;
|
|
103
|
+
});
|
|
104
|
+
}, [data, sortColumn, sortDirection]);
|
|
105
|
+
|
|
106
|
+
const handleSort = (column: keyof T) => {
|
|
107
|
+
if (sortColumn === column) {
|
|
108
|
+
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
|
109
|
+
} else {
|
|
110
|
+
setSortColumn(column);
|
|
111
|
+
setSortDirection('asc');
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const handleSelectAll = () => {
|
|
116
|
+
if (selectedRows.size === data.length) {
|
|
117
|
+
setSelectedRows(new Set());
|
|
118
|
+
} else {
|
|
119
|
+
setSelectedRows(new Set(data.map((row, index) => getRowKey(row, index))));
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const handleSelectRow = (rowKey: string | number) => {
|
|
124
|
+
const newSelection = new Set(selectedRows);
|
|
125
|
+
if (newSelection.has(rowKey)) {
|
|
126
|
+
newSelection.delete(rowKey);
|
|
127
|
+
} else {
|
|
128
|
+
newSelection.add(rowKey);
|
|
129
|
+
}
|
|
130
|
+
setSelectedRows(newSelection);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const getSelectedRowData = () => {
|
|
134
|
+
return data.filter((row, index) =>
|
|
135
|
+
selectedRows.has(getRowKey(row, index))
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
if (loading) {
|
|
140
|
+
return (
|
|
141
|
+
<div className="animate-pulse space-y-2">
|
|
142
|
+
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
143
|
+
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
144
|
+
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (data.length === 0) {
|
|
150
|
+
return (
|
|
151
|
+
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
|
152
|
+
{emptyMessage}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div className="space-y-4">
|
|
159
|
+
{/* Bulk Actions */}
|
|
160
|
+
{selectable && selectedRows.size > 0 && bulkActions.length > 0 && (
|
|
161
|
+
<div className="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
|
162
|
+
<span className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
|
163
|
+
{selectedRows.size} selected
|
|
164
|
+
</span>
|
|
165
|
+
{bulkActions.map((action, index) => (
|
|
166
|
+
<button
|
|
167
|
+
key={index}
|
|
168
|
+
onClick={() => action.onClick(getSelectedRowData())}
|
|
169
|
+
className={`
|
|
170
|
+
px-3 py-1.5 rounded text-sm font-medium
|
|
171
|
+
${action.variant === 'danger'
|
|
172
|
+
? 'bg-red-600 hover:bg-red-700 text-white'
|
|
173
|
+
: 'bg-blue-600 hover:bg-blue-700 text-white'}
|
|
174
|
+
`}
|
|
175
|
+
>
|
|
176
|
+
{action.label}
|
|
177
|
+
</button>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{/* Table */}
|
|
183
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
|
184
|
+
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
185
|
+
<thead className="bg-gray-50 dark:bg-gray-800">
|
|
186
|
+
<tr>
|
|
187
|
+
{selectable && (
|
|
188
|
+
<th className="w-12 px-4 py-3">
|
|
189
|
+
<input
|
|
190
|
+
type="checkbox"
|
|
191
|
+
checked={selectedRows.size === data.length && data.length > 0}
|
|
192
|
+
onChange={handleSelectAll}
|
|
193
|
+
className="rounded"
|
|
194
|
+
/>
|
|
195
|
+
</th>
|
|
196
|
+
)}
|
|
197
|
+
{columns.map((column) => (
|
|
198
|
+
<th
|
|
199
|
+
key={String(column.key)}
|
|
200
|
+
style={{ width: column.width }}
|
|
201
|
+
className={`
|
|
202
|
+
px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider
|
|
203
|
+
${column.sortable ? 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700' : ''}
|
|
204
|
+
`}
|
|
205
|
+
onClick={() => column.sortable && handleSort(column.key)}
|
|
206
|
+
>
|
|
207
|
+
<div className="flex items-center gap-2">
|
|
208
|
+
{column.label}
|
|
209
|
+
{column.sortable && sortColumn === column.key && (
|
|
210
|
+
<span>{sortDirection === 'asc' ? '↑' : '↓'}</span>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
</th>
|
|
214
|
+
))}
|
|
215
|
+
</tr>
|
|
216
|
+
</thead>
|
|
217
|
+
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
|
218
|
+
{sortedData.map((row, rowIndex) => {
|
|
219
|
+
const rowKey = getRowKey(row, rowIndex);
|
|
220
|
+
const isSelected = selectedRows.has(rowKey);
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<tr
|
|
224
|
+
key={rowKey}
|
|
225
|
+
className={`
|
|
226
|
+
${onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''}
|
|
227
|
+
${isSelected ? 'bg-blue-50 dark:bg-blue-900/20' : ''}
|
|
228
|
+
`}
|
|
229
|
+
onClick={() => onRowClick?.(row)}
|
|
230
|
+
>
|
|
231
|
+
{selectable && (
|
|
232
|
+
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
|
233
|
+
<input
|
|
234
|
+
type="checkbox"
|
|
235
|
+
checked={isSelected}
|
|
236
|
+
onChange={() => handleSelectRow(rowKey)}
|
|
237
|
+
className="rounded"
|
|
238
|
+
/>
|
|
239
|
+
</td>
|
|
240
|
+
)}
|
|
241
|
+
{columns.map((column) => (
|
|
242
|
+
<td
|
|
243
|
+
key={String(column.key)}
|
|
244
|
+
className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100"
|
|
245
|
+
>
|
|
246
|
+
{column.render
|
|
247
|
+
? column.render(row[column.key], row)
|
|
248
|
+
: String(row[column.key] ?? '')}
|
|
249
|
+
</td>
|
|
250
|
+
))}
|
|
251
|
+
</tr>
|
|
252
|
+
);
|
|
253
|
+
})}
|
|
254
|
+
</tbody>
|
|
255
|
+
</table>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StatCard - Display a single metric with optional trend indicator
|
|
3
|
+
*
|
|
4
|
+
* Used in plugin status widgets to show key metrics at a glance.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <StatCard
|
|
9
|
+
* label="Active Connections"
|
|
10
|
+
* value={42}
|
|
11
|
+
* unit="connections"
|
|
12
|
+
* trend={{ value: 12, direction: 'up' }}
|
|
13
|
+
* status="healthy"
|
|
14
|
+
* />
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import React from 'react';
|
|
19
|
+
|
|
20
|
+
export interface StatCardProps {
|
|
21
|
+
/** Label describing the metric */
|
|
22
|
+
label: string;
|
|
23
|
+
|
|
24
|
+
/** Current value */
|
|
25
|
+
value: number | string;
|
|
26
|
+
|
|
27
|
+
/** Optional unit (e.g., "MB", "requests/sec") */
|
|
28
|
+
unit?: string;
|
|
29
|
+
|
|
30
|
+
/** Optional suffix (alias for unit, for backward compatibility) */
|
|
31
|
+
suffix?: string;
|
|
32
|
+
|
|
33
|
+
/** Optional trend indicator */
|
|
34
|
+
trend?: {
|
|
35
|
+
value: number;
|
|
36
|
+
direction: 'up' | 'down' | 'stable';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Status indicator */
|
|
40
|
+
status?: 'healthy' | 'warning' | 'error' | 'info';
|
|
41
|
+
|
|
42
|
+
/** Optional click handler */
|
|
43
|
+
onClick?: () => void;
|
|
44
|
+
|
|
45
|
+
/** Optional icon */
|
|
46
|
+
icon?: React.ReactNode;
|
|
47
|
+
|
|
48
|
+
/** Optional sub-value displayed below the label */
|
|
49
|
+
subValue?: string;
|
|
50
|
+
|
|
51
|
+
/** Optional custom color for the icon/accent */
|
|
52
|
+
color?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const StatCard: React.FC<StatCardProps> = ({
|
|
56
|
+
label,
|
|
57
|
+
value,
|
|
58
|
+
unit,
|
|
59
|
+
suffix,
|
|
60
|
+
trend,
|
|
61
|
+
status = 'info',
|
|
62
|
+
onClick,
|
|
63
|
+
icon,
|
|
64
|
+
subValue,
|
|
65
|
+
color,
|
|
66
|
+
}) => {
|
|
67
|
+
// Use suffix as fallback for unit (backward compatibility)
|
|
68
|
+
const displayUnit = unit || suffix;
|
|
69
|
+
const statusColors = {
|
|
70
|
+
healthy: 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20',
|
|
71
|
+
warning: 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20',
|
|
72
|
+
error: 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20',
|
|
73
|
+
info: 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const trendIcons = {
|
|
77
|
+
up: '↑',
|
|
78
|
+
down: '↓',
|
|
79
|
+
stable: '→',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
className={`
|
|
85
|
+
rounded-lg border p-4
|
|
86
|
+
${statusColors[status]}
|
|
87
|
+
${onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}
|
|
88
|
+
`}
|
|
89
|
+
onClick={onClick}
|
|
90
|
+
role={onClick ? 'button' : undefined}
|
|
91
|
+
tabIndex={onClick ? 0 : undefined}
|
|
92
|
+
>
|
|
93
|
+
<div className="flex items-start justify-between">
|
|
94
|
+
<div className="flex-1">
|
|
95
|
+
<p className="text-sm font-medium opacity-80">{label}</p>
|
|
96
|
+
<div className="mt-1 flex items-baseline gap-2">
|
|
97
|
+
<p className="text-2xl font-semibold">
|
|
98
|
+
{value}
|
|
99
|
+
</p>
|
|
100
|
+
{displayUnit && (
|
|
101
|
+
<span className="text-sm opacity-70">{displayUnit}</span>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
{subValue && (
|
|
105
|
+
<p className="text-xs opacity-70 mt-1">{subValue}</p>
|
|
106
|
+
)}
|
|
107
|
+
{trend && (
|
|
108
|
+
<div className="mt-2 flex items-center gap-1 text-sm">
|
|
109
|
+
<span>{trendIcons[trend.direction]}</span>
|
|
110
|
+
<span>{trend.value}%</span>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
{icon && (
|
|
115
|
+
<div className="ml-4 text-2xl opacity-70" style={color ? { color } : undefined}>
|
|
116
|
+
{icon}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTable tests
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) 2025 QwickApps.com. All rights reserved.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import { render, screen } from '@testing-library/react';
|
|
9
|
+
import '@testing-library/jest-dom';
|
|
10
|
+
import { DataTable, type Column, type DataTableProps } from '../DataTable';
|
|
11
|
+
|
|
12
|
+
describe('DataTable', () => {
|
|
13
|
+
interface TestRow {
|
|
14
|
+
id: number;
|
|
15
|
+
name: string;
|
|
16
|
+
email: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const mockData: TestRow[] = [
|
|
20
|
+
{ id: 1, name: 'Alice', email: 'alice@example.com' },
|
|
21
|
+
{ id: 2, name: 'Bob', email: 'bob@example.com' },
|
|
22
|
+
{ id: 3, name: 'Charlie', email: 'charlie@example.com' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const mockColumns: Column<TestRow>[] = [
|
|
26
|
+
{ key: 'id', label: 'ID', sortable: true },
|
|
27
|
+
{ key: 'name', label: 'Name', sortable: true },
|
|
28
|
+
{ key: 'email', label: 'Email', sortable: true },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
describe('Type Safety', () => {
|
|
32
|
+
it('should accept generic type parameter correctly', () => {
|
|
33
|
+
const props: DataTableProps<TestRow> = {
|
|
34
|
+
columns: mockColumns,
|
|
35
|
+
data: mockData,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
expect(props.columns).toBeDefined();
|
|
39
|
+
expect(props.data).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should have type-safe column keys', () => {
|
|
43
|
+
// This test verifies that TypeScript enforces column keys match the data type
|
|
44
|
+
const validColumn: Column<TestRow> = { key: 'name', label: 'Name' };
|
|
45
|
+
expect(validColumn.key).toBe('name');
|
|
46
|
+
|
|
47
|
+
// TypeScript should prevent invalid keys at compile time
|
|
48
|
+
// @ts-expect-error - invalid key should cause type error
|
|
49
|
+
const invalidColumn: Column<TestRow> = { key: 'invalid', label: 'Invalid' };
|
|
50
|
+
expect(invalidColumn).toBeDefined(); // Just to use the variable
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should handle getRowKey with type-safe id extraction', () => {
|
|
54
|
+
// Test the default getRowKey behavior
|
|
55
|
+
const { container } = render(
|
|
56
|
+
<DataTable<TestRow> columns={mockColumns} data={mockData} />
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(container).toBeInTheDocument();
|
|
60
|
+
// The component should render rows using ids as keys
|
|
61
|
+
// This verifies the type-safe id extraction works
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should handle custom getRowKey function', () => {
|
|
65
|
+
const customGetRowKey = (row: TestRow, index: number) => `row-${row.id}-${index}`;
|
|
66
|
+
|
|
67
|
+
render(
|
|
68
|
+
<DataTable<TestRow>
|
|
69
|
+
columns={mockColumns}
|
|
70
|
+
data={mockData}
|
|
71
|
+
getRowKey={customGetRowKey}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Component should render with custom keys
|
|
76
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should handle rows without id property using index fallback', () => {
|
|
80
|
+
interface RowWithoutId {
|
|
81
|
+
name: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const dataWithoutId: RowWithoutId[] = [
|
|
85
|
+
{ name: 'Alice' },
|
|
86
|
+
{ name: 'Bob' },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const columnsWithoutId: Column<RowWithoutId>[] = [
|
|
90
|
+
{ key: 'name', label: 'Name' },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const { container } = render(
|
|
94
|
+
<DataTable<RowWithoutId> columns={columnsWithoutId} data={dataWithoutId} />
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(container).toBeInTheDocument();
|
|
98
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
99
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('Component Rendering', () => {
|
|
104
|
+
it('should render empty message when data is empty', () => {
|
|
105
|
+
render(
|
|
106
|
+
<DataTable<TestRow>
|
|
107
|
+
columns={mockColumns}
|
|
108
|
+
data={[]}
|
|
109
|
+
emptyMessage="No users found"
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(screen.getByText('No users found')).toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should render loading state', () => {
|
|
117
|
+
const { container } = render(
|
|
118
|
+
<DataTable<TestRow> columns={mockColumns} data={mockData} loading={true} />
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Check for loading skeleton (has animate-pulse class)
|
|
122
|
+
const loadingSkeleton = container.querySelector('.animate-pulse');
|
|
123
|
+
expect(loadingSkeleton).toBeInTheDocument();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should render column headers', () => {
|
|
127
|
+
render(<DataTable<TestRow> columns={mockColumns} data={mockData} />);
|
|
128
|
+
|
|
129
|
+
expect(screen.getByText('ID')).toBeInTheDocument();
|
|
130
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
131
|
+
expect(screen.getByText('Email')).toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should render data rows', () => {
|
|
135
|
+
render(<DataTable<TestRow> columns={mockColumns} data={mockData} />);
|
|
136
|
+
|
|
137
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
138
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
139
|
+
expect(screen.getByText('Charlie')).toBeInTheDocument();
|
|
140
|
+
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should use custom render function when provided', () => {
|
|
144
|
+
const columnsWithCustomRender: Column<TestRow>[] = [
|
|
145
|
+
{
|
|
146
|
+
key: 'name',
|
|
147
|
+
label: 'Name',
|
|
148
|
+
render: (value) => <strong>{String(value)}</strong>,
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
render(<DataTable<TestRow> columns={columnsWithCustomRender} data={mockData} />);
|
|
153
|
+
|
|
154
|
+
const strongElement = screen.getByText('Alice').closest('strong');
|
|
155
|
+
expect(strongElement).toBeInTheDocument();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic UI Components
|
|
3
|
+
*
|
|
4
|
+
* Reusable UI primitives for building admin interfaces.
|
|
5
|
+
* Server-specific plugin patterns are in @qwickapps/server/ui
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { StatCard } from './StatCard.js';
|
|
11
|
+
export type { StatCardProps } from './StatCard.js';
|
|
12
|
+
|
|
13
|
+
export { DataTable } from './DataTable.js';
|
|
14
|
+
export type { DataTableProps, Column } from './DataTable.js';
|
|
@@ -154,11 +154,11 @@ export function createSerializableView<P extends ViewProps>(
|
|
|
154
154
|
|
|
155
155
|
// Attach static properties for serialization
|
|
156
156
|
const component = SerializableViewComponent as unknown as SerializableComponent<P>;
|
|
157
|
-
|
|
157
|
+
|
|
158
158
|
// Component identification
|
|
159
159
|
component.tagName = tagName;
|
|
160
160
|
component.version = version;
|
|
161
|
-
component[QWICKAPP_COMPONENT]
|
|
161
|
+
Object.assign(component, { [QWICKAPP_COMPONENT]: QWICKAPP_COMPONENT });
|
|
162
162
|
|
|
163
163
|
// Serialization methods
|
|
164
164
|
component.fromJson = function fromJson(data: unknown): ReactElement {
|
|
@@ -178,8 +178,9 @@ export function createSerializableView<P extends ViewProps>(
|
|
|
178
178
|
if (childrenStrategy.mode === 'content-prop') {
|
|
179
179
|
const propName = childrenStrategy.propName || 'content';
|
|
180
180
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
181
|
-
const
|
|
182
|
-
const
|
|
181
|
+
const typedComponentData = (componentData as Record<string, unknown>) || {};
|
|
182
|
+
const { children, ...rest } = typedComponentData;
|
|
183
|
+
const contentData = { ...rest, [propName]: typedComponentData[propName] || '' };
|
|
183
184
|
return React.createElement(component as ComponentType<P>, contentData as P);
|
|
184
185
|
} else {
|
|
185
186
|
// For react-children strategy, recursively deserialize children
|
|
@@ -197,8 +198,9 @@ export function createSerializableView<P extends ViewProps>(
|
|
|
197
198
|
if (childrenStrategy.mode === 'content-prop') {
|
|
198
199
|
const propName = childrenStrategy.propName || 'content';
|
|
199
200
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
200
|
-
const
|
|
201
|
-
const
|
|
201
|
+
const typedProps = (props as Record<string, unknown>) || {};
|
|
202
|
+
const { children, ...rest } = typedProps;
|
|
203
|
+
const contentValue = typedProps[propName] ?? toText(typedProps.children as ReactNode);
|
|
202
204
|
|
|
203
205
|
// Clean props for content-prop serialization
|
|
204
206
|
const cleanProps: Record<string, unknown> = {};
|
|
@@ -15,7 +15,7 @@ import { createContext, useContext, type ReactNode } from 'react';
|
|
|
15
15
|
import {
|
|
16
16
|
useNavigate,
|
|
17
17
|
useLocation,
|
|
18
|
-
|
|
18
|
+
useInRouterContext,
|
|
19
19
|
} from 'react-router-dom';
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -58,17 +58,23 @@ function ReactRouterNavigationProvider({ children }: { children: ReactNode }) {
|
|
|
58
58
|
}
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
// Defensive check for location - fall back to window.location if React Router's location is undefined
|
|
62
|
+
const location: NavigationLocation | undefined = reactRouterLocation
|
|
63
|
+
? {
|
|
64
|
+
pathname: reactRouterLocation.pathname,
|
|
65
|
+
search: reactRouterLocation.search,
|
|
66
|
+
hash: reactRouterLocation.hash,
|
|
67
|
+
}
|
|
68
|
+
: typeof window !== 'undefined'
|
|
69
|
+
? {
|
|
70
|
+
pathname: window.location.pathname,
|
|
71
|
+
search: window.location.search,
|
|
72
|
+
hash: window.location.hash,
|
|
73
|
+
}
|
|
74
|
+
: undefined;
|
|
75
|
+
|
|
61
76
|
return (
|
|
62
|
-
<NavigationContext.Provider
|
|
63
|
-
value={{
|
|
64
|
-
navigate,
|
|
65
|
-
location: {
|
|
66
|
-
pathname: reactRouterLocation.pathname,
|
|
67
|
-
search: reactRouterLocation.search,
|
|
68
|
-
hash: reactRouterLocation.hash,
|
|
69
|
-
},
|
|
70
|
-
}}
|
|
71
|
-
>
|
|
77
|
+
<NavigationContext.Provider value={{ navigate, location }}>
|
|
72
78
|
{children}
|
|
73
79
|
</NavigationContext.Provider>
|
|
74
80
|
);
|
|
@@ -115,11 +121,11 @@ function FallbackNavigationProvider({ children }: { children: ReactNode }) {
|
|
|
115
121
|
* This is included automatically by QwickApp - you don't need to add it manually.
|
|
116
122
|
*/
|
|
117
123
|
export function NavigationProvider({ children }: { children: ReactNode }) {
|
|
118
|
-
// Check if we're inside a React Router
|
|
119
|
-
//
|
|
120
|
-
const
|
|
124
|
+
// Check if we're inside a React Router using the official hook
|
|
125
|
+
// This is more reliable than checking internal UNSAFE contexts
|
|
126
|
+
const isInRouter = useInRouterContext();
|
|
121
127
|
|
|
122
|
-
if (
|
|
128
|
+
if (isInRouter) {
|
|
123
129
|
// We're inside a Router, use React Router's navigation
|
|
124
130
|
return (
|
|
125
131
|
<ReactRouterNavigationProvider>{children}</ReactRouterNavigationProvider>
|
|
@@ -264,4 +264,4 @@ export function useBaseProps<T extends BaseComponentProps>(props: T) {
|
|
|
264
264
|
/**
|
|
265
265
|
* Type helper for components using base props
|
|
266
266
|
*/
|
|
267
|
-
export type WithBaseProps<P =
|
|
267
|
+
export type WithBaseProps<P = object> = P & BaseComponentProps;
|