Skip to content

Component Patterns

Pattern comuni per componenti React in elerama-frontend.

Panoramica

Questa guida documenta i pattern standard per:

  1. Pagine con Lista - AG Grid + filtri + CRUD
  2. Form con Mutation - Validazione Zod + TanStack Query
  3. Modali/Sheet - Creazione e modifica entità
  4. Gestione Stati - Loading, error, empty states

1. Pagina Lista con AG Grid

Pattern completo per pagine CRUD con griglia dati.

Struttura Base

typescript
import { AuthenticatedLayout } from "@/components/layout/authenticated";
import { useEntitiesList, useDeleteEntity } from "@/hooks/api/entities/useEntitiesApi";
import { createProtectedLoader } from "@/lib/auth/loaders";
import type { EntityListItem } from "@/schemas/entities/entities.schema";
import { AG_GRID_LOCALE_IT } from "@ag-grid-community/locale";
import type { ColDef, ICellRendererParams } from "ag-grid-community";
import {
    ClientSideRowModelModule,
    ModuleRegistry,
    themeQuartz,
} from "ag-grid-community";
import "ag-grid-community/styles/ag-theme-quartz.css";
import { AgGridReact } from "ag-grid-react";
import { useCallback, useMemo, useState } from "react";

// Registrazione moduli AG Grid (una sola volta)
ModuleRegistry.registerModules([ClientSideRowModelModule]);

export const clientLoader = createProtectedLoader({ requireSettings: true });

export default function EntitiesPage() {
    const [search, setSearch] = useState("");
    const [includeDeleted, setIncludeDeleted] = useState(false);

    const { data: entities, isLoading, error, refetch } = useEntitiesList({
        search: search || undefined,
        includeDeleted,
    });

    // Definizione colonne (useMemo per AG Grid)
    const columnDefs = useMemo<ColDef<EntityListItem>[]>(() => [
        { field: "id", headerName: "ID", width: 80 },
        { field: "code", headerName: "Codice", flex: 1 },
        { field: "description", headerName: "Descrizione", flex: 2 },
        {
            headerName: "Azioni",
            width: 120,
            sortable: false,
            filter: false,
            cellRenderer: ActionsRenderer,
        },
    ], []);

    const defaultColDef = useMemo<ColDef>(() => ({
        sortable: true,
        filter: true,
        resizable: true,
    }), []);

    if (error) {
        return <ErrorState error={error} onRetry={refetch} />;
    }

    return (
        <AuthenticatedLayout>
            <div className="p-6">
                <Card>
                    <CardHeader>
                        <CardTitle>Gestione Entità</CardTitle>
                    </CardHeader>
                    <CardContent>
                        {/* Filtri */}
                        <Filters
                            search={search}
                            onSearchChange={setSearch}
                            includeDeleted={includeDeleted}
                            onIncludeDeletedChange={setIncludeDeleted}
                        />

                        {/* Griglia */}
                        <div className="ag-theme-quartz" style={{ height: 500 }}>
                            <AgGridReact<EntityListItem>
                                rowData={entities}
                                loading={isLoading}
                                columnDefs={columnDefs}
                                defaultColDef={defaultColDef}
                                theme={themeQuartz}
                                pagination={true}
                                paginationPageSize={20}
                                localeText={AG_GRID_LOCALE_IT}
                            />
                        </div>
                    </CardContent>
                </Card>
            </div>
        </AuthenticatedLayout>
    );
}

Cell Renderer per Azioni

typescript
// useCallback necessario: passato a componente third-party (AG Grid)
const ActionsRenderer = useCallback(
    (params: ICellRendererParams<EntityListItem>) => {
        const entity = params.data;
        if (!entity) return null;

        return (
            <div className="flex items-center gap-1 h-full">
                {entity.isDeleted ? (
                    <Button
                        variant="ghost"
                        size="icon"
                        onClick={() => handleRestore(entity)}
                        disabled={isMutating}
                        title="Ripristina"
                    >
                        <Recycle className="h-4 w-4" />
                    </Button>
                ) : (
                    <>
                        <Button
                            variant="ghost"
                            size="icon"
                            onClick={() => openSheet(entity)}
                            disabled={isMutating}
                            title="Modifica"
                        >
                            <Settings className="h-4 w-4" />
                        </Button>
                        <Button
                            variant="ghost"
                            size="icon"
                            onClick={() => handleDelete(entity)}
                            disabled={isMutating}
                            title="Elimina"
                        >
                            <Trash className="h-4 w-4 text-destructive" />
                        </Button>
                    </>
                )}
            </div>
        );
    },
    [isMutating]
);

2. Form con Validazione Zod

Pattern per form con validazione client-side e mutation.

Form State Pattern

typescript
import { EntityRequestSchema, type EntityRequest } from "@/schemas/entities/entities.schema";
import { useState } from "react";

