list type safety

This commit is contained in:
Nate Kelley 2025-08-21 11:10:07 -06:00
parent 9f26eb9412
commit ad0231f031
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
9 changed files with 129 additions and 69 deletions

View File

@ -37,7 +37,7 @@ import {
Trash, Trash,
} from '@/components/ui/icons'; } from '@/components/ui/icons';
import { useBusterNotifications } from '@/context/BusterNotifications'; import { useBusterNotifications } from '@/context/BusterNotifications';
import { useIsChatMode } from '@/context/Chats/useIsChatMode'; import { useIsChatMode } from '@/context/Chats/useMode';
import { useDownloadMetricDataCSV } from '@/context/Metrics/useDownloadMetricDataCSV'; import { useDownloadMetricDataCSV } from '@/context/Metrics/useDownloadMetricDataCSV';
import { useDownloadPNGSelectMenu } from '@/context/Metrics/useDownloadMetricDataPNG'; import { useDownloadPNGSelectMenu } from '@/context/Metrics/useDownloadMetricDataPNG';
import { useRenameMetricOnPage } from '@/context/Metrics/useRenameMetricOnPage'; import { useRenameMetricOnPage } from '@/context/Metrics/useRenameMetricOnPage';

View File

@ -0,0 +1,53 @@
import type { RegisteredRouter } from '@tanstack/react-router';
import { create } from 'lodash';
import type { BusterListRowItem } from './interfaces';
export function createLinkItems<
T,
TRouter extends RegisteredRouter,
TOptions,
TFrom extends string = string,
>(
items: BusterListRowItem<T, TRouter, TOptions, TFrom>[]
): BusterListRowItem<T, TRouter, TOptions, TFrom>[] {
return items;
}
export function createLinkItem<
T,
TRouter extends RegisteredRouter = RegisteredRouter,
TOptions = Record<string, unknown>,
TFrom extends string = string,
>(
item: BusterListRowItem<T, TRouter, TOptions, TFrom>
): BusterListRowItem<T, TRouter, TOptions, TFrom> {
return item;
}
const test = createLinkItem({
id: '1',
data: {
name: 'Test',
},
link: {
to: '/app/metrics/$metricId',
params: {
metricId: '1',
},
},
});
const test2 = createLinkItem<{
swag: boolean;
}>({
id: '1',
data: {
swag: true,
},
link: {
to: '/app/metrics/$metricId',
params: {
metricId: '1',
},
},
});

View File

@ -1,3 +1,4 @@
export * from './create-link-items';
export * from './interfaces'; export * from './interfaces';
export * from './ListSelectedOptionPopup'; export * from './ListSelectedOptionPopup';

View File

@ -1,6 +1,6 @@
import type { LinkProps } from '@tanstack/react-router'; import type { LinkProps, RegisteredRouter } from '@tanstack/react-router';
import type React from 'react'; import type React from 'react';
import type { OptionsTo } from '@/types/routes'; import type { ILinkProps } from '@/types/routes';
import type { ContextMenuProps } from '../../context-menu/ContextMenu'; import type { ContextMenuProps } from '../../context-menu/ContextMenu';
export interface BusterListProps<T = unknown> { export interface BusterListProps<T = unknown> {
@ -31,8 +31,12 @@ export type BusterListColumn<T = unknown> = {
}; };
}[keyof T]; }[keyof T];
type BusterListRowLink = { type BusterListRowLink<
link: OptionsTo; TRouter extends RegisteredRouter = RegisteredRouter,
TOptions = Record<string, unknown>,
TFrom extends string = string,
> = {
link: ILinkProps<TRouter, TOptions, TFrom>;
preloadDelay?: LinkProps['preloadDelay']; preloadDelay?: LinkProps['preloadDelay'];
preload?: LinkProps['preload']; preload?: LinkProps['preload'];
}; };
@ -41,7 +45,12 @@ type BusterListRowNotLink = {
link?: never; link?: never;
}; };
export type BusterListRowItem<T = unknown> = { export type BusterListRowItem<
T = unknown,
TRouter extends RegisteredRouter = RegisteredRouter,
TOptions = Record<string, unknown>,
TFrom extends string = string,
> = {
id: string; id: string;
data: T | null; data: T | null;
onClick?: () => void; onClick?: () => void;
@ -49,7 +58,7 @@ export type BusterListRowItem<T = unknown> = {
rowSection?: BusterListSectionRow; rowSection?: BusterListSectionRow;
hidden?: boolean; hidden?: boolean;
dataTestId?: string; dataTestId?: string;
} & (BusterListRowLink | BusterListRowNotLink); } & (BusterListRowLink<TRouter, TOptions, TFrom> | BusterListRowNotLink);
export interface BusterListSectionRow { export interface BusterListSectionRow {
title: string; title: string;

View File

@ -8,3 +8,11 @@ export const useIsChatMode = () => {
}); });
return chatId !== undefined; return chatId !== undefined;
}; };
export const useIsFileMode = () => {
const chatId = useParams({
select: stableSelect,
strict: false,
});
return chatId === undefined;
};

