Commit 4276914d authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: enhance federation dialogs and forms with validation and accessibility improvements

parent 8d780146
Loading
Loading
Loading
Loading
+7 −1
Changes for portal-gui/src/components/data-table/dragable-row.tsx: 7 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -30,7 +30,13 @@ export function DraggableRow<TData>({ row }: DraggableRowProps<TData>) {
      }}
    >
      {row.getVisibleCells().map((cell) => (
        <TableCell key={cell.id}>
        <TableCell
          key={cell.id}
          className={
            (cell.column.columnDef.meta as { className?: string } | undefined)
              ?.className
          }
        >
          {cell.column.id === 'drag' ? (
            <button
              type="button"
+28 −17
Changes for portal-gui/src/components/federation/confirm-action-dialog.tsx: 28 added lines, 17 removed lines.
Original line number Diff line number Diff line
import { useState } from 'react';
import { useState, type ReactNode } from 'react';

import { useSingleFlight } from '@/hooks/useSingleFlight';

import {
  AlertDialog,
@@ -10,34 +12,40 @@ import {
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Partner } from '@/types/federation';

export function ConfirmDeleteFederationDialog({
  partner,
export function ConfirmActionDialog({
  title,
  description,
  confirmLabel,
  pendingLabel,
  failureMessage,
  onOpenChange,
  onConfirm,
}: {
  partner: Partner;
  title: string;
  description: ReactNode;
  confirmLabel: string;
  pendingLabel: string;
  failureMessage: string;
  onOpenChange: (open: boolean) => void;
  onConfirm: () => Promise<void>;
}) {
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const once = useSingleFlight();

  const handleConfirm = async () => {
  const handleConfirm = () =>
    once(async () => {
      setPending(true);
      setError(null);
      try {
        await onConfirm();
        onOpenChange(false);
      } catch (err) {
      setError(
        err instanceof Error ? err.message : 'Failed to delete federation.',
      );
    } finally {
        setError(err instanceof Error ? err.message : failureMessage);
        setPending(false);
      }
  };
    });

  return (
    <AlertDialog
@@ -48,14 +56,17 @@ export function ConfirmDeleteFederationDialog({
    >
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Delete federation?</AlertDialogTitle>
          <AlertDialogDescription>
            Ends our federation with "{partner.name}", including the zones we
            subscribed to. The partner registration stays.
          <AlertDialogTitle>{title}</AlertDialogTitle>
          <AlertDialogDescription className="wrap-anywhere">
            {description}
          </AlertDialogDescription>
        </AlertDialogHeader>

        {error && <p className="text-sm text-destructive">{error}</p>}
        {error && (
          <p role="alert" className="text-sm text-destructive">
            {error}
          </p>
        )}

        <AlertDialogFooter>
          <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
@@ -67,7 +78,7 @@ export function ConfirmDeleteFederationDialog({
            disabled={pending}
            className="text-destructive-foreground bg-destructive hover:bg-destructive/90"
          >
            {pending ? 'Deleting…' : 'Delete'}
            {pending ? pendingLabel : confirmLabel}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
+0 −74
Changes for portal-gui/src/components/federation/confirm-unsubscribe-zone-dialog.tsx: 0 added lines, 74 removed lines.
Original line number Diff line number Diff line
import { useState } from 'react';

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { PartnerZone } from '@/types/federation';

export function ConfirmUnsubscribeZoneDialog({
  zone,
  onOpenChange,
  onConfirm,
}: {
  zone: PartnerZone;
  onOpenChange: (open: boolean) => void;
  onConfirm: () => Promise<void>;
}) {
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleConfirm = async () => {
    setPending(true);
    setError(null);
    try {
      await onConfirm();
      onOpenChange(false);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to unsubscribe.');
    } finally {
      setPending(false);
    }
  };

  return (
    <AlertDialog
      open
      onOpenChange={(open) => {
        if (!pending) onOpenChange(open);
      }}
    >
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Unsubscribe from zone?</AlertDialogTitle>
          <AlertDialogDescription>
            The partner releases the resources it reserved for us in "
            {zone.geographyDetails}".
          </AlertDialogDescription>
        </AlertDialogHeader>

        {error && <p className="text-sm text-destructive">{error}</p>}

        <AlertDialogFooter>
          <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
          <AlertDialogAction
            onClick={(event) => {
              event.preventDefault();
              void handleConfirm();
            }}
            disabled={pending}
            className="text-destructive-foreground bg-destructive hover:bg-destructive/90"
          >
            {pending ? 'Unsubscribing…' : 'Unsubscribe'}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}
+21 −6
Changes for portal-gui/src/components/federation/create-federation-dialog.tsx: 21 added lines, 6 removed lines.
Original line number Diff line number Diff line
import { useState } from 'react';

import { useSingleFlight } from '@/hooks/useSingleFlight';

import { Button } from '@/components/ui/button';
import {
  Dialog,
@@ -35,18 +37,25 @@ export function CreateFederationDialog({
  const [partnerStatusLink, setPartnerStatusLink] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [errors, setErrors] = useState<string[]>([]);
  const [invalid, setInvalid] = useState<Set<string>>(new Set());
  const once = useSingleFlight();

  const handleSubmit = async () => {
  const handleSubmit = () =>
    once(async () => {
      const result = federationRequestSchema.safeParse({
        initialDate,
        partnerStatusLink: partnerStatusLink.trim(),
      });
      if (!result.success) {
        setInvalid(
          new Set(result.error.issues.map((issue) => String(issue.path[0]))),
        );
        setErrors(result.error.issues.map((issue) => issue.message));
        return;
      }
      setSubmitting(true);
      setErrors([]);
      setInvalid(new Set());
      try {
        await onCreate({
          initialDate: new Date(result.data.initialDate).toISOString(),
@@ -55,10 +64,9 @@ export function CreateFederationDialog({
        onOpenChange(false);
      } catch (err) {
        setErrors([err instanceof Error ? err.message : 'The request failed.']);
    } finally {
        setSubmitting(false);
      }
  };
    });

  return (
    <Dialog
@@ -68,9 +76,9 @@ export function CreateFederationDialog({
      }}
    >
      <DialogContent className="sm:max-w-lg">
        <DialogHeader>
        <DialogHeader className="min-w-0">
          <DialogTitle>Create Federation</DialogTitle>
          <DialogDescription>
          <DialogDescription className="wrap-anywhere">
            Our Federation Manager asks {partner.name} to federate with us. Our
            own identity is filled in by the Federation Manager.
          </DialogDescription>
@@ -81,6 +89,8 @@ export function CreateFederationDialog({
            <Label htmlFor="federation-initial-date">Initial date *</Label>
            <Input
              id="federation-initial-date"
              aria-required
              aria-invalid={invalid.has('initialDate') || undefined}
              type="datetime-local"
              value={initialDate}
              onChange={(e) => setInitialDate(e.target.value)}
@@ -92,6 +102,8 @@ export function CreateFederationDialog({
            </Label>
            <Input
              id="federation-status-link"
              aria-required
              aria-invalid={invalid.has('partnerStatusLink') || undefined}
              className="font-mono"
              value={partnerStatusLink}
              onChange={(e) => setPartnerStatusLink(e.target.value)}
@@ -103,7 +115,10 @@ export function CreateFederationDialog({
          </div>

          {errors.length > 0 && (
            <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
            <div
              role="alert"
              className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"
            >
              {errors.join(' ')}
            </div>
          )}
+6 −1
Changes for portal-gui/src/components/federation/federation-state-badge.tsx: 6 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -5,7 +5,12 @@ import type { FederationState } from '@/types/federation';

export function FederationStateBadge({ state }: { state: FederationState }) {
  if (state === 'loading') {
    return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />;
    return (
      <>
        <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
        <span className="sr-only">Checking federation</span>
      </>
    );
  }
  if (state === 'unknown') {
    return <span className="text-muted-foreground">—</span>;
Loading