Add to collection dropdown

This commit is contained in:
Nate Kelley 2025-03-24 13:25:08 -06:00
parent 4c474de977
commit 6be73bcf50
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
6 changed files with 250 additions and 477 deletions

View File

@ -32,13 +32,20 @@ export const useGetCollectionsList = (
});
};
export const useGetCollection = (collectionId: string | undefined) => {
const useFetchCollection = () => {
const getAssetPassword = useBusterAssetsContextSelector((state) => state.getAssetPassword);
const { password } = getAssetPassword(collectionId!);
return useMemoizedFn(async (collectionId: string) => {
const { password } = getAssetPassword(collectionId!);
return collectionsGetCollection({ id: collectionId!, password });
});
};
export const useGetCollection = (collectionId: string | undefined) => {
const fetchCollection = useFetchCollection();
return useQuery({
...collectionQueryKeys.collectionsGetCollection(collectionId!),
queryFn: () => collectionsGetCollection({ id: collectionId!, password }),
queryFn: () => fetchCollection(collectionId!),
enabled: !!collectionId
});
};
@ -185,19 +192,21 @@ export const useUpdateCollectionShare = () => {
});
};
export const useAddAssetToCollection = () => {
export const useAddAssetToCollection = (useInvalidate = true) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addAssetToCollection,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: collectionQueryKeys.collectionsGetCollection(variables.id).queryKey
});
if (useInvalidate) {
queryClient.invalidateQueries({
queryKey: collectionQueryKeys.collectionsGetCollection(variables.id).queryKey
});
}
}
});
};
export const useRemoveAssetFromCollection = () => {
export const useRemoveAssetFromCollection = (useInvalidate = true) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: removeAssetFromCollection,
@ -208,13 +217,79 @@ export const useRemoveAssetFromCollection = () => {
queryClient.setQueryData(queryKey, (previousData) => {
const ids = variables.assets.map((a) => a.id);
return create(previousData!, (draft) => {
draft.assets = draft.assets!.filter((a) => !ids.includes(a.id));
draft.assets = draft.assets?.filter((a) => !ids.includes(a.id)) || [];
});
});
},
onSuccess: (_, variables) => {
if (useInvalidate) {
queryClient.invalidateQueries({
queryKey: collectionQueryKeys.collectionsGetCollection(variables.id).queryKey
});
}
}
});
};
export const useAddAndRemoveAssetsFromCollection = () => {
const queryClient = useQueryClient();
const fetchCollection = useFetchCollection();
const { mutateAsync: addAssetToCollection } = useAddAssetToCollection(false);
const { mutateAsync: removeAssetFromCollection } = useRemoveAssetFromCollection(false);
const addAndRemoveAssetsToCollection = useMemoizedFn(
async (variables: {
collectionId: string;
assets: {
type: 'metric' | 'dashboard';
id: string;
}[];
}) => {
let currentCollection = queryClient.getQueryData<BusterCollection>(
collectionQueryKeys.collectionsGetCollection(variables.collectionId).queryKey
);
if (!currentCollection) {
currentCollection = await fetchCollection(variables.collectionId);
queryClient.setQueryData(
collectionQueryKeys.collectionsGetCollection(variables.collectionId).queryKey,
currentCollection
);
}
if (!currentCollection) throw new Error('Collection not found');
const removedAssets =
currentCollection.assets
?.filter((a) => !variables.assets.some((b) => b.id === a.id))
.map((a) => ({
type: a.asset_type as 'metric' | 'dashboard',
id: a.id
})) || [];
const addedAssets = variables.assets.filter(
(a) => !currentCollection.assets?.some((b) => b.id === a.id)
);
await Promise.all([
addedAssets.length > 0 &&
addAssetToCollection({
id: variables.collectionId,
assets: addedAssets
}),
removedAssets.length > 0 &&
removeAssetFromCollection({
id: variables.collectionId,
assets: removedAssets
})
]);
}
);
return useMutation({
mutationFn: addAndRemoveAssetsToCollection,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: collectionQueryKeys.collectionsGetCollection(variables.id).queryKey
queryKey: collectionQueryKeys.collectionsGetCollection(variables.collectionId).queryKey
});
}
});

