Skip to content

Commit 5afa110

Browse files
authored
Adapt the xDS web UI to the YAML-over-HTTP resource API (line#1338)
Motivation: The web UI still spoke JSON. Modifications: - Web UI, talk YAML to the API. Result: - The xDS web UI reads, edits and saves resources as YAML end-to-end against the HTTP API.
1 parent 852e082 commit 5afa110

28 files changed

Lines changed: 745 additions & 422 deletions
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
// Regression test for the xDS resource editor layout: while editing, the commit-summary + Save row lives in a
4+
// sticky action bar so Save stays on screen instead of falling below the 60vh editor.
5+
//
6+
// This exercises the REAL xDS edit UI, which needs a backend with the xDS plugin enabled AND the sample data
7+
// pre-created — i.e. the dedicated xDS test server (`./gradlew :xds:runXdsTestServer`, port 36462, admin/admin,
8+
// which pre-creates the 'my-group' group and 'my-cluster-2' cluster). The default e2e backend
9+
// (`npm run backend` / runTestShiroServer) has no xDS endpoints, so this spec is opt-in: it is skipped unless
10+
// XDS_E2E=1 is set (so it never fails in CI against the xDS-less backend). To run it:
11+
//
12+
// ./gradlew :xds:runXdsTestServer # in one shell (leave running)
13+
// XDS_E2E=1 npm run test:e2e # in the webapp directory
14+
//
15+
// The pre-created group/cluster ids mirror XdsTestServer.SAMPLE_GROUP / SAMPLE_CLUSTER_2.
16+
const GROUP = 'my-group';
17+
const CLUSTER = 'my-cluster-2';
18+
19+
// A modest viewport reproduces the original bug: the 60vh editor plus the tabs/toolbar/References panel above
20+
// it pushed the Save button below the fold.
21+
test.use({ viewport: { width: 1280, height: 720 } });
22+
23+
test.beforeEach(async ({ page }) => {
24+
test.skip(!process.env.XDS_E2E, 'Set XDS_E2E=1 and run ./gradlew :xds:runXdsTestServer (see file header).');
25+
26+
await page.goto('/');
27+
await expect(page.getByText(/Login/)).toBeVisible();
28+
await page.getByPlaceholder('ID').fill('admin');
29+
await page.getByPlaceholder('Password').fill('admin');
30+
await page.getByRole('button', { name: 'Login' }).click();
31+
});
32+
33+
test('Save button stays in the viewport while editing a cluster', async ({ page }) => {
34+
await page.goto(`/app/xds/resource?group=${GROUP}&type=clusters&id=${CLUSTER}`);
35+
36+
// The resource opens read-only; wait for it to load, then switch to edit mode.
37+
const editButton = page.getByRole('button', { name: /^Edit$/ });
38+
await expect(editButton).toBeVisible();
39+
await editButton.click();
40+
41+
// The sticky action bar keeps both the commit-summary input and Save reachable without scrolling.
42+
await expect(page.getByPlaceholder(/Update cluster/i)).toBeVisible();
43+
const saveButton = page.getByRole('button', { name: /^Save$/ });
44+
await expect(saveButton).toBeVisible();
45+
await expect(saveButton).toBeInViewport();
46+
});

webapp/package-lock.json

Lines changed: 23 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

webapp/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"cronstrue": "^2.50.0",
3232
"date-fns": "^3.6.0",
3333
"framer-motion": "^11.2.13",
34+
"js-yaml": "^4.2.0",
3435
"json5": "^2.2.3",
3536
"jsonpath": "^1.1.1",
3637
"next": "^14.2.4",
@@ -51,6 +52,7 @@
5152
"@testing-library/react": "^16.0.0",
5253
"@testing-library/user-event": "^14.5.2",
5354
"@types/jest": "^29.5.12",
55+
"@types/js-yaml": "^4.0.9",
5456
"@types/jsonpath": "^0.2.0",
5557
"@types/node": "^20.14.10",
5658
"@types/prismjs": "^1.26.4",

webapp/src/dogma/common/components/JsonEditor.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,18 @@ interface JsonEditorProps {
2323
onChange?: (value: string) => void;
2424
readOnly?: boolean;
2525
height?: string | number;
26+
language?: 'json' | 'yaml';
2627
}
2728

2829
// Wraps the Monaco editor and configures it to use the locally bundled
2930
// `monaco-editor` package (provided by MonacoWebpackPlugin) instead of a CDN.
30-
export const JsonEditor = ({ value, onChange, readOnly = false, height = '60vh' }: JsonEditorProps) => {
31+
export const JsonEditor = ({
32+
value,
33+
onChange,
34+
readOnly = false,
35+
height = '60vh',
36+
language = 'json',
37+
}: JsonEditorProps) => {
3138
const { colorMode } = useColorMode();
3239
const [ready, setReady] = useState(false);
3340

@@ -53,7 +60,7 @@ export const JsonEditor = ({ value, onChange, readOnly = false, height = '60vh'
5360
return (
5461
<Editor
5562
height={height}
56-
defaultLanguage="json"
63+
defaultLanguage={language}
5764
theme={colorMode === 'light' ? 'light' : 'vs-dark'}
5865
value={value}
5966
onChange={(v) => onChange?.(v ?? '')}

webapp/src/dogma/features/api/baseQuery.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import { AuthState, clearAuth } from 'dogma/features/auth/authSlice';
2121
const baseQuery = fetchBaseQuery({
2222
baseUrl: `${process.env.NEXT_PUBLIC_HOST || ''}/`,
2323
credentials: 'include',
24+
// YAML and plain-text error responses must not be JSON-parsed. 'content-type' switches between
25+
// JSON.parse (application/json) and raw text (everything else) based on the response header.
26+
responseHandler: 'content-type',
2427
prepareHeaders: (headers, { getState, type }) => {
2528
const { auth } = getState() as { auth: AuthState };
2629

webapp/src/dogma/features/services/ErrorMessageParser.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ class ErrorMessageParser {
1717
// value is always a string.
1818
return ErrorMessageParser.asString(object.error);
1919
}
20+
if (object.data && typeof object.data === 'string') {
21+
return object.data;
22+
}
2023
if (object.data && object.data.message) {
2124
let message = ErrorMessageParser.asString(object.data.message);
2225
if (object.data.detail) {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2026 LY Corporation
3+
*
4+
* LY Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
import { ReactNode } from 'react';
17+
import { Box, Flex, HStack, Input, Spacer, useColorModeValue } from '@chakra-ui/react';
18+
19+
interface EditorActionBarProps {
20+
commitSummary: string;
21+
onCommitSummaryChange: (value: string) => void;
22+
commitPlaceholder: string;
23+
// Constrains the bar to the form width (the K8s aggregator form uses "3xl"). Defaults to the full
24+
// width of the content area (used by the full-width YAML resource editor).
25+
maxW?: string;
26+
// Action buttons (Cancel/Save/Preview/Create), rendered right-aligned.
27+
children: ReactNode;
28+
}
29+
30+
// A sticky footer that pins the commit-summary input and action buttons to the bottom of the viewport
31+
// while editing, so Save/Create stays reachable without scrolling past a tall editor or form. It relies
32+
// on the page (body) being the scroll container, matching the rest of the app.
33+
export const EditorActionBar = ({
34+
commitSummary,
35+
onCommitSummaryChange,
36+
commitPlaceholder,
37+
maxW,
38+
children,
39+
}: EditorActionBarProps) => {
40+
// Match the default Chakra body background (the app uses the stock theme, no extendTheme) so the bar
41+
// opaquely covers any editor/form content it overlaps in both color modes.
42+
const bg = useColorModeValue('white', 'gray.800');
43+
const borderColor = useColorModeValue('gray.200', 'gray.700');
44+
return (
45+
<Box
46+
position="sticky"
47+
bottom={0}
48+
zIndex={1}
49+
mt={4}
50+
py={3}
51+
bg={bg}
52+
borderTopWidth="1px"
53+
borderColor={borderColor}
54+
maxW={maxW}
55+
>
56+
<Flex align="center" gap={3}>
57+
{/* The descriptive placeholder ("Update cluster: ...") stands in for a visible label so the bar
58+
stays a single row; aria-label keeps it accessible. */}
59+
<Input
60+
aria-label="Commit summary"
61+
maxW="md"
62+
value={commitSummary}
63+
onChange={(e) => onCommitSummaryChange(e.target.value)}
64+
placeholder={commitPlaceholder}
65+
/>
66+
<Spacer />
67+
<HStack spacing={3} flexShrink={0}>
68+
{children}
69+
</HStack>
70+
</Flex>
71+
</Box>
72+
);
73+
};

0 commit comments

Comments
 (0)