@nubitio/crud 0.5.26 → 0.5.27

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/index.cjs CHANGED
@@ -2183,10 +2183,20 @@ function serializeDetailRows(rows, detailFields, detailIdField, isEditMode, adap
2183
2183
  function canEditFieldInline(field) {
2184
2184
  return !field.isIdentity && !field.readonly && field.type !== "file" && field.visibleOnForm !== false;
2185
2185
  }
2186
- function useInlineEdit({ mode, url, idField, adapter = HydraAdapter, httpClient, fields, onSaveSuccess, onSaveError }) {
2186
+ function valuesEqual$1(a, b) {
2187
+ if (a === b) return true;
2188
+ if (a == null && b == null) return true;
2189
+ if (a == null || b == null) return false;
2190
+ const numA = Number(a);
2191
+ const numB = Number(b);
2192
+ if (!Number.isNaN(numA) && !Number.isNaN(numB)) return numA === numB;
2193
+ return String(a) === String(b);
2194
+ }
2195
+ function useInlineEdit({ mode, url, idField, adapter = HydraAdapter, httpClient, fields, onSaveSuccess, onSaveError, onBatchSave }) {
2187
2196
  const [draftRows, setDraftRows] = (0, react$1.useState)(/* @__PURE__ */ new Map());
2188
2197
  const [savingRows, setSavingRows] = (0, react$1.useState)(/* @__PURE__ */ new Set());
2189
2198
  const [rowErrors, setRowErrors] = (0, react$1.useState)(/* @__PURE__ */ new Map());
2199
+ const [activeCell, setActiveCell] = (0, react$1.useState)(null);
2190
2200
  const draftRowsRef = (0, react$1.useRef)(draftRows);
2191
2201
  draftRowsRef.current = draftRows;
2192
2202
  const optsRef = (0, react$1.useRef)({
@@ -2196,7 +2206,8 @@ function useInlineEdit({ mode, url, idField, adapter = HydraAdapter, httpClient,
2196
2206
  httpClient,
2197
2207
  fields,
2198
2208
  onSaveSuccess,
2199
- onSaveError
2209
+ onSaveError,
2210
+ onBatchSave
2200
2211
  });
2201
2212
  optsRef.current = {
2202
2213
  url,
@@ -2205,9 +2216,21 @@ function useInlineEdit({ mode, url, idField, adapter = HydraAdapter, httpClient,
2205
2216
  httpClient,
2206
2217
  fields,
2207
2218
  onSaveSuccess,
2208
- onSaveError
2219
+ onSaveError,
2220
+ onBatchSave
2209
2221
  };
2210
- const isEditing = (0, react$1.useCallback)((key) => draftRowsRef.current.has(key), []);
2222
+ const isEditing = (0, react$1.useCallback)((key) => draftRows.has(key), [draftRows]);
2223
+ const isCellActive = (0, react$1.useCallback)((key, fieldName) => activeCell != null && activeCell.key === key && activeCell.fieldName === fieldName, [activeCell]);
2224
+ const isCellDirty = (0, react$1.useCallback)((key, fieldName, original) => {
2225
+ const draft = draftRows.get(key);
2226
+ if (!draft) return false;
2227
+ return !valuesEqual$1(draft[fieldName], original[fieldName]);
2228
+ }, [draftRows]);
2229
+ const hasDraftChanges = (0, react$1.useCallback)((key, original) => {
2230
+ const draft = draftRows.get(key);
2231
+ if (!draft) return false;
2232
+ return optsRef.current.fields.some((field) => canEditFieldInline(field) && !valuesEqual$1(draft[field.name], original[field.name]));
2233
+ }, [draftRows]);
2211
2234
  const startEdit = (0, react$1.useCallback)((row) => {
2212
2235
  const key = row[optsRef.current.idField];
2213
2236
  setDraftRows((prev) => {
@@ -2219,7 +2242,28 @@ function useInlineEdit({ mode, url, idField, adapter = HydraAdapter, httpClient,
2219
2242
  next.delete(key);
2220
2243
  return next;
2221
2244
  });
2245
+ setActiveCell(null);
2222
2246
  }, [mode]);
2247
+ const startCellEdit = (0, react$1.useCallback)((row, fieldName) => {
2248
+ const key = row[optsRef.current.idField];
2249
+ setDraftRows((prev) => {
2250
+ const existing = prev.get(key);
2251
+ const withoutKey = (mode === "row" ? [] : Array.from(prev.entries())).filter(([entryKey]) => entryKey !== key);
2252
+ return new Map([...withoutKey, [key, existing ? { ...existing } : { ...row }]]);
2253
+ });
2254
+ setRowErrors((prev) => {
2255
+ const next = new Map(prev);
2256
+ next.delete(key);
2257
+ return next;
2258
+ });
2259
+ setActiveCell({
2260
+ key,
2261
+ fieldName
2262
+ });
2263
+ }, [mode]);
2264
+ const stopCellEdit = (0, react$1.useCallback)(() => {
2265
+ setActiveCell(null);
2266
+ }, []);
2223
2267
  const cancelEdit = (0, react$1.useCallback)((key) => {
2224
2268
  setDraftRows((prev) => {
2225
2269
  const next = new Map(prev);
@@ -2231,10 +2275,12 @@ function useInlineEdit({ mode, url, idField, adapter = HydraAdapter, httpClient,
2231
2275
  next.delete(key);
2232
2276
  return next;
2233
2277
  });
2278
+ setActiveCell((current) => current?.key === key ? null : current);
2234
2279
  }, []);
2235
2280
  const discardAll = (0, react$1.useCallback)(() => {
2236
2281
  setDraftRows(/* @__PURE__ */ new Map());
2237
2282
  setRowErrors(/* @__PURE__ */ new Map());
2283
+ setActiveCell(null);
2238
2284
  }, []);
2239
2285
  const updateDraft = (0, react$1.useCallback)((key, fieldName, value) => {
2240
2286
  setDraftRows((prev) => {
@@ -2301,35 +2347,102 @@ function useInlineEdit({ mode, url, idField, adapter = HydraAdapter, httpClient,
2301
2347
  });
2302
2348
  }
2303
2349
  }, []);
2350
+ const validateDraft = (0, react$1.useCallback)((key, draft, fs) => {
2351
+ const errors = {};
2352
+ fs.forEach((field) => {
2353
+ if (!canEditFieldInline(field)) return;
2354
+ if (field.required && (draft[field.name] == null || draft[field.name] === "")) errors[field.name] = "required";
2355
+ });
2356
+ if (Object.keys(errors).length === 0) return true;
2357
+ setRowErrors((prev) => new Map(prev).set(key, errors));
2358
+ return false;
2359
+ }, []);
2360
+ const doSaveBatch = (0, react$1.useCallback)(async () => {
2361
+ const { fields: fs, onBatchSave: saveBatch, onSaveError: onErr } = optsRef.current;
2362
+ if (!saveBatch) return false;
2363
+ const entries = Array.from(draftRowsRef.current.entries());
2364
+ if (entries.length === 0) return false;
2365
+ if (!entries.every(([key, draft]) => validateDraft(key, draft, fs))) return false;
2366
+ const keys = entries.map(([key]) => key);
2367
+ setSavingRows((prev) => new Set([...prev, ...keys]));
2368
+ try {
2369
+ await saveBatch(entries.map(([, draft]) => ({ ...draft })));
2370
+ setDraftRows(/* @__PURE__ */ new Map());
2371
+ setRowErrors(/* @__PURE__ */ new Map());
2372
+ return true;
2373
+ } catch (err) {
2374
+ keys.forEach((key) => onErr?.(key, err));
2375
+ return false;
2376
+ } finally {
2377
+ setSavingRows((prev) => {
2378
+ const next = new Set(prev);
2379
+ keys.forEach((key) => next.delete(key));
2380
+ return next;
2381
+ });
2382
+ }
2383
+ }, [validateDraft]);
2304
2384
  return {
2305
2385
  draftRows,
2306
2386
  savingRows,
2307
2387
  rowErrors,
2388
+ activeCell,
2308
2389
  isEditing,
2390
+ isCellActive,
2391
+ isCellDirty,
2392
+ hasDraftChanges,
2309
2393
  startEdit,
2394
+ startCellEdit,
2395
+ stopCellEdit,
2310
2396
  cancelEdit,
2311
2397
  discardAll,
2312
2398
  updateDraft,
2313
2399
  saveRow: (0, react$1.useCallback)(async (key) => {
2314
- if (await doSaveRow(key)) optsRef.current.onSaveSuccess?.();
2315
- }, [doSaveRow]),
2400
+ if (optsRef.current.onBatchSave) {
2401
+ const ok = await doSaveBatch();
2402
+ if (ok) optsRef.current.onSaveSuccess?.();
2403
+ return ok;
2404
+ }
2405
+ const ok = await doSaveRow(key);
2406
+ if (ok) optsRef.current.onSaveSuccess?.();
2407
+ return ok;
2408
+ }, [doSaveBatch, doSaveRow]),
2316
2409
  saveAll: (0, react$1.useCallback)(async () => {
2410
+ if (optsRef.current.onBatchSave) {
2411
+ const ok = await doSaveBatch();
2412
+ if (ok) optsRef.current.onSaveSuccess?.();
2413
+ return ok;
2414
+ }
2317
2415
  const keys = Array.from(draftRowsRef.current.keys());
2318
- if ((await Promise.allSettled(keys.map((key) => doSaveRow(key)))).some((r) => r.status === "fulfilled" && r.value)) optsRef.current.onSaveSuccess?.();
2319
- }, [doSaveRow])
2416
+ const anySuccess = (await Promise.allSettled(keys.map((key) => doSaveRow(key)))).some((r) => r.status === "fulfilled" && r.value);
2417
+ if (anySuccess) optsRef.current.onSaveSuccess?.();
2418
+ return anySuccess;
2419
+ }, [doSaveBatch, doSaveRow])
2320
2420
  };
2321
2421
  }
2322
- //#endregion
2323
- //#region packages/crud/datagrid/InlineEditCell.tsx
2422
+ function focusInlineControl(container) {
2423
+ if (!container) return;
2424
+ const focusable = container.querySelector("input:not([type=\"hidden\"]):not([disabled]):not([readonly]), select:not([disabled]), textarea:not([disabled]), .nb-date-picker__input:not([readonly]), .nb-dropdown__trigger:not([disabled])");
2425
+ focusable?.focus();
2426
+ if (focusable instanceof HTMLInputElement && focusable.type !== "checkbox") focusable.select();
2427
+ }
2324
2428
  /**
2325
- * Renders a single cell as an editable control during inline row editing.
2326
- * Uses the same FieldTypeModule.ControlRender path as the full form, but
2327
- * with a compact common-props class so controls fit inside a table cell.
2429
+ * Renders a single cell as an editable control during inline editing.
2430
+ * Uses the same FieldTypeModule.ControlRender path as the full form, with
2431
+ * compact styling so controls fit naturally inside a table cell.
2328
2432
  */
2329
- function InlineEditCell({ field, rowKey, draft, onChange, errors, disabled = false, allRemoteOptions, httpClient, t }) {
2433
+ function InlineEditCell({ field, rowKey, draft, onChange, errors, disabled = false, allRemoteOptions, httpClient, t, autoFocus = true }) {
2434
+ const containerRef = (0, react.useRef)(null);
2330
2435
  const typeModule = getFieldTypeModule(field.type);
2331
2436
  const fieldError = errors?.[field.name];
2332
2437
  const errorClass = fieldError ? " is-error" : "";
2438
+ (0, react.useEffect)(() => {
2439
+ if (!autoFocus) return;
2440
+ focusInlineControl(containerRef.current);
2441
+ }, [
2442
+ autoFocus,
2443
+ field.name,
2444
+ rowKey
2445
+ ]);
2333
2446
  const commonProps = {
2334
2447
  className: `nb-inline-control${errorClass}`,
2335
2448
  disabled,
@@ -2350,7 +2463,16 @@ function InlineEditCell({ field, rowKey, draft, onChange, errors, disabled = fal
2350
2463
  upsertUploadedFile: () => {}
2351
2464
  };
2352
2465
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2466
+ ref: containerRef,
2353
2467
  className: `nb-inline-cell${errorClass ? " nb-inline-cell--error" : ""}`,
2468
+ onKeyDown: (event) => {
2469
+ if (event.key === "Escape") event.stopPropagation();
2470
+ },
2471
+ onMouseDown: (event) => {
2472
+ if (event.target.closest(".nb-date-picker__panel, .nb-form__lookup-menu, .nb-dropdown__menu, .nb-datagrid__actions-popover")) return;
2473
+ event.stopPropagation();
2474
+ },
2475
+ onClick: (event) => event.stopPropagation(),
2354
2476
  children: typeModule.ControlRender({
2355
2477
  field,
2356
2478
  value: draft[field.name],
@@ -2479,6 +2601,25 @@ function normalizeIcon$1(icon) {
2479
2601
  function isDateLikeField(field) {
2480
2602
  return field.type === "date" || field.type === "datetime";
2481
2603
  }
2604
+ function isCellEditMode(editMode) {
2605
+ return editMode === "cell" || editMode === "batch";
2606
+ }
2607
+ function resolveInlineEditToolbar(editMode, inlineEditToolbar) {
2608
+ if (inlineEditToolbar === false) return null;
2609
+ const defaultShow = isCellEditMode(editMode);
2610
+ if (inlineEditToolbar == null) return defaultShow ? {
2611
+ save: true,
2612
+ revert: true
2613
+ } : null;
2614
+ if (typeof inlineEditToolbar === "boolean") return inlineEditToolbar ? {
2615
+ save: true,
2616
+ revert: true
2617
+ } : null;
2618
+ return {
2619
+ save: inlineEditToolbar.save !== false,
2620
+ revert: inlineEditToolbar.revert !== false
2621
+ };
2622
+ }
2482
2623
  function getToolbarKey(action, index) {
2483
2624
  return action.key ?? action.text ?? String(index);
2484
2625
  }
@@ -2832,6 +2973,15 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
2832
2973
  fieldsRef.current = options.fields;
2833
2974
  const loadSeqRef = (0, react.useRef)(0);
2834
2975
  const loadRows = (0, react.useCallback)(async () => {
2976
+ if (options.data) {
2977
+ rowsRef.current = options.data;
2978
+ setRows(options.data);
2979
+ setTotalCount(options.data.length);
2980
+ setGridSummary(options.gridSummary ?? null);
2981
+ setIsGridLoading(false);
2982
+ onContentReadyRef.current?.();
2983
+ return options.data;
2984
+ }
2835
2985
  if (options.manualLoad) return rowsRef.current;
2836
2986
  setIsGridLoading(true);
2837
2987
  const seq = ++loadSeqRef.current;
@@ -2855,6 +3005,8 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
2855
3005
  }, [
2856
3006
  filterOperators,
2857
3007
  filters,
3008
+ options.data,
3009
+ options.gridSummary,
2858
3010
  options.manualLoad,
2859
3011
  options.paging,
2860
3012
  page,
@@ -2898,30 +3050,58 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
2898
3050
  resourceStoreFactory,
2899
3051
  visibleFields
2900
3052
  ]);
2901
- const canInlineEditMode = options.editMode === "row" || options.editMode === "batch";
3053
+ const canInlineEditMode = options.editMode === "row" || options.editMode === "cell" || options.editMode === "batch";
3054
+ const cellEditMode = isCellEditMode(options.editMode);
3055
+ const rowInlineMode = options.editMode === "row";
3056
+ const inlineEditToolbar = resolveInlineEditToolbar(options.editMode, options.inlineEditToolbar);
3057
+ const showRowInlineActions = options.inlineRowActions ?? rowInlineMode;
2902
3058
  const inlineEdit = useInlineEdit({
2903
- mode: options.editMode === "batch" ? "batch" : "row",
3059
+ mode: cellEditMode ? "batch" : "row",
2904
3060
  url: options.url,
2905
3061
  idField,
2906
3062
  adapter: options.adapter,
2907
3063
  httpClient,
2908
3064
  fields: options.fields,
2909
3065
  onSaveSuccess: () => void loadRows(),
2910
- onSaveError: () => {}
3066
+ onSaveError: () => {},
3067
+ onBatchSave: options.onBatchSave
2911
3068
  });
3069
+ const dirtyRowCount = (0, react.useMemo)(() => rows.filter((row) => {
3070
+ const key = row[idField] ?? row;
3071
+ return inlineEdit.draftRows.has(key) && inlineEdit.hasDraftChanges(key, row);
3072
+ }).length, [
3073
+ rows,
3074
+ idField,
3075
+ inlineEdit.draftRows,
3076
+ inlineEdit.hasDraftChanges
3077
+ ]);
3078
+ const hasPendingInlineEdits = dirtyRowCount > 0;
3079
+ (0, react.useEffect)(() => {
3080
+ if (!inlineEdit.activeCell) return;
3081
+ const handler = (event) => {
3082
+ const target = event.target;
3083
+ if (target.closest(".nb-datagrid__edit-cell")) return;
3084
+ if (target.closest(".nb-date-picker__panel, .nb-form__lookup-menu, .nb-dropdown__menu, .nb-datagrid__actions-popover")) return;
3085
+ inlineEdit.stopCellEdit();
3086
+ };
3087
+ document.addEventListener("mousedown", handler);
3088
+ return () => document.removeEventListener("mousedown", handler);
3089
+ }, [inlineEdit.activeCell, inlineEdit.stopCellEdit]);
2912
3090
  const handleStateRef = (0, react.useRef)({
2913
3091
  selectedKeys,
2914
3092
  filters,
2915
3093
  filterOperators,
2916
3094
  loadRows,
2917
- idField
3095
+ idField,
3096
+ inlineEdit
2918
3097
  });
2919
3098
  handleStateRef.current = {
2920
3099
  selectedKeys,
2921
3100
  filters,
2922
3101
  filterOperators,
2923
3102
  loadRows,
2924
- idField
3103
+ idField,
3104
+ inlineEdit
2925
3105
  };
2926
3106
  (0, react.useImperativeHandle)(ref, () => ({
2927
3107
  showLoading: (message) => {
@@ -2961,7 +3141,9 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
2961
3141
  setFilterInputs(nextFilters);
2962
3142
  setFilterOperators(nextOperators);
2963
3143
  setPage(0);
2964
- }
3144
+ },
3145
+ hasEditData: () => handleStateRef.current.inlineEdit.draftRows.size > 0,
3146
+ saveChanges: () => handleStateRef.current.inlineEdit.saveAll()
2965
3147
  }), [options.fields, t]);
2966
3148
  const selectedRows = rows.filter((row) => selectedKeys.includes(row[idField]));
2967
3149
  const selectRow = (row) => {
@@ -2975,7 +3157,7 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
2975
3157
  const rowEditable = (row) => options.canEditRow?.(row) !== false;
2976
3158
  const rowDeletable = (row) => options.canDeleteRow?.(row) !== false;
2977
3159
  const buildRowActions = (row) => [
2978
- ...options.allowEdit && rowEditable(row) && canInlineEditMode && !inlineEdit.isEditing(row[idField]) ? [{
3160
+ ...options.allowEdit && rowEditable(row) && rowInlineMode && canInlineEditMode && !inlineEdit.isEditing(row[idField]) ? [{
2979
3161
  text: t("grid.inlineEditRow"),
2980
3162
  icon: "ph-pencil-simple",
2981
3163
  disabled: options.editDisabled,
@@ -3005,7 +3187,7 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
3005
3187
  }] : []
3006
3188
  ];
3007
3189
  const openRow = (row) => {
3008
- if (options.allowEdit && rowEditable(row) && canInlineEditMode) {
3190
+ if (options.allowEdit && rowEditable(row) && rowInlineMode && canInlineEditMode) {
3009
3191
  inlineEdit.startEdit(row);
3010
3192
  return;
3011
3193
  }
@@ -3140,7 +3322,7 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
3140
3322
  };
3141
3323
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
3142
3324
  const hasCheckbox = options.selectionMode === "multiple";
3143
- const hasBuiltInRowActions = Boolean(options.allowEdit && (canInlineEditMode || options.onEdit || options.events?.EDIT) || options.allowView && options.onView || options.allowDelete && (options.onDelete || options.events?.DELETE));
3325
+ const hasBuiltInRowActions = Boolean(options.allowEdit && (rowInlineMode && showRowInlineActions || options.onEdit || options.events?.EDIT) || options.allowView && options.onView || options.allowDelete && (options.onDelete || options.events?.DELETE));
3144
3326
  const hasRowActions = Boolean(options.mode !== "minimal" && (hasBuiltInRowActions || options.rowActions));
3145
3327
  const colSpan = visibleFields.length + (hasCheckbox ? 1 : 0) + (options.detailFields ? 1 : 0) + (hasRowActions ? 1 : 0);
3146
3328
  const hasDetail = Boolean(options.detailFields);
@@ -3155,15 +3337,26 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
3155
3337
  actionsColWidth
3156
3338
  });
3157
3339
  const resolvedColWidths = (0, react.useMemo)(() => {
3158
- if (visibleFields.length === 0) return colWidths;
3159
- const dataTotal = visibleFields.reduce((sum, f) => sum + getColumnWidth(f, colWidths), 0);
3340
+ if (visibleFields.length === 0 || containerWidth <= 0) return colWidths;
3341
+ const bases = visibleFields.map((f) => getColumnWidth(f, colWidths));
3342
+ const dataTotal = bases.reduce((sum, width) => sum + width, 0);
3160
3343
  const fixedTotal = (hasCheckbox ? CHECKBOX_COL_WIDTH : 0) + (hasDetail ? DETAIL_COL_WIDTH : 0) + (hasRowActions ? actionsColWidth : 0);
3161
- const extra = Math.max(0, containerWidth - dataTotal - fixedTotal);
3344
+ const available = Math.max(0, containerWidth - fixedTotal);
3345
+ if (dataTotal > available && available > 0) {
3346
+ const scale = available / dataTotal;
3347
+ const result = {};
3348
+ visibleFields.forEach((field, index) => {
3349
+ const floor = field.minWidth ?? MIN_COL_WIDTH;
3350
+ result[field.name] = Math.max(floor, Math.floor(bases[index] * scale));
3351
+ });
3352
+ return result;
3353
+ }
3354
+ const extra = Math.max(0, available - dataTotal);
3162
3355
  if (extra === 0 || dataTotal === 0) return colWidths;
3163
3356
  let distributed = 0;
3164
3357
  const result = {};
3165
3358
  visibleFields.forEach((f, i) => {
3166
- const base = getColumnWidth(f, colWidths);
3359
+ const base = bases[i];
3167
3360
  const share = i < visibleFields.length - 1 ? Math.round(extra * (base / dataTotal)) : extra - distributed;
3168
3361
  result[f.name] = base + share;
3169
3362
  distributed += share;
@@ -3175,7 +3368,8 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
3175
3368
  hasCheckbox,
3176
3369
  hasDetail,
3177
3370
  hasRowActions,
3178
- containerWidth
3371
+ containerWidth,
3372
+ actionsColWidth
3179
3373
  ]);
3180
3374
  const tableLayoutStyle = { "--nb-datagrid-layout-width": `${layoutWidth}px` };
3181
3375
  const filterableFields = visibleFields.filter((field) => field.filterable);
@@ -3254,6 +3448,18 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
3254
3448
  children: activeFilterCount
3255
3449
  })]
3256
3450
  }),
3451
+ inlineEditToolbar?.revert && hasPendingInlineEdits && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.IconButton, {
3452
+ className: "nb-datagrid__toolbar-icon-action nb-datagrid__toolbar-icon-action--revert",
3453
+ icon: "ph ph-arrow-counter-clockwise",
3454
+ label: t("grid.inlineRevertChanges"),
3455
+ onClick: () => inlineEdit.discardAll()
3456
+ }),
3457
+ inlineEditToolbar?.save && hasPendingInlineEdits && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.IconButton, {
3458
+ className: "nb-datagrid__toolbar-icon-action nb-datagrid__toolbar-icon-action--save",
3459
+ icon: "ph ph-floppy-disk",
3460
+ label: t("grid.inlineSaveChanges"),
3461
+ onClick: () => void inlineEdit.saveAll()
3462
+ }),
3257
3463
  toolbar.showRefresh !== false && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_nubitio_ui.IconButton, {
3258
3464
  icon: "ph ph-arrow-clockwise",
3259
3465
  label: t("grid.buttonRefresh"),
@@ -3261,26 +3467,13 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
3261
3467
  })
3262
3468
  ]
3263
3469
  }),
3264
- options.editMode === "batch" && inlineEdit.draftRows.size > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3265
- className: "nb-datagrid__batch-bar",
3470
+ cellEditMode && hasPendingInlineEdits && !inlineEditToolbar && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3471
+ className: "nb-datagrid__batch-bar nb-datagrid__batch-bar--compact",
3266
3472
  role: "status",
3267
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3473
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3268
3474
  className: "nb-datagrid__batch-bar-label",
3269
- children: t("grid.inlineUnsavedRows", { count: inlineEdit.draftRows.size })
3270
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3271
- className: "nb-datagrid__batch-bar-actions",
3272
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3273
- type: "button",
3274
- className: "nb-datagrid__batch-bar-btn nb-datagrid__batch-bar-btn--discard",
3275
- onClick: () => inlineEdit.discardAll(),
3276
- children: t("grid.inlineDiscardAll")
3277
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3278
- type: "button",
3279
- className: "nb-datagrid__batch-bar-btn nb-datagrid__batch-bar-btn--save",
3280
- onClick: () => void inlineEdit.saveAll(),
3281
- children: t("grid.inlineSaveAll", { count: inlineEdit.draftRows.size })
3282
- })]
3283
- })]
3475
+ children: t("grid.inlineUnsavedRows", { count: dirtyRowCount })
3476
+ })
3284
3477
  }),
