Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions webapp/e2e/xds-resource-edit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { test, expect } from '@playwright/test';

// Regression test for the xDS resource editor layout: while editing, the commit-summary + Save row lives in a
// sticky action bar so Save stays on screen instead of falling below the 60vh editor.
//
// This exercises the REAL xDS edit UI, which needs a backend with the xDS plugin enabled AND the sample data
// pre-created — i.e. the dedicated xDS test server (`./gradlew :xds:runXdsTestServer`, port 36462, admin/admin,
// which pre-creates the 'my-group' group and 'my-cluster-2' cluster). The default e2e backend
// (`npm run backend` / runTestShiroServer) has no xDS endpoints, so this spec is opt-in: it is skipped unless
// XDS_E2E=1 is set (so it never fails in CI against the xDS-less backend). To run it:
//
// ./gradlew :xds:runXdsTestServer # in one shell (leave running)
// XDS_E2E=1 npm run test:e2e # in the webapp directory
//
// The pre-created group/cluster ids mirror XdsTestServer.SAMPLE_GROUP / SAMPLE_CLUSTER_2.
const GROUP = 'my-group';
const CLUSTER = 'my-cluster-2';

// A modest viewport reproduces the original bug: the 60vh editor plus the tabs/toolbar/References panel above
// it pushed the Save button below the fold.
test.use({ viewport: { width: 1280, height: 720 } });

test.beforeEach(async ({ page }) => {
test.skip(!process.env.XDS_E2E, 'Set XDS_E2E=1 and run ./gradlew :xds:runXdsTestServer (see file header).');

await page.goto('/');
await expect(page.getByText(/Login/)).toBeVisible();
await page.getByPlaceholder('ID').fill('admin');
await page.getByPlaceholder('Password').fill('admin');
await page.getByRole('button', { name: 'Login' }).click();
});

test('Save button stays in the viewport while editing a cluster', async ({ page }) => {
await page.goto(`/app/xds/resource?group=${GROUP}&type=clusters&id=${CLUSTER}`);

// The resource opens read-only; wait for it to load, then switch to edit mode.
const editButton = page.getByRole('button', { name: /^Edit$/ });
await expect(editButton).toBeVisible();
await editButton.click();

// The sticky action bar keeps both the commit-summary input and Save reachable without scrolling.
await expect(page.getByPlaceholder(/Update cluster/i)).toBeVisible();
const saveButton = page.getByRole('button', { name: /^Save$/ });
await expect(saveButton).toBeVisible();
await expect(saveButton).toBeInViewport();
});
32 changes: 23 additions & 9 deletions webapp/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"cronstrue": "^2.50.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.2.13",
"js-yaml": "^4.2.0",
"json5": "^2.2.3",
"jsonpath": "^1.1.1",
"next": "^14.2.4",
Expand All @@ -51,6 +52,7 @@
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.12",
"@types/js-yaml": "^4.0.9",
"@types/jsonpath": "^0.2.0",
"@types/node": "^20.14.10",
"@types/prismjs": "^1.26.4",
Expand Down
11 changes: 9 additions & 2 deletions webapp/src/dogma/common/components/JsonEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,18 @@ interface JsonEditorProps {
onChange?: (value: string) => void;
readOnly?: boolean;
height?: string | number;
language?: 'json' | 'yaml';
}

// Wraps the Monaco editor and configures it to use the locally bundled
// `monaco-editor` package (provided by MonacoWebpackPlugin) instead of a CDN.
export const JsonEditor = ({ value, onChange, readOnly = false, height = '60vh' }: JsonEditorProps) => {
export const JsonEditor = ({
value,
onChange,
readOnly = false,
height = '60vh',
language = 'json',
}: JsonEditorProps) => {
const { colorMode } = useColorMode();
const [ready, setReady] = useState(false);

Expand All @@ -53,7 +60,7 @@ export const JsonEditor = ({ value, onChange, readOnly = false, height = '60vh'
return (
<Editor
height={height}
defaultLanguage="json"
defaultLanguage={language}
theme={colorMode === 'light' ? 'light' : 'vs-dark'}
value={value}
onChange={(v) => onChange?.(v ?? '')}
Expand Down
3 changes: 3 additions & 0 deletions webapp/src/dogma/features/api/baseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ import { AuthState, clearAuth } from 'dogma/features/auth/authSlice';
const baseQuery = fetchBaseQuery({
baseUrl: `${process.env.NEXT_PUBLIC_HOST || ''}/`,
credentials: 'include',
// YAML and plain-text error responses must not be JSON-parsed. 'content-type' switches between
// JSON.parse (application/json) and raw text (everything else) based on the response header.
responseHandler: 'content-type',
prepareHeaders: (headers, { getState, type }) => {
const { auth } = getState() as { auth: AuthState };

Expand Down
3 changes: 3 additions & 0 deletions webapp/src/dogma/features/services/ErrorMessageParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ class ErrorMessageParser {
// value is always a string.
return ErrorMessageParser.asString(object.error);
}
if (object.data && typeof object.data === 'string') {
return object.data;
}
if (object.data && object.data.message) {
let message = ErrorMessageParser.asString(object.data.message);
if (object.data.detail) {
Expand Down
73 changes: 73 additions & 0 deletions webapp/src/dogma/features/xds/EditorActionBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright 2026 LY Corporation
*
* LY Corporation 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:
*
* https://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.
*/
import { ReactNode } from 'react';
import { Box, Flex, HStack, Input, Spacer, useColorModeValue } from '@chakra-ui/react';

interface EditorActionBarProps {
commitSummary: string;
onCommitSummaryChange: (value: string) => void;
commitPlaceholder: string;
// Constrains the bar to the form width (the K8s aggregator form uses "3xl"). Defaults to the full
// width of the content area (used by the full-width YAML resource editor).
maxW?: string;
// Action buttons (Cancel/Save/Preview/Create), rendered right-aligned.
children: ReactNode;
}

// A sticky footer that pins the commit-summary input and action buttons to the bottom of the viewport
// while editing, so Save/Create stays reachable without scrolling past a tall editor or form. It relies
// on the page (body) being the scroll container, matching the rest of the app.
export const EditorActionBar = ({
commitSummary,
onCommitSummaryChange,
commitPlaceholder,
maxW,
children,
}: EditorActionBarProps) => {
// Match the default Chakra body background (the app uses the stock theme, no extendTheme) so the bar
// opaquely covers any editor/form content it overlaps in both color modes.
const bg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
return (
<Box
position="sticky"
bottom={0}
zIndex={1}
mt={4}
py={3}
bg={bg}
borderTopWidth="1px"
borderColor={borderColor}
maxW={maxW}
>
<Flex align="center" gap={3}>
{/* The descriptive placeholder ("Update cluster: ...") stands in for a visible label so the bar
stays a single row; aria-label keeps it accessible. */}
<Input
aria-label="Commit summary"
maxW="md"
value={commitSummary}
onChange={(e) => onCommitSummaryChange(e.target.value)}
placeholder={commitPlaceholder}
/>
<Spacer />
<HStack spacing={3} flexShrink={0}>
{children}
</HStack>
</Flex>
</Box>
);
};
Loading
Loading