Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ and this project adheres to

### Changed

- Improve version pinning behavior in collaborative editor
[#4121](https://github.com/OpenFn/lightning/issues/4121)
- Unify disabled button states across collaborative editor for consistent
styling and behaviour [#4179](https://github.com/OpenFn/lightning/issues/4179)

Expand Down
81 changes: 55 additions & 26 deletions assets/js/collaborative-editor/components/VersionDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react';

import { useURLState } from '#/react/lib/use-url-state';

import { cn } from '../../utils/cn';
import {
useRequestVersions,
Expand Down Expand Up @@ -30,11 +32,16 @@ export function VersionDropdown({
const versionsError = useVersionsError();
const requestVersions = useRequestVersions();

// Check if version is pinned via URL parameter
const { params } = useURLState();
const isPinnedVersion = params['v'] !== undefined && params['v'] !== null;

// Show placeholder while loading version information
const isLoadingVersion = currentVersion === null || latestVersion === null;

// Determine if viewing latest version (only when we have both values)
const isLatestVersion = !isLoadingVersion && currentVersion === latestVersion;
// Determine if viewing latest version (only when we have both values AND no pinned version)
const isLatestVersion =
!isLoadingVersion && currentVersion === latestVersion && !isPinnedVersion;

// Format version display
const currentVersionDisplay = isLoadingVersion
Expand Down Expand Up @@ -95,8 +102,8 @@ export function VersionDropdown({
}
}, [versionsError]);

const handleVersionClick = (version: Version) => {
if (version.is_latest) {
const handleVersionClick = (version: Version | 'latest') => {
if (version === 'latest') {
onVersionSelect('latest');
} else {
onVersionSelect(version.lock_version);
Expand Down Expand Up @@ -146,44 +153,66 @@ export function VersionDropdown({
No versions available
</div>
) : (
versions.map((version, index) => {
const isSelected = version.is_latest
? isLatestVersion
: version.lock_version === currentVersion;

// Show "latest" for the latest version, otherwise show version number
// For the first item (which is latest), show "latest"
// For subsequent items, show version number even if they have is_latest=true
const displayText =
index === 0 && version.is_latest
? 'latest'
: `v${String(version.lock_version).substring(0, 7)}`;

return (
<>
{/* First, show "latest" option that removes version parameter */}
{versions.length > 0 && versions[0].is_latest && (
<button
key={version.lock_version}
key="latest"
type="button"
onClick={() => handleVersionClick(version)}
onClick={() => handleVersionClick('latest')}
className={cn(
'w-full text-left px-4 py-2 text-sm hover:bg-gray-100 flex items-center justify-between',
isSelected
isLatestVersion
? 'bg-primary-50 text-primary-900'
: 'text-gray-700'
)}
role="menuitem"
>
<div className="flex flex-col">
<span className="font-medium">{displayText}</span>
<span className="font-medium">latest</span>
<span className="text-xs text-gray-500">
{new Date(version.inserted_at).toLocaleString()}
{new Date(versions[0].inserted_at).toLocaleString()}
</span>
</div>
{isSelected && (
{isLatestVersion && (
<span className="hero-check h-4 w-4 text-primary-600" />
)}
</button>
);
})
)}

{/* Then show all versions with version numbers (including latest) */}
{versions.map(version => {
const isSelected =
!isLatestVersion && version.lock_version === currentVersion;

const displayText = `v${String(version.lock_version).substring(0, 7)}`;

return (
<button
key={version.lock_version}
type="button"
onClick={() => handleVersionClick(version)}
className={cn(
'w-full text-left px-4 py-2 text-sm hover:bg-gray-100 flex items-center justify-between',
isSelected
? 'bg-primary-50 text-primary-900'
: 'text-gray-700'
)}
role="menuitem"
>
<div className="flex flex-col">
<span className="font-medium">{displayText}</span>
<span className="text-xs text-gray-500">
{new Date(version.inserted_at).toLocaleString()}
</span>
</div>
{isSelected && (
<span className="hero-check h-4 w-4 text-primary-600" />
)}
</button>
);
})}
</>
)}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ import {
useHistoryLoading,
useRunSteps,
} from '../../hooks/useHistory';
import { useIsNewWorkflow } from '../../hooks/useSessionContext';
import {
useIsNewWorkflow,
useLatestSnapshotLockVersion,
} from '../../hooks/useSessionContext';
import { useVersionMismatch } from '../../hooks/useVersionMismatch';
import { useNodeSelection } from '../../hooks/useWorkflow';
import { useKeyboardShortcut } from '../../keyboard';
import type { RunSummary } from '../../types/history';

import MiniHistory from './MiniHistory';
import { VersionMismatchBanner } from './VersionMismatchBanner';
import CollaborativeWorkflowDiagramImpl from './WorkflowDiagram';

interface CollaborativeWorkflowDiagramProps {
Expand All @@ -42,6 +44,7 @@ export function CollaborativeWorkflowDiagram({
const isNewWorkflow = useIsNewWorkflow();
const isHistoryChannelConnected = useHistoryChannelConnected();
const { params, updateSearchParams } = useURLState();
const latestSnapshotLockVersion = useLatestSnapshotLockVersion();

// Get history data and commands
const history = useHistory();
Expand Down Expand Up @@ -85,14 +88,22 @@ export function CollaborativeWorkflowDiagram({
// Find the workorder that contains this run
const workorder = history.find(wo => wo.runs.some(r => r.id === run.id));

// Only include version parameter if the run's version differs from latest
// This prevents pinning to read-only mode when viewing latest version runs
const runVersion = workorder?.version;
const shouldPinVersion =
runVersion !== null &&
runVersion !== undefined &&
runVersion !== latestSnapshotLockVersion;

// Single atomic update - both version and run in one call
// This prevents race conditions between two separate updateSearchParams calls
updateSearchParams({
v: workorder ? String(workorder.version) : null,
v: shouldPinVersion ? String(runVersion) : null,
run: run.id,
});
},
[history, updateSearchParams]
[history, latestSnapshotLockVersion, updateSearchParams]
);

// Clear URL parameter when deselecting run
Expand Down Expand Up @@ -155,14 +166,6 @@ export function CollaborativeWorkflowDiagram({
return (
<div ref={containerRef} className={className}>
<ReactFlowProvider>
{versionMismatch && (
<VersionMismatchBanner
runVersion={versionMismatch.runVersion}
currentVersion={versionMismatch.currentVersion}
className="absolute top-4 left-1/2 -translate-x-1/2 z-[45] max-w-md"
/>
)}

<CollaborativeWorkflowDiagramImpl
selection={currentNode.id}
onSelectionChange={selectNode}
Expand All @@ -188,6 +191,7 @@ export function CollaborativeWorkflowDiagram({
historyCommands.clearError();
void historyCommands.requestHistory();
}}
versionMismatch={versionMismatch}
/>
)}
</ReactFlowProvider>
Expand Down
36 changes: 36 additions & 0 deletions assets/js/collaborative-editor/components/diagram/MiniHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { relativeLocale } from '../../../hooks';
import { duration } from '../../../utils/duration';
import truncateUid from '../../../utils/truncateUID';
import { useProject } from '../../hooks/useSessionContext';
import { useVersionSelect } from '../../hooks/useVersionSelect';
import { useWorkflowState } from '../../hooks/useWorkflow';
import type { RunSummary, WorkOrder } from '../../types/history';
import {
Expand All @@ -41,6 +42,8 @@ import { RunBadge } from '../common/RunBadge';
import { ShortcutKeys } from '../ShortcutKeys';
import { Tooltip } from '../Tooltip';

import { VersionMismatchBanner } from './VersionMismatchBanner';

// Extended types with selection state for UI
type RunWithSelection = RunSummary & { selected?: boolean };
type WorkOrderWithSelection = Omit<WorkOrder, 'runs'> & {
Expand Down Expand Up @@ -284,6 +287,11 @@ interface MiniHistoryProps {
// New props for panel variant
variant?: 'floating' | 'panel';
onBack?: () => void;
// Version mismatch detection
versionMismatch?: {
runVersion: number;
currentVersion: number;
} | null;
}

export default function MiniHistory({
Expand All @@ -298,13 +306,22 @@ export default function MiniHistory({
onRetry,
variant = 'floating',
onBack,
versionMismatch,
}: MiniHistoryProps) {
const [expandedWorder, setExpandedWorder] = useState('');
const now = new Date();

// Get project and workflow IDs from state for navigation
const project = useProject();
const workflow = useWorkflowState(state => state.workflow);
const handleVersionSelect = useVersionSelect();

// Handler to navigate to the run's version
const handleGoToVersion = () => {
if (versionMismatch) {
handleVersionSelect(versionMismatch.runVersion);
}
};

// Clear expanded work order when panel collapses
React.useEffect(() => {
Expand Down Expand Up @@ -532,6 +549,16 @@ export default function MiniHistory({
</div>
)}

{/* Version mismatch banner when collapsed */}
{collapsed && versionMismatch && (
<VersionMismatchBanner
runVersion={versionMismatch.runVersion}
currentVersion={versionMismatch.currentVersion}
onGoToVersion={handleGoToVersion}
compact={true}
/>
)}

<div
className={`overflow-y-auto no-scrollbar max-h-82
transition-opacity duration-200 ${
Expand Down Expand Up @@ -605,6 +632,15 @@ export default function MiniHistory({
</div>
)}
</div>

{/* Version mismatch banner at bottom of panel */}
{!collapsed && versionMismatch && (
<VersionMismatchBanner
runVersion={versionMismatch.runVersion}
currentVersion={versionMismatch.currentVersion}
onGoToVersion={handleGoToVersion}
/>
)}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,58 +9,57 @@
* as the workflow structure may have changed between versions.
*
* Features:
* - Compact two-line design with version information
* - Dismissible via X button
* - Positioned at top-center of canvas
* - Compact design with version information
* - Action button to navigate to the correct version
* - Positioned at bottom of MiniHistory panel
*/

import { useState } from 'react';

import { cn } from '#/utils/cn';

import { Tooltip } from '../Tooltip';

interface VersionMismatchBannerProps {
runVersion: number;
currentVersion: number;
onGoToVersion: () => void;
className?: string;
compact?: boolean;
}

export function VersionMismatchBanner({
runVersion,
currentVersion,
onGoToVersion,
className,
compact = false,
}: VersionMismatchBannerProps) {
const [dismissed, setDismissed] = useState(false);

if (dismissed) {
return null;
}
const tooltipText = `This run was executed on v${runVersion}, but you're visualizing it on v${currentVersion} of the workflow. Big things may have changed.`;

return (
<div
className={cn('bg-yellow-50 rounded-md shadow-sm', className)}
className={cn('bg-yellow-50', className)}
role="alert"
aria-live="polite"
>
<div className="flex items-start gap-2 p-3">
<span
className="hero-information-circle h-5 w-5 text-yellow-800 shrink-0"
aria-hidden="true"
/>
<div className="flex-1 min-w-0">
<div className="text-xs text-yellow-800 font-medium">
Canvas shows v{currentVersion} (Selected run: v{runVersion})
</div>
<div className="text-xs text-yellow-700 mt-0.5">
Canvas layout may differ from actual run
</div>
</div>
<div className="flex items-center gap-2 px-3 py-2">
<Tooltip content={tooltipText} side="top">
<span
className="hero-information-circle h-4 w-4 text-yellow-800 shrink-0"
aria-hidden="true"
/>
</Tooltip>
{!compact && (
<span className="text-xs text-yellow-800">
This run took place on version {runVersion}.
</span>
)}
<span className="flex-grow" />
<button
type="button"
onClick={() => setDismissed(true)}
className="shrink-0 text-yellow-700 cursor-pointer hover:text-yellow-800 transition-colors"
aria-label="Dismiss version mismatch warning"
onClick={onGoToVersion}
className="text-xs font-medium text-yellow-900 hover:text-yellow-950 whitespace-nowrap"
>
<span className="hero-x-mark h-4 w-4 -mt-3" />
View as executed <span aria-hidden="true">&rarr;</span>
</button>
</div>
</div>
Expand Down
Loading