diff --git a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java index 98ffc441..fabff018 100644 --- a/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java +++ b/core/src/main/java/dev/vml/es/acm/core/repo/RepoResource.java @@ -3,13 +3,13 @@ import dev.vml.es.acm.core.util.*; import java.io.*; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.util.*; import java.util.concurrent.Executors; import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Stream; import javax.jcr.Node; +import javax.jcr.NodeIterator; import javax.jcr.Property; import javax.jcr.RepositoryException; import org.apache.commons.io.IOUtils; @@ -35,6 +35,8 @@ */ public class RepoResource { + public static final String FILE_MIME_TYPE_DEFAULT = "application/octet-stream"; + private final Repo repo; private final String path; @@ -402,6 +404,50 @@ public RepoResource moveInPlace(RepoResource target, boolean replace) { return target; } + // AEM 6.5.0 has no 'resourceResolver.orderBefore' so adding own based on: + // https://github.com/apache/sling-org-apache-sling-jcr-resource/commit/3b8f01d226124417bdfdba4ca2086114a73a7c5d + public boolean orderBefore(String siblingName) { + Node parentNode = parent().requireNode(); + String name = getName(); + try { + long existingNodePosition = -1; + long siblingNodePosition = -1; + long index = 0; + + NodeIterator nodeIterator = parentNode.getNodes(); + while (nodeIterator.hasNext()) { + Node childNode = nodeIterator.nextNode(); + String childName = childNode.getName(); + + if (childName.equals(name)) { + existingNodePosition = index; + } + if (siblingName != null && childName.equals(siblingName)) { + siblingNodePosition = index; + } else if (siblingName == null && childName.equals(name)) { + if (existingNodePosition == nodeIterator.getSize() - 1) { + return false; + } + } + index++; + } + + if (siblingName != null + && existingNodePosition >= 0 + && siblingNodePosition >= 0 + && existingNodePosition == siblingNodePosition - 1) { + return false; + } + + parentNode.orderBefore(name, siblingName); + repo.commit(String.format("reordering resource '%s' before '%s'", path, siblingName)); + repo.getLogger().info("Reordered resource '{}' before '{}'", path, siblingName); + return true; + } catch (RepositoryException e) { + throw new RepoException(String.format("Cannot reorder resource '%s' before '%s'", path, siblingName), e); + } + } + public RepoResource parent() { String parentPath = parentPath(); if (parentPath == null) { @@ -534,100 +580,92 @@ public RepoResourceState state() { return new RepoResourceState(path, true, resource.getValueMap()); } - public RepoResource saveFile(String mimeType, Consumer dataWriter) { - saveFileInternal(mimeType, null, dataWriter); - return this; + public RepoResource saveFile(InputStream data) { + return saveFile(FILE_MIME_TYPE_DEFAULT, data); } - public RepoResource saveFile(String mimeType, File file) { - saveFile(mimeType, (OutputStream os) -> { - try (InputStream is = Files.newInputStream(file.toPath())) { - IOUtils.copy(is, os); - } catch (IOException e) { - throw new RepoException(String.format("Cannot write file '%s' to path '%s'!", file.getPath(), path), e); - } - }); + public RepoResource saveFile(String mimeType, InputStream data) { + return saveFile(Collections.singletonMap(JcrConstants.JCR_MIMETYPE, mimeType), data); + } + + public RepoResource saveFile(Map properties, InputStream data) { + saveFileInternal(properties, data, null); return this; } - public RepoResource saveFile(String mimeType, Object data) { - saveFileInternal(mimeType, data, null); + public RepoResource saveFile(Consumer dataWriter) { + return saveFile(FILE_MIME_TYPE_DEFAULT, dataWriter); + } + + public RepoResource saveFile(String mimeType, Consumer dataWriter) { + return saveFile(Collections.singletonMap(JcrConstants.JCR_MIMETYPE, mimeType), dataWriter); + } + + public RepoResource saveFile(Map properties, Consumer dataWriter) { + saveFileInternal(properties, null, dataWriter); return this; } - private void saveFileInternal(String mimeType, Object data, Consumer dataWriter) { + private void saveFileInternal(Map properties, InputStream data, Consumer dataWriter) { + if (properties == null) { + properties = new HashMap<>(); + } + if (!properties.containsKey(JcrConstants.JCR_MIMETYPE)) { + Map propertiesMutable = new HashMap<>(properties); + propertiesMutable.put(JcrConstants.JCR_MIMETYPE, FILE_MIME_TYPE_DEFAULT); + properties = propertiesMutable; + } + Resource mainResource = resolve(); try { - if (mainResource == null) { - String parentPath = StringUtils.substringBeforeLast(path, "/"); - Resource parent = repo.getResourceResolver().getResource(parentPath); - if (parent == null) { - throw new RepoException( - String.format("Cannot save file as parent path '%s' does not exist!", parentPath)); - } - String name = StringUtils.substringAfterLast(path, "/"); - Map mainValues = new HashMap<>(); - mainValues.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_FILE); - mainResource = repo.getResourceResolver().create(parent, name, mainValues); + if (mainResource != null) { + repo.getResourceResolver().delete(mainResource); + } - Map contentValues = new HashMap<>(); - setFileContent(contentValues, mimeType, data, dataWriter); - repo.getResourceResolver().create(mainResource, JcrConstants.JCR_CONTENT, contentValues); + Resource parent = parent().resolve(); + if (parent == null) { + throw new RepoException( + String.format("Cannot save file as parent path '%s' does not exist!", parentPath())); + } + String name = getName(); + Map mainValues = new HashMap<>(); + mainValues.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_FILE); + mainResource = repo.getResourceResolver().create(parent, name, mainValues); - repo.commit(String.format("creating file at path '%s'", path)); - repo.getLogger().info("Created file at path '{}'", path); - } else { - Resource contentResource = mainResource.getChild(JcrConstants.JCR_CONTENT); - if (contentResource == null) { - Map contentValues = new HashMap<>(); - setFileContent(contentValues, mimeType, data, dataWriter); - repo.getResourceResolver().create(mainResource, JcrConstants.JCR_CONTENT, contentValues); - } else { - ModifiableValueMap contentValues = - Objects.requireNonNull(contentResource.adaptTo(ModifiableValueMap.class)); - setFileContent(contentValues, mimeType, data, dataWriter); - } + Map contentValues = new HashMap<>(); + setFileContent(contentValues, properties, data, dataWriter); + repo.getResourceResolver().create(mainResource, JcrConstants.JCR_CONTENT, contentValues); - repo.commit(String.format("updating file at path '%s'", path)); - repo.getLogger().info("Updated file at path '{}'", path); - } + repo.commit(String.format("saving file at path '%s'", path)); + repo.getLogger().info("Saved file at path '{}'", path); } catch (PersistenceException e) { throw new RepoException(String.format("Cannot save file at path '%s'!", path), e); } } private void setFileContent( - Map contentValues, String mimeType, Object data, Consumer dataWriter) { + Map contentValues, + Map props, + InputStream data, + Consumer dataWriter) { + contentValues.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_RESOURCE); + contentValues.putAll(props); + if (dataWriter != null) { - setFileContent(contentValues, mimeType, dataWriter); + final PipedInputStream pis = new PipedInputStream(); + Executors.newSingleThreadExecutor().submit(() -> { + try (PipedOutputStream pos = new PipedOutputStream(pis)) { + dataWriter.accept(pos); + } catch (Exception e) { + throw new RepoException(String.format("Cannot write data to file at path '%s'!", path), e); + } + }); + contentValues.put(JcrConstants.JCR_DATA, pis); } else { - setFileContent(contentValues, mimeType, data); + contentValues.put(JcrConstants.JCR_DATA, data); } } - private void setFileContent(Map contentValues, String mimeType, Object data) { - contentValues.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_RESOURCE); - contentValues.put(JcrConstants.JCR_ENCODING, "utf-8"); - contentValues.put(JcrConstants.JCR_MIMETYPE, mimeType); - contentValues.put(JcrConstants.JCR_DATA, data); - } - - // https://stackoverflow.com/a/27172165 - private void setFileContent(Map contentValues, String mimeType, Consumer dataWriter) { - contentValues.put(JcrConstants.JCR_PRIMARYTYPE, JcrConstants.NT_RESOURCE); - contentValues.put(JcrConstants.JCR_ENCODING, "utf-8"); - contentValues.put(JcrConstants.JCR_MIMETYPE, mimeType); - final PipedInputStream pis = new PipedInputStream(); - Executors.newSingleThreadExecutor().submit(() -> { - try (PipedOutputStream pos = new PipedOutputStream(pis)) { - dataWriter.accept(pos); - } catch (Exception e) { - throw new RepoException(String.format("Cannot write data to file at path '%s'!", path), e); - } - }); - contentValues.put(JcrConstants.JCR_DATA, pis); - } - public InputStream readFileAsStream() { Resource resource = require(); Resource contentResource = resource.getChild(JcrConstants.JCR_CONTENT); 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 0d7e94d8..5b0042f3 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,8 +2,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import dev.vml.es.acm.core.AcmException; -import dev.vml.es.acm.core.code.ExecutableMetadata; import dev.vml.es.acm.core.code.Executable; +import dev.vml.es.acm.core.code.ExecutableMetadata; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; diff --git a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java index 18cafc6c..9e2af844 100644 --- a/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java +++ b/core/src/main/java/dev/vml/es/acm/core/script/ScriptRepository.java @@ -7,6 +7,9 @@ import dev.vml.es.acm.core.repo.RepoException; import dev.vml.es.acm.core.repo.RepoResource; import dev.vml.es.acm.core.util.ResourceSpliterator; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Optional; import java.util.stream.Stream; @@ -55,10 +58,10 @@ private Resource getRoot(ScriptType type) throws RepoException { } public Script save(Code code) { - return save(code.getId(), code.getContent()); + return save(code.getId(), new ByteArrayInputStream(code.getContent().getBytes(StandardCharsets.UTF_8))); } - public Script save(String id, Object data) throws AcmException { + public Script save(String id, InputStream data) throws AcmException { if (!ScriptType.byPath(id).isPresent()) { throw new AcmException(String.format("Cannot save script '%s' at unsupported path!", id)); } diff --git a/test/e2e/002-console.spec.ts b/test/e2e/002-console.spec.ts index 2b074c10..1f84f746 100644 --- a/test/e2e/002-console.spec.ts +++ b/test/e2e/002-console.spec.ts @@ -25,7 +25,7 @@ test.describe('Console', () => { await page.getByRole('tab', { name: 'Output' }).click(); await expectExecutionProgressBarSucceeded(page); - const output = await readFromCodeEditor(page); + const output = await readFromCodeEditor(page, 'Console Output'); expectToHaveMultilineText(output, ` Hello World! `); diff --git a/test/e2e/003-tool-access.spec.ts b/test/e2e/003-tool-access.spec.ts index 73dec8ac..476d316f 100644 --- a/test/e2e/003-tool-access.spec.ts +++ b/test/e2e/003-tool-access.spec.ts @@ -63,7 +63,7 @@ test.describe('Tool Access', () => { await page.getByRole('tab', { name: 'Output' }).click(); await expectExecutionProgressBarSucceeded(page); - const output = await readFromCodeEditor(page); + const output = await readFromCodeEditor(page, 'Console Output'); expect(output).toContain('Setup complete!'); await newAemContext(browser, 'acm-test-user', 'test1234', async (testUserPage) => { diff --git a/test/e2e/004-history.spec.ts b/test/e2e/004-history.spec.ts index bb739e75..4936db50 100644 --- a/test/e2e/004-history.spec.ts +++ b/test/e2e/004-history.spec.ts @@ -17,7 +17,7 @@ test.describe('History', () => { await expect(firstRow.locator('[role="rowheader"]')).toContainText('Console'); await firstRow.click(); await page.getByRole('tab', { name: 'Output' }).click(); - const firstOutput = await readFromCodeEditor(page); + const firstOutput = await readFromCodeEditor(page, 'Execution Output'); expect(firstOutput).toContain('Setup complete!'); await page.goto('/acm#/history'); @@ -27,7 +27,24 @@ test.describe('History', () => { await expect(secondRow.locator('[role="rowheader"]')).toContainText('Console'); await secondRow.click(); await page.getByRole('tab', { name: 'Output' }).click(); - const secondOutput = await readFromCodeEditor(page); + const secondOutput = await readFromCodeEditor(page, 'Execution Output'); expect(secondOutput).toContain('Hello World!'); }); + + test('Shows automatic script executions', async ({ page }) => { + await page.goto('/acm'); + await page.getByRole('button', { name: 'History' }).click(); + + const grid = page.locator('[role="grid"][aria-label="Executions table"]'); + await expect(grid).toBeVisible(); + const rows = grid.locator('[role="row"]'); + + await page.getByRole('searchbox', { name: 'Executable' }).fill('example/ACME-20_once'); + await expect(rows.nth(1)).toContainText('Script \'example/ACME-20_once\''); + await expect(rows.nth(1)).toContainText('succeeded'); + + await page.getByRole('searchbox', { name: 'Executable' }).fill('example/ACME-21_changed'); + await expect(rows.nth(1)).toContainText('Script \'example/ACME-21_changed\''); + await expect(rows.nth(1)).toContainText('succeeded'); + }); }); diff --git a/test/e2e/005-manual-scripts.spec.ts b/test/e2e/005-manual-scripts.spec.ts new file mode 100644 index 00000000..821a0c87 --- /dev/null +++ b/test/e2e/005-manual-scripts.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; +import { + expectExecutionProgressBarSucceeded, + expectExecutionDetails, + expectExecutionTimings, + expectExecutionInputs, + expectExecutionOutputs, + expectOutputTexts, + expectOutputFileDownload, +} from './utils/expect'; +import { readFromCodeEditor } from './utils/editor'; + +test.describe('Manual Scripts', () => { + test('Execute CSV Generation With I/O', async ({ page }) => { + await page.goto('/acm'); + await page.getByRole('button', { name: 'Scripts' }).click(); + await page.getByText('example/ACME-203_output-csv').click(); + await page.getByRole('button', { name: 'Execute' }).click(); + + await page.getByRole('textbox', { name: 'Users to' }).fill('5000'); + await page.getByRole('textbox', { name: 'First names' }).fill('John\nJane\nJack\nAlice\nBob\nRobert'); + await page.getByRole('textbox', { name: 'Last names' }).fill('Doe\nSmith\nBrown\nJohnson\nWhite\nJordan'); + + await page.getByRole('button', { name: 'Start' }).click(); + await expectExecutionProgressBarSucceeded(page); + + const output = await readFromCodeEditor(page, 'Execution Output'); + expect(output).toContain('[SUCCESS] Users CSV report generation ended successfully'); + + await page.getByRole('tab', { name: 'Details' }).click(); + + await page.waitForTimeout(1000); + await page.screenshot(); + + await expectExecutionDetails(page); + await expectExecutionTimings(page); + + await expectExecutionInputs(page, { + count: 5000, + firstNames: 'John\nJane\nJack\nAlice\nBob\nRobert', + lastNames: 'Doe\nSmith\nBrown\nJohnson\nWhite\nJordan', + }); + + await expectExecutionOutputs(page, [ + { + type: 'FILE', + name: 'report', + label: 'Report', + downloadName: 'report.csv', + }, + { + type: 'TEXT', + name: 'summary', + value: 'Processed 5000 user(s)', + }, + ]); + + await page.getByRole('tab', { name: 'Output' }).click(); + + await page.getByRole('button', { name: 'Review' }).click(); + await page.getByRole('tab', { name: 'Texts' }).click(); + await expectOutputTexts(page, ['Processed 5000 user(s)']); + await page.getByTestId('modal').getByRole('button', { name: 'Close' }).click(); + + await page.getByRole('button', { name: 'Review' }).click(); + await page.getByRole('tab', { name: 'Files' }).click(); + await expectOutputFileDownload(page, 'Download Archive', /\.(zip)$/); + + await page.getByRole('button', { name: 'Review' }).click(); + await page.getByRole('tab', { name: 'Files' }).click(); + await expectOutputFileDownload(page, 'Download Console', /\.console\.log$/); + + await page.getByRole('button', { name: 'Review' }).click(); + await page.getByRole('tab', { name: 'Files' }).click(); + await expectOutputFileDownload(page, 'Download Report', /\.csv$/); + }); +}); diff --git a/test/e2e/utils/context.ts b/test/e2e/utils/context.ts index 156997b3..116d10b1 100644 --- a/test/e2e/utils/context.ts +++ b/test/e2e/utils/context.ts @@ -13,10 +13,12 @@ export async function newAemContext( }, }); + const page = await context.newPage(); + try { - const page = await context.newPage(); await callback(page); } finally { + await page.close(); await context.close(); } } diff --git a/test/e2e/utils/editor.ts b/test/e2e/utils/editor.ts index 7c23d109..f67e6b04 100644 --- a/test/e2e/utils/editor.ts +++ b/test/e2e/utils/editor.ts @@ -22,24 +22,32 @@ export async function writeToCodeEditor(page: Page, code: string, editorIndex: n }, { editorIndex, codeClean }); } -export async function readFromCodeEditor(page: Page, editorIndex: number = 0): Promise { - await page.waitForFunction((data: { editorIndex: number }) => { +export async function readFromCodeEditor(page: Page, ariaLabel: string): Promise { + await page.waitForFunction((label: string) => { const editors = (window as any).monaco?.editor?.getEditors?.() || []; - if (editors.length <= data.editorIndex) { - return false; - } - - const editor = editors[data.editorIndex]; - const model = editor.getModel(); - const value = model ? model.getValue() : ''; - - return value.trim().length > 0; - }, { editorIndex }, { timeout: 10000 }); + return editors.some((editor: any) => { + const domNode = editor.getDomNode(); + const textarea = domNode?.querySelector('textarea'); + return textarea?.getAttribute('aria-label') === label; + }); + }, ariaLabel, { timeout: 10000 }); - return await page.evaluate((data: { editorIndex: number }) => { + return await page.evaluate((label: string) => { const editors = (window as any).monaco?.editor?.getEditors?.() || []; - const editor = editors[data.editorIndex]; - const model = editor.getModel(); + const editor = editors.find((e: any) => { + const domNode = e.getDomNode(); + const textarea = domNode?.querySelector('textarea'); + return textarea?.getAttribute('aria-label') === label; + }); + const model = editor?.getModel(); return model ? model.getValue() : ''; - }, { editorIndex }); + }, ariaLabel); +} + +export async function readFromCodeEditorAsJson( + page: Page, + ariaLabel: string +): Promise { + const content = await readFromCodeEditor(page, ariaLabel); + return JSON.parse(content); } diff --git a/test/e2e/utils/expect.ts b/test/e2e/utils/expect.ts index 4dbd9ca5..7d9fea13 100644 --- a/test/e2e/utils/expect.ts +++ b/test/e2e/utils/expect.ts @@ -1,5 +1,7 @@ import { expect, Page } from '@playwright/test'; import { Strings } from './lang'; +import { readFromCodeEditorAsJson } from './editor'; +import { getLabeledValueText } from './labeledValue'; export async function expectCompilationSucceeded(page: Page) { await expect(async () => { @@ -28,4 +30,61 @@ export function expectToHaveMultilineText(actual: string, expected: string) { for (const line of lines) { expect(actual).toContain(line); } +} + +export function expectToMatchTimestamp(actual: string) { + expect(actual).toMatch(/\d+ \w+ \d{4} at \d+:\d+( \(.+\))?/); +} + +export async function expectExecutionDetails(page: Page, options: { user?: string; status?: string } = {}) { + const { user = 'admin', status = 'succeeded' } = options; + + const executionStatus = page.locator('#execution-status'); + const executionId = await getLabeledValueText(page, 'ID', executionStatus); + expect(executionId).toMatch(/^\d{4}\/\d+\/\d+\/\d+\/\d+\//); + + const userName = await getLabeledValueText(page, 'User', executionStatus); + expect(userName).toBe(user); + + const statusBadge = await getLabeledValueText(page, 'Status', executionStatus); + expect(statusBadge.toLowerCase()).toBe(status.toLowerCase()); +} + +export async function expectExecutionTimings(page: Page) { + const executionTiming = page.locator('#execution-timing'); + + const startedAt = await getLabeledValueText(page, 'Started At', executionTiming); + expectToMatchTimestamp(startedAt); + + const duration = await getLabeledValueText(page, 'Duration', executionTiming); + expect(duration).toMatch(/\d+ ms \(\d+ seconds?\)/); + + const endedAt = await getLabeledValueText(page, 'Ended At', executionTiming); + expectToMatchTimestamp(endedAt); +} + +export async function expectExecutionInputs(page: Page, expectedInputs: Record) { + const inputs = await readFromCodeEditorAsJson>(page, 'Execution Inputs JSON'); + expect(inputs).toEqual(expectedInputs); +} + +export async function expectExecutionOutputs(page: Page, expectedOutputs: Array) { + const outputs = await readFromCodeEditorAsJson>(page, 'Execution Outputs JSON'); + expect(outputs).toHaveLength(expectedOutputs.length); + expectedOutputs.forEach((expected, index) => { + expect(outputs[index]).toMatchObject(expected); + }); +} + +export async function expectOutputTexts(page: Page, expectedTexts: string[]) { + for (const text of expectedTexts) { + await expect(page.getByText(text)).toBeVisible(); + } +} + +export async function expectOutputFileDownload(page: Page, buttonName: string, filenamePattern: RegExp) { + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: buttonName }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(filenamePattern); } \ No newline at end of file diff --git a/test/e2e/utils/labeledValue.ts b/test/e2e/utils/labeledValue.ts new file mode 100644 index 00000000..f2711ee5 --- /dev/null +++ b/test/e2e/utils/labeledValue.ts @@ -0,0 +1,24 @@ +import { Locator, Page } from '@playwright/test'; + +/** + * Gets the value text from a LabeledValue by its label + * Works with various HTML structures by: + * 1. Finding any element containing the label text + * 2. Getting its parent container + * 3. Finding the first non-empty sibling with the value + */ +export async function getLabeledValueText(page: Page, label: string, scope?: Locator): Promise { + const container = scope || page; + + // Find the label element (could be label, span, or any element with the text) + const labelElement = container.locator(`:text-is("${label}")`).first(); + + // Get the parent container (the Field wrapper) + const fieldContainer = labelElement.locator('..'); + + // Get all text content from the container and remove the label text + const fullText = await fieldContainer.textContent() || ''; + const valueText = fullText.replace(label, '').trim(); + + return valueText; +} 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 b663e5e7..99565ba1 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 @@ -21,7 +21,6 @@ void doRun() { def page = repo.get(inputs.value("pagePath")) try { def pageImage = page.child("jcr:content/image").ensure("nt:unstructured") - pageImage.child("file/jcr:content/dam:thumbnails").delete() pageImage.child("file").saveFile("image/jpeg", pageThumnail.readFileAsStream()) } finally { pageThumnail.delete() 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 ec6f92c5..891c4c42 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 @@ -25,7 +25,7 @@ graph TD */ void describeRun() { - inputs.integerNumber("count") { label = "Users to generate"; min = 1; value = 100000 } + 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" } } diff --git a/ui.frontend/src/App.css b/ui.frontend/src/App.css index b2d77194..682b3a92 100644 --- a/ui.frontend/src/App.css +++ b/ui.frontend/src/App.css @@ -7,6 +7,15 @@ input[type='search'] { box-sizing: border-box !important; } +/* Disable Coral UI 3 tooltips (native HTML5 validation) */ +.coral3-Tooltip, +coral-tooltip { + display: none !important; + visibility: hidden !important; + opacity: 0 !important; + pointer-events: none !important; +} + .coral3-Shell-header { z-index: 1; } diff --git a/ui.frontend/src/components/ExecutableMetadata.tsx b/ui.frontend/src/components/ExecutableMetadata.tsx index be2a9e66..5fa07918 100644 --- a/ui.frontend/src/components/ExecutableMetadata.tsx +++ b/ui.frontend/src/components/ExecutableMetadata.tsx @@ -3,9 +3,9 @@ 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 { Strings } from '../utils/strings'; import Markdown from './Markdown'; import SnippetCode from './SnippetCode'; -import { Strings } from '../utils/strings'; type ExecutableMetadataProps = { metadata: ExecutableMetadataType | null | undefined; @@ -40,6 +40,7 @@ const ExecutableMetadata: React.FC = ({ metadata }) => = ({ inputs }) => { = ({ inputs }) => { ) : ( - + )} diff --git a/ui.frontend/src/components/ExecutionOutputs.tsx b/ui.frontend/src/components/ExecutionOutputs.tsx index 86f362a1..3fc14d63 100644 --- a/ui.frontend/src/components/ExecutionOutputs.tsx +++ b/ui.frontend/src/components/ExecutionOutputs.tsx @@ -34,6 +34,7 @@ const ExecutionOutputs: React.FC = ({ outputs }) => { = ({ outputs }) => { ) : ( - + )} diff --git a/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx b/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx index 1ad3b0f8..9b35ad17 100644 --- a/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx +++ b/ui.frontend/src/components/ExecutionReviewOutputsButton.tsx @@ -125,7 +125,7 @@ const ExecutionReviewOutputsButton: React.FC Archive Console and generated outputs bundled as ZIP archive - @@ -138,7 +138,7 @@ const ExecutionReviewOutputsButton: React.FC Console Execution logs and errors as text file - @@ -151,7 +151,7 @@ const ExecutionReviewOutputsButton: React.FC {outputFile.label || Strings.capitalizeWords(outputFile.name)} {outputFile.description && {outputFile.description}} - diff --git a/ui.frontend/src/components/InfoCard.tsx b/ui.frontend/src/components/InfoCard.tsx index f8fc2f0a..a7da57c2 100644 --- a/ui.frontend/src/components/InfoCard.tsx +++ b/ui.frontend/src/components/InfoCard.tsx @@ -3,11 +3,12 @@ import { ReactNode } from 'react'; type InfoCardProps = { children: ReactNode; + id?: string; }; -const InfoCard = ({ children }: InfoCardProps) => { +const InfoCard = ({ children, id }: InfoCardProps) => { return ( - + {children} diff --git a/ui.frontend/src/components/PathPicker.tsx b/ui.frontend/src/components/PathPicker.tsx index b3ad5b31..5679e60e 100644 --- a/ui.frontend/src/components/PathPicker.tsx +++ b/ui.frontend/src/components/PathPicker.tsx @@ -338,7 +338,7 @@ const PathField = forwardRef(({ root, onSele return ( - + diff --git a/ui.frontend/src/components/SnippetCode.css b/ui.frontend/src/components/SnippetCode.css index 7865a059..a59d25a4 100644 --- a/ui.frontend/src/components/SnippetCode.css +++ b/ui.frontend/src/components/SnippetCode.css @@ -1,8 +1,12 @@ .snippet-code-wrapper { - overflow-x: auto; max-width: 100%; } +.snippet-code-wrapper-scrollable { + max-width: 100%; + overflow-x: auto; +} + .snippet-code { display: inline-block; min-width: 100%; @@ -11,7 +15,6 @@ font-weight: normal; unicode-bidi: isolate; white-space: pre; - padding: var(--spectrum-global-dimension-size-100); border-radius: var(--spectrum-global-dimension-size-50); } diff --git a/ui.frontend/src/components/SnippetCode.tsx b/ui.frontend/src/components/SnippetCode.tsx index 06e38e18..b7d75461 100644 --- a/ui.frontend/src/components/SnippetCode.tsx +++ b/ui.frontend/src/components/SnippetCode.tsx @@ -59,9 +59,10 @@ interface SnippetCodeProps { content: string; language: 'groovy' | 'java' | 'javascript' | 'typescript' | 'json' | 'xml' | 'yaml' | 'bash'; fontSize?: 'small' | 'medium'; + scrollable?: boolean; } -const SnippetCode: React.FC = ({ content, language, fontSize = 'medium' }) => { +const SnippetCode: React.FC = ({ content, language, fontSize = 'medium', scrollable = false }) => { const codeRef = useRef(null); useEffect(() => { @@ -77,7 +78,7 @@ const SnippetCode: React.FC = ({ content, language, fontSize = }, [content, language, fontSize]); return ( -
+
{content}
); diff --git a/ui.frontend/src/pages/ConsolePage.tsx b/ui.frontend/src/pages/ConsolePage.tsx index 48f3112d..708bc3cc 100644 --- a/ui.frontend/src/pages/ConsolePage.tsx +++ b/ui.frontend/src/pages/ConsolePage.tsx @@ -152,7 +152,7 @@ const ConsolePage = () => { - + @@ -179,7 +179,7 @@ const ConsolePage = () => { - + diff --git a/ui.frontend/src/pages/ExecutionView.tsx b/ui.frontend/src/pages/ExecutionView.tsx index 74991f43..f10ecfba 100644 --- a/ui.frontend/src/pages/ExecutionView.tsx +++ b/ui.frontend/src/pages/ExecutionView.tsx @@ -102,9 +102,8 @@ const ExecutionView = () => { - {/* Row 1: Execution Info */} - +
@@ -117,24 +116,22 @@ const ExecutionView = () => {
- +
- {/* Row 2: I/O */} - + - + - {/* Row 3: Executable Info */} - +
@@ -142,7 +139,7 @@ const ExecutionView = () => { {!isExecutableConsole(execution.executable.id) && } - + @@ -160,7 +157,7 @@ const ExecutionView = () => { - + @@ -186,7 +183,7 @@ const ExecutionView = () => { - + diff --git a/ui.frontend/vite.config.ts b/ui.frontend/vite.config.ts index deaa995e..cb349486 100644 --- a/ui.frontend/vite.config.ts +++ b/ui.frontend/vite.config.ts @@ -68,5 +68,10 @@ export default defineConfig({ build: { outDir: `../ui.apps/src/main/content/jcr_root${buildPath}`, emptyOutDir: true, + rollupOptions: { + output: { + chunkFileNames: (chunkInfo) => `chunks/${chunkInfo.name.replace(/[:[\]@]/g, '-')}-[hash].js`, + }, + }, }, });