3285
3478
  options.aboveGrid ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3286
3479
  className: "nb-datagrid__above-grid",
@@ -3576,9 +3769,11 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
3576
3769
  const key = row[idField] ?? rowIndex;
3577
3770
  const selected = selectedKeys.includes(key);
3578
3771
  const editing = inlineEdit.draftRows.has(key);
3772
+ const rowHasChanges = editing && inlineEdit.hasDraftChanges(key, row);
3579
3773
  const saving = inlineEdit.savingRows.has(key);
3580
3774
  const rowDraft = inlineEdit.draftRows.get(key) ?? row;
3581
3775
  const rowFieldErrors = inlineEdit.rowErrors.get(key);
3776
+ const rowInRowEdit = rowInlineMode && editing;
3582
3777
  const detailFields = typeof options.detailFields === "function" ? options.detailFields(row) : options.detailFields;
3583
3778
  const detailUrl = options.detailUrl?.replace("{id}", String(key));
3584
3779
  const expanded = expandedKeys.has(key);
@@ -3588,17 +3783,19 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
3588
3783
  "nb-datagrid__row",
3589
3784
  expanded ? "nb-datagrid__row--expanded" : "",
3590
3785
  selected ? "nb-datagrid__row--selected" : "",
3591
- editing ? "nb-datagrid__row--editing" : "",
3786
+ rowInRowEdit ? "nb-datagrid__row--editing" : "",
3787
+ rowHasChanges ? "nb-datagrid__row--dirty" : "",
3592
3788
  saving ? "nb-datagrid__row--saving" : ""
