refactor(tender-details): enhance translation page tests and layout

- Updated Cypress tests for the tender translation page to improve visibility checks and streamline assertions.
- Refactored test structure to utilize a dedicated function for fetching the visible title heading.
- Adjusted test cases to ensure accurate language switching and URL handling for tender details.
- Improved layout consistency in the translation page by refining element visibility checks and ensuring proper section rendering.
This commit is contained in:
AmirReza Jamali
2026-05-11 16:11:09 +03:30
parent 283aa9385e
commit f397158a29
36 changed files with 1325 additions and 854 deletions
@@ -46,16 +46,16 @@ const DatePicker = <T extends FieldValues>({
minDate,
}: IProps<T>) => {
return (
<div>
<div className="space-y-3">
<label
htmlFor={name}
className="text-body-sm font-medium capitalize text-dark dark:text-white"
className="block text-body-sm font-semibold capitalize tracking-[0.01em] text-dark dark:text-white"
>
{label}
{required && <span className="ml-1 select-none text-error">*</span>}
</label>
<div className="mt-3">
<div>
<Controller
control={control}
name={name}
@@ -96,18 +96,19 @@ const DatePicker = <T extends FieldValues>({
format={`YYYY/MM/DD ${timePicker ? "HH:mm" : ""}`}
containerClassName="w-full"
inputClass={cn(
"w-full rounded-lg border-[1.5px] border-stroke bg-transparent px-5.5 py-3 pl-12.5 text-dark outline-none transition focus:border-primary disabled:cursor-default disabled:bg-gray-2 dark:border-dark-3 dark:bg-dark-2 dark:text-white dark:focus:border-primary dark:disabled:bg-dark",
error && "!border-red focus:!border-red dark:!border-red",
"w-full rounded-xl border border-stroke/70 bg-white/90 px-5.5 py-3 pl-12.5 text-dark 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)] disabled:cursor-default disabled:bg-gray-2/70 dark:border-dark-3/80 dark:bg-dark-2/85 dark:text-white 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)] dark:disabled:bg-dark",
error &&
"border-red/80 focus:border-red focus:shadow-[0_0_0_2px_rgba(239,68,68,0.16),0_10px_24px_rgba(239,68,68,0.1)] dark:border-red/80 dark:focus:shadow-[0_0_0_2px_rgba(239,68,68,0.24),0_10px_24px_rgba(127,29,29,0.45)]",
)}
placeholder={placeholder}
/>
<span className="absolute left-4.5 top-1/2 -translate-y-1/2">
<span className="pointer-events-none absolute left-4.5 top-1/2 -translate-y-1/2 text-dark-5 transition-colors dark:text-dark-6">
<CalendarIcon />
</span>
</div>
{error && (
<p className="text-red mt-1.5 text-sm text-error">
<p className="mt-1.5 text-sm font-medium text-error">
{error.message as string}
</p>
)}
@@ -3,6 +3,7 @@ import { PencilSquareIcon, TrashIcon } from "@/assets/icons";
import { Switch } from "@/components/FormElements/switch";
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
import Modal from "@/components/ui/modal";
@@ -15,6 +16,7 @@ import {
TableRow,
} from "@/components/ui/table";
import TableSkeleton from "@/components/ui/TableSkeleton";
import Pagination from "@/components/ui/pagination";
import { _TooltipDefaultParams } from "@/constants/tooltip";
import { getPaginatedRowNumber, unixToDate } from "@/utils/shared";
import { startTransition } from "react";
@@ -44,6 +46,9 @@ const CompanyCategoriesTable = () => {
setFilterValue,
columns,
isMutating,
metadata,
setParams,
shouldShowPagination,
} = useCompanyCategoriesPresenter();
return (
@@ -70,6 +75,7 @@ const CompanyCategoriesTable = () => {
list={optimisticCategories}
columns={columns}
emptyMessage="No categories were found"
isLoading={isLoading}
>
{optimisticCategories.map((category, index) => (
<TableRow
@@ -78,7 +84,11 @@ const CompanyCategoriesTable = () => {
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell colSpan={100}>
{getPaginatedRowNumber({ index })}
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell colSpan={100}>{category.name}</TableCell>
<TableCell colSpan={100}>{category.description}</TableCell>
@@ -179,6 +189,19 @@ const CompanyCategoriesTable = () => {
onCancel={() => setIsModalOpen(false)}
/>
)}
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
/>
</IsVisible>
</ListWrapper>
);
};
@@ -35,6 +35,8 @@ const useCompanyCategoriesPresenter = () => {
useEffect(() => {
const newParams: Record<string, any> = {
...apiDefaultParams,
offset: 0,
limit: 10,
};
for (const [key, value] of searchParams.entries()) {
newParams[key] = value;
@@ -45,6 +47,10 @@ const useCompanyCategoriesPresenter = () => {
const { data, isLoading, isError } = useCompanyCategoriesQuery(params);
const categories = data?.data.categories;
const metadata = data?.meta;
const shouldShowPagination =
!isLoading &&
(data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const [optimisticCategories, addOptimisticPublished] = useOptimistic<
TCompanyCategory[],
TogglePublishedOptimistic
@@ -58,7 +64,7 @@ const useCompanyCategoriesPresenter = () => {
useToggleCompanyCategoryPublished();
const search = (data: any) => {
const newParams = { ...params, ...data };
const newParams = { ...params, ...data, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
@@ -102,6 +108,8 @@ const useCompanyCategoriesPresenter = () => {
watch,
setFilterValue,
isMutating,
metadata,
shouldShowPagination,
};
};
+24 -1
View File
@@ -7,9 +7,11 @@ import {
} from "@/assets/icons";
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
import ConfirmationModal from "@/components/ui/ConfirmationModal";
import IsVisible from "@/components/ui/IsVisible";
import ListHeader from "@/components/ui/ListHeader";
import ListWrapper from "@/components/ui/ListWrapper";
import Modal from "@/components/ui/modal";
import Pagination from "@/components/ui/pagination";
import {
Table,
TableBody,
@@ -45,6 +47,9 @@ const CompaniesTable = () => {
watch,
setFilterValue,
isMutating,
metadata,
setParams,
shouldShowPagination,
} = useCompanyListPresenter();
const companies = allCompanies.filter(
(c): c is NonNullable<typeof c> => c != null,
@@ -75,6 +80,7 @@ const CompaniesTable = () => {
list={companies}
columns={columns}
emptyMessage="No companies were found"
isLoading={isPending}
>
{companies.map((company, index) => (
<TableRow
@@ -82,7 +88,11 @@ const CompaniesTable = () => {
className="odd:bg-gray-2 dark:odd:bg-gray-7"
>
<TableCell className="text-start" colSpan={100}>
{getPaginatedRowNumber({ index })}
{getPaginatedRowNumber({
index,
page: metadata?.page,
limit: metadata?.limit,
})}
</TableCell>
<TableCell className="text-start" colSpan={100}>
{company.name}
@@ -198,6 +208,19 @@ const CompaniesTable = () => {
onCancel={() => setIsModalOpen(false)}
/>
)}
<IsVisible condition={shouldShowPagination}>
<Pagination
currentPage={metadata?.page ? metadata.page - 1 : 0}
totalPages={metadata?.pages ?? 1}
onPageChange={(e) => {
setParams((currentParams) => ({
...currentParams,
offset: e.selected * (metadata?.limit ?? 10),
limit: metadata?.limit ?? 10,
}));
}}
/>
</IsVisible>
</ListWrapper>
);
};
@@ -1,20 +1,18 @@
"use client";
import { ConfirmationModalProps } from "@/components/ui/ConfirmationModal";
import { apiDefaultParams } from "@/constants/Api_Params";
import {
useCompaniesInfiniteQuery,
useCompaniesQuery,
useDeleteCompanyQuery,
} from "@/hooks/queries";
import { TCompany } from "@/lib/api";
import { deleteEmptyKeys } from "@/utils/shared";
import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useInView } from "react-intersection-observer";
export const useCompanyListPresenter = () => {
const deleteModal = useRef<HTMLDialogElement | null>(null);
const { inView } = useInView({ threshold: 0.5 });
const [currentCompany, setCurrentCompany] = useState<TCompany | null>(null);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const [modalConfig, setModalConfig] = useState<
@@ -50,7 +48,9 @@ export const useCompanyListPresenter = () => {
useEffect(() => {
const newParams: Record<string, any> = {
sort: "created_at",
...apiDefaultParams,
offset: 0,
limit: 10,
};
for (const [key, value] of searchParams.entries()) {
newParams[key] = value;
@@ -59,28 +59,17 @@ export const useCompanyListPresenter = () => {
filterFormReset(newParams);
}, [searchParams, filterFormReset]);
const {
data,
error,
isPending,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useCompaniesInfiniteQuery(params);
const { data, error, isPending, isError } = useCompaniesQuery(params);
useEffect(() => {
if (inView && hasNextPage && data && data.pages.length < 5) {
fetchNextPage();
}
}, [inView, hasNextPage, data, fetchNextPage]);
const shouldShowPagination =
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const { mutate: deleteCompany } = useDeleteCompanyQuery(() =>
setIsModalOpen(false),
);
const search = (data: any) => {
const newParams = { ...params, ...data };
const newParams = { ...params, ...data, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
@@ -91,22 +80,18 @@ export const useCompanyListPresenter = () => {
deleteCompany(currentCompany.id);
}
};
const allCompanies = data?.pages.flatMap((page) => page.data.companies) ?? [];
const allCompanies = data?.data.companies ?? [];
const metadata = data?.meta;
return {
allCompanies,
onConfirmDelete,
deleteCompany,
isFetchingNextPage,
data,
metadata,
columns,
pathName,
currentCompany,
hasNextPage,
fetchNextPage,
modalConfig,
setModalConfig,
setCurrentCompany,
deleteModal,
error,
isModalOpen,
setIsModalOpen,
@@ -121,5 +106,7 @@ export const useCompanyListPresenter = () => {
watch,
setFilterValue,
isMutating,
setParams,
shouldShowPagination,
};
};
@@ -1,3 +1,4 @@
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
import InputGroup from "@/components/FormElements/InputGroup";
import { Select } from "@/components/FormElements/select";
import { Button } from "@/components/ui-elements/button";
@@ -6,6 +7,7 @@ import { TenderStatus } from "@/constants/enums";
import { Languages } from "@/constants/languages";
import { getEnumAsArray } from "@/utils/shared";
import {
Control,
FieldValues,
UseFormHandleSubmit,
UseFormRegister,
@@ -15,6 +17,7 @@ import {
interface TenderListFiltersProps {
filterRegister: UseFormRegister<FieldValues>;
control: Control<FieldValues>;
setIsFilterModalOpen: (isOpen: boolean) => void;
isMutating: number;
handleFilterSubmit: UseFormHandleSubmit<FieldValues>;
@@ -25,6 +28,7 @@ interface TenderListFiltersProps {
const TenderListFilters = ({
filterRegister,
control,
setIsFilterModalOpen,
isMutating,
handleFilterSubmit,
@@ -37,11 +41,22 @@ const TenderListFilters = ({
className="grid !min-w-full grid-cols-1 gap-4 xl:grid-cols-2"
onSubmit={handleFilterSubmit(search)}
>
<InputGroup
{...filterRegister("q")}
name="q"
label="keyword"
type="text"
/>
<InputGroup
{...filterRegister("title")}
name="title"
label="title"
placeholder="Title"
type="text"
/>
<InputGroup
{...filterRegister("description")}
name="description"
label="description"
type="text"
/>
<Select
@@ -49,27 +64,100 @@ const TenderListFilters = ({
items={getEnumAsArray(TenderStatus)}
label="status"
name="status"
placeholder="Status"
clearable
value={watch("status")}
onClear={() => setValue("status", undefined)}
/>
<InputGroup
{...filterRegister("country")}
name="country"
label="country"
type="text"
/>
<Select
{...filterRegister("country_code")}
items={Countries}
label="country code"
name="country_code"
placeholder="Country Code"
clearable
value={watch("country_code")}
onClear={() => setValue("country_code", undefined)}
/>
<InputGroup
{...filterRegister("country_codes")}
name="country_codes"
label="country codes"
type="text"
/>
<InputGroup
{...filterRegister("notice_type")}
name="notice_type"
label="notice type"
type="text"
/>
<InputGroup
{...filterRegister("notice_types")}
name="notice_types"
label="notice types"
type="text"
/>
<InputGroup
{...filterRegister("form_types")}
name="form_types"
label="form types"
type="text"
/>
<InputGroup
{...filterRegister("main_classification")}
name="main_classification"
label="main classification"
type="text"
/>
<InputGroup
{...filterRegister("classifications")}
name="classifications"
label="classifications"
type="text"
/>
<InputGroup
{...filterRegister("cpv_codes")}
name="cpv_codes"
label="cpv codes"
type="text"
/>
<DatePicker
control={control}
name="created_at_range"
label="created at range"
range
timePicker
/>
<DatePicker
control={control}
name="tender_deadline_range"
label="tender deadline range"
range
timePicker
/>
<DatePicker
control={control}
name="publication_date_range"
label="publication date range"
range
timePicker
/>
<DatePicker
control={control}
name="submission_deadline_range"
label="submission deadline range"
range
timePicker
/>
<Select
{...filterRegister("lang")}
items={Languages}
label="language"
name="lang"
placeholder="Language"
clearable
value={watch("lang")}
onClear={() => setValue("lang", undefined)}
@@ -83,10 +171,10 @@ const TenderListFilters = ({
className="mt-6 w-fit capitalize"
label={
isMutating ? (
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)
<div className="h-5 w-5 animate-spin rounded-full border-b-2 border-gray-1"></div>
) : (
<span>Search</span>
)
}
/>
<Button
+2
View File
@@ -41,6 +41,7 @@ const TendersTable = () => {
search,
watch,
setFilterValue,
control,
isMutating,
shouldShowPagination,
} = useTenderListPresenter();
@@ -167,6 +168,7 @@ const TendersTable = () => {
<TenderListFilters
setValue={setFilterValue}
filterRegister={filterRegister}
control={control}
handleFilterSubmit={handleFilterSubmit}
isMutating={isMutating as number}
search={search}
@@ -8,6 +8,110 @@ import { useIsMutating } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
const COMMA_SEPARATED_FILTER_KEYS = new Set([
"country_codes",
"notice_types",
"form_types",
"classifications",
"cpv_codes",
]);
const normalizeFilterValues = (values: Record<string, any>) => {
const normalized: Record<string, any> = {};
for (const [key, value] of Object.entries(values)) {
if (value === undefined || value === null) {
continue;
}
if (typeof value === "string") {
const trimmedValue = value.trim();
if (!trimmedValue) {
continue;
}
if (COMMA_SEPARATED_FILTER_KEYS.has(key)) {
normalized[key] = trimmedValue
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.join(",");
continue;
}
normalized[key] = trimmedValue;
continue;
}
normalized[key] = value;
}
return normalized;
};
const mapDateRangeFilters = (values: Record<string, any>) => {
const mappedValues = { ...values };
const mapRange = ({
key,
fromKey,
toKey,
aliases = [],
}: {
key: string;
fromKey: string;
toKey: string;
aliases?: Array<{ from: string; to: string }>;
}) => {
const rangeValue = mappedValues[key];
if (!Array.isArray(rangeValue)) {
return;
}
const [from, to] = rangeValue;
if (from !== undefined && from !== null && from !== "") {
mappedValues[fromKey] = from;
aliases.forEach((alias) => {
mappedValues[alias.from] = from;
});
}
if (to !== undefined && to !== null && to !== "") {
mappedValues[toKey] = to;
aliases.forEach((alias) => {
mappedValues[alias.to] = to;
});
}
delete mappedValues[key];
};
mapRange({
key: "created_at_range",
fromKey: "created_at_from",
toKey: "created_at_to",
});
mapRange({
key: "tender_deadline_range",
fromKey: "tender_deadline_from",
toKey: "tender_deadline_to",
aliases: [{ from: "deadline_from", to: "deadline_to" }],
});
mapRange({
key: "publication_date_range",
fromKey: "publication_date_from",
toKey: "publication_date_to",
});
mapRange({
key: "submission_deadline_range",
fromKey: "submission_deadline_from",
toKey: "submission_deadline_to",
aliases: [{ from: "submission_date_from", to: "submission_date_to" }],
});
return mappedValues;
};
const useTenderListPresenter = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
@@ -27,6 +131,7 @@ const useTenderListPresenter = () => {
reset: filterFormReset,
watch,
setValue: setFilterValue,
control,
} = useForm();
useEffect(() => {
@@ -48,7 +153,9 @@ const useTenderListPresenter = () => {
!isPending && (data?.meta?.pages ?? 1) * (data?.meta?.limit ?? 10) > 10;
const search = (data: any) => {
const newParams = { ...params, ...data, offset: 0 };
const mappedDateRanges = mapDateRangeFilters(data);
const normalizedData = normalizeFilterValues(mappedDateRanges);
const newParams = { ...params, ...normalizedData, offset: 0 };
const cleanedParams = deleteEmptyKeys(newParams);
const queryString = new URLSearchParams(cleanedParams).toString();
router.push(`${pathName}?${queryString}`);
@@ -88,6 +195,7 @@ const useTenderListPresenter = () => {
search,
watch,
setFilterValue,
control,
isMutating,
shouldShowPagination,
};
+5 -2
View File
@@ -49,12 +49,15 @@ const Modal: React.FC<ModalProps> = ({
const modalContent = (
<div
className="fixed inset-0 z-999999 flex items-center justify-center bg-dark/50 dark:bg-black/50"
onClick={onClose}
onClick={(e) => {
if (e.target === e.currentTarget) {
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