From de482082277c6b53dc3e9804e20610b5e13e26eb Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Wed, 7 Aug 2024 19:39:45 +0100 Subject: [PATCH 1/2] Basic client-side pagination --- app/table/QueryTable.tsx | 41 +++++++++++++++++++++++++++------------ app/ui/lib/Pagination.tsx | 30 +++++++++++++++------------- 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/app/table/QueryTable.tsx b/app/table/QueryTable.tsx index f4906c3d80..46f89b8409 100644 --- a/app/table/QueryTable.tsx +++ b/app/table/QueryTable.tsx @@ -8,8 +8,13 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { hashKey, type UseQueryOptions } from '@tanstack/react-query' -import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table' -import React, { useCallback, useMemo, type ComponentType } from 'react' +import { + getCoreRowModel, + getPaginationRowModel, + useReactTable, + type ColumnDef, +} from '@tanstack/react-table' +import React, { useCallback, useMemo, useState, type ComponentType } from 'react' import { useApiQuery, @@ -21,7 +26,6 @@ import { } from '@oxide/api' import { Pagination } from '~/components/Pagination' -import { usePagination } from '~/hooks/use-pagination' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TableEmptyBox } from '~/ui/lib/Table' @@ -77,13 +81,13 @@ const makeQueryTable = >( emptyState, columns, }: QueryTableProps) { - const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination() + const [currentPage, setCurrentPage] = useState(0) const { data, isLoading } = useApiQuery( query, { path: params.path, - query: { ...params.query, page_token: currentPage, limit: pageSize }, + query: { ...params.query, limit: 10000 }, }, options ) @@ -97,14 +101,28 @@ const makeQueryTable = >( data: tableData, getRowId, getCoreRowModel: getCoreRowModel(), - manualPagination: true, + getPaginationRowModel: getPaginationRowModel(), + state: { + pagination: { + pageIndex: currentPage, + pageSize, + }, + }, + onPaginationChange: (updater) => { + if (typeof updater === 'function') { + const newState = updater({ pageIndex: currentPage, pageSize }) + setCurrentPage(newState.pageIndex) + } + }, + manualPagination: false, + pageCount: Math.ceil(tableData.length / pageSize), }) if (debug) console.table((data as { items?: any[] })?.items || data) if (isLoading) return null - const isEmpty = tableData.length === 0 && !hasPrev + const isEmpty = tableData.length === 0 if (isEmpty) { return ( {emptyState || } @@ -117,11 +135,10 @@ const makeQueryTable = >( table.nextPage()} + onPrev={() => table.previousPage()} /> ) diff --git a/app/ui/lib/Pagination.tsx b/app/ui/lib/Pagination.tsx index 3badef005f..03807ec279 100644 --- a/app/ui/lib/Pagination.tsx +++ b/app/ui/lib/Pagination.tsx @@ -17,7 +17,7 @@ const PageInput = ({ number, className }: PageInputProps) => { return ( @@ -29,23 +29,24 @@ const PageInput = ({ number, className }: PageInputProps) => { export interface PaginationProps { type?: 'inline' | 'page' pageSize: number - hasNext: boolean - hasPrev: boolean - nextPage: string | undefined - onNext: (nextPage: string) => void + currentPage: number + pageCount: number + onNext: () => void onPrev: () => void className?: string } export const Pagination = ({ type = 'inline', pageSize, - hasNext, - hasPrev, - nextPage, + currentPage, + pageCount, onNext, onPrev, className, }: PaginationProps) => { + const hasNext = currentPage < pageCount - 1 + const hasPrev = currentPage > 0 + return ( <>
rows per page - + + of
+
+ Updated {toLocaleTimeString(lastFetched)} +
), } diff --git a/app/components/TableFilter.tsx b/app/components/TableFilter.tsx new file mode 100644 index 0000000000..2bfe83f4f6 --- /dev/null +++ b/app/components/TableFilter.tsx @@ -0,0 +1,324 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { CloseButton, Popover, PopoverButton, PopoverPanel } from '@headlessui/react' +import type { Column, ColumnFiltersState } from '@tanstack/react-table' +import cn from 'classnames' +import { isEqual } from 'lodash' +import { useEffect, useState, type Dispatch, type SetStateAction } from 'react' + +import { + AddRoundel12Icon, + Close12Icon, + Filter12Icon, +} from '@oxide/design-system/icons/react' + +import { defaultColumnFilters } from '~/table/QueryTable' +import { Button, buttonStyle } from '~/ui/lib/Button' +import { DateRangePicker } from '~/ui/lib/DateRangePicker' +import { Listbox } from '~/ui/lib/Listbox' +import { NumberInput } from '~/ui/lib/NumberInput' +import { TextInput } from '~/ui/lib/TextInput' +import { titleCase } from '~/util/str' +import { GiB, KiB, MiB, TiB } from '~/util/units' + +export function TableFilter({ + disabled, + columnFilters, + setColumnFilters, + localFilters, + setLocalFilters, + columnOptions, +}: { + disabled: boolean + localFilters: ColumnFiltersState + setLocalFilters: Dispatch> + columnFilters: ColumnFiltersState + setColumnFilters: Dispatch> + columnOptions: Column[] +}) { + const [popoverElement, setPopoverElement] = useState() + const setPopoverRef = (element: HTMLDivElement | null) => { + setPopoverElement(element) + } + + const addFilter = () => { + setLocalFilters([ + ...localFilters, + { id: columnOptions[0].columnDef.id || '', value: '' }, + ]) + } + + const updateFilter = (index: number, key: 'id' | 'value', value: string) => { + const newFilters = [...localFilters] + newFilters[index] = { ...newFilters[index], [key]: value } + if (key === 'id') { + newFilters[index].value = '' // We want to reset the value when the filter column changes + } + setLocalFilters(newFilters) + } + + const deleteFilter = (index: number) => { + const newFilters = localFilters.filter((_, i) => i !== index) + if (newFilters.length > 0) { + setLocalFilters(newFilters) + } else { + setLocalFilters([...defaultColumnFilters]) + } + } + + const applyFilters = () => { + // we don't want to include the empty filter awaiting input + // in the actual columnFilter used against the table + const trimmedLocalFilters = localFilters.filter((filter) => filter.id !== '') + setColumnFilters(trimmedLocalFilters) + } + + useEffect(() => { + if (!popoverElement) { + if (columnFilters.length > 0) { + setLocalFilters(columnFilters) + } else { + setLocalFilters([...defaultColumnFilters]) + } + } + }, [columnFilters, popoverElement, setLocalFilters]) + + const clearFilters = () => { + setLocalFilters([...defaultColumnFilters]) + setColumnFilters([]) + } + + const isFiltering = columnFilters.length > 0 + const isDefault = + isEqual(localFilters, defaultColumnFilters) && columnFilters.length === 0 + + const hasChanges = !isEqual(localFilters, columnFilters) && !isDefault + + return ( + + svg]:text-disabled' + )} + disabled={disabled} + > + + {isFiltering && ( +
{columnFilters.length}
+ )} +
+ +
+ Filter Snapshots + + Clear + +
+
+
+ {localFilters.map((filter, index) => { + const columnOption = columnOptions.find((option) => option.id === filter.id) + const availableOptions = columnOptions.filter( + (col) => !localFilters.some((f) => f.id === col.id) || col.id === filter.id + ) + const isRange = !!( + columnOption && columnOption.columnDef.meta?.filterVariant === 'range' + ) + return ( +
+
+ ({ + value: col.id, + label: titleCase( + typeof col.columnDef.header === 'string' + ? col.columnDef.header + : col.id + ), + })) || [] + } + onChange={(value) => updateFilter(index, 'id', value)} + className={cn(isRange ? 'w-1/3' : 'w-1/2', 'flex-shrink-0')} + /> + updateFilter(index, 'value', value)} + /> +
+ +
+ ) + })} +
+
+ + + + Apply + +
+
+
+
+ ) +} + +const DynamicFilterInput = ({ + column, + filterValue, + onFilterChange, +}: { + column: Column | undefined + filterValue: string | number[] + onFilterChange: (value: string | number[]) => void +}) => { + if (!column) { + return ( + + ) + } + + const { filterVariant, options } = column.columnDef.meta ?? {} + + if (filterVariant === 'range') { + return + } else if (filterVariant === 'select' && options) { + return ( + ({ value: val, label: titleCase(val) }))} + onChange={(val) => onFilterChange(val)} + className="w-1/2" + /> + ) + } else if (filterVariant === 'datetime') { + return ( + { + onFilterChange(range) + }} + label="Label" + hideTimeZone + hourCycle={24} + disableTime + className="w-1/2 bg-default" + /> + ) + } else { + return ( + onFilterChange(e.target.value)} + className="w-1/2 rounded border shadow" + placeholder="Filter value" + /> + ) + } +} + +const units = [ + { label: 'KiB', value: KiB }, + { label: 'MiB', value: MiB }, + { label: 'TiB', value: TiB }, + { label: 'GiB', value: GiB }, +] + +const FilterNumberRange = ({ + filterValue, + onChange, +}: { + filterValue: string | number[] + onChange: (value: string | number[]) => void +}) => { + const [unit, setUnit] = useState(MiB.toString()) + const numberUnit = parseInt(unit) + + let min = undefined + let max = undefined + if (Array.isArray(filterValue) && filterValue.length === 2) { + min = typeof filterValue[0] === 'number' ? filterValue[0] / numberUnit : undefined + max = typeof filterValue[1] === 'number' ? filterValue[1] / numberUnit : undefined + } + + console.log(filterValue) + + return ( +
+ ({ label: unit.label, value: unit.value.toString() }))} + onChange={(val) => { + setUnit(val) + }} + className="w-1/3" + /> + onChange([value * numberUnit, max ?? NaN])} + className="w-1/3" + placeholder="Min" + formatOptions={{ + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }} + /> + onChange([min ?? NaN, value * numberUnit])} + className="w-1/3" + placeholder="Max" + formatOptions={{ + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }} + /> +
+ ) +} diff --git a/app/components/form/fields/DateTimeRangePicker.tsx b/app/components/form/fields/DateTimeRangePicker.tsx index 291f7c7020..b112bbdd15 100644 --- a/app/components/form/fields/DateTimeRangePicker.tsx +++ b/app/components/form/fields/DateTimeRangePicker.tsx @@ -79,7 +79,7 @@ export function useDateTimeRangePicker({ } } -type DateTimeRange = { start: DateValue; end: DateValue } +export type DateTimeRange = { start: DateValue; end: DateValue } type DateTimeRangePickerProps = { range: DateTimeRange @@ -89,6 +89,7 @@ type DateTimeRangePickerProps = { onRangeChange?: (preset: RangeKeyAll) => void minValue?: DateValue | undefined maxValue?: DateValue | undefined + disableTime?: boolean } export function DateTimeRangePicker({ @@ -99,6 +100,7 @@ export function DateTimeRangePicker({ minValue, maxValue, onRangeChange, + disableTime = false, }: DateTimeRangePickerProps) { return (
@@ -126,6 +128,7 @@ export function DateTimeRangePicker({ maxValue={maxValue} hideTimeZone className="[&_.rounded-l]:!rounded-l-none [&_button]:!border-l-0" + disableTime={disableTime} />
diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index 27fecb8c71..2408f0cb61 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -33,7 +33,7 @@ import { addToast } from '~/stores/toast' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' -import { Columns, DescriptionCell } from '~/table/columns/common' +import { Columns, TruncateCell } from '~/table/columns/common' import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' import { CopyableIp } from '~/ui/lib/CopyableIp' @@ -263,7 +263,7 @@ export function NetworkingTab() { }), ipColHelper.accessor((row) => ('description' in row ? row.description : undefined), { header: 'description', - cell: (info) => , + cell: (info) => , }), ] diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index cced8b2804..174ac11746 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -18,6 +18,7 @@ import { } from '@oxide/api' import { Snapshots16Icon, Snapshots24Icon } from '@oxide/design-system/icons/react' +import { SnapshotStateEnumArray } from '~/api/__generated__/validate' import { DocsPopover } from '~/components/DocsPopover' import { SnapshotStatusBadge } from '~/components/StatusBadge' import { getProjectSelector, useProjectSelector } from '~/hooks' @@ -30,7 +31,6 @@ import { Badge } from '~/ui/lib/Badge' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' -import { TableActions } from '~/ui/lib/Table' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' @@ -84,17 +84,20 @@ SnapshotsPage.loader = async ({ params }: LoaderFunctionArgs) => { const colHelper = createColumnHelper() const staticCols = [ - colHelper.accessor('name', {}), - colHelper.accessor('description', Columns.description), + colHelper.accessor('name', Columns.name), colHelper.accessor('diskId', { header: 'disk', cell: (info) => , + size: 125, }), colHelper.accessor('state', { cell: (info) => , + size: 125, + meta: { filterVariant: 'select', options: [...SnapshotStateEnumArray] }, }), colHelper.accessor('size', Columns.size), colHelper.accessor('timeCreated', Columns.timeCreated), + colHelper.accessor('description', Columns.description), ] export function SnapshotsPage() { @@ -143,10 +146,11 @@ export function SnapshotsPage() { links={[docLinks.snapshots]} /> - - New snapshot - - } /> +
} + actions={New snapshot} + /> ) diff --git a/app/table/QueryTable.tsx b/app/table/QueryTable.tsx index 46f89b8409..3999bd857c 100644 --- a/app/table/QueryTable.tsx +++ b/app/table/QueryTable.tsx @@ -7,16 +7,23 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { hashKey, type UseQueryOptions } from '@tanstack/react-query' +import { hashKey, useIsFetching, type UseQueryOptions } from '@tanstack/react-query' import { getCoreRowModel, + getFilteredRowModel, getPaginationRowModel, + getSortedRowModel, useReactTable, type ColumnDef, + type ColumnFiltersState, + type RowData, } from '@tanstack/react-table' -import React, { useCallback, useMemo, useState, type ComponentType } from 'react' +import cn from 'classnames' +import React, { useCallback, useMemo, useRef, useState, type ComponentType } from 'react' +import { useSearchParams } from 'react-router-dom' import { + apiQueryClient, useApiQuery, type ApiError, type ApiListMethods, @@ -24,10 +31,13 @@ import { type Result, type ResultItem, } from '@oxide/api' +import { Close12Icon, Search16Icon } from '@oxide/design-system/icons/react' import { Pagination } from '~/components/Pagination' +import { useIntervalPicker } from '~/components/RefetchIntervalPicker' +import { TableFilter } from '~/components/TableFilter' import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { TableEmptyBox } from '~/ui/lib/Table' +import { TableActions, TableEmptyBox } from '~/ui/lib/Table' import { Table } from './Table' @@ -62,11 +72,27 @@ type QueryTableProps = { pageSize?: number rowHeight?: 'small' | 'large' emptyState: React.ReactElement + actions?: React.ReactElement columns: ColumnDef[] } +declare module '@tanstack/react-table' { + // allows us to define custom properties for our columns + interface ColumnMeta { + filterVariant?: 'text' | 'range' | 'select' | 'datetime' + options?: string[] + } +} + export const PAGE_SIZE = 25 +export const defaultColumnFilters = [ + { + id: '', + value: '', + }, +] + // eslint-disable-next-line @typescript-eslint/no-explicit-any const makeQueryTable = >( query: any, @@ -80,66 +106,238 @@ const makeQueryTable = >( rowHeight = 'small', emptyState, columns, + actions, }: QueryTableProps) { - const [currentPage, setCurrentPage] = useState(0) + const [globalFilter, setGlobalFilter] = useState('') + const [searching, setSearching] = useState(false) + const searchInputRef = useRef(null) + + // localFilters are the state used for the filter popover + // columnFilters are the applied state used to do the actual filtering + const [localFilters, setLocalFilters] = useState(defaultColumnFilters) + const [columnFilters, setColumnFilters] = useState([]) const { data, isLoading } = useApiQuery( query, { path: params.path, - query: { ...params.query, limit: 10000 }, + query: { ...params.query, limit: 500000 }, }, options ) + const { intervalPicker } = useIntervalPicker({ + enabled: true, + isLoading: useIsFetching({ queryKey: [query] }) > 0, + fn: () => apiQueryClient.invalidateQueries(query), + variant: 'table', + }) + const tableData: any[] = useMemo(() => (data as any)?.items || [], [data]) const getRowId = useCallback((row: any) => row.name, []) + const [searchParams, setSearchParams] = useSearchParams() + const page = searchParams.get('page') + const currentPage = page ? Number(page) : 0 + + const nextPage = () => { + const maxPage = table.getPageCount() - 1 + if (currentPage < maxPage) { + updatePage(currentPage + 1) + } + } + + const prevPage = () => { + if (currentPage > 0) { + updatePage(currentPage - 1) + } + } + + const updatePage = (p: number) => { + if (p === 0) { + // first page does not need to be in the URL + searchParams.delete('page') + } else { + searchParams.set('page', p.toString()) + } + setSearchParams(searchParams, { replace: true }) + } + const table = useReactTable({ columns, data: tableData, getRowId, getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + enableSortingRemoval: false, getPaginationRowModel: getPaginationRowModel(), + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: 'includesString', + initialState: { + sorting: [ + { + id: 'timeCreated', + desc: true, + }, + ], + }, state: { pagination: { pageIndex: currentPage, pageSize, }, + globalFilter, + columnFilters, }, onPaginationChange: (updater) => { if (typeof updater === 'function') { const newState = updater({ pageIndex: currentPage, pageSize }) - setCurrentPage(newState.pageIndex) + updatePage(newState.pageIndex) } }, manualPagination: false, - pageCount: Math.ceil(tableData.length / pageSize), }) if (debug) console.table((data as { items?: any[] })?.items || data) - if (isLoading) return null - const isEmpty = tableData.length === 0 - if (isEmpty) { - return ( - {emptyState || } - ) + const filteredLength = table.getPrePaginationRowModel().rows.length + const noResults = tableData.length === 0 + const isEmpty = noResults || filteredLength === 0 + + const resetSearch = () => { + setGlobalFilter('') + setSearching(false) } + const clearAllFilters = () => { + setGlobalFilter('') + setSearching(false) + setLocalFilters([...defaultColumnFilters]) + setColumnFilters([]) + } + + const isFilteringColumn = columnFilters.length > 0 + return ( <> -
+ +
{intervalPicker}
+
+
+ {(globalFilter || isFilteringColumn) && ( +
+ {filteredLength.toLocaleString()} matches +
+ )} +
+ + + setGlobalFilter(el.target.value)} + onBlur={() => { + if (globalFilter === '') { + setSearching(false) + } + }} + /> + +
+
+ !!item.accessorFn)} // If it doesnt have an accessorFn it probably isn't filterable + /> + {actions} +
+
+ {isEmpty ? ( + + {noResults ? ( + emptyState || + ) : ( + } + title="No matches" + body={ + isFilteringColumn + ? 'Could not find items that match filters' + : `Could not find item "${globalFilter}"` + } + buttonText={isFilteringColumn ? 'Clear filters' : 'Clear search'} + onClick={clearAllFilters} + /> + )} + + ) : ( +
+ )} table.nextPage()} - onPrev={() => table.previousPage()} + onNext={nextPage} + onPrev={prevPage} /> ) } + +const SearchIcon12 = ({ className }: { className?: string }) => ( + + + +) diff --git a/app/table/Table.tsx b/app/table/Table.tsx index 759ea6800a..f2517de398 100644 --- a/app/table/Table.tsx +++ b/app/table/Table.tsx @@ -31,14 +31,45 @@ export const Table = ({ {table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => ( - - {flexRender(header.column.columnDef.header, header.getContext())} - - ))} + {headerGroup.headers.map((header) => { + const sortDir = header.column.getIsSorted() + + return ( + +
+ {flexRender(header.column.columnDef.header, header.getContext())} +
+ + +
+
+
+ ) + })}
))}
@@ -73,6 +104,10 @@ export const Table = ({ {...(i === 0 ? firstCellProps : {})} className={cell.column.columnDef.meta?.tdClassName} height={rowHeight} + style={{ + minWidth: `${cell.column.getSize()}px`, + maxWidth: `${cell.column.getSize()}px`, + }} > {flexRender(cell.column.columnDef.cell, cell.getContext())} @@ -83,3 +118,19 @@ export const Table = ({ ) + +const SortArrow = ({ className }: { className?: string }) => ( + + + +) diff --git a/app/table/columns/common.tsx b/app/table/columns/common.tsx index c8de914199..729f7f06eb 100644 --- a/app/table/columns/common.tsx +++ b/app/table/columns/common.tsx @@ -6,8 +6,11 @@ * Copyright Oxide Computer Company */ +import { parseAbsolute } from '@internationalized/date' +import { type Row } from '@tanstack/react-table' import { filesize } from 'filesize' +import type { DateTimeRange } from '~/components/form/fields/DateTimeRangePicker' import { DateTime } from '~/ui/lib/DateTime' import { Truncate } from '~/ui/lib/Truncate' @@ -30,16 +33,62 @@ function sizeCell(info: Info) { ) } -export const DescriptionCell = ({ text }: { text?: string }) => - text ? : +export const TruncateCell = ({ text, length = 48 }: { text?: string; length?: number }) => + text ? : /** Columns used in a bunch of tables */ export const Columns = { /** Truncates text if too long, full text in tooltip */ + name: { + cell: (info: Info) => , + size: 200, + }, description: { - cell: (info: Info) => , + cell: (info: Info) => ( + + ), + size: 225, + }, + size: { + cell: sizeCell, + disableGlobalFilter: true, + size: 125, + meta: { filterVariant: 'range' as const }, + }, + timeCreated: { + header: 'created', + cell: dateCell, + disableGlobalFilter: true, + meta: { filterVariant: 'datetime' as const }, + filterFn: dateTimeFilter, }, - size: { cell: sizeCell }, - timeCreated: { header: 'created', cell: dateCell }, - timeModified: { header: 'modified', cell: dateCell }, + timeModified: { + header: 'modified', + cell: dateCell, + disableGlobalFilter: true, + meta: { filterVariant: 'datetime' as const }, + filterFn: dateTimeFilter, + }, +} + +function dateTimeFilter( + row: Row, + columnId: string, + filterValue: DateTimeRange +): boolean { + const rowDate: Date = row.getValue(columnId) + const isoString = rowDate.toISOString() + + const rowValue = parseAbsolute(isoString, 'UTC') + const { start, end } = filterValue + + // we filter the dateTime by day ignorning the hours + // and minutes to avoid requiring that within the UI + // may revise if we think the user requires that granularity + const startOfDay = start.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }) + const endOfDay = end.set({ hour: 23, minute: 59, second: 59, millisecond: 999 }) + + const rowValueDate = rowValue.set({ hour: 0, minute: 0, second: 0, millisecond: 0 }) + + return rowValueDate.compare(startOfDay) >= 0 && rowValueDate.compare(endOfDay) <= 0 } diff --git a/app/ui/lib/DateRangePicker.tsx b/app/ui/lib/DateRangePicker.tsx index 8f04817ef1..8c4a5a7938 100644 --- a/app/ui/lib/DateRangePicker.tsx +++ b/app/ui/lib/DateRangePicker.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { getLocalTimeZone } from '@internationalized/date' +import { getLocalTimeZone, Time } from '@internationalized/date' import type { TimeValue } from '@react-types/datepicker' import cn from 'classnames' import { useMemo, useRef } from 'react' @@ -22,6 +22,7 @@ import { RangeCalendar } from './RangeCalendar' interface DateRangePickerProps extends DateRangePickerStateOptions { label: string className?: string + disableTime?: boolean } export function DateRangePicker(props: DateRangePickerProps) { @@ -35,15 +36,11 @@ export function DateRangePicker(props: DateRangePickerProps) { const formatter = useDateFormatter({ dateStyle: 'short', - timeStyle: 'short', - hourCycle: 'h24', + ...(props.disableTime ? {} : { timeStyle: 'short', hourCycle: 'h24' }), }) const label = useMemo(() => { - // This is here to make TS happy. This should be impossible in practice - // because we always pass a value to this component and there is no way to - // unset the value through the UI. - if (!state.dateRange) return 'No range selected' + if (!state.dateRange) return Select date range return formatter.formatRange( state.dateRange.start.toDate(getLocalTimeZone()), @@ -56,19 +53,24 @@ export function DateRangePicker(props: DateRangePickerProps) { aria-label={props.label} className={cn('relative flex-col text-left', props.className)} > -
+
@@ -90,23 +92,25 @@ export function DateRangePicker(props: DateRangePickerProps) { -
- state.setTime('start', v)} - hourCycle={24} - className="shrink-0 grow basis-0" - /> -
- state.setTime('end', v)} - hourCycle={24} - className="shrink-0 grow basis-0" - /> -
+ {!props.disableTime && ( +
+ state.setTime('start', v)} + hourCycle={24} + className="shrink-0 grow basis-0" + /> +
+ state.setTime('end', v)} + hourCycle={24} + className="shrink-0 grow basis-0" + /> +
+ )}
)} diff --git a/app/ui/lib/DateTime.tsx b/app/ui/lib/DateTime.tsx index 828d1bf1ed..d2deb4e564 100644 --- a/app/ui/lib/DateTime.tsx +++ b/app/ui/lib/DateTime.tsx @@ -9,7 +9,7 @@ import { toLocaleDateString, toLocaleTimeString } from '~/util/date' export const DateTime = ({ date, locale }: { date: Date; locale?: string }) => ( -