From 1f9c9b082f6e70e638cc5941dc96c31535d5cf04 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:11:53 +1300 Subject: [PATCH] feat(policies): banner and confirmation on change policy [C9S-20] (#1988) --- api/portainer.go | 17 +-- app/assets/css/button.css | 8 +- app/portainer/services/endpointProvider.ts | 15 +-- .../PageHeader/HeaderTitle.module.css | 2 +- app/react/components/WebEditorForm.tsx | 12 +- .../ColorPicker/ColorPicker.stories.tsx | 21 ++++ .../ColorPicker/ColorPicker.test.tsx | 67 +++++++++++ .../ColorPicker/ColorPicker.tsx | 110 ++++++++++++++++++ .../FormSection/FormSection.tsx | 15 ++- app/react/hooks/current-environment-store.ts | 24 ++-- .../CreateView/UpdateIngressPrompt.tsx | 79 +++---------- .../ConfigureForm/ConfigureForm.tsx | 12 +- .../ListView/columns/useColumns.tsx | 2 +- .../portainer/environments/utils/index.ts | 2 +- app/react/sidebar/SidebarItem/SidebarItem.tsx | 2 +- .../sidebar/SidebarItem/SidebarParent.tsx | 2 +- 16 files changed, 270 insertions(+), 120 deletions(-) create mode 100644 app/react/components/form-components/ColorPicker/ColorPicker.stories.tsx create mode 100644 app/react/components/form-components/ColorPicker/ColorPicker.test.tsx create mode 100644 app/react/components/form-components/ColorPicker/ColorPicker.tsx diff --git a/api/portainer.go b/api/portainer.go index 8a2215781..3e460796f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -2452,14 +2452,15 @@ const ( const ( // PolicyType constants - RbacK8s PolicyType = "rbac-k8s" - SecurityK8s PolicyType = "security-k8s" - SetupK8s PolicyType = "setup-k8s" - RegistryK8s PolicyType = "registry-k8s" - RbacDocker PolicyType = "rbac-docker" - SecurityDocker PolicyType = "security-docker" - SetupDocker PolicyType = "setup-docker" - RegistryDocker PolicyType = "registry-docker" + RbacK8s PolicyType = "rbac-k8s" + SecurityK8s PolicyType = "security-k8s" + SetupK8s PolicyType = "setup-k8s" + RegistryK8s PolicyType = "registry-k8s" + RbacDocker PolicyType = "rbac-docker" + SecurityDocker PolicyType = "security-docker" + SetupDocker PolicyType = "setup-docker" + RegistryDocker PolicyType = "registry-docker" + ChangeConfirmation PolicyType = "change-confirmation" ) type HelmInstallStatus string diff --git a/app/assets/css/button.css b/app/assets/css/button.css index 547d9fdc0..aca35155f 100644 --- a/app/assets/css/button.css +++ b/app/assets/css/button.css @@ -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; } diff --git a/app/portainer/services/endpointProvider.ts b/app/portainer/services/endpointProvider.ts index c6e224ec6..3c562e221 100644 --- a/app/portainer/services/endpointProvider.ts +++ b/app/portainer/services/endpointProvider.ts @@ -19,11 +19,14 @@ export function EndpointProvider() { pingInterval: null, }; - environmentStore.subscribe((state) => { - if (!state.environmentId) { - setCurrentEndpoint(null); + 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() { diff --git a/app/react/components/PageHeader/HeaderTitle.module.css b/app/react/components/PageHeader/HeaderTitle.module.css index eeae7fb48..2ce0920c3 100644 --- a/app/react/components/PageHeader/HeaderTitle.module.css +++ b/app/react/components/PageHeader/HeaderTitle.module.css @@ -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; } diff --git a/app/react/components/WebEditorForm.tsx b/app/react/components/WebEditorForm.tsx index 84c37c0df..9bd986644 100644 --- a/app/react/components/WebEditorForm.tsx +++ b/app/react/components/WebEditorForm.tsx @@ -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; @@ -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; }); diff --git a/app/react/components/form-components/ColorPicker/ColorPicker.stories.tsx b/app/react/components/form-components/ColorPicker/ColorPicker.stories.tsx new file mode 100644 index 000000000..36ac926dd --- /dev/null +++ b/app/react/components/form-components/ColorPicker/ColorPicker.stories.tsx @@ -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 ( + + ); +} diff --git a/app/react/components/form-components/ColorPicker/ColorPicker.test.tsx b/app/react/components/form-components/ColorPicker/ColorPicker.test.tsx new file mode 100644 index 000000000..926d241a5 --- /dev/null +++ b/app/react/components/form-components/ColorPicker/ColorPicker.test.tsx @@ -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( + {}} 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( + + ); + + 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( + + ); + + 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( + {}} 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'); + }); +}); diff --git a/app/react/components/form-components/ColorPicker/ColorPicker.tsx b/app/react/components/form-components/ColorPicker/ColorPicker.tsx new file mode 100644 index 000000000..b92acd5c9 --- /dev/null +++ b/app/react/components/form-components/ColorPicker/ColorPicker.tsx @@ -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 ( +
+ + setIsFocused(true)} + onBlur={handleBlur} + className="w-28 uppercase" + maxLength={7} + placeholder="e.g. #ffbbbb" + spellCheck={false} + data-cy={dataCy} + /> +
+ ); + + function handleColorChange(e: ChangeEvent) { + const hex = e.target.value; + setLocalHex(hex); + onChange(hex); + } + + function handleTextChange(e: ChangeEvent) { + 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'; +} diff --git a/app/react/components/form-components/FormSection/FormSection.tsx b/app/react/components/form-components/FormSection/FormSection.tsx index 82a83b60a..a0bf8c648 100644 --- a/app/react/components/form-components/FormSection/FormSection.tsx +++ b/app/react/components/form-components/FormSection/FormSection.tsx @@ -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) { const [isExpanded, setIsExpanded] = useState(!defaultFolded); - const id = `foldingButton${title}`; + const collapsibleIdSuffix = typeof title === 'string' ? title : id; + const collapsibleId = collapsibleIdSuffix + ? `foldingButton${collapsibleIdSuffix}` + : undefined; return ( -
+
{isFoldable && ( { setIsExpanded((isExpanded) => !isExpanded); setIsDefaultFolded?.(isExpanded); diff --git a/app/react/hooks/current-environment-store.ts b/app/react/hooks/current-environment-store.ts index 20aa1fae1..9630433d0 100644 --- a/app/react/hooks/current-environment-store.ts +++ b/app/react/hooks/current-environment-store.ts @@ -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,15 +10,17 @@ export const environmentStore = createStore<{ setEnvironmentId(id: EnvironmentId): void; clear(): void; }>()( - persist( - (set) => ({ - environmentId: undefined, - setEnvironmentId: (id: EnvironmentId) => set({ environmentId: id }), - clear: () => set({ environmentId: undefined }), - }), - { - name: keyBuilder('environmentId'), - getStorage: () => sessionStorage, - } + subscribeWithSelector( + persist( + (set) => ({ + environmentId: undefined, + setEnvironmentId: (id: EnvironmentId) => set({ environmentId: id }), + clear: () => set({ environmentId: undefined }), + }), + { + name: keyBuilder('environmentId'), + getStorage: () => sessionStorage, + } + ) ) ); diff --git a/app/react/kubernetes/applications/CreateView/UpdateIngressPrompt.tsx b/app/react/kubernetes/applications/CreateView/UpdateIngressPrompt.tsx index 8693a238a..08e1891d1 100644 --- a/app/react/kubernetes/applications/CreateView/UpdateIngressPrompt.tsx +++ b/app/react/kubernetes/applications/CreateView/UpdateIngressPrompt.tsx @@ -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, + servicePortsToUpdate: Array +) { + 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 ( - onSubmit()} aria-label={title}> - - - -
    -
  • Updating the application may cause a service interruption.
  • -
  • {noMatchSentence}
  • -
- - -
- - - -
- ); -} - -export function confirmUpdateAppIngress( - ingressesToUpdate: Array, - servicePortsToUpdate: Array -) { - const hasOneIngress = ingressesToUpdate.length === 1; - const hasOnePort = servicePortsToUpdate.length === 1; - - return openModal(UpdateIngressPrompt, { - title: 'Are you sure?', - hasOneIngress, - hasOnePort, + const result = await openSwitchPrompt('Are you sure?', inputLabel, { + message: ( +
    +
  • Updating the application may cause a service interruption.
  • +
  • {noMatchSentence}
  • +
+ ), + confirmButton: buildConfirmButton('Update'), + 'data-cy': 'kube-update-ingress-prompt-switch', }); + + return result ? { noMatch: result.value } : undefined; } diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx index ac254c77e..9ea66b6f3 100644 --- a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx @@ -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; }); diff --git a/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx b/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx index c69194b8b..cbd5c77bd 100644 --- a/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx +++ b/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx @@ -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}{' '} diff --git a/app/react/portainer/environments/utils/index.ts b/app/react/portainer/environments/utils/index.ts index 19b2859de..965f142e3 100644 --- a/app/react/portainer/environments/utils/index.ts +++ b/app/react/portainer/environments/utils/index.ts @@ -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`); } } diff --git a/app/react/sidebar/SidebarItem/SidebarItem.tsx b/app/react/sidebar/SidebarItem/SidebarItem.tsx index 7208516f3..98d3e8fcc 100644 --- a/app/react/sidebar/SidebarItem/SidebarItem.tsx +++ b/app/react/sidebar/SidebarItem/SidebarItem.tsx @@ -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', { diff --git a/app/react/sidebar/SidebarItem/SidebarParent.tsx b/app/react/sidebar/SidebarItem/SidebarParent.tsx index f510d6894..d23244a5d 100644 --- a/app/react/sidebar/SidebarItem/SidebarParent.tsx +++ b/app/react/sidebar/SidebarItem/SidebarParent.tsx @@ -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,