@asteby/metacore-runtime-react 5.0.0 → 6.1.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 6.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [1c93e68]
8
+ - @asteby/metacore-ui@0.6.0
9
+
3
10
  ## 5.0.0
4
11
 
5
12
  ### Patch Changes
@@ -0,0 +1,29 @@
1
+ import type { GetDynamicColumns } from './dynamic-columns-shim';
2
+ /** Host-supplied helpers consumed by avatar/image cell renderers. */
3
+ export interface DynamicColumnsHelpers {
4
+ /**
5
+ * Resolves a relative or absolute media path into a renderable URL. Hosts
6
+ * typically prepend their CDN/storage base. If omitted, paths are passed
7
+ * through verbatim.
8
+ */
9
+ getImageUrl?: (path: string) => string;
10
+ /**
11
+ * API origin used to build avatar URLs when the row carries a bare filename
12
+ * instead of an absolute URL or sibling `.avatar` field. Usually
13
+ * `import.meta.env.VITE_API_URL.replace('/api', '')`.
14
+ */
15
+ apiBaseUrl?: string;
16
+ }
17
+ /**
18
+ * Builds the canonical column factory used by `<DynamicTable>` when the host
19
+ * does not supply its own. Pass `{ getImageUrl, apiBaseUrl }` to wire avatar
20
+ * URL resolution.
21
+ */
22
+ export declare function makeDefaultGetDynamicColumns(helpers?: DynamicColumnsHelpers): GetDynamicColumns;
23
+ /**
24
+ * Eager-built variant — equivalent to `makeDefaultGetDynamicColumns()`. Use
25
+ * this when the host has no helpers to inject and a stable function reference
26
+ * suffices.
27
+ */
28
+ export declare const defaultGetDynamicColumns: GetDynamicColumns;
29
+ //# sourceMappingURL=dynamic-columns.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AAwHD;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CA+UnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
@@ -0,0 +1,308 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Default `getDynamicColumns` factory used by hosts that don't need a custom
3
+ // renderer. Supports every cell type produced by kernel/dynamic metadata:
4
+ // badge (static + endpoint-loaded options), avatar, phone, date, boolean,
5
+ // relation-badge-list, media-gallery, image, plus a generic text fallback.
6
+ //
7
+ // The implementation was previously duplicated across `link` and `ops`
8
+ // (~550 LOC each, drifting). It now lives here so a single fix propagates
9
+ // to every host. Hosts inject app-specific URL helpers via the `helpers`
10
+ // argument so the SDK stays free of environment-bound code.
11
+ import * as React from 'react';
12
+ import { format } from 'date-fns';
13
+ import { es, enUS } from 'date-fns/locale';
14
+ import * as icons from 'lucide-react';
15
+ import { MoreHorizontal } from 'lucide-react';
16
+ import { Avatar, AvatarFallback, AvatarImage, Badge, Button, Checkbox, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@asteby/metacore-ui';
17
+ import { DataTableColumnHeader, FilterableColumnHeader, } from '@asteby/metacore-ui/data-table';
18
+ import { generateBadgeStyles } from '@asteby/metacore-ui/lib';
19
+ import { OptionsContext } from './options-context';
20
+ import { DynamicIcon } from './dynamic-icon';
21
+ const defaultGetImageUrl = (path) => path;
22
+ const getNestedValue = (obj, path) => path.split('.').reduce((acc, part) => acc && acc[part], obj);
23
+ const lowerFirst = (value) => {
24
+ if (!value)
25
+ return value;
26
+ return value.charAt(0).toLowerCase() + value.slice(1);
27
+ };
28
+ const getPathVariants = (path) => {
29
+ if (!path)
30
+ return [];
31
+ const normalized = path
32
+ .split('.')
33
+ .map((segment) => lowerFirst(segment) || segment)
34
+ .join('.');
35
+ return Array.from(new Set([path, normalized])).filter(Boolean);
36
+ };
37
+ const getValueFromPathVariants = (obj, path) => {
38
+ if (!path)
39
+ return undefined;
40
+ for (const candidate of getPathVariants(path)) {
41
+ const value = getNestedValue(obj, candidate);
42
+ if (value !== undefined && value !== null)
43
+ return value;
44
+ }
45
+ return undefined;
46
+ };
47
+ const useIsDarkTheme = () => {
48
+ const [isDark, setIsDark] = React.useState(() => typeof document !== 'undefined' &&
49
+ document.documentElement.classList.contains('dark'));
50
+ React.useEffect(() => {
51
+ if (typeof document === 'undefined')
52
+ return;
53
+ const sync = () => setIsDark(document.documentElement.classList.contains('dark'));
54
+ sync();
55
+ const observer = new MutationObserver(sync);
56
+ observer.observe(document.documentElement, {
57
+ attributes: true,
58
+ attributeFilter: ['class'],
59
+ });
60
+ return () => observer.disconnect();
61
+ }, []);
62
+ return isDark;
63
+ };
64
+ const renderRelationBadges = (items, col) => {
65
+ if (!Array.isArray(items) || items.length === 0) {
66
+ return _jsx("span", { className: "text-muted-foreground", children: "-" });
67
+ }
68
+ return (_jsx("div", { className: "flex flex-wrap gap-1", children: items.map((item, idx) => {
69
+ const relationTarget = col.relationPath
70
+ ? getValueFromPathVariants(item, col.relationPath) ?? item
71
+ : item;
72
+ const displaySource = relationTarget ?? item;
73
+ let displayValue = col.displayField !== undefined && col.displayField !== null
74
+ ? getValueFromPathVariants(displaySource, col.displayField)
75
+ : displaySource;
76
+ if (displayValue === undefined || displayValue === null) {
77
+ displayValue = displaySource;
78
+ }
79
+ const label = displayValue !== undefined && displayValue !== null
80
+ ? String(displayValue)
81
+ : '-';
82
+ let iconValue;
83
+ if (col.iconField) {
84
+ const rawIcon = getValueFromPathVariants(displaySource, col.iconField);
85
+ if (rawIcon !== undefined && rawIcon !== null) {
86
+ iconValue = String(rawIcon);
87
+ }
88
+ }
89
+ return (_jsxs(Badge, { variant: "outline", className: "flex items-center gap-1", children: [iconValue && (_jsx(DynamicIcon, { name: iconValue, className: "h-3 w-3" })), _jsx("span", { children: label })] }, `${col.key}-${idx}`));
90
+ }) }));
91
+ };
92
+ const OptionBadge = ({ option }) => {
93
+ const isDark = useIsDarkTheme();
94
+ const colorStyles = option.color ? generateBadgeStyles(option.color, { isDark }) : {};
95
+ return (_jsxs(Badge, { variant: "outline", className: "flex items-center gap-1 border-0", style: colorStyles, children: [option.icon && _jsx(DynamicIcon, { name: option.icon, className: "h-3.5 w-3.5" }), _jsx("span", { children: option.label })] }));
96
+ };
97
+ const BadgeWithEndpointOptions = ({ endpoint, value }) => {
98
+ const { optionsMap } = React.useContext(OptionsContext);
99
+ const options = optionsMap.get(endpoint) || [];
100
+ const option = options.find((opt) => opt.value === value);
101
+ if (option)
102
+ return _jsx(OptionBadge, { option: option, fallback: String(value) });
103
+ return _jsx(Badge, { variant: "outline", children: String(value) });
104
+ };
105
+ /**
106
+ * Builds the canonical column factory used by `<DynamicTable>` when the host
107
+ * does not supply its own. Pass `{ getImageUrl, apiBaseUrl }` to wire avatar
108
+ * URL resolution.
109
+ */
110
+ export function makeDefaultGetDynamicColumns(helpers = {}) {
111
+ const getImageUrl = helpers.getImageUrl ?? defaultGetImageUrl;
112
+ const apiBaseUrl = helpers.apiBaseUrl ?? '';
113
+ return function defaultGetDynamicColumns(metadata, onAction, t, currentLanguage, filterConfigs) {
114
+ const dateLocale = currentLanguage === 'en' ? enUS : es;
115
+ const columns = [
116
+ {
117
+ id: 'select',
118
+ header: ({ table }) => (_jsx(Checkbox, { checked: table.getIsAllPageRowsSelected() ||
119
+ (table.getIsSomePageRowsSelected() && 'indeterminate'), onCheckedChange: (value) => table.toggleAllPageRowsSelected(!!value), "aria-label": "Select all", className: "translate-y-[2px]" })),
120
+ cell: ({ row }) => (_jsx(Checkbox, { checked: row.getIsSelected(), onCheckedChange: (value) => row.toggleSelected(!!value), "aria-label": "Select row", className: "translate-y-[2px]" })),
121
+ enableSorting: false,
122
+ enableHiding: false,
123
+ },
124
+ ];
125
+ metadata.columns.forEach((col) => {
126
+ if (col.hidden)
127
+ return;
128
+ const translatedLabel = col.label;
129
+ const filterConfig = filterConfigs?.get(col.key);
130
+ const columnMeta = {
131
+ label: translatedLabel,
132
+ };
133
+ if (filterConfig) {
134
+ const fm = {
135
+ filterable: true,
136
+ filterType: filterConfig.filterType,
137
+ filterKey: filterConfig.filterKey,
138
+ filterOptions: filterConfig.options,
139
+ filterLoading: filterConfig.loading,
140
+ filterSearchEndpoint: filterConfig.searchEndpoint,
141
+ selectedValues: filterConfig.selectedValues,
142
+ onFilterChange: filterConfig.onFilterChange,
143
+ };
144
+ Object.assign(columnMeta, fm);
145
+ }
146
+ columns.push({
147
+ accessorKey: col.key,
148
+ id: col.key,
149
+ meta: columnMeta,
150
+ header: ({ column }) => filterConfig ? (_jsx(FilterableColumnHeader, { column: column, title: translatedLabel })) : (_jsx(DataTableColumnHeader, { column: column, title: translatedLabel })),
151
+ cell: ({ row }) => {
152
+ const value = getNestedValue(row.original, col.key);
153
+ // Endpoint-loaded badge options (preloaded into OptionsContext)
154
+ if (col.cellStyle === 'badge' && col.useOptions && col.searchEndpoint) {
155
+ if (!value)
156
+ return _jsx("span", { className: "text-muted-foreground", children: "-" });
157
+ return _jsx(BadgeWithEndpointOptions, { endpoint: col.searchEndpoint, value: value });
158
+ }
159
+ // Static badge options — map value → label/icon/color
160
+ if (col.cellStyle === 'badge' && col.options && col.options.length > 0) {
161
+ if (!value && value !== 0)
162
+ return _jsx("span", { className: "text-muted-foreground", children: "-" });
163
+ const option = col.options.find((o) => o.value === String(value));
164
+ if (option)
165
+ return _jsx(OptionBadge, { option: option, fallback: String(value) });
166
+ return _jsx(Badge, { variant: "outline", children: String(value) });
167
+ }
168
+ if (col.cellStyle === 'relation-badge-list') {
169
+ return renderRelationBadges(value, col);
170
+ }
171
+ switch (col.type) {
172
+ case 'date': {
173
+ if (!value)
174
+ return _jsx("span", { className: "text-muted-foreground", children: "-" });
175
+ try {
176
+ const date = new Date(value);
177
+ if (isNaN(date.getTime()) || date.getFullYear() <= 1) {
178
+ return _jsx("span", { className: "text-muted-foreground", children: "-" });
179
+ }
180
+ return (_jsxs("div", { className: "flex items-center gap-1.5 text-muted-foreground", children: [_jsx(icons.Calendar, { className: "h-3.5 w-3.5 opacity-70" }), _jsx("span", { className: "text-sm font-medium", children: format(date, 'PPP', { locale: dateLocale }) })] }));
181
+ }
182
+ catch {
183
+ return _jsx("span", { children: String(value) });
184
+ }
185
+ }
186
+ case 'search':
187
+ case 'avatar': {
188
+ const namePath = col.tooltip || col.key;
189
+ const name = getNestedValue(row.original, namePath) || 'N/A';
190
+ const desc = getNestedValue(row.original, col.description || '');
191
+ let avatarSrc;
192
+ if (col.key.includes('.')) {
193
+ const parentPath = col.key.split('.').slice(0, -1).join('.');
194
+ const avatarPath = `${parentPath}.avatar`;
195
+ const possibleAvatar = getNestedValue(row.original, avatarPath);
196
+ if (possibleAvatar)
197
+ avatarSrc = String(possibleAvatar);
198
+ }
199
+ else if (value &&
200
+ (String(value).startsWith('http') || String(value).startsWith('https'))) {
201
+ avatarSrc = String(value);
202
+ }
203
+ else if (value) {
204
+ avatarSrc = `${apiBaseUrl}${col.basePath || ''}${value}`;
205
+ }
206
+ return (_jsxs("div", { className: "flex items-center gap-3 min-w-0", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg ring-1 ring-border/50", children: [_jsx(AvatarImage, { src: getImageUrl(avatarSrc || ''), alt: String(name), className: "object-cover" }), _jsx(AvatarFallback, { className: "text-[10px] font-bold bg-primary/5 text-primary rounded-lg", children: String(name)
207
+ .split(' ')
208
+ .map((n) => n[0])
209
+ .slice(0, 2)
210
+ .join('')
211
+ .toUpperCase() })] }), _jsxs("div", { className: "flex flex-col min-w-0 overflow-hidden", children: [_jsx("span", { className: "font-medium text-sm truncate leading-none mb-0.5 text-foreground/90", children: String(name) }), desc && (_jsx("span", { className: "text-[11px] text-muted-foreground truncate leading-none", children: String(desc) }))] })] }));
212
+ }
213
+ case 'relation-badge-list':
214
+ return renderRelationBadges(value, col);
215
+ case 'phone': {
216
+ if (!value)
217
+ return _jsx("span", { className: "text-muted-foreground", children: "-" });
218
+ return _jsx("span", { className: "font-medium text-sm", children: String(value) });
219
+ }
220
+ case 'boolean':
221
+ return value ? _jsx(Badge, { children: "S\u00ED" }) : _jsx(Badge, { variant: "secondary", children: "No" });
222
+ case 'media-gallery': {
223
+ if (!value || (Array.isArray(value) && value.length === 0)) {
224
+ return _jsx("span", { className: "text-muted-foreground", children: "-" });
225
+ }
226
+ const mediaItems = Array.isArray(value) ? value : [];
227
+ const visibleItems = mediaItems.slice(0, 3);
228
+ const remaining = mediaItems.length - 3;
229
+ return (_jsxs("div", { className: "flex -space-x-2 overflow-hidden", children: [visibleItems.map((item, i) => {
230
+ const src = item.url;
231
+ if (item.type === 'image') {
232
+ return (_jsxs(Avatar, { className: "inline-block h-8 w-8 rounded-full ring-2 ring-background", children: [_jsx(AvatarImage, { src: src, className: "object-cover" }), _jsx(AvatarFallback, { children: item.type?.[0] })] }, i));
233
+ }
234
+ return (_jsx("div", { className: "inline-flex h-8 w-8 items-center justify-center rounded-full bg-muted ring-2 ring-background", children: _jsx(DynamicIcon, { name: item.type === 'video'
235
+ ? 'Video'
236
+ : item.type === 'audio'
237
+ ? 'AudioLines'
238
+ : 'FileText', className: "h-4 w-4" }) }, i));
239
+ }), remaining > 0 && (_jsxs("div", { className: "flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-medium ring-2 ring-background", children: ["+", remaining] }))] }));
240
+ }
241
+ case 'image': {
242
+ const imageValue = value ||
243
+ (Array.isArray(row.original.media)
244
+ ? row.original.media.find((m) => m.type === 'image')?.url
245
+ : null);
246
+ if (!imageValue)
247
+ return _jsx("span", { className: "text-muted-foreground", children: "-" });
248
+ return (_jsx("div", { className: "h-10 w-10 relative rounded overflow-hidden bg-muted flex items-center justify-center", children: _jsx("img", { src: getImageUrl(String(imageValue)), alt: "Thumbnail", className: "h-full w-full object-contain", onError: (e) => {
249
+ ;
250
+ e.currentTarget.style.display = 'none';
251
+ } }) }));
252
+ }
253
+ default: {
254
+ if (typeof value === 'object' && value !== null) {
255
+ return (_jsx("span", { className: "text-muted-foreground text-xs", children: JSON.stringify(value) }));
256
+ }
257
+ if (col.key === 'description' ||
258
+ col.key === 'features' ||
259
+ col.key.includes('description')) {
260
+ return (_jsx("div", { className: "max-w-[350px]", title: String(value), children: _jsx("span", { className: "truncate font-medium block", children: value !== null && value !== undefined ? String(value) : '-' }) }));
261
+ }
262
+ return (_jsx("span", { className: "truncate font-medium", children: value !== null && value !== undefined ? String(value) : '-' }));
263
+ }
264
+ }
265
+ },
266
+ enableSorting: col.sortable,
267
+ enableHiding: true,
268
+ });
269
+ });
270
+ if (metadata.hasActions && metadata.actions.length > 0) {
271
+ columns.push({
272
+ id: 'actions',
273
+ header: () => _jsx("div", { className: "text-right", children: t ? t('common.actions') : 'Acciones' }),
274
+ size: 80,
275
+ maxSize: 80,
276
+ meta: {},
277
+ cell: ({ row }) => (_jsx("div", { className: "flex items-center justify-end", children: _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { variant: "ghost", className: "h-8 w-8 p-0", children: [_jsx("span", { className: "sr-only", children: "Abrir men\u00FA" }), _jsx(MoreHorizontal, { className: "h-4 w-4" })] }) }), _jsx(DropdownMenuContent, { align: "end", children: metadata.actions
278
+ .filter((action) => {
279
+ if (!action.condition)
280
+ return true;
281
+ const { field, operator, value } = action.condition;
282
+ const rowValue = String(row.original[field] ?? '');
283
+ const values = Array.isArray(value) ? value : [value];
284
+ switch (operator) {
285
+ case 'eq':
286
+ return rowValue === values[0];
287
+ case 'neq':
288
+ return rowValue !== values[0];
289
+ case 'in':
290
+ return values.includes(rowValue);
291
+ case 'not_in':
292
+ return !values.includes(rowValue);
293
+ default:
294
+ return true;
295
+ }
296
+ })
297
+ .map((action) => (_jsxs(DropdownMenuItem, { onClick: () => onAction && onAction(action.key, row.original), children: [_jsx(DynamicIcon, { name: action.icon, className: "mr-2 h-4 w-4" }), action.label] }, action.key))) })] }) })),
298
+ });
299
+ }
300
+ return columns;
301
+ };
302
+ }
303
+ /**
304
+ * Eager-built variant — equivalent to `makeDefaultGetDynamicColumns()`. Use
305
+ * this when the host has no helpers to inject and a stable function reference
306
+ * suffices.
307
+ */
308
+ export const defaultGetDynamicColumns = makeDefaultGetDynamicColumns();
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export * from './api-context';
12
12
  export * from './metadata-cache';
