Files
thrilltrack-explorer/docs/versioning/FRONTEND.md
2025-10-15 17:54:53 +00:00

13 KiB

Frontend Integration Guide

How to use versioning in React components

Overview

The versioning system provides React hooks and components for displaying version history, comparing versions, and rolling back changes. All components are fully typed with TypeScript discriminated unions.

Type System

Core Types

// Entity type union
type EntityType = 'park' | 'ride' | 'company' | 'ride_model';

// Change type enum
type ChangeType = 'created' | 'updated' | 'deleted' | 'restored' | 'archived';

// Base version metadata
interface BaseVersion {
  version_id: string;
  version_number: number;
  created_at: string;
  created_by: string | null;
  change_type: ChangeType;
  change_reason: string | null;
  submission_id: string | null;
  is_current: boolean;
}

Entity-Specific Versions

The system uses discriminated unions for type-safe version handling:

// Discriminated union based on entity type
type EntityVersion = 
  | ParkVersion 
  | RideVersion 
  | CompanyVersion 
  | RideModelVersion;

// Example: Park version
interface ParkVersion extends BaseVersion {
  park_id: string;
  name: string;
  slug: string;
  description: string | null;
  park_type: string;
  status: string;
  opening_date: string | null;
  closing_date: string | null;
  location_id: string | null;
  operator_id: string | null;
  property_owner_id: string | null;
  // ... all park fields
}

Version Diff

interface VersionDiff {
  [fieldName: string]: {
    from: any;
    to: any;
    changed: boolean;
  };
}

React Hooks

useEntityVersions

Purpose: Fetch and manage version history for an entity.

Usage

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

const MyComponent = ({ parkId }: { parkId: string }) => {
  const {
    versions,
    currentVersion,
    loading,
    fieldHistory,
    fetchVersions,
    fetchFieldHistory,
    compareVersions,
    rollbackToVersion,
  } = useEntityVersions('park', parkId);

  // versions: Array of all versions
  // currentVersion: Latest version with is_current = true
  // loading: Boolean loading state
  // fieldHistory: Array of field changes for selected version
};

Return Type

interface UseEntityVersionsReturn {
  versions: EntityVersion[];
  currentVersion: EntityVersion | null;
  loading: boolean;
  fieldHistory: FieldChange[];
  fetchVersions: () => Promise<void>;
  fetchFieldHistory: (versionId: string) => Promise<void>;
  compareVersions: (fromId: string, toId: string) => Promise<VersionDiff | null>;
  rollbackToVersion: (versionId: string, reason: string) => Promise<boolean>;
}

Methods

fetchVersions()

  • Fetches all versions for the entity
  • Automatically called on mount and entity ID change
  • Returns: Promise<void>

fetchFieldHistory(versionId)

  • Fetches detailed field changes for a specific version
  • Populates fieldHistory state
  • Parameters:
    • versionId: string - Version to analyze
  • Returns: Promise<void>

compareVersions(fromId, toId)

  • Compares two versions using database function
  • Parameters:
    • fromId: string - Older version ID
    • toId: string - Newer version ID
  • Returns: Promise<VersionDiff | null>

rollbackToVersion(versionId, reason)

  • Restores entity to a previous version
  • Creates new version with change_type='restored'
  • Requires moderator role
  • Parameters:
    • versionId: string - Target version to restore
    • reason: string - Reason for rollback
  • Returns: Promise<boolean> - Success status

useVersionComparison

Purpose: Compare two specific versions and get diff.

Usage

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

const ComparisonView = () => {
  const { diff, loading, error } = useVersionComparison(
    'park',
    fromVersionId,
    toVersionId
  );

  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  
  return (
    <div>
      {Object.entries(diff || {}).map(([field, change]) => (
        <FieldDiff
          key={field}
          field={field}
          from={change.from}
          to={change.to}
        />
      ))}
    </div>
  );
};

Return Type

interface UseVersionComparisonReturn {
  diff: VersionDiff | null;
  loading: boolean;
  error: string | null;
}

Components

VersionIndicator

Purpose: Display current version badge on entity pages.

Props

