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
ng-model-options="{ debounce: 300 }"
data-cy="k8sNamespace-namespaceSearchInput"
aria-label="Search input"
/>
</div>
<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="toolBarTitle vertical-center">
{{ $ctrl.titleText }}
@ -6,7 +6,7 @@
<div class="searchBar vertical-center !mr-0">
<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 class="w-1/5">
<por-select
@ -45,4 +45,4 @@
</div>
<div ng-if="!$ctrl.loading && $ctrl.charts.length === 0" class="text-muted text-center"> No helm charts available. </div>
</div>
</div>
</section>

View File

@ -40,7 +40,7 @@
<pr-icon icon="'plus'" class="vertical-center"></pr-icon>
Show custom values
</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>
Loading values.yaml...
</span>

View File

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

View File

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

View File

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

View File

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

View File

@ -29,6 +29,7 @@ export function TextTip({
'small gap-1 align-top text-xs',
inline ? 'inline-flex' : 'flex'
)}
role="status"
>
<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);
return (
<Table.Container noWidget={noWidget}>
<Table.Container noWidget={noWidget} aria-label={title}>
<DatatableHeader
onSearchChange={handleSearchBarChange}
searchValue={settings.search}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,13 +39,18 @@ export function HelmRepositoryForm({
>
{({ values, errors, handleSubmit, isValid, dirty }) => (
<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
as={Input}
name="URL"
value={values.URL}
autoComplete="off"
id="URL"
id="url-field"
/>
</FormControl>
<div className="form-group">

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,8 @@ import clsx from 'clsx';
import { ChevronDown } from 'lucide-react';
import { PropsWithChildren, useState } from 'react';
import { AutomationTestingProps } from '@/types';
import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
@ -15,9 +17,9 @@ type Props = {
label: string;
icon: React.ReactNode;
to: string;
'data-cy': string;
pathOptions?: PathOptions;
params?: object;
listId: string;
};
export function SidebarParent({
@ -27,8 +29,9 @@ export function SidebarParent({
to,
params,
pathOptions,
listId,
'data-cy': dataCy,
}: PropsWithChildren<Props>) {
}: PropsWithChildren<Props & AutomationTestingProps>) {
const anchorProps = useSidebarSrefActive(
to,
undefined,
@ -78,7 +81,14 @@ export function SidebarParent({
<button
type="button"
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">
<Icon
@ -98,6 +108,7 @@ export function SidebarParent({
const childList = (
<ul
id={listId}
// 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', {
hidden: !isExpanded,