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
fieldHistorystate - Parameters:
versionId: string- Version to analyze
- Returns:
Promise<void>
compareVersions(fromId, toId)
- Compares two versions using database function
- Parameters:
fromId: string- Older version IDtoId: 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 restorereason: 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
EntityVersionHistorydialog - 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.