@bit.rhplus/shared-grid-form

v0.0.10

Published

Komponenta pro sledování a správu změn v AG Grid tabulkách s field-level trackingem. Funguje analogicky k `shared-form` (pro formulářová pole), ale je navržena pro řádkové operace v AG Grid.

Readme

shared-grid-form

Komponenta pro sledování a správu změn v AG Grid tabulkách s field-level trackingem. Funguje analogicky k shared-form (pro formulářová pole), ale je navržena pro řádkové operace v AG Grid.

Architektura

Komponenta používá two-state systém postavený na Jotai atomech:

Original Items (itemsAtomFamily)          Data ze serveru, nikdy se přímo nemění
        +
Pending Changes (pendingChangesAtomFamily)  Field-level změny per řádek
        ↓
Effective Items (computed)                  Merged výsledek pro zobrazení v gridu
        ↓
Tlačítko "Uložit" → getPendingChanges()   Strukturovaný diff pro API

Princip fungování

  1. Originální data (items) se načtou ze serveru a uloží do Jotai atomu
  2. Uživatel edituje buňky v gridu → změny se ukládají do pendingChanges atomu
  3. Grid zobrazuje effectiveItems = originální data + aplikované změny
  4. Při uložení se zavolá getPendingChanges() → vrátí strukturovaný diff { added, updated, deleted }
  5. Po úspěšném uložení se zavolá clearPendingChanges() → vyčistí pending stav

Struktura pending changes

pendingChanges = {
  // Editovaný existující řádek - uchovává pouze změněná pole
  "row-id-1": {
    operation: "UPDATE",
    changes: { quantity: 15, price: 200 },
    originalData: { id: "row-id-1", quantity: 10, price: 100, name: "Produkt A" }
  },

  // Nově přidaný řádek - dočasné ID
  "temp-1708012345-4567": {
    operation: "ADD",
    changes: { name: "Nový produkt", quantity: 1, price: 0 },
    originalData: null
  },

  // Řádek označený k mazání
  "row-id-2": {
    operation: "DELETE",
    changes: null,
    originalData: { id: "row-id-2", quantity: 5, price: 50, name: "Produkt B" }
  }
}

Soubory

| Soubor | Popis | |--------|-------| | atoms.js | Jotai atomy a hlavní hook useSharedGridItems | | SharedGridForm.jsx | React Context provider obalující AG Grid | | index.js | Exporty | | styles.css | CSS třídy pro vizuální indikátory změn |


API Reference

useSharedGridItems(instanceId)

Hlavní hook pro správu grid dat. Vrací kompletní API pro práci se změnami.

import { useSharedGridItems } from '@bit.rhplus/shared-grid-form';

const {
  // --- Data ---
  items,              // any[]         originální items ze serveru
  effectiveItems,     // any[]         items + pending changes (pro grid rowData)
  pendingChanges,     // object        raw pending changes objekt

  // --- Computed stav ---
  isDirty,            // boolean       existují nesmazané změny?
  dirtyRowIds,        // string[]      pole ID řádků se změnami

  // --- Settery ---
  setItems,           // (items) => void     nastavení originálních items (z Detail)
  setPendingChanges,  // (changes) => void   přímý přístup k pending atomu

  // --- Mutační metody ---
  updateCell,         // (rowId, field, value) => void    sleduje změnu jedné buňky
  addRow,             // (rowData) => string              přidá řádek, vrací tempId
  deleteRow,          // (rowId) => void                  označí řádek k mazání
  resetRow,           // (rowId) => void                  vrátí řádek do původního stavu
  resetAll,           // () => void                       vrátí všechny řádky do původního stavu

  // --- Pro ukládání ---
  getPendingChanges,  // () => { added, updated, deleted }   strukturované změny
  clearPendingChanges,// () => void                          vyčistí po úspěšném uložení
} = useSharedGridItems(instanceId);

updateCell(rowId, field, value)

