use-canvas-list-variables.ts15.4 KBView on GitHub /**
* useCanvasListVariables — the one place a canvas's filter set is derived.
*
* Pulled out of `useCanvasConversations` so the rows and their roll-up numbers are computed
* from the SAME object. The metrics bar sits directly above the table; if the two derived
* their filters independently they would eventually disagree, and a summary that contradicts
* the rows under it is worse than no summary at all. See
* apps/mail/docs/pipeline-tabs-and-canvas-aggregates.md §3.2.
*
* Returns `listVariables` (the exact tRPC input, minus cursor) plus the few intermediates
* `useCanvasConversations` still needs for its query key and post-fetch sync.
*/
import {
computeFiltersFromConfig,
computeBackendSortFromConfig,
applyDynamicDates,
} from '../utils/compute-canvas-filters';
import type { ConversationViewConfig } from '@/modules/canvas/types/canvas-types';
import { useCanvasConfiguration } from '@/modules/crm/hooks/use-canvas-configuration';
import { useMemo, useEffect, useState } from 'react';
import { useCedarStore } from '@/modules/store';
export function useCanvasListVariables(
canvasId: string | null | undefined,
pinnedConversationIds?: string[],
options?: { limit?: number },
) {
const pageLimit = options?.limit ?? 50;
// ── Single source of truth: useCanvasConfiguration ──────────────────────
const canvasConfig = useCanvasConfiguration(canvasId);
const { selectedAopIds, customFieldDefinitions } = canvasConfig;
// Read viewConfig for filterSortConfiguration + searchQuery
const viewConfig = useCedarStore((state) => {
if (!canvasId) return null;
const c = state.canvasesById[canvasId];
return c?.viewConfig as ConversationViewConfig | null | undefined;
});
// Read ownerUserIds filter from viewConfig.
// Semantics (passed directly to backend):
// undefined/null/[] → current user's conversations only (default)
// 'all_org' → org-wide search
// [id, ...] → filter to specific owners
const ownerUserIds = viewConfig?.ownerUserIds;
// AOP list for sort (type column → maps AOP names to IDs)
const aopsById = useCedarStore((state) => state.aopsById);
const aops = useMemo(
() => Object.values(aopsById).map((a) => ({ id: a.id, name: a.name })),
[aopsById],
);
// Apply dynamic dates (e.g. 'deal-actions' tab resolves "before" to end of today)
const filterSortConfig = useMemo(
() =>
viewConfig?.filterSortConfiguration
? applyDynamicDates(viewConfig.filterSortConfiguration, canvasId ?? '')
: {},
[viewConfig?.filterSortConfiguration, canvasId],
);
const searchQuery = viewConfig?.searchQuery;
// ── Date epoch for relative filter cache invalidation ─────────────
// When any filter uses dateRelative, we need to re-resolve offsets when the day rolls over.
// A 60-second interval checks for day changes and triggers re-render + cache invalidation.
const hasRelativeFilters = useMemo(
() => Object.values(filterSortConfig).some((col) => col.filter?.dateRelative),
[filterSortConfig],
);
const [dateEpoch, setDateEpoch] = useState(() => new Date().toDateString());
useEffect(() => {
if (!hasRelativeFilters) return;
const interval = setInterval(() => {
const today = new Date().toDateString();
setDateEpoch((prev) => (prev !== today ? today : prev));
}, 60_000);
return () => clearInterval(interval);
}, [hasRelativeFilters]);
// ── Compute filters ─────────────────────────────────────────────────────
const { filters, customFieldFilters: backendCustomFieldFilters } = useMemo(
() => computeFiltersFromConfig(filterSortConfig, searchQuery, customFieldDefinitions),
// dateEpoch forces re-resolution of relative date offsets when the day rolls over
// eslint-disable-next-line react-hooks/exhaustive-deps
[filterSortConfig, searchQuery, customFieldDefinitions, dateEpoch],
);
// ── Compute AOP IDs for backend ─────────────────────────────────────────
const backendAopIds = useMemo(() => {
if (!selectedAopIds || selectedAopIds.length === 0) return undefined;
return selectedAopIds;
}, [selectedAopIds]);
// ── Compute individual filter fields (mirrors useCRMConversations) ──────
const backendStatus = useMemo(() => {
const hasInclude = filters.status && filters.status.length > 0;
const hasExclude = filters.excludedStatus && filters.excludedStatus.length > 0;
if (!hasInclude && !hasExclude) return undefined;
return {
include: hasInclude ? filters.status : undefined,
exclude: hasExclude ? filters.excludedStatus : undefined,
};
}, [filters.status, filters.excludedStatus]);
const backendPriority = useMemo(() => {
const hasInclude = filters.priority && filters.priority.length > 0;
const hasExclude = filters.excludedPriority && filters.excludedPriority.length > 0;
if (!hasInclude && !hasExclude) return undefined;
return {
include: hasInclude ? filters.priority : undefined,
exclude: hasExclude ? filters.excludedPriority : undefined,
};
}, [filters.priority, filters.excludedPriority]);
const backendNextStepDate = useMemo(() => filters.nextStepDate?.date, [filters.nextStepDate]);
const backendNextStepDateOperator = useMemo(
() => filters.nextStepDate?.operator,
[filters.nextStepDate?.operator],
);
const backendNextStepDateTo = useMemo(
() => filters.nextStepDate?.dateTo,
[filters.nextStepDate?.dateTo],
);
const backendLastContactDate = useMemo(() => filters.lastContact?.date, [filters.lastContact]);
const backendLastContactDateOperator = useMemo(
() => filters.lastContact?.operator,
[filters.lastContact?.operator],
);
const backendLastContactDateTo = useMemo(
() => filters.lastContact?.dateTo,
[filters.lastContact?.dateTo],
);
const backendLastMeetingDate = useMemo(() => {
if (filters.lastMeeting?.operator === 'empty') return undefined;
return filters.lastMeeting?.date;
}, [filters.lastMeeting?.date, filters.lastMeeting?.operator]);
const backendLastMeetingDateOperator = useMemo(
() => filters.lastMeeting?.operator,
[filters.lastMeeting?.operator],
);
const backendLastMeetingDateTo = useMemo(
() => filters.lastMeeting?.dateTo,
[filters.lastMeeting?.dateTo],
);
const backendDealValue = useMemo(() => {
if (filters.dealValue?.operator === 'empty') return undefined;
if (!filters.dealValue?.value) return undefined;
const num = Number(filters.dealValue.value);
return isNaN(num) ? undefined : num;
}, [filters.dealValue?.value, filters.dealValue?.operator]);
const backendDealValueOperator = useMemo(
() => filters.dealValue?.operator,
[filters.dealValue?.operator],
);
const backendCrmSynced = useMemo(() => filters.crmSynced, [filters.crmSynced]);
const backendHasFutureCalendar = useMemo(() => filters.hasFutureCalendar, [filters.hasFutureCalendar]);
const backendLastEventByTypeDate = useMemo(() => {
if (filters.lastEventByType?.operator === 'empty') return undefined;
return filters.lastEventByType?.date;
}, [filters.lastEventByType]);
const backendLastEventByTypeDateOperator = useMemo(
() => filters.lastEventByType?.operator,
[filters.lastEventByType?.operator],
);
const backendLastEventByTypeDateTo = useMemo(
() => filters.lastEventByType?.dateTo,
[filters.lastEventByType?.dateTo],
);
const backendLastEventByTypeTypes = useMemo(
() => (filters.lastEventByType as { types?: string[] } | undefined)?.types,
[filters.lastEventByType],
);
const backendHasTodoTasks = useMemo(() => filters.hasTodoTasks, [filters.hasTodoTasks]);
const backendTaskTypes = useMemo(() => filters.taskTypes, [filters.taskTypes]);
const backendTaskDueDate = useMemo(() => {
if (filters.taskDueDate?.operator === 'empty') return undefined;
return filters.taskDueDate?.date;
}, [filters.taskDueDate]);
const backendTaskDueDateOperator = useMemo(
() => filters.taskDueDate?.operator,
[filters.taskDueDate?.operator],
);
const backendTaskDueDateTo = useMemo(
() => filters.taskDueDate?.dateTo,
[filters.taskDueDate?.dateTo],
);
const backendCurrentActionHasTasks = useMemo(
() => filters.currentActionHasTasks,
[filters.currentActionHasTasks],
);
const backendCurrentActionDueBefore = useMemo(
() => filters.currentActionDueBefore,
[filters.currentActionDueBefore],
);
const backendCurrentActionTaskTypes = useMemo(
() => filters.currentActionTaskTypes,
[filters.currentActionTaskTypes],
);
const backendEventFilter = useMemo(() => {
const hasInclude = filters.eventTypes && filters.eventTypes.length > 0;
const hasExclude = filters.excludedEventTypes && filters.excludedEventTypes.length > 0;
const eventDateOperator =
filters.eventDate?.operator && filters.eventDate.operator !== 'empty'
? (filters.eventDate.operator as 'before' | 'after' | 'on' | 'range')
: undefined;
const hasEventDate = filters.eventDate?.date && eventDateOperator;
if (!hasInclude && !hasExclude && !hasEventDate) return undefined;
return {
types:
hasInclude || hasExclude
? {
include: hasInclude ? filters.eventTypes : undefined,
exclude: hasExclude ? filters.excludedEventTypes : undefined,
}
: undefined,
date: hasEventDate ? filters.eventDate?.date : undefined,
dateTo: hasEventDate && eventDateOperator === 'range' ? filters.eventDate?.dateTo : undefined,
dateOperator: eventDateOperator,
};
}, [filters.eventTypes, filters.excludedEventTypes, filters.eventDate]);
const backendLatestEventFilter = useMemo(() => {
const hasTypes = filters.latestEventType && filters.latestEventType.length > 0;
const latestDateOperator =
filters.latestEventDate?.operator && filters.latestEventDate.operator !== 'empty'
? (filters.latestEventDate.operator as 'before' | 'after' | 'on' | 'range')
: undefined;
const hasDate = filters.latestEventDate?.date && latestDateOperator;
if (!hasTypes && !hasDate) return undefined;
return {
types: hasTypes ? filters.latestEventType : undefined,
date: hasDate ? filters.latestEventDate?.date : undefined,
dateTo:
hasDate && latestDateOperator === 'range' ? filters.latestEventDate?.dateTo : undefined,
dateOperator: latestDateOperator,
};
}, [filters.latestEventType, filters.latestEventDate]);
// ── Compute backend sort ────────────────────────────────────────────────
const backendSortBy = useMemo(
() => computeBackendSortFromConfig(filterSortConfig, aops, customFieldDefinitions),
[filterSortConfig, aops, customFieldDefinitions],
);
// ── Build list variables ────────────────────────────────────────────────
const isPinnedToList = Boolean(pinnedConversationIds && pinnedConversationIds.length > 0);
const listVariables = useMemo(() => {
if (isPinnedToList) {
return {
conversationIds: pinnedConversationIds,
limit: pageLimit,
search: undefined,
aopIds: undefined,
status: undefined,
priority: undefined,
nextStepDate: undefined,
nextStepDateOperator: undefined,
nextStepDateTo: undefined,
lastContactDate: undefined,
lastContactDateOperator: undefined,
lastContactDateTo: undefined,
lastMeetingDate: undefined,
lastMeetingDateOperator: undefined,
dealValue: undefined,
dealValueOperator: undefined,
crmSynced: undefined,
hasFutureCalendar: undefined,
lastEventByTypeDate: undefined,
lastEventByTypeDateOperator: undefined,
lastEventByTypeDateTo: undefined,
lastEventByTypeTypes: undefined,
hasTodoTasks: undefined,
taskTypes: undefined,
taskDueDate: undefined,
taskDueDateOperator: undefined,
taskDueDateTo: undefined,
currentActionHasTasks: undefined,
currentActionDueBefore: undefined,
currentActionTaskTypes: undefined,
eventFilter: undefined,
latestEventFilter: undefined,
sortBy: undefined,
customFieldFilters: undefined,
ownerUserIds: undefined,
};
}
return {
search: searchQuery,
aopIds: backendAopIds,
status: backendStatus,
priority: backendPriority,
nextStepDate: backendNextStepDate,
nextStepDateOperator: backendNextStepDateOperator,
nextStepDateTo: backendNextStepDateTo,
lastContactDate: backendLastContactDate,
lastContactDateOperator: backendLastContactDateOperator,
lastContactDateTo: backendLastContactDateTo,
lastMeetingDate: backendLastMeetingDate,
lastMeetingDateOperator: backendLastMeetingDateOperator,
lastMeetingDateTo: backendLastMeetingDateTo,
dealValue: backendDealValue,
dealValueOperator: backendDealValueOperator,
crmSynced: backendCrmSynced,
hasFutureCalendar: backendHasFutureCalendar,
lastEventByTypeDate: backendLastEventByTypeDate,
lastEventByTypeDateOperator: backendLastEventByTypeDateOperator,
lastEventByTypeDateTo: backendLastEventByTypeDateTo,
lastEventByTypeTypes: backendLastEventByTypeTypes,
hasTodoTasks: backendHasTodoTasks,
taskTypes: backendTaskTypes,
taskDueDate: backendTaskDueDate,
taskDueDateOperator: backendTaskDueDateOperator,
taskDueDateTo: backendTaskDueDateTo,
currentActionHasTasks: backendCurrentActionHasTasks,
currentActionDueBefore: backendCurrentActionDueBefore,
currentActionTaskTypes: backendCurrentActionTaskTypes,
eventFilter: backendEventFilter,
latestEventFilter: backendLatestEventFilter,
sortBy: backendSortBy,
customFieldFilters:
backendCustomFieldFilters && backendCustomFieldFilters.length > 0
? backendCustomFieldFilters
: undefined,
ownerUserIds,
limit: pageLimit,
conversationIds: undefined,
};
}, [
isPinnedToList,
pinnedConversationIds,
searchQuery,
backendAopIds,
backendStatus,
backendPriority,
backendNextStepDate,
backendNextStepDateOperator,
backendNextStepDateTo,
backendLastContactDate,
backendLastContactDateOperator,
backendLastContactDateTo,
backendLastMeetingDate,
backendLastMeetingDateOperator,
backendLastMeetingDateTo,
backendDealValue,
backendDealValueOperator,
backendCrmSynced,
backendHasFutureCalendar,
backendLastEventByTypeDate,
backendLastEventByTypeDateOperator,
backendLastEventByTypeDateTo,
backendLastEventByTypeTypes,
backendHasTodoTasks,
backendTaskTypes,
backendTaskDueDate,
backendTaskDueDateOperator,
backendTaskDueDateTo,
backendCurrentActionHasTasks,
backendCurrentActionDueBefore,
backendCurrentActionTaskTypes,
backendEventFilter,
backendLatestEventFilter,
backendSortBy,
backendCustomFieldFilters,
ownerUserIds,
pageLimit,
]);
return {
listVariables,
filters,
filterSortConfig,
dateEpoch,
hasRelativeFilters,
customFieldDefinitions,
selectedAopIds,
};
}