@cosmicdrift/kumiko-bundled-features 0.259.0 → 0.260.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.260.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -129,12 +129,12 @@
|
|
|
129
129
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
130
130
|
},
|
|
131
131
|
"dependencies": {
|
|
132
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
132
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.260.0",
|
|
133
|
+
"@cosmicdrift/kumiko-framework": "0.260.0",
|
|
134
|
+
"@cosmicdrift/kumiko-headless": "0.260.0",
|
|
135
|
+
"@cosmicdrift/kumiko-renderer": "0.260.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer-web": "0.260.0",
|
|
137
|
+
"@cosmicdrift/kumiko-types": "0.260.0",
|
|
138
138
|
"@mollie/api-client": "^4.5.0",
|
|
139
139
|
"@node-rs/argon2": "^2.0.2",
|
|
140
140
|
"@types/mailparser": "^3.4.6",
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
],
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
167
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
166
|
+
"@cosmicdrift/kumiko-locale-de": "0.260.0",
|
|
167
|
+
"@cosmicdrift/kumiko-locale-es": "0.260.0"
|
|
168
168
|
}
|
|
169
169
|
}
|
|
@@ -242,6 +242,46 @@ describe("ledger integration — reverse-transaction (Storno)", () => {
|
|
|
242
242
|
// Original + exactly ONE Storno — a second reverse must not book again.
|
|
243
243
|
expect(await listTransactions()).toHaveLength(2);
|
|
244
244
|
});
|
|
245
|
+
|
|
246
|
+
test("the Storno inherits the original's subject — an eq filter on subjectId finds both", async () => {
|
|
247
|
+
const bank = await createAccount("Bank Subj", "asset");
|
|
248
|
+
const rent = await createAccount("Subj Income", "income");
|
|
249
|
+
const tx = await createTransaction(
|
|
250
|
+
[
|
|
251
|
+
{ accountId: bank, amount: 40000 },
|
|
252
|
+
{ accountId: rent, amount: -40000 },
|
|
253
|
+
],
|
|
254
|
+
{ description: "Miete WE1", subjectType: "lease", subjectId: "lease-1" },
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
await stack.http.writeOk(LedgerHandlers.reverseTransaction, { id: tx.id }, admin);
|
|
258
|
+
|
|
259
|
+
const rows = await listTransactions(admin, {
|
|
260
|
+
filter: { field: "subjectId", op: "eq", value: "lease-1" },
|
|
261
|
+
});
|
|
262
|
+
expect(rows).toHaveLength(2);
|
|
263
|
+
for (const row of rows) {
|
|
264
|
+
expect(row["subjectType"]).toBe("lease");
|
|
265
|
+
expect(row["subjectId"]).toBe("lease-1");
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("reversing a booking without a subject leaves the Storno without one", async () => {
|
|
270
|
+
const bank = await createAccount("Bank NoSubj", "asset");
|
|
271
|
+
const rent = await createAccount("NoSubj Income", "income");
|
|
272
|
+
const tx = await createTransaction([
|
|
273
|
+
{ accountId: bank, amount: 15000 },
|
|
274
|
+
{ accountId: rent, amount: -15000 },
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
await stack.http.writeOk(LedgerHandlers.reverseTransaction, { id: tx.id }, admin);
|
|
278
|
+
|
|
279
|
+
const rows = await listTransactions();
|
|
280
|
+
const storno = rows.find((r) => r["reference"] === tx.id);
|
|
281
|
+
expect(storno).toBeDefined();
|
|
282
|
+
expect(storno?.["subjectType"]).toBeNull();
|
|
283
|
+
expect(storno?.["subjectId"]).toBeNull();
|
|
284
|
+
});
|
|
245
285
|
});
|
|
246
286
|
|
|
247
287
|
describe("ledger integration — trial balance (golden invariant)", () => {
|
|
@@ -68,6 +68,12 @@ export function createReverseTransactionHandler(
|
|
|
68
68
|
amount: -l.amount,
|
|
69
69
|
}));
|
|
70
70
|
|
|
71
|
+
// The reversal shares its subject with the original so filtering by
|
|
72
|
+
// subjectId still finds the Storno; unset on the original stays unset.
|
|
73
|
+
const subjectType =
|
|
74
|
+
typeof original["subjectType"] === "string" ? original["subjectType"] : null;
|
|
75
|
+
const subjectId = typeof original["subjectId"] === "string" ? original["subjectId"] : null;
|
|
76
|
+
|
|
71
77
|
return transactionExecutor.create(
|
|
72
78
|
{
|
|
73
79
|
id: generateId(),
|
|
@@ -78,6 +84,8 @@ export function createReverseTransactionHandler(
|
|
|
78
84
|
reference: payload.id,
|
|
79
85
|
status: "posted",
|
|
80
86
|
lines,
|
|
87
|
+
subjectType,
|
|
88
|
+
subjectId,
|
|
81
89
|
},
|
|
82
90
|
event.user,
|
|
83
91
|
ctx.db,
|
|
@@ -20,9 +20,9 @@ import {
|
|
|
20
20
|
TokensProvider,
|
|
21
21
|
} from "@cosmicdrift/kumiko-renderer";
|
|
22
22
|
import { defaultPrimitives, defaultTokens } from "@cosmicdrift/kumiko-renderer-web";
|
|
23
|
-
import { render, waitFor } from "@testing-library/react";
|
|
23
|
+
import { fireEvent, render, waitFor } from "@testing-library/react";
|
|
24
24
|
import type { ReactNode } from "react";
|
|
25
|
-
import { NotesHistoryQueries } from "../constants";
|
|
25
|
+
import { NotesHistoryHandlers, NotesHistoryQueries } from "../constants";
|
|
26
26
|
import { defaultTranslations } from "../web/i18n";
|
|
27
27
|
import { NotesSection } from "../web/notes-section";
|
|
28
28
|
|
|
@@ -203,3 +203,106 @@ describe("NotesSection — entry display", () => {
|
|
|
203
203
|
expect(metaEl.textContent).toContain(formatWhen(INSERTED_AT));
|
|
204
204
|
});
|
|
205
205
|
});
|
|
206
|
+
|
|
207
|
+
function makeWriteCapturingDispatcher(): {
|
|
208
|
+
readonly dispatcher: Dispatcher;
|
|
209
|
+
readonly writes: { readonly type: string; readonly payload: unknown }[];
|
|
210
|
+
} {
|
|
211
|
+
const writes: { type: string; payload: unknown }[] = [];
|
|
212
|
+
const statusStore = createStore<DispatcherStatus>("online");
|
|
213
|
+
const query = (async (type: string) => {
|
|
214
|
+
if (type === NotesHistoryQueries.noteList) {
|
|
215
|
+
return { isSuccess: true, data: { rows: [] } };
|
|
216
|
+
}
|
|
217
|
+
return { isSuccess: true, data: null };
|
|
218
|
+
}) as unknown as Dispatcher["query"];
|
|
219
|
+
const write = (async (type: string, payload: unknown) => {
|
|
220
|
+
writes.push({ type, payload });
|
|
221
|
+
return { isSuccess: true, data: null };
|
|
222
|
+
}) as unknown as Dispatcher["write"];
|
|
223
|
+
return {
|
|
224
|
+
dispatcher: {
|
|
225
|
+
write,
|
|
226
|
+
query,
|
|
227
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
228
|
+
statusStore,
|
|
229
|
+
async *stream() {},
|
|
230
|
+
pendingWrites: () => [],
|
|
231
|
+
pendingFiles: () => [],
|
|
232
|
+
} as unknown as Dispatcher, // @cast-boundary test-stub
|
|
233
|
+
writes,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function renderSectionWith(dispatcher: Dispatcher): ReturnType<typeof render> {
|
|
238
|
+
const wrapper = ({ children }: { readonly children: ReactNode }): ReactNode => (
|
|
239
|
+
<TokensProvider value={stubTokens}>
|
|
240
|
+
<LocaleProvider
|
|
241
|
+
resolver={stubResolver}
|
|
242
|
+
fallbackBundles={[defaultTranslations, kumikoDefaultTranslations]}
|
|
243
|
+
>
|
|
244
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
245
|
+
<LiveEventsProvider value={stubLiveEvents}>
|
|
246
|
+
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
247
|
+
</LiveEventsProvider>
|
|
248
|
+
</PrimitivesProvider>
|
|
249
|
+
</LocaleProvider>
|
|
250
|
+
</TokensProvider>
|
|
251
|
+
);
|
|
252
|
+
return render(<NotesSection entityName="contact" entityId="contact-1" />, { wrapper });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function requireTextarea(container: HTMLElement): HTMLTextAreaElement {
|
|
256
|
+
const textarea = container.querySelector("textarea");
|
|
257
|
+
if (textarea === null) throw new Error("no textarea rendered");
|
|
258
|
+
return textarea;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
describe("NotesSection — Ctrl/Cmd+Enter submit shortcut", () => {
|
|
262
|
+
test("Ctrl+Enter with a non-empty draft dispatches addNote, same as the button", async () => {
|
|
263
|
+
const { dispatcher, writes } = makeWriteCapturingDispatcher();
|
|
264
|
+
const view = renderSectionWith(dispatcher);
|
|
265
|
+
await waitFor(() => expect(view.getByTestId("notes-section-add")).toBeTruthy());
|
|
266
|
+
|
|
267
|
+
const textarea = requireTextarea(view.container);
|
|
268
|
+
fireEvent.change(textarea, { target: { value: "Ctrl+Enter note" } });
|
|
269
|
+
fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true });
|
|
270
|
+
|
|
271
|
+
await waitFor(() => expect(writes).toHaveLength(1));
|
|
272
|
+
expect(writes[0]).toEqual({
|
|
273
|
+
type: NotesHistoryHandlers.addNote,
|
|
274
|
+
payload: { entityType: "contact", entityId: "contact-1", body: "Ctrl+Enter note" },
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("Cmd+Enter (metaKey) also dispatches addNote", async () => {
|
|
279
|
+
const { dispatcher, writes } = makeWriteCapturingDispatcher();
|
|
280
|
+
const view = renderSectionWith(dispatcher);
|
|
281
|
+
await waitFor(() => expect(view.getByTestId("notes-section-add")).toBeTruthy());
|
|
282
|
+
|
|
283
|
+
const textarea = requireTextarea(view.container);
|
|
284
|
+
fireEvent.change(textarea, { target: { value: "Cmd+Enter note" } });
|
|
285
|
+
fireEvent.keyDown(textarea, { key: "Enter", metaKey: true });
|
|
286
|
+
|
|
287
|
+
await waitFor(() => expect(writes).toHaveLength(1));
|
|
288
|
+
expect(writes[0]?.payload).toEqual({
|
|
289
|
+
entityType: "contact",
|
|
290
|
+
entityId: "contact-1",
|
|
291
|
+
body: "Cmd+Enter note",
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("Ctrl+Enter with an empty draft does nothing", async () => {
|
|
296
|
+
const { dispatcher, writes } = makeWriteCapturingDispatcher();
|
|
297
|
+
const view = renderSectionWith(dispatcher);
|
|
298
|
+
await waitFor(() => expect(view.getByTestId("notes-section-add")).toBeTruthy());
|
|
299
|
+
|
|
300
|
+
const textarea = requireTextarea(view.container);
|
|
301
|
+
fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true });
|
|
302
|
+
|
|
303
|
+
// No affirmative signal to await on a no-op — give pending microtasks a
|
|
304
|
+
// tick, then assert nothing landed.
|
|
305
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
306
|
+
expect(writes).toHaveLength(0);
|
|
307
|
+
});
|
|
308
|
+
});
|