Sleduje změnu jedné buňky. Automaticky:

  • Vytvoří UPDATE záznam při první změně existujícího řádku (uloží originální data pro undo)
  • Sloučí s existujícími změnami při dalších editacích
  • Ignoruje editace na smazaných řádcích
updateCell("row-123", "quantity", 15);
updateCell("row-123", "price", 200);
// → pendingChanges["row-123"].changes = { quantity: 15, price: 200 }

addRow(rowData)

Přidá nový řádek s automaticky vygenerovaným dočasným ID (temp-{timestamp}-{random}). Vrací toto ID.

const tempId = addRow({ name: "Nový produkt", quantity: 1, price: 0 });
// tempId = "temp-1708012345-4567"

deleteRow(rowId)

Označí řádek k mazání. Speciální chování:

  • Existující řádek → uloží do pendingChanges jako DELETE (uschová originalData pro undo)
  • Nově přidaný řádek (ADD) → úplně odstraní z pendingChanges (ADD + DELETE se vzájemně ruší)
deleteRow("row-123");    // existující → označen DELETE
deleteRow("temp-456");   // nový → odstraněn z pending (jako by nikdy nebyl přidán)

resetRow(rowId)

Vrátí konkrétní řádek do původního stavu - odstraní jeho záznam z pendingChanges. Funguje pro všechny typy operací:

  • UPDATE → řádek se vrátí na originální hodnoty
  • DELETE → řádek se znovu zobrazí
  • ADD → nový řádek zmizí
resetRow("row-123"); // vrátí řádek do originálu

resetAll()

Zahodí všechny pending changes. Vše se vrátí na originální data ze serveru.

resetAll(); // isDirty = false, effectiveItems = items

getPendingChanges()

Vrátí strukturovaný objekt změn pro odeslání na API:

const { added, updated, deleted } = getPendingChanges();

// added   = [{ id: "temp-123", name: "Nový", quantity: 1 }, ...]
// updated = [{ id: "row-1", quantity: 15, price: 200 }, ...]
// deleted = ["row-2", "row-3"]

clearPendingChanges()

Vyčistí všechny pending changes. Volá se po úspěšném uložení na server.


<SharedGridForm>

React Context provider, který obaluje AG Grid a zpřístupňuje API skrze kontext.

<SharedGridForm
  instanceId="detail_invoices_123"  // povinné - unikátní ID instance
  editableDefault={true}            // výchozí editovatelnost buněk (default: true)
>
  <Grid columnDefs={columnDefs} />
</SharedGridForm>

Props

| Prop | Typ | Default | Popis | |------|-----|---------|-------| | instanceId | string | povinné | Unikátní identifikátor instance gridu. Musí odpovídat instanceId v Detail komponentě. | | editableDefault | boolean | true | Výchozí editovatelnost pro sloupce bez explicitního editable nastavení. | | children | ReactNode | povinné | AG Grid komponenta a další obsah. |

Co SharedGridForm zajišťuje automaticky

  • Aktualizace gridu při změně effectiveItems (volá gridApi.setGridOption('rowData', ...))
  • Blokování editace smazaných řádků (isCellEditable vrací false pro DELETE operace)
  • Zpětná kompatibilita - updateItem wrapper detekuje starý i nový formát volání

useSharedGrid()

Hook pro přístup ke kontextu z dceřiných komponent uvnitř <SharedGridForm>. Používá se primárně interně v Grid komponentě.

import { useSharedGrid } from '@bit.rhplus/shared-grid-form';

const {
  gridApi,           // AG Grid API reference
  items,             // effectiveItems (pro grid rowData)
  pendingChanges,    // raw pending changes
  isDirty,           // boolean
  dirtyRowIds,       // string[]

  // Metody (s aliasy pro zpětnou kompatibilitu)
  addItem,           // alias pro addRow
  updateItem,        // wrapper: podporuje (id, field, value) i (id, {changes})
  deleteItem,        // alias pro deleteRow
  resetRow,
  resetAll,
  getPendingChanges,
  clearPendingChanges,

  // Grid integrace
  onGridReady,       // callback pro AG Grid
  isCellEditable,    // funkce pro kontrolu editovatelnosti
} = useSharedGrid();

