diff --git a/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java b/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java index e2a0bca7..e02d5dfe 100644 --- a/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java +++ b/core/src/main/java/dev/vml/es/acm/core/gui/SpaSettings.java @@ -7,6 +7,7 @@ import org.osgi.service.metatype.annotations.AttributeDefinition; import org.osgi.service.metatype.annotations.Designate; import org.osgi.service.metatype.annotations.ObjectClassDefinition; +import org.osgi.service.metatype.annotations.Option; @Component(service = SpaSettings.class, immediate = true) @Designate(ocd = SpaSettings.Config.class) @@ -18,6 +19,8 @@ public class SpaSettings implements Serializable { private int executionCodeOutputChunkSize; + private String executionReviewOutputsPolicy; + private long scriptStatsLimit; @Activate @@ -26,6 +29,7 @@ protected void activate(Config config) { this.appStateInterval = config.appStateInterval(); this.executionPollInterval = config.executionPollInterval(); this.executionCodeOutputChunkSize = config.executionCodeOutputChunkSize(); + this.executionReviewOutputsPolicy = config.executionReviewOutputsPolicy(); this.scriptStatsLimit = config.scriptStatsLimit(); } @@ -45,6 +49,10 @@ public long getScriptStatsLimit() { return scriptStatsLimit; } + public String getExecutionReviewOutputsPolicy() { + return executionReviewOutputsPolicy; + } + @ObjectClassDefinition(name = "AEM Content Manager - SPA Settings") public @interface Config { @@ -61,6 +69,15 @@ public long getScriptStatsLimit() { @AttributeDefinition(name = "Execution Code Output Chunk Size", description = "In bytes. Default is 2 MB.") int executionCodeOutputChunkSize() default 2 * 1024 * 1024; + @AttributeDefinition( + name = "Execution Review Outputs Policy", + description = + "Controls if the review outputs dialog opens automatically after a script execution succeeds with generated outputs. " + + "Manual: user opens it explicitly via the 'Review' button. Auto: it opens by itself once outputs are ready. " + + "Applies to script executions only; console executions always stay manual.", + options = {@Option(label = "Manual", value = "manual"), @Option(label = "Auto", value = "auto")}) + String executionReviewOutputsPolicy() default "auto"; + @AttributeDefinition( name = "Script Stats Limit", description = diff --git a/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx b/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx index 9b35ad17..760453dd 100644 --- a/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx +++ b/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx @@ -8,7 +8,7 @@ import Help from '@spectrum-icons/workflow/Help'; import Info from '@spectrum-icons/workflow/Info'; import Preview from '@spectrum-icons/workflow/Preview'; import Print from '@spectrum-icons/workflow/Print'; -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Execution } from '../types/execution.ts'; import { FileOutput, Output, OutputNames, TextOutput } from '../types/output.ts'; import { ToastTimeoutQuick } from '../utils/spectrum.ts'; @@ -18,16 +18,26 @@ import Markdown from './Markdown.tsx'; interface ExecutionReviewOutputsButtonProps extends Omit, 'onPress'> { execution: Execution; + autoOpen?: boolean; } -const ExecutionReviewOutputsButton: React.FC = ({ execution, ...buttonProps }) => { +const ExecutionReviewOutputsButton: React.FC = ({ execution, autoOpen = false, ...buttonProps }) => { const [dialogOpen, setDialogOpen] = useState(false); + const autoOpenedRef = useRef(false); const outputs = execution.outputs || {}; const outputValues = Object.values(outputs); const outputFiles = outputValues.filter((output) => output.type === 'FILE') as FileOutput[]; const outputTexts = outputValues.filter((output) => output.type === 'TEXT') as TextOutput[]; + // Opens the dialog once per execution when it just completed with outputs to review + useEffect(() => { + if (autoOpen && outputValues.length > 0 && !autoOpenedRef.current) { + autoOpenedRef.current = true; + setDialogOpen(true); + } + }, [autoOpen, outputValues.length]); + const handleOpenDialog = () => { setDialogOpen(true); }; diff --git a/ui.frontend/src/hooks/execution.ts b/ui.frontend/src/hooks/execution.ts index 9cf411df..3566eb8d 100644 --- a/ui.frontend/src/hooks/execution.ts +++ b/ui.frontend/src/hooks/execution.ts @@ -1,6 +1,7 @@ import { ToastQueue } from '@react-spectrum/toast'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useInterval } from 'react-use'; +import { isExecutableScript } from '../types/executable'; import { Execution, ExecutionStatus, isExecutionPending } from '../types/execution'; import { QueueOutput } from '../types/main'; import { apiRequest } from '../utils/api'; @@ -14,6 +15,7 @@ export const useExecutionPolling = (executionId: string | undefined | null, poll const [executing, setExecuting] = useState(!!executionId); const [loading, setLoading] = useState(true); const [wasPending, setWasPending] = useState(false); + const [justCompleted, setJustCompleted] = useState(false); const formatter = useFormatter(); const pollExecutionState = async (executionId: string) => { @@ -37,6 +39,7 @@ export const useExecutionPolling = (executionId: string | undefined | null, poll const recentlyCompleted = formatter.isRecent(queuedExecution.endDate, 2 * pollInterval); if (recentlyCompleted || wasPending) { + setJustCompleted(true); if (queuedExecution.status === ExecutionStatus.FAILED) { ToastQueue.negative('Code execution failed!', { timeout: ToastTimeoutQuick }); } else if (queuedExecution.status === ExecutionStatus.SKIPPED) { @@ -63,7 +66,29 @@ export const useExecutionPolling = (executionId: string | undefined | null, poll executing && executionId ? appState.spaSettings.executionPollInterval : null, ); - return { execution, setExecution, executing, setExecuting, loading }; + return { execution, setExecution, executing, setExecuting, loading, justCompleted }; +}; + +// Signals a script execution just succeeded with a 'auto' review policy, exactly once per execution id +export const useExecutionReviewAutoOpen = (execution: Execution | null, justCompleted: boolean): boolean => { + const appState = useAppState(); + const autoOpenedIdRef = useRef(null); + + const autoOpen = + !!execution && + appState.spaSettings.executionReviewOutputsPolicy === 'auto' && + isExecutableScript(execution.executable.id) && + execution.status === ExecutionStatus.SUCCEEDED && + justCompleted && + autoOpenedIdRef.current !== execution.id; + + useEffect(() => { + if (autoOpen && execution) { + autoOpenedIdRef.current = execution.id; + } + }, [autoOpen, execution]); + + return autoOpen; }; export const pollExecutionPending = async (executionId: string, pollInterval: number): Promise => { diff --git a/ui.frontend/src/pages/ExecutionView.tsx b/ui.frontend/src/pages/ExecutionView.tsx index c0b5ed69..de470eb4 100644 --- a/ui.frontend/src/pages/ExecutionView.tsx +++ b/ui.frontend/src/pages/ExecutionView.tsx @@ -23,11 +23,11 @@ import Toggle from '../components/Toggle.tsx'; import ThreeColumnBar from '../components/ThreeColumnBar'; import UserInfo from '../components/UserInfo'; import { useAppState } from '../hooks/app.ts'; -import { useExecutionPolling } from '../hooks/execution'; +import { useExecutionPolling, useExecutionReviewAutoOpen } from '../hooks/execution'; import { useFormatter } from '../hooks/formatter'; import { useNavigationTab } from '../hooks/navigation'; import { isExecutableConsole, isExecutableScript } from '../types/executable.ts'; -import { isExecutionPending } from '../types/execution.ts'; +import { ExecutionStatus, isExecutionPending } from '../types/execution.ts'; import { GROOVY_LANGUAGE_ID } from '../utils/monaco/groovy.ts'; import { LOG_LANGUAGE_ID } from '../utils/monaco/log.ts'; import { ToastTimeoutQuick } from '../utils/spectrum.ts'; @@ -37,9 +37,10 @@ const ExecutionView = () => { const { executionId } = useParams<{ executionId: string }>(); const formatter = useFormatter(); const [autoscrollOutput, setAutoscrollOutput] = useState(true); - const { execution, setExecution, loading } = useExecutionPolling(executionId, appState.spaSettings.executionPollInterval); + const { execution, setExecution, loading, justCompleted } = useExecutionPolling(executionId, appState.spaSettings.executionPollInterval); const [selectedTab, handleTabChange] = useNavigationTab('details'); const navigate = useNavigate(); + const autoOpenReview = useExecutionReviewAutoOpen(execution, justCompleted); if (loading) { return ( @@ -170,7 +171,7 @@ const ExecutionView = () => { - + diff --git a/ui.frontend/src/types/main.ts b/ui.frontend/src/types/main.ts index 045ca28d..6f16760b 100644 --- a/ui.frontend/src/types/main.ts +++ b/ui.frontend/src/types/main.ts @@ -53,6 +53,7 @@ export const StateDefault: State = { appStateInterval: 3000, executionPollInterval: 1400, scriptStatsLimit: 20, + executionReviewOutputsPolicy: 'manual', }, healthStatus: { healthy: true, @@ -88,6 +89,7 @@ export type SpaSettings = { appStateInterval: number; executionPollInterval: number; scriptStatsLimit: number; + executionReviewOutputsPolicy: 'manual' | 'auto'; }; export type InstanceSettings = {