feat(kube): add a11y props for smoke tests [EE-6747] (#11263)

pull/11285/head
Chaim Lev-Ari 2024-02-29 09:26:13 +02:00 committed by GitHub
parent 42c2a52a6b
commit 6c70049ecc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 143 additions and 62 deletions

View File

@ -21,6 +21,7 @@
auto-focus auto-focus
ng-model-options="{ debounce: 300 }" ng-model-options="{ debounce: 300 }"
data-cy="k8sNamespace-namespaceSearchInput" data-cy="k8sNamespace-namespaceSearchInput"
aria-label="Search input"
/> />
</div> </div>
<div class="actionBar !mr-0 !gap-3" ng-if="$ctrl.isAdmin"> <div class="actionBar !mr-0 !gap-3" ng-if="$ctrl.isAdmin">

View File

@ -1,4 +1,4 @@
<div class="datatable"> <section class="datatable" aria-label="Helm charts">
<div class="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0"> <div class="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0">
<div class="toolBarTitle vertical-center"> <div class="toolBarTitle vertical-center">
{{ $ctrl.titleText }} {{ $ctrl.titleText }}
@ -6,7 +6,7 @@
<div class="searchBar vertical-center !mr-0"> <div class="searchBar vertical-center !mr-0">
<pr-icon icon="'search'" class="searchIcon"></pr-icon> <pr-icon icon="'search'" class="searchIcon"></pr-icon>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus ng-model-options="{ debounce: 300 }" /> <input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus ng-model-options="{ debounce: 300 }" aria-label="Search input" />
</div> </div>
<div class="w-1/5"> <div class="w-1/5">
<por-select <por-select
@ -45,4 +45,4 @@
</div> </div>
<div ng-if="!$ctrl.loading && $ctrl.charts.length === 0" class="text-muted text-center"> No helm charts available. </div> <div ng-if="!$ctrl.loading && $ctrl.charts.length === 0" class="text-muted text-center"> No helm charts available. </div>
</div> </div>
</div> </section>

View File

@ -40,7 +40,7 @@
<pr-icon icon="'plus'" class="vertical-center"></pr-icon> <pr-icon icon="'plus'" class="vertical-center"></pr-icon>
Show custom values Show custom values
</button> </button>
<span class="small interactive vertical-center" ng-if="$ctrl.state.loadingValues"> <span class="small interactive vertical-center" ng-if="$ctrl.state.loadingValues" role="status">
<pr-icon icon="'refresh-cw'" class="mr-1"></pr-icon> <pr-icon icon="'refresh-cw'" class="mr-1"></pr-icon>
Loading values.yaml... Loading values.yaml...
</span> </span>

View File

@ -49,6 +49,7 @@
<input <input
type="text" type="text"
class="form-control" class="form-control"
id="configuration_name"
name="configuration_name" name="configuration_name"
ng-model="ctrl.formValues.Name" ng-model="ctrl.formValues.Name"
ng-pattern="/^[a-z0-9]([a-z0-9-.]{0,61}[a-z0-9])?$/" ng-pattern="/^[a-z0-9]([a-z0-9-.]{0,61}[a-z0-9])?$/"

View File

@ -197,6 +197,7 @@ export const ngModule = angular
'onChange', 'onChange',
'visibleTooltip', 'visibleTooltip',
'dataCy', 'dataCy',
'disabled',
]) ])
) )
.component( .component(

View File

@ -10,7 +10,7 @@ export const switchField = r2a(SwitchField, [
'name', 'name',
'labelClass', 'labelClass',
'fieldClass', 'fieldClass',
'dataCy', 'data-cy',
'disabled', 'disabled',
'onChange', 'onChange',
'featureId', 'featureId',

View File

@ -43,6 +43,7 @@ export function PageHeader({
onClick={onClickedRefresh} onClick={onClickedRefresh}
className="m-0 p-0 focus:text-inherit" className="m-0 p-0 focus:text-inherit"
disabled={loading} disabled={loading}
title="Refresh page"
> >
<RefreshCw className="icon" /> <RefreshCw className="icon" />
</Button> </Button>

View File

@ -29,6 +29,7 @@ export function TextTip({
'small gap-1 align-top text-xs', 'small gap-1 align-top text-xs',
inline ? 'inline-flex' : 'flex' inline ? 'inline-flex' : 'flex'
)} )}
role="status"
> >
<Icon icon={icon} mode={getMode(color)} className="!mt-0.5 flex-none" /> <Icon icon={icon} mode={getMode(color)} className="!mt-0.5 flex-none" />

View File

@ -169,7 +169,7 @@ export function Datatable<D extends DefaultType>({
const selectedItems = selectedRowModel.rows.map((row) => row.original); const selectedItems = selectedRowModel.rows.map((row) => row.original);
return ( return (
<Table.Container noWidget={noWidget}> <Table.Container noWidget={noWidget} aria-label={title}>
<DatatableHeader <DatatableHeader
onSearchChange={handleSearchBarChange} onSearchChange={handleSearchBarChange}
searchValue={settings.search} searchValue={settings.search}

View File

@ -5,24 +5,30 @@ import { Widget, WidgetBody } from '@@/Widget';
interface Props { interface Props {
// workaround to remove the widget, ideally we should have a different component to wrap the table with a widget // workaround to remove the widget, ideally we should have a different component to wrap the table with a widget
noWidget?: boolean; noWidget?: boolean;
'aria-label'?: string;
} }
export function TableContainer({ export function TableContainer({
children, children,
noWidget = false, noWidget = false,
'aria-label': ariaLabel,
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
if (noWidget) { if (noWidget) {
return <div className="datatable">{children}</div>; return (
<section className="datatable" aria-label={ariaLabel}>
{children}
</section>
);
} }
return ( return (
<div className="row"> <div className="row">
<div className="col-sm-12"> <div className="col-sm-12">
<div className="datatable"> <section className="datatable" aria-label={ariaLabel}>
<Widget> <Widget>
<WidgetBody className="no-padding">{children}</WidgetBody> <WidgetBody className="no-padding">{children}</WidgetBody>
</Widget> </Widget>
</div> </section>
</div> </div>
</div> </div>
); );

View File

@ -17,6 +17,7 @@ export interface Props {
dataCy?: string; dataCy?: string;
// true if you want to always show the tooltip // true if you want to always show the tooltip
visibleTooltip?: boolean; visibleTooltip?: boolean;
disabled?: boolean;
} }
export function Slider({ export function Slider({
@ -27,6 +28,7 @@ export function Slider({
onChange, onChange,
dataCy, dataCy,
visibleTooltip: visible, visibleTooltip: visible,
disabled,
}: Props) { }: Props) {
const marks = { const marks = {
[min]: visible && value / max < 0.1 ? '' : translateMinValue(min), [min]: visible && value / max < 0.1 ? '' : translateMinValue(min),
@ -34,14 +36,14 @@ export function Slider({
}; };
return ( return (
<div className={styles.root}> <div className={styles.root} data-cy={dataCy}>
<RcSlider <RcSlider
handleRender={visible ? sliderTooltip : undefined} handleRender={visible ? sliderTooltip : undefined}
min={min} min={min}
max={max} max={max}
marks={marks} marks={marks}
step={step} step={step}
data-cy={dataCy} disabled={disabled}
value={value} value={value}
onChange={onChange} onChange={onChange}
/> />

View File

@ -10,6 +10,7 @@ export function SliderWithInput({
step = 1, step = 1,
dataCy, dataCy,
visibleTooltip = false, visibleTooltip = false,
inputId,
}: { }: {
value: number; value: number;
onChange: (value: number) => void; onChange: (value: number) => void;
@ -18,6 +19,7 @@ export function SliderWithInput({
dataCy: string; dataCy: string;
step?: number; step?: number;
visibleTooltip?: boolean; visibleTooltip?: boolean;
inputId?: string;
}) { }) {
return ( return (
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
@ -44,6 +46,7 @@ export function SliderWithInput({
onChange={(e) => onChange(e.target.valueAsNumber)} onChange={(e) => onChange(e.target.valueAsNumber)}
className="w-32" className="w-32"
data-cy={`${dataCy}Input`} data-cy={`${dataCy}Input`}
id={inputId}
/> />
</div> </div>
); );

View File

@ -2,6 +2,7 @@ import clsx from 'clsx';
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { AutomationTestingProps } from '@/types';
import { BEFeatureIndicator } from '@@/BEFeatureIndicator'; import { BEFeatureIndicator } from '@@/BEFeatureIndicator';
@ -9,7 +10,7 @@ import './Switch.css';
import styles from './Switch.module.css'; import styles from './Switch.module.css';
export interface Props { export interface Props extends AutomationTestingProps {
checked: boolean; checked: boolean;
id: string; id: string;
name: string; name: string;
@ -17,7 +18,6 @@ export interface Props {
index?: number; index?: number;
className?: string; className?: string;
dataCy?: string;
disabled?: boolean; disabled?: boolean;
featureId?: FeatureId; featureId?: FeatureId;
} }
@ -27,7 +27,7 @@ export function Switch({
checked, checked,
id, id,
disabled, disabled,
dataCy, 'data-cy': dataCy,
onChange, onChange,
index, index,
featureId, featureId,
@ -42,6 +42,8 @@ export function Switch({
business: limitedToBE, business: limitedToBE,
limited: limitedToBE, limited: limitedToBE,
})} })}
data-cy={dataCy}
aria-checked={checked}
> >
<input <input
type="checkbox" type="checkbox"
@ -51,7 +53,7 @@ export function Switch({
disabled={disabled || limitedToBE} disabled={disabled || limitedToBE}
onChange={({ target: { checked } }) => onChange(checked, index)} onChange={({ target: { checked } }) => onChange(checked, index)}
/> />
<span className="slider round before:content-['']" data-cy={dataCy} /> <span className="slider round before:content-['']" />
</label> </label>
{limitedToBE && <BEFeatureIndicator featureId={featureId} />} {limitedToBE && <BEFeatureIndicator featureId={featureId} />}
</> </>

View File

@ -3,13 +3,14 @@ import uuid from 'uuid';
import { ComponentProps, PropsWithChildren, ReactNode } from 'react'; import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
import { FeatureId } from '@/react/portainer/feature-flags/enums'; import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { AutomationTestingProps } from '@/types';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
import styles from './SwitchField.module.css'; import styles from './SwitchField.module.css';
import { Switch } from './Switch'; import { Switch } from './Switch';
export interface Props { export interface Props extends AutomationTestingProps {
label: string; label: string;
checked: boolean; checked: boolean;
onChange(value: boolean, index?: number): void; onChange(value: boolean, index?: number): void;
@ -21,7 +22,7 @@ export interface Props {
labelClass?: string; labelClass?: string;
switchClass?: string; switchClass?: string;
fieldClass?: string; fieldClass?: string;
dataCy?: string;
disabled?: boolean; disabled?: boolean;
featureId?: FeatureId; featureId?: FeatureId;
valueExplanation?: ReactNode; valueExplanation?: ReactNode;
@ -35,7 +36,7 @@ export function SwitchField({
name = uuid(), name = uuid(),
labelClass, labelClass,
fieldClass, fieldClass,
dataCy, 'data-cy': dataCy,
disabled, disabled,
onChange, onChange,
featureId, featureId,
@ -65,7 +66,7 @@ export function SwitchField({
onChange={onChange} onChange={onChange}
index={index} index={index}
featureId={featureId} featureId={featureId}
dataCy={dataCy} data-cy={dataCy}
/> />
{valueExplanation && <span>{valueExplanation}</span>} {valueExplanation && <span>{valueExplanation}</span>}
</div> </div>

View File

@ -40,7 +40,7 @@ export function StorageClassDatatable({ storageClassValues }: Props) {
className="mr-2 mb-0" className="mr-2 mb-0"
id={`kubeSetup-storageToggle${storageClassValue.Name}`} id={`kubeSetup-storageToggle${storageClassValue.Name}`}
name={`kubeSetup-storageToggle${storageClassValue.Name}`} name={`kubeSetup-storageToggle${storageClassValue.Name}`}
dataCy={`kubeSetup-storageToggle${storageClassValue.Name}`} data-cy={`kubeSetup-storageToggle${storageClassValue.Name}`}
/> />
<span>{storageClassValue.Name}</span> <span>{storageClassValue.Name}</span>
</div> </div>
@ -69,7 +69,7 @@ export function StorageClassDatatable({ storageClassValues }: Props) {
) )
} }
className="mr-2 mb-0" className="mr-2 mb-0"
dataCy={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`} data-cy={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`}
id={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`} id={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`}
name={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`} name={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`}
/> />

View File

@ -11,10 +11,10 @@ import { Tooltip } from '@@/Tip/Tooltip';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { Card } from '@@/Card';
import { InputGroup } from '@@/form-components/InputGroup';
import { InlineLoader } from '@@/InlineLoader'; import { InlineLoader } from '@@/InlineLoader';
import { Select } from '@@/form-components/ReactSelect'; import { Select } from '@@/form-components/ReactSelect';
import { Card } from '@@/Card';
import { InputGroup } from '@@/form-components/InputGroup';
import { AnnotationsForm } from '../../annotations/AnnotationsForm'; import { AnnotationsForm } from '../../annotations/AnnotationsForm';
@ -399,9 +399,16 @@ export function IngressForm({
<div className="row"> <div className="row">
<div className="form-group col-sm-6 col-lg-4 !pl-0 !pr-2"> <div className="form-group col-sm-6 col-lg-4 !pl-0 !pr-2">
<InputGroup size="small"> <InputGroup size="small">
<InputGroup.Addon required>Hostname</InputGroup.Addon> <InputGroup.Addon
required
as="label"
htmlFor={`ingress_host_${hostIndex}`}
>
Hostname
</InputGroup.Addon>
<InputGroup.Input <InputGroup.Input
name={`ingress_host_${hostIndex}`} name={`ingress_host_${hostIndex}`}
id={`ingress_host_${hostIndex}`}
type="text" type="text"
className="form-control form-control-sm" className="form-control form-control-sm"
placeholder="e.g. example.com" placeholder="e.g. example.com"
@ -411,6 +418,7 @@ export function IngressForm({
} }
/> />
</InputGroup> </InputGroup>
{errors[`hosts[${hostIndex}].host`] && ( {errors[`hosts[${hostIndex}].host`] && (
<FormError className="!mb-0 mt-1"> <FormError className="!mb-0 mt-1">
{errors[`hosts[${hostIndex}].host`]} {errors[`hosts[${hostIndex}].host`]}
@ -420,10 +428,17 @@ export function IngressForm({
<div className="form-group col-sm-6 col-lg-4 !pl-2 !pr-0"> <div className="form-group col-sm-6 col-lg-4 !pl-2 !pr-0">
<InputGroup size="small"> <InputGroup size="small">
<InputGroup.Addon>TLS secret</InputGroup.Addon> <InputGroup.Addon
as="label"
htmlFor={`ingress_tls_${hostIndex}`}
>
TLS secret
</InputGroup.Addon>
<Select <Select
key={tlsOptions.toString() + host.Secret} key={tlsOptions.toString() + host.Secret}
name={`ingress_tls_${hostIndex}`} name={`ingress_tls_${hostIndex}`}
inputId={`ingress_tls_${hostIndex}`}
options={tlsOptions}
value={ value={
host.Secret !== undefined host.Secret !== undefined
? { ? {
@ -442,7 +457,6 @@ export function IngressForm({
} }
noOptionsMessage={() => 'No TLS secrets available'} noOptionsMessage={() => 'No TLS secrets available'}
size="sm" size="sm"
options={tlsOptions}
/> />
{!host.NoHost && ( {!host.NoHost && (
<div className="input-group-btn"> <div className="input-group-btn">
@ -501,10 +515,17 @@ export function IngressForm({
> >
<div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0"> <div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0">
<InputGroup size="small"> <InputGroup size="small">
<InputGroup.Addon required>Service</InputGroup.Addon> <InputGroup.Addon
required
as="label"
htmlFor={`ingress_service_${hostIndex}_${pathIndex}`}
>
Service
</InputGroup.Addon>
<Select <Select
key={serviceOptions.toString() + path.ServiceName} key={serviceOptions.toString() + path.ServiceName}
name={`ingress_service_${hostIndex}_${pathIndex}`} name={`ingress_service_${hostIndex}_${pathIndex}`}
id={`ingress_service_${hostIndex}_${pathIndex}`}
options={serviceOptions} options={serviceOptions}
value={ value={
path.ServiceName path.ServiceName
@ -551,12 +572,17 @@ export function IngressForm({
{servicePorts && ( {servicePorts && (
<> <>
<InputGroup size="small"> <InputGroup size="small">
<InputGroup.Addon required> <InputGroup.Addon
required
as="label"
htmlFor={`ingress_servicePort_${hostIndex}_${pathIndex}`}
>
Service port Service port
</InputGroup.Addon> </InputGroup.Addon>
<Select <Select
key={servicePorts.toString() + path.ServicePort} key={servicePorts.toString() + path.ServicePort}
name={`ingress_servicePort_${hostIndex}_${pathIndex}`} name={`ingress_servicePort_${hostIndex}_${pathIndex}`}
id={`ingress_servicePort_${hostIndex}_${pathIndex}`}
options={ options={
servicePorts[path.ServiceName]?.map( servicePorts[path.ServiceName]?.map(
(portOption) => ({ (portOption) => ({
@ -607,10 +633,16 @@ export function IngressForm({
<div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0"> <div className="form-group col-sm-3 col-xl-2 !m-0 !pl-0">
<InputGroup size="small"> <InputGroup size="small">
<InputGroup.Addon>Path type</InputGroup.Addon> <InputGroup.Addon
<Select<Option<string>> as="label"
htmlFor={`ingress_pathType_${hostIndex}_${pathIndex}`}
>
Path type
</InputGroup.Addon>
<Select
key={servicePorts.toString() + path.PathType} key={servicePorts.toString() + path.PathType}
name={`ingress_pathType_${hostIndex}_${pathIndex}`} name={`ingress_pathType_${hostIndex}_${pathIndex}`}
id={`ingress_pathType_${hostIndex}_${pathIndex}`}
options={ options={
pathTypes?.map((type) => ({ pathTypes?.map((type) => ({
label: type, label: type,
@ -657,10 +689,17 @@ export function IngressForm({
<div className="form-group col-sm-3 col-xl-3 !m-0 !pl-0"> <div className="form-group col-sm-3 col-xl-3 !m-0 !pl-0">
<InputGroup size="small"> <InputGroup size="small">
<InputGroup.Addon required>Path</InputGroup.Addon> <InputGroup.Addon
required
as="label"
htmlFor={`ingress_route_${hostIndex}-${pathIndex}`}
>
Path
</InputGroup.Addon>
<InputGroup.Input <InputGroup.Input
className="form-control" className="form-control"
name={`ingress_route_${hostIndex}-${pathIndex}`} name={`ingress_route_${hostIndex}-${pathIndex}`}
id={`ingress_route_${hostIndex}-${pathIndex}`}
placeholder="/example" placeholder="/example"
data-pattern="/^(\/?[a-zA-Z0-9]+([a-zA-Z0-9-/_]*[a-zA-Z0-9])?|[a-zA-Z0-9]+)|(\/){1}$/" data-pattern="/^(\/?[a-zA-Z0-9]+([a-zA-Z0-9-/_]*[a-zA-Z0-9])?|[a-zA-Z0-9]+)|(\/){1}$/"
data-cy={`k8sAppCreate-route_${hostIndex}-${pathIndex}`} data-cy={`k8sAppCreate-route_${hostIndex}-${pathIndex}`}

View File

@ -13,7 +13,7 @@ export function LoadBalancerFormSection() {
disable the use of load balancers in this namespace. disable the use of load balancers in this namespace.
</TextTip> </TextTip>
<SwitchField <SwitchField
dataCy="k8sNamespaceCreate-loadBalancerQuotaToggle" data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"
label="Load balancer quota" label="Load balancer quota"
labelClass="col-sm-3 col-lg-2" labelClass="col-sm-3 col-lg-2"
fieldClass="pt-2" fieldClass="pt-2"

View File

@ -81,16 +81,19 @@ export function ResourceQuotaFormSection({
inputId="memory-limit" inputId="memory-limit"
> >
<div className="col-xs-8"> <div className="col-xs-8">
<SliderWithInput {memoryLimit >= 0 && (
value={Number(values.memory) ?? 0} <SliderWithInput
onChange={(value) => value={Number(values.memory) ?? 0}
onChange({ ...values, memory: `${value}` }) onChange={(value) =>
} onChange({ ...values, memory: `${value}` })
max={memoryLimit} }
step={128} max={memoryLimit}
dataCy="k8sNamespaceCreate-memoryLimit" step={128}
visibleTooltip dataCy="k8sNamespaceCreate-memoryLimit"
/> visibleTooltip
inputId="memory-limit"
/>
)}
{errors?.memory && ( {errors?.memory && (
<FormError className="pt-1">{errors.memory}</FormError> <FormError className="pt-1">{errors.memory}</FormError>
)} )}

View File

@ -43,7 +43,7 @@ export function AccessControlForm({
<div className="form-group"> <div className="form-group">
<div className="col-sm-12"> <div className="col-sm-12">
<SwitchField <SwitchField
dataCy="portainer-accessMgmtToggle" data-cy="portainer-accessMgmtToggle"
checked={accessControlEnabled} checked={accessControlEnabled}
name={withNamespace('accessControlEnabled')} name={withNamespace('accessControlEnabled')}
label="Enable access control" label="Enable access control"

View File

@ -1,10 +1,10 @@
import { useRouter } from '@uirouter/react'; import { useRouter } from '@uirouter/react';
import { Plus, Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { pluralize } from '@/portainer/helpers/strings'; import { pluralize } from '@/portainer/helpers/strings';
import { confirmDestructive } from '@@/modals/confirm'; import { confirmDestructive } from '@@/modals/confirm';
import { Button } from '@@/buttons'; import { AddButton, Button } from '@@/buttons';
import { HelmRepository } from './types'; import { HelmRepository } from './types';
import { useDeleteHelmRepositoriesMutation } from './helm-repositories.service'; import { useDeleteHelmRepositoriesMutation } from './helm-repositories.service';
@ -18,7 +18,7 @@ export function HelmRepositoryDatatableActions({ selectedItems }: Props) {
const deleteHelmRepoMutation = useDeleteHelmRepositoriesMutation(); const deleteHelmRepoMutation = useDeleteHelmRepositoriesMutation();
return ( return (
<> <div className="flex gap-2">
<Button <Button
disabled={selectedItems.length < 1} disabled={selectedItems.length < 1}
color="dangerlight" color="dangerlight"
@ -29,16 +29,10 @@ export function HelmRepositoryDatatableActions({ selectedItems }: Props) {
Remove Remove
</Button> </Button>
<Button <AddButton to="portainer.account.createHelmRepository">
onClick={() =>
router.stateService.go('portainer.account.createHelmRepository')
}
data-cy="credentials-addButton"
icon={Plus}
>
Add Helm repository Add Helm repository
</Button> </AddButton>
</> </div>
); );
async function onDeleteClick(selectedItems: HelmRepository[]) { async function onDeleteClick(selectedItems: HelmRepository[]) {

View File

@ -39,13 +39,18 @@ export function HelmRepositoryForm({
> >
{({ values, errors, handleSubmit, isValid, dirty }) => ( {({ values, errors, handleSubmit, isValid, dirty }) => (
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate> <Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
<FormControl inputId="url" label="URL" errors={errors.URL} required> <FormControl
inputId="url-field"
label="URL"
errors={errors.URL}
required
>
<Field <Field
as={Input} as={Input}
name="URL" name="URL"
value={values.URL} value={values.URL}
autoComplete="off" autoComplete="off"
id="URL" id="url-field"
/> />
</FormControl> </FormControl>
<div className="form-group"> <div className="form-group">

View File

@ -81,6 +81,7 @@ export function DockerSidebar({ environmentId, environment }: Props) {
to="docker.templates" to="docker.templates"
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
data-cy="portainerSidebar-templates" data-cy="portainerSidebar-templates"
listId="dockerSidebar-templates"
> >
<SidebarItem <SidebarItem
label="Application" label="Application"
@ -185,6 +186,7 @@ export function DockerSidebar({ environmentId, environment }: Props) {
to={setupSubMenuProps.to} to={setupSubMenuProps.to}
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
data-cy="portainerSidebar-host-area" data-cy="portainerSidebar-host-area"
listId="portainerSidebar-host-area"
> >
<SidebarItem <SidebarItem
label="Details" label="Details"

View File

@ -58,6 +58,7 @@ export function EdgeComputeSidebar() {
label="Edge Templates" label="Edge Templates"
to="edge.templates" to="edge.templates"
data-cy="edgeSidebar-templates" data-cy="edgeSidebar-templates"
listId="edgeSidebar-templates"
> >
<SidebarItem <SidebarItem
label="Application" label="Application"

View File

@ -59,6 +59,7 @@ export function KubernetesSidebar({ environmentId }: Props) {
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
pathOptions={{ includePaths: ['kubernetes.ingresses'] }} pathOptions={{ includePaths: ['kubernetes.ingresses'] }}
data-cy="k8sSidebar-networking" data-cy="k8sSidebar-networking"
listId="k8sSidebar-networking"
> >
<SidebarItem <SidebarItem
to="kubernetes.services" to="kubernetes.services"
@ -97,7 +98,8 @@ export function KubernetesSidebar({ environmentId }: Props) {
to="kubernetes.cluster" to="kubernetes.cluster"
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
pathOptions={{ includePaths: ['kubernetes.registries'] }} pathOptions={{ includePaths: ['kubernetes.registries'] }}
data-cy="k8sSidebar-cluster" data-cy="k8sSidebar-cluster-area"
listId="k8sSidebar-cluster-area"
> >
<SidebarItem <SidebarItem
label="Details" label="Details"
@ -108,7 +110,7 @@ export function KubernetesSidebar({ environmentId }: Props) {
]} ]}
params={{ endpointId: environmentId }} params={{ endpointId: environmentId }}
isSubMenu isSubMenu
data-cy="k8sSidebar-clusterDetails" data-cy="k8sSidebar-cluster"
/> />
<Authorized <Authorized
authorizations="K8sClusterSetupRW" authorizations="K8sClusterSetupRW"

View File

@ -39,6 +39,7 @@ export function SettingsSidebar({ isPureAdmin, isAdmin, isTeamLeader }: Props) {
to="portainer.users" to="portainer.users"
pathOptions={{ includePaths: ['portainer.teams', 'portainer.roles'] }} pathOptions={{ includePaths: ['portainer.teams', 'portainer.roles'] }}
data-cy="portainerSidebar-userRelated" data-cy="portainerSidebar-userRelated"
listId="portainerSidebar-userRelated"
> >
<SidebarItem <SidebarItem
to="portainer.users" to="portainer.users"
@ -77,6 +78,7 @@ export function SettingsSidebar({ isPureAdmin, isAdmin, isTeamLeader }: Props) {
], ],
}} }}
data-cy="portainerSidebar-environments-area" data-cy="portainerSidebar-environments-area"
listId="portainer-environments"
> >
<SidebarItem <SidebarItem
label="Environments" label="Environments"
@ -125,6 +127,7 @@ export function SettingsSidebar({ isPureAdmin, isAdmin, isTeamLeader }: Props) {
includePaths: ['portainer.activityLogs'], includePaths: ['portainer.activityLogs'],
}} }}
data-cy="k8sSidebar-logs" data-cy="k8sSidebar-logs"
listId="k8sSidebar-logs"
> >
<SidebarItem <SidebarItem
label="Authentication" label="Authentication"
@ -147,6 +150,7 @@ export function SettingsSidebar({ isPureAdmin, isAdmin, isTeamLeader }: Props) {
icon={HardDrive} icon={HardDrive}
to="portainer.endpoints.updateSchedules" to="portainer.endpoints.updateSchedules"
data-cy="portainerSidebar-environments-area" data-cy="portainerSidebar-environments-area"
listId="portainer-environments-area"
> >
<EdgeUpdatesSidebarItem /> <EdgeUpdatesSidebarItem />
</SidebarParent> </SidebarParent>
@ -164,6 +168,7 @@ export function SettingsSidebar({ isPureAdmin, isAdmin, isTeamLeader }: Props) {
label="Settings" label="Settings"
icon={Settings} icon={Settings}
data-cy="portainerSidebar-settings" data-cy="portainerSidebar-settings"
listId="portainer-settings"
> >
<SidebarItem <SidebarItem
to="portainer.settings" to="portainer.settings"

View File

@ -2,6 +2,8 @@ import clsx from 'clsx';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren, useState } from 'react';
import { AutomationTestingProps } from '@/types';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
@ -15,9 +17,9 @@ type Props = {
label: string; label: string;
icon: React.ReactNode; icon: React.ReactNode;
to: string; to: string;
'data-cy': string;
pathOptions?: PathOptions; pathOptions?: PathOptions;
params?: object; params?: object;
listId: string;
}; };
export function SidebarParent({ export function SidebarParent({
@ -27,8 +29,9 @@ export function SidebarParent({
to, to,
params, params,
pathOptions, pathOptions,
listId,
'data-cy': dataCy, 'data-cy': dataCy,
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props & AutomationTestingProps>) {
const anchorProps = useSidebarSrefActive( const anchorProps = useSidebarSrefActive(
to, to,
undefined, undefined,
@ -78,7 +81,14 @@ export function SidebarParent({
<button <button
type="button" type="button"
className="flex-none border-none bg-transparent flex items-center group p-0 px-3 h-8" className="flex-none border-none bg-transparent flex items-center group p-0 px-3 h-8"
onClick={() => setIsExpanded(!isExpanded)} onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsExpanded((isExpanded) => !isExpanded);
}}
title={isExpanded ? 'Collapse' : 'Expand'}
aria-expanded={isExpanded}
aria-controls={listId}
> >
<div className="flex items-center group-hover:bg-blue-5 be:group-hover:bg-gray-5 group-hover:th-dark:bg-gray-true-7 group-hover:bg-opacity-10 be:group-hover:bg-opacity-10 rounded-full p-[3px] transition ease-in-out"> <div className="flex items-center group-hover:bg-blue-5 be:group-hover:bg-gray-5 group-hover:th-dark:bg-gray-true-7 group-hover:bg-opacity-10 be:group-hover:bg-opacity-10 rounded-full p-[3px] transition ease-in-out">
<Icon <Icon
@ -98,6 +108,7 @@ export function SidebarParent({
const childList = ( const childList = (
<ul <ul
id={listId}
// pl-11 must be important because it needs to avoid the padding from '.root ul' in sidebar.module.css // pl-11 must be important because it needs to avoid the padding from '.root ul' in sidebar.module.css
className={clsx('text-white !pl-11', { className={clsx('text-white !pl-11', {
hidden: !isExpanded, hidden: !isExpanded,