OperationType

Enum s typy operací:

import { OperationType } from '@bit.rhplus/shared-grid-form';

OperationType.ADD     // "ADD"
OperationType.UPDATE  // "UPDATE"
OperationType.DELETE  // "DELETE"

createTempId()

Utility pro generování dočasných ID pro nové řádky:

import { createTempId } from '@bit.rhplus/shared-grid-form';

const id = createTempId(); // "temp-1708012345-4567"

Vizuální indikátory

Komponenta poskytuje CSS třídy pro barevné zvýraznění řádků s čekajícími změnami. Tyto třídy se aplikují automaticky přes rowClassRules v Grid komponentě.

| CSS třída | Barva | Význam | |-----------|-------|--------| | .row-pending-add | Světle zelená | Nově přidaný řádek | | .row-pending-update | Světle žlutá | Editovaný řádek | | .row-pending-delete | Světle červená + přeškrtnutí | Řádek označený k mazání |

Import stylů:

import '@bit.rhplus/shared-grid-form/styles.css';

Použití

Základní příklad - sekce Items v Detail komponentě

import React, { useCallback } from 'react';
import Button from 'antd/es/button';
import { PlusOutlined } from '@ant-design/icons';
import { SharedGridForm, useSharedGridItems } from '@bit.rhplus/shared-grid-form';
import Grid, { ColumnBuilder } from '@bit.rhplus/ui.grid';

const Items = ({ instanceId }) => {
  const { addRow } = useSharedGridItems(instanceId);

  const columnDefs = React.useMemo(() => {
    return new ColumnBuilder()
      .addColumn({ headerName: 'Produkt', field: 'product', width: 200 })
      .addColumn({ headerName: 'Množství', field: 'quantity', width: 120, editable: true })
      .addCurrencyColumn({ headerName: 'Cena', field: 'price', width: 150, editable: true })
      .build();
  }, []);

  const handleAddRow = useCallback(() => {
    addRow({ product: '', quantity: 1, price: 0 });
  }, [addRow]);

  return (
    <SharedGridForm instanceId={instanceId}>
      <Button type="primary" icon={<PlusOutlined />} onClick={handleAddRow}>
        Přidat položku
      </Button>
      <div className="ag-theme-alpine" style={{ height: 400 }}>
        <Grid columnDefs={columnDefs} />
      </div>
    </SharedGridForm>
  );
};

Práce s dirty stavem a undo

import { useSharedGridItems } from '@bit.rhplus/shared-grid-form';

const GridToolbar = ({ instanceId }) => {
  const { isDirty, dirtyRowIds, resetAll, getPendingChanges } = useSharedGridItems(instanceId);

  return (
    <div>
      {isDirty && (
        <span>Neuložené změny: {dirtyRowIds.length} řádků</span>
      )}

      <Button onClick={resetAll} disabled={!isDirty}>
        Zahodit změny
      </Button>

      <Button onClick={() => {
        const changes = getPendingChanges();
        console.log('Přidáno:', changes.added);
        console.log('Upraveno:', changes.updated);
        console.log('Smazáno:', changes.deleted);
      }}>
        Zobrazit změny
      </Button>
    </div>
  );
};

Undo pro konkrétní řádek (např. v kontextovém menu)

import { useSharedGridItems } from '@bit.rhplus/shared-grid-form';

const RowActions = ({ instanceId, rowId }) => {
  const { deleteRow, resetRow, pendingChanges } = useSharedGridItems(instanceId);

  const hasChanges = !!pendingChanges[rowId];

  return (
    <Space>
      <Button danger onClick={() => deleteRow(rowId)}>
        Smazat
      </Button>
      {hasChanges && (
        <Button onClick={() => resetRow(rowId)}>
          Vrátit změny
        </Button>
      )}
    </Space>
  );
};

