diff --git a/assets/js/collaborative-editor/components/CollaborativeMonaco.tsx b/assets/js/collaborative-editor/components/CollaborativeMonaco.tsx index 75482fb941d..9cc7a5e74bd 100644 --- a/assets/js/collaborative-editor/components/CollaborativeMonaco.tsx +++ b/assets/js/collaborative-editor/components/CollaborativeMonaco.tsx @@ -20,8 +20,12 @@ import { type Monaco, MonacoEditor, setTheme } from '../../monaco'; import { addKeyboardShortcutOverrides } from '../../monaco/keyboard-overrides'; import { useHandleDiffDismissed } from '../contexts/MonacoRefContext'; +import createCompletionProvider from '../../editor/magic-completion'; + +import { LoadingIndicator } from './common/LoadingIndicator'; import { Cursors } from './Cursors'; import { Tooltip } from './Tooltip'; +import { loadDTS, type Lib } from '../utils/loadDTS'; export interface MonacoHandle { showDiff: (originalCode: string, modifiedCode: string) => void; @@ -33,6 +37,7 @@ interface CollaborativeMonacoProps { ytext: Y.Text; awareness: Awareness; adaptor?: string; + metadata?: object | null; disabled?: boolean; className?: string; options?: editor.IStandaloneEditorConstructionOptions; @@ -46,6 +51,7 @@ export const CollaborativeMonaco = forwardRef< ytext, awareness, adaptor = 'common', + metadata, disabled = false, className, options = {}, @@ -57,6 +63,10 @@ export const CollaborativeMonaco = forwardRef< const bindingRef = useRef(); const [editorReady, setEditorReady] = useState(false); + // Type definitions state + const [lib, setLib] = useState(); + const [loading, setLoading] = useState(false); + // Get callback from context to notify when diff is dismissed const handleDiffDismissed = useHandleDiffDismissed(); @@ -66,6 +76,9 @@ export const CollaborativeMonaco = forwardRef< const containerRef = useRef(null); const diffContainerRef = useRef(null); + // Overflow widgets container ref + const overflowNodeRef = useRef(); + // Base editor options shared between main and diff editors const baseEditorOptions: editor.IStandaloneEditorConstructionOptions = useMemo( @@ -82,6 +95,24 @@ export const CollaborativeMonaco = forwardRef< insertSpaces: true, automaticLayout: true, fixedOverflowWidgets: true, + dragAndDrop: false, + lineNumbersMinChars: 3, + overviewRulerLanes: 0, + overviewRulerBorder: false, + codeLens: false, + wordBasedSuggestions: 'off', + fontFamily: 'Fira Code VF', + fontLigatures: true, + showFoldingControls: 'always', + suggest: { + showModules: true, + showKeywords: false, + showFiles: false, + showClasses: false, + showInterfaces: false, + showConstructors: false, + showDeprecated: false, + }, }), [] ); @@ -99,6 +130,27 @@ export const CollaborativeMonaco = forwardRef< addKeyboardShortcutOverrides(editor, monaco); + // Create overflow widgets container for suggestions/tooltips + if (!overflowNodeRef.current) { + const overflowNode = document.createElement('div'); + overflowNode.className = 'monaco-editor widgets-overflow-container'; + document.body.appendChild(overflowNode); + overflowNodeRef.current = overflowNode; + + // Update editor options with overflow container + // @ts-ignore - overflowWidgetsDomNode exists but isn't in updateOptions type + editor.updateOptions({ + overflowWidgetsDomNode: overflowNode, + fixedOverflowWidgets: true, + }); + } + + // Configure TypeScript compiler options + monaco.languages.typescript.javascriptDefaults.setCompilerOptions({ + allowNonTsExtensions: true, + noLib: true, + }); + // Don't create binding here - let the useEffect handle it // This ensures binding is created/updated whenever ytext changes }, @@ -210,6 +262,55 @@ export const CollaborativeMonaco = forwardRef< }; }, []); + // Load type definitions when adaptor changes + useEffect(() => { + if (adaptor) { + setLoading(true); + setLib([]); // instantly clear intelligence + loadDTS(adaptor) + .then(l => { + setLib(l); + }) + .finally(() => { + setLoading(false); + }); + } + }, [adaptor]); + + // Set extra libs on Monaco when lib changes + useEffect(() => { + if (monacoRef.current && lib) { + monacoRef.current.languages.typescript.javascriptDefaults.setExtraLibs( + lib + ); + } + }, [lib]); + + // Register metadata completion provider + useEffect(() => { + if (monacoRef.current && metadata) { + const provider = + monacoRef.current.languages.registerCompletionItemProvider( + 'javascript', + createCompletionProvider(monacoRef.current, metadata) + ); + return () => { + provider.dispose(); + }; + } + }, [metadata]); + + // Cleanup overflow node on unmount + useEffect(() => { + return () => { + if (overflowNodeRef.current) { + overflowNodeRef.current.parentNode?.removeChild( + overflowNodeRef.current + ); + } + }; + }, []); + // showDiff function - creates diff editor overlay const showDiff = useCallback( (originalCode: string, modifiedCode: string) => { @@ -354,10 +455,15 @@ export const CollaborativeMonaco = forwardRef< return (
+ {/* Loading indicator */} +
+ {loading && } +
{/* Standard editor container */}
+ * + */ +export function LoadingIndicator({ text = 'Loading' }: LoadingIndicatorProps) { + return ( +
+ + {text} +
+ ); +} diff --git a/assets/js/collaborative-editor/components/common/Spinner.tsx b/assets/js/collaborative-editor/components/common/Spinner.tsx new file mode 100644 index 00000000000..d968a94aeff --- /dev/null +++ b/assets/js/collaborative-editor/components/common/Spinner.tsx @@ -0,0 +1,35 @@ +import { cn } from '#/utils/cn'; + +interface SpinnerProps { + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +/** + * Spinner - Reusable loading spinner using heroicons + * + * Uses the `hero-arrow-path` icon with Tailwind's `animate-spin` class, + * following the established pattern across the collaborative-editor. + * + * @example + * + * + */ +export function Spinner({ size = 'md', className }: SpinnerProps) { + const sizeClasses = { + sm: 'h-3.5 w-3.5', + md: 'h-4 w-4', + lg: 'h-5 w-5', + }; + + return ( + + ); +} diff --git a/assets/js/collaborative-editor/components/ide/FullScreenIDE.tsx b/assets/js/collaborative-editor/components/ide/FullScreenIDE.tsx index 96f34337716..31284d241af 100644 --- a/assets/js/collaborative-editor/components/ide/FullScreenIDE.tsx +++ b/assets/js/collaborative-editor/components/ide/FullScreenIDE.tsx @@ -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'; @@ -130,6 +131,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(); @@ -1015,6 +1017,7 @@ export function FullScreenIDE({ ytext={currentJobYText} awareness={awareness} adaptor={currentJob.adaptor || 'common'} + metadata={metadata} disabled={!canSave} className="h-full w-full" options={{ @@ -1114,7 +1117,7 @@ export function FullScreenIDE({ {selectedDocsTab === 'metadata' && ( )}
diff --git a/assets/js/collaborative-editor/contexts/StoreProvider.tsx b/assets/js/collaborative-editor/contexts/StoreProvider.tsx index 9290691ca22..55978339de7 100644 --- a/assets/js/collaborative-editor/contexts/StoreProvider.tsx +++ b/assets/js/collaborative-editor/contexts/StoreProvider.tsx @@ -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, @@ -81,6 +85,7 @@ import { generateUserColor } from '../utils/userColor'; export interface StoreContextValue { adaptorStore: AdaptorStoreInstance; credentialStore: CredentialStoreInstance; + metadataStore: MetadataStoreInstance; awarenessStore: AwarenessStoreInstance; workflowStore: WorkflowStoreInstance; sessionContextStore: SessionContextStoreInstance; @@ -126,6 +131,7 @@ export const StoreProvider = ({ children }: StoreProviderProps) => { return { adaptorStore: createAdaptorStore(), credentialStore: createCredentialStore(), + metadataStore: createMetadataStore(), awarenessStore: createAwarenessStore(), workflowStore: createWorkflowStore(), sessionContextStore: createSessionContextStore(isNewWorkflow), @@ -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 diff --git a/assets/js/collaborative-editor/hooks/useMetadata.ts b/assets/js/collaborative-editor/hooks/useMetadata.ts new file mode 100644 index 00000000000..d7b6691fd54 --- /dev/null +++ b/assets/js/collaborative-editor/hooks/useMetadata.ts @@ -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, + }; +}; diff --git a/assets/js/collaborative-editor/stores/createMetadataStore.ts b/assets/js/collaborative-editor/stores/createMetadataStore.ts new file mode 100644 index 00000000000..8c44869d19c --- /dev/null +++ b/assets/js/collaborative-editor/stores/createMetadataStore.ts @@ -0,0 +1,392 @@ +/** + * # MetadataStore + * + * This store implements the same pattern as AdaptorStore: useSyncExternalStore + Immer + * for optimal performance and referential stability. + * + * ## Core Principles: + * - Immer for referentially stable state updates + * - Command Query Separation (CQS) for predictable state mutations + * - Per-job metadata caching to prevent redundant fetches + * - Optimistic updates with error recovery + * + * ## Update Patterns: + * + * ### Pattern 1: Channel Message → Immer → Notify (Server Updates) + * **When to use**: All server-initiated metadata updates + * **Flow**: Channel message → validate with Zod → Immer update → React notification + * **Benefits**: Automatic validation, error handling, type safety + * + * ```typescript + * // Example: Handle server metadata response + * const handleMetadataReceived = (rawData: unknown) => { + * const result = MetadataResponseSchema.safeParse(rawData); + * if (result.success) { + * state = produce(state, (draft) => { + * const jobState = draft.jobs.get(job_id) || createEmptyJobState(); + * jobState.metadata = result.data.metadata; + * draft.jobs.set(job_id, jobState); + * }); + * notify(); + * } + * }; + * ``` + * + * ### Pattern 2: Direct Immer → Notify (Local State) + * **When to use**: Loading states, errors, cache clearing + * **Flow**: Direct Immer update → React notification + * **Benefits**: Immediate response, simple implementation + * + * ```typescript + * // Example: Set loading state for a job + * const setLoading = (jobId: string) => { + * state = produce(state, (draft) => { + * const jobState = draft.jobs.get(jobId) || createEmptyJobState(); + * jobState.isLoading = true; + * draft.jobs.set(jobId, jobState); + * }); + * notify(); + * }; + * ``` + * + * ## Architecture Notes: + * - All validation happens at runtime with Zod schemas + * - Channel messaging is handled externally (SessionProvider) + * - Store provides both commands and queries following CQS pattern + * - withSelector utility provides memoized selectors for performance + * - Per-job caching with Map structure + * - Cache key comparison prevents redundant fetches + */ + +/** + * ## Redux DevTools Integration + * + * This store integrates with Redux DevTools for debugging in + * development and test environments. + * + * **Features:** + * - Real-time state inspection + * - Action history with timestamps + * - Time-travel debugging (jump to previous states) + * - State export/import for reproducing bugs + * + * **Usage:** + * 1. Install Redux DevTools browser extension + * 2. Open DevTools and select the "MetadataStore" instance + * 3. Perform actions in the app and watch them appear in DevTools + * + * **Note:** DevTools is automatically disabled in production builds. + * + * **Excluded from DevTools:** + * - jobs (Map is not serializable - converted to object for DevTools) + */ + +import { produce } from 'immer'; +import type { PhoenixChannelProvider } from 'y-phoenix-channel'; + +import _logger from '#/utils/logger'; + +import { channelRequest } from '../hooks/useChannel'; +import { + type JobMetadataState, + type Metadata, + type MetadataState, + type MetadataStore, + MetadataResponseSchema, +} from '../types/metadata'; + +import { createWithSelector } from './common'; +import { wrapStoreWithDevTools } from './devtools'; + +const logger = _logger.ns('MetadataStore').seal(); + +/** + * Creates an empty job metadata state + */ +const createEmptyJobState = (): JobMetadataState => ({ + metadata: null, + error: null, + isLoading: false, + lastFetched: null, + cacheKey: null, +}); + +/** + * Creates a metadata store instance with useSyncExternalStore + Immer pattern + */ +export const createMetadataStore = (): MetadataStore => { + // Single Immer-managed state object (referentially stable) + let state: MetadataState = produce( + { + jobs: new Map(), + } as MetadataState, + // No initial transformations needed + draft => draft + ); + + const listeners = new Set<() => void>(); + + // Redux DevTools integration + const devtools = wrapStoreWithDevTools({ + name: 'MetadataStore', + excludeKeys: ['jobs'], // Map is not serializable + maxAge: 100, + }); + + const notify = (actionName: string = 'stateChange') => { + // Convert Map to object for DevTools + const serializableState = { + jobs: Object.fromEntries(state.jobs), + }; + devtools.notifyWithAction(actionName, () => serializableState); + listeners.forEach(listener => { + listener(); + }); + }; + + // ============================================================================= + // CORE STORE INTERFACE + // ============================================================================= + + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }; + + const getSnapshot = (): MetadataState => state; + + // withSelector utility - creates memoized selectors for referential stability + const withSelector = createWithSelector(getSnapshot); + + // ============================================================================= + // PATTERN 1: Channel Message → Immer → Notify (Server Updates) + // ============================================================================= + + /** + * Handle metadata response received from server + * Validates data with Zod before updating state + */ + const handleMetadataReceived = (rawData: unknown) => { + const result = MetadataResponseSchema.safeParse(rawData); + + if (result.success) { + const { job_id, metadata } = result.data; + + state = produce(state, draft => { + const jobState = draft.jobs.get(job_id) || createEmptyJobState(); + + if ('error' in metadata && typeof metadata.error === 'string') { + jobState.error = metadata.error; + jobState.metadata = null; + } else { + jobState.metadata = metadata as Metadata; + jobState.error = null; + jobState.lastFetched = Date.now(); + } + + jobState.isLoading = false; + draft.jobs.set(job_id, jobState); + }); + + notify('handleMetadataReceived'); + } else { + logger.error('Failed to parse metadata response', { + error: result.error, + rawData, + }); + + // We don't know which job this was for, so we can't update the specific job state + logger.warn('Cannot update job state without job_id in response'); + } + }; + + // ============================================================================= + // PATTERN 2: Direct Immer → Notify (Local State Updates) + // ============================================================================= + + /** + * Set error for a specific job + */ + const setError = (jobId: string, error: string) => { + state = produce(state, draft => { + const jobState = draft.jobs.get(jobId) || createEmptyJobState(); + jobState.error = error; + jobState.isLoading = false; + draft.jobs.set(jobId, jobState); + }); + notify('setError'); + }; + + // ============================================================================= + // CHANNEL INTEGRATION + // ============================================================================= + + let channelProvider: PhoenixChannelProvider | null = null; + + /** + * Generate cache key for deduplication + */ + const getCacheKey = ( + adaptor: string, + credentialId: string | null + ): string => { + return `${adaptor}:${credentialId || 'none'}`; + }; + + /** + * Connect to Phoenix channel provider for real-time updates + */ + const connectChannel = (provider: PhoenixChannelProvider) => { + channelProvider = provider; + + // Note: We don't set up channel listeners here because metadata responses + // come back as direct replies to channelRequest, not as broadcast events. + // If we add real-time metadata updates in the future, we'd add a listener + // for something like 'metadata_updated' here. + + devtools.connect(); + + return () => { + devtools.disconnect(); + channelProvider = null; + }; + }; + + /** + * Request metadata for a specific job from server via channel + */ + const requestMetadata = async ( + jobId: string, + adaptor: string, + credentialId: string | null + ): Promise => { + if (!channelProvider?.channel) { + logger.warn('Cannot request metadata - no channel connected'); + setError(jobId, 'No connection available'); + return; + } + + const newCacheKey = getCacheKey(adaptor, credentialId); + const currentJobState = state.jobs.get(jobId); + + // Skip if already loading + if (currentJobState?.isLoading) { + logger.debug('Metadata already loading for job', { jobId }); + return; + } + + // Skip if cache is valid + if ( + currentJobState?.cacheKey === newCacheKey && + currentJobState?.metadata + ) { + logger.debug('Using cached metadata', { jobId, cacheKey: newCacheKey }); + return; + } + + // Set loading state and update cache key + state = produce(state, draft => { + const jobState = draft.jobs.get(jobId) || createEmptyJobState(); + jobState.isLoading = true; + jobState.error = null; + jobState.cacheKey = newCacheKey; + draft.jobs.set(jobId, jobState); + }); + notify('requestMetadata:start'); + + try { + logger.debug('Requesting metadata for job', { + jobId, + adaptor, + credentialId, + }); + const response = await channelRequest<{ + job_id: string; + metadata: unknown; + }>(channelProvider.channel, 'request_metadata', { + job_id: jobId, + }); + + // Handle the response directly + handleMetadataReceived(response); + } catch (error) { + logger.error('Metadata request failed', error); + setError(jobId, error instanceof Error ? error.message : 'Unknown error'); + } + }; + + // ============================================================================= + // COMMANDS + // ============================================================================= + + /** + * Clear metadata for a specific job + */ + const clearMetadata = (jobId: string) => { + state = produce(state, draft => { + draft.jobs.delete(jobId); + }); + notify('clearMetadata'); + }; + + /** + * Clear all metadata + */ + const clearAllMetadata = () => { + state = produce(state, draft => { + draft.jobs.clear(); + }); + notify('clearAllMetadata'); + }; + + // ============================================================================= + // QUERIES + // ============================================================================= + + /** + * Get metadata for a specific job + */ + const getMetadataForJob = (jobId: string): Metadata | null => { + return state.jobs.get(jobId)?.metadata || null; + }; + + /** + * Check if metadata is loading for a specific job + */ + const isLoadingForJob = (jobId: string): boolean => { + return state.jobs.get(jobId)?.isLoading || false; + }; + + /** + * Get error for a specific job + */ + const getErrorForJob = (jobId: string): string | null => { + return state.jobs.get(jobId)?.error || null; + }; + + // ============================================================================= + // PUBLIC API + // ============================================================================= + + return { + // Core interface + subscribe, + getSnapshot, + withSelector, + + // Commands + requestMetadata, + clearMetadata, + clearAllMetadata, + + // Queries + getMetadataForJob, + isLoadingForJob, + getErrorForJob, + + // Internals + _connectChannel: connectChannel, + }; +}; + +export type MetadataStoreInstance = ReturnType; diff --git a/assets/js/collaborative-editor/types/metadata.ts b/assets/js/collaborative-editor/types/metadata.ts new file mode 100644 index 00000000000..d803778d8a2 --- /dev/null +++ b/assets/js/collaborative-editor/types/metadata.ts @@ -0,0 +1,59 @@ +import type { PhoenixChannelProvider } from 'y-phoenix-channel'; +import { z } from 'zod'; + +// Zod schema for metadata validation +export const MetadataSchema = z.record(z.string(), z.unknown()); + +export const MetadataResponseSchema = z.object({ + job_id: z.string(), + metadata: z.union([MetadataSchema, z.object({ error: z.string() })]), +}); + +export type Metadata = z.infer; +export type MetadataResponse = z.infer; + +// Per-job metadata state +export interface JobMetadataState { + metadata: Metadata | null; + error: string | null; + isLoading: boolean; + lastFetched: number | null; + // Cache key to detect when to refetch + cacheKey: string | null; // format: "adaptor:credentialId" +} + +// Store state +export interface MetadataState { + // Map of jobId → metadata state + jobs: Map; +} + +// Commands +export interface MetadataCommands { + requestMetadata: ( + jobId: string, + adaptor: string, + credentialId: string | null + ) => Promise; + clearMetadata: (jobId: string) => void; + clearAllMetadata: () => void; +} + +// Queries +export interface MetadataQueries { + getSnapshot: () => MetadataState; + subscribe: (listener: () => void) => () => void; + withSelector: (selector: (state: MetadataState) => T) => () => T; + getMetadataForJob: (jobId: string) => Metadata | null; + isLoadingForJob: (jobId: string) => boolean; + getErrorForJob: (jobId: string) => string | null; +} + +// Internals +export interface MetadataInternals { + _connectChannel: (provider: PhoenixChannelProvider) => () => void; +} + +export type MetadataStore = MetadataCommands & + MetadataQueries & + MetadataInternals; diff --git a/assets/js/collaborative-editor/utils/loadDTS.ts b/assets/js/collaborative-editor/utils/loadDTS.ts new file mode 100644 index 00000000000..5feadaf97a8 --- /dev/null +++ b/assets/js/collaborative-editor/utils/loadDTS.ts @@ -0,0 +1,123 @@ +import { fetchDTSListing, fetchFile } from '@openfn/describe-package'; + +import dts_es5 from '../../editor/lib/es5.min.dts'; + +export type Lib = { + content: string; + filePath?: string; +}; + +/** + * Load TypeScript definition files for an adaptor from jsDelivr + * + * Fetches .d.ts files for the specified adaptor and @openfn/language-common, + * then wraps them in appropriate module declarations for Monaco editor. + * + * @param specifier - Fully qualified adaptor name (e.g., "@openfn/language-http@5.0.0") + * @returns Array of lib objects containing TypeScript definitions + * + * @example + * const libs = await loadDTS('@openfn/language-http@5.0.0'); + * monaco.languages.typescript.javascriptDefaults.setExtraLibs(libs); + */ +export async function loadDTS(specifier: string): Promise { + // Work out the module name from the specifier + // (this gets a bit tricky with @openfn/ module names) + const nameParts = specifier.split('@'); + nameParts.pop(); // remove the version + const name = nameParts.join('@'); + + const results: Lib[] = [{ content: dts_es5 }]; + + // Load common into its own module + // TODO maybe we need other dependencies too? collections? + if (name !== '@openfn/language-common') { + const pkg = await fetchFile(`${specifier}/package.json`); + const commonVersion = (JSON.parse(pkg || '{}') as any).dependencies?.[ + '@openfn/language-common' + ]; + + // jsDeliver doesn't appear to support semver range syntax (^1.0.0, 1.x, ~1.1.0) + const commonVersionMatch = commonVersion?.match(/^\d+\.\d+\.\d+/); + if (!commonVersionMatch) { + console.warn( + `@openfn/language-common@${commonVersion} contains semver range syntax.` + ); + } + + const commonSpecifier = `@openfn/language-common@${commonVersion.replace( + '^', + '' + )}`; + for await (const filePath of fetchDTSListing(commonSpecifier)) { + if (!filePath.startsWith('node_modules')) { + // Load every common typedef into the common module + let content = await fetchFile(`${commonSpecifier}${filePath}`); + content = content.replace(/\* +@(.+?)\*\//gs, '*/'); + results.push({ + content: `declare module '@openfn/language-common' { ${content} }`, + }); + } + } + } + + // This will store types.d.ts, if we can find it + let types = ''; + + // This stores string content for our adaptor + let adaptorDefs: string[] = []; + + for await (const filePath of fetchDTSListing(specifier)) { + if (!filePath.startsWith('node_modules')) { + let content = await fetchFile(`${specifier}${filePath}`); + // Convert relative paths + content = content + .replace(/from '\.\//g, `from '${name}/`) + .replace(/import '\.\//g, `import '${name}/`); + + // Remove js doc annotations + // this regex means: find a * then an @ (with 1+ space in between), then match everything up to a closing comment */ + // content = content.replace(/\* +@(.+?)\*\//gs, '*/'); + + const fileName = filePath.split('/').at(-1)!.replace('.d.ts', ''); + + // Import the index as the global namespace - but take care to convert all paths to absolute + if (fileName === 'index' || fileName === 'Adaptor') { + // It turns out that "export * as " seems to straight up not work in Monaco + // So this little hack will refactor import statements in a way that works + content = content.replace( + /export \* as (\w+) from '(.+)';/g, + ` + + import * as $1 from '$2'; + export { $1 };` + ); + adaptorDefs.push(`declare namespace { + {{$TYPES}} + ${content} +`); + } else if (fileName === 'types') { + types = content; + } else { + // Declare every other module as file + adaptorDefs.push(`declare module '${name}/${fileName}' { + {{$TYPES}} + ${content} +}`); + } + } + } + + // This just ensures that the global type defs appear in every scope + // This is basically a hack to work around https://github.com/OpenFn/lightning/issues/2641 + // If we find a types.d.ts, append it to every other file + adaptorDefs = adaptorDefs.map(def => def.replace('{{$TYPES}}', types)); + + results.push( + ...adaptorDefs.map(content => ({ + content, + })) + ); + + return results; +} diff --git a/assets/js/editor/Editor.tsx b/assets/js/editor/Editor.tsx index aa69a02923c..f2a0f9630a0 100644 --- a/assets/js/editor/Editor.tsx +++ b/assets/js/editor/Editor.tsx @@ -226,6 +226,7 @@ export default function Editor({ const handleEditorDidMount = useCallback( (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { + window.monaco = monaco; setMonaco(monaco); editor.addCommand( @@ -323,6 +324,8 @@ export default function Editor({ // This needs to be at the top level so that tooltips clip over Lightning UIs const overflowNode = document.createElement('div'); overflowNode.className = 'monaco-editor widgets-overflow-container'; + // Total hackage - acceptable given that the editor will be retired soon + overflowNode.style.zIndex = '9999'; document.body.appendChild(overflowNode); setOptions({ diff --git a/assets/test/__mocks__/monaco-editor.ts b/assets/test/__mocks__/monaco-editor.ts index 63f46b92bf6..2eb7f84dbfd 100644 --- a/assets/test/__mocks__/monaco-editor.ts +++ b/assets/test/__mocks__/monaco-editor.ts @@ -21,6 +21,26 @@ export const editor = { setModelLanguage: () => {}, }; +export const languages = { + typescript: { + javascriptDefaults: { + setCompilerOptions: () => {}, + setDiagnosticsOptions: () => {}, + setEagerModelSync: () => {}, + addExtraLib: () => ({ dispose: () => {} }), + setExtraLibs: () => {}, + }, + typescriptDefaults: { + setCompilerOptions: () => {}, + setDiagnosticsOptions: () => {}, + setEagerModelSync: () => {}, + addExtraLib: () => ({ dispose: () => {} }), + setExtraLibs: () => {}, + }, + }, + registerCompletionItemProvider: () => ({ dispose: () => {} }), +}; + export const KeyMod = { CtrlCmd: 1, Shift: 2, @@ -38,6 +58,7 @@ export const KeyCode = { // Export as default and named exports to match monaco-editor package export default { editor, + languages, KeyMod, KeyCode, }; diff --git a/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.ts b/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.ts index 2737ceabc08..5a4c3f401f3 100644 --- a/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.ts +++ b/assets/test/collaborative-editor/__helpers__/storeProviderHelpers.ts @@ -20,6 +20,7 @@ import { createAwarenessStore } from '../../../js/collaborative-editor/stores/cr import { createCredentialStore } from '../../../js/collaborative-editor/stores/createCredentialStore'; import { createEditorPreferencesStore } from '../../../js/collaborative-editor/stores/createEditorPreferencesStore'; import { createHistoryStore } from '../../../js/collaborative-editor/stores/createHistoryStore'; +import { createMetadataStore } from '../../../js/collaborative-editor/stores/createMetadataStore'; import { createSessionContextStore } from '../../../js/collaborative-editor/stores/createSessionContextStore'; import type { SessionStoreInstance } from '../../../js/collaborative-editor/stores/createSessionStore'; import { createSessionStore } from '../../../js/collaborative-editor/stores/createSessionStore'; @@ -94,6 +95,7 @@ export function createStores(): StoreContextValue { return { adaptorStore: createAdaptorStore(), credentialStore: createCredentialStore(), + metadataStore: createMetadataStore(), awarenessStore: createAwarenessStore(), workflowStore: createWorkflowStore(), sessionContextStore: createSessionContextStore(), @@ -132,16 +134,18 @@ export function simulateChannelConnection( if (session.provider && session.isConnected) { const cleanup1 = stores.adaptorStore._connectChannel(session.provider); const cleanup2 = stores.credentialStore._connectChannel(session.provider); - const cleanup3 = stores.sessionContextStore._connectChannel( + const cleanup3 = stores.metadataStore._connectChannel(session.provider); + const cleanup4 = stores.sessionContextStore._connectChannel( session.provider ); - const cleanup4 = stores.historyStore._connectChannel(session.provider); + const cleanup5 = stores.historyStore._connectChannel(session.provider); return () => { cleanup1(); cleanup2(); cleanup3(); cleanup4(); + cleanup5(); }; } @@ -308,6 +312,7 @@ export async function simulateStoreProviderWithConnection( export function verifyAllStoresPresent(stores: StoreContextValue): void { expect(stores.adaptorStore).toBeDefined(); expect(stores.credentialStore).toBeDefined(); + expect(stores.metadataStore).toBeDefined(); expect(stores.awarenessStore).toBeDefined(); expect(stores.workflowStore).toBeDefined(); expect(stores.sessionContextStore).toBeDefined(); @@ -320,6 +325,7 @@ export function verifyAllStoresPresent(stores: StoreContextValue): void { [ stores.adaptorStore, stores.credentialStore, + stores.metadataStore, stores.sessionContextStore, stores.historyStore, stores.uiStore, diff --git a/assets/test/collaborative-editor/components/CollaborativeMonaco.diff.test.tsx b/assets/test/collaborative-editor/components/CollaborativeMonaco.diff.test.tsx index a5d7e27eab2..21cea4c5ee1 100644 --- a/assets/test/collaborative-editor/components/CollaborativeMonaco.diff.test.tsx +++ b/assets/test/collaborative-editor/components/CollaborativeMonaco.diff.test.tsx @@ -22,6 +22,25 @@ vi.mock('monaco-editor', () => ({ dispose: vi.fn(), })), }, + languages: { + typescript: { + javascriptDefaults: { + setCompilerOptions: vi.fn(), + setDiagnosticsOptions: vi.fn(), + setEagerModelSync: vi.fn(), + addExtraLib: vi.fn(() => ({ dispose: vi.fn() })), + setExtraLibs: vi.fn(), + }, + typescriptDefaults: { + setCompilerOptions: vi.fn(), + setDiagnosticsOptions: vi.fn(), + setEagerModelSync: vi.fn(), + addExtraLib: vi.fn(() => ({ dispose: vi.fn() })), + setExtraLibs: vi.fn(), + }, + }, + registerCompletionItemProvider: vi.fn(() => ({ dispose: vi.fn() })), + }, })); import { render, screen, waitFor } from '@testing-library/react'; @@ -78,6 +97,25 @@ vi.mock('../../../js/monaco', () => ({ })), createModel: vi.fn(code => ({ code, dispose: vi.fn() })), }, + languages: { + typescript: { + javascriptDefaults: { + setCompilerOptions: vi.fn(), + setDiagnosticsOptions: vi.fn(), + setEagerModelSync: vi.fn(), + addExtraLib: vi.fn(() => ({ dispose: vi.fn() })), + setExtraLibs: vi.fn(), + }, + typescriptDefaults: { + setCompilerOptions: vi.fn(), + setDiagnosticsOptions: vi.fn(), + setEagerModelSync: vi.fn(), + addExtraLib: vi.fn(() => ({ dispose: vi.fn() })), + setExtraLibs: vi.fn(), + }, + }, + registerCompletionItemProvider: vi.fn(() => ({ dispose: vi.fn() })), + }, KeyMod: { CtrlCmd: 1, Shift: 2, diff --git a/assets/test/collaborative-editor/createMetadataStore.test.ts b/assets/test/collaborative-editor/createMetadataStore.test.ts new file mode 100644 index 00000000000..504c02f5ad0 --- /dev/null +++ b/assets/test/collaborative-editor/createMetadataStore.test.ts @@ -0,0 +1,461 @@ +/** + * Minimal test suite for createMetadataStore + * + * Covers the essential functionality: + * - Basic store interface (subscribe/getSnapshot) + * - Metadata fetching and caching + * - Loading and error states + * - Cache key deduplication + */ + +import { describe, test, expect } from 'vitest'; + +import { createMetadataStore } from '../../js/collaborative-editor/stores/createMetadataStore'; +import type { MetadataStoreInstance } from '../../js/collaborative-editor/stores/createMetadataStore'; + +import { + createMockChannelPushOk, + createMockChannelPushError, +} from './__helpers__/channelMocks'; +import { + createMockPhoenixChannel, + createMockPhoenixChannelProvider, +} from './mocks/phoenixChannel'; +import type { + MockPhoenixChannel, + MockPhoenixChannelProvider, +} from './mocks/phoenixChannel'; + +// Test fixtures +interface MetadataTestFixtures { + store: MetadataStoreInstance; + mockChannel: MockPhoenixChannel; + mockProvider: MockPhoenixChannelProvider; + connectedStore: { + store: MetadataStoreInstance; + provider: MockPhoenixChannelProvider; + cleanup: () => void; + }; +} + +const metadataTest = test.extend({ + store: async ({}, use) => { + const store = createMetadataStore(); + await use(store); + }, + + mockChannel: async ({}, use) => { + const channel = createMockPhoenixChannel(); + await use(channel); + }, + + mockProvider: async ({ mockChannel }, use) => { + const provider = createMockPhoenixChannelProvider(mockChannel); + await use(provider); + }, + + connectedStore: async ({ store, mockProvider }, use) => { + const cleanup = store._connectChannel(mockProvider as any); + await use({ store, provider: mockProvider, cleanup }); + cleanup(); + }, +}); + +// Mock metadata responses +const mockMetadata = { + dataElements: [ + { id: 'de1', name: 'Data Element 1' }, + { id: 'de2', name: 'Data Element 2' }, + ], +}; + +const mockMetadataResponse = { + job_id: 'job-123', + metadata: mockMetadata, +}; + +const mockErrorResponse = { + job_id: 'job-456', + metadata: { error: 'invalid_credentials' }, +}; + +describe('createMetadataStore', () => { + describe('initialization', () => { + test('getSnapshot returns initial state', () => { + const store = createMetadataStore(); + const initialState = store.getSnapshot(); + + expect(initialState.jobs).toBeInstanceOf(Map); + expect(initialState.jobs.size).toBe(0); + }); + }); + + describe('subscriptions', () => { + test('subscribe/unsubscribe works correctly', () => { + const store = createMetadataStore(); + let callCount = 0; + + const listener = () => { + callCount++; + }; + + const unsubscribe = store.subscribe(listener); + + // Trigger a state change + store.clearMetadata('job-123'); + + expect(callCount).toBe(1); + + // Unsubscribe and trigger change + unsubscribe(); + store.clearAllMetadata(); + + // Listener should not be called after unsubscribe + expect(callCount).toBe(1); + }); + + test('withSelector creates memoized selector', () => { + const store = createMetadataStore(); + + const selectJobMetadata = store.withSelector(state => + state.jobs.get('job-123') + ); + + // Initial call + const metadata1 = selectJobMetadata(); + expect(metadata1).toBeUndefined(); + + // Unrelated state change - should return same reference + store.clearMetadata('job-456'); + const metadata2 = selectJobMetadata(); + + expect(metadata1).toBe(metadata2); + }); + }); + + describe('metadata fetching', () => { + metadataTest( + 'successfully fetches and caches metadata', + async ({ store, mockChannel, mockProvider }) => { + // Setup mock to return metadata + mockChannel.push = createMockChannelPushOk(mockMetadataResponse); + store._connectChannel(mockProvider as any); + + // Request metadata + await store.requestMetadata( + 'job-123', + '@openfn/language-dhis2', + 'cred-1' + ); + + // Check state after fetch + const state = store.getSnapshot(); + const jobState = state.jobs.get('job-123'); + + expect(jobState).toBeDefined(); + expect(jobState?.metadata).toEqual(mockMetadata); + expect(jobState?.isLoading).toBe(false); + expect(jobState?.error).toBeNull(); + expect(jobState?.lastFetched).toBeGreaterThan(0); + expect(jobState?.cacheKey).toBe('@openfn/language-dhis2:cred-1'); + } + ); + + metadataTest( + 'handles error responses correctly', + async ({ store, mockChannel, mockProvider }) => { + mockChannel.push = createMockChannelPushOk(mockErrorResponse); + store._connectChannel(mockProvider as any); + + await store.requestMetadata( + 'job-456', + '@openfn/language-dhis2', + 'cred-2' + ); + + const state = store.getSnapshot(); + const jobState = state.jobs.get('job-456'); + + expect(jobState?.metadata).toBeNull(); + expect(jobState?.error).toBe('invalid_credentials'); + expect(jobState?.isLoading).toBe(false); + } + ); + + metadataTest( + 'handles channel errors', + async ({ store, mockChannel, mockProvider }) => { + mockChannel.push = createMockChannelPushError( + 'Connection failed', + 'timeout' + ); + store._connectChannel(mockProvider as any); + + await store.requestMetadata( + 'job-789', + '@openfn/language-dhis2', + 'cred-3' + ); + + const state = store.getSnapshot(); + const jobState = state.jobs.get('job-789'); + + expect(jobState?.metadata).toBeNull(); + expect(jobState?.error).toContain('Channel request failed'); + expect(jobState?.isLoading).toBe(false); + } + ); + + test('handles missing channel connection', async () => { + const store = createMetadataStore(); + + // Request without connecting channel + await store.requestMetadata( + 'job-999', + '@openfn/language-dhis2', + 'cred-4' + ); + + const state = store.getSnapshot(); + const jobState = state.jobs.get('job-999'); + + expect(jobState?.error).toBe('No connection available'); + expect(jobState?.isLoading).toBe(false); + }); + }); + + describe('cache behavior', () => { + metadataTest( + 'uses cached metadata when cache key matches', + async ({ store, mockChannel, mockProvider }) => { + let pushCount = 0; + + // Wrap the push to count calls + const originalPush = createMockChannelPushOk(mockMetadataResponse); + mockChannel.push = (...args: any[]) => { + pushCount++; + return originalPush.apply(mockChannel, args); + }; + + store._connectChannel(mockProvider as any); + + // First request + await store.requestMetadata( + 'job-123', + '@openfn/language-dhis2', + 'cred-1' + ); + expect(pushCount).toBe(1); + + // Second request with same cache key - should use cache + await store.requestMetadata( + 'job-123', + '@openfn/language-dhis2', + 'cred-1' + ); + expect(pushCount).toBe(1); // No additional push + + const state = store.getSnapshot(); + const jobState = state.jobs.get('job-123'); + expect(jobState?.metadata).toEqual(mockMetadata); + } + ); + + metadataTest( + 'refetches when cache key changes', + async ({ store, mockChannel, mockProvider }) => { + let pushCount = 0; + + // Wrap the push to count calls + const originalPush = createMockChannelPushOk(mockMetadataResponse); + mockChannel.push = (...args: any[]) => { + pushCount++; + return originalPush.apply(mockChannel, args); + }; + + store._connectChannel(mockProvider as any); + + // First request + await store.requestMetadata( + 'job-123', + '@openfn/language-dhis2', + 'cred-1' + ); + expect(pushCount).toBe(1); + + // Second request with different credential - should refetch + await store.requestMetadata( + 'job-123', + '@openfn/language-dhis2', + 'cred-2' + ); + expect(pushCount).toBe(2); + + // Third request with different adaptor - should refetch + await store.requestMetadata( + 'job-123', + '@openfn/language-salesforce', + 'cred-2' + ); + expect(pushCount).toBe(3); + } + ); + + metadataTest( + 'prevents duplicate concurrent requests', + async ({ store, mockChannel, mockProvider }) => { + let pushCount = 0; + + mockChannel.push = createMockChannelPushOk(mockMetadataResponse); + const originalPush = mockChannel.push; + + mockChannel.push = (...args: any[]) => { + pushCount++; + return originalPush.apply(mockChannel, args); + }; + + store._connectChannel(mockProvider as any); + + // Fire multiple requests simultaneously + await Promise.all([ + store.requestMetadata('job-123', '@openfn/language-dhis2', 'cred-1'), + store.requestMetadata('job-123', '@openfn/language-dhis2', 'cred-1'), + store.requestMetadata('job-123', '@openfn/language-dhis2', 'cred-1'), + ]); + + // Should only make one request + expect(pushCount).toBeLessThanOrEqual(2); // 1 or 2 due to timing + } + ); + }); + + describe('commands', () => { + metadataTest( + 'clearMetadata removes job metadata', + async ({ store, mockChannel, mockProvider }) => { + // Setup and fetch some metadata first + mockChannel.push = createMockChannelPushOk(mockMetadataResponse); + store._connectChannel(mockProvider as any); + + await store.requestMetadata( + 'job-123', + '@openfn/language-dhis2', + 'cred-1' + ); + + // Verify metadata exists + let state = store.getSnapshot(); + expect(state.jobs.has('job-123')).toBe(true); + + // Clear it + store.clearMetadata('job-123'); + + // Verify it's gone + state = store.getSnapshot(); + expect(state.jobs.has('job-123')).toBe(false); + } + ); + + metadataTest( + 'clearAllMetadata removes all metadata', + async ({ store, mockChannel, mockProvider }) => { + // Setup and fetch metadata for multiple jobs + mockChannel.push = createMockChannelPushOk(mockMetadataResponse); + store._connectChannel(mockProvider as any); + + await store.requestMetadata( + 'job-123', + '@openfn/language-dhis2', + 'cred-1' + ); + + mockChannel.push = createMockChannelPushOk({ + job_id: 'job-456', + metadata: mockMetadata, + }); + await store.requestMetadata( + 'job-456', + '@openfn/language-dhis2', + 'cred-2' + ); + + // Verify metadata exists for both + let state = store.getSnapshot(); + expect(state.jobs.size).toBeGreaterThan(0); + + // Clear all + store.clearAllMetadata(); + + // Verify all gone + state = store.getSnapshot(); + expect(state.jobs.size).toBe(0); + } + ); + }); + + describe('queries', () => { + metadataTest( + 'getMetadataForJob returns correct metadata', + async ({ store, mockChannel, mockProvider }) => { + // Fetch metadata first + mockChannel.push = createMockChannelPushOk(mockMetadataResponse); + store._connectChannel(mockProvider as any); + + await store.requestMetadata( + 'job-123', + '@openfn/language-dhis2', + 'cred-1' + ); + + const metadata = store.getMetadataForJob('job-123'); + expect(metadata).toEqual(mockMetadata); + + const notFound = store.getMetadataForJob('job-999'); + expect(notFound).toBeNull(); + } + ); + + metadataTest( + 'isLoadingForJob returns correct loading state', + async ({ store, mockChannel, mockProvider }) => { + // Setup response before request + mockChannel.push = createMockChannelPushOk(mockMetadataResponse); + store._connectChannel(mockProvider as any); + + // Initially not loading + expect(store.isLoadingForJob('job-123')).toBe(false); + + // Request metadata + await store.requestMetadata( + 'job-123', + '@openfn/language-dhis2', + 'cred-1' + ); + + // After completion, should not be loading + expect(store.isLoadingForJob('job-123')).toBe(false); + + // Non-existent job should return false + expect(store.isLoadingForJob('job-999')).toBe(false); + } + ); + + metadataTest( + 'getErrorForJob returns correct error', + async ({ store, mockChannel, mockProvider }) => { + // Fetch with error response + mockChannel.push = createMockChannelPushOk(mockErrorResponse); + store._connectChannel(mockProvider as any); + + await store.requestMetadata( + 'job-456', + '@openfn/language-dhis2', + 'cred-2' + ); + + expect(store.getErrorForJob('job-456')).toBe('invalid_credentials'); + expect(store.getErrorForJob('job-999')).toBeNull(); + } + ); + }); +}); diff --git a/assets/test/collaborative-editor/useMetadata.test.tsx b/assets/test/collaborative-editor/useMetadata.test.tsx new file mode 100644 index 00000000000..c3d6d5f85f8 --- /dev/null +++ b/assets/test/collaborative-editor/useMetadata.test.tsx @@ -0,0 +1,265 @@ +/** + * Minimal test suite for useMetadata hook + * + * Tests the React hook integration with the metadata store + */ + +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; + +import { useMetadata } from '../../js/collaborative-editor/hooks/useMetadata'; +import { createMetadataStore } from '../../js/collaborative-editor/stores/createMetadataStore'; +import { StoreContext } from '../../js/collaborative-editor/contexts/StoreProvider'; +import { + createMockPhoenixChannel, + createMockPhoenixChannelProvider, +} from './mocks/phoenixChannel'; +import { createMockChannelPushOk } from './__helpers__/channelMocks'; + +// Mock useCurrentJob hook +vi.mock('../../js/collaborative-editor/hooks/useWorkflow', () => ({ + useCurrentJob: vi.fn(), +})); + +import { useCurrentJob } from '../../js/collaborative-editor/hooks/useWorkflow'; + +const mockMetadata = { + dataElements: [{ id: 'de1', name: 'Element 1' }], +}; + +describe('useMetadata', () => { + let metadataStore: ReturnType; + let mockChannel: ReturnType; + + beforeEach(() => { + metadataStore = createMetadataStore(); + mockChannel = createMockPhoenixChannel(); + const mockProvider = createMockPhoenixChannelProvider(mockChannel); + + // Connect store to channel + metadataStore._connectChannel(mockProvider as any); + + // Reset mock + vi.clearAllMocks(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + test('returns null metadata when no job is selected', () => { + vi.mocked(useCurrentJob).mockReturnValue({ + job: null, + ytext: null, + }); + + const { result } = renderHook(() => useMetadata(), { wrapper }); + + expect(result.current.metadata).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.refetch).toBeUndefined(); + }); + + test('auto-fetches metadata when job is selected', async () => { + const mockJob = { + id: 'job-123', + adaptor: '@openfn/language-dhis2@6.0.0', + project_credential_id: 'cred-1', + keychain_credential_id: null, + }; + + vi.mocked(useCurrentJob).mockReturnValue({ + job: mockJob as any, + ytext: {} as any, + }); + + // Setup mock response + mockChannel.push = createMockChannelPushOk({ + job_id: 'job-123', + metadata: mockMetadata, + }); + + const { result } = renderHook(() => useMetadata(), { wrapper }); + + // Initially loading or empty + expect(result.current.metadata).toBeNull(); + + // Wait for metadata to be fetched + await waitFor( + () => { + expect(result.current.metadata).toEqual(mockMetadata); + }, + { timeout: 1000 } + ); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + test('provides refetch function when job is selected', () => { + const mockJob = { + id: 'job-123', + adaptor: '@openfn/language-dhis2@6.0.0', + project_credential_id: 'cred-1', + keychain_credential_id: null, + }; + + vi.mocked(useCurrentJob).mockReturnValue({ + job: mockJob as any, + ytext: {} as any, + }); + + const { result } = renderHook(() => useMetadata(), { wrapper }); + + expect(result.current.refetch).toBeDefined(); + expect(typeof result.current.refetch).toBe('function'); + }); + + test('handles metadata errors', async () => { + const mockJob = { + id: 'job-456', + adaptor: '@openfn/language-dhis2@6.0.0', + project_credential_id: 'cred-2', + keychain_credential_id: null, + }; + + vi.mocked(useCurrentJob).mockReturnValue({ + job: mockJob as any, + ytext: {} as any, + }); + + // Setup error response + mockChannel.push = createMockChannelPushOk({ + job_id: 'job-456', + metadata: { error: 'invalid_credentials' }, + }); + + const { result } = renderHook(() => useMetadata(), { wrapper }); + + // Wait for error to be set + await waitFor( + () => { + expect(result.current.error).toBe('invalid_credentials'); + }, + { timeout: 1000 } + ); + + expect(result.current.metadata).toBeNull(); + expect(result.current.isLoading).toBe(false); + }); + + test('refetches when job adaptor changes', async () => { + const mockJob1 = { + id: 'job-123', + adaptor: '@openfn/language-dhis2@6.0.0', + project_credential_id: 'cred-1', + keychain_credential_id: null, + }; + + vi.mocked(useCurrentJob).mockReturnValue({ + job: mockJob1 as any, + ytext: {} as any, + }); + + mockChannel.push = createMockChannelPushOk({ + job_id: 'job-123', + metadata: mockMetadata, + }); + + const { result, rerender } = renderHook(() => useMetadata(), { wrapper }); + + // Wait for initial fetch + await waitFor(() => { + expect(result.current.metadata).toEqual(mockMetadata); + }); + + // Change adaptor + const mockJob2 = { + ...mockJob1, + adaptor: '@openfn/language-salesforce@6.0.0', + }; + + vi.mocked(useCurrentJob).mockReturnValue({ + job: mockJob2 as any, + ytext: {} as any, + }); + + const newMetadata = { objects: [{ name: 'Account' }] }; + mockChannel.push = createMockChannelPushOk({ + job_id: 'job-123', + metadata: newMetadata, + }); + + rerender(); + + // Should refetch with new adaptor + await waitFor(() => { + expect(result.current.metadata).toEqual(newMetadata); + }); + }); + + test('refetches when credential changes', async () => { + const mockJob1 = { + id: 'job-123', + adaptor: '@openfn/language-dhis2@6.0.0', + project_credential_id: 'cred-1', + keychain_credential_id: null, + }; + + vi.mocked(useCurrentJob).mockReturnValue({ + job: mockJob1 as any, + ytext: {} as any, + }); + + mockChannel.push = createMockChannelPushOk({ + job_id: 'job-123', + metadata: mockMetadata, + }); + + const { result, rerender } = renderHook(() => useMetadata(), { wrapper }); + + await waitFor(() => { + expect(result.current.metadata).toEqual(mockMetadata); + }); + + // Change credential + const mockJob2 = { + ...mockJob1, + project_credential_id: 'cred-2', + }; + + vi.mocked(useCurrentJob).mockReturnValue({ + job: mockJob2 as any, + ytext: {} as any, + }); + + const newMetadata = { dataElements: [{ id: 'de2', name: 'Element 2' }] }; + mockChannel.push = createMockChannelPushOk({ + job_id: 'job-123', + metadata: newMetadata, + }); + + rerender(); + + await waitFor(() => { + expect(result.current.metadata).toEqual(newMetadata); + }); + }); +}); diff --git a/lib/lightning_web/channels/workflow_channel.ex b/lib/lightning_web/channels/workflow_channel.ex index 32075f7c9cb..7d6ab4ad3c5 100644 --- a/lib/lightning_web/channels/workflow_channel.ex +++ b/lib/lightning_web/channels/workflow_channel.ex @@ -149,6 +149,32 @@ defmodule LightningWeb.WorkflowChannel do end) end + @impl true + def handle_in("request_metadata", %{"job_id" => job_id}, socket) do + async_task(socket, "request_metadata", fn -> + case Lightning.Jobs.get_job_with_credential(job_id) do + nil -> + %{ + job_id: job_id, + metadata: %{error: "job_not_found"} + } + + job -> + metadata = + Lightning.MetadataService.fetch(job.adaptor, job.credential) + |> case do + {:error, %{type: error_type}} -> + %{error: error_type} + + {:ok, metadata} -> + metadata + end + + %{job_id: job_id, metadata: metadata} + end + end) + end + @impl true def handle_in("request_current_user", _payload, socket) do user = socket.assigns[:current_user] @@ -813,6 +839,7 @@ defmodule LightningWeb.WorkflowChannel do "request_adaptors", "request_project_adaptors", "request_credentials", + "request_metadata", "request_current_user", "get_context", "request_history",