@kronor/dtv 0.2.9
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/.editorconfig +12 -0
- package/.github/copilot-instructions.md +64 -0
- package/.github/workflows/ci.yml +51 -0
- package/.husky/pre-commit +8 -0
- package/README.md +63 -0
- package/docs/api/README.md +32 -0
- package/docs/api/cell-renderers.md +121 -0
- package/docs/api/no-rows-component.md +71 -0
- package/docs/api/runtime.md +78 -0
- package/e2e/app.spec.ts +6 -0
- package/e2e/cell-renderer-setfilterstate.spec.ts +63 -0
- package/e2e/filter-sharing.spec.ts +113 -0
- package/e2e/filter-url-persistence.spec.ts +36 -0
- package/e2e/graphqlMock.ts +144 -0
- package/e2e/multi-field-filters.spec.ts +95 -0
- package/e2e/pagination.spec.ts +38 -0
- package/e2e/payment-request-email-filter.spec.ts +67 -0
- package/e2e/save-filter-splitbutton.spec.ts +68 -0
- package/e2e/simple-view-email-filter.spec.ts +67 -0
- package/e2e/simple-view-transforms.spec.ts +171 -0
- package/e2e/simple-view.spec.ts +104 -0
- package/e2e/transform-regression.spec.ts +108 -0
- package/eslint.config.js +30 -0
- package/index.html +17 -0
- package/jest.config.js +10 -0
- package/package.json +45 -0
- package/playwright.config.ts +54 -0
- package/public/vite.svg +1 -0
- package/src/App.externalRuntime.test.ts +190 -0
- package/src/App.tsx +540 -0
- package/src/assets/react.svg +1 -0
- package/src/components/AIAssistantForm.tsx +241 -0
- package/src/components/FilterForm.test.ts +82 -0
- package/src/components/FilterForm.tsx +375 -0
- package/src/components/PhoneNumberFilter.tsx +102 -0
- package/src/components/SavedFilterList.tsx +181 -0
- package/src/components/SpeechInput.tsx +67 -0
- package/src/components/Table.tsx +119 -0
- package/src/components/TablePagination.tsx +40 -0
- package/src/components/aiAssistant.test.ts +270 -0
- package/src/components/aiAssistant.ts +291 -0
- package/src/framework/cell-renderer-components/CurrencyAmount.tsx +30 -0
- package/src/framework/cell-renderer-components/LayoutHelpers.tsx +74 -0
- package/src/framework/cell-renderer-components/Link.tsx +28 -0
- package/src/framework/cell-renderer-components/Mapping.tsx +11 -0
- package/src/framework/cell-renderer-components.test.ts +353 -0
- package/src/framework/column-definition.tsx +85 -0
- package/src/framework/currency.test.ts +46 -0
- package/src/framework/currency.ts +62 -0
- package/src/framework/data.staticConditions.test.ts +46 -0
- package/src/framework/data.test.ts +167 -0
- package/src/framework/data.ts +162 -0
- package/src/framework/filter-form-state.test.ts +189 -0
- package/src/framework/filter-form-state.ts +185 -0
- package/src/framework/filter-sharing.test.ts +135 -0
- package/src/framework/filter-sharing.ts +118 -0
- package/src/framework/filters.ts +194 -0
- package/src/framework/graphql.buildHasuraConditions.test.ts +473 -0
- package/src/framework/graphql.paginationKey.test.ts +29 -0
- package/src/framework/graphql.test.ts +286 -0
- package/src/framework/graphql.ts +462 -0
- package/src/framework/native-runtime/index.tsx +33 -0
- package/src/framework/native-runtime/nativeComponents.test.ts +108 -0
- package/src/framework/runtime-reference.test.ts +172 -0
- package/src/framework/runtime.ts +15 -0
- package/src/framework/saved-filters.test.ts +422 -0
- package/src/framework/saved-filters.ts +293 -0
- package/src/framework/state.test.ts +86 -0
- package/src/framework/state.ts +148 -0
- package/src/framework/transform.test.ts +51 -0
- package/src/framework/view-parser-initialvalues.test.ts +228 -0
- package/src/framework/view-parser.ts +714 -0
- package/src/framework/view.test.ts +1805 -0
- package/src/framework/view.ts +38 -0
- package/src/index.css +6 -0
- package/src/main.tsx +99 -0
- package/src/views/index.ts +12 -0
- package/src/views/payment-requests/components/NoRowsExtendDateRange.tsx +37 -0
- package/src/views/payment-requests/components/PaymentMethod.tsx +184 -0
- package/src/views/payment-requests/components/PaymentStatusTag.tsx +61 -0
- package/src/views/payment-requests/index.ts +1 -0
- package/src/views/payment-requests/runtime.tsx +145 -0
- package/src/views/payment-requests/view.json +692 -0
- package/src/views/payment-requests-initial-values.test.ts +73 -0
- package/src/views/request-log/index.ts +2 -0
- package/src/views/request-log/runtime.tsx +47 -0
- package/src/views/request-log/view.json +123 -0
- package/src/views/simple-test-view/index.ts +3 -0
- package/src/views/simple-test-view/runtime.tsx +85 -0
- package/src/views/simple-test-view/view.json +191 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +7 -0
- package/tsconfig.app.json +26 -0
- package/tsconfig.jest.json +6 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +24 -0
- package/vite.config.ts +11 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// src/components/aiAssistant.ts
|
|
2
|
+
import { FilterExpr, FilterSchemasAndGroups, FilterExprFieldNode } from '../framework/filters';
|
|
3
|
+
import { FilterFormState } from '../framework/filter-form-state';
|
|
4
|
+
import { buildInitialFormState, createDefaultFilterState, FormStateInitMode } from '../framework/state';
|
|
5
|
+
import { RefObject } from 'react';
|
|
6
|
+
import { Toast } from 'primereact/toast';
|
|
7
|
+
import { FilterState } from '../framework/state';
|
|
8
|
+
|
|
9
|
+
// --- Shared prompt and serialization helpers ---
|
|
10
|
+
export interface AIApi {
|
|
11
|
+
sendPrompt(
|
|
12
|
+
filterSchema: FilterSchemasAndGroups,
|
|
13
|
+
userPrompt: string,
|
|
14
|
+
setFormState: (state: FilterState) => void,
|
|
15
|
+
apiKey: string,
|
|
16
|
+
toast?: RefObject<Toast | null>
|
|
17
|
+
): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sanitizeFilterExpr(expr: FilterExpr): object {
|
|
21
|
+
if (expr.type === 'and' || expr.type === 'or') {
|
|
22
|
+
return {
|
|
23
|
+
type: expr.type,
|
|
24
|
+
filters: expr.filters.map(sanitizeFilterExpr)
|
|
25
|
+
};
|
|
26
|
+
} else if (expr.type === 'not') {
|
|
27
|
+
return {
|
|
28
|
+
type: expr.type,
|
|
29
|
+
child: sanitizeFilterExpr(expr.filter)
|
|
30
|
+
};
|
|
31
|
+
} else {
|
|
32
|
+
// For dropdown and multiselect, include items
|
|
33
|
+
if ((expr.value.type === 'dropdown' || expr.value.type === 'multiselect')) {
|
|
34
|
+
return {
|
|
35
|
+
type: expr.type,
|
|
36
|
+
field: expr.field,
|
|
37
|
+
items: expr.value.items
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
type: expr.type,
|
|
42
|
+
field: expr.field
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sanitizeFilterSchemaForAI(filterSchema: FilterSchemasAndGroups): object[] {
|
|
48
|
+
// Adapt to new schema shape
|
|
49
|
+
return filterSchema.filters.map((field) => ({
|
|
50
|
+
id: field.id,
|
|
51
|
+
expression: sanitizeFilterExpr(field.expression),
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildAiPrompt(filterSchema: FilterSchemasAndGroups, userPrompt: string): string {
|
|
56
|
+
const filterFormStateType = `type FilterFormState =
|
|
57
|
+
| { type: 'leaf'; value: any; }
|
|
58
|
+
| { type: 'and' | 'or'; children: FilterFormState[]; }
|
|
59
|
+
| { type: 'not'; child: FilterFormState; };`;
|
|
60
|
+
const sanitizedSchema = sanitizeFilterSchemaForAI(filterSchema);
|
|
61
|
+
const schemaStr = JSON.stringify(sanitizedSchema, null, 2);
|
|
62
|
+
const currentDate = new Date().toString();
|
|
63
|
+
return [
|
|
64
|
+
`Given the following filter schema (in JSON):`,
|
|
65
|
+
schemaStr,
|
|
66
|
+
'',
|
|
67
|
+
`And the following type definition for FilterFormState:`,
|
|
68
|
+
filterFormStateType,
|
|
69
|
+
'',
|
|
70
|
+
`The current date is: ${currentDate}`,
|
|
71
|
+
'',
|
|
72
|
+
`Generate a valid JSON object with filter IDs as keys and values containing filter state according to the filter expression in the schema, that matches a user request.`,
|
|
73
|
+
`For filter trees, preserve the structure of and/or/not according to the schema and the FilterFormState type.`,
|
|
74
|
+
`User request: ${userPrompt}`,
|
|
75
|
+
'',
|
|
76
|
+
`For date filters, always send the value as a plain string in standard date-time string format. Skip filters that are not relevant to the user request.`,
|
|
77
|
+
`Output only the object mapping filter IDs to FilterFormState, like: {"filter-id": {...filterFormState...}}`
|
|
78
|
+
].join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Helper to merge AI-generated filter object with current state
|
|
82
|
+
function mergeAiStateWithCurrent(currentState: FilterState, aiStateObject: Record<string, any>, filterSchema: FilterSchemasAndGroups): FilterState {
|
|
83
|
+
const newState = new Map(currentState);
|
|
84
|
+
|
|
85
|
+
// For each filter in the AI response, merge it with the corresponding current state
|
|
86
|
+
Object.entries(aiStateObject).forEach(([filterId, aiFilterState]) => {
|
|
87
|
+
const filterDef = filterSchema.filters.find(f => f.id === filterId);
|
|
88
|
+
if (filterDef) {
|
|
89
|
+
const currentFilterState = currentState.get(filterId) || buildInitialFormState(filterDef.expression);
|
|
90
|
+
const mergedFilterState = mergeFilterFormState(filterDef.expression, currentFilterState, aiFilterState);
|
|
91
|
+
newState.set(filterId, mergedFilterState);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return newState;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Recursively merge AI state into existing FilterFormState using schema-guided traversal
|
|
99
|
+
export function mergeFilterFormState(schema: FilterExpr, currentState: FilterFormState, aiState: any): FilterFormState {
|
|
100
|
+
if (!aiState) return currentState;
|
|
101
|
+
|
|
102
|
+
// Patch: If schema expects an 'in' or 'notIn' array but AI produced an OR list of single values, collapse to array
|
|
103
|
+
// Example AI output (FilterFormState shape): { type: 'or', children: [ { type: 'leaf', value: 'a' }, { type: 'leaf', value: 'b' } ] }
|
|
104
|
+
// We convert it to a single leaf with value ['a','b'] so downstream logic treats it as an IN list.
|
|
105
|
+
if ((schema.type === 'in' || schema.type === 'notIn') && aiState.type === 'or' && Array.isArray(aiState.children)) {
|
|
106
|
+
const collectValues = (node: any, acc: any[]) => {
|
|
107
|
+
if (!node) return acc;
|
|
108
|
+
if (node.type === 'leaf') {
|
|
109
|
+
if (node.value !== undefined && node.value !== '') {
|
|
110
|
+
acc.push(node.value);
|
|
111
|
+
}
|
|
112
|
+
} else if (node.type === 'or' && Array.isArray(node.children)) {
|
|
113
|
+
node.children.forEach((c: any) => collectValues(c, acc));
|
|
114
|
+
}
|
|
115
|
+
return acc;
|
|
116
|
+
};
|
|
117
|
+
const values = Array.from(new Set(collectValues(aiState, [])));
|
|
118
|
+
return {
|
|
119
|
+
...currentState,
|
|
120
|
+
type: 'leaf',
|
|
121
|
+
value: values
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Special case: AI returns NOT wrapped around a leaf for a customOperator field - convert to not-equals
|
|
126
|
+
if (currentState.type === 'leaf' && aiState.type === 'not' && aiState.child?.type === 'leaf') {
|
|
127
|
+
const schemaField = schema as FilterExprFieldNode;
|
|
128
|
+
const control = schemaField.value;
|
|
129
|
+
|
|
130
|
+
if (control.type === 'customOperator') {
|
|
131
|
+
const childValue = aiState.child.value;
|
|
132
|
+
const notEqualsOperator = control.operators?.find((op: any) =>
|
|
133
|
+
op.value.includes('neq') || op.value.includes('not_equals') || op.label.toLowerCase().includes('not equals')
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (notEqualsOperator) {
|
|
137
|
+
const value = typeof childValue === 'string' ?
|
|
138
|
+
{ operator: notEqualsOperator.value, value: childValue } :
|
|
139
|
+
childValue;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
...currentState,
|
|
143
|
+
value: value
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (currentState.type === 'leaf' && aiState.type === 'leaf') {
|
|
150
|
+
let value = aiState.value;
|
|
151
|
+
|
|
152
|
+
// Use control info from schema for customOperator
|
|
153
|
+
const schemaField = schema as FilterExprFieldNode;
|
|
154
|
+
const control = schemaField.value;
|
|
155
|
+
|
|
156
|
+
// Patch for 'in' and 'notIn' to ensure value is an array
|
|
157
|
+
if (schemaField.type === 'in' || schemaField.type === 'notIn') {
|
|
158
|
+
if (!Array.isArray(value)) {
|
|
159
|
+
value = [value];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Patch customOperator if AI returned a plain string
|
|
164
|
+
if (control.type === 'customOperator' && typeof value === 'string') {
|
|
165
|
+
const defaultOperator = control.operators?.[0]?.value;
|
|
166
|
+
value = { operator: defaultOperator, value: value };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Create Date objects from ISO strings for date fields
|
|
170
|
+
if (control.type === 'date' && typeof value === 'string') {
|
|
171
|
+
const date = new Date(value);
|
|
172
|
+
if (!isNaN(date.getTime())) {
|
|
173
|
+
value = date;
|
|
174
|
+
} else {
|
|
175
|
+
console.warn(`Failed to parse date for field ${schemaField.field}:`, value);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
...currentState,
|
|
181
|
+
value: value
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (currentState.type === 'and' && schema.type === 'and' &&
|
|
186
|
+
aiState.type === 'and' && Array.isArray(aiState.children)) {
|
|
187
|
+
return {
|
|
188
|
+
...currentState,
|
|
189
|
+
children: currentState.children.map((child, i) => {
|
|
190
|
+
const childSchema = schema.filters[i];
|
|
191
|
+
const childAiState = aiState.children[i];
|
|
192
|
+
return childSchema ?
|
|
193
|
+
mergeFilterFormState(childSchema, child, childAiState) :
|
|
194
|
+
child;
|
|
195
|
+
})
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (currentState.type === 'or' && schema.type === 'or' &&
|
|
200
|
+
aiState.type === 'or' && Array.isArray(aiState.children)) {
|
|
201
|
+
return {
|
|
202
|
+
...currentState,
|
|
203
|
+
children: currentState.children.map((child, i) => {
|
|
204
|
+
const childSchema = schema.filters[i];
|
|
205
|
+
const childAiState = aiState.children[i];
|
|
206
|
+
return childSchema ?
|
|
207
|
+
mergeFilterFormState(childSchema, child, childAiState) :
|
|
208
|
+
child;
|
|
209
|
+
})
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (currentState.type === 'not' && schema.type === 'not' && aiState.type === 'not') {
|
|
214
|
+
return {
|
|
215
|
+
...currentState,
|
|
216
|
+
child: mergeFilterFormState(schema.filter, currentState.child, aiState.child)
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return currentState;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- Gemini Flash-Lite implementation ---
|
|
224
|
+
export const GeminiApi: AIApi = {
|
|
225
|
+
async sendPrompt(filterSchema, userPrompt, setFormState, geminiApiKey, toast) {
|
|
226
|
+
const prompt = buildAiPrompt(filterSchema, userPrompt);
|
|
227
|
+
try {
|
|
228
|
+
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent?key=${geminiApiKey}`,
|
|
229
|
+
{
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers: {
|
|
232
|
+
'Content-Type': 'application/json',
|
|
233
|
+
},
|
|
234
|
+
body: JSON.stringify({
|
|
235
|
+
contents: [{ role: 'user', parts: [{ text: prompt }] }]
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
if (!response.ok) throw new Error('Gemini API error');
|
|
240
|
+
const data = await response.json();
|
|
241
|
+
const aiContent = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
242
|
+
// Use [\s\S] instead of dot-all flag for compatibility
|
|
243
|
+
const match = aiContent.match(/\{[\s\S]*\}/);
|
|
244
|
+
if (match) {
|
|
245
|
+
const parsed = JSON.parse(match[0]);
|
|
246
|
+
|
|
247
|
+
// Make an empty state and merge with AI response
|
|
248
|
+
const currentState = createDefaultFilterState(filterSchema, FormStateInitMode.Empty);
|
|
249
|
+
const mergedState = mergeAiStateWithCurrent(currentState, parsed, filterSchema);
|
|
250
|
+
|
|
251
|
+
setFormState(mergedState);
|
|
252
|
+
} else {
|
|
253
|
+
const errorMessage = 'Could not parse FilterFormState from Gemini response. Check the console.';
|
|
254
|
+
if (toast?.current) {
|
|
255
|
+
toast.current.show({
|
|
256
|
+
severity: 'warn',
|
|
257
|
+
summary: 'Parse Error',
|
|
258
|
+
detail: errorMessage,
|
|
259
|
+
life: 3000
|
|
260
|
+
});
|
|
261
|
+
} else {
|
|
262
|
+
alert(errorMessage);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch (err) {
|
|
266
|
+
console.error(err);
|
|
267
|
+
const errorMessage = 'Failed to get response from Gemini API.';
|
|
268
|
+
if (toast?.current) {
|
|
269
|
+
toast.current.show({
|
|
270
|
+
severity: 'error',
|
|
271
|
+
summary: 'API Error',
|
|
272
|
+
detail: errorMessage,
|
|
273
|
+
life: 3000
|
|
274
|
+
});
|
|
275
|
+
} else {
|
|
276
|
+
alert(errorMessage);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
export function generateFilterWithAI(
|
|
283
|
+
filterSchema: FilterSchemasAndGroups,
|
|
284
|
+
userPrompt: string,
|
|
285
|
+
setFormState: (state: FilterState) => void,
|
|
286
|
+
apiImpl: AIApi,
|
|
287
|
+
geminiApiKey: string,
|
|
288
|
+
toast?: RefObject<Toast | null>
|
|
289
|
+
): Promise<void> {
|
|
290
|
+
return apiImpl.sendPrompt(filterSchema, userPrompt, setFormState, geminiApiKey, toast);
|
|
291
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// React import not required; file uses JSX via TSX but React 17+ JSX transform auto-injects.
|
|
2
|
+
|
|
3
|
+
import { resolveLocale, getCurrencyFractionDigits } from '../currency';
|
|
4
|
+
|
|
5
|
+
export interface CurrencyAmountProps {
|
|
6
|
+
amount: number | string;
|
|
7
|
+
currency: string;
|
|
8
|
+
locale?: string;
|
|
9
|
+
options?: Intl.NumberFormatOptions;
|
|
10
|
+
className?: string;
|
|
11
|
+
fractionDigitsOverride?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function CurrencyAmount({ amount, currency, locale, options = {}, className = '', fractionDigitsOverride }: CurrencyAmountProps) {
|
|
15
|
+
const parsedAmount = Number(amount);
|
|
16
|
+
if (isNaN(parsedAmount)) return null;
|
|
17
|
+
const resolvedLocale = resolveLocale(locale);
|
|
18
|
+
const fractionDigits = typeof fractionDigitsOverride === 'number' ? fractionDigitsOverride : getCurrencyFractionDigits(currency, resolvedLocale);
|
|
19
|
+
const formatOptions: Intl.NumberFormatOptions = {
|
|
20
|
+
style: 'currency',
|
|
21
|
+
currency,
|
|
22
|
+
minimumFractionDigits: fractionDigits,
|
|
23
|
+
maximumFractionDigits: fractionDigits,
|
|
24
|
+
...options
|
|
25
|
+
};
|
|
26
|
+
const formatter = new Intl.NumberFormat(resolvedLocale, formatOptions);
|
|
27
|
+
return <span className={className}>{formatter.format(parsedAmount)}</span>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default CurrencyAmount;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
function wrapStringChildren(children: React.ReactNode) {
|
|
4
|
+
if (typeof children === "string") {
|
|
5
|
+
return <span>{children}</span>;
|
|
6
|
+
}
|
|
7
|
+
if (Array.isArray(children)) {
|
|
8
|
+
return children.map((child, i) =>
|
|
9
|
+
typeof child === "string" ? <span key={i}>{child}</span> : child
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return children;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getAlignClass(align?: string) {
|
|
16
|
+
switch (align) {
|
|
17
|
+
case 'start': return 'tw:items-start';
|
|
18
|
+
case 'center': return 'tw:items-center';
|
|
19
|
+
case 'end': return 'tw:items-end';
|
|
20
|
+
case 'stretch': return 'tw:items-stretch';
|
|
21
|
+
case 'baseline': return 'tw:items-baseline';
|
|
22
|
+
default: return '';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getJustifyClass(justify?: string) {
|
|
27
|
+
switch (justify) {
|
|
28
|
+
case 'start': return 'tw:justify-start';
|
|
29
|
+
case 'center': return 'tw:justify-center';
|
|
30
|
+
case 'end': return 'tw:justify-end';
|
|
31
|
+
case 'between': return 'tw:justify-between';
|
|
32
|
+
case 'around': return 'tw:justify-around';
|
|
33
|
+
case 'evenly': return 'tw:justify-evenly';
|
|
34
|
+
default: return '';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getWrapClass(wrap?: string | boolean) {
|
|
39
|
+
if (wrap === true || wrap === 'wrap') return 'tw:flex-wrap';
|
|
40
|
+
if (wrap === 'nowrap') return 'tw:flex-nowrap';
|
|
41
|
+
if (wrap === 'wrap-reverse') return 'tw:flex-wrap-reverse';
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Horizontal stack (row) with gap
|
|
46
|
+
export function FlexRow({ gap = "tw:gap-2", className = "", align, justify, wrap, children }: { gap?: string; className?: string; align?: string; justify?: string; wrap?: string | boolean; children: React.ReactNode }) {
|
|
47
|
+
return (
|
|
48
|
+
<div className={`tw:flex tw:flex-row ${gap} ${getAlignClass(align)} ${getJustifyClass(justify)} ${getWrapClass(wrap)} ${className}`.trim()}>
|
|
49
|
+
{wrapStringChildren(children)}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Vertical stack (column) with gap
|
|
55
|
+
export function FlexColumn({ gap = "tw:gap-2", className = "", align, justify, children }: { gap?: string; className?: string; align?: string; justify?: string; children: React.ReactNode }) {
|
|
56
|
+
return (
|
|
57
|
+
<div className={`tw:flex tw:flex-col ${gap} ${getAlignClass(align)} ${getJustifyClass(justify)} ${className}`.trim()}>
|
|
58
|
+
{wrapStringChildren(children)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Spacer for use in flex layouts
|
|
64
|
+
export function Spacer() {
|
|
65
|
+
return <div className="tw:flex-1" />;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// FormattedDate: formats a date string using toLocaleString
|
|
69
|
+
export function DateTime({ date, locale = undefined, options = undefined, className = "" }: { date: string; locale?: string; options?: Intl.DateTimeFormatOptions; className?: string }) {
|
|
70
|
+
if (!date) return null;
|
|
71
|
+
const d = new Date(date);
|
|
72
|
+
if (isNaN(d.getTime())) return <span className={className}>{date}</span>;
|
|
73
|
+
return <span className={className}>{d.toLocaleString(locale, options)}</span>;
|
|
74
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface LinkProps {
|
|
4
|
+
text: string;
|
|
5
|
+
href: string;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A reusable Link component for use throughout the application.
|
|
11
|
+
* Provides consistent styling and behavior for links.
|
|
12
|
+
*/
|
|
13
|
+
export const Link: React.FC<LinkProps> = ({
|
|
14
|
+
text,
|
|
15
|
+
href,
|
|
16
|
+
className = "tw:text-blue-500 tw:underline hover:tw:text-blue-700 tw:cursor-pointer",
|
|
17
|
+
}) => {
|
|
18
|
+
return (
|
|
19
|
+
<a
|
|
20
|
+
href={href}
|
|
21
|
+
className={className}
|
|
22
|
+
>
|
|
23
|
+
{text}
|
|
24
|
+
</a>
|
|
25
|
+
);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default Link;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generic mapping component for displaying mapped values.
|
|
5
|
+
* @param value The key to map.
|
|
6
|
+
* @param map The mapping object.
|
|
7
|
+
* @param fallback Optional fallback if value is not found.
|
|
8
|
+
*/
|
|
9
|
+
export function Mapping<T extends string | number, U>({ value, map, fallback }: { value: T; map: Record<T, U>; fallback?: React.ReactNode }) {
|
|
10
|
+
return <>{map[value] ?? fallback ?? value}</>;
|
|
11
|
+
}
|