vultus-core 0.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/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "vultus-core",
3
+ "version": "0.1.0",
4
+ "license": "Apache-2.0",
5
+ "description": "Framework-neutral action model + hooks for vultus (antd-free by design)",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/deai-network/unitas.git",
9
+ "directory": "packages/vultus-core"
10
+ },
11
+ "author": "Kristof Csillag <kristof.csillag@deai-labs.com>",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./src/index.ts",
15
+ "./link": "./src/LinkContext.tsx"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "!src/**/__tests__",
20
+ "!src/**/*.test.ts",
21
+ "!src/**/*.test.tsx",
22
+ "README.md"
23
+ ],
24
+ "peerDependencies": {
25
+ "react": ">=18",
26
+ "react-i18next": ">=15"
27
+ },
28
+ "devDependencies": {
29
+ "@testing-library/jest-dom": "^6.9.1",
30
+ "@testing-library/react": "^16.3.2",
31
+ "@types/react": "^19.0.0",
32
+ "@types/react-dom": "^19.0.0",
33
+ "i18next": "^25.0.0",
34
+ "jsdom": "^29.0.1",
35
+ "react": "^19.2.4",
36
+ "react-dom": "^19.2.4",
37
+ "react-i18next": "^17.0.0",
38
+ "typescript": "^5.7.0",
39
+ "vitest": "^3.2.4"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc",
43
+ "test": "vitest run"
44
+ }
45
+ }
@@ -0,0 +1,18 @@
1
+ import { createContext, useContext, type ComponentType, type ReactNode } from 'react';
2
+
3
+ /**
4
+ * How an internal (`/`-prefixed) markdown link renders. Hosts with a router
5
+ * inject an SPA link; the default is a full-navigation anchor so router-less
6
+ * consumers (optio-conversation-ui) work with zero setup.
7
+ */
8
+ export type InternalLinkComponent = ComponentType<{ href: string; children?: ReactNode }>;
9
+
10
+ const DefaultInternalLink: InternalLinkComponent = ({ href, children }) => (
11
+ <a href={href}>{children}</a>
12
+ );
13
+
14
+ export const InternalLinkContext = createContext<InternalLinkComponent>(DefaultInternalLink);
15
+
16
+ export function useInternalLink(): InternalLinkComponent {
17
+ return useContext(InternalLinkContext);
18
+ }
@@ -0,0 +1,15 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ /**
4
+ * How the action framework surfaces an otherwise-unhandled error (no form field,
5
+ * no inline error target). Default is a no-op so vultus-core stays antd-free and
6
+ * framework-neutral; vultus-antd's VultusProvider wires this to antd's
7
+ * message.error. Hosts may inject any toast/notification mechanism.
8
+ */
9
+ export type MessageSink = (text: string) => void;
10
+
11
+ export const MessageSinkContext = createContext<MessageSink>(() => {});
12
+
13
+ export function useMessageSink(): MessageSink {
14
+ return useContext(MessageSinkContext);
15
+ }
@@ -0,0 +1,125 @@
1
+ import type React from 'react';
2
+ import type { MutableRefObject } from 'react';
3
+ import { getVerdict, getReason } from './decision.js';
4
+ import { resolve } from './resolve.js';
5
+ import { routeApiError } from './routeApiError.js';
6
+ import { parseApiError } from './parseApiError.js';
7
+ import type { MessageSink } from './MessageSink.js';
8
+ import type {
9
+ ActionErrorCtx,
10
+ ActionOptions,
11
+ ActionStatus,
12
+ ConfirmationSpec,
13
+ Decision,
14
+ ErrorRoutesRegistry,
15
+ } from './types.js';
16
+
17
+ /**
18
+ * Hookless core of the action framework, shared by useAction (single) and
19
+ * useActionList (dynamic list) so the firing machinery + ActionStatus shape
20
+ * live in exactly one place — neither entry point reimplements the other.
21
+ */
22
+
23
+ export interface ResolvedFields {
24
+ label: string;
25
+ icon: React.ReactNode | undefined;
26
+ variant: 'default' | 'primary' | 'danger';
27
+ disabled: boolean;
28
+ reason: string | undefined;
29
+ invisible: boolean;
30
+ confirmation: ConfirmationSpec | undefined;
31
+ }
32
+
33
+ /** Resolve the static (hookless) ActionStatus fields from the options. */
34
+ export function resolveActionFields(opts: ActionOptions<any, any, any>): ResolvedFields {
35
+ const enabledD = resolve<Decision>(opts.enabled, true);
36
+ return {
37
+ label: resolve(opts.label, ''),
38
+ icon: resolve<React.ReactNode | undefined>(opts.icon, undefined),
39
+ variant: resolve<'default' | 'primary' | 'danger'>(opts.variant, 'default'),
40
+ disabled: !getVerdict(enabledD, true),
41
+ reason: getReason(enabledD),
42
+ invisible: resolve(opts.invisible, false),
43
+ confirmation: resolve<ConfirmationSpec | undefined>(opts.confirmation, undefined),
44
+ };
45
+ }
46
+
47
+ export interface FirePromiseDeps<RouteId extends string> {
48
+ disabled: boolean;
49
+ reason: string | undefined;
50
+ getPending: () => boolean;
51
+ setPending: (value: boolean) => void;
52
+ setErrors: (errors: string[]) => void;
53
+ errCtxRef: MutableRefObject<ActionErrorCtx | null>;
54
+ t: (key: string, opts?: Record<string, unknown>) => string;
55
+ registry?: ErrorRoutesRegistry<RouteId>;
56
+ messageSink?: MessageSink;
57
+ }
58
+
59
+ /** Build the async fire (pending guard + error routing) for one action. */
60
+ export function makeFirePromise<TArgs, TResult, RouteId extends string>(
61
+ opts: ActionOptions<TArgs, TResult, RouteId>,
62
+ deps: FirePromiseDeps<RouteId>,
63
+ ): (args: TArgs) => Promise<void> {
64
+ return async (args: TArgs) => {
65
+ if (deps.disabled) {
66
+ console.warn(`Action '${opts.id}': disabled — ${deps.reason ?? '(no reason)'}`);
67
+ return;
68
+ }
69
+ if (deps.getPending()) {
70
+ console.warn(`Action '${opts.id}': already pending — ignoring fire`);
71
+ return;
72
+ }
73
+ deps.setErrors([]);
74
+ deps.setPending(true);
75
+ try {
76
+ const result = await opts.fire(args);
77
+ opts.onSuccess?.(result as TResult);
78
+ } catch (err) {
79
+ if (opts.errorRoute && deps.registry) {
80
+ routeApiError(
81
+ err,
82
+ {
83
+ route: opts.errorRoute,
84
+ form: deps.errCtxRef.current?.form,
85
+ setInlineError: deps.errCtxRef.current?.setInlineError,
86
+ t: deps.t,
87
+ showError: deps.messageSink,
88
+ },
89
+ deps.registry,
90
+ );
91
+ }
92
+ const parsed = parseApiError(err);
93
+ deps.setErrors([parsed.message ?? 'Action failed']);
94
+ } finally {
95
+ deps.setPending(false);
96
+ }
97
+ };
98
+ }
99
+
100
+ /** Assemble the public ActionStatus from resolved fields + state + fire. */
101
+ export function assembleStatus<TArgs>(
102
+ opts: ActionOptions<TArgs, any, any>,
103
+ fields: ResolvedFields,
104
+ pending: boolean,
105
+ errors: string[],
106
+ firePromise: (args: TArgs) => Promise<void>,
107
+ ): ActionStatus<TArgs> {
108
+ const fire = (args: TArgs) => {
109
+ void firePromise(args);
110
+ };
111
+ return {
112
+ id: opts.id,
113
+ label: fields.label,
114
+ icon: fields.icon,
115
+ variant: fields.variant,
116
+ pending,
117
+ disabled: fields.disabled,
118
+ reason: fields.reason,
119
+ invisible: fields.invisible,
120
+ confirmation: fields.confirmation,
121
+ errors,
122
+ fire: fire as ActionStatus<TArgs>['fire'],
123
+ firePromise: firePromise as ActionStatus<TArgs>['firePromise'],
124
+ };
125
+ }
@@ -0,0 +1,21 @@
1
+ import type { Decision } from './types.js';
2
+
3
+ export const allow = (reason?: string): Decision => ({ verdict: true, reason });
4
+ export const deny = (reason?: string): Decision => ({ verdict: false, reason });
5
+ export const denyWithReason = (reason: string): Decision => ({ verdict: false, reason });
6
+
7
+ export const getVerdict = (d: Decision | undefined, fallback: boolean): boolean =>
8
+ d === undefined ? fallback : typeof d === 'boolean' ? d : d.verdict;
9
+
10
+ export const getReason = (d: Decision | undefined): string | undefined =>
11
+ d === undefined ? undefined : typeof d === 'boolean' ? undefined : d.reason;
12
+
13
+ export const andDecisions = (a: Decision, b: Decision): Decision => {
14
+ const av = getVerdict(a, false);
15
+ const bv = getVerdict(b, false);
16
+ if (av && bv) {
17
+ const reasons = [getReason(a), getReason(b)].filter(Boolean) as string[];
18
+ return { verdict: true, reason: reasons.join('; ') || undefined };
19
+ }
20
+ return av ? b : a;
21
+ };
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from './types.js';
2
+ export * from './decision.js';
3
+ export * from './resolve.js';
4
+ export * from './parseApiError.js';
5
+ export * from './routeApiError.js';
6
+ export { ActionErrorContext, useActionErrorCtx } from './useActionErrorCtx.js';
7
+ export { useAction } from './useAction.js';
8
+ export { useActionList } from './useActionList.js';
9
+ export { InternalLinkContext, useInternalLink, type InternalLinkComponent } from './LinkContext.js';
10
+ export { MessageSinkContext, useMessageSink, type MessageSink } from './MessageSink.js';
@@ -0,0 +1,11 @@
1
+ import type { ParsedApiError } from './types.js';
2
+
3
+ export function parseApiError(err: unknown): ParsedApiError {
4
+ if (typeof err !== 'object' || err === null) return { status: 0 };
5
+ const e = err as { status?: number; body?: { message?: string; reason?: string } };
6
+ return {
7
+ status: e.status ?? 0,
8
+ reason: e.body?.reason,
9
+ message: e.body?.message,
10
+ };
11
+ }
package/src/resolve.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { ValueOrFn } from './types.js';
2
+
3
+ export function resolve<T>(v: ValueOrFn<T> | undefined, fallback: T): T {
4
+ if (v === undefined) return fallback;
5
+ return typeof v === 'function' ? (v as () => T)() : v;
6
+ }
@@ -0,0 +1,25 @@
1
+ import { parseApiError } from './parseApiError.js';
2
+ import type { ApiErrorContext, ErrorRoutesRegistry } from './types.js';
3
+
4
+ export function routeApiError<RouteId extends string>(
5
+ err: unknown,
6
+ ctx: ApiErrorContext<RouteId>,
7
+ registry: ErrorRoutesRegistry<RouteId>,
8
+ ): void {
9
+ const parsed = parseApiError(err);
10
+ const routeMap = registry[ctx.route];
11
+ const entry = parsed.reason ? routeMap?.[parsed.reason] : undefined;
12
+ const text = entry?.i18nKey
13
+ ? ctx.t(entry.i18nKey)
14
+ : (parsed.message ?? ctx.t('common.error'));
15
+
16
+ if (entry?.field && ctx.form) {
17
+ ctx.form.setFields([{ name: entry.field, errors: [text] }]);
18
+ return;
19
+ }
20
+ if (ctx.setInlineError) {
21
+ ctx.setInlineError(text);
22
+ return;
23
+ }
24
+ (ctx.showError ?? (() => {}))(text);
25
+ }
package/src/types.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type React from 'react';
2
+ import type { MessageSink } from './MessageSink.js';
3
+
4
+ /**
5
+ * Minimal structural form contract used by error-routing (antd's FormInstance is
6
+ * assignable to this). Keeps vultus-core antd-free — see AGENTS.md.
7
+ */
8
+ export interface FormLike {
9
+ setFields(fields: { name: string | number | (string | number)[]; errors: string[] }[]): void;
10
+ }
11
+
12
+ export type Decision = boolean | { verdict: boolean; reason?: string };
13
+ export type ValueOrFn<T> = T | (() => T);
14
+
15
+ export type ConfirmationSpec =
16
+ | { kind: 'popconfirm'; question: string }
17
+ | { kind: 'cascade-modal'; title: string; content: React.ReactNode }
18
+ | { kind: 'typing'; title: string; entityName: string; description: React.ReactNode };
19
+
20
+ export interface ActionOptions<TArgs = void, TResult = void, RouteId extends string = string> {
21
+ id: string;
22
+ label: ValueOrFn<string>;
23
+ icon?: ValueOrFn<React.ReactNode>;
24
+ variant?: ValueOrFn<'default' | 'primary' | 'danger'>;
25
+ enabled?: ValueOrFn<Decision>;
26
+ invisible?: ValueOrFn<boolean>;
27
+ confirmation?: ValueOrFn<ConfirmationSpec | undefined>;
28
+ errorRoute?: RouteId;
29
+ fire: (args: TArgs) => Promise<TResult> | TResult;
30
+ onSuccess?: (result: TResult) => void;
31
+ }
32
+
33
+ // Conditional rest-tuple lets call sites omit args entirely when TArgs is
34
+ // void (e.g. plain ActionButton click) and requires the value otherwise
35
+ // (e.g. FormSubmitButton passing typed form values).
36
+ type FireArgs<TArgs> = [TArgs] extends [void] ? [] : [args: TArgs];
37
+
38
+ export interface ActionStatus<TArgs = void> {
39
+ id: string;
40
+ label: string;
41
+ icon?: React.ReactNode;
42
+ variant: 'default' | 'primary' | 'danger';
43
+ pending: boolean;
44
+ disabled: boolean;
45
+ reason?: string;
46
+ invisible: boolean;
47
+ confirmation?: ConfirmationSpec;
48
+ errors: string[];
49
+ fire(...args: FireArgs<TArgs>): void;
50
+ firePromise(...args: FireArgs<TArgs>): Promise<void>;
51
+ }
52
+
53
+ export type ParsedApiError = {
54
+ status: number;
55
+ reason?: string;
56
+ message?: string;
57
+ };
58
+
59
+ export type RegistryEntry = { i18nKey: string; field?: string };
60
+ export type ErrorRoutesRegistry<RouteId extends string> =
61
+ Record<RouteId, Record<string, RegistryEntry>>;
62
+
63
+ export type ApiErrorContext<RouteId extends string> = {
64
+ route: RouteId;
65
+ form?: FormLike;
66
+ setInlineError?: (text: string | null) => void;
67
+ t: (key: string) => string;
68
+ showError?: MessageSink;
69
+ };
70
+
71
+ export type ActionErrorCtx = {
72
+ form: FormLike;
73
+ setInlineError: (text: string | null) => void;
74
+ };
@@ -0,0 +1,61 @@
1
+ import { useMemo, useRef, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { useActionErrorCtx } from './useActionErrorCtx.js';
4
+ import { useMessageSink } from './MessageSink.js';
5
+ import { resolveActionFields, makeFirePromise, assembleStatus } from './action-core.js';
6
+ import type { ActionOptions, ActionStatus, ErrorRoutesRegistry } from './types.js';
7
+
8
+ export function useAction<TArgs = void, TResult = void, RouteId extends string = string>(
9
+ opts: ActionOptions<TArgs, TResult, RouteId>,
10
+ registry?: ErrorRoutesRegistry<RouteId>,
11
+ ): ActionStatus<TArgs> {
12
+ const errCtx = useActionErrorCtx();
13
+ const errCtxRef = useRef(errCtx);
14
+ errCtxRef.current = errCtx;
15
+ const { t } = useTranslation();
16
+ const messageSink = useMessageSink();
17
+
18
+ const [pending, setPending] = useState(false);
19
+ const [errors, setErrors] = useState<string[]>([]);
20
+ const pendingRef = useRef(pending);
21
+ pendingRef.current = pending;
22
+
23
+ const fields = resolveActionFields(opts);
24
+
25
+ const firePromise = useMemo(
26
+ () =>
27
+ makeFirePromise(opts, {
28
+ disabled: fields.disabled,
29
+ reason: fields.reason,
30
+ getPending: () => pendingRef.current,
31
+ setPending,
32
+ setErrors,
33
+ errCtxRef,
34
+ t,
35
+ registry,
36
+ messageSink,
37
+ }),
38
+ [opts, fields.disabled, fields.reason, registry, t, messageSink],
39
+ );
40
+
41
+ // Granular deps by design: memo invalidates on the specific opts/fields
42
+ // primitives below, not on the whole `opts`/`fields` objects (which are
43
+ // fresh each render and would defeat the memo).
44
+ return useMemo<ActionStatus<TArgs>>(
45
+ () => assembleStatus(opts, fields, pending, errors, firePromise),
46
+ // eslint-disable-next-line react-hooks/exhaustive-deps
47
+ [
48
+ opts.id,
49
+ fields.label,
50
+ fields.icon,
51
+ fields.variant,
52
+ pending,
53
+ fields.disabled,
54
+ fields.reason,
55
+ fields.invisible,
56
+ fields.confirmation,
57
+ errors,
58
+ firePromise,
59
+ ],
60
+ );
61
+ }
@@ -0,0 +1,8 @@
1
+ import { createContext, useContext } from 'react';
2
+ import type { ActionErrorCtx } from './types.js';
3
+
4
+ export const ActionErrorContext = createContext<ActionErrorCtx | null>(null);
5
+
6
+ export function useActionErrorCtx(): ActionErrorCtx | null {
7
+ return useContext(ActionErrorContext);
8
+ }
@@ -0,0 +1,58 @@
1
+ import { useMemo, useRef, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { useActionErrorCtx } from './useActionErrorCtx.js';
4
+ import { useMessageSink } from './MessageSink.js';
5
+ import { resolveActionFields, makeFirePromise, assembleStatus } from './action-core.js';
6
+ import type { ActionOptions, ActionStatus, ErrorRoutesRegistry } from './types.js';
7
+
8
+ /**
9
+ * Build a DYNAMIC list of actions from a spec array — the list counterpart to
10
+ * useAction. The hook count is fixed regardless of list length (one shared
11
+ * error context, one pending/errors map, one memo), so it is usable where
12
+ * useAction — a per-call hook — cannot be looped (rules of hooks). Pending and
13
+ * errors are tracked per action id in the shared maps; each action gets real
14
+ * per-item pending. The firing machinery + ActionStatus shape are the exact
15
+ * same shared helpers useAction uses (see action-core.ts) — nothing is
16
+ * reimplemented.
17
+ */
18
+ export function useActionList<TArgs = void, TResult = void, RouteId extends string = string>(
19
+ specs: ActionOptions<TArgs, TResult, RouteId>[],
20
+ registry?: ErrorRoutesRegistry<RouteId>,
21
+ ): ActionStatus<TArgs>[] {
22
+ const errCtx = useActionErrorCtx();
23
+ const errCtxRef = useRef(errCtx);
24
+ errCtxRef.current = errCtx;
25
+ const { t } = useTranslation();
26
+ const messageSink = useMessageSink();
27
+
28
+ const [pendingMap, setPendingMap] = useState<Record<string, boolean>>({});
29
+ const [errorsMap, setErrorsMap] = useState<Record<string, string[]>>({});
30
+ const pendingRef = useRef(pendingMap);
31
+ pendingRef.current = pendingMap;
32
+
33
+ return useMemo(
34
+ () =>
35
+ specs.map((opts) => {
36
+ const fields = resolveActionFields(opts);
37
+ const firePromise = makeFirePromise(opts, {
38
+ disabled: fields.disabled,
39
+ reason: fields.reason,
40
+ getPending: () => !!pendingRef.current[opts.id],
41
+ setPending: (value) => setPendingMap((m) => ({ ...m, [opts.id]: value })),
42
+ setErrors: (errs) => setErrorsMap((m) => ({ ...m, [opts.id]: errs })),
43
+ errCtxRef,
44
+ t,
45
+ registry,
46
+ messageSink,
47
+ });
48
+ return assembleStatus(
49
+ opts,
50
+ fields,
51
+ !!pendingMap[opts.id],
52
+ errorsMap[opts.id] ?? [],
53
+ firePromise,
54
+ );
55
+ }),
56
+ [specs, pendingMap, errorsMap, registry, t, messageSink],
57
+ );
58
+ }