interface VersionIndicatorProps {
  entityType: EntityType;
  entityId: string;
  entityName: string;
  compact?: boolean;  // Compact mode shows only version number
}

Usage

import { VersionIndicator } from '@/components/versioning/VersionIndicator';

// Full mode (with "Last edited" timestamp)
<VersionIndicator 
  entityType="park" 
  entityId={park.id} 
  entityName={park.name}
/>

// Compact mode (badge only)
<VersionIndicator 
  entityType="park" 
  entityId={park.id} 
  entityName={park.name}
  compact
/>

Output

  • Displays: "Version 3 · Last edited 2 hours ago"
  • Clicking opens EntityVersionHistory dialog
  • Compact mode: Just "v3" badge

EntityVersionHistory

Purpose: Display complete version timeline with comparison and rollback.

Props

interface EntityVersionHistoryProps {
  entityType: EntityType;
  entityId: string;
  entityName: string;
}

Usage

import { EntityVersionHistory } from '@/components/versioning/EntityVersionHistory';

<Dialog>
  <DialogContent>
    <EntityVersionHistory
      entityType="park"
      entityId={park.id}
      entityName={park.name}
    />
  </DialogContent>
</Dialog>

Features

  • Timeline of all versions
  • Select two versions to compare
  • Rollback to any previous version
  • User attribution and timestamps
  • Change type badges (created, updated, restored)

VersionComparisonDialog

Purpose: Side-by-side diff view of two versions.

Props

interface VersionComparisonDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  entityType: EntityType;
  fromVersionId: string | null;
  toVersionId: string | null;
}

Usage

import { VersionComparisonDialog } from '@/components/versioning/VersionComparisonDialog';

<VersionComparisonDialog
  open={showComparison}
  onOpenChange={setShowComparison}
  entityType="park"
  fromVersionId={selectedVersions[0]}
  toVersionId={selectedVersions[1]}
/>

Output

  • Field-by-field comparison
  • Highlights added, removed, and modified fields
  • Color-coded changes (green=added, red=removed, yellow=modified)
  • Formatted values (dates, JSON, etc.)

RollbackDialog

Purpose: Confirm and execute version rollback.

Props

interface RollbackDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  entityType: EntityType;
  entityId: string;
  targetVersion: EntityVersion;
  onRollback: (versionId: string, reason: string) => Promise<boolean>;
}

Usage

import { RollbackDialog } from '@/components/versioning/RollbackDialog';

<RollbackDialog
  open={showRollback}
  onOpenChange={setShowRollback}
  entityType="park"
  entityId={park.id}
  targetVersion={selectedVersion}
  onRollback={rollbackToVersion}
/>

Features

  • Requires rollback reason (text input)
  • Shows preview of target version
  • Confirms action with user
  • Displays success/error toast

Integration Examples

Adding Version History to Entity Pages

// Example: ParkDetail.tsx

import { VersionIndicator } from '@/components/versioning/VersionIndicator';
import { EntityHistoryTabs } from '@/components/history/EntityHistoryTabs';

const ParkDetail = () => {
  const { park } = usePark();

  return (
    <div>
      {/* Header with version indicator */}
      <div className="flex justify-between items-center">
        <h1>{park.name}</h1>
        <VersionIndicator 
          entityType="park" 
          entityId={park.id} 
          entityName={park.name}
        />
      </div>

      {/* Tabs with history */}
      <Tabs>
        <TabsList>
          <TabsTrigger value="details">Details</TabsTrigger>
          <TabsTrigger value="history">History</TabsTrigger>
        </TabsList>

        <TabsContent value="details">
          {/* Entity details */}
        </TabsContent>

        <TabsContent value="history">
          <EntityHistoryTabs
            entityType="park"
            entityId={park.id}
            entityName={park.name}
            events={[]} // Event history
            formerNames={park.former_names}
            currentName={park.name}
          />
        </TabsContent>
      </Tabs>
    </div>
  );
};

Custom Version List