3593
3789
  ].filter(Boolean).join(" "),
3594
3790
  tabIndex: 0,
3595
3791
  "aria-selected": selected,
3596
3792
  onClick: () => {
3597
- if (!editing) selectRow(row);
3793
+ if (rowInRowEdit) return;
3794
+ selectRow(row);
3598
3795
  },
3599
3796
  onDoubleClick: () => {
3600
- if (editing) return;
3601
- if (options.allowEdit && rowEditable(row) && canInlineEditMode) {
3797
+ if (rowInRowEdit || inlineEdit.activeCell) return;
3798
+ if (options.allowEdit && rowEditable(row) && rowInlineMode && canInlineEditMode) {
3602
3799
  inlineEdit.startEdit(row);
3603
3800
  return;
3604
3801
  }
@@ -3610,9 +3807,10 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
3610
3807
  if (options.allowView && options.onView) options.onView(row);
3611
3808
  },
3612
3809
  onKeyDown: (event) => {
3613
- if (editing && event.key === "Escape") inlineEdit.cancelEdit(key);
3614
- else if (editing && event.key === "Enter") inlineEdit.saveRow(key);
3615
- else if (!editing && event.key === "Enter" && options.allowEdit && canInlineEditMode && rowEditable(row)) inlineEdit.startEdit(row);
3810
+ if (inlineEdit.activeCell && event.key === "Escape") inlineEdit.stopCellEdit();
3811
+ else if (rowInRowEdit && event.key === "Escape") inlineEdit.cancelEdit(key);
3812
+ else if (rowInRowEdit && event.key === "Enter" && showRowInlineActions) inlineEdit.saveRow(key);
3813
+ else if (!editing && event.key === "Enter" && options.allowEdit && rowInlineMode && canInlineEditMode && rowEditable(row)) inlineEdit.startEdit(row);
3616
3814
  else if (!editing && event.key === "Enter" && options.allowEdit && (options.onEdit || options.events?.EDIT)) if (options.onEdit) options.onEdit(row);
3617
3815
  else emit(options.events.EDIT, { row });
3618
3816
  else if (!editing && event.key === "Enter" && options.allowView && options.onView) options.onView(row);
@@ -3662,13 +3860,29 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
3662
3860
  }),
