feat(policies): banner and confirmation on change policy [C9S-20] (#1988)

This commit is contained in:
Ali
2026-03-13 14:11:53 +13:00
committed by GitHub
parent 722c1875af
commit 1f9c9b082f
16 changed files with 270 additions and 120 deletions

View File

@@ -2460,6 +2460,7 @@ const (
SecurityDocker PolicyType = "security-docker"
SetupDocker PolicyType = "setup-docker"
RegistryDocker PolicyType = "registry-docker"
ChangeConfirmation PolicyType = "change-confirmation"
)
type HelmInstallStatus string

View File

@@ -152,8 +152,8 @@ fieldset[disabled] .btn {
.btn.btn-link {
@apply text-blue-8 hover:text-blue-9 disabled:text-gray-5;
@apply th-dark:text-blue-8 th-dark:hover:text-blue-7;
@apply th-highcontrast:text-blue-8 th-highcontrast:hover:text-blue-7;
@apply th-dark:text-blue-7 th-dark:hover:text-blue-8;
@apply th-highcontrast:text-blue-5 th-highcontrast:hover:text-blue-6;
}
.btn-group {
@@ -177,14 +177,14 @@ fieldset[disabled] .btn {
a.no-link,
a[ng-click] {
@apply text-current;
@apply text-current th-dark:text-current th-highcontrast:text-current;
@apply hover:text-current hover:no-underline;
@apply focus:text-current focus:no-underline;
}
a,
a.hyperlink {
@apply text-blue-8 hover:text-blue-9;
@apply text-blue-8 hover:text-blue-9 th-dark:text-blue-7 th-dark:hover:text-blue-8 th-highcontrast:text-blue-5 th-highcontrast:hover:text-blue-6;
@apply cursor-pointer hover:underline;
}

View File

@@ -19,11 +19,14 @@ export function EndpointProvider() {
pingInterval: null,
};
environmentStore.subscribe((state) => {
if (!state.environmentId) {
environmentStore.subscribe(
(state) => state.environmentId,
(environmentId) => {
if (!environmentId) {
setCurrentEndpoint(null);
}
});
}
);
return { endpointID, setCurrentEndpoint, currentEndpoint, clean };
@@ -50,9 +53,7 @@ export function EndpointProvider() {
);
}
document.title = endpoint
? `${DEFAULT_TITLE} | ${endpoint.Name}`
: `${DEFAULT_TITLE}`;
document.title = endpoint ? `${endpoint.Name}` : `${DEFAULT_TITLE}`;
}
function currentEndpoint() {

View File

@@ -38,7 +38,7 @@
line-height: 1.42857143;
white-space: nowrap;
font-size: 14px;
color: var(--text-dropdown-menu-color);
color: var(--text-dropdown-menu-color) !important;
text-decoration: none !important;
border-radius: 5px;
}

View File

@@ -12,9 +12,7 @@ import { CodeEditor } from '@@/CodeEditor';
import { FormSectionTitle } from './form-components/FormSectionTitle';
import { FormError } from './form-components/FormError';
import { confirm } from './modals/confirm';
import { ModalType } from './modals';
import { buildConfirmButton } from './modals/utils';
import { confirmWebEditorDiscard } from './modals/confirm';
import { ShortcutsTooltip } from './CodeEditor/ShortcutsTooltip';
type CodeEditorProps = ComponentProps<typeof CodeEditor>;
@@ -93,13 +91,7 @@ export function usePreventExit(
if (!preventExit) {
return true;
}
const confirmed = await confirm({
modalType: ModalType.Warn,
title: 'Are you sure?',
message:
'You currently have unsaved changes in the text editor. Are you sure you want to leave?',
confirmButton: buildConfirmButton('Yes', 'danger'),
});
const confirmed = await confirmWebEditorDiscard();
return confirmed;
});

View File

@@ -0,0 +1,21 @@
import { Meta } from '@storybook/react';
import { useState } from 'react';
import { ColorPicker } from './ColorPicker';
export default {
component: ColorPicker,
title: 'Components/Form/ColorPicker',
} as Meta;
export function Default() {
const [value, setValue] = useState('#3c8dbc');
return (
<ColorPicker
value={value}
onChange={setValue}
id="story-color-picker"
data-cy="story-color-picker"
/>
);
}

View File

@@ -0,0 +1,67 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { ColorPicker } from './ColorPicker';
describe('ColorPicker', () => {
it('renders with initial value and shows text input', () => {
render(
<ColorPicker value="#3c8dbc" onChange={() => {}} data-cy="color-picker" />
);
expect(screen.getByPlaceholderText('e.g. #ffbbbb')).toBeVisible();
expect(screen.getByPlaceholderText('e.g. #ffbbbb')).toHaveValue('#3c8dbc');
expect(screen.getByLabelText('Choose color')).toBeVisible();
});
it('calls onChange when user enters valid hex in text input', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<ColorPicker value="#000000" onChange={onChange} data-cy="color-picker" />
);
const input = screen.getByPlaceholderText('e.g. #ffbbbb');
await user.clear(input);
await user.type(input, 'ff5500');
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('#ff5500');
});
});
it('calls onChange with expanded hex when user enters 3-digit shorthand', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<ColorPicker value="#000000" onChange={onChange} data-cy="color-picker" />
);
const input = screen.getByPlaceholderText('e.g. #ffbbbb');
await user.clear(input);
await user.type(input, 'f50');
await waitFor(() => {
expect(onChange).toHaveBeenCalledWith('#ff5500');
});
});
it('reverts text input to committed value on blur when input is invalid', async () => {
const user = userEvent.setup();
render(
<ColorPicker value="#3c8dbc" onChange={() => {}} data-cy="color-picker" />
);
const input = screen.getByPlaceholderText('e.g. #ffbbbb');
await user.clear(input);
await user.type(input, 'xx');
expect(input).toHaveValue('#xx');
await user.tab();
expect(input).toHaveValue('#3c8dbc');
});
});

