feat(tests, components): enhance Cypress commands and UI elements for improved testing and accessibility

- Added new Cypress commands for mocking admin-related API interactions, including list, create, update, delete, and change status functionalities.
- Introduced data-cy attributes across various components (e.g., Breadcrumbs, Forms, Tables) to improve testability and accessibility.
- Updated existing components to support new data-cy attributes for better integration with Cypress tests.
This commit is contained in:
AmirReza Jamali
2026-04-25 13:25:23 +03:30
parent 2a4f3e7e4b
commit eab516c7f3
24 changed files with 814 additions and 23 deletions
+14 -3
View File
@@ -7,26 +7,37 @@ interface BreadcrumbProps {
const Breadcrumb = ({ items }: BreadcrumbProps) => {
const currentPage = items[items.length - 1];
const toDataCySegment = (value: string) =>
value.toLowerCase().replace(/[^a-z0-9]+/g, "-");
return (
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div
className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
data-cy="breadcrumb"
>
<h2 className="text-[16px] font-bold leading-[30px] text-dark dark:text-white">
{currentPage.name}
</h2>
<nav>
<nav data-cy="breadcrumb-nav">
<ol className="flex items-center gap-2">
{items.map((item, index) => {
const isLast = index === items.length - 1;
const dataCySegment = toDataCySegment(item.name);
return isLast ? (
<li key={index} className="font-medium text-sm text-primary">
<li
key={index}
className="font-medium text-sm text-primary"
data-cy={`breadcrumb-current-${dataCySegment}`}
>
{item.name}
</li>
) : (
<li key={index} className="flex items-center gap-2">
<Link
href={item.href}
data-cy={`breadcrumb-link-${dataCySegment}`}
className="font-medium text-sm hover:text-primary"
>
{item.name}
@@ -30,6 +30,7 @@ type InputGroupProps<T extends FieldValues> = {
height?: "sm" | "default";
autoComplete?: HTMLInputAutoCompleteAttribute;
defaultValue?: string;
dataCy?: string;
register?: UseFormRegister<T>;
errors?: FieldErrors<T>;
} & Partial<UseFormRegisterReturn>;
@@ -49,6 +50,7 @@ const InputGroup = <T extends FieldValues>({
register,
errors,
autoComplete,
dataCy,
onChange,
onBlur,
ref,
@@ -84,6 +86,7 @@ const InputGroup = <T extends FieldValues>({
name={name}
ref={ref}
autoComplete={autoComplete}
data-cy={dataCy}
onChange={(e) => {
onChange?.(e);
handleChange?.(e);
+4
View File
@@ -25,6 +25,7 @@ type PropsType<T extends FieldValues> = {
clearable?: boolean;
onClear?: () => void;
value?: string;
dataCy?: string;
} & (
| { placeholder?: string; defaultValue?: string }
| { placeholder: string; defaultValue?: string }
@@ -48,6 +49,7 @@ export function Select<T extends FieldValues>({
clearable = false,
onClear,
value: controlledValue,
dataCy,
...props
}: PropsType<T>) {
const id = useId();
@@ -107,6 +109,7 @@ export function Select<T extends FieldValues>({
onChange?.(e);
}}
onBlur={onBlur}
data-cy={dataCy}
className={cn(
"w-full appearance-none rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 shadow-[0_1px_2px_rgba(16,24,40,0.04),0_8px_20px_rgba(16,24,40,0.04)] outline-none backdrop-blur-[1px] transition-all duration-200 focus:border-primary/70 focus:bg-white focus:shadow-[0_0_0_2px_rgba(59,130,246,0.14),0_10px_24px_rgba(59,130,246,0.08)] active:border-primary/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:focus:border-primary/80 dark:focus:shadow-[0_0_0_2px_rgba(59,130,246,0.2),0_10px_24px_rgba(15,23,42,0.45)] [&>option]:text-dark-5 dark:[&>option]:text-dark-6",
isOptionSelected && "text-dark dark:text-white",
@@ -133,6 +136,7 @@ export function Select<T extends FieldValues>({
<button
type="button"
onClick={handleClear}
data-cy={dataCy ? `${dataCy}-clear` : undefined}
className="absolute right-10 top-1/2 -translate-y-1/2 text-dark-5 transition hover:scale-110 hover:text-dark dark:text-dark-6 dark:hover:text-white"
>
<svg
+7 -3
View File
@@ -22,9 +22,13 @@ function EmptyListWrapper<T>({
if (!list.length) {
return (
<TableRow>
<TableCell className="text-center" colSpan={columns.length * 100}>
<h2>{emptyMessage}</h2>
<TableRow data-cy="empty-list-row">
<TableCell
className="text-center"
colSpan={columns.length * 100}
data-cy="empty-list-cell"
>
<h2 data-cy="empty-list-message">{emptyMessage}</h2>
</TableCell>
</TableRow>
);
@@ -33,6 +33,7 @@ const AdminListFilters = ({
<form
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
data-cy="admins-filter-form"
>
<Select
{...filterRegister("status")}
@@ -43,6 +44,7 @@ const AdminListFilters = ({
clearable
value={watch("status")}
onClear={() => setValue("status", undefined)}
dataCy="admins-filter-status-select"
/>
<InputGroup
{...filterRegister("search")}
@@ -50,6 +52,7 @@ const AdminListFilters = ({
label="search"
placeholder="search"
type="search"
dataCy="admins-filter-search-input"
/>
{/* <InputGroup
{...filterRegister("email")}
@@ -86,6 +89,7 @@ const AdminListFilters = ({
<div className="flex gap-4">
<button
type="submit"
data-cy="admins-filter-submit-button"
className="mt-6 flex w-fit justify-center rounded-lg bg-primary p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
>
{isMutating ? (
@@ -96,6 +100,7 @@ const AdminListFilters = ({
</button>
<button
type="button"
data-cy="admins-filter-close-button"
className="mt-6 flex w-fit justify-center rounded-lg bg-error p-[13px] font-medium capitalize text-white hover:bg-opacity-90"
onClick={() => setIsFilterModalOpen(false)}
>
@@ -35,6 +35,7 @@ const ChangeStatusModalContent: FC<IProps> = ({ status, setStatus }) => {
name="status"
defaultValue={status}
placeholder="Please select new Status"
dataCy="admins-change-status-select"
/>
</form>
);
+21 -10
View File
@@ -51,16 +51,21 @@ const AdminsTable = () => {
} = useAdminsPresenter();
return (
<ListWrapper>
<ListWrapper data-cy="admins-page">
<ListHeader
onFilter={() => setIsFilterModalOpen(true)}
createButtonText="Create Admin"
dataCyPrefix="admins"
/>
<Table>
<Table data-cy="admins-table">
<TableHeader>
<TableRow className="border-none uppercase [&>th]:text-start">
{columns.map((column) => (
<TableHead key={column} colSpan={100}>
{columns.map((column, index) => (
<TableHead
key={column}
colSpan={100}
data-cy={`admins-table-header-${index}`}
>
{column}
</TableHead>
))}
@@ -77,31 +82,33 @@ const AdminsTable = () => {
<TableRow
key={item?.id || index}
className="odd:bg-gray-2 dark:odd:bg-gray-7"
data-cy={`admins-table-row-${item?.id ?? index}`}
>
<TableCell className="text-start" colSpan={100}>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-number">
{getPaginatedRowNumber({ index })}
</TableCell>
<TableCell className="text-start" colSpan={100}>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-full-name">
{item?.full_name}
</TableCell>
<TableCell className="text-start" colSpan={100}>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-email">
{item?.email}
</TableCell>
<TableCell className="text-start" colSpan={100}>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-username">
{item?.username}
</TableCell>
<TableCell className="text-start" colSpan={100}>
<TableCell className="text-start" colSpan={100} data-cy="admins-row-status">
<Status status={item?.status ?? ""}>{item?.status}</Status>
</TableCell>
<TableCell className="text-start xl:pr-7.5">
<TableCell className="text-start xl:pr-7.5" data-cy="admins-row-actions">
<div className="flex items-center justify-start gap-x-3.5">
<button
data-tooltip-id="delete"
data-tooltip-content="Delete"
data-tooltip-place="top"
className="hover:text-red-500"
data-cy={`admins-row-delete-${item?.id ?? index}`}
onClick={() => {
setCurrentUser(item);
setIsModalOpen(true);
@@ -116,6 +123,7 @@ const AdminsTable = () => {
data-tooltip-content="Change status"
data-tooltip-place="top"
className="hover:text-yellow-dark"
data-cy={`admins-row-manage-status-${item?.id ?? index}`}
onClick={() => {
setCurrentUser(item);
setIsChangeStatusModalOpen(true);
@@ -132,6 +140,7 @@ const AdminsTable = () => {
data-tooltip-content="Edit"
data-tooltip-place="top"
className="hover:text-primary"
data-cy={`admins-row-edit-${item?.id ?? index}`}
onClick={() => router.push(`/admins/edit/${item.id}`)}
>
<Tooltip {..._TooltipDefaultParams({ id: "edit" })} />
@@ -151,6 +160,8 @@ const AdminsTable = () => {
onConfirm={() => {
changeStatus({ id: currentUser?.id!, credentials: { status } });
}}
confirmText="Save"
cancelText="Cancel"
>
<ChangeStatusModalContent
status={currentUser?.status as AdminStatus}
@@ -38,6 +38,7 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
onSubmit={handleSubmit((data) => {
onSubmit(data);
})}
data-cy={editMode ? "admin-edit-form" : "admin-create-form"}
>
<div className="flex flex-col gap-9">
<ShowcaseSection
@@ -62,6 +63,7 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
required
type="text"
name="full_name"
dataCy="admin-form-full-name-input"
/>
{errors.full_name && (
<p className="mt-1 text-sm text-red-500">
@@ -96,6 +98,7 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
},
})}
name="username"
dataCy="admin-form-username-input"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-500">
@@ -122,6 +125,7 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
},
})}
name="email"
dataCy="admin-form-email-input"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">
@@ -152,6 +156,7 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
},
})}
name="password"
dataCy="admin-form-password-input"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-500">
@@ -180,6 +185,7 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
},
})}
name="phone"
dataCy="admin-form-phone-input"
/>
{errors.phone && (
<p className="mt-1 text-sm text-red-500">
@@ -203,6 +209,7 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
},
})}
name="department"
dataCy="admin-form-department-input"
/>
{errors.department && (
<p className="mt-1 text-sm text-red-500">
@@ -226,6 +233,7 @@ const CreateAdminForm: FC<IProps> = ({ defaultValues, editMode, id }) => {
},
})}
name="position"
dataCy="admin-form-position-input"
/>
{errors.position && (
<p className="mt-1 text-sm text-red-500">
+4
View File
@@ -99,10 +99,12 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = (props) => {
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
data-cy="confirmation-modal"
>
<div
className="fixed inset-0 bg-dark/70 bg-opacity-75 transition-opacity"
onClick={onCancel}
data-cy="confirmation-modal-overlay"
/>
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
@@ -139,6 +141,7 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = (props) => {
className={`inline-flex w-full justify-center rounded-md px-3 py-2 text-sm font-semibold text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto ${currentVariant.confirmBtn} dark:ring-offset-dark-2`}
disabled={isMutating > 0}
onClick={onConfirm}
data-cy="confirmation-modal-confirm"
>
{isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
@@ -150,6 +153,7 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = (props) => {
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-dark shadow-sm ring-1 ring-inset ring-stroke hover:bg-gray focus:outline-none focus:ring-2 focus:ring-dark-5 focus:ring-offset-2 dark:bg-dark-3 dark:text-white dark:ring-stroke-dark dark:hover:bg-dark-4 dark:focus:ring-offset-dark-2 sm:mt-0 sm:w-auto"
onClick={onCancel}
data-cy="confirmation-modal-cancel"
>
{cancelText}
</button>
+2
View File
@@ -24,6 +24,7 @@ const FormFooter = ({ onCancel }: IProps) => {
shape="rounded"
size="small"
type="submit"
data-cy="admin-form-submit-button"
disabled={isMutating > 0}
className="mt-6 w-fit rounded-lg bg-primary p-[13px] font-medium text-white hover:bg-opacity-90"
/>
@@ -32,6 +33,7 @@ const FormFooter = ({ onCancel }: IProps) => {
shape="rounded"
size="small"
type="button"
data-cy="admin-form-cancel-button"
variant="error"
onClick={onCancel ?? router.back}
className="mt-6 w-fit rounded-lg bg-error p-[13px] font-medium text-white hover:bg-opacity-90"
+4
View File
@@ -10,6 +10,7 @@ interface IProps {
createButtonText?: string;
filterButtonText?: string;
onFilter?: () => void;
dataCyPrefix?: string;
}
const ListHeader = ({
@@ -18,6 +19,7 @@ const ListHeader = ({
createButtonText = "Create",
filterButtonText = "Filter",
onFilter,
dataCyPrefix = "list-header",
}: IProps) => {
const pathName = usePathname();
const router = useRouter();
@@ -35,6 +37,7 @@ const ListHeader = ({
shape="rounded"
size="small"
onClick={onFilter}
data-cy={`${dataCyPrefix}-filter-button`}
className="shadow-theme-xs mb-5 self-end rounded-xl border-stroke/70 bg-white/80 px-5 text-sm font-semibold text-dark transition-all duration-300 hover:border-primary/35 hover:text-primary dark:border-dark-3 dark:bg-dark-2 dark:text-white dark:hover:bg-white/[0.08]"
/>
</IsVisible>
@@ -45,6 +48,7 @@ const ListHeader = ({
shape="rounded"
size="small"
onClick={() => router.push(`${pathName}/create`)}
data-cy={`${dataCyPrefix}-create-button`}
className="shadow-theme-xs mb-5 self-end rounded-xl bg-gradient-to-r from-primary to-primary/85 text-sm font-semibold transition-all duration-300 hover:opacity-95"
/>
</IsVisible>
+7 -4
View File
@@ -1,12 +1,15 @@
import { ReactNode } from "react";
import { HTMLAttributes, ReactNode } from "react";
type ListWrapperProps = {
children: ReactNode;
};
} & HTMLAttributes<HTMLDivElement>;
const ListWrapper = ({ children }: ListWrapperProps) => {
const ListWrapper = ({ children, ...props }: ListWrapperProps) => {
return (
<div className="flex flex-col rounded-xl border border-stroke/70 bg-white bg-[linear-gradient(225deg,rgba(87,80,241,0.18)_0%,rgba(87,80,241,0)_10%),linear-gradient(45deg,rgba(87,80,241,0.18)_0%,rgba(87,80,241,0)_10%)] px-7.5 pb-4 pt-7.5 dark:border-dark-3 dark:bg-gray-dark dark:bg-[linear-gradient(225deg,rgba(87,80,241,0.26)_0%,rgba(87,80,241,0)_10%),linear-gradient(45deg,rgba(87,80,241,0.26)_0%,rgba(87,80,241,0)_10%)]">
<div
{...props}
className="flex flex-col rounded-xl border border-stroke/70 bg-white bg-[linear-gradient(225deg,rgba(87,80,241,0.18)_0%,rgba(87,80,241,0)_10%),linear-gradient(45deg,rgba(87,80,241,0.18)_0%,rgba(87,80,241,0)_10%)] px-7.5 pb-4 pt-7.5 dark:border-dark-3 dark:bg-gray-dark dark:bg-[linear-gradient(225deg,rgba(87,80,241,0.26)_0%,rgba(87,80,241,0)_10%),linear-gradient(45deg,rgba(87,80,241,0.26)_0%,rgba(87,80,241,0)_10%)]"
>
{children}
</div>
);
+3 -3
View File
@@ -13,11 +13,11 @@ const TableSkeleton = ({
cellColSpan = 100,
}: IProps) => {
return (
<TableBody>
<TableBody data-cy="table-skeleton">
{Array.from({ length }).map((_, i) => (
<TableRow key={i}>
<TableRow key={i} data-cy={`table-skeleton-row-${i}`}>
{Array.from({ length: column }).map((_, j) => (
<TableCell key={j} colSpan={cellColSpan}>
<TableCell key={j} colSpan={cellColSpan} data-cy="table-skeleton-cell">
<Skeleton className="h-8 rounded-lg" />
</TableCell>
))}
+5
View File
@@ -50,14 +50,17 @@ const Modal: React.FC<ModalProps> = ({
<div
className="fixed inset-0 z-999999 flex items-center justify-center bg-dark/50 dark:bg-black/50"
onClick={onClose}
data-cy="modal-overlay"
>
<div
className={`max-h-[90vh] max-w-[90vw] overflow-auto rounded-lg bg-white p-5 shadow-4 dark:bg-dark-2 ${classNames}`}
onClick={(e) => e.stopPropagation()}
data-cy="modal-content"
>
<button
onClick={onClose}
className="float-right mb-2.5 cursor-pointer border-none bg-transparent p-0 text-2xl text-dark-5 hover:text-primary dark:text-gray-5 dark:hover:text-primary"
data-cy="modal-close-button"
>
<CrossIcon />
</button>
@@ -70,6 +73,7 @@ const Modal: React.FC<ModalProps> = ({
shape="rounded"
className="mt-6 w-fit capitalize"
onClick={onConfirm}
data-cy="modal-confirm-button"
label={
isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
@@ -85,6 +89,7 @@ const Modal: React.FC<ModalProps> = ({
shape="rounded"
className="mt-6 w-fit capitalize"
onClick={onClose}
data-cy="modal-cancel-button"
label={cancelText}
/>
</IsVisible>