3663
3861
  visibleFields.map((field, columnIndex) => {
3664
3862
  const width = getColumnWidth(field, resolvedColWidths);
3665
- if (editing && canEditFieldInline(field)) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3863
+ const editable = options.allowEdit && !options.editDisabled && rowEditable(row) && canEditFieldInline(field);
3864
+ const cellActive = inlineEdit.isCellActive(key, field.name);
3865
+ const cellDirty = editing && inlineEdit.isCellDirty(key, field.name, row);
3866
+ const showCellEditor = editable && (rowInRowEdit && editing || cellEditMode && cellActive);
3867
+ const displayRow = editing ? rowDraft : row;
3868
+ const cellClassName = [
3869
+ showCellEditor ? "nb-datagrid__edit-cell" : "nb-datagrid__data-cell",
3870
+ editable && canInlineEditMode ? "nb-datagrid__cell--editable" : "",
3871
+ cellDirty ? "nb-datagrid__cell--dirty" : "",
3872
+ cellActive ? "nb-datagrid__cell--active" : ""
3873
+ ].filter(Boolean).join(" ");
3874
+ const beginInlineEdit = () => {
3875
+ if (!editable) return;
3876
+ selectRow(row);
3877
+ if (cellEditMode) inlineEdit.startCellEdit(row, field.name);
3878
+ else if (rowInlineMode && !editing) inlineEdit.startEdit(row);
3879
+ };
3880
+ if (showCellEditor) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3666
3881
  style: {
3667
3882
  width,
3668
3883
  textAlign: field.align
3669
3884
  },
3670
- className: "nb-datagrid__edit-cell",
3671
- onClick: (e) => e.stopPropagation(),
3885
+ className: cellClassName,
3672
3886
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InlineEditCell, {
3673
3887
  field,
3674
3888
  rowKey: key,
@@ -3678,7 +3892,8 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
3678
3892
  disabled: saving,
3679
3893
  allRemoteOptions: filterRemoteOptions,
3680
3894
  httpClient,
3681
- t
3895
+ t,
3896
+ autoFocus: true
3682
3897
  })
3683
3898
  }, field.name);
