diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Code.java b/core/src/main/java/dev/vml/es/acm/core/code/Code.java index d4d30937..b90bc282 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/Code.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/Code.java @@ -8,7 +8,7 @@ import org.apache.sling.event.jobs.Job; /** - * Represents a code that can be executed. + * Represents any code (e.g text from interactive console) that can be executed. */ public class Code implements Executable { @@ -61,6 +61,11 @@ public String getContent() { return content; } + @Override + public CodeMetadata getMetadata() { + return CodeMetadata.of(this); + } + public String toString() { return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) .append("id", id) diff --git a/core/src/main/java/dev/vml/es/acm/core/code/CodeMetadata.java b/core/src/main/java/dev/vml/es/acm/core/code/CodeMetadata.java new file mode 100644 index 00000000..e93f25b7 --- /dev/null +++ b/core/src/main/java/dev/vml/es/acm/core/code/CodeMetadata.java @@ -0,0 +1,162 @@ +package dev.vml.es.acm.core.code; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class CodeMetadata implements Serializable { + + public static final CodeMetadata EMPTY = new CodeMetadata(new LinkedHashMap<>()); + + private static final Logger LOG = LoggerFactory.getLogger(CodeMetadata.class); + + private static final Pattern DOC_COMMENT_PATTERN = Pattern.compile("/\\*\\*([^*]|\\*(?!/))*\\*/", Pattern.DOTALL); + private static final Pattern TAG_PATTERN = + Pattern.compile("(?m)^\\s*\\*?\\s*@(\\w+)\\s+(.+?)(?=(?m)^\\s*\\*?\\s*@\\w+|\\*/|$)", Pattern.DOTALL); + private static final Pattern NEWLINE_AFTER_COMMENT = Pattern.compile("^\\s*\\n[\\s\\S]*"); + private static final Pattern BLANK_LINE_AFTER_COMMENT = Pattern.compile("^\\s*\\n\\s*\\n[\\s\\S]*"); + private static final Pattern IMPORT_OR_PACKAGE_BEFORE = + Pattern.compile("[\\s\\S]*(import|package)[\\s\\S]*\\n\\s*\\n\\s*$"); + private static final Pattern FIRST_TAG_PATTERN = Pattern.compile("(?m)^\\s*\\*?\\s*@\\w+"); + private static final Pattern LEADING_ASTERISK = Pattern.compile("(?m)^\\s*\\*\\s?"); + private static final Pattern DOC_MARKERS = Pattern.compile("^/\\*\\*|\\*/$"); + + private Map values; + + public CodeMetadata(Map values) { + this.values = values; + } + + public static CodeMetadata of(Executable executable) { + try { + return parse(executable.getContent()); + } catch (Exception e) { + LOG.warn("Cannot parse code metadata from executable '{}'!", executable.getId(), e); + return EMPTY; + } + } + + public static CodeMetadata parse(String code) { + if (StringUtils.isNotBlank(code)) { + String docComment = findFirstDocComment(code); + if (docComment != null) { + return new CodeMetadata(parseDocComment(docComment)); + } + } + return EMPTY; + } + + /** + * Finds first JavaDoc/GroovyDoc comment that's properly separated with blank lines, + * or directly attached to describeRun() method. + */ + private static String findFirstDocComment(String code) { + Matcher matcher = DOC_COMMENT_PATTERN.matcher(code); + + while (matcher.find()) { + String comment = matcher.group(); + int commentStart = matcher.start(); + int commentEnd = matcher.end(); + + String afterComment = code.substring(commentEnd); + + if (!NEWLINE_AFTER_COMMENT.matcher(afterComment).matches()) { + continue; + } + + String trimmedAfter = afterComment.trim(); + + if (trimmedAfter.startsWith("void describeRun()")) { + return comment; + } + + if (!BLANK_LINE_AFTER_COMMENT.matcher(afterComment).matches()) { + continue; + } + + if (commentStart > 0) { + String beforeComment = code.substring(0, commentStart); + String trimmedBefore = beforeComment.trim(); + if (trimmedBefore.isEmpty() + || IMPORT_OR_PACKAGE_BEFORE.matcher(beforeComment).matches()) { + return comment; + } + } else { + return comment; + } + } + + return null; + } + + /** + * Extracts description and @tags from doc comment. Supports multiple values per tag. + */ + private static Map parseDocComment(String docComment) { + Map result = new LinkedHashMap<>(); + + String content = DOC_MARKERS.matcher(docComment).replaceAll(""); + + // @ at line start (not in email addresses) + Matcher firstTagMatcher = FIRST_TAG_PATTERN.matcher(content); + + if (firstTagMatcher.find()) { + int firstTagIndex = firstTagMatcher.start(); + String description = LEADING_ASTERISK + .matcher(content.substring(0, firstTagIndex)) + .replaceAll("") + .trim(); + if (!description.isEmpty()) { + result.put("description", description); + } + } else { + String description = + LEADING_ASTERISK.matcher(content).replaceAll("").trim(); + if (!description.isEmpty()) { + result.put("description", description); + } + } + + Matcher tagMatcher = TAG_PATTERN.matcher(content); + + while (tagMatcher.find()) { + String tagName = tagMatcher.group(1); + String tagValue = tagMatcher.group(2); + + if (tagValue != null && !tagValue.isEmpty()) { + tagValue = LEADING_ASTERISK.matcher(tagValue).replaceAll("").trim(); + + if (!tagValue.isEmpty()) { + Object existing = result.get(tagName); + + if (existing == null) { + result.put(tagName, tagValue); + } else if (existing instanceof List) { + @SuppressWarnings("unchecked") + List list = (List) existing; + list.add(tagValue); + } else { + List list = new ArrayList<>(); + list.add((String) existing); + list.add(tagValue); + result.put(tagName, list); + } + } + } + } + return result; + } + + @JsonAnyGetter + public Map getValues() { + return values; + } +} diff --git a/core/src/main/java/dev/vml/es/acm/core/code/Executable.java b/core/src/main/java/dev/vml/es/acm/core/code/Executable.java index 9bfa0af1..925b5f74 100644 --- a/core/src/main/java/dev/vml/es/acm/core/code/Executable.java +++ b/core/src/main/java/dev/vml/es/acm/core/code/Executable.java @@ -1,6 +1,5 @@ package dev.vml.es.acm.core.code; -import dev.vml.es.acm.core.AcmException; import java.io.Serializable; public interface Executable extends Serializable { @@ -11,5 +10,7 @@ public interface Executable extends Serializable { String getId(); - String getContent() throws AcmException; + String getContent(); + + CodeMetadata getMetadata(); } diff --git a/core/src/main/java/dev/vml/es/acm/core/script/Script.java b/core/src/main/java/dev/vml/es/acm/core/script/Script.java index df8e975d..ffcdc51d 100644 --- a/core/src/main/java/dev/vml/es/acm/core/script/Script.java +++ b/core/src/main/java/dev/vml/es/acm/core/script/Script.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import dev.vml.es.acm.core.AcmException; +import dev.vml.es.acm.core.code.CodeMetadata; import dev.vml.es.acm.core.code.Executable; import java.io.IOException; import java.io.InputStream; @@ -55,6 +56,11 @@ public String getContent() throws AcmException { } } + @Override + public CodeMetadata getMetadata() { + return CodeMetadata.of(this); + } + @JsonIgnore public String getPath() { return resource.getPath(); diff --git a/core/src/test/java/dev/vml/es/acm/core/code/CodeMetadataTest.java b/core/src/test/java/dev/vml/es/acm/core/code/CodeMetadataTest.java new file mode 100644 index 00000000..7f21ee02 --- /dev/null +++ b/core/src/test/java/dev/vml/es/acm/core/code/CodeMetadataTest.java @@ -0,0 +1,116 @@ +package dev.vml.es.acm.core.code; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import org.junit.jupiter.api.Test; + +class CodeMetadataTest { + + private static final Path SCRIPTS_BASE_PATH = + Paths.get("../ui.content.example/src/main/content/jcr_root/conf/acm/settings/script"); + + private String readScript(String relativePath) throws IOException { + Path scriptPath = SCRIPTS_BASE_PATH.resolve(relativePath); + return new String(Files.readAllBytes(scriptPath), StandardCharsets.UTF_8); + } + + @Test + void shouldParseEmptyCode() { + CodeMetadata metadata = CodeMetadata.parse(""); + + assertTrue(metadata.getValues().isEmpty()); + } + + @Test + void shouldParseNullCode() { + CodeMetadata metadata = CodeMetadata.parse(null); + + assertTrue(metadata.getValues().isEmpty()); + } + + @Test + void shouldParseHelloWorldScript() throws IOException { + String code = readScript("manual/example/ACME-200_hello-world.groovy"); + CodeMetadata metadata = CodeMetadata.parse(code); + + assertEquals( + "Prints \"Hello World!\" to the console.", metadata.getValues().get("description")); + } + + @Test + void shouldParseInputsScript() throws IOException { + String code = readScript("manual/example/ACME-201_inputs.groovy"); + CodeMetadata metadata = CodeMetadata.parse(code); + + String description = (String) metadata.getValues().get("description"); + assertNotNull(description); + assertTrue(description.contains("Prints animal information to the console based on user input")); + assertEquals("", metadata.getValues().get("author")); + } + + @Test + void shouldParsePageThumbnailScript() throws IOException { + String code = readScript("manual/example/ACME-202_page-thumbnail.groovy"); + CodeMetadata metadata = CodeMetadata.parse(code); + String description = (String) metadata.getValues().get("description"); + + assertNotNull(description); + assertTrue(description.contains("Updates the thumbnail")); + assertTrue(description.contains("File must be a JPEG image")); + assertEquals("", metadata.getValues().get("author")); + } + + @Test + void shouldParseScriptWithoutDocComment() throws IOException { + String code = readScript("automatic/example/ACME-20_once.groovy"); + CodeMetadata metadata = CodeMetadata.parse(code); + + assertTrue(metadata.getValues().isEmpty()); + } + + @Test + void shouldParseMultipleAuthors() { + String code = "/**\n" + " * @author John Doe\n" + + " * @author Jane Smith\n" + + " */\n" + + "\n" + + "void doRun() {\n" + + " println \"Hello\"\n" + + "}"; + CodeMetadata metadata = CodeMetadata.parse(code); + Object authors = metadata.getValues().get("author"); + assertTrue(authors instanceof List); + @SuppressWarnings("unchecked") + List authorsList = (List) authors; + + assertEquals(2, authorsList.size()); + assertEquals("John Doe", authorsList.get(0)); + assertEquals("Jane Smith", authorsList.get(1)); + } + + @Test + void shouldParseCustomTags() { + String code = "/**\n" + " * @description Custom script with metadata\n" + + " * @version 1.0.0\n" + + " * @since 2025-01-01\n" + + " * @category migration\n" + + " */\n" + + "\n" + + "void doRun() {\n" + + " println \"Hello\"\n" + + "}"; + + CodeMetadata metadata = CodeMetadata.parse(code); + + assertEquals("Custom script with metadata", metadata.getValues().get("description")); + assertEquals("1.0.0", metadata.getValues().get("version")); + assertEquals("2025-01-01", metadata.getValues().get("since")); + assertEquals("migration", metadata.getValues().get("category")); + } +} diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy index 43ea688c..63e1fe08 100644 --- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy +++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/automatic/example/ACME-100_acl.groovy @@ -1,3 +1,10 @@ +/** + * This script creates content author groups for each tenant-country-language combination. + * + * The groups are named in the format: `{tenant}-{country}-{language}-content-authors`. + * Each group is granted read, write, and replicate permissions on the corresponding content and DAM paths. + */ + def scheduleRun() { return schedules.cron("0 10 * ? * * *") // every hour at minute 10 } diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACM-1_classes_jms.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACM-1_classes_jms.groovy index 9c413f6f..267bb6c0 100644 --- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACM-1_classes_jms.groovy +++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACM-1_classes_jms.groovy @@ -6,7 +6,7 @@ * - print the list (for debugging purposes), * - save it directly in the repository in expected path. * - * @author Krystian Panek + * @author */ import dev.vml.es.acm.core.assist.JavaDictionary diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACM-1_classes_rtjar.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACM-1_classes_rtjar.groovy index aeab8148..5aa4c2a8 100644 --- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACM-1_classes_rtjar.groovy +++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACM-1_classes_rtjar.groovy @@ -6,7 +6,7 @@ * - print the list (for debugging purposes), * - save it directly in the repository in expected path. * - * @author Krystian Panek + * @author */ import dev.vml.es.acm.core.assist.JavaDictionary diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-200_hello-world.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-200_hello-world.groovy index 3783c368..86b260d4 100644 --- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-200_hello-world.groovy +++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-200_hello-world.groovy @@ -1,9 +1,7 @@ /** - * Prints "Hello World!" to the console. + * Prints "Hello World!" to the console. * - * This is a minimal example of AEM Content Manager script. - * - * @author Krystian Panek + * @author */ boolean canRun() { diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-201_inputs.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-201_inputs.groovy index b86aac07..3dc55dd8 100644 --- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-201_inputs.groovy +++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-201_inputs.groovy @@ -1,11 +1,8 @@ /** * Prints animal information to the console based on user input. - * - * This is an example of AEM Content Manager script with inputs. - * - * @author Krystian Panek + * + * @author */ - void describeRun() { inputs.string("animalName") { value = "Whiskers"; validator = "(v, a) => a.animalType === 'cat' ? (v && v.startsWith('W') || 'Cat name must start with W!') : true" } diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-202_page-thumbnail.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-202_page-thumbnail.groovy index 9c80b0d8..66eb2f42 100644 --- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-202_page-thumbnail.groovy +++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-202_page-thumbnail.groovy @@ -3,9 +3,8 @@ * Deletes the existing thumbnails and saves a new one. * File must be a JPEG image. * - * @author Krystian Panek + * @author */ - void describeRun() { inputs.path("pagePath") { rootPathExclusive = '/' } inputs.file("pageThumbnailFile") { mimeTypes = ["image/jpeg"] } diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-csv.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-csv.groovy index 10be01fc..8a3a530f 100644 --- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-csv.groovy +++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-csv.groovy @@ -1,16 +1,20 @@ import java.time.LocalDate import java.util.Random -boolean canRun() { - return conditions.always() -} - +/** + * @description Generates a CSV report of users with random names and birth dates. + * @author + */ void describeRun() { inputs.integerNumber("count") { label = "Users to generate"; min = 1; value = 10000 } inputs.text("firstNames") { label = "First names"; description = "One first name per line"; value = "John\nJane\nJack\nAlice\nBob"} inputs.text("lastNames") { label = "Last names"; description = "One last name per line"; value = "Doe\nSmith\nBrown\nJohnson\nWhite" } } +boolean canRun() { + return conditions.always() +} + void doRun() { out.info "Users CSV report generation started" diff --git a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-xls.groovy b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-xls.groovy index 095ecf59..08f2a9ea 100644 --- a/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-xls.groovy +++ b/ui.content.example/src/main/content/jcr_root/conf/acm/settings/script/manual/example/ACME-203_output-xls.groovy @@ -5,16 +5,20 @@ import java.time.ZoneId import java.util.Date import java.util.Random -boolean canRun() { - return conditions.always() -} - +/** + * @description Generates an XLS report of users with random names and birth dates. + * @author + */ void describeRun() { inputs.integerNumber("count") { label = "Users to generate"; min = 1; value = 100000 } inputs.text("firstNames") { label = "First names"; description = "One first name per line"; value = "John\nJane\nJack\nAlice\nBob"} inputs.text("lastNames") { label = "Last names"; description = "One last name per line"; value = "Doe\nSmith\nBrown\nJohnson\nWhite" } } +boolean canRun() { + return conditions.always() +} + void doRun() { out.info "Users XLS report generation started" diff --git a/ui.frontend/src/components/ExecutableMetadata.tsx b/ui.frontend/src/components/ExecutableMetadata.tsx new file mode 100644 index 00000000..986c7533 --- /dev/null +++ b/ui.frontend/src/components/ExecutableMetadata.tsx @@ -0,0 +1,64 @@ +import { Badge, Content, ContextualHelp, Flex, Heading, LabeledValue, Text, View } from '@adobe/react-spectrum'; +import { Field } from '@react-spectrum/label'; +import DataUnavailable from '@spectrum-icons/workflow/DataUnavailable'; +import React from 'react'; +import { ExecutableMetadata as ExecutableMetadataType } from '../types/executable'; +import Markdown from './Markdown'; + +type ExecutableMetadataProps = { + metadata: ExecutableMetadataType | null | undefined; +}; + +const ExecutableMetadata: React.FC = ({ metadata }) => { + if (!metadata || Object.keys(metadata).length === 0) { + return ( + +
+ + + + Not available + + + Defining metadata + + + Use a JavaDoc or GroovyDoc comment block at the top of your script file: + +
+                  
+                    {`/**
+ * Explain purpose here
+ *
+ * @author 
+ * @version 1.0
+ */`}
+                  
+                
+ + The comment must be followed by a blank line. Description is extracted from text before any @tags. + +
+
+
+
+
+ ); + } + + return ( + + {Object.entries(metadata).map(([key, value]) => { + const label = key.charAt(0).toUpperCase() + key.slice(1); + + if (Array.isArray(value)) { + return value.map((item, index) => } />); + } + + return } />; + })} + + ); +}; + +export default ExecutableMetadata; diff --git a/ui.frontend/src/components/ExecutionInputs.tsx b/ui.frontend/src/components/ExecutionInputs.tsx new file mode 100644 index 00000000..43663801 --- /dev/null +++ b/ui.frontend/src/components/ExecutionInputs.tsx @@ -0,0 +1,66 @@ +import { Badge, Content, ContextualHelp, Flex, Heading, Text, View } from '@adobe/react-spectrum'; +import { Field } from '@react-spectrum/label'; +import Download from '@spectrum-icons/workflow/Download'; +import React from 'react'; +import { Objects } from '../utils/objects'; +import CodeTextarea from './CodeTextarea'; + +type ExecutionInputsProps = { + inputs: Record | null | undefined; +}; + +const ExecutionInputs: React.FC = ({ inputs }) => { + const isEmpty = Objects.isEmpty(inputs ?? undefined); + const count = isEmpty ? 0 : Object.keys(inputs!).length; + + return ( + +
+ {isEmpty ? ( + + + + Not described + + + Defining inputs + + + + Use describeRun() method to collect values from users before execution: + + +
+                  
+                    {`void describeRun() {
+  inputs.path("pagePath") { 
+    rootPathExclusive = '/' 
+  }
+  inputs.file("pageThumbnailFile") { 
+    mimeTypes = ["image/jpeg"] 
+  }
+  inputs.integerNumber("count") { 
+    label = "Users to generate"
+    min = 1
+    value = 10000 
+  }
+}`}
+                  
+                
+ + + Access input values in doRun() using inputs.value("name"). Supports various types: string, integerNumber, decimalNumber, bool, date, time, path, file, and more. + + +
+
+
+ ) : ( + + )} +
+
+ ); +}; + +export default ExecutionInputs; diff --git a/ui.frontend/src/components/ExecutionOutputs.tsx b/ui.frontend/src/components/ExecutionOutputs.tsx new file mode 100644 index 00000000..e1f2ce85 --- /dev/null +++ b/ui.frontend/src/components/ExecutionOutputs.tsx @@ -0,0 +1,66 @@ +import { Badge, Content, ContextualHelp, Flex, Heading, Text, View } from '@adobe/react-spectrum'; +import { Field } from '@react-spectrum/label'; +import Upload from '@spectrum-icons/workflow/UploadToCloud'; +import React from 'react'; +import { Objects } from '../utils/objects'; +import CodeTextarea from './CodeTextarea'; + +type ExecutionOutputsProps = { + outputs: Record | null | undefined; +}; + +const ExecutionOutputs: React.FC = ({ outputs }) => { + const isEmpty = Objects.isEmpty(outputs ?? undefined); + const count = isEmpty ? 0 : Object.keys(outputs!).length; + + return ( + +
+ {isEmpty ? ( + + + + Not generated + + + Generating outputs + + + + Use doRun() method to return structured data, files, or summaries: + + +
+                  
+                    {`void doRun() {
+  def report = outputs.file("report") {
+    label = "Report"
+    description = "Users report as CSV"
+    downloadName = "report.csv"
+  }
+  report.out.println("Name,Surname,Date")
+  report.out.println("John,Doe,2024-01-01")
+  
+  outputs.text("summary") {
+    value = "Processed \${count} users"
+  }
+}`}
+                  
+                
+ + + Use outputs.file() for downloadable assets, reports or outputs.text() for summaries and documentation. + + +
+
+
+ ) : ( + + )} +
+
+ ); +}; + +export default ExecutionOutputs; diff --git a/ui.frontend/src/components/Footer.tsx b/ui.frontend/src/components/Footer.tsx index 4694ca6b..35192cbc 100644 --- a/ui.frontend/src/components/Footer.tsx +++ b/ui.frontend/src/components/Footer.tsx @@ -1,4 +1,6 @@ import { Divider, Flex, Link, Footer as SpectrumFooter, View } from '@adobe/react-spectrum'; +import githubMark from '/github-mark.svg'; +import vmlLogo from '/vml-logo.svg'; const Footer = () => { return ( @@ -8,7 +10,7 @@ const Footer = () => { - VML Logo + VML Logo @@ -19,7 +21,7 @@ const Footer = () => { - GitHub + GitHub View 'Content Manager' on GitHub diff --git a/ui.frontend/src/components/InfoCard.tsx b/ui.frontend/src/components/InfoCard.tsx new file mode 100644 index 00000000..f8fc2f0a --- /dev/null +++ b/ui.frontend/src/components/InfoCard.tsx @@ -0,0 +1,18 @@ +import { Flex, View } from '@adobe/react-spectrum'; +import { ReactNode } from 'react'; + +type InfoCardProps = { + children: ReactNode; +}; + +const InfoCard = ({ children }: InfoCardProps) => { + return ( + + + {children} + + + ); +}; + +export default InfoCard; diff --git a/ui.frontend/src/components/markdown.module.css b/ui.frontend/src/components/markdown.module.css index c0ad672c..9b4da2b1 100644 --- a/ui.frontend/src/components/markdown.module.css +++ b/ui.frontend/src/components/markdown.module.css @@ -2,10 +2,10 @@ font-size: 12px; } -.markdown p:first-child { +.markdown p:first-child, .markdown ol:first-child, .markdown ul:first-child { margin-block-start: 0; } -.markdown p:last-child { +.markdown p:last-child, .markdown ol:last-child, .markdown ul:last-child { margin-block-end: 0; } diff --git a/ui.frontend/src/pages/ExecutionView.tsx b/ui.frontend/src/pages/ExecutionView.tsx index a6b7cbad..ec6b1d0d 100644 --- a/ui.frontend/src/pages/ExecutionView.tsx +++ b/ui.frontend/src/pages/ExecutionView.tsx @@ -1,32 +1,34 @@ -import { Badge, Button, ButtonGroup, Content, Flex, IllustratedMessage, Item, LabeledValue, ProgressBar, Switch, TabList, TabPanels, Tabs, Text, View } from '@adobe/react-spectrum'; -import { Editor } from '@monaco-editor/react'; +import { Button, ButtonGroup, Content, Flex, IllustratedMessage, Item, LabeledValue, ProgressBar, Switch, TabList, TabPanels, Tabs, Text, View } from '@adobe/react-spectrum'; import { Field } from '@react-spectrum/label'; import { ToastQueue } from '@react-spectrum/toast'; import NotFound from '@spectrum-icons/illustrations/NotFound'; import Copy from '@spectrum-icons/workflow/Copy'; import FileCode from '@spectrum-icons/workflow/FileCode'; import History from '@spectrum-icons/workflow/History'; -import InfoOutline from '@spectrum-icons/workflow/InfoOutline'; import Print from '@spectrum-icons/workflow/Print'; import { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import CodeEditor from '../components/CodeEditor.tsx'; import ExecutableIdValue from '../components/ExecutableIdValue'; +import ExecutableMetadata from '../components/ExecutableMetadata'; import ExecutionAbortButton from '../components/ExecutionAbortButton'; import ExecutionCopyOutputButton from '../components/ExecutionCopyOutputButton'; +import ExecutionInputs from '../components/ExecutionInputs'; +import ExecutionOutputs from '../components/ExecutionOutputs'; import ExecutionProgressBar from '../components/ExecutionProgressBar'; import ExecutionReviewOutputsButton from '../components/ExecutionReviewOutputsButton.tsx'; import ExecutionStatusBadge from '../components/ExecutionStatusBadge'; +import InfoCard from '../components/InfoCard'; import Toggle from '../components/Toggle.tsx'; +import UserInfo from '../components/UserInfo'; import { useAppState } from '../hooks/app.ts'; import { useExecutionPolling } from '../hooks/execution'; import { useFormatter } from '../hooks/formatter'; import { useNavigationTab } from '../hooks/navigation'; -import { isExecutableScript } from '../types/executable.ts'; +import { isExecutableConsole, isExecutableScript } from '../types/executable.ts'; import { isExecutionPending } from '../types/execution.ts'; import { GROOVY_LANGUAGE_ID } from '../utils/monaco/groovy.ts'; import { LOG_LANGUAGE_ID } from '../utils/monaco/log.ts'; -import { Objects } from '../utils/objects'; import { ToastTimeoutQuick } from '../utils/spectrum.ts'; const ExecutionView = () => { @@ -100,49 +102,50 @@ const ExecutionView = () => { - - - - - - -
- -
-
-
-
-
- - - - - - - - - - + {/* Row 1: Execution Info */} + + + +
- +
- +
- {Objects.isEmpty(execution.inputs) ? ( - - - Not described - - ) : ( - - - - )} +
-
-
+ + + + + + + + {/* Row 2: Executable Info */} + + + +
+ +
+
+ {!isExecutableConsole(execution.executable.id) && } +
+ + + +
+ {/* Row 3: I/O */} + + + + + + + + diff --git a/ui.frontend/src/pages/ScriptView.tsx b/ui.frontend/src/pages/ScriptView.tsx index bb78f009..02a0081b 100644 --- a/ui.frontend/src/pages/ScriptView.tsx +++ b/ui.frontend/src/pages/ScriptView.tsx @@ -1,5 +1,4 @@ import { Button, ButtonGroup, Content, Flex, IllustratedMessage, Item, LabeledValue, ProgressBar, TabList, TabPanels, Tabs, Text, View } from '@adobe/react-spectrum'; -import { Field } from '@react-spectrum/label'; import { ToastQueue } from '@react-spectrum/toast'; import NotFound from '@spectrum-icons/illustrations/NotFound'; import Copy from '@spectrum-icons/workflow/Copy'; @@ -9,6 +8,8 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import CodeEditor from '../components/CodeEditor'; import CodeExecuteButton from '../components/CodeExecuteButton'; +import ExecutableMetadata from '../components/ExecutableMetadata'; +import InfoCard from '../components/InfoCard'; import { useFeatureEnabled } from '../hooks/app.ts'; import { NavigationSearchParams, useNavigationTab } from '../hooks/navigation'; import { InputValues } from '../types/input.ts'; @@ -150,16 +151,15 @@ const ScriptView = () => { - - - -
- {script.name} -
-
+ + + - -
+ + + + +
diff --git a/ui.frontend/src/types/executable.ts b/ui.frontend/src/types/executable.ts index 8dc52f22..0abfe036 100644 --- a/ui.frontend/src/types/executable.ts +++ b/ui.frontend/src/types/executable.ts @@ -3,7 +3,11 @@ import { ScriptRoot } from './script'; export type Executable = { id: string; content: string; + metadata: ExecutableMetadata; }; + +export type ExecutableMetadata = Record; + export const ExecutableIdConsole = 'console'; export function isExecutableConsole(id: string): boolean { diff --git a/ui.frontend/src/types/script.ts b/ui.frontend/src/types/script.ts index ecb7dc88..17afca34 100644 --- a/ui.frontend/src/types/script.ts +++ b/ui.frontend/src/types/script.ts @@ -1,3 +1,4 @@ +import { Executable } from './executable'; import { ExecutionStatus } from './execution'; import { ExecutionSummary } from './main'; @@ -8,12 +9,10 @@ export enum ScriptType { MOCK = 'MOCK', } -export type Script = { - id: string; +export type Script = Executable & { type: ScriptType; path: string; name: string; - content: string; }; export type ScriptStats = {