Skip to content
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ and this project adheres to
[#4468](https://github.com/OpenFn/lightning/issues/4468)
- Auto-increment job name when adaptor display name is already used in workflow
[#4464](https://github.com/OpenFn/lightning/issues/4464)
- Fixed an issue where metadata failed to load in the Editor
[#4388](https://github.com/OpenFn/lightning/pull/4388)

## [2.15.15] - 2026-03-02

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ export const CollaborativeMonaco = forwardRef<
<div ref={containerRef} className="h-full w-full">
<Cursors />
<MonacoEditor
defaultPath="/job.js" // Required for magic completion
onMount={handleOnMount}
options={editorOptions}
theme="default"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
useJobMatchesRun,
} from '../../hooks/useHistory';
import { useRunRetry } from '../../hooks/useRunRetry';
import { useMetadata } from '../../hooks/useMetadata';
import { useRunRetryShortcuts } from '../../hooks/useRunRetryShortcuts';
import { useSession } from '../../hooks/useSession';
import { useProject } from '../../hooks/useSessionContext';
Expand Down Expand Up @@ -129,6 +130,7 @@ export function FullScreenIDE({
const { selectJob, saveWorkflow } = useWorkflowActions();
const { selectStep } = useHistoryCommands();
const { job: currentJob, ytext: currentJobYText } = useCurrentJob();
const { metadata, isLoading: isMetadataLoading } = useMetadata();
const awareness = useSession(selectAwareness);
const { canSave } = useCanSave();

Expand Down Expand Up @@ -996,6 +998,7 @@ export function FullScreenIDE({
ytext={currentJobYText}
awareness={awareness}
adaptor={currentJob.adaptor || 'common'}
metadata={metadata}
disabled={!canSave}
className="h-full w-full"
options={{
Expand Down Expand Up @@ -1095,7 +1098,7 @@ export function FullScreenIDE({
{selectedDocsTab === 'metadata' && (
<Metadata
adaptor={currJobAdaptor}
metadata={null}
metadata={isMetadataLoading ? true : metadata}
/>
)}
</div>
Expand Down
34 changes: 16 additions & 18 deletions assets/js/collaborative-editor/contexts/StoreProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ import {
type HistoryStoreInstance,
} from '../stores/createHistoryStore';
import type { RunStepsData } from '../types/history';
import {
createMetadataStore,
type MetadataStoreInstance,
} from '../stores/createMetadataStore';
import {
createSessionContextStore,
type SessionContextStoreInstance,
Expand All @@ -81,6 +85,7 @@ import { generateUserColor } from '../utils/userColor';
export interface StoreContextValue {
adaptorStore: AdaptorStoreInstance;
credentialStore: CredentialStoreInstance;
metadataStore: MetadataStoreInstance;
awarenessStore: AwarenessStoreInstance;
workflowStore: WorkflowStoreInstance;
sessionContextStore: SessionContextStoreInstance;
Expand Down Expand Up @@ -126,6 +131,7 @@ export const StoreProvider = ({ children }: StoreProviderProps) => {
return {
adaptorStore: createAdaptorStore(),
credentialStore: createCredentialStore(),
metadataStore: createMetadataStore(),
awarenessStore: createAwarenessStore(),
workflowStore: createWorkflowStore(),
sessionContextStore: createSessionContextStore(isNewWorkflow),
Expand Down Expand Up @@ -189,25 +195,17 @@ export const StoreProvider = ({ children }: StoreProviderProps) => {
// Connect stores when provider is ready
useEffect(() => {
if (session.provider && session.isConnected) {
const cleanup1 = stores.adaptorStore._connectChannel(session.provider);
const cleanup2 = stores.credentialStore._connectChannel(session.provider);
const cleanup3 = stores.sessionContextStore._connectChannel(
session.provider
);
const cleanup4 = stores.historyStore._connectChannel(session.provider);
const cleanup5 = stores.aiAssistantStore._connectChannel(
session.provider
);

return () => {
cleanup1();
cleanup2();
cleanup3();
cleanup4();
cleanup5();
};
const connections = [
stores.adaptorStore,
stores.credentialStore,
stores.metadataStore,
stores.sessionContextStore,
stores.historyStore,
stores.aiAssistantStore,
].map(store => store._connectChannel(session.provider!));

return () => connections.forEach(cleanup => cleanup());
}
return undefined;
}, [session.provider, session.isConnected, stores]);

// Connect/disconnect workflowStore Y.Doc when session changes
Expand Down
127 changes: 127 additions & 0 deletions assets/js/collaborative-editor/hooks/useMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* React hooks for metadata management
*
* Provides hooks to automatically fetch and subscribe to metadata
* for the currently selected job. Metadata is fetched when the job's
* adaptor or credential changes.
*/

import { useContext, useEffect, useSyncExternalStore } from 'react';

import { StoreContext } from '../contexts/StoreProvider';
import type { MetadataStoreInstance } from '../stores/createMetadataStore';
import type { Metadata } from '../types/metadata';

import { useCurrentJob } from './useWorkflow';

/**
* Main hook for accessing the MetadataStore instance
* Handles context access and error handling once
*/
const useMetadataStore = (): MetadataStoreInstance => {
const context = useContext(StoreContext);
if (!context) {
throw new Error('useMetadataStore must be used within a StoreProvider');
}
return context.metadataStore;
};

/**
* Hook to fetch and subscribe to metadata for the currently selected job
*
* Auto-fetches metadata when:
* - Job is selected
* - Adaptor changes
* - Credential changes
*
* Returns metadata state with loading and error indicators.
*/
export const useMetadata = () => {
const metadataStore = useMetadataStore();
const { job } = useCurrentJob();

// Subscribe to metadata state for the current job
const metadata = useSyncExternalStore(
metadataStore.subscribe,
metadataStore.withSelector(() =>
job ? metadataStore.getMetadataForJob(job.id) : null
)
);

const isLoading = useSyncExternalStore(
metadataStore.subscribe,
metadataStore.withSelector(() =>
job ? metadataStore.isLoadingForJob(job.id) : false
)
);

const error = useSyncExternalStore(
metadataStore.subscribe,
metadataStore.withSelector(() =>
job ? metadataStore.getErrorForJob(job.id) : null
)
);

// Auto-fetch metadata when job selection or adaptor/credential changes
useEffect(() => {
if (!job) return;

const { id, adaptor, project_credential_id, keychain_credential_id } = job;
const credentialId =
project_credential_id || keychain_credential_id || null;

if (adaptor) {
void metadataStore.requestMetadata(id, adaptor, credentialId);
}
}, [
job?.id,
job?.adaptor,
job?.project_credential_id,
job?.keychain_credential_id,
metadataStore,
]);

// Provide a refetch function for manual refresh
const refetch = job
? () => {
const credentialId =
job.project_credential_id || job.keychain_credential_id || null;
return metadataStore.requestMetadata(job.id, job.adaptor, credentialId);
}
: undefined;

return {
metadata,
isLoading,
error,
refetch,
};
};

/**
* Hook to get metadata for a specific job ID
* Useful when you need to access metadata for a job that isn't currently selected
*/
export const useMetadataForJob = (jobId: string | null): Metadata | null => {
const metadataStore = useMetadataStore();

return useSyncExternalStore(
metadataStore.subscribe,
metadataStore.withSelector(() =>
jobId ? metadataStore.getMetadataForJob(jobId) : null
)
);
};

/**
* Hook to get metadata commands for manual control
*/
export const useMetadataCommands = () => {
const metadataStore = useMetadataStore();

return {
requestMetadata: metadataStore.requestMetadata,
clearMetadata: metadataStore.clearMetadata,
clearAllMetadata: metadataStore.clearAllMetadata,
};
};
Loading
Loading