3684
3899
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
@@ -3686,14 +3901,20 @@ const NativeDataGridView = (0, react.forwardRef)((options, ref) => {
3686
3901
  width,
3687
3902
  textAlign: field.align
3688
3903
  },
3689
- title: getCellText(field, row, filterRemoteOptions[field.name], t("common.yes"), t("common.no")),
3690
- children: renderCell(field, row, rowIndex, columnIndex, filterRemoteOptions[field.name], t("common.yes"), t("common.no"))
3904
+ className: cellClassName,
3905
+ title: getCellText(field, displayRow, filterRemoteOptions[field.name], t("common.yes"), t("common.no")),
3906
+ onClick: (event) => {
3907
+ if (!editable || !canInlineEditMode) return;
3908
+ event.stopPropagation();
3909
+ beginInlineEdit();
3910
+ },
3911
+ children: renderCell(field, displayRow, rowIndex, columnIndex, filterRemoteOptions[field.name], t("common.yes"), t("common.no"))
3691
3912
  }, field.name);
3692
3913
  }),
3693
3914
  hasRowActions && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
3694
3915
  className: "nb-datagrid__actions-cell",
3695
3916
  onClick: (e) => e.stopPropagation(),
3696
- children: editing ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3917
+ children: rowInRowEdit && showRowInlineActions ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3697
3918
  className: "nb-datagrid__inline-actions",
