feat(date-picker): add clearable option and enhance date handling
- Introduced a clearable option for the DatePicker component, allowing users to easily reset the selected date. - Enhanced date handling logic to support conversion from Unix timestamps and improved value management for single and range selections. - Updated TenderListFilters to utilize the clearable feature in multiple DatePicker instances for better user experience. - Refactored useTenderListPresenter to manage date range filters more effectively, ensuring proper cleanup of cleared values.
This commit is contained in:
@@ -24,6 +24,7 @@ interface IProps<T extends FieldValues> {
|
|||||||
minDate?: number;
|
minDate?: number;
|
||||||
maxDate?: number;
|
maxDate?: number;
|
||||||
timePicker?: boolean;
|
timePicker?: boolean;
|
||||||
|
clearable?: boolean;
|
||||||
rules?: Omit<
|
rules?: Omit<
|
||||||
RegisterOptions<T, Path<T>>,
|
RegisterOptions<T, Path<T>>,
|
||||||
"valueAsNumber" | "valueAsDate" | "setValueAs" | "disabled"
|
"valueAsNumber" | "valueAsDate" | "setValueAs" | "disabled"
|
||||||
@@ -44,6 +45,7 @@ const DatePicker = <T extends FieldValues>({
|
|||||||
rules,
|
rules,
|
||||||
maxDate,
|
maxDate,
|
||||||
minDate,
|
minDate,
|
||||||
|
clearable = false,
|
||||||
}: IProps<T>) => {
|
}: IProps<T>) => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -66,6 +68,50 @@ const DatePicker = <T extends FieldValues>({
|
|||||||
}) => {
|
}) => {
|
||||||
const error = errors[name];
|
const error = errors[name];
|
||||||
|
|
||||||
|
const toUnixSeconds = (v: unknown): number | null => {
|
||||||
|
if (v === "" || v === undefined || v === null) return null;
|
||||||
|
const n = typeof v === "number" ? v : Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPickerValue = ():
|
||||||
|
| DateObject
|
||||||
|
| DateObject[]
|
||||||
|
| undefined => {
|
||||||
|
if (value === "" || value === undefined || value === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (multiple && Array.isArray(value)) {
|
||||||
|
const secs = value
|
||||||
|
.map(toUnixSeconds)
|
||||||
|
.filter((s): s is number => s !== null);
|
||||||
|
if (!secs.length) return undefined;
|
||||||
|
return secs.map((unix) => new DateObject(new Date(unix * 1000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range && Array.isArray(value)) {
|
||||||
|
const secs = value
|
||||||
|
.map(toUnixSeconds)
|
||||||
|
.filter((s): s is number => s !== null);
|
||||||
|
if (!secs.length) return undefined;
|
||||||
|
return secs.map((unix) => new DateObject(new Date(unix * 1000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range && !Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unix = toUnixSeconds(value);
|
||||||
|
if (unix === null) return undefined;
|
||||||
|
return new DateObject(new Date(unix * 1000));
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickerValue = toPickerValue();
|
||||||
|
const hasValue =
|
||||||
|
pickerValue !== undefined &&
|
||||||
|
(Array.isArray(pickerValue) ? pickerValue.length > 0 : true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -81,9 +127,11 @@ const DatePicker = <T extends FieldValues>({
|
|||||||
}
|
}
|
||||||
onlyYearPicker={onlyYearPicker}
|
onlyYearPicker={onlyYearPicker}
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
|
value={pickerValue}
|
||||||
onChange={(date: DateObject | DateObject[] | null) => {
|
onChange={(date: DateObject | DateObject[] | null) => {
|
||||||
if (!date) {
|
if (!date) {
|
||||||
return onChange("");
|
onChange("");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(date)) {
|
if (Array.isArray(date)) {
|
||||||
@@ -97,6 +145,7 @@ const DatePicker = <T extends FieldValues>({
|
|||||||
containerClassName="w-full"
|
containerClassName="w-full"
|
||||||
inputClass={cn(
|
inputClass={cn(
|
||||||
"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",
|
"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",
|
||||||
|
clearable && hasValue && "pr-11",
|
||||||
error &&
|
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)]",
|
"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)]",
|
||||||
)}
|
)}
|
||||||
@@ -105,6 +154,35 @@ const DatePicker = <T extends FieldValues>({
|
|||||||
<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">
|
<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 />
|
<CalendarIcon />
|
||||||
</span>
|
</span>
|
||||||
|
{clearable && hasValue && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Clear date"
|
||||||
|
className="absolute right-3 top-1/2 z-10 -translate-y-1/2 rounded-md p-1 text-dark-5 transition hover:bg-gray-2 hover:text-dark dark:text-dark-6 dark:hover:bg-dark-3 dark:hover:text-white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 4L4 12M4 4L12 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
|
import DatePicker from "@/components/FormElements/DatePicker/DatePicker";
|
||||||
import InputGroup from "@/components/FormElements/InputGroup";
|
import InputGroup from "@/components/FormElements/InputGroup";
|
||||||
import { Select } from "@/components/FormElements/select";
|
|
||||||
import { Button } from "@/components/ui-elements/button";
|
import { Button } from "@/components/ui-elements/button";
|
||||||
import { Countries } from "@/constants/countries";
|
|
||||||
import { TenderStatus } from "@/constants/enums";
|
|
||||||
import { Languages } from "@/constants/languages";
|
|
||||||
import { getEnumAsArray } from "@/utils/shared";
|
|
||||||
import {
|
import {
|
||||||
Control,
|
Control,
|
||||||
FieldValues,
|
FieldValues,
|
||||||
@@ -59,30 +54,14 @@ const TenderListFilters = ({
|
|||||||
label="description"
|
label="description"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<Select
|
|
||||||
{...filterRegister("status")}
|
|
||||||
items={getEnumAsArray(TenderStatus)}
|
|
||||||
label="status"
|
|
||||||
name="status"
|
|
||||||
clearable
|
|
||||||
value={watch("status")}
|
|
||||||
onClear={() => setValue("status", undefined)}
|
|
||||||
/>
|
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("country")}
|
{...filterRegister("country")}
|
||||||
name="country"
|
name="country"
|
||||||
label="country"
|
label="country"
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<Select
|
|
||||||
{...filterRegister("country_code")}
|
|
||||||
items={Countries}
|
|
||||||
label="country code"
|
|
||||||
name="country_code"
|
|
||||||
clearable
|
|
||||||
value={watch("country_code")}
|
|
||||||
onClear={() => setValue("country_code", undefined)}
|
|
||||||
/>
|
|
||||||
<InputGroup
|
<InputGroup
|
||||||
{...filterRegister("country_codes")}
|
{...filterRegister("country_codes")}
|
||||||
name="country_codes"
|
name="country_codes"
|
||||||
@@ -131,6 +110,7 @@ const TenderListFilters = ({
|
|||||||
label="created at range"
|
label="created at range"
|
||||||
range
|
range
|
||||||
timePicker
|
timePicker
|
||||||
|
clearable
|
||||||
/>
|
/>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
control={control}
|
control={control}
|
||||||
@@ -138,6 +118,7 @@ const TenderListFilters = ({
|
|||||||
label="tender deadline range"
|
label="tender deadline range"
|
||||||
range
|
range
|
||||||
timePicker
|
timePicker
|
||||||
|
clearable
|
||||||
/>
|
/>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
control={control}
|
control={control}
|
||||||
@@ -145,6 +126,7 @@ const TenderListFilters = ({
|
|||||||
label="publication date range"
|
label="publication date range"
|
||||||
range
|
range
|
||||||
timePicker
|
timePicker
|
||||||
|
clearable
|
||||||
/>
|
/>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
control={control}
|
control={control}
|
||||||
@@ -152,15 +134,7 @@ const TenderListFilters = ({
|
|||||||
label="submission deadline range"
|
label="submission deadline range"
|
||||||
range
|
range
|
||||||
timePicker
|
timePicker
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
{...filterRegister("lang")}
|
|
||||||
items={Languages}
|
|
||||||
label="language"
|
|
||||||
name="lang"
|
|
||||||
clearable
|
clearable
|
||||||
value={watch("lang")}
|
|
||||||
onClear={() => setValue("lang", undefined)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="col-span-2 flex gap-4">
|
<div className="col-span-2 flex gap-4">
|
||||||
|
|||||||
@@ -50,6 +50,56 @@ const normalizeFilterValues = (values: Record<string, any>) => {
|
|||||||
return normalized;
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseUnixParam = (v: unknown): number | undefined => {
|
||||||
|
if (v === undefined || v === null || v === "") return undefined;
|
||||||
|
const n = typeof v === "number" ? v : Number(v);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRangeFormValue = (
|
||||||
|
params: Record<string, any>,
|
||||||
|
fromKey: string,
|
||||||
|
toKey: string,
|
||||||
|
legacy?: { from: string; to: string },
|
||||||
|
): number[] | undefined => {
|
||||||
|
let from = parseUnixParam(params[fromKey]);
|
||||||
|
let to = parseUnixParam(params[toKey]);
|
||||||
|
if (legacy) {
|
||||||
|
if (from === undefined) from = parseUnixParam(params[legacy.from]);
|
||||||
|
if (to === undefined) to = parseUnixParam(params[legacy.to]);
|
||||||
|
}
|
||||||
|
if (from !== undefined && to !== undefined) return [from, to];
|
||||||
|
if (from !== undefined) return [from];
|
||||||
|
if (to !== undefined) return [to];
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTenderFilterFormValues = (params: Record<string, any>) => ({
|
||||||
|
...params,
|
||||||
|
created_at_range: buildRangeFormValue(
|
||||||
|
params,
|
||||||
|
"created_at_from",
|
||||||
|
"created_at_to",
|
||||||
|
),
|
||||||
|
tender_deadline_range: buildRangeFormValue(
|
||||||
|
params,
|
||||||
|
"tender_deadline_from",
|
||||||
|
"tender_deadline_to",
|
||||||
|
{ from: "deadline_from", to: "deadline_to" },
|
||||||
|
),
|
||||||
|
publication_date_range: buildRangeFormValue(
|
||||||
|
params,
|
||||||
|
"publication_date_from",
|
||||||
|
"publication_date_to",
|
||||||
|
),
|
||||||
|
submission_deadline_range: buildRangeFormValue(
|
||||||
|
params,
|
||||||
|
"submission_deadline_from",
|
||||||
|
"submission_deadline_to",
|
||||||
|
{ from: "submission_date_from", to: "submission_date_to" },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const mapDateRangeFilters = (values: Record<string, any>) => {
|
const mapDateRangeFilters = (values: Record<string, any>) => {
|
||||||
const mappedValues = { ...values };
|
const mappedValues = { ...values };
|
||||||
|
|
||||||
@@ -65,6 +115,22 @@ const mapDateRangeFilters = (values: Record<string, any>) => {
|
|||||||
aliases?: Array<{ from: string; to: string }>;
|
aliases?: Array<{ from: string; to: string }>;
|
||||||
}) => {
|
}) => {
|
||||||
const rangeValue = mappedValues[key];
|
const rangeValue = mappedValues[key];
|
||||||
|
const isCleared =
|
||||||
|
rangeValue === "" ||
|
||||||
|
rangeValue === null ||
|
||||||
|
(Array.isArray(rangeValue) && rangeValue.length === 0);
|
||||||
|
|
||||||
|
if (isCleared) {
|
||||||
|
delete mappedValues[key];
|
||||||
|
delete mappedValues[fromKey];
|
||||||
|
delete mappedValues[toKey];
|
||||||
|
aliases.forEach((alias) => {
|
||||||
|
delete mappedValues[alias.from];
|
||||||
|
delete mappedValues[alias.to];
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!Array.isArray(rangeValue)) {
|
if (!Array.isArray(rangeValue)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -112,6 +178,42 @@ const mapDateRangeFilters = (values: Record<string, any>) => {
|
|||||||
|
|
||||||
return mappedValues;
|
return mappedValues;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TENDER_RANGE_PARAM_KEYS = [
|
||||||
|
"created_at_from",
|
||||||
|
"created_at_to",
|
||||||
|
"tender_deadline_from",
|
||||||
|
"tender_deadline_to",
|
||||||
|
"deadline_from",
|
||||||
|
"deadline_to",
|
||||||
|
"publication_date_from",
|
||||||
|
"publication_date_to",
|
||||||
|
"submission_deadline_from",
|
||||||
|
"submission_deadline_to",
|
||||||
|
"submission_date_from",
|
||||||
|
"submission_date_to",
|
||||||
|
"created_at_range",
|
||||||
|
"tender_deadline_range",
|
||||||
|
"publication_date_range",
|
||||||
|
"submission_deadline_range",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Query keys owned by TenderListFilters — drop from URL when cleared (not in normalized submit). */
|
||||||
|
const TENDER_FILTER_MODAL_KEYS = new Set<string>([
|
||||||
|
"q",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"country",
|
||||||
|
"country_codes",
|
||||||
|
"notice_type",
|
||||||
|
"notice_types",
|
||||||
|
"form_types",
|
||||||
|
"main_classification",
|
||||||
|
"classifications",
|
||||||
|
"cpv_codes",
|
||||||
|
...TENDER_RANGE_PARAM_KEYS,
|
||||||
|
]);
|
||||||
|
|
||||||
const useTenderListPresenter = () => {
|
const useTenderListPresenter = () => {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
|
||||||
@@ -139,13 +241,12 @@ const useTenderListPresenter = () => {
|
|||||||
...apiDefaultParams,
|
...apiDefaultParams,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
lang: "en",
|
|
||||||
};
|
};
|
||||||
for (const [key, value] of searchParams.entries()) {
|
for (const [key, value] of searchParams.entries()) {
|
||||||
newParams[key] = value;
|
newParams[key] = value;
|
||||||
}
|
}
|
||||||
setParams(newParams);
|
setParams(newParams);
|
||||||
filterFormReset(newParams);
|
filterFormReset(buildTenderFilterFormValues(newParams));
|
||||||
}, [searchParams, filterFormReset]);
|
}, [searchParams, filterFormReset]);
|
||||||
|
|
||||||
const { data, error, isPending } = useTendersQuery(params);
|
const { data, error, isPending } = useTendersQuery(params);
|
||||||
@@ -155,7 +256,13 @@ const useTenderListPresenter = () => {
|
|||||||
const search = (data: any) => {
|
const search = (data: any) => {
|
||||||
const mappedDateRanges = mapDateRangeFilters(data);
|
const mappedDateRanges = mapDateRangeFilters(data);
|
||||||
const normalizedData = normalizeFilterValues(mappedDateRanges);
|
const normalizedData = normalizeFilterValues(mappedDateRanges);
|
||||||
const newParams = { ...params, ...normalizedData, offset: 0 };
|
const merged: Record<string, any> = { ...params };
|
||||||
|
for (const key of TENDER_FILTER_MODAL_KEYS) {
|
||||||
|
if (!(key in normalizedData)) {
|
||||||
|
delete merged[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newParams = { ...merged, ...normalizedData, offset: 0 };
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
const queryString = new URLSearchParams(cleanedParams).toString();
|
const queryString = new URLSearchParams(cleanedParams).toString();
|
||||||
router.push(`${pathName}?${queryString}`);
|
router.push(`${pathName}?${queryString}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user