View File

@ -0,0 +1,161 @@
import { useGetMetricsList } from '@/api/buster_rest/metrics';
import { useMemoizedFn } from '@/hooks';
import React, { useLayoutEffect, useMemo, useState } from 'react';
import { InputSelectModal, InputSelectModalProps } from '@/components/ui/modal/InputSelectModal';
import { formatDate } from '@/lib';
import { Button } from '@/components/ui/buttons';
import { useGetDashboardsList } from '@/api/buster_rest/dashboards';
import {
useAddAndRemoveAssetsFromCollection,
useGetCollection
} from '@/api/buster_rest/collections';
import { Text } from '@/components/ui/typography';
import { ASSET_ICONS } from '../config/assetIcons';
import pluralize from 'pluralize';
export const AddToCollectionModal: React.FC<{
open: boolean;
onClose: () => void;
collectionId: string;
}> = React.memo(({ open, onClose, collectionId }) => {
const { data: collection, isFetched: isFetchedCollection } = useGetCollection(collectionId);
const { data: metrics, isFetched: isFetchedMetrics } = useGetMetricsList({});
const { data: dashboards, isFetched: isFetchedDashboards } = useGetDashboardsList({});
const { mutateAsync: addAndRemoveAssetsFromCollection } = useAddAndRemoveAssetsFromCollection();
const [selectedAssets, setSelectedAssets] = useState<string[]>([]);
const columns: InputSelectModalProps['columns'] = [
{
title: 'Name',
dataIndex: 'name',
render: (name, data: { type: 'metric' | 'dashboard' }) => {
const Icon = data.type === 'metric' ? ASSET_ICONS.metrics : ASSET_ICONS.dashboards;
return (
<div className="flex items-center gap-1.5">
<span className="text-icon-color">
<Icon />
</span>
<Text>{name}</Text>
</div>
);
}
},
{
title: 'Last edited',
dataIndex: 'last_edited',
width: 132,
render: (value: string, x) => {
return formatDate({
date: value,
format: 'lll'
});
}
}
];
const rows = useMemo(() => {
return [
...(metrics?.map<{
id: string;
data: { name: string; type: 'metric'; last_edited: string };
}>((metric) => ({
id: metric.id,
data: { name: metric.name, type: 'metric', last_edited: metric.last_edited }
})) || []),
...(dashboards?.map<{
id: string;
data: { name: string; type: 'dashboard'; last_edited: string };
}>((dashboard) => ({
id: dashboard.id,
data: { name: dashboard.name, type: 'dashboard', last_edited: dashboard.last_edited }
})) || [])
];
}, [metrics, dashboards]);
const handleAddAndRemoveMetrics = useMemoizedFn(async () => {
const keyedAssets = rows.reduce<Record<string, { type: 'metric' | 'dashboard'; id: string }>>(
(acc, asset) => {
acc[asset.id] = { type: asset.data.type, id: asset.id };
return acc;
},
{}
);
const assets = selectedAssets.map<{ type: 'metric' | 'dashboard'; id: string }>((asset) => ({
id: asset,
type: keyedAssets[asset].type
}));
await addAndRemoveAssetsFromCollection({
collectionId,
assets
});
onClose();
});
const originalIds = useMemo(() => {
return collection?.assets?.map((asset) => asset.id) || [];
}, [collection?.assets]);
const isSelectedChanged = useMemo(() => {
const newIds = selectedAssets;
return originalIds.length !== newIds.length || originalIds.some((id) => !newIds.includes(id));
}, [originalIds, selectedAssets]);
const emptyState = useMemo(() => {
if (!isFetchedMetrics || !isFetchedDashboards) {
return 'Loading assets...';
}
if (rows.length === 0) {
return 'No assets found';
}
return undefined;
}, [isFetchedMetrics, isFetchedDashboards, rows]);
const footer: NonNullable<InputSelectModalProps['footer']> = useMemo(() => {
return {
left:
selectedAssets.length > 0 ? (
<Button variant="ghost" onClick={() => setSelectedAssets([])}>
Clear selected
</Button>
) : undefined,
secondaryButton: {
text: 'Cancel',
onClick: onClose
},
primaryButton: {
text:
selectedAssets.length === 0
? 'Update collection'
: `Add ${selectedAssets.length} ${pluralize('asset', selectedAssets.length)} to collection`,
onClick: handleAddAndRemoveMetrics,
disabled: !isSelectedChanged,
tooltip: isSelectedChanged
? `Adding ${selectedAssets.length} assets`
: 'No changes to update'
}
};
}, [selectedAssets.length, isSelectedChanged, handleAddAndRemoveMetrics]);
useLayoutEffect(() => {
if (isFetchedCollection) {
const assets = collection?.assets?.map((asset) => asset.id) || [];
setSelectedAssets(assets);
}
}, [isFetchedCollection, collection?.assets]);
return (
<InputSelectModal
width={665}
open={open}
onClose={onClose}
columns={columns}
rows={rows}
onSelectChange={setSelectedAssets}
selectedRowKeys={selectedAssets}
footer={footer}
emptyState={emptyState}
/>
);
});