13
13
  export * from './dynamic-icon';
14
14
  export type { ColumnFilterConfig, FilterOption as DynamicColumnFilterOption, GetDynamicColumns, DynamicIconComponent, } from './dynamic-columns-shim';
15
+ export { defaultGetDynamicColumns, makeDefaultGetDynamicColumns, type DynamicColumnsHelpers, } from './dynamic-columns';
15
16
  export { DynamicRecordDialog } from './dialogs/dynamic-record';
16
17
  export { ExportDialog } from './dialogs/export';
17
18
  export { ImportDialog } from './dialogs/import';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,SAAS,CAAA;AACvB,cAAc,mBAAmB,CAAA;AACjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,gBAAgB,CAAA;AAC9B,OAAO,EACH,qBAAqB,EACrB,KAAK,gBAAgB,GACxB,MAAM,2BAA2B,CAAA;AAClC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,QAAQ,CAAA;AACtB,cAAc,mBAAmB,CAAA;AACjC,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,kBAAkB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,YAAY,EACR,kBAAkB,EAClB,YAAY,IAAI,yBAAyB,EACzC,iBAAiB,EACjB,oBAAoB,GACvB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,SAAS,CAAA;AACvB,cAAc,mBAAmB,CAAA;AACjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,gBAAgB,CAAA;AAC9B,OAAO,EACH,qBAAqB,EACrB,KAAK,gBAAgB,GACxB,MAAM,2BAA2B,CAAA;AAClC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,QAAQ,CAAA;AACtB,cAAc,mBAAmB,CAAA;AACjC,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,kBAAkB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,YAAY,EACR,kBAAkB,EAClB,YAAY,IAAI,yBAAyB,EACzC,iBAAiB,EACjB,oBAAoB,GACvB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACH,wBAAwB,EACxB,4BAA4B,EAC5B,KAAK,qBAAqB,GAC7B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA"}
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ export * from './i18n-provider';
16
16
  export * from './api-context';