View File

@@ -0,0 +1,110 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { Input } from '../Input';
const SHORT_HEX_RE = /^#[0-9a-fA-F]{3}$/;
const FULL_HEX_RE = /^#[0-9a-fA-F]{6}$/;
export interface Props {
value: string;
onChange: (color: string) => void;
'data-cy': string;
id?: string;
pickerId?: string;
}
export function ColorPicker({
value,
onChange,
id = 'colorPickerTextInput',
pickerId = 'colorPickerSwatch',
'data-cy': dataCy = 'color-picker-input',
}: Props) {
const [localHex, setLocalHex] = useState(value);
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
if (!isFocused) {
setLocalHex(value);
}
}, [value, isFocused]);
const swatchColor = getSwatchColor(localHex, value);
return (
<div className="flex items-center gap-2">
<label
aria-label="Choose color"
htmlFor={pickerId}
className="form-control relative h-[34px] w-[34px] shrink-0 cursor-pointer overflow-hidden rounded !mb-0"
style={{ backgroundColor: swatchColor }}
>
<input
type="color"
id={pickerId}
value={swatchColor}
onChange={handleColorChange}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
aria-label="Choose highlight color"
/>
</label>
<Input
id={id}
value={localHex}
onChange={handleTextChange}
onFocus={() => setIsFocused(true)}
onBlur={handleBlur}
className="w-28 uppercase"
maxLength={7}
placeholder="e.g. #ffbbbb"
spellCheck={false}
data-cy={dataCy}
/>
</div>
);
function handleColorChange(e: ChangeEvent<HTMLInputElement>) {
const hex = e.target.value;
setLocalHex(hex);
onChange(hex);
}
function handleTextChange(e: ChangeEvent<HTMLInputElement>) {
const raw = e.target.value;
const normalized = raw.startsWith('#') ? raw : `#${raw}`;
setLocalHex(normalized);
if (isValidHex(normalized)) {
onChange(expandHex(normalized));
}
}
function handleBlur() {
setIsFocused(false);
if (!isValidHex(localHex)) {
setLocalHex(value);
}
}
}
function isValidHex(hex: string): boolean {
return SHORT_HEX_RE.test(hex) || FULL_HEX_RE.test(hex);
}
/** Expands a 3-digit shorthand (#rgb → #rrggbb). Full hex passes through unchanged. */
function expandHex(hex: string): string {
if (SHORT_HEX_RE.test(hex)) {
const [, r, g, b] = hex;
return `#${r}${r}${g}${g}${b}${b}`;
}
return hex;
}
function getSwatchColor(localHex: string, committedValue: string): string {
if (isValidHex(localHex)) {
return expandHex(localHex);
}
if (isValidHex(committedValue)) {
return expandHex(committedValue);
}
return '#ffffff';
}

