@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.259.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.259.0",
133
- "@cosmicdrift/kumiko-framework": "0.259.0",
134
- "@cosmicdrift/kumiko-headless": "0.259.0",
135
- "@cosmicdrift/kumiko-renderer": "0.259.0",
136
- "@cosmicdrift/kumiko-renderer-web": "0.259.0",
137
- "@cosmicdrift/kumiko-types": "0.259.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.259.0",
167
- "@cosmicdrift/kumiko-locale-es": "0.259.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
+ });
@@ -126,6 +126,9 @@ export function NotesSection({
126
126
  name="draft"
127
127
  value={draft}
128
128
  onChange={setDraft}
129
+ onSubmitShortcut={() => {
130
+ if (!busy && draft.trim() !== "") addNote();
131
+ }}
129
132
  />
130
133
  <div>
131
134
  <Button