@olenbetong/appframe-cli 4.3.1 → 4.4.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/cli/af-apply.js +1 -1
  3. package/cli/af-deploy.js +1 -1
  4. package/cli/editor/TransactionsEditor.js +134 -158
  5. package/cli/editor/TransactionsPreviewDialog.js +175 -104
  6. package/cli/editor/tableFormatting.js +9 -80
  7. package/cli/editor/useCommand.js +148 -0
  8. package/cli/editor/useScrollableText.js +28 -0
  9. package/cli/editor/useTransactionEdits.js +142 -0
  10. package/cli/editor/useTransactionText.js +113 -0
  11. package/cli/editor/useTransactions.js +53 -0
  12. package/cli/editor/useTransactionsSelection.js +41 -0
  13. package/cli/editor/useTransactionsTableLayout.js +54 -0
  14. package/cli/editor/{useTransactionsTableViewport.js → useVirtualScrolling.js} +62 -24
  15. package/cli/editor/useVirtualText.js +18 -0
  16. package/package.json +2 -2
  17. package/src/af-apply.ts +1 -1
  18. package/src/af-deploy.ts +1 -1
  19. package/src/editor/TransactionsEditor.tsx +155 -180
  20. package/src/editor/TransactionsPreviewDialog.tsx +196 -146
  21. package/src/editor/tableFormatting.ts +8 -94
  22. package/src/editor/useCommand.ts +191 -0
  23. package/src/editor/useScrollableText.ts +48 -0
  24. package/src/editor/useTransactionEdits.ts +183 -0
  25. package/src/editor/useTransactionText.ts +153 -0
  26. package/src/editor/useTransactions.ts +75 -0
  27. package/src/editor/useTransactionsSelection.ts +54 -0
  28. package/src/editor/useTransactionsTableLayout.ts +84 -0
  29. package/src/editor/{useTransactionsTableViewport.ts → useVirtualScrolling.ts} +87 -38
  30. package/src/editor/useVirtualText.ts +32 -0
  31. package/tsconfig.build.tsbuildinfo +1 -1
  32. package/cli/editor/useTransactionsEditorData.js +0 -206
  33. package/cli/editor/useTransactionsEditorInput.js +0 -109
  34. package/src/editor/useTransactionsEditorData.ts +0 -245
  35. package/src/editor/useTransactionsEditorInput.ts +0 -147
