@asteby/metacore-runtime-react 46.4.0 → 47.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 +38 -0
- package/dist/action-modal-dispatcher.d.ts +10 -1
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +107 -19
- package/dist/audit-info.d.ts +54 -0
- package/dist/audit-info.d.ts.map +1 -0
- package/dist/audit-info.js +142 -0
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +24 -0
- package/dist/business/index.d.ts +3 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +3 -0
- package/dist/business/print-send-dialog.d.ts +27 -0
- package/dist/business/print-send-dialog.d.ts.map +1 -0
- package/dist/business/print-send-dialog.js +55 -0
- package/dist/business/relate-documents.d.ts +41 -0
- package/dist/business/relate-documents.d.ts.map +1 -0
- package/dist/business/relate-documents.js +42 -0
- package/dist/business/vehicle-picker.d.ts +36 -0
- package/dist/business/vehicle-picker.d.ts.map +1 -0
- package/dist/business/vehicle-picker.js +61 -0
- package/dist/cfdi-stamp-panel.d.ts +29 -0
- package/dist/cfdi-stamp-panel.d.ts.map +1 -0
- package/dist/cfdi-stamp-panel.js +53 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +10 -1
- package/dist/document-form-dialog.d.ts +23 -0
- package/dist/document-form-dialog.d.ts.map +1 -0
- package/dist/document-form-dialog.js +196 -0
- package/dist/dynamic-crud-page.d.ts +6 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +4 -2
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +3 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +11 -2
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +3 -0
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +2 -0
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +4 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +6 -2
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/nav-permissions.d.ts +19 -0
- package/dist/nav-permissions.d.ts.map +1 -0
- package/dist/nav-permissions.js +51 -0
- package/dist/navigation-builder.d.ts +6 -0
- package/dist/navigation-builder.d.ts.map +1 -1
- package/dist/navigation-builder.js +26 -1
- package/dist/option-filter.d.ts +31 -0
- package/dist/option-filter.d.ts.map +1 -0
- package/dist/option-filter.js +49 -0
- package/dist/permissions-manager.d.ts +9 -0
- package/dist/permissions-manager.d.ts.map +1 -1
- package/dist/permissions-manager.js +50 -17
- package/dist/print-document-button.d.ts.map +1 -1
- package/dist/print-document-button.js +9 -1
- package/dist/record-mutation-events.d.ts +14 -0
- package/dist/record-mutation-events.d.ts.map +1 -0
- package/dist/record-mutation-events.js +37 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-options-resolver.d.ts +8 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +8 -2
- package/dist/use-print-document.d.ts +15 -1
- package/dist/use-print-document.d.ts.map +1 -1
- package/dist/use-print-document.js +52 -1
- package/dist/use-record-mutation-tick.d.ts +6 -0
- package/dist/use-record-mutation-tick.d.ts.map +1 -0
- package/dist/use-record-mutation-tick.js +11 -0
- package/package.json +3 -3
- package/src/__tests__/action-modal-feedback.test.tsx +116 -0
- package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
- package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
- package/src/__tests__/audit-info.test.tsx +103 -0
- package/src/__tests__/business-pickers.test.tsx +157 -0
- package/src/__tests__/dependent-options.test.tsx +12 -5
- package/src/__tests__/document-form-dialog.test.tsx +190 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
- package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
- package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
- package/src/__tests__/nav-permissions.test.tsx +76 -0
- package/src/__tests__/option-filter.test.tsx +115 -0
- package/src/__tests__/permissions-manager.test.tsx +21 -4
- package/src/__tests__/structured-view-value.test.tsx +5 -1
- package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
- package/src/__tests__/wizard-action-modal.test.tsx +1 -1
- package/src/action-modal-dispatcher.tsx +124 -6
- package/src/audit-info.tsx +219 -0
- package/src/business/catalog.ts +24 -0
- package/src/business/index.ts +3 -0
- package/src/business/print-send-dialog.tsx +175 -0
- package/src/business/relate-documents.tsx +182 -0
- package/src/business/vehicle-picker.tsx +188 -0
- package/src/cfdi-stamp-panel.tsx +146 -0
- package/src/dialogs/dynamic-record.tsx +18 -0
- package/src/document-form-dialog.tsx +357 -0
- package/src/dropdown-menu-entry.test.ts +23 -0
- package/src/dynamic-crud-page.tsx +21 -2
- package/src/dynamic-form.tsx +3 -0
- package/src/dynamic-kanban.tsx +10 -4
- package/src/dynamic-line-items.tsx +3 -0
- package/src/dynamic-multi-select-field.tsx +2 -0
- package/src/dynamic-select-field.tsx +4 -0
- package/src/dynamic-table.tsx +6 -2
- package/src/index.ts +41 -0
- package/src/nav-permissions.ts +58 -0
- package/src/navigation-builder.tsx +27 -1
- package/src/option-filter.ts +88 -0
- package/src/permissions-manager.tsx +39 -26
- package/src/print-document-button.tsx +9 -1
- package/src/record-mutation-events.ts +50 -0
- package/src/types.ts +90 -0
- package/src/use-options-resolver.ts +19 -1
- package/src/use-print-document.ts +66 -3
- package/src/use-record-mutation-tick.ts +12 -0
- package/vitest.config.ts +1 -0
- package/vitest.setup.ts +7 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// nav-permissions — the sidebar follows the role's EFFECTIVE permissions
|
|
2
|
+
// (PIT-044). Before, every installed addon's entries were listed for every
|
|
3
|
+
// role and only failed with a 403 after the click.
|
|
4
|
+
//
|
|
5
|
+
// Rules (same capability vocabulary as useCan/gateTableMetadata):
|
|
6
|
+
// - no <PermissionsProvider> mounted, or isAdmin / "*" -> nothing hidden
|
|
7
|
+
// (useCan() is always-true then, so the predicate is a no-op);
|
|
8
|
+
// - an entry with an explicit `requires` capability -> can(requires);
|
|
9
|
+
// - an entry pointing at a model screen (`/m/<model>`) -> can('<model>.index');
|
|
10
|
+
// - any other entry (custom pages, activity, settings…) -> kept. Those are
|
|
11
|
+
// gated by the backend / the host's own `requires`, never guessed here.
|
|
12
|
+
import { useCallback } from 'react'
|
|
13
|
+
import { modelCapability, useCan, usePermissionsActive, type CanFn } from './permissions-context'
|
|
14
|
+
|
|
15
|
+
/** Minimal structural shape of a sidebar leaf (matches `@asteby/metacore-ui` NavLinkItem). */
|
|
16
|
+
export interface NavLeafLike {
|
|
17
|
+
url: string
|
|
18
|
+
requires?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MODEL_URL_RE = /^\/(?:app\/)?m\/([^/?#]+)/i
|
|
22
|
+
|
|
23
|
+
/** Model key of a `/m/<model>` url, or undefined for any other route. */
|
|
24
|
+
export function modelFromNavUrl(url: string | undefined): string | undefined {
|
|
25
|
+
if (!url) return undefined
|
|
26
|
+
const m = MODEL_URL_RE.exec(url)
|
|
27
|
+
if (!m) return undefined
|
|
28
|
+
try {
|
|
29
|
+
return decodeURIComponent(m[1])
|
|
30
|
+
} catch {
|
|
31
|
+
return m[1]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Capability a leaf needs, or undefined when it is not gated by the SDK. */
|
|
36
|
+
export function capabilityForNavItem(item: NavLeafLike): string | undefined {
|
|
37
|
+
if (item.requires) return item.requires
|
|
38
|
+
const model = modelFromNavUrl(item.url)
|
|
39
|
+
return model ? modelCapability(model, 'index') : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pure visibility predicate for a leaf given a capability checker. */
|
|
43
|
+
export function isNavItemAllowed(item: NavLeafLike, can: CanFn): boolean {
|
|
44
|
+
const cap = capabilityForNavItem(item)
|
|
45
|
+
return cap ? can(cap) : true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hook for the host's sidebar: returns a predicate for `<AppSidebar
|
|
50
|
+
* isItemVisible>` / `filterNavGroups`, or `undefined` when permission gating
|
|
51
|
+
* is not active (nothing to filter).
|
|
52
|
+
*/
|
|
53
|
+
export function useNavItemVisible(): ((item: NavLeafLike) => boolean) | undefined {
|
|
54
|
+
const can = useCan()
|
|
55
|
+
const active = usePermissionsActive()
|
|
56
|
+
const predicate = useCallback((item: NavLeafLike) => isNavItemAllowed(item, can), [can])
|
|
57
|
+
return active ? predicate : undefined
|
|
58
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// NavigationBuilder — merges a host's base sidebar with `manifest.navigation`
|
|
2
2
|
// contributions from every loaded addon. Pure function + a React hook.
|
|
3
3
|
import { useMemo } from 'react'
|
|
4
|
+
import { useCan, type CanFn } from './permissions-context'
|
|
4
5
|
|
|
5
6
|
export interface NavItem {
|
|
6
7
|
key: string
|
|
@@ -59,8 +60,33 @@ export function mergeNavigation(base: NavItem[], contributions: AddonNavigationC
|
|
|
59
60
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Drops items whose `requires` capability the user lacks (PIT-044). A parent
|
|
65
|
+
* left without children is dropped too, unless it links somewhere itself.
|
|
66
|
+
*/
|
|
67
|
+
export function filterNavigationByCapability(items: NavItem[], can: CanFn): NavItem[] {
|
|
68
|
+
const out: NavItem[] = []
|
|
69
|
+
for (const item of items) {
|
|
70
|
+
if (item.requires && !can(item.requires)) continue
|
|
71
|
+
if (item.children) {
|
|
72
|
+
const children = filterNavigationByCapability(item.children, can)
|
|
73
|
+
if (children.length === 0 && item.children.length > 0 && !item.to) continue
|
|
74
|
+
out.push({ ...item, children })
|
|
75
|
+
} else {
|
|
76
|
+
out.push(item)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
export function useNavigation(base: NavItem[], contributions: AddonNavigationContribution[]): NavItem[] {
|
|
63
|
-
|
|
83
|
+
// useCan() allows everything without a <PermissionsProvider>, so hosts that
|
|
84
|
+
// never opt in keep the full tree.
|
|
85
|
+
const can = useCan()
|
|
86
|
+
return useMemo(
|
|
87
|
+
() => filterNavigationByCapability(mergeNavigation(base, contributions), can),
|
|
88
|
+
[base, contributions, can],
|
|
89
|
+
)
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
export interface NavigationBuilderProps {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Option filter — lets a manifest HIDE options of a relation / dynamic picker
|
|
2
|
+
// (e.g. keep cancelled invoices out of the "abonos" invoice selector, PIT-059).
|
|
3
|
+
//
|
|
4
|
+
// The filter runs client-side over the options the resolver already fetched, so
|
|
5
|
+
// it needs no kernel change: it reads the extra scalar columns the options
|
|
6
|
+
// endpoint returns (they land in `option.meta`) plus the standard `label`,
|
|
7
|
+
// `name`, `description`, `color`, `icon` keys. Fully opt-in: a field without
|
|
8
|
+
// `option_filter` / `optionFilter` behaves exactly as before.
|
|
9
|
+
import type { ResolvedOption } from './use-options-resolver'
|
|
10
|
+
|
|
11
|
+
export interface OptionFilterRule {
|
|
12
|
+
/** Option property to test: a `meta` key (e.g. `status`) or `id`/`value`/`label`/`name`/`description`/`color`/`icon`. */
|
|
13
|
+
field: string
|
|
14
|
+
/** Keep only options whose value equals this. */
|
|
15
|
+
equals?: string | number | boolean
|
|
16
|
+
/** snake_case alias of `notEquals`. */
|
|
17
|
+
not_equals?: string | number | boolean
|
|
18
|
+
/** Hide options whose value equals this. */
|
|
19
|
+
notEquals?: string | number | boolean
|
|
20
|
+
/** Keep only options whose value is one of these. */
|
|
21
|
+
in?: Array<string | number | boolean>
|
|
22
|
+
/** Hide options whose value is one of these. */
|
|
23
|
+
not_in?: Array<string | number | boolean>
|
|
24
|
+
/** camelCase alias of `not_in`. */
|
|
25
|
+
notIn?: Array<string | number | boolean>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One rule, or a list of rules that must ALL pass (AND). */
|
|
29
|
+
export type OptionFilter = OptionFilterRule | OptionFilterRule[]
|
|
30
|
+
|
|
31
|
+
type FilterCarrier = { optionFilter?: unknown; option_filter?: unknown }
|
|
32
|
+
|
|
33
|
+
/** Reads the filter from a field (camelCase or the snake_case the kernel serves). */
|
|
34
|
+
export function getOptionFilter(field: unknown): OptionFilterRule[] {
|
|
35
|
+
if (!field || typeof field !== 'object') return []
|
|
36
|
+
const f = field as FilterCarrier
|
|
37
|
+
const raw = f.optionFilter ?? f.option_filter
|
|
38
|
+
const list = Array.isArray(raw) ? raw : raw && typeof raw === 'object' ? [raw] : []
|
|
39
|
+
return list.filter(
|
|
40
|
+
(r): r is OptionFilterRule =>
|
|
41
|
+
!!r && typeof r === 'object' && typeof (r as OptionFilterRule).field === 'string' && (r as OptionFilterRule).field !== '',
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function norm(v: unknown): string {
|
|
46
|
+
return String(v).trim().toLowerCase()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readProp(opt: ResolvedOption, key: string): unknown {
|
|
50
|
+
if (key === 'id' || key === 'value' || key === 'label' || key === 'name' || key === 'description' || key === 'color' || key === 'icon' || key === 'image') {
|
|
51
|
+
return (opt as unknown as Record<string, unknown>)[key]
|
|
52
|
+
}
|
|
53
|
+
return opt.meta?.[key]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** True when the option satisfies the rule. Comparison is trimmed + case-insensitive. */
|
|
57
|
+
export function optionPassesRule(opt: ResolvedOption, rule: OptionFilterRule): boolean {
|
|
58
|
+
const raw = readProp(opt, rule.field)
|
|
59
|
+
const known = raw !== undefined && raw !== null
|
|
60
|
+
const v = known ? norm(raw) : ''
|
|
61
|
+
const notEquals = rule.notEquals ?? rule.not_equals
|
|
62
|
+
const notIn = rule.notIn ?? rule.not_in
|
|
63
|
+
// Positive rules need the property to exist; negative rules cannot exclude
|
|
64
|
+
// what they cannot see, so an option lacking the property is kept.
|
|
65
|
+
if (rule.equals !== undefined && !(known && v === norm(rule.equals))) return false
|
|
66
|
+
if (Array.isArray(rule.in) && !(known && rule.in.some((x) => norm(x) === v))) return false
|
|
67
|
+
if (notEquals !== undefined && known && v === norm(notEquals)) return false
|
|
68
|
+
if (Array.isArray(notIn) && known && notIn.some((x) => norm(x) === v)) return false
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Applies the field's filter to resolved options. Returns the SAME array when
|
|
74
|
+
* there is nothing to filter. `keepValue` (the current selection) is never
|
|
75
|
+
* hidden, so an existing record keeps showing its label after the option
|
|
76
|
+
* became ineligible.
|
|
77
|
+
*/
|
|
78
|
+
export function applyOptionFilter(
|
|
79
|
+
options: ResolvedOption[],
|
|
80
|
+
rules: OptionFilterRule[],
|
|
81
|
+
keepValue?: unknown,
|
|
82
|
+
): ResolvedOption[] {
|
|
83
|
+
if (rules.length === 0) return options
|
|
84
|
+
const keep = keepValue === undefined || keepValue === null || keepValue === '' ? null : String(keepValue)
|
|
85
|
+
return options.filter(
|
|
86
|
+
(o) => (keep !== null && String(o.id) === keep) || rules.every((r) => optionPassesRule(o, r)),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -73,7 +73,6 @@ import {
|
|
|
73
73
|
Popover,
|
|
74
74
|
PopoverContent,
|
|
75
75
|
PopoverTrigger,
|
|
76
|
-
Separator,
|
|
77
76
|
Skeleton,
|
|
78
77
|
Collapsible,
|
|
79
78
|
CollapsibleContent,
|
|
@@ -360,6 +359,31 @@ export function suggestRoleIcon(label: string): string {
|
|
|
360
359
|
return 'Shield'
|
|
361
360
|
}
|
|
362
361
|
|
|
362
|
+
/** Label of the synthetic module that hosts the general (non-model) flags. */
|
|
363
|
+
export const GENERAL_MODULE_LABEL = 'Permisos generales'
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The general flags (`general.work_after_hours`, …) as a synthetic module, so
|
|
367
|
+
* they are picked from the module combobox and edited in the same two-column
|
|
368
|
+
* action grid as any module's actions (#1021). Each flag keeps its full
|
|
369
|
+
* capability key through `PermissionActionDef.capability`.
|
|
370
|
+
*/
|
|
371
|
+
export function generalPermissionsModule(general: GeneralPermissionDef[]): PermissionModuleDef {
|
|
372
|
+
return {
|
|
373
|
+
key: 'general',
|
|
374
|
+
label: GENERAL_MODULE_LABEL,
|
|
375
|
+
icon: 'SlidersHorizontal',
|
|
376
|
+
kind: 'model',
|
|
377
|
+
actions: general.map((g) => ({
|
|
378
|
+
key: g.key.replace(/^general\./, ''),
|
|
379
|
+
label: g.label,
|
|
380
|
+
description: g.description,
|
|
381
|
+
capability: g.key,
|
|
382
|
+
kind: 'custom',
|
|
383
|
+
})),
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
363
387
|
/**
|
|
364
388
|
* Normalize whatever `loadModules` returned into the canonical grouped shape.
|
|
365
389
|
*
|
|
@@ -613,6 +637,15 @@ export function PermissionsManager({
|
|
|
613
637
|
|
|
614
638
|
const allModules = React.useMemo(() => (groups ? flattenGroups(groups) : []), [groups])
|
|
615
639
|
|
|
640
|
+
// What the module picker lists: the catalog groups plus a trailing synthetic
|
|
641
|
+
// group for the general flags. `allModules` / "Otorgar todo" keep using the
|
|
642
|
+
// plain catalog, so that button's scope does not change.
|
|
643
|
+
const pickerGroups = React.useMemo<ModuleGroup[] | null>(() => {
|
|
644
|
+
if (!groups) return groups
|
|
645
|
+
if (!general || general.length === 0) return groups
|
|
646
|
+
return [...groups, { title: '', modules: [generalPermissionsModule(general)] }]
|
|
647
|
+
}, [groups, general])
|
|
648
|
+
|
|
616
649
|
// ---- initial load: catalog + roles in parallel -------------------------
|
|
617
650
|
React.useEffect(() => {
|
|
618
651
|
let cancelled = false
|
|
@@ -670,8 +703,8 @@ export function PermissionsManager({
|
|
|
670
703
|
[roles, activeRoleId],
|
|
671
704
|
)
|
|
672
705
|
const activeModule = React.useMemo(
|
|
673
|
-
() => moduleAtEntry(
|
|
674
|
-
[
|
|
706
|
+
() => moduleAtEntry(pickerGroups, activeModuleKey)?.module ?? null,
|
|
707
|
+
[pickerGroups, activeModuleKey],
|
|
675
708
|
)
|
|
676
709
|
|
|
677
710
|
const dirty = baseline !== null && draft !== null && !capabilitySetsEqual(baseline, draft)
|
|
@@ -841,8 +874,8 @@ export function PermissionsManager({
|
|
|
841
874
|
const moduleTotal = activeModule?.actions.length ?? 0
|
|
842
875
|
const checksDisabled = !activeRole || !draft || loadingPerms || saving
|
|
843
876
|
const activeModuleGroupTitle = React.useMemo(
|
|
844
|
-
() => moduleAtEntry(
|
|
845
|
-
[
|
|
877
|
+
() => moduleAtEntry(pickerGroups, activeModuleKey)?.group.title ?? '',
|
|
878
|
+
[pickerGroups, activeModuleKey],
|
|
846
879
|
)
|
|
847
880
|
|
|
848
881
|
// ---- render --------------------------------------------------------------
|
|
@@ -1052,26 +1085,6 @@ export function PermissionsManager({
|
|
|
1052
1085
|
)}
|
|
1053
1086
|
</div>
|
|
1054
1087
|
|
|
1055
|
-
{(general?.length ?? 0) > 0 && (
|
|
1056
|
-
<>
|
|
1057
|
-
<Separator />
|
|
1058
|
-
<div>
|
|
1059
|
-
<h3 className="mb-2 text-sm font-semibold">Permisos Generales</h3>
|
|
1060
|
-
<div className="flex flex-col gap-2">
|
|
1061
|
-
{general!.map((g) => (
|
|
1062
|
-
<CapabilityCheck
|
|
1063
|
-
key={g.key}
|
|
1064
|
-
checked={draft?.has(g.key) ?? false}
|
|
1065
|
-
disabled={checksDisabled}
|
|
1066
|
-
onToggle={() => toggleCapability(g.key)}
|
|
1067
|
-
label={g.label}
|
|
1068
|
-
description={g.description}
|
|
1069
|
-
/>
|
|
1070
|
-
))}
|
|
1071
|
-
</div>
|
|
1072
|
-
</div>
|
|
1073
|
-
</>
|
|
1074
|
-
)}
|
|
1075
1088
|
</CardContent>
|
|
1076
1089
|
</Card>
|
|
1077
1090
|
|
|
@@ -1122,7 +1135,7 @@ export function PermissionsManager({
|
|
|
1122
1135
|
<CommandInput placeholder="Buscar módulo…" />
|
|
1123
1136
|
<CommandList className="max-h-[360px]">
|
|
1124
1137
|
<CommandEmpty>Sin módulos.</CommandEmpty>
|
|
1125
|
-
{(
|
|
1138
|
+
{(pickerGroups ?? []).map((group, gi) => (
|
|
1126
1139
|
<CommandGroup
|
|
1127
1140
|
key={group.title || `__untitled_${gi}`}
|
|
1128
1141
|
heading={group.title || undefined}
|
|
@@ -51,7 +51,15 @@ export function PrintDocumentButton({
|
|
|
51
51
|
if (e.defaultPrevented) return
|
|
52
52
|
setBusy(true)
|
|
53
53
|
try {
|
|
54
|
-
|
|
54
|
+
// A caller that handles errors itself keeps ownership of the error toast.
|
|
55
|
+
await printDocument({
|
|
56
|
+
model,
|
|
57
|
+
id,
|
|
58
|
+
key: documentKey,
|
|
59
|
+
mode,
|
|
60
|
+
filename,
|
|
61
|
+
feedback: onError ? { progress: true, success: true, error: false } : true,
|
|
62
|
+
})
|
|
55
63
|
} catch (err) {
|
|
56
64
|
onError?.(err)
|
|
57
65
|
} finally {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// record-mutation-events — a tiny window-level bus that tells every mounted
|
|
2
|
+
// list (DynamicTable, DynamicKanban) "a record of model X just changed".
|
|
3
|
+
//
|
|
4
|
+
// Why: the lists only reloaded when the *caller* bumped `refreshTrigger`. A
|
|
5
|
+
// create/edit/delete done from a dialog the shell opens (the dynamic record
|
|
6
|
+
// dialog, a federated action modal, a wizard, a confirmation) never reached
|
|
7
|
+
// the table, so the row stayed stale until a manual refresh (#1020). Emitting
|
|
8
|
+
// here decouples the mutation site from the list that displays the model.
|
|
9
|
+
|
|
10
|
+
export type RecordMutationKind = 'create' | 'update' | 'delete'
|
|
11
|
+
|
|
12
|
+
export const RECORD_MUTATION_EVENT = 'metacore:record-mutated'
|
|
13
|
+
|
|
14
|
+
export interface RecordMutationDetail {
|
|
15
|
+
/** Model key as the emitter knows it (matched case-insensitively). */
|
|
16
|
+
model: string
|
|
17
|
+
kind: RecordMutationKind
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const norm = (m: string | undefined | null) => (m ?? '').trim().toLowerCase()
|
|
21
|
+
|
|
22
|
+
/** Announce a successful mutation. Safe to call outside a browser (no-op). */
|
|
23
|
+
export function emitRecordMutation(model: string | undefined | null, kind: RecordMutationKind): void {
|
|
24
|
+
if (!model || typeof window === 'undefined' || typeof CustomEvent === 'undefined') return
|
|
25
|
+
try {
|
|
26
|
+
window.dispatchEvent(
|
|
27
|
+
new CustomEvent<RecordMutationDetail>(RECORD_MUTATION_EVENT, { detail: { model, kind } }),
|
|
28
|
+
)
|
|
29
|
+
} catch {
|
|
30
|
+
/* a listener must never break the save flow */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Listen for mutations of `model` (case-insensitive). Returns an unsubscribe.
|
|
36
|
+
*/
|
|
37
|
+
export function subscribeRecordMutations(
|
|
38
|
+
model: string | undefined | null,
|
|
39
|
+
cb: (detail: RecordMutationDetail) => void,
|
|
40
|
+
): () => void {
|
|
41
|
+
if (typeof window === 'undefined') return () => {}
|
|
42
|
+
const want = norm(model)
|
|
43
|
+
const handler = (e: Event) => {
|
|
44
|
+
const d = (e as CustomEvent<RecordMutationDetail>).detail
|
|
45
|
+
if (!d || !want || norm(d.model) !== want) return
|
|
46
|
+
cb(d)
|
|
47
|
+
}
|
|
48
|
+
window.addEventListener(RECORD_MUTATION_EVENT, handler)
|
|
49
|
+
return () => window.removeEventListener(RECORD_MUTATION_EVENT, handler)
|
|
50
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OptionFilter } from './option-filter'
|
|
1
2
|
// Shared metadata shape consumed by every host. Some hosts add a `link`
|
|
2
3
|
// action type with a `linkUrl` template — represented here as part of the
|
|
3
4
|
// `type` union so the SDK can render it uniformly.
|
|
@@ -22,6 +23,63 @@ export interface ImportSpecMeta {
|
|
|
22
23
|
instructions?: string[]
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** Paso de renglones de un tipo de documento (usa el editor de renglones del SDK). */
|
|
27
|
+
export interface DocumentFormLines {
|
|
28
|
+
/** Campo del payload que recibe los renglones. Default: `lines` (o `lines_field` del manifest). */
|
|
29
|
+
field?: string
|
|
30
|
+
/** Columnas opcionales del editor (`discount`, `tax`, `unit`…). */
|
|
31
|
+
columns?: string[]
|
|
32
|
+
/** `sale` (precio de lista) o `cost` (compras). Default `sale`. */
|
|
33
|
+
price_source?: 'sale' | 'cost'
|
|
34
|
+
/** Al menos un renglón para guardar. Default true. */
|
|
35
|
+
required?: boolean
|
|
36
|
+
/** Título del paso. Default «Renglones». */
|
|
37
|
+
title?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Un tipo de documento (Factura, Nota de crédito, REP, Global, Traslado…). */
|
|
41
|
+
export interface DocumentFormType {
|
|
42
|
+
key: string
|
|
43
|
+
label: string
|
|
44
|
+
description?: string
|
|
45
|
+
icon?: string
|
|
46
|
+
/** Valor que se escribe en `type_field` (p. ej. `"E"`). Default: `key`. */
|
|
47
|
+
value?: string
|
|
48
|
+
/** Campos del tipo, en el mismo vocabulario que los campos de una acción. */
|
|
49
|
+
fields: ActionFieldDef[]
|
|
50
|
+
/** Valores fijos que viajan en el alta de este tipo. */
|
|
51
|
+
defaults?: Record<string, unknown>
|
|
52
|
+
/** Con esto el tipo tiene paso de renglones. */
|
|
53
|
+
lines?: DocumentFormLines | boolean
|
|
54
|
+
/** Endpoint de alta propio de este tipo. Default: el del modelo. */
|
|
55
|
+
endpoint?: string
|
|
56
|
+
/** Texto del botón final. Default «Crear». */
|
|
57
|
+
submit_label?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DocumentFormsManifest {
|
|
61
|
+
/** Columna discriminadora del modelo (p. ej. `"type"`). Sin ella no se escribe el tipo. */
|
|
62
|
+
type_field?: string
|
|
63
|
+
/** Campo por defecto de los renglones. Default `lines`. */
|
|
64
|
+
lines_field?: string
|
|
65
|
+
types: DocumentFormType[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Audit columns of a model, served by the kernel (>= v0.188) on the table
|
|
70
|
+
* metadata. Each value is the COLUMN NAME (the key in the row), not a value;
|
|
71
|
+
* a key is omitted when the model lacks that column (append-only ledgers have
|
|
72
|
+
* only `created_*`). Purely additive — older kernels omit `audit` entirely.
|
|
73
|
+
*/
|
|
74
|
+
export interface AuditMeta {
|
|
75
|
+
created_at?: string
|
|
76
|
+
created_by?: string
|
|
77
|
+
updated_at?: string
|
|
78
|
+
updated_by?: string
|
|
79
|
+
deleted_at?: string
|
|
80
|
+
deleted_by?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
25
83
|
export interface TableMetadata {
|
|
26
84
|
title: string
|
|
27
85
|
endpoint: string
|
|
@@ -36,6 +94,18 @@ export interface TableMetadata {
|
|
|
36
94
|
canExport?: boolean
|
|
37
95
|
canImport?: boolean
|
|
38
96
|
canCreate?: boolean
|
|
97
|
+
/**
|
|
98
|
+
* Audit columns (when/who) of the model. Feeds `<AuditInfo>`; absent on
|
|
99
|
+
* older kernels or models without audit columns.
|
|
100
|
+
*/
|
|
101
|
+
audit?: AuditMeta
|
|
102
|
+
/**
|
|
103
|
+
* Formularios de alta guiados por tipo de documento (FAC-12). Cuando trae
|
|
104
|
+
* al menos un tipo, `DynamicCRUDPage` abre `DocumentFormDialog` en lugar del
|
|
105
|
+
* formulario genérico del modelo. Lo sirve el kernel o el addon; el SDK no
|
|
106
|
+
* conoce campos fiscales.
|
|
107
|
+
*/
|
|
108
|
+
document_forms?: DocumentFormsManifest
|
|
39
109
|
/**
|
|
40
110
|
* Cómo se pinta el alta genérica. `generic` (o ausente) es el botón Crear.
|
|
41
111
|
* `hidden` lo quita. `action` lo quita porque el módulo trae la suya
|
|
@@ -331,6 +401,16 @@ export interface ColumnDefinition {
|
|
|
331
401
|
* widgets — `searchEndpoint` stays as the legacy escape hatch.
|
|
332
402
|
*/
|
|
333
403
|
ref?: string
|
|
404
|
+
/**
|
|
405
|
+
* Hides options of this relation/dynamic picker (e.g. cancelled invoices in
|
|
406
|
+
* the payments selector). One rule or a list (AND), tested client-side
|
|
407
|
+
* against the option's extra columns (`meta`): `{ field: 'status', not_in:
|
|
408
|
+
* ['cancelada'] }`. The options endpoint must return that column. Absent →
|
|
409
|
+
* the picker lists everything (retrocompat). See `option-filter.ts`.
|
|
410
|
+
*/
|
|
411
|
+
optionFilter?: OptionFilter
|
|
412
|
+
/** snake_case alias served by the kernel manifest for `optionFilter`. */
|
|
413
|
+
option_filter?: OptionFilter
|
|
334
414
|
/**
|
|
335
415
|
* Write-time rules the SDK also pre-flights. Object form `{regex,min,max,custom}`
|
|
336
416
|
* or a Laravel / go-playground string (`required|min:2|email`).
|
|
@@ -497,6 +577,16 @@ export interface ActionFieldDef {
|
|
|
497
577
|
* `useOptionsResolver` against `/api/options/<ref>?field=id`.
|
|
498
578
|
*/
|
|
499
579
|
ref?: string
|
|
580
|
+
/**
|
|
581
|
+
* Hides options of this relation/dynamic picker (e.g. cancelled invoices in
|
|
582
|
+
* the payments selector). One rule or a list (AND), tested client-side
|
|
583
|
+
* against the option's extra columns (`meta`): `{ field: 'status', not_in:
|
|
584
|
+
* ['cancelada'] }`. The options endpoint must return that column. Absent →
|
|
585
|
+
* the picker lists everything (retrocompat). See `option-filter.ts`.
|
|
586
|
+
*/
|
|
587
|
+
optionFilter?: OptionFilter
|
|
588
|
+
/** snake_case alias served by the kernel manifest for `optionFilter`. */
|
|
589
|
+
option_filter?: OptionFilter
|
|
500
590
|
/**
|
|
501
591
|
* snake_case aliases the kernel manifest may serve for a belongs_to FK
|
|
502
592
|
* target instead of `ref`. Treated as equivalent to `ref` by the SDK so a
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// free; legacy callers that still ship `searchEndpoint` keep working.
|
|
13
13
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
14
14
|
import { useApi } from './api-context'
|
|
15
|
+
import { applyOptionFilter, type OptionFilterRule } from './option-filter'
|
|
15
16
|
import { loadQueryPart, optionsBatchToken, optionsModelFromUrl } from './query-batch'
|
|
16
17
|
|
|
17
18
|
export interface ResolvedOption {
|
|
@@ -90,6 +91,13 @@ export interface UseOptionsResolverArgs {
|
|
|
90
91
|
* `fieldKey` / `query` / `limit` exactly the same way.
|
|
91
92
|
*/
|
|
92
93
|
endpoint?: string
|
|
94
|
+
/**
|
|
95
|
+
* Client-side rules that hide fetched options (see `option-filter.ts`).
|
|
96
|
+
* Empty/undefined → no filtering (retrocompat).
|
|
97
|
+
*/
|
|
98
|
+
optionFilter?: OptionFilterRule[]
|
|
99
|
+
/** Current selection: never hidden by `optionFilter`, so its label survives. */
|
|
100
|
+
keepValue?: unknown
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
export interface UseOptionsResolverResult {
|
|
@@ -182,6 +190,8 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
|
|
|
182
190
|
enabled = true,
|
|
183
191
|
endpoint,
|
|
184
192
|
filterValue,
|
|
193
|
+
optionFilter,
|
|
194
|
+
keepValue,
|
|
185
195
|
} = args
|
|
186
196
|
|
|
187
197
|
const api = useApi()
|
|
@@ -322,8 +332,16 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
|
|
|
322
332
|
}
|
|
323
333
|
}, [api, url, effectiveField, query, limit, enabled, filterValue, refreshKey])
|
|
324
334
|
|
|
335
|
+
// Rules arrive as a fresh array each render; key on their content.
|
|
336
|
+
const filterKey = optionFilter && optionFilter.length > 0 ? JSON.stringify(optionFilter) : ''
|
|
337
|
+
const visibleOptions = useMemo(
|
|
338
|
+
() => (filterKey ? applyOptionFilter(options, optionFilter ?? [], keepValue) : options),
|
|
339
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
340
|
+
[options, filterKey, keepValue],
|
|
341
|
+
)
|
|
342
|
+
|
|
325
343
|
return {
|
|
326
|
-
options,
|
|
344
|
+
options: visibleOptions,
|
|
327
345
|
meta,
|
|
328
346
|
loading,
|
|
329
347
|
error,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// The ApiClient is a PEER via <ApiProvider> (same one useAddonSettings uses), so
|
|
18
18
|
// this hook constructs no client of its own.
|
|
19
19
|
import { useCallback } from 'react'
|
|
20
|
+
import { toast } from 'sonner'
|
|
20
21
|
import { useApi } from './api-context'
|
|
21
22
|
|
|
22
23
|
export interface PrintDocumentArgs {
|
|
@@ -40,6 +41,42 @@ export interface PrintDocumentArgs {
|
|
|
40
41
|
* that is how downloads end up literally named with mustache braces.
|
|
41
42
|
*/
|
|
42
43
|
filename?: string
|
|
44
|
+
/**
|
|
45
|
+
* User feedback. Default: a toast while the file is prepared, a success toast
|
|
46
|
+
* when it is ready and an error toast with the server's reason when it fails
|
|
47
|
+
* (a download used to give NO sign of life — PIT «Descargar Factura CFDI»).
|
|
48
|
+
* `false` silences all of it; `{ error: false }` leaves errors to the caller
|
|
49
|
+
* (the promise still rejects either way).
|
|
50
|
+
*/
|
|
51
|
+
feedback?: boolean | { progress?: boolean; success?: boolean; error?: boolean }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const noun = (mode: 'print' | 'download' | 'open') =>
|
|
55
|
+
mode === 'download' ? 'Descargando documento…' : mode === 'open' ? 'Abriendo documento…' : 'Preparando impresión…'
|
|
56
|
+
|
|
57
|
+
const readyMessage = (mode: 'print' | 'download' | 'open') =>
|
|
58
|
+
mode === 'download' ? 'Documento descargado' : mode === 'open' ? 'Documento abierto' : 'Documento listo para imprimir'
|
|
59
|
+
|
|
60
|
+
/** Best-effort message from an error whose body is a Blob (responseType: 'blob'). */
|
|
61
|
+
export async function documentErrorMessage(err: unknown): Promise<string> {
|
|
62
|
+
const fallback = 'No se pudo generar el documento. Inténtalo de nuevo.'
|
|
63
|
+
const e = err as { response?: { data?: unknown; status?: number }; message?: unknown } | undefined
|
|
64
|
+
let data = e?.response?.data
|
|
65
|
+
try {
|
|
66
|
+
if (typeof Blob !== 'undefined' && data instanceof Blob) {
|
|
67
|
+
data = JSON.parse(await data.text())
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
data = undefined
|
|
71
|
+
}
|
|
72
|
+
const d = data as { message?: unknown; details?: unknown; error?: unknown } | string | undefined
|
|
73
|
+
if (typeof d === 'string' && d.trim()) return d.trim().slice(0, 200)
|
|
74
|
+
if (d && typeof d === 'object') {
|
|
75
|
+
const msg = [d.message, d.details, d.error].find((x) => typeof x === 'string' && x.trim()) as string | undefined
|
|
76
|
+
if (msg) return msg.trim()
|
|
77
|
+
}
|
|
78
|
+
if (e?.response?.status === 404) return 'El documento no está disponible todavía.'
|
|
79
|
+
return fallback
|
|
43
80
|
}
|
|
44
81
|
|
|
45
82
|
/** Parse filename from Content-Disposition (RFC 5987 / quoted). */
|
|
@@ -71,14 +108,14 @@ export function looksLikeFilenameTemplate(name: string | undefined): boolean {
|
|
|
71
108
|
*/
|
|
72
109
|
export function usePrintDocument() {
|
|
73
110
|
const api = useApi()
|
|
74
|
-
|
|
111
|
+
const run = useCallback(
|
|
75
112
|
async ({
|
|
76
113
|
model,
|
|
77
114
|
id,
|
|
78
115
|
key,
|
|
79
|
-
mode
|
|
116
|
+
mode,
|
|
80
117
|
filename,
|
|
81
|
-
}: PrintDocumentArgs): Promise<string> => {
|
|
118
|
+
}: Required<Pick<PrintDocumentArgs, 'model' | 'id' | 'key' | 'mode'>> & Pick<PrintDocumentArgs, 'filename'>): Promise<string> => {
|
|
82
119
|
const url = `/data/${encodeURIComponent(model)}/${encodeURIComponent(
|
|
83
120
|
id,
|
|
84
121
|
)}/documents/${encodeURIComponent(key)}.pdf`
|
|
@@ -145,4 +182,30 @@ export function usePrintDocument() {
|
|
|
145
182
|
},
|
|
146
183
|
[api],
|
|
147
184
|
)
|
|
185
|
+
return useCallback(
|
|
186
|
+
async ({
|
|
187
|
+
model,
|
|
188
|
+
id,
|
|
189
|
+
key,
|
|
190
|
+
mode = 'print',
|
|
191
|
+
filename,
|
|
192
|
+
feedback = true,
|
|
193
|
+
}: PrintDocumentArgs): Promise<string> => {
|
|
194
|
+
const fb = feedback === false ? {} : feedback === true ? { progress: true, success: true, error: true } : feedback
|
|
195
|
+
const toastId = fb.progress ? toast.loading(noun(mode)) : undefined
|
|
196
|
+
try {
|
|
197
|
+
const blobUrl = await run({ model, id, key, mode, filename })
|
|
198
|
+
if (toastId !== undefined) toast.dismiss(toastId)
|
|
199
|
+
if (fb.success) toast.success(readyMessage(mode))
|
|
200
|
+
return blobUrl
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (toastId !== undefined) toast.dismiss(toastId)
|
|
203
|
+
if (fb.error) toast.error(await documentErrorMessage(err))
|
|
204
|
+
throw err
|
|
205
|
+
} finally {
|
|
206
|
+
if (toastId !== undefined) toast.dismiss(toastId)
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
[run],
|
|
210
|
+
)
|
|
148
211
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { subscribeRecordMutations } from './record-mutation-events'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A counter that increments whenever a record of `model` is created, updated
|
|
6
|
+
* or deleted anywhere in the app. Fold it into a list's refetch deps.
|
|
7
|
+
*/
|
|
8
|
+
export function useRecordMutationTick(model: string | undefined | null): number {
|
|
9
|
+
const [tick, setTick] = useState(0)
|
|
10
|
+
useEffect(() => subscribeRecordMutations(model, () => setTick((n) => n + 1)), [model])
|
|
11
|
+
return tick
|
|
12
|
+
}
|
package/vitest.config.ts
CHANGED
package/vitest.setup.ts
CHANGED
|
@@ -27,3 +27,10 @@ function installMemoryStorage(name: 'localStorage' | 'sessionStorage') {
|
|
|
27
27
|
|
|
28
28
|
installMemoryStorage('localStorage')
|
|
29
29
|
installMemoryStorage('sessionStorage')
|
|
30
|
+
|
|
31
|
+
// `waitFor` defaults to 1s; the DynamicTable/DynamicKanban suites render large
|
|
32
|
+
// trees and miss it when the whole monorepo's tests run in parallel (turbo).
|
|
33
|
+
// A longer ceiling only affects failing waits, passing tests return immediately.
|
|
34
|
+
import { configure } from '@testing-library/react'
|
|
35
|
+
|
|
36
|
+
configure({ asyncUtilTimeout: 5000 })
|