View File

@ -8,13 +8,12 @@ import {
} from '@/components/features/metrics/StatusBadgeIndicator'; } from '@/components/features/metrics/StatusBadgeIndicator';
import { Avatar } from '@/components/ui/avatar'; import { Avatar } from '@/components/ui/avatar';
import type { BusterListColumn, BusterListRowItem } from '@/components/ui/list'; import type { BusterListColumn, BusterListRowItem } from '@/components/ui/list';
import { BusterList, ListEmptyStateWithButton } from '@/components/ui/list'; import { BusterList, createLinkItem, ListEmptyStateWithButton } from '@/components/ui/list';
import { useCreateListByDate } from '@/components/ui/list/useCreateListByDate'; import { useCreateListByDate } from '@/components/ui/list/useCreateListByDate';
import { Text } from '@/components/ui/typography'; import { Text } from '@/components/ui/typography';
import { useMemoizedFn } from '@/hooks/useMemoizedFn'; import { useMemoizedFn } from '@/hooks/useMemoizedFn';
import { formatDate } from '@/lib/date'; import { formatDate } from '@/lib/date';
import { makeHumanReadble } from '@/lib/text'; import { makeHumanReadble } from '@/lib/text';
import type { OptionsTo } from '@/types/routes';
import { MetricSelectedOptionPopup } from './MetricItemsSelectedPopup'; import { MetricSelectedOptionPopup } from './MetricItemsSelectedPopup';
export const MetricItemsContainer: React.FC<{ export const MetricItemsContainer: React.FC<{
@ -36,7 +35,8 @@ export const MetricItemsContainer: React.FC<{
const metricsByDate: BusterListRowItem<BusterMetricListItem>[] = useMemo(() => { const metricsByDate: BusterListRowItem<BusterMetricListItem>[] = useMemo(() => {
return Object.entries(logsRecord).flatMap<BusterListRowItem<BusterMetricListItem>>( return Object.entries(logsRecord).flatMap<BusterListRowItem<BusterMetricListItem>>(
([key, metrics]) => { ([key, metrics]) => {
const records = metrics.map((metric) => ({ const records = metrics.map((metric) =>
createLinkItem({
id: metric.id, id: metric.id,
data: metric, data: metric,
link: { link: {
@ -44,8 +44,9 @@ export const MetricItemsContainer: React.FC<{
params: { params: {
metricId: metric.id, metricId: metric.id,
}, },
} satisfies OptionsTo, },
})); })
);
const hasRecords = records.length > 0; const hasRecords = records.length > 0;
if (!hasRecords) { if (!hasRecords) {
return []; return [];

View File

@ -1,5 +1,5 @@
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import React, { useMemo } from 'react'; import React from 'react';
import { useGetMetric } from '@/api/buster_rest/metrics'; import { useGetMetric } from '@/api/buster_rest/metrics';
import { CreateChatButton } from '@/components/features/AssetLayout/CreateChatButton'; import { CreateChatButton } from '@/components/features/AssetLayout/CreateChatButton';
import { SaveMetricToCollectionButton } from '@/components/features/buttons/SaveMetricToCollectionButton'; import { SaveMetricToCollectionButton } from '@/components/features/buttons/SaveMetricToCollectionButton';
@ -7,8 +7,8 @@ import { SaveMetricToDashboardButton } from '@/components/features/buttons/SaveM
import { ShareMetricButton } from '@/components/features/buttons/ShareMetricButton'; import { ShareMetricButton } from '@/components/features/buttons/ShareMetricButton';
import { ThreeDotMenuButton } from '@/components/features/metrics/MetricThreeDotMenu'; import { ThreeDotMenuButton } from '@/components/features/metrics/MetricThreeDotMenu';
import { SquareChartPen } from '@/components/ui/icons'; import { SquareChartPen } from '@/components/ui/icons';
import { useIsFileMode } from '@/context/Chats/useMode';
import { useIsMetricReadOnly } from '@/context/Metrics/useIsMetricReadOnly'; import { useIsMetricReadOnly } from '@/context/Metrics/useIsMetricReadOnly';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
import { canEdit, getIsEffectiveOwner } from '@/lib/share'; import { canEdit, getIsEffectiveOwner } from '@/lib/share';
import { FileButtonContainer } from '../FileButtonContainer'; import { FileButtonContainer } from '../FileButtonContainer';
import { HideButtonContainer } from '../HideButtonContainer'; import { HideButtonContainer } from '../HideButtonContainer';
@ -18,6 +18,7 @@ export const MetricContainerHeaderButtons: React.FC<{
metricId: string; metricId: string;
metricVersionNumber: number; metricVersionNumber: number;
}> = React.memo(({ metricId, metricVersionNumber }) => { }> = React.memo(({ metricId, metricVersionNumber }) => {
const isFileMode = useIsFileMode();
const { isViewingOldVersion } = useIsMetricReadOnly({ const { isViewingOldVersion } = useIsMetricReadOnly({
metricId: metricId || '', metricId: metricId || '',
}); });
@ -43,7 +44,7 @@ export const MetricContainerHeaderButtons: React.FC<{
isViewingOldVersion={isViewingOldVersion} isViewingOldVersion={isViewingOldVersion}
versionNumber={metricVersionNumber} versionNumber={metricVersionNumber}
/> />
<HideButtonContainer show={selectedLayout === 'file-only'}> <HideButtonContainer show={isFileMode}>
<CreateChatButton assetId={metricId} assetType="metric" /> <CreateChatButton assetId={metricId} assetType="metric" />
</HideButtonContainer> </HideButtonContainer>
</FileButtonContainer> </FileButtonContainer>
@ -53,62 +54,49 @@ export const MetricContainerHeaderButtons: React.FC<{
MetricContainerHeaderButtons.displayName = 'MetricContainerHeaderButtons'; MetricContainerHeaderButtons.displayName = 'MetricContainerHeaderButtons';
const EditChartButton = React.memo(({ metricId }: { metricId: string }) => { const EditChartButton = React.memo(({ metricId }: { metricId: string }) => {
const onChangePage = useAppLayoutContextSelector((x) => x.onChangePage); const isEditorOpen = true;
const selectedFileViewSecondary = useChatLayoutContextSelector( // const metricVersionNumber = useChatLayoutContextSelector((x) => x.metricVersionNumber);
(x) => x.selectedFileViewSecondary // const editableSecondaryView: MetricFileViewSecondary = 'chart-edit';
); // const isSelectedView = selectedFileViewSecondary === editableSecondaryView;
const chatId = useChatIndividualContextSelector((x) => x.chatId);
const metricVersionNumber = useChatLayoutContextSelector((x) => x.metricVersionNumber);
const editableSecondaryView: MetricFileViewSecondary = 'chart-edit';
const isSelectedView = selectedFileViewSecondary === editableSecondaryView;
const href = useMemo(() => { // const href = useMemo(() => {
if (isSelectedView) { // if (isSelectedView) {
return assetParamsToRoute({ // return assetParamsToRoute({
chatId, // chatId,
assetId: metricId, // assetId: metricId,
type: 'metric', // type: 'metric',
secondaryView: undefined, // secondaryView: undefined,
versionNumber: metricVersionNumber, // versionNumber: metricVersionNumber,
page: 'chart', // page: 'chart',
}); // });
} // }
return assetParamsToRoute({ // return assetParamsToRoute({
chatId, // chatId,
assetId: metricId, // assetId: metricId,
type: 'metric', // type: 'metric',
secondaryView: 'chart-edit', // secondaryView: 'chart-edit',
versionNumber: metricVersionNumber, // versionNumber: metricVersionNumber,
page: 'chart', // page: 'chart',
}); // });
}, [chatId, metricId, isSelectedView, metricVersionNumber]); // }, [chatId, metricId, isSelectedView, metricVersionNumber]);
//I HAVE NO IDEA WHY... but onClickButton is called twice if wrapped in a link
const onClickButton = useMemoizedFn(() => {
onChangePage(href, { shallow: true });
});
return ( return (
<Link <Link
href={href} to="/app/metrics/$metricId/chart"
prefetch={true} params={{
passHref metricId,
}}
data-testid="edit-chart-button" data-testid="edit-chart-button"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}} }}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onClickButton();
}}
> >
<SelectableButton <SelectableButton
tooltipText="Edit chart" tooltipText="Edit chart"
icon={<SquareChartPen />} icon={<SquareChartPen />}
selected={isSelectedView} selected={isEditorOpen}
/> />
</Link> </Link>
); );

View File

@ -1,5 +1,5 @@
import type { FileRouteTypes } from '@/routeTree.gen'; import type { FileRouteTypes } from '@/routeTree.gen';
import type { OptionsTo } from '@/types/routes'; import type { ILinkProps } from '@/types/routes';
type RouteFilePaths = FileRouteTypes['to']; type RouteFilePaths = FileRouteTypes['to'];
@ -269,7 +269,7 @@ class RouteBuilder<T extends RouteBuilderState = NonNullable<unknown>> {
/** /**
* Build navigation options with route and params * Build navigation options with route and params
*/ */
buildNavigationOptions(): OptionsTo { buildNavigationOptions(): ILinkProps {
const route = this.build(); const route = this.build();
const params = this.getParams(); const params = this.getParams();
const search = this.getSearchParams(); const search = this.getSearchParams();
@ -289,7 +289,7 @@ class RouteBuilder<T extends RouteBuilderState = NonNullable<unknown>> {
} }
// Type assertion through unknown for complex generic type // Type assertion through unknown for complex generic type
return navOptions as OptionsTo; return navOptions as unknown as ILinkProps;
} }
/** /**
@ -425,7 +425,7 @@ class RouteBuilder<T extends RouteBuilderState = NonNullable<unknown>> {
* }); * });
* // Result: { to: '/app/dashboards/dashboard-456', params: { dashboardId: 'dashboard-456', metricId: 'metric-789' }, search: { dashboard_version_number: 3, metric_version_number: 2 } } * // Result: { to: '/app/dashboards/dashboard-456', params: { dashboardId: 'dashboard-456', metricId: 'metric-789' }, search: { dashboard_version_number: 3, metric_version_number: 2 } }
*/ */
export const assetParamsToRoute = (params: AssetParamsToRoute): OptionsTo => { export const assetParamsToRoute = (params: AssetParamsToRoute): ILinkProps => {
const builder = new RouteBuilder(); const builder = new RouteBuilder();
// Build route based on asset type and additional params // Build route based on asset type and additional params

View File

@ -14,7 +14,7 @@ export type ILinkOptions = Partial<
>; >;
export type ILinkProps< export type ILinkProps<
TRouter extends RegisteredRouter, TRouter extends RegisteredRouter = RegisteredRouter,
TOptions, TOptions = Record<string, unknown>,
TFrom extends string = string, TFrom extends string = string,
> = ValidateLinkOptions<TRouter, TOptions, TFrom> & ILinkOptions; > = ValidateLinkOptions<TRouter, TOptions, TFrom> & ILinkOptions;