3698
3919
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3699
3920
  type: "button",
@@ -6978,6 +7199,8 @@ const CrudPageInner = ({ resource, onFormDataChange, initialRecordId = null, ini
6978
7199
  selectionMode: hasMultipleSelection ? "multiple" : "single",
6979
7200
  onSelectionChanged: handleSelectionChanged,
6980
7201
  editMode: resolvedResource.editMode,
7202
+ inlineEditToolbar: resolvedResource.inlineEditToolbar,
7203
+ inlineRowActions: resolvedResource.inlineRowActions,
6981
7204
  visibleColumns: presetState.visibleColumns,
6982
7205
  beforeToolbar: renderPresetSelector,
6983
7206
  aboveGrid: aboveGridContent,
package/dist/index.d.cts CHANGED
@@ -23,6 +23,8 @@ interface GridHandle {
23
23
  getSelectedRows: () => DataRecord$1[];
24
24
  getFilter: () => unknown[];
25
25
  filter: (filterRule: FilterRule | null) => void;
26
+ hasEditData: () => boolean;
27
+ saveChanges: () => Promise<boolean>;
26
28
  }
27
29
  //#endregion
28
30
  //#region packages/crud/field/FieldType.d.ts
@@ -974,6 +976,13 @@ interface ResourceConfig<T extends DataRecord$1 = DataRecord$1> {
974
976
  * - `'row'` | `'cell'` | `'batch'` enable inline editing when supported.
975
977
  */
976
978
  editMode?: 'popup' | 'row' | 'cell' | 'batch';
979
+ /** Toolbar save/revert for inline edit. `false` hides them (use GridHandle instead). */
980
+ inlineEditToolbar?: boolean | {
981
+ save?: boolean;
982
+ revert?: boolean;
983
+ };
984
+ /** Per-row save/cancel in the actions column. Defaults by edit mode. */
985
+ inlineRowActions?: boolean;
977
986
  /** Optional layout descriptor for the dialog form: tabs or collapsible sections. */
978
987
  formLayout?: FormLayout;
979
988
  /** Named column visibility presets. When defined, a preset selector is shown in the toolbar. */
@@ -1543,6 +1552,8 @@ interface DataGridViewOptions {
1543
1552
  id: string;
1544
1553
  title: string;
1545
1554
  url: string;
1555
+ /** Controlled rows. When provided, the grid renders this data instead of loading from url. */
1556
+ data?: DataRecord$1[];
1546
1557
  detailUrl?: string;
1547
1558
  fields: Field[];
1548
1559
  detailFields?: Field[] | ((parentRow: DataRecord$1) => Field[]);
@@ -1581,7 +1592,20 @@ interface DataGridViewOptions {
1581
1592
  stateStoringEnabled?: boolean;
1582
1593
  toolbarVisible?: boolean;
1583
1594
  selectionMode?: 'single' | 'multiple';
1584
- inlineActions?: boolean;
1595
+ /**
1596
+ * Save / revert buttons shown in the toolbar when inline edits are pending.
1597
+ * Set to `false` to hide and handle persistence via `GridHandle.saveChanges()`.
1598
+ * Defaults to `true` for `cell` and `batch` edit modes.
1599
+ */
1600
+ inlineEditToolbar?: boolean | {
1601
+ save?: boolean;
1602
+ revert?: boolean;
1603
+ };
1604
+ /**
1605
+ * Per-row save / cancel icons in the actions column while a row is being edited.
1606
+ * Defaults to `true` for `row` mode and `false` for `cell` / `batch`.
1607
+ */
1608
+ inlineRowActions?: boolean;
1585
1609
  onBack?: () => void;
1586
1610
  onAdd?: () => void;
1587
1611
  onEdit?: (row: DataRecord$1) => void;
@@ -1591,6 +1615,8 @@ interface DataGridViewOptions {
1591
1615
  onRowPrepared?: (event: unknown) => void;
1592
1616
  onContentReady?: () => void;
1593
1617
  onFilterChange?: (filters: Record<string, string>) => void;
1618
+ /** Custom batch persistence. Defaults to PATCHing each row to url/id when omitted. */
1619
+ onBatchSave?: (rows: DataRecord$1[]) => void | Promise<void>;
1594
1620
  filterRow?: boolean;
1595
1621
  headerFilter?: boolean;
1596
1622
  manualLoad?: boolean;