diff --git a/web/src/api/buster_rest/collections/queryRequests.ts b/web/src/api/buster_rest/collections/queryRequests.ts index 9266383b0..605ee8aeb 100644 --- a/web/src/api/buster_rest/collections/queryRequests.ts +++ b/web/src/api/buster_rest/collections/queryRequests.ts @@ -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( + 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 }); } }); diff --git a/web/src/components/features/modal/AddToCollectionModal.tsx b/web/src/components/features/modal/AddToCollectionModal.tsx new file mode 100644 index 000000000..1231f78e0 --- /dev/null +++ b/web/src/components/features/modal/AddToCollectionModal.tsx @@ -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([]); + + 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 ( +
+ + + + {name} +
+ ); + } + }, + { + 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>( + (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 = useMemo(() => { + return { + left: + selectedAssets.length > 0 ? ( + + ) : 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 ( + + ); +}); diff --git a/web/src/components/features/modal/AddToDashboardModal.tsx b/web/src/components/features/modal/AddToDashboardModal.tsx index 4fc17a53a..58d8b8504 100644 --- a/web/src/components/features/modal/AddToDashboardModal.tsx +++ b/web/src/components/features/modal/AddToDashboardModal.tsx @@ -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; diff --git a/web/src/components/features/modal/AddTypeModal.tsx b/web/src/components/features/modal/AddTypeModal.tsx deleted file mode 100644 index 536b9d0e9..000000000 --- a/web/src/components/features/modal/AddTypeModal.tsx +++ /dev/null @@ -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(filterOptions[0]!.value); - const [inputValue, setInputValue] = React.useState(''); - const [ongoingSearchItems, setOngoingSearchItems] = React.useState([]); - const [initialSearchItems, setInitialSearchItems] = React.useState([]); - const [loadedInitialSearchItems, setLoadedInitialSearchItems] = React.useState(false); - const [selectedItemIds, setSelectedItemIds] = React.useState>({}); - const [submitting, setSubmitting] = React.useState(false); - const inputRef = useRef(null); - const scrollContainerRef = useRef(null); - const allSeenItems = useRef>({}); - - 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 = ; - const boldedText = boldHighlights(fallbackName(name, type), highlights); - - return ( -
-
{icon}
- {boldedText} -
- ); - } - }, - { - 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>((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 - >((acc, metric) => { - acc[metric.id] = true; - return acc; - }, {}); - setSelectedItemIds(objectMetrics); - } else if (collection) { - const objectMetrics = (collection.assets || []).reduce>( - (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) => { - 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 ( - - - - - - ); -}); - -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) => void; - inputRef: React.RefObject; - scrollContainerRef: React.RefObject; - 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' && ( -
- - -
- )} - - - -
- -
- - {} - -
- - No metrics or dashboards found -
- ) : ( -
- -
- ) - } - /> - - - ); - } -); - -ModalContent.displayName = 'ModalContent'; -const ModalFooter: React.FC<{ - onOk: () => void; - onCancel: () => void; - type: 'collection' | 'dashboard'; - submitting: boolean; - selectedItemIds: Record; - 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 ( -
- {copyText} - -
- - -
-
- ); -}); - -ModalFooter.displayName = 'ModalFooter'; diff --git a/web/src/components/features/modal/Test.tsx b/web/src/components/features/modal/Test.tsx deleted file mode 100644 index 0015cd5e4..000000000 --- a/web/src/components/features/modal/Test.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import React, { useRef } from 'react'; - -export const Test = () => { - const ref = useRef(null); - return
Test
; -}; diff --git a/web/src/controllers/CollectionIndividualController/CollectionIndividualContent.tsx b/web/src/controllers/CollectionIndividualController/CollectionIndividualContent.tsx index 465f0d818..206db602a 100644 --- a/web/src/controllers/CollectionIndividualController/CollectionIndividualContent.tsx +++ b/web/src/controllers/CollectionIndividualController/CollectionIndividualContent.tsx @@ -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} /> - );