diff --git a/datahub-web-react/src/app/permissions/policy/ManagePolicies.tsx b/datahub-web-react/src/app/permissions/policy/ManagePolicies.tsx index 72c22f3bddc2c..5765babcb575e 100644 --- a/datahub-web-react/src/app/permissions/policy/ManagePolicies.tsx +++ b/datahub-web-react/src/app/permissions/policy/ManagePolicies.tsx @@ -370,6 +370,7 @@ export const ManagePolicies = () => { {showPolicyBuilderModal && ( ; setPolicy: (policy: Omit) => void; visible: boolean; + focusPolicyUrn: string | undefined; onClose: () => void; onSave: (savePolicy: Omit) => void; }; @@ -39,9 +40,11 @@ const NextButtonContainer = styled.div` * Component used for constructing new policies. The purpose of this flow is to populate or edit a Policy * object through a sequence of steps. */ -export default function PolicyBuilderModal({ policy, setPolicy, visible, onClose, onSave }: Props) { +export default function PolicyBuilderModal({ policy, setPolicy, visible, onClose, onSave, focusPolicyUrn }: Props) { // Step control-flow. const [activeStepIndex, setActiveStepIndex] = useState(0); + const [selectedTags, setSelectedTags] = useState([]); + const [isEditState,setEditState] = useState(true) // Go to next step const next = () => { @@ -90,12 +93,17 @@ export default function PolicyBuilderModal({ policy, setPolicy, visible, onClose title: 'Configure Privileges', content: ( { setPolicy({ ...policy, resources }); }} + setSelectedTags={setSelectedTags} + selectedTags={selectedTags} + setEditState={setEditState} + isEditState={isEditState} privileges={policy.privileges} setPrivileges={(privileges: string[]) => setPolicy({ ...policy, privileges })} /> diff --git a/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx b/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx index ac73a1f5ece7c..7a0de67f41419 100644 --- a/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx +++ b/datahub-web-react/src/app/permissions/policy/PolicyPrivilegeForm.tsx @@ -1,6 +1,6 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; -import { Form, Select, Tag, Tooltip, Typography } from 'antd'; +import { Form, Select, Tag, Tooltip, Typography, Tag as CustomTag } from 'antd'; import styled from 'styled-components/macro'; import { useEntityRegistry } from '../../useEntityRegistry'; @@ -9,13 +9,14 @@ import { useGetSearchResultsForMultipleLazyQuery, useGetSearchResultsLazyQuery, } from '../../../graphql/search.generated'; -import { ResourceFilter, PolicyType, EntityType, Domain } from '../../../types.generated'; +import { ResourceFilter, PolicyType, EntityType, Domain, Entity } from '../../../types.generated'; import { convertLegacyResourceFilter, createCriterionValue, createCriterionValueWithEntity, EMPTY_POLICY, getFieldValues, + getFieldValuesOfTags, mapResourceTypeToDisplayName, mapResourceTypeToEntityType, mapResourceTypeToPrivileges, @@ -24,20 +25,28 @@ import { import DomainNavigator from '../../domain/nestedDomains/domainNavigator/DomainNavigator'; import { BrowserWrapper } from '../../shared/tags/AddTagsTermsModal'; import ClickOutside from '../../shared/ClickOutside'; +import { TagTermLabel } from '../../shared/tags/TagTermLabel'; +import { ENTER_KEY_CODE } from '../../shared/constants'; +import { useGetRecommendations } from '../../shared/recommendation'; type Props = { policyType: PolicyType; resources?: ResourceFilter; setResources: (resources: ResourceFilter) => void; + selectedTags?: any[]; + setSelectedTags: (data: any) => void; + setEditState: (data: boolean) => void; + isEditState: boolean; privileges: Array; setPrivileges: (newPrivs: Array) => void; + focusPolicyUrn: string | undefined; }; const SearchResultContainer = styled.div` display: flex; justify-content: space-between; align-items: center; - padding: 12px; + padding: 4px; `; const PrivilegesForm = styled(Form)` @@ -46,6 +55,21 @@ const PrivilegesForm = styled(Form)` margin-bottom: 40px; `; +const TagSelect = styled(Select)` + width: 480px; +`; + +const StyleTag = styled(CustomTag)` + margin: 2px; + display: flex; + justify-content: start; + align-items: center; + white-space: nowrap; + opacity: 1; + color: #434343; + line-height: 16px; +`; + /** * Component used to construct the "privileges" and "resources" portion of a DataHub * access Policy. @@ -56,10 +80,21 @@ export default function PolicyPrivilegeForm({ setResources, privileges, setPrivileges, + setSelectedTags, + selectedTags, + setEditState, + isEditState, + focusPolicyUrn, }: Props) { const entityRegistry = useEntityRegistry(); const [domainInputValue, setDomainInputValue] = useState(''); const [isFocusedOnInput, setIsFocusedOnInput] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [tagTermSearch, { data: tagTermSearchData }] = useGetSearchResultsLazyQuery(); + const [recommendedData] = useGetRecommendations([EntityType.Tag]); + const tagSearchResults = tagTermSearchData?.search?.searchResults?.map((searchResult) => searchResult.entity) || []; + + const inputEl = useRef(null); // Configuration used for displaying options const { @@ -295,6 +330,131 @@ export default function PolicyPrivilegeForm({ setDomainInputValue(''); } + function handleBlurTag() { + setInputValue(''); + } + + const renderSearchResultTags = (entity: Entity) => { + const displayName = + entity.type === EntityType.Tag ? (entity as any).name : entityRegistry.getDisplayName(entity.type, entity); + const tagOrTermComponent = ; + return ( + + {tagOrTermComponent} + + ); + }; + const tags = getFieldValues(resources.filter, 'TAG') || []; + const newTag = getFieldValues(resources.filter, 'TAG').map((criterionValue) => { + if (criterionValue?.value) { + return criterionValue?.value; + } + return criterionValue; + }); + + const editTags = getFieldValuesOfTags(resources.filter, 'TAG').map((criterionValue) => { + if (criterionValue?.value) { + return criterionValue?.entity; + } + return criterionValue; + }); + const tagResult = !inputValue || inputValue.length === 0 ? recommendedData : tagSearchResults; + useEffect(() => { + if (focusPolicyUrn && isEditState && setEditState && editTags && newTag) { + setEditState(false); + const filter = resources.filter || { + criteria: [], + }; + setSelectedTags(editTags); + setResources({ + ...resources, + filter: setFieldValues(filter, 'TAG', [...(newTag as any)]), + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focusPolicyUrn, isEditState, setEditState, editTags, newTag]); + + const onSelectValue = (urn: string) => { + const filter = resources.filter || { + criteria: [], + }; + const selectedTagOption = tagResult?.find((tag) => tag.urn === urn); + + setResources({ + ...resources, + filter: setFieldValues(filter, 'TAG', [...tags, urn as any]), + }); + setSelectedTags([...(selectedTags as any), selectedTagOption]); + if (inputEl && inputEl.current) { + (inputEl.current as any).blur(); + } + }; + + // When a Tag search result is deselected, remove the Tags + const onDeselectValue = (urn: string) => { + const filter = resources.filter || { + criteria: [], + }; + setInputValue(''); + setSelectedTags(selectedTags?.filter((term) => term.urn !== urn)); + + setResources({ + ...resources, + filter: setFieldValues( + filter, + 'TAG', + tags?.filter((criterionValue) => (criterionValue as any) !== urn), + ), + }); + }; + + const type = EntityType.Tag; + const handleSearch = (text: string) => { + if (text.length > 0) { + tagTermSearch({ + variables: { + input: { + type, + query: text, + start: 0, + count: 10, + }, + }, + }); + } + }; + + const tagSearchOptions = tagResult?.map((result) => { + return renderSearchResultTags(result); + }); + + function clearInput() { + setInputValue(''); + setTimeout(() => setIsFocusedOnInput(true), 0); // call after click outside + } + + const tagRender = (props) => { + // eslint-disable-next-line react/prop-types + const { closable, onClose, value } = props; + const onPreventMouseDown = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const selectedItem = selectedTags?.find((term) => term?.urn === value); + return ( + + {selectedItem?.name} + + ); + }; + + function handleKeyDown(event) { + if (event.keyCode === ENTER_KEY_CODE) { + (inputEl.current as any).blur(); + } + } + return ( {showResourceFilterInput && ( @@ -362,6 +522,38 @@ export default function PolicyPrivilegeForm({ )} + {showResourceFilterInput && ( + Select Tags}> + + The policy will apply to all entities containing all of the chosen tags. If no tags are + selected, the policy will not account for tags. + + onSelectValue(asset)} + onDeselect={(asset: any) => onDeselectValue(asset)} + onSearch={(value: string) => { + // eslint-disable-next-line react/prop-types + handleSearch(value.trim()); + // eslint-disable-next-line react/prop-types + setInputValue(value.trim()); + }} + tagRender={tagRender} + value={tags} + onClear={clearInput} + onBlur={handleBlurTag} + onInputKeyDown={handleKeyDown} + > + {tagSearchOptions} + + + )} {showResourceFilterInput && ( Select Domains}> diff --git a/datahub-web-react/src/app/permissions/policy/policyUtils.ts b/datahub-web-react/src/app/permissions/policy/policyUtils.ts index 27aa8fcd351e9..c7ec171bc2c29 100644 --- a/datahub-web-react/src/app/permissions/policy/policyUtils.ts +++ b/datahub-web-react/src/app/permissions/policy/policyUtils.ts @@ -118,6 +118,10 @@ export const getFieldValues = (filter: Maybe | undefined, res return filter?.criteria?.find((criterion) => criterion.field === resourceFieldType)?.values || []; }; +export const getFieldValuesOfTags = (filter: Maybe | undefined, resourceFieldType: string) => { + return filter?.criteria?.find((criterion) => criterion.field === resourceFieldType)?.values || []; +}; + export const setFieldValues = ( filter: PolicyMatchFilter, resourceFieldType: string, diff --git a/datahub-web-react/src/app/permissions/policy/usePolicy.ts b/datahub-web-react/src/app/permissions/policy/usePolicy.ts index 6f359805e42db..d04ea25d20b23 100644 --- a/datahub-web-react/src/app/permissions/policy/usePolicy.ts +++ b/datahub-web-react/src/app/permissions/policy/usePolicy.ts @@ -44,19 +44,22 @@ export function usePolicy( const [deletePolicy, { error: deletePolicyError }] = useDeletePolicyMutation(); - const toFilterInput = (filter: PolicyMatchFilter): PolicyMatchFilterInput => { + const toFilterInput = (filter: PolicyMatchFilter,state?:string | undefined): PolicyMatchFilterInput => { + console.log({state}) return { criteria: filter.criteria?.map((criterion): PolicyMatchCriterionInput => { return { field: criterion.field, - values: criterion.values.map((criterionValue) => criterionValue.value), + values: criterion.values.map((criterionValue) => + criterion.field === 'TAG' && state !=='TOGGLE' ? (criterionValue as any) : criterionValue.value, + ), condition: criterion.condition, }; }), }; }; - const toPolicyInput = (policy: Omit): PolicyUpdateInput => { + const toPolicyInput = (policy: Omit,state?:string | undefined): PolicyUpdateInput => { let policyInput: PolicyUpdateInput = { type: policy.type, name: policy.name, @@ -79,7 +82,7 @@ export function usePolicy( allResources: policy.resources.allResources, }; if (policy.resources.filter) { - resourceFilter = { ...resourceFilter, filter: toFilterInput(policy.resources.filter) }; + resourceFilter = { ...resourceFilter, filter: toFilterInput(policy.resources.filter,state) }; } // Add the resource filters. policyInput = { @@ -151,7 +154,7 @@ export function usePolicy( updatePolicy({ variables: { urn: policy?.urn as string, // There must be a focus policy urn. - input: toPolicyInput(newPolicy), + input: toPolicyInput(newPolicy,'TOGGLE'), }, }).then(()=>{ const updatePolicies= { @@ -178,6 +181,7 @@ export function usePolicy( __typename: 'ListPoliciesResult', urn: focusPolicyUrn, ...savePolicy, + resources: null, }; analytics.event({ type: EventType.UpdatePolicyEvent,