diff --git a/apps/studio/components/interface/Settings/Logs/Logs.utils.ts b/apps/studio/components/interface/Settings/Logs/Logs.utils.ts deleted file mode 100644 index ed9620ab96f..00000000000 --- a/apps/studio/components/interface/Settings/Logs/Logs.utils.ts +++ /dev/null @@ -1,91 +0,0 @@ -export const genChartQuery = ( - table: LogsTableName, - params: LogsEndpointParams, - filters: Filters -) => { - const [startOffset, trunc] = calcChartStart(params) - let where = genWhereStatement(table, filters) - const errorCondition = getErrorCondition(table) - const warningCondition = getWarningCondition(table) - - // pg_cron logs are a subset of postgres logs - // to calculate the chart, we need to query postgres logs - if (table === LogsTableName.PG_CRON) { - table = LogsTableName.POSTGRES - where = `where (parsed.application_name = 'pg_cron' OR event_message LIKE '%cron job%')` - } - - let joins = genCrossJoinUnnests(table) - - const q = ` -SELECT --- log-event-chart - timestamp_trunc(t.timestamp, ${trunc}) as timestamp, - count(CASE WHEN NOT (${errorCondition} OR ${warningCondition}) THEN 1 END) as ok_count, - count(CASE WHEN ${errorCondition} THEN 1 END) as error_count, - count(CASE WHEN ${warningCondition} THEN 1 END) as warning_count -FROM - ${table} t - ${joins} - ${ - where - ? where + ` and t.timestamp > '${startOffset.toISOString()}'` - : `where t.timestamp > '${startOffset.toISOString()}'` - } -GROUP BY -timestamp -ORDER BY - timestamp ASC - ` - return q -} - -export const unifiedLogsQuery = ( - table: LogsTableName, - params: LogsEndpointParams, - filters: Filters, - limit: number -) => { - const [startOffset, trunc] = calcChartStart(params) - let where = genWhereStatement(table, filters) - let joins = genCrossJoinUnnests(table) - - // pg_cron logs are a subset of postgres logs - // to calculate the chart, we need to query postgres logs - if (table === LogsTableName.PG_CRON) { - table = LogsTableName.POSTGRES - where = `where (parsed.application_name = 'pg_cron' OR event_message LIKE '%cron job%')` - } - - return ` -select - id, - timestamp, - event_message, - request.method, - request.path, - response.status_code -from ( - select - id, - timestamp, - event_message, - metadata - from edge_logs - cross join unnest(metadata) as m - cross join unnest(m.request) as request - cross join unnest(m.response) as response - ${where} - union all - select - id, - timestamp, - event_message, - metadata - from postgres_logs - ${where} -) as unified_logs -order by timestamp desc -limit ${limit} -` -} diff --git a/apps/studio/components/interfaces/DataTableDemo/infinite/_components/hover-card-timestamp.tsx b/apps/studio/components/interfaces/DataTableDemo/infinite/_components/hover-card-timestamp.tsx index acaca19c7a8..76532b0cb78 100644 --- a/apps/studio/components/interfaces/DataTableDemo/infinite/_components/hover-card-timestamp.tsx +++ b/apps/studio/components/interfaces/DataTableDemo/infinite/_components/hover-card-timestamp.tsx @@ -1,62 +1,15 @@ -'use client' +import { format } from 'date-fns' -import { HoverCard, HoverCardContent, HoverCardTrigger } from 'ui' -import { useCopyToClipboard } from 'components/interfaces/DataTableDemo/hooks/use-copy-to-clipboard' import { cn } from 'ui' -import { UTCDate } from '@date-fns/utc' -import { HoverCardPortal } from '@radix-ui/react-hover-card' -import { format, formatDistanceToNowStrict } from 'date-fns' -import { Copy } from 'lucide-react' -import { Check } from 'lucide-react' -import type { ComponentPropsWithoutRef } from 'react' import { TimestampInfo } from 'ui-patterns' -type HoverCardContentProps = ComponentPropsWithoutRef - interface HoverCardTimestampProps { date: Date - side?: HoverCardContentProps['side'] - sideOffset?: HoverCardContentProps['sideOffset'] - align?: HoverCardContentProps['align'] - alignOffset?: HoverCardContentProps['alignOffset'] className?: string } -export function HoverCardTimestamp({ - date, - side = 'right', - align = 'start', - alignOffset = -4, - sideOffset, - className, -}: HoverCardTimestampProps) { - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone - +export function HoverCardTimestamp({ date, className }: HoverCardTimestampProps) { return ( - // - // - //
- // {format(date, 'MMM')} - // {format(date, 'dd')} - // - // {format(date, 'HH:mm:ss')} - // .{format(date, 'SS')} - // - //
- //
- // {/* REMINDER: allows us to port the content to the document.body, which is helpful when using opacity-50 on the row element */} - // - // - //
- // - // - // - // - //
- //
- //
- //
-
@@ -71,25 +24,3 @@ export function HoverCardTimestamp({
) } - -function Row({ value, label }: { value: string; label: string }) { - const { copy, isCopied } = useCopyToClipboard() - - return ( -
{ - e.stopPropagation() - copy(value) - }} - > -
{label}
-
- - {!isCopied ? : } - - {value} -
-
- ) -} diff --git a/apps/studio/components/interfaces/UnifiedLogs/query-options-new.ts b/apps/studio/components/interfaces/UnifiedLogs/QueryOptions.ts similarity index 93% rename from apps/studio/components/interfaces/UnifiedLogs/query-options-new.ts rename to apps/studio/components/interfaces/UnifiedLogs/QueryOptions.ts index 9f739500217..b6bf9bd2323 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/query-options-new.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/QueryOptions.ts @@ -1,54 +1,22 @@ import { useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' +import { ARRAY_DELIMITER, SORT_DELIMITER } from 'components/ui/DataTable/DataTable.constants' import { post } from 'data/fetchers' -import { getLogsChartQuery, getLogsCountQuery, getUnifiedLogsQuery } from './logs.queries' -import { ARRAY_DELIMITER, SORT_DELIMITER } from './UnifiedLogs.constants' -import { ColumnSchema, type BaseChartSchema, type FacetMetadataSchema } from './UnifiedLogs.schema' -import type { SearchParamsType, UnifiedLogsMeta } from './UnifiedLogs.types' +import { getLogsChartQuery, getLogsCountQuery, getUnifiedLogsQuery } from './UnifiedLogs.queries' +import { type FacetMetadataSchema } from './UnifiedLogs.schema' +import type { + ExtendedColumnSchema, + InfiniteQueryResponse, + PageParam, + SearchParamsType, + UnifiedLogsMeta, +} from './UnifiedLogs.types' // Debug mode flag - set to true to enable detailed logs const DEBUG_MODE = false -export type UnifiedLogSchema = { - id: string - timestamp: Date - log_type: string - code: string - level: string - path: string | null - event_message: string - method: string - api_role: string - auth_user: string | null -} - -// Extended column schema to include raw timestamp -export type ExtendedColumnSchema = ColumnSchema & { - timestamp: string // Original database timestamp - date: Date // Date object for display -} - -export type InfiniteQueryMeta> = { - totalRowCount: number - filterRowCount: number - chartData: BaseChartSchema[] - facets: Record - metadata?: TMeta -} - -export type InfiniteQueryResponse = { - data: TData - meta: InfiniteQueryMeta - prevCursor: number | null - nextCursor: number | null -} - -// Define pageParam type -type PageParam = { cursor: number; direction: 'next' | 'prev' } - -// Helper function to create simpler API query string -export const createApiQueryString = (params: Record): string => { +export function createApiQueryString(params: Record): string { const queryParams = new URLSearchParams() for (const [key, value] of Object.entries(params)) { @@ -57,27 +25,40 @@ export const createApiQueryString = (params: Record): string => { if (key === 'date' && Array.isArray(value) && value.length === 2) { queryParams.set('dateStart', value[0].getTime().toString()) queryParams.set('dateEnd', value[1].getTime().toString()) + } else if ( + [ + 'latency', + 'timing.dns', + 'timing.connection', + 'timing.tls', + 'timing.ttfb', + 'timing.transfer', + 'status', + ].includes(key) && + Array.isArray(value) && + value.length > 0 + ) { + if (value.length >= 2) { + queryParams.set(`${key}Start`, value[0].toString()) + queryParams.set(`${key}End`, value[value.length - 1].toString()) + } } else if (Array.isArray(value)) { if (value.length > 0) { queryParams.set(key, value.join(ARRAY_DELIMITER)) } } else if (key === 'sort' && typeof value === 'object' && value !== null) { - queryParams.set( - key, - `${(value as { id: string; desc: boolean }).id}${SORT_DELIMITER}${(value as { id: string; desc: boolean }).desc ? 'desc' : 'asc'}` - ) + queryParams.set(key, `${value.id}${SORT_DELIMITER}${value.desc ? 'desc' : 'asc'}`) } else if (value instanceof Date) { queryParams.set(key, value.getTime().toString()) } else { queryParams.set(key, String(value)) } } + const queryString = queryParams.toString() return queryString ? `?${queryString}` : '' } -// Build SQL WHERE clause from search params - // Add a new function to fetch chart data for the entire time period export const useChartData = (search: SearchParamsType, projectRef: string) => { // Create a stable query key object by removing nulls/undefined, uuid, and live diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx index 00636b8a0f7..b197d52e756 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx @@ -8,8 +8,15 @@ import { parseAsTimestamp, } from 'nuqs' +import { + ARRAY_DELIMITER, + LEVELS, + RANGE_DELIMITER, + SLIDER_DELIMITER, + SORT_DELIMITER, +} from 'components/ui/DataTable/DataTable.constants' import { ChartConfig } from 'ui' -import { TooltipLabel } from './TooltipLabel' +import { TooltipLabel } from './components/TooltipLabel' export const CHART_CONFIG = { success: { @@ -26,15 +33,8 @@ export const CHART_CONFIG = { }, } satisfies ChartConfig -export const ARRAY_DELIMITER = ',' -const SLIDER_DELIMITER = '-' -const SPACE_DELIMITER = '_' -export const RANGE_DELIMITER = '-' -export const SORT_DELIMITER = '.' - -const LEVELS = ['success', 'warning', 'error'] as const -const REGIONS = ['ams', 'fra', 'gru', 'hkg', 'iad', 'syd'] as const -const METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const +export const REGIONS = ['ams', 'fra', 'gru', 'hkg', 'iad', 'syd'] as const +export const METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const const parseAsSort = createParser({ parse(queryValue) { @@ -63,14 +63,47 @@ export const SEARCH_PARAMS_PARSER = { host: parseAsString, pathname: parseAsString, date: parseAsArrayOf(parseAsTimestamp, RANGE_DELIMITER), + // REQUIRED FOR SORTING & PAGINATION sort: parseAsSort, size: parseAsInteger.withDefault(40), start: parseAsInteger.withDefault(0), + // REQUIRED FOR INFINITE SCROLLING (Live Mode and Load More) direction: parseAsStringLiteral(['prev', 'next']).withDefault('next'), cursor: parseAsTimestamp.withDefault(new Date()), live: parseAsBoolean.withDefault(false), + // REQUIRED FOR SELECTION uuid: parseAsString, } + +/** UNUSED */ + +// export const regions: Record = { +// // REGIONS +// ams: { label: 'Amsterdam', flag: '🇳🇱' }, +// fra: { label: 'Frankfurt', flag: '🇩🇪' }, +// gru: { label: 'Sao Paulo', flag: '🇧🇷' }, +// hkg: { label: 'Hong Kong', flag: '🇭🇰' }, +// iad: { label: 'Washington D.C.', flag: '🇺🇸' }, +// syd: { label: 'Sydney', flag: '🇦🇺' }, +// // VERCEL EDGE REGIONS +// hnd1: { label: 'Tokyo', flag: '🇯🇵' }, +// sin1: { label: 'Singapore', flag: '🇸🇬' }, +// cpt1: { label: 'Cape Town', flag: '🇿🇦' }, +// fra1: { label: 'Paris', flag: '🇫🇷' }, +// hkg1: { label: 'Hong Kong', flag: '🇭🇰' }, +// syd1: { label: 'Sydney', flag: '🇦🇺' }, +// gru1: { label: 'Sao Paulo', flag: '🇧🇷' }, +// dub1: { label: 'Dublin', flag: '🇮🇪' }, +// sfo1: { label: 'San Francisco', flag: '🇺🇸' }, +// cdg1: { label: 'Paris', flag: '🇫🇷' }, +// icn1: { label: 'Seoul', flag: '🇰🇷' }, +// kix1: { label: 'Osaka', flag: '🇯🇵' }, +// iad1: { label: 'Washington D.C.', flag: '🇺🇸' }, +// arn1: { label: 'Stockholm', flag: '🇸🇪' }, +// bom1: { label: 'Mumbai', flag: '🇮🇳' }, +// lhr1: { label: 'London', flag: '🇬🇧' }, +// cle1: { label: 'Cleveland', flag: '🇺🇸' }, +// } diff --git a/apps/studio/components/interfaces/UnifiedLogs/constants.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx similarity index 65% rename from apps/studio/components/interfaces/UnifiedLogs/constants.tsx rename to apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx index e97b3a6abe9..a69ddbc529d 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/constants.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx @@ -1,21 +1,15 @@ import { format } from 'date-fns' import { User } from 'lucide-react' -import type { - DataTableFilterField, - Option, - SheetField, -} from 'components/interfaces/DataTableDemo/components/data-table/types' -import { LEVELS } from 'components/interfaces/DataTableDemo/constants/levels' -import { METHODS } from 'components/interfaces/DataTableDemo/constants/method' -import { getLevelColor, getLevelLabel } from 'components/interfaces/DataTableDemo/lib/request/level' -import { getStatusColor } from 'components/interfaces/DataTableDemo/lib/request/status-code' +import { LEVELS } from 'components/ui/DataTable/DataTable.constants' +import { DataTableFilterField, Option } from 'components/ui/DataTable/DataTable.types' +import { getLevelColor, getStatusColor } from 'components/ui/DataTable/DataTable.utils' import { cn } from 'ui' - -// custom imports -import { TraceButton } from './components/trace-button' -import type { LogsMeta } from './query-options' -import { type ColumnSchema, LOG_TYPES } from './UnifiedLogs.schema' +import { METHODS } from './UnifiedLogs.constants' +import { ColumnSchema, LOG_TYPES } from './UnifiedLogs.schema' +import { LogsMeta, SheetField } from './UnifiedLogs.types' +import { getLevelLabel } from './UnifiedLogs.utils' +import { TraceButton } from './components/TraceButton' // instead of filterFields, maybe just 'fields' with a filterDisabled prop? // that way, we could have 'message' or 'headers' field with label and value as well as type! @@ -109,23 +103,6 @@ export const filterFields = [ return {props.value} }, }, - // { - // label: 'Regions', - // value: 'regions', - // type: 'checkbox', - // options: REGIONS.map((region) => ({ label: region, value: region })), - // component: (props: Option) => { - // return {props.value} - // }, - // }, - // Commenting out latency field as it's not in the schema - // { - // label: 'Latency', - // value: 'latency', - // type: 'slider', - // min: 0, - // max: 5000, - // }, ] satisfies DataTableFilterField[] export const sheetFields = [ @@ -181,13 +158,6 @@ export const sheetFields = [ ), skeletonClassName: 'w-56', }, - // { - // id: 'logs', - // label: 'Logs', - // type: 'readonly', - // component: (props) =>
{JSON.stringify(props.logs, null, 2)}
, - // skeletonClassName: 'w-full', - // }, { id: 'status', label: 'Status', @@ -220,48 +190,4 @@ export const sheetFields = [ type: 'input', skeletonClassName: 'w-56', }, - // { - // id: 'regions', - // label: 'Regions', - // type: 'checkbox', - // skeletonClassName: 'w-12', - // component: (props) => , - // }, - // Commenting out latency field as it's not in the schema - // { - // id: 'latency', - // label: 'Latency', - // type: 'slider', - // component: (props) => ( - // <> - // {formatMilliseconds(props.latency)} - // ms - // - // ), - // skeletonClassName: 'w-16', - // }, - // Commenting out headers field as it's not in the schema - // { - // id: 'headers', - // label: 'Headers', - // type: 'readonly', - // component: (props) => ( - // // REMINDER: negative margin to make it look like the header is on the same level of the tab triggers - // - // ), - // className: 'flex-col items-start w-full gap-1', - // }, - // Commenting out message field as it's not in the schema - // { - // id: 'message', - // label: 'Message', - // type: 'readonly', - // condition: (props) => props.message !== undefined, - // component: (props) => ( - // - // {JSON.stringify(props.message, null, 2)} - // - // ), - // className: 'flex-col items-start w-full gap-1', - // }, ] satisfies SheetField[] diff --git a/apps/studio/components/interfaces/UnifiedLogs/hooks/useUnifiedLogs.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts similarity index 50% rename from apps/studio/components/interfaces/UnifiedLogs/hooks/useUnifiedLogs.ts rename to apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts index 42530dbd8bc..d751f9673b0 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/hooks/useUnifiedLogs.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts @@ -1,54 +1,65 @@ -// apps/studio/components/interfaces/logs-new/hooks/useUnifiedLogs.ts - import { useInfiniteQuery } from '@tanstack/react-query' -import SuperJSON from 'superjson' -import { useMemo } from 'react' -import { useQueryStates } from 'nuqs' -import { searchParamsParser } from '../search-params' -import { - ARRAY_DELIMITER, - RANGE_DELIMITER, - SLIDER_DELIMITER, - SORT_DELIMITER, -} from 'components/interfaces/DataTableDemo/lib/delimiters' import { useParams } from 'next/navigation' -import { InfiniteQueryResponse } from '../query-options' +import { useQueryState, useQueryStates } from 'nuqs' +import { useEffect, useMemo, useRef } from 'react' +import SuperJSON from 'superjson' -// Define your unified logs schema type here -export type UnifiedLogSchema = { - id: string - timestamp: Date - log_type: - | 'edge' - | 'postgres' - | 'function logs' - | 'edge function' - | 'auth' - | 'supavisor' - | 'postgres upgrade' - code: string - level: string - path: string | null - event_message: string - method: string - api_role: string - auth_user: string | null +import { useHotKey } from 'hooks/ui/useHotKey' +import { createApiQueryString } from './QueryOptions' +import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' +import { + InfiniteQueryResponse, + PageParam, + UnifiedLogSchema, + UnifiedLogsMeta, +} from './UnifiedLogs.types' + +export const useResetFocus = () => { + useHotKey(() => { + // FIXME: some dedicated div[tabindex="0"] do not auto-unblur (e.g. the DataTableFilterResetButton) + // REMINDER: we cannot just document.activeElement?.blur(); as the next tab will focus the next element in line, + // which is not what we want. We want to reset entirely. + document.body.setAttribute('tabindex', '0') + document.body.focus() + document.body.removeAttribute('tabindex') + }, '.') } -export type UnifiedLogsMeta = { - // Add any specific metadata for unified logs - logTypeCounts: Record +export const useLiveMode = (data: TData[]) => { + const [live] = useQueryState('live', SEARCH_PARAMS_PARSER.live) + // REMINDER: used to capture the live mode on timestamp + const liveTimestamp = useRef(live ? new Date().getTime() : undefined) + + useEffect(() => { + if (live) liveTimestamp.current = new Date().getTime() + else liveTimestamp.current = undefined + }, [live]) + + const anchorRow = useMemo(() => { + if (!live) return undefined + + const item = data.find((item) => { + // return first item that is there if not liveTimestamp + if (!liveTimestamp.current) return true + // return first item that is after the liveTimestamp + if (item.date.getTime() > liveTimestamp.current) return false + return true + // return first item if no liveTimestamp + }) + + return item + }, [live, data]) + + return { row: anchorRow, timestamp: liveTimestamp.current } } -// Define PageParam type for TypeScript clarity -type PageParam = { cursor: number; direction: 'next' | 'prev' } - +// [Joshen] This isn't currently being used - check if can deprecate export function useUnifiedLogs() { // Get project ref from URL params const { ref: projectRef } = useParams<{ ref: string }>() // Use your existing search params - const [search, setSearch] = useQueryStates(searchParamsParser) + const [search, setSearch] = useQueryStates(SEARCH_PARAMS_PARSER) // Add log_type to search params if needed const queryParams = useMemo(() => { @@ -59,6 +70,7 @@ export function useUnifiedLogs() { }, [search]) // Create the query + // @ts-ignore const result = useInfiniteQuery({ queryKey: ['unified-logs', projectRef, JSON.stringify(queryParams)], queryFn: async ({ pageParam }) => { @@ -121,47 +133,3 @@ export function useUnifiedLogs() { removeLogTypeFilter, } } - -// Helper function to create API query string - complete implementation -function createApiQueryString(params: Record): string { - const queryParams = new URLSearchParams() - - for (const [key, value] of Object.entries(params)) { - if (value === null || value === undefined) continue - - if (key === 'date' && Array.isArray(value) && value.length === 2) { - queryParams.set('dateStart', value[0].getTime().toString()) - queryParams.set('dateEnd', value[1].getTime().toString()) - } else if ( - [ - 'latency', - 'timing.dns', - 'timing.connection', - 'timing.tls', - 'timing.ttfb', - 'timing.transfer', - 'status', - ].includes(key) && - Array.isArray(value) && - value.length > 0 - ) { - if (value.length >= 2) { - queryParams.set(`${key}Start`, value[0].toString()) - queryParams.set(`${key}End`, value[value.length - 1].toString()) - } - } else if (Array.isArray(value)) { - if (value.length > 0) { - queryParams.set(key, value.join(ARRAY_DELIMITER)) - } - } else if (key === 'sort' && typeof value === 'object' && value !== null) { - queryParams.set(key, `${value.id}${SORT_DELIMITER}${value.desc ? 'desc' : 'asc'}`) - } else if (value instanceof Date) { - queryParams.set(key, value.getTime().toString()) - } else { - queryParams.set(key, String(value)) - } - } - - const queryString = queryParams.toString() - return queryString ? `?${queryString}` : '' -} diff --git a/apps/studio/components/interfaces/UnifiedLogs/logs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts similarity index 92% rename from apps/studio/components/interfaces/UnifiedLogs/logs.queries.ts rename to apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts index 5f397d8cffc..d2823d0d126 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/logs.queries.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts @@ -1,20 +1,23 @@ -/** - * Reusable SQL queries for logs - */ - import dayjs from 'dayjs' -import { BASE_CONDITIONS_EXCLUDED_PARAMS, EXCLUDED_QUERY_PARAMS } from './constants/query-params' + import { SearchParamsType } from './UnifiedLogs.types' -// Types for plan IDs - import actual type if available -type PlanId = 'free' | 'pro' | 'team' | 'enterprise' +// Pagination and control parameters +const PAGINATION_PARAMS = ['sort', 'start', 'size', 'uuid', 'cursor', 'direction', 'live'] as const + +// Special filter parameters that need custom handling +const SPECIAL_FILTER_PARAMS = ['date'] as const + +// Combined list of all parameters to exclude from standard filtering +const EXCLUDED_QUERY_PARAMS = [...PAGINATION_PARAMS, ...SPECIAL_FILTER_PARAMS] as const +const BASE_CONDITIONS_EXCLUDED_PARAMS = [...PAGINATION_PARAMS, 'date', 'level'] as const /** * Builds query conditions from search parameters and returns WHERE clause * @param search SearchParamsType object containing query parameters * @returns Object with whereConditions array and formatted WHERE clause */ -export const buildQueryConditions = (search: SearchParamsType) => { +const buildQueryConditions = (search: SearchParamsType) => { const whereConditions: string[] = [] // Process all search parameters for filtering @@ -45,7 +48,7 @@ export const buildQueryConditions = (search: SearchParamsType) => { /** * Builds level-specific condition for different log types */ -export const buildLevelConditions = (logType: string, levelFilter: string[]) => { +const buildLevelConditions = (logType: string, levelFilter: string[]) => { const conditions = [] switch (logType) { @@ -92,7 +95,7 @@ export const buildLevelConditions = (logType: string, levelFilter: string[]) => /** * Creates WHERE clause for a specific log type including level filtering */ -export const createFilterWhereClause = ( +const createFilterWhereClause = ( logType: string, levelFilter: string[], baseConditions: string[] @@ -132,7 +135,7 @@ export const createFilterWhereClause = ( /** * Builds base conditions array from search params */ -export const buildBaseConditions = (search: SearchParamsType): string[] => { +const buildBaseConditions = (search: SearchParamsType): string[] => { const baseConditions: string[] = [] Object.entries(search).forEach(([key, value]) => { @@ -154,27 +157,12 @@ export const buildBaseConditions = (search: SearchParamsType): string[] => { return baseConditions } -/** - * Determine if we should show the user an upgrade prompt while browsing logs - * Ported from the older implementation (apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts) - */ -export const maybeShowUpgradePrompt = (from: string | null | undefined, planId?: PlanId) => { - const day = Math.abs(dayjs().diff(dayjs(from), 'day')) - - return ( - (day > 1 && planId === 'free') || - (day > 7 && planId === 'pro') || - (day > 28 && planId === 'team') || - (day > 90 && planId === 'enterprise') - ) -} - /** * Calculates how much the chart start datetime should be offset given the current datetime filter params * and determines the appropriate bucketing level (minute, hour, day) * Ported from the older implementation (apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts) */ -export const calculateChartBucketing = (search: SearchParamsType | Record): string => { +const calculateChartBucketing = (search: SearchParamsType | Record): string => { // Extract start and end times from the date array if available const dateRange = search.date || [] @@ -233,7 +221,7 @@ export const calculateChartBucketing = (search: SearchParamsType | Record { +const getEdgeLogsQuery = () => { return ` select id, @@ -272,7 +260,7 @@ export const getEdgeLogsQuery = () => { // Postgrest logs // WHERE pathname includes `/rest/` -export const getPostgrestLogsQuery = () => { +const getPostgrestLogsQuery = () => { return ` select id, @@ -310,7 +298,7 @@ export const getPostgrestLogsQuery = () => { /** * Postgres logs query fragment */ -export const getPostgresLogsQuery = () => { +const getPostgresLogsQuery = () => { return ` select id, @@ -340,7 +328,7 @@ export const getPostgresLogsQuery = () => { /** * Edge function logs query fragment */ -export const getEdgeFunctionLogsQuery = () => { +const getEdgeFunctionLogsQuery = () => { return ` select id, @@ -386,7 +374,7 @@ export const getEdgeFunctionLogsQuery = () => { /** * Auth logs query fragment */ -export const getAuthLogsQuery = () => { +const getAuthLogsQuery = () => { return ` select al.id as id, @@ -428,7 +416,7 @@ export const getAuthLogsQuery = () => { /** * Supavisor logs query fragment */ -export const getSupavisorLogsQuery = () => { +const getSupavisorLogsQuery = () => { return ` select id, @@ -456,7 +444,7 @@ export const getSupavisorLogsQuery = () => { /** * Combine all log sources to create the unified logs CTE */ -export const getUnifiedLogsCTE = () => { +const getUnifiedLogsCTE = () => { return ` WITH unified_logs AS ( ${getEdgeLogsQuery()} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts index b29e630f4ba..98efdf68549 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts @@ -1,9 +1,11 @@ import { z } from 'zod' -import { LEVELS } from './constants/levels' -import { METHODS } from './constants/method' -import { REGIONS } from './constants/region' -import { ARRAY_DELIMITER, RANGE_DELIMITER } from './UnifiedLogs.constants' +import { + ARRAY_DELIMITER, + LEVELS, + RANGE_DELIMITER, +} from 'components/ui/DataTable/DataTable.constants' +import { METHODS, REGIONS } from './UnifiedLogs.constants' export const LOG_TYPES = [ 'edge', diff --git a/apps/studio/components/interfaces/UnifiedLogs/client.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx similarity index 86% rename from apps/studio/components/interfaces/UnifiedLogs/client.tsx rename to apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index 0d91d16a946..2c97db0e282 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/client.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -16,26 +16,20 @@ import { import { useQueryStates } from 'nuqs' import { useEffect, useMemo, useState } from 'react' -// import { useLocalStorage } from 'components/interfaces/DataTableDemo/hooks/use-local-storage' -import { DataTableInfinite } from 'components/interfaces/data-table/data-table-infinite' -import { TimelineChart } from 'components/interfaces/data-table/timeline-chart' -import { DataTableFilterCommand } from 'components/interfaces/DataTableDemo/components/data-table/data-table-filter-command' -import { DataTableProvider } from 'components/interfaces/DataTableDemo/components/data-table/data-table-provider' -import { DataTableSheetDetails } from 'components/interfaces/DataTableDemo/components/data-table/data-table-sheet/data-table-sheet-details' -import { DataTableToolbar } from 'components/interfaces/DataTableDemo/components/data-table/data-table-toolbar' -import { LiveButton } from 'components/interfaces/DataTableDemo/infinite/_components/live-button' -import { LiveRow } from 'components/interfaces/DataTableDemo/infinite/_components/live-row' -import { RefreshButton } from 'components/interfaces/DataTableDemo/infinite/_components/refresh-button' -import { getLevelRowClassName } from 'components/interfaces/DataTableDemo/lib/request/level' -import { arrSome, inDateRange } from 'components/interfaces/DataTableDemo/lib/table/filterfns' - -// components pulled out and modified -import { DataTableHeaderLayout } from '../data-table/data-table-header-layout' -import { DataTableSideBarLayout } from '../data-table/data-table-side-bar-layout' -import { FilterSideBar } from '../data-table/filter-side-bar' - -// specific imports import { useParams } from 'common' +import { arrSome, inDateRange } from 'components/ui/DataTable/DataTable.utils' +import { DataTableFilterCommand } from 'components/ui/DataTable/DataTableFilters/DataTableFilterCommand' +import { DataTableHeaderLayout } from 'components/ui/DataTable/DataTableHeaderLayout' +import { DataTableInfinite } from 'components/ui/DataTable/DataTableInfinite' +import { DataTableSheetDetails } from 'components/ui/DataTable/DataTableSheetDetails' +import { DataTableSideBarLayout } from 'components/ui/DataTable/DataTableSideBarLayout' +import { DataTableToolbar } from 'components/ui/DataTable/DataTableToolbar' +import { FilterSideBar } from 'components/ui/DataTable/FilterSideBar' +import { LiveButton } from 'components/ui/DataTable/LiveButton' +import { LiveRow } from 'components/ui/DataTable/LiveRow' +import { DataTableProvider } from 'components/ui/DataTable/providers/DataTableProvider' +import { RefreshButton } from 'components/ui/DataTable/RefreshButton' +import { TimelineChart } from 'components/ui/DataTable/TimelineChart' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { ChartConfig, @@ -49,20 +43,39 @@ import { TabsList_Shadcn_ as TabsList, TabsTrigger_Shadcn_ as TabsTrigger, } from 'ui' - -import { columns, logEventBus } from './columns' -import { MemoizedDataTableSheetContent } from './components/data-table-sheet-content' -import { FunctionLogsTab } from './components/function-logs-tab' -import { TraceDetailTab } from './components/trace-detail-tab' -import { filterFields as defaultFilterFields, sheetFields } from './constants' -import { dataOptions, useChartData } from './query-options-new' - +import { COLUMNS } from './components/Columns' +import { MemoizedDataTableSheetContent } from './components/DataTableSheetContent' +import { FunctionLogsTab } from './components/FunctionLogsTab' +import { TraceDetailTab } from './components/TraceDetailTab' +import { dataOptions, useChartData } from './QueryOptions' import { CHART_CONFIG, SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' -import { getFacetedUniqueValues, useLiveMode, useResetFocus } from './UnifiedLogs.utils' +import { filterFields as defaultFilterFields, sheetFields } from './UnifiedLogs.fields' +import { useLiveMode, useResetFocus } from './UnifiedLogs.hooks' +import { getFacetedUniqueValues, getLevelRowClassName, logEventBus } from './UnifiedLogs.utils' // Debug mode flag - set to true to enable detailed logs const DEBUG_FILTER_PROCESSING = false +/** + * [Joshen] + * Phase 1: Clean up + * - Deprecate unused files + code + * - Make all files + code style consistent + * - Resolve circular imports + * Aim is to prevent PR from bloating up any further + * Search for components/interfaces/DataTableDemo + * Ensure that all useDataTable is going from ui/DataTable + * + * Phase 2: Fixes + Refactors + * - Refactor infinite query to make it consistent with our writing style + * - Resolve all other issues that we identified + * Aim is ensure that the UI is fully functional and ready for incremental roll out + * + * Phase 3: Ad-hoc adjustments + * - Incremental roll-out + * Aim is to get this ready for GA for all users + */ + /** * [Joshen] Slowly... This branch is a huge mess... Needs a shit ton of refactoring * - Deprecate infinite-logs-example @@ -71,6 +84,9 @@ const DEBUG_FILTER_PROCESSING = false * - Deprecate Trace (?) * - Rename logs-new to UnifiedLogs * - Components all need to be renamed for follow convention + * - Resolve all circular imports + * + * First step is to clean up - make all the files consistent so that I can make sense of the code */ export const UnifiedLogsTable = () => { @@ -139,13 +155,13 @@ export const UnifiedLogsTable = () => { ) => { const rowTimestamp = row.original.timestamp const isPast = rowTimestamp <= (liveMode.timestamp || -1) - const levelClassName = getLevelRowClassName(row.original.level) + const levelClassName = getLevelRowClassName(row.original.level as any) return cn(levelClassName, isPast ? 'opacity-50' : 'opacity-100') } const table = useReactTable({ data: flatData, - columns, + columns: COLUMNS, state: { columnFilters, sorting, @@ -290,7 +306,7 @@ export const UnifiedLogsTable = () => { return ( { renderActions={() => [ , fetchPreviousPage ? ( - + ) : null, ]} /> @@ -331,7 +351,7 @@ export const UnifiedLogsTable = () => {
{ renderLiveRow={(props) => { if (!liveMode.timestamp) return null if (props?.row.original.uuid !== liveMode?.row?.uuid) return null - return + return }} setColumnOrder={setColumnOrder} setColumnVisibility={setColumnVisibility} + searchParamsParser={SEARCH_PARAMS_PARSER} />
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts index d1f345f3731..9158dcf9acd 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts @@ -1,10 +1,88 @@ import { type inferParserType } from 'nuqs' import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' +import type { BaseChartSchema, ColumnSchema, FacetMetadataSchema } from './UnifiedLogs.schema' + +type Percentile = 50 | 75 | 90 | 95 | 99 + +export type UnifiedLogSchema = { + id: string + timestamp: Date + log_type: + | 'edge' + | 'postgres' + | 'function logs' + | 'edge function' + | 'auth' + | 'supavisor' + | 'postgres upgrade' + code: string + level: string + path: string | null + event_message: string + method: string + api_role: string + auth_user: string | null +} + +// Extended column schema to include raw timestamp +export type ExtendedColumnSchema = ColumnSchema & { + timestamp: string // Original database timestamp + date: Date // Date object for display +} + +export type LogsMeta = { + currentPercentiles: Record +} export type UnifiedLogsMeta = { - logTypeCounts: Record + logTypeCounts: Record currentPercentiles: Record } +export type PageParam = { cursor: number; direction: 'next' | 'prev' } + export type SearchParamsType = inferParserType + +type InfiniteQueryMeta> = { + totalRowCount: number + filterRowCount: number + chartData: BaseChartSchema[] + facets: Record + metadata?: TMeta +} + +export type InfiniteQueryResponse = { + data: TData + meta: InfiniteQueryMeta + prevCursor: number | null + nextCursor: number | null +} + +export type SearchParams = { + [key: string]: string | string[] | undefined +} + +/** ----------------------------------------- */ + +export type SheetField> = { + id: keyof TData + label: string + // FIXME: rethink that! I dont think we need this as there is no input type + // REMINDER: readonly if we only want to copy the value (e.g. uuid) + // TODO: we might have some values that are not in the data but can be computed + type: 'readonly' | 'input' | 'checkbox' | 'slider' | 'timerange' + component?: ( + // REMINDER: this is used to pass additional data like the `InfiniteQueryMeta` + props: TData & { + metadata?: { + totalRows: number + filterRows: number + totalRowsFetched: number + } & TMeta + } + ) => JSX.Element | null | string + condition?: (props: TData) => boolean + className?: string + skeletonClassName?: string +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts index 67324e1ca29..a32831a39a8 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts @@ -1,52 +1,23 @@ -// @ts-ignore -import { _LEVELS } from '@/constants/levels' import { type Table as TTable } from '@tanstack/react-table' -import { useQueryState } from 'nuqs' -import { useHotKey } from 'hooks/ui/useHotKey' -import { useEffect, useMemo, useRef } from 'react' +import { LEVELS } from 'components/ui/DataTable/DataTable.constants' import { cn } from 'ui' -import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' import { FacetMetadataSchema } from './UnifiedLogs.schema' -export const useResetFocus = () => { - useHotKey(() => { - // FIXME: some dedicated div[tabindex="0"] do not auto-unblur (e.g. the DataTableFilterResetButton) - // REMINDER: we cannot just document.activeElement?.blur(); as the next tab will focus the next element in line, - // which is not what we want. We want to reset entirely. - document.body.setAttribute('tabindex', '0') - document.body.focus() - document.body.removeAttribute('tabindex') - }, '.') -} +export const logEventBus = { + listeners: new Map void>>(), -// TODO: make a BaseObject (incl. date and uuid e.g. for every upcoming branch of infinite table) -export const useLiveMode = (data: TData[]) => { - const [live] = useQueryState('live', SEARCH_PARAMS_PARSER.live) - // REMINDER: used to capture the live mode on timestamp - const liveTimestamp = useRef(live ? new Date().getTime() : undefined) + on(event: 'selectTraceTab', callback: (rowId: string) => void) { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()) + } + this.listeners.get(event)?.add(callback) + return () => this.listeners.get(event)?.delete(callback) + }, - useEffect(() => { - if (live) liveTimestamp.current = new Date().getTime() - else liveTimestamp.current = undefined - }, [live]) - - const anchorRow = useMemo(() => { - if (!live) return undefined - - const item = data.find((item) => { - // return first item that is there if not liveTimestamp - if (!liveTimestamp.current) return true - // return first item that is after the liveTimestamp - if (item.date.getTime() > liveTimestamp.current) return false - return true - // return first item if no liveTimestamp - }) - - return item - }, [live, data]) - - return { row: anchorRow, timestamp: liveTimestamp.current } + emit(event: 'selectTraceTab', rowId: string) { + this.listeners.get(event)?.forEach((callback) => callback(rowId)) + }, } export const getFacetedUniqueValues = (facets?: Record) => { @@ -66,7 +37,7 @@ export const getFacetedMinMaxValues = (facets?: Record { +export const getLevelLabel = (value: (typeof LEVELS)[number]): string => { switch (value) { case 'success': return '2xx' @@ -79,7 +50,7 @@ export const getLevelLabel = (value: (typeof _LEVELS)[number]): string => { } } -export function getLevelRowClassName(value: (typeof _LEVELS)[number]): string { +export function getLevelRowClassName(value: (typeof LEVELS)[number]): string { switch (value) { case 'success': return '' diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/auth-user-hover-card.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx similarity index 96% rename from apps/studio/components/interfaces/UnifiedLogs/components/auth-user-hover-card.tsx rename to apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx index 43f3c8a95aa..0d9f329dd92 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/auth-user-hover-card.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx @@ -1,7 +1,5 @@ -'use client' - -import React from 'react' import { User } from 'lucide-react' + import { HoverCard, HoverCardContent, diff --git a/apps/studio/components/interfaces/UnifiedLogs/columns.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx similarity index 80% rename from apps/studio/components/interfaces/UnifiedLogs/columns.tsx rename to apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx index e54ceb2cc1d..292dc954b65 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/columns.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx @@ -1,35 +1,18 @@ -import type { ColumnDef } from '@tanstack/react-table' +import { ColumnDef } from '@tanstack/react-table' import { Bolt } from 'lucide-react' -import { TextWithTooltip } from 'components/interfaces/DataTableDemo/components/custom/text-with-tooltip' -import { DataTableColumnHeader } from 'components/interfaces/DataTableDemo/components/data-table/data-table-column-header' -import { DataTableColumnLevelIndicator } from 'components/interfaces/DataTableDemo/components/data-table/data-table-column/data-table-column-level-indicator' -import { DataTableColumnStatusCode } from 'components/interfaces/DataTableDemo/components/data-table/data-table-column/data-table-column-status-code' -import { HoverCardTimestamp } from 'components/interfaces/DataTableDemo/infinite/_components/hover-card-timestamp' +import { DataTableColumnHeader } from 'components/ui/DataTable/DataTableColumn/DataTableColumnHeader' +import { DataTableColumnLevelIndicator } from 'components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator' +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { AuthUserHoverCard } from './components/auth-user-hover-card' +import { ColumnFilterSchema, ColumnSchema } from '../UnifiedLogs.schema' +import { logEventBus } from '../UnifiedLogs.utils' +import { AuthUserHoverCard } from './AuthUserHoverCard' +import { HoverCardTimestamp } from './HoverCardTimestamp' import { LogTypeIcon } from './LogTypeIcon' -import type { ColumnSchema } from './UnifiedLogs.schema' +import { TextWithTooltip } from './TextWithTooltip' -// Event broadcaster for selecting the trace tab -// This will be used to communicate between the columns and the client component -export const logEventBus = { - listeners: new Map void>>(), - - on(event: 'selectTraceTab', callback: (rowId: string) => void) { - if (!this.listeners.has(event)) { - this.listeners.set(event, new Set()) - } - this.listeners.get(event)?.add(callback) - return () => this.listeners.get(event)?.delete(callback) - }, - - emit(event: 'selectTraceTab', rowId: string) { - this.listeners.get(event)?.forEach((callback) => callback(rowId)) - }, -} - -export const columns: ColumnDef[] = [ +export const COLUMNS: ColumnDef[] = [ { accessorKey: 'level', header: '', @@ -175,7 +158,7 @@ export const columns: ColumnDef[] = [ accessorKey: 'pathname', header: 'Pathname', cell: ({ row }) => { - const value = row.getValue('pathname') + const value = row.getValue('pathname') ?? '' return }, size: 320, diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/DataTableInfinite.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/DataTableInfinite.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/data-table-sheet-content.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetContent.tsx similarity index 70% rename from apps/studio/components/interfaces/UnifiedLogs/components/data-table-sheet-content.tsx rename to apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetContent.tsx index ebe4504dc97..89b8eab3a26 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/data-table-sheet-content.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetContent.tsx @@ -1,23 +1,40 @@ -'use client' - -import * as React from 'react' -import { cn } from 'ui' import { Table } from '@tanstack/react-table' -import { DataTableSheetRowAction } from 'components/interfaces/DataTableDemo/components/data-table/data-table-sheet/data-table-sheet-row-action' -import { - DataTableFilterField, - SheetField, -} from 'components/interfaces/DataTableDemo/components/data-table/types' -import { SheetDetailsContentSkeleton } from 'components/interfaces/DataTableDemo/components/data-table/data-table-sheet/data-table-sheet-skeleton' +import { memo } from 'react' + +import { DataTableFilterField } from 'components/ui/DataTable/DataTable.types' +import { cn, Skeleton } from 'ui' +import { SheetField } from '../UnifiedLogs.types' +import { DataTableSheetRowAction } from './DataTableSheetRowAction' + +interface SheetDetailsContentSkeletonProps { + fields: SheetField[] +} + +const SheetDetailsContentSkeleton = ({ + fields, +}: SheetDetailsContentSkeletonProps) => { + return ( +
+ {fields.map((field) => ( +
+
{field.label}
+
+ +
+
+ ))} +
+ ) +} interface DataTableSheetContentProps extends React.HTMLAttributes { data?: TData table: Table fields: SheetField[] filterFields: DataTableFilterField[] - // totalRows: number; - // filterRows: number; - // totalRowsFetched: number; metadata?: TMeta & { totalRows: number filterRows: number @@ -82,7 +99,7 @@ export function DataTableSheetContent({ ) } -export const MemoizedDataTableSheetContent = React.memo(DataTableSheetContent, (prev, next) => { +export const MemoizedDataTableSheetContent = memo(DataTableSheetContent, (prev, next) => { // REMINDER: only check if data is the same, rest is useless return prev.data === next.data }) as typeof DataTableSheetContent diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetRowAction.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetRowAction.tsx new file mode 100644 index 00000000000..47f02763d75 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetRowAction.tsx @@ -0,0 +1,187 @@ +import { Table } from '@tanstack/react-table' +import { endOfDay, endOfHour, startOfDay, startOfHour } from 'date-fns' +import { + CalendarClock, + CalendarDays, + CalendarSearch, + ChevronLeft, + ChevronRight, + Copy, + Equal, + Search, +} from 'lucide-react' +import { ComponentPropsWithRef } from 'react' + +import { DataTableFilterField } from 'components/ui/DataTable/DataTable.types' +import { useCopyToClipboard } from 'hooks/ui/useCopyToClipboard' +import { + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'ui' + +interface DataTableSheetRowActionProps> + extends ComponentPropsWithRef { + fieldValue: TFields['value'] + filterFields: TFields[] + value: string | number + table: Table +} + +export function DataTableSheetRowAction>({ + fieldValue, + filterFields, + value, + children, + className, + table, + onKeyDown, + ...props +}: DataTableSheetRowActionProps) { + const { copy, isCopied } = useCopyToClipboard() + const field = filterFields.find((field) => field.value === fieldValue) + const column = table.getColumn(fieldValue.toString()) + + if (!field || !column) return null + + function renderOptions() { + if (!field) return null + switch (field.type) { + case 'checkbox': + return ( + { + // FIXME: + const filterValue = column?.getFilterValue() as undefined | Array + const newValue = filterValue?.includes(value) + ? filterValue + : [...(filterValue || []), value] + + column?.setFilterValue(newValue) + }} + className="flex items-center gap-2" + > + + Include + + ) + case 'input': + return ( + column?.setFilterValue(value)} + className="flex items-center gap-2" + > + + Include + + ) + case 'slider': + return ( + + column?.setFilterValue([0, value])} + className="flex items-center gap-2" + > + {/* FIXME: change icon as it is not clear */} + + Less or equal than + + column?.setFilterValue([value, 5000])} + className="flex items-center gap-2" + > + {/* FIXME: change icon as it is not clear */} + + Greater or equal than + + column?.setFilterValue([value])} + className="flex items-center gap-2" + > + + Equal to + + + ) + case 'timerange': + const date = new Date(value) + return ( + + column?.setFilterValue([date])} + className="flex items-center gap-2" + > + + Exact timestamp + + { + const start = startOfHour(date) + const end = endOfHour(date) + column?.setFilterValue([start, end]) + }} + className="flex items-center gap-2" + > + + Same hour + + { + const start = startOfDay(date) + const end = endOfDay(date) + column?.setFilterValue([start, end]) + }} + className="flex items-center gap-2" + > + + Same day + + + ) + default: + return null + } + } + + return ( + + { + if (e.key === 'ArrowDown') { + // REMINDER: default behavior is to open the dropdown menu + // But because we use it to navigate between rows, we need to prevent it + // and only use "Enter" to select the option + e.preventDefault() + } + onKeyDown?.(e) + }} + {...props} + > + {children} + {isCopied ? ( +
Value copied
+ ) : null} +
+ + {renderOptions()} + + copy(String(value), { timeout: 1000 })} + className="flex items-center gap-2" + > + + Copy value + + +
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/function-logs-tab.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/FunctionLogsTab.tsx similarity index 80% rename from apps/studio/components/interfaces/UnifiedLogs/components/function-logs-tab.tsx rename to apps/studio/components/interfaces/UnifiedLogs/components/FunctionLogsTab.tsx index 58a74e94a83..3607f940d9b 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/function-logs-tab.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/FunctionLogsTab.tsx @@ -1,7 +1,5 @@ -import React from 'react' - -import { DataTableColumnStatusCode } from 'components/interfaces/DataTableDemo/components/data-table/data-table-column/data-table-column-status-code' -import { HoverCardTimestamp } from 'components/interfaces/DataTableDemo/infinite/_components/hover-card-timestamp' +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { HoverCardTimestamp } from './HoverCardTimestamp' interface FunctionLogEntry { id: string @@ -15,7 +13,7 @@ interface FunctionLogsTabProps { logs?: FunctionLogEntry[] } -export const FunctionLogsTab: React.FC = ({ logs = [] }) => { +export const FunctionLogsTab = ({ logs = [] }: FunctionLogsTabProps) => { if (!logs || logs.length === 0) { return (
diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx new file mode 100644 index 00000000000..76532b0cb78 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx @@ -0,0 +1,26 @@ +import { format } from 'date-fns' + +import { cn } from 'ui' +import { TimestampInfo } from 'ui-patterns' + +interface HoverCardTimestampProps { + date: Date + className?: string +} + +export function HoverCardTimestamp({ date, className }: HoverCardTimestampProps) { + return ( +
+ +
+ {format(date, 'MMM')} + {format(date, 'dd')} + + {format(date, 'HH:mm:ss')} + .{format(date, 'SS')} + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/LogTypeIcon.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx similarity index 96% rename from apps/studio/components/interfaces/UnifiedLogs/LogTypeIcon.tsx rename to apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx index 91dadb4a4cc..9837fee0e34 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/LogTypeIcon.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx @@ -1,9 +1,8 @@ -'use client' +import { BookHeart, Box, Cpu, Database, Globe } from 'lucide-react' import { Auth, EdgeFunctions, Storage } from 'icons' -import { BookHeart, Box, Cpu, Database, Globe } from 'lucide-react' import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { type LOG_TYPES } from './UnifiedLogs.schema' +import { type LOG_TYPES } from '../UnifiedLogs.schema' interface LogTypeIconProps { type: (typeof LOG_TYPES)[number] diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/TextWithTooltip.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/TextWithTooltip.tsx new file mode 100644 index 00000000000..fae42e933ac --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/TextWithTooltip.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from 'react' +import { cn, Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger } from 'ui' + +interface TextWithTooltipProps { + text: string | number + className?: string +} + +export function TextWithTooltip({ text, className }: TextWithTooltipProps) { + const [isTruncated, setIsTruncated] = useState(false) + const textRef = useRef(null) + + useEffect(() => { + const checkTruncation = () => { + if (textRef.current) { + const { scrollWidth, clientWidth } = textRef.current + setIsTruncated(scrollWidth > clientWidth) + } + } + + const resizeObserver = new ResizeObserver(() => { + checkTruncation() + }) + + if (textRef.current) { + resizeObserver.observe(textRef.current) + } + + checkTruncation() + + return () => { + resizeObserver.disconnect() + } + }, []) + + return ( + + + +
+ {text} +
+
+ + {text} + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/TooltipLabel.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/TooltipLabel.tsx similarity index 86% rename from apps/studio/components/interfaces/UnifiedLogs/TooltipLabel.tsx rename to apps/studio/components/interfaces/UnifiedLogs/components/TooltipLabel.tsx index b29ae4c7946..7745d22b864 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/TooltipLabel.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/TooltipLabel.tsx @@ -1,4 +1,4 @@ -import { getLevelLabel } from './UnifiedLogs.utils' +import { getLevelLabel } from '../UnifiedLogs.utils' export const TooltipLabel = ({ level }: { level: 'success' | 'warning' | 'error' }) => { return ( diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/trace-button.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/TraceButton.tsx similarity index 96% rename from apps/studio/components/interfaces/UnifiedLogs/components/trace-button.tsx rename to apps/studio/components/interfaces/UnifiedLogs/components/TraceButton.tsx index 7ca4b36ba57..4164a51177f 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/trace-button.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/TraceButton.tsx @@ -1,5 +1,3 @@ -'use client' - import { Bolt } from 'lucide-react' import { Badge } from 'ui' diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/trace-detail-tab.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/TraceDetailTab.tsx similarity index 88% rename from apps/studio/components/interfaces/UnifiedLogs/components/trace-detail-tab.tsx rename to apps/studio/components/interfaces/UnifiedLogs/components/TraceDetailTab.tsx index f52543844da..736f4fbf9e9 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/trace-detail-tab.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/TraceDetailTab.tsx @@ -1,6 +1,3 @@ -'use client' - -import React from 'react' import TraceViewerClient from 'components/interfaces/Trace/trace-viewer-client' interface TraceDetailTabProps { diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/data-table-sheet-details-layout.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/data-table-sheet-details-layout.tsx deleted file mode 100644 index afb19bd1be2..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/components/data-table-sheet-details-layout.tsx +++ /dev/null @@ -1,56 +0,0 @@ -'use client' - -import * as React from 'react' -import { DataTableSheetDetails } from 'components/interfaces/DataTableDemo/components/data-table/data-table-sheet/data-table-sheet-details' -import { - Tabs_Shadcn_ as Tabs, - TabsContent_Shadcn_ as TabsContent, - TabsList_Shadcn_ as TabsList, - TabsTrigger_Shadcn_ as TabsTrigger, -} from 'ui/src/components/shadcn/ui/tabs' -import { TraceDetailTab } from './trace-detail-tab' - -interface DataTableSheetDetailsLayoutProps { - title?: string - titleClassName?: string - hasTrace?: boolean - traceId?: string - children: React.ReactNode -} - -export function DataTableSheetDetailsLayout({ - title, - titleClassName, - hasTrace, - traceId, - children, -}: DataTableSheetDetailsLayoutProps) { - return ( - - {hasTrace ? ( - - - Log Details - Trace - - - - {children} - - - - - - - ) : ( - children - )} - - ) -} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/tabbed-detail-panel.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/tabbed-detail-panel.tsx deleted file mode 100644 index bdedc94d405..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/components/tabbed-detail-panel.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client' - -import * as React from 'react' -import { TraceDetailTab } from './trace-detail-tab' -import { DataTableSheetDetails } from 'components/interfaces/DataTableDemo/components/data-table/data-table-sheet/data-table-sheet-details' -import { - Tabs_Shadcn_ as Tabs, - TabsContent_Shadcn_ as TabsContent, - TabsList_Shadcn_ as TabsList, - TabsTrigger_Shadcn_ as TabsTrigger, - cn, -} from 'ui' -import { MemoizedDataTableSheetContent } from './data-table-sheet-content' -import { Table } from '@tanstack/react-table' -import { - DataTableFilterField, - SheetField, -} from 'components/interfaces/DataTableDemo/components/data-table/types' - -interface LogDetailsTabProps { - table: Table - data?: TData - filterFields: DataTableFilterField[] - fields: SheetField[] - metadata?: TMeta & { - totalRows: number - filterRows: number - totalRowsFetched: number - } -} - -export function LogDetailsTab({ - table, - data, - filterFields, - fields, - metadata, -}: LogDetailsTabProps) { - return ( - - ) -} - -interface TabbedDetailPanelProps { - title?: string - titleClassName?: string - table: Table - data?: TData - filterFields: DataTableFilterField[] - fields: SheetField[] - metadata?: TMeta & { - totalRows: number - filterRows: number - totalRowsFetched: number - } -} - -export function TabbedDetailPanel({ - title, - titleClassName, - table, - data, - filterFields, - fields, - metadata, -}: TabbedDetailPanelProps) { - // Check if the data has a trace - const hasTrace = data?.has_trace - - return ( - - - - Log Details - {hasTrace && Trace} - - - - - - - {hasTrace && ( - - - - )} - - - ) -} diff --git a/apps/studio/components/interfaces/UnifiedLogs/constants/date-preset.ts b/apps/studio/components/interfaces/UnifiedLogs/constants/date-preset.ts deleted file mode 100644 index 305aa489893..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/constants/date-preset.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { DatePreset } from "@/components/data-table/types"; -import { addDays, addHours, endOfDay, startOfDay } from "date-fns"; - -export const presets = [ - { - label: "Today", - from: startOfDay(new Date()), - to: endOfDay(new Date()), - shortcut: "d", // day - }, - { - label: "Yesterday", - from: startOfDay(addDays(new Date(), -1)), - to: endOfDay(addDays(new Date(), -1)), - shortcut: "y", - }, - { - label: "Last hour", - from: addHours(new Date(), -1), - to: new Date(), - shortcut: "h", - }, - { - label: "Last 7 days", - from: startOfDay(addDays(new Date(), -7)), - to: endOfDay(new Date()), - shortcut: "w", - }, - { - label: "Last 14 days", - from: startOfDay(addDays(new Date(), -14)), - to: endOfDay(new Date()), - shortcut: "b", // bi-weekly - }, - { - label: "Last 30 days", - from: startOfDay(addDays(new Date(), -30)), - to: endOfDay(new Date()), - shortcut: "m", - }, -] satisfies DatePreset[]; diff --git a/apps/studio/components/interfaces/UnifiedLogs/constants/levels.ts b/apps/studio/components/interfaces/UnifiedLogs/constants/levels.ts deleted file mode 100644 index 4626401262c..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/constants/levels.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const LEVELS = ["success", "warning", "error"] as const; - -// FIXME: improve -export const _LEVELS = [...LEVELS, "info"] as const; diff --git a/apps/studio/components/interfaces/UnifiedLogs/constants/method.ts b/apps/studio/components/interfaces/UnifiedLogs/constants/method.ts deleted file mode 100644 index 3989dd9ce44..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/constants/method.ts +++ /dev/null @@ -1 +0,0 @@ -export const METHODS = ["GET", "POST", "PUT", "DELETE"] as const; diff --git a/apps/studio/components/interfaces/UnifiedLogs/constants/query-params.ts b/apps/studio/components/interfaces/UnifiedLogs/constants/query-params.ts deleted file mode 100644 index eb7956088fb..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/constants/query-params.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Parameters that should be excluded from standard SQL query condition building - */ - -// Pagination and control parameters -export const PAGINATION_PARAMS = [ - 'sort', - 'start', - 'size', - 'uuid', - 'cursor', - 'direction', - 'live', -] as const - -export type PaginationParamType = (typeof PAGINATION_PARAMS)[number] - -// Special filter parameters that need custom handling -export const SPECIAL_FILTER_PARAMS = ['date'] as const - -export type SpecialFilterParamType = (typeof SPECIAL_FILTER_PARAMS)[number] - -// Combined list of all parameters to exclude from standard filtering -export const EXCLUDED_QUERY_PARAMS = [...PAGINATION_PARAMS, ...SPECIAL_FILTER_PARAMS] as const - -export type ExcludedQueryParamType = (typeof EXCLUDED_QUERY_PARAMS)[number] - -/** - * Parameters that need special handling in buildBaseConditions and raw SQL queries - * This is different from the EXCLUDED_QUERY_PARAMS used in getUnifiedLogsQuery. - * - * In raw SQL queries against log type tables, 'level' needs to be processed differently - * because it maps to different columns/conditions depending on the log type. - */ -export const BASE_CONDITIONS_EXCLUDED_PARAMS = [...PAGINATION_PARAMS, 'date', 'level'] as const diff --git a/apps/studio/components/interfaces/UnifiedLogs/constants/region.ts b/apps/studio/components/interfaces/UnifiedLogs/constants/region.ts deleted file mode 100644 index 358ebd59c04..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/constants/region.ts +++ /dev/null @@ -1,49 +0,0 @@ -export const REGIONS = ["ams", "fra", "gru", "hkg", "iad", "syd"] as const; - -export const VERCEL_EDGE_REGIONS = [ - "hnd1", - "sin1", - "cpt1", - "fra1", - "hkg1", - "syd1", - "gru1", - "dub1", - "sfo1", - "cdg1", - "icn1", - "kix1", - "iad1", - "arn1", - "bom1", - "lhr1", - "cle1", -] as const; - -export const regions: Record = { - // REGIONS - ams: { label: "Amsterdam", flag: "🇳🇱" }, - fra: { label: "Frankfurt", flag: "🇩🇪" }, - gru: { label: "Sao Paulo", flag: "🇧🇷" }, - hkg: { label: "Hong Kong", flag: "🇭🇰" }, - iad: { label: "Washington D.C.", flag: "🇺🇸" }, - syd: { label: "Sydney", flag: "🇦🇺" }, - // VERCEL EDGE REGIONS - hnd1: { label: "Tokyo", flag: "🇯🇵" }, - sin1: { label: "Singapore", flag: "🇸🇬" }, - cpt1: { label: "Cape Town", flag: "🇿🇦" }, - fra1: { label: "Paris", flag: "🇫🇷" }, - hkg1: { label: "Hong Kong", flag: "🇭🇰" }, - syd1: { label: "Sydney", flag: "🇦🇺" }, - gru1: { label: "Sao Paulo", flag: "🇧🇷" }, - dub1: { label: "Dublin", flag: "🇮🇪" }, - sfo1: { label: "San Francisco", flag: "🇺🇸" }, - cdg1: { label: "Paris", flag: "🇫🇷" }, - icn1: { label: "Seoul", flag: "🇰🇷" }, - kix1: { label: "Osaka", flag: "🇯🇵" }, - iad1: { label: "Washington D.C.", flag: "🇺🇸" }, - arn1: { label: "Stockholm", flag: "🇸🇪" }, - bom1: { label: "Mumbai", flag: "🇮🇳" }, - lhr1: { label: "London", flag: "🇬🇧" }, - cle1: { label: "Cleveland", flag: "🇺🇸" }, -}; diff --git a/apps/studio/components/interfaces/UnifiedLogs/constants/tag.ts b/apps/studio/components/interfaces/UnifiedLogs/constants/tag.ts deleted file mode 100644 index 020ac349c3f..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/constants/tag.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const TAGS = ["web", "api", "enterprise", "app"] as const; - -export const tagColor: Record> = { - api: { - badge: - "text-[#10b981] bg-[#10b981]/10 border-[#10b981]/20 hover:bg-[#10b981]/10", - dot: "bg-[#10b981]", - }, - web: { - badge: - "text-[#0ea5e9] bg-[#0ea5e9]/10 border-[#0ea5e9]/20 hover:bg-[#0ea5e9]/10", - dot: "bg-[#0ea5e9]", - }, - enterprise: { - badge: - "text-[#ec4899] bg-[#ec4899]/10 border-[#ec4899]/20 hover:bg-[#ec4899]/10", - dot: "bg-[#ec4899]", - }, - app: { - badge: - "text-[#f97316] bg-[#f97316]/10 border-[#f97316]/20 hover:bg-[#f97316]/10", - dot: "bg-[#f97316]", - }, -}; diff --git a/apps/studio/components/interfaces/UnifiedLogs/query-options.ts b/apps/studio/components/interfaces/UnifiedLogs/query-options.ts deleted file mode 100644 index f0e8bc20cd6..00000000000 --- a/apps/studio/components/interfaces/UnifiedLogs/query-options.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { Percentile } from 'components/interfaces/DataTableDemo/lib/request/percentile' -// Removed keepPreviousData import as it causes persistent lint errors -// import { keepPreviousData } from '@tanstack/react-query' -import SuperJSON from 'superjson' -import type { BaseChartSchema, ColumnSchema, FacetMetadataSchema } from './UnifiedLogs.schema' -// Removed: import { searchParamsSerializer } from './search-params' -import { ARRAY_DELIMITER, SORT_DELIMITER } from 'components/interfaces/DataTableDemo/lib/delimiters' -import { SearchParamsType } from './UnifiedLogs.types' - -// Restore the helper function to create query params string -const createApiQueryString = (params: Record): string => { - const queryParams = new URLSearchParams() - - for (const [key, value] of Object.entries(params)) { - if (value === null || value === undefined) continue - - // Using logic similar to the original light version - if (key === 'date' && Array.isArray(value) && value.length === 2) { - queryParams.set('dateStart', value[0].getTime().toString()) - queryParams.set('dateEnd', value[1].getTime().toString()) - } else if ( - [ - 'latency', - 'timing.dns', - 'timing.connection', - 'timing.tls', - 'timing.ttfb', - 'timing.transfer', - 'status', - ].includes(key) && - Array.isArray(value) && - value.length > 0 - ) { - // Assuming slider or range gives min/max - // Use the correct delimiters if necessary based on how backend expects ranges - if (value.length === 1) { - // Handle single value if needed, maybe just set the key? - // queryParams.set(key, value[0].toString()); - } else if (value.length >= 2) { - queryParams.set(`${key}Start`, value[0].toString()) - queryParams.set(`${key}End`, value[value.length - 1].toString()) - } - } else if (Array.isArray(value)) { - if (value.length > 0) { - // Use comma (ARRAY_DELIMITER) separation for arrays - queryParams.set(key, value.join(ARRAY_DELIMITER)) - } - } else if (key === 'sort' && typeof value === 'object' && value !== null) { - queryParams.set( - key, - `${(value as { id: string; desc: boolean }).id}${SORT_DELIMITER}${(value as { id: string; desc: boolean }).desc ? 'desc' : 'asc'}` - ) - } else if (value instanceof Date) { - queryParams.set(key, value.getTime().toString()) - } else { - queryParams.set(key, String(value)) - } - } - const queryString = queryParams.toString() - return queryString ? `?${queryString}` : '' -} - -export type LogsMeta = { - currentPercentiles: Record -} - -export type InfiniteQueryMeta> = { - totalRowCount: number - filterRowCount: number - chartData: BaseChartSchema[] - facets: Record - metadata?: TMeta -} - -export type InfiniteQueryResponse = { - data: TData - meta: InfiniteQueryMeta - prevCursor: number | null - nextCursor: number | null -} - -// Define pageParam type -type PageParam = { cursor: number; direction: 'next' | 'prev' } - -export const dataOptions = (search: SearchParamsType) => { - // Create a stable query key object by removing nulls/undefined, uuid, and live - const queryKeyParams = Object.entries(search).reduce( - (acc, [key, value]) => { - if (!['uuid', 'live'].includes(key) && value !== null && value !== undefined) { - acc[key] = value - } - return acc - }, - {} as Record - ) - - // Simply return the options object - return { - queryKey: [ - 'data-table', - // Use JSON.stringify for a stable key representation - JSON.stringify(queryKeyParams), - ], - queryFn: async ({ pageParam }: { pageParam: PageParam }) => { - // Added type for pageParam - const cursor = pageParam?.cursor ? new Date(pageParam.cursor) : undefined - const direction = pageParam?.direction - - // Construct params for API call, removing uuid and live - const apiParams = { - ...search, - uuid: null, // Explicitly remove for API call - live: null, // Explicitly remove for API call - ...(cursor && { cursor }), // Add cursor if present - ...(direction && { direction }), // Add direction if present - } - - // Assuming the API route is /api/data-table/infinite - const response = await fetch(`/api/data-table/infinite${createApiQueryString(apiParams)}`) - - if (!response.ok) { - throw new Error('Network response was not ok') - } - - const jsonString = await response.text() - return SuperJSON.parse>(jsonString) - }, - initialPageParam: { cursor: new Date().getTime(), direction: 'next' } as PageParam, - getPreviousPageParam: ( - firstPage: InfiniteQueryResponse, - _pages: InfiniteQueryResponse[] // Added types - ) => { - if (!firstPage.prevCursor) return null - return { cursor: firstPage.prevCursor, direction: 'prev' } as PageParam - }, - getNextPageParam: ( - lastPage: InfiniteQueryResponse, - _pages: InfiniteQueryResponse[] // Added types - ) => { - if (!lastPage.nextCursor) return null - return { cursor: lastPage.nextCursor, direction: 'next' } as PageParam - }, - refetchOnWindowFocus: false, - // Removed placeholderData due to persistent lint/resolution issues - // placeholderData: keepPreviousData, - } -} diff --git a/apps/studio/components/ui/DataTable/DataTable.constants.ts b/apps/studio/components/ui/DataTable/DataTable.constants.ts new file mode 100644 index 00000000000..da947462782 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTable.constants.ts @@ -0,0 +1,50 @@ +import { addDays, addHours, endOfDay, startOfDay } from 'date-fns' + +import { DatePreset } from './DataTable.types' + +export const ARRAY_DELIMITER = ',' +export const SLIDER_DELIMITER = '-' +export const SPACE_DELIMITER = '_' +export const RANGE_DELIMITER = '-' +export const SORT_DELIMITER = '.' + +export const LEVELS = ['success', 'warning', 'error', 'info'] as const + +export const presets = [ + { + label: 'Today', + from: startOfDay(new Date()), + to: endOfDay(new Date()), + shortcut: 'd', // day + }, + { + label: 'Yesterday', + from: startOfDay(addDays(new Date(), -1)), + to: endOfDay(addDays(new Date(), -1)), + shortcut: 'y', + }, + { + label: 'Last hour', + from: addHours(new Date(), -1), + to: new Date(), + shortcut: 'h', + }, + { + label: 'Last 7 days', + from: startOfDay(addDays(new Date(), -7)), + to: endOfDay(new Date()), + shortcut: 'w', + }, + { + label: 'Last 14 days', + from: startOfDay(addDays(new Date(), -14)), + to: endOfDay(new Date()), + shortcut: 'b', // bi-weekly + }, + { + label: 'Last 30 days', + from: startOfDay(addDays(new Date(), -30)), + to: endOfDay(new Date()), + shortcut: 'm', + }, +] satisfies DatePreset[] diff --git a/apps/studio/components/ui/DataTable/DataTable.types.ts b/apps/studio/components/ui/DataTable/DataTable.types.ts new file mode 100644 index 00000000000..9ee5cada849 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTable.types.ts @@ -0,0 +1,61 @@ +// TODO: we could type the value(!) especially when using enums +export type Option = { + label: string + value: string | boolean | number | undefined +} + +export type DatePreset = { + label: string + from: Date + to: Date + shortcut: string +} + +export type Input = { + type: 'input' + options?: Option[] +} + +export type Checkbox = { + type: 'checkbox' + component?: (props: Option) => JSX.Element | null + options?: Option[] +} + +export type Slider = { + type: 'slider' + min: number + max: number + // if options is undefined, we will provide all the steps between min and max + options?: Option[] +} + +export type Timerange = { + type: 'timerange' + options?: Option[] // required for TS + presets?: DatePreset[] +} + +export type Base = { + label: string + value: keyof TData + /** + * Defines if the accordion in the filter bar is open by default + */ + defaultOpen?: boolean + /** + * Defines if the command input is disabled for this field + */ + commandDisabled?: boolean +} + +export type DataTableCheckboxFilterField = Base & Checkbox +export type DataTableSliderFilterField = Base & Slider +export type DataTableInputFilterField = Base & Input +export type DataTableTimerangeFilterField = Base & Timerange + +export type DataTableFilterField = + | DataTableCheckboxFilterField + | DataTableSliderFilterField + | DataTableInputFilterField + | DataTableTimerangeFilterField diff --git a/apps/studio/components/ui/DataTable/DataTable.utils.ts b/apps/studio/components/ui/DataTable/DataTable.utils.ts new file mode 100644 index 00000000000..12997306dab --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTable.utils.ts @@ -0,0 +1,129 @@ +import { FilterFn } from '@tanstack/react-table' +import { isAfter, isBefore, isSameDay } from 'date-fns' + +import { LEVELS } from './DataTable.constants' + +export function formatCompactNumber(value: number) { + if (value >= 100 && value < 1000) { + return value.toString() // Keep the number as is if it's in the hundreds + } else if (value >= 1000 && value < 1000000) { + return (value / 1000).toFixed(1) + 'k' // Convert to 'k' for thousands + } else if (value >= 1000000) { + return (value / 1000000).toFixed(1) + 'M' // Convert to 'M' for millions + } else { + return value.toString() // Optionally handle numbers less than 100 if needed + } +} + +export function isArrayOfNumbers(arr: any): arr is number[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => typeof item === 'number') +} + +export function isArrayOfDates(arr: any): arr is Date[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => item instanceof Date) +} + +export function isArrayOfStrings(arr: any): arr is string[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => typeof item === 'string') +} + +export function isArrayOfBooleans(arr: any): arr is boolean[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => typeof item === 'boolean') +} + +export const inDateRange: FilterFn = (row, columnId, value) => { + const date = new Date(row.getValue(columnId)) + const [start, end] = value as Date[] + + if (isNaN(date.getTime())) return false + + // if no end date, check if it's the same day + if (!end) return isSameDay(date, start) + + return isAfter(date, start) && isBefore(date, end) +} + +inDateRange.autoRemove = (val: any) => !Array.isArray(val) || !val.length || !isArrayOfDates(val) + +export const arrSome: FilterFn = (row, columnId, filterValue) => { + if (!Array.isArray(filterValue)) return false + return filterValue.some((val) => row.getValue(columnId) === val) +} + +arrSome.autoRemove = (val: any) => !Array.isArray(val) || !val?.length + +export function getLevelColor( + value: (typeof LEVELS)[number] +): Record<'text' | 'bg' | 'border', string> { + switch (value) { + case 'success': + return { + text: 'text-muted', + bg: 'bg-muted', + border: 'border-muted', + } + case 'warning': + return { + text: 'text-warning', + bg: 'bg-warning', + border: 'border-warning', + } + case 'error': + return { + text: 'text-destructive', + bg: 'bg-destructive', + border: 'border-destructive', + } + case 'info': + default: + return { + text: 'text-info', + bg: 'bg-info', + border: 'border-info', + } + } +} + +export function getStatusColor(value?: number | string): Record<'text' | 'bg' | 'border', string> { + switch (value) { + case '1': + case 'info': + return { + text: 'text-blue-500', + bg: '', + border: 'border-blue-200 dark:border-blue-800', + } + case '2': + case 'success': + return { + text: 'text-foreground-lighter', + bg: '', + border: 'border-green-200 dark:border-green-800', + } + case '4': + case 'warning': + case 'redirect': + return { + text: 'text-warning-600 dark:text-warning', + bg: 'bg-warning-300 dark:bg-waning-200', + border: 'border border-warning-400/50 dark:border-warning-400/50', + } + case '5': + case 'error': + return { + text: 'text-destructive', + bg: 'bg-destructive-300 dark:bg-destructive-300/50', + border: 'border border-destructive-400/50 dark:border-destructive-400/50', + } + default: + return { + text: 'text-foreground-lighter', + bg: '', + border: '', + } + } +} diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx new file mode 100644 index 00000000000..7bd07547b55 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx @@ -0,0 +1,54 @@ +import { type Column } from '@tanstack/react-table' +import { ChevronDown, ChevronUp } from 'lucide-react' + +import { Button, cn, type ButtonProps } from 'ui' + +interface DataTableColumnHeaderProps extends ButtonProps { + column: Column + title: string +} + +export const DataTableColumnHeader = ({ + column, + title, + className, + ...props +}: DataTableColumnHeaderProps) => { + if (!column.getCanSort()) { + return
{title}
+ } + + return ( + + ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx new file mode 100644 index 00000000000..c0038b7c3dd --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx @@ -0,0 +1,17 @@ +import { cn } from 'ui' +import { LEVELS } from '../DataTable.constants' +import { getLevelColor } from '../DataTable.utils' + +export const DataTableColumnLevelIndicator = ({ + value, + className, +}: { + value: (typeof LEVELS)[number] + className?: string +}) => { + return ( +
+
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx new file mode 100644 index 00000000000..175e3a477a8 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx @@ -0,0 +1,35 @@ +import { Minus } from 'lucide-react' + +import { cn } from 'ui' +import { getStatusColor } from '../DataTable.utils' + +export const DataTableColumnStatusCode = ({ + value, + level, + className, +}: { + value?: number | string + level?: string + className?: string +}) => { + const colors = getStatusColor(level) + if (!value) { + return + } + + return ( +
+
+ {value} +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx new file mode 100644 index 00000000000..26fbbdc57d3 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx @@ -0,0 +1,118 @@ +import { Search } from 'lucide-react' +import { useState } from 'react' + +import { Checkbox_Shadcn_ as Checkbox, cn, Label_Shadcn_ as Label, Skeleton } from 'ui' +import type { DataTableCheckboxFilterField } from '../DataTable.types' +import { formatCompactNumber } from '../DataTable.utils' +import { InputWithAddons } from '../InputWithAddons' +import { useDataTable } from '../providers/DataTableProvider' + +export function DataTableFilterCheckbox({ + value: _value, + options, + component, +}: DataTableCheckboxFilterField) { + const value = _value as string + const [inputValue, setInputValue] = useState('') + const { table, columnFilters, isLoading, getFacetedUniqueValues } = useDataTable() + const column = table.getColumn(value) + // REMINDER: avoid using column?.getFilterValue() + const filterValue = columnFilters.find((i) => i.id === value)?.value + const facetedValue = getFacetedUniqueValues?.(table, value) || column?.getFacetedUniqueValues() + + const Component = component + + // filter out the options based on the input value + const filterOptions = options?.filter( + (option) => inputValue === '' || option.label.toLowerCase().includes(inputValue.toLowerCase()) + ) + + // CHECK: it could be filterValue or searchValue + const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : [] + + // REMINDER: if no options are defined, while fetching data, we should show a skeleton + if (isLoading && !filterOptions?.length) + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ + +
+ ))} +
+ ) + + if (!filterOptions?.length) return null + + return ( +
+ {options && options.length > 4 ? ( + } + containerClassName="h-9 rounded-lg" + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + /> + ) : null} + {/* FIXME: due to the added max-h and overflow-y-auto, the hover state and border is laying on top of the scroll bar */} +
+ {filterOptions + // TODO: we shoudn't sort the options here, instead filterOptions should be sorted by default + // .sort((a, b) => a.label.localeCompare(b.label)) + .map((option, index) => { + const checked = filters.includes(option.value) + + return ( +
+ { + const newValue = checked + ? [...(filters || []), option.value] + : filters?.filter((value) => option.value !== value) + column?.setFilterValue(newValue?.length ? newValue : undefined) + }} + /> + +
+ ) + })} +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx new file mode 100644 index 00000000000..19ac0a54c02 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx @@ -0,0 +1,400 @@ +import { formatDistanceToNow } from 'date-fns' +import { LoaderCircle, Search, X } from 'lucide-react' +import { ParserBuilder } from 'nuqs' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { useHotKey } from 'hooks/ui/useHotKey' +import { + cn, + Command_Shadcn_ as Command, + CommandEmpty_Shadcn_ as CommandEmpty, + CommandGroup_Shadcn_ as CommandGroup, + CommandInput_Shadcn_ as CommandInput, + CommandItem_Shadcn_ as CommandItem, + CommandList_Shadcn_ as CommandList, + CommandSeparator_Shadcn_ as CommandSeparator, + Separator, +} from 'ui' +import type { DataTableFilterField } from '../DataTable.types' +import { formatCompactNumber } from '../DataTable.utils' +import { Kbd } from '../primitives/Kbd' +import { useDataTable } from '../providers/DataTableProvider' +import { + columnFiltersParser, + getFieldOptions, + getFilterValue, + getWordByCaretPosition, + replaceInputByFieldType, +} from './DataTableFilters.utils' + +// FIXME: there is an issue on cmdk if I wanna only set a single slider value... + +interface DataTableFilterCommandProps { + // TODO: maybe use generics for the parser + searchParamsParser: Record> +} + +export function DataTableFilterCommand({ searchParamsParser }: DataTableFilterCommandProps) { + const { table, isLoading, filterFields: _filterFields, getFacetedUniqueValues } = useDataTable() + const columnFilters = table.getState().columnFilters + const inputRef = useRef(null) + const [open, setOpen] = useState(false) + const [currentWord, setCurrentWord] = useState('') + const filterFields = useMemo( + () => _filterFields?.filter((i) => !i.commandDisabled), + [_filterFields] + ) + const columnParser = useMemo( + () => columnFiltersParser({ searchParamsParser, filterFields }), + [searchParamsParser, filterFields] + ) + const [inputValue, setInputValue] = useState(columnParser.serialize(columnFilters)) + const [lastSearches, setLastSearches] = useLocalStorage< + { + search: string + timestamp: number + }[] + >('data-table-command', []) + + useHotKey(() => setOpen((open) => !open), 'k') + + useEffect(() => { + // TODO: we could check for ARRAY_DELIMITER or SLIDER_DELIMITER to auto-set filter when typing + if (currentWord !== '' && open) return + // reset + if (currentWord !== '' && !open) setCurrentWord('') + // avoid recursion + if (inputValue.trim() === '' && !open) return + + const searchParams = columnParser.parse(inputValue) + + const currentFilters = table.getState().columnFilters + const currentEnabledFilters = currentFilters.filter((filter) => { + const field = _filterFields?.find((field) => field.value === filter.id) + return !field?.commandDisabled + }) + const currentDisabledFilters = currentFilters.filter((filter) => { + const field = _filterFields?.find((field) => field.value === filter.id) + return field?.commandDisabled + }) + + const commandDisabledFilterKeys = currentDisabledFilters.reduce( + (prev, curr) => { + prev[curr.id] = curr.value + return prev + }, + {} as Record + ) + + for (const key of Object.keys(searchParams)) { + const value = searchParams[key as keyof typeof searchParams] + table.getColumn(key)?.setFilterValue(value) + } + const currentFiltersToReset = currentEnabledFilters.filter((filter) => { + return !(filter.id in searchParams) + }) + for (const filter of currentFiltersToReset) { + table.getColumn(filter.id)?.setFilterValue(undefined) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputValue, open, currentWord]) + + useEffect(() => { + // REMINDER: only update the input value if the command is closed (avoids jumps while open) + if (!open) { + setInputValue(columnParser.serialize(columnFilters)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnFilters, filterFields, open]) + + useEffect(() => { + if (open) { + inputRef?.current?.focus() + } + }, [open]) + + return ( +
+ + div]:border-none bg', + open ? 'visible' : 'hidden' + )} + filter={(value, search, keywords) => + getFilterValue({ value, search, keywords, currentWord }) + } + // loop + > + { + if (e.key === 'Escape') inputRef?.current?.blur() + }} + onBlur={() => { + setOpen(false) + // FIXME: doesnt reflect the jumps + // FIXME: will save non-existing searches + // TODO: extract into function + const search = inputValue.trim() + if (!search) return + const timestamp = Date.now() + const searchIndex = lastSearches.findIndex((item) => item.search === search) + if (searchIndex !== -1) { + lastSearches[searchIndex].timestamp = timestamp + setLastSearches(lastSearches) + return + } + setLastSearches([...lastSearches, { search, timestamp }]) + return + }} + onInput={(e) => { + const caretPosition = e.currentTarget?.selectionStart || -1 + const value = e.currentTarget?.value || '' + const word = getWordByCaretPosition({ value, caretPosition }) + setCurrentWord(word) + }} + placeholder="Search data table..." + className="text-foreground" + /> +
+
+ {/* default height is 300px but in case of more, we'd like to tease the user */} + + + {filterFields.map((field) => { + if (typeof field.value !== 'string') return null + if (inputValue.includes(`${field.value}:`)) return null + // TBD: should we handle this in the component? + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={(value) => { + setInputValue((prev) => { + if (currentWord.trim() === '') { + const input = `${prev}${value}` + return `${input}:` + } + // lots of cheat + const isStarting = currentWord === prev + const prefix = isStarting ? '' : ' ' + const input = prev.replace(`${prefix}${currentWord}`, `${prefix}${value}`) + return `${input}:` + }) + setCurrentWord(`${value}:`) + }} + className="group" + > + {field.value} + + + ) + })} + + + + {filterFields?.map((field) => { + if (typeof field.value !== 'string') return null + if (!currentWord.includes(`${field.value}:`)) return null + + const column = table.getColumn(field.value) + const facetedValue = + getFacetedUniqueValues?.(table, field.value) || column?.getFacetedUniqueValues() + + const options = getFieldOptions({ field }) + + return options.map((optionValue) => { + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={(value) => { + setInputValue((prev) => + replaceInputByFieldType({ + prev, + currentWord, + optionValue, + value, + field, + }) + ) + setCurrentWord('') + }} + > + {`${optionValue}`} + {facetedValue?.has(optionValue) ? ( + + {formatCompactNumber(facetedValue.get(optionValue) || 0)} + + ) : null} + + ) + }) + })} + + + + {lastSearches + ?.sort((a, b) => b.timestamp - a.timestamp) + .slice(0, 5) + .map((item) => { + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={(value) => { + const search = value.replace('suggestion:', '') + setInputValue(`${search} `) + setCurrentWord('') + }} + className="group" + > + {item.search} + + {formatDistanceToNow(item.timestamp, { + addSuffix: true, + })} + + + + ) + })} + + No results found. + +
+
+ + Use ↑ ↓ to navigate + + + Enter to query + + + Esc to close + + + + Union: regions:a,b + + + Range: p95:59-340 + +
+ {lastSearches.length ? ( + + ) : null} +
+
+
+
+
+ ) +} + +// function CommandItemType + +function CommandItemSuggestions({ field }: { field: DataTableFilterField }) { + const { table, getFacetedMinMaxValues, getFacetedUniqueValues } = useDataTable() + const value = field.value as string + switch (field.type) { + case 'checkbox': { + return ( + + {getFacetedUniqueValues + ? Array.from(getFacetedUniqueValues(table, value)?.keys() || []) + .map((value) => `[${value}]`) + .join(' ') + : field.options?.map(({ value }) => `[${value}]`).join(' ')} + + ) + } + case 'slider': { + const [min, max] = getFacetedMinMaxValues?.(table, value) || [field.min, field.max] + return ( + + [{min} - {max}] + + ) + } + case 'input': { + return ( + + [{`${String(field.value)}`} input] + + ) + } + default: { + return null + } + } +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx new file mode 100644 index 00000000000..c5030af0153 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx @@ -0,0 +1,74 @@ +import { + Accordion_Shadcn_ as Accordion, + AccordionContent_Shadcn_ as AccordionContent, + AccordionItem_Shadcn_ as AccordionItem, + AccordionTrigger_Shadcn_ as AccordionTrigger, +} from 'ui' + +import { DataTableFilterCheckbox } from './DataTableFilterCheckbox' +import { DataTableFilterInput } from './DataTableFilterInput' +import { DataTableFilterResetButton } from './DataTableFilterResetButton' +import { DataTableFilterSlider } from './DataTableFilterSlider' +import { DataTableFilterTimerange } from './DataTableFilterTimerange' + +import { useDataTable } from '../providers/DataTableProvider' + +// FIXME: use @container (especially for the slider element) to restructure elements + +// TODO: only pass the columns to generate the filters! +// https://tanstack.com/table/v8/docs/framework/react/examples/filters + +export function DataTableFilterControls() { + const { filterFields } = useDataTable() + return ( + defaultOpen) + ?.map(({ value }) => value as string)} + > + {filterFields?.map((field) => { + const value = field.value as string + return ( + + +
+
+

{field.label}

+ {value !== field.label.toLowerCase() && !field.commandDisabled ? ( +

+ {value} +

+ ) : null} +
+ +
+
+ + {/* REMINDER: avoid the focus state to be cut due to overflow-hidden */} + {/* REMINDER: need to move within here because of accordion height animation */} +
+ {(() => { + switch (field.type) { + case 'checkbox': { + return + } + case 'slider': { + return + } + case 'input': { + return + } + case 'timerange': { + return + } + } + })()} +
+
+
+ ) + })} +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx new file mode 100644 index 00000000000..131f602b2e8 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx @@ -0,0 +1,80 @@ +import { VisuallyHidden } from '@radix-ui/react-visually-hidden' +import { FilterIcon } from 'lucide-react' +import { useRef } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { + Button, + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from 'ui' +import { useMediaQuery } from '../hooks/useMediaQuery' +import { Kbd } from '../primitives/Kbd' +import { DataTableFilterControls } from './DataTableFilterControls' + +export function DataTableFilterControlsDrawer() { + const triggerButtonRef = useRef(null) + const isMobile = useMediaQuery('(max-width: 640px)') + + useHotKey(() => { + triggerButtonRef.current?.click() + }, 'b') + + return ( + + + + + + + + + +

+ Toggle controls with{' '} + + ⌘ + B + +

+
+
+
+ + + + Filters + Adjust your table filters + + +
+ +
+ + + + + +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx new file mode 100644 index 00000000000..21d3e217d5f --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx @@ -0,0 +1,52 @@ +import { Search } from 'lucide-react' +import { useEffect, useState } from 'react' + +import { Label_Shadcn_ as Label } from 'ui' +import type { DataTableInputFilterField } from '../DataTable.types' +import { useDebounce } from '../hooks/useDebounce' +import { InputWithAddons } from '../InputWithAddons' +import { useDataTable } from '../providers/DataTableProvider' + +function getFilter(filterValue: unknown) { + return typeof filterValue === 'string' ? filterValue : null +} + +export function DataTableFilterInput({ value: _value }: DataTableInputFilterField) { + const value = _value as string + const { table, columnFilters } = useDataTable() + const column = table.getColumn(value) + const filterValue = columnFilters.find((i) => i.id === value)?.value + const filters = getFilter(filterValue) + const [input, setInput] = useState(filters) + + const debouncedInput = useDebounce(input, 500) + + useEffect(() => { + const newValue = debouncedInput?.trim() === '' ? null : debouncedInput + if (debouncedInput === null) return + column?.setFilterValue(newValue) + }, [debouncedInput]) + + useEffect(() => { + if (debouncedInput?.trim() !== filters) { + setInput(filters) + } + }, [filters]) + + return ( +
+ + } + containerClassName="h-9 rounded-lg" + name={value} + id={value} + value={input || ''} + onChange={(e) => setInput(e.target.value)} + /> +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx new file mode 100644 index 00000000000..d0665532653 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx @@ -0,0 +1,41 @@ +import { X } from 'lucide-react' + +import { Button } from 'ui' +import type { DataTableFilterField } from '../DataTable.types' +import { useDataTable } from '../providers/DataTableProvider' + +export function DataTableFilterResetButton({ value: _value }: DataTableFilterField) { + const { columnFilters, table } = useDataTable() + const value = _value as string + const column = table.getColumn(value) + const filterValue = columnFilters.find((f) => f.id === value)?.value + + // TODO: check if we could useMemo + const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : [] + + if (filters.length === 0) return null + + return ( + + ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx new file mode 100644 index 00000000000..14d8d23d6bc --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' + +import { Label_Shadcn_ as Label } from 'ui' +import type { DataTableSliderFilterField } from '../DataTable.types' +import { isArrayOfNumbers } from '../DataTable.utils' +import { useDebounce } from '../hooks/useDebounce' +import { InputWithAddons } from '../InputWithAddons' +import { Slider } from '../primitives/Slider' +import { useDataTable } from '../providers/DataTableProvider' + +function getFilter(filterValue: unknown) { + return typeof filterValue === 'number' + ? [filterValue, filterValue] + : Array.isArray(filterValue) && isArrayOfNumbers(filterValue) + ? filterValue.length === 1 + ? [filterValue[0], filterValue[0]] + : filterValue + : null +} + +// TODO: discuss if we even need the `defaultMin` and `defaultMax` +export function DataTableFilterSlider({ + value: _value, + min: defaultMin, + max: defaultMax, +}: DataTableSliderFilterField) { + const value = _value as string + const { table, columnFilters, getFacetedMinMaxValues } = useDataTable() + const column = table.getColumn(value) + const filterValue = columnFilters.find((i) => i.id === value)?.value + const filters = getFilter(filterValue) + const [input, setInput] = useState(filters) + const [min, max] = getFacetedMinMaxValues?.(table, value) || + column?.getFacetedMinMaxValues() || [defaultMin, defaultMax] + + const debouncedInput = useDebounce(input, 500) + + useEffect(() => { + if (debouncedInput?.length === 2) { + column?.setFilterValue(debouncedInput) + } + }, [debouncedInput]) + + useEffect(() => { + if (debouncedInput?.length !== 2) { + } else if (!filters) { + setInput(null) + } else if (debouncedInput[0] !== filters[0] || debouncedInput[1] !== filters[1]) { + setInput(filters) + } + }, [filters]) + + return ( +
+
+
+ + setInput((prev) => [Number(e.target.value), prev?.[1] || max])} + /> +
+
+ + setInput((prev) => [prev?.[0] || min, Number(e.target.value)])} + /> +
+
+ setInput(values)} + /> +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx new file mode 100644 index 00000000000..b8577678ea6 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx @@ -0,0 +1,40 @@ +import { useMemo } from 'react' +import type { DateRange } from 'react-day-picker' + +import type { DataTableTimerangeFilterField } from '../DataTable.types' +import { isArrayOfDates } from '../DataTable.utils' +import { useDataTable } from '../providers/DataTableProvider' + +import { DatePickerWithRange } from 'components/interfaces/DataTableDemo/components/custom/date-picker-with-range' + +export function DataTableFilterTimerange({ + value: _value, + presets, +}: DataTableTimerangeFilterField) { + const value = _value as string + const { table, columnFilters } = useDataTable() + const column = table.getColumn(value) + const filterValue = columnFilters.find((i) => i.id === value)?.value + + const date: DateRange | undefined = useMemo( + () => + filterValue instanceof Date + ? { from: filterValue, to: undefined } + : Array.isArray(filterValue) && isArrayOfDates(filterValue) + ? { from: filterValue?.[0], to: filterValue?.[1] } + : undefined, + [filterValue] + ) + + const setDate = (date: DateRange | undefined) => { + if (!date) return // TODO: remove from search params if columnFilter is removed + if (date.from && !date.to) { + column?.setFilterValue([date.from]) + } + if (date.to && date.from) { + column?.setFilterValue([date.from, date.to]) + } + } + + return +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts new file mode 100644 index 00000000000..c65b6106b07 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts @@ -0,0 +1,259 @@ +import { ColumnFiltersState } from '@tanstack/react-table' +import { ParserBuilder } from 'nuqs' + +import { ARRAY_DELIMITER, RANGE_DELIMITER, SLIDER_DELIMITER } from '../DataTable.constants' +import type { DataTableFilterField } from '../DataTable.types' +import { isArrayOfDates } from '../DataTable.utils' + +/** + * Extracts the word from the given string at the specified caret position. + */ +export function getWordByCaretPosition({ + value, + caretPosition, +}: { + value: string + caretPosition: number +}) { + let start = caretPosition + let end = caretPosition + + while (start > 0 && value[start - 1] !== ' ') start-- + while (end < value.length && value[end] !== ' ') end++ + + const word = value.substring(start, end) + return word +} + +export function replaceInputByFieldType({ + prev, + currentWord, + optionValue, + value, + field, +}: { + prev: string + currentWord: string + optionValue?: string | number | boolean | undefined // FIXME: use DataTableFilterField["options"][number]; + value: string + field: DataTableFilterField +}) { + switch (field.type) { + case 'checkbox': { + if (currentWord.includes(ARRAY_DELIMITER)) { + const words = currentWord.split(ARRAY_DELIMITER) + words[words.length - 1] = `${optionValue}` + const input = prev.replace(currentWord, words.join(ARRAY_DELIMITER)) + return `${input.trim()} ` + } + } + case 'slider': { + if (currentWord.includes(SLIDER_DELIMITER)) { + const words = currentWord.split(SLIDER_DELIMITER) + words[words.length - 1] = `${optionValue}` + const input = prev.replace(currentWord, words.join(SLIDER_DELIMITER)) + return `${input.trim()} ` + } + } + case 'timerange': { + if (currentWord.includes(RANGE_DELIMITER)) { + const words = currentWord.split(RANGE_DELIMITER) + words[words.length - 1] = `${optionValue}` + const input = prev.replace(currentWord, words.join(RANGE_DELIMITER)) + return `${input.trim()} ` + } + } + default: { + const input = prev.replace(currentWord, value) + return `${input.trim()} ` + } + } +} + +export function getFieldOptions({ field }: { field: DataTableFilterField }) { + switch (field.type) { + case 'slider': { + return field.options?.length + ? field.options + .map(({ value }) => value) + .sort((a, b) => Number(a) - Number(b)) + .filter(notEmpty) + : Array.from({ length: field.max - field.min + 1 }, (_, i) => field.min + i) || [] + } + default: { + return field.options?.map(({ value }) => value).filter(notEmpty) || [] + } + } +} + +export function getFilterValue({ + value, + search, + currentWord, +}: { + value: string + search: string + keywords?: string[] | undefined + currentWord: string +}): number { + /** + * @example value "suggestion:public:true regions,ams,gru,fra" + */ + if (value.startsWith('suggestion:')) { + const rawValue = value.toLowerCase().replace('suggestion:', '') + if (rawValue.includes(search)) return 1 + return 0 + } + + /** */ + if (value.toLowerCase().includes(currentWord.toLowerCase())) return 1 + + /** + * @example checkbox [filter, query] = ["regions", "ams,gru,fra"] + * @example slider [filter, query] = ["p95", "0-3000"] + * @example input [filter, query] = ["name", "api"] + */ + const [filter, query] = currentWord.toLowerCase().split(':') + if (query && value.startsWith(`${filter}:`)) { + if (query.includes(ARRAY_DELIMITER)) { + /** + * array of n elements + * @example queries = ["ams", "gru", "fra"] + */ + const queries = query.split(ARRAY_DELIMITER) + const rawValue = value.toLowerCase().replace(`${filter}:`, '') + if (queries.some((item, i) => item === rawValue && i !== queries.length - 1)) return 0 + if (queries.some((item) => rawValue.includes(item))) return 1 + } + if (query.includes(SLIDER_DELIMITER)) { + /** + * range between 2 elements + * @example queries = ["0", "3000"] + */ + const queries = query.split(SLIDER_DELIMITER) + const rawValue = value.toLowerCase().replace(`${filter}:`, '') + + const rawValueAsNumber = Number.parseInt(rawValue) + const queryAsNumber = Number.parseInt(queries[0]) + + if (queryAsNumber < rawValueAsNumber) { + if (rawValue.includes(queries[1])) return 1 + return 0 + } + return 0 + } + const rawValue = value.toLowerCase().replace(`${filter}:`, '') + if (rawValue.includes(query)) return 1 + } + return 0 +} + +export function getFieldValueByType({ + field, + value, +}: { + field?: DataTableFilterField + value: unknown +}) { + if (!field) return null + + switch (field.type) { + case 'slider': { + if (Array.isArray(value)) { + return value.join(SLIDER_DELIMITER) + } + return value + } + case 'checkbox': { + if (Array.isArray(value)) { + return value.join(ARRAY_DELIMITER) + } + // REMINER: inversed logic + if (typeof value === 'string') { + return value.split(ARRAY_DELIMITER) + } + return value + } + case 'timerange': { + if (Array.isArray(value)) { + if (isArrayOfDates(value)) { + return value.map((date) => date.getTime()).join(RANGE_DELIMITER) + } + return value.join(RANGE_DELIMITER) + } + if (value instanceof Date) { + return value.getTime() + } + return value + } + default: { + return value + } + } +} + +export function notEmpty(value: TValue | null | undefined): value is TValue { + return value !== null && value !== undefined +} + +export function columnFiltersParser({ + searchParamsParser, + filterFields, +}: { + searchParamsParser: Record> + filterFields: DataTableFilterField[] +}) { + return { + parse: (inputValue: string) => { + // Use regex to properly extract field:value pairs + // This properly handles spaces within values + const filterPairs: Record = {} + + // This regex properly extracts field:value pairs where: + // 1. Values can contain spaces (both normal spaces and URL-encoded + characters) + // 2. It finds word characters followed by a colon as the field name + // 3. It then captures everything (including spaces and + characters) until + // it finds another field:value pattern or the end of string + // This solves the issue with values like "edge function" or "edge+function" in URLs + const regex = /(\w+):([^]*?)(?=\s+\w+:|$)/g + let match + + console.log('DataTableFilterCommand parsing input:', inputValue) + + while ((match = regex.exec(inputValue)) !== null) { + const [_, fieldName, fieldValue] = match + if (fieldName && fieldValue) { + filterPairs[fieldName] = fieldValue.trim() + console.log(`Parsed filter pair: ${fieldName} = ${fieldValue.trim()}`) + } + } + + const searchParams = Object.entries(filterPairs).reduce( + (prev, [key, value]) => { + const parser = searchParamsParser[key] + if (!parser) return prev + + prev[key] = parser.parse(value) + return prev + }, + {} as Record + ) + + return searchParams + }, + serialize: (columnFilters: ColumnFiltersState) => { + const values = columnFilters.reduce((prev, curr) => { + const { commandDisabled } = filterFields?.find((field) => curr.id === field.value) || { + commandDisabled: true, + } // if column filter is not found, disable the command by default + const parser = searchParamsParser[curr.id] + + if (commandDisabled || !parser) return prev + + return `${prev}${curr.id}:${parser.serialize(curr.value)} ` + }, '') + + return values + }, + } +} diff --git a/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx b/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx new file mode 100644 index 00000000000..5222d38f14c --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx @@ -0,0 +1,35 @@ +import { forwardRef, PropsWithChildren, useEffect, useRef } from 'react' +import { cn } from 'ui' + +export const DataTableHeaderLayout = forwardRef< + HTMLDivElement, + PropsWithChildren<{ + setTopBarHeight: (height: number) => void + }> +>(({ setTopBarHeight, ...props }, ref) => { + const topBarRef = useRef(null) + + useEffect(() => { + const observer = new ResizeObserver(() => { + const rect = topBarRef.current?.getBoundingClientRect() + if (rect) { + setTopBarHeight(rect.height) + } + }) + + const topBar = topBarRef.current + if (!topBar) return + + observer.observe(topBar) + return () => observer.unobserve(topBar) + }, [topBarRef]) + + return ( +
+ ) +}) +DataTableHeaderLayout.displayName = 'DataTableHeaderLayout' diff --git a/apps/studio/components/ui/DataTable/DataTableInfinite.tsx b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx new file mode 100644 index 00000000000..ce8cde69559 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx @@ -0,0 +1,247 @@ +import { type FetchNextPageOptions } from '@tanstack/react-query' +import type { ColumnDef, Row, Table as TTable, VisibilityState } from '@tanstack/react-table' +import { flexRender } from '@tanstack/react-table' +import { LoaderCircle } from 'lucide-react' +import { useQueryState } from 'nuqs' +import { Fragment, memo, ReactNode, UIEvent, useCallback, useRef } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, cn } from 'ui' +import { formatCompactNumber } from './DataTable.utils' +import { useDataTable } from './providers/DataTableProvider' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './Table' + +// TODO: add a possible chartGroupBy +export interface DataTableInfiniteProps { + columns: ColumnDef[] + defaultColumnVisibility?: VisibilityState + totalRows?: number + filterRows?: number + totalRowsFetched?: number + isFetching?: boolean + isLoading?: boolean + hasNextPage?: boolean + fetchNextPage: (options?: FetchNextPageOptions | undefined) => Promise + renderLiveRow?: (props?: { row: Row }) => ReactNode + setColumnOrder: (columnOrder: string[]) => void + setColumnVisibility: (columnVisibility: VisibilityState) => void + + // [Joshen] See if we can type this properly + searchParamsParser: any +} + +export function DataTableInfinite({ + columns, + defaultColumnVisibility = {}, + isFetching, + isLoading, + fetchNextPage, + hasNextPage, + totalRows = 0, + filterRows = 0, + totalRowsFetched = 0, + renderLiveRow, + setColumnOrder, + setColumnVisibility, + searchParamsParser, +}: DataTableInfiniteProps) { + const { table } = useDataTable() + + const tableRef = useRef(null) + // const [topBarHeight, setTopBarHeight] = useState(0) + // FIXME: searchParamsParser needs to be passed as property + + const onScroll = useCallback( + (e: UIEvent) => { + const onPageBottom = + Math.ceil(e.currentTarget.scrollTop + e.currentTarget.clientHeight) >= + e.currentTarget.scrollHeight + + if (onPageBottom && !isFetching && totalRowsFetched < filterRows) { + fetchNextPage() + } + }, + [fetchNextPage, isFetching, filterRows, totalRowsFetched] + ) + + useHotKey(() => { + setColumnOrder([]) + setColumnVisibility(defaultColumnVisibility) + }, 'u') + + return ( + <> + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getCanResize() && ( +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={cn( + 'user-select-none absolute -right-2 top-0 z-10 flex h-full w-4 cursor-col-resize touch-none justify-center', + 'before:absolute before:inset-y-0 before:w-px before:translate-x-px before:bg-border' + )} + /> + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + // REMINDER: if we want to add arrow navigation https://github.com/TanStack/table/discussions/2752#discussioncomment-192558 + + {renderLiveRow?.({ row })} + + + )) + ) : ( + + {renderLiveRow?.()} + + + No results. + + + + )} + + + {hasNextPage || isFetching || isLoading ? ( +
+ +

+ Showing{' '} + + {formatCompactNumber(totalRowsFetched)} + {' '} + of{' '} + {formatCompactNumber(totalRows)}{' '} + rows +

+
+ ) : ( +

+ No more data to load ( + {formatCompactNumber(filterRows)}{' '} + of {formatCompactNumber(totalRows)}{' '} + rows) +

+ )} +
+
+
+
+ + ) +} + +/** + * REMINDER: this is the heaviest component in the table if lots of rows + * Some other components are rendered more often necessary, but are fixed size (not like rows that can grow in height) + * e.g. DataTableFilterControls, DataTableFilterCommand, DataTableToolbar, DataTableHeader + */ + +function Row({ + row, + table, + selected, + searchParamsParser, +}: { + row: Row + table: TTable + // REMINDER: row.getIsSelected(); - just for memoization + selected?: boolean + searchParamsParser: any +}) { + // REMINDER: rerender the row when live mode is toggled - used to opacity the row + // via the `getRowClassName` prop - but for some reasons it wil render the row on data fetch + useQueryState('live', searchParamsParser.live) + + return ( + row.toggleSelected()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + row.toggleSelected() + } + }} + className={cn( + // '[&>:not(:last-child)]:border-r', + // 'outline-1 -outline-offset-1 outline-primary transition-colors focus-visible:bg-muted/50 focus-visible:outline data-[state=selected]:outline', + table.options.meta?.getRowClassName?.(row) + )} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) +} + +const MemoizedRow = memo( + Row, + (prev, next) => prev.row.id === next.row.id && prev.selected === next.selected +) as typeof Row diff --git a/apps/studio/components/ui/DataTable/DataTableResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx new file mode 100644 index 00000000000..06a80799f08 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx @@ -0,0 +1,37 @@ +import { X } from 'lucide-react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' +import { Kbd } from './primitives/Kbd' +import { useDataTable } from './providers/DataTableProvider' + +export function DataTableResetButton() { + const { table } = useDataTable() + useHotKey(table.resetColumnFilters, 'Escape') + + return ( + + + + + + +

+ Reset filters with{' '} + + ⌘ + Esc + +

+
+
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx new file mode 100644 index 00000000000..12d4c535c97 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx @@ -0,0 +1,161 @@ +import { ChevronDown, ChevronUp, X } from 'lucide-react' +import { ReactNode, useCallback, useEffect, useMemo } from 'react' + +import { + Button, + cn, + Separator, + Skeleton, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from 'ui' +import { Kbd } from './primitives/Kbd' +import { useDataTable } from './providers/DataTableProvider' + +export interface DataTableSheetDetailsProps { + title?: ReactNode + titleClassName?: string + children?: ReactNode +} + +export function DataTableSheetDetails({ + title, + titleClassName, + children, +}: DataTableSheetDetailsProps) { + const { table, rowSelection, isLoading } = useDataTable() + + const selectedRowKey = Object.keys(rowSelection)?.[0] + + const selectedRow = useMemo(() => { + if (isLoading && !selectedRowKey) return + return table.getCoreRowModel().flatRows.find((row) => row.id === selectedRowKey) + }, [selectedRowKey, isLoading]) + + const index = table.getCoreRowModel().flatRows.findIndex((row) => row.id === selectedRow?.id) + + const nextId = useMemo(() => table.getCoreRowModel().flatRows[index + 1]?.id, [index, isLoading]) + + const prevId = useMemo(() => table.getCoreRowModel().flatRows[index - 1]?.id, [index, isLoading]) + + const onPrev = useCallback(() => { + if (prevId) table.setRowSelection({ [prevId]: true }) + }, [prevId, isLoading]) + + const onNext = useCallback(() => { + if (nextId) table.setRowSelection({ [nextId]: true }) + }, [nextId, isLoading]) + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (!selectedRowKey) return + + // REMINDER: prevent dropdown navigation inside of sheet to change row selection + const activeElement = document.activeElement + const isMenuActive = activeElement?.closest('[role="menu"]') + + if (isMenuActive) return + + if (e.key === 'ArrowUp') { + e.preventDefault() + onPrev() + } + if (e.key === 'ArrowDown') { + e.preventDefault() + onNext() + } + } + + document.addEventListener('keydown', down) + return () => document.removeEventListener('keydown', down) + }, [selectedRowKey, onNext, onPrev]) + + return ( + // { + // // REMINDER: focus back to the row that was selected + // // We need to manually focus back due to missing Trigger component + // const el = selectedRowKey ? document.getElementById(selectedRowKey) : null + // table.resetRowSelection() + + // // REMINDER: when navigating between tabs in the sheet and exit the sheet, the tab gets lost + // // We need a minimal delay to allow the sheet to close before focusing back to the row + // setTimeout(() => el?.focus(), 0) + // }} + // > + // e.preventDefault()} + // className="overflow-y-auto p-0 sm:max-w-md" + // hideClose + // > + // +
+
+
+ {isLoading && !selectedRowKey ? : title} +
+
+ + + + + + +

+ Navigate ↑ +

+
+
+
+ + + + + + +

+ Navigate ↓ +

+
+
+
+ + {/* */} + + {/* */} +
+
+ + {children} +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx b/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx new file mode 100644 index 00000000000..ec9162a8fed --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx @@ -0,0 +1,66 @@ +import { ReactNode, useMemo } from 'react' + +import { cn } from 'ui' +import { useDataTable } from './providers/DataTableProvider' + +interface DataTableSideBarLayoutProps { + children: ReactNode + className?: string + topBarHeight?: number +} + +export function DataTableSideBarLayout({ + children, + className, + topBarHeight = 0, +}: DataTableSideBarLayoutProps) { + const { table } = useDataTable() + + /** + * https://tanstack.com/table/v8/docs/guide/column-sizing#advanced-column-resizing-performance + * Instead of calling `column.getSize()` on every render for every header + * and especially every data cell (very expensive), + * we will calculate all column sizes at once at the root table level in a useMemo + * and pass the column sizes down as CSS variables to the element. + */ + const columnSizeVars = useMemo(() => { + const headers = table.getFlatHeaders() + const colSizes: { [key: string]: string } = {} + for (let i = 0; i < headers.length; i++) { + const header = headers[i]! + // REMINDER: replace "." with "-" to avoid invalid CSS variable name (e.g. "timing.dns" -> "timing-dns") + colSizes[`--header-${header.id.replace('.', '-')}-size`] = `${header.getSize()}px` + colSizes[`--col-${header.column.id.replace('.', '-')}-size`] = `${header.column.getSize()}px` + } + return colSizes + }, [ + // TODO: check if we need this + table.getState().columnSizingInfo, + table.getState().columnSizing, + table.getState().columnVisibility, + ]) + + return ( +
+ {children} +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableToolbar.tsx b/apps/studio/components/ui/DataTable/DataTableToolbar.tsx new file mode 100644 index 00000000000..11c5eb44d6f --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableToolbar.tsx @@ -0,0 +1,89 @@ +import { PanelLeftClose } from 'lucide-react' +import { ReactNode, useMemo } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' +import { formatCompactNumber } from './DataTable.utils' +import { DataTableFilterControlsDrawer } from './DataTableFilters/DataTableFilterControlsDrawer' +import { DataTableResetButton } from './DataTableResetButton' +import { DataTableViewOptions } from './DataTableViewOptions' +import { Kbd } from './primitives/Kbd' +import { useControls } from './providers/ControlsProvider' +import { useDataTable } from './providers/DataTableProvider' + +interface DataTableToolbarProps { + renderActions?: () => ReactNode +} + +export function DataTableToolbar({ renderActions }: DataTableToolbarProps) { + const { table, isLoading, columnFilters } = useDataTable() + const { open, setOpen } = useControls() + useHotKey(() => setOpen((prev) => !prev), 'b') + const filters = table.getState().columnFilters + + const rows = useMemo( + () => ({ + total: table.getCoreRowModel().rows.length, + filtered: table.getFilteredRowModel().rows.length, + }), + [isLoading, columnFilters] + ) + + return ( +
+
+ + + + + + +

+ Toggle controls with{' '} + + ⌘ + B + +

+
+
+
+
+ +
+
+

+ {formatCompactNumber(rows.filtered)} of{' '} + {formatCompactNumber(rows.total)} row(s){' '} + filtered +

+

+ {formatCompactNumber(rows.filtered)}{' '} + row(s) +

+
+
+
+ {filters.length ? : null} + {renderActions?.()} + +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx b/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx new file mode 100644 index 00000000000..b102c0bda82 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx @@ -0,0 +1,100 @@ +import { Check, GripVertical, Settings2 } from 'lucide-react' +import { useMemo, useState } from 'react' + +import { + Button, + cn, + Command_Shadcn_ as Command, + CommandEmpty_Shadcn_ as CommandEmpty, + CommandGroup_Shadcn_ as CommandGroup, + CommandInput_Shadcn_ as CommandInput, + CommandItem_Shadcn_ as CommandItem, + CommandList_Shadcn_ as CommandList, + Popover_Shadcn_ as Popover, + PopoverContent_Shadcn_ as PopoverContent, + PopoverTrigger_Shadcn_ as PopoverTrigger, +} from 'ui' +import { Sortable, SortableDragHandle, SortableItem } from './primitives/Sortable' +import { useDataTable } from './providers/DataTableProvider' + +export function DataTableViewOptions() { + const { table, enableColumnOrdering } = useDataTable() + const [open, setOpen] = useState(false) + const [drag, setDrag] = useState(false) + const [search, setSearch] = useState('') + + const columnOrder = table.getState().columnOrder + + const sortedColumns = useMemo( + () => + table.getAllColumns().sort((a, b) => { + return columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id) + }), + [columnOrder] + ) + + return ( + + + + + + + + + No option found. + + ({ id: c.id }))} + onValueChange={(items) => table.setColumnOrder(items.map((c) => c.id))} + overlay={
} + onDragStart={() => setDrag(true)} + onDragEnd={() => setDrag(false)} + onDragCancel={() => setDrag(false)} + > + {sortedColumns + .filter( + (column) => typeof column.accessorFn !== 'undefined' && column.getCanHide() + ) + .map((column) => ( + + column.toggleVisibility(!column.getIsVisible())} + className={'capitalize'} + disabled={drag} + > +
+ +
+ {(column.columnDef.meta as any)?.label || column.id} + {enableColumnOrdering && !search ? ( + + + ) : null} +
+
+ ))} + + + + + + + ) +} diff --git a/apps/studio/components/ui/DataTable/FilterSideBar.tsx b/apps/studio/components/ui/DataTable/FilterSideBar.tsx new file mode 100644 index 00000000000..11d7fae40fb --- /dev/null +++ b/apps/studio/components/ui/DataTable/FilterSideBar.tsx @@ -0,0 +1,30 @@ +import { useDataTable } from './providers/DataTableProvider' + +import { DataTableFilterControls } from './DataTableFilters/DataTableFilterControls' +import { DataTableResetButton } from './DataTableResetButton' + +import { cn } from 'ui' + +export function FilterSideBar() { + const { table } = useDataTable() + + return ( +
+
+
+

Filters

+
{table.getState().columnFilters.length ? : null}
+
+
+
+ +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/InputWithAddons.tsx b/apps/studio/components/ui/DataTable/InputWithAddons.tsx new file mode 100644 index 00000000000..0b27280f9c8 --- /dev/null +++ b/apps/studio/components/ui/DataTable/InputWithAddons.tsx @@ -0,0 +1,40 @@ +import { cn } from 'ui' + +import { forwardRef, InputHTMLAttributes, ReactNode } from 'react' + +export interface InputWithAddonsProps extends InputHTMLAttributes { + leading?: ReactNode + trailing?: ReactNode + containerClassName?: string +} + +const InputWithAddons = forwardRef( + ({ leading, trailing, containerClassName, className, ...props }, ref) => { + return ( +
+ {leading ? ( +
{leading}
+ ) : null} + + {trailing ? ( +
{trailing}
+ ) : null} +
+ ) + } +) +InputWithAddons.displayName = 'InputWithAddons' + +export { InputWithAddons } diff --git a/apps/studio/components/ui/DataTable/LiveButton.tsx b/apps/studio/components/ui/DataTable/LiveButton.tsx new file mode 100644 index 00000000000..74411d49175 --- /dev/null +++ b/apps/studio/components/ui/DataTable/LiveButton.tsx @@ -0,0 +1,73 @@ +import type { FetchPreviousPageOptions } from '@tanstack/react-query' +import { CirclePause, CirclePlay } from 'lucide-react' +import { useQueryStates } from 'nuqs' +import { useEffect } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, cn } from 'ui' +import { useDataTable } from './providers/DataTableProvider' + +const REFRESH_INTERVAL = 10_000 + +interface LiveButtonProps { + searchParamsParser: any + fetchPreviousPage?: (options?: FetchPreviousPageOptions | undefined) => Promise +} + +export function LiveButton({ fetchPreviousPage, searchParamsParser }: LiveButtonProps) { + const [{ live, date, sort }, setSearch] = useQueryStates(searchParamsParser) + const { table } = useDataTable() + useHotKey(handleClick, 'j') + + useEffect(() => { + let timeoutId: NodeJS.Timeout + + async function fetchData() { + if (live) { + await fetchPreviousPage?.() + timeoutId = setTimeout(fetchData, REFRESH_INTERVAL) + } else { + clearTimeout(timeoutId) + } + } + + fetchData() + + return () => { + clearTimeout(timeoutId) + } + }, [live, fetchPreviousPage]) + + // REMINDER: make sure to reset live when date is set + // TODO: test properly + useEffect(() => { + if ((date || sort) && live) { + setSearch((prev) => ({ ...prev, live: null })) + } + }, [date, sort]) + + function handleClick() { + setSearch((prev) => ({ + ...prev, + live: !prev.live, + date: null, + sort: null, + })) + table.getColumn('date')?.setFilterValue(undefined) + table.resetSorting() + } + + return ( + + ) +} diff --git a/apps/studio/components/ui/DataTable/LiveRow.tsx b/apps/studio/components/ui/DataTable/LiveRow.tsx new file mode 100644 index 00000000000..76c63acd8be --- /dev/null +++ b/apps/studio/components/ui/DataTable/LiveRow.tsx @@ -0,0 +1,18 @@ +import { TableCell, TableRow } from 'ui' +import { DataTableColumnLevelIndicator } from './DataTableColumn/DataTableColumnLevelIndicator' + +export function LiveRow({ colSpan }: { colSpan: number }) { + return ( + + + + + + Live Mode + + + ) +} diff --git a/apps/studio/components/ui/DataTable/RefreshButton.tsx b/apps/studio/components/ui/DataTable/RefreshButton.tsx new file mode 100644 index 00000000000..8e791264b72 --- /dev/null +++ b/apps/studio/components/ui/DataTable/RefreshButton.tsx @@ -0,0 +1,28 @@ +import { LoaderCircle, RefreshCcw } from 'lucide-react' +import { Button } from 'ui' + +import { useDataTable } from './providers/DataTableProvider' + +interface RefreshButtonProps { + onClick: () => void +} + +export function RefreshButton({ onClick }: RefreshButtonProps) { + const { isLoading } = useDataTable() + + return ( + + ) +} diff --git a/apps/studio/components/ui/DataTable/Table.tsx b/apps/studio/components/ui/DataTable/Table.tsx new file mode 100644 index 00000000000..655e0df400c --- /dev/null +++ b/apps/studio/components/ui/DataTable/Table.tsx @@ -0,0 +1,116 @@ +import { ComponentPropsWithRef, forwardRef } from 'react' +import { cn } from 'ui' +import { + Table as ShadcnTable, + TableBody as ShadcnTableBody, + TableCaption as ShadcnTableCaption, + TableCell as ShadcnTableCell, + TableFooter as ShadcnTableFooter, + TableHead as ShadcnTableHead, + TableHeader as ShadcnTableHeader, + TableRow as ShadcnTableRow, +} from 'ui/src/components/shadcn/ui/table' + +// Only create a custom component for Table with the added props +const Table = forwardRef>( + ({ className, ...props }, ref) => ( + + ) +) +Table.displayName = 'Table' + +const TableHeader = forwardRef< + HTMLTableSectionElement, + ComponentPropsWithRef +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = 'TableHeader' + +const TableBody = forwardRef< + HTMLTableSectionElement, + ComponentPropsWithRef +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = 'TableBody' + +const TableFooter = forwardRef< + HTMLTableSectionElement, + ComponentPropsWithRef +>(({ className, ...props }, ref) => ( + +)) +TableFooter.displayName = 'TableFooter' + +const TableRow = forwardRef>( + ({ className, ...props }, ref) => ( + *]:border-t [&>:not(:last-child)]:border-r', + 'border-b-0', + className + )} + {...props} + /> + ) +) +TableRow.displayName = 'TableRow' + +const TableHead = forwardRef>( + ({ className, ...props }, ref) => ( + .cursor-col-resize]:last:opacity-0', + 'text-muted-foreground h-8 px-2 text-left align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', + className + )} + {...props} + /> + ) +) +TableHead.displayName = 'TableHead' + +const TableCell = forwardRef>( + ({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px] truncate', + className + )} + {...props} + /> + ) +) +TableCell.displayName = 'TableCell' + +const TableCaption = forwardRef< + HTMLTableCaptionElement, + ComponentPropsWithRef +>(({ className, ...props }, ref) => ( + +)) +TableCaption.displayName = 'TableCaption' + +export { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } diff --git a/apps/studio/components/ui/DataTable/TimelineChart.tsx b/apps/studio/components/ui/DataTable/TimelineChart.tsx new file mode 100644 index 00000000000..76acf6daf25 --- /dev/null +++ b/apps/studio/components/ui/DataTable/TimelineChart.tsx @@ -0,0 +1,166 @@ +import { format } from 'date-fns' +import { useMemo, useState } from 'react' +import { Bar, BarChart, CartesianGrid, ReferenceArea, XAxis } from 'recharts' +import type { CategoricalChartFunc } from 'recharts/types/chart/generateCategoricalChart' + +import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, cn } from 'ui' +import { useDataTable } from './providers/DataTableProvider' + +export type BaseChartSchema = { timestamp: number; [key: string]: number } +export const description = 'A stacked bar chart' + +interface TimelineChartProps { + className?: string + /** + * The table column id to filter by - needs to be a type of `timerange` (e.g. "date"). + * TBD: if using keyof TData to be closer to the data table props + */ + columnId: string + /** + * Same data as of the InfiniteQueryMeta. + */ + data: TChart[] + chartConfig: ChartConfig +} + +export function TimelineChart({ + data, + className, + columnId, + chartConfig, +}: TimelineChartProps) { + const { table } = useDataTable() + const [refAreaLeft, setRefAreaLeft] = useState(null) + const [refAreaRight, setRefAreaRight] = useState(null) + const [isSelecting, setIsSelecting] = useState(false) + + // REMINDER: date has to be a string for tooltip label to work - don't ask me why + const chart = useMemo( + () => + data.map((item) => ({ + ...item, + [columnId]: new Date(item.timestamp).toString(), + })), + [data] + ) + + const timerange = useMemo(() => { + if (data.length === 0) return { interval: 0, period: undefined } + const first = data[0].timestamp + const last = data[data.length - 1].timestamp + const interval = Math.abs(first - last) // in ms + return { interval, period: calculatePeriod(interval) } + }, [data]) + + const handleMouseDown: CategoricalChartFunc = (e) => { + if (e.activeLabel) { + setRefAreaLeft(e.activeLabel) + setIsSelecting(true) + } + } + + const handleMouseMove: CategoricalChartFunc = (e) => { + if (isSelecting && e.activeLabel) { + setRefAreaRight(e.activeLabel) + } + } + + const handleMouseUp: CategoricalChartFunc = (e) => { + if (refAreaLeft && refAreaRight) { + const [left, right] = [refAreaLeft, refAreaRight].sort( + (a, b) => new Date(a).getTime() - new Date(b).getTime() + ) + table.getColumn(columnId)?.setFilterValue([new Date(left), new Date(right)]) + } + setRefAreaLeft(null) + setRefAreaRight(null) + setIsSelecting(false) + } + + return ( + + + + { + const date = new Date(value) + if (isNaN(date.getTime())) return 'N/A' + if (timerange.period === '10m') { + return format(date, 'HH:mm:ss') + } else if (timerange.period === '1d') { + return format(date, 'HH:mm') + } else if (timerange.period === '1w') { + return format(date, 'LLL dd HH:mm') + } + return format(date, 'LLL dd, y') + }} + /> + { + const date = new Date(value) + if (isNaN(date.getTime())) return 'N/A' + if (timerange.period === '10m') { + return format(date, 'LLL dd, HH:mm:ss') + } + return format(date, 'LLL dd, y HH:mm') + }} + /> + } + /> + {/* TODO: we could use the `{timestamp, ...rest} = data[0]` to dynamically create the bars but that would mean the order can be very much random */} + + + + {refAreaLeft && refAreaRight && ( + + )} + + + ) +} + +// TODO: check what's a good abbreviation for month vs. minutes +function calculatePeriod(interval: number): '10m' | '1d' | '1w' | '1mo' { + if (interval <= 1000 * 60 * 10) { + // less than 10 minutes + return '10m' + } else if (interval <= 1000 * 60 * 60 * 24) { + // less than 1 day + return '1d' + } else if (interval <= 1000 * 60 * 60 * 24 * 7) { + // less than 1 week + return '1w' + } + return '1mo' // defaults to 1 month +} diff --git a/apps/studio/components/ui/DataTable/hooks/README.md b/apps/studio/components/ui/DataTable/hooks/README.md new file mode 100644 index 00000000000..a517e071125 --- /dev/null +++ b/apps/studio/components/ui/DataTable/hooks/README.md @@ -0,0 +1,3 @@ +## DataTable/hooks + +Ideally all these shift to top level lib/hooks folder as these could be reused / are duplicates to what we already have diff --git a/apps/studio/components/ui/DataTable/hooks/useDebounce.ts b/apps/studio/components/ui/DataTable/hooks/useDebounce.ts new file mode 100644 index 00000000000..fc9515530a8 --- /dev/null +++ b/apps/studio/components/ui/DataTable/hooks/useDebounce.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react' + +// consider using https://github.com/xnimorz/use-debounce +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/apps/studio/components/ui/DataTable/hooks/useMediaQuery.ts b/apps/studio/components/ui/DataTable/hooks/useMediaQuery.ts new file mode 100644 index 00000000000..8551e7e97ef --- /dev/null +++ b/apps/studio/components/ui/DataTable/hooks/useMediaQuery.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react' + +export function useMediaQuery(query: string) { + const [value, setValue] = useState(false) + + useEffect(() => { + function onChange(event: MediaQueryListEvent) { + setValue(event.matches) + } + + const result = matchMedia(query) + result.addEventListener('change', onChange) + setValue(result.matches) + + return () => result.removeEventListener('change', onChange) + }, [query]) + + return value +} diff --git a/apps/studio/components/ui/DataTable/primitives/DatePickerWithRange.tsx b/apps/studio/components/ui/DataTable/primitives/DatePickerWithRange.tsx new file mode 100644 index 00000000000..291b4e916cd --- /dev/null +++ b/apps/studio/components/ui/DataTable/primitives/DatePickerWithRange.tsx @@ -0,0 +1,266 @@ +import { format } from 'date-fns' +import { Calendar as CalendarIcon } from 'lucide-react' +import { HTMLAttributes, useEffect, useState } from 'react' +import type { DateRange } from 'react-day-picker' + +import { PopoverSeparator } from '@ui/components/shadcn/ui/popover' +import { + Button, + Calendar, + cn, + Input, + Label_Shadcn_ as Label, + Popover_Shadcn_ as Popover, + PopoverContent_Shadcn_ as PopoverContent, + PopoverTrigger_Shadcn_ as PopoverTrigger, + Select_Shadcn_ as Select, + SelectContent_Shadcn_ as SelectContent, + SelectGroup_Shadcn_ as SelectGroup, + SelectItem_Shadcn_ as SelectItem, + SelectLabel_Shadcn_ as SelectLabel, + SelectTrigger_Shadcn_ as SelectTrigger, + SelectValue_Shadcn_ as SelectValue, +} from 'ui' +import { presets as defaultPresets } from '../DataTable.constants' +import type { DatePreset } from '../DataTable.types' +import { useDebounce } from '../hooks/useDebounce' +import { kbdVariants } from './Kbd' + +interface DatePickerWithRangeProps extends HTMLAttributes { + date: DateRange | undefined + setDate: (date: DateRange | undefined) => void + presets?: DatePreset[] +} + +export function DatePickerWithRange({ + className, + date, + setDate, + presets = defaultPresets, +}: DatePickerWithRangeProps) { + const [open, setOpen] = useState(false) + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (!open) return + + presets.map((preset) => { + if (preset.shortcut === e.key) { + setDate({ from: preset.from, to: preset.to }) + } + }) + } + document.addEventListener('keydown', down) + return () => document.removeEventListener('keydown', down) + }, [setDate, presets, open]) + + return ( +
+ + + + + +
+
+ +
+
+ +
+ + +
+ + +
+
+
+ ) +} + +function DatePresets({ + selected, + onSelect, + presets, +}: { + selected: DateRange | undefined + onSelect: (date: DateRange | undefined) => void + presets: DatePreset[] +}) { + return ( +
+

Date Range

+
+ {presets.map(({ label, shortcut, from, to }) => { + const isActive = selected?.from === from && selected?.to === to + return ( + + ) + })} +
+
+ ) +} + +function DatePresetsSelect({ + selected, + onSelect, + presets, +}: { + selected: DateRange | undefined + onSelect: (date: DateRange | undefined) => void + presets: DatePreset[] +}) { + function findPreset(from?: Date, to?: Date) { + return presets.find((p) => p.from === from && p.to === to)?.shortcut + } + const [value, setValue] = useState(findPreset(selected?.from, selected?.to)) + + useEffect(() => { + const preset = findPreset(selected?.from, selected?.to) + if (preset === value) return + setValue(preset) + }, [selected, presets]) + + return ( + + ) +} + +// REMINDER: We can add min max date range validation https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#setting_maximum_and_minimum_dates_and_times +function CustomDateRange({ + selected, + onSelect, +}: { + selected: DateRange | undefined + onSelect: (date: DateRange | undefined) => void +}) { + const [dateFrom, setDateFrom] = useState(selected?.from) + const [dateTo, setDateTo] = useState(selected?.to) + const debounceDateFrom = useDebounce(dateFrom, 1000) + const debounceDateTo = useDebounce(dateTo, 1000) + + const formatDateForInput = (date: Date | undefined): string => { + if (!date) return '' + const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000) + return utcDate.toISOString().slice(0, 16) + } + + useEffect(() => { + onSelect({ from: debounceDateFrom, to: debounceDateTo }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debounceDateFrom, debounceDateTo]) + + return ( +
+

Custom Range

+
+
+ + { + const newDate = new Date(e.target.value) + if (!Number.isNaN(newDate.getTime())) { + setDateFrom(newDate) + } + }} + disabled={!selected?.from} + /> +
+
+ + { + const newDate = new Date(e.target.value) + if (!Number.isNaN(newDate.getTime())) { + setDateTo(newDate) + } + }} + disabled={!selected?.to} + /> +
+
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/primitives/Kbd.tsx b/apps/studio/components/ui/DataTable/primitives/Kbd.tsx new file mode 100644 index 00000000000..3b169c6540d --- /dev/null +++ b/apps/studio/components/ui/DataTable/primitives/Kbd.tsx @@ -0,0 +1,51 @@ +// Copy Pasta from: https://github.com/sadmann7/shadcn-table/blob/main/src/components/kbd.tsx#L54 +import { type VariantProps, cva } from 'class-variance-authority' +import { forwardRef } from 'react' + +import { cn } from 'ui' + +export const kbdVariants = cva( + 'select-none rounded border px-1.5 py-px font-mono text-[0.7rem] font-normal font-mono shadow-sm disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-accent text-accent-foreground', + outline: 'bg-background text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +export interface KbdProps + extends React.ComponentPropsWithoutRef<'kbd'>, + VariantProps { + /** + * The title of the `abbr` element inside the `kbd` element. + * @default undefined + * @type string | undefined + * @example title="Command" + */ + abbrTitle?: string +} + +const Kbd = forwardRef( + ({ abbrTitle, children, className, variant, ...props }, ref) => { + return ( + + {abbrTitle ? ( + + {children} + + ) : ( + children + )} + + ) + } +) +Kbd.displayName = 'Kbd' + +export { Kbd } diff --git a/apps/studio/components/ui/DataTable/primitives/Slider.tsx b/apps/studio/components/ui/DataTable/primitives/Slider.tsx new file mode 100644 index 00000000000..ff114b61b56 --- /dev/null +++ b/apps/studio/components/ui/DataTable/primitives/Slider.tsx @@ -0,0 +1,33 @@ +// Props to https://github.com/shadcn-ui/ui/issues/885#issuecomment-2059600641 + +import * as SliderPrimitive from '@radix-ui/react-slider' +import { ComponentPropsWithoutRef, ElementRef, forwardRef, Fragment } from 'react' + +import { cn } from 'ui' + +const Slider = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const initialValue = Array.isArray(props.value) ? props.value : [props.min, props.max] + + return ( + + + + + {initialValue.map((_, index) => ( + + + + ))} + + ) +}) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } diff --git a/apps/studio/components/ui/DataTable/primitives/Sortable.tsx b/apps/studio/components/ui/DataTable/primitives/Sortable.tsx new file mode 100644 index 00000000000..c138ce567dd --- /dev/null +++ b/apps/studio/components/ui/DataTable/primitives/Sortable.tsx @@ -0,0 +1,330 @@ +import type { + DndContextProps, + DraggableSyntheticListeners, + DropAnimation, + UniqueIdentifier, +} from '@dnd-kit/core' +import { + closestCenter, + defaultDropAnimationSideEffects, + DndContext, + DragOverlay, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + restrictToHorizontalAxis, + restrictToParentElement, + restrictToVerticalAxis, +} from '@dnd-kit/modifiers' +import { + arrayMove, + horizontalListSortingStrategy, + SortableContext, + useSortable, + verticalListSortingStrategy, + type SortableContextProps, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { Slot, type SlotProps } from '@radix-ui/react-slot' +import { createContext, forwardRef, useContext, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' + +import { composeRefs } from 'components/interfaces/DataTableDemo/lib/compose-refs' +import { Button, cn, type ButtonProps } from 'ui' + +const orientationConfig = { + vertical: { + modifiers: [restrictToVerticalAxis, restrictToParentElement], + strategy: verticalListSortingStrategy, + }, + horizontal: { + modifiers: [restrictToHorizontalAxis, restrictToParentElement], + strategy: horizontalListSortingStrategy, + }, + mixed: { + modifiers: [restrictToParentElement], + strategy: undefined, + }, +} + +interface SortableProps extends DndContextProps { + /** + * An array of data items that the sortable component will render. + * @example + * value={[ + * { id: 1, name: 'Item 1' }, + * { id: 2, name: 'Item 2' }, + * ]} + */ + value: TData[] + + /** + * An optional callback function that is called when the order of the data items changes. + * It receives the new array of items as its argument. + * @example + * onValueChange={(items) => console.log(items)} + */ + onValueChange?: (items: TData[]) => void + + /** + * An optional callback function that is called when an item is moved. + * It receives an event object with `activeIndex` and `overIndex` properties, representing the original and new positions of the moved item. + * This will override the default behavior of updating the order of the data items. + * @type (event: { activeIndex: number; overIndex: number }) => void + * @example + * onMove={(event) => console.log(`Item moved from index ${event.activeIndex} to index ${event.overIndex}`)} + */ + onMove?: (event: { activeIndex: number; overIndex: number }) => void + + /** + * A collision detection strategy that will be used to determine the closest sortable item. + * @default closestCenter + * @type DndContextProps["collisionDetection"] + */ + collisionDetection?: DndContextProps['collisionDetection'] + + /** + * An array of modifiers that will be used to modify the behavior of the sortable component. + * @default + * [restrictToVerticalAxis, restrictToParentElement] + * @type Modifier[] + */ + modifiers?: DndContextProps['modifiers'] + + /** + * A sorting strategy that will be used to determine the new order of the data items. + * @default verticalListSortingStrategy + * @type SortableContextProps["strategy"] + */ + strategy?: SortableContextProps['strategy'] + + /** + * Specifies the axis for the drag-and-drop operation. It can be "vertical", "horizontal", or "both". + * @default "vertical" + * @type "vertical" | "horizontal" | "mixed" + */ + orientation?: 'vertical' | 'horizontal' | 'mixed' + + /** + * An optional React node that is rendered on top of the sortable component. + * It can be used to display additional information or controls. + * @default null + * @type React.ReactNode | null + * @example + * overlay={} + */ + overlay?: React.ReactNode | null +} + +function Sortable({ + value, + onValueChange, + onDragStart, + onDragEnd, + onDragCancel, + collisionDetection = closestCenter, + modifiers, + strategy, + onMove, + orientation = 'vertical', + overlay, + children, + ...props +}: SortableProps) { + const [activeId, setActiveId] = useState(null) + const sensors = useSensors( + useSensor(MouseSensor), + useSensor(TouchSensor), + useSensor(KeyboardSensor) + ) + + const config = orientationConfig[orientation] + + return ( + { + setActiveId(event.active.id) + onDragStart?.(event) + }} + onDragEnd={(event) => { + const { active, over } = event + if (over && active.id !== over?.id) { + const activeIndex = value.findIndex((item) => item.id === active.id) + const overIndex = value.findIndex((item) => item.id === over.id) + + if (onMove) { + onMove({ activeIndex, overIndex }) + } else { + onValueChange?.(arrayMove(value, activeIndex, overIndex)) + } + } + setActiveId(null) + onDragEnd?.(event) + }} + onDragCancel={(event) => { + setActiveId?.(null) + onDragCancel?.(event) + }} + collisionDetection={collisionDetection} + {...props} + > + + {children} + + {overlay + ? // https://docs.dndkit.com/api-documentation/draggable/drag-overlay#portals + createPortal( + {overlay}, + document.body + ) + : null} + + ) +} + +const dropAnimationOpts: DropAnimation = { + sideEffects: defaultDropAnimationSideEffects({ + styles: { + active: { + opacity: '0.4', + }, + }, + }), +} + +interface SortableOverlayProps extends React.ComponentPropsWithRef { + activeId?: UniqueIdentifier | null +} + +const SortableOverlay = forwardRef( + ({ activeId, dropAnimation = dropAnimationOpts, children, ...props }, ref) => { + return ( + + {activeId ? ( + + {children} + + ) : null} + + ) + } +) +SortableOverlay.displayName = 'SortableOverlay' + +interface SortableItemContextProps { + attributes: React.HTMLAttributes + listeners: DraggableSyntheticListeners | undefined + isDragging?: boolean +} + +const SortableItemContext = createContext({ + attributes: {}, + listeners: undefined, + isDragging: false, +}) + +function useSortableItem() { + const context = useContext(SortableItemContext) + + if (!context) { + throw new Error('useSortableItem must be used within a SortableItem') + } + + return context +} + +interface SortableItemProps extends SlotProps { + /** + * The unique identifier of the item. + * @example "item-1" + * @type UniqueIdentifier + */ + value: UniqueIdentifier + + /** + * Specifies whether the item should act as a trigger for the drag-and-drop action. + * @default false + * @type boolean | undefined + */ + asTrigger?: boolean + + /** + * Merges the item's props into its immediate child. + * @default false + * @type boolean | undefined + */ + asChild?: boolean +} + +const SortableItem = forwardRef( + ({ value, asTrigger, asChild, className, ...props }, ref) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: value, + }) + + const context = useMemo( + () => ({ + attributes, + listeners, + isDragging, + }), + [attributes, listeners, isDragging] + ) + const style: React.CSSProperties = { + opacity: isDragging ? 0.5 : 1, + transform: CSS.Translate.toString(transform), + transition, + } + + const Comp = asChild ? Slot : 'div' + + return ( + + )} + style={style} + {...(asTrigger ? attributes : {})} + {...(asTrigger ? listeners : {})} + {...props} + /> + + ) + } +) +SortableItem.displayName = 'SortableItem' + +interface SortableDragHandleProps extends ButtonProps { + withHandle?: boolean +} + +const SortableDragHandle = forwardRef( + ({ className, ...props }, ref) => { + const { attributes, listeners, isDragging } = useSortableItem() + + return ( +