Integrace s Detail komponentou

Detail komponenta (src/shared/detail/) koordinuje formulářová data (shared-form) a grid data (shared-grid-form) při manuálním ukládání.

Datový tok při ukládání

Uživatel klikne "Uložit"
        ↓
Detail (useDetailSave)
  ├── formData ← useSharedForm(instanceId).data
  ├── gridChanges ← useSharedGridItems(instanceId).getPendingChanges()
  │     → { added: [...], updated: [...], deleted: [...] }
  └── allData = { ...formData, items: gridChanges }
        ↓
API call (saveApi nebo onSave callback)
        ↓
Úspěch → clearPendingGridChanges() + refetch()
Chyba  → pending changes zůstávají (uživatel může opravit a zkusit znovu)

Jak Detail používá shared-grid-form

// src/shared/detail/desktop/index.jsx (zjednodušeno)
const Detail = (props) => {
  const { data: formData, setData } = useSharedForm(instanceId);
  const {
    items: gridItems,
    setItems,
    getPendingChanges: getPendingGridChanges,
    clearPendingChanges: clearPendingGridChanges,
  } = useSharedGridItems(instanceId);

  // useDetailData načte data ze serveru a zavolá setData() + setItems()
  const { displayData, isLoading, refetch } = useDetailData({
    useQueryHook, id, setData, setItems, prepareDataAfterLoad
  });

  // useDetailSave sbírá formData + gridChanges a posílá na API
  const { handleSave, isSaving } = useDetailSave({
    formData,
    gridItems,                    // fallback na plný seznam
    getPendingGridChanges,        // preferované: strukturované změny
    clearPendingGridChanges,      // vyčistí po úspěšném uložení
    id, saveApi, refetch, ...
  });

  return (
    <Layout>
      <DetailContent sections={sections} instanceId={instanceId} />
      <DetailFooter handleSave={handleSave} isSaving={isSaving} />
    </Layout>
  );
};

Edge cases

Smazání nově přidaného řádku

Pokud uživatel přidá řádek (addRow) a poté ho smaže (deleteRow), operace se vzájemně ruší - řádek je kompletně odstraněn z pendingChanges bez zanechání stopy.

Editace smazaného řádku

Pokus o updateCell na řádku s operací DELETE je ignorován (no-op). Navíc isCellEditable automaticky vrací false pro smazané řádky, takže AG Grid nedovolí editaci.

Vícenásobná editace stejného pole

Při opakované editaci stejného pole (např. quantity: 10 → 15 → 20) se v changes uchovává vždy jen poslední hodnota (20). Originální hodnota (10) zůstává v originalData pro případný undo.

Reset smazaného řádku

Volání resetRow na řádek s operací DELETE ho obnoví - odstraní DELETE záznam z pendingChanges a řádek se znovu objeví v effectiveItems s originálními daty.


Vztah ke shared-form

| Vlastnost | shared-form | shared-grid-form | |-----------|-------------|-------------------| | Účel | Formulářová pole (Ant Design Form) | Tabulkové řádky (AG Grid) | | Stav | data + changedData | items + pendingChanges | | Granularita | Field-level (per pole) | Field-level (per buňka v řádku) | | Operace | Pouze UPDATE | ADD, UPDATE, DELETE | | Undo | Ne | Ano (resetRow, resetAll) | | Ukládání | Manuální (Detail) | Manuální (Detail) | | State management | Jotai atomFamily | Jotai atomFamily | | Computed merge | allData = { ...data, ...changedData } | effectiveItems (useMemo) |


Struktura souborů

src/bit/shared-grid-form/
├── atoms.js              # Jotai atomy, useSharedGridItems hook, createTempId, OperationType
├── SharedGridForm.jsx    # React Context provider, useSharedGrid hook
├── index.js              # Exporty
├── styles.css            # CSS pro vizuální indikátory (row-pending-add/update/delete)
└── README.md             # Tento soubor