This is an automated email from the ASF dual-hosted git repository. jamesbognar pushed a commit to branch release-manager in repository https://gitbox.apache.org/repos/asf/juneau.git
commit 0241c70c26c8142e0a1352605da9843d988eda33 Author: James Bognar <[email protected]> AuthorDate: Tue Aug 18 09:08:00 2026 -0400 Adopt console-ui chrome/theme, rich views toolkit, and add Playwright suite De-forks the app shell onto juneau-rest-server-console-ui's themed chrome (ReleaseManagerTheme, ConsoleAssetsRest) instead of app-local chrome CSS, and rebuilds the Releases tab on juneau-rest-server-views with the ribbon, unified paging control, per-column search and themed table styling. Adds an Admin surface and a standalone Playwright end-to-end suite under e2e/, which boots its own instance on a dedicated test port and is not wired into the Maven build. --- .gitignore | 6 + e2e/package-lock.json | 78 +++ e2e/package.json | 15 + e2e/playwright.config.ts | 64 +++ e2e/tests/home.spec.ts | 14 + e2e/tests/nav.spec.ts | 58 +++ e2e/tests/releases.spec.ts | 190 ++++++++ e2e/tests/smoke.spec.ts | 52 ++ e2e/tsconfig.json | 14 + pom.xml | 15 + .../org/apache/juneau/releng/AppConfiguration.java | 11 + .../apache/juneau/releng/ConsoleAssetsRest.java | 78 +++ .../apache/juneau/releng/ReleaseManagerTheme.java | 74 +++ .../java/org/apache/juneau/releng/RootRest.java | 3 +- .../org/apache/juneau/releng/rest/AdminRest.java | 98 ++++ .../apache/juneau/releng/rest/CredentialRest.java | 36 +- .../org/apache/juneau/releng/rest/HomeRest.java | 5 +- .../org/apache/juneau/releng/rest/ReleaseRest.java | 104 +++- .../apache/juneau/releng/rest/ReleaseRunRest.java | 5 +- src/main/resources/static/css/chrome.css | 539 ++++----------------- src/main/resources/static/css/new-release.css | 208 ++++---- src/main/resources/static/js/new-release.js | 24 +- src/main/resources/templates/admin.ftlh | 35 ++ src/main/resources/templates/base.ftlh | 60 ++- src/main/resources/templates/credentials.ftlh | 6 +- src/main/resources/templates/home.ftlh | 8 +- src/main/resources/templates/new-release.ftlh | 12 +- src/main/resources/templates/release-detail.ftlh | 4 +- src/main/resources/templates/releases.ftlh | 44 +- .../apache/juneau/releng/rest/AdminRestTest.java | 179 +++++++ .../apache/juneau/releng/rest/ReleaseRestTest.java | 48 ++ 31 files changed, 1439 insertions(+), 648 deletions(-) diff --git a/.gitignore b/.gitignore index 5e6084f339..65096e8747 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,9 @@ target/ # Eclipse IDE metadata (machine-specific; the shared .settings/ convention prefs ARE tracked) .classpath .project + +# Playwright e2e suite (e2e/) - dependencies + generated run artifacts +e2e/node_modules/ +e2e/test-results/ +e2e/playwright-report/ +e2e/.playwright/ diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000000..d1799ef082 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "release-manager-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "release-manager-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.55.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://nexus-proxy.repo.local.sfdc.net/nexus/content/groups/npm-all/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://nexus-proxy.repo.local.sfdc.net/nexus/content/groups/npm-all/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://nexus-proxy.repo.local.sfdc.net/nexus/content/groups/npm-all/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://nexus-proxy.repo.local.sfdc.net/nexus/content/groups/npm-all/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000000..3d54242d4c --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,15 @@ +{ + "name": "release-manager-e2e", + "version": "1.0.0", + "private": true, + "description": "Playwright end-to-end tests for the Apache Juneau Release Manager app. Standalone (not wired into the Maven build) — boots its own app instance on a dedicated test port.", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:ui": "playwright test --ui", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.55.0" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000000..3d84997d8c --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,64 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'path'; +import os from 'os'; + +/** + * Dedicated test port for the Spring Boot app this suite boots for itself. MUST NOT be 8790 — that port is + * owned by a separately-running coordinator instance of the app that this suite must never touch. + */ +const TEST_PORT = 8791; +const BASE_URL = `http://127.0.0.1:${TEST_PORT}`; + +// The app repo root is the parent of this e2e/ directory. +const APP_ROOT = path.resolve(__dirname, '..'); + +// Personal JDK layout used across this workspace: ~/jdk/default -> current JDK 17 install. +const JAVA_HOME = process.env.JAVA_HOME ?? path.join(os.homedir(), 'jdk', 'default'); + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + // Single worker always: all tests share ONE webServer-booted app instance (dedicated test port), and hammering + // a single dev-mode Tomcat/Spring instance with concurrent first-touch requests was observed to intermittently + // 500 (likely a lazy-init race in the app, not something this suite should paper over with retries). Re-tested + // with the unified-paging-ribbon suite (Aug 2026): re-enabling parallelism (8 workers) still failed ~2/3 runs + // on the very first navigation (`/rest/releases` intermittently 500s before the app's lazy init settles). + workers: 1, + reporter: 'html', + timeout: 30_000, + + use: { + baseURL: BASE_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + // Boots the app on its OWN dedicated port (8791), fully independent of the coordinator's live instance on + // 8790. `spring-boot.run.arguments=--server.port=...` passes a command-line argument to the forked app JVM, + // which Spring's property-source precedence honors over the server.port=8790 baked into + // application.properties. Maven dependency resolution can be slow on a cold cache, but the app itself starts + // in ~1s once the classpath is resolved — 120s covers both. `reuseExistingServer: !CI` lets a developer who + // already has a local instance running on 8791 skip the (re)boot; it never touches 8790 either way. + webServer: { + command: + `mvn -q spring-boot:run -Dspring-boot.run.arguments=--server.port=${TEST_PORT}`, + url: BASE_URL + '/rest/home', + cwd: APP_ROOT, + timeout: 120_000, + reuseExistingServer: !process.env.CI, + env: { + ...process.env, + JAVA_HOME, + PATH: `${path.join(JAVA_HOME, 'bin')}:${process.env.PATH}`, + }, + }, +}); diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts new file mode 100644 index 0000000000..244096e6f4 --- /dev/null +++ b/e2e/tests/home.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Home page', () => { + test('boots and renders the workflow heading', async ({ page }) => { + const response = await page.goto('/rest/home'); + expect(response?.status()).toBe(200); + + await expect(page.getByRole('heading', { name: 'Workflow' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Prerequisites' })).toBeVisible(); + + // Home nav tab is marked active on its own page. + await expect(page.getByRole('link', { name: 'Home' })).toHaveClass(/active/); + }); +}); diff --git a/e2e/tests/nav.spec.ts b/e2e/tests/nav.spec.ts new file mode 100644 index 0000000000..e465188a36 --- /dev/null +++ b/e2e/tests/nav.spec.ts @@ -0,0 +1,58 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** + * Each top-nav tab points at a real /rest/* route (base.ftlh) — this table is the single source of truth for + * which route each tab should land on and what landmark proves the right page rendered. Kept independent of the + * control-row/ribbon markup (which is in flux) — these are page-level landmarks only. + */ +const NAV_TABS: { name: string; path: string; heading: string | RegExp }[] = [ + { name: 'Home', path: '/rest/home', heading: 'Workflow' }, + { name: 'Credentials', path: '/rest/credentials', heading: /apache|github|gpg/i }, + { name: 'Releases', path: '/rest/releases', heading: 'All Releases' }, + // New Release has no single fixed heading — its Input-vs-Execution subtab starts on whichever one matches + // current run state (state-dependent), so it's asserted separately below via the always-present subtab bar. + { name: 'New Release', path: '/rest/runs', heading: null }, + { name: 'Admin', path: '/rest/admin', heading: 'Admin' }, +]; + +// The Admin page composes its own tab bar reusing the "Releases"/"Credentials" labels, so every top-nav lookup +// is scoped to the persistent header nav (base.ftlh's `nav.jc-nav`) to avoid ambiguity there. +function topNav(page: Page) { + return page.locator('nav.jc-nav'); +} + +test.describe('Top navigation', () => { + for (const tab of NAV_TABS) { + test(`"${tab.name}" tab is present and navigates to a working page`, async ({ page }) => { + await page.goto('/rest/home'); + + const link = topNav(page).getByRole('link', { name: tab.name, exact: true }); + await expect(link).toBeVisible(); + await expect(link).toHaveAttribute('href', tab.path); + + const response = await page.goto(tab.path); + expect(response?.status()).toBe(200); + + if (tab.name === 'New Release') { + // Input/Execution subtabs (role="tab") are always present regardless of whether a run is active. + await expect(page.getByRole('tab', { name: 'Input' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Execution' })).toBeVisible(); + } else if (tab.heading instanceof RegExp) { + // Credentials has no single fixed heading (data-driven content), so assert on a resilient text + // landmark instead of a specific DOM structure. + await expect(page.getByText(tab.heading).first()).toBeVisible(); + } else if (tab.heading) { + await expect(page.getByRole('heading', { name: tab.heading })).toBeVisible(); + } + }); + } + + test('all five nav tabs are present on every page', async ({ page }) => { + for (const startTab of NAV_TABS) { + await page.goto(startTab.path); + for (const tab of NAV_TABS) { + await expect(topNav(page).getByRole('link', { name: tab.name, exact: true })).toBeVisible(); + } + } + }); +}); diff --git a/e2e/tests/releases.spec.ts b/e2e/tests/releases.spec.ts new file mode 100644 index 0000000000..60947eb463 --- /dev/null +++ b/e2e/tests/releases.spec.ts @@ -0,0 +1,190 @@ +import { test, expect, type Locator, type Page } from '@playwright/test'; + +/** + * The Releases tab renders a rich-view toolkit table: `table#releases` / `table[data-juneau-view="releases"]`, + * hydrated client-side from `/rest/releases/data` by juneau-views.js. Row content comes from real `git tag + * juneau-*` history in the local apache/juneau checkout (rm.repo.dir), so "9.2.0" is expected to always be + * present as a released version in this environment. + */ +const TABLE_SELECTOR = '#releases, [data-juneau-view="releases"]'; + +function releasesTable(page: Page): Locator { + return page.locator(TABLE_SELECTOR); +} + +function dataRows(page: Page): Locator { + // Scope to tbody rows only — the header row (with any per-column search inputs) lives in <thead>. + return releasesTable(page).locator('tbody tr'); +} + +test.describe('Releases table', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/rest/releases'); + await expect(releasesTable(page)).toBeVisible(); + // Wait for the real ajax-loaded data (not DataTables' transient "loading"/"no data" placeholder row) before + // any test measures row counts, so a race against the initial draw can't be mistaken for a filter effect. + await expect(releasesTable(page).getByText('9.2.0', { exact: true }).first()).toBeVisible(); + }); + + test('loads more than zero data rows', async ({ page }) => { + await expect(dataRows(page).first()).toBeVisible(); + const count = await dataRows(page).count(); + expect(count).toBeGreaterThan(0); + }); + + test('a known released version appears in the table', async ({ page }) => { + await expect(releasesTable(page).getByText('9.2.0', { exact: true }).first()).toBeVisible(); + }); + + test('status pills render with expected text', async ({ page }) => { + // "tag:status" rendering keeps the raw status value as display text (e.g. RELEASED for tag-derived rows). + // Other statuses (DRAFT/DISTRIBUTED/FAILED) are state-dependent and not asserted here since they depend on + // whatever in-progress runs happen to exist locally. + await expect(releasesTable(page).locator('.tag.status').filter({ hasText: 'RELEASED' }).first()).toBeVisible(); + }); + + test('search filters the visible row count down', async ({ page }) => { + const initialCount = await dataRows(page).count(); + expect(initialCount).toBeGreaterThan(0); + + const searchBox = page.getByRole('searchbox').or(page.getByPlaceholder(/search/i)).first(); + await expect(searchBox).toBeVisible(); + await searchBox.fill('9.0'); + + // expect.poll auto-retries until DataTables' redraw settles. + await expect + .poll(async () => dataRows(page).count(), { message: 'row count should drop below the pre-filter count' }) + .toBeLessThan(initialCount); + + // "9.0" should isolate the 9.0.x releases specifically. + await expect(releasesTable(page).getByText('9.0.0', { exact: true }).first()).toBeVisible(); + }); + + test.describe('ribbon (right actions cluster — data-testid="ribbon")', () => { + // Accessible-name-based assertions only — the control row's exact grouping/order isn't asserted here, + // just that the expected actions exist, are reachable by accessible name, and live in the right cluster. + function ribbon(page: Page): Locator { + return page.getByTestId('ribbon'); + } + + test('export (copy/csv) and refresh actions are present and enabled', async ({ page }) => { + const copyBtn = ribbon(page).getByRole('button', { name: /copy/i }); + const csvBtn = ribbon(page).getByRole('button', { name: /csv/i }); + const refreshBtn = ribbon(page).getByRole('button', { name: /refresh/i }); + + await expect(copyBtn).toBeVisible(); + await expect(copyBtn).toBeEnabled(); + await expect(csvBtn).toBeVisible(); + await expect(csvBtn).toBeEnabled(); + await expect(refreshBtn).toBeVisible(); + await expect(refreshBtn).toBeEnabled(); + }); + + test('refresh re-draws the table without erroring', async ({ page }) => { + const refreshBtn = ribbon(page).getByRole('button', { name: /refresh/i }); + await refreshBtn.click(); + // The table should still be present and populated after a refresh. + await expect(dataRows(page).first()).toBeVisible(); + }); + }); + + test.describe('paging (unified ribbon — single control, data-testid="paging")', () => { + // Paging now exists in exactly ONE place: the unified segmented ribbon rendered with data-testid="paging" + // (juneau-views.js buildPagingPill). Scoping to it is no longer strictly required for disambiguation (the + // old redundant right-side compact prev/next ribbon is gone), but keeping the scope is cheap and future-proof. + function paging(page: Page): Locator { + return page.getByTestId('paging'); + } + + test('paging ribbon controls are present by accessible name', async ({ page }) => { + // aria-labels per juneau-views.js's buildPagingPill: "First page"/"Previous page"/"Next page"/"Last page". + const pill = paging(page); + await expect(pill.getByRole('button', { name: 'First page' })).toBeVisible(); + await expect(pill.getByRole('button', { name: 'Previous page' })).toBeVisible(); + await expect(pill.getByRole('button', { name: 'Next page' })).toBeVisible(); + await expect(pill.getByRole('button', { name: 'Last page' })).toBeVisible(); + }); + + test('first/prev/next/last are correctly enabled/disabled at the boundaries', async ({ page }) => { + const totalRows = await dataRows(page).count(); + test.skip(totalRows <= 25, 'not enough rows in this environment to exercise multi-page paging'); + + const pill = paging(page); + const firstBtn = pill.getByRole('button', { name: 'First page' }); + const prevBtn = pill.getByRole('button', { name: 'Previous page' }); + const nextBtn = pill.getByRole('button', { name: 'Next page' }); + const lastBtn = pill.getByRole('button', { name: 'Last page' }); + + // On page 1: First/Prev disabled, Next/Last enabled (assuming more than one page of data). + await expect(firstBtn).toBeDisabled(); + await expect(prevBtn).toBeDisabled(); + await expect(nextBtn).toBeEnabled(); + await expect(lastBtn).toBeEnabled(); + + await lastBtn.click(); + // On the last page: First/Prev enabled, Next/Last disabled. + await expect(nextBtn).toBeDisabled(); + await expect(lastBtn).toBeDisabled(); + await expect(firstBtn).toBeEnabled(); + await expect(prevBtn).toBeEnabled(); + + await firstBtn.click(); + await expect(firstBtn).toBeDisabled(); + await expect(prevBtn).toBeDisabled(); + }); + + test('when there are enough rows, paging to the last page changes the displayed rows', async ({ page }) => { + const totalRows = await dataRows(page).count(); + // Default page size is 25 rows (juneau-views.js PAGE_SIZE_OPTIONS[0]); only meaningful to page if there's + // more than one page's worth of data. Skip gracefully otherwise rather than asserting a false negative. + test.skip(totalRows <= 25, 'not enough rows in this environment to exercise multi-page paging'); + + const firstRowBefore = await dataRows(page).first().innerText(); + await paging(page).getByRole('button', { name: 'Last page' }).click(); + await expect + .poll(async () => dataRows(page).first().innerText()) + .not.toBe(firstRowBefore); + }); + + test('the range segment doubles as a page-size menu button', async ({ page }) => { + const menuBtn = paging(page).locator('.juneau-view-pagingpill-menubtn'); + await expect(menuBtn).toBeVisible(); + await expect(menuBtn).toHaveAttribute('aria-haspopup', 'listbox'); + await expect(menuBtn).toHaveAttribute('aria-expanded', 'false'); + + await menuBtn.click(); + await expect(menuBtn).toHaveAttribute('aria-expanded', 'true'); + + const menu = paging(page).getByRole('listbox'); + await expect(menu).toBeVisible(); + const options = menu.getByRole('option'); + // "25 rows" / "100 rows" / "All rows", per juneau-views.js PAGE_SIZE_OPTIONS. + await expect(options).toHaveCount(3); + + // Escape closes the menu and returns focus to the button, without changing the page size. + await page.keyboard.press('Escape'); + await expect(menuBtn).toHaveAttribute('aria-expanded', 'false'); + await expect(menuBtn).toBeFocused(); + }); + + test('picking a larger page size from the menu grows the visible row count and updates the range text', async ({ page }) => { + const totalRows = await dataRows(page).count(); + test.skip(totalRows <= 25, 'not enough rows in this environment to observe a row-count increase at size 100'); + + const menuBtn = paging(page).locator('.juneau-view-pagingpill-menubtn'); + const initialRangeText = await menuBtn.innerText(); + + await menuBtn.click(); + await paging(page).getByRole('option', { name: '100 rows' }).click(); + + // expect.poll auto-retries until DataTables' redraw settles. + await expect + .poll(async () => dataRows(page).count(), { message: 'row count should grow once page size is 100' }) + .toBeGreaterThan(25); + await expect + .poll(async () => menuBtn.innerText()) + .not.toBe(initialRangeText); + await expect(menuBtn).toHaveAttribute('aria-expanded', 'false'); + }); + }); +}); diff --git a/e2e/tests/smoke.spec.ts b/e2e/tests/smoke.spec.ts new file mode 100644 index 0000000000..862b58fe77 --- /dev/null +++ b/e2e/tests/smoke.spec.ts @@ -0,0 +1,52 @@ +import { test, expect, type Page } from '@playwright/test'; + +/** Collects `console.error`-severity messages for the lifetime of a page navigation. */ +function collectConsoleErrors(page: Page): string[] { + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + page.on('pageerror', (err) => errors.push(err.message)); + return errors; +} + +test.describe('Smoke: primary content renders without console errors', () => { + test('New Release page renders its primary content', async ({ page }) => { + const errors = collectConsoleErrors(page); + const response = await page.goto('/rest/runs'); + expect(response?.status()).toBe(200); + + // Input/Execution subtabs are always present regardless of whether a run is active. + await expect(page.getByRole('tab', { name: 'Input' })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Execution' })).toBeVisible(); + + expect(errors, `console errors on /rest/runs: ${errors.join('; ')}`).toEqual([]); + }); + + test('Admin page renders its primary content', async ({ page }) => { + const errors = collectConsoleErrors(page); + const response = await page.goto('/rest/admin'); + expect(response?.status()).toBe(200); + + await expect(page.getByRole('heading', { name: 'Admin' })).toBeVisible(); + // Composed tab bar (PageTable) — leaf tabs for the two dogfooded views. Scoped to .jc-tab-bar (the + // PageTable-owned tablist) since the top nav also has same-named "Releases"/"Credentials" links. + const adminTabBar = page.locator('.jc-tab-bar'); + await expect(adminTabBar.getByText('Releases', { exact: true })).toBeVisible(); + await expect(adminTabBar.getByText('Credentials', { exact: true })).toBeVisible(); + + expect(errors, `console errors on /rest/admin: ${errors.join('; ')}`).toEqual([]); + }); + + test('Credentials page renders its primary content', async ({ page }) => { + const errors = collectConsoleErrors(page); + const response = await page.goto('/rest/credentials'); + expect(response?.status()).toBe(200); + + // One card per managed credential (Apache LDAP / GPG / GitHub) — assert on the label text rather than a + // specific count, since the set of managed credentials could grow. + await expect(page.getByText(/GitHub token/i)).toBeVisible(); + + expect(errors, `console errors on /rest/credentials: ${errors.join('; ')}`).toEqual([]); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000000..a8428a10e7 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["playwright.config.ts", "tests/**/*.ts"] +} diff --git a/pom.xml b/pom.xml index 62a0d01259..2cfb48f198 100644 --- a/pom.xml +++ b/pom.xml @@ -61,6 +61,21 @@ <artifactId>juneau-rest-server-view-freemarker</artifactId> <version>${juneau.version}</version> </dependency> + <dependency> + <groupId>org.apache.juneau</groupId> + <artifactId>juneau-rest-server-console-ui</artifactId> + <version>${juneau.version}</version> + </dependency> + <dependency> + <groupId>org.apache.juneau</groupId> + <artifactId>juneau-rest-server-console-ui-freemarker</artifactId> + <version>${juneau.version}</version> + </dependency> + <dependency> + <groupId>org.apache.juneau</groupId> + <artifactId>juneau-rest-server-views</artifactId> + <version>${juneau.version}</version> + </dependency> <dependency> <groupId>org.apache.juneau</groupId> <artifactId>juneau-secret-keychain</artifactId> diff --git a/src/main/java/org/apache/juneau/releng/AppConfiguration.java b/src/main/java/org/apache/juneau/releng/AppConfiguration.java index fbbb9a61d0..495b99d0e5 100644 --- a/src/main/java/org/apache/juneau/releng/AppConfiguration.java +++ b/src/main/java/org/apache/juneau/releng/AppConfiguration.java @@ -183,6 +183,17 @@ public class AppConfiguration { return new ServletRegistrationBean<>(rest, "/rest/*"); } + /** + * The shared console-ui chrome stylesheet + themeable logo/page-background assets, mounted independently of + * {@code /rest/*} so the site-absolute {@code /juneau-console/*} URLs every tab's {@code base.ftlh} links + * against resolve the same way regardless of which tab rendered the page. See {@link ConsoleAssetsRest}'s + * class Javadoc for why the servlet itself corrects for the container's servlet-path handling. + */ + @Bean + public ServletRegistrationBean<Servlet> consoleAssetsRegistration() { + return new ServletRegistrationBean<>(new ConsoleAssetsRest(), "/juneau-console/*"); + } + // ========================================================================================== // Release-orchestration engine and New Release tab wiring. // ========================================================================================== diff --git a/src/main/java/org/apache/juneau/releng/ConsoleAssetsRest.java b/src/main/java/org/apache/juneau/releng/ConsoleAssetsRest.java new file mode 100644 index 0000000000..b8d1746553 --- /dev/null +++ b/src/main/java/org/apache/juneau/releng/ConsoleAssetsRest.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.releng; + +import java.io.IOException; + +import org.apache.juneau.commons.inject.Bean; +import org.apache.juneau.rest.server.Rest; +import org.apache.juneau.rest.server.console.ConsoleChromeMixin; +import org.apache.juneau.rest.server.servlet.BasicRestServlet; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Serves the shared console-ui chrome stylesheet and its two themeable assets (logo, page-background image) at + * the server root, independent of the app's own {@code /rest/*} mount, so the {@code <link>} reference in every + * tab's {@code base.ftlh} resolves the same way regardless of which tab rendered the page. + * + * <p> + * Registered directly (not composed onto {@code HomeRest}/{@code ReleaseRest}/etc.) via a dedicated + * {@code ServletRegistrationBean} in {@link AppConfiguration} at {@code /juneau-console/*} — see there for + * why. + * + * <h5 class='section'>Servlet-path double-consumption fix:</h5> + * <p> + * {@code ConsoleChromeMixin}'s {@code @RestGet} paths are absolute-looking literals baked into the shipped jar + * ({@code ConsoleChromeMixin#CHROME_CSS_PATH} et al., e.g. {@code "/juneau-console/chrome.css"}). Juneau's + * request dispatch resolves an operation's path against {@code getContextPath() + getServletPath()} subtracted + * from the request URI — <b>not</b> against the servlet container's {@code getPathInfo()}. Mounting this + * servlet at the container url-pattern {@code "/juneau-console/*"} (mirroring the app's other + * {@code ServletRegistrationBean} mounts, e.g. {@code NexusMockRest} at {@code /mock/nexus/*}) makes the + * container report {@code servletPath="/juneau-console"}, leaving only {@code "/chrome.css"} once that prefix is + * subtracted — one copy of the {@code /juneau-console} segment short of what the mixin's hardcoded path + * expects, so the real endpoint would only resolve at the doubled + * {@code /juneau-console/juneau-console/chrome.css}. {@link #service} corrects this at the servlet boundary by + * wrapping every request so {@code getServletPath()} reports an empty string, which makes Juneau compute the + * same path it would if this servlet were mounted at the site root — without actually claiming the site + * root (and disrupting the app's other routes: Spring MVC static resources, {@code /rest/*}, + * {@code /mock/nexus/*}, {@code /events/*}), since the container's own {@code /juneau-console/*} url-pattern + * still gates which requests even reach this servlet. + */ +@Rest(mixins=ConsoleChromeMixin.class) +public class ConsoleAssetsRest extends BasicRestServlet { + private static final long serialVersionUID = 1L; + + @Bean + public ConsoleChromeMixin consoleChrome() { + return ConsoleChromeMixin.create() + .theme(ReleaseManagerTheme.INSTANCE) + .logo("/static/img/oakleaf.svg") + .pageBackgroundImage("/static/img/topo-bg.png") + .build(); + } + + @Override /* Overridden from HttpServlet */ + public void service(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException { + super.service(new HttpServletRequestWrapper(req) { + @Override public String getServletPath() { return ""; } + }, res); + } +} diff --git a/src/main/java/org/apache/juneau/releng/ReleaseManagerTheme.java b/src/main/java/org/apache/juneau/releng/ReleaseManagerTheme.java new file mode 100644 index 0000000000..1bf98b19c7 --- /dev/null +++ b/src/main/java/org/apache/juneau/releng/ReleaseManagerTheme.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.releng; + +import org.apache.juneau.rest.server.console.Theme; + +/** + * The Release Manager app's console-ui theme. + * + * <p> + * Its token values currently just restate {@link Theme#OPEN}'s own values — the app's existing look already + * matches the shipped default. A dedicated class (rather than an inline builder call at the mixin-wiring site) + * gives the app one obvious, named place to diverge its palette later. The logo and page-background image are + * deliberately <b>not</b> part of this theme — those are {@code ConsoleChromeMixin} builder inputs (see + * {@link ConsoleAssetsRest}), kept out of the token model entirely. + */ +public final class ReleaseManagerTheme { + + /** The Release Manager's console-ui theme. */ + public static final Theme INSTANCE = build(); + + private ReleaseManagerTheme() {} + + private static Theme build() { + return Theme.create("release-manager") + .token("--jc-font", "'Inter', 'Source Sans 3', system-ui, sans-serif") + .token("--jc-page-bg", "linear-gradient(180deg, #b0c4df 0%, #c7d5e8 22%, #e4eaf2 55%, #f5f6f9 100%)") + .token("--jc-header-icon-color", "#747474") + .token("--jc-accent", "#1589EE") + .token("--jc-accent-wash", "rgba(21,137,238,0.1)") + .token("--jc-link", "#0174d3") + .token("--jc-text", "#080707") + .token("--jc-text-soft", "#080707cc") + .token("--jc-text-muted", "#706e6b") + .token("--jc-border", "#dddbda") + .token("--jc-border-2", "#ced4da") + .token("--jc-card-bg", "#f5f6f9") + .token("--jc-chrome-bg", "#f3f2f2") + .token("--jc-white", "#ffffff") + .token("--jc-btn-primary", "#1a5297") + .token("--jc-btn-primary-hover", "#005fb2") + .token("--jc-danger", "#c23934") + .token("--jc-success", "#2e844a") + .token("--jc-avatar-bg", "linear-gradient(135deg, #1589EE, #1a5297)") + .token("--jc-radius", "0.25rem") + .token("--jc-tag-green-bg", "#b8e6c4") + .token("--jc-tag-green-text", "#155724") + .token("--jc-tag-green-border", "#9fd6ad") + .token("--jc-tag-blue-bg", "#dceefb") + .token("--jc-tag-blue-text", "#0c5460") + .token("--jc-tag-blue-border", "#c3e0f3") + .token("--jc-tag-amber-bg", "#fff3cd") + .token("--jc-tag-amber-text", "#856404") + .token("--jc-tag-amber-border", "#ffe69c") + .token("--jc-tag-neutral-bg", "#e2e3e5") + .token("--jc-tag-neutral-text", "#383d41") + .token("--jc-tag-neutral-border", "#c6c8ca") + .build(); + } +} diff --git a/src/main/java/org/apache/juneau/releng/RootRest.java b/src/main/java/org/apache/juneau/releng/RootRest.java index 969f289719..625bd25d28 100644 --- a/src/main/java/org/apache/juneau/releng/RootRest.java +++ b/src/main/java/org/apache/juneau/releng/RootRest.java @@ -24,9 +24,10 @@ import org.apache.juneau.releng.rest.ReleaseRest; import org.apache.juneau.releng.rest.MilestoneRest; import org.apache.juneau.releng.rest.CredentialRest; import org.apache.juneau.releng.rest.ReleaseRunRest; +import org.apache.juneau.releng.rest.AdminRest; @Rest(path = "/rest/*", title = "Apache Juneau · Release Manager", children = { HomeRest.class, ReleaseRest.class, - MilestoneRest.class, CredentialRest.class, ReleaseRunRest.class }) + MilestoneRest.class, CredentialRest.class, ReleaseRunRest.class, AdminRest.class }) @SuppressWarnings({ "java:S110" // Inheritance depth is imposed by the Juneau REST servlet hierarchy. }) public class RootRest extends BasicSpringRestServletGroup { diff --git a/src/main/java/org/apache/juneau/releng/rest/AdminRest.java b/src/main/java/org/apache/juneau/releng/rest/AdminRest.java new file mode 100644 index 0000000000..6b0c439c69 --- /dev/null +++ b/src/main/java/org/apache/juneau/releng/rest/AdminRest.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.juneau.releng.rest; + +import org.apache.juneau.commons.inject.Bean; +import org.apache.juneau.marshall.html.HtmlSerializer; +import org.apache.juneau.rest.server.Rest; +import org.apache.juneau.rest.server.RestGet; +import org.apache.juneau.rest.server.servlet.BasicRestResource; +import org.apache.juneau.rest.server.view.View; +import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin; +import org.apache.juneau.rest.server.view.freemarker.FreemarkerView; +import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer; +import org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin; +import org.apache.juneau.rest.server.views.PageDef; +import org.apache.juneau.rest.server.views.PageTable; +import org.apache.juneau.rest.server.views.Tab; +import org.apache.juneau.rest.server.views.ViewsMixin; + +/** + * Admin tab (TODO-399 Phase C dogfood): a single multi-tab page composing the app's existing + * {@link ReleaseRest#releasesView() Releases} and {@link CredentialRest#credentialsView() Credentials} rich views + * into one {@link PageDef}, rendered by {@link PageTable}. + * + * <p> + * This resource references each tab's child {@link org.apache.juneau.rest.server.views.ViewDef ViewDef} by calling + * the sibling resources' declarative view-builder methods directly — it does not duplicate their column/ribbon + * definitions, and each child view's {@code dataUrl} stays absolute (its owning resource's own mount), so the + * ajax data draws still hit {@link ReleaseRest#data()} / {@link CredentialRest#status()} exactly as they do from + * the standalone Releases/Credentials pages. Per {@link PageTable}'s contract, the emitted per-view markup (marker + * table + VIEW_META sidecar) is byte-for-byte identical to what {@link ReleaseRest#page()} / + * {@code CredentialRest}'s own view would emit standalone — this resource only adds the tab-bar/panel shell + * and the PAGE_META sidecar around them. + */ +@Rest(path = "/admin", title = "Admin", responseProcessors = FreemarkerViewRenderer.class, mixins = ViewsMixin.class) +public class AdminRest extends BasicRestResource { + + /** This resource's absolute mount (RootRest {@code /rest/*} + {@code /admin}), used to resolve asset URLs. */ + static final String MOUNT = "/rest/admin"; + + // Return type stays FreemarkerMixin - FreemarkerViewRenderer does an exact-type bean lookup (see + // ConsoleFreemarkerMixin's class Javadoc). + @Bean + public FreemarkerMixin freemarker() { + return ConsoleFreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build(); + } + + /** + * The composed page definition: one leaf tab per existing rich view. {@code build()} validates unique tab ids + * and unique referenced {@code ViewDef} ids across the page (TODO-399 Phase C {@code PageDef} validation + * rules). + */ + static PageDef adminPage() { + return PageDef.create("admin") + .title("Admin") + .tabs( + Tab.create("releases", "Releases").view(ReleaseRest.releasesView()), + Tab.create("credentials", "Credentials").view(CredentialRest.credentialsView())) + .build(); + } + + /** Human page — the composed tab/sub-tab page shell (emitted as trusted markup) + PAGE_META sidecar. */ + @RestGet("/") + public View page() { + var markup = HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(PageTable.of(adminPage())); + return FreemarkerView.of("admin") + .attr("pageTable", markup) + .attr("viewsCssUrl", asset(ViewsMixin.VIEWS_CSS_PATH)) + .attr("rendersJsUrl", asset(ViewsMixin.RENDERS_JS_PATH)) + .attr("iconsJsUrl", asset(ViewsMixin.ICONS_JS_PATH)) + .attr("ribbonJsUrl", asset(ViewsMixin.RIBBON_JS_PATH)) + .attr("viewsJsUrl", asset(ViewsMixin.VIEWS_JS_PATH)) + .attr("pagesJsUrl", asset(ViewsMixin.PAGES_JS_PATH)); + } + + /** + * Resolves a toolkit asset to an absolute, cache-busted URL for the FreeMarker head block, mirroring + * {@code ReleaseRest#asset(String)}. + */ + private static String asset(String path) { + return ViewsMixin.viewAssetUrl(path).replace("servlet:", MOUNT); + } +} diff --git a/src/main/java/org/apache/juneau/releng/rest/CredentialRest.java b/src/main/java/org/apache/juneau/releng/rest/CredentialRest.java index fa3b625b6c..848c546d20 100644 --- a/src/main/java/org/apache/juneau/releng/rest/CredentialRest.java +++ b/src/main/java/org/apache/juneau/releng/rest/CredentialRest.java @@ -30,6 +30,12 @@ import org.apache.juneau.rest.server.view.View; import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin; import org.apache.juneau.rest.server.view.freemarker.FreemarkerView; import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer; +import org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin; +import org.apache.juneau.rest.server.views.Column; +import org.apache.juneau.rest.server.views.RibbonAction; +import org.apache.juneau.rest.server.views.ViewDef; +import org.apache.juneau.rest.server.views.ViewDef.DataMode; +import org.apache.juneau.rest.server.views.ViewDef.Dir; import org.apache.juneau.releng.credential.CredentialService; import org.apache.juneau.releng.credential.CredentialStatus; import org.apache.juneau.releng.credential.Validator.ValidationResult; @@ -38,15 +44,43 @@ import org.apache.juneau.releng.credential.Validator.ValidationResult; @Rest(path = "/credentials", title = "Credentials", responseProcessors = FreemarkerViewRenderer.class) public class CredentialRest extends BasicRestResource { + /** This resource's absolute mount (RootRest {@code /rest/*} + {@code /credentials}), used by {@link #credentialsView()}. */ + static final String MOUNT = "/rest/credentials"; + private final CredentialService service; public CredentialRest(CredentialService service) { this.service = service; } + /** + * The rich-view toolkit's declarative view of the Credentials list (TODO-399 Phase C dogfood): a second, + * independently-composable {@link ViewDef} alongside {@link ReleaseRest#releasesView()}, wired into the RM + * {@code Admin} tab page ({@code AdminRest}). Client-side data mode: {@link #status()} already returns the + * bare {@code List<CredentialStatus>} the toolkit's client-mode ajax (({@code dataSrc: ""})) expects, so no new + * server-side query wiring is needed. + */ + static ViewDef credentialsView() { + return ViewDef.create("credentials") + .rowType(CredentialStatus.class) + .dataMode(DataMode.CLIENT) + .dataUrl(MOUNT + "/status") + .defaultOrder("name", Dir.ASC) + .columns( + Column.of("name").title("Name"), + Column.of("label").title("Label"), + Column.of("present").title("Present"), + Column.of("lastValid").title("Valid"), + Column.of("lastMessage").title("Message")) + .ribbon(RibbonAction.refresh()) + .build(); + } + + // Return type stays FreemarkerMixin - FreemarkerViewRenderer does an exact-type bean lookup (see + // ConsoleFreemarkerMixin's class Javadoc). @Bean public FreemarkerMixin freemarker() { - return FreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build(); + return ConsoleFreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build(); } /** Human page. */ diff --git a/src/main/java/org/apache/juneau/releng/rest/HomeRest.java b/src/main/java/org/apache/juneau/releng/rest/HomeRest.java index 13eb2b32ba..eb6c115be6 100644 --- a/src/main/java/org/apache/juneau/releng/rest/HomeRest.java +++ b/src/main/java/org/apache/juneau/releng/rest/HomeRest.java @@ -25,14 +25,17 @@ import org.apache.juneau.rest.server.view.View; import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin; import org.apache.juneau.rest.server.view.freemarker.FreemarkerView; import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer; +import org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin; /** Home tab: default landing page with basic usage instructions. */ @Rest(path = "/home", title = "Home", responseProcessors = FreemarkerViewRenderer.class) public class HomeRest extends BasicRestResource { + // Return type stays FreemarkerMixin - FreemarkerViewRenderer does an exact-type bean lookup (see + // ConsoleFreemarkerMixin's class Javadoc). @Bean public FreemarkerMixin freemarker() { - return FreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build(); + return ConsoleFreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build(); } /** Human page. */ diff --git a/src/main/java/org/apache/juneau/releng/rest/ReleaseRest.java b/src/main/java/org/apache/juneau/releng/rest/ReleaseRest.java index ccc0fb8ebb..6b8879d7e8 100644 --- a/src/main/java/org/apache/juneau/releng/rest/ReleaseRest.java +++ b/src/main/java/org/apache/juneau/releng/rest/ReleaseRest.java @@ -21,39 +21,129 @@ import java.util.List; import org.apache.juneau.commons.inject.Bean; import org.apache.juneau.http.Path; import org.apache.juneau.http.response.NotFound; +import org.apache.juneau.marshall.html.HtmlSerializer; import org.apache.juneau.rest.server.Rest; import org.apache.juneau.rest.server.RestGet; +import org.apache.juneau.rest.server.converter.ProtocolQueryable; +import org.apache.juneau.rest.server.converter.QueryableSettings; import org.apache.juneau.rest.server.servlet.BasicRestResource; import org.apache.juneau.rest.server.view.View; import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin; import org.apache.juneau.rest.server.view.freemarker.FreemarkerView; import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer; +import org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin; +import org.apache.juneau.rest.server.views.Column; +import org.apache.juneau.rest.server.views.RibbonAction; +import org.apache.juneau.rest.server.views.ViewDef; +import org.apache.juneau.rest.server.views.ViewDef.DataMode; +import org.apache.juneau.rest.server.views.ViewDef.Dir; +import org.apache.juneau.rest.server.views.ViewTable; +import org.apache.juneau.rest.server.views.ViewsMixin; import org.apache.juneau.releng.release.Release; import org.apache.juneau.releng.release.ReleaseListService; -/** Releases tab: server-rendered HTML page + JSON data endpoint. */ -@Rest(path = "/releases", title = "Releases", responseProcessors = FreemarkerViewRenderer.class) +/** + * Releases tab: server-rendered HTML page + DataTables server-side-processing data endpoint. + * + * <p> + * Built on the {@code juneau-rest-server-views} rich-view toolkit: {@link #releasesView()} declares the typed + * {@link ViewDef} (columns + renderers + ribbon), {@link #page()} emits its {@link ViewTable} shell as trusted markup + * into the FreeMarker template, and {@link #data()} serves the {@code DataTablesResults} envelope via + * {@link ProtocolQueryable} + the view's {@link ViewDef#queryableSettings() queryable settings}. The four runtime + * assets are served by the composed {@link ViewsMixin} at this resource's mount. + */ +@Rest(path = "/releases", title = "Releases", responseProcessors = FreemarkerViewRenderer.class, mixins = ViewsMixin.class) public class ReleaseRest extends BasicRestResource { + /** This resource's absolute mount (RootRest {@code /rest/*} + {@code /releases}), used to resolve asset/data URLs. */ + static final String MOUNT = "/rest/releases"; + private final ReleaseListService service; public ReleaseRest(ReleaseListService service) { this.service = service; } + // Return type stays FreemarkerMixin - FreemarkerViewRenderer does an exact-type bean lookup (see + // ConsoleFreemarkerMixin's class Javadoc). @Bean public FreemarkerMixin freemarker() { - return FreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build(); + return ConsoleFreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build(); + } + + /** + * The DataTables server-side-processing settings for {@link #data()}, sourced from the view definition so the + * protocol's positional column resolution stays in sync with the declared columns. + */ + @Bean + public QueryableSettings queryableSettings() { + return releasesView().queryableSettings(); + } + + /** + * The typed rich-view definition for the Releases tab (server-side mode): a linked Version column, {@code tag}- + * rendered Status/Stage pills (emitting the shared {@code .tag.<domain>.<value>} classes the app's console-ui + * palette themes), timestamp/date columns, and a copy/csv export + column-search + status quick-filter + refresh + * ribbon. Data arrives via ajax draws against {@link #data()}. Static (no instance state) so {@code AdminRest} + * can reuse this same declarative definition when composing the {@code Admin} tab page (TODO-399 Phase C + * dogfood). + */ + static ViewDef releasesView() { + return ViewDef.create("releases") + .rowType(Release.class) + .dataMode(DataMode.SERVER) + .dataUrl(MOUNT + "/data") + .defaultOrder("version", Dir.DESC) + .columns( + Column.of("version").title("Version").render("linked").href(MOUNT + "/{version}/1"), + Column.of("rc").title("RC"), + Column.of("status").title("Status").render("tag:status"), + Column.of("stage").title("Stage").render("tag:stage"), + Column.of("voteCloses").title("Vote closes").render("ts-zulu"), + Column.of("released").title("Released").render("date"), + Column.of("githubReleaseUrl").title("GitHub").render("linked").href("{githubReleaseUrl}").orderable(false), + Column.of("milestoneUrl").title("Milestone").render("linked").href("{milestoneUrl}").orderable(false)) + .ribbon( + // "filters" clusters the column-search toggle and the dropped-only quick-filter into one + // segmented ribbon group (visual-parity control-row layout: filter-ribbon); "export" actions are + // clustered into their own group automatically (one action, one visual cluster - see juneau- + // ribbon.js's buildRibbon). + RibbonAction.columnSearchToggle().group("filters"), + RibbonAction.option("dropped-only").title("Dropped only").column("status").value("DROPPED").persist(true) + .symbol("filter_alt").group("filters"), + RibbonAction.export("copy", "csv").optional("excel", "pdf"), + RibbonAction.refresh()) + .build(); } - /** Human page — server-rendered releases table, enhanced by DataTables. */ + /** Human page — the rich-view table shell (emitted as trusted markup) + JSON sidecar, hydrated by the toolkit JS. */ @RestGet("/") public View page() { - return FreemarkerView.of("releases").attr("releases", service.list()); + var markup = HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(ViewTable.of(releasesView())); + return FreemarkerView.of("releases") + .attr("viewTable", markup) + .attr("viewsCssUrl", asset(ViewsMixin.VIEWS_CSS_PATH)) + .attr("rendersJsUrl", asset(ViewsMixin.RENDERS_JS_PATH)) + .attr("iconsJsUrl", asset(ViewsMixin.ICONS_JS_PATH)) + .attr("ribbonJsUrl", asset(ViewsMixin.RIBBON_JS_PATH)) + .attr("viewsJsUrl", asset(ViewsMixin.VIEWS_JS_PATH)); } - /** Machine endpoint — bare JSON array for curl/CLI/DataTables ajax. */ - @RestGet("/data") + /** + * Resolves a toolkit asset to an absolute, cache-busted URL for the FreeMarker head block. {@link ViewsMixin}'s + * {@code servlet:}-relative form is rewritten to this resource's absolute mount because the FreeMarker template + * is rendered outside Juneau's {@code HtmlDoc} URL-resolution (which would otherwise resolve {@code servlet:}). + */ + private static String asset(String path) { + return ViewsMixin.viewAssetUrl(path).replace("servlet:", MOUNT); + } + + /** + * Machine endpoint — the DataTables server-side-processing envelope ({@code {draw, recordsTotal, recordsFiltered, + * data}}). The method returns the row {@code List}; {@link ProtocolQueryable} parses the DataTables request, runs + * the shared query engine (search/sort/paginate), and wraps the page in a {@code DataTablesResults} envelope. + */ + @RestGet(path = "/data", converters = ProtocolQueryable.class) public List<Release> data() { return service.list(); } diff --git a/src/main/java/org/apache/juneau/releng/rest/ReleaseRunRest.java b/src/main/java/org/apache/juneau/releng/rest/ReleaseRunRest.java index eeecdb002c..fd5deb6a12 100644 --- a/src/main/java/org/apache/juneau/releng/rest/ReleaseRunRest.java +++ b/src/main/java/org/apache/juneau/releng/rest/ReleaseRunRest.java @@ -31,6 +31,7 @@ import org.apache.juneau.rest.server.view.View; import org.apache.juneau.rest.server.view.freemarker.FreemarkerMixin; import org.apache.juneau.rest.server.view.freemarker.FreemarkerView; import org.apache.juneau.rest.server.view.freemarker.FreemarkerViewRenderer; +import org.apache.juneau.rest.server.view.freemarker.console.ConsoleFreemarkerMixin; import org.apache.juneau.releng.engine.DropRcService; import org.apache.juneau.releng.engine.ExecutionMode; import org.apache.juneau.releng.engine.Preview; @@ -50,9 +51,11 @@ public class ReleaseRunRest extends BasicRestResource { this.dropRc = dropRc; } + // Return type stays FreemarkerMixin - FreemarkerViewRenderer does an exact-type bean lookup (see + // ConsoleFreemarkerMixin's class Javadoc). @Bean public FreemarkerMixin freemarker() { - return FreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build(); + return ConsoleFreemarkerMixin.create().basePath("/templates/").templateSuffix(".ftlh").build(); } /** Human page — the pipeline control panel for the active run (or an empty start form). */ diff --git a/src/main/resources/static/css/chrome.css b/src/main/resources/static/css/chrome.css index 0435d3d012..3678e78a81 100644 --- a/src/main/resources/static/css/chrome.css +++ b/src/main/resources/static/css/chrome.css @@ -16,301 +16,20 @@ */ /* ========================================================================== - Apache Juneau · Release Manager — shared "chrome" stylesheet + Apache Juneau · Release Manager — app-specific chrome extensions ========================================================================== PURPOSE - This file defines the admin-console app shell (header, primary nav, card, - DataTable, tag/pill system) using only open, redistributable assets. It is - designed to evolve into the *real* shared chrome CSS for the Release - Manager UI. - - SWAPPABLE THEME TOKENS - All brand-specific bits are expressed as CSS custom properties on :root - so a sibling app can re-skin the exact same markup/CSS by overriding a - small token block — no other rule in this file should need to change. - - Default theme = "Apache" (this file, below) — open assets only. + The shared admin-console shell (header, nav, card, button, tag-base rules) + is now served by the shipped juneau-rest-server-console-ui chrome.css at + /juneau-console/chrome.css (see base.ftlh's <head> link). This file holds + only what's genuinely app-specific and has no console-ui equivalent: + DataTables-chrome integration, credential cards, the release-detail list + grid, two app-only tag values, and Home-tab content styling. Every + var(--jc-*) reference below resolves against the tokens the shipped chrome + response appends (Theme.OPEN, overridden by ReleaseManagerTheme). ========================================================================== */ -:root { - /* ---- Swappable brand tokens (override per-app) ---- */ - --rm-font: 'Inter', 'Source Sans 3', system-ui, -apple-system, sans-serif; - --rm-logo: url("/img/oakleaf.svg"); - --rm-logo-size: 36px 48px; - --rm-page-bg: url("/img/topo-bg.png") center top / cover no-repeat fixed, linear-gradient(180deg, #b0c4df 0%, #c7d5e8 22%, #e4eaf2 55%, #f5f6f9 100%); - --rm-header-icon-color: #747474; - - /* ---- Brand accent ---- */ - --rm-accent: #1589EE; - --rm-accent-2: #1589ed; - --rm-accent-wash: #1589ee1a; - - /* ---- Text ---- */ - --rm-link: #0174d3; - --rm-text: #080707; - --rm-text-soft: #080707cc; - --rm-text-muted: #706e6b; - --rm-text-muted-2: #3e3e3c; - - /* ---- Borders / surfaces ---- */ - --rm-border: #dddbda; - --rm-border-2: #ced4da; - --rm-card-bg: #f5f6f9; - --rm-chrome-bg: #f3f2f2; - --rm-white: #ffffff; - - /* ---- Buttons / status ---- */ - --rm-btn-primary: #1a5297; - --rm-btn-primary-hover: #005fb2; - --rm-danger: #c23934; - --rm-success: #2e844a; - - /* ---- Tag / pill palette ---- */ - --rm-tag-green-bg: #b8e6c4; - --rm-tag-green-text: #155724; - --rm-tag-green-border: #9fd6ad; - - --rm-tag-blue-bg: #dceefb; - --rm-tag-blue-text: #0c5460; - --rm-tag-blue-border: #c3e0f3; - - --rm-tag-amber-bg: #fff3cd; - --rm-tag-amber-text: #856404; - --rm-tag-amber-border: #ffe69c; - - --rm-tag-neutral-bg: #e2e3e5; - --rm-tag-neutral-text: #383d41; - --rm-tag-neutral-border: #c6c8ca; - - --rm-radius: 0.25rem; -} - -/* ========================================================================== - Base / page - ========================================================================== */ - -* { box-sizing: border-box; } - -html, body { - margin: 0; - padding: 0; - font-family: var(--rm-font); - color: var(--rm-text); - background: var(--rm-page-bg); - min-height: 100vh; -} - -body { - display: flex; - flex-direction: column; -} - -a { color: var(--rm-link); text-decoration: none; } -a:hover { text-decoration: underline; } - -/* ========================================================================== - Header - ========================================================================== */ - -.rm-header { - display: flex; - align-items: center; - height: 68px; - background: var(--rm-white); - position: sticky; - top: 0; - z-index: 20; -} - -.rm-brand { - display: flex; - align-items: center; - flex: 1 1 auto; - min-width: 0; -} - -.rm-brand-mark { - width: 48px; - height: 48px; - margin: 10px 20px; - flex: 0 0 auto; - border-radius: var(--rm-radius); - background-color: var(--rm-white); - background-image: var(--rm-logo); - background-repeat: no-repeat; - background-position: center; - background-size: var(--rm-logo-size); -} - -.rm-brand-title { - font-size: 1.125rem; - font-weight: 600; - color: var(--rm-text-soft); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.rm-brand-title .rm-brand-sep { - margin: 0 6px; - color: var(--rm-text-muted); - font-weight: 400; -} - -.rm-header-actions { - display: flex; - align-items: center; - gap: 4px; - padding-right: 16px; - flex: 0 0 auto; -} - -.rm-icon-btn { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: 50%; - color: var(--rm-header-icon-color); - background: transparent; - border: none; - cursor: pointer; -} - -.rm-icon-btn:hover { background: var(--rm-chrome-bg); } - -.rm-icon-btn svg { width: 18px; height: 18px; fill: currentColor; } - -.rm-avatar { - width: 30px; - height: 30px; - border-radius: 50%; - background: linear-gradient(135deg, var(--rm-accent), var(--rm-btn-primary)); - color: var(--rm-white); - font-size: 0.75rem; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; - margin-left: 8px; -} - -/* ========================================================================== - Primary navigation - ========================================================================== */ - -.nav { - display: flex; - align-items: stretch; - background: var(--rm-white); - border-bottom: 3px solid var(--rm-accent); - padding: 0 16px; - position: sticky; - top: 68px; - z-index: 19; -} - -.nav-tab { - display: flex; - align-items: center; - font-size: 0.8125rem; - font-weight: 600; - color: var(--rm-text-soft); - padding: 8px 10px; - margin-right: 2px; - border-top: 3px solid transparent; - border-bottom: none; - cursor: pointer; - transition: background-color 0.1s ease-in; -} - -.nav-tab:hover { - background: var(--rm-accent-wash); - text-decoration: none; -} - -.nav-tab.active { - background: var(--rm-accent-wash); - border-top-color: var(--rm-accent); - color: var(--rm-text); -} - -/* ========================================================================== - Page scaffolding - ========================================================================== */ - -.rm-main { - flex: 1 1 auto; - padding: 20px 24px 40px; -} - -.rm-page-header { - display: flex; - align-items: baseline; - justify-content: space-between; - max-width: 1180px; - margin: 0 auto 14px; - flex-wrap: wrap; - gap: 10px; -} - -.rm-page-header h1 { - font-size: 1.375rem; - font-weight: 700; - color: var(--rm-text); - margin: 0; -} - -.rm-page-header .rm-page-sub { - font-size: 0.8125rem; - color: var(--rm-text-muted); - margin-top: 2px; -} - -.rm-breadcrumb { - font-size: 0.75rem; - color: var(--rm-text-muted); - margin-bottom: 4px; -} - -.rm-breadcrumb a { color: var(--rm-text-muted); } -.rm-breadcrumb .sep { margin: 0 6px; } - -/* ========================================================================== - Card - ========================================================================== */ - -.rm-card { - max-width: 1180px; - margin: 0 auto; - background: var(--rm-card-bg); - border: 1px solid var(--rm-border); - border-radius: var(--rm-radius); - padding: 18px 20px 8px; -} - -.rm-card-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; - flex-wrap: wrap; - gap: 10px; -} - -.rm-card-header h2 { - font-size: 1rem; - font-weight: 700; - color: var(--rm-text); - margin: 0; -} - -.rm-card-header a { - font-size: 0.8125rem; -} - /* ========================================================================== Detail list (release detail page) ========================================================================== */ @@ -325,101 +44,40 @@ a:hover { text-decoration: underline; } .rm-detail-list dt { font-weight: 600; - color: var(--rm-text-soft); + color: var(--jc-text-soft); } .rm-detail-list dd { margin: 0; } +/* Release Manager app's own "stage" vocabulary (Release.stage — not part of + the mockup's controlled vocabulary above, but slotted into the same + green/blue/neutral domains for visual consistency). */ +.tag.stage.distributed { background: var(--jc-tag-green-bg); color: var(--jc-tag-green-text); border-color: var(--jc-tag-green-border); } +.tag.stage.awaiting-vote { background: var(--jc-tag-blue-bg); color: var(--jc-tag-blue-text); border-color: var(--jc-tag-blue-border); } + /* ========================================================================== - Buttons + Buttons (app-specific variants not shipped by console-ui's chrome.css, + which only defines .jc-btn/-primary/-outline/-danger/-success) ========================================================================== */ -.btn { - display: inline-flex; - align-items: center; - gap: 6px; - font-family: var(--rm-font); - font-size: 0.8125rem; - font-weight: 600; - padding: 7px 14px; - border-radius: var(--rm-radius); - border: 1px solid transparent; - cursor: pointer; - line-height: 1.2; -} - -.btn-primary { - background: var(--rm-btn-primary); - color: var(--rm-white); -} -.btn-primary:hover { background: var(--rm-btn-primary-hover); } - -.btn-outline { - background: var(--rm-white); - color: var(--rm-text-soft); - border-color: var(--rm-border-2); +/* A deliberately cautionary amber action, distinct from the primary/danger + variants (e.g. "Arm this run" for LIVE mutation). Layers on the shipped + .jc-btn base the same way .jc-btn-danger/-success do. */ +.jc-btn-warning { + background-color: var(--jc-tag-amber-bg); + color: var(--jc-tag-amber-text); + border-color: var(--jc-tag-amber-border); } -.btn-outline:hover { background: var(--rm-chrome-bg); } +.jc-btn-warning:hover { filter: brightness(0.97); } -.btn-danger { - background: var(--rm-white); - color: var(--rm-danger); - border-color: var(--rm-border-2); +/* Compact button modifier for dense action rows (e.g. the New-Release rail's + per-step Dry-run/Run/Resume buttons). */ +.jc-btn-sm { + font-size: 0.75rem; + padding: 5px 10px; } -.btn-danger:hover { background: #fdeceb; } - -/* ========================================================================== - Tag / pill system - ========================================================================== */ - -.tag { - display: inline-block; - padding: 1px 6px; - border-radius: 3px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.5px; - text-transform: uppercase; - border: 1px solid transparent; - white-space: nowrap; - line-height: 1.5; - /* Neutral default so any value outside the known domain vocabulary below - (e.g. free-text release "stage" values this app has beyond the mockup's - controlled vocabulary) still renders as a filled pill. */ - background: var(--rm-tag-neutral-bg); - color: var(--rm-tag-neutral-text); - border-color: var(--rm-tag-neutral-border); -} - -/* domain: status */ -.tag.status.released, -.tag.stage.released { background: var(--rm-tag-green-bg); color: var(--rm-tag-green-text); border-color: var(--rm-tag-green-border); } - -.tag.status.voting, -.tag.status.rc, -.tag.stage.voting, -.tag.stage.rc { background: var(--rm-tag-blue-bg); color: var(--rm-tag-blue-text); border-color: var(--rm-tag-blue-border); } - -.tag.status.draft, -.tag.status.pending, -.tag.stage.draft, -.tag.stage.pending, -.tag.stage.building { background: var(--rm-tag-amber-bg); color: var(--rm-tag-amber-text); border-color: var(--rm-tag-amber-border); } - -.tag.status.dropped, -.tag.status.unknown, -.tag.stage.dropped, -.tag.stage.unknown, -.tag.stage.archived, -.tag.stage.cancelled { background: var(--rm-tag-neutral-bg); color: var(--rm-tag-neutral-text); border-color: var(--rm-tag-neutral-border); } - -/* Release Manager app's own "stage" vocabulary (Release.stage — not part of - the mockup's controlled vocabulary above, but slotted into the same - green/blue/neutral domains for visual consistency). */ -.tag.stage.distributed { background: var(--rm-tag-green-bg); color: var(--rm-tag-green-text); border-color: var(--rm-tag-green-border); } -.tag.stage.awaiting-vote { background: var(--rm-tag-blue-bg); color: var(--rm-tag-blue-text); border-color: var(--rm-tag-blue-border); } /* ========================================================================== Data table (DataTables integration) @@ -430,7 +88,7 @@ a:hover { text-decoration: underline; } align-items: center; justify-content: space-between; font-size: 0.8125rem; - color: var(--rm-text-muted-2); + color: #3e3e3c; padding: 6px 2px 10px; flex-wrap: wrap; gap: 10px; @@ -445,13 +103,13 @@ a:hover { text-decoration: underline; } .rm-table-length select, .rm-table-search input { - font-family: var(--rm-font); + font-family: var(--jc-font); font-size: 0.8125rem; padding: 4px 8px; - border: 1px solid var(--rm-border-2); - border-radius: var(--rm-radius); - background: var(--rm-white); - color: var(--rm-text); + border: 1px solid var(--jc-border-2); + border-radius: var(--jc-radius); + background: var(--jc-white); + color: var(--jc-text); } .rm-table-search input { width: 200px; } @@ -467,9 +125,9 @@ table.rm-table { width: 100%; min-width: 720px; border-collapse: collapse; - background: var(--rm-white); - border: 1px solid var(--rm-border); - border-radius: var(--rm-radius); + background: var(--jc-white); + border: 1px solid var(--jc-border); + border-radius: var(--jc-radius); overflow: hidden; } @@ -478,11 +136,11 @@ table.rm-table thead th { font-weight: 700; letter-spacing: 0.3px; text-transform: uppercase; - color: var(--rm-text-muted-2); + color: #3e3e3c; text-align: left; padding: 10px 12px; - border-bottom: 2px solid var(--rm-border); - background: var(--rm-chrome-bg); + border-bottom: 2px solid var(--jc-border); + background: var(--jc-chrome-bg); white-space: nowrap; } @@ -490,27 +148,27 @@ table.rm-table thead th.sortable { cursor: pointer; position: relative; } table.rm-table thead th.sortable::after { content: '\2195'; margin-left: 5px; - color: var(--rm-border-2); + color: var(--jc-border-2); font-weight: 400; } table.rm-table tbody td { font-size: 0.8125rem; - color: var(--rm-text); + color: var(--jc-text); padding: 9px 12px; - border-bottom: 1px solid var(--rm-border); + border-bottom: 1px solid var(--jc-border); vertical-align: middle; } table.rm-table tbody tr:nth-child(odd) { background: #fbfbfc; } -table.rm-table tbody tr:hover { background: var(--rm-accent-wash); } +table.rm-table tbody tr:hover { background: var(--jc-accent-wash); } table.rm-table tbody tr:last-child td { border-bottom: none; } td.details-control { width: 28px; text-align: center; cursor: pointer; - color: var(--rm-text-muted); + color: var(--jc-text-muted); } td.details-control .chevron { @@ -518,8 +176,8 @@ td.details-control .chevron { width: 16px; height: 16px; border-radius: 50%; - border: 1px solid var(--rm-border-2); - color: var(--rm-text-muted-2); + border: 1px solid var(--jc-border-2); + color: #3e3e3c; font-size: 10px; line-height: 15px; text-align: center; @@ -529,9 +187,9 @@ td.details-control .chevron { tr.row-expanded td.details-control .chevron { transform: rotate(90deg); } tr.detail-row td { - background: var(--rm-card-bg); + background: var(--jc-card-bg); padding: 14px 18px; - border-bottom: 1px solid var(--rm-border); + border-bottom: 1px solid var(--jc-border); } tr.detail-row .detail-grid { @@ -545,42 +203,31 @@ tr.detail-row dt { font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.3px; - color: var(--rm-text-muted); + color: var(--jc-text-muted); margin: 0 0 2px; } tr.detail-row dd { margin: 0; - color: var(--rm-text); + color: var(--jc-text); } .rm-table-info { font-size: 0.75rem; - color: var(--rm-text-muted); + color: var(--jc-text-muted); padding: 10px 2px 4px; } .version-cell { font-weight: 700; - color: var(--rm-text); + color: var(--jc-text); } -.rc-cell { color: var(--rm-text-muted-2); } +.rc-cell { color: #3e3e3c; } .links-cell a { margin-right: 10px; font-size: 0.8125rem; } .links-cell a:last-child { margin-right: 0; } -/* ========================================================================== - Footer - ========================================================================== */ - -.rm-footer { - text-align: center; - font-size: 0.75rem; - color: var(--rm-text-muted); - padding: 24px 0 32px; -} - /* ========================================================================== DataTables integration (Release Manager app only — not part of the mockup) ========================================================================== @@ -597,8 +244,8 @@ tr.detail-row dd { ========================================================================== */ div.dt-container { - font-family: var(--rm-font); - color: var(--rm-text-muted-2); + font-family: var(--jc-font); + color: #3e3e3c; font-size: 0.8125rem; } @@ -606,7 +253,7 @@ div.dt-container .dt-length, div.dt-container .dt-search, div.dt-container .dt-info, div.dt-container .dt-paging { - color: var(--rm-text-muted-2); + color: #3e3e3c; padding: 6px 2px; } @@ -614,20 +261,20 @@ div.dt-container .dt-search input, div.dt-container .dt-length select, div.dt-container select.dt-input, div.dt-container input.dt-input { - font-family: var(--rm-font); + font-family: var(--jc-font); font-size: 0.8125rem; padding: 4px 8px; - border: 1px solid var(--rm-border-2); - border-radius: var(--rm-radius); - background: var(--rm-white); - color: var(--rm-text); + border: 1px solid var(--jc-border-2); + border-radius: var(--jc-radius); + background: var(--jc-white); + color: var(--jc-text); } div.dt-container .dt-search input { width: 200px; } table.dataTable.rm-table thead > tr > th { - background: var(--rm-chrome-bg); - color: var(--rm-text-muted-2); + background: var(--jc-chrome-bg); + color: #3e3e3c; } table.dataTable.rm-table thead > tr > th.dt-orderable-asc, @@ -637,30 +284,30 @@ table.dataTable.rm-table thead > tr > th.dt-orderable-desc { table.dataTable.rm-table thead > tr > th .dt-column-order:before, table.dataTable.rm-table thead > tr > th .dt-column-order:after { - color: var(--rm-border-2); + color: var(--jc-border-2); } table.dataTable.rm-table thead > tr > th.dt-ordering-asc .dt-column-order:before, table.dataTable.rm-table thead > tr > th.dt-ordering-desc .dt-column-order:after { - color: var(--rm-accent); + color: var(--jc-accent); } div.dt-container .dt-paging .dt-paging-button { - font-family: var(--rm-font); - border-radius: var(--rm-radius); + font-family: var(--jc-font); + border-radius: var(--jc-radius); } div.dt-container .dt-paging .dt-paging-button.current, div.dt-container .dt-paging .dt-paging-button.current:hover { - background: var(--rm-accent) !important; - border-color: var(--rm-accent) !important; - color: var(--rm-white) !important; + background: var(--jc-accent) !important; + border-color: var(--jc-accent) !important; + color: var(--jc-white) !important; } div.dt-container .dt-paging .dt-paging-button:not(.current):not(.disabled):hover { - background: var(--rm-accent-wash) !important; - border-color: var(--rm-accent) !important; - color: var(--rm-text) !important; + background: var(--jc-accent-wash) !important; + border-color: var(--jc-accent) !important; + color: var(--jc-text) !important; } /* ========================================================================== @@ -672,7 +319,7 @@ div.dt-container .dt-paging .dt-paging-button:not(.current):not(.disabled):hover margin: 0; padding: 4px 0 4px 20px; font-size: 0.8125rem; - color: var(--rm-text); + color: var(--jc-text); line-height: 1.6; } @@ -691,8 +338,8 @@ div.dt-container .dt-paging .dt-paging-button:not(.current):not(.disabled):hover .rm-page-sub code { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 0.75rem; - background: var(--rm-chrome-bg); - border: 1px solid var(--rm-border); + background: var(--jc-chrome-bg); + border: 1px solid var(--jc-border); border-radius: 3px; padding: 1px 5px; } @@ -710,9 +357,9 @@ div.dt-container .dt-paging .dt-paging-button:not(.current):not(.disabled):hover ========================================================================== */ .cred { - border: 1px solid var(--rm-border); - border-radius: var(--rm-radius); - background: var(--rm-white); + border: 1px solid var(--jc-border); + border-radius: var(--jc-radius); + background: var(--jc-white); padding: 16px 18px; margin-bottom: 14px; } @@ -723,7 +370,7 @@ div.dt-container .dt-paging .dt-paging-button:not(.current):not(.disabled):hover margin: 0 0 8px; font-size: 0.9375rem; font-weight: 700; - color: var(--rm-text); + color: var(--jc-text); } .cred .row { @@ -735,18 +382,18 @@ div.dt-container .dt-paging .dt-paging-button:not(.current):not(.disabled):hover } .cred input { - font-family: var(--rm-font); + font-family: var(--jc-font); font-size: 0.8125rem; padding: 6px 10px; - border: 1px solid var(--rm-border-2); - border-radius: var(--rm-radius); - background: var(--rm-white); - color: var(--rm-text); + border: 1px solid var(--jc-border-2); + border-radius: var(--jc-radius); + background: var(--jc-white); + color: var(--jc-text); } .cred .msg { font-size: 0.75rem; - color: var(--rm-text-muted); + color: var(--jc-text-muted); margin-top: 8px; } @@ -758,6 +405,6 @@ div.dt-container .dt-paging .dt-paging-button:not(.current):not(.disabled):hover font-weight: 600; } -.pill.valid { background: var(--rm-tag-green-bg); color: var(--rm-tag-green-text); } -.pill.invalid { background: #fdeceb; color: var(--rm-danger); } -.pill.unset { background: var(--rm-tag-neutral-bg); color: var(--rm-tag-neutral-text); } +.pill.valid { background: var(--jc-tag-green-bg); color: var(--jc-tag-green-text); } +.pill.invalid { background: #fdeceb; color: var(--jc-danger); } +.pill.unset { background: var(--jc-tag-neutral-bg); color: var(--jc-tag-neutral-text); } diff --git a/src/main/resources/static/css/new-release.css b/src/main/resources/static/css/new-release.css index 94b8ede47a..1bcf625c4a 100644 --- a/src/main/resources/static/css/new-release.css +++ b/src/main/resources/static/css/new-release.css @@ -38,12 +38,12 @@ New step-status tag values (extends chrome.css's tag system; see header) ========================================================================== */ -.tag.status.succeeded { background: var(--rm-tag-green-bg); color: var(--rm-tag-green-text); border-color: var(--rm-tag-green-border); } -.tag.status.running { background: var(--rm-tag-blue-bg); color: var(--rm-tag-blue-text); border-color: var(--rm-tag-blue-border); } +.tag.status.succeeded { background: var(--jc-tag-green-bg); color: var(--jc-tag-green-text); border-color: var(--jc-tag-green-border); } +.tag.status.running { background: var(--jc-tag-blue-bg); color: var(--jc-tag-blue-text); border-color: var(--jc-tag-blue-border); } .tag.status.skipped, .tag.status.awaiting-vote, -.tag.status.neutral { background: var(--rm-tag-neutral-bg); color: var(--rm-tag-neutral-text); border-color: var(--rm-tag-neutral-border); } -.tag.status.failed { background: #fdeceb; color: var(--rm-danger); border-color: #f3c6c2; } +.tag.status.neutral { background: var(--jc-tag-neutral-bg); color: var(--jc-tag-neutral-text); border-color: var(--jc-tag-neutral-border); } +.tag.status.failed { background: #fdeceb; color: var(--jc-danger); border-color: #f3c6c2; } .tag.status.running::before { content: '\25CF'; margin-right: 4px; font-size: 8px; position: relative; top: -1px; animation: rm-pulse 1.4s ease-in-out infinite; } .tag.status.awaiting-vote::before { content: '\23F8'; margin-right: 3px; font-size: 9px; position: relative; top: 0; } @@ -57,9 +57,9 @@ .rm-run-header { max-width: 1180px; margin: 0 auto 16px; - background: var(--rm-card-bg); - border: 1px solid var(--rm-border); - border-radius: var(--rm-radius); + background: var(--jc-card-bg); + border: 1px solid var(--jc-border); + border-radius: var(--jc-radius); padding: 16px 20px; } @@ -81,7 +81,7 @@ .rm-run-title { font-size: 1.0625rem; font-weight: 700; - color: var(--rm-text); + color: var(--jc-text); margin: 0; } @@ -97,14 +97,14 @@ align-items: center; gap: 4px; font-size: 0.75rem; - color: var(--rm-text-muted-2); - background: var(--rm-white); - border: 1px solid var(--rm-border-2); + color: var(--jc-text-soft); + background: var(--jc-white); + border: 1px solid var(--jc-border-2); border-radius: 999px; padding: 3px 10px; } -.rm-meta-chip b { color: var(--rm-text); font-weight: 700; } +.rm-meta-chip b { color: var(--jc-text); font-weight: 700; } .rm-run-actions { display: flex; @@ -118,19 +118,19 @@ align-items: center; gap: 10px; font-size: 0.8125rem; - color: var(--rm-text-muted-2); - background: var(--rm-tag-neutral-bg); - border: 1px solid var(--rm-tag-neutral-border); - border-radius: var(--rm-radius); + color: var(--jc-text-soft); + background: var(--jc-tag-neutral-bg); + border: 1px solid var(--jc-tag-neutral-border); + border-radius: var(--jc-radius); padding: 9px 14px; } -.rm-vote-banner b { color: var(--rm-text); } +.rm-vote-banner b { color: var(--jc-text); } .rm-vote-banner .rm-vote-countdown { margin-left: auto; font-weight: 700; - color: var(--rm-text); + color: var(--jc-text); white-space: nowrap; } @@ -145,7 +145,7 @@ font-size: 0.75rem; line-height: 1.65; padding: 12px 14px; - border-radius: var(--rm-radius); + border-radius: var(--jc-radius); max-height: 240px; overflow-y: auto; white-space: pre-wrap; @@ -183,7 +183,7 @@ font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.3px; - color: var(--rm-text-muted); + color: var(--jc-text-muted); margin: 14px 0 6px; } @@ -191,7 +191,7 @@ text-transform: none; letter-spacing: 0; font-size: 0.6875rem; - color: var(--rm-text-muted); + color: var(--jc-text-muted); } /* ========================================================================== @@ -209,26 +209,26 @@ .rm-step-title-row h3 { font-size: 0.9375rem; font-weight: 700; - color: var(--rm-text); + color: var(--jc-text); margin: 0; } .rm-step-num { - color: var(--rm-text-muted); + color: var(--jc-text-muted); font-weight: 600; font-size: 0.8125rem; } .rm-step-desc { font-size: 0.8125rem; - color: var(--rm-text-muted-2); + color: var(--jc-text-soft); margin: 0 0 12px; max-width: 62ch; } .rm-step-times { font-size: 0.75rem; - color: var(--rm-text-muted); + color: var(--jc-text-muted); margin-bottom: 12px; } @@ -239,19 +239,14 @@ margin-bottom: 4px; } -.btn-sm { - font-size: 0.75rem; - padding: 5px 10px; -} - -.btn[disabled] { +.jc-btn[disabled] { opacity: 0.45; cursor: not-allowed; } .rm-step-note { font-size: 0.75rem; - color: var(--rm-text-muted); + color: var(--jc-text-muted); margin-top: 8px; } @@ -270,18 +265,18 @@ gap: 4px; font-size: 0.75rem; font-weight: 600; - color: var(--rm-text-muted-2); + color: var(--jc-text-soft); } .rm-vote-form select, .rm-vote-form textarea { - font-family: var(--rm-font); + font-family: var(--jc-font); font-size: 0.8125rem; padding: 6px 10px; - border: 1px solid var(--rm-border-2); - border-radius: var(--rm-radius); - background: var(--rm-white); - color: var(--rm-text); + border: 1px solid var(--jc-border-2); + border-radius: var(--jc-radius); + background: var(--jc-white); + color: var(--jc-text); resize: vertical; } @@ -299,9 +294,9 @@ .rm-rail-col { flex: 0 0 340px; - background: var(--rm-white); - border: 1px solid var(--rm-border); - border-radius: var(--rm-radius); + background: var(--jc-white); + border: 1px solid var(--jc-border); + border-radius: var(--jc-radius); overflow: hidden; max-height: 640px; overflow-y: auto; @@ -312,16 +307,16 @@ align-items: center; gap: 8px; padding: 9px 12px; - border-bottom: 1px solid var(--rm-border); + border-bottom: 1px solid var(--jc-border); cursor: pointer; } .rm-rail-item:last-child { border-bottom: none; } -.rm-rail-item:hover { background: var(--rm-accent-wash); } +.rm-rail-item:hover { background: var(--jc-accent-wash); } .rm-rail-item.selected { - background: var(--rm-accent-wash); - box-shadow: inset 3px 0 0 var(--rm-accent); + background: var(--jc-accent-wash); + box-shadow: inset 3px 0 0 var(--jc-accent); } .rm-rail-num { @@ -329,7 +324,7 @@ width: 18px; text-align: right; font-size: 0.6875rem; - color: var(--rm-text-muted); + color: var(--jc-text-muted); } .rm-rail-name { @@ -337,7 +332,7 @@ min-width: 0; font-size: 0.8125rem; font-weight: 600; - color: var(--rm-text); + color: var(--jc-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -348,9 +343,9 @@ .rm-detail-col { flex: 1 1 auto; min-width: 0; - background: var(--rm-white); - border: 1px solid var(--rm-border); - border-radius: var(--rm-radius); + background: var(--jc-white); + border: 1px solid var(--jc-border); + border-radius: var(--jc-radius); padding: 18px 20px; } @@ -367,9 +362,9 @@ } .rm-step-row { - background: var(--rm-white); - border: 1px solid var(--rm-border); - border-radius: var(--rm-radius); + background: var(--jc-white); + border: 1px solid var(--jc-border); + border-radius: var(--jc-radius); overflow: hidden; } @@ -381,11 +376,11 @@ cursor: pointer; } -.rm-step-row-head:hover { background: var(--rm-accent-wash); } +.rm-step-row-head:hover { background: var(--jc-accent-wash); } .rm-step-row.expanded .rm-step-row-head { - border-bottom: 1px solid var(--rm-border); - background: var(--rm-accent-wash); + border-bottom: 1px solid var(--jc-border); + background: var(--jc-accent-wash); } .rm-step-chevron { @@ -394,8 +389,8 @@ width: 16px; height: 16px; border-radius: 50%; - border: 1px solid var(--rm-border-2); - color: var(--rm-text-muted-2); + border: 1px solid var(--jc-border-2); + color: var(--jc-text-soft); font-size: 10px; line-height: 15px; text-align: center; @@ -409,7 +404,7 @@ width: 20px; text-align: right; font-size: 0.75rem; - color: var(--rm-text-muted); + color: var(--jc-text-muted); } .rm-step-row-name { @@ -417,7 +412,7 @@ min-width: 0; font-size: 0.8438rem; font-weight: 600; - color: var(--rm-text); + color: var(--jc-text); } .rm-step-row-body { @@ -432,13 +427,13 @@ ========================================================================== */ .rm-table td.step-num-cell { - color: var(--rm-text-muted-2); + color: var(--jc-text-soft); width: 32px; } .rm-table td.step-name-cell { font-weight: 600; - color: var(--rm-text); + color: var(--jc-text); } .rm-table .step-actions-cell { @@ -446,10 +441,10 @@ text-align: right; } -.rm-table .step-actions-cell .btn { margin-left: 6px; } +.rm-table .step-actions-cell .jc-btn { margin-left: 6px; } tr.step-detail-row td { - background: var(--rm-card-bg); + background: var(--jc-card-bg); padding: 14px 18px; } @@ -471,27 +466,27 @@ tr.step-detail-row td { } .row input { - font-family: var(--rm-font); + font-family: var(--jc-font); font-size: 0.8125rem; padding: 6px 10px; - border: 1px solid var(--rm-border-2); - border-radius: var(--rm-radius); - background: var(--rm-white); - color: var(--rm-text); + border: 1px solid var(--jc-border-2); + border-radius: var(--jc-radius); + background: var(--jc-white); + color: var(--jc-text); } .hint { font-size: 0.8125rem; - color: var(--rm-text-muted); + color: var(--jc-text-muted); font-style: italic; } /* Marks a step whose apply() mutates remote state (spec §3.8), slotted into the same amber domain chrome.css already uses for "pending"/in-progress. */ .tag.mutating { - background: var(--rm-tag-amber-bg); - color: var(--rm-tag-amber-text); - border-color: var(--rm-tag-amber-border); + background: var(--jc-tag-amber-bg); + color: var(--jc-tag-amber-text); + border-color: var(--jc-tag-amber-border); } /* new-release.js renders the console into a <pre>, not the mockup's <div> — @@ -503,9 +498,9 @@ pre.rm-console { /* A review-gate step held after its read-only work ran — reuses the same paused affordance as the vote gate, since both are "done its work, awaiting a human". */ .tag.status.awaiting-review { - background: var(--rm-tag-blue-bg); - color: var(--rm-tag-blue-text); - border-color: var(--rm-tag-blue-border); + background: var(--jc-tag-blue-bg); + color: var(--jc-tag-blue-text); + border-color: var(--jc-tag-blue-border); } .tag.status.awaiting-review::before { content: '\23F8'; margin-right: 3px; font-size: 9px; } @@ -518,16 +513,16 @@ pre.rm-console { max-width: 1180px; margin: 0 auto 16px; padding: 10px 14px; - border: 1px solid var(--rm-tag-neutral-border); + border: 1px solid var(--jc-tag-neutral-border); border-radius: 8px; font-size: 13px; - color: var(--rm-muted); + color: var(--jc-text-muted); display: flex; align-items: center; gap: 10px; } -.rm-mode-banner.safe { background: var(--rm-tag-green-bg); border-color: var(--rm-tag-green-border); } +.rm-mode-banner.safe { background: var(--jc-tag-green-bg); border-color: var(--jc-tag-green-border); } .rm-mode-banner.live { background: #fdeceb; border-color: #f3c6c2; } .rm-mode-chip { @@ -541,8 +536,8 @@ pre.rm-console { border: 1px solid transparent; } -.rm-mode-chip.safe { background: var(--rm-tag-green-bg); color: var(--rm-tag-green-text); border-color: var(--rm-tag-green-border); } -.rm-mode-chip.live { background: #fdeceb; color: var(--rm-danger); border-color: #f3c6c2; } +.rm-mode-chip.safe { background: var(--jc-tag-green-bg); color: var(--jc-tag-green-text); border-color: var(--jc-tag-green-border); } +.rm-mode-chip.live { background: #fdeceb; color: var(--jc-danger); border-color: #f3c6c2; } /* ========================================================================== App-local Input / Execution subtabs (client-side show/hide; see new-release.js) @@ -554,17 +549,17 @@ pre.rm-console { max-width: 1180px; margin: 0 auto 16px; padding: 4px; - background: var(--rm-white); - border: 1px solid var(--rm-border); + background: var(--jc-white); + border: 1px solid var(--jc-border); border-radius: 999px; width: fit-content; } .rm-subtab { - font-family: var(--rm-font); + font-family: var(--jc-font); font-size: 0.8125rem; font-weight: 600; - color: var(--rm-text-muted-2); + color: var(--jc-text-soft); background: transparent; border: none; border-radius: 999px; @@ -572,11 +567,11 @@ pre.rm-console { cursor: pointer; } -.rm-subtab:hover { background: var(--rm-accent-wash); } +.rm-subtab:hover { background: var(--jc-accent-wash); } .rm-subtab.active { - background: var(--rm-accent); - color: var(--rm-white); + background: var(--jc-accent); + color: var(--jc-white); } .rm-subtab-panel[hidden] { display: none; } @@ -613,24 +608,24 @@ pre.rm-console { .rm-field label { font-size: 0.75rem; font-weight: 700; - color: var(--rm-text-muted-2); + color: var(--jc-text-soft); } .rm-field-opt { font-weight: 500; font-style: italic; - color: var(--rm-text-muted); + color: var(--jc-text-muted); } .rm-field input, .rm-field textarea { - font-family: var(--rm-font); + font-family: var(--jc-font); font-size: 0.8125rem; padding: 8px 11px; - border: 1px solid var(--rm-border-2); - border-radius: var(--rm-radius); - background: var(--rm-white); - color: var(--rm-text); + border: 1px solid var(--jc-border-2); + border-radius: var(--jc-radius); + background: var(--jc-white); + color: var(--jc-text); width: 100%; box-sizing: border-box; } @@ -643,13 +638,13 @@ pre.rm-console { .rm-field input:focus, .rm-field textarea:focus { outline: none; - border-color: var(--rm-accent); - box-shadow: 0 0 0 2px var(--rm-accent-wash); + border-color: var(--jc-accent); + box-shadow: 0 0 0 2px var(--jc-accent-wash); } .rm-field input[readonly] { - background: var(--rm-card-bg); - color: var(--rm-text-muted-2); + background: var(--jc-card-bg); + color: var(--jc-text-soft); } .rm-mode-picker { @@ -662,7 +657,7 @@ pre.rm-console { font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; - color: var(--rm-text-muted); + color: var(--jc-text-muted); margin-bottom: 8px; } @@ -686,7 +681,7 @@ pre.rm-console { margin: 0; box-shadow: none; border: none; - accent-color: var(--rm-accent); + accent-color: var(--jc-accent); } .rm-form-actions { @@ -699,16 +694,7 @@ pre.rm-console { /* The run is armed for live mutation (LIVE only). */ .tag.armed { background: #fdeceb; - color: var(--rm-danger); + color: var(--jc-danger); border-color: #f3c6c2; font-weight: 700; } - -/* "Arm this run" — a deliberately cautionary amber action distinct from the primary/danger buttons. */ -.btn-warning { - background: var(--rm-tag-amber-bg); - color: var(--rm-tag-amber-text); - border: 1px solid var(--rm-tag-amber-border); -} - -.btn-warning:hover { filter: brightness(0.97); } diff --git a/src/main/resources/static/js/new-release.js b/src/main/resources/static/js/new-release.js index 24efd4632a..7020120f91 100644 --- a/src/main/resources/static/js/new-release.js +++ b/src/main/resources/static/js/new-release.js @@ -56,9 +56,9 @@ knownIssues: val('nr-knownIssues'), acknowledgements: val('nr-acknowledgements') }) }); - if (msg) { msg.textContent = r.ok ? 'Saved.' : 'Save failed.'; msg.style.color = r.ok ? '' : 'var(--rm-danger)'; } + if (msg) { msg.textContent = r.ok ? 'Saved.' : 'Save failed.'; msg.style.color = r.ok ? '' : 'var(--jc-danger)'; } } catch (e) { - if (msg) { msg.textContent = 'Save failed.'; msg.style.color = 'var(--rm-danger)'; } + if (msg) { msg.textContent = 'Save failed.'; msg.style.color = 'var(--jc-danger)'; } } }; @@ -108,20 +108,20 @@ // Mirrors the mockup's renderActions(step): button set is entirely a function of current status // (spec §3 decisions #11/#12 — every button below is the SAME preview/apply call regardless of status). function renderActions(stepId, status) { - const dryRun = '<button class="btn btn-outline btn-sm" onclick="nrDryRun(\'' + stepId + '\')">Dry-run</button>'; + const dryRun = '<button class="jc-btn jc-btn-outline jc-btn-sm" onclick="nrDryRun(\'' + stepId + '\')">Dry-run</button>'; if (status === 'running') - return '<button class="btn btn-outline btn-sm" disabled>Dry-run</button> <button class="btn btn-primary btn-sm" disabled>Running…</button>'; + return '<button class="jc-btn jc-btn-outline jc-btn-sm" disabled>Dry-run</button> <button class="jc-btn jc-btn-primary jc-btn-sm" disabled>Running…</button>'; if (status === 'failed') - return dryRun + ' <button class="btn btn-primary btn-sm" onclick="nrApply(\'' + stepId + '\')">Resume</button>'; + return dryRun + ' <button class="jc-btn jc-btn-primary jc-btn-sm" onclick="nrApply(\'' + stepId + '\')">Resume</button>'; if (status === 'awaiting-vote') return renderVoteGateActions(); if (status === 'awaiting-review') - return dryRun + ' <button class="btn btn-primary btn-sm" onclick="nrConfirmReview(\'' + stepId + '\')">Confirm review</button>'; + return dryRun + ' <button class="jc-btn jc-btn-primary jc-btn-sm" onclick="nrConfirmReview(\'' + stepId + '\')">Confirm review</button>'; if (status === 'succeeded') - return dryRun + ' <button class="btn btn-outline btn-sm" onclick="nrApply(\'' + stepId + '\')">Re-run</button>'; + return dryRun + ' <button class="jc-btn jc-btn-outline jc-btn-sm" onclick="nrApply(\'' + stepId + '\')">Re-run</button>'; if (status === 'skipped') - return dryRun + ' <button class="btn btn-outline btn-sm" onclick="nrApply(\'' + stepId + '\')">Run anyway</button>'; - return dryRun + ' <button class="btn btn-primary btn-sm" onclick="nrApply(\'' + stepId + '\')">' + runLabel + '</button>'; + return dryRun + ' <button class="jc-btn jc-btn-outline jc-btn-sm" onclick="nrApply(\'' + stepId + '\')">Run anyway</button>'; + return dryRun + ' <button class="jc-btn jc-btn-primary jc-btn-sm" onclick="nrApply(\'' + stepId + '\')">' + runLabel + '</button>'; } // §5.15/§5.16: entering vote-gate only opens the vote (sets AWAITING_VOTE) — it is NOT itself the @@ -131,12 +131,12 @@ // requires the operator to pick a real outcome and type the tally summary read off the vote thread. function renderVoteGateActions() { if (mode !== 'LIVE') - return '<button class="btn btn-primary btn-sm" onclick="nrVoteResult(\'passed\')">' + runLabel + '</button>' + return '<button class="jc-btn jc-btn-primary jc-btn-sm" onclick="nrVoteResult(\'passed\')">' + runLabel + '</button>' + '<span class="rm-step-note">Records a simulated passing vote (no real 72h wait or tally) and advances the run.</span>'; return '<div class="rm-vote-form">' + '<label>Vote outcome<select id="nr-vote-outcome"><option value="passed">Passed</option><option value="rejected">Rejected</option></select></label>' + '<label>Tally summary<textarea id="nr-vote-tally" rows="3" placeholder="+1/0/-1 counts, binding voters, read off the vote thread"></textarea></label>' - + '<button class="btn btn-primary btn-sm" onclick="nrVoteResult()">Submit vote result</button>' + + '<button class="jc-btn jc-btn-primary jc-btn-sm" onclick="nrVoteResult()">Submit vote result</button>' + '</div>'; } @@ -264,7 +264,7 @@ if (armArea) { armArea.innerHTML = snap.armed ? '<span class="tag armed" title="This run is armed for live mutation">ARMED</span>' - : '<button class="btn btn-warning" onclick="nrArm()">Arm this run</button>'; + : '<button class="jc-btn jc-btn-warning" onclick="nrArm()">Arm this run</button>'; } let selected = null; diff --git a/src/main/resources/templates/admin.ftlh b/src/main/resources/templates/admin.ftlh new file mode 100644 index 0000000000..8b8f174d55 --- /dev/null +++ b/src/main/resources/templates/admin.ftlh @@ -0,0 +1,35 @@ +<#-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<#assign activeTab = "admin"> +<#macro content> + <div class="jc-card"> + <div class="jc-card-header"> + <h2>Admin</h2> + </div> + + <#-- TODO-399 Phase C: the composed tabs/sub-tabs page shell (PageTable.of(...) via AdminRest.page()), + pre-serialized server-side and inserted as trusted markup: ?no_esc suppresses this .ftlh's HTML + auto-escaping so the emitted [data-juneau-page]/<table data-juneau-view>/<script> markup is not + double-escaped. juneau-pages.js hash-routes the tabs and lazy-inits each panel's DataTable on first + activation via juneau-views.js's initTable. --> + <div class="rm-table-scroll"> + ${pageTable?no_esc} + </div> + </div> +</#macro> +<#include "base.ftlh"> diff --git a/src/main/resources/templates/base.ftlh b/src/main/resources/templates/base.ftlh index 56233bc319..e59bac0ac7 100644 --- a/src/main/resources/templates/base.ftlh +++ b/src/main/resources/templates/base.ftlh @@ -28,22 +28,31 @@ <meta charset="utf-8"> <title>Apache Juneau · Release Manager</title> <link rel="icon" type="image/svg+xml" href="/img/oakleaf.svg"> - <#if activeTab?? && activeTab == 'releases'> + <#if activeTab?? && (activeTab == 'releases' || activeTab == 'admin')> <link rel="stylesheet" href="/datatables/dataTables.dataTables.min.css"> + <#-- DataTables Buttons extension (copy/csv export) — caller-provided per ASF category-A discipline; CDN + coordinates per the toolkit design §6.11. Only the copy/csv cluster needs this; excel/pdf (JSZip/pdfmake) + stay unloaded and are feature-detected off by juneau-ribbon.js. --> + <link rel="stylesheet" href="https://cdn.datatables.net/buttons/3.2.0/css/buttons.dataTables.min.css"> + <#-- First-party toolkit base .tag chip stylesheet (neutral); the app's console-ui palette themes the same + .tag.<domain>.<value> classes. This same stylesheet also carries the TODO-399 Phase C .jc-tab/.jc-panel + page-shell base rules the Admin tab's juneau-pages.js runtime relies on. --> + <link rel="stylesheet" href="${viewsCssUrl!'/rest/releases/juneau-views.css'}"> </#if> + <link rel="stylesheet" href="/juneau-console/chrome.css"> <link rel="stylesheet" href="/css/chrome.css"> </head> <body> - <header class="rm-header"> - <div class="rm-brand"> - <div class="rm-brand-mark" role="img" aria-label="Apache Juneau"></div> - <div class="rm-brand-title">Apache Juneau <span class="rm-brand-sep">·</span> Release Manager</div> + <header class="jc-header"> + <div class="jc-brand"> + <div class="jc-logo" role="img" aria-label="Apache Juneau"></div> + <div class="jc-brand-title">Apache Juneau <span class="jc-brand-sep">·</span> Release Manager</div> </div> - <div class="rm-header-actions"> - <button class="rm-icon-btn" title="Help" aria-label="Help"> + <div class="jc-header-actions"> + <button class="jc-icon-btn" title="Help" aria-label="Help"> <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><text x="12" y="16.5" text-anchor="middle" font-size="11" font-weight="700" fill="currentColor">?</text></svg> </button> - <div class="rm-avatar" title="Release Manager">RM</div> + <div class="jc-avatar" title="Release Manager">RM</div> </div> </header> @@ -51,22 +60,41 @@ Active tab is driven by the `activeTab` attribute each page template assigns before including this layout — no hardcoded per-page string compares here. --> - <nav class="nav"> - <a class="nav-tab<#if activeTab?? && activeTab == 'home'> active</#if>" href="/rest/home">Home</a> - <a class="nav-tab<#if activeTab?? && activeTab == 'credentials'> active</#if>" href="/rest/credentials">Credentials</a> - <a class="nav-tab<#if activeTab?? && activeTab == 'releases'> active</#if>" href="/rest/releases">Releases</a> - <a class="nav-tab<#if activeTab?? && activeTab == 'new'> active</#if>" href="/rest/runs">New Release</a> + <nav class="jc-nav"> + <a class="jc-nav-tab<#if activeTab?? && activeTab == 'home'> active</#if>" href="/rest/home">Home</a> + <a class="jc-nav-tab<#if activeTab?? && activeTab == 'credentials'> active</#if>" href="/rest/credentials">Credentials</a> + <a class="jc-nav-tab<#if activeTab?? && activeTab == 'releases'> active</#if>" href="/rest/releases">Releases</a> + <a class="jc-nav-tab<#if activeTab?? && activeTab == 'new'> active</#if>" href="/rest/runs">New Release</a> + <a class="jc-nav-tab<#if activeTab?? && activeTab == 'admin'> active</#if>" href="/rest/admin">Admin</a> </nav> - <main class="rm-main"> + <main class="jc-main"> <#-- child templates define the "content" macro, then <#include> this layout --> <@content/> </main> - <#if activeTab?? && activeTab == 'releases'> + <#if activeTab?? && (activeTab == 'releases' || activeTab == 'admin')> + <#-- Caller-provided libs: self-hosted jQuery + DataTables, plus the DataTables Buttons extension (copy/csv) + from CDN. Then the first-party toolkit runtime in load order renders -> icons -> ribbon -> views; + juneau-views.js scans table[data-juneau-view] on DOMContentLoaded and hydrates the server-side table + + ribbon. juneau-icons.js MUST load before juneau-ribbon.js: the ribbon/paging-pill buttons resolve their + glyphs from the icon registry (window.JuneauViews.icons) at build time, so the registry must already + exist when juneau-ribbon.js/juneau-views.js render them. The old hand-rolled /js/releases.js DataTable + init is retired — the toolkit owns initialization now. --> <script src="/datatables/jquery.min.js"></script> <script src="/datatables/dataTables.min.js"></script> - <script src="/js/releases.js"></script> + <script src="https://cdn.datatables.net/buttons/3.2.0/js/dataTables.buttons.min.js"></script> + <script src="https://cdn.datatables.net/buttons/3.2.0/js/buttons.html5.min.js"></script> + <script src="${rendersJsUrl!'/rest/releases/juneau-renders.js'}"></script> + <script src="${iconsJsUrl!'/rest/releases/juneau-icons.js'}"></script> + <script src="${ribbonJsUrl!'/rest/releases/juneau-ribbon.js'}"></script> + <script src="${viewsJsUrl!'/rest/releases/juneau-views.js'}"></script> + </#if> + <#if activeTab?? && activeTab == 'admin'> + <#-- TODO-399 Phase C: the opt-in tabs/sub-tabs page runtime — only the Admin tab's composed PageDef page loads + this; the single-view Releases tab above does not. Must load after juneau-views.js (it calls the + now-public NS.init.initTable to lazy-init a panel's DataTable on first activation). --> + <script src="${pagesJsUrl!'/rest/admin/juneau-pages.js'}"></script> </#if> <#if activeTab?? && activeTab == 'credentials'> <script src="/js/credentials.js"></script> diff --git a/src/main/resources/templates/credentials.ftlh b/src/main/resources/templates/credentials.ftlh index c4898d8ec2..e6543bd3aa 100644 --- a/src/main/resources/templates/credentials.ftlh +++ b/src/main/resources/templates/credentials.ftlh @@ -17,7 +17,7 @@ <#assign activeTab = "credentials"> <#macro content> - <div class="rm-card"> + <div class="jc-card"> <#list credentials as c> <div class="cred" data-name="${c.name}"> <h3>${c.label} @@ -30,8 +30,8 @@ <input type="text" class="acct" placeholder="<#if c.name=='apache'>Apache availid<#else>GPG key ID</#if>"> </#if> <input type="password" class="secret" placeholder="<#if c.name=='apache'>password<#elseif c.name=='gpg'>passphrase<#else>token</#if>"> - <button class="btn btn-primary" onclick="rmSet('${c.name}', this)">Save</button> - <button class="btn btn-outline" onclick="rmValidate('${c.name}', this)">Validate</button> + <button class="jc-btn jc-btn-primary" onclick="rmSet('${c.name}', this)">Save</button> + <button class="jc-btn jc-btn-outline" onclick="rmValidate('${c.name}', this)">Validate</button> </div> <div class="msg">${c.lastMessage!''}</div> </div> diff --git a/src/main/resources/templates/home.ftlh b/src/main/resources/templates/home.ftlh index 631075926d..1ab226d10d 100644 --- a/src/main/resources/templates/home.ftlh +++ b/src/main/resources/templates/home.ftlh @@ -17,8 +17,8 @@ <#assign activeTab = "home"> <#macro content> - <div class="rm-card"> - <div class="rm-card-header"> + <div class="jc-card"> + <div class="jc-card-header"> <h2>Workflow</h2> </div> <ol class="rm-home-steps"> @@ -28,8 +28,8 @@ </ol> </div> - <div class="rm-card"> - <div class="rm-card-header"> + <div class="jc-card"> + <div class="jc-card-header"> <h2>Prerequisites</h2> </div> <ul class="rm-home-prereqs"> diff --git a/src/main/resources/templates/new-release.ftlh b/src/main/resources/templates/new-release.ftlh index b3aead6c27..70ad3dd45a 100644 --- a/src/main/resources/templates/new-release.ftlh +++ b/src/main/resources/templates/new-release.ftlh @@ -38,7 +38,7 @@ <#-- ===================== INPUT SUBTAB: release metadata + narrative ===================== --> <section class="rm-subtab-panel" id="nr-panel-input" data-subtab="input"<#if hasRun> hidden</#if>> - <div class="rm-card rm-form-card"> + <div class="jc-card rm-form-card"> <#if !hasRun> <h2>Start a new release</h2> <div class="rm-form-grid"> @@ -114,9 +114,9 @@ <div class="rm-form-actions"> <#if !hasRun> - <button class="btn btn-primary" onclick="nrStart()">Start</button> + <button class="jc-btn jc-btn-primary" onclick="nrStart()">Start</button> <#else> - <button class="btn btn-primary" onclick="nrSaveDetails()">Save details</button> + <button class="jc-btn jc-btn-primary" onclick="nrSaveDetails()">Save details</button> <span id="nr-details-msg" class="rm-step-note"></span> </#if> </div> @@ -126,7 +126,7 @@ <#-- ===================== EXECUTION SUBTAB: step rail + detail/console ===================== --> <section class="rm-subtab-panel" id="nr-panel-exec" data-subtab="exec"<#if !hasRun> hidden</#if>> <#if !hasRun> - <div class="rm-card"> + <div class="jc-card"> <p class="hint">No active run yet. Fill in the Input tab and click Start to begin.</p> </div> <#else> @@ -142,11 +142,11 @@ <#if armed!false> <span class="tag armed" title="This run is armed for live mutation">ARMED</span> <#else> - <button class="btn btn-warning" onclick="nrArm()">Arm this run</button> + <button class="jc-btn jc-btn-warning" onclick="nrArm()">Arm this run</button> </#if> </span> </#if> - <button class="btn btn-danger" onclick="nrDropRc()">Drop RC</button> + <button class="jc-btn jc-btn-danger" onclick="nrDropRc()">Drop RC</button> </div> </div> <div class="rm-run-meta" style="margin-top:10px;"> diff --git a/src/main/resources/templates/release-detail.ftlh b/src/main/resources/templates/release-detail.ftlh index 65ad2f47fd..293ed4ccd6 100644 --- a/src/main/resources/templates/release-detail.ftlh +++ b/src/main/resources/templates/release-detail.ftlh @@ -17,8 +17,8 @@ <#assign activeTab = "releases"> <#macro content> - <div class="rm-card"> - <div class="rm-card-header"> + <div class="jc-card"> + <div class="jc-card-header"> <h2>Release ${release.version!'—'}</h2> <a href="/rest/releases">← All releases</a> </div> diff --git a/src/main/resources/templates/releases.ftlh b/src/main/resources/templates/releases.ftlh index c947c753ac..ae76387609 100644 --- a/src/main/resources/templates/releases.ftlh +++ b/src/main/resources/templates/releases.ftlh @@ -17,47 +17,17 @@ <#assign activeTab = "releases"> <#macro content> - <div class="rm-card"> - <div class="rm-card-header"> + <div class="jc-card"> + <div class="jc-card-header"> <h2>All Releases</h2> </div> + <#-- Rich-view toolkit table + JSON sidecar, pre-serialized server-side (ReleaseRest.page via + ViewTable.of) and inserted as trusted markup: ?no_esc suppresses this .ftlh's HTML auto-escaping + so the emitted <table data-juneau-view>/<script> markup is not double-escaped. juneau-views.js + hydrates it into a server-side DataTable + ribbon on DOMContentLoaded. --> <div class="rm-table-scroll"> - <table id="releases" class="rm-table"> - <thead> - <tr> - <th>Version</th><th>RC</th><th>Status</th><th>Stage</th> - <th>Vote closes</th><th>Released</th><th>Links</th> - </tr> - </thead> - <tbody> - <#list releases as r> - <#if r.rc?? && r.rc != '—'> - <#assign rcNumber = r.rc?replace('RC', '')> - <#else> - <#assign rcNumber = '1'> - </#if> - <tr> - <td class="version-cell"><#if r.version??><a href="/rest/releases/${r.version}/${rcNumber}">${r.version}</a><#else>—</#if></td> - <td class="rc-cell">${r.rc!'—'}</td> - <td><span class="tag status ${(r.status!'UNKNOWN')?lower_case}">${r.status!'—'}</span></td> - <td> - <#if !(r.stage??) || r.stage == '—'> - — - <#else> - <span class="tag stage ${r.stage?lower_case?replace(' ', '-')}">${r.stage}</span> - </#if> - </td> - <td><@ts value=r.voteCloses/></td> - <td>${r.released!'—'}</td> - <td class="links-cell"> - <#if r.githubReleaseUrl??><a href="${r.githubReleaseUrl}">GitHub</a></#if> - <#if r.milestoneUrl??><a href="${r.milestoneUrl}">Milestone</a></#if> - </td> - </tr> - </#list> - </tbody> - </table> + ${viewTable?no_esc} </div> </div> </#macro> diff --git a/src/test/java/org/apache/juneau/releng/rest/AdminRestTest.java b/src/test/java/org/apache/juneau/releng/rest/AdminRestTest.java new file mode 100644 index 0000000000..134bd115b9 --- /dev/null +++ b/src/test/java/org/apache/juneau/releng/rest/AdminRestTest.java @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.juneau.releng.rest; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.apache.juneau.commons.inject.StackOverlay; +import org.apache.juneau.commons.utils.IoUtils; +import org.apache.juneau.marshall.html.HtmlSerializer; +import org.apache.juneau.rest.mock.MockRestClient; +import org.apache.juneau.rest.server.views.PageTable; +import org.apache.juneau.rest.server.views.ViewTable; +import org.apache.juneau.rest.server.views.ViewsMixin; +import org.junit.jupiter.api.Test; + +/** + * TODO-399 Phase C dogfood (tasks 10-12): the RM {@code Admin} tab composes the existing Releases/Credentials + * {@link org.apache.juneau.rest.server.views.ViewDef ViewDef}s into one {@link org.apache.juneau.rest.server.views.PageDef PageDef} + * page, rendered by {@link PageTable} and served through {@link AdminRest}. + */ +class AdminRestTest { + + /** + * {@link MockRestClient#create(Object)} caches its {@code RestContext} per resource class (see + * {@code ReleaseRestTest}'s identical helper javadoc); pass a fresh {@link StackOverlay} to opt out. + */ + @SuppressWarnings({ + "resource" // Caller owns and closes the returned MockRestClient (via try-with-resources); Eclipse JDT @Owning warning is by design. + }) + private static MockRestClient client() { + return MockRestClient.builder(new AdminRest()).overridingBeanStore(new StackOverlay()).build(); + } + + // ----------------------------------------------------------------------------------------------------------- + // Task 10: PageDef composition + // ----------------------------------------------------------------------------------------------------------- + + @Test + void adminPageComposesOneTabPerExistingView() { + var page = AdminRest.adminPage(); + assertEquals("admin", page.id); + assertEquals(2, page.tabs.size()); + assertEquals("releases", page.tabs.get(0).id); + assertEquals("releases", page.tabs.get(0).view.id); + assertEquals("credentials", page.tabs.get(1).id); + assertEquals("credentials", page.tabs.get(1).view.id); + } + + @Test + void adminPageBuildsWithoutValidationErrors() { + // PageDef.build() rejects duplicate tab ids / duplicate referenced ViewDef ids (Phase C task 2 rules); + // building here (rather than only in AdminRest.adminPage()) proves the composition is actually valid, + // not just that adminPage() happens not to throw. + assertDoesNotThrow(AdminRest::adminPage); + } + + // ----------------------------------------------------------------------------------------------------------- + // Task 11: PageTable wiring + no regression to the standalone per-view output + // ----------------------------------------------------------------------------------------------------------- + + @Test + void pageServesPageTableShellWithBothPanelsAndSidecars() throws Exception { + try (var client = client()) { + try (var resp = client.request("GET", "/").run()) { + assertEquals(200, resp.getStatusCode()); + var body = resp.getBodyAsString(); + assertTrue(body.contains("data-juneau-page='admin'"), "Missing page shell: " + body); + assertTrue(body.contains("data-juneau-view='releases'"), "Missing releases panel: " + body); + assertTrue(body.contains("data-juneau-view='credentials'"), "Missing credentials panel: " + body); + assertTrue(body.contains("juneau-page:admin"), "Missing PAGE_META sidecar: " + body); + assertTrue(body.contains("juneau-view:releases"), "Missing releases VIEW_META sidecar: " + body); + assertTrue(body.contains("juneau-view:credentials"), "Missing credentials VIEW_META sidecar: " + body); + } + } + } + + @Test + void wrappedReleasesViewMarkupIsByteForByteIdenticalToStandalone() { + var wrapped = HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(PageTable.of(AdminRest.adminPage())); + var standalone = HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(ViewTable.of(ReleaseRest.releasesView())); + assertTrue(wrapped.contains(standalone), + "Page-wrapped Releases view markup diverged from the standalone ViewTable.of(...) output."); + } + + @Test + void wrappedCredentialsViewMarkupIsByteForByteIdenticalToStandalone() { + var wrapped = HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(PageTable.of(AdminRest.adminPage())); + var standalone = HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(ViewTable.of(CredentialRest.credentialsView())); + assertTrue(wrapped.contains(standalone), + "Page-wrapped Credentials view markup diverged from the standalone ViewTable.of(...) output."); + } + + @Test + void assetsAreServedAtTheAdminMount() throws Exception { + try (var client = client()) { + try (var resp = client.request("GET", ViewsMixin.PAGES_JS_PATH).run()) { + assertEquals(200, resp.getStatusCode()); + assertTrue(resp.getBodyAsString().contains("JuneauViews")); + } + try (var resp = client.request("GET", ViewsMixin.VIEWS_JS_PATH).run()) { + assertEquals(200, resp.getStatusCode()); + } + } + } + + // ----------------------------------------------------------------------------------------------------------- + // Task 12: the composed page uses the self-contained -views tab shell (PageTable's classes), not hand-rolled + // per-page tab markup — verified here structurally (the shell's own marker classes); base.ftlh's nav-link and + // asset-include wiring for the Admin tab is exercised end-to-end by pageServesPageTableShellWithBothPanelsAndSidecars + // once rendered through the FreeMarker template at /admin. + // ----------------------------------------------------------------------------------------------------------- + + @Test + void composedPageUsesTheSharedTabShellClassesNotBespokeMarkup() { + var html = HtmlSerializer.DEFAULT_SIMPLE_SQ.toString(PageTable.of(AdminRest.adminPage())); + assertTrue(html.contains("class='" + PageTable.TAB_BAR_CLASS + "'") || html.contains(PageTable.TAB_BAR_CLASS)); + assertTrue(html.contains(PageTable.TAB_CLASS)); + assertTrue(html.contains(PageTable.PANEL_CLASS)); + } + + /** + * The app's own cross-resource nav (base.ftlh's {@code .jc-nav}) gains an Admin entry, and the Admin tab pulls + * in the opt-in {@code juneau-pages.js} runtime after {@code juneau-views.js} — the top-level nav + * mechanism itself (real links across {@code @Rest(children=...)} resources) is intentionally unchanged + * (design doc non-goal: no top-level cross-resource navigation redesign; Phase C owns only the in-page tab + * switch, which {@link #composedPageUsesTheSharedTabShellClassesNotBespokeMarkup()} already verifies uses the + * shared shell classes). + */ + @Test + void baseTemplateWiresTheAdminNavLinkAndPagesRuntime() throws IOException { + String base; + try (var in = AdminRestTest.class.getResourceAsStream("/templates/base.ftlh")) { + assertNotNull(in, "templates/base.ftlh not found on the test classpath"); + base = new String(IoUtils.readBytes(in), StandardCharsets.UTF_8); + } + assertTrue(base.contains("href=\"/rest/admin\""), "Missing Admin nav link: " + base); + assertTrue(base.contains("activeTab == 'admin'"), "Missing admin-tab conditional asset wiring: " + base); + assertTrue(base.contains("pagesJsUrl"), "Missing juneau-pages.js include for the Admin tab: " + base); + } + + /** + * Regression: {@code juneau-icons.js} was never included on the page (only renders/ribbon/views were), so the + * icon registry was absent at ribbon-build time and every ribbon/paging-pill button fell back to rendering its + * label as plain text instead of a glyph. Asserts the include exists AND is ordered before {@code ribbonJsUrl} + * (the ribbon/pill buttons resolve their icons from the registry when they're built, so it must already exist). + */ + @Test + void baseTemplateIncludesIconsJsBeforeRibbonJs() throws IOException { + String base; + try (var in = AdminRestTest.class.getResourceAsStream("/templates/base.ftlh")) { + assertNotNull(in, "templates/base.ftlh not found on the test classpath"); + base = new String(IoUtils.readBytes(in), StandardCharsets.UTF_8); + } + assertTrue(base.contains("iconsJsUrl"), "Missing juneau-icons.js include: " + base); + var iconsIdx = base.indexOf("iconsJsUrl"); + var ribbonIdx = base.indexOf("ribbonJsUrl"); + assertTrue(ribbonIdx >= 0, "Missing juneau-ribbon.js include: " + base); + assertTrue(iconsIdx < ribbonIdx, + "juneau-icons.js must be included before juneau-ribbon.js (icon registry must exist when the ribbon builds its buttons): " + base); + } +} diff --git a/src/test/java/org/apache/juneau/releng/rest/ReleaseRestTest.java b/src/test/java/org/apache/juneau/releng/rest/ReleaseRestTest.java index f48fbcc67f..3a32235872 100644 --- a/src/test/java/org/apache/juneau/releng/rest/ReleaseRestTest.java +++ b/src/test/java/org/apache/juneau/releng/rest/ReleaseRestTest.java @@ -91,4 +91,52 @@ class ReleaseRestTest { } } } + + /** + * The {@code /data} endpoint speaks the DataTables server-side-processing contract: given a request carrying + * DataTables params it returns a {@code DataTablesResults} envelope ({@code {draw, recordsTotal, recordsFiltered, + * data}}) with server-side per-column filtering applied — not the bare {@code List<Release>} array it used + * to return. Wired via the {@code juneau-rest-server-views} toolkit ({@code ViewDef.queryableSettings()} + + * {@code ProtocolQueryable}); this proves the envelope shape and that filtering happens on the server. + */ + /** + * Regression: the rendered Releases page never included {@code juneau-icons.js} (only renders/ribbon/views + * were wired up), so the icon registry was absent when the ribbon built its buttons and every button fell back + * to rendering its label as plain text instead of a glyph. Asserts the served page's script list carries the + * icons include, ordered before {@code juneau-ribbon.js} (the ribbon resolves icons from the registry when it + * builds its buttons, so the registry must already exist). + */ + @Test + void pageIncludesIconsJsScriptBeforeRibbonJs() throws Exception { + try (var client = client(rest(List.of(release("9.2.1", "RELEASED"))))) { + try (var resp = client.request("GET", "/").run()) { + assertEquals(200, resp.getStatusCode()); + var body = resp.getBodyAsString(); + assertTrue(body.contains("juneau-icons.js"), "Missing juneau-icons.js script include: " + body); + var iconsIdx = body.indexOf("juneau-icons.js"); + var ribbonIdx = body.indexOf("juneau-ribbon.js"); + assertTrue(ribbonIdx >= 0, "Missing juneau-ribbon.js script include: " + body); + assertTrue(iconsIdx < ribbonIdx, + "juneau-icons.js must be included before juneau-ribbon.js: " + body); + } + } + } + + @Test + void dataReturnsDataTablesEnvelopeWithServerSideFilterApplied() throws Exception { + var releases = List.of(release("9.2.1", "RELEASED"), release("9.3.0", "VOTING")); + try (var client = client(rest(releases))) { + try (var resp = client.request("GET", + "/data?draw=3&start=0&length=10&columns[0][data]=status&columns[0][search][value]=RELEASED").run()) { + assertEquals(200, resp.getStatusCode()); + var body = resp.getBodyAsString(); + assertFalse(body.trim().startsWith("["), "Expected an envelope object, not a bare array: " + body); + assertTrue(body.contains("recordsTotal"), "Missing recordsTotal: " + body); + assertTrue(body.contains("recordsFiltered"), "Missing recordsFiltered: " + body); + assertTrue(body.contains("draw"), "Missing draw: " + body); + assertTrue(body.contains("9.2.1"), "Filtered-in row missing: " + body); + assertFalse(body.contains("9.3.0"), "Filtered-out row present: " + body); + } + } + } }