View File

@ -4,11 +4,7 @@ import React, { useLayoutEffect, useMemo, useState } from 'react';
import { InputSelectModal, InputSelectModalProps } from '@/components/ui/modal/InputSelectModal';
import { formatDate } from '@/lib';
import { Button } from '@/components/ui/buttons';
import {
useAddAndRemoveMetricsFromDashboard,
useAddMetricsToDashboard,
useGetDashboard
} from '@/api/buster_rest/dashboards';
import { useAddAndRemoveMetricsFromDashboard, useGetDashboard } from '@/api/buster_rest/dashboards';
export const AddToDashboardModal: React.FC<{
open: boolean;

View File

@ -1,452 +0,0 @@
import React, { useLayoutEffect, useMemo, useRef } from 'react';
import { Input } from '@/components/ui/inputs';
import { AppSegmented } from '@/components/ui/segmented';
import { Text } from '@/components/ui/typography';
import { BusterList, BusterListColumn, BusterListRow } from '@/components/ui/list';
import { useMemoizedFn, useThrottleFn } from '@/hooks';
import { boldHighlights, formatDate } from '@/lib';
import {
type BusterDashboardResponse,
type BusterSearchResult,
ShareAssetType
} from '@/api/asset_interfaces';
import { CircleSpinnerLoaderContainer } from '@/components/ui/loaders';
import { BusterCollection } from '@/api/asset_interfaces';
import isEmpty from 'lodash/isEmpty';
import { type SegmentedItem } from '@/components/ui/segmented';
import { Speaker, Xmark } from '@/components/ui/icons';
import { AppModal } from '@/components/ui/modal';
import { Button } from '@/components/ui/buttons';
import { Separator } from '@/components/ui/seperator';
import { SearchParams } from '@/api/request_interfaces/search/interfaces';
import { useUpdateCollection } from '@/api/buster_rest/collections';
import { useUpdateDashboard } from '@/api/buster_rest/dashboards';
const filterOptions = [
{ label: 'All', value: 'all' },
{ label: 'Metrics', value: 'metrics' },
{ label: 'Dashboards', value: 'dashboards' }
];
export const AddTypeModal: React.FC<{
open: boolean;
onClose: () => void;
type?: 'collection' | 'dashboard';
dashboardResponse?: BusterDashboardResponse;
collection?: BusterCollection;
}> = React.memo(({ type = 'collection', open, onClose, collection, dashboardResponse }) => {
const { mutateAsync: updateCollection } = useUpdateCollection();
const { mutateAsync: updateDashboard } = useUpdateDashboard();
const [selectedFilter, setSelectedFilter] = React.useState<string>(filterOptions[0]!.value);
const [inputValue, setInputValue] = React.useState<string>('');
const [ongoingSearchItems, setOngoingSearchItems] = React.useState<BusterSearchResult[]>([]);
const [initialSearchItems, setInitialSearchItems] = React.useState<BusterSearchResult[]>([]);
const [loadedInitialSearchItems, setLoadedInitialSearchItems] = React.useState(false);
const [selectedItemIds, setSelectedItemIds] = React.useState<Record<string, boolean>>({});
const [submitting, setSubmitting] = React.useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const allSeenItems = useRef<Record<string, BusterSearchResult>>({});
const mountedInitial = useRef(false);
const dashboard = dashboardResponse?.dashboard;
const searchItems = inputValue ? ongoingSearchItems : ongoingSearchItems;
const columns: BusterListColumn[] = useMemo(() => {
const fallbackName = (name: string, type: ShareAssetType) => {
if (type === ShareAssetType.DASHBOARD && !name) {
return 'New dashboard';
}
if (type === ShareAssetType.METRIC && !name) {
return 'New metric';
}
return name;
};
return [
{
title: 'Name',
dataIndex: 'name',
render: (_, record: BusterSearchResult) => {
const { name, type, highlights } = record;
const icon = <Speaker />;
const boldedText = boldHighlights(fallbackName(name, type), highlights);
return (
<div className="flex items-center space-x-2">
<div className="flex items-center">{icon}</div>
<Text>{boldedText}</Text>
</div>
);
}
},
{
title: 'Updated at',
dataIndex: 'updated_at',
width: 125,
render: (data) => formatDate({ date: data, format: 'lll' })
}
];
}, []);
const rows: BusterListRow[] = useMemo(() => {
return searchItems.map((item) => ({
id: item.id,
data: item,
onClick: () => {
setSelectedItemIds((prev) => ({ ...prev, [item.id]: !prev[item.id] }));
}
}));
}, [searchItems]);
const selectedItemIdsKeys = useMemo(() => {
return Object.entries(selectedItemIds)
.filter(([, value]) => value)
.map(([key]) => key);
}, [selectedItemIds]);
const onSelectChange = useMemoizedFn((selectedRowKeys: string[]) => {
const updatedSelectedItemIds = selectedRowKeys.reduce<Record<string, boolean>>((acc, curr) => {
acc[curr] = true;
return acc;
}, {});
setSelectedItemIds({ ...updatedSelectedItemIds });
});
const addToAllSeenItems = useMemoizedFn((results: BusterSearchResult[]) => {
results.forEach((item) => {
allSeenItems.current[item.id] = item;
});
});
const onSetSelectedFilter = useMemoizedFn(async (value: string) => {
setSelectedFilter(value);
let results: BusterSearchResult[] = [];
if (value === 'metrics') {
results = await onSearchInput.run(inputValue, 'useMetrics');
} else if (value === 'dashboards') {
results = await onSearchInput.run(inputValue, 'useDashboards');
} else {
results = await onSearchInput.run(inputValue);
}
setInitialSearchItems(results);
setLoadedInitialSearchItems(true);
addToAllSeenItems(results);
});
const onSearchInput = useThrottleFn(
async (query: string, params?: 'useMetrics' | 'useDashboards') => {
let results: BusterSearchResult[] = [];
let include: (keyof SearchParams)[] = [];
if (type === 'collection') {
include = ['exclude_dashboards', 'exclude_metrics'];
if (params === 'useMetrics') {
include = ['exclude_metrics'];
} else if (params === 'useDashboards') {
include = ['exclude_dashboards'];
}
results = await onBusterSearch({
query
});
} else if (type === 'dashboard') {
include = ['exclude_metrics'];
results = await onBusterSearch({
query
});
}
if (isEmpty(initialSearchItems) && !query) {
setInitialSearchItems(results);
addToAllSeenItems(results);
}
setLoadedInitialSearchItems(true);
setOngoingSearchItems(results);
addToAllSeenItems(results);
return results;
},
{ wait: 400 }
);
const initSelectedItems = useMemoizedFn(() => {
if (dashboardResponse) {
const objectMetrics = Object.values(dashboardResponse.metrics).reduce<
Record<string, boolean>
>((acc, metric) => {
acc[metric.id] = true;
return acc;
}, {});
setSelectedItemIds(objectMetrics);
} else if (collection) {
const objectMetrics = (collection.assets || []).reduce<Record<string, boolean>>(
(acc, asset) => {
acc[asset.id] = true;
return acc;
},
{}
);
setSelectedItemIds(objectMetrics);
}
});
const onSubmit = useMemoizedFn(async () => {
setSubmitting(true);
const selectedIds = Object.entries(selectedItemIds)
.filter(([, value]) => value)
.map(([key]) => key);
if (type === 'collection') {
const assets = selectedIds.map((id) => {
const type = allSeenItems.current[id]?.type;
return {
type,
id
};
});
await updateCollection({
id: collection!.id,
assets
});
} else if (type === 'dashboard') {
console.log('TODO: add metrics to dashboard', dashboard!.id, selectedIds);
await updateDashboard({
id: dashboard!.id
// metrics: selectedIds
});
}
setSubmitting(false);
return;
});
const onModalOkay = useMemoizedFn(async () => {
await onSubmit();
onClose();
});
const onBusterSearch = useMemoizedFn(async ({ query }: { query: string }) => {
return [];
});
const onChangeSearchInput = useMemoizedFn((value: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(value.target.value);
onSearchInput.run(value.target.value);
});
useLayoutEffect(() => {
if (open) {
setInputValue('');
initSelectedItems();
setTimeout(() => {
onSetSelectedFilter(filterOptions[0]!.value);
}, 20);
setTimeout(() => {
inputRef.current?.focus();
}, 150);
}
if (!mountedInitial.current) {
onSearchInput.run('');
mountedInitial.current = true;
}
}, [open]);
return (
<AppModal
open={open}
onClose={onClose}
width={800}
header={{
title: 'Add Metrics & Dashboards'
}}
footer={{
primaryButton: {
text: 'Apply',
onClick: onModalOkay
}
}}>
<ModalContent
type={type}
dashboardResponse={dashboardResponse}
collection={collection}
selectedFilter={selectedFilter}
onSetSelectedFilter={onSetSelectedFilter}
inputValue={inputValue}
onChangeSearchInput={onChangeSearchInput}
inputRef={inputRef}
scrollContainerRef={scrollContainerRef}
rows={rows}
columns={columns}
loadedInitialSearchItems={loadedInitialSearchItems}
selectedItemIdsKeys={selectedItemIdsKeys}
onSelectChange={onSelectChange}
onClose={onClose}
/>
<ModalFooter
type={type}
onOk={onModalOkay}
selectedItemIds={selectedItemIds}
submitting={submitting}
onCancel={onClose}
dashboardResponse={dashboardResponse}
collection={collection}
/>
</AppModal>
);
});
AddTypeModal.displayName = 'AddTypeModal';
const ModalContent: React.FC<{
type: 'collection' | 'dashboard';
dashboardResponse?: BusterDashboardResponse;
collection?: BusterCollection;
selectedFilter: string;
onSetSelectedFilter: (value: string) => void;
inputValue: string;
onChangeSearchInput: (value: React.ChangeEvent<HTMLInputElement>) => void;
inputRef: React.RefObject<HTMLInputElement>;
scrollContainerRef: React.RefObject<HTMLDivElement>;
rows: BusterListRow[];
columns: BusterListColumn[];
loadedInitialSearchItems: boolean;
selectedItemIdsKeys: string[];
onSelectChange: (selectedRowKeys: string[]) => void;
onClose: () => void;
}> = React.memo(
({
type,
selectedFilter,
onSetSelectedFilter,
inputValue,
onChangeSearchInput,
inputRef,
scrollContainerRef,
rows,
columns,
loadedInitialSearchItems,
selectedItemIdsKeys,
onSelectChange,
onClose
}) => {
const onSetSelectedFiltersPreflight = useMemoizedFn((value: SegmentedItem) => {
onSetSelectedFilter(value.value as string);
});
const placeholder =
type === 'collection'
? 'Search for existing metrics and dashboards...'
: 'Search for existing metrics...';
return (
<>
{type === 'collection' && (
<div className="flex items-center justify-between px-4 py-2">
<AppSegmented
options={filterOptions}
value={selectedFilter}
onChange={onSetSelectedFiltersPreflight}
/>
<Button variant="ghost" onClick={onClose} prefix={<Xmark />} />
</div>
)}
<Separator className="my-0!" />
<div className="flex h-[48px] items-center space-x-2">
<Input
ref={inputRef}
size="tall"
className=""
variant="ghost"
placeholder={placeholder}
value={inputValue}
onChange={onChangeSearchInput}
/>
</div>
{<Separator />}
<div
className="max-h-[57vh] overflow-auto"
ref={scrollContainerRef}
style={{
height: rows.length === 0 ? 150 : rows.length * 48 + 32
}}>
<BusterList
selectedRowKeys={selectedItemIdsKeys}
onSelectChange={onSelectChange}
rows={rows}
showSelectAll={false}
columns={columns}
emptyState={
loadedInitialSearchItems ? (
<div className="flex h-[200px] min-h-[200px] items-center justify-center">
<Text variant="tertiary">No metrics or dashboards found</Text>
</div>
) : (
<div className="min-h-[200px flex h-[200px] items-center justify-center">
<CircleSpinnerLoaderContainer />
</div>
)
}
/>
</div>
</>
);
}
);
ModalContent.displayName = 'ModalContent';
const ModalFooter: React.FC<{
onOk: () => void;
onCancel: () => void;
type: 'collection' | 'dashboard';
submitting: boolean;
selectedItemIds: Record<string, boolean>;
dashboardResponse?: BusterDashboardResponse;
collection?: BusterCollection;
}> = React.memo(({ dashboardResponse, submitting, selectedItemIds, type, onOk, onCancel }) => {
const copyText =
type === 'collection'
? `Select the metrics & dashboards that you would like to add to your collection.`
: '';
const disabled = useMemo(() => {
if (isEmpty(selectedItemIds)) return true;
if (dashboardResponse) {
const metricIds = Object.values(dashboardResponse.metrics).map((metric) => metric.id);
const allSelectedIds = Object.entries(selectedItemIds)
.filter(([, value]) => value)
.map(([key]) => key);
if (metricIds.length !== allSelectedIds.length) return false;
const allAreSelected = metricIds.every((id) => selectedItemIds[id]);
return allAreSelected;
}
return false;
}, [selectedItemIds, dashboardResponse]);
return (
<div className="flex w-full items-center justify-between">
<Text variant="secondary">{copyText}</Text>
<div className="flex space-x-2">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button disabled={disabled} loading={submitting} variant="primary" onClick={onOk}>
Apply
</Button>
</div>
</div>
);
});
ModalFooter.displayName = 'ModalFooter';

View File

@ -1,6 +0,0 @@
import React, { useRef } from 'react';
export const Test = () => {
const ref = useRef<HTMLDivElement>(null);
return <div ref={ref}>Test</div>;
};

View File

@ -12,13 +12,13 @@ import {
} from '@/api/asset_interfaces';
import { Text } from '@/components/ui/typography';
import { ListEmptyStateWithButton } from '@/components/ui/list';
import { AddTypeModal } from '@/components/features/modal/AddTypeModal';
import { ShareAssetType } from '@/api/asset_interfaces';
import { useMemoizedFn } from '@/hooks';
import { BusterList, BusterListColumn, BusterListRow } from '@/components/ui/list';
import { CollectionIndividualSelectedPopup } from './CollectionsIndividualPopup';
import { ASSET_ICONS } from '@/components/features/config/assetIcons';
import { useUpdateCollection } from '@/api/buster_rest/collections';
import { AddToCollectionModal } from '@/components/features/modal/AddToCollectionModal';
export const CollectionIndividualContent: React.FC<{
collection: BusterCollection | undefined;
@ -47,11 +47,10 @@ export const CollectionIndividualContent: React.FC<{
loadedAsset={loadedAsset}
/>
<AddTypeModal
<AddToCollectionModal
open={openAddTypeModal}
onClose={onCloseModal}
type="collection"
collection={collection}
collectionId={collection.id}
/>
</>
);