Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 35 additions & 10 deletions apps/shade/src/components/ui/filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export interface FilterI18nConfig {
// Default English i18n configuration
export const DEFAULT_I18N: FilterI18nConfig = {
// UI Labels
addFilter: 'Add filter',
addFilter: '',
searchFields: 'Search fields...',
noFieldsFound: 'No fields found.',
noResultsFound: 'No results found.',
Expand All @@ -119,7 +119,7 @@ export const DEFAULT_I18N: FilterI18nConfig = {
percent: '%',
defaultCurrency: '$',
defaultColor: '#000000',
addFilterTitle: 'Add filter',
addFilterTitle: '',

// Operators
operators: {
Expand Down Expand Up @@ -236,7 +236,7 @@ const filterInputVariants = cva(
size: {
lg: 'h-10 px-2.5 text-sm has-[[data-slot=filters-prefix]]:ps-0 has-[[data-slot=filters-suffix]]:pe-0',
md: 'h-[34px] px-2 text-sm has-[[data-slot=filters-prefix]]:ps-0 has-[[data-slot=filters-suffix]]:pe-0',
sm: 'h-8 px-1.5 text-xs has-[[data-slot=filters-prefix]]:ps-0 has-[[data-slot=filters-suffix]]:pe-0'
sm: 'h-8 px-2 text-xs has-[[data-slot=filters-prefix]]:ps-0 has-[[data-slot=filters-suffix]]:pe-0'
},
cursorPointer: {
true: 'cursor-pointer',
Expand Down Expand Up @@ -747,6 +747,8 @@ export interface FilterFieldConfig<T = unknown> {
// Controlled values support for this field
value?: T[];
onValueChange?: (values: T[]) => void;
// Auto-close dropdown after selection (even for multiselect types)
autoCloseOnSelect?: boolean;
}

// Helper functions to handle both flat and grouped field configurations
Expand Down Expand Up @@ -941,7 +943,7 @@ function FilterOperatorDropdown<T = unknown>({field, operator, values, onChange}
// If hideOperatorSelect is true, just render the operator as plain text
if (field.hideOperatorSelect) {
return (
<div className="flex items-center border-x px-3 text-sm text-muted-foreground">
<div className="flex items-center self-stretch border border-r-[0px] px-3 text-sm text-muted-foreground">
{operatorLabel}
</div>
);
Expand Down Expand Up @@ -1046,6 +1048,7 @@ function SelectOptionsPopover<T = unknown>({

const handleClose = () => {
setOpen(false);
setSearchInput('');
onClose?.();
};

Expand Down Expand Up @@ -1119,7 +1122,7 @@ function SelectOptionsPopover<T = unknown>({
value={option.label}
onSelect={() => {
if (isMultiSelect) {
const newValues = [...effectiveValues, option.value] as T[];
var newValues = [...effectiveValues, option.value] as T[];
if (field.maxSelections && newValues.length > field.maxSelections) {
return; // Don't exceed max selections
}
Expand All @@ -1128,6 +1131,10 @@ function SelectOptionsPopover<T = unknown>({
} else {
onChange(newValues);
}
// Auto-close if configured
if (field.autoCloseOnSelect) {
onClose?.();
}
// For multiselect, don't close the popover to allow multiple selections
} else {
if (field.onValueChange) {
Expand Down Expand Up @@ -1191,7 +1198,13 @@ function SelectOptionsPopover<T = unknown>({
)}
</div>
</PopoverTrigger>
<PopoverContent align="start" className={cn('w-[200px] p-0', field.className)}>
<PopoverContent
align="start"
className={cn(
'p-0 data-[state=closed]:!animation-none data-[state=closed]:!duration-0',
field.className || 'w-[200px]'
)}
>
<Command shouldFilter={!isAsyncSearch}>
{field.searchable !== false && (
<CommandInput
Expand Down Expand Up @@ -1255,10 +1268,13 @@ function SelectOptionsPopover<T = unknown>({
return; // Don't exceed max selections
}
onChange(newValues);
// Auto-close if configured
if (field.autoCloseOnSelect) {
handleClose();
}
} else {
onChange([option.value] as T[]);
setOpen(false);
handleClose();
}
}}
>
Expand Down Expand Up @@ -1625,7 +1641,7 @@ function FilterValueSelector<T = unknown>({field, values, onChange, operator}: F
)}
</div>
</PopoverTrigger>
<PopoverContent className={cn('w-36 p-0', field.popoverContentClassName)}>
<PopoverContent className={cn('w-36 p-0 data-[state=closed]:!animation-none data-[state=closed]:!duration-0', field.popoverContentClassName)}>
<Command shouldFilter={!isAsyncSearch}>
{field.searchable !== false && (
<CommandInput
Expand Down Expand Up @@ -2059,7 +2075,13 @@ export function Filters<T = unknown>({
</button>
)}
</PopoverTrigger>
<PopoverContent align={popoverAlign} className={cn('w-[200px] p-0', popoverContentClassName)}>
<PopoverContent
align={popoverAlign}
className={cn(
'p-0 data-[state=closed]:!animation-none data-[state=closed]:!duration-0',
selectedFieldForOptions?.className || popoverContentClassName || 'w-[200px]'
)}
>
{selectedFieldForOptions ? (
// Show original select/multiselect rendering without back button
// SelectOptionsPopover renders its own Command component when inline={true}
Expand All @@ -2073,7 +2095,10 @@ export function Filters<T = unknown>({
const shouldClosePopover = selectedFieldForOptions.type === 'select';
addFilterWithOption(selectedFieldForOptions, values as unknown[], shouldClosePopover);
}}
onClose={() => setAddFilterOpen(false)}
onClose={() => {
setAddFilterOpen(false);
setSelectedFieldKeyForOptions(null);
}}
Comment on lines +2098 to +2101
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clear tempSelectedValues when closing via onClose.
When autoCloseOnSelect closes the add-filter popover, tempSelectedValues isn’t cleared, so the next field can inherit stale values and even flip a single-select into multiselect behavior.

🐛 Suggested fix
 onClose={() => {
     setAddFilterOpen(false);
     setSelectedFieldKeyForOptions(null);
+    setTempSelectedValues([]);
 }}
🤖 Prompt for AI Agents
In `@apps/shade/src/components/ui/filters.tsx` around lines 2098 - 2101, The
onClose handler for the add-filter popover currently calls
setAddFilterOpen(false) and setSelectedFieldKeyForOptions(null) but does not
clear tempSelectedValues, which allows stale selections to persist and leak into
subsequent fields (affecting autoCloseOnSelect and single/multi-select
behavior); update the onClose callback to also reset tempSelectedValues (e.g.,
call setTempSelectedValues([]) or the appropriate empty value) so that when
onClose is invoked the temporary selection state is cleared along with
setAddFilterOpen and setSelectedFieldKeyForOptions.

/>
) : (
// Show field selection - needs Command wrapper for search/list
Expand Down
42 changes: 27 additions & 15 deletions apps/stats/src/views/Stats/components/stats-filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const useUtmOptionsForField = (fieldKey: string, currentFilters: Filter[] = [])
value: value,
// Add a custom icon element that shows the count badge
icon: (
<span className="flex items-center justify-center rounded-full bg-grey-200 px-2 py-0.5 text-xs font-medium text-grey-900 dark:bg-grey-800 dark:text-grey-100">
<span className="order-2 font-mono text-xs text-muted-foreground">
{visits.toLocaleString()}
</span>
)
Expand Down Expand Up @@ -156,7 +156,7 @@ const useSourceOptions = (currentFilters: Filter[] = []) => {
value,
// Add a custom icon element that shows the count badge
icon: (
<span className="flex items-center justify-center rounded-full bg-grey-200 px-2 py-0.5 text-xs font-medium text-grey-900 dark:bg-grey-800 dark:text-grey-100">
<span className="order-2 font-mono text-xs text-muted-foreground">
{visits.toLocaleString()}
</span>
)
Expand Down Expand Up @@ -185,7 +185,7 @@ const usePostOptions = () => {
// When searching, filter by title containing the search query
// When not searching, fetch latest 20 published posts
const hasSearchQuery = debouncedSearchQuery.trim().length > 0;

const filter = hasSearchQuery
? `title:~'${debouncedSearchQuery.replace(/'/g, '\\\'')}'+status:[published,sent]`
: 'status:[published,sent]';
Expand All @@ -211,7 +211,7 @@ const usePostOptions = () => {
value: post.uuid
}));
}, [browseData]);

// Memoize the callback to avoid recreating the function on each render
const setSearchQuery = useCallback((query: string) => {
setSearchQueryInternal(query);
Expand Down Expand Up @@ -383,7 +383,8 @@ function StatsFilter({filters, utmTrackingEnabled = false, onChange, ...props}:
defaultOperator: 'is',
hideOperatorSelect: true,
options: utmSourceOptions,
searchable: true
searchable: true,
selectedOptionsClassName: 'hidden'
},
{
key: 'utm_medium',
Expand All @@ -395,7 +396,8 @@ function StatsFilter({filters, utmTrackingEnabled = false, onChange, ...props}:
defaultOperator: 'is',
hideOperatorSelect: true,
options: utmMediumOptions,
searchable: true
searchable: true,
selectedOptionsClassName: 'hidden'
},
{
key: 'utm_campaign',
Expand All @@ -407,7 +409,8 @@ function StatsFilter({filters, utmTrackingEnabled = false, onChange, ...props}:
defaultOperator: 'is',
hideOperatorSelect: true,
options: utmCampaignOptions,
searchable: true
searchable: true,
selectedOptionsClassName: 'hidden'
},
{
key: 'utm_content',
Expand All @@ -419,7 +422,8 @@ function StatsFilter({filters, utmTrackingEnabled = false, onChange, ...props}:
defaultOperator: 'is',
hideOperatorSelect: true,
options: utmContentOptions,
searchable: true
searchable: true,
selectedOptionsClassName: 'hidden'
},
{
key: 'utm_term',
Expand All @@ -431,7 +435,8 @@ function StatsFilter({filters, utmTrackingEnabled = false, onChange, ...props}:
defaultOperator: 'is',
hideOperatorSelect: true,
options: utmTermOptions,
searchable: true
searchable: true,
selectedOptionsClassName: 'hidden'
}
] : [];

Expand All @@ -444,20 +449,25 @@ function StatsFilter({filters, utmTrackingEnabled = false, onChange, ...props}:
label: 'Audience',
type: 'multiselect',
icon: <LucideIcon.Users />,
options: audienceOptions.map(({value, label, icon}) => ({value, label, icon}))
options: audienceOptions.map(({value, label, icon}) => ({value, label, icon})),
defaultOperator: 'is any of',
hideOperatorSelect: true,
autoCloseOnSelect: true
},
{
key: 'post',
label: 'Post or page',
type: 'select',
icon: <LucideIcon.File />,
icon: <LucideIcon.PenLine />,
options: postOptions,
searchable: true,
asyncSearch: true,
isLoading: postLoading,
onSearchChange: setSearchQuery,
operators: supportedOperators,
defaultOperator: 'is',
className: 'w-80',
popoverContentClassName: 'w-80',
hideOperatorSelect: true
},
{
Expand All @@ -470,7 +480,8 @@ function StatsFilter({filters, utmTrackingEnabled = false, onChange, ...props}:
defaultOperator: 'is',
hideOperatorSelect: true,
options: sourceOptions,
searchable: true
searchable: true,
selectedOptionsClassName: 'hidden'
}
]
},
Expand All @@ -483,12 +494,13 @@ function StatsFilter({filters, utmTrackingEnabled = false, onChange, ...props}:

return (
<Filters
addButtonIcon={filters.length ? <LucideIcon.Plus /> : <LucideIcon.ListFilter />}
addButtonText={filters.length ? 'Add filter' : 'Filter'}
className='mb-6 mt-0.5 [&>button]:order-last'
addButtonIcon={<LucideIcon.FunnelPlus />}
addButtonText={filters.length ? '' : ''}
className='mb-6 [&>button]:order-last'
fields={groupedFields}
filters={filters}
showSearchInput={false}
size='sm'
onChange={handleFilterChange}
Comment on lines +497 to 504
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Provide an accessible label for the icon-only add button.
With addButtonText empty and defaults now blank, the button can end up without an accessible name. Please supply a label (or custom button with aria-label) to keep it accessible.

🔧 Example fix
-            addButtonText={filters.length ? '' : ''}
+            addButtonText="Add filter"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
addButtonIcon={<LucideIcon.FunnelPlus />}
addButtonText={filters.length ? '' : ''}
className='mb-6 [&>button]:order-last'
fields={groupedFields}
filters={filters}
showSearchInput={false}
size='sm'
onChange={handleFilterChange}
addButtonIcon={<LucideIcon.FunnelPlus />}
addButtonText="Add filter"
className='mb-6 [&>button]:order-last'
fields={groupedFields}
filters={filters}
showSearchInput={false}
size='sm'
onChange={handleFilterChange}
🤖 Prompt for AI Agents
In `@apps/stats/src/views/Stats/components/stats-filter.tsx` around lines 497 -
504, The icon-only add button currently has no accessible name because
addButtonText is empty; update the JSX where addButtonIcon/addButtonText are set
to supply an accessible label — either set addButtonText='Add filter' (or a
localized equivalent) or pass an explicit aria-label via a prop like
addButtonProps={{ 'aria-label': 'Add filter' }} on the same component so the add
button has a programmatic name; modify the snippet that sets addButtonIcon and
addButtonText in stats-filter.tsx accordingly.

{...props}
/>
Expand Down
2 changes: 1 addition & 1 deletion apps/stats/src/views/Stats/layout/stats-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const StatsHeader:React.FC<StatsHeaderProps> = ({
)}
</div>
</header>
<Navbar className='sticky top-0 z-40 flex-col items-start gap-y-5 border-none bg-white/70 py-8 backdrop-blur-md lg:flex-row lg:items-center dark:bg-black'>
<Navbar className='sticky top-0 z-40 flex-col items-start gap-y-5 border-none bg-white/70 pb-6 pt-9 backdrop-blur-md lg:flex-row lg:items-center dark:bg-black'>
<PageMenu defaultValue={normalizedPath} responsive>
<PageMenuItem value="/analytics/" onClick={() => {
navigate('/analytics/');
Expand Down