diff --git a/packages/app-builder/src/locales/ar/filters.json b/packages/app-builder/src/locales/ar/filters.json index c611546d62..5a2956d145 100644 --- a/packages/app-builder/src/locales/ar/filters.json +++ b/packages/app-builder/src/locales/ar/filters.json @@ -1,5 +1,8 @@ { "clear_filters": "مسح الفلاتر", + "ds.addNewFilter.label": "إضافة فلتر جديد", + "ds.chooseFilter.label": "اختر فلتر", + "ds.noOptionsAvailable.label": "لا يوجد خيارات متاحة", "filters": "الفلاتر", "new_filter": "فلتر جدبد", "up_to": "حتى {{date}}" diff --git a/packages/app-builder/src/locales/en/filters.json b/packages/app-builder/src/locales/en/filters.json index a640c26aec..da273552b7 100644 --- a/packages/app-builder/src/locales/en/filters.json +++ b/packages/app-builder/src/locales/en/filters.json @@ -1,5 +1,8 @@ { "clear_filters": "clear filters", + "ds.addNewFilter.label": "Add new filter", + "ds.chooseFilter.label": "Choose a filter", + "ds.noOptionsAvailable.label": "No options available", "filters": "filters", "new_filter": "new filter", "up_to": "up to {{duration}}" diff --git a/packages/app-builder/src/locales/fr/filters.json b/packages/app-builder/src/locales/fr/filters.json index 256eae4f1d..a2fa40b18f 100644 --- a/packages/app-builder/src/locales/fr/filters.json +++ b/packages/app-builder/src/locales/fr/filters.json @@ -1,5 +1,8 @@ { "clear_filters": "effacer les filtres", + "ds.addNewFilter.label": "Ajouter un nouveau filtre", + "ds.chooseFilter.label": "Choisir un filtre", + "ds.noOptionsAvailable.label": "Aucune option disponible", "filters": "filtres", "new_filter": "nouveau filtre", "up_to": "jusqu'à {{duration}}" diff --git a/packages/ui-design-system/src/FiltersBar/FiltersBar.stories.tsx b/packages/ui-design-system/src/FiltersBar/FiltersBar.stories.tsx new file mode 100644 index 0000000000..8fbf898708 --- /dev/null +++ b/packages/ui-design-system/src/FiltersBar/FiltersBar.stories.tsx @@ -0,0 +1,103 @@ +import { type Meta, type StoryFn } from '@storybook/react'; +import { useState } from 'react'; + +import { FiltersBar } from './FiltersBar'; +import { type FilterDescriptor } from './types'; + +const Story: Meta = { + component: FiltersBar, + title: 'FiltersBar', +}; + +export default Story; + +const mainDescriptors: FilterDescriptor[] = [ + { type: 'text', name: 'search', placeholder: 'Search terms', operator: 'in' }, + { type: 'number', name: 'amount', placeholder: 'Amount', operator: 'eq' }, + { type: 'boolean', name: 'isActive', placeholder: 'Active' }, + { + type: 'select', + name: 'status', + placeholder: 'Status', + options: ['new', 'pending', 'approved', 'rejected'], + }, +]; + +const dynamicDescriptors: FilterDescriptor[] = [ + { type: 'date-range-popover', name: 'createdAt', placeholder: 'Created between' }, + { type: 'text', name: 'tags', placeholder: 'Tags', operator: 'in' }, + { type: 'boolean', name: 'archived', placeholder: 'Archived' }, + { + type: 'select', + name: 'owner', + placeholder: 'Owner', + options: [ + { label: 'Alice', value: 'alice' }, + { label: 'Bob', value: 'bob' }, + { label: 'Charlie', value: 'charlie' }, + ], + }, +]; + +export const Basic: StoryFn = (args) => { + const [state, setState] = useState<{ value: Record; active: string[] }>({ + value: {}, + active: [], + }); + + return ( + setState(next)} + /> + ); +}; + +export const WithInitialActive: StoryFn = (args) => { + const [state, setState] = useState<{ value: Record; active: string[] }>({ + value: {}, + active: ['createdAt', 'owner'], + }); + + return ( + setState(next)} + /> + ); +}; + +export const PrefilledValues: StoryFn = (args) => { + const [state, setState] = useState<{ value: Record; active: string[] }>({ + value: { + search: [ + { operator: 'in', value: 'fraud' }, + { operator: 'in', value: 'chargeback' }, + ], + amount: { operator: 'gte', value: 1000 }, + isActive: true, + status: 'approved', + createdAt: { type: 'dynamic', fromNow: '-P30D' }, + }, + active: ['createdAt'], + }); + + return ( + setState(next)} + /> + ); +}; diff --git a/packages/ui-design-system/src/FiltersBar/FiltersBar.tsx b/packages/ui-design-system/src/FiltersBar/FiltersBar.tsx new file mode 100644 index 0000000000..0b14c29a24 --- /dev/null +++ b/packages/ui-design-system/src/FiltersBar/FiltersBar.tsx @@ -0,0 +1,232 @@ +import { useMemo, useState } from 'react'; +import { match } from 'ts-pattern'; +import { ButtonV2 } from '../Button/Button'; +import { Checkbox } from '../Checkbox/Checkbox'; +import { useI18n } from '../contexts/I18nContext'; +import { Modal } from '../Modal/Modal'; +import { cn } from '../utils'; +import { BooleanValueFilter } from './internals/BooleanValueFilter'; +import { DateRangeFilterPopover } from './internals/DateRangeFilterPopover'; +import { FiltersBarContext, type FiltersBarContextValue } from './internals/FiltersBarContext'; +import { NumberValueFilter } from './internals/NumberValueFilter'; +import { SelectOptionFilter } from './internals/SelectOptionFilter'; +import { TextMatchFilter } from './internals/TextMatchFilter'; +import { + type DateRangePopoverFilter, + type Filter, + type FilterBarLevel, + type FilterDescriptor, + type FiltersBarProps, + type MultiSelectFilterDescriptor, + type NumberFilter, + type NumberFilterDescriptor, + type NumberOperator, + type RadioFilterDescriptor, + type SelectFilterDescriptor, + type TextFilter, + type TextFilterDescriptor, + type TextOperator, +} from './types'; + +export const NUMBER_OPERATORS: Set = new Set([ + 'eq', + 'ne', + 'lt', + 'lte', + 'gt', + 'gte', +]); +export const TEXT_OPERATORS: Set = new Set(['in']); + +// Context is now provided by ./context with a dev-safe fallback + +export function FiltersBar({ + descriptors = [], + dynamicDescriptors = [], + value, + active = [], + onChange, +}: FiltersBarProps) { + const { t } = useI18n(); + const [isAddModalOpen, setAddModalOpen] = useState(false); + + const contextValue = useMemo(() => { + const emitSet = (name: string, newValue: unknown) => { + const nextValue = { ...value, [name]: newValue }; + onChange({ type: 'set', name, value: newValue }, { value: nextValue, active }); + }; + const emitRemove = (name: string) => { + const nextValue = { ...value } as Record; + delete nextValue[name]; + const nextActive = active.filter((n) => n !== name); + onChange({ type: 'remove', name }, { value: nextValue, active: nextActive }); + }; + const emitToggleActive = (name: string, isActive: boolean) => { + const nextActive = isActive + ? Array.from(new Set([...active, name])) + : active.filter((n) => n !== name); + onChange({ type: 'toggleActive', name, isActive }, { value, active: nextActive }); + }; + const getValue = (name: string) => value[name]; + const isActive = (name: string) => active.includes(name); + return { emitSet, emitRemove, emitToggleActive, getValue, isActive }; + }, [value, active, onChange]); + + const getFilter = ( + d: FilterDescriptor, + value: unknown, + opts: Partial>, + ): Filter => { + const selectedValue = value ?? null; + const commonProps = { + name: d.name, + placeholder: d.placeholder, + removable: opts.removable ?? false, + isActive: opts.isActive ?? selectedValue ?? false, + }; + switch (d.type) { + case 'text': + return { + ...commonProps, + type: 'text' as const, + selectedValue: (selectedValue as TextFilter['selectedValue']) ?? null, + operator: (d as TextFilterDescriptor).operator, + } as Filter; + case 'number': + return { + ...commonProps, + type: 'number' as const, + selectedValue: (selectedValue as NumberFilter['selectedValue']) ?? null, + operator: (d as NumberFilterDescriptor).operator, + } as Filter; + case 'boolean': + return { + ...commonProps, + type: 'boolean' as const, + selectedValue: (selectedValue as boolean | null) ?? null, + } as Filter; + case 'checkbox': + return { + ...commonProps, + type: 'checkbox' as const, + selectedValue: (selectedValue as boolean | null) ?? null, + } as Filter; + case 'select': + return { + ...commonProps, + type: 'select' as const, + selectedValue: (selectedValue as string | null) ?? null, + options: (d as SelectFilterDescriptor).options, + } as Filter; + case 'multi-select': + return { + ...commonProps, + type: 'multi-select' as const, + selectedValue: (selectedValue as string[] | null) ?? null, + options: (d as MultiSelectFilterDescriptor).options, + } as Filter; + case 'date-range-popover': + return { + ...commonProps, + type: 'date-range-popover' as const, + selectedValue: (selectedValue as DateRangePopoverFilter['selectedValue']) ?? null, + } as Filter; + case 'radio': + return { + ...commonProps, + type: 'radio' as const, + selectedValue: (selectedValue as string | null) ?? null, + options: (d as RadioFilterDescriptor).options, + } as Filter; + default: + return undefined as never; + } + }; + const mainFilters: Filter[] = useMemo( + () => descriptors.map((d) => getFilter(d, value[d.name], {})), + [descriptors, value], + ); + + const additionalFilters: Filter[] = useMemo( + () => + dynamicDescriptors + // .filter((d) => active.includes(d.name)) + .map((d) => getFilter(d, value[d.name], { removable: true })), + [dynamicDescriptors, value, active], + ); + + const filtersMap = useMemo(() => { + return new Map([ + ['main', mainFilters], + ...(additionalFilters.length > 0 ? [['additional', additionalFilters] as const] : []), + ]); + }, [mainFilters, additionalFilters]); + + return ( + +
+ {Array.from(filtersMap.entries()).map(([level, renderedFilters]) => ( +
+ {renderedFilters + .filter((filter) => level === 'main' || active.includes(filter.name)) + .map((filter) => + match(filter) + .with({ type: 'text' }, (textFilter) => { + return ; + }) + .with({ type: 'checkbox' }, () => ) + .with({ type: 'number' }, (numberFilter) => ( + + )) + .with({ type: 'boolean' }, (booleanFilter) => ( + + )) + .with({ type: 'select' }, (selectFilter) => ( + + )) + .with({ type: 'date-range-popover' }, (dateRangePopoverFilter) => ( + + )) + .with({ type: 'radio' }, () =>
Radio filter not implemented yet
) + .with({ type: 'multi-select' }, () => ( +
Multi-select filter not implemented yet
+ )) + .otherwise(() =>
Filter not implemented yet
), + )} + + {level === 'additional' && ( + <> + setAddModalOpen(true)}> + {t('filters:ds.addNewFilter.label')} + + + + {t('filters:ds.chooseFilter.label')} +
+ {dynamicDescriptors + .filter((d) => !active.includes(d.name)) + .map((d) => ( + + ))} +
+
+
+ + )} +
+ ))} +
+
+ ); +} diff --git a/packages/ui-design-system/src/FiltersBar/internals/BooleanValueFilter.tsx b/packages/ui-design-system/src/FiltersBar/internals/BooleanValueFilter.tsx new file mode 100644 index 0000000000..e909fd3b89 --- /dev/null +++ b/packages/ui-design-system/src/FiltersBar/internals/BooleanValueFilter.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { Checkbox } from '../../Checkbox/Checkbox'; +import { cn } from '../../utils'; +import { type BooleanFilter, type FilterBarLevel } from '../types'; +import { FilterItem, FilterPopover } from './FilterPopover'; +import { useFiltersBarContext } from './FiltersBarContext'; + +export function BooleanValueFilter({ + filter, + level, +}: { + filter: BooleanFilter; + level: FilterBarLevel; +}) { + const f = filter as BooleanFilter; + const committed = (level === 'additional' ? f.selectedValue : f.selectedValue) as + | boolean + | null + | undefined; + const label = committed ? String(committed) : f.placeholder; + const [isOpen, setOpen] = useState(false); + const [localChecked, setLocalChecked] = useState<'indeterminate' | boolean>( + f.selectedValue === null ? 'indeterminate' : Boolean(f.selectedValue), + ); + const { emitSet, emitRemove } = useFiltersBarContext(); + useEffect(() => { + if (isOpen) { + setLocalChecked(f.selectedValue === null ? 'indeterminate' : Boolean(f.selectedValue)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + if (f.removable) { + return ( + + + {label} + {f.removable ? ( + { + setLocalChecked('indeterminate'); + emitRemove(f.name); + setOpen(false); + }} + /> + ) : null} + + +
+
+ setLocalChecked(checked as any)} + /> + Checked +
+
+ +
+
+
+
+ ); + } + return ; +} diff --git a/packages/ui-design-system/src/FiltersBar/internals/DateRangeFilter.tsx b/packages/ui-design-system/src/FiltersBar/internals/DateRangeFilter.tsx new file mode 100644 index 0000000000..ced72c0590 --- /dev/null +++ b/packages/ui-design-system/src/FiltersBar/internals/DateRangeFilter.tsx @@ -0,0 +1,214 @@ +import { clsx } from 'clsx'; +import { add, type Locale, sub } from 'date-fns'; +import { createContext, useCallback, useContext } from 'react'; +import { Temporal } from 'temporal-polyfill'; +import { Calendar, type DateRange } from '../../Calendar/Calendar'; +import { useFormatting } from '../../contexts/FormattingContext'; +import { useI18n } from '../../contexts/I18nContext'; +import type { DateRangeFilterType, StaticDateRangeFilterType } from '../types'; + +function adaptStaticDateRangeFilterType({ from, to }: DateRange): StaticDateRangeFilterType { + const startDate = from?.toISOString() ?? ''; + // Add a day to the end date because the user expects the end date to be included. + // To fully understand that, think about the special case where the user selects the same day in the Calendar picker (from = to) + // "From" means the start of the day, and "to" means the end of the day. + const endDate = to ? add(to, { days: 1 }).toISOString() : ''; + + return { + type: 'static', + startDate, + endDate, + }; +} + +function adaptDateRange({ startDate, endDate }: StaticDateRangeFilterType): DateRange | undefined { + const from = startDate ? new Date(startDate) : undefined; + // look at adaptStaticDateRangeFilterType for the reason why we substract a day + const to = endDate ? sub(new Date(endDate), { days: 1 }) : undefined; + + return from || to ? { from, to } : undefined; +} + +const DateRangeFilterContext = createContext< + | { + fromNow?: string; + calendarSelected?: DateRange; + onCalendarSelect: (range?: DateRange) => void; + onFromNowSelect: (fromNow: string) => void; + } + | undefined +>(undefined); + +const useDateRangeFilterContext = () => { + const ctx = useContext(DateRangeFilterContext); + if (!ctx) { + throw new Error( + 'useDateRangeFilterContext must be used within DateRangeFilterContext.Provider', + ); + } + return ctx; +}; + +function DateRangeFilterRoot({ + dateRangeFilter, + setDateRangeFilter, + locale, + children, + className, +}: { + dateRangeFilter: DateRangeFilterType; + setDateRangeFilter: (dateRangeFilter: DateRangeFilterType) => void; + locale: Locale; + children: React.ReactNode; + className?: string; +}) { + const calendarSelected = + dateRangeFilter?.type === 'static' ? adaptDateRange(dateRangeFilter) : undefined; + + const onCalendarSelect = useCallback( + (range?: DateRange) => { + setDateRangeFilter(adaptStaticDateRangeFilterType(range ?? { from: undefined })); + }, + [setDateRangeFilter], + ); + + const fromNow = dateRangeFilter?.type === 'dynamic' ? dateRangeFilter.fromNow : undefined; + const onFromNowSelect = useCallback( + (fromNow: string) => { + setDateRangeFilter({ + type: 'dynamic', + fromNow, + }); + }, + [setDateRangeFilter], + ); + + const value = { + fromNow, + calendarSelected, + onCalendarSelect, + onFromNowSelect, + }; + return ( + +
{children}
+
+ ); +} + +export const fromNowDurations = [ + Temporal.Duration.from({ days: -7 }).toString(), + Temporal.Duration.from({ days: -14 }).toString(), + Temporal.Duration.from({ days: -30 }).toString(), + Temporal.Duration.from({ months: -3 }).toString(), + Temporal.Duration.from({ months: -6 }).toString(), + Temporal.Duration.from({ months: -12 }).toString(), +] as const; + +function DateRangeFilterFromNowPicker({ title, className }: { title: string; className?: string }) { + const { language, formatDuration } = useFormatting(); + const { onFromNowSelect } = useDateRangeFilterContext(); + const { fromNow } = useDateRangeFilterContext(); + + return ( +
+
+

{title}

+
+
+ {fromNowDurations.map((duration) => ( + + ))} +
+
+ ); +} + +function DateRangeFilterCalendar({ className, locale }: { className?: string; locale: Locale }) { + const { calendarSelected, onCalendarSelect } = useDateRangeFilterContext(); + + return ( +
+ +
+ ); +} + +function DateRangeFilterSummary({ className }: { className?: string }) { + const { language, formatDuration } = useFormatting(); + const { t } = useI18n(); + const { fromNow, calendarSelected } = useDateRangeFilterContext(); + + if (fromNow) { + return ( +
+ +
+ ); + } + + return ( +
+ + + +
+ ); +} + +function FormatStaticDate({ date, className }: { date?: string | Date; className?: string }) { + const { language, formatDateTimeWithoutPresets } = useFormatting(); + + const dateTime = typeof date === 'string' ? date : date?.toDateString(); + const formattedDate = date + ? formatDateTimeWithoutPresets(date, { + language, + dateStyle: 'short', + }) + : '--/--/----'; + + return ( + + ); +} + +export const DateRangeFilter = { + Root: DateRangeFilterRoot, + FromNowPicker: DateRangeFilterFromNowPicker, + Calendar: DateRangeFilterCalendar, + Summary: DateRangeFilterSummary, +}; diff --git a/packages/ui-design-system/src/FiltersBar/internals/DateRangeFilterPopover.tsx b/packages/ui-design-system/src/FiltersBar/internals/DateRangeFilterPopover.tsx new file mode 100644 index 0000000000..7acdd1d965 --- /dev/null +++ b/packages/ui-design-system/src/FiltersBar/internals/DateRangeFilterPopover.tsx @@ -0,0 +1,80 @@ +import { ar, enUS, fr } from 'date-fns/locale'; +import { useMemo, useState } from 'react'; +import { useFormatting } from '../../contexts/FormattingContext'; +import type { DateRangeFilterType, DateRangePopoverFilter } from '../types'; +import { DateRangeFilter } from './DateRangeFilter'; +import { FilterItem, FilterPopover } from './FilterPopover'; +import { useFiltersBarContext } from './FiltersBarContext'; + +export function DateRangeFilterPopover({ filter }: { filter: DateRangePopoverFilter }) { + const drFilter = filter; + const { language, formatDateTimeWithoutPresets } = useFormatting(); + const { emitSet } = useFiltersBarContext(); + const dateFnsLocale = useMemo(() => { + switch (language) { + case 'fr': + return fr; + case 'ar': + return ar; + default: + return enUS; + } + }, [language]); + + const summary = (() => { + const from = + drFilter.selectedValue?.type === 'static' ? drFilter.selectedValue.startDate : undefined; + const to = + drFilter.selectedValue?.type === 'static' ? drFilter.selectedValue.endDate : undefined; + if (!from && !to) return drFilter.placeholder; + const fmt = (d?: Date) => + d ? formatDateTimeWithoutPresets(d, { language, dateStyle: 'short' }) : '--/--/----'; + return from && to ? `${fmt(new Date(from))} → ${fmt(new Date(to))}` : drFilter.placeholder; + })(); + + // Use a polyfill or fallback for Temporal.Duration if not available. + // Here, we fallback to a string "-P7D" for "7 days ago" in ISO 8601 duration format. + const defaultDynamicFromNow = '-P7D'; + + const [localDateRangeFilter, setLocalDateRangeFilter] = useState( + filter.selectedValue ?? { type: 'dynamic', fromNow: defaultDynamicFromNow }, + ); + + const [isOpen, setIsOpen] = useState(false); + + return ( + { + setIsOpen(open); + if (!open) { + emitSet(drFilter.name, localDateRangeFilter ?? null); + } + }} + > + + {summary} + {drFilter.removable ? ( + { + emitSet(drFilter.name, null); + }} + /> + ) : null} + + + setLocalDateRangeFilter(value as any)} + locale={dateFnsLocale} + > +
+ + +
+ +
+
+
+ ); +} diff --git a/packages/ui-design-system/src/FiltersBar/internals/FilterPopover.tsx b/packages/ui-design-system/src/FiltersBar/internals/FilterPopover.tsx new file mode 100644 index 0000000000..d2f503cb3e --- /dev/null +++ b/packages/ui-design-system/src/FiltersBar/internals/FilterPopover.tsx @@ -0,0 +1,85 @@ +import * as Popover from '@radix-ui/react-popover'; +import clsx from 'clsx'; +import { type ComponentPropsWithoutRef, forwardRef } from 'react'; +import { Icon } from 'ui-icons'; + +const FilterPopoverContent = forwardRef< + HTMLDivElement, + React.ComponentProps +>(function FilterPopoverContent({ className, children, ...props }, ref) { + return ( + + + {children} + + + ); +}); + +export const FilterPopover = { + Root: Popover.Root, + Trigger: Popover.Trigger, + Anchor: Popover.Anchor, + Content: FilterPopoverContent, +}; + +const FilterItemRoot = forwardRef(function FilterItem( + { className, ...props }, + ref, +) { + return ( + + ); +}); + +const FilterItemTrigger = forwardRef( + function FilterItem({ className, ...props }, ref) { + return ( + + ); + }, +); + +const FilterItemClear = forwardRef>( + function FilterItem({ className, ...props }, ref) { + return ( + + ); + }, +); + +export const FilterItem = { + Root: FilterItemRoot, + Trigger: FilterItemTrigger, + Clear: FilterItemClear, +}; diff --git a/packages/ui-design-system/src/FiltersBar/internals/FiltersBarContext.ts b/packages/ui-design-system/src/FiltersBar/internals/FiltersBarContext.ts new file mode 100644 index 0000000000..40c8ee1d09 --- /dev/null +++ b/packages/ui-design-system/src/FiltersBar/internals/FiltersBarContext.ts @@ -0,0 +1,15 @@ +import { createSimpleContext } from '@marble/shared'; + +export interface FiltersBarContextValue { + emitSet: (name: string, value: unknown) => void; + emitRemove: (name: string) => void; + emitToggleActive: (name: string, isActive: boolean) => void; + getValue: (name: string) => unknown; + isActive: (name: string) => boolean; +} + +export const FiltersBarContext = createSimpleContext('FiltersBar'); + +export function useFiltersBarContext(): FiltersBarContextValue { + return FiltersBarContext.useValue(); +} diff --git a/packages/ui-design-system/src/FiltersBar/internals/NumberValueFilter.tsx b/packages/ui-design-system/src/FiltersBar/internals/NumberValueFilter.tsx new file mode 100644 index 0000000000..18a175c16f --- /dev/null +++ b/packages/ui-design-system/src/FiltersBar/internals/NumberValueFilter.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from 'react'; +import { Input } from '../../Input/Input'; +import { MenuCommand } from '../../MenuCommand/MenuCommand'; +import { cn } from '../../utils'; +import { NUMBER_OPERATORS } from '../FiltersBar'; +import { ComparisonFilter, type NumberFilter, NumberOperator } from '../types'; +import { FilterItem, FilterPopover } from './FilterPopover'; +import { useFiltersBarContext } from './FiltersBarContext'; + +export function NumberValueFilter({ filter }: { filter: NumberFilter }) { + const [isOpen, setOpen] = useState(true); + + const [opSelectIsOpen, setOpSelectIsOpen] = useState(false); + const [localValue, setLocalValue] = useState>( + filter.selectedValue ?? { operator: 'eq', value: 0 }, + ); + const { emitSet, emitRemove } = useFiltersBarContext(); + useEffect(() => { + if (isOpen) setLocalValue(filter.selectedValue ?? { operator: 'eq', value: 0 }); + }, [isOpen]); + + const onOperatorChange = (operator: string) => { + // Check if operator is a valid NumberOperator value + if (!NUMBER_OPERATORS.has(operator as NumberOperator)) + throw new Error(`Invalid operator: ${operator}`); + + setLocalValue({ operator: operator as NumberOperator, value: localValue.value }); + setOpSelectIsOpen(false); + }; + if (filter.removable) { + return ( + + + + {filter.name} {filter.selectedValue?.operator}{' '} + {filter.selectedValue?.value} + + {filter.removable ? ( + { + emitRemove(filter.name); + // Keep popover state consistent + setOpen(false); + }} + /> + ) : null} + + +
+
+ + + + {(() => { + switch (localValue.operator) { + case 'eq': + return '='; + case 'ne': + return '≠'; + case 'gt': + return '>'; + case 'gte': + return '≥'; + case 'lt': + return '<'; + case 'lte': + return '≤'; + } + })()} + + + + + onOperatorChange(v)}> + = + + onOperatorChange('ne')}> + ≠ + + onOperatorChange('gt')}> + {'>'} + + onOperatorChange('gte')}> + ≥ + + onOperatorChange('lt')}> + {'<'} + + onOperatorChange('lte')}> + ≤ + + + + + + setLocalValue({ + operator: localValue.operator, + value: Number(e.currentTarget.value), + }) + } + /> +
+
+ +
+
+
+
+ ); + } + return ; +} diff --git a/packages/ui-design-system/src/FiltersBar/internals/SelectOptionFilter.tsx b/packages/ui-design-system/src/FiltersBar/internals/SelectOptionFilter.tsx new file mode 100644 index 0000000000..80ef773d5a --- /dev/null +++ b/packages/ui-design-system/src/FiltersBar/internals/SelectOptionFilter.tsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; +import { Button } from '../../Button/Button'; +import { useI18n } from '../../contexts/I18nContext'; +import { MenuCommand } from '../../MenuCommand/MenuCommand'; +import { type SelectFilter } from '../types'; +import { useFiltersBarContext } from './FiltersBarContext'; + +export function SelectOptionFilter(props: SelectFilter) { + const { t } = useI18n(); + const { options, placeholder, selectedValue, name } = props; + const { emitSet } = useFiltersBarContext(); + const [internalSelectedValue, setInternalSelectedValue] = useState( + (selectedValue as string) || '', + ); + + useEffect(() => { + setInternalSelectedValue((selectedValue as string) || ''); + }, [selectedValue]); + + const hasOptions = options?.length ?? false; + + const handleSelect = (value: string) => { + setInternalSelectedValue(value); + emitSet(name, value); + }; + + const getOptionLabel = (option: string | { label: string; value: string }) => { + return typeof option === 'string' ? option : option.label; + }; + + const getOptionValue = (option: string | { label: string; value: string }) => { + return typeof option === 'string' ? option : option.value; + }; + + const getSelectedLabel = () => { + if (!internalSelectedValue) return placeholder || 'Select'; + const selectedOption = options?.find( + (option: string | { label: string; value: string }) => + getOptionValue(option) === internalSelectedValue, + ); + return selectedOption ? getOptionLabel(selectedOption) : internalSelectedValue; + }; + + return ( +
+ + + + + + + {hasOptions ? ( + options?.map((option: string | { label: string; value: string }) => { + const value = getOptionValue(option); + const label = getOptionLabel(option); + return ( + handleSelect(value)}> + {label} + + ); + }) + ) : ( + {t('filters:ds.noOptionsAvailable.label')} + )} + + + +
+ ); +} diff --git a/packages/ui-design-system/src/FiltersBar/internals/TextMatchFilter.tsx b/packages/ui-design-system/src/FiltersBar/internals/TextMatchFilter.tsx new file mode 100644 index 0000000000..ce7bebdf42 --- /dev/null +++ b/packages/ui-design-system/src/FiltersBar/internals/TextMatchFilter.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react'; +import { Input } from '../../Input/Input'; +import { cn } from '../../utils'; +import { type TextFilter } from '../types'; +import { FilterItem, FilterPopover } from './FilterPopover'; +import { useFiltersBarContext } from './FiltersBarContext'; + +export function TextMatchFilter({ filter }: { filter: TextFilter }) { + const [isOpen, setOpen] = useState(true); + const [localText, setLocalText] = useState( + filter.selectedValue?.map((f) => f.value) ?? [], + ); + const { emitSet, emitRemove } = useFiltersBarContext(); + useEffect(() => { + if (isOpen) setLocalText(filter.selectedValue?.map((f) => f.value) ?? []); + }, [isOpen]); + if (filter.removable) { + return ( + + + + {filter.name} + + in {filter.selectedValue?.map((f) => f.value).join(',')} + + + {filter.removable ? ( + { + emitRemove(filter.name); + setOpen(false); + }} + /> + ) : null} + + +
+ setLocalText(e.currentTarget.value.split(','))} + /> +
+ +
+
+
+
+ ); + } + return ; +} diff --git a/packages/ui-design-system/src/FiltersBar/types.ts b/packages/ui-design-system/src/FiltersBar/types.ts new file mode 100644 index 0000000000..332048bf2b --- /dev/null +++ b/packages/ui-design-system/src/FiltersBar/types.ts @@ -0,0 +1,141 @@ +export type FilterBarLevel = 'main' | 'additional'; + +export type CommittedDynamicValues = Record; + +export interface BaseFilter { + name: string; + placeholder: string; + selectedValue: T | null; + onChange?: (value: T | null) => void; + removable?: boolean; + isOpen?: boolean; + isActive: boolean; + onOpenChange?: (open: boolean) => void; +} + +export type NumberOperator = 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte'; + +export type TextOperator = 'in'; + +export interface ComparisonFilter { + operator: NumberOperator | TextOperator; + value: T; +} + +export interface StaticDateRangeFilterType { + type: 'static'; + startDate: string; + endDate: string; +} + +export interface DynamicDateRangeFilterType { + type: 'dynamic'; + fromNow: string; +} + +export type DateRangeFilterType = + | StaticDateRangeFilterType + | DynamicDateRangeFilterType + | null + | undefined; + +export interface NumberFilter extends BaseFilter> { + type: 'number'; + operator: NumberOperator; +} +export interface TextFilter extends BaseFilter[]> { + type: 'text'; + operator: TextOperator; +} +export interface BooleanFilter extends BaseFilter { + type: 'boolean'; +} +export interface CheckboxFilter extends BaseFilter { + type: 'checkbox'; +} +export interface SelectFilter extends BaseFilter { + type: 'select'; + options: string[] | { label: string; value: string }[]; +} +export interface MultiSelectFilter extends BaseFilter { + type: 'multi-select'; + options: string[] | { label: string; value: string }[]; +} +export interface DateRangePopoverFilter extends BaseFilter { + type: 'date-range-popover'; + onClear?: () => void; +} +export interface RadioFilter extends BaseFilter { + type: 'radio'; + options: string[] | { label: string; value: string }[]; +} +export type Filter = + | TextFilter + | NumberFilter + | BooleanFilter + | CheckboxFilter + | SelectFilter + | MultiSelectFilter + | DateRangePopoverFilter + | RadioFilter; + +// New controlled API types +export interface BaseFilterDescriptor { + name: string; + placeholder: string; + removable?: boolean; +} +export interface NumberFilterDescriptor extends BaseFilterDescriptor { + type: 'number'; + operator: NumberOperator; +} +export interface TextFilterDescriptor extends BaseFilterDescriptor { + type: 'text'; + operator: TextOperator; +} +export interface BooleanFilterDescriptor extends BaseFilterDescriptor { + type: 'boolean'; +} +export interface CheckboxFilterDescriptor extends BaseFilterDescriptor { + type: 'checkbox'; +} +export interface SelectFilterDescriptor extends BaseFilterDescriptor { + type: 'select'; + options: string[] | { label: string; value: string }[]; +} +export interface MultiSelectFilterDescriptor extends BaseFilterDescriptor { + type: 'multi-select'; + options: string[] | { label: string; value: string }[]; +} +export interface DateRangePopoverFilterDescriptor extends BaseFilterDescriptor { + type: 'date-range-popover'; +} +export interface RadioFilterDescriptor extends BaseFilterDescriptor { + type: 'radio'; + options: string[] | { label: string; value: string }[]; +} +export type FilterDescriptor = + | TextFilterDescriptor + | NumberFilterDescriptor + | BooleanFilterDescriptor + | CheckboxFilterDescriptor + | SelectFilterDescriptor + | MultiSelectFilterDescriptor + | DateRangePopoverFilterDescriptor + | RadioFilterDescriptor; + +export type FilterChange = + | { type: 'set'; name: string; value: unknown } + | { type: 'remove'; name: string } + | { type: 'toggleActive'; name: string; isActive: boolean }; + +export interface FiltersBarProps { + descriptors: FilterDescriptor[]; + dynamicDescriptors?: FilterDescriptor[]; + value: Record; + active?: string[]; + onChange: ( + change: FilterChange, + next: { value: Record; active: string[] }, + ) => void; +} diff --git a/packages/ui-design-system/src/contexts/FormattingContext.tsx b/packages/ui-design-system/src/contexts/FormattingContext.tsx new file mode 100644 index 0000000000..b5c4423d3e --- /dev/null +++ b/packages/ui-design-system/src/contexts/FormattingContext.tsx @@ -0,0 +1,82 @@ +import { createContext, useContext } from 'react'; + +export type SupportedLanguage = string; + +export type FormatDateTimeOptions = Intl.DateTimeFormatOptions & { + language?: SupportedLanguage; +}; + +export interface FormattingContextValue { + language: SupportedLanguage; + formatDateTimeWithoutPresets: (date: Date | string, options?: FormatDateTimeOptions) => string; + formatDuration: (duration: string, language?: SupportedLanguage) => string; +} + +const defaultLanguage: SupportedLanguage = 'en'; + +function defaultFormatDateTimeWithoutPresets( + date: Date | string, + options?: FormatDateTimeOptions, +): string { + const d = typeof date === 'string' ? new Date(date) : date; + const lang = options?.language ?? defaultLanguage; + try { + // Provide a sane default with short date unless options override + const fmt = new Intl.DateTimeFormat(lang, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + ...(options ?? {}), + }); + return fmt.format(d); + } catch { + return d.toString(); + } +} + +function defaultFormatDuration(duration: string, language?: SupportedLanguage): string { + // Very lightweight humanization for negative Temporal durations like "-P7D", "-P6M" + // Fallback to the raw string if we cannot parse. + const lang = language ?? defaultLanguage; + try { + // Minimal parse: expect patterns like PnD, PnM, PnY with optional leading minus + const negative = duration.startsWith('-'); + const raw = negative ? duration.slice(1) : duration; + const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?$/i.exec(raw); + if (!match) return duration; + const years = Number(match[1] ?? 0); + const months = Number(match[2] ?? 0); + const days = Number(match[3] ?? 0); + const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'always', style: 'long' }); + if (years) return rtf.format(-years, 'year'); + if (months) return rtf.format(-months, 'month'); + if (days) return rtf.format(-days, 'day'); + return duration; + } catch { + return duration; + } +} + +const FormattingContext = createContext({ + language: defaultLanguage, + formatDateTimeWithoutPresets: defaultFormatDateTimeWithoutPresets, + formatDuration: defaultFormatDuration, +}); + +export function FormattingProvider({ + value, + children, +}: { + value: FormattingContextValue; + children: React.ReactNode; +}) { + return {children}; +} + +export function useFormatting(): FormattingContextValue { + return useContext(FormattingContext); +} + +export function useFormatLanguage(): SupportedLanguage { + return useContext(FormattingContext).language; +} diff --git a/packages/ui-design-system/src/contexts/I18nContext.tsx b/packages/ui-design-system/src/contexts/I18nContext.tsx new file mode 100644 index 0000000000..6733de0373 --- /dev/null +++ b/packages/ui-design-system/src/contexts/I18nContext.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from 'react'; + +export interface I18nContextValue { + locale: string; + t: (key: string, options?: Record) => string; +} + +const defaultValue: I18nContextValue = { + locale: 'en', + t: (key: string) => key, +}; + +const I18nContext = createContext(defaultValue); + +export function I18nProvider({ + value, + children, +}: { + value: I18nContextValue; + children: React.ReactNode; +}) { + return {children}; +} + +export function useI18n(): I18nContextValue { + return useContext(I18nContext); +} diff --git a/packages/ui-design-system/src/index.ts b/packages/ui-design-system/src/index.ts index d9a14ca6b1..220b0936de 100644 --- a/packages/ui-design-system/src/index.ts +++ b/packages/ui-design-system/src/index.ts @@ -6,6 +6,9 @@ export * from './Code/Code'; export * from './Collapsible/Collapsible'; export * from './Combobox/Combobox'; export * from './Command/Command'; +export * from './contexts/FormattingContext'; +export * from './contexts/I18nContext'; +export * from './FiltersBar/FiltersBar'; export * from './HiddenInputs/HiddenInputs'; export * from './Input/Input'; export * from './Kbd/Kbd';