add a three dot dropdown

This commit is contained in:
Nate Kelley 2025-03-14 15:00:37 -06:00
parent 5eac58f805
commit 34fd70b117
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
10 changed files with 186 additions and 59 deletions

View File

@ -35,6 +35,10 @@ export type BusterMetric = {
id: string;
name: string;
}[];
versions: {
version_number: number;
updated_at: string;
}[];
} & BusterShare;
export type DataMetadata = {

View File

@ -320,15 +320,14 @@ export const WithSecondaryLabel: Story = {
value: '1',
label: 'Profile Settings',
secondaryLabel: 'User preferences',
onClick: fn(),
icon: <PaintRoller />
onClick: fn()
},
{
value: '2',
label: 'Storage',
secondaryLabel: '45GB used',
onClick: fn(),
icon: <Storage />
selected: true
},
{ type: 'divider' },
{

View File

@ -325,9 +325,9 @@ const DropdownItem = <T,>({
<>
{icon && !loading && <span className="text-icon-color">{icon}</span>}
<div className={cn('flex flex-col gap-y-1', truncate && 'overflow-hidden')}>
<div className={cn('flex flex-col space-y-2', truncate && 'overflow-hidden')}>
<span className={cn(truncate && 'truncate')}>{label}</span>
{secondaryLabel && <span className="text-gray-light text2xs">{secondaryLabel}</span>}
{secondaryLabel && <span className="text-gray-light text-xs">{secondaryLabel}</span>}
</div>
{loading && <CircleSpinnerLoader size={9} />}
{shortcut && <DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut>}
@ -367,7 +367,8 @@ const DropdownItem = <T,>({
);
}
if (selectType === 'single') {
//I do not think this selected check is stable... look into refactoring
if (selectType === 'single' || selected) {
return (
<DropdownMenuCheckboxItemSingle
checked={selected}
@ -426,7 +427,7 @@ const DropdownSubMenuWrapper = <T,>({
<DropdownMenuSub>
<DropdownMenuSubTrigger>{children}</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuSubContent sideOffset={8}>
{items?.map((item, index) => (
<DropdownItemSelector
key={dropdownItemKey(item, index)}

View File

@ -2,13 +2,8 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import {
ArrowRight,
ArrowUpRight,
CaretRight,
Check3 as Check,
ChevronRight
} from '../icons/NucleoIconOutlined';
import { ArrowRight, ArrowUpRight, Check3 as Check } from '../icons/NucleoIconOutlined';
import { CaretRight } from '../icons/NucleoIconFilled';
import { cn } from '@/lib/classMerge';
import { Checkbox } from '../checkbox/Checkbox';
import { Button } from '../buttons/Button';
@ -34,13 +29,14 @@ const DropdownMenuSubTrigger = React.forwardRef<
ref={ref}
className={cn(
'focus:bg-item-hover data-[state=open]:bg-item-hover flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0',
'dropdown-item mx-1 [&.dropdown-item:first-child]:mt-1! [&.dropdown-item:has(+.dropdown-separator)]:mb-1 [&.dropdown-item:has(~.dropdown-separator)]:mt-1 [&.dropdown-item:last-child]:mb-1!',
inset && 'pl-8',
className
)}
{...props}>
{children}
<div className="ml-auto !text-sm">
<ChevronRight />
<div className="text-2xs text-icon-color ml-auto">
<CaretRight />
</div>
</DropdownMenuPrimitive.SubTrigger>
));
@ -49,7 +45,7 @@ DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayNam
const baseContentClass = cn(
`data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden `,
'bg-background text-foreground ',
'rounded-md border p-1'
'rounded-md border'
);
const DropdownMenuSubContent = React.forwardRef<
@ -126,7 +122,7 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const itemClass = cn(
'focus:bg-item-hover focus:text-foreground',
'relative flex cursor-pointer items-center rounded-sm py-1.5 text-sm outline-none select-none',
'relative flex cursor-pointer items-center rounded-sm py-1.5 text-base outline-none select-none',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'gap-1.5',
'mx-1 dropdown-item [&.dropdown-item:has(+.dropdown-separator)]:mb-1 [&.dropdown-item:has(~.dropdown-separator)]:mt-1 [&.dropdown-item:first-child]:mt-1! [&.dropdown-item:last-child]:mb-1!'
@ -159,7 +155,7 @@ const DropdownMenuCheckboxItemSingle = React.forwardRef<
{children}
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<div className="flex h-4 w-4 items-center justify-center">
<div className="text-icon-color flex h-4 w-4 items-center justify-center text-sm">
<Check />
</div>
</DropdownMenuPrimitive.ItemIndicator>

View File

@ -21,3 +21,4 @@ export * from './useUpdateLayoutEffect';
export * from './useScroll';
export * from './useUpdateEffect';
export * from './useWhyDidYouUpdate';
export * from './useSetInterval';

View File

@ -0,0 +1,60 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useMemoizedFn } from './useMemoizedFn';
/**
* A hook that provides a safe way to use setInterval in React components.
* The interval will be automatically cleared when the component unmounts.
* The callback and delay will be properly updated when they change.
*
* @param callback The function to be called at each interval
* @param delay The delay in milliseconds between each call. If null, the interval is paused
* @returns An object containing functions to control the interval
*
* @example
* ```tsx
* const { start, stop, isActive } = useSetInterval(() => {
* console.log('This runs every second');
* }, 1000);
* ```
*/
export function useSetInterval(callback: () => void, delay: number | null) {
const intervalRef = useRef<NodeJS.Timeout>();
const savedCallback = useMemoizedFn(callback);
const [isActive, setIsActive] = useState(false);
useEffect(() => {
if (delay !== null) {
setIsActive(true);
intervalRef.current = setInterval(() => savedCallback(), delay);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
setIsActive(false);
}
};
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
setIsActive(false);
}
}
}, [delay, savedCallback]);
const start = useCallback(() => {
if (!isActive && delay !== null) {
setIsActive(true);
intervalRef.current = setInterval(() => savedCallback(), delay);
}
}, [delay, isActive, savedCallback]);
const stop = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
setIsActive(false);
}
}, []);
return { start, stop, isActive } as const;
}

View File

@ -1,15 +1,15 @@
import React, { useMemo } from 'react';
import { FileContainerButtonsProps } from './interfaces';
import { MetricFileViewSecondary, useChatLayoutContextSelector } from '../../ChatLayoutContext';
import { FileContainerButtonsProps } from '../interfaces';
import { MetricFileViewSecondary, useChatLayoutContextSelector } from '../../../ChatLayoutContext';
import { useMemoizedFn } from '@/hooks';
import { useChatIndividualContextSelector } from '../../ChatContext';
import { HideButtonContainer } from './HideButtonContainer';
import { FileButtonContainer } from './FileButtonContainer';
import { CreateChatButton } from './CreateChatButtont';
import { SelectableButton } from './SelectableButton';
import { SaveMetricToCollectionButton } from '../../../../components/features/buttons/SaveMetricToCollectionButton';
import { SaveMetricToDashboardButton } from '../../../../components/features/buttons/SaveMetricToDashboardButton';
import { ShareMetricButton } from '../../../../components/features/buttons/ShareMetricButton';
import { useChatIndividualContextSelector } from '../../../ChatContext';
import { HideButtonContainer } from '../HideButtonContainer';
import { FileButtonContainer } from '../FileButtonContainer';
import { CreateChatButton } from '../CreateChatButtont';
import { SelectableButton } from '../SelectableButton';
import { SaveMetricToCollectionButton } from '../../../../../components/features/buttons/SaveMetricToCollectionButton';
import { SaveMetricToDashboardButton } from '../../../../../components/features/buttons/SaveMetricToDashboardButton';
import { ShareMetricButton } from '../../../../../components/features/buttons/ShareMetricButton';
import {
Code3,
Dots,
@ -22,6 +22,7 @@ import { useDeleteMetric, useGetMetric } from '@/api/buster_rest/metrics';
import { Button } from '@/components/ui/buttons';
import { Dropdown, DropdownItems } from '@/components/ui/dropdown';
import { useBusterNotifications } from '@/context/BusterNotifications';
import { ThreeDotMenuButton } from './MetricThreeDotMenu';
export const MetricContainerHeaderButtons: React.FC<FileContainerButtonsProps> = React.memo(() => {
const renderViewLayoutKey = useChatLayoutContextSelector((x) => x.renderViewLayoutKey);
@ -111,33 +112,3 @@ const ShareMetricButtonLocal = React.memo(({ metricId }: { metricId: string }) =
return <ShareMetricButton metricId={metricId} />;
});
ShareMetricButtonLocal.displayName = 'ShareMetricButtonLocal';
const ThreeDotMenuButton = React.memo(({ metricId }: { metricId: string }) => {
const { mutateAsync: deleteMetric, isPending: isDeletingMetric } = useDeleteMetric();
const { openSuccessMessage } = useBusterNotifications();
const onSetSelectedFile = useChatLayoutContextSelector((x) => x.onSetSelectedFile);
const items: DropdownItems = useMemo(
() => [
{
label: 'Delete',
value: 'delete',
icon: <Trash />,
loading: isDeletingMetric,
onClick: async () => {
await deleteMetric({ ids: [metricId] });
openSuccessMessage('Metric deleted');
onSetSelectedFile(null);
}
}
],
[deleteMetric, isDeletingMetric, metricId, openSuccessMessage, onSetSelectedFile]
);
return (
<Dropdown items={items}>
<Button prefix={<Dots />} variant="ghost" />
</Dropdown>
);
});
ThreeDotMenuButton.displayName = 'ThreeDotMenuButton';

View File

@ -0,0 +1,93 @@
import { useDeleteMetric, useGetMetric } from '@/api/buster_rest/metrics';
import { DropdownItems } from '@/components/ui/dropdown';
import { Trash, Dots, Pencil, Chart, SquareChart, Download4, History } from '@/components/ui/icons';
import { useBusterNotifications } from '@/context/BusterNotifications';
import { useChatLayoutContextSelector } from '@/layouts/ChatLayout/ChatLayoutContext';
import { useMemo } from 'react';
import { Dropdown } from '@/components/ui/dropdown';
import { Button } from '@/components/ui/buttons';
import React from 'react';
import { timeFromNow } from '@/lib/date';
export const ThreeDotMenuButton = React.memo(({ metricId }: { metricId: string }) => {
const { mutateAsync: deleteMetric, isPending: isDeletingMetric } = useDeleteMetric();
const { openSuccessMessage } = useBusterNotifications();
const onSetSelectedFile = useChatLayoutContextSelector((x) => x.onSetSelectedFile);
const { data } = useGetMetric(metricId, (x) => ({
versions: x.versions,
version_number: x.version_number
}));
const { versions = [], version_number } = data || {};
const versionHistoryItems: DropdownItems = useMemo(() => {
return versions.map((x) => ({
label: `Version ${x.version_number}`,
secondaryLabel: timeFromNow(x.updated_at, false),
value: x.version_number.toString(),
selected: x.version_number === version_number
}));
}, [versions, version_number]);
const items: DropdownItems = useMemo(
() => [
{
label: 'Version history',
value: 'version-history',
icon: <History />,
items: versionHistoryItems
},
{ type: 'divider' },
{
label: 'Download as CSV',
value: 'download-csv',
icon: <Download4 />,
onClick: () => {
console.log('download csv');
}
},
{
label: 'Download as PNG',
value: 'download-png',
icon: <SquareChart />,
onClick: () => {
console.log('download png');
}
},
{ type: 'divider' },
{
label: 'Rename metric',
value: 'rename',
icon: <Pencil />,
onClick: () => {
console.log('rename');
}
},
{
label: 'Delete metric',
value: 'delete',
icon: <Trash />,
loading: isDeletingMetric,
onClick: async () => {
await deleteMetric({ ids: [metricId] });
openSuccessMessage('Metric deleted');
onSetSelectedFile(null);
}
}
],
[
deleteMetric,
isDeletingMetric,
metricId,
openSuccessMessage,
onSetSelectedFile,
versionHistoryItems
]
);
return (
<Dropdown items={items} side="bottom" align="end">
<Button prefix={<Dots />} variant="ghost" />
</Dropdown>
);
});
ThreeDotMenuButton.displayName = 'ThreeDotMenuButton';

View File

@ -0,0 +1 @@
export * from './MetricContainerHeaderButtons';

View File

@ -107,6 +107,7 @@ const extractDateForFormatting = (
if (isDate(date)) return new Date(date);
return String(date);
};
export const formatDate = ({
date,
format,