mirror of
https://github.com/datahub-project/datahub.git
synced 2025-09-25 17:15:09 +00:00
feat(customHomePage/assetCollection): add dynamic filters (#14435)
Co-authored-by: Chris Collins <chriscollins3456@gmail.com>
This commit is contained in:
parent
b3fafc38be
commit
d9dda1b059
@ -122,6 +122,12 @@ public class UpsertPageModuleResolver implements DataFetcher<CompletableFuture<D
|
||||
UrnArray urnArray = new UrnArray(urns);
|
||||
|
||||
assetCollectionParams.setAssetUrns(urnArray);
|
||||
|
||||
if (paramsInput.getAssetCollectionParams().getDynamicFilterJson() != null) {
|
||||
assetCollectionParams.setDynamicFilterJson(
|
||||
paramsInput.getAssetCollectionParams().getDynamicFilterJson());
|
||||
}
|
||||
|
||||
gmsParams.setAssetCollectionParams(assetCollectionParams);
|
||||
}
|
||||
|
||||
|
@ -68,6 +68,12 @@ public class PageModuleParamsMapper
|
||||
.collect(Collectors.toList());
|
||||
|
||||
assetCollectionParams.setAssetUrns(assetUrnStrings);
|
||||
|
||||
if (params.getAssetCollectionParams().getDynamicFilterJson() != null) {
|
||||
assetCollectionParams.setDynamicFilterJson(
|
||||
params.getAssetCollectionParams().getDynamicFilterJson());
|
||||
}
|
||||
|
||||
result.setAssetCollectionParams(assetCollectionParams);
|
||||
}
|
||||
|
||||
|
@ -133,6 +133,15 @@ input AssetCollectionModuleParamsInput {
|
||||
The list of asset urns for the asset collection module
|
||||
"""
|
||||
assetUrns: [String!]!
|
||||
|
||||
"""
|
||||
Optional dynamic filters
|
||||
|
||||
The stringified json representing the logical predicate built in the UI to select assets.
|
||||
This predicate is turned into orFilters to send through graphql since graphql doesn't support
|
||||
arbitrary nesting. This string is used to restore the UI for this logical predicate.
|
||||
"""
|
||||
dynamicFilterJson: String
|
||||
}
|
||||
|
||||
"""
|
||||
@ -311,6 +320,15 @@ type AssetCollectionModuleParams {
|
||||
The list of asset urns for the asset collection module
|
||||
"""
|
||||
assetUrns: [String!]!
|
||||
|
||||
"""
|
||||
Optional dynamic filter
|
||||
|
||||
The stringified json representing the logical predicate built in the UI to select assets.
|
||||
This predicate is turned into orFilters to send through graphql since graphql doesn't support
|
||||
arbitrary nesting. This string is used to restore the UI for this logical predicate.
|
||||
"""
|
||||
dynamicFilterJson: String
|
||||
}
|
||||
|
||||
"""
|
||||
|
@ -1,11 +1,14 @@
|
||||
import { Form } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
|
||||
import BaseModuleModal from '@app/homeV3/moduleModals/common/BaseModuleModal';
|
||||
import ModuleDetailsForm from '@app/homeV3/moduleModals/common/ModuleDetailsForm';
|
||||
import AssetsSection from '@app/homeV3/modules/assetCollection/AssetsSection';
|
||||
import { SELECT_ASSET_TYPE_DYNAMIC, SELECT_ASSET_TYPE_MANUAL } from '@app/homeV3/modules/assetCollection/constants';
|
||||
import { LogicalPredicate } from '@app/sharedV2/queryBuilder/builder/types';
|
||||
import { isEmptyLogicalPredicate } from '@app/sharedV2/queryBuilder/builder/utils';
|
||||
|
||||
import { DataHubPageModuleType } from '@types';
|
||||
|
||||
@ -26,15 +29,66 @@ const AssetCollectionModal = () => {
|
||||
(urn): urn is string => typeof urn === 'string',
|
||||
);
|
||||
const urn = initialState?.urn;
|
||||
|
||||
const currentDynamicFilterLogicalPredicate: LogicalPredicate | undefined = useMemo(
|
||||
() =>
|
||||
initialState?.properties?.params?.assetCollectionParams?.dynamicFilterJson
|
||||
? JSON.parse(initialState.properties.params.assetCollectionParams.dynamicFilterJson)
|
||||
: undefined,
|
||||
[initialState?.properties?.params?.assetCollectionParams?.dynamicFilterJson],
|
||||
);
|
||||
|
||||
const currentSelectAssetType = useMemo(() => {
|
||||
if (currentAssets.length === 0 && currentDynamicFilterLogicalPredicate) {
|
||||
return SELECT_ASSET_TYPE_DYNAMIC;
|
||||
}
|
||||
return SELECT_ASSET_TYPE_MANUAL;
|
||||
}, [currentDynamicFilterLogicalPredicate, currentAssets]);
|
||||
|
||||
const [selectAssetType, setSelectAssetType] = useState<string>(currentSelectAssetType);
|
||||
|
||||
const [dynamicFilter, setDynamicFilter] = useState<LogicalPredicate | null | undefined>(
|
||||
currentDynamicFilterLogicalPredicate,
|
||||
);
|
||||
|
||||
const [selectedAssetUrns, setSelectedAssetUrns] = useState<string[]>(currentAssets);
|
||||
|
||||
const nameValue = Form.useWatch('name', form);
|
||||
|
||||
const isDisabled = !nameValue?.trim() || !selectedAssetUrns.length;
|
||||
const isDisabled = useMemo(() => {
|
||||
if (!nameValue?.trim()) return true;
|
||||
|
||||
switch (selectAssetType) {
|
||||
case SELECT_ASSET_TYPE_MANUAL:
|
||||
return !selectedAssetUrns.length;
|
||||
case SELECT_ASSET_TYPE_DYNAMIC:
|
||||
return isEmptyLogicalPredicate(dynamicFilter);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}, [nameValue, selectAssetType, selectedAssetUrns, dynamicFilter]);
|
||||
|
||||
const handleUpsertAssetCollectionModule = () => {
|
||||
const getAssetCollectionParams = () => {
|
||||
switch (selectAssetType) {
|
||||
case SELECT_ASSET_TYPE_MANUAL:
|
||||
return {
|
||||
assetUrns: selectedAssetUrns,
|
||||
dynamicFilterJson: undefined,
|
||||
};
|
||||
case SELECT_ASSET_TYPE_DYNAMIC:
|
||||
return {
|
||||
assetUrns: [],
|
||||
dynamicFilterJson: JSON.stringify(dynamicFilter),
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
form.validateFields().then((values) => {
|
||||
const { name } = values;
|
||||
|
||||
upsertModule({
|
||||
urn,
|
||||
name,
|
||||
@ -42,9 +96,7 @@ const AssetCollectionModal = () => {
|
||||
// scope: initialState?.properties.visibility.scope || undefined,
|
||||
type: DataHubPageModuleType.AssetCollection,
|
||||
params: {
|
||||
assetCollectionParams: {
|
||||
assetUrns: selectedAssetUrns,
|
||||
},
|
||||
assetCollectionParams: getAssetCollectionParams(),
|
||||
},
|
||||
});
|
||||
close();
|
||||
@ -61,7 +113,14 @@ const AssetCollectionModal = () => {
|
||||
>
|
||||
<ModalContent>
|
||||
<ModuleDetailsForm form={form} formValues={{ name: currentName }} />
|
||||
<AssetsSection selectedAssetUrns={selectedAssetUrns} setSelectedAssetUrns={setSelectedAssetUrns} />
|
||||
<AssetsSection
|
||||
selectAssetType={selectAssetType}
|
||||
setSelectAssetType={setSelectAssetType}
|
||||
selectedAssetUrns={selectedAssetUrns}
|
||||
setSelectedAssetUrns={setSelectedAssetUrns}
|
||||
dynamicFilter={dynamicFilter}
|
||||
setDynamicFilter={setDynamicFilter}
|
||||
/>
|
||||
</ModalContent>
|
||||
</BaseModuleModal>
|
||||
);
|
||||
|
@ -5,6 +5,9 @@ import EmptyContent from '@app/homeV3/module/components/EmptyContent';
|
||||
import EntityItem from '@app/homeV3/module/components/EntityItem';
|
||||
import LargeModule from '@app/homeV3/module/components/LargeModule';
|
||||
import { ModuleProps } from '@app/homeV3/module/types';
|
||||
import { excludeEmptyAndFilters } from '@app/searchV2/utils/filterUtils';
|
||||
import { LogicalPredicate } from '@app/sharedV2/queryBuilder/builder/types';
|
||||
import { convertLogicalPredicateToOrFilters } from '@app/sharedV2/queryBuilder/builder/utils';
|
||||
|
||||
import { useGetSearchResultsForMultipleQuery } from '@graphql/search.generated';
|
||||
import { DataHubPageModuleType, Entity } from '@types';
|
||||
@ -20,19 +23,72 @@ const AssetCollectionModule = (props: ModuleProps) => {
|
||||
[props.module.properties.params.assetCollectionParams?.assetUrns],
|
||||
);
|
||||
|
||||
const dynamicFilterLogicalPredicate: LogicalPredicate | undefined = useMemo(
|
||||
() =>
|
||||
props.module.properties.params.assetCollectionParams?.dynamicFilterJson
|
||||
? JSON.parse(props.module.properties.params.assetCollectionParams?.dynamicFilterJson)
|
||||
: undefined,
|
||||
[props.module.properties.params.assetCollectionParams?.dynamicFilterJson],
|
||||
);
|
||||
|
||||
const shouldFetchByDynamicFilter = useMemo(
|
||||
() => assetUrns.length === 0 && !!dynamicFilterLogicalPredicate,
|
||||
[assetUrns, dynamicFilterLogicalPredicate],
|
||||
);
|
||||
|
||||
const dynamicOrFilters = useMemo(() => {
|
||||
if (dynamicFilterLogicalPredicate) {
|
||||
const orFilters = excludeEmptyAndFilters(convertLogicalPredicateToOrFilters(dynamicFilterLogicalPredicate));
|
||||
return orFilters;
|
||||
}
|
||||
return undefined;
|
||||
}, [dynamicFilterLogicalPredicate]);
|
||||
|
||||
const totalForInfiniteScroll = useMemo(
|
||||
() => (shouldFetchByDynamicFilter ? undefined : assetUrns.length),
|
||||
[shouldFetchByDynamicFilter, assetUrns],
|
||||
);
|
||||
|
||||
const { loading, refetch } = useGetSearchResultsForMultipleQuery({
|
||||
variables: {
|
||||
input: {
|
||||
start: 0,
|
||||
count: DEFAULT_PAGE_SIZE,
|
||||
query: '*',
|
||||
filters: [{ field: 'urn', values: assetUrns }],
|
||||
...(shouldFetchByDynamicFilter
|
||||
? { orFilters: dynamicOrFilters }
|
||||
: {
|
||||
filters: [{ field: 'urn', values: assetUrns }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
skip: assetUrns.length === 0,
|
||||
skip: assetUrns.length === 0 && !dynamicOrFilters?.length,
|
||||
});
|
||||
|
||||
const fetchEntities = useCallback(
|
||||
const fetchEntitiesByDynamicFilter = useCallback(
|
||||
async (start: number, count: number): Promise<Entity[]> => {
|
||||
if (!dynamicOrFilters?.length) return [];
|
||||
|
||||
const result = await refetch({
|
||||
input: {
|
||||
start,
|
||||
count,
|
||||
query: '*',
|
||||
orFilters: dynamicOrFilters,
|
||||
},
|
||||
});
|
||||
|
||||
const results =
|
||||
result.data?.searchAcrossEntities?.searchResults
|
||||
?.map((res) => res.entity)
|
||||
.filter((entity): entity is Entity => !!entity) || [];
|
||||
|
||||
return results;
|
||||
},
|
||||
[dynamicOrFilters, refetch],
|
||||
);
|
||||
|
||||
const fetchEntitiesByAssetUrns = useCallback(
|
||||
async (start: number, count: number): Promise<Entity[]> => {
|
||||
if (assetUrns.length === 0) return [];
|
||||
// urn slicing is done at the front-end to maintain the order of assets to show with pagination
|
||||
@ -57,6 +113,16 @@ const AssetCollectionModule = (props: ModuleProps) => {
|
||||
[assetUrns, refetch],
|
||||
);
|
||||
|
||||
const fetchEntities = useCallback(
|
||||
async (start: number, count: number): Promise<Entity[]> => {
|
||||
if (shouldFetchByDynamicFilter) {
|
||||
return fetchEntitiesByDynamicFilter(start, count);
|
||||
}
|
||||
return fetchEntitiesByAssetUrns(start, count);
|
||||
},
|
||||
[fetchEntitiesByDynamicFilter, fetchEntitiesByAssetUrns, shouldFetchByDynamicFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<LargeModule {...props} loading={loading}>
|
||||
<InfiniteScrollList<Entity>
|
||||
@ -73,7 +139,7 @@ const AssetCollectionModule = (props: ModuleProps) => {
|
||||
description="Edit the module and add assets to see them in this list"
|
||||
/>
|
||||
}
|
||||
totalItemCount={assetUrns.length}
|
||||
totalItemCount={totalForInfiniteScroll}
|
||||
/>
|
||||
</LargeModule>
|
||||
);
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { colors } from '@components';
|
||||
import { Divider } from 'antd';
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import SelectAssetsSection from '@app/homeV3/modules/assetCollection/SelectAssetsSection';
|
||||
import SelectedAssetsSection from '@app/homeV3/modules/assetCollection/SelectedAssetsSection';
|
||||
import { SELECT_ASSET_TYPE_MANUAL } from '@app/homeV3/modules/assetCollection/constants';
|
||||
import { LogicalPredicate } from '@app/sharedV2/queryBuilder/builder/types';
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
@ -27,26 +29,48 @@ export const VerticalDivider = styled(Divider)`
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
selectAssetType: string;
|
||||
setSelectAssetType: (newSelectAssetType: string) => void;
|
||||
selectedAssetUrns: string[];
|
||||
setSelectedAssetUrns: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
dynamicFilter: LogicalPredicate | null | undefined;
|
||||
setDynamicFilter: (newDynamicFilter: LogicalPredicate | null | undefined) => void;
|
||||
};
|
||||
|
||||
const AssetsSection = ({ selectedAssetUrns, setSelectedAssetUrns }: Props) => {
|
||||
const AssetsSection = ({
|
||||
selectAssetType,
|
||||
setSelectAssetType,
|
||||
selectedAssetUrns,
|
||||
setSelectedAssetUrns,
|
||||
dynamicFilter,
|
||||
setDynamicFilter,
|
||||
}: Props) => {
|
||||
const shouldShowSelectedAssetsSection = useMemo(
|
||||
() => selectAssetType === SELECT_ASSET_TYPE_MANUAL,
|
||||
[selectAssetType],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<LeftSection>
|
||||
<SelectAssetsSection
|
||||
selectAssetType={selectAssetType}
|
||||
setSelectAssetType={setSelectAssetType}
|
||||
selectedAssetUrns={selectedAssetUrns}
|
||||
setSelectedAssetUrns={setSelectedAssetUrns}
|
||||
dynamicFilter={dynamicFilter}
|
||||
setDynamicFilter={setDynamicFilter}
|
||||
/>
|
||||
</LeftSection>
|
||||
<VerticalDivider type="vertical" />
|
||||
<RightSection>
|
||||
<SelectedAssetsSection
|
||||
selectedAssetUrns={selectedAssetUrns}
|
||||
setSelectedAssetUrns={setSelectedAssetUrns}
|
||||
/>
|
||||
</RightSection>
|
||||
{shouldShowSelectedAssetsSection && (
|
||||
<RightSection>
|
||||
<SelectedAssetsSection
|
||||
selectedAssetUrns={selectedAssetUrns}
|
||||
setSelectedAssetUrns={setSelectedAssetUrns}
|
||||
/>
|
||||
</RightSection>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
import LogicalFiltersBuilder from '@app/sharedV2/queryBuilder/LogicalFiltersBuilder';
|
||||
import { LogicalOperatorType, LogicalPredicate } from '@app/sharedV2/queryBuilder/builder/types';
|
||||
import { properties } from '@app/sharedV2/queryBuilder/properties';
|
||||
|
||||
const EMPTY_FILTER = {
|
||||
operator: LogicalOperatorType.AND,
|
||||
operands: [],
|
||||
};
|
||||
|
||||
type Props = {
|
||||
dynamicFilter: LogicalPredicate | null | undefined;
|
||||
setDynamicFilter: (newDynamicFilter: LogicalPredicate | null | undefined) => void;
|
||||
};
|
||||
|
||||
const DynamicSelectAssetsTab = ({ dynamicFilter, setDynamicFilter }: Props) => {
|
||||
return (
|
||||
<LogicalFiltersBuilder
|
||||
filters={dynamicFilter ?? EMPTY_FILTER}
|
||||
onChangeFilters={setDynamicFilter}
|
||||
properties={properties}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicSelectAssetsTab;
|
@ -0,0 +1,107 @@
|
||||
import { Checkbox, Loader, SearchBar, Text } from '@components';
|
||||
import React, { useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import EntityItem from '@app/homeV3/module/components/EntityItem';
|
||||
import AssetFilters from '@app/homeV3/modules/assetCollection/AssetFilters';
|
||||
import EmptySection from '@app/homeV3/modules/assetCollection/EmptySection';
|
||||
import useGetAssetResults from '@app/homeV3/modules/assetCollection/useGetAssetResults';
|
||||
import { LoaderContainer } from '@app/homeV3/styledComponents';
|
||||
import { getEntityDisplayType } from '@app/searchV2/autoCompleteV2/utils';
|
||||
import useAppliedFilters from '@app/searchV2/filtersV2/context/useAppliedFilters';
|
||||
import { useEntityRegistryV2 } from '@app/useEntityRegistry';
|
||||
|
||||
import { DataHubPageModuleType, Entity } from '@types';
|
||||
|
||||
const ItemDetailsContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ResultsContainer = styled.div`
|
||||
margin: 0 -16px 0 -8px;
|
||||
position: relative;
|
||||
max-height: 300px;
|
||||
padding-right: 8px;
|
||||
`;
|
||||
|
||||
const ScrollableResultsContainer = styled.div`
|
||||
max-height: inherit;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
selectedAssetUrns: string[];
|
||||
setSelectedAssetUrns: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
};
|
||||
|
||||
const ManualSelectAssetsTab = ({ selectedAssetUrns, setSelectedAssetUrns }: Props) => {
|
||||
const entityRegistry = useEntityRegistryV2();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string | undefined>();
|
||||
const { appliedFilters, updateFieldFilters } = useAppliedFilters();
|
||||
const { entities, loading } = useGetAssetResults({ searchQuery, appliedFilters });
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (urn: string) => {
|
||||
setSelectedAssetUrns((prev) => (prev.includes(urn) ? prev.filter((u) => u !== urn) : [...prev, urn]));
|
||||
};
|
||||
|
||||
const customDetailsRenderer = (entity: Entity) => {
|
||||
const displayType = getEntityDisplayType(entity, entityRegistry);
|
||||
|
||||
return (
|
||||
<ItemDetailsContainer>
|
||||
<Text color="gray" size="sm">
|
||||
{displayType}
|
||||
</Text>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
isChecked={selectedAssetUrns?.includes(entity.urn)}
|
||||
onCheckboxChange={() => handleCheckboxChange(entity.urn)}
|
||||
/>
|
||||
</ItemDetailsContainer>
|
||||
);
|
||||
};
|
||||
|
||||
let content;
|
||||
if (loading) {
|
||||
content = (
|
||||
<LoaderContainer>
|
||||
<Loader />
|
||||
</LoaderContainer>
|
||||
);
|
||||
} else if (entities && entities.length > 0) {
|
||||
content = entities?.map((entity) => (
|
||||
<EntityItem
|
||||
entity={entity}
|
||||
key={entity.urn}
|
||||
customDetailsRenderer={customDetailsRenderer}
|
||||
moduleType={DataHubPageModuleType.AssetCollection}
|
||||
padding="8px 0 8px 8px"
|
||||
navigateOnlyOnNameClick
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
content = <EmptySection />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchBar value={searchQuery} onChange={handleSearchChange} />
|
||||
<AssetFilters
|
||||
searchQuery={searchQuery}
|
||||
appliedFilters={appliedFilters}
|
||||
updateFieldFilters={updateFieldFilters}
|
||||
/>
|
||||
<ResultsContainer>
|
||||
<ScrollableResultsContainer>{content}</ScrollableResultsContainer>
|
||||
</ResultsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManualSelectAssetsTab;
|
@ -1,17 +1,13 @@
|
||||
import { Checkbox, Loader, SearchBar, Text } from '@components';
|
||||
import React, { useState } from 'react';
|
||||
import { Text } from '@components';
|
||||
import React, { useCallback } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import EntityItem from '@app/homeV3/module/components/EntityItem';
|
||||
import AssetFilters from '@app/homeV3/modules/assetCollection/AssetFilters';
|
||||
import EmptySection from '@app/homeV3/modules/assetCollection/EmptySection';
|
||||
import useGetAssetResults from '@app/homeV3/modules/assetCollection/useGetAssetResults';
|
||||
import { LoaderContainer } from '@app/homeV3/styledComponents';
|
||||
import { getEntityDisplayType } from '@app/searchV2/autoCompleteV2/utils';
|
||||
import useAppliedFilters from '@app/searchV2/filtersV2/context/useAppliedFilters';
|
||||
import { useEntityRegistryV2 } from '@app/useEntityRegistry';
|
||||
|
||||
import { DataHubPageModuleType, Entity } from '@types';
|
||||
import DynamicSelectAssetsTab from '@app/homeV3/modules/assetCollection/DynamicSelectAssetsTab';
|
||||
import ManualSelectAssetsTab from '@app/homeV3/modules/assetCollection/ManualSelectAssetsTab';
|
||||
import { SELECT_ASSET_TYPE_DYNAMIC, SELECT_ASSET_TYPE_MANUAL } from '@app/homeV3/modules/assetCollection/constants';
|
||||
import ButtonTabs from '@app/homeV3/modules/shared/ButtonTabs/ButtonTabs';
|
||||
import { Tab } from '@app/homeV3/modules/shared/ButtonTabs/types';
|
||||
import { LogicalPredicate } from '@app/sharedV2/queryBuilder/builder/types';
|
||||
|
||||
const AssetsSection = styled.div`
|
||||
display: flex;
|
||||
@ -19,96 +15,56 @@ const AssetsSection = styled.div`
|
||||
gap: 8px;
|
||||
`;
|
||||
|
||||
const ItemDetailsContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ResultsContainer = styled.div`
|
||||
margin: 0 -16px 0 -8px;
|
||||
position: relative;
|
||||
max-height: 300px;
|
||||
padding-right: 8px;
|
||||
`;
|
||||
|
||||
const ScrollableResultsContainer = styled.div`
|
||||
max-height: inherit;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
selectAssetType: string;
|
||||
setSelectAssetType: (newSelectAssetType: string) => void;
|
||||
selectedAssetUrns: string[];
|
||||
setSelectedAssetUrns: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
dynamicFilter: LogicalPredicate | null | undefined;
|
||||
setDynamicFilter: (newDynamicFilter: LogicalPredicate | null | undefined) => void;
|
||||
};
|
||||
|
||||
const SelectAssetsSection = ({ selectedAssetUrns, setSelectedAssetUrns }: Props) => {
|
||||
const entityRegistry = useEntityRegistryV2();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string | undefined>();
|
||||
const { appliedFilters, updateFieldFilters } = useAppliedFilters();
|
||||
const { entities, loading } = useGetAssetResults({ searchQuery, appliedFilters });
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (urn: string) => {
|
||||
setSelectedAssetUrns((prev) => (prev.includes(urn) ? prev.filter((u) => u !== urn) : [...prev, urn]));
|
||||
};
|
||||
|
||||
const customDetailsRenderer = (entity: Entity) => {
|
||||
const displayType = getEntityDisplayType(entity, entityRegistry);
|
||||
|
||||
return (
|
||||
<ItemDetailsContainer>
|
||||
<Text color="gray" size="sm">
|
||||
{displayType}
|
||||
</Text>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
isChecked={selectedAssetUrns?.includes(entity.urn)}
|
||||
onCheckboxChange={() => handleCheckboxChange(entity.urn)}
|
||||
const SelectAssetsSection = ({
|
||||
selectAssetType,
|
||||
setSelectAssetType,
|
||||
selectedAssetUrns,
|
||||
setSelectedAssetUrns,
|
||||
dynamicFilter,
|
||||
setDynamicFilter,
|
||||
}: Props) => {
|
||||
const tabs: Tab[] = [
|
||||
{
|
||||
key: SELECT_ASSET_TYPE_MANUAL,
|
||||
label: 'Select Assets',
|
||||
content: (
|
||||
<ManualSelectAssetsTab
|
||||
selectedAssetUrns={selectedAssetUrns}
|
||||
setSelectedAssetUrns={setSelectedAssetUrns}
|
||||
/>
|
||||
</ItemDetailsContainer>
|
||||
);
|
||||
};
|
||||
),
|
||||
},
|
||||
{
|
||||
key: SELECT_ASSET_TYPE_DYNAMIC,
|
||||
label: 'Dynamic Filter',
|
||||
content: <DynamicSelectAssetsTab dynamicFilter={dynamicFilter} setDynamicFilter={setDynamicFilter} />,
|
||||
},
|
||||
];
|
||||
|
||||
let content;
|
||||
if (loading) {
|
||||
content = (
|
||||
<LoaderContainer>
|
||||
<Loader />
|
||||
</LoaderContainer>
|
||||
);
|
||||
} else if (entities && entities.length > 0) {
|
||||
content = entities?.map((entity) => (
|
||||
<EntityItem
|
||||
entity={entity}
|
||||
key={entity.urn}
|
||||
customDetailsRenderer={customDetailsRenderer}
|
||||
moduleType={DataHubPageModuleType.AssetCollection}
|
||||
padding="8px 0 8px 8px"
|
||||
navigateOnlyOnNameClick
|
||||
/>
|
||||
));
|
||||
} else {
|
||||
content = <EmptySection />;
|
||||
}
|
||||
const onTabChanged = useCallback(
|
||||
(newActiveTabKey: string) => {
|
||||
if (newActiveTabKey === SELECT_ASSET_TYPE_MANUAL || newActiveTabKey === SELECT_ASSET_TYPE_DYNAMIC) {
|
||||
setSelectAssetType?.(newActiveTabKey);
|
||||
}
|
||||
},
|
||||
[setSelectAssetType],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssetsSection>
|
||||
<Text color="gray" weight="bold">
|
||||
Search and Select Assets
|
||||
</Text>
|
||||
<SearchBar value={searchQuery} onChange={handleSearchChange} />
|
||||
<AssetFilters
|
||||
searchQuery={searchQuery}
|
||||
appliedFilters={appliedFilters}
|
||||
updateFieldFilters={updateFieldFilters}
|
||||
/>
|
||||
<ResultsContainer>
|
||||
<ScrollableResultsContainer>{content}</ScrollableResultsContainer>
|
||||
</ResultsContainer>
|
||||
<ButtonTabs tabs={tabs} onTabClick={onTabChanged} defaultKey={selectAssetType} />
|
||||
</AssetsSection>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,2 @@
|
||||
export const SELECT_ASSET_TYPE_MANUAL = 'manual';
|
||||
export const SELECT_ASSET_TYPE_DYNAMIC = 'dynamic';
|
@ -2,9 +2,9 @@ import { Input } from '@components';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import FormItem from '@app/homeV3/modules/hierarchyViewModule/components/form/components/FormItem';
|
||||
import RelatedEntitiesSection from '@app/homeV3/modules/hierarchyViewModule/components/form/sections/relatedEntities/RelatedEntitiesSection';
|
||||
import SelectAssetsSection from '@app/homeV3/modules/hierarchyViewModule/components/form/sections/selectAssets/SelectAssetsSection';
|
||||
import FormItem from '@app/homeV3/modules/shared/Form/FormItem';
|
||||
|
||||
const FormWrapper = styled.div`
|
||||
display: flex;
|
||||
|
@ -2,13 +2,13 @@ import { Form } from 'antd';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { useHierarchyFormContext } from '@app/homeV3/modules/hierarchyViewModule/components/form/HierarchyFormContext';
|
||||
import FormItem from '@app/homeV3/modules/hierarchyViewModule/components/form/components/FormItem';
|
||||
import {
|
||||
FORM_FIELD_RELATED_ENTITIES_FILTER,
|
||||
FORM_FIELD_SHOW_RELATED_ENTITIES,
|
||||
} from '@app/homeV3/modules/hierarchyViewModule/components/form/constants';
|
||||
import ShowRelatedEntitiesSwitch from '@app/homeV3/modules/hierarchyViewModule/components/form/sections/relatedEntities/components/ShowRelatedEntitiesToggler';
|
||||
import { HierarchyForm } from '@app/homeV3/modules/hierarchyViewModule/components/form/types';
|
||||
import FormItem from '@app/homeV3/modules/shared/Form/FormItem';
|
||||
import LogicalFiltersBuilder from '@app/sharedV2/queryBuilder/LogicalFiltersBuilder';
|
||||
import { LogicalOperatorType, LogicalPredicate } from '@app/sharedV2/queryBuilder/builder/types';
|
||||
import { properties } from '@app/sharedV2/queryBuilder/properties';
|
||||
|
@ -5,11 +5,11 @@ import styled from 'styled-components';
|
||||
|
||||
import DomainsSelectableTreeView from '@app/homeV3/modules/hierarchyViewModule/components/domains/DomainsSelectableTreeView';
|
||||
import { useHierarchyFormContext } from '@app/homeV3/modules/hierarchyViewModule/components/form/HierarchyFormContext';
|
||||
import FormItem from '@app/homeV3/modules/hierarchyViewModule/components/form/components/FormItem';
|
||||
import { FORM_FIELD_ASSET_TYPE } from '@app/homeV3/modules/hierarchyViewModule/components/form/constants';
|
||||
import EntityTypeTabs from '@app/homeV3/modules/hierarchyViewModule/components/form/sections/selectAssets/assetTypeTabs/AssetTypeTabs';
|
||||
import GlossarySelectableTreeView from '@app/homeV3/modules/hierarchyViewModule/components/glossary/GlossarySelectableTreeView';
|
||||
import { ASSET_TYPE_DOMAINS, ASSET_TYPE_GLOSSARY } from '@app/homeV3/modules/hierarchyViewModule/constants';
|
||||
import ButtonTabs from '@app/homeV3/modules/shared/ButtonTabs/ButtonTabs';
|
||||
import FormItem from '@app/homeV3/modules/shared/Form/FormItem';
|
||||
|
||||
const Wrapper = styled.div``;
|
||||
|
||||
@ -56,7 +56,7 @@ export default function SelectAssetsSection() {
|
||||
Search and Select Assets
|
||||
</Text>
|
||||
<FormItem name={FORM_FIELD_ASSET_TYPE}>
|
||||
<EntityTypeTabs tabs={tabs} onTabClick={onTabClick} defaultKey={assetType ?? defaultAssetsType} />
|
||||
<ButtonTabs tabs={tabs} onTabClick={onTabClick} defaultKey={assetType ?? defaultAssetsType} />
|
||||
</FormItem>
|
||||
</Wrapper>
|
||||
);
|
||||
|
@ -1,8 +1,8 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { TabButtons } from '@app/homeV3/modules/hierarchyViewModule/components/form/sections/selectAssets/assetTypeTabs/TabButtons';
|
||||
import { Tab } from '@app/homeV3/modules/hierarchyViewModule/components/form/sections/selectAssets/assetTypeTabs/types';
|
||||
import { TabButtons } from '@app/homeV3/modules/shared/ButtonTabs/TabButtons';
|
||||
import { Tab } from '@app/homeV3/modules/shared/ButtonTabs/types';
|
||||
|
||||
const TabContentWrapper = styled.div<{ $visible?: boolean }>`
|
||||
${(props) => !props.$visible && 'display: none;'}
|
||||
@ -11,10 +11,10 @@ const TabContentWrapper = styled.div<{ $visible?: boolean }>`
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
defaultKey?: string;
|
||||
onTabClick: (key: string) => void;
|
||||
onTabClick?: (key: string) => void;
|
||||
}
|
||||
|
||||
export default function EntityTypeTabs({ tabs, defaultKey, onTabClick }: Props) {
|
||||
export default function ButtonTabs({ tabs, defaultKey, onTabClick }: Props) {
|
||||
const [activeKey, setActiveKey] = useState<string | undefined>(defaultKey ?? tabs?.[0]?.key);
|
||||
const [renderedKeys, setRenderedKeys] = useState<string[]>(activeKey ? [activeKey] : []);
|
||||
|
||||
@ -22,7 +22,7 @@ export default function EntityTypeTabs({ tabs, defaultKey, onTabClick }: Props)
|
||||
(key: string) => {
|
||||
setActiveKey(key);
|
||||
setRenderedKeys((prev) => [...new Set([...prev, key])]);
|
||||
onTabClick(key);
|
||||
onTabClick?.(key);
|
||||
},
|
||||
[onTabClick],
|
||||
);
|
@ -2,7 +2,7 @@ import { Button, colors } from '@components';
|
||||
import React, { useCallback } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Tab } from '@app/homeV3/modules/hierarchyViewModule/components/form/sections/selectAssets/assetTypeTabs/types';
|
||||
import { Tab } from '@app/homeV3/modules/shared/ButtonTabs/types';
|
||||
|
||||
const StyledButton = styled(Button)<{ $active?: boolean }>`
|
||||
width: 100%;
|
@ -1,5 +1,9 @@
|
||||
import { QuickFilterField } from '@app/searchV2/autoComplete/quickFilters/utils';
|
||||
import { getAutoCompleteInputFromQuickFilter, getFiltersWithQuickFilter } from '@app/searchV2/utils/filterUtils';
|
||||
import {
|
||||
excludeEmptyAndFilters,
|
||||
getAutoCompleteInputFromQuickFilter,
|
||||
getFiltersWithQuickFilter,
|
||||
} from '@app/searchV2/utils/filterUtils';
|
||||
|
||||
describe('getAutoCompleteInputFromQuickFilter', () => {
|
||||
it('should create a platform filter if the selected quick filter is a platform', () => {
|
||||
@ -43,3 +47,41 @@ describe('getFiltersWithQuickFilter', () => {
|
||||
expect(filterResult).toMatchObject([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('excludeEmptyAndFilters', () => {
|
||||
it('should handle filter out empty filters', () => {
|
||||
const result = excludeEmptyAndFilters(undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = excludeEmptyAndFilters([]);
|
||||
|
||||
expect(result).toMatchObject([]);
|
||||
});
|
||||
|
||||
it('should handle array of filled filters', () => {
|
||||
const result = excludeEmptyAndFilters([
|
||||
{ and: [{ field: 'test', values: ['test'] }] },
|
||||
{ and: [{ field: 'test2', values: ['test2'] }] },
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject([
|
||||
{ and: [{ field: 'test', values: ['test'] }] },
|
||||
{ and: [{ field: 'test2', values: ['test2'] }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle mixed empty and filled filters', () => {
|
||||
const result = excludeEmptyAndFilters([{ and: [] }, { and: [{ field: 'test', values: ['test'] }] }]);
|
||||
|
||||
expect(result).toMatchObject([{ and: [{ field: 'test', values: ['test'] }] }]);
|
||||
});
|
||||
|
||||
it('should handle array of empty filters', () => {
|
||||
const result = excludeEmptyAndFilters([{ and: [] }, { and: [] }]);
|
||||
|
||||
expect(result).toMatchObject([]);
|
||||
});
|
||||
});
|
||||
|
@ -214,3 +214,7 @@ export function combineOrFilters(orFilter1: AndFilterInput[], orFilter2: AndFilt
|
||||
|
||||
return mergedFilter;
|
||||
}
|
||||
|
||||
export function excludeEmptyAndFilters(filters: AndFilterInput[] | undefined): AndFilterInput[] | undefined {
|
||||
return filters?.filter((filter) => filter.and?.length);
|
||||
}
|
||||
|
@ -1,5 +1,9 @@
|
||||
import { LogicalOperatorType } from '@app/sharedV2/queryBuilder/builder/types';
|
||||
import { convertLogicalPredicateToOrFilters, isLogicalPredicate } from '@app/sharedV2/queryBuilder/builder/utils';
|
||||
import { LogicalOperatorType, LogicalPredicate } from '@app/sharedV2/queryBuilder/builder/types';
|
||||
import {
|
||||
convertLogicalPredicateToOrFilters,
|
||||
isEmptyLogicalPredicate,
|
||||
isLogicalPredicate,
|
||||
} from '@app/sharedV2/queryBuilder/builder/utils';
|
||||
|
||||
describe('utils', () => {
|
||||
describe('isLogicalPredicate', () => {
|
||||
@ -205,6 +209,10 @@ describe('utils', () => {
|
||||
];
|
||||
|
||||
const EMPTY_LOGICAL_PREDICATE = {};
|
||||
const LOGICAL_PREDICATE_WITH_EMPTY_OPERANDS = {
|
||||
operator: LogicalOperatorType.AND,
|
||||
operands: [],
|
||||
};
|
||||
|
||||
const LOGICAL_PREDICATE_WITH_UNKNOWN_OPERATION = {
|
||||
operator: 'UNKNOWN',
|
||||
@ -235,4 +243,16 @@ describe('utils', () => {
|
||||
expect(convertLogicalPredicateToOrFilters(LOGICAL_PREDICATE_WITH_UNKNOWN_OPERATION)).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmptyLogicalPredicate', () => {
|
||||
it('should handle not empty logical predicate', () => {
|
||||
expect(isEmptyLogicalPredicate(BASIC_AND_LOGICAL_PREDICATE)).toBeFalsy();
|
||||
});
|
||||
it('should handle empty logical predicate', () => {
|
||||
expect(isEmptyLogicalPredicate(EMPTY_LOGICAL_PREDICATE as LogicalPredicate)).toBeTruthy();
|
||||
});
|
||||
it('should handle logical predicate with empty', () => {
|
||||
expect(isEmptyLogicalPredicate(LOGICAL_PREDICATE_WITH_EMPTY_OPERANDS)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -109,3 +109,7 @@ export const convertToLogicalPredicate = (predicate: LogicalPredicate | Property
|
||||
// Already is a logical predicate.
|
||||
return predicate as LogicalPredicate;
|
||||
};
|
||||
|
||||
export function isEmptyLogicalPredicate(predicate: LogicalPredicate | null | undefined) {
|
||||
return !predicate?.operands?.length;
|
||||
}
|
||||
|
@ -36,6 +36,7 @@ fragment PageModule on DataHubPageModule {
|
||||
}
|
||||
assetCollectionParams {
|
||||
assetUrns
|
||||
dynamicFilterJson
|
||||
}
|
||||
linkParams {
|
||||
linkUrl
|
||||
|
@ -7,4 +7,13 @@ import com.linkedin.common.Urn
|
||||
*/
|
||||
record AssetCollectionModuleParams {
|
||||
assetUrns: array[Urn]
|
||||
|
||||
/**
|
||||
* Optional dynamic filters
|
||||
*
|
||||
* The stringified json representing the logical predicate built in the UI to select assets.
|
||||
* This predicate is turned into orFilters to send through graphql since graphql doesn't support
|
||||
* arbitrary nesting. This string is used to restore the UI for this logical predicate.
|
||||
*/
|
||||
dynamicFilterJson: optional string
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user