// State per form
const [formData, setFormData] = useState<EntityRequest>({
    code: "",
    description: "",
});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});

// Handler submit con validazione Zod
const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    // Validazione con Zod
    const result = EntityRequestSchema.safeParse(formData);
    if (!result.success) {
        const errors: Record<string, string> = {};
        result.error.issues.forEach((issue) => {
            if (issue.path[0]) {
                errors[String(issue.path[0])] = issue.message;
            }
        });
        setFormErrors(errors);
        return;
    }

    // Mutation (result.data è validato)
    if (editingEntity) {
        updateEntity({ id: editingEntity.id, data: result.data });
    } else {
        createEntity(result.data);
    }
};

Form Fields con Error Display

typescript
<Field>
    <FieldLabel htmlFor="code">Codice *</FieldLabel>
    <Input
        id="code"
        type="text"
        value={formData.code}
        onChange={(e) =>
            setFormData((prev) => ({
                ...prev,
                code: e.target.value,
            }))
        }
        placeholder="Es: SAMSUNG"
        maxLength={50}
        className={cn(formErrors.code && "border-destructive")}
    />
    {formErrors.code && (
        <p className="text-sm text-destructive mt-1">
            {formErrors.code}
        </p>
    )}
</Field>

3. Sheet per Creazione/Modifica

Pattern per side panel CRUD.

State e Handlers

typescript
import type { EntityListItem, EntityRequest } from "@/schemas/entities/entities.schema";
import { useState } from "react";

// State per sheet
const [isSheetOpen, setIsSheetOpen] = useState(false);
const [editingEntity, setEditingEntity] = useState<EntityListItem | null>(null);
const [formData, setFormData] = useState<EntityRequest>({
    code: "",
    description: "",
});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});

// Handler apertura (nuovo o modifica)
const openSheet = (entity?: EntityListItem) => {
    if (entity) {
        setEditingEntity(entity);
        setFormData({
            code: entity.code,
            description: entity.description,
        });
    } else {
        setEditingEntity(null);
        setFormData({ code: "", description: "" });
    }
    setFormErrors({});
    setIsSheetOpen(true);
};

// Handler chiusura
const closeSheet = () => {
    setIsSheetOpen(false);
    setEditingEntity(null);
    setFormData({ code: "", description: "" });
    setFormErrors({});
};

Sheet Component

typescript
import {
    SheetContent,
    SheetRoot,
    Button,
    Field,
    FieldGroup,
    FieldLabel,
    Input,
    Spinner,
} from "@elerama/ui";

<SheetRoot open={isSheetOpen} onOpenChange={setIsSheetOpen}>
    <SheetContent side="right" className="w-[400px] sm:max-w-[400px]">
        <div className="flex flex-col h-full">
            {/* Header */}
            <div className="border-b pb-4 mb-4">
                <h2 className="text-lg font-semibold">
                    {editingEntity ? "Modifica Entità" : "Nuova Entità"}
                </h2>
                <p className="text-sm text-muted-foreground">
                    {editingEntity
                        ? `Modifica i dati di ${editingEntity.code}`
                        : "Inserisci i dati della nuova entità"}
                </p>
            </div>

            {/* Form */}
            <form onSubmit={handleSubmit} className="flex-1 flex flex-col">
                <FieldGroup className="flex-1">
                    {/* Campi form */}
                </FieldGroup>

                {/* Footer con bottoni */}
                <div className="flex gap-2 pt-4 border-t mt-4">
                    <Button
                        type="button"
                        variant="outline"
                        onClick={closeSheet}
                        disabled={isPending}
                        className="flex-1"
                    >
                        Annulla
                    </Button>
                    <Button
                        type="submit"
                        disabled={isPending}
                        className="flex-1"
                    >
                        {isPending ? (
                            <Spinner className="h-4 w-4" />
                        ) : editingEntity ? (
                            "Salva"
                        ) : (
                            "Crea"
                        )}
                    </Button>
                </div>
            </form>
        </div>
    </SheetContent>
</SheetRoot>

4. Mutations con Toast Feedback

Pattern per gestione operazioni CRUD con feedback.

typescript
import { useCreateEntity, useUpdateEntity, useDeleteEntity } from "@/hooks/api/entities/useEntitiesApi";
import { useToast } from "@elerama/ui";

