@ram_28/kf-ai-sdk 2.0.12 → 2.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api.cjs +1 -1
- package/dist/api.mjs +2 -2
- package/dist/attachment-constants-B5jlqoKI.cjs +1 -0
- package/dist/attachment-constants-C2UHWxmp.js +63 -0
- package/dist/auth.cjs +1 -1
- package/dist/auth.mjs +1 -1
- package/dist/bdo/core/types.d.ts +4 -0
- package/dist/bdo/core/types.d.ts.map +1 -1
- package/dist/bdo/fields/NumberField.d.ts.map +1 -1
- package/dist/bdo/fields/ReferenceField.d.ts +3 -2
- package/dist/bdo/fields/ReferenceField.d.ts.map +1 -1
- package/dist/bdo/fields/SelectField.d.ts +1 -1
- package/dist/bdo/fields/SelectField.d.ts.map +1 -1
- package/dist/bdo/fields/UserField.d.ts +5 -0
- package/dist/bdo/fields/UserField.d.ts.map +1 -1
- package/dist/bdo.cjs +1 -1
- package/dist/bdo.mjs +107 -153
- package/dist/client-DnO2KKrw.cjs +1 -0
- package/dist/{client-CMERmrC-.js → client-iQTqFDNI.js} +34 -30
- package/dist/components/hooks/useForm/createItemProxy.d.ts +4 -0
- package/dist/components/hooks/useForm/createItemProxy.d.ts.map +1 -1
- package/dist/components/hooks/useForm/createResolver.d.ts.map +1 -1
- package/dist/components/hooks/useForm/useForm.d.ts +1 -0
- package/dist/components/hooks/useForm/useForm.d.ts.map +1 -1
- package/dist/form.cjs +1 -1
- package/dist/form.mjs +368 -203
- package/dist/{metadata-BfJtHz84.cjs → metadata-DgLSJkF5.cjs} +1 -1
- package/dist/{metadata-CwAo6a8e.js → metadata-DpfI3zRN.js} +1 -1
- package/dist/table.cjs +1 -1
- package/dist/table.mjs +1 -1
- package/dist/workflow/types.d.ts +3 -2
- package/dist/workflow/types.d.ts.map +1 -1
- package/dist/workflow.cjs +1 -1
- package/dist/workflow.d.ts +0 -2
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.mjs +204 -274
- package/dist/workflow.types.d.ts +0 -1
- package/dist/workflow.types.d.ts.map +1 -1
- package/docs/api.md +45 -253
- package/docs/bdo.md +130 -711
- package/docs/useAuth.md +42 -104
- package/docs/useFilter.md +117 -1591
- package/docs/useForm.md +263 -861
- package/docs/useTable.md +255 -1096
- package/docs/workflow.md +10 -155
- package/package.json +1 -1
- package/sdk/api/client.ts +18 -4
- package/sdk/bdo/core/types.ts +1 -0
- package/sdk/bdo/fields/NumberField.ts +2 -1
- package/sdk/bdo/fields/ReferenceField.ts +4 -3
- package/sdk/bdo/fields/SelectField.ts +2 -2
- package/sdk/bdo/fields/UserField.ts +14 -0
- package/sdk/components/hooks/useForm/createItemProxy.ts +221 -4
- package/sdk/components/hooks/useForm/createResolver.ts +16 -1
- package/sdk/components/hooks/useForm/useForm.ts +151 -50
- package/sdk/workflow/types.ts +3 -2
- package/sdk/workflow.ts +0 -7
- package/sdk/workflow.types.ts +0 -7
- package/dist/client-BnVxSHAm.cjs +0 -1
- package/dist/workflow/components/useActivityTable/index.d.ts +0 -4
- package/dist/workflow/components/useActivityTable/index.d.ts.map +0 -1
- package/dist/workflow/components/useActivityTable/types.d.ts +0 -53
- package/dist/workflow/components/useActivityTable/types.d.ts.map +0 -1
- package/dist/workflow/components/useActivityTable/useActivityTable.d.ts +0 -4
- package/dist/workflow/components/useActivityTable/useActivityTable.d.ts.map +0 -1
- package/sdk/workflow/components/useActivityTable/index.ts +0 -8
- package/sdk/workflow/components/useActivityTable/types.ts +0 -67
- package/sdk/workflow/components/useActivityTable/useActivityTable.ts +0 -145
package/docs/workflow.md
CHANGED
|
@@ -11,8 +11,6 @@ import {
|
|
|
11
11
|
Activity,
|
|
12
12
|
ActivityInstance,
|
|
13
13
|
useActivityForm,
|
|
14
|
-
useActivityTable,
|
|
15
|
-
ActivityTableStatus,
|
|
16
14
|
} from "@ram_28/kf-ai-sdk/workflow";
|
|
17
15
|
|
|
18
16
|
// Type-only exports
|
|
@@ -22,9 +20,6 @@ import type {
|
|
|
22
20
|
WorkflowStartResponseType,
|
|
23
21
|
UseActivityFormOptions,
|
|
24
22
|
UseActivityFormReturn,
|
|
25
|
-
UseActivityTableOptionsType,
|
|
26
|
-
UseActivityTableReturnType,
|
|
27
|
-
ActivityRowType,
|
|
28
23
|
} from "@ram_28/kf-ai-sdk/workflow";
|
|
29
24
|
|
|
30
25
|
// Field classes (for defining Activity fields)
|
|
@@ -87,72 +82,11 @@ System fields present on every activity instance. Returned alongside activity-sp
|
|
|
87
82
|
type ActivityInstanceFieldsType = {
|
|
88
83
|
_id: StringFieldType;
|
|
89
84
|
Status: SelectFieldType<"InProgress" | "Completed">;
|
|
90
|
-
AssignedTo:
|
|
85
|
+
AssignedTo: ReferenceFieldType<{ _id: StringFieldType; username: StringFieldType }>;
|
|
91
86
|
CompletedAt: DateTimeFieldType;
|
|
92
87
|
};
|
|
93
88
|
```
|
|
94
89
|
|
|
95
|
-
### ActivityTableStatus (constant)
|
|
96
|
-
|
|
97
|
-
```typescript
|
|
98
|
-
const ActivityTableStatus = {
|
|
99
|
-
InProgress: 'inprogress',
|
|
100
|
-
Completed: 'completed',
|
|
101
|
-
} as const;
|
|
102
|
-
|
|
103
|
-
type ActivityTableStatusType =
|
|
104
|
-
(typeof ActivityTableStatus)[keyof typeof ActivityTableStatus];
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
### ActivityRowType\<A\>
|
|
108
|
-
|
|
109
|
-
Row type for activity table data. Combines activity instance system fields with entity-specific fields.
|
|
110
|
-
|
|
111
|
-
```typescript
|
|
112
|
-
type ActivityRowType<A extends Activity<any, any, any>> =
|
|
113
|
-
ActivityInstanceFieldsType & ExtractActivityEntity<A>;
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
Concrete example — for `ManagerApprovalActivity` with `{ ManagerApproved: boolean, ManagerReason: string }`:
|
|
117
|
-
|
|
118
|
-
```typescript
|
|
119
|
-
// ActivityRowType<ManagerApprovalActivity> resolves to:
|
|
120
|
-
{
|
|
121
|
-
// System fields (from ActivityInstanceFieldsType)
|
|
122
|
-
_id: string;
|
|
123
|
-
Status: "InProgress" | "Completed";
|
|
124
|
-
AssignedTo: UserFieldType;
|
|
125
|
-
CompletedAt: string;
|
|
126
|
-
|
|
127
|
-
// Entity fields (from ManagerApprovalEntityType)
|
|
128
|
-
ManagerApproved: boolean;
|
|
129
|
-
ManagerReason: string;
|
|
130
|
-
}
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
### UseActivityTableOptionsType\<A\>
|
|
134
|
-
|
|
135
|
-
```typescript
|
|
136
|
-
interface UseActivityTableOptionsType<A extends Activity<any, any, any>> {
|
|
137
|
-
status: ActivityTableStatusType;
|
|
138
|
-
onError?: (error: Error) => void;
|
|
139
|
-
onSuccess?: (data: ActivityRowType<A>[]) => void;
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
### UseActivityTableReturnType\<A\>
|
|
144
|
-
|
|
145
|
-
```typescript
|
|
146
|
-
interface UseActivityTableReturnType<A extends Activity<any, any, any>> {
|
|
147
|
-
rows: ActivityRowType<A>[];
|
|
148
|
-
totalItems: number;
|
|
149
|
-
isLoading: boolean;
|
|
150
|
-
isFetching: boolean;
|
|
151
|
-
error: Error | null;
|
|
152
|
-
refetch: () => Promise<ListResponseType<ActivityRowType<A>>>;
|
|
153
|
-
}
|
|
154
|
-
```
|
|
155
|
-
|
|
156
90
|
### UseActivityFormOptions\<A\>
|
|
157
91
|
|
|
158
92
|
```typescript
|
|
@@ -448,40 +382,6 @@ User clicks Complete
|
|
|
448
382
|
|
|
449
383
|
---
|
|
450
384
|
|
|
451
|
-
## useActivityTable Hook
|
|
452
|
-
|
|
453
|
-
React hook for listing workflow activity instances. Fetches data from
|
|
454
|
-
`getInProgressList()` or `getCompletedList()` and the corresponding
|
|
455
|
-
metrics endpoint.
|
|
456
|
-
|
|
457
|
-
### Signature
|
|
458
|
-
|
|
459
|
-
```typescript
|
|
460
|
-
useActivityTable(activity: A, options: UseActivityTableOptionsType<A>)
|
|
461
|
-
: UseActivityTableReturnType<A>
|
|
462
|
-
```
|
|
463
|
-
|
|
464
|
-
### Options
|
|
465
|
-
|
|
466
|
-
| Property | Type | Default | Description |
|
|
467
|
-
|----------|------|---------|-------------|
|
|
468
|
-
| `status` | `ActivityTableStatusType` | *required* | `ActivityTableStatus.InProgress` or `ActivityTableStatus.Completed` |
|
|
469
|
-
| `onError` | `(error: Error) => void` | — | Error callback |
|
|
470
|
-
| `onSuccess` | `(data: ActivityRowType<A>[]) => void` | — | Success callback |
|
|
471
|
-
|
|
472
|
-
### Return Value
|
|
473
|
-
|
|
474
|
-
| Property | Type | Description |
|
|
475
|
-
|----------|------|-------------|
|
|
476
|
-
| `rows` | `ActivityRowType<A>[]` | Activity instance records (system + entity fields) |
|
|
477
|
-
| `totalItems` | `number` | Total count (from metrics endpoint) |
|
|
478
|
-
| `isLoading` | `boolean` | Initial load in progress |
|
|
479
|
-
| `isFetching` | `boolean` | Any fetch in progress (including refetch) |
|
|
480
|
-
| `error` | `Error \| null` | Fetch error |
|
|
481
|
-
| `refetch` | `() => Promise<...>` | Refetch both list and metrics |
|
|
482
|
-
|
|
483
|
-
---
|
|
484
|
-
|
|
485
385
|
## Use Case: Employee Creating Leave
|
|
486
386
|
|
|
487
387
|
### Step 1 — Start the workflow
|
|
@@ -642,63 +542,18 @@ function LeaveRequestPage() {
|
|
|
642
542
|
|
|
643
543
|
## Use Case: Manager Approving Leave
|
|
644
544
|
|
|
645
|
-
### Step 1 — List in-progress items
|
|
545
|
+
### Step 1 — List in-progress items
|
|
646
546
|
|
|
647
|
-
```
|
|
648
|
-
import {
|
|
649
|
-
import { useActivityTable, ActivityTableStatus } from "@ram_28/kf-ai-sdk/workflow";
|
|
650
|
-
import { SimpleLeaveProcess, ManagerApprovalActivity } from "@/bdo/workflows/SimpleLeaveProcess";
|
|
651
|
-
|
|
652
|
-
function ManagerApprovalPage() {
|
|
653
|
-
const activity = useMemo(() => new SimpleLeaveProcess().managerApprovalActivity(), []);
|
|
654
|
-
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
547
|
+
```typescript
|
|
548
|
+
import { SimpleLeaveProcess } from "@/bdo/workflows/SimpleLeaveProcess";
|
|
655
549
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
});
|
|
550
|
+
const wf = new SimpleLeaveProcess();
|
|
551
|
+
const activity = wf.managerApprovalActivity();
|
|
659
552
|
|
|
660
|
-
|
|
661
|
-
if (error) return <div>Error: {error.message}</div>;
|
|
662
|
-
|
|
663
|
-
if (selectedId) {
|
|
664
|
-
return (
|
|
665
|
-
<ApprovalForm
|
|
666
|
-
activityInstanceId={selectedId}
|
|
667
|
-
onComplete={() => {
|
|
668
|
-
setSelectedId(null);
|
|
669
|
-
refetch();
|
|
670
|
-
}}
|
|
671
|
-
/>
|
|
672
|
-
);
|
|
673
|
-
}
|
|
553
|
+
const result = await activity.getInProgressList();
|
|
674
554
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
<h2>Pending Approvals ({totalItems})</h2>
|
|
678
|
-
<table>
|
|
679
|
-
<thead>
|
|
680
|
-
<tr>
|
|
681
|
-
<th>ID</th>
|
|
682
|
-
<th>Status</th>
|
|
683
|
-
<th>Assigned To</th>
|
|
684
|
-
<th>Action</th>
|
|
685
|
-
</tr>
|
|
686
|
-
</thead>
|
|
687
|
-
<tbody>
|
|
688
|
-
{rows.map((row) => (
|
|
689
|
-
<tr key={row._id}>
|
|
690
|
-
<td>{row._id}</td>
|
|
691
|
-
<td>{row.Status}</td>
|
|
692
|
-
<td>{row.AssignedTo._name}</td>
|
|
693
|
-
<td>
|
|
694
|
-
<button onClick={() => setSelectedId(row._id)}>Review</button>
|
|
695
|
-
</td>
|
|
696
|
-
</tr>
|
|
697
|
-
))}
|
|
698
|
-
</tbody>
|
|
699
|
-
</table>
|
|
700
|
-
</div>
|
|
701
|
-
);
|
|
555
|
+
for (const item of result.Data) {
|
|
556
|
+
console.log(item._id, item.Status, item.AssignedTo.username);
|
|
702
557
|
}
|
|
703
558
|
```
|
|
704
559
|
|
|
@@ -848,7 +703,7 @@ const progressList = await wf.progress(BPInstanceId);
|
|
|
848
703
|
|-------|------|-------------|
|
|
849
704
|
| `_id` | `StringFieldType` | Unique activity instance identifier |
|
|
850
705
|
| `Status` | `SelectFieldType<"InProgress" \| "Completed">` | Current status |
|
|
851
|
-
| `AssignedTo` | `
|
|
706
|
+
| `AssignedTo` | `ReferenceFieldType<UserRefType>` | Assigned user (has `._id` and `.username`) |
|
|
852
707
|
| `CompletedAt` | `DateTimeFieldType` | Completion timestamp (`"YYYY-MM-DDTHH:MM:SS"`) |
|
|
853
708
|
|
|
854
709
|
---
|
package/package.json
CHANGED
package/sdk/api/client.ts
CHANGED
|
@@ -253,7 +253,7 @@ export function createResourceClient<T = any>(
|
|
|
253
253
|
throw new Error(`Failed to create ${basePath}: ${response.statusText}`);
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
return response.json();
|
|
256
|
+
return (await response.json()).Data;
|
|
257
257
|
},
|
|
258
258
|
|
|
259
259
|
async update(
|
|
@@ -272,7 +272,7 @@ export function createResourceClient<T = any>(
|
|
|
272
272
|
);
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
return response.json();
|
|
275
|
+
return (await response.json()).Data;
|
|
276
276
|
},
|
|
277
277
|
|
|
278
278
|
async delete(id: string): Promise<DeleteResponseType> {
|
|
@@ -555,7 +555,12 @@ export function createResourceClient<T = any>(
|
|
|
555
555
|
|
|
556
556
|
const responseData: { Data: FileDownloadResponseType } =
|
|
557
557
|
await response.json();
|
|
558
|
-
|
|
558
|
+
const downloadData = responseData.Data;
|
|
559
|
+
// Normalize: runtime returns DownloadUrl, components expect URL
|
|
560
|
+
if (downloadData && (downloadData as any).DownloadUrl && !(downloadData as any).URL) {
|
|
561
|
+
(downloadData as any).URL = (downloadData as any).DownloadUrl;
|
|
562
|
+
}
|
|
563
|
+
return downloadData;
|
|
559
564
|
},
|
|
560
565
|
|
|
561
566
|
async getDownloadUrls(
|
|
@@ -578,7 +583,16 @@ export function createResourceClient<T = any>(
|
|
|
578
583
|
|
|
579
584
|
const responseData: { Data: FileDownloadResponseType[] } =
|
|
580
585
|
await response.json();
|
|
581
|
-
|
|
586
|
+
const downloadList = responseData.Data;
|
|
587
|
+
// Normalize: runtime returns DownloadUrl, components expect URL
|
|
588
|
+
if (Array.isArray(downloadList)) {
|
|
589
|
+
downloadList.forEach((item) => {
|
|
590
|
+
if (item && (item as any).DownloadUrl && !(item as any).URL) {
|
|
591
|
+
(item as any).URL = (item as any).DownloadUrl;
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return downloadList;
|
|
582
596
|
},
|
|
583
597
|
|
|
584
598
|
async deleteAttachment(
|
package/sdk/bdo/core/types.ts
CHANGED
|
@@ -119,6 +119,7 @@ export interface UserFieldMetaType extends BaseFieldMetaType {
|
|
|
119
119
|
Type: "User";
|
|
120
120
|
Constraint?: BaseConstraintType;
|
|
121
121
|
View?: {
|
|
122
|
+
DataObject?: { Type: string; Id: string };
|
|
122
123
|
Filter?: Record<string, unknown>;
|
|
123
124
|
Sort?: unknown[];
|
|
124
125
|
BusinessEntity?: string;
|
|
@@ -27,7 +27,8 @@ export class NumberField extends BaseField<NumberFieldType> {
|
|
|
27
27
|
get fractionPart(): number | undefined { return (this._meta as NumberFieldMetaType).Constraint?.FractionPart; }
|
|
28
28
|
|
|
29
29
|
validate(value: NumberFieldType | undefined): ValidationResultType {
|
|
30
|
-
if (value
|
|
30
|
+
if (value == null || value === ("" as any)) return { valid: true, errors: [] };
|
|
31
|
+
if (typeof value !== "number" || isNaN(value)) {
|
|
31
32
|
return {
|
|
32
33
|
valid: false,
|
|
33
34
|
errors: [`${this.label} must be a valid number`],
|
|
@@ -68,14 +68,15 @@ export class ReferenceField<TRef = unknown> extends BaseField<
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* Fetch referenced records from the backend
|
|
71
|
+
* Fetch referenced records from the backend via the fetchField API.
|
|
72
|
+
* Requires the field to be bound to a parent BDO.
|
|
72
73
|
*/
|
|
73
|
-
async fetchOptions(instanceId
|
|
74
|
+
async fetchOptions(instanceId: string): Promise<TRef[]> {
|
|
74
75
|
if (!this._parentBoId) {
|
|
75
76
|
throw new Error(
|
|
76
77
|
`Field ${this.id} not bound to a BDO. Cannot fetch options.`
|
|
77
78
|
);
|
|
78
79
|
}
|
|
79
|
-
return api(this._parentBoId).fetchField<TRef>(instanceId
|
|
80
|
+
return api(this._parentBoId).fetchField<TRef>(instanceId, this.id);
|
|
80
81
|
}
|
|
81
82
|
}
|
|
@@ -57,14 +57,14 @@ export class SelectField<T extends string | number = string> extends BaseField<T
|
|
|
57
57
|
/**
|
|
58
58
|
* Fetch dynamic options from the backend, returned as typed SelectOption[]
|
|
59
59
|
*/
|
|
60
|
-
async fetchOptions(instanceId
|
|
60
|
+
async fetchOptions(instanceId: string): Promise<SelectOptionType<T>[]> {
|
|
61
61
|
if (!this._parentBoId) {
|
|
62
62
|
throw new Error(
|
|
63
63
|
`Field ${this.id} not bound to a BDO. Cannot fetch options.`
|
|
64
64
|
);
|
|
65
65
|
}
|
|
66
66
|
const response = await api(this._parentBoId).fetchField<FetchFieldOptionType>(
|
|
67
|
-
instanceId
|
|
67
|
+
instanceId,
|
|
68
68
|
this.id
|
|
69
69
|
);
|
|
70
70
|
return response.map((item) => ({
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { UserFieldType } from "../../types/base-fields";
|
|
7
7
|
import type { UserFieldMetaType, ValidationResultType } from "../core/types";
|
|
8
|
+
import { api } from "../../api/client";
|
|
8
9
|
import { BaseField } from "./BaseField";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -27,6 +28,19 @@ export class UserField extends BaseField<UserFieldType> {
|
|
|
27
28
|
return (this._meta as UserFieldMetaType).View?.BusinessEntity;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Fetch user records from the backend via the fetchField API.
|
|
33
|
+
* Requires the field to be bound to a parent BDO.
|
|
34
|
+
*/
|
|
35
|
+
async fetchOptions(instanceId: string): Promise<UserFieldType[]> {
|
|
36
|
+
if (!this._parentBoId) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Field ${this.id} not bound to a BDO. Cannot fetch options.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return api(this._parentBoId).fetchField<UserFieldType>(instanceId, this.id);
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
validate(value: UserFieldType | undefined): ValidationResultType {
|
|
31
45
|
if (value === undefined || value === null) {
|
|
32
46
|
return { valid: true, errors: [] };
|
|
@@ -2,6 +2,10 @@ import type { UseFormReturn, Path, FieldValues } from "react-hook-form";
|
|
|
2
2
|
import type { BaseBdo } from "../../../bdo";
|
|
3
3
|
import type { BaseFieldMetaType } from "../../../bdo/core/types";
|
|
4
4
|
import type { BaseField } from "../../../bdo/fields/BaseField";
|
|
5
|
+
import type { FileType } from "../../../types/base-fields";
|
|
6
|
+
import type { FileDownloadResponseType, AttachmentViewType } from "../../../types/common";
|
|
7
|
+
import { api } from "../../../api/client";
|
|
8
|
+
import { validateFileExtension, extractFileExtension } from "../../../bdo/fields/attachment-constants";
|
|
5
9
|
import { validateConstraints } from "./createResolver";
|
|
6
10
|
import type {
|
|
7
11
|
FormItemType,
|
|
@@ -17,6 +21,10 @@ import type {
|
|
|
17
21
|
* Key principle: Item has NO state. It's a view over RHF's state.
|
|
18
22
|
* Editable fields get set(), readonly fields do not.
|
|
19
23
|
*
|
|
24
|
+
* Draft-based upload: In create mode (no _id), upload() automatically creates
|
|
25
|
+
* a draft record via draftInteraction() to get an _id, then uploads immediately.
|
|
26
|
+
* On form submit, if a draft _id exists, update() is used instead of create().
|
|
27
|
+
*
|
|
20
28
|
* @param bdo - The BDO instance for field metadata
|
|
21
29
|
* @param form - The RHF useForm return object
|
|
22
30
|
* @returns SmartFormItem proxy
|
|
@@ -28,6 +36,35 @@ export function createItemProxy<B extends BaseBdo<any, any, any>>(
|
|
|
28
36
|
const fields = bdo.getFields();
|
|
29
37
|
const accessorCache = new Map<string, EditableFormFieldAccessorType<unknown> | ReadonlyFormFieldAccessorType<unknown>>();
|
|
30
38
|
|
|
39
|
+
// Draft tracking for create mode — shared across all attachment fields in this form
|
|
40
|
+
const boIdShared = bdo.getBoId();
|
|
41
|
+
let draftId: string | null = null;
|
|
42
|
+
let draftPromise: Promise<string> | null = null;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ensures a record _id exists for attachment uploads.
|
|
46
|
+
* In edit mode, returns the existing _id.
|
|
47
|
+
* In create mode, creates a draft record via draftInteraction() to get an _id.
|
|
48
|
+
* The draft _id is shared across all attachment fields and only created once.
|
|
49
|
+
*/
|
|
50
|
+
async function ensureDraft(): Promise<string> {
|
|
51
|
+
// If form already has an _id (edit mode or previous draft), use it
|
|
52
|
+
const existing = form.getValues("_id" as Path<FieldValues>) as string | undefined;
|
|
53
|
+
if (existing) return existing;
|
|
54
|
+
if (draftId) return draftId;
|
|
55
|
+
if (!draftPromise) {
|
|
56
|
+
draftPromise = api(boIdShared).draftInteraction({}).then((d: any) => {
|
|
57
|
+
draftId = d._id;
|
|
58
|
+
form.setValue("_id" as Path<FieldValues>, draftId as any, { shouldDirty: false });
|
|
59
|
+
return draftId!;
|
|
60
|
+
}).catch((err: Error) => {
|
|
61
|
+
draftPromise = null;
|
|
62
|
+
throw err;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return draftPromise;
|
|
66
|
+
}
|
|
67
|
+
|
|
31
68
|
return new Proxy({} as FormItemType<ExtractEditableType<B>, ExtractReadonlyType<B>>, {
|
|
32
69
|
get(_, prop: string | symbol) {
|
|
33
70
|
// Handle symbol properties (e.g., Symbol.toStringTag)
|
|
@@ -50,6 +87,11 @@ export function createItemProxy<B extends BaseBdo<any, any, any>>(
|
|
|
50
87
|
return () => form.trigger();
|
|
51
88
|
}
|
|
52
89
|
|
|
90
|
+
// Internal: check if a draft was created (used by handleSubmit)
|
|
91
|
+
if (prop === "_hasDraft") {
|
|
92
|
+
return () => !!draftId;
|
|
93
|
+
}
|
|
94
|
+
|
|
53
95
|
// Return cached accessor if available
|
|
54
96
|
if (accessorCache.has(prop)) {
|
|
55
97
|
return accessorCache.get(prop);
|
|
@@ -67,7 +109,21 @@ export function createItemProxy<B extends BaseBdo<any, any, any>>(
|
|
|
67
109
|
// Full validation: type + constraint + expression (matches createResolver pipeline)
|
|
68
110
|
const validate = () => {
|
|
69
111
|
if (!bdoField) return { valid: true, errors: [] };
|
|
70
|
-
|
|
112
|
+
let value = form.getValues(prop as Path<FieldValues>);
|
|
113
|
+
|
|
114
|
+
// Coerce string → number for NumberField (HTML inputs always send strings)
|
|
115
|
+
if ("integerPart" in bdoField && typeof value === "string" && value !== "") {
|
|
116
|
+
const num = Number(value);
|
|
117
|
+
if (!isNaN(num)) {
|
|
118
|
+
value = num;
|
|
119
|
+
form.setValue(prop as Path<FieldValues>, num as any, { shouldDirty: false });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Match backend BDO core: skip ALL validation for non-required empty fields
|
|
124
|
+
if (!bdoField.required && (value == null || value === "" || (Array.isArray(value) && value.length === 0))) {
|
|
125
|
+
return { valid: true, errors: [] };
|
|
126
|
+
}
|
|
71
127
|
|
|
72
128
|
// 1. Type validation
|
|
73
129
|
const typeResult = bdoField.validate(value);
|
|
@@ -98,13 +154,20 @@ export function createItemProxy<B extends BaseBdo<any, any, any>>(
|
|
|
98
154
|
};
|
|
99
155
|
|
|
100
156
|
if (!isReadOnly) {
|
|
157
|
+
// Defensive get(): File fields return [] instead of null/undefined, Image returns null
|
|
158
|
+
const fieldGet = () => {
|
|
159
|
+
const val = form.getValues(prop as Path<FieldValues>);
|
|
160
|
+
if (fieldMeta.Type === "File") return val ?? [];
|
|
161
|
+
return val;
|
|
162
|
+
};
|
|
163
|
+
|
|
101
164
|
const accessor: EditableFormFieldAccessorType<unknown> = {
|
|
102
165
|
label: bdoField?.label ?? prop,
|
|
103
166
|
required: bdoField?.required ?? false,
|
|
104
167
|
readOnly: false,
|
|
105
168
|
defaultValue: bdoField?.defaultValue,
|
|
106
169
|
meta: fieldMeta,
|
|
107
|
-
get:
|
|
170
|
+
get: fieldGet,
|
|
108
171
|
getOrDefault,
|
|
109
172
|
set: (value: unknown) => {
|
|
110
173
|
form.setValue(prop as Path<FieldValues>, value as any, {
|
|
@@ -115,20 +178,172 @@ export function createItemProxy<B extends BaseBdo<any, any, any>>(
|
|
|
115
178
|
},
|
|
116
179
|
validate,
|
|
117
180
|
};
|
|
181
|
+
|
|
182
|
+
// Enrich Image/File field accessors with attachment methods (draft-based upload)
|
|
183
|
+
if (fieldMeta.Type === "Image" || fieldMeta.Type === "File") {
|
|
184
|
+
const boId = boIdShared;
|
|
185
|
+
const requireInstanceId = (): string => {
|
|
186
|
+
const id = form.getValues("_id" as Path<FieldValues>) as string | undefined;
|
|
187
|
+
if (!id) throw new Error("Save the record before attachment operations");
|
|
188
|
+
return id;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
if (fieldMeta.Type === "Image") {
|
|
192
|
+
// Image: single file upload — always uploads immediately (draft in create mode)
|
|
193
|
+
(accessor as any).upload = async (file: File): Promise<FileType> => {
|
|
194
|
+
validateFileExtension(file.name, "Image");
|
|
195
|
+
const id = await ensureDraft();
|
|
196
|
+
|
|
197
|
+
const [uploadInfo] = await api(boId).getUploadUrl(id, prop, [
|
|
198
|
+
{ FileName: file.name, Size: file.size, FileExtension: extractFileExtension(file.name) },
|
|
199
|
+
]);
|
|
200
|
+
await fetch(uploadInfo.UploadUrl.URL, {
|
|
201
|
+
method: "PUT",
|
|
202
|
+
headers: { "Content-Type": uploadInfo.ContentType },
|
|
203
|
+
body: file,
|
|
204
|
+
});
|
|
205
|
+
const metadata: FileType = {
|
|
206
|
+
_id: uploadInfo._id,
|
|
207
|
+
_name: uploadInfo._name,
|
|
208
|
+
FileName: uploadInfo.FileName,
|
|
209
|
+
FileExtension: uploadInfo.FileExtension,
|
|
210
|
+
Size: uploadInfo.Size,
|
|
211
|
+
ContentType: uploadInfo.ContentType,
|
|
212
|
+
};
|
|
213
|
+
form.setValue(prop as Path<FieldValues>, metadata as any, { shouldDirty: true });
|
|
214
|
+
return metadata;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
(accessor as any).deleteAttachment = async (): Promise<void> => {
|
|
218
|
+
const val = form.getValues(prop as Path<FieldValues>) as any;
|
|
219
|
+
const instanceId = requireInstanceId();
|
|
220
|
+
if (!(val?._id)) throw new Error(`${prop} has no image to delete`);
|
|
221
|
+
await api(boId).deleteAttachment(instanceId, prop, val._id);
|
|
222
|
+
form.setValue(prop as Path<FieldValues>, null as any, { shouldDirty: true });
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
(accessor as any).getDownloadUrl = async (viewType?: AttachmentViewType): Promise<FileDownloadResponseType> => {
|
|
226
|
+
const val = form.getValues(prop as Path<FieldValues>) as any;
|
|
227
|
+
const instanceId = requireInstanceId();
|
|
228
|
+
if (!(val?._id)) throw new Error(`${prop} has no image`);
|
|
229
|
+
return api(boId).getDownloadUrl(instanceId, prop, val._id, viewType);
|
|
230
|
+
};
|
|
231
|
+
} else {
|
|
232
|
+
// File field — multi-file, always uploads immediately (draft in create mode)
|
|
233
|
+
(accessor as any).upload = async (files: File[]): Promise<FileType[]> => {
|
|
234
|
+
for (const file of files) validateFileExtension(file.name, "File");
|
|
235
|
+
const id = await ensureDraft();
|
|
236
|
+
|
|
237
|
+
const requests = files.map((file) => ({
|
|
238
|
+
FileName: file.name,
|
|
239
|
+
Size: file.size,
|
|
240
|
+
FileExtension: extractFileExtension(file.name),
|
|
241
|
+
}));
|
|
242
|
+
const uploadInfos = await api(boId).getUploadUrl(id, prop, requests);
|
|
243
|
+
const uploaded: FileType[] = await Promise.all(
|
|
244
|
+
files.map(async (file, i) => {
|
|
245
|
+
await fetch(uploadInfos[i].UploadUrl.URL, {
|
|
246
|
+
method: "PUT",
|
|
247
|
+
headers: { "Content-Type": uploadInfos[i].ContentType },
|
|
248
|
+
body: file,
|
|
249
|
+
});
|
|
250
|
+
return {
|
|
251
|
+
_id: uploadInfos[i]._id,
|
|
252
|
+
_name: uploadInfos[i]._name,
|
|
253
|
+
FileName: uploadInfos[i].FileName,
|
|
254
|
+
FileExtension: uploadInfos[i].FileExtension,
|
|
255
|
+
Size: uploadInfos[i].Size,
|
|
256
|
+
ContentType: uploadInfos[i].ContentType,
|
|
257
|
+
};
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
const current = (form.getValues(prop as Path<FieldValues>) as FileType[] | undefined) ?? [];
|
|
261
|
+
form.setValue(prop as Path<FieldValues>, [...current, ...uploaded] as any, { shouldDirty: true });
|
|
262
|
+
return uploaded;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
(accessor as any).deleteAttachment = async (attachmentId: string): Promise<void> => {
|
|
266
|
+
const current = (form.getValues(prop as Path<FieldValues>) as any[]) ?? [];
|
|
267
|
+
const instanceId = requireInstanceId();
|
|
268
|
+
await api(boId).deleteAttachment(instanceId, prop, attachmentId);
|
|
269
|
+
form.setValue(
|
|
270
|
+
prop as Path<FieldValues>,
|
|
271
|
+
current.filter((f) => f._id !== attachmentId) as any,
|
|
272
|
+
{ shouldDirty: true },
|
|
273
|
+
);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
(accessor as any).getDownloadUrl = async (
|
|
277
|
+
attachmentId: string,
|
|
278
|
+
viewType?: AttachmentViewType,
|
|
279
|
+
): Promise<FileDownloadResponseType> => {
|
|
280
|
+
const instanceId = requireInstanceId();
|
|
281
|
+
return api(boId).getDownloadUrl(instanceId, prop, attachmentId, viewType);
|
|
282
|
+
};
|
|
283
|
+
(accessor as any).getDownloadUrls = async (
|
|
284
|
+
viewType?: AttachmentViewType,
|
|
285
|
+
): Promise<FileDownloadResponseType[]> => {
|
|
286
|
+
const instanceId = requireInstanceId();
|
|
287
|
+
return api(boId).getDownloadUrls(instanceId, prop, viewType);
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
118
292
|
accessorCache.set(prop, accessor);
|
|
119
293
|
return accessor;
|
|
120
294
|
}
|
|
121
295
|
|
|
296
|
+
// Defensive get() for readonly accessor too
|
|
297
|
+
const readonlyGet = () => {
|
|
298
|
+
const val = form.getValues(prop as Path<FieldValues>);
|
|
299
|
+
if (fieldMeta.Type === "File") return val ?? [];
|
|
300
|
+
return val;
|
|
301
|
+
};
|
|
302
|
+
|
|
122
303
|
const accessor: ReadonlyFormFieldAccessorType<unknown> = {
|
|
123
304
|
label: bdoField?.label ?? prop,
|
|
124
305
|
required: bdoField?.required ?? false,
|
|
125
306
|
readOnly: true,
|
|
126
307
|
defaultValue: bdoField?.defaultValue,
|
|
127
308
|
meta: fieldMeta,
|
|
128
|
-
get:
|
|
309
|
+
get: readonlyGet,
|
|
129
310
|
getOrDefault,
|
|
130
311
|
validate,
|
|
131
312
|
};
|
|
313
|
+
|
|
314
|
+
// Enrich readonly Image/File field accessors with download methods
|
|
315
|
+
if (fieldMeta.Type === "Image" || fieldMeta.Type === "File") {
|
|
316
|
+
const boId = boIdShared;
|
|
317
|
+
const requireInstanceId = (): string => {
|
|
318
|
+
const id = form.getValues("_id" as Path<FieldValues>) as string | undefined;
|
|
319
|
+
if (!id) throw new Error("Cannot perform attachment operation: item has no _id. Save the item first.");
|
|
320
|
+
return id;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
if (fieldMeta.Type === "Image") {
|
|
324
|
+
(accessor as any).getDownloadUrl = async (viewType?: AttachmentViewType): Promise<FileDownloadResponseType> => {
|
|
325
|
+
const val = form.getValues(prop as Path<FieldValues>) as any;
|
|
326
|
+
const instanceId = requireInstanceId();
|
|
327
|
+
if (!(val?._id)) throw new Error(`${prop} has no image to download`);
|
|
328
|
+
return api(boId).getDownloadUrl(instanceId, prop, val._id, viewType);
|
|
329
|
+
};
|
|
330
|
+
} else {
|
|
331
|
+
(accessor as any).getDownloadUrl = async (
|
|
332
|
+
attachmentId: string,
|
|
333
|
+
viewType?: AttachmentViewType,
|
|
334
|
+
): Promise<FileDownloadResponseType> => {
|
|
335
|
+
const instanceId = requireInstanceId();
|
|
336
|
+
return api(boId).getDownloadUrl(instanceId, prop, attachmentId, viewType);
|
|
337
|
+
};
|
|
338
|
+
(accessor as any).getDownloadUrls = async (
|
|
339
|
+
viewType?: AttachmentViewType,
|
|
340
|
+
): Promise<FileDownloadResponseType[]> => {
|
|
341
|
+
const instanceId = requireInstanceId();
|
|
342
|
+
return api(boId).getDownloadUrls(instanceId, prop, viewType);
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
132
347
|
accessorCache.set(prop, accessor);
|
|
133
348
|
return accessor;
|
|
134
349
|
},
|
|
@@ -137,6 +352,8 @@ export function createItemProxy<B extends BaseBdo<any, any, any>>(
|
|
|
137
352
|
if (typeof prop === "symbol") return false;
|
|
138
353
|
if (prop === "_id" || prop === "toJSON" || prop === "validate")
|
|
139
354
|
return true;
|
|
355
|
+
if (prop === "_hasDraft")
|
|
356
|
+
return true;
|
|
140
357
|
return prop in fields;
|
|
141
358
|
},
|
|
142
359
|
|
|
@@ -148,7 +365,7 @@ export function createItemProxy<B extends BaseBdo<any, any, any>>(
|
|
|
148
365
|
if (typeof prop === "symbol") return undefined;
|
|
149
366
|
return {
|
|
150
367
|
configurable: true,
|
|
151
|
-
enumerable: prop !== "toJSON" && prop !== "validate",
|
|
368
|
+
enumerable: prop !== "toJSON" && prop !== "validate" && prop !== "_hasDraft",
|
|
152
369
|
};
|
|
153
370
|
},
|
|
154
371
|
});
|