const CustomVersionList = ({ parkId }: { parkId: string }) => {
  const { versions, loading } = useEntityVersions('park', parkId);

  if (loading) return <Skeleton />;

  return (
    <ul>
      {versions.map((version) => (
        <li key={version.version_id}>
          <Badge>{version.change_type}</Badge>
          <span>Version {version.version_number}</span>
          <span>{formatDate(version.created_at)}</span>
          <UserAvatar userId={version.created_by} />
        </li>
      ))}
    </ul>
  );
};

Version Comparison Widget

const VersionComparison = ({ 
  entityType, 
  versionIds 
}: { 
  entityType: EntityType; 
  versionIds: [string, string] 
}) => {
  const { diff, loading } = useVersionComparison(
    entityType,
    versionIds[0],
    versionIds[1]
  );

  if (loading) return <Spinner />;

  const changedFields = Object.entries(diff || {}).filter(
    ([_, change]) => change.changed
  );

  return (
    <Card>
      <CardHeader>
        <CardTitle>{changedFields.length} fields changed</CardTitle>
      </CardHeader>
      <CardContent>
        {changedFields.map(([field, change]) => (
          <div key={field} className="grid grid-cols-2 gap-4">
            <div>
              <Label>{field}</Label>
              <pre>{JSON.stringify(change.from, null, 2)}</pre>
            </div>
            <div>
              <Label>{field}</Label>
              <pre>{JSON.stringify(change.to, null, 2)}</pre>
            </div>
          </div>
        ))}
      </CardContent>
    </Card>
  );
};

Real-Time Updates

The useEntityVersions hook automatically subscribes to real-time changes via Supabase:

// Automatic subscription in useEntityVersions
useEffect(() => {
  const channel = supabase
    .channel(`${tableName}:${entityId}`)
    .on(
      'postgres_changes',
      {
        event: '*',
        schema: 'public',
        table: tableName,
        filter: `${entityType}_id=eq.${entityId}`,
      },
      () => {
        fetchVersions(); // Refetch on change
      }
    )
    .subscribe();

  return () => {
    supabase.removeChannel(channel);
  };
}, [entityType, entityId]);

Type Guards

Use type guards to narrow discriminated unions:

function isParkVersion(version: EntityVersion): version is ParkVersion {
  return 'park_id' in version;
}

function isRideVersion(version: EntityVersion): version is RideVersion {
  return 'ride_id' in version;
}

// Usage
if (isParkVersion(version)) {
  console.log(version.park_type); // TypeScript knows this is a park
}

Error Handling

All hooks handle errors gracefully:

const { versions, loading } = useEntityVersions('park', parkId);

// Loading state
if (loading) return <Skeleton />;

// Error state (versions will be empty array)
if (versions.length === 0) {
  return <EmptyState message="No version history available" />;
}

// Success state
return <VersionList versions={versions} />;

Best Practices

DO

Use VersionIndicator on all entity detail pages
Add history tab to entity pages
Let hooks manage state and subscriptions
Use type guards for discriminated unions
Handle loading and error states

DON'T

Manually query version tables (use hooks)
Bypass type system with any
Forget to cleanup subscriptions
Display sensitive data (created_by IDs) to public

Performance Optimization

Pagination

For entities with many versions, implement pagination:

const { versions } = useEntityVersions('park', parkId);
const [page, setPage] = useState(1);
const pageSize = 10;

const paginatedVersions = versions.slice(
  (page - 1) * pageSize,
  page * pageSize
);

Lazy Loading

Only load version details when needed:

const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const { fieldHistory, fetchFieldHistory } = useEntityVersions('park', parkId);

const handleVersionClick = (versionId: string) => {
  setSelectedVersion(versionId);
  fetchFieldHistory(versionId); // Load on demand
};

Testing

Example Unit Test

import { renderHook, waitFor } from '@testing-library/react';
import { useEntityVersions } from '@/hooks/useEntityVersions';

describe('useEntityVersions', () => {
  it('fetches versions on mount', async () => {
    const { result } = renderHook(() => 
      useEntityVersions('park', 'test-park-id')
    );

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
      expect(result.current.versions.length).toBeGreaterThan(0);
    });
  });
});

Troubleshooting

See TROUBLESHOOTING.md for common issues and solutions.