@cosmicdrift/kumiko-headless 0.191.0 → 0.193.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-headless",
3
- "version": "0.191.0",
3
+ "version": "0.193.0",
4
4
  "description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@cosmicdrift/kumiko-framework": "0.191.0",
39
+ "@cosmicdrift/kumiko-framework": "0.193.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -214,6 +214,23 @@ describe("createFormController — submit()", () => {
214
214
  expect(disp.writeSpy).toHaveBeenCalledWith("app:write:task:create", { note: "" });
215
215
  });
216
216
 
217
+ test("payloadMode: 'values' with stripEmptySeeds: false — keeps an untouched empty-string field", async () => {
218
+ // Same setup as the strip test above, but the caller opts out — the
219
+ // untouched "" seed must survive unchanged in the payload.
220
+ const disp = makeDispatcher();
221
+ const form = createFormController({
222
+ initial: { title: "hello", dueDate: "" },
223
+ submit: { dispatcher: disp, type: "app:write:task:create", stripEmptySeeds: false },
224
+ });
225
+
226
+ await form.submit();
227
+
228
+ expect(disp.writeSpy).toHaveBeenCalledWith("app:write:task:create", {
229
+ title: "hello",
230
+ dueDate: "",
231
+ });
232
+ });
233
+
217
234
  test("stale-submit race: edits during the in-flight write stay dirty after success", async () => {
218
235
  // User submits "hello", the network takes 50ms. During those 50ms the
219
236
  // user types "world" into the same field. The server sees "hello"
@@ -325,6 +325,8 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
325
325
  };
326
326
  }
327
327
  payload = submittedSnapshot.changes;
328
+ } else if (submitCfg.stripEmptySeeds === false) {
329
+ payload = submittedValues;
328
330
  } else {
329
331
  payload = stripUntouchedEmptyStrings(submittedValues, submittedSnapshot.initial);
330
332
  }
package/src/form/types.ts CHANGED
@@ -231,8 +231,14 @@ export type FormControllerOptions<TValues extends FormValues, TCtx = unknown> =
231
231
  // objects — but that assumes an update flow. For creates, `changes ===
232
232
  // values` in practice because initial is empty.
233
233
  //
234
- // - "values" — send the full current `values` object. Right for create
234
+ // - "values" — send the current `values` object. Right for create
235
235
  // handlers whose schema expects a full entity payload.
236
+ // Untouched fields that are still "" (the seed
237
+ // buildInitialValues gives controlled optional inputs)
238
+ // are stripped from the payload before it's sent, because
239
+ // `.optional()` server schemas accept `undefined` but not
240
+ // `""`. Set `stripEmptySeeds: false` on SubmitConfig to
241
+ // opt out and send those fields as `""` unchanged.
236
242
  // - "changes" — send only the `changes` delta. Right for update
237
243
  // handlers; noop when the form is un-dirty (submit
238
244
  // short-circuits into a no-network success).