export default function EntitiesPage() {
    const { success: showSuccess, error: showError } = useToast();

    // Create mutation
    const { mutate: createEntity, isPending: isCreating } = useCreateEntity({
        onSuccess: () => {
            showSuccess("Entità creata con successo");
            closeSheet();
        },
        onError: (error) => {
            showError(error.message);
        },
    });

    // Update mutation
    const { mutate: updateEntity, isPending: isUpdating } = useUpdateEntity({
        onSuccess: () => {
            showSuccess("Entità aggiornata con successo");
            closeSheet();
        },
        onError: (error) => {
            showError(error.message);
        },
    });

    // Delete mutation con conferma
    const { mutate: deleteEntity, isPending: isDeleting } = useDeleteEntity({
        onSuccess: () => {
            showSuccess("Entità eliminata");
        },
        onError: (error) => {
            showError(error.message);
        },
    });

    // Handler delete con conferma
    const handleDelete = (entity: EntityListItem) => {
        if (window.confirm(`Sei sicuro di voler eliminare "${entity.description}"?`)) {
            deleteEntity(entity.id);
        }
    };

    // Flag combinato per disabilitare UI durante mutation
    const isMutating = isCreating || isUpdating || isDeleting;

    // ...
}

5. Gestione Stati UI

Error State

typescript
import { Alert, AlertDescription, AlertTitle, Button } from "@elerama/ui";
import { AuthenticatedLayout } from "@/components/layout/authenticated";

interface ErrorStateProps {
    error: Error;
    onRetry: () => void;
}

function ErrorState({ error, onRetry }: ErrorStateProps) {
    return (
        <AuthenticatedLayout>
            <div className="flex flex-1 items-center justify-center p-6">
                <Alert variant="destructive" className="max-w-md">
                    <AlertTitle>Errore nel caricamento</AlertTitle>
                    <AlertDescription>{error.message}</AlertDescription>
                    <Button onClick={onRetry} className="mt-4">
                        Riprova
                    </Button>
                </Alert>
            </div>
        </AuthenticatedLayout>
    );
}

Loading State

typescript
import { Spinner } from "@elerama/ui";

// Loading inline
{isLoading && <Spinner className="h-4 w-4" />}

// Loading full page
{isLoading && (
    <div className="flex items-center justify-center h-64">
        <Spinner className="h-8 w-8" />
    </div>
)}

Empty State

typescript
{data?.length === 0 && (
    <div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
        <p className="text-lg">Nessun elemento trovato</p>
        <p className="text-sm">Crea il primo elemento per iniziare</p>
        <Button onClick={() => openSheet()} className="mt-4">
            + Nuovo Elemento
        </Button>
    </div>
)}

6. Filtri Lista

Search + Toggle Pattern

typescript
import { Search } from "@elerama/icons";
import { Input } from "@elerama/ui";

<div className="flex flex-wrap gap-4 mb-4">
    {/* Campo ricerca */}
    <div className="relative flex-1 min-w-[200px] max-w-[400px]">
        <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
        <Input
            type="text"
            placeholder="Cerca per codice o descrizione..."
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            className="pl-10"
        />
    </div>

    {/* Toggle eliminati */}
    <label className="flex items-center gap-2 cursor-pointer">
        <input
            type="checkbox"
            checked={includeDeleted}
            onChange={(e) => setIncludeDeleted(e.target.checked)}
            className="h-4 w-4 rounded border-gray-300"
        />
        <span className="text-sm text-muted-foreground">
            Mostra eliminati
        </span>
    </label>
</div>

7. Status Badge Pattern

typescript
import { Check, X } from "@elerama/icons";
import { cn } from "@/lib/utils";

// Cell renderer per status
cellRenderer: (params: ICellRendererParams<EntityListItem>) => {
    const isDeleted = params.value;
    return (
        <span
            className={cn(
                "inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium",
                isDeleted
                    ? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
                    : "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
            )}
        >
            {isDeleted ? (
                <>
                    <X className="h-3 w-3" /> Eliminato
                </>
            ) : (
                <>
                    <Check className="h-3 w-3" /> Attivo
                </>
            )}
        </span>
    );
}

Note su useCallback/useMemo

Per via del React Compiler, NON usare useCallback/useMemo di default.

ECCEZIONI (usa quando necessario):

  • Cell renderer per AG Grid (componente third-party)
  • Column definitions per AG Grid
  • Callbacks passati a useEffect come dipendenze
  • Props passate a componenti non compilati
typescript
// SÌ - AG Grid non è compilato da React Compiler
const columnDefs = useMemo<ColDef[]>(() => [...], []);

// SÌ - Cell renderer passato a AG Grid
const ActionsRenderer = useCallback(..., [isMutating]);

// NO - Componenti normali (React Compiler ottimizza)
// Non serve: const handleClick = useCallback(...);

Checklist

  • [ ] Layout corretto (AuthenticatedLayout)
  • [ ] Gestione error state con retry
  • [ ] Gestione loading state
  • [ ] Gestione empty state
  • [ ] Validazione form con Zod
  • [ ] Feedback toast su mutation
  • [ ] Conferma su delete
  • [ ] Disabilitazione UI durante mutation
  • [ ] useMemo/useCallback solo per AG Grid

Riferimenti

Documentazione Elerama Frontend