@@ -0,0 +1,191 @@
1
+ import { useInput } from "ink";
2
+ import { useCallback, useMemo, useRef } from "react";
3
+
4
+ type KeyHandler = (input: string, key: Parameters<Parameters<typeof useInput>[0]>[1]) => void;
5
+
6
+ type ShortcutDefinition = {
7
+ key: string | null;
8
+ ctrl: boolean;
9
+ meta: boolean;
10
+ shift: boolean;
11
+ };
12
+
13
+ type Shortcut = string | string[];
14
+
15
+ const HOME_SEQUENCES = new Set(["\u001b[H", "\u001b[1~", "\u001bOH"]);
16
+ const END_SEQUENCES = new Set(["\u001b[F", "\u001b[4~", "\u001bOF"]);
17
+ const PAGE_UP_SEQUENCES = new Set(["\u001b[5~"]);
18
+ const PAGE_DOWN_SEQUENCES = new Set(["\u001b[6~"]);
19
+
20
+ const KEY_ALIASES: Record<string, string> = {
21
+ leftarrow: "left",
22
+ rightarrow: "right",
23
+ uparrow: "up",
24
+ downarrow: "down",
25
+ return: "enter",
26
+ enter: "enter",
27
+ esc: "escape",
28
+ spacebar: "space",
29
+ pagedown: "pagedown",
30
+ pageup: "pageup",
31
+ };
32
+
33
+ // Keys that are considered non-printable/navigation and for which we ignore extra modifiers
34
+ // when matching (i.e., allow escape/home/end/pageup/pagedown/tab/enter/delete/backspace to
35
+ // match even if the terminal sets stray meta/shift/ctrl flags).
36
+ const RELAXED_KEYS = new Set([
37
+ "escape",
38
+ "left",
39
+ "right",
40
+ "up",
41
+ "down",
42
+ "home",
43
+ "end",
44
+ "pageup",
45
+ "pagedown",
46
+ "tab",
47
+ "enter",
48
+ "delete",
49
+ "backspace",
50
+ ]);
51
+
52
+ function parseShortcut(shortcut: string): ShortcutDefinition {
53
+ let tokens = shortcut
54
+ .trim()
55
+ .toLowerCase()
56
+ .split("+")
57
+ .map((token) => token.trim())
58
+ .filter(Boolean);
59
+
60
+ let definition: ShortcutDefinition = {
61
+ key: null,
62
+ ctrl: false,
63
+ meta: false,
64
+ shift: false,
65
+ };
66
+
67
+ for (let token of tokens) {
68
+ if (token === "ctrl" || token === "control") {
69
+ definition.ctrl = true;
70
+ continue;
71
+ }
72
+ if (token === "meta" || token === "cmd" || token === "command") {
73
+ definition.meta = true;
74
+ continue;
75
+ }
76
+ if (token === "shift") {
77
+ definition.shift = true;
78
+ continue;
79
+ }
80
+
81
+ let alias = KEY_ALIASES[token];
82
+ definition.key = alias ?? token;
83
+ }
84
+
85
+ return definition;
86
+ }
87
+
88
+ function getInputKey(input: string, key: Parameters<KeyHandler>[1]): string | null {
89
+ if (key.escape) {
90
+ return "escape";
91
+ }
92
+ if (key.leftArrow) {
93
+ return "left";
94
+ }
95
+ if (key.rightArrow) {
96
+ return "right";
97
+ }
98
+ if (key.upArrow) {
99
+ return "up";
100
+ }
101
+ if (key.downArrow) {
102
+ return "down";
103
+ }
104
+ if (key.pageUp || PAGE_UP_SEQUENCES.has(input)) {
105
+ return "pageup";
106
+ }
107
+ if (key.pageDown || PAGE_DOWN_SEQUENCES.has(input)) {
108
+ return "pagedown";
109
+ }
110
+ if (key.return) {
111
+ return "enter";
112
+ }
113
+ if (key.delete) {
114
+ return "delete";
115
+ }
116
+ if (key.backspace) {
117
+ return "backspace";
118
+ }
119
+ if (key.tab) {
120
+ return "tab";
121
+ }
122
+ if (key.shift && !input) {
123
+ return null;
124
+ }
125
+ if (input === " ") {
126
+ return "space";
127
+ }
128
+ if (HOME_SEQUENCES.has(input) || ((key as { home?: boolean }).home ?? false)) {
129
+ return "home";
130
+ }
131
+ if (END_SEQUENCES.has(input) || ((key as { end?: boolean }).end ?? false)) {
132
+ return "end";
133
+ }
134
+ if (input) {
135
+ return input.toLowerCase();
136
+ }
137
+ return null;
138
+ }
139
+
140
+ function matchesShortcut(definition: ShortcutDefinition, input: string, key: Parameters<KeyHandler>[1]): boolean {
141
+ let actualKey = getInputKey(input, key);
142
+
143
+ // If the terminal reports a key we treat as non-printable/navigation,
144
+ // relax extra modifiers (only require those explicitly specified),
145
+ // but for printable keys we require an exact modifier match.
146
+ let relaxed = actualKey !== null && RELAXED_KEYS.has(actualKey);
147
+
148
+ // Require specified modifiers to be present
149
+ if (definition.ctrl && !key.ctrl) return false;
150
+ if (definition.meta && !key.meta) return false;
151
+ if (definition.shift && !key.shift) return false;
152
+
153
+ // For printable keys (non-relaxed), disallow extra modifiers
154
+ if (!relaxed) {
155
+ if (!definition.ctrl && key.ctrl) return false;
156
+ if (!definition.meta && key.meta) return false;
157
+ if (!definition.shift && key.shift) return false;
158
+ }
159
+
160
+ if (definition.key === null) {
161
+ // Modifiers-only shortcut: match only if we identified some key press
162
+ // (prevents bare modifier from triggering)
163
+ return actualKey !== null;
164
+ }
165
+ return actualKey === definition.key;
166
+ }
167
+
168
+ function normalizeShortcuts(shortcuts: Shortcut): ShortcutDefinition[] {
169
+ let entries = Array.isArray(shortcuts) ? shortcuts : [shortcuts];
170
+ return entries.map(parseShortcut);
171
+ }
172
+
173
+ export function useCommand(shortcut: Shortcut, handler: () => void | Promise<void>, enabled = true) {
174
+ let definitions = useMemo(() => normalizeShortcuts(shortcut), [shortcut]);
175
+ let callback = useRef(handler);
176
+ callback.current = handler;
177
+
178
+ let inputHandler: KeyHandler = useCallback(
179
+ (input, key) => {
180
+ for (let definition of definitions) {
181
+ if (matchesShortcut(definition, input, key)) {
182
+ callback.current();
183
+ return;
184
+ }
185
+ }
186
+ },
187
+ [definitions],
188
+ );
189
+
190
+ useInput(inputHandler, { isActive: enabled });
191
+ }
@@ -0,0 +1,48 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ import { useCommand } from "./useCommand.js";
4
+
5
+ export type UseScrollableTextOptions = {
6
+ textLength: number;
7
+ viewportHeight: number;
8
+ signature: string;
9
+ };
10
+
11
+ export type UseScrollableTextResult = {
12
+ scrollOffset: number;
13
+ canScroll: boolean;
14
+ maxScrollOffset: number;
15
+ pageStep: number;
16
+ };
17
+
18
+ export function useScrollableText({
19
+ textLength,
20
+ viewportHeight,
21
+ signature,
22
+ }: UseScrollableTextOptions): UseScrollableTextResult {
23
+ let [scrollOffset, setScrollOffset] = useState(0);
24
+
25
+ let lastSignatureRef = useRef(signature);
26
+ useEffect(() => {
27
+ if (lastSignatureRef.current !== signature) {
28
+ lastSignatureRef.current = signature;
29
+ setScrollOffset(0);
30
+ }
31
+ }, [signature]);
32
+
33
+ let maxScrollOffset = useMemo(() => Math.max((textLength || 1) - viewportHeight, 0), [textLength, viewportHeight]);
34
+
35
+ useEffect(() => {
36
+ setScrollOffset((offset) => Math.min(offset, maxScrollOffset));
37
+ }, [maxScrollOffset]);
38
+
39
+ let canScroll = textLength > viewportHeight && viewportHeight > 0;
40
+ let pageStep = useMemo(() => Math.max(viewportHeight - 1, 1), [viewportHeight]);
41
+
42
+ useCommand("up", () => setScrollOffset((offset) => Math.max(offset - 1, 0)), canScroll);
43
+ useCommand("down", () => setScrollOffset((offset) => Math.min(offset + 1, maxScrollOffset)), canScroll);
44
+ useCommand("pageup", () => setScrollOffset((offset) => Math.max(offset - pageStep, 0)), canScroll);
45
+ useCommand("pagedown", () => setScrollOffset((offset) => Math.min(offset + pageStep, maxScrollOffset)), canScroll);
46
+
47
+ return { scrollOffset, canScroll, maxScrollOffset, pageStep };
48
+ }
@@ -0,0 +1,183 @@
1
+ import {
2
+ type Transaction,
3
+ type TransactionStatusCode,
4
+ type TransactionsRecord,
5
+ transactionStatuses,
6
+ } from "@olenbetong/appframe-updater";
7
+ import { useCallback, useEffect, useMemo, useState } from "react";
8
+
9
+ import type { Server } from "../lib/Server.js";
10
+ import type { TransactionsEditorRefreshOptions } from "./useTransactions.js";
11
+
12
+ type UseTransactionEditsOptions = {
13
+ normalizedTransactions: Transaction[];
14
+ transactionRecordMap: Map<string, TransactionsRecord>;
15
+ selectedPrimKeys: Set<string>;
16
+ server: Server;
17
+ onActionMessage?: (message: string | null, color: "gray" | "red" | "yellow" | "green") => void;
18
+ refresh: (options?: TransactionsEditorRefreshOptions) => Promise<void> | void;
19
+ };
20
+
21
+ type UseTransactionEditsResult = {
22
+ pendingStatuses: Record<string, TransactionStatusCode>;
23
+ modifiedPrimKeys: Set<string>;
24
+ cycleStatus: (focusedPrimKey: string | null, options?: { exclusive?: boolean }) => void;
25
+ save: (options?: { primKey?: string | null }) => Promise<void>;
26
+ saving: boolean;
27
+ };
28
+
29
+ const STATUS_CYCLE = Object.freeze(
30
+ (Object.values(transactionStatuses) as TransactionStatusCode[]).sort((a, b) => a - b),
31
+ );
32
+
33
+ export function useTransactionEdits({
34
+ normalizedTransactions,
35
+ transactionRecordMap,
36
+ selectedPrimKeys,
37
+ server,
38
+ onActionMessage,
39
+ refresh,
40
+ }: UseTransactionEditsOptions): UseTransactionEditsResult {
41
+ let [pendingStatuses, setPendingStatuses] = useState<Record<string, TransactionStatusCode>>({});
42
+ let [saving, setSaving] = useState(false);
43
+
44
+ let normalizedTransactionMap = useMemo(
45
+ () => new Map(normalizedTransactions.map((transaction) => [transaction.PrimKey, transaction])),
46
+ [normalizedTransactions],
47
+ );
48
+
49
+ let availablePrimKeys = useMemo(
50
+ () => new Set(normalizedTransactions.map((transaction) => transaction.PrimKey)),
51
+ [normalizedTransactions],
52
+ );
53
+
54
+ useEffect(() => {
55
+ setPendingStatuses((current) => {
56
+ let changed = false;
57
+ let next: Record<string, TransactionStatusCode> = {};
58
+ for (let [key, value] of Object.entries(current)) {
59
+ if (availablePrimKeys.has(key)) {
60
+ next[key] = value;
61
+ } else {
62
+ changed = true;
63
+ }
64
+ }
65
+ return changed ? next : current;
66
+ });
67
+ }, [availablePrimKeys]);
68
+
69
+ let modifiedPrimKeys = useMemo(() => {
70
+ let modified = new Set<string>();
71
+ for (let transaction of normalizedTransactions) {
72
+ let override = pendingStatuses[transaction.PrimKey];
73
+ if (override !== undefined && override !== transaction.Status) {
74
+ modified.add(transaction.PrimKey);
75
+ }
76
+ }
77
+ return modified;
78
+ }, [normalizedTransactions, pendingStatuses]);
79
+
80
+ let cycleStatus = useCallback(
81
+ (focusedPrimKey: string | null, options?: { exclusive?: boolean }) => {
82
+ let targets: string[] = [];
83
+ if (options?.exclusive) {
84
+ if (focusedPrimKey) {
85
+ targets = [focusedPrimKey];
86
+ }
87
+ } else if (selectedPrimKeys.size > 0) {
88
+ targets = Array.from(selectedPrimKeys);
89
+ } else if (focusedPrimKey) {
90
+ targets = [focusedPrimKey];
91
+ }
92
+
93
+ if (targets.length === 0) {
94
+ return;
95
+ }
96
+
97
+ setPendingStatuses((current) => {
98
+ let next = { ...current };
99
+ let changed = false;
100
+ for (let primKey of targets) {
101
+ let transaction = normalizedTransactionMap.get(primKey);
102
+ if (!transaction) {
103
+ continue;
104
+ }
105
+ let originalStatus = transaction.Status;
106
+ let currentStatus = current[primKey] ?? originalStatus;
107
+ let cycleIndex = STATUS_CYCLE.indexOf(currentStatus);
108
+ let nextStatus = STATUS_CYCLE[(cycleIndex + 1) % STATUS_CYCLE.length];
109
+ if (nextStatus === originalStatus) {
110
+ if (primKey in next) {
111
+ delete next[primKey];
112
+ changed = true;
113
+ }
114
+ } else if (next[primKey] !== nextStatus) {
115
+ next[primKey] = nextStatus;
116
+ changed = true;
117
+ }
118
+ }
119
+ return changed ? next : current;
120
+ });
121
+ },
122
+ [normalizedTransactionMap, selectedPrimKeys],
123
+ );
124
+
125
+ let save = useCallback(
126
+ async (options?: { primKey?: string | null }) => {
127
+ if (saving) {
128
+ return;
129
+ }
130
+ let restrictedPrimKeys = options?.primKey ? new Set([options.primKey]) : null;
131
+
132
+ let updates = Object.entries(pendingStatuses)
133
+ .filter(([primKey]) => !restrictedPrimKeys || restrictedPrimKeys.has(primKey))
134
+ .map(([primKey, status]) => {
135
+ let record = transactionRecordMap.get(primKey);
136
+ if (!record) {
137
+ return null;
138
+ }
139
+ if (status === record.Status) {
140
+ return null;
141
+ }
142
+ return { record, status };
143
+ })
144
+ .filter(Boolean) as Array<{ record: TransactionsRecord; status: number }>;
145
+
146
+ if (updates.length === 0) {
147
+ onActionMessage?.("No changes to save.", "gray");
148
+ return;
149
+ }
150
+
151
+ setSaving(true);
152
+ onActionMessage?.(`Saving ${updates.length} update${updates.length === 1 ? "" : "s"}…`, "yellow");
153
+ try {
154
+ await Promise.all(
155
+ updates.map(({ record, status }) => {
156
+ let payload = record.PrimKey
157
+ ? { PrimKey: record.PrimKey, Status: status }
158
+ : { ID: record.ID, Status: status };
159
+ return server.dsTransactions.update(payload);
160
+ }),
161
+ );
162
+ onActionMessage?.(`Saved ${updates.length} update${updates.length === 1 ? "" : "s"}.`, "green");
163
+ setPendingStatuses((current) => {
164
+ let next = { ...current };
165
+ for (let { record } of updates) {
166
+ delete next[record.PrimKey];
167
+ }
168
+ return next;
169
+ });
170
+ let anchor = updates[0]?.record.PrimKey ?? null;
171
+ await refresh({ preserveViewport: true, anchorPrimKey: anchor });
172
+ } catch (caught) {
173
+ let message = caught instanceof Error ? caught.message : String(caught);
174
+ onActionMessage?.(`Failed to save changes: ${message}`, "red");
175
+ } finally {
176
+ setSaving(false);
177
+ }
178
+ },
179
+ [pendingStatuses, refresh, saving, server, transactionRecordMap, onActionMessage],
180
+ );
181
+
182
+ return { pendingStatuses, modifiedPrimKeys, cycleStatus, save, saving };
183
+ }
@@ -0,0 +1,153 @@
1
+ import type { TransactionsRecord } from "@olenbetong/appframe-updater";
2
+ import { useMemo } from "react";
3
+
4
+ function formatPreviewValue(value: unknown) {
5
+ if (typeof value === "string") {
6
+ let lines = value.split(/\r?\n/);
7
+ return lines.length > 0 ? lines : [""];
8
+ }
9
+
10
+ if (value && typeof value === "object") {
11
+ try {
12
+ let serialized = JSON.stringify(value, null, 2);
13
+ if (serialized) {
14
+ return serialized.split("\n");
15
+ }
16
+ } catch {}
17
+ }
18
+
19
+ if (value === null) {
20
+ return ["null"];
21
+ }
22
+
23
+ return [String(value)];
24
+ }
25
+
26
+ function wrapLineForWidth(line: string, maxWidth: number) {
27
+ if (maxWidth <= 0) {
28
+ return [line];
29
+ }
30
+
31
+ let normalized = line.replaceAll("\t", " ");
32
+ if (normalized.length <= maxWidth) {
33
+ return [normalized];
34
+ }
35
+
36
+ let segments: string[] = [];
37
+ let remaining = normalized;
38
+ while (remaining.length > maxWidth) {
39
+ let breakIndex = remaining.lastIndexOf(" ", maxWidth);
40
+ if (breakIndex <= 0) {
41
+ breakIndex = maxWidth;
42
+ }
43
+
44
+ let segment = remaining.slice(0, breakIndex).trimEnd();
45
+ segments.push(segment);
46
+ remaining = remaining.slice(breakIndex).replace(/^\s+/, "");
47
+ }
48
+
49
+ segments.push(remaining);
50
+ return segments;
51
+ }
52
+
53
+ function wrapLinesForWidth(lines: string[], maxWidth: number) {
54
+ if (lines.length === 0) {
55
+ return [""];
56
+ }
57
+
58
+ return lines.flatMap((line) => {
59
+ let wrapped = wrapLineForWidth(line, maxWidth);
60
+ return wrapped.length > 0 ? wrapped : [""];
61
+ });
62
+ }
63
+
64
+ function truncateKey(key: string, width: number) {
65
+ if (width <= 0) {
66
+ return "";
67
+ }
68
+
69
+ if (key.length <= width) {
70
+ return key.padEnd(width);
71
+ }
72
+
73
+ if (width === 1) {
74
+ return "…";
75
+ }
76
+
77
+ return `${key.slice(0, width - 1)}…`;
78
+ }
79
+
80
+ export type UseTransactionTextOptions = {
81
+ transaction: TransactionsRecord | undefined;
82
+ statusPreviewValue: string;
83
+ frameWidth: number;
84
+ };
85
+
86
+ export type UseTransactionTextResult = {
87
+ text: string[];
88
+ dialogWidth: number;
89
+ };
90
+
91
+ export function useTransactionText({
92
+ transaction,
93
+ statusPreviewValue,
94
+ frameWidth,
95
+ }: UseTransactionTextOptions): UseTransactionTextResult {
96
+ return useMemo(() => {
97
+ let entries = transaction
98
+ ? Object.entries(transaction)
99
+ .filter(([key]) => key !== "PrimKey")
100
+ .map(([key, value]) => ({
101
+ key,
102
+ lines: formatPreviewValue(key === "Status" ? statusPreviewValue : value),
103
+ }))
104
+ : [];
105
+
106
+ let maxKeyLength = entries.reduce((max, entry) => Math.max(max, entry.key.length), 0);
107
+ let rawKeyColumnWidth = Math.max(maxKeyLength, 1);
108
+ let availableDialogWidth = Math.max(frameWidth - 4, 1);
109
+ let dialogWidth = Math.min(Math.max(rawKeyColumnWidth + 80, 10), availableDialogWidth);
110
+ if (!Number.isFinite(dialogWidth) || dialogWidth <= 0) {
111
+ dialogWidth = 10;
112
+ }
113
+
114
+ let contentWidth = Math.max(dialogWidth - 4, 1);
115
+
116
+ let keyColumnWidth = entries.length > 0 ? Math.min(rawKeyColumnWidth, Math.max(contentWidth - 1, 0)) : 0;
117
+ if (keyColumnWidth < 0) {
118
+ keyColumnWidth = 0;
119
+ }
120
+
121
+ let separatorWidth = keyColumnWidth > 0 ? 1 : 0;
122
+ let valueColumnWidth = Math.max(contentWidth - keyColumnWidth - separatorWidth, 1);
123
+
124
+ let text: string[] = [];
125
+ if (entries.length === 0) {
126
+ return { text, dialogWidth };
127
+ }
128
+
129
+ let spacer = "";
130
+ let blankKey = keyColumnWidth > 0 ? " ".repeat(keyColumnWidth) : "";
131
+ let separator = separatorWidth > 0 ? " " : "";
132
+
133
+ entries.forEach((entry, entryIndex) => {
134
+ let valueLines = wrapLinesForWidth(entry.lines, valueColumnWidth);
135
+ if (valueLines.length === 0) {
136
+ valueLines = [""];
137
+ }
138
+
139
+ let formattedKey = truncateKey(entry.key, keyColumnWidth);
140
+ valueLines.forEach((line, lineIndex) => {
141
+ let keySegment = lineIndex === 0 ? formattedKey : blankKey;
142
+ let combined = keyColumnWidth > 0 ? `${keySegment}${separator}${line}` : line;
143
+ text.push(combined);
144
+ });
145
+
146
+ if (entryIndex < entries.length - 1) {
147
+ text.push(spacer);
148
+ }
149
+ });
150
+
151
+ return { text, dialogWidth };
152
+ }, [transaction, statusPreviewValue, frameWidth]);
153
+ }
@@ -0,0 +1,75 @@
1
+ import {
2
+ getTransactions,
3
+ type Transaction,
4
+ type TransactionFilter,
5
+ type TransactionsRecord,
6
+ } from "@olenbetong/appframe-updater";
7
+ import { useCallback, useEffect, useMemo, useState } from "react";
8
+
9
+ import type { Server } from "../lib/Server.js";
10
+
11
+ export type TransactionsEditorRefreshOptions = {
12
+ preserveViewport?: boolean;
13
+ anchorPrimKey?: string | null;
14
+ };
15
+
16
+ type UseTransactionsOptions = {
17
+ server: Server;
18
+ namespace: string | number;
19
+ filter?: TransactionFilter;
20
+ };
21
+
22
+ export function useTransactions({ filter, server, namespace }: UseTransactionsOptions) {
23
+ let [transactions, setTransactions] = useState<TransactionsRecord[]>([]);
24
+ let [loading, setLoading] = useState(false);
25
+ let [error, setError] = useState<Error | undefined>();
26
+
27
+ let refresh = useCallback(
28
+ async (_options: TransactionsEditorRefreshOptions = {}) => {
29
+ setLoading(true);
30
+ setError(undefined);
31
+ try {
32
+ let data = (await getTransactions(server.client, filter ?? "edit", namespace)) as TransactionsRecord[];
33
+ setTransactions(data);
34
+ } catch (caught) {
35
+ setError(caught as Error);
36
+ } finally {
37
+ setLoading(false);
38
+ }
39
+ },
40
+ [namespace, server, filter],
41
+ );
42
+
43
+ useEffect(() => {
44
+ setError(undefined);
45
+ refresh();
46
+ }, [refresh]);
47
+
48
+ let normalizedTransactions = useMemo<Transaction[]>(
49
+ () =>
50
+ transactions.map((transaction) => ({
51
+ ID: transaction.ID,
52
+ Namespace: transaction.Namespace,
53
+ Name: transaction.Name,
54
+ CreatedBy: transaction.CreatedBy ?? transaction.CreatedByName ?? transaction.LocalCreatedBy,
55
+ Status: transaction.Status,
56
+ LastError: transaction.LastError ?? undefined,
57
+ PrimKey: transaction.PrimKey,
58
+ })),
59
+ [transactions],
60
+ );
61
+
62
+ let transactionMap = useMemo(
63
+ () => new Map(transactions.map((transaction) => [transaction.PrimKey, transaction])),
64
+ [transactions],
65
+ );
66
+
67
+ return {
68
+ transactions,
69
+ loading,
70
+ error,
71
+ refresh,
72
+ normalizedTransactions,
73
+ transactionMap,
74
+ };
75
+ }
@@ -0,0 +1,54 @@
1
+ import type { TransactionsRecord } from "@olenbetong/appframe-updater";
2
+ import { useCallback, useEffect, useState } from "react";
3
+
4
+ type TransactionsLike = Pick<TransactionsRecord, "PrimKey">;
5
+
6
+ type UseTransactionsSelectionResult = {
7
+ selectedPrimKeys: Set<string>;
8
+ toggleSelection: (primKey: string | null | undefined) => void;
9
+ resetSelection: () => void;
10
+ };
11
+
12
+ export function useTransactionsSelection(transactions: TransactionsLike[]): UseTransactionsSelectionResult {
13
+ let [selectedPrimKeys, setSelectedPrimKeys] = useState<Set<string>>(new Set());
14
+
15
+ useEffect(() => {
16
+ let availablePrimKeys = new Set(transactions.map((transaction) => transaction.PrimKey));
17
+ setSelectedPrimKeys((current) => {
18
+ if (current.size === 0) {
19
+ return current;
20
+ }
21
+ let next = new Set<string>();
22
+ let changed = false;
23
+ for (let key of current) {
24
+ if (availablePrimKeys.has(key)) {
25
+ next.add(key);
26
+ } else {
27
+ changed = true;
28
+ }
29
+ }
30
+ return changed ? next : current;
31
+ });
32
+ }, [transactions]);
33
+
34
+ let toggleSelection = useCallback((primKey: string | null | undefined) => {
35
+ if (!primKey) {
36
+ return;
37
+ }
38
+ setSelectedPrimKeys((current) => {
39
+ let next = new Set(current);
40
+ if (next.has(primKey)) {
41
+ next.delete(primKey);
42
+ } else {
43
+ next.add(primKey);
44
+ }
45
+ return next;
46
+ });
47
+ }, []);
48
+
49
+ let resetSelection = useCallback(() => {
50
+ setSelectedPrimKeys(new Set());
51
+ }, []);
52
+
53
+ return { selectedPrimKeys, toggleSelection, resetSelection };
54
+ }