@@ -246,6 +252,12 @@ export type SubmitConfig<TValues extends FormValues = FormValues> = {
246
252
  // Qualified write-handler name (e.g. "orders:write:order:create").
247
253
  readonly type: string;
248
254
  readonly payloadMode?: SubmitPayloadMode;
255
+ /** Only applies to payloadMode "values". Controls whether untouched
256
+ * empty-string fields (still equal to their initial "" seed) are
257
+ * stripped from the payload before dispatch — see SubmitPayloadMode's
258
+ * "values" doc for why the stripping exists. Default `true` (current
259
+ * behavior). Set `false` to send those fields as `""` unchanged. */
260
+ readonly stripEmptySeeds?: boolean;
249
261
  // Optional payload transformer — overrides payloadMode. Used for
250
262
  // nested-writes: the submit path calls buildPayload(snapshot) once at
251
263
  // submit-time and sends the result. The snapshot is the one captured
@@ -549,4 +549,48 @@ describe("computeEditViewModel — embedded-list cells (#1835)", () => {
549
549
  const field = asFields(vm.sections[0]).fields[0];
550
550
  expect(field?.embeddedListCurrency).toBeUndefined();
551
551
  });
552
+
553
+ test("imageVariant is the FIRST variant declared on the field def", () => {
554
+ const entity = {
555
+ fields: {
556
+ avatar: {
557
+ type: "image",
558
+ variants: {
559
+ profile: { fit: "cover", size: { width: 512, height: 512 }, format: "webp" },
560
+ big: { fit: "cover", size: { width: 1024, height: 1024 }, format: "webp" },
561
+ },
562
+ },
563
+ },
564
+ } as unknown as EntityDefinition;
565
+
566
+ const vm = computeEditViewModel({
567
+ screen: editScreen({ sections: [{ title: "x", fields: ["avatar"] }] }),
568
+ entity,
569
+ values: {},
570
+ translate,
571
+ featureName: "orders",
572
+ });
573
+
574
+ const field = asFields(vm.sections[0]).fields[0];
575
+ expect(field?.imageVariant).toBe("profile");
576
+ });
577
+
578
+ test("imageVariant is undefined for an image field without variants", () => {
579
+ const entity = {
580
+ fields: {
581
+ avatar: { type: "image" },
582
+ },
583
+ } as unknown as EntityDefinition;
584
+
585
+ const vm = computeEditViewModel({
586
+ screen: editScreen({ sections: [{ title: "x", fields: ["avatar"] }] }),
587
+ entity,
588
+ values: {},
589
+ translate,
590
+ featureName: "orders",
591
+ });
592
+
593
+ const field = asFields(vm.sections[0]).fields[0];
594
+ expect(field?.imageVariant).toBeUndefined();
595
+ });
552
596
  });
@@ -170,8 +170,14 @@ export function computeEditViewModel<
170
170
  // importing it just for one fallback string.
171
171
  const resolvedCurrency = entity.defaultCurrency ?? "EUR";
172
172
  const fileDef = isFileType
173
- ? (fieldDef as unknown as { accept?: readonly string[]; maxSize?: string })
173
+ ? (fieldDef as unknown as {
174
+ accept?: readonly string[];
175
+ maxSize?: string;
176
+ variants?: Readonly<Record<string, unknown>>;
177
+ })
174
178
  : undefined;
179
+ const imageVariant =
180
+ fieldDef.type === "image" ? Object.keys(fileDef?.variants ?? {})[0] : undefined;
175
181
  // Embedded-LIST field (`multiple: true`) — per-cell metadata for a
176
182
  // renderer to draw one row per array item (invoice-positions-style
177
183
  // table). A plain (non-list) embedded field emits none of this; the
@@ -261,6 +267,7 @@ export function computeEditViewModel<
261
267
  ...(fileDef?.accept !== undefined && { accept: fileDef.accept }),
262
268
  ...(fileDef?.maxSize !== undefined && { maxSize: fileDef.maxSize }),
263
269
  ...(isFileType && { entityType: screen.entity, fieldName: normalized.field }),
270
+ ...(imageVariant !== undefined && { imageVariant }),
264
271
  ...(normalized.icon !== undefined && { icon: normalized.icon }),
265
272
  ...(embeddedListCells !== undefined && { embeddedListCells }),
266
273
  ...(embeddedListDef?.minItems !== undefined && {
@@ -181,6 +181,13 @@ export type EditFieldViewModel = {
181
181
  * die richtige Field-Def prüfen kann. */
182
182
  readonly entityType?: string;
183
183
  readonly fieldName?: string;
184
+ /** Only for `type: "image"` — the variant name the preview requests
185
+ * (`/api/files/:id/variant/:name`) instead of the original. The FIRST
186
+ * variant declared on the field def wins; a spec may legitimately carry
187
+ * neither `size` nor `maxEdge` (format-/blur-only), so "the smallest one"
188
+ * is not a rule that always has an answer. Absent when the field declares
189
+ * no variants — the preview then loads the original. */
190
+ readonly imageVariant?: string;
184
191
  /** Prefix-icon key from `EditFieldSpec.icon`, passed through unchanged
185
192
  * (no i18n — it's a symbolic key, not a display string). The renderer
186
193
  * resolves it against the FIELD_ICONS registry; unknown keys silently