17
17
  export * from './metadata-cache';
18
18
  export * from './dynamic-icon';
19
+ export { defaultGetDynamicColumns, makeDefaultGetDynamicColumns, } from './dynamic-columns';
19
20
  export { DynamicRecordDialog } from './dialogs/dynamic-record';
20
21
  export { ExportDialog } from './dialogs/export';
21
22
  export { ImportDialog } from './dialogs/import';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "5.0.0",
3
+ "version": "6.1.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,8 +29,8 @@
29
29
  "lucide-react": ">=0.460",
30
30
  "date-fns": ">=3",
31
31
  "react-day-picker": ">=8",
32
- "@asteby/metacore-ui": "^0.5.0",
33
- "@asteby/metacore-sdk": "^2.2.0"
32
+ "@asteby/metacore-sdk": "^2.2.0",
33
+ "@asteby/metacore-ui": "^0.6.0"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
36
  "@tanstack/react-router": {
@@ -56,7 +56,7 @@
56
56
  "typescript": "^5.6.0",
57
57
  "zustand": "^5.0.0",
58
58
  "@asteby/metacore-sdk": "2.2.0",
59
- "@asteby/metacore-ui": "0.5.0"
59
+ "@asteby/metacore-ui": "0.6.0"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsc -p tsconfig.json",
@@ -0,0 +1,527 @@
1
+ // Default `getDynamicColumns` factory used by hosts that don't need a custom
2
+ // renderer. Supports every cell type produced by kernel/dynamic metadata:
3
+ // badge (static + endpoint-loaded options), avatar, phone, date, boolean,
4
+ // relation-badge-list, media-gallery, image, plus a generic text fallback.
5
+ //
6
+ // The implementation was previously duplicated across `link` and `ops`
7
+ // (~550 LOC each, drifting). It now lives here so a single fix propagates
8
+ // to every host. Hosts inject app-specific URL helpers via the `helpers`
9
+ // argument so the SDK stays free of environment-bound code.
10
+
11
+ import * as React from 'react'
12
+ import { ColumnDef } from '@tanstack/react-table'
13
+ import { format } from 'date-fns'
14
+ import { es, enUS } from 'date-fns/locale'
15
+ import * as icons from 'lucide-react'
16
+ import { MoreHorizontal } from 'lucide-react'
17
+ import {
18
+ Avatar,
19
+ AvatarFallback,
20
+ AvatarImage,
21
+ Badge,
22
+ Button,
23
+ Checkbox,
24
+ DropdownMenu,
25
+ DropdownMenuContent,
26
+ DropdownMenuItem,
27
+ DropdownMenuTrigger,
28
+ } from '@asteby/metacore-ui'
29
+ import {
30
+ DataTableColumnHeader,
31
+ FilterableColumnHeader,
32
+ type ColumnFilterMeta,
33
+ } from '@asteby/metacore-ui/data-table'
34
+ import { generateBadgeStyles } from '@asteby/metacore-ui/lib'
35
+ import { OptionsContext } from './options-context'
36
+ import { DynamicIcon } from './dynamic-icon'
37
+ import type { TableMetadata, ColumnDefinition } from './types'
38
+ import type {
39
+ ColumnFilterConfig,
40
+ GetDynamicColumns,
41
+ } from './dynamic-columns-shim'
42
+
43
+ /** Host-supplied helpers consumed by avatar/image cell renderers. */
44
+ export interface DynamicColumnsHelpers {
45
+ /**
46
+ * Resolves a relative or absolute media path into a renderable URL. Hosts
47
+ * typically prepend their CDN/storage base. If omitted, paths are passed
48
+ * through verbatim.
49
+ */
50
+ getImageUrl?: (path: string) => string
51
+ /**
52
+ * API origin used to build avatar URLs when the row carries a bare filename
53
+ * instead of an absolute URL or sibling `.avatar` field. Usually
54
+ * `import.meta.env.VITE_API_URL.replace('/api', '')`.
55
+ */
56
+ apiBaseUrl?: string
57
+ }
58
+
59
+ const defaultGetImageUrl = (path: string) => path
60
+
61
+ const getNestedValue = (obj: any, path: string) =>
62
+ path.split('.').reduce((acc, part) => acc && acc[part], obj)
63
+
64
+ const lowerFirst = (value?: string) => {
65
+ if (!value) return value
66
+ return value.charAt(0).toLowerCase() + value.slice(1)
67
+ }
68
+
69
+ const getPathVariants = (path?: string) => {
70
+ if (!path) return []
71
+ const normalized = path
72
+ .split('.')
73
+ .map((segment) => lowerFirst(segment) || segment)
74
+ .join('.')
75
+ return Array.from(new Set([path, normalized])).filter(Boolean)
76
+ }
77
+
78
+ const getValueFromPathVariants = (obj: any, path?: string) => {
79
+ if (!path) return undefined
80
+ for (const candidate of getPathVariants(path)) {
81
+ const value = getNestedValue(obj, candidate as string)
82
+ if (value !== undefined && value !== null) return value
83
+ }
84
+ return undefined
85
+ }
86
+
87
+ const useIsDarkTheme = () => {
88
+ const [isDark, setIsDark] = React.useState(() =>
89
+ typeof document !== 'undefined' &&
90
+ document.documentElement.classList.contains('dark')
91
+ )
92
+ React.useEffect(() => {
93
+ if (typeof document === 'undefined') return
94
+ const sync = () =>
95
+ setIsDark(document.documentElement.classList.contains('dark'))
96
+ sync()
97
+ const observer = new MutationObserver(sync)
98
+ observer.observe(document.documentElement, {
99
+ attributes: true,
100
+ attributeFilter: ['class'],
101
+ })
102
+ return () => observer.disconnect()
103
+ }, [])
104
+ return isDark
105
+ }
106
+
107
+ const renderRelationBadges = (items: any, col: ColumnDefinition) => {
108
+ if (!Array.isArray(items) || items.length === 0) {
109
+ return <span className="text-muted-foreground">-</span>
110
+ }
111
+ return (
112
+ <div className="flex flex-wrap gap-1">
113
+ {items.map((item: any, idx: number) => {
114
+ const relationTarget = col.relationPath
115
+ ? getValueFromPathVariants(item, col.relationPath) ?? item
116
+ : item
117
+ const displaySource = relationTarget ?? item
118
+ let displayValue =
119
+ col.displayField !== undefined && col.displayField !== null
120
+ ? getValueFromPathVariants(displaySource, col.displayField)
121
+ : displaySource
122
+ if (displayValue === undefined || displayValue === null) {
123
+ displayValue = displaySource
124
+ }
125
+ const label =
126
+ displayValue !== undefined && displayValue !== null
127
+ ? String(displayValue)
128
+ : '-'
129
+ let iconValue: string | undefined
130
+ if (col.iconField) {
131
+ const rawIcon = getValueFromPathVariants(displaySource, col.iconField)
132
+ if (rawIcon !== undefined && rawIcon !== null) {
133
+ iconValue = String(rawIcon)
134
+ }
135
+ }
136
+ return (
137
+ <Badge
138
+ key={`${col.key}-${idx}`}
139
+ variant="outline"
140
+ className="flex items-center gap-1"
141
+ >
142
+ {iconValue && (
143
+ <DynamicIcon name={iconValue} className="h-3 w-3" />
144
+ )}
145
+ <span>{label}</span>
146
+ </Badge>
147
+ )
148
+ })}
149
+ </div>
150
+ )
151
+ }
152
+
153
+ interface OptionBadgeProps {
154
+ option: { value: string; label: string; icon?: string; color?: string }
155
+ fallback: string
156
+ }
157
+
158
+ const OptionBadge: React.FC<OptionBadgeProps> = ({ option }) => {
159
+ const isDark = useIsDarkTheme()
160
+ const colorStyles = option.color ? generateBadgeStyles(option.color, { isDark }) : {}
161
+ return (
162
+ <Badge variant="outline" className="flex items-center gap-1 border-0" style={colorStyles}>
163
+ {option.icon && <DynamicIcon name={option.icon} className="h-3.5 w-3.5" />}
164
+ <span>{option.label}</span>
165
+ </Badge>
166
+ )
167
+ }
168
+
169
+ const BadgeWithEndpointOptions: React.FC<{ endpoint: string; value: any }> = ({ endpoint, value }) => {
170
+ const { optionsMap } = React.useContext(OptionsContext)
171
+ const options = optionsMap.get(endpoint) || []
172
+ const option = options.find((opt: any) => opt.value === value)
173
+ if (option) return <OptionBadge option={option} fallback={String(value)} />
174
+ return <Badge variant="outline">{String(value)}</Badge>
175
+ }
176
+
177
+ /**
178
+ * Builds the canonical column factory used by `<DynamicTable>` when the host
179
+ * does not supply its own. Pass `{ getImageUrl, apiBaseUrl }` to wire avatar
180
+ * URL resolution.
181
+ */
182
+ export function makeDefaultGetDynamicColumns(
183
+ helpers: DynamicColumnsHelpers = {},
184
+ ): GetDynamicColumns {
185
+ const getImageUrl = helpers.getImageUrl ?? defaultGetImageUrl
186
+ const apiBaseUrl = helpers.apiBaseUrl ?? ''
187
+
188
+ return function defaultGetDynamicColumns(
189
+ metadata: TableMetadata,
190
+ onAction?: (action: string, row: any) => void,
191
+ t?: (key: string, options?: any) => string,
192
+ currentLanguage?: string,
193
+ filterConfigs?: Map<string, ColumnFilterConfig>,
194
+ ): ColumnDef<any>[] {
195
+ const dateLocale = currentLanguage === 'en' ? enUS : es
196
+ const columns: ColumnDef<any>[] = [
197
+ {
198
+ id: 'select',
199
+ header: ({ table }) => (
200
+ <Checkbox
201
+ checked={
202
+ table.getIsAllPageRowsSelected() ||
203
+ (table.getIsSomePageRowsSelected() && 'indeterminate')
204
+ }
205
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
206
+ aria-label="Select all"
207
+ className="translate-y-[2px]"
208
+ />
209
+ ),
210
+ cell: ({ row }) => (
211
+ <Checkbox
212
+ checked={row.getIsSelected()}
213
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
214
+ aria-label="Select row"
215
+ className="translate-y-[2px]"
216
+ />
217
+ ),
218
+ enableSorting: false,
219
+ enableHiding: false,
220
+ },
221
+ ]
222
+
223
+ metadata.columns.forEach((col) => {
224
+ if (col.hidden) return
225
+
226
+ const translatedLabel = col.label
227
+ const filterConfig = filterConfigs?.get(col.key)
228
+
229
+ const columnMeta: Record<string, unknown> = {
230
+ label: translatedLabel,
231
+ }
232
+ if (filterConfig) {
233
+ const fm: ColumnFilterMeta = {
234
+ filterable: true,
235
+ filterType: filterConfig.filterType as ColumnFilterMeta['filterType'],
236
+ filterKey: filterConfig.filterKey,
237
+ filterOptions: filterConfig.options,
238
+ filterLoading: filterConfig.loading,
239
+ filterSearchEndpoint: filterConfig.searchEndpoint,
240
+ selectedValues: filterConfig.selectedValues,
241
+ onFilterChange: filterConfig.onFilterChange,
242
+ }
243
+ Object.assign(columnMeta, fm)
244
+ }
245
+
246
+ columns.push({
247
+ accessorKey: col.key,
248
+ id: col.key,
249
+ meta: columnMeta,
250
+ header: ({ column }) =>
251
+ filterConfig ? (
252
+ <FilterableColumnHeader column={column} title={translatedLabel} />
253
+ ) : (
254
+ <DataTableColumnHeader column={column} title={translatedLabel} />
255
+ ),
256
+ cell: ({ row }) => {
257
+ const value = getNestedValue(row.original, col.key)
258
+
259
+ // Endpoint-loaded badge options (preloaded into OptionsContext)
260
+ if (col.cellStyle === 'badge' && col.useOptions && col.searchEndpoint) {
261
+ if (!value) return <span className="text-muted-foreground">-</span>
262
+ return <BadgeWithEndpointOptions endpoint={col.searchEndpoint} value={value} />
263
+ }
264
+
265
+ // Static badge options — map value → label/icon/color
266
+ if (col.cellStyle === 'badge' && col.options && col.options.length > 0) {
267
+ if (!value && value !== 0) return <span className="text-muted-foreground">-</span>
268
+ const option = col.options.find((o) => o.value === String(value))
269
+ if (option) return <OptionBadge option={option} fallback={String(value)} />
270
+ return <Badge variant="outline">{String(value)}</Badge>
271
+ }
272
+
273
+ if (col.cellStyle === 'relation-badge-list') {
274
+ return renderRelationBadges(value, col)
275
+ }
276
+
277
+ switch (col.type) {
278
+ case 'date': {
279
+ if (!value) return <span className="text-muted-foreground">-</span>
280
+ try {
281
+ const date = new Date(value)
282
+ if (isNaN(date.getTime()) || date.getFullYear() <= 1) {
283
+ return <span className="text-muted-foreground">-</span>
284
+ }
285
+ return (
286
+ <div className="flex items-center gap-1.5 text-muted-foreground">
287
+ <icons.Calendar className="h-3.5 w-3.5 opacity-70" />
288
+ <span className="text-sm font-medium">
289
+ {format(date, 'PPP', { locale: dateLocale })}
290
+ </span>
291
+ </div>
292
+ )
293
+ } catch {
294
+ return <span>{String(value)}</span>
295
+ }
296
+ }
297
+
298
+ case 'search':
299
+ case 'avatar': {
300
+ const namePath = col.tooltip || col.key
301
+ const name = getNestedValue(row.original, namePath) || 'N/A'
302
+ const desc = getNestedValue(row.original, col.description || '')
303
+
304
+ let avatarSrc: string | undefined
305
+ if (col.key.includes('.')) {
306
+ const parentPath = col.key.split('.').slice(0, -1).join('.')
307
+ const avatarPath = `${parentPath}.avatar`
308
+ const possibleAvatar = getNestedValue(row.original, avatarPath)
309
+ if (possibleAvatar) avatarSrc = String(possibleAvatar)
310
+ } else if (
311
+ value &&
312
+ (String(value).startsWith('http') || String(value).startsWith('https'))
313
+ ) {
314
+ avatarSrc = String(value)
315
+ } else if (value) {
316
+ avatarSrc = `${apiBaseUrl}${col.basePath || ''}${value}`
317
+ }
318
+
319
+ return (
320
+ <div className="flex items-center gap-3 min-w-0">
321
+ <Avatar className="h-8 w-8 rounded-lg ring-1 ring-border/50">
322
+ <AvatarImage
323
+ src={getImageUrl(avatarSrc || '')}
324
+ alt={String(name)}
325
+ className="object-cover"
326
+ />
327
+ <AvatarFallback className="text-[10px] font-bold bg-primary/5 text-primary rounded-lg">
328
+ {String(name)
329
+ .split(' ')
330
+ .map((n: string) => n[0])
331
+ .slice(0, 2)
332
+ .join('')
333
+ .toUpperCase()}
334
+ </AvatarFallback>
335
+ </Avatar>
336
+ <div className="flex flex-col min-w-0 overflow-hidden">
337
+ <span className="font-medium text-sm truncate leading-none mb-0.5 text-foreground/90">
338
+ {String(name)}
339
+ </span>
340
+ {desc && (
341
+ <span className="text-[11px] text-muted-foreground truncate leading-none">
342
+ {String(desc)}
343
+ </span>
344
+ )}
345
+ </div>
346
+ </div>
347
+ )
348
+ }
349
+
350
+ case 'relation-badge-list':
351
+ return renderRelationBadges(value, col)
352
+
353
+ case 'phone': {
354
+ if (!value) return <span className="text-muted-foreground">-</span>
355
+ return <span className="font-medium text-sm">{String(value)}</span>
356
+ }
357
+
358
+ case 'boolean':
359
+ return value ? <Badge>Sí</Badge> : <Badge variant="secondary">No</Badge>
360
+
361
+ case 'media-gallery': {
362
+ if (!value || (Array.isArray(value) && value.length === 0)) {
363
+ return <span className="text-muted-foreground">-</span>
364
+ }
365
+ const mediaItems = Array.isArray(value) ? value : []
366
+ const visibleItems = mediaItems.slice(0, 3)
367
+ const remaining = mediaItems.length - 3
368
+ return (
369
+ <div className="flex -space-x-2 overflow-hidden">
370
+ {visibleItems.map((item: any, i: number) => {
371
+ const src = item.url
372
+ if (item.type === 'image') {
373
+ return (
374
+ <Avatar
375
+ key={i}
376
+ className="inline-block h-8 w-8 rounded-full ring-2 ring-background"
377
+ >
378
+ <AvatarImage src={src} className="object-cover" />
379
+ <AvatarFallback>{item.type?.[0]}</AvatarFallback>
380
+ </Avatar>
381
+ )
382
+ }
383
+ return (
384
+ <div
385
+ key={i}
386
+ className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-muted ring-2 ring-background"
387
+ >
388
+ <DynamicIcon
389
+ name={
390
+ item.type === 'video'
391
+ ? 'Video'
392
+ : item.type === 'audio'
393
+ ? 'AudioLines'
394
+ : 'FileText'
395
+ }
396
+ className="h-4 w-4"
397
+ />
398
+ </div>
399
+ )
400
+ })}
401
+ {remaining > 0 && (
402
+ <div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-medium ring-2 ring-background">
403
+ +{remaining}
404
+ </div>
405
+ )}
406
+ </div>
407
+ )
408
+ }
409
+
410
+ case 'image': {
411
+ const imageValue =
412
+ value ||
413
+ (Array.isArray(row.original.media)
414
+ ? row.original.media.find((m: any) => m.type === 'image')?.url
415
+ : null)
416
+ if (!imageValue) return <span className="text-muted-foreground">-</span>
417
+ return (
418
+ <div className="h-10 w-10 relative rounded overflow-hidden bg-muted flex items-center justify-center">
419
+ <img
420
+ src={getImageUrl(String(imageValue))}
421
+ alt="Thumbnail"
422
+ className="h-full w-full object-contain"
423
+ onError={(e) => {
424
+ ;(e.currentTarget as HTMLImageElement).style.display = 'none'
425
+ }}
426
+ />
427
+ </div>
428
+ )
429
+ }
430
+
431
+ default: {
432
+ if (typeof value === 'object' && value !== null) {
433
+ return (
434
+ <span className="text-muted-foreground text-xs">
435
+ {JSON.stringify(value)}
436
+ </span>
437
+ )
438
+ }
439
+ if (
440
+ col.key === 'description' ||
441
+ col.key === 'features' ||
442
+ col.key.includes('description')
443
+ ) {
444
+ return (
445
+ <div className="max-w-[350px]" title={String(value)}>
446
+ <span className="truncate font-medium block">
447
+ {value !== null && value !== undefined ? String(value) : '-'}
448
+ </span>
449
+ </div>
450
+ )
451
+ }
452
+ return (
453
+ <span className="truncate font-medium">
454
+ {value !== null && value !== undefined ? String(value) : '-'}
455
+ </span>
456
+ )
457
+ }
458
+ }
459
+ },
460
+ enableSorting: col.sortable,
461
+ enableHiding: true,
462
+ })
463
+ })
464
+
465
+ if (metadata.hasActions && metadata.actions.length > 0) {
466
+ columns.push({
467
+ id: 'actions',
468
+ header: () => <div className="text-right">{t ? t('common.actions') : 'Acciones'}</div>,
469
+ size: 80,
470
+ maxSize: 80,
471
+ meta: {},
472
+ cell: ({ row }) => (
473
+ <div className="flex items-center justify-end">
474
+ <DropdownMenu>
475
+ <DropdownMenuTrigger asChild>
476
+ <Button variant="ghost" className="h-8 w-8 p-0">
477
+ <span className="sr-only">Abrir menú</span>
478
+ <MoreHorizontal className="h-4 w-4" />
479
+ </Button>
480
+ </DropdownMenuTrigger>
481
+ <DropdownMenuContent align="end">
482
+ {metadata.actions
483
+ .filter((action) => {
484
+ if (!action.condition) return true
485
+ const { field, operator, value } = action.condition
486
+ const rowValue = String((row.original as any)[field] ?? '')
487
+ const values = Array.isArray(value) ? value : [value]
488
+ switch (operator) {
489
+ case 'eq':
490
+ return rowValue === values[0]
491
+ case 'neq':
492
+ return rowValue !== values[0]
493
+ case 'in':
494
+ return values.includes(rowValue)
495
+ case 'not_in':
496
+ return !values.includes(rowValue)
497
+ default:
498
+ return true
499
+ }
500
+ })
501
+ .map((action) => (
502
+ <DropdownMenuItem
503
+ key={action.key}
504
+ onClick={() => onAction && onAction(action.key, row.original)}
505
+ >
506
+ <DynamicIcon name={action.icon} className="mr-2 h-4 w-4" />
507
+ {action.label}
508
+ </DropdownMenuItem>
509
+ ))}
510
+ </DropdownMenuContent>
511
+ </DropdownMenu>
512
+ </div>
513
+ ),
514
+ })
515
+ }
516
+
517
+ return columns
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Eager-built variant — equivalent to `makeDefaultGetDynamicColumns()`. Use
523
+ * this when the host has no helpers to inject and a stable function reference
524
+ * suffices.
525
+ */
526
+ export const defaultGetDynamicColumns: GetDynamicColumns =
527
+ makeDefaultGetDynamicColumns()
package/src/index.ts CHANGED
@@ -25,6 +25,11 @@ export type {
25
25
  GetDynamicColumns,
26
26
  DynamicIconComponent,
27
27
  } from './dynamic-columns-shim'
28
+ export {
29
+ defaultGetDynamicColumns,
30
+ makeDefaultGetDynamicColumns,
31
+ type DynamicColumnsHelpers,
32
+ } from './dynamic-columns'
28
33
  export { DynamicRecordDialog } from './dialogs/dynamic-record'
29
34
  export { ExportDialog } from './dialogs/export'
30
35
  export { ImportDialog } from './dialogs/import'