feat(policies): banner and confirmation on change policy [C9S-20] (#1988)
This commit is contained in:
@@ -2460,6 +2460,7 @@ const (
|
||||
SecurityDocker PolicyType = "security-docker"
|
||||
SetupDocker PolicyType = "setup-docker"
|
||||
RegistryDocker PolicyType = "registry-docker"
|
||||
ChangeConfirmation PolicyType = "change-confirmation"
|
||||
)
|
||||
|
||||
type HelmInstallStatus string
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
110
app/react/components/form-components/ColorPicker/ColorPicker.tsx
Normal file
110
app/react/components/form-components/ColorPicker/ColorPicker.tsx
Normal 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';
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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}{' '}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user