From 04815460391d45c749161272aefc602c0c294357 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 9 Dec 2024 19:11:29 +0200 Subject: [PATCH 01/34] feat: add filters --- .../components/activity/activity-filters.tsx | 163 ++++++++++++++++++ apps/dashboard/src/pages/activity-feed.tsx | 26 ++- 2 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 apps/dashboard/src/components/activity/activity-filters.tsx diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx new file mode 100644 index 00000000000..acc32967129 --- /dev/null +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -0,0 +1,163 @@ +import { useEffect } from 'react'; +import { ChannelTypeEnum } from '@novu/shared'; +import { Input, InputField } from '../primitives/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../primitives/select'; +import { useFetchWorkflows } from '../../hooks/use-fetch-workflows'; +import { useForm, ControllerRenderProps } from 'react-hook-form'; +import { Form, FormControl } from '../primitives/form/form'; +import { FormItem } from '../primitives/form/form'; +import { FormField } from '../primitives/form/form'; +import { Search, Filter } from 'lucide-react'; +import { RiCalendarLine, RiListCheck, RiRouteFill, RiSearchLine } from 'react-icons/ri'; + +interface IActivityFilters { + onFiltersChange: (filters: IActivityFiltersData) => void; +} + +interface IActivityFiltersData { + dateRange: string; + channels: ChannelTypeEnum[]; + templates: string[]; + searchTerm: string; +} + +const DATE_RANGE_OPTIONS = [ + { value: '24h', label: 'Last 24 hours' }, + { value: '7d', label: 'Last 7 days' }, + { value: '30d', label: 'Last 30 days' }, + { value: '90d', label: 'Last 90 days' }, +]; + +const CHANNEL_OPTIONS = [ + { value: ChannelTypeEnum.SMS, label: 'SMS' }, + { value: ChannelTypeEnum.EMAIL, label: 'Email' }, + { value: ChannelTypeEnum.IN_APP, label: 'In-App' }, + { value: ChannelTypeEnum.PUSH, label: 'Push' }, +]; + +const defaultValues: IActivityFiltersData = { + dateRange: '30d', + channels: [], + templates: [], + searchTerm: '', +}; + +export function ActivityFilters({ onFiltersChange }: IActivityFilters) { + const form = useForm({ + defaultValues, + }); + + const { data: workflowTemplates } = useFetchWorkflows({ limit: 100 }); + + useEffect(() => { + const subscription = form.watch((value) => { + onFiltersChange(value as IActivityFiltersData); + }); + + return () => subscription.unsubscribe(); + }, [form.watch, onFiltersChange]); + + return ( +
+ + }) => ( + + + + )} + /> + + }) => ( + + + + )} + /> + + }) => ( + + + + )} + /> + + ( + + + + + + + )} + /> + + + ); +} diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx index ae0c2cb6370..e0539b23838 100644 --- a/apps/dashboard/src/pages/activity-feed.tsx +++ b/apps/dashboard/src/pages/activity-feed.tsx @@ -1,10 +1,18 @@ import { DashboardLayout } from '@/components/dashboard-layout'; import { ActivityTable } from '@/components/activity/activity-table'; +import { ActivityFilters } from '@/components/activity/activity-filters'; import { Badge } from '../components/primitives/badge'; import { useSearchParams } from 'react-router-dom'; -import { IActivity } from '@novu/shared'; +import { IActivity, ChannelTypeEnum } from '@novu/shared'; import { PageMeta } from '../components/page-meta'; +interface IActivityFiltersData { + dateRange?: string; + channels?: ChannelTypeEnum[]; + templates?: string[]; + searchTerm?: string; +} + export function ActivityFeed() { const [searchParams, setSearchParams] = useSearchParams(); const activityItemId = searchParams.get('activityItemId'); @@ -20,6 +28,19 @@ export function ActivityFeed() { }); }; + const handleFiltersChange = (filters: IActivityFiltersData) => { + setSearchParams((prev) => { + Object.entries(filters).forEach(([key, value]) => { + if (value && (typeof value === 'string' || Array.isArray(value))) { + prev.set(key, Array.isArray(value) ? value.join(',') : value); + } else { + prev.delete(key); + } + }); + return prev; + }); + }; + return ( <> @@ -33,7 +54,8 @@ export function ActivityFeed() { } > -
+ +
From f70c0daceb2a2782f9a40c2d868a3968bc26cdf4 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 9 Dec 2024 19:56:28 +0200 Subject: [PATCH 02/34] fix: date search --- .../dtos/activities-request.dto.ts | 15 +++ .../notifications/notification.controller.ts | 2 + .../get-activity-feed.command.ts | 8 ++ .../get-activity-feed.usecase.ts | 9 +- apps/dashboard/src/api/activity.ts | 17 ++- apps/dashboard/src/api/api.client.ts | 22 ++-- .../components/activity/activity-filters.tsx | 55 +++++--- .../components/activity/activity-table.tsx | 14 +- apps/dashboard/src/hooks/use-activities.ts | 3 +- apps/dashboard/src/pages/activity-feed.tsx | 122 ++++++++++++++---- .../notification/notification.repository.ts | 10 ++ 11 files changed, 211 insertions(+), 66 deletions(-) diff --git a/apps/api/src/app/notifications/dtos/activities-request.dto.ts b/apps/api/src/app/notifications/dtos/activities-request.dto.ts index a9c7f1b5b71..095f0c7a2f6 100644 --- a/apps/api/src/app/notifications/dtos/activities-request.dto.ts +++ b/apps/api/src/app/notifications/dtos/activities-request.dto.ts @@ -1,5 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { ChannelTypeEnum } from '@novu/shared'; +import { IsDateString, IsOptional } from 'class-validator'; export class ActivitiesRequestDto { @ApiPropertyOptional({ @@ -43,4 +44,18 @@ export class ActivitiesRequestDto { required: false, }) transactionId?: string; + + @ApiPropertyOptional({ + type: String, + required: false, + }) + @IsOptional() + startDate?: string; + + @ApiPropertyOptional({ + type: String, + required: false, + }) + @IsOptional() + endDate?: string; } diff --git a/apps/api/src/app/notifications/notification.controller.ts b/apps/api/src/app/notifications/notification.controller.ts index 00eaf03cee0..bf7e287ec3f 100644 --- a/apps/api/src/app/notifications/notification.controller.ts +++ b/apps/api/src/app/notifications/notification.controller.ts @@ -77,6 +77,8 @@ export class NotificationsController { search: query.search, subscriberIds: subscribersQuery, transactionId: query.transactionId, + startDate: query.startDate, + endDate: query.endDate, }) ); } diff --git a/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.command.ts b/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.command.ts index 59703d5da27..9a9f9add052 100644 --- a/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.command.ts +++ b/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.command.ts @@ -33,4 +33,12 @@ export class GetActivityFeedCommand extends EnvironmentWithUserCommand { @IsOptional() @IsString() transactionId?: string; + + @IsOptional() + @IsString() + startDate?: string; + + @IsOptional() + @IsString() + endDate?: string; } diff --git a/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.usecase.ts b/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.usecase.ts index da3e99e2b96..22bc291050a 100644 --- a/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.usecase.ts +++ b/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.usecase.ts @@ -57,7 +57,14 @@ export class GetActivityFeed { private async getFeedNotifications(command: GetActivityFeedCommand, subscriberIds: string[], LIMIT: number) { const { data: notifications } = await this.notificationRepository.getFeed( command.environmentId, - { channels: command.channels, templates: command.templates, subscriberIds, transactionId: command.transactionId }, + { + channels: command.channels, + templates: command.templates, + subscriberIds, + transactionId: command.transactionId, + startDate: command.startDate, + endDate: command.endDate, + }, command.page * LIMIT, LIMIT ); diff --git a/apps/dashboard/src/api/activity.ts b/apps/dashboard/src/api/activity.ts index b62ae8a519f..8c833a719c2 100644 --- a/apps/dashboard/src/api/activity.ts +++ b/apps/dashboard/src/api/activity.ts @@ -7,6 +7,9 @@ export interface IActivityFilters { email?: string; subscriberId?: string; transactionId?: string; + search?: string; + startDate?: string; + endDate?: string; } interface ActivityResponse { @@ -18,7 +21,8 @@ interface ActivityResponse { export function getActivityList( environment: IEnvironment, page = 0, - filters?: IActivityFilters + filters?: IActivityFilters, + signal?: AbortSignal ): Promise { const searchParams = new URLSearchParams(); searchParams.append('page', page.toString()); @@ -38,9 +42,20 @@ export function getActivityList( if (filters?.transactionId) { searchParams.append('transactionId', filters.transactionId); } + if (filters?.startDate) { + searchParams.append('startDate', filters.startDate); + } + if (filters?.endDate) { + searchParams.append('endDate', filters.endDate); + } + + if (filters?.search) { + searchParams.append('search', filters.search); + } return get(`/notifications?${searchParams.toString()}`, { environment, + signal, }); } diff --git a/apps/dashboard/src/api/api.client.ts b/apps/dashboard/src/api/api.client.ts index 2c39067ca7e..45b1341e7c8 100644 --- a/apps/dashboard/src/api/api.client.ts +++ b/apps/dashboard/src/api/api.client.ts @@ -22,9 +22,10 @@ const request = async ( method?: HttpMethod; headers?: HeadersInit; version?: 'v1' | 'v2'; + signal?: AbortSignal; } ): Promise => { - const { body, environment, headers, method = 'GET', version = 'v1' } = options || {}; + const { body, environment, headers, method = 'GET', version = 'v1', signal } = options || {}; try { const jwt = await getToken(); const config: RequestInit = { @@ -35,6 +36,7 @@ const request = async ( ...(environment && { 'Novu-Environment-Id': environment._id }), ...headers, }, + signal, }; if (body) { @@ -65,26 +67,26 @@ const request = async ( } }; -type RequestOptions = { body?: unknown; environment?: IEnvironment }; +type RequestOptions = { body?: unknown; environment?: IEnvironment; signal?: AbortSignal }; -export const get = (endpoint: string, { environment }: RequestOptions = {}) => - request(endpoint, { method: 'GET', environment }); +export const get = (endpoint: string, { environment, signal }: RequestOptions = {}) => + request(endpoint, { method: 'GET', environment, signal }); export const post = (endpoint: string, options: RequestOptions) => request(endpoint, { method: 'POST', ...options }); export const put = (endpoint: string, options: RequestOptions) => request(endpoint, { method: 'PUT', ...options }); -export const del = (endpoint: string, { environment }: RequestOptions = {}) => - request(endpoint, { method: 'DELETE', environment }); +export const del = (endpoint: string, { environment, signal }: RequestOptions = {}) => + request(endpoint, { method: 'DELETE', environment, signal }); export const patch = (endpoint: string, options: RequestOptions) => request(endpoint, { method: 'PATCH', ...options }); -export const getV2 = (endpoint: string, { environment }: RequestOptions = {}) => - request(endpoint, { version: 'v2', method: 'GET', environment }); +export const getV2 = (endpoint: string, { environment, signal }: RequestOptions = {}) => + request(endpoint, { version: 'v2', method: 'GET', environment, signal }); export const postV2 = (endpoint: string, options: RequestOptions) => request(endpoint, { version: 'v2', method: 'POST', ...options }); export const putV2 = (endpoint: string, options: RequestOptions) => request(endpoint, { version: 'v2', method: 'PUT', ...options }); -export const delV2 = (endpoint: string, { environment }: RequestOptions = {}) => - request(endpoint, { version: 'v2', method: 'DELETE', environment }); +export const delV2 = (endpoint: string, { environment, signal }: RequestOptions = {}) => + request(endpoint, { version: 'v2', method: 'DELETE', environment, signal }); export const patchV2 = (endpoint: string, options: RequestOptions) => request(endpoint, { version: 'v2', method: 'PATCH', ...options }); diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index acc32967129..04747f3866c 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -7,11 +7,11 @@ import { useForm, ControllerRenderProps } from 'react-hook-form'; import { Form, FormControl } from '../primitives/form/form'; import { FormItem } from '../primitives/form/form'; import { FormField } from '../primitives/form/form'; -import { Search, Filter } from 'lucide-react'; import { RiCalendarLine, RiListCheck, RiRouteFill, RiSearchLine } from 'react-icons/ri'; interface IActivityFilters { onFiltersChange: (filters: IActivityFiltersData) => void; + initialValues: IActivityFiltersData; } interface IActivityFiltersData { @@ -35,27 +35,39 @@ const CHANNEL_OPTIONS = [ { value: ChannelTypeEnum.PUSH, label: 'Push' }, ]; -const defaultValues: IActivityFiltersData = { - dateRange: '30d', - channels: [], - templates: [], - searchTerm: '', -}; - -export function ActivityFilters({ onFiltersChange }: IActivityFilters) { +export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFilters) { const form = useForm({ - defaultValues, + defaultValues: initialValues, }); const { data: workflowTemplates } = useFetchWorkflows({ limit: 100 }); useEffect(() => { const subscription = form.watch((value) => { - onFiltersChange(value as IActivityFiltersData); + if (value) { + onFiltersChange(value as IActivityFiltersData); + } }); return () => subscription.unsubscribe(); - }, [form.watch, onFiltersChange]); + }, [onFiltersChange]); + + // Only reset non-search fields when initialValues change + useEffect(() => { + const { searchTerm: currentSearchTerm, ...currentValues } = form.getValues(); + const { searchTerm: newSearchTerm, ...newValues } = initialValues; + + const hasNonSearchChanges = Object.entries(newValues).some(([key, value]) => { + const current = currentValues[key as keyof typeof currentValues]; + + return JSON.stringify(value) !== JSON.stringify(current); + }); + + if (hasNonSearchChanges) { + form.reset({ ...initialValues, searchTerm: currentSearchTerm }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValues]); return (
@@ -67,7 +79,7 @@ export function ActivityFilters({ onFiltersChange }: IActivityFilters) { field.onChange(value ? value.split(',').filter(Boolean) : [])} + onValueChange={(value) => { + if (value === 'all') { + field.onChange([]); + } else { + field.onChange(value ? value.split(',').filter(Boolean) : []); + } + }} > - + - + + Show All {workflowTemplates?.workflows?.map((template) => ( {template.name} diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index 7d3ce2d42e5..2659e8f999c 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -13,20 +13,22 @@ import { StatusBadge } from './components/status-badge'; import { StepIndicators } from './components/step-indicators'; import { Pagination } from './components/pagination'; import { useRef, useEffect } from 'react'; +import { IActivityFilters } from '@/api/activity'; export interface ActivityTableProps { selectedActivityId: string | null; onActivitySelect: (activity: IActivity) => void; + filters?: IActivityFilters; } -export function ActivityTable({ selectedActivityId, onActivitySelect }: ActivityTableProps) { +export function ActivityTable({ selectedActivityId, onActivitySelect, filters }: ActivityTableProps) { const queryClient = useQueryClient(); const { currentEnvironment } = useEnvironment(); const hoverTimerRef = useRef(null); const [searchParams] = useSearchParams(); const location = useLocation(); const navigate = useNavigate(); - const { activities, isLoading, hasMore } = useActivities(); + const { activities, isLoading, hasMore } = useActivities({ filters }); const offset = parseInt(searchParams.get('offset') || '0'); const limit = parseInt(searchParams.get('limit') || '10'); @@ -160,12 +162,8 @@ function SkeletonRow() { ); } -function getSubscriberDisplay(subscriber?: Pick) { +function getSubscriberDisplay(subscriber?: Pick) { if (!subscriber) return ''; - if (subscriber.firstName || subscriber.lastName) { - return `• ${subscriber.firstName || ''} ${subscriber.lastName || ''}`; - } - - return ''; + return subscriber.subscriberId ? `• ${subscriber.subscriberId}` : ''; } diff --git a/apps/dashboard/src/hooks/use-activities.ts b/apps/dashboard/src/hooks/use-activities.ts index df66acb681a..2d0e870d007 100644 --- a/apps/dashboard/src/hooks/use-activities.ts +++ b/apps/dashboard/src/hooks/use-activities.ts @@ -23,8 +23,9 @@ export function useActivities({ filters }: UseActivitiesOptions = {}) { const { data, isLoading, isFetching } = useQuery({ queryKey: ['activitiesList', currentEnvironment?._id, offset, limit, filters], - queryFn: () => getActivityList(currentEnvironment!, Math.floor(offset / limit), filters), + queryFn: ({ signal }) => getActivityList(currentEnvironment!, Math.floor(offset / limit), filters, signal), staleTime: 0, + enabled: !!currentEnvironment, }); return { diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx index e0539b23838..0d8c9ca24ee 100644 --- a/apps/dashboard/src/pages/activity-feed.tsx +++ b/apps/dashboard/src/pages/activity-feed.tsx @@ -5,41 +5,105 @@ import { Badge } from '../components/primitives/badge'; import { useSearchParams } from 'react-router-dom'; import { IActivity, ChannelTypeEnum } from '@novu/shared'; import { PageMeta } from '../components/page-meta'; +import { useCallback, useMemo } from 'react'; +import { IActivityFilters } from '@/api/activity'; +import { useDebounce } from '@/hooks/use-debounce'; interface IActivityFiltersData { - dateRange?: string; - channels?: ChannelTypeEnum[]; - templates?: string[]; - searchTerm?: string; + dateRange: string; + channels: ChannelTypeEnum[]; + templates: string[]; + searchTerm: string; } +const DEFAULT_DATE_RANGE = '30d'; + export function ActivityFeed() { const [searchParams, setSearchParams] = useSearchParams(); const activityItemId = searchParams.get('activityItemId'); - const handleActivitySelect = (activity: IActivity) => { - setSearchParams((prev) => { - if (activity._id === activityItemId) { - prev.delete('activityItemId'); - } else { - prev.set('activityItemId', activity._id); - } - return prev; - }); - }; - - const handleFiltersChange = (filters: IActivityFiltersData) => { - setSearchParams((prev) => { - Object.entries(filters).forEach(([key, value]) => { - if (value && (typeof value === 'string' || Array.isArray(value))) { - prev.set(key, Array.isArray(value) ? value.join(',') : value); + const handleActivitySelect = useCallback( + (activity: IActivity) => { + setSearchParams((prev) => { + if (activity._id === activityItemId) { + prev.delete('activityItemId'); } else { - prev.delete(key); + prev.set('activityItemId', activity._id); } + return prev; + }); + }, + [activityItemId, setSearchParams] + ); + + const updateSearchParams = useCallback( + (data: IActivityFiltersData) => { + setSearchParams((prev) => { + // Clear existing filter params + ['channels', 'templates', 'searchTerm', 'dateRange'].forEach((key) => prev.delete(key)); + + // Set new filter params + if (data.channels?.length) { + prev.set('channels', data.channels.join(',')); + } + if (data.templates?.length) { + prev.set('templates', data.templates.join(',')); + } + if (data.searchTerm) { + prev.set('searchTerm', data.searchTerm); + } + if (data.dateRange && data.dateRange !== DEFAULT_DATE_RANGE) { + prev.set('dateRange', data.dateRange); + } + + return prev; }); - return prev; - }); - }; + }, + [setSearchParams] + ); + + const handleFiltersChange = useDebounce(updateSearchParams, 500); + + const filters = useMemo(() => { + const result: IActivityFilters = {}; + + const channels = searchParams.get('channels')?.split(',').filter(Boolean); + if (channels?.length) { + result.channels = channels as ChannelTypeEnum[]; + } + + const templates = searchParams.get('templates')?.split(',').filter(Boolean); + if (templates?.length) { + result.templates = templates; + } + + const searchTerm = searchParams.get('searchTerm'); + if (searchTerm) { + result.search = searchTerm; + } + + const dateRange = searchParams.get('dateRange'); + if (dateRange) { + if (dateRange === '24h') { + result.endDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + } else if (dateRange === '7d') { + result.endDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + } else if (dateRange === '30d') { + result.endDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + } + } + + return result; + }, [searchParams]); + + const initialFilterValues = useMemo(() => { + return { + dateRange: searchParams.get('dateRange') || DEFAULT_DATE_RANGE, + channels: (searchParams.get('channels')?.split(',').filter(Boolean) as ChannelTypeEnum[]) || [], + templates: searchParams.get('templates')?.split(',').filter(Boolean) || [], + searchTerm: searchParams.get('searchTerm') || '', + }; + }, [searchParams]); return ( <> @@ -54,9 +118,13 @@ export function ActivityFeed() { } > - -
- + +
+
diff --git a/libs/dal/src/repositories/notification/notification.repository.ts b/libs/dal/src/repositories/notification/notification.repository.ts index 799bd3224fb..03e910d2473 100644 --- a/libs/dal/src/repositories/notification/notification.repository.ts +++ b/libs/dal/src/repositories/notification/notification.repository.ts @@ -31,6 +31,8 @@ export class NotificationRepository extends BaseRepository< templates?: string[] | null; subscriberIds?: string[]; transactionId?: string; + startDate?: string; + endDate?: string; } = {}, skip = 0, limit = 10 @@ -43,6 +45,14 @@ export class NotificationRepository extends BaseRepository< requestQuery.transactionId = query.transactionId; } + if (query.startDate) { + requestQuery.createdAt = { $lte: query.startDate }; + } + + if (query.endDate) { + requestQuery.createdAt = { $gte: query.endDate }; + } + if (query?.templates) { requestQuery._templateId = { $in: query.templates, From eeedb91e9084e2c0143890799ebe3e7a61ee96af Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 9 Dec 2024 19:59:34 +0200 Subject: [PATCH 03/34] default search --- apps/dashboard/src/components/activity/activity-filters.tsx | 1 - apps/dashboard/src/pages/activity-feed.tsx | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index 04747f3866c..31192fdb79b 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -25,7 +25,6 @@ const DATE_RANGE_OPTIONS = [ { value: '24h', label: 'Last 24 hours' }, { value: '7d', label: 'Last 7 days' }, { value: '30d', label: 'Last 30 days' }, - { value: '90d', label: 'Last 90 days' }, ]; const CHANNEL_OPTIONS = [ diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx index 0d8c9ca24ee..76f8af5b346 100644 --- a/apps/dashboard/src/pages/activity-feed.tsx +++ b/apps/dashboard/src/pages/activity-feed.tsx @@ -91,6 +91,8 @@ export function ActivityFeed() { } else if (dateRange === '30d') { result.endDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); } + } else { + result.endDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); } return result; From 274808c3529fee069edb5a7c2fd2c80da69d42dd Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 9 Dec 2024 20:06:52 +0200 Subject: [PATCH 04/34] fix: refactor use search params --- .../src/hooks/use-activity-url-state.ts | 110 ++++++++++++++++++ apps/dashboard/src/pages/activity-feed.tsx | 110 ++---------------- apps/dashboard/src/types/activity.ts | 15 +++ 3 files changed, 135 insertions(+), 100 deletions(-) create mode 100644 apps/dashboard/src/hooks/use-activity-url-state.ts create mode 100644 apps/dashboard/src/types/activity.ts diff --git a/apps/dashboard/src/hooks/use-activity-url-state.ts b/apps/dashboard/src/hooks/use-activity-url-state.ts new file mode 100644 index 00000000000..749186d0d37 --- /dev/null +++ b/apps/dashboard/src/hooks/use-activity-url-state.ts @@ -0,0 +1,110 @@ +import { useSearchParams } from 'react-router-dom'; +import { useCallback, useMemo } from 'react'; +import { IActivity, ChannelTypeEnum } from '@novu/shared'; +import { IActivityFilters } from '@/api/activity'; +import { IActivityFiltersData, IActivityUrlState } from '@/types/activity'; + +const DEFAULT_DATE_RANGE = '30d'; + +function parseFilters(searchParams: URLSearchParams): IActivityFilters { + const result: IActivityFilters = {}; + + const channels = searchParams.get('channels')?.split(',').filter(Boolean); + if (channels?.length) { + result.channels = channels as ChannelTypeEnum[]; + } + + const templates = searchParams.get('templates')?.split(',').filter(Boolean); + if (templates?.length) { + result.templates = templates; + } + + const searchTerm = searchParams.get('searchTerm'); + if (searchTerm) { + result.search = searchTerm; + } + + const dateRange = searchParams.get('dateRange'); + const endDate = new Date(Date.now() - getDateRangeInDays(dateRange || DEFAULT_DATE_RANGE) * 24 * 60 * 60 * 1000); + result.endDate = endDate.toISOString(); + + return result; +} + +function getDateRangeInDays(range: string): number { + switch (range) { + case '24h': + return 1; + case '7d': + return 7; + case '30d': + default: + return 30; + } +} + +function parseFilterValues(searchParams: URLSearchParams): IActivityFiltersData { + return { + dateRange: searchParams.get('dateRange') || DEFAULT_DATE_RANGE, + channels: (searchParams.get('channels')?.split(',').filter(Boolean) as ChannelTypeEnum[]) || [], + templates: searchParams.get('templates')?.split(',').filter(Boolean) || [], + searchTerm: searchParams.get('searchTerm') || '', + }; +} + +export function useActivityUrlState(): IActivityUrlState & { + handleActivitySelect: (activity: IActivity) => void; + handleFiltersChange: (data: IActivityFiltersData) => void; +} { + const [searchParams, setSearchParams] = useSearchParams(); + const activityItemId = searchParams.get('activityItemId'); + + const handleActivitySelect = useCallback( + (activity: IActivity) => { + setSearchParams((prev) => { + if (activity._id === activityItemId) { + prev.delete('activityItemId'); + } else { + prev.set('activityItemId', activity._id); + } + return prev; + }); + }, + [activityItemId, setSearchParams] + ); + + const handleFiltersChange = useCallback( + (data: IActivityFiltersData) => { + setSearchParams((prev) => { + ['channels', 'templates', 'searchTerm', 'dateRange'].forEach((key) => prev.delete(key)); + + if (data.channels?.length) { + prev.set('channels', data.channels.join(',')); + } + if (data.templates?.length) { + prev.set('templates', data.templates.join(',')); + } + if (data.searchTerm) { + prev.set('searchTerm', data.searchTerm); + } + if (data.dateRange && data.dateRange !== DEFAULT_DATE_RANGE) { + prev.set('dateRange', data.dateRange); + } + + return prev; + }); + }, + [setSearchParams] + ); + + const filters = useMemo(() => parseFilters(searchParams), [searchParams]); + const filterValues = useMemo(() => parseFilterValues(searchParams), [searchParams]); + + return { + activityItemId, + filters, + filterValues, + handleActivitySelect, + handleFiltersChange, + }; +} diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx index 76f8af5b346..b480cf4b8a4 100644 --- a/apps/dashboard/src/pages/activity-feed.tsx +++ b/apps/dashboard/src/pages/activity-feed.tsx @@ -2,110 +2,20 @@ import { DashboardLayout } from '@/components/dashboard-layout'; import { ActivityTable } from '@/components/activity/activity-table'; import { ActivityFilters } from '@/components/activity/activity-filters'; import { Badge } from '../components/primitives/badge'; -import { useSearchParams } from 'react-router-dom'; -import { IActivity, ChannelTypeEnum } from '@novu/shared'; import { PageMeta } from '../components/page-meta'; -import { useCallback, useMemo } from 'react'; -import { IActivityFilters } from '@/api/activity'; +import { useActivityUrlState } from '@/hooks/use-activity-url-state'; import { useDebounce } from '@/hooks/use-debounce'; -interface IActivityFiltersData { - dateRange: string; - channels: ChannelTypeEnum[]; - templates: string[]; - searchTerm: string; -} - -const DEFAULT_DATE_RANGE = '30d'; - export function ActivityFeed() { - const [searchParams, setSearchParams] = useSearchParams(); - const activityItemId = searchParams.get('activityItemId'); - - const handleActivitySelect = useCallback( - (activity: IActivity) => { - setSearchParams((prev) => { - if (activity._id === activityItemId) { - prev.delete('activityItemId'); - } else { - prev.set('activityItemId', activity._id); - } - return prev; - }); - }, - [activityItemId, setSearchParams] - ); - - const updateSearchParams = useCallback( - (data: IActivityFiltersData) => { - setSearchParams((prev) => { - // Clear existing filter params - ['channels', 'templates', 'searchTerm', 'dateRange'].forEach((key) => prev.delete(key)); - - // Set new filter params - if (data.channels?.length) { - prev.set('channels', data.channels.join(',')); - } - if (data.templates?.length) { - prev.set('templates', data.templates.join(',')); - } - if (data.searchTerm) { - prev.set('searchTerm', data.searchTerm); - } - if (data.dateRange && data.dateRange !== DEFAULT_DATE_RANGE) { - prev.set('dateRange', data.dateRange); - } - - return prev; - }); - }, - [setSearchParams] - ); - - const handleFiltersChange = useDebounce(updateSearchParams, 500); - - const filters = useMemo(() => { - const result: IActivityFilters = {}; - - const channels = searchParams.get('channels')?.split(',').filter(Boolean); - if (channels?.length) { - result.channels = channels as ChannelTypeEnum[]; - } - - const templates = searchParams.get('templates')?.split(',').filter(Boolean); - if (templates?.length) { - result.templates = templates; - } - - const searchTerm = searchParams.get('searchTerm'); - if (searchTerm) { - result.search = searchTerm; - } - - const dateRange = searchParams.get('dateRange'); - if (dateRange) { - if (dateRange === '24h') { - result.endDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - } else if (dateRange === '7d') { - result.endDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); - } else if (dateRange === '30d') { - result.endDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); - } - } else { - result.endDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); - } - - return result; - }, [searchParams]); + const { + activityItemId, + filters, + filterValues, + handleActivitySelect, + handleFiltersChange: handleFiltersChangeRaw, + } = useActivityUrlState(); - const initialFilterValues = useMemo(() => { - return { - dateRange: searchParams.get('dateRange') || DEFAULT_DATE_RANGE, - channels: (searchParams.get('channels')?.split(',').filter(Boolean) as ChannelTypeEnum[]) || [], - templates: searchParams.get('templates')?.split(',').filter(Boolean) || [], - searchTerm: searchParams.get('searchTerm') || '', - }; - }, [searchParams]); + const handleFiltersChange = useDebounce(handleFiltersChangeRaw, 500); return ( <> @@ -120,7 +30,7 @@ export function ActivityFeed() { } > - +
Date: Mon, 9 Dec 2024 22:25:59 +0200 Subject: [PATCH 05/34] fix: pick --- apps/dashboard/src/components/activity/activity-table.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index 2659e8f999c..6a0781420ee 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -162,8 +162,12 @@ function SkeletonRow() { ); } -function getSubscriberDisplay(subscriber?: Pick) { +function getSubscriberDisplay(subscriber?: Pick) { if (!subscriber) return ''; - return subscriber.subscriberId ? `• ${subscriber.subscriberId}` : ''; + if (subscriber.firstName || subscriber.lastName) { + return `• ${subscriber.firstName || ''} ${subscriber.lastName || ''}`; + } + + return ''; } From 009fc8d6813f74184caab0a1ebdc517200af470e Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 9 Dec 2024 23:16:37 +0200 Subject: [PATCH 06/34] feat: wip --- apps/dashboard/package.json | 1 + .../components/activity/activity-filters.tsx | 121 +++----- .../components/activity/activity-table.tsx | 1 + .../data-table/data-table-column-header.tsx | 61 ++++ .../data-table/data-table-faceted-filter.tsx | 285 ++++++++++++++++++ .../data-table/data-table-row-action.tsx | 64 ++++ .../data-table/data-table-toolbar.tsx | 45 +++ .../data-table/data-table-view-options.tsx | 50 +++ .../primitives/data-table/data-table.tsx | 97 ++++++ .../src/components/primitives/radio-group.tsx | 35 +++ pnpm-lock.yaml | 34 +++ 11 files changed, 717 insertions(+), 77 deletions(-) create mode 100644 apps/dashboard/src/components/primitives/data-table/data-table-column-header.tsx create mode 100644 apps/dashboard/src/components/primitives/data-table/data-table-faceted-filter.tsx create mode 100644 apps/dashboard/src/components/primitives/data-table/data-table-row-action.tsx create mode 100644 apps/dashboard/src/components/primitives/data-table/data-table-toolbar.tsx create mode 100644 apps/dashboard/src/components/primitives/data-table/data-table-view-options.tsx create mode 100644 apps/dashboard/src/components/primitives/data-table/data-table.tsx create mode 100644 apps/dashboard/src/components/primitives/radio-group.tsx diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 1cb8c9863dc..f9c48b2a32b 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -42,6 +42,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.0", diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index 31192fdb79b..24d50547937 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -7,7 +7,8 @@ import { useForm, ControllerRenderProps } from 'react-hook-form'; import { Form, FormControl } from '../primitives/form/form'; import { FormItem } from '../primitives/form/form'; import { FormField } from '../primitives/form/form'; -import { RiCalendarLine, RiListCheck, RiRouteFill, RiSearchLine } from 'react-icons/ri'; +import { RiCalendarLine, RiListCheck, RiSearchLine } from 'react-icons/ri'; +import { DataTableFacetedFilter } from '../primitives/data-table/data-table-faceted-filter'; interface IActivityFilters { onFiltersChange: (filters: IActivityFiltersData) => void; @@ -51,7 +52,6 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil return () => subscription.unsubscribe(); }, [onFiltersChange]); - // Only reset non-search fields when initialValues change useEffect(() => { const { searchTerm: currentSearchTerm, ...currentValues } = form.getValues(); const { searchTerm: newSearchTerm, ...newValues } = initialValues; @@ -65,7 +65,6 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil if (hasNonSearchChanges) { form.reset({ ...initialValues, searchTerm: currentSearchTerm }); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialValues]); return ( @@ -73,24 +72,22 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil }) => ( + name="templates" + render={({ field }) => ( - + ({ + label: workflow.name, + value: workflow._id, + })) || [] + } + selected={field.value} + onSelect={(values) => field.onChange(values)} + /> )} /> @@ -98,67 +95,33 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil }) => ( + render={({ field }) => ( - + field.onChange(values)} + /> )} /> }) => ( + name="dateRange" + render={({ field }) => ( - + field.onChange(values[0])} + /> )} /> @@ -168,10 +131,14 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil name="searchTerm" render={({ field }) => ( - - - - + )} /> diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index 6a0781420ee..42346123154 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -14,6 +14,7 @@ import { StepIndicators } from './components/step-indicators'; import { Pagination } from './components/pagination'; import { useRef, useEffect } from 'react'; import { IActivityFilters } from '@/api/activity'; +import { DataTableFacetedFilter } from '../primitives/data-table/data-table-faceted-filter'; export interface ActivityTableProps { selectedActivityId: string | null; diff --git a/apps/dashboard/src/components/primitives/data-table/data-table-column-header.tsx b/apps/dashboard/src/components/primitives/data-table/data-table-column-header.tsx new file mode 100644 index 00000000000..aac80fa7440 --- /dev/null +++ b/apps/dashboard/src/components/primitives/data-table/data-table-column-header.tsx @@ -0,0 +1,61 @@ +import { Column } from '@tanstack/react-table'; +import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/registry/new-york/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/registry/new-york/ui/dropdown-menu'; + +interface DataTableColumnHeaderProps extends React.HTMLAttributes { + column: Column; + title: string; +} + +export function DataTableColumnHeader({ + column, + title, + className, +}: DataTableColumnHeaderProps) { + if (!column.getCanSort()) { + return
{title}
; + } + + return ( +
+ + + + + + column.toggleSorting(false)}> + + Asc + + column.toggleSorting(true)}> + + Desc + + + column.toggleVisibility(false)}> + + Hide + + + +
+ ); +} diff --git a/apps/dashboard/src/components/primitives/data-table/data-table-faceted-filter.tsx b/apps/dashboard/src/components/primitives/data-table/data-table-faceted-filter.tsx new file mode 100644 index 00000000000..2044b952331 --- /dev/null +++ b/apps/dashboard/src/components/primitives/data-table/data-table-faceted-filter.tsx @@ -0,0 +1,285 @@ +import * as React from 'react'; +import { Check, PlusCircle } from 'lucide-react'; + +import { Button } from '../button'; +import { Badge } from '../badge'; +import { cn } from '../../../utils/ui'; +import { Popover, PopoverContent, PopoverTrigger } from '../popover'; +import { Separator } from '../separator'; +import { Input } from '../input'; +import { RadioGroup, RadioGroupItem } from '../radio-group'; +import { Label } from '../label'; + +type ValueType = 'single' | 'multi' | 'text'; +type SizeType = 'default' | 'small'; + +const sizeVariants = { + default: { + trigger: 'h-8', + input: 'h-8', + content: 'p-2', + item: 'py-1.5 px-2', + badge: 'px-2 py-0.5 text-xs', + separator: 'h-4', + }, + small: { + trigger: 'h-7 px-2 py-1.5', + input: 'h-7 px-2 py-1.5', + content: 'p-1.5', + item: 'py-1 px-1.5', + badge: 'px-1.5 py-0 text-[11px]', + separator: 'h-3.5', + }, +} as const; + +const inputStyles = { + base: 'border-neutral-200 placeholder:text-neutral-400 focus:border-neutral-400 focus:ring-0 focus:ring-offset-0', + text: 'text-neutral-600', +} as const; + +interface DataTableFacetedFilterProps { + title?: string; + type?: ValueType; + size?: SizeType; + options?: { + label: string; + value: string; + icon?: React.ComponentType<{ className?: string }>; + }[]; + selected?: string[]; + onSelect?: (values: string[]) => void; + value?: string; + onChange?: (value: string) => void; + placeholder?: string; +} + +export function DataTableFacetedFilter({ + title, + type = 'multi', + size = 'default', + options = [], + selected = [], + onSelect, + value = '', + onChange, + placeholder, +}: DataTableFacetedFilterProps) { + const [searchQuery, setSearchQuery] = React.useState(''); + + const selectedValues = React.useMemo(() => new Set(selected), [selected]); + const currentValue = React.useMemo(() => value, [value]); + const sizes = sizeVariants[size]; + + const filteredOptions = React.useMemo(() => { + if (!searchQuery) return options; + return options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); + }, [options, searchQuery]); + + const handleSelect = (selectedValue: string) => { + if (type === 'single') { + onSelect?.([selectedValue]); + return; + } + + const newSelectedValues = new Set(selectedValues); + if (newSelectedValues.has(selectedValue)) { + newSelectedValues.delete(selectedValue); + } else { + newSelectedValues.add(selectedValue); + } + onSelect?.(Array.from(newSelectedValues)); + }; + + const handleClear = () => { + if (type === 'text') { + onChange?.(''); + } else { + onSelect?.([]); + } + setSearchQuery(''); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + onChange?.(e.target.value); + }; + + const handleSearchChange = (value: string) => { + setSearchQuery(value); + }; + + const renderBadge = (content: React.ReactNode, key?: string) => ( + + {content} + + ); + + const renderTriggerContent = () => { + if (type === 'text') { + return currentValue ? renderBadge(`${title}: ${currentValue}`) : null; + } + + if (selectedValues.size === 0) return null; + + const selectedCount = selectedValues.size; + const selectedItems = options.filter((option) => selectedValues.has(option.value)); + + return ( + <> + +
{renderBadge(selectedCount)}
+
+ {selectedCount > 2 && type === 'multi' + ? renderBadge(`${selectedCount} selected`) + : selectedItems.map((option) => renderBadge(option.label, option.value))} +
+ + ); + }; + + const renderContent = () => { + if (type === 'text') { + return ( +
+ + {currentValue && ( + <> + + + + )} +
+ ); + } + + if (type === 'single') { + return ( +
+ handleSearchChange(e.target.value)} + className={cn('w-full', sizes.input, inputStyles.base, inputStyles.text)} + /> +
+ handleSelect(value)}> + {filteredOptions.map((option) => ( +
+ + +
+ ))} +
+
+ {selectedValues.size > 0 && ( + <> + + + + )} +
+ ); + } + + return ( +
+ handleSearchChange(e.target.value)} + className={cn('w-full', sizes.input, inputStyles.base, inputStyles.text)} + /> +
+ {filteredOptions.map((option) => { + const isSelected = selectedValues.has(option.value); + return ( +
handleSelect(option.value)} + className={cn( + 'flex cursor-pointer items-center space-x-2 rounded-sm hover:bg-neutral-50', + isSelected && 'bg-neutral-50', + sizes.item + )} + > +
+ +
+ {option.icon && } + {option.label} +
+ ); + })} +
+ {selectedValues.size > 0 && ( + <> + + + + )} +
+ ); + }; + + return ( + + + + + + {renderContent()} + + + ); +} diff --git a/apps/dashboard/src/components/primitives/data-table/data-table-row-action.tsx b/apps/dashboard/src/components/primitives/data-table/data-table-row-action.tsx new file mode 100644 index 00000000000..221a736f9f4 --- /dev/null +++ b/apps/dashboard/src/components/primitives/data-table/data-table-row-action.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { Row } from '@tanstack/react-table'; +import { MoreHorizontal } from 'lucide-react'; + +import { Button } from '@/registry/new-york/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/registry/new-york/ui/dropdown-menu'; + +import { labels } from '../data/data'; +import { taskSchema } from '../data/schema'; + +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const task = taskSchema.parse(row.original); + + return ( + + + + + + Edit + Make a copy + Favorite + + + Labels + + + {labels.map((label) => ( + + {label.label} + + ))} + + + + + + Delete + ⌘⌫ + + + + ); +} diff --git a/apps/dashboard/src/components/primitives/data-table/data-table-toolbar.tsx b/apps/dashboard/src/components/primitives/data-table/data-table-toolbar.tsx new file mode 100644 index 00000000000..d59eb0d3a69 --- /dev/null +++ b/apps/dashboard/src/components/primitives/data-table/data-table-toolbar.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { Table } from '@tanstack/react-table'; +import { X } from 'lucide-react'; + +import { Button } from '@/registry/new-york/ui/button'; +import { Input } from '@/registry/new-york/ui/input'; +import { DataTableViewOptions } from '@/app/(app)/examples/tasks/components/data-table-view-options'; + +import { priorities, statuses } from '../data/data'; +import { DataTableFacetedFilter } from './data-table-faceted-filter'; + +interface DataTableToolbarProps { + table: Table; +} + +export function DataTableToolbar({ table }: DataTableToolbarProps) { + const isFiltered = table.getState().columnFilters.length > 0; + + return ( +
+
+ table.getColumn('title')?.setFilterValue(event.target.value)} + className="h-8 w-[150px] lg:w-[250px]" + /> + {table.getColumn('status') && ( + + )} + {table.getColumn('priority') && ( + + )} + {isFiltered && ( + + )} +
+ +
+ ); +} diff --git a/apps/dashboard/src/components/primitives/data-table/data-table-view-options.tsx b/apps/dashboard/src/components/primitives/data-table/data-table-view-options.tsx new file mode 100644 index 00000000000..ffa401086c3 --- /dev/null +++ b/apps/dashboard/src/components/primitives/data-table/data-table-view-options.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'; +import { Table } from '@tanstack/react-table'; +import { Settings2 } from 'lucide-react'; + +import { Button } from '@/registry/new-york/ui/button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, +} from '@/registry/new-york/ui/dropdown-menu'; + +interface DataTableViewOptionsProps { + table: Table; +} + +export function DataTableViewOptions({ table }: DataTableViewOptionsProps) { + return ( + + + + + + Toggle columns + + {table + .getAllColumns() + .filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide()) + .map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {column.id} + + ); + })} + + + ); +} diff --git a/apps/dashboard/src/components/primitives/data-table/data-table.tsx b/apps/dashboard/src/components/primitives/data-table/data-table.tsx new file mode 100644 index 00000000000..1f94a4a162b --- /dev/null +++ b/apps/dashboard/src/components/primitives/data-table/data-table.tsx @@ -0,0 +1,97 @@ +'use client'; + +import * as React from 'react'; +import { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; + +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/registry/new-york/ui/table'; + +import { DataTablePagination } from './data-table-pagination'; +import { DataTableToolbar } from './data-table-toolbar'; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ columns, data }: DataTableProps) { + const [rowSelection, setRowSelection] = React.useState({}); + const [columnVisibility, setColumnVisibility] = React.useState({}); + const [columnFilters, setColumnFilters] = React.useState([]); + const [sorting, setSorting] = React.useState([]); + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }); + + return ( +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ +
+ ); +} diff --git a/apps/dashboard/src/components/primitives/radio-group.tsx b/apps/dashboard/src/components/primitives/radio-group.tsx new file mode 100644 index 00000000000..105a7357e41 --- /dev/null +++ b/apps/dashboard/src/components/primitives/radio-group.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'; +import { cn } from '@/utils/ui'; +import { DotFilledIcon } from '@radix-ui/react-icons'; + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ; +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8f804f85b3..445966432b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -728,6 +728,9 @@ importers: '@radix-ui/react-progress': specifier: ^1.1.0 version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-radio-group': + specifier: ^1.2.1 + version: 1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-scroll-area': specifier: ^1.2.0 version: 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -12903,6 +12906,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-radio-group@1.2.1': + resolution: {integrity: sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.0.4': resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} peerDependencies: @@ -50353,6 +50369,24 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-radio-group@1.2.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.25.6 From 9143683adef263c414996095583a23bc771c40de Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 9 Dec 2024 23:29:59 +0200 Subject: [PATCH 07/34] fix: add params --- apps/dashboard/src/api/activity.ts | 5 - .../components/activity/activity-filters.tsx | 93 +++++++++++++------ .../data-table/data-table-faceted-filter.tsx | 9 +- .../src/hooks/use-activity-url-state.ts | 23 +++-- apps/dashboard/src/types/activity.ts | 3 +- 5 files changed, 90 insertions(+), 43 deletions(-) diff --git a/apps/dashboard/src/api/activity.ts b/apps/dashboard/src/api/activity.ts index 8c833a719c2..97d01108c08 100644 --- a/apps/dashboard/src/api/activity.ts +++ b/apps/dashboard/src/api/activity.ts @@ -7,7 +7,6 @@ export interface IActivityFilters { email?: string; subscriberId?: string; transactionId?: string; - search?: string; startDate?: string; endDate?: string; } @@ -49,10 +48,6 @@ export function getActivityList( searchParams.append('endDate', filters.endDate); } - if (filters?.search) { - searchParams.append('search', filters.search); - } - return get(`/notifications?${searchParams.toString()}`, { environment, signal, diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index 24d50547937..30674b7c0b2 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { ChannelTypeEnum } from '@novu/shared'; import { Input, InputField } from '../primitives/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../primitives/select'; @@ -9,6 +9,8 @@ import { FormItem } from '../primitives/form/form'; import { FormField } from '../primitives/form/form'; import { RiCalendarLine, RiListCheck, RiSearchLine } from 'react-icons/ri'; import { DataTableFacetedFilter } from '../primitives/data-table/data-table-faceted-filter'; +import { Button } from '../primitives/button'; +import { cn } from '../../utils/ui'; interface IActivityFilters { onFiltersChange: (filters: IActivityFiltersData) => void; @@ -19,7 +21,8 @@ interface IActivityFiltersData { dateRange: string; channels: ChannelTypeEnum[]; templates: string[]; - searchTerm: string; + transactionId: string; + subscriberId: string; } const DATE_RANGE_OPTIONS = [ @@ -36,11 +39,30 @@ const CHANNEL_OPTIONS = [ ]; export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFilters) { + const originalInitialValues = useRef(initialValues); + const form = useForm({ defaultValues: initialValues, }); const { data: workflowTemplates } = useFetchWorkflows({ limit: 100 }); + const formValues = form.watch(); + + const hasChanges = useMemo(() => { + const original = originalInitialValues.current; + return Object.entries(original).some(([key, value]) => { + const current = formValues[key as keyof IActivityFiltersData]; + if (Array.isArray(value) && Array.isArray(current)) { + return value.length !== current.length || value.some((v, i) => v !== current[i]); + } + return value !== current; + }); + }, [formValues]); + + const handleReset = () => { + form.reset(originalInitialValues.current); + onFiltersChange(originalInitialValues.current); + }; useEffect(() => { const subscription = form.watch((value) => { @@ -52,24 +74,25 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil return () => subscription.unsubscribe(); }, [onFiltersChange]); - useEffect(() => { - const { searchTerm: currentSearchTerm, ...currentValues } = form.getValues(); - const { searchTerm: newSearchTerm, ...newValues } = initialValues; - - const hasNonSearchChanges = Object.entries(newValues).some(([key, value]) => { - const current = currentValues[key as keyof typeof currentValues]; - - return JSON.stringify(value) !== JSON.stringify(current); - }); - - if (hasNonSearchChanges) { - form.reset({ ...initialValues, searchTerm: currentSearchTerm }); - } - }, [initialValues]); - return ( + ( + + field.onChange(values[0])} + /> + + )} + /> ( - + field.onChange(values[0])} + title="Transaction ID" + value={field.value} + onChange={field.onChange} + placeholder="Search by Transaction ID" /> )} @@ -128,20 +151,34 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil ( - + )} /> + + {hasChanges && ( + + )} ); diff --git a/apps/dashboard/src/components/primitives/data-table/data-table-faceted-filter.tsx b/apps/dashboard/src/components/primitives/data-table/data-table-faceted-filter.tsx index 2044b952331..0bac7436756 100644 --- a/apps/dashboard/src/components/primitives/data-table/data-table-faceted-filter.tsx +++ b/apps/dashboard/src/components/primitives/data-table/data-table-faceted-filter.tsx @@ -119,7 +119,12 @@ export function DataTableFacetedFilter({ const renderTriggerContent = () => { if (type === 'text') { - return currentValue ? renderBadge(`${title}: ${currentValue}`) : null; + return currentValue ? ( + <> + + {renderBadge(`${currentValue}`)} + + ) : null; } if (selectedValues.size === 0) return null; @@ -272,7 +277,7 @@ export function DataTableFacetedFilter({ sizes.trigger )} > - + {(type === 'text' ? !currentValue : selectedValues.size === 0) && } {title} {renderTriggerContent()} diff --git a/apps/dashboard/src/hooks/use-activity-url-state.ts b/apps/dashboard/src/hooks/use-activity-url-state.ts index 749186d0d37..2dccbb9ab36 100644 --- a/apps/dashboard/src/hooks/use-activity-url-state.ts +++ b/apps/dashboard/src/hooks/use-activity-url-state.ts @@ -19,9 +19,14 @@ function parseFilters(searchParams: URLSearchParams): IActivityFilters { result.templates = templates; } - const searchTerm = searchParams.get('searchTerm'); - if (searchTerm) { - result.search = searchTerm; + const transactionId = searchParams.get('transactionId'); + if (transactionId) { + result.transactionId = transactionId; + } + + const subscriberId = searchParams.get('subscriberId'); + if (subscriberId) { + result.subscriberId = subscriberId; } const dateRange = searchParams.get('dateRange'); @@ -48,7 +53,8 @@ function parseFilterValues(searchParams: URLSearchParams): IActivityFiltersData dateRange: searchParams.get('dateRange') || DEFAULT_DATE_RANGE, channels: (searchParams.get('channels')?.split(',').filter(Boolean) as ChannelTypeEnum[]) || [], templates: searchParams.get('templates')?.split(',').filter(Boolean) || [], - searchTerm: searchParams.get('searchTerm') || '', + transactionId: searchParams.get('transactionId') || '', + subscriberId: searchParams.get('subscriberId') || '', }; } @@ -76,7 +82,7 @@ export function useActivityUrlState(): IActivityUrlState & { const handleFiltersChange = useCallback( (data: IActivityFiltersData) => { setSearchParams((prev) => { - ['channels', 'templates', 'searchTerm', 'dateRange'].forEach((key) => prev.delete(key)); + ['channels', 'templates', 'transactionId', 'subscriberId', 'dateRange'].forEach((key) => prev.delete(key)); if (data.channels?.length) { prev.set('channels', data.channels.join(',')); @@ -84,8 +90,11 @@ export function useActivityUrlState(): IActivityUrlState & { if (data.templates?.length) { prev.set('templates', data.templates.join(',')); } - if (data.searchTerm) { - prev.set('searchTerm', data.searchTerm); + if (data.transactionId) { + prev.set('transactionId', data.transactionId); + } + if (data.subscriberId) { + prev.set('subscriberId', data.subscriberId); } if (data.dateRange && data.dateRange !== DEFAULT_DATE_RANGE) { prev.set('dateRange', data.dateRange); diff --git a/apps/dashboard/src/types/activity.ts b/apps/dashboard/src/types/activity.ts index 6b23617ca30..bb78f17e3cc 100644 --- a/apps/dashboard/src/types/activity.ts +++ b/apps/dashboard/src/types/activity.ts @@ -5,7 +5,8 @@ export interface IActivityFiltersData { dateRange: string; channels: ChannelTypeEnum[]; templates: string[]; - searchTerm: string; + transactionId: string; + subscriberId: string; } export interface IActivityUrlState { From c1a9e3ae556105baf20cbaa1025e6f4823f5d66a Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 9 Dec 2024 23:55:38 +0200 Subject: [PATCH 08/34] feat: add selection --- apps/dashboard/src/api/activity.ts | 12 +- .../components/activity/activity-filters.tsx | 284 ++++++++++++------ .../components/activity/activity-table.tsx | 2 +- .../data-table/data-table-column-header.tsx | 61 ---- .../data-table/data-table-row-action.tsx | 64 ---- .../data-table/data-table-toolbar.tsx | 45 --- .../data-table/data-table-view-options.tsx | 50 --- .../primitives/data-table/data-table.tsx | 97 ------ .../faceted-form-filter.tsx} | 24 +- 9 files changed, 220 insertions(+), 419 deletions(-) delete mode 100644 apps/dashboard/src/components/primitives/data-table/data-table-column-header.tsx delete mode 100644 apps/dashboard/src/components/primitives/data-table/data-table-row-action.tsx delete mode 100644 apps/dashboard/src/components/primitives/data-table/data-table-toolbar.tsx delete mode 100644 apps/dashboard/src/components/primitives/data-table/data-table-view-options.tsx delete mode 100644 apps/dashboard/src/components/primitives/data-table/data-table.tsx rename apps/dashboard/src/components/primitives/{data-table/data-table-faceted-filter.tsx => form/faceted-form-filter.tsx} (94%) diff --git a/apps/dashboard/src/api/activity.ts b/apps/dashboard/src/api/activity.ts index 97d01108c08..7e5b0a65cfe 100644 --- a/apps/dashboard/src/api/activity.ts +++ b/apps/dashboard/src/api/activity.ts @@ -27,20 +27,28 @@ export function getActivityList( searchParams.append('page', page.toString()); if (filters?.channels?.length) { - searchParams.append('channels', filters.channels.join(',')); + filters.channels.forEach((channel) => { + searchParams.append('channels', channel); + }); } if (filters?.templates?.length) { - searchParams.append('templates', filters.templates.join(',')); + filters.templates.forEach((template) => { + searchParams.append('templates', template); + }); } + if (filters?.email) { searchParams.append('emails', filters.email); } + if (filters?.subscriberId) { searchParams.append('subscriberIds', filters.subscriberId); } + if (filters?.transactionId) { searchParams.append('transactionId', filters.transactionId); } + if (filters?.startDate) { searchParams.append('startDate', filters.startDate); } diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index 30674b7c0b2..c55827c0d9b 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -1,16 +1,13 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ChannelTypeEnum } from '@novu/shared'; -import { Input, InputField } from '../primitives/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../primitives/select'; import { useFetchWorkflows } from '../../hooks/use-fetch-workflows'; -import { useForm, ControllerRenderProps } from 'react-hook-form'; -import { Form, FormControl } from '../primitives/form/form'; -import { FormItem } from '../primitives/form/form'; -import { FormField } from '../primitives/form/form'; -import { RiCalendarLine, RiListCheck, RiSearchLine } from 'react-icons/ri'; -import { DataTableFacetedFilter } from '../primitives/data-table/data-table-faceted-filter'; +import { useForm } from 'react-hook-form'; +import { Form, FormItem, FormField } from '../primitives/form/form'; +import { FacetedFormFilter } from '../primitives/form/faceted-form-filter'; import { Button } from '../primitives/button'; import { cn } from '../../utils/ui'; +import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'; +import { PlusCircle } from 'lucide-react'; interface IActivityFilters { onFiltersChange: (filters: IActivityFiltersData) => void; @@ -38,8 +35,31 @@ const CHANNEL_OPTIONS = [ { value: ChannelTypeEnum.PUSH, label: 'Push' }, ]; +interface FilterOption { + value: keyof IActivityFiltersData; + label: string; +} + +const FILTER_OPTIONS: FilterOption[] = [ + { value: 'templates', label: 'Workflows' }, + { value: 'channels', label: 'Channels' }, + { value: 'transactionId', label: 'Transaction ID' }, + { value: 'subscriberId', label: 'Subscriber ID' }, +]; + +const defaultValues: IActivityFiltersData = { + dateRange: '30d', + channels: [], + templates: [], + transactionId: '', + subscriberId: '', +}; + export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFilters) { - const originalInitialValues = useRef(initialValues); + const originalInitialValues = useRef(defaultValues); + const [activeFilters, setActiveFilters] = useState([]); + const [openFilter, setOpenFilter] = useState(null); + const [isFiltersOpen, setIsFiltersOpen] = useState(false); const form = useForm({ defaultValues: initialValues, @@ -50,18 +70,21 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil const hasChanges = useMemo(() => { const original = originalInitialValues.current; - return Object.entries(original).some(([key, value]) => { - const current = formValues[key as keyof IActivityFiltersData]; - if (Array.isArray(value) && Array.isArray(current)) { - return value.length !== current.length || value.some((v, i) => v !== current[i]); + return Object.entries(formValues).some(([key, value]) => { + const defaultValue = original[key as keyof IActivityFiltersData]; + if (Array.isArray(value) && Array.isArray(defaultValue)) { + return value.length > 0; } - return value !== current; + return value !== defaultValue; }); }, [formValues]); const handleReset = () => { form.reset(originalInitialValues.current); onFiltersChange(originalInitialValues.current); + setActiveFilters([]); + setOpenFilter(null); + setIsFiltersOpen(false); }; useEffect(() => { @@ -74,6 +97,123 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil return () => subscription.unsubscribe(); }, [onFiltersChange]); + // Initialize active filters based on non-empty values + useEffect(() => { + const initialActiveFilters = FILTER_OPTIONS.filter((filter) => { + const value = initialValues[filter.value]; + return Array.isArray(value) ? value.length > 0 : value !== ''; + }); + setActiveFilters(initialActiveFilters); + }, []); + + const renderFilter = (filter: FilterOption) => { + switch (filter.value) { + case 'templates': + return ( + ( + + ({ + label: workflow.name, + value: workflow._id, + })) || [] + } + selected={field.value} + onSelect={(values) => field.onChange(values)} + open={openFilter === filter.value} + onOpenChange={(open) => setOpenFilter(open ? filter.value : null)} + /> + + )} + /> + ); + case 'channels': + return ( + ( + + field.onChange(values)} + open={openFilter === filter.value} + onOpenChange={(open) => setOpenFilter(open ? filter.value : null)} + /> + + )} + /> + ); + case 'transactionId': + return ( + ( + + setOpenFilter(open ? filter.value : null)} + /> + + )} + /> + ); + case 'subscriberId': + return ( + ( + + setOpenFilter(open ? filter.value : null)} + /> + + )} + /> + ); + } + }; + + const handleAddFilter = (filter: FilterOption) => { + setActiveFilters((prev) => [...prev, filter]); + setOpenFilter(filter.value); + setIsFiltersOpen(false); + }; + + const availableFilters = FILTER_OPTIONS.filter( + (filter) => !activeFilters.some((active) => active.value === filter.value) + ); + return (
@@ -82,100 +222,54 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil name="dateRange" render={({ field }) => ( - field.onChange(values[0])} - /> - - )} - /> - ( - - ({ - label: workflow.name, - value: workflow._id, - })) || [] - } - selected={field.value} - onSelect={(values) => field.onChange(values)} + open={openFilter === 'dateRange'} + onOpenChange={(open) => setOpenFilter(open ? 'dateRange' : null)} /> )} /> - ( - - field.onChange(values)} - /> - - )} - /> + {activeFilters.map((filter) => renderFilter(filter))} - ( - - - - )} - /> - - ( - - - - )} - /> + + + + + +
+ {availableFilters.map((filter) => ( +
handleAddFilter(filter)} + className={cn( + 'flex items-center px-2 py-1.5 text-sm text-neutral-600', + 'cursor-pointer rounded-sm hover:bg-neutral-50 hover:text-neutral-900', + 'outline-none focus-visible:bg-neutral-50 focus-visible:text-neutral-900' + )} + > + {filter.label} +
+ ))} + {availableFilters.length === 0 && ( +

No filters available

+ )} +
+
+
{hasChanges && ( - )} diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index 42346123154..cee42db1172 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -14,7 +14,7 @@ import { StepIndicators } from './components/step-indicators'; import { Pagination } from './components/pagination'; import { useRef, useEffect } from 'react'; import { IActivityFilters } from '@/api/activity'; -import { DataTableFacetedFilter } from '../primitives/data-table/data-table-faceted-filter'; +import { FacetedFormFilter } from '../primitives/form/faceted-form-filter'; export interface ActivityTableProps { selectedActivityId: string | null; diff --git a/apps/dashboard/src/components/primitives/data-table/data-table-column-header.tsx b/apps/dashboard/src/components/primitives/data-table/data-table-column-header.tsx deleted file mode 100644 index aac80fa7440..00000000000 --- a/apps/dashboard/src/components/primitives/data-table/data-table-column-header.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Column } from '@tanstack/react-table'; -import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from 'lucide-react'; - -import { cn } from '@/lib/utils'; -import { Button } from '@/registry/new-york/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/registry/new-york/ui/dropdown-menu'; - -interface DataTableColumnHeaderProps extends React.HTMLAttributes { - column: Column; - title: string; -} - -export function DataTableColumnHeader({ - column, - title, - className, -}: DataTableColumnHeaderProps) { - if (!column.getCanSort()) { - return
{title}
; - } - - return ( -
- - - - - - column.toggleSorting(false)}> - - Asc - - column.toggleSorting(true)}> - - Desc - - - column.toggleVisibility(false)}> - - Hide - - - -
- ); -} diff --git a/apps/dashboard/src/components/primitives/data-table/data-table-row-action.tsx b/apps/dashboard/src/components/primitives/data-table/data-table-row-action.tsx deleted file mode 100644 index 221a736f9f4..00000000000 --- a/apps/dashboard/src/components/primitives/data-table/data-table-row-action.tsx +++ /dev/null @@ -1,64 +0,0 @@ -'use client'; - -import { Row } from '@tanstack/react-table'; -import { MoreHorizontal } from 'lucide-react'; - -import { Button } from '@/registry/new-york/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from '@/registry/new-york/ui/dropdown-menu'; - -import { labels } from '../data/data'; -import { taskSchema } from '../data/schema'; - -interface DataTableRowActionsProps { - row: Row; -} - -export function DataTableRowActions({ row }: DataTableRowActionsProps) { - const task = taskSchema.parse(row.original); - - return ( - - - - - - Edit - Make a copy - Favorite - - - Labels - - - {labels.map((label) => ( - - {label.label} - - ))} - - - - - - Delete - ⌘⌫ - - - - ); -} diff --git a/apps/dashboard/src/components/primitives/data-table/data-table-toolbar.tsx b/apps/dashboard/src/components/primitives/data-table/data-table-toolbar.tsx deleted file mode 100644 index d59eb0d3a69..00000000000 --- a/apps/dashboard/src/components/primitives/data-table/data-table-toolbar.tsx +++ /dev/null @@ -1,45 +0,0 @@ -'use client'; - -import { Table } from '@tanstack/react-table'; -import { X } from 'lucide-react'; - -import { Button } from '@/registry/new-york/ui/button'; -import { Input } from '@/registry/new-york/ui/input'; -import { DataTableViewOptions } from '@/app/(app)/examples/tasks/components/data-table-view-options'; - -import { priorities, statuses } from '../data/data'; -import { DataTableFacetedFilter } from './data-table-faceted-filter'; - -interface DataTableToolbarProps { - table: Table; -} - -export function DataTableToolbar({ table }: DataTableToolbarProps) { - const isFiltered = table.getState().columnFilters.length > 0; - - return ( -
-
- table.getColumn('title')?.setFilterValue(event.target.value)} - className="h-8 w-[150px] lg:w-[250px]" - /> - {table.getColumn('status') && ( - - )} - {table.getColumn('priority') && ( - - )} - {isFiltered && ( - - )} -
- -
- ); -} diff --git a/apps/dashboard/src/components/primitives/data-table/data-table-view-options.tsx b/apps/dashboard/src/components/primitives/data-table/data-table-view-options.tsx deleted file mode 100644 index ffa401086c3..00000000000 --- a/apps/dashboard/src/components/primitives/data-table/data-table-view-options.tsx +++ /dev/null @@ -1,50 +0,0 @@ -'use client'; - -import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'; -import { Table } from '@tanstack/react-table'; -import { Settings2 } from 'lucide-react'; - -import { Button } from '@/registry/new-york/ui/button'; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuSeparator, -} from '@/registry/new-york/ui/dropdown-menu'; - -interface DataTableViewOptionsProps { - table: Table; -} - -export function DataTableViewOptions({ table }: DataTableViewOptionsProps) { - return ( - - - - - - Toggle columns - - {table - .getAllColumns() - .filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide()) - .map((column) => { - return ( - column.toggleVisibility(!!value)} - > - {column.id} - - ); - })} - - - ); -} diff --git a/apps/dashboard/src/components/primitives/data-table/data-table.tsx b/apps/dashboard/src/components/primitives/data-table/data-table.tsx deleted file mode 100644 index 1f94a4a162b..00000000000 --- a/apps/dashboard/src/components/primitives/data-table/data-table.tsx +++ /dev/null @@ -1,97 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { - ColumnDef, - ColumnFiltersState, - SortingState, - VisibilityState, - flexRender, - getCoreRowModel, - getFacetedRowModel, - getFacetedUniqueValues, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, -} from '@tanstack/react-table'; - -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/registry/new-york/ui/table'; - -import { DataTablePagination } from './data-table-pagination'; -import { DataTableToolbar } from './data-table-toolbar'; - -interface DataTableProps { - columns: ColumnDef[]; - data: TData[]; -} - -export function DataTable({ columns, data }: DataTableProps) { - const [rowSelection, setRowSelection] = React.useState({}); - const [columnVisibility, setColumnVisibility] = React.useState({}); - const [columnFilters, setColumnFilters] = React.useState([]); - const [sorting, setSorting] = React.useState([]); - - const table = useReactTable({ - data, - columns, - state: { - sorting, - columnVisibility, - rowSelection, - columnFilters, - }, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - onColumnVisibilityChange: setColumnVisibility, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - getFacetedRowModel: getFacetedRowModel(), - getFacetedUniqueValues: getFacetedUniqueValues(), - }); - - return ( -
- -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} - ))} - - )) - ) : ( - - - No results. - - - )} - -
-
- -
- ); -} diff --git a/apps/dashboard/src/components/primitives/data-table/data-table-faceted-filter.tsx b/apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx similarity index 94% rename from apps/dashboard/src/components/primitives/data-table/data-table-faceted-filter.tsx rename to apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx index 0bac7436756..4d0264e99bc 100644 --- a/apps/dashboard/src/components/primitives/data-table/data-table-faceted-filter.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx @@ -37,7 +37,7 @@ const inputStyles = { text: 'text-neutral-600', } as const; -interface DataTableFacetedFilterProps { +interface FacetedFilterProps { title?: string; type?: ValueType; size?: SizeType; @@ -51,9 +51,11 @@ interface DataTableFacetedFilterProps { value?: string; onChange?: (value: string) => void; placeholder?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; } -export function DataTableFacetedFilter({ +export function FacetedFormFilter({ title, type = 'multi', size = 'default', @@ -63,13 +65,22 @@ export function DataTableFacetedFilter({ value = '', onChange, placeholder, -}: DataTableFacetedFilterProps) { + open, + onOpenChange, +}: FacetedFilterProps) { const [searchQuery, setSearchQuery] = React.useState(''); + const inputRef = React.useRef(null); const selectedValues = React.useMemo(() => new Set(selected), [selected]); const currentValue = React.useMemo(() => value, [value]); const sizes = sizeVariants[size]; + React.useEffect(() => { + if (open && inputRef.current) { + inputRef.current.focus(); + } + }, [open]); + const filteredOptions = React.useMemo(() => { if (!searchQuery) return options; return options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); @@ -87,6 +98,7 @@ export function DataTableFacetedFilter({ } else { newSelectedValues.add(selectedValue); } + onSelect?.(Array.from(newSelectedValues)); }; @@ -96,6 +108,7 @@ export function DataTableFacetedFilter({ } else { onSelect?.([]); } + setSearchQuery(''); }; @@ -150,6 +163,7 @@ export function DataTableFacetedFilter({ return (
handleSearchChange(e.target.value)} @@ -215,6 +230,7 @@ export function DataTableFacetedFilter({ return (
handleSearchChange(e.target.value)} @@ -267,7 +283,7 @@ export function DataTableFacetedFilter({ }; return ( - + - - -
- {availableFilters.map((filter) => ( -
handleAddFilter(filter)} - className={cn( - 'flex items-center px-2 py-1.5 text-sm text-neutral-600', - 'cursor-pointer rounded-sm hover:bg-neutral-50 hover:text-neutral-900', - 'outline-none focus-visible:bg-neutral-50 focus-visible:text-neutral-900' - )} - > - {filter.label} -
- ))} - {availableFilters.length === 0 && ( -

No filters available

- )} -
-
-
+ ( + + + + )} + /> {hasChanges && ( + + ); +} + +interface FilterInputProps { + inputRef: React.RefObject; + value: string; + onChange: (e: React.ChangeEvent) => void; + placeholder?: string; + size: SizeType; +} + +function FilterInput({ inputRef, value, onChange, placeholder, size }: FilterInputProps) { + return ( + + ); +} + +// Filter Type Components +interface TextFilterContentProps { + inputRef: React.RefObject; + value: string; + onChange: (e: React.ChangeEvent) => void; + onClear: () => void; + placeholder?: string; + size: SizeType; +} + +function TextFilterContent({ inputRef, value, onChange, onClear, placeholder, size }: TextFilterContentProps) { + return ( +
+ + {value && } +
+ ); +} + +interface SingleFilterContentProps { + inputRef: React.RefObject; + title?: string; + options: FilterOption[]; + selectedValues: Set; + onSelect: (value: string) => void; + onClear: () => void; + searchQuery: string; + onSearchChange: (value: string) => void; + size: SizeType; +} + +function SingleFilterContent({ + inputRef, + title, + options, + selectedValues, + onSelect, + onClear, + searchQuery, + onSearchChange, + size, +}: SingleFilterContentProps) { + return ( +
+ onSearchChange(e.target.value)} + placeholder={`Search ${title}...`} + size={size} + /> +
+ + {options.map((option) => ( +
+ + +
+ ))} +
+
+ {selectedValues.size > 0 && } +
+ ); +} + +interface MultiFilterContentProps { + inputRef: React.RefObject; + title?: string; + options: FilterOption[]; + selectedValues: Set; + onSelect: (value: string) => void; + onClear: () => void; + searchQuery: string; + onSearchChange: (value: string) => void; + size: SizeType; +} + +function MultiFilterContent({ + inputRef, + title, + options, + selectedValues, + onSelect, + onClear, + searchQuery, + onSearchChange, + size, +}: MultiFilterContentProps) { + return ( +
+ onSearchChange(e.target.value)} + placeholder={`Search ${title}...`} + size={size} + /> +
+ {options.map((option) => { + const isSelected = selectedValues.has(option.value); + return ( +
onSelect(option.value)} + className={cn( + 'flex cursor-pointer items-center space-x-2 rounded-sm hover:bg-neutral-50', + isSelected && 'bg-neutral-50', + STYLES.size[size].item + )} + > +
+ +
+ {option.icon && } + {option.label} +
+ ); + })} +
+ {selectedValues.size > 0 && } +
+ ); +} + +// Main Component export function FacetedFormFilter({ title, type = 'multi', @@ -73,7 +264,12 @@ export function FacetedFormFilter({ const selectedValues = React.useMemo(() => new Set(selected), [selected]); const currentValue = React.useMemo(() => value, [value]); - const sizes = sizeVariants[size]; + const sizes = STYLES.size[size]; + + const filteredOptions = React.useMemo(() => { + if (!searchQuery) return options; + return options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); + }, [options, searchQuery]); React.useEffect(() => { if (open && inputRef.current) { @@ -81,11 +277,6 @@ export function FacetedFormFilter({ } }, [open]); - const filteredOptions = React.useMemo(() => { - if (!searchQuery) return options; - return options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); - }, [options, searchQuery]); - const handleSelect = (selectedValue: string) => { if (type === 'single') { onSelect?.([selectedValue]); @@ -108,36 +299,17 @@ export function FacetedFormFilter({ } else { onSelect?.([]); } - setSearchQuery(''); }; - const handleInputChange = (e: React.ChangeEvent) => { - onChange?.(e.target.value); - }; - - const handleSearchChange = (value: string) => { - setSearchQuery(value); - }; - - const renderBadge = (content: React.ReactNode, key?: string) => ( - - {content} - - ); - const renderTriggerContent = () => { - if (type === 'text') { - return currentValue ? ( + if (type === 'text' && currentValue) { + return ( <> - {renderBadge(`${currentValue}`)} + - ) : null; + ); } if (selectedValues.size === 0) return null; @@ -148,140 +320,53 @@ export function FacetedFormFilter({ return ( <> -
{renderBadge(selectedCount)}
+
+ +
- {selectedCount > 2 && type === 'multi' - ? renderBadge(`${selectedCount} selected`) - : selectedItems.map((option) => renderBadge(option.label, option.value))} + {selectedCount > 2 && type === 'multi' ? ( + + ) : ( + selectedItems.map((option) => ) + )}
); }; const renderContent = () => { + const commonProps = { + inputRef, + title, + size, + onClear: handleClear, + }; + if (type === 'text') { return ( -
- - {currentValue && ( - <> - - - - )} -
+ onChange?.(e.target.value)} + placeholder={placeholder} + /> ); } - if (type === 'single') { - return ( -
- handleSearchChange(e.target.value)} - className={cn('w-full', sizes.input, inputStyles.base, inputStyles.text)} - /> -
- handleSelect(value)}> - {filteredOptions.map((option) => ( -
- - -
- ))} -
-
- {selectedValues.size > 0 && ( - <> - - - - )} -
- ); - } + const filterProps = { + ...commonProps, + options: filteredOptions, + selectedValues, + onSelect: handleSelect, + searchQuery, + onSearchChange: (value: string) => setSearchQuery(value), + }; - return ( -
- handleSearchChange(e.target.value)} - className={cn('w-full', sizes.input, inputStyles.base, inputStyles.text)} - /> -
- {filteredOptions.map((option) => { - const isSelected = selectedValues.has(option.value); - return ( -
handleSelect(option.value)} - className={cn( - 'flex cursor-pointer items-center space-x-2 rounded-sm hover:bg-neutral-50', - isSelected && 'bg-neutral-50', - sizes.item - )} - > -
- -
- {option.icon && } - {option.label} -
- ); - })} -
- {selectedValues.size > 0 && ( - <> - - - - )} -
- ); + return type === 'single' ? : ; }; + const isEmpty = type === 'text' ? !currentValue : selectedValues.size === 0; + return ( @@ -290,11 +375,11 @@ export function FacetedFormFilter({ size="sm" className={cn( 'border-neutral-200 text-neutral-600 hover:border-neutral-300 hover:bg-neutral-50 hover:text-neutral-900', - (type === 'text' ? !currentValue : selectedValues.size === 0) && 'border-dashed', + isEmpty && 'border-dashed', sizes.trigger )} > - {(type === 'text' ? !currentValue : selectedValues.size === 0) && } + {isEmpty && } {title} {renderTriggerContent()} From 9906bf3d71488fadf48a128f3dad97119599ef0f Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 11:43:21 +0200 Subject: [PATCH 12/34] fix: reorder components --- .../primitives/form/faceted-form-filter.tsx | 295 +++++++++--------- 1 file changed, 145 insertions(+), 150 deletions(-) diff --git a/apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx b/apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx index 6b309556873..a73f322099a 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx @@ -9,7 +9,6 @@ import { Input } from '../input'; import { RadioGroup, RadioGroupItem } from '../radio-group'; import { Label } from '../label'; -// Types type ValueType = 'single' | 'multi' | 'text'; type SizeType = 'default' | 'small'; @@ -33,7 +32,151 @@ interface FacetedFilterProps { onOpenChange?: (open: boolean) => void; } -// Constants +export function FacetedFormFilter({ + title, + type = 'multi', + size = 'default', + options = [], + selected = [], + onSelect, + value = '', + onChange, + placeholder, + open, + onOpenChange, +}: FacetedFilterProps) { + const [searchQuery, setSearchQuery] = React.useState(''); + const inputRef = React.useRef(null); + + const selectedValues = React.useMemo(() => new Set(selected), [selected]); + const currentValue = React.useMemo(() => value, [value]); + const sizes = STYLES.size[size]; + + const filteredOptions = React.useMemo(() => { + if (!searchQuery) return options; + return options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); + }, [options, searchQuery]); + + React.useEffect(() => { + if (open && inputRef.current) { + inputRef.current.focus(); + } + }, [open]); + + const handleSelect = (selectedValue: string) => { + if (type === 'single') { + onSelect?.([selectedValue]); + return; + } + + const newSelectedValues = new Set(selectedValues); + if (newSelectedValues.has(selectedValue)) { + newSelectedValues.delete(selectedValue); + } else { + newSelectedValues.add(selectedValue); + } + + onSelect?.(Array.from(newSelectedValues)); + }; + + const handleClear = () => { + if (type === 'text') { + onChange?.(''); + } else { + onSelect?.([]); + } + setSearchQuery(''); + }; + + const renderTriggerContent = () => { + if (type === 'text' && currentValue) { + return ( + <> + + + + ); + } + + if (selectedValues.size === 0) return null; + + const selectedCount = selectedValues.size; + const selectedItems = options.filter((option) => selectedValues.has(option.value)); + + return ( + <> + +
+ +
+
+ {selectedCount > 2 && type === 'multi' ? ( + + ) : ( + selectedItems.map((option) => ) + )} +
+ + ); + }; + + const renderContent = () => { + const commonProps = { + inputRef, + title, + size, + onClear: handleClear, + }; + + if (type === 'text') { + return ( + onChange?.(e.target.value)} + placeholder={placeholder} + /> + ); + } + + const filterProps = { + ...commonProps, + options: filteredOptions, + selectedValues, + onSelect: handleSelect, + searchQuery, + onSearchChange: (value: string) => setSearchQuery(value), + }; + + return type === 'single' ? : ; + }; + + const isEmpty = type === 'text' ? !currentValue : selectedValues.size === 0; + + return ( + + + + + + {renderContent()} + + + ); +} + const STYLES = { size: { default: { @@ -60,7 +203,6 @@ const STYLES = { clearButton: 'w-full justify-center px-2 text-xs text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900', } as const; -// Subcomponents interface FilterBadgeProps { content: React.ReactNode; size: SizeType; @@ -115,7 +257,6 @@ function FilterInput({ inputRef, value, onChange, placeholder, size }: FilterInp ); } -// Filter Type Components interface TextFilterContentProps { inputRef: React.RefObject; value: string; @@ -244,149 +385,3 @@ function MultiFilterContent({
); } - -// Main Component -export function FacetedFormFilter({ - title, - type = 'multi', - size = 'default', - options = [], - selected = [], - onSelect, - value = '', - onChange, - placeholder, - open, - onOpenChange, -}: FacetedFilterProps) { - const [searchQuery, setSearchQuery] = React.useState(''); - const inputRef = React.useRef(null); - - const selectedValues = React.useMemo(() => new Set(selected), [selected]); - const currentValue = React.useMemo(() => value, [value]); - const sizes = STYLES.size[size]; - - const filteredOptions = React.useMemo(() => { - if (!searchQuery) return options; - return options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); - }, [options, searchQuery]); - - React.useEffect(() => { - if (open && inputRef.current) { - inputRef.current.focus(); - } - }, [open]); - - const handleSelect = (selectedValue: string) => { - if (type === 'single') { - onSelect?.([selectedValue]); - return; - } - - const newSelectedValues = new Set(selectedValues); - if (newSelectedValues.has(selectedValue)) { - newSelectedValues.delete(selectedValue); - } else { - newSelectedValues.add(selectedValue); - } - - onSelect?.(Array.from(newSelectedValues)); - }; - - const handleClear = () => { - if (type === 'text') { - onChange?.(''); - } else { - onSelect?.([]); - } - setSearchQuery(''); - }; - - const renderTriggerContent = () => { - if (type === 'text' && currentValue) { - return ( - <> - - - - ); - } - - if (selectedValues.size === 0) return null; - - const selectedCount = selectedValues.size; - const selectedItems = options.filter((option) => selectedValues.has(option.value)); - - return ( - <> - -
- -
-
- {selectedCount > 2 && type === 'multi' ? ( - - ) : ( - selectedItems.map((option) => ) - )} -
- - ); - }; - - const renderContent = () => { - const commonProps = { - inputRef, - title, - size, - onClear: handleClear, - }; - - if (type === 'text') { - return ( - onChange?.(e.target.value)} - placeholder={placeholder} - /> - ); - } - - const filterProps = { - ...commonProps, - options: filteredOptions, - selectedValues, - onSelect: handleSelect, - searchQuery, - onSearchChange: (value: string) => setSearchQuery(value), - }; - - return type === 'single' ? : ; - }; - - const isEmpty = type === 'text' ? !currentValue : selectedValues.size === 0; - - return ( - - - - - - {renderContent()} - - - ); -} From fe47dbf6be95e6071cacac698eac5eecc2b70520 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 11:45:01 +0200 Subject: [PATCH 13/34] Update activity-table.tsx --- apps/dashboard/src/components/activity/activity-table.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index cee42db1172..6a0781420ee 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -14,7 +14,6 @@ import { StepIndicators } from './components/step-indicators'; import { Pagination } from './components/pagination'; import { useRef, useEffect } from 'react'; import { IActivityFilters } from '@/api/activity'; -import { FacetedFormFilter } from '../primitives/form/faceted-form-filter'; export interface ActivityTableProps { selectedActivityId: string | null; From f2a62df5fdf5743e4fff138dfbbd974c99490619 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 11:49:10 +0200 Subject: [PATCH 14/34] fix: styling --- .../primitives/form/faceted-form-filter.tsx | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx b/apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx index a73f322099a..eb276280c69 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx @@ -32,6 +32,32 @@ interface FacetedFilterProps { onOpenChange?: (open: boolean) => void; } +const STYLES = { + size: { + default: { + trigger: 'h-8', + input: 'h-8', + content: 'p-2', + item: 'py-1.5 px-2', + badge: 'px-2 py-0.5 text-xs', + separator: 'h-4', + }, + small: { + trigger: 'h-7 px-1.5 py-1.5', + input: 'h-7 px-2 py-1.5', + content: 'p-1.5', + item: 'py-1 px-1.5', + badge: 'px-1.5 py-0 text-[11px]', + separator: 'h-3.5 mx-1', + }, + }, + input: { + base: 'border-neutral-200 placeholder:text-neutral-400 focus:border-neutral-400 focus:ring-0 focus:ring-offset-0', + text: 'text-neutral-600', + }, + clearButton: 'w-full justify-center px-2 text-xs text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900', +} as const; + export function FacetedFormFilter({ title, type = 'multi', @@ -160,12 +186,12 @@ export function FacetedFormFilter({ variant="outline" size="sm" className={cn( - 'border-neutral-200 text-neutral-600 hover:border-neutral-300 hover:bg-neutral-50 hover:text-neutral-900', + 'border-neutral-200 px-1.5 text-neutral-600 hover:border-neutral-300 hover:bg-neutral-50 hover:text-neutral-900', isEmpty && 'border-dashed', sizes.trigger )} > - {isEmpty && } + {isEmpty && } {title} {renderTriggerContent()} @@ -177,32 +203,6 @@ export function FacetedFormFilter({ ); } -const STYLES = { - size: { - default: { - trigger: 'h-8', - input: 'h-8', - content: 'p-2', - item: 'py-1.5 px-2', - badge: 'px-2 py-0.5 text-xs', - separator: 'h-4', - }, - small: { - trigger: 'h-7 px-2 py-1.5', - input: 'h-7 px-2 py-1.5', - content: 'p-1.5', - item: 'py-1 px-1.5', - badge: 'px-1.5 py-0 text-[11px]', - separator: 'h-3.5', - }, - }, - input: { - base: 'border-neutral-200 placeholder:text-neutral-400 focus:border-neutral-400 focus:ring-0 focus:ring-offset-0', - text: 'text-neutral-600', - }, - clearButton: 'w-full justify-center px-2 text-xs text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900', -} as const; - interface FilterBadgeProps { content: React.ReactNode; size: SizeType; From 776c8530c3d782d7f3f62b873d370cd9dd548c02 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 11:56:47 +0200 Subject: [PATCH 15/34] feat: refactor sub components --- .../components/activity/activity-filters.tsx | 2 +- .../components/clear-button.tsx | 22 + .../components/filter-badge.tsx | 21 + .../components/filter-input.tsx | 24 ++ .../components/multi-filter-content.tsx | 70 ++++ .../components/single-filter-content.tsx | 53 +++ .../components/text-filter-content.tsx | 22 + .../faceted-filter/facated-form-filter.tsx | 159 +++++++ .../primitives/form/faceted-filter/styles.ts | 25 ++ .../primitives/form/faceted-filter/types.ts | 24 ++ .../primitives/form/faceted-form-filter.tsx | 387 ------------------ 11 files changed, 421 insertions(+), 388 deletions(-) create mode 100644 apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx create mode 100644 apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-badge.tsx create mode 100644 apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx create mode 100644 apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx create mode 100644 apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx create mode 100644 apps/dashboard/src/components/primitives/form/faceted-filter/components/text-filter-content.tsx create mode 100644 apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx create mode 100644 apps/dashboard/src/components/primitives/form/faceted-filter/styles.ts create mode 100644 apps/dashboard/src/components/primitives/form/faceted-filter/types.ts delete mode 100644 apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index 417bcfbbca6..429c14b3691 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -3,8 +3,8 @@ import { ChannelTypeEnum } from '@novu/shared'; import { useFetchWorkflows } from '../../hooks/use-fetch-workflows'; import { useForm } from 'react-hook-form'; import { Form, FormItem, FormField } from '../primitives/form/form'; -import { FacetedFormFilter } from '../primitives/form/faceted-form-filter'; import { Button } from '../primitives/button'; +import { FacetedFormFilter } from '../primitives/form/faceted-filter/facated-form-filter'; interface IActivityFilters { onFiltersChange: (filters: IActivityFiltersData) => void; diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx new file mode 100644 index 00000000000..712477095c0 --- /dev/null +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx @@ -0,0 +1,22 @@ +import { Button } from '../../../button'; +import { Separator } from '../../../separator'; +import { cn } from '../../../../../utils/ui'; +import { SizeType } from '../types'; +import { STYLES } from '../styles'; + +interface ClearButtonProps { + onClick: () => void; + size: SizeType; + label?: string; +} + +export function ClearButton({ onClick, size, label = 'Clear filter' }: ClearButtonProps) { + return ( + <> + + + + ); +} diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-badge.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-badge.tsx new file mode 100644 index 00000000000..047d7584e4e --- /dev/null +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-badge.tsx @@ -0,0 +1,21 @@ +import { Badge } from '../../../badge'; +import { cn } from '../../../../../utils/ui'; +import { SizeType } from '../types'; +import { STYLES } from '../styles'; + +interface FilterBadgeProps { + content: React.ReactNode; + size: SizeType; + className?: string; +} + +export function FilterBadge({ content, size, className }: FilterBadgeProps) { + return ( + + {content} + + ); +} diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx new file mode 100644 index 00000000000..de49023b610 --- /dev/null +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx @@ -0,0 +1,24 @@ +import { Input } from '../../../input'; +import { cn } from '../../../../../utils/ui'; +import { SizeType } from '../types'; +import { STYLES } from '../styles'; + +interface FilterInputProps { + inputRef: React.RefObject; + value: string; + onChange: (e: React.ChangeEvent) => void; + placeholder?: string; + size: SizeType; +} + +export function FilterInput({ inputRef, value, onChange, placeholder, size }: FilterInputProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx new file mode 100644 index 00000000000..278405f9e2f --- /dev/null +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx @@ -0,0 +1,70 @@ +import { Check } from 'lucide-react'; +import { cn } from '../../../../../utils/ui'; +import { FilterOption, SizeType } from '../types'; +import { STYLES } from '../styles'; +import { FilterInput } from './filter-input'; +import { ClearButton } from './clear-button'; + +interface MultiFilterContentProps { + inputRef: React.RefObject; + title?: string; + options: FilterOption[]; + selectedValues: Set; + onSelect: (value: string) => void; + onClear: () => void; + searchQuery: string; + onSearchChange: (value: string) => void; + size: SizeType; +} + +export function MultiFilterContent({ + inputRef, + title, + options, + selectedValues, + onSelect, + onClear, + searchQuery, + onSearchChange, + size, +}: MultiFilterContentProps) { + return ( +
+ onSearchChange(e.target.value)} + placeholder={`Search ${title}...`} + size={size} + /> +
+ {options.map((option) => { + const isSelected = selectedValues.has(option.value); + return ( +
onSelect(option.value)} + className={cn( + 'flex cursor-pointer items-center space-x-2 rounded-sm hover:bg-neutral-50', + isSelected && 'bg-neutral-50', + STYLES.size[size].item + )} + > +
+ +
+ {option.icon && } + {option.label} +
+ ); + })} +
+ {selectedValues.size > 0 && } +
+ ); +} diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx new file mode 100644 index 00000000000..4bc11291d17 --- /dev/null +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx @@ -0,0 +1,53 @@ +import { RadioGroup, RadioGroupItem } from '../../../radio-group'; +import { Label } from '../../../label'; +import { FilterOption, SizeType } from '../types'; +import { STYLES } from '../styles'; +import { FilterInput } from './filter-input'; +import { ClearButton } from './clear-button'; + +interface SingleFilterContentProps { + inputRef: React.RefObject; + title?: string; + options: FilterOption[]; + selectedValues: Set; + onSelect: (value: string) => void; + onClear: () => void; + searchQuery: string; + onSearchChange: (value: string) => void; + size: SizeType; +} + +export function SingleFilterContent({ + inputRef, + title, + options, + selectedValues, + onSelect, + onClear, + searchQuery, + onSearchChange, + size, +}: SingleFilterContentProps) { + return ( +
+ onSearchChange(e.target.value)} + placeholder={`Search ${title}...`} + size={size} + /> +
+ + {options.map((option) => ( +
+ + +
+ ))} +
+
+ {selectedValues.size > 0 && } +
+ ); +} diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/text-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/text-filter-content.tsx new file mode 100644 index 00000000000..cdc2578dcb5 --- /dev/null +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/text-filter-content.tsx @@ -0,0 +1,22 @@ +import { SizeType } from '../types'; +import { STYLES } from '../styles'; +import { FilterInput } from './filter-input'; +import { ClearButton } from './clear-button'; + +interface TextFilterContentProps { + inputRef: React.RefObject; + value: string; + onChange: (e: React.ChangeEvent) => void; + onClear: () => void; + placeholder?: string; + size: SizeType; +} + +export function TextFilterContent({ inputRef, value, onChange, onClear, placeholder, size }: TextFilterContentProps) { + return ( +
+ + {value && } +
+ ); +} diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx new file mode 100644 index 00000000000..8b6d56523c9 --- /dev/null +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx @@ -0,0 +1,159 @@ +import * as React from 'react'; +import { PlusCircle } from 'lucide-react'; +import { Button } from '../../button'; +import { Separator } from '../../separator'; +import { Popover, PopoverContent, PopoverTrigger } from '../../popover'; +import { cn } from '../../../../utils/ui'; +import { FacetedFilterProps } from './types'; +import { STYLES } from './styles'; +import { FilterBadge } from './components/filter-badge'; +import { TextFilterContent } from './components/text-filter-content'; +import { SingleFilterContent } from './components/single-filter-content'; +import { MultiFilterContent } from './components/multi-filter-content'; + +export function FacetedFormFilter({ + title, + type = 'multi', + size = 'default', + options = [], + selected = [], + onSelect, + value = '', + onChange, + placeholder, + open, + onOpenChange, +}: FacetedFilterProps) { + const [searchQuery, setSearchQuery] = React.useState(''); + const inputRef = React.useRef(null); + + const selectedValues = React.useMemo(() => new Set(selected), [selected]); + const currentValue = React.useMemo(() => value, [value]); + const sizes = STYLES.size[size]; + + const filteredOptions = React.useMemo(() => { + if (!searchQuery) return options; + return options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); + }, [options, searchQuery]); + + React.useEffect(() => { + if (open && inputRef.current) { + inputRef.current.focus(); + } + }, [open]); + + const handleSelect = (selectedValue: string) => { + if (type === 'single') { + onSelect?.([selectedValue]); + return; + } + + const newSelectedValues = new Set(selectedValues); + if (newSelectedValues.has(selectedValue)) { + newSelectedValues.delete(selectedValue); + } else { + newSelectedValues.add(selectedValue); + } + + onSelect?.(Array.from(newSelectedValues)); + }; + + const handleClear = () => { + if (type === 'text') { + onChange?.(''); + } else { + onSelect?.([]); + } + setSearchQuery(''); + }; + + const renderTriggerContent = () => { + if (type === 'text' && currentValue) { + return ( + <> + + + + ); + } + + if (selectedValues.size === 0) return null; + + const selectedCount = selectedValues.size; + const selectedItems = options.filter((option) => selectedValues.has(option.value)); + + return ( + <> + +
+ +
+
+ {selectedCount > 2 && type === 'multi' ? ( + + ) : ( + selectedItems.map((option) => ) + )} +
+ + ); + }; + + const renderContent = () => { + const commonProps = { + inputRef, + title, + size, + onClear: handleClear, + }; + + if (type === 'text') { + return ( + onChange?.(e.target.value)} + placeholder={placeholder} + /> + ); + } + + const filterProps = { + ...commonProps, + options: filteredOptions, + selectedValues, + onSelect: handleSelect, + searchQuery, + onSearchChange: (value: string) => setSearchQuery(value), + }; + + return type === 'single' ? : ; + }; + + const isEmpty = type === 'text' ? !currentValue : selectedValues.size === 0; + + return ( + + + + + + {renderContent()} + + + ); +} + +export * from './types'; diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/styles.ts b/apps/dashboard/src/components/primitives/form/faceted-filter/styles.ts new file mode 100644 index 00000000000..d21ccbfa731 --- /dev/null +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/styles.ts @@ -0,0 +1,25 @@ +export const STYLES = { + size: { + default: { + trigger: 'h-8', + input: 'h-8', + content: 'p-2', + item: 'py-1.5 px-2', + badge: 'px-2 py-0.5 text-xs', + separator: 'h-4', + }, + small: { + trigger: 'h-7 px-1.5 py-1.5', + input: 'h-7 px-2 py-1.5', + content: 'p-1.5', + item: 'py-1 px-1.5', + badge: 'px-1.5 py-0 text-[11px]', + separator: 'h-3.5 mx-1', + }, + }, + input: { + base: 'border-neutral-200 placeholder:text-neutral-400 focus:border-neutral-400 focus:ring-0 focus:ring-offset-0', + text: 'text-neutral-600', + }, + clearButton: 'w-full justify-center px-2 text-xs text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900', +} as const; diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/types.ts b/apps/dashboard/src/components/primitives/form/faceted-filter/types.ts new file mode 100644 index 00000000000..0f88205041d --- /dev/null +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/types.ts @@ -0,0 +1,24 @@ +import { ComponentType } from 'react'; + +export type ValueType = 'single' | 'multi' | 'text'; +export type SizeType = 'default' | 'small'; + +export interface FilterOption { + label: string; + value: string; + icon?: ComponentType<{ className?: string }>; +} + +export interface FacetedFilterProps { + title?: string; + type?: ValueType; + size?: SizeType; + options?: FilterOption[]; + selected?: string[]; + onSelect?: (values: string[]) => void; + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} diff --git a/apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx b/apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx deleted file mode 100644 index eb276280c69..00000000000 --- a/apps/dashboard/src/components/primitives/form/faceted-form-filter.tsx +++ /dev/null @@ -1,387 +0,0 @@ -import * as React from 'react'; -import { Check, PlusCircle } from 'lucide-react'; -import { Button } from '../button'; -import { Badge } from '../badge'; -import { cn } from '../../../utils/ui'; -import { Popover, PopoverContent, PopoverTrigger } from '../popover'; -import { Separator } from '../separator'; -import { Input } from '../input'; -import { RadioGroup, RadioGroupItem } from '../radio-group'; -import { Label } from '../label'; - -type ValueType = 'single' | 'multi' | 'text'; -type SizeType = 'default' | 'small'; - -interface FilterOption { - label: string; - value: string; - icon?: React.ComponentType<{ className?: string }>; -} - -interface FacetedFilterProps { - title?: string; - type?: ValueType; - size?: SizeType; - options?: FilterOption[]; - selected?: string[]; - onSelect?: (values: string[]) => void; - value?: string; - onChange?: (value: string) => void; - placeholder?: string; - open?: boolean; - onOpenChange?: (open: boolean) => void; -} - -const STYLES = { - size: { - default: { - trigger: 'h-8', - input: 'h-8', - content: 'p-2', - item: 'py-1.5 px-2', - badge: 'px-2 py-0.5 text-xs', - separator: 'h-4', - }, - small: { - trigger: 'h-7 px-1.5 py-1.5', - input: 'h-7 px-2 py-1.5', - content: 'p-1.5', - item: 'py-1 px-1.5', - badge: 'px-1.5 py-0 text-[11px]', - separator: 'h-3.5 mx-1', - }, - }, - input: { - base: 'border-neutral-200 placeholder:text-neutral-400 focus:border-neutral-400 focus:ring-0 focus:ring-offset-0', - text: 'text-neutral-600', - }, - clearButton: 'w-full justify-center px-2 text-xs text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900', -} as const; - -export function FacetedFormFilter({ - title, - type = 'multi', - size = 'default', - options = [], - selected = [], - onSelect, - value = '', - onChange, - placeholder, - open, - onOpenChange, -}: FacetedFilterProps) { - const [searchQuery, setSearchQuery] = React.useState(''); - const inputRef = React.useRef(null); - - const selectedValues = React.useMemo(() => new Set(selected), [selected]); - const currentValue = React.useMemo(() => value, [value]); - const sizes = STYLES.size[size]; - - const filteredOptions = React.useMemo(() => { - if (!searchQuery) return options; - return options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); - }, [options, searchQuery]); - - React.useEffect(() => { - if (open && inputRef.current) { - inputRef.current.focus(); - } - }, [open]); - - const handleSelect = (selectedValue: string) => { - if (type === 'single') { - onSelect?.([selectedValue]); - return; - } - - const newSelectedValues = new Set(selectedValues); - if (newSelectedValues.has(selectedValue)) { - newSelectedValues.delete(selectedValue); - } else { - newSelectedValues.add(selectedValue); - } - - onSelect?.(Array.from(newSelectedValues)); - }; - - const handleClear = () => { - if (type === 'text') { - onChange?.(''); - } else { - onSelect?.([]); - } - setSearchQuery(''); - }; - - const renderTriggerContent = () => { - if (type === 'text' && currentValue) { - return ( - <> - - - - ); - } - - if (selectedValues.size === 0) return null; - - const selectedCount = selectedValues.size; - const selectedItems = options.filter((option) => selectedValues.has(option.value)); - - return ( - <> - -
- -
-
- {selectedCount > 2 && type === 'multi' ? ( - - ) : ( - selectedItems.map((option) => ) - )} -
- - ); - }; - - const renderContent = () => { - const commonProps = { - inputRef, - title, - size, - onClear: handleClear, - }; - - if (type === 'text') { - return ( - onChange?.(e.target.value)} - placeholder={placeholder} - /> - ); - } - - const filterProps = { - ...commonProps, - options: filteredOptions, - selectedValues, - onSelect: handleSelect, - searchQuery, - onSearchChange: (value: string) => setSearchQuery(value), - }; - - return type === 'single' ? : ; - }; - - const isEmpty = type === 'text' ? !currentValue : selectedValues.size === 0; - - return ( - - - - - - {renderContent()} - - - ); -} - -interface FilterBadgeProps { - content: React.ReactNode; - size: SizeType; - className?: string; -} - -function FilterBadge({ content, size, className }: FilterBadgeProps) { - return ( - - {content} - - ); -} - -interface ClearButtonProps { - onClick: () => void; - size: SizeType; - label?: string; -} - -function ClearButton({ onClick, size, label = 'Clear filter' }: ClearButtonProps) { - return ( - <> - - - - ); -} - -interface FilterInputProps { - inputRef: React.RefObject; - value: string; - onChange: (e: React.ChangeEvent) => void; - placeholder?: string; - size: SizeType; -} - -function FilterInput({ inputRef, value, onChange, placeholder, size }: FilterInputProps) { - return ( - - ); -} - -interface TextFilterContentProps { - inputRef: React.RefObject; - value: string; - onChange: (e: React.ChangeEvent) => void; - onClear: () => void; - placeholder?: string; - size: SizeType; -} - -function TextFilterContent({ inputRef, value, onChange, onClear, placeholder, size }: TextFilterContentProps) { - return ( -
- - {value && } -
- ); -} - -interface SingleFilterContentProps { - inputRef: React.RefObject; - title?: string; - options: FilterOption[]; - selectedValues: Set; - onSelect: (value: string) => void; - onClear: () => void; - searchQuery: string; - onSearchChange: (value: string) => void; - size: SizeType; -} - -function SingleFilterContent({ - inputRef, - title, - options, - selectedValues, - onSelect, - onClear, - searchQuery, - onSearchChange, - size, -}: SingleFilterContentProps) { - return ( -
- onSearchChange(e.target.value)} - placeholder={`Search ${title}...`} - size={size} - /> -
- - {options.map((option) => ( -
- - -
- ))} -
-
- {selectedValues.size > 0 && } -
- ); -} - -interface MultiFilterContentProps { - inputRef: React.RefObject; - title?: string; - options: FilterOption[]; - selectedValues: Set; - onSelect: (value: string) => void; - onClear: () => void; - searchQuery: string; - onSearchChange: (value: string) => void; - size: SizeType; -} - -function MultiFilterContent({ - inputRef, - title, - options, - selectedValues, - onSelect, - onClear, - searchQuery, - onSearchChange, - size, -}: MultiFilterContentProps) { - return ( -
- onSearchChange(e.target.value)} - placeholder={`Search ${title}...`} - size={size} - /> -
- {options.map((option) => { - const isSelected = selectedValues.has(option.value); - return ( -
onSelect(option.value)} - className={cn( - 'flex cursor-pointer items-center space-x-2 rounded-sm hover:bg-neutral-50', - isSelected && 'bg-neutral-50', - STYLES.size[size].item - )} - > -
- -
- {option.icon && } - {option.label} -
- ); - })} -
- {selectedValues.size > 0 && } -
- ); -} From 66c59a17d60f12c4cd7997676b2dddbe29955b82 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 12:52:01 +0200 Subject: [PATCH 16/34] fix: styling --- .../components/activity/activity-filters.tsx | 49 ++++++++++--------- .../src/components/icons/enter-line.tsx | 12 +++++ .../components/clear-button.tsx | 14 ++++-- .../components/filter-badge.tsx | 8 ++- .../components/filter-input.tsx | 28 ++++++++--- .../components/multi-filter-content.tsx | 24 +++++---- .../components/single-filter-content.tsx | 25 ++++++---- .../components/text-filter-content.tsx | 29 +++++++++-- .../faceted-filter/facated-form-filter.tsx | 32 ++++++++---- .../primitives/form/faceted-filter/styles.ts | 6 +-- .../primitives/form/faceted-filter/types.ts | 4 ++ 11 files changed, 161 insertions(+), 70 deletions(-) create mode 100644 apps/dashboard/src/components/icons/enter-line.tsx diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index 429c14b3691..b94c770eefc 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form'; import { Form, FormItem, FormField } from '../primitives/form/form'; import { Button } from '../primitives/button'; import { FacetedFormFilter } from '../primitives/form/faceted-filter/facated-form-filter'; +import { CalendarIcon } from 'lucide-react'; interface IActivityFilters { onFiltersChange: (filters: IActivityFiltersData) => void; @@ -41,55 +42,55 @@ const defaultValues: IActivityFiltersData = { }; export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFilters) { - const originalInitialValues = useRef(defaultValues); const form = useForm({ - defaultValues: initialValues, + defaultValues: initialValues || defaultValues, }); - const { data: workflowTemplates } = useFetchWorkflows({ limit: 100 }); - const formValues = form.watch(); + const { data: workflowTemplates } = useFetchWorkflows({}); const hasChanges = useMemo(() => { - const original = originalInitialValues.current; - return Object.entries(formValues).some(([key, value]) => { - const defaultValue = original[key as keyof IActivityFiltersData]; - if (Array.isArray(value) && Array.isArray(defaultValue)) { - return value.length > 0; - } - return value !== defaultValue; - }); - }, [formValues]); + const currentValues = form.getValues(); - const handleReset = () => { - form.reset(originalInitialValues.current); - onFiltersChange(originalInitialValues.current); - }; + return ( + currentValues.dateRange !== defaultValues.dateRange || + currentValues.channels.length > 0 || + currentValues.templates.length > 0 || + currentValues.transactionId !== defaultValues.transactionId || + currentValues.subscriberId !== defaultValues.subscriberId + ); + }, [form.watch()]); useEffect(() => { const subscription = form.watch((value) => { - if (value) { - onFiltersChange(value as IActivityFiltersData); - } + onFiltersChange(value as IActivityFiltersData); }); return () => subscription.unsubscribe(); - }, [onFiltersChange]); + }, [form, onFiltersChange]); + + const handleReset = () => { + form.reset(defaultValues); + }; return ( - + ( field.onChange(values[0])} + hideSearch + hideClear + icon={CalendarIcon} /> )} diff --git a/apps/dashboard/src/components/icons/enter-line.tsx b/apps/dashboard/src/components/icons/enter-line.tsx new file mode 100644 index 00000000000..d3030835e61 --- /dev/null +++ b/apps/dashboard/src/components/icons/enter-line.tsx @@ -0,0 +1,12 @@ +export function EnterLineIcon(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx index 712477095c0..c125b07b0f5 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx @@ -8,13 +8,21 @@ interface ClearButtonProps { onClick: () => void; size: SizeType; label?: string; + className?: string; + separatorClassName?: string; } -export function ClearButton({ onClick, size, label = 'Clear filter' }: ClearButtonProps) { +export function ClearButton({ + onClick, + size, + label = 'Clear filter', + className, + separatorClassName, +}: ClearButtonProps) { return ( <> - - diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-badge.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-badge.tsx index 047d7584e4e..34a9a0828d3 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-badge.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-badge.tsx @@ -13,7 +13,13 @@ export function FilterBadge({ content, size, className }: FilterBadgeProps) { return ( {content} diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx index de49023b610..632530d5837 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx @@ -2,6 +2,8 @@ import { Input } from '../../../input'; import { cn } from '../../../../../utils/ui'; import { SizeType } from '../types'; import { STYLES } from '../styles'; +import { ArrowLeftRight } from 'lucide-react'; +import { EnterLineIcon } from '../../../../icons/enter-line'; interface FilterInputProps { inputRef: React.RefObject; @@ -9,16 +11,26 @@ interface FilterInputProps { onChange: (e: React.ChangeEvent) => void; placeholder?: string; size: SizeType; + showEnterIcon?: boolean; } -export function FilterInput({ inputRef, value, onChange, placeholder, size }: FilterInputProps) { +export function FilterInput({ inputRef, value, onChange, placeholder, size, showEnterIcon = false }: FilterInputProps) { return ( - +
+
+ + {showEnterIcon && ( +
+ +
+ )} +
+
); } diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx index 278405f9e2f..be6069a413c 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx @@ -15,6 +15,8 @@ interface MultiFilterContentProps { searchQuery: string; onSearchChange: (value: string) => void; size: SizeType; + hideSearch?: boolean; + hideClear?: boolean; } export function MultiFilterContent({ @@ -27,17 +29,21 @@ export function MultiFilterContent({ searchQuery, onSearchChange, size, + hideSearch = false, + hideClear = false, }: MultiFilterContentProps) { return (
- onSearchChange(e.target.value)} - placeholder={`Search ${title}...`} - size={size} - /> -
+ {!hideSearch && ( + onSearchChange(e.target.value)} + placeholder={`Search ${title}...`} + size={size} + /> + )} +
{options.map((option) => { const isSelected = selectedValues.has(option.value); return ( @@ -64,7 +70,7 @@ export function MultiFilterContent({ ); })}
- {selectedValues.size > 0 && } + {!hideClear && selectedValues.size > 0 && }
); } diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx index 4bc11291d17..f01c5681673 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx @@ -4,6 +4,7 @@ import { FilterOption, SizeType } from '../types'; import { STYLES } from '../styles'; import { FilterInput } from './filter-input'; import { ClearButton } from './clear-button'; +import { cn } from '../../../../../utils/ui'; interface SingleFilterContentProps { inputRef: React.RefObject; @@ -15,6 +16,8 @@ interface SingleFilterContentProps { searchQuery: string; onSearchChange: (value: string) => void; size: SizeType; + hideSearch?: boolean; + hideClear?: boolean; } export function SingleFilterContent({ @@ -27,17 +30,21 @@ export function SingleFilterContent({ searchQuery, onSearchChange, size, + hideSearch = false, + hideClear = false, }: SingleFilterContentProps) { return (
- onSearchChange(e.target.value)} - placeholder={`Search ${title}...`} - size={size} - /> -
+ {!hideSearch && ( + onSearchChange(e.target.value)} + placeholder={`Search ${title}...`} + size={size} + /> + )} +
{options.map((option) => (
@@ -47,7 +54,7 @@ export function SingleFilterContent({ ))}
- {selectedValues.size > 0 && } + {!hideClear && selectedValues.size > 0 && }
); } diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/text-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/text-filter-content.tsx index cdc2578dcb5..ee023d9d7f7 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/text-filter-content.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/text-filter-content.tsx @@ -2,6 +2,7 @@ import { SizeType } from '../types'; import { STYLES } from '../styles'; import { FilterInput } from './filter-input'; import { ClearButton } from './clear-button'; +import { cn } from '../../../../../utils/ui'; interface TextFilterContentProps { inputRef: React.RefObject; @@ -10,13 +11,33 @@ interface TextFilterContentProps { onClear: () => void; placeholder?: string; size: SizeType; + hideSearch?: boolean; + hideClear?: boolean; } -export function TextFilterContent({ inputRef, value, onChange, onClear, placeholder, size }: TextFilterContentProps) { +export function TextFilterContent({ + inputRef, + value, + onChange, + onClear, + placeholder, + size, + hideSearch = false, + hideClear = false, +}: TextFilterContentProps) { return ( -
- - {value && } +
+ {!hideSearch && ( + + )} + {!hideClear && value && }
); } diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx index 8b6d56523c9..c7863b5b463 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx @@ -23,6 +23,10 @@ export function FacetedFormFilter({ placeholder, open, onOpenChange, + icon: Icon, + hideTitle = false, + hideSearch = false, + hideClear = false, }: FacetedFilterProps) { const [searchQuery, setSearchQuery] = React.useState(''); const inputRef = React.useRef(null); @@ -71,7 +75,6 @@ export function FacetedFormFilter({ if (type === 'text' && currentValue) { return ( <> - ); @@ -84,7 +87,6 @@ export function FacetedFormFilter({ return ( <> -
@@ -105,6 +107,8 @@ export function FacetedFormFilter({ title, size, onClear: handleClear, + hideSearch, + hideClear, }; if (type === 'text') { @@ -139,17 +143,27 @@ export function FacetedFormFilter({ variant="outline" size="sm" className={cn( - 'border-neutral-200 px-1.5 text-neutral-600 hover:border-neutral-300 hover:bg-neutral-50 hover:text-neutral-900', - isEmpty && 'border-dashed', - sizes.trigger + 'h-10 border-neutral-300 bg-white px-3 text-neutral-600', + 'hover:border-neutral-300 hover:bg-neutral-50/30 hover:text-neutral-700', + 'rounded-lg transition-colors duration-200 ease-out', + sizes.trigger, + isEmpty && 'border-dashed border-neutral-200 px-1.5 hover:border-neutral-300', + !isEmpty && 'border-solid bg-white' )} > - {isEmpty && } - {title} - {renderTriggerContent()} +
+ {Icon && } + {isEmpty && } + {(isEmpty || !hideTitle) && ( + + {title} + + )} + {!isEmpty && renderTriggerContent()} +
- + {renderContent()} diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/styles.ts b/apps/dashboard/src/components/primitives/form/faceted-filter/styles.ts index d21ccbfa731..43019c41602 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/styles.ts +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/styles.ts @@ -9,11 +9,11 @@ export const STYLES = { separator: 'h-4', }, small: { - trigger: 'h-7 px-1.5 py-1.5', - input: 'h-7 px-2 py-1.5', + trigger: 'h-7 px-1 py-1 pl-1.5', + input: 'h-6 text-xs', content: 'p-1.5', item: 'py-1 px-1.5', - badge: 'px-1.5 py-0 text-[11px]', + badge: 'px-2 py-0 text-xs', separator: 'h-3.5 mx-1', }, }, diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/types.ts b/apps/dashboard/src/components/primitives/form/faceted-filter/types.ts index 0f88205041d..0159fa6f41c 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/types.ts +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/types.ts @@ -21,4 +21,8 @@ export interface FacetedFilterProps { placeholder?: string; open?: boolean; onOpenChange?: (open: boolean) => void; + icon?: ComponentType<{ className?: string }>; + hideTitle?: boolean; + hideSearch?: boolean; + hideClear?: boolean; } From 03f819a00d39d9187185d2f6053210d01ad48fe5 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 13:30:44 +0200 Subject: [PATCH 17/34] fix: styling --- .../components/activity/activity-filters.tsx | 3 - .../components/clear-button.tsx | 9 +- .../components/multi-filter-content.tsx | 82 +++++++++++++---- .../components/single-filter-content.tsx | 92 ++++++++++++++++--- .../faceted-filter/facated-form-filter.tsx | 2 +- .../primitives/form/faceted-filter/styles.ts | 2 +- 6 files changed, 149 insertions(+), 41 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index b94c770eefc..591ed1edf4a 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -81,15 +81,12 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil render={({ field }) => ( field.onChange(values[0])} - hideSearch - hideClear icon={CalendarIcon} /> diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx index c125b07b0f5..9a4bd489675 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx @@ -12,16 +12,9 @@ interface ClearButtonProps { separatorClassName?: string; } -export function ClearButton({ - onClick, - size, - label = 'Clear filter', - className, - separatorClassName, -}: ClearButtonProps) { +export function ClearButton({ onClick, size, label = 'Clear filter', className }: ClearButtonProps) { return ( <> - diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx index be6069a413c..4316c8a8bb1 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx @@ -4,6 +4,9 @@ import { FilterOption, SizeType } from '../types'; import { STYLES } from '../styles'; import { FilterInput } from './filter-input'; import { ClearButton } from './clear-button'; +import { RiArrowDownLine, RiArrowUpLine } from 'react-icons/ri'; +import { EnterLineIcon } from '../../../../icons/enter-line'; +import { useEffect, useState } from 'react'; interface MultiFilterContentProps { inputRef: React.RefObject; @@ -32,8 +35,40 @@ export function MultiFilterContent({ hideSearch = false, hideClear = false, }: MultiFilterContentProps) { + const [focusedIndex, setFocusedIndex] = useState(-1); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (options.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : prev)); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + break; + case 'Enter': + e.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < options.length) { + onSelect(options[focusedIndex].value); + } + break; + } + } + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [focusedIndex, options, onSelect]); + return ( -
+
+
+ {title && {title}} + {!hideClear && selectedValues.size > 0 && } +
{!hideSearch && ( )} -
- {options.map((option) => { +
+ {options.map((option, index) => { const isSelected = selectedValues.has(option.value); + const isFocused = index === focusedIndex; + return (
onSelect(option.value)} + onMouseEnter={() => setFocusedIndex(index)} className={cn( - 'flex cursor-pointer items-center space-x-2 rounded-sm hover:bg-neutral-50', - isSelected && 'bg-neutral-50', - STYLES.size[size].item + 'flex cursor-pointer items-center rounded-[6px] p-1 hover:bg-[#F8F8F8]', + isSelected && 'bg-[#F8F8F8]', + isFocused && 'ring-1 ring-neutral-200' )} > -
- -
- {option.icon && } - {option.label} + {option.icon && } + {option.label} + {isSelected && ( +
+ +
+ )}
); })}
- {!hideClear && selectedValues.size > 0 && } +
+
+
+ +
+
+ +
+ Navigate +
+
+ +
+
); } diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx index f01c5681673..6e1094e44f9 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx @@ -1,10 +1,12 @@ import { RadioGroup, RadioGroupItem } from '../../../radio-group'; import { Label } from '../../../label'; import { FilterOption, SizeType } from '../types'; -import { STYLES } from '../styles'; import { FilterInput } from './filter-input'; import { ClearButton } from './clear-button'; import { cn } from '../../../../../utils/ui'; +import { RiArrowDownLine, RiArrowUpLine } from 'react-icons/ri'; +import { EnterLineIcon } from '../../../../icons/enter-line'; +import { useEffect, useState } from 'react'; interface SingleFilterContentProps { inputRef: React.RefObject; @@ -33,8 +35,51 @@ export function SingleFilterContent({ hideSearch = false, hideClear = false, }: SingleFilterContentProps) { + const [focusedIndex, setFocusedIndex] = useState(-1); + const currentValue = Array.from(selectedValues)[0] || ''; + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (options.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : prev)); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + break; + case 'Enter': + e.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < options.length) { + onSelect(options[focusedIndex].value); + } + break; + } + } + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [focusedIndex, options, onSelect]); + + // Initialize focusedIndex based on selected value + useEffect(() => { + if (currentValue) { + const index = options.findIndex((option) => option.value === currentValue); + if (index !== -1) { + setFocusedIndex(index); + } + } + }, [currentValue, options]); + return ( -
+
+
+ {title && {title}} + {!hideClear && selectedValues.size > 0 && } +
{!hideSearch && ( )} -
- - {options.map((option) => ( -
- - -
- ))} +
+ + {options.map((option, index) => { + const isFocused = index === focusedIndex; + + return ( +
setFocusedIndex(index)} + onClick={() => onSelect(option.value)} + > + + +
+ ); + })}
- {!hideClear && selectedValues.size > 0 && } +
+
+
+ +
+
+ +
+ Navigate +
+
+ +
+
); } diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx index c7863b5b463..78041024d34 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx @@ -163,7 +163,7 @@ export function FacetedFormFilter({
- + {renderContent()} diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/styles.ts b/apps/dashboard/src/components/primitives/form/faceted-filter/styles.ts index 43019c41602..81ee69c3dfb 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/styles.ts +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/styles.ts @@ -21,5 +21,5 @@ export const STYLES = { base: 'border-neutral-200 placeholder:text-neutral-400 focus:border-neutral-400 focus:ring-0 focus:ring-offset-0', text: 'text-neutral-600', }, - clearButton: 'w-full justify-center px-2 text-xs text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900', + clearButton: 'justify-center px-0 text-xs text-foreground-500 hover:bg-neutral-50 hover:text-foreground-800', } as const; From 4fe3e5d9d678a837e98668bf8c4c82f34c81bd7f Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 13:41:55 +0200 Subject: [PATCH 18/34] fix: design of cards and activity filters --- .../components/activity/activity-filters.tsx | 3 + .../components/base-filter-content.tsx | 74 +++++++++++++++ .../components/multi-filter-content.tsx | 83 +++++------------ .../components/single-filter-content.tsx | 93 +++++-------------- .../components/text-filter-content.tsx | 33 ++++--- .../hooks/use-keyboard-navigation.ts | 53 +++++++++++ 6 files changed, 191 insertions(+), 148 deletions(-) create mode 100644 apps/dashboard/src/components/primitives/form/faceted-filter/components/base-filter-content.tsx create mode 100644 apps/dashboard/src/components/primitives/form/faceted-filter/hooks/use-keyboard-navigation.ts diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index 591ed1edf4a..bebd4c115bb 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -83,6 +83,9 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil ; + title?: string; + onClear: () => void; + size: SizeType; + hideSearch?: boolean; + hideClear?: boolean; + searchValue?: string; + onSearchChange?: (e: React.ChangeEvent) => void; + searchPlaceholder?: string; + showNavigationFooter?: boolean; + showEnterIcon?: boolean; + children?: React.ReactNode; +} + +export function BaseFilterContent({ + inputRef, + title, + onClear, + size, + hideSearch = false, + hideClear = false, + searchValue = '', + onSearchChange, + searchPlaceholder, + showNavigationFooter = false, + showEnterIcon = false, + children, +}: BaseFilterContentProps) { + return ( +
+
+ {title && {title}} + {!hideClear && searchValue && } +
+ + {!hideSearch && onSearchChange && ( + + )} + + {children} + + {showNavigationFooter && ( +
+
+
+ +
+
+ +
+ Navigate +
+
+ +
+
+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx index 4316c8a8bb1..106e356b9f6 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx @@ -1,12 +1,8 @@ import { Check } from 'lucide-react'; import { cn } from '../../../../../utils/ui'; import { FilterOption, SizeType } from '../types'; -import { STYLES } from '../styles'; -import { FilterInput } from './filter-input'; -import { ClearButton } from './clear-button'; -import { RiArrowDownLine, RiArrowUpLine } from 'react-icons/ri'; -import { EnterLineIcon } from '../../../../icons/enter-line'; -import { useEffect, useState } from 'react'; +import { BaseFilterContent } from './base-filter-content'; +import { useKeyboardNavigation } from '../hooks/use-keyboard-navigation'; interface MultiFilterContentProps { inputRef: React.RefObject; @@ -35,49 +31,28 @@ export function MultiFilterContent({ hideSearch = false, hideClear = false, }: MultiFilterContentProps) { - const [focusedIndex, setFocusedIndex] = useState(-1); + const { focusedIndex, setFocusedIndex } = useKeyboardNavigation({ + options, + onSelect, + }); - useEffect(() => { - function handleKeyDown(e: KeyboardEvent) { - if (options.length === 0) return; - - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : prev)); - break; - case 'ArrowUp': - e.preventDefault(); - setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev)); - break; - case 'Enter': - e.preventDefault(); - if (focusedIndex >= 0 && focusedIndex < options.length) { - onSelect(options[focusedIndex].value); - } - break; - } - } - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [focusedIndex, options, onSelect]); + const handleSearchChange = (e: React.ChangeEvent) => { + onSearchChange(e.target.value); + }; return ( -
-
- {title && {title}} - {!hideClear && selectedValues.size > 0 && } -
- {!hideSearch && ( - onSearchChange(e.target.value)} - placeholder={`Search ${title}...`} - size={size} - /> - )} +
{options.map((option, index) => { const isSelected = selectedValues.has(option.value); @@ -105,20 +80,6 @@ export function MultiFilterContent({ ); })}
-
-
-
- -
-
- -
- Navigate -
-
- -
-
-
+ ); } diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx index 6e1094e44f9..ac00f2623b8 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx @@ -1,12 +1,9 @@ import { RadioGroup, RadioGroupItem } from '../../../radio-group'; import { Label } from '../../../label'; import { FilterOption, SizeType } from '../types'; -import { FilterInput } from './filter-input'; -import { ClearButton } from './clear-button'; import { cn } from '../../../../../utils/ui'; -import { RiArrowDownLine, RiArrowUpLine } from 'react-icons/ri'; -import { EnterLineIcon } from '../../../../icons/enter-line'; -import { useEffect, useState } from 'react'; +import { BaseFilterContent } from './base-filter-content'; +import { useKeyboardNavigation } from '../hooks/use-keyboard-navigation'; interface SingleFilterContentProps { inputRef: React.RefObject; @@ -35,60 +32,30 @@ export function SingleFilterContent({ hideSearch = false, hideClear = false, }: SingleFilterContentProps) { - const [focusedIndex, setFocusedIndex] = useState(-1); const currentValue = Array.from(selectedValues)[0] || ''; + const { focusedIndex, setFocusedIndex } = useKeyboardNavigation({ + options, + onSelect, + initialSelectedValue: currentValue, + }); - useEffect(() => { - function handleKeyDown(e: KeyboardEvent) { - if (options.length === 0) return; - - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : prev)); - break; - case 'ArrowUp': - e.preventDefault(); - setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev)); - break; - case 'Enter': - e.preventDefault(); - if (focusedIndex >= 0 && focusedIndex < options.length) { - onSelect(options[focusedIndex].value); - } - break; - } - } - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [focusedIndex, options, onSelect]); - - // Initialize focusedIndex based on selected value - useEffect(() => { - if (currentValue) { - const index = options.findIndex((option) => option.value === currentValue); - if (index !== -1) { - setFocusedIndex(index); - } - } - }, [currentValue, options]); + const handleSearchChange = (e: React.ChangeEvent) => { + onSearchChange(e.target.value); + }; return ( -
-
- {title && {title}} - {!hideClear && selectedValues.size > 0 && } -
- {!hideSearch && ( - onSearchChange(e.target.value)} - placeholder={`Search ${title}...`} - size={size} - /> - )} +
{options.map((option, index) => { @@ -111,20 +78,6 @@ export function SingleFilterContent({ })}
-
-
-
- -
-
- -
- Navigate -
-
- -
-
-
+ ); } diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/text-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/text-filter-content.tsx index ee023d9d7f7..7f3a8fe1b7d 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/text-filter-content.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/text-filter-content.tsx @@ -1,8 +1,5 @@ import { SizeType } from '../types'; -import { STYLES } from '../styles'; -import { FilterInput } from './filter-input'; -import { ClearButton } from './clear-button'; -import { cn } from '../../../../../utils/ui'; +import { BaseFilterContent } from './base-filter-content'; interface TextFilterContentProps { inputRef: React.RefObject; @@ -13,6 +10,7 @@ interface TextFilterContentProps { size: SizeType; hideSearch?: boolean; hideClear?: boolean; + title?: string; } export function TextFilterContent({ @@ -24,20 +22,21 @@ export function TextFilterContent({ size, hideSearch = false, hideClear = false, + title, }: TextFilterContentProps) { return ( -
- {!hideSearch && ( - - )} - {!hideClear && value && } -
+ ); } diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/hooks/use-keyboard-navigation.ts b/apps/dashboard/src/components/primitives/form/faceted-filter/hooks/use-keyboard-navigation.ts new file mode 100644 index 00000000000..304191594a5 --- /dev/null +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/hooks/use-keyboard-navigation.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; +import { FilterOption } from '../types'; + +interface UseKeyboardNavigationProps { + options: FilterOption[]; + onSelect: (value: string) => void; + initialSelectedValue?: string; +} + +export function useKeyboardNavigation({ options, onSelect, initialSelectedValue }: UseKeyboardNavigationProps) { + const [focusedIndex, setFocusedIndex] = useState(-1); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (options.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : prev)); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + break; + case 'Enter': + e.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < options.length) { + onSelect(options[focusedIndex].value); + } + break; + } + } + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [focusedIndex, options, onSelect]); + + // Initialize focusedIndex based on selected value + useEffect(() => { + if (initialSelectedValue) { + const index = options.findIndex((option) => option.value === initialSelectedValue); + if (index !== -1) { + setFocusedIndex(index); + } + } + }, [initialSelectedValue, options]); + + return { + focusedIndex, + setFocusedIndex, + }; +} From 091aa316188875894bf0a56fbf0d7d002693976e Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 13:46:56 +0200 Subject: [PATCH 19/34] fix: refactor watch state --- .cspell.json | 3 ++- .../components/activity/activity-filters.tsx | 20 +++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.cspell.json b/.cspell.json index 5c2cc507f90..5835fe02191 100644 --- a/.cspell.json +++ b/.cspell.json @@ -713,7 +713,8 @@ "hsforms", "touchpoint", "Angularjs", - "navigatable" + "navigatable", + "facated" ], "flagWords": [], "patterns": [ diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index bebd4c115bb..e327e483414 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef } from 'react'; import { ChannelTypeEnum } from '@novu/shared'; import { useFetchWorkflows } from '../../hooks/use-fetch-workflows'; -import { useForm } from 'react-hook-form'; +import { useForm, useFormState } from 'react-hook-form'; import { Form, FormItem, FormField } from '../primitives/form/form'; import { Button } from '../primitives/button'; import { FacetedFormFilter } from '../primitives/form/faceted-filter/facated-form-filter'; @@ -46,19 +46,19 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil defaultValues: initialValues || defaultValues, }); - const { data: workflowTemplates } = useFetchWorkflows({}); + const watchedValues = form.watch(); const hasChanges = useMemo(() => { - const currentValues = form.getValues(); - return ( - currentValues.dateRange !== defaultValues.dateRange || - currentValues.channels.length > 0 || - currentValues.templates.length > 0 || - currentValues.transactionId !== defaultValues.transactionId || - currentValues.subscriberId !== defaultValues.subscriberId + watchedValues.dateRange !== defaultValues.dateRange || + watchedValues.channels.length > 0 || + watchedValues.templates.length > 0 || + watchedValues.transactionId !== defaultValues.transactionId || + watchedValues.subscriberId !== defaultValues.subscriberId ); - }, [form.watch()]); + }, [watchedValues]); + + const { data: workflowTemplates } = useFetchWorkflows({}); useEffect(() => { const subscription = form.watch((value) => { From e245bdda71715002705534c710e3ba55971ebc60 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 13:52:23 +0200 Subject: [PATCH 20/34] fix: imports --- apps/dashboard/src/components/activity/activity-filters.tsx | 4 ++-- .../form/faceted-filter/components/clear-button.tsx | 1 - .../form/faceted-filter/components/filter-input.tsx | 1 - .../primitives/form/faceted-filter/facated-form-filter.tsx | 1 - 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index e327e483414..f41e8ba36e0 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo } from 'react'; import { ChannelTypeEnum } from '@novu/shared'; import { useFetchWorkflows } from '../../hooks/use-fetch-workflows'; -import { useForm, useFormState } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { Form, FormItem, FormField } from '../primitives/form/form'; import { Button } from '../primitives/button'; import { FacetedFormFilter } from '../primitives/form/faceted-filter/facated-form-filter'; diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx index 9a4bd489675..85557d05e30 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx @@ -1,5 +1,4 @@ import { Button } from '../../../button'; -import { Separator } from '../../../separator'; import { cn } from '../../../../../utils/ui'; import { SizeType } from '../types'; import { STYLES } from '../styles'; diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx index 632530d5837..5b5d9e25c03 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx @@ -2,7 +2,6 @@ import { Input } from '../../../input'; import { cn } from '../../../../../utils/ui'; import { SizeType } from '../types'; import { STYLES } from '../styles'; -import { ArrowLeftRight } from 'lucide-react'; import { EnterLineIcon } from '../../../../icons/enter-line'; interface FilterInputProps { diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx index 78041024d34..1f9e95a2d43 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { PlusCircle } from 'lucide-react'; import { Button } from '../../button'; -import { Separator } from '../../separator'; import { Popover, PopoverContent, PopoverTrigger } from '../../popover'; import { cn } from '../../../../utils/ui'; import { FacetedFilterProps } from './types'; From 13c6b27553d20a88c8866078a38963652884c5d5 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 13:54:39 +0200 Subject: [PATCH 21/34] fix: remove unused --- .../form/faceted-filter/facated-form-filter.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx index 1f9e95a2d43..02b3f048276 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx @@ -1,14 +1,14 @@ -import * as React from 'react'; import { PlusCircle } from 'lucide-react'; +import * as React from 'react'; +import { cn } from '../../../../utils/ui'; import { Button } from '../../button'; import { Popover, PopoverContent, PopoverTrigger } from '../../popover'; -import { cn } from '../../../../utils/ui'; -import { FacetedFilterProps } from './types'; -import { STYLES } from './styles'; import { FilterBadge } from './components/filter-badge'; -import { TextFilterContent } from './components/text-filter-content'; -import { SingleFilterContent } from './components/single-filter-content'; import { MultiFilterContent } from './components/multi-filter-content'; +import { SingleFilterContent } from './components/single-filter-content'; +import { TextFilterContent } from './components/text-filter-content'; +import { STYLES } from './styles'; +import { FacetedFilterProps } from './types'; export function FacetedFormFilter({ title, From 290a62bb2742867f4ca7d67b8bf26031f96bd699 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 15:00:44 +0200 Subject: [PATCH 22/34] feat: empty state --- .../activity/activity-empty-state.tsx | 125 ++++++++++++++++++ .../components/activity/activity-filters.tsx | 22 +-- .../components/activity/activity-table.tsx | 15 ++- apps/dashboard/src/pages/activity-feed.tsx | 23 +++- 4 files changed, 172 insertions(+), 13 deletions(-) create mode 100644 apps/dashboard/src/components/activity/activity-empty-state.tsx diff --git a/apps/dashboard/src/components/activity/activity-empty-state.tsx b/apps/dashboard/src/components/activity/activity-empty-state.tsx new file mode 100644 index 00000000000..262205858dd --- /dev/null +++ b/apps/dashboard/src/components/activity/activity-empty-state.tsx @@ -0,0 +1,125 @@ +import { Button } from '@/components/primitives/button'; +import { cn } from '@/utils/ui'; +import { PlayCircleIcon } from 'lucide-react'; +import { RiCloseCircleLine } from 'react-icons/ri'; +import { motion } from 'motion/react'; +import { ExternalLink } from '../shared/external-link'; +import { useNavigate } from 'react-router-dom'; +import { buildRoute, ROUTES } from '@/utils/routes'; +import { useEnvironment } from '@/context/environment/hooks'; + +interface ActivityEmptyStateProps { + className?: string; + emptySearchResults?: boolean; + onClearFilters?: () => void; +} + +export function ActivityEmptyState({ className, emptySearchResults, onClearFilters }: ActivityEmptyStateProps) { + const navigate = useNavigate(); + const { currentEnvironment } = useEnvironment(); + + const handleNavigateToWorkflows = () => { + navigate(buildRoute(ROUTES.WORKFLOWS, { environmentSlug: currentEnvironment?.slug ?? '' })); + }; + + return ( +
+ +
+ +
+ +
+

+ {emptySearchResults ? 'No activity match that filter' : 'No activity in the past 30 days'} +

+

+ {emptySearchResults + ? 'Try changing your filter to see more activity or trigger notifications that match your search criteria.' + : "Your activity feed is empty. Once events start appearing, you'll be able to track notifications, troubleshoot issues, and view delivery details."} +

+
+ + {emptySearchResults && onClearFilters && ( +
+ +
+ )} + + {!emptySearchResults && ( +
+ + View Docs + + +
+ )} +
+
+ ); +} + +function ActivitiyIllustration() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index f41e8ba36e0..00ca4ff1fa6 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -7,12 +7,13 @@ import { Button } from '../primitives/button'; import { FacetedFormFilter } from '../primitives/form/faceted-filter/facated-form-filter'; import { CalendarIcon } from 'lucide-react'; -interface IActivityFilters { +export interface IActivityFilters { onFiltersChange: (filters: IActivityFiltersData) => void; initialValues: IActivityFiltersData; + onReset?: () => void; } -interface IActivityFiltersData { +export interface IActivityFiltersData { dateRange: string; channels: ChannelTypeEnum[]; templates: string[]; @@ -33,28 +34,28 @@ const CHANNEL_OPTIONS = [ { value: ChannelTypeEnum.PUSH, label: 'Push' }, ]; -const defaultValues: IActivityFiltersData = { +export const defaultActivityFilters: IActivityFiltersData = { dateRange: '30d', channels: [], templates: [], transactionId: '', subscriberId: '', -}; +} as const; -export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFilters) { +export function ActivityFilters({ onFiltersChange, initialValues, onReset }: IActivityFilters) { const form = useForm({ - defaultValues: initialValues || defaultValues, + defaultValues: initialValues || defaultActivityFilters, }); const watchedValues = form.watch(); const hasChanges = useMemo(() => { return ( - watchedValues.dateRange !== defaultValues.dateRange || + watchedValues.dateRange !== defaultActivityFilters.dateRange || watchedValues.channels.length > 0 || watchedValues.templates.length > 0 || - watchedValues.transactionId !== defaultValues.transactionId || - watchedValues.subscriberId !== defaultValues.subscriberId + watchedValues.transactionId !== defaultActivityFilters.transactionId || + watchedValues.subscriberId !== defaultActivityFilters.subscriberId ); }, [watchedValues]); @@ -69,7 +70,8 @@ export function ActivityFilters({ onFiltersChange, initialValues }: IActivityFil }, [form, onFiltersChange]); const handleReset = () => { - form.reset(defaultValues); + form.reset(defaultActivityFilters); + onReset?.(); }; return ( diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index 6a0781420ee..b27c99ff6f8 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -12,6 +12,7 @@ import { useActivities } from '@/hooks/use-activities'; import { StatusBadge } from './components/status-badge'; import { StepIndicators } from './components/step-indicators'; import { Pagination } from './components/pagination'; +import { ActivityEmptyState } from './activity-empty-state'; import { useRef, useEffect } from 'react'; import { IActivityFilters } from '@/api/activity'; @@ -19,9 +20,17 @@ export interface ActivityTableProps { selectedActivityId: string | null; onActivitySelect: (activity: IActivity) => void; filters?: IActivityFilters; + hasActiveFilters: boolean; + onClearFilters: () => void; } -export function ActivityTable({ selectedActivityId, onActivitySelect, filters }: ActivityTableProps) { +export function ActivityTable({ + selectedActivityId, + onActivitySelect, + filters, + hasActiveFilters, + onClearFilters, +}: ActivityTableProps) { const queryClient = useQueryClient(); const { currentEnvironment } = useEnvironment(); const hoverTimerRef = useRef(null); @@ -65,6 +74,10 @@ export function ActivityTable({ selectedActivityId, onActivitySelect, filters }: }; }, []); + if (!isLoading && activities.length === 0) { + return ; + } + return (
{ + // Ignore endDate as it's always present + if (key === 'endDate') return false; + // For arrays, check if they have any items + if (Array.isArray(value)) return value.length > 0; + // For other values, check if they exist + return !!value; + }); + + const handleClearFilters = () => { + handleFiltersChange(defaultActivityFilters); + }; + return ( <> @@ -30,12 +43,18 @@ export function ActivityFeed() { } > - +
From f0cea85d806c1512a8ea5c353d5b5d0be3bac3b0 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 15:06:35 +0200 Subject: [PATCH 23/34] fix: flicker --- .../activity/activity-empty-state.tsx | 127 +++++++++++----- .../components/activity/activity-table.tsx | 138 ++++++++++-------- apps/dashboard/src/pages/activity-feed.tsx | 2 +- 3 files changed, 169 insertions(+), 98 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-empty-state.tsx b/apps/dashboard/src/components/activity/activity-empty-state.tsx index 262205858dd..f6722051ad2 100644 --- a/apps/dashboard/src/components/activity/activity-empty-state.tsx +++ b/apps/dashboard/src/components/activity/activity-empty-state.tsx @@ -2,7 +2,7 @@ import { Button } from '@/components/primitives/button'; import { cn } from '@/utils/ui'; import { PlayCircleIcon } from 'lucide-react'; import { RiCloseCircleLine } from 'react-icons/ri'; -import { motion } from 'motion/react'; +import { motion, AnimatePresence } from 'motion/react'; import { ExternalLink } from '../shared/external-link'; import { useNavigate } from 'react-router-dom'; import { buildRoute, ROUTES } from '@/utils/routes'; @@ -23,50 +23,99 @@ export function ActivityEmptyState({ className, emptySearchResults, onClearFilte }; return ( -
+ -
- -
+ + + + -
-

- {emptySearchResults ? 'No activity match that filter' : 'No activity in the past 30 days'} -

-

- {emptySearchResults - ? 'Try changing your filter to see more activity or trigger notifications that match your search criteria.' - : "Your activity feed is empty. Once events start appearing, you'll be able to track notifications, troubleshoot issues, and view delivery details."} -

-
+ +

+ {emptySearchResults ? 'No activity match that filter' : 'No activity in the past 30 days'} +

+

+ {emptySearchResults + ? 'Try changing your filter to see more activity or trigger notifications that match your search criteria.' + : "Your activity feed is empty. Once events start appearing, you'll be able to track notifications, troubleshoot issues, and view delivery details."} +

+
- {emptySearchResults && onClearFilters && ( -
- -
- )} + {emptySearchResults && onClearFilters && ( + + + + )} - {!emptySearchResults && ( -
- - View Docs - - -
- )} + {!emptySearchResults && ( + + + View Docs + + + + )} +
-
+ ); } diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index b27c99ff6f8..b44e18948ad 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -15,6 +15,7 @@ import { Pagination } from './components/pagination'; import { ActivityEmptyState } from './activity-empty-state'; import { useRef, useEffect } from 'react'; import { IActivityFilters } from '@/api/activity'; +import { AnimatePresence, motion } from 'motion/react'; export interface ActivityTableProps { selectedActivityId: string | null; @@ -74,66 +75,87 @@ export function ActivityTable({ }; }, []); - if (!isLoading && activities.length === 0) { - return ; - } - return ( -
-
} - containerClassname="border-x-0 border-b-0 border-t border-t-neutral-200 rounded-none shadow-none" - > - - - Event - Status - Steps - Triggered Date - - - - {activities.map((activity) => ( - onActivitySelect(activity)} - onMouseEnter={() => handleRowMouseEnter(activity)} - onMouseLeave={handleRowMouseLeave} - > - -
- - {activity.template?.name || 'Deleted workflow'} - - - {activity.transactionId} {getSubscriberDisplay(activity.subscriber)} - -
-
- - - - - - - - - {formatDate(activity.createdAt)} - - -
- ))} -
-
+ + {!isLoading && activities.length === 0 ? ( + + + + ) : ( + + } + containerClassname="border-x-0 border-b-0 border-t border-t-neutral-200 rounded-none shadow-none" + > + + + Event + Status + Steps + Triggered Date + + + + {activities.map((activity) => ( + onActivitySelect(activity)} + onMouseEnter={() => handleRowMouseEnter(activity)} + onMouseLeave={handleRowMouseLeave} + > + +
+ + {activity.template?.name || 'Deleted workflow'} + + + {activity.transactionId}{' '} + {getSubscriberDisplay( + activity.subscriber as Pick + )} + +
+
+ + + + + + + + + {formatDate(activity.createdAt)} + + +
+ ))} +
+
- -
+ + + )} + ); } diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx index 8dbfcdcb305..beb64fe9c3c 100644 --- a/apps/dashboard/src/pages/activity-feed.tsx +++ b/apps/dashboard/src/pages/activity-feed.tsx @@ -48,7 +48,7 @@ export function ActivityFeed() { initialValues={filterValues} onReset={handleClearFilters} /> -
+
Date: Tue, 10 Dec 2024 15:08:06 +0200 Subject: [PATCH 24/34] Update activity-empty-state.tsx --- .../src/components/activity/activity-empty-state.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-empty-state.tsx b/apps/dashboard/src/components/activity/activity-empty-state.tsx index f6722051ad2..7bbe06d807c 100644 --- a/apps/dashboard/src/components/activity/activity-empty-state.tsx +++ b/apps/dashboard/src/components/activity/activity-empty-state.tsx @@ -55,7 +55,7 @@ export function ActivityEmptyState({ className, emptySearchResults, onClearFilte }} className="relative" > - + From d047bb73140be09b85ddda261f291ef060d3ef26 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 15:08:53 +0200 Subject: [PATCH 25/34] Update activity-empty-state.tsx --- .../src/components/activity/activity-empty-state.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-empty-state.tsx b/apps/dashboard/src/components/activity/activity-empty-state.tsx index 7bbe06d807c..d9e3fc15bd5 100644 --- a/apps/dashboard/src/components/activity/activity-empty-state.tsx +++ b/apps/dashboard/src/components/activity/activity-empty-state.tsx @@ -122,7 +122,7 @@ export function ActivityEmptyState({ className, emptySearchResults, onClearFilte function ActivityIllustration() { return ( - + - + - - + + - - + + From 474181a5c7fdfc357099182d884ae9fa709e4546 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 19:32:14 +0200 Subject: [PATCH 26/34] fix: merge --- .../src/components/activity/activity-filters.tsx | 8 ++++---- apps/dashboard/src/hooks/use-activity-url-state.ts | 12 ++++++------ apps/dashboard/src/types/activity.ts | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index 00ca4ff1fa6..f95d5f950df 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -16,7 +16,7 @@ export interface IActivityFilters { export interface IActivityFiltersData { dateRange: string; channels: ChannelTypeEnum[]; - templates: string[]; + workflows: string[]; transactionId: string; subscriberId: string; } @@ -37,7 +37,7 @@ const CHANNEL_OPTIONS = [ export const defaultActivityFilters: IActivityFiltersData = { dateRange: '30d', channels: [], - templates: [], + workflows: [], transactionId: '', subscriberId: '', } as const; @@ -53,7 +53,7 @@ export function ActivityFilters({ onFiltersChange, initialValues, onReset }: IAc return ( watchedValues.dateRange !== defaultActivityFilters.dateRange || watchedValues.channels.length > 0 || - watchedValues.templates.length > 0 || + watchedValues.workflows.length > 0 || watchedValues.transactionId !== defaultActivityFilters.transactionId || watchedValues.subscriberId !== defaultActivityFilters.subscriberId ); @@ -100,7 +100,7 @@ export function ActivityFilters({ onFiltersChange, initialValues, onReset }: IAc ( Date: Thu, 12 Dec 2024 10:03:24 +0200 Subject: [PATCH 27/34] fix: merge --- .../src/components/activity/activity-table.tsx | 12 +++++++++--- apps/dashboard/src/pages/activity-feed.tsx | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index 524dfa8847a..b8a075c67c8 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -19,9 +19,17 @@ export interface ActivityTableProps { selectedActivityId: string | null; onActivitySelect: (activity: IActivity) => void; filters?: IActivityFilters; + hasActiveFilters: boolean; + onClearFilters: () => void; } -export function ActivityTable({ selectedActivityId, onActivitySelect, filters }: ActivityTableProps) { +export function ActivityTable({ + selectedActivityId, + onActivitySelect, + filters, + hasActiveFilters, + onClearFilters, +}: ActivityTableProps) { const [searchParams] = useSearchParams(); const location = useLocation(); const navigate = useNavigate(); @@ -89,8 +97,6 @@ export function ActivityTable({ selectedActivityId, onActivitySelect, filters }: 'bg-neutral-50 after:absolute after:right-0 after:top-0 after:h-[calc(100%-1px)] after:w-[5px] after:bg-neutral-200' )} onClick={() => onActivitySelect(activity)} - onMouseEnter={() => handleRowMouseEnter(activity)} - onMouseLeave={handleRowMouseLeave} >
diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx index 086b40d4589..7f1ed1f40fa 100644 --- a/apps/dashboard/src/pages/activity-feed.tsx +++ b/apps/dashboard/src/pages/activity-feed.tsx @@ -10,7 +10,7 @@ import { useDebounce } from '@/hooks/use-debounce'; import { useSearchParams } from 'react-router-dom'; export function ActivityFeed() { - const [searchParams, setSearchParams] = useSearchParams(); + const [_, setSearchParams] = useSearchParams(); const { activityItemId, From 0566b89b2d632d1676288ec7d2ea571c7e135703 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 12 Dec 2024 11:29:11 +0200 Subject: [PATCH 28/34] fix: layout --- .../src/components/activity/activity-filters.tsx | 2 +- apps/dashboard/src/pages/activity-feed.tsx | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index f95d5f950df..1899b144237 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -76,7 +76,7 @@ export function ActivityFilters({ onFiltersChange, initialValues, onReset }: IAc return ( - + } > -
- - + +
Date: Thu, 12 Dec 2024 11:40:01 +0200 Subject: [PATCH 29/34] fix: pr comments --- .../components/activity/activity-filters.tsx | 21 ++++++++++--------- .../components/base-filter-content.tsx | 4 ++-- .../faceted-filter/facated-form-filter.tsx | 6 +----- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-filters.tsx b/apps/dashboard/src/components/activity/activity-filters.tsx index 1899b144237..ddcf836b1b8 100644 --- a/apps/dashboard/src/components/activity/activity-filters.tsx +++ b/apps/dashboard/src/components/activity/activity-filters.tsx @@ -7,19 +7,19 @@ import { Button } from '../primitives/button'; import { FacetedFormFilter } from '../primitives/form/faceted-filter/facated-form-filter'; import { CalendarIcon } from 'lucide-react'; -export interface IActivityFilters { - onFiltersChange: (filters: IActivityFiltersData) => void; - initialValues: IActivityFiltersData; +export type ActivityFilters = { + onFiltersChange: (filters: ActivityFiltersData) => void; + initialValues: ActivityFiltersData; onReset?: () => void; -} +}; -export interface IActivityFiltersData { +export type ActivityFiltersData = { dateRange: string; channels: ChannelTypeEnum[]; workflows: string[]; transactionId: string; subscriberId: string; -} +}; const DATE_RANGE_OPTIONS = [ { value: '24h', label: 'Last 24 hours' }, @@ -32,9 +32,10 @@ const CHANNEL_OPTIONS = [ { value: ChannelTypeEnum.EMAIL, label: 'Email' }, { value: ChannelTypeEnum.IN_APP, label: 'In-App' }, { value: ChannelTypeEnum.PUSH, label: 'Push' }, + { value: ChannelTypeEnum.CHAT, label: 'Chat' }, ]; -export const defaultActivityFilters: IActivityFiltersData = { +export const defaultActivityFilters: ActivityFiltersData = { dateRange: '30d', channels: [], workflows: [], @@ -42,8 +43,8 @@ export const defaultActivityFilters: IActivityFiltersData = { subscriberId: '', } as const; -export function ActivityFilters({ onFiltersChange, initialValues, onReset }: IActivityFilters) { - const form = useForm({ +export function ActivityFilters({ onFiltersChange, initialValues, onReset }: ActivityFilters) { + const form = useForm({ defaultValues: initialValues || defaultActivityFilters, }); @@ -63,7 +64,7 @@ export function ActivityFilters({ onFiltersChange, initialValues, onReset }: IAc useEffect(() => { const subscription = form.watch((value) => { - onFiltersChange(value as IActivityFiltersData); + onFiltersChange(value as ActivityFiltersData); }); return () => subscription.unsubscribe(); diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/base-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/base-filter-content.tsx index 82c8f58b538..8b6cf71aa79 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/base-filter-content.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/base-filter-content.tsx @@ -34,7 +34,7 @@ export function BaseFilterContent({ children, }: BaseFilterContentProps) { return ( -
+
{title && {title}} {!hideClear && searchValue && } @@ -51,7 +51,7 @@ export function BaseFilterContent({ /> )} - {children} +
{children}
{showNavigationFooter && (
diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx index 02b3f048276..d4f89fb62dd 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx @@ -72,11 +72,7 @@ export function FacetedFormFilter({ const renderTriggerContent = () => { if (type === 'text' && currentValue) { - return ( - <> - - - ); + return ; } if (selectedValues.size === 0) return null; From 3c716bfc2f924e6fe35af9dd7d62fef8e5d7308a Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 12 Dec 2024 11:57:17 +0200 Subject: [PATCH 30/34] fix: filternames --- apps/dashboard/src/api/activity.ts | 6 +++--- .../activity/activity-empty-state.tsx | 2 +- .../components/activity/activity-filters.tsx | 3 ++- .../components/activity/activity-table.tsx | 4 ++-- .../components/base-filter-content.tsx | 4 ++-- .../faceted-filter/facated-form-filter.tsx | 14 ++++++++++--- .../src/hooks/use-activity-url-state.ts | 20 +++++++++---------- .../src/hooks/use-fetch-activities.ts | 4 ++-- apps/dashboard/src/types/activity.ts | 14 ++++++------- 9 files changed, 40 insertions(+), 31 deletions(-) diff --git a/apps/dashboard/src/api/activity.ts b/apps/dashboard/src/api/activity.ts index 85bedb29b88..70266e5e62e 100644 --- a/apps/dashboard/src/api/activity.ts +++ b/apps/dashboard/src/api/activity.ts @@ -1,7 +1,7 @@ import { IActivity, IEnvironment } from '@novu/shared'; import { get } from './api.client'; -export interface IActivityFilters { +export type ActivityFilters = { channels?: string[]; workflows?: string[]; email?: string; @@ -9,7 +9,7 @@ export interface IActivityFilters { transactionId?: string; startDate?: string; endDate?: string; -} +}; interface ActivityResponse { data: IActivity[]; @@ -20,7 +20,7 @@ interface ActivityResponse { export function getActivityList( environment: IEnvironment, page = 0, - filters?: IActivityFilters, + filters?: ActivityFilters, signal?: AbortSignal ): Promise { const searchParams = new URLSearchParams(); diff --git a/apps/dashboard/src/components/activity/activity-empty-state.tsx b/apps/dashboard/src/components/activity/activity-empty-state.tsx index d9e3fc15bd5..33821eac170 100644 --- a/apps/dashboard/src/components/activity/activity-empty-state.tsx +++ b/apps/dashboard/src/components/activity/activity-empty-state.tsx @@ -26,7 +26,7 @@ export function ActivityEmptyState({ className, emptySearchResults, onClearFilte { form.reset(defaultActivityFilters); + onFiltersChange(defaultActivityFilters); onReset?.(); }; @@ -173,7 +174,7 @@ export function ActivityFilters({ onFiltersChange, initialValues, onReset }: Act /> {hasChanges && ( - )} diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index b8a075c67c8..ba6ebbcf06a 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -10,7 +10,7 @@ import { ActivityEmptyState } from './activity-empty-state'; import { AnimatePresence, motion } from 'motion/react'; import { ArrowPagination } from './components/arrow-pagination'; import { useEffect } from 'react'; -import { IActivityFilters } from '@/api/activity'; +import { ActivityFilters } from '@/api/activity'; import { useFetchActivities } from '../../hooks/use-fetch-activities'; import { toast } from 'sonner'; import { Skeleton } from '@/components/primitives/skeleton'; @@ -18,7 +18,7 @@ import { Skeleton } from '@/components/primitives/skeleton'; export interface ActivityTableProps { selectedActivityId: string | null; onActivitySelect: (activity: IActivity) => void; - filters?: IActivityFilters; + filters?: ActivityFilters; hasActiveFilters: boolean; onClearFilters: () => void; } diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/base-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/base-filter-content.tsx index 8b6cf71aa79..c8b9395ec6c 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/base-filter-content.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/base-filter-content.tsx @@ -37,7 +37,7 @@ export function BaseFilterContent({
{title && {title}} - {!hideClear && searchValue && } + {!hideClear && }
{!hideSearch && onSearchChange && ( @@ -51,7 +51,7 @@ export function BaseFilterContent({ /> )} -
{children}
+
{children}
{showNavigationFooter && (
diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx index d4f89fb62dd..abb848ba445 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/facated-form-filter.tsx @@ -96,6 +96,16 @@ export function FacetedFormFilter({ ); }; + const isEmpty = type === 'text' ? !currentValue : selectedValues.size === 0; + + const shouldShowClear = React.useMemo(() => { + if (hideClear) return false; + if (type === 'text') return Boolean(currentValue); + if (type === 'multi' || type === 'single') return !isEmpty; + + return false; + }, [hideClear, type, currentValue, isEmpty]); + const renderContent = () => { const commonProps = { inputRef, @@ -103,7 +113,7 @@ export function FacetedFormFilter({ size, onClear: handleClear, hideSearch, - hideClear, + hideClear: !shouldShowClear, }; if (type === 'text') { @@ -129,8 +139,6 @@ export function FacetedFormFilter({ return type === 'single' ? : ; }; - const isEmpty = type === 'text' ? !currentValue : selectedValues.size === 0; - return ( diff --git a/apps/dashboard/src/hooks/use-activity-url-state.ts b/apps/dashboard/src/hooks/use-activity-url-state.ts index 2bcf04dec59..248e1947c5e 100644 --- a/apps/dashboard/src/hooks/use-activity-url-state.ts +++ b/apps/dashboard/src/hooks/use-activity-url-state.ts @@ -1,13 +1,13 @@ import { useSearchParams } from 'react-router-dom'; import { useCallback, useMemo } from 'react'; import { IActivity, ChannelTypeEnum } from '@novu/shared'; -import { IActivityFilters } from '@/api/activity'; -import { IActivityFiltersData, IActivityUrlState } from '@/types/activity'; +import { ActivityFilters } from '@/api/activity'; +import { ActivityFiltersData, ActivityUrlState } from '@/types/activity'; const DEFAULT_DATE_RANGE = '30d'; -function parseFilters(searchParams: URLSearchParams): IActivityFilters { - const result: IActivityFilters = {}; +function parseFilters(searchParams: URLSearchParams): ActivityFilters { + const result: ActivityFilters = {}; const channels = searchParams.get('channels')?.split(',').filter(Boolean); if (channels?.length) { @@ -48,19 +48,19 @@ function getDateRangeInDays(range: string): number { } } -function parseFilterValues(searchParams: URLSearchParams): IActivityFiltersData { +function parseFilterValues(searchParams: URLSearchParams): ActivityFiltersData { return { dateRange: searchParams.get('dateRange') || DEFAULT_DATE_RANGE, channels: (searchParams.get('channels')?.split(',').filter(Boolean) as ChannelTypeEnum[]) || [], - workflows: searchParams.get('templates')?.split(',').filter(Boolean) || [], + workflows: searchParams.get('workflows')?.split(',').filter(Boolean) || [], transactionId: searchParams.get('transactionId') || '', subscriberId: searchParams.get('subscriberId') || '', }; } -export function useActivityUrlState(): IActivityUrlState & { +export function useActivityUrlState(): ActivityUrlState & { handleActivitySelect: (activity: IActivity) => void; - handleFiltersChange: (data: IActivityFiltersData) => void; + handleFiltersChange: (data: ActivityFiltersData) => void; } { const [searchParams, setSearchParams] = useSearchParams(); const activityItemId = searchParams.get('activityItemId'); @@ -80,9 +80,9 @@ export function useActivityUrlState(): IActivityUrlState & { ); const handleFiltersChange = useCallback( - (data: IActivityFiltersData) => { + (data: ActivityFiltersData) => { setSearchParams((prev) => { - ['channels', 'templates', 'transactionId', 'subscriberId', 'dateRange'].forEach((key) => prev.delete(key)); + ['channels', 'workflows', 'transactionId', 'subscriberId', 'dateRange'].forEach((key) => prev.delete(key)); if (data.channels?.length) { prev.set('channels', data.channels.join(',')); diff --git a/apps/dashboard/src/hooks/use-fetch-activities.ts b/apps/dashboard/src/hooks/use-fetch-activities.ts index 393cef86c45..2142b9682d1 100644 --- a/apps/dashboard/src/hooks/use-fetch-activities.ts +++ b/apps/dashboard/src/hooks/use-fetch-activities.ts @@ -1,10 +1,10 @@ import { useQuery } from '@tanstack/react-query'; -import { getActivityList, IActivityFilters } from '@/api/activity'; +import { getActivityList, ActivityFilters } from '@/api/activity'; import { useEnvironment } from '../context/environment/hooks'; import { IActivity } from '@novu/shared'; interface UseActivitiesOptions { - filters?: IActivityFilters; + filters?: ActivityFilters; page?: number; limit?: number; } diff --git a/apps/dashboard/src/types/activity.ts b/apps/dashboard/src/types/activity.ts index 43c878d4719..189e3214855 100644 --- a/apps/dashboard/src/types/activity.ts +++ b/apps/dashboard/src/types/activity.ts @@ -1,16 +1,16 @@ import { ChannelTypeEnum } from '@novu/shared'; -import { IActivityFilters } from '@/api/activity'; +import { ActivityFilters } from '@/api/activity'; -export interface IActivityFiltersData { +export type ActivityFiltersData = { dateRange: string; channels: ChannelTypeEnum[]; workflows: string[]; transactionId: string; subscriberId: string; -} +}; -export interface IActivityUrlState { +export type ActivityUrlState = { activityItemId: string | null; - filters: IActivityFilters; - filterValues: IActivityFiltersData; -} + filters: ActivityFilters; + filterValues: ActivityFiltersData; +}; From 06057e778276b3eab3021346749763abc74461f4 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 12 Dec 2024 12:06:41 +0200 Subject: [PATCH 31/34] fix: copy --- .../src/components/activity/activity-empty-state.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-empty-state.tsx b/apps/dashboard/src/components/activity/activity-empty-state.tsx index 33821eac170..e4880ea9a02 100644 --- a/apps/dashboard/src/components/activity/activity-empty-state.tsx +++ b/apps/dashboard/src/components/activity/activity-empty-state.tsx @@ -72,8 +72,8 @@ export function ActivityEmptyState({ className, emptySearchResults, onClearFilte

{emptySearchResults - ? 'Try changing your filter to see more activity or trigger notifications that match your search criteria.' - : "Your activity feed is empty. Once events start appearing, you'll be able to track notifications, troubleshoot issues, and view delivery details."} + ? 'Change your search criteria.' + : 'Your activity feed is empty. Once you trigger your first workflow, you can monitor notifications and view delivery details.'}

From a57232d6df901e4980f007d52d4d66ab66c04d59 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 12 Dec 2024 12:12:10 +0200 Subject: [PATCH 32/34] fix: pr fixes --- .../components/clear-button.tsx | 8 ++-- .../components/filter-input.tsx | 28 ++++++------- .../components/multi-filter-content.tsx | 4 +- .../components/single-filter-content.tsx | 40 +++++++++---------- 4 files changed, 37 insertions(+), 43 deletions(-) diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx index 85557d05e30..90bb6ef7cb3 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/clear-button.tsx @@ -13,10 +13,8 @@ interface ClearButtonProps { export function ClearButton({ onClick, size, label = 'Clear filter', className }: ClearButtonProps) { return ( - <> - - + ); } diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx index 5b5d9e25c03..e9f14bf1f87 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/filter-input.tsx @@ -15,21 +15,19 @@ interface FilterInputProps { export function FilterInput({ inputRef, value, onChange, placeholder, size, showEnterIcon = false }: FilterInputProps) { return ( -
-
- - {showEnterIcon && ( -
- -
- )} -
+
+ + {showEnterIcon && ( +
+ +
+ )}
); } diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx index 106e356b9f6..451a9fdced6 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/multi-filter-content.tsx @@ -4,7 +4,7 @@ import { FilterOption, SizeType } from '../types'; import { BaseFilterContent } from './base-filter-content'; import { useKeyboardNavigation } from '../hooks/use-keyboard-navigation'; -interface MultiFilterContentProps { +type MultiFilterContentProps = { inputRef: React.RefObject; title?: string; options: FilterOption[]; @@ -16,7 +16,7 @@ interface MultiFilterContentProps { size: SizeType; hideSearch?: boolean; hideClear?: boolean; -} +}; export function MultiFilterContent({ inputRef, diff --git a/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx b/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx index ac00f2623b8..d086588d136 100644 --- a/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx +++ b/apps/dashboard/src/components/primitives/form/faceted-filter/components/single-filter-content.tsx @@ -56,28 +56,26 @@ export function SingleFilterContent({ searchPlaceholder={`Search ${title}...`} showNavigationFooter={true} > -
- - {options.map((option, index) => { - const isFocused = index === focusedIndex; + + {options.map((option, index) => { + const isFocused = index === focusedIndex; - return ( -
setFocusedIndex(index)} - onClick={() => onSelect(option.value)} - > - - -
- ); - })} -
-
+ return ( +
setFocusedIndex(index)} + onClick={() => onSelect(option.value)} + > + + +
+ ); + })} + ); } From 70551d79bdaf7440f33fbf9029338be65347205f Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 12 Dec 2024 12:50:43 +0200 Subject: [PATCH 33/34] fix: range clicking --- apps/dashboard/src/api/activity.ts | 23 +++++++++++++------ .../components/activity/activity-table.tsx | 11 +++++---- .../src/hooks/use-activity-url-state.ts | 15 +----------- .../src/hooks/use-fetch-activities.ts | 12 ++++++++-- apps/dashboard/src/pages/activity-feed.tsx | 14 +++++++---- 5 files changed, 43 insertions(+), 32 deletions(-) diff --git a/apps/dashboard/src/api/activity.ts b/apps/dashboard/src/api/activity.ts index 70266e5e62e..059e72d8606 100644 --- a/apps/dashboard/src/api/activity.ts +++ b/apps/dashboard/src/api/activity.ts @@ -7,8 +7,7 @@ export type ActivityFilters = { email?: string; subscriberId?: string; transactionId?: string; - startDate?: string; - endDate?: string; + dateRange?: string; }; interface ActivityResponse { @@ -50,11 +49,9 @@ export function getActivityList( searchParams.append('transactionId', filters.transactionId); } - if (filters?.startDate) { - searchParams.append('startDate', filters.startDate); - } - if (filters?.endDate) { - searchParams.append('endDate', filters.endDate); + if (filters?.dateRange) { + const endDate = new Date(Date.now() - getDateRangeInDays(filters?.dateRange) * 24 * 60 * 60 * 1000); + searchParams.append('endDate', endDate.toISOString()); } return get(`/notifications?${searchParams.toString()}`, { @@ -63,6 +60,18 @@ export function getActivityList( }); } +function getDateRangeInDays(range: string): number { + switch (range) { + case '24h': + return 1; + case '7d': + return 7; + case '30d': + default: + return 30; + } +} + export function getNotification(notificationId: string, environment: IEnvironment) { return get<{ data: IActivity }>(`/notifications/${notificationId}`, { environment, diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index ba6ebbcf06a..6b067946f30 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -34,7 +34,11 @@ export function ActivityTable({ const location = useLocation(); const navigate = useNavigate(); const page = parsePageParam(searchParams.get('page')); - const { activities, isLoading, hasMore, error } = useFetchActivities({ filters, page }); + const { activities, isLoading, hasMore, error } = useFetchActivities({ + filters, + page, + refetchOnWindowFocus: true, + }); useEffect(() => { if (error) { @@ -44,13 +48,13 @@ export function ActivityTable({ } }, [error]); - const handlePageChange = (newPage: number) => { + function handlePageChange(newPage: number) { const newParams = createSearchParams({ ...Object.fromEntries(searchParams), page: newPage.toString(), }); navigate(`${location.pathname}?${newParams}`); - }; + } return ( @@ -178,7 +182,6 @@ function getSubscriberDisplay(subscriber?: Pick({ queryKey: ['activitiesList', currentEnvironment?._id, page, filters], queryFn: ({ signal }) => getActivityList(currentEnvironment!, page, filters, signal), - staleTime: 0, + staleTime, + refetchOnWindowFocus, enabled: !!currentEnvironment, }); diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx index cdbd1eca281..1b96139a8a3 100644 --- a/apps/dashboard/src/pages/activity-feed.tsx +++ b/apps/dashboard/src/pages/activity-feed.tsx @@ -10,7 +10,7 @@ import { useDebounce } from '@/hooks/use-debounce'; import { useSearchParams } from 'react-router-dom'; export function ActivityFeed() { - const [_, setSearchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const { activityItemId, @@ -25,6 +25,7 @@ export function ActivityFeed() { const hasActiveFilters = Object.entries(filters).some(([key, value]) => { // Ignore endDate as it's always present if (key === 'endDate') return false; + // For arrays, check if they have any items if (Array.isArray(value)) return value.length > 0; // For other values, check if they exist @@ -36,11 +37,14 @@ export function ActivityFeed() { }; const handleActivityPanelSelect = (activityId: string) => { - setSearchParams((prev) => { - prev.set('activityItemId', activityId); + setSearchParams( + (prev) => { + prev.set('activityItemId', activityId); - return prev; - }); + return prev; + }, + { replace: true } + ); }; return ( From 3f62c06902c9c220650d016a0f4955b2168462af Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 12 Dec 2024 12:56:40 +0200 Subject: [PATCH 34/34] fix: pr comments --- .../app/notifications/dtos/activities-request.dto.ts | 6 +++--- .../src/app/notifications/notification.controller.ts | 4 ++-- .../get-activity-feed/get-activity-feed.command.ts | 4 ++-- .../get-activity-feed/get-activity-feed.usecase.ts | 4 ++-- apps/dashboard/src/api/activity.ts | 4 ++-- .../notification/notification.repository.ts | 12 ++++++------ 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/api/src/app/notifications/dtos/activities-request.dto.ts b/apps/api/src/app/notifications/dtos/activities-request.dto.ts index 095f0c7a2f6..c6778d19950 100644 --- a/apps/api/src/app/notifications/dtos/activities-request.dto.ts +++ b/apps/api/src/app/notifications/dtos/activities-request.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { ChannelTypeEnum } from '@novu/shared'; -import { IsDateString, IsOptional } from 'class-validator'; +import { IsOptional } from 'class-validator'; export class ActivitiesRequestDto { @ApiPropertyOptional({ @@ -50,12 +50,12 @@ export class ActivitiesRequestDto { required: false, }) @IsOptional() - startDate?: string; + after?: string; @ApiPropertyOptional({ type: String, required: false, }) @IsOptional() - endDate?: string; + before?: string; } diff --git a/apps/api/src/app/notifications/notification.controller.ts b/apps/api/src/app/notifications/notification.controller.ts index bf7e287ec3f..22dd7732eaa 100644 --- a/apps/api/src/app/notifications/notification.controller.ts +++ b/apps/api/src/app/notifications/notification.controller.ts @@ -77,8 +77,8 @@ export class NotificationsController { search: query.search, subscriberIds: subscribersQuery, transactionId: query.transactionId, - startDate: query.startDate, - endDate: query.endDate, + after: query.after, + before: query.before, }) ); } diff --git a/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.command.ts b/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.command.ts index 9a9f9add052..2abe7cc6373 100644 --- a/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.command.ts +++ b/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.command.ts @@ -36,9 +36,9 @@ export class GetActivityFeedCommand extends EnvironmentWithUserCommand { @IsOptional() @IsString() - startDate?: string; + after?: string; @IsOptional() @IsString() - endDate?: string; + before?: string; } diff --git a/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.usecase.ts b/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.usecase.ts index 22bc291050a..e0cbe7f059b 100644 --- a/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.usecase.ts +++ b/apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.usecase.ts @@ -62,8 +62,8 @@ export class GetActivityFeed { templates: command.templates, subscriberIds, transactionId: command.transactionId, - startDate: command.startDate, - endDate: command.endDate, + after: command.after, + before: command.before, }, command.page * LIMIT, LIMIT diff --git a/apps/dashboard/src/api/activity.ts b/apps/dashboard/src/api/activity.ts index 059e72d8606..5ba5c14d812 100644 --- a/apps/dashboard/src/api/activity.ts +++ b/apps/dashboard/src/api/activity.ts @@ -50,8 +50,8 @@ export function getActivityList( } if (filters?.dateRange) { - const endDate = new Date(Date.now() - getDateRangeInDays(filters?.dateRange) * 24 * 60 * 60 * 1000); - searchParams.append('endDate', endDate.toISOString()); + const after = new Date(Date.now() - getDateRangeInDays(filters?.dateRange) * 24 * 60 * 60 * 1000); + searchParams.append('after', after.toISOString()); } return get(`/notifications?${searchParams.toString()}`, { diff --git a/libs/dal/src/repositories/notification/notification.repository.ts b/libs/dal/src/repositories/notification/notification.repository.ts index 03e910d2473..50bc5e21b46 100644 --- a/libs/dal/src/repositories/notification/notification.repository.ts +++ b/libs/dal/src/repositories/notification/notification.repository.ts @@ -31,8 +31,8 @@ export class NotificationRepository extends BaseRepository< templates?: string[] | null; subscriberIds?: string[]; transactionId?: string; - startDate?: string; - endDate?: string; + after?: string; + before?: string; } = {}, skip = 0, limit = 10 @@ -45,12 +45,12 @@ export class NotificationRepository extends BaseRepository< requestQuery.transactionId = query.transactionId; } - if (query.startDate) { - requestQuery.createdAt = { $lte: query.startDate }; + if (query.after) { + requestQuery.createdAt = { $gte: query.after }; } - if (query.endDate) { - requestQuery.createdAt = { $gte: query.endDate }; + if (query.before) { + requestQuery.createdAt = { $lte: query.before }; } if (query?.templates) {