View File

@@ -13,6 +13,7 @@ interface Props {
className?: string;
htmlFor?: string;
setIsDefaultFolded?: (isDefaultFolded: boolean) => void;
id?: string;
}
export function FormSection({
@@ -25,22 +26,26 @@ export function FormSection({
className,
htmlFor = '',
setIsDefaultFolded,
id,
}: PropsWithChildren<Props>) {
const [isExpanded, setIsExpanded] = useState(!defaultFolded);
const id = `foldingButton${title}`;
const collapsibleIdSuffix = typeof title === 'string' ? title : id;
const collapsibleId = collapsibleIdSuffix
? `foldingButton${collapsibleIdSuffix}`
: undefined;
return (
<div className={className}>
<div className={className} id={id}>
<FormSectionTitle
htmlFor={isFoldable ? id : htmlFor}
htmlFor={isFoldable ? collapsibleId : htmlFor}
titleSize={titleSize}
className={titleClassName}
>
{isFoldable && (
<CollapseExpandButton
isExpanded={isExpanded}
data-cy={id}
id={id}
data-cy={collapsibleId}
id={collapsibleId}
onClick={() => {
setIsExpanded((isExpanded) => !isExpanded);
setIsDefaultFolded?.(isExpanded);

View File

@@ -1,5 +1,5 @@
import { createStore } from 'zustand';
import { persist } from 'zustand/middleware';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
@@ -10,6 +10,7 @@ export const environmentStore = createStore<{
setEnvironmentId(id: EnvironmentId): void;
clear(): void;
}>()(
subscribeWithSelector(
persist(
(set) => ({
environmentId: undefined,
@@ -21,4 +22,5 @@ export const environmentStore = createStore<{
getStorage: () => sessionStorage,
}
)
)
);

View File

@@ -1,69 +1,28 @@
import { useState } from 'react';
import { Modal, openModal } from '@@/modals';
import { Button } from '@@/buttons';
import { SwitchField } from '@@/form-components/SwitchField';
function UpdateIngressPrompt({
onSubmit,
title,
hasOneIngress,
hasOnePort,
}: {
onSubmit: (value?: { noMatch: boolean }) => void;
title: string;
hasOneIngress: boolean;
hasOnePort: boolean;
}) {
const [value, setValue] = useState(false);
import { openSwitchPrompt } from '@@/modals/SwitchPrompt';
import { buildConfirmButton } from '@@/modals/utils';
export async function confirmUpdateAppIngress(
ingressesToUpdate: Array<unknown>,
servicePortsToUpdate: Array<unknown>
) {
const hasOneIngress = ingressesToUpdate.length === 1;
const hasOnePort = servicePortsToUpdate.length === 1;
const rulePlural = !hasOneIngress ? 'rules' : 'rule';
const noMatchSentence = !hasOnePort
? `Service ports in this application no longer match the ingress ${rulePlural}.`
: `A service port in this application no longer matches the ingress ${rulePlural} which may break ingress rule paths.`;
const inputLabel = `Update ingress ${rulePlural} to match the service port changes`;
return (
<Modal onDismiss={() => onSubmit()} aria-label={title}>
<Modal.Header title={title} />
<Modal.Body>
const result = await openSwitchPrompt('Are you sure?', inputLabel, {
message: (
<ul className="ml-3">
<li>Updating the application may cause a service interruption.</li>
<li>{noMatchSentence}</li>
</ul>
<SwitchField
name="noMatch"
data-cy="kube-update-ingress-prompt-switch"
label={inputLabel}
checked={value}
onChange={setValue}
/>
</Modal.Body>
<Modal.Footer>
<Button
onClick={() => onSubmit({ noMatch: value })}
color="primary"
data-cy="update-ingress-confirm-button"
>
Update
</Button>
</Modal.Footer>
</Modal>
);
}
export function confirmUpdateAppIngress(
ingressesToUpdate: Array<unknown>,
servicePortsToUpdate: Array<unknown>
) {
const hasOneIngress = ingressesToUpdate.length === 1;
const hasOnePort = servicePortsToUpdate.length === 1;
return openModal(UpdateIngressPrompt, {
title: 'Are you sure?',
hasOneIngress,
hasOnePort,
),
confirmButton: buildConfirmButton('Update'),
'data-cy': 'kube-update-ingress-prompt-switch',
});
return result ? { noMatch: result.value } : undefined;
}

View File

@@ -15,9 +15,7 @@ import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip';
import { SwitchField } from '@@/form-components/SwitchField';
import { FormActions } from '@@/form-components/FormActions';
import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
import { confirmGenericDiscard } from '@@/modals/confirm';
import { InsightsBox } from '@@/InsightsBox';
import { useIngressControllerClassMapQuery } from '../../ingressClass/useIngressControllerClassMap';
@@ -112,13 +110,7 @@ function InnerForm({
if (!isFormChanged(values, initialValues)) {
return true;
}
const confirmed = await confirm({
modalType: ModalType.Warn,
title: 'Are you sure?',
message:
'You currently have unsaved changes in the cluster setup view. Are you sure you want to leave?',
confirmButton: buildConfirmButton('Yes', 'danger'),
});
const confirmed = await confirmGenericDiscard();
return confirmed;
});

View File

@@ -64,7 +64,7 @@ export function useColumns() {
params={{ id: item.Name, tab: 'events' }}
data-cy={`namespace-warning-link-${item.Name}`}
// use the badge text and hover color
className="text-inherit hover:text-inherit"
className="!text-inherit hover:text-inherit"
title="View events"
>
{item.UnhealthyEventCount}{' '}

View File

@@ -31,7 +31,7 @@ export function getPlatformType(
case EnvironmentType.Azure:
return PlatformType.Azure;
default:
throw new Error(`Environment Type ${envType} is not supported`);
throw new Error(`Environment type ${envType} is not supported`);
}
}

View File

@@ -104,7 +104,7 @@ function ItemAnchor({
onClick={onClick}
className={clsx(
className,
'text-inherit no-underline hover:text-inherit hover:no-underline focus:text-inherit focus:no-underline',
'!text-inherit no-underline hover:!text-inherit hover:!no-underline focus:!text-inherit focus:!no-underline',
'flex h-8 w-full flex-1 items-center space-x-4 rounded-md text-sm',
'transition-colors duration-200 hover:bg-graphite-500',
{

View File

@@ -66,7 +66,7 @@ export function SidebarParent({
to={to}
params={params}
className={clsx(
'w-full h-full items-center flex list-none border-none text-inherit hover:text-inherit hover:no-underline focus:text-inherit focus:no-underline',
'w-full h-full items-center flex list-none border-none !text-inherit hover:!text-inherit hover:no-underline focus:!text-inherit focus:!no-underline',
{
'justify-start': isSidebarOpen,
'justify-center': !isSidebarOpen,