From db370eedd22ee44659803f4c3380dfc915089179 Mon Sep 17 00:00:00 2001 From: amit-apptware <amit.gaikwad@apptware.com> Date: Tue, 11 Mar 2025 14:57:26 +0530 Subject: [PATCH 01/11] feat(ui/incident-v2): add incident v2 design integration --- .../IncidentActivityAvatar.tsx | 28 + .../IncidentActivityContent.tsx | 45 ++ .../IncidentActivitySection.tsx | 34 ++ .../IncidentAssigneeSelector.tsx | 156 ++++++ .../AcrylComponents/IncidentDetailDrawer.tsx | 73 +++ .../AcrylComponents/IncidentDrawerHeader.tsx | 66 +++ .../AcrylComponents/IncidentEditor.tsx | 253 +++++++++ .../IncidentLinkedAssetsList.tsx | 159 ++++++ .../AcrylComponents/IncidentSelectedField.tsx | 154 ++++++ .../Incident/AcrylComponents/IncidentView.tsx | 258 +++++++++ .../tabs/Incident/AcrylComponents/constant.ts | 6 + .../hooks/useIncidentHandler.ts | 206 +++++++ .../AcrylComponents/styledComponents.tsx | 327 ++++++++++++ .../Incident/IncidentAssigneeAvatarStack.tsx | 13 + .../tabs/Incident/IncidentFilterContainer.tsx | 109 ++++ .../shared/tabs/Incident/IncidentList.tsx | 122 +++++ .../tabs/Incident/IncidentListLoading.tsx | 7 + .../tabs/Incident/IncidentListTable.tsx | 152 ++++++ .../tabs/Incident/IncidentResolutionPopup.tsx | 155 ++++++ .../tabs/Incident/IncidentResolveButton.tsx | 126 +++++ .../shared/tabs/Incident/IncidentTab.tsx | 156 +----- .../tabs/Incident/IncidentTitleContainer.tsx | 63 +++ .../shared/tabs/Incident/ResolvedSection.tsx | 65 +++ .../tabs/Incident/__tests__/utils.test.tsx | 122 +++++ .../Incident/components/AddIncidentModal.tsx | 158 +++--- .../Incident/components/IncidentListItem.tsx | 25 +- .../components/ResolveIncidentModal.tsx | 18 +- .../entityV2/shared/tabs/Incident/constant.ts | 222 ++++++++ .../entityV2/shared/tabs/Incident/hooks.tsx | 197 +++++++ .../shared/tabs/Incident/incidentUtils.ts | 10 +- .../shared/tabs/Incident/styledComponents.tsx | 152 ++++++ .../entityV2/shared/tabs/Incident/types.ts | 120 +++++ .../entityV2/shared/tabs/Incident/utils.tsx | 501 ++++++++++++++++++ 33 files changed, 4003 insertions(+), 255 deletions(-) create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityAvatar.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityContent.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivitySection.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentAssigneeSelector.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentDetailDrawer.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentDrawerHeader.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentEditor.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentLinkedAssetsList.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentSelectedField.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentView.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/constant.ts create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/hooks/useIncidentHandler.ts create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/styledComponents.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentAssigneeAvatarStack.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentFilterContainer.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentList.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentListLoading.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentListTable.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentResolutionPopup.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentResolveButton.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentTitleContainer.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/ResolvedSection.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/__tests__/utils.test.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/constant.ts create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/hooks.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/styledComponents.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/types.ts create mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Incident/utils.tsx diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityAvatar.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityAvatar.tsx new file mode 100644 index 00000000000000..44a67189845774 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityAvatar.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Avatar } from '@src/alchemy-components'; +import { CorpUser } from '@src/types.generated'; +import { HoverEntityTooltip } from '@src/app/recommendations/renderer/component/HoverEntityTooltip'; +import { useEntityRegistryV2 } from '@src/app/useEntityRegistry'; +import useGetUserName from '../../Dataset/Stats/StatsTabV2/graphs/ChangeHistoryGraph/components/ChangeHistoryDrawer/useGetUserName'; + +type TimelineDotProps = { + user?: CorpUser; +}; + +export default function IncidentActivityAvatar({ user }: TimelineDotProps) { + const entityRegistry = useEntityRegistryV2(); + const getUserName = useGetUserName(); + + const avatarUrl = user?.editableProperties?.pictureLink || undefined; + + if (!user) return null; + + return ( + <HoverEntityTooltip entity={user} showArrow={false}> + <Link to={`${entityRegistry.getEntityUrl(user.type, user.urn)}`}> + <Avatar name={getUserName(user)} imageUrl={avatarUrl} size="xl" /> + </Link> + </HoverEntityTooltip> + ); +} diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityContent.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityContent.tsx new file mode 100644 index 00000000000000..b089dd1e832c18 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityContent.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { useEntityRegistryV2 } from '@src/app/useEntityRegistry'; +import { colors, Text } from '@src/alchemy-components'; +import { getTimeFromNow } from '@src/app/shared/time/timeUtils'; +import { ActivityStatusText, Content, ContentRow } from './styledComponents'; +import useGetUserName from '../../Dataset/Stats/StatsTabV2/graphs/ChangeHistoryGraph/components/ChangeHistoryDrawer/useGetUserName'; +import { TimelineContentDetails } from '../types'; + +type TimelineContentProps = { + incidentActivities: TimelineContentDetails; +}; + +export default function IncidentActivityContent({ incidentActivities }: TimelineContentProps) { + const { action, actor, time } = incidentActivities; + const getUserName = useGetUserName(); + const entityRegistry = useEntityRegistryV2(); + + return ( + <Content> + <ContentRow> + <Text + style={{ + display: 'flex', + flexDirection: 'row', + gap: '4px', + }} + > + <ActivityStatusText>{action}</ActivityStatusText> + <Text color="gray" type="span" style={{ color: colors.gray[1700] }}> + by + </Text> + <ActivityStatusText> + {actor && ( + <Link to={`${entityRegistry.getEntityUrl(actor.type, actor.urn)}`}> + {getUserName(actor)} + </Link> + )} + </ActivityStatusText> + </Text> + <Text style={{ color: colors.gray[1700] }}>{getTimeFromNow(time)}</Text> + </ContentRow> + </Content> + ); +} diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivitySection.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivitySection.tsx new file mode 100644 index 00000000000000..cad66d07a3b1b9 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivitySection.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Timeline } from '@src/alchemy-components'; + +import IncidentActivityContent from './IncidentActivityContent'; +import { ActivityLabelSection, ActivitySection, TimelineWrapper } from './styledComponents'; +import TimelineSkeleton from '../../Dataset/Stats/StatsTabV2/graphs/ChangeHistoryGraph/components/ChangeHistoryDrawer/components/TimeLineSkeleton'; +import { TimelineContentDetails } from '../types'; +import IncidentActivityAvatar from './IncidentActivityAvatar'; + +type IncidentActivitySectionProps = { + loading: boolean; + renderActivities: any[]; +}; + +export const IncidentActivitySection = ({ loading, renderActivities }: IncidentActivitySectionProps) => { + return ( + <ActivitySection> + <ActivityLabelSection>Activity</ActivityLabelSection> + {loading ? ( + <TimelineSkeleton /> + ) : ( + <TimelineWrapper> + <Timeline + items={renderActivities} + renderDot={(item) => <IncidentActivityAvatar user={item?.actor} />} + renderContent={(item: TimelineContentDetails) => ( + <IncidentActivityContent incidentActivities={item} /> + )} + /> + </TimelineWrapper> + )} + </ActivitySection> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentAssigneeSelector.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentAssigneeSelector.tsx new file mode 100644 index 00000000000000..532fc1bbfc24d8 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentAssigneeSelector.tsx @@ -0,0 +1,156 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { LoadingOutlined } from '@ant-design/icons'; +import { Entity, EntityType } from '@src/types.generated'; +import _ from 'lodash'; +import { Avatar, SimpleSelect } from '@src/alchemy-components'; +import { SelectOption } from '@src/alchemy-components/components/Select/Nested/types'; +import { useGetRecommendations } from '@src/app/shared/recommendation'; +import { useGetAutoCompleteResultsLazyQuery } from '@src/graphql/search.generated'; +import { useGetEntitiesLazyQuery } from '@src/graphql/entity.generated'; +import { useEntityRegistryV2 } from '@src/app/useEntityRegistry'; +import { getAssigneeWithURN } from '../utils'; +import { LoadingWrapper } from './styledComponents'; +import { IncidentTableRow } from '../types'; + +interface AssigneeSelectorProps { + data?: IncidentTableRow; + form: any; + setCachedAssignees: React.Dispatch<React.SetStateAction<any[]>>; +} + +const renderSelectedAssignee = (selectedOption: SelectOption) => { + return selectedOption ? <Avatar name={selectedOption?.label} showInPill /> : null; +}; + +const renderCustomAssigneeOption = (option: SelectOption) => { + return <Avatar name={option?.label} showInPill />; +}; + +export const IncidentAssigneeSelector = ({ data, form, setCachedAssignees }: AssigneeSelectorProps) => { + const { recommendedData, loading: recommendationsLoading } = useGetRecommendations([EntityType.CorpUser]); + const [useSearch, setUseSearch] = useState(false); + const assigneeValues = data?.assignees && getAssigneeWithURN(data.assignees); + const [selectedAssigneeOptions, setSelectedAssigneeOptions] = useState<SelectOption[]>([]); + + const [assigneeList, setAssigneeList] = useState<any>(assigneeValues || []); + + const [userSearch, { data: userSearchData }] = useGetAutoCompleteResultsLazyQuery(); + + const ownerSearchResults: Array<Entity> = userSearchData?.autoComplete?.entities || []; + const ownerResult = useSearch ? ownerSearchResults : recommendedData; + const [getAssigneeEntities, { data: resolvedAssigneeEntities }] = useGetEntitiesLazyQuery(); + const entityRegistry = useEntityRegistryV2(); + + useEffect(() => { + if (data?.assignees?.length) { + getAssigneeEntities({ + variables: { + urns: data?.assignees?.map((assignee) => assignee.urn), + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + // Prepare Options + const ownerSearchOptions = useMemo(() => { + const newOwnerResult: any[] = [...ownerResult]; + if (resolvedAssigneeEntities?.entities?.length) { + newOwnerResult.push(...(resolvedAssigneeEntities?.entities || [])); + } + const options = + newOwnerResult?.map((entity) => ({ + value: entity.urn, + label: entityRegistry.getDisplayName(entity.type, entity), + entity, + })) || []; + const uniqueOptions = _.uniqBy(options, 'value'); + return uniqueOptions; + }, [ownerResult, entityRegistry, resolvedAssigneeEntities]); + + useEffect(() => { + if (resolvedAssigneeEntities?.entities?.length) { + const options = resolvedAssigneeEntities.entities.map((entity: any) => ({ + value: entity?.urn, + label: entity.properties?.displayName, + entity, + })); + setSelectedAssigneeOptions(options); + const assigneeWithCacheData = resolvedAssigneeEntities.entities.map((entity) => ({ + ...entity, + status: 'ACTIVE', + })); + setCachedAssignees(assigneeWithCacheData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resolvedAssigneeEntities]); + + const onUpdateAssignees = (values: string[]) => { + const newSelectedAssigneeOptions: SelectOption[] = []; + values.forEach((value) => { + const alreadySelected = newSelectedAssigneeOptions.some((item) => item?.value === value); + + if (!alreadySelected) { + const option = + ownerSearchOptions.find((o) => o?.value === value) || + selectedAssigneeOptions.find((o) => o?.value === value); + + if (option) { + newSelectedAssigneeOptions.push(option as SelectOption); + } + } + }); + + setSelectedAssigneeOptions(newSelectedAssigneeOptions); + setAssigneeList(newSelectedAssigneeOptions.map((a) => a.value)); + const updatedAssignees = newSelectedAssigneeOptions.map((assignee) => ({ + ...assignee.entity, + status: 'ACTIVE', + })); + setCachedAssignees(updatedAssignees); + form.setFieldValue( + 'assigneeUrns', + newSelectedAssigneeOptions.map((a) => a.value), + ); + }; + + const handleSearch = (type: EntityType, text: string) => { + if (text) { + userSearch({ + variables: { + input: { type, query: text, limit: 10 }, + }, + }); + setUseSearch(true); + } else { + setUseSearch(false); + } + }; + return recommendationsLoading ? ( + <LoadingWrapper> + <LoadingOutlined /> + </LoadingWrapper> + ) : ( + <SimpleSelect + options={ownerSearchOptions} + isMultiSelect + placeholder="Select assignee" + width="full" + optionListStyle={{ + maxHeight: '20vh', + overflow: 'auto', + }} + onUpdate={onUpdateAssignees} + values={assigneeList} + onSearchChange={(value: string) => handleSearch(EntityType.CorpUser, value)} + showSearch + combinedSelectedAndSearchOptions={_.uniqBy([...ownerSearchOptions, ...selectedAssigneeOptions], 'value')} + renderCustomSelectedValue={renderSelectedAssignee} + renderCustomOptionText={renderCustomAssigneeOption} + selectLabelProps={{ variant: 'custom' }} + optionListTestId="incident-assignees-options-list" + data-testid="incident-assignees-select-input-type" + ignoreMaxHeight + /> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentDetailDrawer.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentDetailDrawer.tsx new file mode 100644 index 00000000000000..856483e9791fb9 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentDetailDrawer.tsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; + +import { Drawer, Modal } from 'antd'; +import ClickOutside from '@src/app/shared/ClickOutside'; +import { Incident } from '@src/types.generated'; +import { IncidentDrawerHeader } from './IncidentDrawerHeader'; +import { IncidentView } from './IncidentView'; +import { IncidentEditor } from './IncidentEditor'; +import { IncidentTableRow } from '../types'; +import { IncidentAction } from '../constant'; + +const modalBodyStyle = { padding: 0, fontFamily: 'Mulish, sans-serif' }; + +type IncidentDetailDrawerProps = { + urn: string; + mode: IncidentAction; + incident?: IncidentTableRow; + onCancel?: () => void; + onSubmit?: (incident?: Incident) => void; +}; + +export const IncidentDetailDrawer = ({ mode, onCancel, onSubmit, incident }: IncidentDetailDrawerProps) => { + const [isEditView, setIsEditView] = useState<boolean>(false); + const showEditor = isEditView || mode === IncidentAction.ADD; + const modalClosePopup = () => { + if (showEditor) { + Modal.confirm({ + title: 'Exit Editor', + content: `Are you sure you want to exit the editor? All changes will be lost`, + onOk() { + onCancel?.(); + }, + onCancel() {}, + okText: 'Yes', + maskClosable: true, + closable: true, + }); + } else { + onCancel?.(); + } + }; + return ( + <ClickOutside onClickOutside={modalClosePopup} wrapperClassName="incident-monitor-builder-modal"> + <Drawer + width={600} + placement="right" + closable={false} + visible + bodyStyle={modalBodyStyle} + onClose={modalClosePopup} + > + <IncidentDrawerHeader + mode={mode} + onClose={onCancel} + isEditActive={isEditView} + setIsEditActive={setIsEditView} + data={incident} + /> + {showEditor ? ( + <IncidentEditor + onClose={onCancel} + data={incident} + mode={mode} + incidentUrn={incident?.urn} + onSubmit={onSubmit} + /> + ) : ( + <IncidentView incident={incident as IncidentTableRow} /> + )} + </Drawer> + </ClickOutside> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentDrawerHeader.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentDrawerHeader.tsx new file mode 100644 index 00000000000000..17c8caf7a42fe0 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentDrawerHeader.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Link as LinkIcon, PencilSimpleLine, X } from '@phosphor-icons/react'; +import styled from 'styled-components'; +import { Tooltip2 } from '@src/alchemy-components/components/Tooltip2'; +import { useIncidentURNCopyLink } from '../hooks'; +import { IncidentAction } from '../constant'; +import { IncidentTableRow } from '../types'; +import { StyledHeader, StyledHeaderActions, StyledTitle } from './styledComponents'; + +const EditButton = styled(PencilSimpleLine)` + :hover { + cursor: pointer; + } +`; + +const CloseButton = styled(X)` + :hover { + cursor: pointer; + } +`; + +const LinkButton = styled(LinkIcon)` + :hover { + cursor: pointer; + } +`; + +type IncidentDrawerHeaderProps = { + mode: IncidentAction; + onClose?: () => void; + isEditActive: boolean; + setIsEditActive: React.Dispatch<React.SetStateAction<boolean>>; + data?: IncidentTableRow; +}; + +export const IncidentDrawerHeader = ({ + mode, + onClose, + isEditActive, + setIsEditActive, + data, +}: IncidentDrawerHeaderProps) => { + const handleIncidentLinkCopy = useIncidentURNCopyLink(data ? data?.urn : ''); + return ( + <StyledHeader> + <StyledTitle>{mode === IncidentAction.ADD ? 'Create New Incident' : data?.title}</StyledTitle> + <StyledHeaderActions> + {mode === IncidentAction.VIEW && isEditActive === false && ( + <> + <Tooltip2 title="Edit Incident"> + <EditButton + size={20} + onClick={() => setIsEditActive(!isEditActive)} + data-testid="edit-incident-icon" + /> + </Tooltip2> + <Tooltip2 title="Copy Link"> + <LinkButton size={20} onClick={handleIncidentLinkCopy} /> + </Tooltip2> + </> + )} + <CloseButton size={20} onClick={() => onClose?.()} data-testid="incident-drawer-close-button" /> + </StyledHeaderActions> + </StyledHeader> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentEditor.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentEditor.tsx new file mode 100644 index 00000000000000..ea21ad20dd76e8 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentEditor.tsx @@ -0,0 +1,253 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { IncidentStage, IncidentState, IncidentType } from '@src/types.generated'; +import { Input } from '@src/alchemy-components'; +import colors from '@src/alchemy-components/theme/foundations/colors'; +import { Editor } from '@src/alchemy-components/components/Editor/Editor'; +import { useUserContext } from '@src/app/context/useUserContext'; +import { Form } from 'antd'; + +import { + INCIDENT_CATEGORIES, + INCIDENT_OPTION_LABEL_MAPPING, + INCIDENT_PRIORITIES, + INCIDENT_STAGES, + INCIDENT_STATES, + IncidentAction, +} from '../constant'; +import { getAssigneeWithURN, getLinkedAssetsData, validateForm } from '../utils'; +import { + IncidentFooter, + SelectFormItem, + SaveButton, + StyledForm, + StyledFormElements, + InputFormItem, + StyledSpinner, +} from './styledComponents'; +import { IncidentEditorProps } from '../types'; +import { IncidentLinkedAssetsList } from './IncidentLinkedAssetsList'; +import { IncidentSelectField } from './IncidentSelectedField'; +import { IncidentAssigneeSelector } from './IncidentAssigneeSelector'; +import { useIncidentHandler } from './hooks/useIncidentHandler'; + +export const IncidentEditor = ({ + incidentUrn, + onSubmit, + data, + onClose, + mode = IncidentAction.ADD, +}: IncidentEditorProps) => { + const assigneeValues = data?.assignees && getAssigneeWithURN(data.assignees); + const isFormValid = Boolean(data?.title?.length && data?.description && data?.type && data?.customType); + const { user } = useUserContext(); + const userHasChangedState = useRef(false); + const isFirstRender = useRef(true); + const [cachedAssignees, setCachedAssignees] = useState<any>([]); + const [cachedLinkedAssets, setCachedLinkedAssets] = useState<any>([]); + const [isLoadingAssigneeOrAssets, setIsLoadingAssigneeOrAssets] = useState(true); + + const [isRequiredFieldsFilled, setIsRequiredFieldsFilled] = useState<boolean>( + mode === IncidentAction.VIEW ? !isFormValid : false, + ); + + const { handleSubmit, form, isLoading } = useIncidentHandler({ + incidentUrn, + mode, + onSubmit, + onClose, + user, + assignees: cachedAssignees, + linkedAssets: cachedLinkedAssets, + }); + const formValues = Form.useWatch([], form); + + useEffect(() => { + // Set the initial value for the custom category field when it becomes visible + if (formValues?.type === IncidentType.Custom) { + if (form.getFieldValue('customType') === '') form.setFieldValue('customType', data?.customType || ''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formValues]); + + useEffect(() => { + // Skip effect on first render + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + + // Ensure we don't override user's choice if they manually change the state + if ( + mode === IncidentAction.VIEW && + (formValues?.status === IncidentStage.Fixed || formValues?.status === IncidentStage.NoActionRequired) && + formValues?.state !== IncidentState.Resolved + ) { + form.setFieldValue('state', IncidentState.Resolved); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formValues?.status]); + + const handleValuesChange = (changedValues: any) => { + Object.keys(changedValues).forEach((fieldName) => form.setFields([{ name: fieldName, errors: [] }])); + // Update custom type status based on its value + const changedFormValues = form.getFieldsValue(); + const { title, description, type, customType } = changedFormValues; + + // Mark that user manually changed the state + if ('state' in changedValues) { + userHasChangedState.current = true; + } + + if (title?.trim() && description?.trim() && type && (type !== IncidentType.Custom || customType?.trim())) { + setIsRequiredFieldsFilled(true); // Enable the button + } else { + setIsRequiredFieldsFilled(false); // Disable the button + } + }; + + const actionButtonLabel = mode === IncidentAction.ADD ? 'Create' : 'Update'; + const showCustomCategory = form.getFieldValue('type') === IncidentType.Custom; + const isLinkedAssetPresent = !formValues?.resourceUrns?.length; + const isSubmitButtonDisabled = + !validateForm(form) || + !isRequiredFieldsFilled || + isLoadingAssigneeOrAssets || + isLinkedAssetPresent || + isLoading; + + return ( + <StyledForm + form={form} + layout="vertical" + onFinish={handleSubmit} + onValuesChange={handleValuesChange} + initialValues={{ + title: data?.title || '', + description: data?.description || '', + type: data?.type || '', + priority: data?.priority || '', + status: data?.stage || '', + customType: data?.customType || '', + state: data?.state || '', + message: data?.message || '', + }} + > + <StyledFormElements data-testid="incident-editor-form-container"> + <InputFormItem label="Name" name="title"> + <Input + label="" + placeholder="Provide a name..." + inputTestId="incident-name-input" + color={colors.gray[600]} + /> + </InputFormItem> + <InputFormItem label="Description" name="description"> + <Editor + doNotFocus + className="add-incident-description" + placeholder="Provide a description..." + content={mode === IncidentAction.VIEW ? data?.description : ''} + /> + </InputFormItem> + <IncidentSelectField + incidentLabelMap={INCIDENT_OPTION_LABEL_MAPPING.category} + options={INCIDENT_CATEGORIES} + onUpdate={(value) => { + if (value !== IncidentType.Custom) { + form.setFieldValue('customType', ''); + } + }} + form={form} + isDisabled={mode === IncidentAction.VIEW} + handleValuesChange={handleValuesChange} + value={formValues?.[INCIDENT_OPTION_LABEL_MAPPING.category.fieldName]} + /> + {showCustomCategory && ( + <SelectFormItem label="Custom Category" name="customType"> + <Input + label="" + placeholder="Enter category name..." + required + styles={{ + width: '50%', + }} + id="custom-incident-type-input" + /> + </SelectFormItem> + )} + <IncidentSelectField + incidentLabelMap={INCIDENT_OPTION_LABEL_MAPPING.priority} + options={INCIDENT_PRIORITIES} + form={form} + handleValuesChange={handleValuesChange} + value={formValues?.[INCIDENT_OPTION_LABEL_MAPPING.priority.fieldName]} + /> + <IncidentSelectField + incidentLabelMap={INCIDENT_OPTION_LABEL_MAPPING.stage} + options={INCIDENT_STAGES} + form={form} + handleValuesChange={handleValuesChange} + value={formValues?.[INCIDENT_OPTION_LABEL_MAPPING.stage.fieldName]} + /> + <SelectFormItem label="Assignees" name="assigneeUrns" initialValue={assigneeValues || []}> + <IncidentAssigneeSelector form={form} data={data} setCachedAssignees={setCachedAssignees} /> + </SelectFormItem> + <SelectFormItem + label="Linked Assets" + name="resourceUrns" + initialValue={getLinkedAssetsData(data?.linkedAssets) || []} + > + <IncidentLinkedAssetsList + form={form} + data={data} + mode={mode} + setCachedLinkedAssets={setCachedLinkedAssets} + setIsLinkedAssetsLoading={setIsLoadingAssigneeOrAssets} + /> + </SelectFormItem> + {mode === IncidentAction.VIEW && ( + <IncidentSelectField + incidentLabelMap={INCIDENT_OPTION_LABEL_MAPPING.state} + options={INCIDENT_STATES} + form={form} + handleValuesChange={handleValuesChange} + showClear={false} + value={formValues?.[INCIDENT_OPTION_LABEL_MAPPING.state.fieldName]} + /> + )} + {form.getFieldValue('state') === IncidentState.Resolved && ( + <SelectFormItem + label="Resolution Note" + name="message" + rules={[{ required: false }]} + customStyle={{ + color: colors.gray[600], + }} + > + <Input + label="" + placeholder="Add a resolution note......" + styles={{ + width: '50%', + }} + id="incident-message" + /> + </SelectFormItem> + )} + </StyledFormElements> + <IncidentFooter> + <SaveButton data-testid="incident-create-button" type="submit" disabled={isSubmitButtonDisabled}> + {/* {actionButtonLabel} */} + {isLoading ? ( + <> + <StyledSpinner /> + {actionButtonLabel === 'Create' ? 'Creating...' : 'Updating...'} + </> + ) : ( + actionButtonLabel + )} + </SaveButton> + </IncidentFooter> + </StyledForm> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentLinkedAssetsList.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentLinkedAssetsList.tsx new file mode 100644 index 00000000000000..546e72bc6b9a01 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentLinkedAssetsList.tsx @@ -0,0 +1,159 @@ +import React, { useEffect, useState } from 'react'; +import { LoadingOutlined } from '@ant-design/icons'; +import { Button, Pill } from '@src/alchemy-components'; +import { Plus } from 'phosphor-react'; +import styled from 'styled-components'; +import { useGetEntitiesLazyQuery } from '@src/graphql/entity.generated'; +import { EntityCapabilityType } from '@src/app/entityV2/Entity'; +import { useEntityRegistryV2 } from '@src/app/useEntityRegistry'; +import { useEntityData } from '@src/app/entity/shared/EntityContext'; +import { IncidentLinkedAssetsListProps } from '../types'; +import { AssetWrapper, LinkedAssets, LoadingWrapper } from './styledComponents'; +import { LinkedAssetsContainer } from '../styledComponents'; +import { SearchSelectModal } from '../../../components/styled/search/SearchSelectModal'; +import { IncidentAction } from '../constant'; + +const RESOURCE_URN_FIELD_NAME = 'resourceUrns'; + +const StyledButton = styled(Button)` + padding: 4px; + margin-top: 8px; +`; + +export const IncidentLinkedAssetsList = ({ + form, + data, + mode, + setCachedLinkedAssets, + setIsLinkedAssetsLoading, +}: IncidentLinkedAssetsListProps) => { + const { urn } = useEntityData(); + const [getEntities, { data: resolvedLinkedAssets, loading: entitiesLoading }] = useGetEntitiesLazyQuery(); + const entityRegistry = useEntityRegistryV2(); + + const [linkedAssets, setLinkedAssets] = useState<any[]>(data?.linkedAssets || []); + const [isBatchAddAssetListModalVisible, setIsBatchAddAssetListModalVisible] = useState(false); + + useEffect(() => { + form.setFieldValue( + RESOURCE_URN_FIELD_NAME, + linkedAssets?.map((asset) => asset?.urn), + ); + }, [linkedAssets, form]); + + useEffect(() => { + setCachedLinkedAssets(linkedAssets); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [linkedAssets]); + + const removeLinkedAsset = (asset) => { + console.log('Removing linked asset '); + const selectedAssets = linkedAssets?.filter((existingAsset: any) => existingAsset.urn !== asset.urn); + setLinkedAssets(selectedAssets as any); + }; + + const batchAddAssets = (entityUrns: Array<string>) => { + const updatedUrns = [...form.getFieldValue(RESOURCE_URN_FIELD_NAME), ...entityUrns]; + const uniqueUrns = [...new Set(updatedUrns)]; + form.setFieldValue(RESOURCE_URN_FIELD_NAME, uniqueUrns); + setIsBatchAddAssetListModalVisible(false); + getEntities({ + variables: { urns: form.getFieldValue(RESOURCE_URN_FIELD_NAME) }, + }); + }; + + useEffect(() => { + if (mode === IncidentAction.ADD) { + getEntities({ + variables: { + urns: [urn], + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + setLinkedAssets(resolvedLinkedAssets?.entities as any); + if (mode === IncidentAction.ADD) { + form.setFieldValue(RESOURCE_URN_FIELD_NAME, [urn]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resolvedLinkedAssets]); + + useEffect(() => { + if (data?.linkedAssets?.length) { + getEntities({ + variables: { + urns: data?.linkedAssets?.map((asset) => asset.urn), + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + useEffect(() => { + setIsLinkedAssetsLoading(entitiesLoading); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entitiesLoading]); + + return ( + <> + <AssetWrapper> + {entitiesLoading && ( + <LoadingWrapper> + <LoadingOutlined /> + </LoadingWrapper> + )} + {form.getFieldValue(RESOURCE_URN_FIELD_NAME)?.length > 0 && + (entitiesLoading ? ( + <LoadingWrapper> + <LoadingOutlined /> + </LoadingWrapper> + ) : ( + <LinkedAssetsContainer> + <LinkedAssets data-testid="drawer-incident-linked-assets"> + {linkedAssets?.map((asset: any) => { + return ( + <Pill + key={asset.urn} + label={asset?.properties?.name} + rightIcon="Close" + color="violet" + variant="outline" + onClickRightIcon={() => { + console.log('Clicked to remove'); + removeLinkedAsset(asset); + }} + clickable + /> + ); + })} + </LinkedAssets> + </LinkedAssetsContainer> + ))} + {!entitiesLoading && ( + <StyledButton + variant="outline" + type="button" + data-testid="add-linked-asset-incident-button" + onClick={() => setIsBatchAddAssetListModalVisible(true)} + > + <Plus /> + </StyledButton> + )} + </AssetWrapper> + {isBatchAddAssetListModalVisible && ( + <SearchSelectModal + titleText="Link assets to incident" + continueText="Add" + onContinue={batchAddAssets} + onCancel={() => setIsBatchAddAssetListModalVisible(false)} + fixedEntityTypes={Array.from( + entityRegistry.getTypesWithSupportedCapabilities(EntityCapabilityType.GLOSSARY_TERMS), + )} + /> + )} + </> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentSelectedField.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentSelectedField.tsx new file mode 100644 index 00000000000000..32328fe82ea65d --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentSelectedField.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { SelectOption } from '@src/alchemy-components/components/Select/Nested/types'; +import { IncidentPriorityLabel } from '@src/alchemy-components/components/IncidentPriorityLabel'; +import { IncidentStagePill } from '@src/alchemy-components/components/IncidentStagePill'; +import { colors, SimpleSelect } from '@src/alchemy-components'; +import { IncidentState } from '@src/types.generated'; +import { Check, Warning } from '@phosphor-icons/react'; +import { IconLabel } from '@src/alchemy-components/components/IconLabel'; +import { getCapitalizeWord } from '@src/alchemy-components/components/IncidentStagePill/utils'; +import { IconType } from '@src/alchemy-components/components/IconLabel/types'; +import { FormInstance } from 'antd/es/form/Form'; + +import { INCIDENT_OPTION_LABEL_MAPPING } from '../constant'; +import { SelectFormItem, SelectWrapper } from './styledComponents'; +import { IncidentConstant } from '../types'; + +const IncidentStates = { + [IncidentState.Active]: { + label: IncidentState.Active, + icon: <Warning color={colors.red[1200]} width={17} height={15} />, + }, + [IncidentState.Resolved]: { + label: IncidentState.Resolved, + icon: <Check color={colors.green[1200]} width={17} height={15} />, + }, +}; + +interface IncidentSelectFieldProps { + incidentLabelMap: { + label: string; + name: string; + fieldName: string; + }; + options: Array<any>; + onUpdate?: (value: string) => void; + form: FormInstance; + handleValuesChange: (values: any) => void; + showClear?: boolean; + width?: string; + customStyle?: React.CSSProperties; + isDisabled?: boolean; + value?: string; +} + +export const IncidentSelectField = ({ + incidentLabelMap, + options, + onUpdate, + form, + handleValuesChange, + showClear = true, + width, + customStyle, + isDisabled, + value, +}: IncidentSelectFieldProps) => { + const { label, name } = incidentLabelMap; + const placeholder = label.toLowerCase() === IncidentConstant.PRIORITY ? 'priority level' : label.toLowerCase(); + const isRequiredField = label.toLowerCase() === IncidentConstant.CATEGORY; + const renderOption = (option: SelectOption) => { + switch (label) { + case INCIDENT_OPTION_LABEL_MAPPING.category.label: + return option.label; + case INCIDENT_OPTION_LABEL_MAPPING.state.label: + return ( + <IconLabel + name={getCapitalizeWord(IncidentStates[option.value]?.label)} + icon={IncidentStates[option.value]?.icon} + type={IconType.ICON} + /> + ); + case INCIDENT_OPTION_LABEL_MAPPING.priority.label: + return ( + <IncidentPriorityLabel + style={{ + width: '22px', + justifyContent: 'center', + }} + priority={option.value} + title={option.label} + /> + ); + case INCIDENT_OPTION_LABEL_MAPPING.stage.label: + return <IncidentStagePill stage={option.value} />; + default: + return null; + } + }; + + const renderSelectedValue = (selectedOption: SelectOption) => { + if (!selectedOption) { + return null; + } + switch (label) { + case INCIDENT_OPTION_LABEL_MAPPING.category.label: + return selectedOption?.label; + case INCIDENT_OPTION_LABEL_MAPPING.state.label: + return ( + <IconLabel + name={getCapitalizeWord(IncidentStates[selectedOption?.value]?.label)} + icon={IncidentStates[selectedOption?.value]?.icon} + type={IconType.ICON} + /> + ); + case INCIDENT_OPTION_LABEL_MAPPING.priority.label: + return ( + <IncidentPriorityLabel + style={{ + width: 'auto', + }} + priority={selectedOption?.value} + title={selectedOption?.label} + /> + ); + case INCIDENT_OPTION_LABEL_MAPPING.stage.label: + return <IncidentStagePill stage={selectedOption?.value} />; + default: + return null; + } + }; + + return ( + <SelectFormItem + label={label} + name={name} + rules={[{ required: isRequiredField, message: `Please select ${label.toLowerCase()}!` }]} + initialValue={value} + customStyle={customStyle} + > + <SelectWrapper style={{ width: width ?? '50%' }}> + <SimpleSelect + options={options} + values={value ? [value] : []} + onUpdate={(selectedValues: string[]) => { + onUpdate?.(selectedValues[0]); + form.setFieldsValue({ ...form.getFieldsValue(), [name]: selectedValues[0] }); + handleValuesChange(form.getFieldsValue()); + }} + placeholder={`Select ${placeholder}`} + size="md" + showClear={showClear} + width={10} + renderCustomOptionText={renderOption} + renderCustomSelectedValue={renderSelectedValue} + selectLabelProps={{ variant: 'custom' }} + position="start" + optionListTestId={`${label?.toLocaleLowerCase()}-options-list`} + data-testid={`${label?.toLocaleLowerCase()}-select-input-type`} + isDisabled={isDisabled} + /> + </SelectWrapper> + </SelectFormItem> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentView.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentView.tsx new file mode 100644 index 00000000000000..80726ef151604c --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentView.tsx @@ -0,0 +1,258 @@ +import React, { useEffect, useState } from 'react'; +import { useHistory } from 'react-router'; +import styled from 'styled-components'; +import { IncidentStagePill } from '@src/alchemy-components/components/IncidentStagePill'; +import { getCapitalizeWord } from '@src/alchemy-components/components/IncidentStagePill/utils'; +import { EntityLinkList } from '@src/app/homeV2/reference/sections/EntityLinkList'; +import { IncidentPriorityLabel } from '@src/alchemy-components/components/IncidentPriorityLabel'; +import { Avatar } from '@src/alchemy-components'; +import { + Assertion, + AssertionInfo, + CorpUser, + EntityType, + IncidentSourceType, + IncidentState, +} from '@src/types.generated'; +import { Check, Warning } from '@phosphor-icons/react'; +import { IconLabel } from '@src/alchemy-components/components/IconLabel'; +import { IconType } from '@src/alchemy-components/components/IconLabel/types'; +import colors from '@src/alchemy-components/theme/foundations/colors'; +import { useGetEntitiesLazyQuery } from '@src/graphql/entity.generated'; +import { useEntityRegistry } from '@src/app/useEntityRegistry'; +import { + CategoryText, + Container, + DescriptionSection, + DetailsLabel, + DetailsSection, + Divider, + Header, + ListContainer, + ListItemContainer, + Text, +} from './styledComponents'; +import CompactMarkdownViewer from '../../Documentation/components/CompactMarkdownViewer'; +import { getAssigneeNamesWithAvatarUrl } from '../utils'; +import { IncidentTableRow } from '../types'; +import { getPlainTextDescriptionFromAssertion } from '../../Dataset/Validations/assertion/profile/summary/utils'; +import { INCIDENT_STATE_TO_ACTIVITY, DEFAULT_MAX_ENTITIES_TO_SHOW } from './constant'; +import { IncidentActivitySection } from './IncidentActivitySection'; +import { getOnOpenAssertionLink } from '../hooks'; + +const ThinDivider = styled(Divider)` + margin: 12px 0px; + border-color: ${colors.gray[100]}; +`; + +const IncidentStates = { + [IncidentState.Active]: { + label: IncidentState.Active, + icon: <Warning color={colors.red[1200]} width={20} height={20} />, + }, + [IncidentState.Resolved]: { + label: IncidentState.Resolved, + icon: <Check color={colors.green[1200]} width={20} height={20} />, + }, +}; + +export const IncidentView = ({ incident }: { incident: IncidentTableRow }) => { + const entityRegistry = useEntityRegistry(); + const history = useHistory(); + const [getAssigneeEntities, { data: resolvedAssignees, loading }] = useGetEntitiesLazyQuery(); + + const [isDescriptionOpen, setDescriptionOpen] = useState<boolean>(true); + const [incidentCreator, setIncidentCreator] = useState<CorpUser | any>(); + const [incidentResolver, setIncidentResolver] = useState<CorpUser | any>(); + + const [entityCount, setEntityCount] = useState(DEFAULT_MAX_ENTITIES_TO_SHOW); + + const { type, source } = incident.source || {}; + const { label, icon } = IncidentStates[incident?.state] || {}; + + const incidentActivityActors = { + creator: incident?.creator?.actor, + lastUpdated: incident?.lastUpdated?.actor, + }; + + useEffect(() => { + // Dynamically construct the urns array + const urns: any = []; + + if (incidentActivityActors.creator) urns.push(incidentActivityActors?.creator); + if (incidentActivityActors.lastUpdated) urns.push(incidentActivityActors?.lastUpdated); + + if (urns?.length) { + getAssigneeEntities({ + variables: { + urns, + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [incident?.creator?.actor, incident?.lastUpdated?.actor]); + + useEffect(() => { + if (resolvedAssignees?.entities?.length) { + resolvedAssignees.entities.forEach((entity) => { + if (incidentActivityActors.creator === incidentActivityActors.lastUpdated) { + setIncidentCreator(entity); + setIncidentResolver(entity); + } else if (entity?.urn === incidentActivityActors.creator) { + setIncidentCreator(entity); + } else if (entity?.urn === incidentActivityActors.lastUpdated) { + setIncidentResolver(entity); + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resolvedAssignees]); + + const navigateToUser = (user) => { + history.push(`${entityRegistry.getEntityUrl(EntityType.CorpUser, user.urn)}`); + }; + + const renderAvatar = (assignee) => { + return ( + <Avatar + name={assignee?.name} + imageUrl={assignee?.imageUrl} + showInPill + onClick={() => navigateToUser(assignee)} + /> + ); + }; + + const renderAssignees = (assignees) => { + return assignees?.map((assignee) => { + return <ListItemContainer key={assignee?.name}>{renderAvatar(assignee)}</ListItemContainer>; + }); + }; + + const renderActivities = (() => { + // TODO Amit: Move this into a separate utilities file. + const { creator, lastUpdated } = incidentActivityActors; + const isCreatedAndUpdatedBySameUser = creator === lastUpdated; + + if (isCreatedAndUpdatedBySameUser) { + if (incident?.state === IncidentState.Resolved) { + return [ + { + actor: incidentCreator, + action: INCIDENT_STATE_TO_ACTIVITY.RAISED, + time: incident?.creator?.time, + }, + { + actor: incidentResolver, + action: INCIDENT_STATE_TO_ACTIVITY.RESOLVED, + time: incident?.lastUpdated?.time, + }, + ]; + } + + if (incident?.state === IncidentState.Active) { + return [ + { + actor: incidentCreator, + action: INCIDENT_STATE_TO_ACTIVITY.RAISED, + time: incident?.creator?.time, + }, + ]; + } + } + + return [ + { + actor: incidentCreator, + action: INCIDENT_STATE_TO_ACTIVITY.RAISED, + time: incident?.creator?.time, + }, + { + actor: incidentResolver, + action: INCIDENT_STATE_TO_ACTIVITY.RESOLVED, + time: incident?.lastUpdated?.time, + }, + ]; + })(); + + /** Assertion Related Logic. */ + const isAssertionFailureIncident = type === IncidentSourceType.AssertionFailure; + const assertion = source as Assertion; + const onClickAssertion = getOnOpenAssertionLink(assertion?.urn); + + let assertionDescription = ''; + if (isAssertionFailureIncident && source) { + assertionDescription = getPlainTextDescriptionFromAssertion(assertion.info as AssertionInfo); + } + + return ( + <Container> + <DescriptionSection> + <Header onClick={() => setDescriptionOpen(!isDescriptionOpen)}> + <DetailsLabel>Description</DetailsLabel> + <CompactMarkdownViewer + content={incident?.description || ''} + lineLimit={2} + fixedLineHeight + scrollableY={false} + /> + </Header> + </DescriptionSection> + <DetailsSection> + <DetailsLabel>Category</DetailsLabel> + <CategoryText>{incident?.type && getCapitalizeWord(incident?.type)}</CategoryText> + </DetailsSection> + <DetailsSection> + <DetailsLabel>Priority</DetailsLabel> + <IncidentPriorityLabel + priority={incident?.priority} + title={incident?.priority ? getCapitalizeWord(incident?.priority) : incident?.priority} + /> + </DetailsSection> + <DetailsSection> + <DetailsLabel>Stage</DetailsLabel> + <IncidentStagePill stage={incident?.stage} /> + </DetailsSection> + <DetailsSection> + <DetailsLabel>Assignees</DetailsLabel> + <ListContainer>{renderAssignees(getAssigneeNamesWithAvatarUrl(incident?.assignees))}</ListContainer> + </DetailsSection> + <DetailsSection> + <DetailsLabel>Linked Assets</DetailsLabel> + <ListContainer style={{ maxWidth: '30vw' }}> + <EntityLinkList + entities={incident?.linkedAssets?.slice(0, entityCount)} + showMore={incident?.linkedAssets?.length > entityCount} + loading={false} + showMoreCount={ + entityCount + DEFAULT_MAX_ENTITIES_TO_SHOW > incident?.linkedAssets?.length + ? incident?.linkedAssets?.length - entityCount + : DEFAULT_MAX_ENTITIES_TO_SHOW + } + onClickMore={() => setEntityCount(entityCount + DEFAULT_MAX_ENTITIES_TO_SHOW)} + /> + </ListContainer> + </DetailsSection> + <DetailsSection> + <DetailsLabel>State</DetailsLabel> + <CategoryText> + <IconLabel + style={{ paddingLeft: 8 }} + name={getCapitalizeWord(label)} + icon={icon} + type={IconType.ICON} + /> + </CategoryText> + </DetailsSection> + + {isAssertionFailureIncident ? ( + <DetailsSection> + <DetailsLabel>Raised By</DetailsLabel> + <Text onClick={() => onClickAssertion()}>{assertionDescription}</Text> + </DetailsSection> + ) : null} + <ThinDivider /> + <IncidentActivitySection loading={loading} renderActivities={renderActivities} /> + </Container> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/constant.ts b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/constant.ts new file mode 100644 index 00000000000000..d45bf9abcfaf63 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/constant.ts @@ -0,0 +1,6 @@ +export const DEFAULT_MAX_ENTITIES_TO_SHOW = 5; + +export const INCIDENT_STATE_TO_ACTIVITY = { + RAISED: 'Raised', + RESOLVED: 'Resolved', +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/hooks/useIncidentHandler.ts b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/hooks/useIncidentHandler.ts new file mode 100644 index 00000000000000..d10250930737ab --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/hooks/useIncidentHandler.ts @@ -0,0 +1,206 @@ +import { useState } from 'react'; +import { useEntityData } from '@src/app/entity/shared/EntityContext'; +import { useRaiseIncidentMutation, useUpdateIncidentMutation } from '@src/graphql/mutations.generated'; +import { EntityType, IncidentSourceType, IncidentState } from '@src/types.generated'; +import analytics, { EntityActionType, EventType } from '@src/app/analytics'; +import _ from 'lodash'; +import { Form, message } from 'antd'; +import { useApolloClient } from '@apollo/client'; +import handleGraphQLError from '@src/app/shared/handleGraphQLError'; +import { IncidentAction } from '../../constant'; +import { PAGE_SIZE, updateActiveIncidentInCache } from '../../incidentUtils'; + +export const getCacheIncident = ({ + values, + responseData, + user, + incidentUrn, +}: { + values: any; + responseData?: any; + user?: any; + incidentUrn?: string; +}) => { + const newIncident = { + urn: incidentUrn ?? responseData?.data?.raiseIncident, + type: EntityType.Incident, + incidentType: values.type, + customType: values.customType || null, + title: values.title, + description: values.description, + startedAt: null, + tags: null, + status: { + state: values?.state, + stage: values?.stage, + message: values?.message || null, + lastUpdated: { + __typename: 'AuditStamp', + time: Date.now(), + actor: user?.urn, + }, + }, + source: { + type: IncidentSourceType.Manual, + source: { + urn: '', + type: 'Assertion', + platform: { + urn: '', + name: '', + properties: { displayName: '', logoUrl: '' }, + }, + }, + }, + linkedAssets: { + relationships: values.linkedAssets?.map((linkedAsset) => ({ + entity: { + ...linkedAsset, + }, + })), + }, + + priority: values.priority, + created: { + time: Date.now(), + actor: user?.urn, + }, + assignees: values.assignees, + }; + return newIncident; +}; + +export const useIncidentHandler = ({ mode, onSubmit, incidentUrn, onClose, user, assignees, linkedAssets }) => { + const [raiseIncidentMutation] = useRaiseIncidentMutation(); + const [updateIncidentMutation] = useUpdateIncidentMutation(); + const [form] = Form.useForm(); + const { urn, entityType } = useEntityData(); + const client = useApolloClient(); + const isAddIncidentMode = mode === IncidentAction.ADD; + + const handleAddIncident = async (input: any) => { + return raiseIncidentMutation({ + variables: { + input: { + ...input, + priority: input.priority || undefined, + status: { + ...input.status, + stage: input.status.stage || undefined, + }, + }, + }, + }); + }; + + const handleUpdateIncident = async (input: any, incidentUpdateUrn: string) => { + return updateIncidentMutation({ + variables: { + input: { + ...input, + priority: input.priority || null, + status: { + ...input.status, + stage: input.status.stage || null, + }, + }, + urn: incidentUpdateUrn, + }, + }); + }; + + const showMessage = (content: string) => { + message.success({ + content, + duration: 2, + }); + }; + + const finalizeSubmission = () => { + onSubmit?.(); + onClose?.(); + }; + + const handleSubmissionError = (error: any) => { + const action = isAddIncidentMode ? 'raise' : 'update'; + handleGraphQLError({ + error, + defaultMessage: `Failed to ${action} incident!`, + permissionMessage: `Unauthorized to ${action} incident.`, + }); + }; + + const [isLoading, setIsLoading] = useState(false); + const handleSubmit = async () => { + try { + setIsLoading(true); + + const values = form.getFieldsValue(); + const baseInput = { + ...values, + resourceUrn: urn, + status: { + stage: values.status, + state: values.state || IncidentState.Active, + message: values.message, + }, + }; + const newInput = _.omit(baseInput, ['state', 'message']); + const newUpdateInput = _.omit(newInput, ['resourceUrn', 'type']); + const input = !isAddIncidentMode ? newUpdateInput : newInput; + + if (isAddIncidentMode) { + const responseData: any = await handleAddIncident(input); + if (responseData) { + showMessage('Incident Added'); + } + const newIncident = getCacheIncident({ + values: { + ...values, + state: baseInput.status.state, + stage: baseInput.status.stage, + message: baseInput.status.message, + assignees, + linkedAssets, + }, + incidentUrn: responseData?.data?.raiseIncident, + user, + }); + updateActiveIncidentInCache(client, urn, newIncident, PAGE_SIZE); + analytics.event({ + type: EventType.EntityActionEvent, + entityType, + entityUrn: urn, + actionType: EntityActionType.AddIncident, + }); + } else if (incidentUrn) { + const updatedIncidentResponse: any = await handleUpdateIncident(input, incidentUrn); + if (updatedIncidentResponse?.data?.updateIncident) { + const updatedIncident = getCacheIncident({ + values: { + ...values, + state: baseInput.status.state, + stage: baseInput.status.stage || '', + message: baseInput.status.message, + priority: values.priority || null, + assignees, + linkedAssets, + }, + user, + incidentUrn, + }); + updateActiveIncidentInCache(client, urn, updatedIncident, PAGE_SIZE); + } + showMessage('Incident Updated'); + } + + finalizeSubmission(); + } catch (error: any) { + console.log('error>>>>>', error); + handleSubmissionError(error); + } finally { + setIsLoading(false); // Stop loading + } + }; + return { handleSubmit, form, isLoading }; +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/styledComponents.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/styledComponents.tsx new file mode 100644 index 00000000000000..f6f3f6d9f8e871 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/styledComponents.tsx @@ -0,0 +1,327 @@ +import { Form, Table } from 'antd'; +import styled, { keyframes } from 'styled-components'; +import { ANTD_GRAY, REDESIGN_COLORS } from '@src/app/entityV2/shared/constants'; +import { Button, colors } from '@src/alchemy-components'; + +export const IncidentListStyledTable = styled(Table)` + max-width: none; + &&& .ant-table-thead .ant-table-cell { + font-weight: 600; + font-size: 12px; + color: ${ANTD_GRAY[8]}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + &&& .ant-table-expanded-row > .ant-table-cell { + padding-left: 0px; + } + &&& .ant-table-tbody > tr > td > .ant-table-wrapper:only-child .ant-table, + .ant-table-tbody > tr > td > .ant-table-expanded-row-fixed > .ant-table-wrapper:only-child .ant-table { + margin-left: 0px; + } + && + .ant-table-thead + > tr + > th:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not( + [colspan] + )::before { + border: 1px solid ${ANTD_GRAY[4]}; + } + &&& .ant-table-thead > tr > th { + line-height: 5px; + } + &&& .ant-table-cell { + background-color: transparent; + } + + &&& .acryl-selected-incidents-table-row { + background-color: ${ANTD_GRAY[4]}; + } + + .group-header { + cursor: pointer; + background-color: ${ANTD_GRAY[3]}; + } + &&& .acryl-incidents-table-row { + cursor: pointer; + background-color: ${ANTD_GRAY[2]}; + :hover { + background-color: ${ANTD_GRAY[3]}; + } + } +`; + +export const ListContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: 5px; + overflow: auto; + @media (max-width: 768px) { + max-width: 50vw; + } + @media (max-width: 1024px) { + max-width: 35vw; + } + @media (max-width: 1440px) { + max-width: 15vw; + } + @media (max-width: 1680px) { + max-width: 22vw; + } + @media (max-width: 1920px) { + max-width: 20vw; + } + @media (min-width: 1920px) { + max-width: 15vw; + } +`; + +export const ListItemContainer = styled.div` + display: flex; + border-radius: 200px; + padding: 4px; + min-width: fit-content; +`; + +export const DescriptionSection = styled.div` + margin-bottom: 30px; +`; + +export const DetailsSection = styled.div` + margin-bottom: 30px; + line-height: 17.57px; + display: flex; + align-items: center; +`; + +export const DetailsLabel = styled.div` + margin-right: 60px; + width: 20%; + font-weight: 600; + color: ${colors.gray[1700]}; + font-size: 12px; +`; + +export const ActivitySection = styled.div` + display: flex; + flex-direction: column; +`; + +export const ActivityLabelSection = styled.div` + font-weight: 500; + font-size: 18px; + color: ${colors.gray[600]}; + padding: 8px 4px; + margin-bottom: 8px; +`; + +export const TimelineWrapper = styled.div` + margin-left: 12px; + margin-top: 16px; +`; + +export const ContentRow = styled.div` + display: flex; + flex-direction: column; +`; + +export const Content = styled.div` + position: relative; + display: flex; + flex-direction: column; + gap: 0px; + top: -8px; + margin-left: 11px; +`; + +export const ActivityStatusText = styled.div` + font-size: 14px; + color: ${colors.gray[600]}; + font-weight: 500; + a { + color: inherit; + &:hover { + color: inherit; + } + } +`; + +export const Header = styled.div` + display: flex; + align-items: center; + cursor: pointer; + margin-bottom: 1rem; +`; + +export const ToggleIcon = styled.span` + color: #666; +`; + +export const Divider = styled.div` + border-top: 1px solid #e0e0e0; + margin: 16px 0; +`; + +export const Container = styled.div` + padding: 20px; + width: 100%; +`; + +export const Text = styled.div` + font-size: 12px; + color: #0066cc; + text-decoration: underline; + &&:hover { + cursor: pointer; + } +`; + +export const CategoryText = styled.div` + font-size: 14px; + font-weight: 400; + color: ${REDESIGN_COLORS.TEXT_HEADING}; +`; + +export const SelectFormItem = styled(Form.Item)<{ customStyle?: React.CSSProperties }>` + width: auto !important; + .ant-form-item-row { + display: flex !important; + flex-direction: ${({ customStyle }) => customStyle?.flexDirection || 'row !important'}; + justify-content: space-between; + align-items: ${({ customStyle }) => customStyle?.alignItems || 'center'}; + flex-wrap: nowrap; + } + .ant-form-item-control { + width: auto !important; + } + .ant-form-item-label { + min-width: 25%; + } + .ant-form-item-label > label { + color: ${({ customStyle }) => customStyle?.color || `${colors.gray[600]} !important`}; + } + .ant-form-item-label > label.ant-form-item-required::before { + content: none; + } +`; + +export const StyledForm = styled(Form)` + max-width: 600px; +`; + +export const InputFormItem = styled(Form.Item)` + margin-bottom: 16px; + font-size: large; + font-weight: 500; + line-height: 20.08px; + text-align: left; + .ant-form-item-label > label { + color: ${colors.gray[600]} !important; + } +`; + +export const SaveButton = styled(Button)<{ disabled: boolean }>` + width: 100%; + height: 36px; + border-radius: 4px; + border: 1px solid ${colors.violet[500]}; + background-color: ${({ disabled }) => (disabled ? '#f9fafc' : colors.violet[500])}; + font-size: 16px; + font-weight: 600; + line-height: 22.59px; + justify-content: center; + color: ${({ disabled }) => (disabled ? '#8088a3' : '#ffffff')}; +`; + +export const IncidentFooter = styled.div` + width: 100%; + padding: 16px; + border-top: 1px solid #e9eaee; + box-shadow: 0px 0px 6px 0px #5d668b33; + max-height: 68px; + position: absolute; + bottom: 0; + background-color: white; +`; + +export const StyledFormElements = styled.div` + margin: 16px; + overflow-y: auto; + overflow-x: hidden; + height: 84vh; + font-size: large; + font-weight: 500; + line-height: 20.08px; + text-align: left; + @media (max-width: 1024px) { + height: 80vh; + } + @media (max-width: 1440px) { + height: 84vh; + } + padding: 4px; +`; + +export const SelectWrapper = styled.div``; +export const AssetWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +`; + +export const LoadingWrapper = styled.div` + display: flex; + justify-content: center; + margin: 5px; +`; + +export const LinkedAssets = styled.div` + display: flex; + gap: 5px; + flex-wrap: wrap; +`; + +export const StyledHeader = styled.div` + display: flex; + height: 64px; + align-items: center; + padding: 16px; + justify-content: space-between; + box-shadow: 0px 0px 6px 0px #5d668b33; +`; + +export const StyledHeaderActions = styled.div` + display: flex; + gap: 20px; + align-items: center; + color: ${colors.gray[1800]}; + cursor: pointer; +`; + +export const StyledTitle = styled.span` + font-size: large; + font-weight: 700; + line-height: 20.08px; + text-align: left; + color: ${colors.gray[600]}; +`; + +const spin = keyframes` + 100% { + transform: rotate(360deg); + } +`; + +export const StyledSpinner = styled.div` + width: 16px; + height: 16px; + margin-right: 8px; + border: 2px solid #8088a3; + color: #8088a3; + border-top: 2px solid transparent; + border-radius: 50%; + animation: ${spin} 0.8s linear infinite; + display: inline-block; +`; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentAssigneeAvatarStack.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentAssigneeAvatarStack.tsx new file mode 100644 index 00000000000000..80afede054d061 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentAssigneeAvatarStack.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { AvatarStack } from '@src/alchemy-components/components/AvatarStack/AvatarStack'; +import { MAX_VISIBLE_ASSIGNEE } from './constant'; +import { AssigneeAvatarStackContainer } from './styledComponents'; + +export const IncidentAssigneeAvatarStack = ({ assignees }: { assignees: any[] }) => { + return ( + <AssigneeAvatarStackContainer data-testid="incident-avatar-stack"> + <AvatarStack avatars={assignees?.slice(0, MAX_VISIBLE_ASSIGNEE)} /> + {assignees?.length > MAX_VISIBLE_ASSIGNEE && <span>{`+${assignees.length - MAX_VISIBLE_ASSIGNEE}`}</span>} + </AssigneeAvatarStackContainer> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentFilterContainer.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentFilterContainer.tsx new file mode 100644 index 00000000000000..024a6cb20cad8c --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentFilterContainer.tsx @@ -0,0 +1,109 @@ +import React, { useMemo } from 'react'; +import { IncidentTable } from './types'; +import { INCIDENT_DEFAULT_FILTERS, INCIDENT_GROUP_BY_FILTER_OPTIONS } from './constant'; +import { FilterSelect } from '../../FilterSelect'; +import { FiltersContainer, SearchFilterContainer, StyledFilterContainer } from './styledComponents'; +import { GroupBySelect } from '../../GroupBySelect'; +import { InlineListSearch } from '../../components/search/InlineListSearch'; + +interface FilterItem { + name: string; + category: string; + count: number; + displayName: string; +} + +interface IncidentAssigneeAvatarStack { + filteredIncidents: IncidentTable; + originalFilterOptions: any; + handleFilterChange: (filter: any) => void; + selectedFilters: any; +} + +export const IncidentFilterContainer: React.FC<IncidentAssigneeAvatarStack> = ({ + filteredIncidents, + originalFilterOptions, + handleFilterChange, + selectedFilters, +}) => { + const handleSearchTextChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const searchText = event.target.value; + handleFilterChange({ + ...selectedFilters, + filterCriteria: { ...selectedFilters.filterCriteria, searchText }, + }); + }; + + const handleIncidentGroupByChange = (value: string) => { + handleFilterChange({ ...selectedFilters, groupBy: value }); + }; + + const handleFilterOptionChange = (updatedFilters: FilterItem[]) => { + /** Set Recommended Filters when there is value in type,stage or priority if not then set it as empty to clear the filter */ + const selectedRecommendedFilters = updatedFilters.reduce<Record<string, string[]>>( + (acc, selectedfilter) => { + acc[selectedfilter.category] = acc[selectedfilter.category] || []; + acc[selectedfilter.category].push(selectedfilter.name); + return acc; + }, + { type: [], stage: [], priority: [], state: [] }, + ); + + handleFilterChange({ + ...selectedFilters, + filterCriteria: { ...selectedFilters.filterCriteria, ...selectedRecommendedFilters }, + }); + }; + + const initialSelectedOptions = useMemo(() => { + const recommendedFilters = originalFilterOptions?.recommendedFilters || []; + const { stage, type, priority, state } = + selectedFilters.filterCriteria || INCIDENT_DEFAULT_FILTERS.filterCriteria; + + const appliedRecommendedFilters = recommendedFilters.filter( + (item) => + (state.includes(item.name) && item.category === 'state') || + (stage.includes(item.name) && item.category === 'stage') || + (type.includes(item.name) && item.category === 'type') || + (priority.includes(item.name) && item.category === 'priority'), + ); + return appliedRecommendedFilters?.map((filter) => ({ + value: filter.name, + label: filter.displayName, + parentValue: filter.category, + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedFilters]); + + return ( + <SearchFilterContainer> + {/* ************Render Search Component ************************* */} + <InlineListSearch + searchText={selectedFilters.filterCriteria.searchText} + debouncedSetFilterText={handleSearchTextChange} + matchResultCount={filteredIncidents.searchMatchesCount || 0} + numRows={filteredIncidents.totalCount || 0} + entityTypeName="incident" + /> + + {/* ************Render Filter Component ************************* */} + <FiltersContainer> + <StyledFilterContainer> + <FilterSelect + filterOptions={originalFilterOptions?.filterGroupOptions || []} + onFilterChange={handleFilterOptionChange} + initialSelectedOptions={initialSelectedOptions} + /> + </StyledFilterContainer> + {/* ************Render Group By Component ************************* */} + <div> + <GroupBySelect + options={INCIDENT_GROUP_BY_FILTER_OPTIONS} + selectedValue={selectedFilters.groupBy} + onSelect={handleIncidentGroupByChange} + /> + </div> + </FiltersContainer> + </SearchFilterContainer> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentList.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentList.tsx new file mode 100644 index 00000000000000..c28b4bd366f92d --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentList.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useState } from 'react'; +import { Empty } from 'antd'; + +import { useGetEntityIncidentsQuery } from '../../../../../graphql/incident.generated'; +import { useEntityData } from '../../../../entity/shared/EntityContext'; +import { PAGE_SIZE } from './incidentUtils'; +import { EntityPrivileges, Incident } from '../../../../../types.generated'; +import { combineEntityDataWithSiblings } from '../../../../entity/shared/siblingUtils'; +import { useIsSeparateSiblingsMode } from '../../useIsSeparateSiblingsMode'; +import { IncidentTitleContainer } from './IncidentTitleContainer'; +import { IncidentListFilter, IncidentTable } from './types'; +import { INCIDENT_DEFAULT_FILTERS, IncidentAction } from './constant'; +import { IncidentFilterContainer } from './IncidentFilterContainer'; +import { IncidentListTable } from './IncidentListTable'; +import { getFilteredTransformedIncidentData } from './utils'; +import { IncidentDetailDrawer } from './AcrylComponents/IncidentDetailDrawer'; +import { IncidentListLoading } from './IncidentListLoading'; +import { getQueryParams } from '../Dataset/Validations/assertionUtils'; + +export const IncidentList = () => { + const { urn } = useEntityData(); + const [showIncidentBuilder, setShowIncidentBuilder] = useState(false); + const [visibleIncidents, setVisibleIncidents] = useState<IncidentTable>({ + incidents: [], + groupBy: { type: [], priority: [], stage: [], state: [] }, + }); + const [allIncidentData, setAllIncidentData] = useState<Incident[]>([]); + + const isSeparateSiblingsMode = useIsSeparateSiblingsMode(); + const incidentUrnParam = getQueryParams('incident_urn', window.location); + const incidentDefaultFilters = INCIDENT_DEFAULT_FILTERS; + if (incidentUrnParam) { + incidentDefaultFilters.filterCriteria.state = []; + } + + const [selectedFilters, setSelectedFilters] = useState<IncidentListFilter>(incidentDefaultFilters); + // Fetch filtered incidents. + const { loading, data, refetch } = useGetEntityIncidentsQuery({ + variables: { + urn, + start: 0, + count: PAGE_SIZE, + }, + fetchPolicy: 'cache-first', + }); + + // get filtered Incident as per the filter object + const getFilteredIncidents = (incidents: Incident[]) => { + const filteredIncidentData: IncidentTable = getFilteredTransformedIncidentData(incidents, selectedFilters); + setVisibleIncidents(filteredIncidentData); + }; + + useEffect(() => { + const combinedData = isSeparateSiblingsMode ? data : combineEntityDataWithSiblings(data); + const allIncidents = + (combinedData && + (combinedData as any).entity?.incidents?.incidents?.map((incident) => incident as Incident)) || + []; + setAllIncidentData(allIncidents); + getFilteredIncidents(allIncidents); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + useEffect(() => { + // after filter change need to get filtered incidents + if (allIncidentData?.length > 0) { + getFilteredIncidents(allIncidentData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedFilters]); + + const handleFilterChange = (filter) => { + setSelectedFilters(filter); + }; + + const privileges = (data?.entity as any)?.privileges as EntityPrivileges; + + const renderListTable = () => { + if (loading) { + return <IncidentListLoading />; + } + if ((visibleIncidents?.incidents || []).length > 0) { + return ( + <IncidentListTable + incidentData={visibleIncidents} + filter={selectedFilters} + refetch={() => { + refetch(); + }} + /> + ); + } + return <Empty description="No incidents yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />; + }; + return ( + <> + <IncidentTitleContainer privileges={privileges} setShowIncidentBuilder={setShowIncidentBuilder} /> + {allIncidentData?.length > 0 && !loading && ( + <IncidentFilterContainer + filteredIncidents={visibleIncidents} + originalFilterOptions={visibleIncidents?.originalFilterOptions} + handleFilterChange={handleFilterChange} + selectedFilters={selectedFilters} + /> + )} + {renderListTable()} + {showIncidentBuilder && ( + <IncidentDetailDrawer + urn={urn} + mode={IncidentAction.ADD} + onSubmit={() => { + setTimeout(() => { + refetch(); + }, 2000); + setShowIncidentBuilder(false); + }} + onCancel={() => setShowIncidentBuilder(false)} + /> + )} + </> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentListLoading.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentListLoading.tsx new file mode 100644 index 00000000000000..626f30fafdb8cc --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentListLoading.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +import { TableLoadingSkeleton } from '../../TableLoadingSkeleton'; + +export const IncidentListLoading = () => { + return <TableLoadingSkeleton />; +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentListTable.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentListTable.tsx new file mode 100644 index 00000000000000..6a1541d3443d29 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentListTable.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useState } from 'react'; +import { useEntityData } from '@src/app/entity/shared/EntityContext'; +import { Table } from '@src/alchemy-components'; +import { SortingState } from '@src/alchemy-components/components/Table/types'; +import { IncidentDetailDrawer } from './AcrylComponents/IncidentDetailDrawer'; +import { IncidentListFilter, IncidentTable, IncidentTableRow } from './types'; +import { useIncidentsTableColumns, useOpenIncidentDetailModal } from './hooks'; +import { getSiblingWithUrn } from '../Dataset/Validations/acrylUtils'; +import { StyledTableContainer } from './styledComponents'; +import { IncidentAction } from './constant'; +import { getSortedIncidents } from './utils'; +import { useGetExpandedTableGroupsFromEntityUrnInUrl } from '../../hooks'; + +type Props = { + incidentData: IncidentTable; + filter: IncidentListFilter; + refetch: () => void; +}; + +export const IncidentListTable = ({ incidentData, filter, refetch }: Props) => { + const { entityData } = useEntityData(); + const { groupBy } = filter; + + const { expandedGroupIds, setExpandedGroupIds } = useGetExpandedTableGroupsFromEntityUrnInUrl( + incidentData?.groupBy ? incidentData?.groupBy[groupBy] : [], + { isGroupBy: !!groupBy }, + 'incident_urn', + (group) => group.incidents, + ); + + // get columns data from the custom hooks + const incidentsTableCols = useIncidentsTableColumns(); + const [sortedOptions, setSortedOptions] = useState<{ sortColumn: string; sortOrder: SortingState }>({ + sortColumn: '', + sortOrder: SortingState.ORIGINAL, + }); + + const [focusIncidentUrn, setFocusIncidentUrn] = useState<string | null>(null); + const [focusIncidentData, setFocusIncidentData] = useState<IncidentTableRow>(); + + const focusedIncident = incidentData.incidents.find((incident) => incident.urn === focusIncidentUrn); + const focusedEntityUrn = focusedIncident ? entityData?.urn : undefined; + + const getGroupData = () => { + return (incidentData?.groupBy && incidentData?.groupBy[groupBy]) || []; + }; + + const updateIncidentData = (selectedURN: string) => { + const data = groupBy ? getGroupData() : incidentData.incidents || []; + const urnExists = data + .map((item) => item.incidents) + .flat() + .filter((incident) => selectedURN.includes(incident.urn)); + setFocusIncidentData(urnExists[0]); + }; + + useOpenIncidentDetailModal(setFocusIncidentUrn, updateIncidentData); + + const focusedIncidentEntity = + focusedEntityUrn && entityData ? getSiblingWithUrn(entityData, focusedEntityUrn) : undefined; + + useEffect(() => { + if (focusIncidentUrn && !focusedIncident) { + setFocusIncidentUrn(null); + setFocusIncidentData(undefined); + } + }, [focusIncidentUrn, focusedIncident]); + + const onIncidentExpand = (record) => { + const key = record.name; + setExpandedGroupIds((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); + }; + + const rowClassName = (record) => { + if (record.groupName) { + return 'group-header'; + } + if (record.urn === focusIncidentUrn) { + return 'acryl-selected-table-row'; + } + return 'acryl-incident-table-row'; + }; + + const onRowClick = (record) => { + setFocusIncidentData(record); + setFocusIncidentUrn(record.urn); + }; + + const rowDataTestId = (record) => { + return record.groupName ? `incident-group-${record.name}` : `incident-row-${record.title}`; + }; + + return ( + <> + <StyledTableContainer style={{ height: '100vh', overflow: 'hidden' }}> + <Table + columns={incidentsTableCols} + data={groupBy ? getGroupData() : incidentData.incidents || []} + showHeader + isScrollable + rowClassName={rowClassName} + handleSortColumnChange={({ + sortColumn, + sortOrder, + }: { + sortColumn: string; + sortOrder: SortingState; + }) => setSortedOptions({ sortColumn, sortOrder })} + expandable={{ + expandedRowRender: (record) => { + let sortedIncidents = record.incidents; + if (sortedOptions.sortColumn && sortedOptions.sortOrder) { + sortedIncidents = getSortedIncidents(record, sortedOptions); + } + return ( + <Table + columns={incidentsTableCols} + data={sortedIncidents} + showHeader={false} + isBorderless + isExpandedInnerTable + onRowClick={onRowClick} + rowClassName={rowClassName} + rowDataTestId={rowDataTestId} + /> + ); + }, + rowExpandable: () => !!groupBy, + expandIconPosition: 'end', + expandedGroupIds, + }} + onExpand={onIncidentExpand} + rowDataTestId={rowDataTestId} + /> + </StyledTableContainer> + {focusIncidentUrn && focusedIncidentEntity && ( + <IncidentDetailDrawer + urn={focusIncidentUrn} + mode={IncidentAction.VIEW} + incident={focusIncidentData} + onCancel={() => setFocusIncidentUrn(null)} + onSubmit={() => { + setTimeout(() => { + refetch(); + }, 2000); + setFocusIncidentUrn(null); + }} + /> + )} + </> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentResolutionPopup.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentResolutionPopup.tsx new file mode 100644 index 00000000000000..f330a5e2bda0d8 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentResolutionPopup.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { Modal, Form, Input, message } from 'antd'; +import { IncidentStage, IncidentState } from '@src/types.generated'; +import { Button, colors } from '@src/alchemy-components'; +import { useUpdateIncidentStatusMutation } from '@src/graphql/mutations.generated'; +import { useApolloClient } from '@apollo/client'; +import { useUserContext } from '@src/app/context/useUserContext'; + +import handleGraphQLError from '@src/app/shared/handleGraphQLError'; +import analytics, { EntityActionType, EventType } from '@src/app/analytics'; +import { useEntityData } from '@src/app/entity/shared/EntityContext'; +import { ModalButtonContainer } from '@src/app/shared/button/styledComponents'; +import { IncidentTableRow } from './types'; +import { IncidentSelectField } from './AcrylComponents/IncidentSelectedField'; +import { INCIDENT_OPTION_LABEL_MAPPING, INCIDENT_RESOLUTION_STAGES } from './constant'; +import { FormItem, ModalHeading, ModalTitleContainer } from './styledComponents'; +import { getCacheIncident } from './AcrylComponents/hooks/useIncidentHandler'; +import { PAGE_SIZE, updateActiveIncidentInCache } from './incidentUtils'; + +type IncidentResolutionPopupProps = { + incident: IncidentTableRow; + handleClose: () => void; +}; + +const { TextArea } = Input; + +const modalBodyStyle = { fontFamily: 'Mulish, sans-serif' }; + +const ModalTitle = () => ( + <ModalTitleContainer> + <ModalHeading>Resolve Incident</ModalHeading> + </ModalTitleContainer> +); + +export const IncidentResolutionPopup = ({ incident, handleClose }: IncidentResolutionPopupProps) => { + const client = useApolloClient(); + const { user } = useUserContext(); + const { urn, entityType } = useEntityData(); + const [updateIncidentStatusMutation] = useUpdateIncidentStatusMutation(); + const [form] = Form.useForm(); + + const handleValuesChange = (changedValues: any) => { + Object.keys(changedValues).forEach((fieldName) => form.setFields([{ name: fieldName, errors: [] }])); + }; + + const handleResolveIncident = (formData: any) => { + message.loading({ content: 'Updating...' }); + + updateIncidentStatusMutation({ + variables: { + urn: incident.urn, + input: { stage: formData?.status, message: formData?.note, state: IncidentState.Resolved }, + }, + }) + .then(() => { + message.destroy(); + analytics.event({ + type: EventType.EntityActionEvent, + entityType, + entityUrn: incident.urn, + actionType: EntityActionType.ResolvedIncident, + }); + + const values = { + title: incident.title, + description: incident.description, + type: incident.type, + priority: incident.priority, + state: IncidentState.Resolved, + customType: incident.customType, + stage: formData?.status || IncidentStage.Fixed, + message: formData?.note, + assigneeUrns: incident.assignees.map((a) => a.urn), + resourceUrns: incident.linkedAssets.map((l) => l.urn), + }; + + const updatedIncident = getCacheIncident({ + values, + incidentUrn: incident.urn, + user, + }); + + updateActiveIncidentInCache(client, urn, updatedIncident, PAGE_SIZE); + message.success({ content: 'Incident updated!', duration: 2 }); + handleClose?.(); + }) + .catch((error) => { + handleGraphQLError({ + error, + defaultMessage: 'Failed to update incident! An unexpected error occurred', + permissionMessage: + 'Unauthorized to update incident for this asset. Please contact your DataHub administrator.', + }); + }); + }; + + return ( + <Modal + title={<ModalTitle />} + visible + destroyOnClose + onCancel={handleClose} + centered + width={500} + style={modalBodyStyle} + footer={ + <ModalButtonContainer> + <Button key="cancel" variant="text" onClick={handleClose}> + Cancel + </Button> + <Button form="resolveIncident" key="submit" type="submit" data-testid="incident-save-button"> + Save + </Button> + </ModalButtonContainer> + } + > + <Form + form={form} + name="resolveIncident" + onFinish={handleResolveIncident} + layout="vertical" + initialValues={{ status: IncidentStage.Fixed }} + > + <IncidentSelectField + incidentLabelMap={INCIDENT_OPTION_LABEL_MAPPING.stage} + options={INCIDENT_RESOLUTION_STAGES} + onUpdate={(value) => form.setFieldsValue({ status: value })} + form={form} + handleValuesChange={handleValuesChange} + showClear={false} + width="100%" + customStyle={{ flexDirection: 'column', alignItems: 'normal' }} + value={form.getFieldValue(INCIDENT_OPTION_LABEL_MAPPING.stage.fieldName)} + /> + <FormItem + name="note" + label="Note" + rules={[ + { + required: false, + message: 'A note is required.', + }, + ]} + style={{ color: colors.gray[600] }} + > + <TextArea + rows={4} + placeholder="Add a resolved note - optional" + data-testid="incident-resolve-note-input" + /> + </FormItem> + </Form> + </Modal> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentResolveButton.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentResolveButton.tsx new file mode 100644 index 00000000000000..bf4e6d4d4502d7 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentResolveButton.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useState } from 'react'; +import { CorpUser, IncidentState } from '@src/types.generated'; +import { Button, colors, Pill, Popover } from '@src/alchemy-components'; +import { useGetEntitiesLazyQuery } from '@src/graphql/entity.generated'; +import { Check } from '@phosphor-icons/react'; +import { LoadingOutlined } from '@ant-design/icons'; +import { useUserContext } from '@src/app/context/useUserContext'; +import styled from 'styled-components'; + +import { ResolverNameContainer } from './styledComponents'; +import { IncidentTableRow } from './types'; +import { IncidentResolutionPopup } from './IncidentResolutionPopup'; +import { LoadingWrapper } from './AcrylComponents/styledComponents'; +import { ResolvedSection } from './ResolvedSection'; + +const ME = 'Me'; + +const Container = styled.div` + margin-right: 12px; + display: flex; + justify-content: end; +`; + +const ResolveButton = styled(Button)` + margin: 0px; + padding: 0px; +`; + +export const IncidentResolveButton = ({ incident }: { incident: IncidentTableRow }) => { + const me = useUserContext(); + const [showResolvePopup, setShowResolvePopup] = useState(false); + const [incidentResolver, setIncidentResolver] = useState<CorpUser | any>(null); + const [getAssigneeEntities, { data: resolvedAssigneeEntities, loading }] = useGetEntitiesLazyQuery(); + const resolverName = + me?.urn === incidentResolver?.urn + ? ME + : incidentResolver?.properties?.displayName || incidentResolver?.username; + useEffect(() => { + if (incident?.lastUpdated?.actor) { + getAssigneeEntities({ + variables: { + urns: [incident?.lastUpdated?.actor], + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [incident]); + + useEffect(() => { + if (resolvedAssigneeEntities?.entities?.length) { + setIncidentResolver(resolvedAssigneeEntities?.entities?.[0]); + } + }, [resolvedAssigneeEntities]); + + const handleShowPopup = () => { + setShowResolvePopup(!showResolvePopup); + }; + + const checkIconRenderer = () => { + return <Check color="#248F5B" height={9} width={12} />; + }; + + const showPopoverWithResolver = loading ? ( + <LoadingWrapper> + <LoadingOutlined /> + </LoadingWrapper> + ) : ( + <ResolverNameContainer> + <Popover + content={ + incidentResolver ? ( + <ResolvedSection + resolverUrn={incidentResolver.urn} + resolverName={incidentResolver?.properties?.displayName || incidentResolver?.username} + resolverImageUrl={incidentResolver?.editableProperties?.pictureLink} + resolverMessage={incident?.message} + resolvedDateAndTime={incident?.lastUpdated?.time} + /> + ) : null + } + placement="bottom" + > + <div> + <Pill + label={resolverName} + clickable={false} + customIconRenderer={checkIconRenderer} + customStyle={{ + maxWidth: '100px', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + backgroundColor: colors.gray[1300], + color: colors.green[1000], + }} + /> + </div> + </Popover> + </ResolverNameContainer> + ); + + return ( + <Container + onClick={(e) => { + e.stopPropagation(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + } + }} + tabIndex={0} + data-testid="incident-resolve-button-container" + > + {incident?.state === IncidentState.Active ? ( + <ResolveButton variant="text" onClick={handleShowPopup}> + Resolve + </ResolveButton> + ) : ( + showPopoverWithResolver + )} + + {showResolvePopup && <IncidentResolutionPopup incident={incident} handleClose={handleShowPopup} />} + </Container> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentTab.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentTab.tsx index 4c46eb30aac667..fd726e7471db38 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentTab.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentTab.tsx @@ -1,157 +1,7 @@ -import React, { useState } from 'react'; -import styled from 'styled-components'; -import { Button, Empty, List, Select, Typography } from 'antd'; -import { Tooltip } from '@components'; -import { PlusOutlined } from '@ant-design/icons'; -import { useGetEntityIncidentsQuery } from '../../../../../graphql/incident.generated'; -import TabToolbar from '../../components/styled/TabToolbar'; -import { useEntityContext, useEntityData } from '../../../../entity/shared/EntityContext'; -import IncidentListItem from './components/IncidentListItem'; -import { INCIDENT_DISPLAY_STATES, PAGE_SIZE, getIncidentsStatusSummary } from './incidentUtils'; -import { Incident, IncidentState } from '../../../../../types.generated'; -import { IncidentSummary } from './components/IncidentSummary'; -import { AddIncidentModal } from './components/AddIncidentModal'; -import { IncidentsLoadingSection } from './components/IncidentsLoadingSection'; -import { ANTD_GRAY } from '../../constants'; -import { combineEntityDataWithSiblings } from '../../../../entity/shared/siblingUtils'; -import { useIsSeparateSiblingsMode } from '../../useIsSeparateSiblingsMode'; +import React from 'react'; -const Header = styled.div` - border-bottom: 1px solid ${ANTD_GRAY[3]}; - box-shadow: ${(props) => props.theme.styles['box-shadow']}; -`; - -const Summary = styled.div` - display: flex; - align-items: center; - justify-content: space-between; -`; - -const IncidentList = styled.div` - flex: 1; - height: 100%; - overflow: scroll; -`; - -const IncidentStyledList = styled(List)` - &&& { - width: 100%; - border-color: ${(props) => props.theme.styles['border-color-base']}; - flex: 1; - } -`; - -const IncidentStateSelect = styled(Select)` - width: 100px; - margin: 0px 40px; -`; +import { IncidentList } from './IncidentList'; export const IncidentTab = () => { - const { refetch: refetchEntity } = useEntityContext(); - const { urn, entityType } = useEntityData(); - const incidentStates = INCIDENT_DISPLAY_STATES; - const [selectedIncidentState, setSelectedIncidentState] = useState<IncidentState | undefined>(IncidentState.Active); - const [isRaiseIncidentModalVisible, setIsRaiseIncidentModalVisible] = useState(false); - const isSeparateSiblingsMode = useIsSeparateSiblingsMode(); - - // Fetch filtered incidents. - const { loading, data, refetch } = useGetEntityIncidentsQuery({ - variables: { - urn, - start: 0, - count: PAGE_SIZE, - }, - fetchPolicy: 'cache-first', - }); - - const hasData = (data?.entity as any)?.incidents; - const combinedData = isSeparateSiblingsMode ? data : combineEntityDataWithSiblings(data); - const allIncidents = - (combinedData && (combinedData as any).entity?.incidents?.incidents?.map((incident) => incident as Incident)) || - []; - const filteredIncidents = allIncidents.filter( - (incident) => !selectedIncidentState || incident.status?.state === selectedIncidentState, - ); - const incidentList = filteredIncidents?.map((incident) => ({ - urn: incident?.urn, - created: incident.created, - customType: incident.customType, - description: incident.description, - status: incident.status, - type: incident?.incidentType, - title: incident?.title, - })); - - const canEditIncidents = (data?.entity as any)?.privileges?.canEditIncidents || false; - - function handleRefetch() { - refetch(); - refetchEntity(); - } - - return ( - <> - <Header> - <TabToolbar> - <Tooltip - showArrow={false} - title={!canEditIncidents && 'You do not have permission to create an incidents for this asset'} - > - <Button - icon={<PlusOutlined />} - onClick={() => canEditIncidents && setIsRaiseIncidentModalVisible(true)} - type="text" - disabled={!canEditIncidents} - > - Raise Incident - </Button> - <AddIncidentModal - urn={urn} - entityType={entityType} - refetch={handleRefetch} - visible={isRaiseIncidentModalVisible} - onClose={() => setIsRaiseIncidentModalVisible(false)} - /> - </Tooltip> - </TabToolbar> - <Summary> - <IncidentSummary summary={getIncidentsStatusSummary(allIncidents)} /> - <IncidentStateSelect - value={selectedIncidentState} - onChange={(newState: any) => setSelectedIncidentState(newState)} - autoFocus - > - {incidentStates.map((incidentType) => { - return ( - <Select.Option key={incidentType.type} value={incidentType.type}> - <Typography.Text>{incidentType.name}</Typography.Text> - </Select.Option> - ); - })} - </IncidentStateSelect> - </Summary> - </Header> - - {(loading && !hasData && <IncidentsLoadingSection />) || null} - {hasData && ( - <IncidentList> - <IncidentStyledList - bordered - locale={{ - emptyText: ( - <Empty - description={`No${ - selectedIncidentState ? ` ${selectedIncidentState.toLocaleLowerCase()} ` : '' - } incidents`} - image={Empty.PRESENTED_IMAGE_SIMPLE} - /> - ), - }} - dataSource={incidentList} - renderItem={(item: any) => <IncidentListItem refetch={refetch} incident={item} />} - /> - </IncidentList> - )} - </> - ); + return <IncidentList />; }; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentTitleContainer.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentTitleContainer.tsx new file mode 100644 index 00000000000000..22924391fe3a17 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentTitleContainer.tsx @@ -0,0 +1,63 @@ +import React, { Dispatch, SetStateAction } from 'react'; +import { Tooltip, Typography } from 'antd'; +import styled from 'styled-components'; +import { PlusOutlined } from '@ant-design/icons'; +import { EntityPrivileges } from '@src/types.generated'; +import { Button, colors } from '@src/alchemy-components'; + +const TitleContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin: 20px; + div { + border-bottom: 0px; + } +`; +const IncidentListTitle = styled.div` + && { + margin-bottom: 0px; + font-size: 18px; + font-weight: 700; + } +`; + +const CreateButton = styled(Button)` + height: 40px; +`; + +const SubTitle = styled(Typography.Text)` + font-size: 14px; + color: ${colors.gray[1700]}; +`; + +export const IncidentTitleContainer = ({ + privileges, + setShowIncidentBuilder, +}: { + privileges: EntityPrivileges; + setShowIncidentBuilder: Dispatch<SetStateAction<boolean>>; +}) => { + const noPermissionsMessage = 'You do not have permission to edit incidents for this asset.'; + + const canEditIncidents = privileges?.canEditIncidents || false; + + return ( + <TitleContainer> + <div className="left-section"> + <IncidentListTitle>Incidents</IncidentListTitle> + <SubTitle>View and manage ongoing data incidents for this asset</SubTitle> + </div> + <Tooltip showArrow={false} title={(!canEditIncidents && noPermissionsMessage) || null}> + <CreateButton + onClick={() => canEditIncidents && setShowIncidentBuilder(true)} + disabled={!canEditIncidents} + data-testid="create-incident-btn-main" + className="create-incident-button" + > + <PlusOutlined /> Create + </CreateButton> + </Tooltip> + </TitleContainer> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/ResolvedSection.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/ResolvedSection.tsx new file mode 100644 index 00000000000000..d7001027996d80 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/ResolvedSection.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Avatar } from '@src/alchemy-components'; +import { useEntityRegistry } from '@src/app/useEntityRegistry'; +import { useHistory } from 'react-router'; +import { EntityType } from '@src/types.generated'; + +import { + ResolverDetails, + ResolverDetailsContainer, + ResolverInfoContainer, + ResolverSubTitle, + ResolverSubTitleContainer, + ResolverTitleContainer, +} from './styledComponents'; +import { getFormattedDateForResolver } from './utils'; + +type ResolvedSectionProps = { + resolverUrn: string; + resolverName: string; + resolverImageUrl?: string; + resolverMessage?: string; + resolvedDateAndTime?: number; +}; + +export const ResolvedSection = ({ + resolverUrn, + resolverName, + resolverMessage, + resolvedDateAndTime, + resolverImageUrl, +}: ResolvedSectionProps) => { + const entityRegistry = useEntityRegistry(); + const history = useHistory(); + + const navigateToResolverProfile = () => { + history.push(entityRegistry.getEntityUrl(EntityType.CorpUser, resolverUrn)); + }; + + return ( + <ResolverInfoContainer> + <ResolverTitleContainer>Incident Resolved</ResolverTitleContainer> + <ResolverDetailsContainer> + <ResolverSubTitleContainer> + <ResolverSubTitle>Resolved By</ResolverSubTitle> + <ResolverDetails> + <Avatar + name={resolverName} + imageUrl={resolverImageUrl} + showInPill + onClick={navigateToResolverProfile} + /> + </ResolverDetails> + </ResolverSubTitleContainer> + <ResolverSubTitleContainer> + <ResolverSubTitle>Note</ResolverSubTitle> + <ResolverDetails>{resolverMessage || '-'}</ResolverDetails> + </ResolverSubTitleContainer> + <ResolverSubTitleContainer> + <ResolverSubTitle>Resolved Date and Time</ResolverSubTitle> + <ResolverDetails>{getFormattedDateForResolver(resolvedDateAndTime)}</ResolverDetails> + </ResolverSubTitleContainer> + </ResolverDetailsContainer> + </ResolverInfoContainer> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/__tests__/utils.test.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/__tests__/utils.test.tsx new file mode 100644 index 00000000000000..f2dbfbebb79342 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/__tests__/utils.test.tsx @@ -0,0 +1,122 @@ +import { format } from 'date-fns'; +import { SortingState } from '@src/alchemy-components/components/Table/types'; +import { + getFilteredTransformedIncidentData, + getLinkedAssetsCount, + getAssigneeWithURN, + getAssigneeNamesWithAvatarUrl, + getLinkedAssetsData, + getFormattedDateForResolver, + validateForm, + getSortedIncidents, + getExistingIncidents, +} from '../utils'; +import { IncidentListFilter } from '../types'; + +describe('Utility Functions', () => { + test('getFilteredTransformedIncidentData should filter and transform incident data', () => { + const incidents: any = [ + { + title: 'Incident 1', + status: { stage: 'WorkInProgress' }, + incidentType: 'DATASET_COLUMN', + priority: 'High', + type: 'INCIDENT', + }, + { + title: 'Incident 2', + status: { stage: 'Closed' }, + incidentType: 'DATASET_COLUMN_1', + priority: 'Low', + type: 'INCIDENT', + }, + ]; + + const filter: IncidentListFilter = { + sortBy: '', + groupBy: '', + filterCriteria: { + searchText: '', + priority: ['Low'], + stage: [], + state: [], + type: [], + }, + }; + + const result = getFilteredTransformedIncidentData(incidents, filter); + + // Assert total count (total number of incidents) + expect(result.totalCount).toBe(2); + + // Assert filtered incidents (only matching "Low" priority incidents) + expect(result.incidents).toHaveLength(1); + expect(result.incidents).toEqual([ + { + urn: undefined, + created: undefined, + creator: undefined, + customType: undefined, + description: undefined, + stage: 'Closed', + state: undefined, + type: 'DATASET_COLUMN_1', + title: 'Incident 2', + priority: 'Low', + linkedAssets: undefined, + assignees: undefined, + source: undefined, + lastUpdated: undefined, + message: undefined, + }, + ]); + }); + + test('getLinkedAssetsCount should return formatted asset count', () => { + expect(getLinkedAssetsCount(999)).toBe('999'); + expect(getLinkedAssetsCount(1000)).toBe('1k'); + expect(getLinkedAssetsCount(1000000)).toBe('1m'); + }); + + test('getAssigneeWithURN should return URNs of assignees', () => { + const assignees = [{ urn: 'urn1' }, { urn: 'urn2' }]; + expect(getAssigneeWithURN(assignees)).toEqual(['urn1', 'urn2']); + }); + + test('getAssigneeNamesWithAvatarUrl should return names with default image URLs', () => { + const assignees = [{ properties: { displayName: 'John Doe' } }]; + expect(getAssigneeNamesWithAvatarUrl(assignees)).toEqual([{ name: 'John Doe', imageUrl: '' }]); + }); + + test('getLinkedAssetsData should return linked asset URNs', () => { + const assets = ['urn1', { urn: 'urn2' }]; + expect(getLinkedAssetsData(assets)).toEqual(['urn1', 'urn2']); + }); + + test('getFormattedDateForResolver should return formatted date', () => { + const timestamp = new Date('2023-10-10T12:00:00Z').getTime(); + expect(getFormattedDateForResolver(timestamp)).toBe(format(new Date(timestamp), "M/d/yyyy 'at' h:mm a")); + }); + + test('validateForm should return true if no errors exist', () => { + const form = { getFieldsError: () => [{ errors: [] }] }; + expect(validateForm(form)).toBe(true); + }); + + test('getSortedIncidents should sort incidents based on column', () => { + const incidents = [{ title: 'B' }, { title: 'A' }]; + const record = { incidents }; + const sorted = getSortedIncidents(record, { sortColumn: 'name', sortOrder: SortingState.ASCENDING }); + expect(sorted[0].title).toBe('A'); + }); + + test('getExistingIncidents should return combined incidents from entity data', () => { + const currData = { + entity: { + incidents: { incidents: [{ id: 1 }] }, + siblingsSearch: { searchResults: [{ entity: { incidents: { incidents: [{ id: 2 }] } } }] }, + }, + }; + expect(getExistingIncidents(currData)).toEqual([{ id: 1 }, { id: 2 }]); + }); +}); diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/components/AddIncidentModal.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/components/AddIncidentModal.tsx index 2ebba4ebec73d3..aad6e7687d37d6 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/components/AddIncidentModal.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/components/AddIncidentModal.tsx @@ -1,14 +1,12 @@ import React, { useState } from 'react'; -import { message, Modal, Form, Input, Typography, Select } from 'antd'; +import { message, Modal, Button, Form, Input, Typography, Select } from 'antd'; import { useApolloClient } from '@apollo/client'; import styled from 'styled-components'; import { Editor } from '@src/app/entity/shared/tabs/Documentation/components/editor/Editor'; import { ANTD_GRAY } from '@src/app/entity/shared/constants'; -import { ModalButtonContainer } from '@src/app/shared/button/styledComponents'; -import { Button } from '@src/alchemy-components'; import analytics, { EventType, EntityActionType } from '../../../../../analytics'; import { EntityType, IncidentSourceType, IncidentState, IncidentType } from '../../../../../../types.generated'; -import { INCIDENT_DISPLAY_TYPES, PAGE_SIZE, addActiveIncidentToCache } from '../incidentUtils'; +import { INCIDENT_DISPLAY_TYPES, PAGE_SIZE, updateActiveIncidentInCache } from '../incidentUtils'; import { useRaiseIncidentMutation } from '../../../../../../graphql/mutations.generated'; import handleGraphQLError from '../../../../../shared/handleGraphQLError'; import { useUserContext } from '../../../../../context/useUserContext'; @@ -98,7 +96,7 @@ export const AddIncidentModal = ({ urn, entityType, visible, onClose, refetch }: entityUrn: urn, actionType: EntityActionType.AddIncident, }); - addActiveIncidentToCache(client, urn, newIncident, PAGE_SIZE); + updateActiveIncidentInCache(client, urn, newIncident, PAGE_SIZE); handleClose(); setTimeout(() => { refetch?.(); @@ -116,91 +114,87 @@ export const AddIncidentModal = ({ urn, entityType, visible, onClose, refetch }: }; return ( - <> - <Modal - title="Raise Incident" - visible={visible} - destroyOnClose - onCancel={handleClose} - width={600} - footer={[ - <ModalButtonContainer> - <Button variant="text" onClick={handleClose}> - Cancel - </Button> - <Button form="addIncidentForm" key="submit"> - Raise - </Button> - </ModalButtonContainer>, - ]} - > - <Form form={form} name="addIncidentForm" onFinish={handleAddIncident} layout="vertical"> - <Form.Item label={<Typography.Text strong>Type</Typography.Text>}> - <Form.Item name="type" style={{ marginBottom: '0px' }}> - <Select - value={selectedIncidentType} - onChange={onSelectIncidentType} - defaultValue={IncidentType.Operational} - autoFocus - > - {incidentTypes.map((incidentType) => ( - <Select.Option key={incidentType.type} value={incidentType.type}> - <Typography.Text>{incidentType.name}</Typography.Text> - </Select.Option> - ))} - </Select> - </Form.Item> - </Form.Item> - {isOtherTypeSelected && ( - <Form.Item - name="customType" - label="Custom Type" - rules={[ - { - required: selectedIncidentType === IncidentType.Custom, - message: 'A custom type is required.', - }, - ]} + <Modal + title="Raise Incident" + visible={visible} + destroyOnClose + onCancel={handleClose} + width={600} + footer={[ + <Button type="text" onClick={handleClose}> + Cancel + </Button>, + <Button type="primary" form="addIncidentForm" key="submit" htmlType="submit"> + Raise + </Button>, + ]} + > + <Form form={form} name="addIncidentForm" onFinish={handleAddIncident} layout="vertical"> + <Form.Item label={<Typography.Text strong>Type</Typography.Text>}> + <Form.Item name="type" style={{ marginBottom: '0px' }}> + <Select + value={selectedIncidentType} + onChange={onSelectIncidentType} + defaultValue={IncidentType.Operational} + autoFocus > - <Input placeholder="Freshness" /> - </Form.Item> - )} - <Form.Item - name="title" - label="Title" - rules={[ - { - required: true, - message: 'A title is required.', - }, - ]} - > - <Input placeholder="What went wrong?" /> + {incidentTypes.map((incidentType) => ( + <Select.Option key={incidentType.type} value={incidentType.type}> + <Typography.Text>{incidentType.name}</Typography.Text> + </Select.Option> + ))} + </Select> </Form.Item> + </Form.Item> + {isOtherTypeSelected && ( <Form.Item - name="description" - label="Description" + name="customType" + label="Custom Type" rules={[ { - required: true, - message: 'A description is required.', + required: selectedIncidentType === IncidentType.Custom, + message: 'A custom type is required.', }, ]} > - <StyledEditor - doNotFocus - className="add-incident-description" - onKeyDown={(e) => { - // Preventing the modal from closing when the Enter key is pressed - if (e.key === 'Enter') { - e.preventDefault(); - e.stopPropagation(); - } - }} - /> + <Input placeholder="Freshness" /> </Form.Item> - </Form> - </Modal> - </> + )} + <Form.Item + name="title" + label="Title" + rules={[ + { + required: true, + message: 'A title is required.', + }, + ]} + > + <Input placeholder="What went wrong?" /> + </Form.Item> + <Form.Item + name="description" + label="Description" + rules={[ + { + required: true, + message: 'A description is required.', + }, + ]} + > + <StyledEditor + doNotFocus + className="add-incident-description" + onKeyDown={(e) => { + // Preventing the modal from closing when the Enter key is pressed + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + } + }} + /> + </Form.Item> + </Form> + </Modal> ); }; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/components/IncidentListItem.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/components/IncidentListItem.tsx index 89869db2a2d61f..661adb052730f2 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/components/IncidentListItem.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/components/IncidentListItem.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import styled from 'styled-components'; -import { Dropdown, List, Menu, message, Tag, Typography } from 'antd'; -import { Tooltip, Popover, Button } from '@components'; +import { Button, Dropdown, List, Menu, message, Tag, Typography } from 'antd'; +import { Tooltip, Popover } from '@components'; import { CheckCircleFilled, CheckOutlined, MoreOutlined, WarningFilled } from '@ant-design/icons'; import { Link } from 'react-router-dom'; import { EntityType, IncidentState, IncidentType } from '../../../../../../types.generated'; @@ -122,6 +122,18 @@ const IncidentResolvedContainer = styled.div` margin-right: 30px; `; +const IncidentResolvedButton = styled(Button)` + background: #ffffff; + border: 1px solid #d9d9d9; + box-sizing: border-box; + box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.1); + border-radius: 5px; + color: #262626; + font-weight: 500; + font-size: 12px; + line-height: 20px; +`; + const MenuIcon = styled(MoreOutlined)` display: flex; justify-content: center; @@ -294,10 +306,13 @@ export default function IncidentListItem({ incident, refetch }: Props) { </IncidentResolvedTextContainer> ) : ( <IncidentResolvedContainer> - <Button onClick={() => handleResolved()} data-testid="resolve-incident"> - <CheckOutlined /> + <IncidentResolvedButton + icon={<CheckOutlined />} + onClick={() => handleResolved()} + data-testid="resolve-incident" + > Resolve - </Button> + </IncidentResolvedButton> <WarningFilled style={{ fontSize: '28px', marginLeft: '16px', color: FAILURE_COLOR_HEX }} /> </IncidentResolvedContainer> )} diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/components/ResolveIncidentModal.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/components/ResolveIncidentModal.tsx index ed75f86aa4b406..b915ef0a76a3f9 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/components/ResolveIncidentModal.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/components/ResolveIncidentModal.tsx @@ -1,7 +1,5 @@ import React from 'react'; -import { Modal, Form, Input } from 'antd'; -import { Button } from '@src/alchemy-components'; -import { ModalButtonContainer } from '@src/app/shared/button/styledComponents'; +import { Modal, Button, Form, Input } from 'antd'; import { IncidentState } from '../../../../../../types.generated'; const { TextArea } = Input; @@ -37,14 +35,12 @@ export const ResolveIncidentModal = ({ destroyOnClose onCancel={handleClose} footer={[ - <ModalButtonContainer> - <Button variant="text" onClick={handleClose}> - Cancel - </Button> - <Button variant="filled" form="resolveIncidentForm" key="submit" data-testid="confirm-resolve"> - Resolve - </Button> - </ModalButtonContainer>, + <Button type="text" onClick={handleClose}> + Cancel + </Button>, + <Button form="resolveIncidentForm" key="submit" htmlType="submit" data-testid="confirm-resolve"> + Resolve + </Button>, ]} > <Form form={form} name="resolveIncidentForm" onFinish={onResolvedIncident} layout="vertical"> diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/constant.ts b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/constant.ts new file mode 100644 index 00000000000000..a8b728f46e3027 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/constant.ts @@ -0,0 +1,222 @@ +import { IncidentPriority, IncidentStage, IncidentState, IncidentType } from '@src/types.generated'; + +export const INCIDENT_DEFAULT_FILTERS = { + sortBy: '', + groupBy: 'priority', + filterCriteria: { + searchText: '', + priority: [], + stage: [], + type: [], + state: [IncidentState.Active], + }, +}; + +export const INCIDENT_GROUP_BY_FILTER_OPTIONS = [ + { label: 'Priority', value: 'priority' }, + { label: 'Stage', value: 'stage' }, + { label: 'Category', value: 'type' }, + { label: 'State', value: 'state' }, +]; + +export const INCIDENT_TYPE_NAME_MAP = { + CUSTOM: 'Custom', + FIELD: 'Column', + FRESHNESS: 'Freshness', + DATASET: 'Other', + DATA_SCHEMA: 'Schema', + OPERATIONAL: 'Operational', + SQL: 'SQL', + VOLUME: 'Volume', +}; + +export const INCIDENT_CATEGORIES = [ + { + label: INCIDENT_TYPE_NAME_MAP.OPERATIONAL, + value: IncidentType.Operational, + }, + { + label: INCIDENT_TYPE_NAME_MAP.DATA_SCHEMA, + value: IncidentType.DataSchema, + }, + { + label: INCIDENT_TYPE_NAME_MAP.FIELD, + value: IncidentType.Field, + }, + { + label: INCIDENT_TYPE_NAME_MAP.FRESHNESS, + value: IncidentType.Freshness, + }, + { + label: INCIDENT_TYPE_NAME_MAP.SQL, + value: IncidentType.Sql, + }, + { + label: INCIDENT_TYPE_NAME_MAP.VOLUME, + value: IncidentType.Volume, + }, + { + label: INCIDENT_TYPE_NAME_MAP.CUSTOM, + value: IncidentType.Custom, + }, +]; + +export enum IncidentAction { + ADD = 'add', + VIEW = 'view', +} + +interface IncidentPriorityInterface { + label: string; + value: IncidentPriority; +} + +export const INCIDENT_PRIORITIES: IncidentPriorityInterface[] = [ + { + label: 'Critical', + value: IncidentPriority.Critical, + }, + { + label: 'High', + value: IncidentPriority.High, + }, + { + label: 'Medium', + value: IncidentPriority.Medium, + }, + { + label: 'Low', + value: IncidentPriority.Low, + }, +]; + +export const INCIDENT_STAGES = [ + { + label: 'Triage', + value: IncidentStage.Triage, + }, + { + label: 'Investigation', + value: IncidentStage.Investigation, + }, + { + label: 'In Progress', + value: IncidentStage.WorkInProgress, + }, + { + label: 'Fixed', + value: IncidentStage.Fixed, + }, + { + label: 'No Action', + value: IncidentStage.NoActionRequired, + }, +]; + +export const INCIDENT_RESOLUTION_STAGES = [ + { + label: 'Fixed', + value: IncidentStage.Fixed, + }, + { + label: 'No Action', + value: IncidentStage.NoActionRequired, + }, +]; + +export const INCIDENT_STATES = [ + { + label: 'Resolved', + value: IncidentState.Resolved, + }, + { + label: 'Active', + value: IncidentState.Active, + }, +]; + +export const INCIDENT_OPTION_LABEL_MAPPING = { + category: { + label: 'Category', + name: 'type', + fieldName: 'type', + }, + priority: { + label: 'Priority', + name: 'priority', + fieldName: 'priority', + }, + stage: { + label: 'Stage', + name: 'status', + fieldName: 'status', + }, + state: { + label: 'Status', + name: 'state', + fieldName: 'state', + }, +}; + +export const INCIDENT_STAGE_NAME_MAP = { + FIXED: 'Fixed', + INVESTIGATION: 'Investigation', + NO_ACTION_REQUIRED: 'No Action', + TRIAGE: 'Triage', + WORK_IN_PROGRESS: 'In progress', + None: 'None', +}; + +export const INCIDENT_STATE_NAME_MAP = { + ACTIVE: 'Active', + RESOLVED: 'Resolved', +}; + +export const INCIDENT_PRIORITY_NAME_MAP = { + CRITICAL: 'Critical', + HIGH: 'High', + LOW: 'Low', + MEDIUM: 'Medium', + None: 'None', +}; + +export const INCIDENT_TYPES = [ + IncidentType.Custom, + IncidentType.DataSchema, + IncidentType.Field, + IncidentType.Freshness, + IncidentType.Operational, + IncidentType.Sql, + IncidentType.Volume, +]; + +export const INCIDENT_STAGES_TYPES = [ + IncidentStage.Fixed, + IncidentStage.Investigation, + IncidentStage.NoActionRequired, + IncidentStage.Triage, + IncidentStage.WorkInProgress, +]; + +export const INCIDENT_STATES_TYPES = [IncidentState.Active, IncidentState.Resolved]; + +export const INCIDENT_PRIORITY = [ + IncidentPriority.Critical, + IncidentPriority.High, + IncidentPriority.Medium, + IncidentPriority.Low, +]; + +export const PRIORITY_ORDER = [INCIDENT_PRIORITY_NAME_MAP.None, ...INCIDENT_PRIORITY]; + +export const STAGE_ORDER = [ + INCIDENT_PRIORITY_NAME_MAP.None, + INCIDENT_STAGE_NAME_MAP.TRIAGE, + INCIDENT_STAGE_NAME_MAP.INVESTIGATION, + INCIDENT_STAGE_NAME_MAP.WORK_IN_PROGRESS, + INCIDENT_STAGE_NAME_MAP.FIXED, + INCIDENT_STAGE_NAME_MAP.NO_ACTION_REQUIRED, +]; +export const STATE_ORDER = [INCIDENT_STATE_NAME_MAP.ACTIVE, INCIDENT_STATE_NAME_MAP.RESOLVED]; + +export const MAX_VISIBLE_ASSIGNEE = 5; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/hooks.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/hooks.tsx new file mode 100644 index 00000000000000..038e1c0af6ccfe --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/hooks.tsx @@ -0,0 +1,197 @@ +import React, { useEffect, useMemo } from 'react'; +import { message } from 'antd'; +import { useHistory, useLocation } from 'react-router'; + +import { getTimeFromNow } from '@src/app/shared/time/timeUtils'; +import { IncidentStagePill } from '@src/alchemy-components/components/IncidentStagePill'; +import { IncidentPriorityLabel } from '@src/alchemy-components/components/IncidentPriorityLabel/IncidentPriorityLabel'; +import { getCapitalizeWord } from '@src/alchemy-components/components/IncidentStagePill/utils'; +import { AlignmentOptions } from '@src/alchemy-components/theme/config'; +import { getQueryParams } from '../Dataset/Validations/assertionUtils'; +import { getAssigneeNamesWithAvatarUrl, getLinkedAssetsCount } from './utils'; +import { IncidentResolveButton } from './IncidentResolveButton'; +import { IncidentAssigneeAvatarStack } from './IncidentAssigneeAvatarStack'; +import { CategoryType } from './styledComponents'; + +export const useIncidentsTableColumns = () => { + return useMemo(() => { + const columns = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + render: (record) => + record.groupName ? ( + <div>{record.groupName}</div> + ) : ( + <IncidentPriorityLabel priority={record?.priority} title={record?.title} /> + ), + width: '25%', + sorter: (a, b) => { + return a - b; + }, + }, + { + title: 'Stage', + dataIndex: 'stage', + key: 'stage', + render: (record) => + !record.groupName && ( + <CategoryType data-testid="incident-stage"> + <IncidentStagePill showLabel stage={record?.stage} /> + </CategoryType> + ), + width: '15%', + }, + { + title: 'Category', + dataIndex: 'type', + key: 'type', + render: (record) => + !record.groupName && ( + <CategoryType data-testid="incident-category" title={getCapitalizeWord(String(record?.type))}> + {getCapitalizeWord(String(record?.type))} + </CategoryType> + ), + sorter: (a, b) => { + return (b.type || '').localeCompare(a.type || '', undefined, { sensitivity: 'base' }); + }, + width: '12%', + }, + { + title: 'Opened ', + dataIndex: 'created', + key: 'created', + render: (record) => { + return !record.groupName && <div>{getTimeFromNow(record.created)}</div>; + }, + sorter: (a, b) => { + return a?.created - b?.created; + }, + width: '12%', + }, + { + title: 'Assets', + dataIndex: 'linkedAssets', + tooltipTitle: 'Linked Assets', + key: 'linkedAssets', + width: '9%', + render: (record) => + !record.groupName && ( + <div data-testid="incident-linked-assets"> + {getLinkedAssetsCount(record?.linkedAssets?.length)} + </div> + ), + sorter: (a, b) => { + return a.linkedAssets?.length - b.linkedAssets?.length; + }, + }, + { + title: 'Assignees', + dataIndex: 'assignees', + key: 'assignees', + width: '12%', + render: (record) => + !record.groupName && ( + <IncidentAssigneeAvatarStack assignees={getAssigneeNamesWithAvatarUrl(record?.assignees)} /> + ), + sorter: (a, b) => { + return a?.assignees?.length - b?.assignees?.length; + }, + }, + { + title: '', + dataIndex: '', + key: 'actions', + width: '15%', + render: (record) => { + return !record.groupName && <IncidentResolveButton incident={record} />; + }, + alignment: 'right' as AlignmentOptions, + }, + ]; + return columns; + }, []); +}; + +export const useIncidentURNCopyLink = (Urn: string) => { + const onCopyLink = () => { + const assertionUrn = Urn; + + // Create a URL with the assertion_urn query parameter + const currentUrl = new URL(window.location.href); + + // Add or update the assertion_urn query parameter + currentUrl.searchParams.set('incident_urn', encodeURIComponent(assertionUrn)); + + // The updated URL with the new or modified query parameter + const incidentUrl = currentUrl.href; + + // Copy the URL to the clipboard + navigator.clipboard.writeText(incidentUrl).then( + () => { + message.success('Link copied to clipboard!'); + }, + () => { + message.error('Failed to copy link to clipboard.'); + }, + ); + }; + + return onCopyLink; +}; + +export const getOnOpenAssertionLink = (Urn: string) => { + return () => { + if (!Urn) { + return false; + } + const assertionUrn = Urn; + + // Create a URL with the assertion_urn query parameter + const currentUrl = new URL(window.location.href); + currentUrl.pathname = currentUrl.pathname.replace('Incidents', 'Quality/List'); + + // Add or update the assertion_urn query parameter + currentUrl.searchParams.set('assertion_urn', encodeURIComponent(assertionUrn)); + // Delete is lineage mode from url + currentUrl.searchParams.delete('is_lineage_mode'); + // The updated URL with the new or modified query parameter + const assertionUrl = currentUrl.href; + // Replace current url with new one + window.location.replace(assertionUrl); + return true; + }; +}; + +/** + * Hook to manage the details view of assertions based on URL query parameters. + * + * @param {Function} setFocusAssertionUrn - Function to set details of the viewing assertion and open detail Modal. + * @returns {Object} Object containing the 'assertionUrnParam' from the URL. + */ +export const useOpenIncidentDetailModal = (setFocusIncidentUrn, updateIncidentData) => { + const location = useLocation(); + const history = useHistory(); + const incidentUrnParam = getQueryParams('incident_urn', location); + + useEffect(() => { + if (incidentUrnParam) { + const decodedIncidentUrn = decodeURIComponent(incidentUrnParam); + + setFocusIncidentUrn(decodedIncidentUrn); + updateIncidentData(decodedIncidentUrn); + + // Remove the query parameter from the URL + const newUrlParams = new URLSearchParams(location.search); + newUrlParams.delete('incident_urn'); + const newUrl = `${location.pathname}?${newUrlParams.toString()}`; + + // Use React Router's history.replace to replace the current URL + history.replace(newUrl); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [incidentUrnParam, setFocusIncidentUrn, location.search, location.pathname, history]); + + return { incidentUrnParam }; +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/incidentUtils.ts b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/incidentUtils.ts index 69ca1874f269d9..12d11745fdd33e 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/incidentUtils.ts +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/incidentUtils.ts @@ -1,6 +1,7 @@ import { GetEntityIncidentsDocument } from '../../../../../graphql/incident.generated'; import { IncidentType, IncidentState, Incident } from '../../../../../types.generated'; +import { getExistingIncidents } from './utils'; export const PAGE_SIZE = 100; @@ -52,7 +53,7 @@ export const addOrUpdateIncidentInList = (existingIncidents, newIncidents) => { didUpdate = true; return newIncidents; } - return { incident, siblings: null }; + return incident; }); return didUpdate ? updatedIncidents : [newIncidents, ...existingIncidents]; }; @@ -75,8 +76,9 @@ export const updateListIncidentsCache = (client, urn, incident, pageSize) => { return; } + const existingIncidents = getExistingIncidents(currData); + // Add our new incidents into the existing list. - const existingIncidents = [...(currData?.entity?.incidents?.incidents || [])]; const newIncidents = addOrUpdateIncidentInList(existingIncidents, incident); const didAddIncident = newIncidents.length > existingIncidents.length; @@ -104,7 +106,7 @@ export const updateListIncidentsCache = (client, urn, incident, pageSize) => { }, // Add the missing 'siblings' field with the appropriate data siblings: currData?.entity?.siblings || null, - siblingsSearch: currData?.entity?.siblingsSearch || null, + siblingsSearch: null, }, }, }); @@ -137,7 +139,7 @@ export const getIncidentsStatusSummary = (incidents: Array<Incident>) => { /** * Add raised incident to cache */ -export const addActiveIncidentToCache = (client, urn, incident, pageSize) => { +export const updateActiveIncidentInCache = (client, urn, incident, pageSize) => { // Add to active and overall list updateListIncidentsCache(client, urn, incident, pageSize); }; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/styledComponents.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/styledComponents.tsx new file mode 100644 index 00000000000000..99eff2acdb6e29 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/styledComponents.tsx @@ -0,0 +1,152 @@ +import { Form } from 'antd'; +import styled from 'styled-components'; + +import { colors } from '@src/alchemy-components'; +import radius from '@src/alchemy-components/theme/foundations/radius'; +import spacing from '@src/alchemy-components/theme/foundations/spacing'; +import { ANTD_GRAY, REDESIGN_COLORS } from '@src/app/entityV2/shared/constants'; + +export const StyledTableContainer = styled.div` + table tr.acryl-selected-table-row { + background-color: ${ANTD_GRAY[4]}; + } + margin: 0px 12px 12px 12px; +`; + +export const LinkedAssetsContainer = styled.div<{ hasButton?: boolean; width?: string }>(({ hasButton }) => ({ + border: `1px solid ${colors.gray[100]}`, + borderRadius: radius.lg, + padding: spacing.xxsm, + boxShadow: '0px 1px 2px 0px rgba(33, 23, 95, 0.07)', + backgroundColor: colors.white, + width: 'auto', + maxHeight: '40vh', + overflow: 'auto', + + '&:hover': hasButton + ? { + border: `1px solid ${colors.violet[500]}`, + cursor: 'pointer', + } + : {}, +})); + +export const FiltersContainer = styled.div` + display: flex; + gap: 16px; +`; + +export const StyledFilterContainer = styled.div` + button { + box-shadow: none !important; + height: 36px !important; + font-size: 14px !important; + border-radius: 8px !important; + color: #5f6685; + } +`; +export const SearchFilterContainer = styled.div` + display: flex; + justify-content: space-between; + padding: 0px 10px; + margin-bottom: 8px; + margin-top: 8px; + gap: 12px; +`; + +export const ModalTitleContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const ModalHeading = styled.span` + font-weight: 700; + font-size: 16px; + color: ${colors.gray[600]}; +`; + +export const ModalDescription = styled.p` + font-weight: 500; + font-size: 14px; + color: ${colors.gray[1700]}; +`; + +export const FormItem = styled(Form.Item)` + .ant-form-item-label > label { + color: ${colors.gray[600]} !important; + font-size: 12px; + } + .ant-form-item-control textarea { + font-weight: 400; + font-size: 14px; + } +`; + +export const IconContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + border: 1px solid #ebecf0; + height: 22px; + width: 22px; +`; + +export const ResolverNameContainer = styled.div` + display: flex; + align-items: center; + justify-content: end; + gap: 4px; +`; + +export const ResolverInfoContainer = styled.div` + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 8px; + color: ${colors.gray[600]}; +`; + +export const ResolverTitleContainer = styled.div` + font-size: 14px; + font-weight: 500; + color: ${colors.gray[600]}; +`; + +export const ResolverDetailsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +export const ResolverSubTitleContainer = styled.div` + display: flex; + flex-direction: column; + gap: 4px; +`; +export const ResolverSubTitle = styled.div` + font-size: 12px; + font-weight: 700; + color: ${colors.gray[600]}; +`; + +export const ResolverDetails = styled.div` + font-size: 14px; + font-weight: 400; + color: ${colors.gray[1700]}; + width: 250px; +`; + +export const AssigneeAvatarStackContainer = styled.div` + display: flex; +`; + +export const CategoryType = styled.div` + color: ${REDESIGN_COLORS.BODY_TEXT}; + text-transform: capitalize; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100px; +`; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/types.ts b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/types.ts new file mode 100644 index 00000000000000..89941be60e016e --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/types.ts @@ -0,0 +1,120 @@ +import { + AuditStamp, + CorpUser, + Incident, + IncidentPriority, + IncidentSource, + IncidentStage, + IncidentState, + IncidentType, + OwnerType, +} from '@src/types.generated'; +import { BaseItemType } from '@src/alchemy-components/components/Timeline/types'; +import { IncidentAction } from './constant'; + +export type IncidentListFilter = { + sortBy: string; + groupBy: string; + filterCriteria: { + searchText: string; + priority: string[]; + stage: string[]; + type: string[]; + state: string[]; + }; +}; + +export type IncidentGroupBy = { + priority: IncidentGroup[]; + stage: IncidentGroup[]; + type: IncidentGroup[]; + state: IncidentGroup[]; +}; + +export type IncidentTable = { + incidents: IncidentTableRow[]; + groupBy: IncidentGroupBy; + filterOptions?: any; + originalFilterOptions?: any; + searchMatchesCount?: number; + totalCount?: number; +}; + +export type IncidentGroup = { + name: string; + icon: React.ReactNode; + description?: string; + incidents: IncidentTableRow[]; + untransformedIncidents: Incident[]; + // summary?: IncidentStatusSummary; + type?: IncidentType; + stage?: IncidentStage; + state?: IncidentState; + priority?: IncidentPriority; + groupName?: JSX.Element; +}; + +export type IncidentFilterOptions = { + filterGroupOptions: { + type: IncidentType[]; + stage: IncidentStage[]; + priority: IncidentPriority[]; + state: IncidentState[]; + }; + recommendedFilters: IncidentRecommendedFilter[]; +}; + +export type IncidentRecommendedFilter = { + name: string; + category: 'type' | 'stage' | 'priority' | 'state'; + count: number; + displayName: string; +}; + +export type IncidentTableRow = { + urn: string; + created: number; + creator: AuditStamp; + customType: string; + description: string; + stage: IncidentStage; + state: IncidentState; + type: IncidentType; + title: string; + priority: IncidentPriority; + source: IncidentSource; + assignees: Array<OwnerType>; + linkedAssets: any[]; + message: string; + lastUpdated: AuditStamp; +}; + +export type IncidentEditorProps = { + incidentUrn?: string; + refetch?: () => void; + onSubmit?: (incident?: Incident) => void; + onClose?: () => void; + data?: IncidentTableRow; + mode?: IncidentAction; +}; + +export type IncidentLinkedAssetsListProps = { + form: any; + data?: IncidentTableRow; + mode: IncidentAction; + setCachedLinkedAssets: React.Dispatch<React.SetStateAction<any[]>>; + setIsLinkedAssetsLoading: React.Dispatch<React.SetStateAction<boolean>>; +}; + +export interface TimelineContentDetails extends BaseItemType { + action: string; + actor: CorpUser; + time: number; +} + +export enum IncidentConstant { + PRIORITY = 'priority', + STAGE = 'stage', + CATEGORY = 'category', + STATE = 'state', +} diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/utils.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/utils.tsx new file mode 100644 index 00000000000000..7be28d4d3aa7b7 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/utils.tsx @@ -0,0 +1,501 @@ +import React from 'react'; +import { Incident, IncidentPriority, IncidentStage, IncidentState, IncidentType } from '@src/types.generated'; +import Fuse from 'fuse.js'; +import { getCapitalizeWord } from '@src/alchemy-components/components/IncidentStagePill/utils'; +import { SortingState } from '@src/alchemy-components/components/Table/types'; +import { format } from 'date-fns'; +import { + IncidentFilterOptions, + IncidentGroup, + IncidentGroupBy, + IncidentListFilter, + IncidentRecommendedFilter, + IncidentTable, + IncidentTableRow, +} from './types'; +import { + INCIDENT_PRIORITY, + INCIDENT_PRIORITY_NAME_MAP, + INCIDENT_STAGE_NAME_MAP, + INCIDENT_STAGES_TYPES, + INCIDENT_STATE_NAME_MAP, + INCIDENT_STATES_TYPES, + INCIDENT_TYPE_NAME_MAP, + INCIDENT_TYPES, + PRIORITY_ORDER, + STAGE_ORDER, + STATE_ORDER, +} from './constant'; + +// Fuse.js setup for search functionality +const fuse = new Fuse<any>([], { + keys: ['title'], + threshold: 0.4, +}); + +const getIncidentGroupTypeIcon = () => { + return null; +}; + +const mapLinkedAssetData = (incident) => { + return incident?.linkedAssets?.relationships + ?.filter((item) => item.entity?.properties !== null) + .map((item) => item.entity); +}; + +const mapIncidentData = (incidents: Incident[]): IncidentTableRow[] => { + return incidents?.map( + (incident: Incident) => + ({ + urn: incident?.urn, + created: incident?.created?.time, + creator: incident?.created, + customType: incident?.customType, + description: incident?.description, + stage: incident?.status?.stage, + state: incident?.status?.state, + type: incident?.incidentType, + title: incident?.title, + priority: incident?.priority, + linkedAssets: mapLinkedAssetData(incident), + assignees: incident?.assignees, + source: incident?.source, + lastUpdated: incident?.status?.lastUpdated, + message: incident?.status?.message, + } as IncidentTableRow), + ); +}; + +// Helper function to create incident groups from grouped data +const createIncidentGroupsFromMap = ( + groupedMap: Map<string, Incident[]>, + keyName: keyof IncidentGroup, +): IncidentGroup[] => { + const incidentGroups: IncidentGroup[] = []; + + groupedMap.forEach((groupedIncidents, key) => { + const newGroup: IncidentGroup = { + name: key, + icon: getIncidentGroupTypeIcon(), + untransformedIncidents: groupedIncidents, + incidents: mapIncidentData(groupedIncidents), + [keyName]: key, // Dynamically set the group key (type, stage, or priority) + groupName: ( + <> + {getCapitalizeWord(key)} <span>({groupedIncidents.length})</span> + </> + ), + }; + incidentGroups.push(newGroup); + }); + + return incidentGroups; +}; + +// Helper function to group incidents based on a key +const groupIncidentsBy = (incidents: Incident[], keyExtractor: (incident: Incident) => string | undefined | null) => { + const groupedMap = new Map<string, Incident[]>(); + + incidents.forEach((incident) => { + const key = keyExtractor(incident) || 'None'; + const groupedIncidents = groupedMap.get(key) || []; + groupedIncidents.push(incident); + groupedMap.set(key, groupedIncidents); + }); + + return groupedMap; +}; + +const orderedIncidents = (priorityIncidentGroups) => { + const newOrderedIncidents: any = []; + PRIORITY_ORDER.forEach((priority) => { + const founItem = priorityIncidentGroups?.find((group) => group?.name === priority); + if (founItem) { + newOrderedIncidents.push(founItem); + } + }); + return newOrderedIncidents; +}; + +export const createIncidentGroups = (incidents: Array<Incident>): IncidentGroupBy => { + // Pre-sort the list of incidents based on which has been most recently created. + incidents?.sort((a, b) => a?.created?.time - b?.created?.time); + + // Group incidents by type, stage, and priority + const typeToIncidents = groupIncidentsBy(incidents, (incident) => incident?.incidentType); + const stageToIncidents = groupIncidentsBy(incidents, (incident) => incident?.status?.stage); + const stateToIncidents = groupIncidentsBy(incidents, (incident) => incident?.status?.state); + const priorityToIncidents = groupIncidentsBy(incidents, (incident) => incident.priority); + + // Create IncidentGroup objects for each group + const typeIncidentGroups = createIncidentGroupsFromMap(typeToIncidents, 'type'); + const stageIncidentGroups = createIncidentGroupsFromMap(stageToIncidents, 'stage'); + const stateIncidentGroups = createIncidentGroupsFromMap(stateToIncidents, 'state'); + const priorityIncidentGroups = createIncidentGroupsFromMap(priorityToIncidents, 'priority'); + return { + priority: orderedIncidents(priorityIncidentGroups), + stage: stageIncidentGroups, + type: typeIncidentGroups, + state: stateIncidentGroups, + }; +}; + +const generateStageFilters = (data: IncidentStage[], order: string[]) => { + const newOrderedArray: any = []; + + if (Array.isArray(data)) { + // ✅ Type guard ensures 'find' exists + order.forEach((stage) => { + const foundItem = data.find((item) => { + const filter = item as unknown as IncidentRecommendedFilter; + return filter?.displayName?.toLowerCase() === stage?.toLowerCase(); + }); + if (foundItem) { + newOrderedArray.push(foundItem); + } + }); + } + + return newOrderedArray; +}; + +const generatePriorityFilters = (data: IncidentPriority[], order: string[]) => { + const newOrderedArray: any = []; + + if (Array.isArray(data)) { + // ✅ Type guard ensures 'find' exists + order.forEach((priority) => { + const foundItem = data.find((item) => { + const filter = item as unknown as IncidentRecommendedFilter; + return filter?.displayName?.toLowerCase() === priority?.toLowerCase(); + }); + if (foundItem) { + newOrderedArray.push(foundItem); + } + }); + } + + return newOrderedArray; +}; + +const generateStateFilters = (data: IncidentState[], order: string[]) => { + const newOrderedArray: any = []; + + if (Array.isArray(data)) { + // ✅ Type guard ensures 'find' exists + order.forEach((state) => { + const foundItem = data.find((item) => { + const filter = item as unknown as IncidentRecommendedFilter; + return filter?.displayName?.toLowerCase() === state?.toLowerCase(); + }); + if (foundItem) { + newOrderedArray.push(foundItem); + } + }); + } + + return newOrderedArray; +}; + +// Build the Filter Options as per the type & status +const buildFilterOptions = (key: string, value: Record<string, number>, filterOptions: IncidentFilterOptions) => { + Object.entries(value).forEach(([name, count]) => { + let displayName; + switch (key) { + case 'type': + displayName = INCIDENT_TYPE_NAME_MAP[name]; + break; + case 'stage': + displayName = INCIDENT_STAGE_NAME_MAP[name]; + break; + case 'priority': + displayName = INCIDENT_PRIORITY_NAME_MAP[name]; + break; + case 'state': + displayName = INCIDENT_STATE_NAME_MAP[name]; + break; + default: + break; + } + + const filterItem = { name, category: key, count, displayName } as IncidentRecommendedFilter; + filterOptions.recommendedFilters.push(filterItem); + filterOptions.filterGroupOptions[key].push(filterItem); + }); + + // Reorder the stage options if the key is 'stage' + if (key === 'stage') { + /* eslint-disable-next-line no-param-reassign */ + filterOptions.filterGroupOptions[key] = generateStageFilters( + filterOptions.filterGroupOptions[key], + STAGE_ORDER, + ); + } + + // Reorder the priority options if the key is 'priority' + if (key === 'priority') { + /* eslint-disable-next-line no-param-reassign */ + filterOptions.filterGroupOptions[key] = generatePriorityFilters( + filterOptions.filterGroupOptions[key], + PRIORITY_ORDER, + ); + } + + // Reorder the priority options if the key is 'priority' + if (key === 'state') { + /* eslint-disable-next-line no-param-reassign */ + filterOptions.filterGroupOptions[key] = generateStateFilters( + filterOptions.filterGroupOptions[key], + STATE_ORDER, + ); + } +}; + +/** Create filter option list as per the incident data present + * for example + * status :[ + * + { + name: "SUCCESS", + category: 'status', + count:10, + displayName: "Passing" + } + * ] + * + * +*/ +const extractFilterOptionListFromIncidents = (incidents: Incident[]) => { + const filterOptions: IncidentFilterOptions = { + filterGroupOptions: { + type: [], + stage: [], + priority: [], + state: [], + }, + recommendedFilters: [], + }; + + const filterGroupCounts = { + type: {} as Record<string, number>, + stage: {} as Record<string, number>, + state: {} as Record<string, number>, + priority: {} as Record<string, number>, + }; + + // maintain array to show all the Incident Type count even if it is not present + const remainingIncidentTypes: IncidentType[] = [...INCIDENT_TYPES]; + const remainingIncidentStages = [...INCIDENT_STAGES_TYPES]; + const remainingIncidentStates = [...INCIDENT_STATES_TYPES]; + const remainingIncidentPriorities = [...INCIDENT_PRIORITY]; + + incidents.forEach((incident: Incident) => { + // filter out tracked types + const type = incident.incidentType as IncidentType; + if (type && ![IncidentType.DatasetColumn].includes(type)) { + const index = remainingIncidentTypes.indexOf(type); + if (index > -1) { + remainingIncidentTypes.splice(index, 1); + } + filterGroupCounts.type[type] = (filterGroupCounts.type[type] || 0) + 1; + } + + // filter out tracked stages + const stage = incident.status.stage as IncidentStage; + if (stage) { + const stageIndex = remainingIncidentStages.indexOf(stage); + if (stageIndex > -1) { + remainingIncidentStages.splice(stageIndex, 1); + } + + filterGroupCounts.stage[stage] = (filterGroupCounts.stage[stage] || 0) + 1; + } + + // filter out tracked states + const state = incident.status.state as IncidentState; + if (state) { + const stateIndex = remainingIncidentStates.indexOf(state); + if (stateIndex > -1) { + remainingIncidentStates.splice(stateIndex, 1); + } + + filterGroupCounts.state[state] = (filterGroupCounts.state[state] || 0) + 1; + } + + // filter out tracked priorities + const priority = (incident.priority || 'None') as IncidentPriority; + const priorityIndex = remainingIncidentPriorities.indexOf(priority); + if (priorityIndex > -1) { + remainingIncidentPriorities.splice(priorityIndex, 1); + } + + filterGroupCounts.priority[priority] = (filterGroupCounts.priority[priority] || 0) + 1; + }); + + // Add remaining Incident type with count 0 + remainingIncidentTypes.forEach((incidentType: IncidentType) => { + filterGroupCounts.type[incidentType] = 0; + }); + + // Add remaining Incident status with count 0 + remainingIncidentStages.forEach((stage: IncidentStage) => { + filterGroupCounts.stage[stage] = 0; + }); + + // Add remaining Incident status with count 0 + remainingIncidentStates.forEach((state: IncidentState) => { + filterGroupCounts.state[state] = 0; + }); + + // Add remaining Incident status with count 0 + remainingIncidentPriorities.forEach((incidentPriority: IncidentPriority) => { + filterGroupCounts.priority[incidentPriority] = 0; + }); + + buildFilterOptions('type', filterGroupCounts.type, filterOptions); + buildFilterOptions('stage', filterGroupCounts.stage, filterOptions); + buildFilterOptions('priority', filterGroupCounts.priority, filterOptions); + buildFilterOptions('state', filterGroupCounts.state, filterOptions); + return filterOptions; +}; + +// Assign Filtered Incidents to group +const assignFilteredIncidentToGroup = (filteredIncidents: Incident[]): IncidentTable => { + const incidentRawData: IncidentTable = { + incidents: [], + groupBy: { type: [], stage: [], priority: [], state: [] }, + filterOptions: {}, + }; + incidentRawData.incidents = mapIncidentData(filteredIncidents); + incidentRawData.groupBy = createIncidentGroups(filteredIncidents); + incidentRawData.filterOptions = extractFilterOptionListFromIncidents(filteredIncidents); + return incidentRawData; +}; + +const getFilteredIncidents = (incidents: Incident[], filter: IncidentListFilter) => { + const { priority, stage, type, state } = filter.filterCriteria; + + // Apply type, priority, and stage + return incidents.filter((incident: Incident) => { + const matchesCategory = type.length === 0 || type.includes(incident.incidentType); + const matchesPriority = priority.length === 0 || priority.includes(incident.priority || 'None'); + const matchesStage = stage.length === 0 || stage.includes(incident.status.stage || 'None'); + const matchesState = state.length === 0 || state.includes(incident.status.state); + return matchesCategory && matchesPriority && matchesStage && matchesState; + }); +}; + +/** Return return filter incident as per selected type status and other things + * it returns transformated into + * 1. group of incidents as per type , status + * 2. Transform data into {@link IncidentListTableRow } data + * 2. Filter out incidents as per the search text + * 3. filter out incidents as per the selected type and status + */ +export const getFilteredTransformedIncidentData = (incidents: Incident[], filter: IncidentListFilter): any => { + // Apply search filter if searchText is provided + let filteredIncidents = incidents; + const { searchText } = filter.filterCriteria; + let searchMatchesCount = 0; + + if (searchText) { + fuse.setCollection(incidents || []); + const result = fuse.search(searchText); + filteredIncidents = result.map((match) => match.item); + searchMatchesCount = filteredIncidents.length; + } + + // Apply type, status, and other filters + filteredIncidents = getFilteredIncidents(filteredIncidents, filter); + + // Transform filtered incidents + const incidentRawData = assignFilteredIncidentToGroup(filteredIncidents); + incidentRawData.searchMatchesCount = searchMatchesCount; + incidentRawData.totalCount = incidents?.length; + incidentRawData.originalFilterOptions = extractFilterOptionListFromIncidents(incidents); + return incidentRawData; +}; + +export const getLinkedAssetsCount = (asset: number): string => { + const units = ['k', 'm', 'b']; + let unitIndex = -1; + + while (asset >= 1000 && unitIndex < units.length - 1) { + /* eslint-disable-next-line no-param-reassign */ + asset /= 1000; + unitIndex++; + } + + return unitIndex === -1 + ? asset.toString() + : Intl.NumberFormat('en', { maximumFractionDigits: 1 }).format(asset) + units[unitIndex]; +}; + +export const getAssigneeWithURN = (assignees) => { + return assignees?.map((assignee) => assignee.urn); +}; + +export const getAssigneeNamesWithAvatarUrl = (assignees) => { + return assignees?.map((assignee) => { + return { + urn: assignee.urn, + name: assignee.properties?.displayName, + imageUrl: '', + }; + }); +}; + +export const getLinkedAssetsData = (assets) => { + return assets?.map((item) => (typeof item === 'string' ? item : item.urn)); +}; + +export const getFormattedDateForResolver = (lastUpdate) => { + // Create a Date object from the timestamp + const date = new Date(lastUpdate); + + // Format the date and time + const resolvedDateTime = format(date, "M/d/yyyy 'at' h:mm a"); + + return resolvedDateTime; +}; + +// Helper function for validating the form errors +export const validateForm = (form) => !form.getFieldsError().some(({ errors }) => errors.length > 0); + +export const getSortedIncidents = (record: any, sortedOptions: { sortColumn: string; sortOrder: SortingState }) => { + const { sortOrder, sortColumn } = sortedOptions; + + if (sortOrder === SortingState.ORIGINAL) return record.incidents; + + const localeOptions = { sensitivity: 'base' }; + + const sortFunctions = { + created: (a, b) => (sortOrder === SortingState.ASCENDING ? b.created - a.created : a.created - b.created), + name: (a, b) => + sortOrder === SortingState.ASCENDING + ? (a.title || '').localeCompare(b.title || '', undefined, localeOptions) + : (b.title || '').localeCompare(a.title || '', undefined, localeOptions), + type: (a, b) => + sortOrder === SortingState.ASCENDING + ? (a.type || '').localeCompare(b.type || '', undefined, localeOptions) + : (b.type || '').localeCompare(a.type || '', undefined, localeOptions), + linkedAssets: (a, b) => + sortOrder === SortingState.ASCENDING + ? b.linkedAssets.length - a.linkedAssets.length + : a.linkedAssets.length - b.linkedAssets.length, + assignees: (a, b) => + sortOrder === SortingState.ASCENDING + ? b.assignees?.length - a.assignees?.length + : a.assignees?.length - b.assignees?.length, + }; + + const sortFunction = sortFunctions[sortColumn]; + return sortFunction ? [...record.incidents].sort(sortFunction) : record.incidents; +}; + +export const getExistingIncidents = (currData) => { + return [ + ...(currData?.entity?.incidents?.incidents || []), + ...(currData?.entity?.siblingsSearch?.searchResults[0]?.entity?.incidents?.incidents || []), + ]; +}; From 203c91278a38a490143f689f032ab94cab018e4e Mon Sep 17 00:00:00 2001 From: amit-apptware <amit.gaikwad@apptware.com> Date: Tue, 11 Mar 2025 16:23:30 +0530 Subject: [PATCH 02/11] feat(ui/incident-v2): add incident v2 design integration --- .../IncidentStagePill.stories.tsx | 90 +++++++++++++++++++ .../IncidentStagePill/IncidentStagePill.tsx | 60 +++++++++++++ .../components/IncidentStagePill/constant.ts | 9 ++ .../components/IncidentStagePill/index.ts | 1 + .../components/IncidentStagePill/utils.ts | 5 ++ .../src/app/entityV2/shared/FilterSelect.tsx | 83 +++++++++++++++++ .../app/entityV2/shared/TimelineSkeleton.tsx | 60 +++++++++++++ .../src/app/entityV2/shared/hooks.tsx | 52 +++++++++++ .../IncidentActivityAvatar.tsx | 2 +- .../IncidentActivityContent.tsx | 2 +- .../IncidentActivitySection.tsx | 2 +- .../tabs/Incident/IncidentFilterContainer.tsx | 2 +- .../entityV2/shared/tabs/Incident/hooks.tsx | 16 +++- 13 files changed, 379 insertions(+), 5 deletions(-) create mode 100644 datahub-web-react/src/alchemy-components/components/IncidentStagePill/IncidentStagePill.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/IncidentStagePill/IncidentStagePill.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/IncidentStagePill/constant.ts create mode 100644 datahub-web-react/src/alchemy-components/components/IncidentStagePill/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/IncidentStagePill/utils.ts create mode 100644 datahub-web-react/src/app/entityV2/shared/FilterSelect.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/TimelineSkeleton.tsx create mode 100644 datahub-web-react/src/app/entityV2/shared/hooks.tsx diff --git a/datahub-web-react/src/alchemy-components/components/IncidentStagePill/IncidentStagePill.stories.tsx b/datahub-web-react/src/alchemy-components/components/IncidentStagePill/IncidentStagePill.stories.tsx new file mode 100644 index 00000000000000..7d8ac6642ae58c --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/IncidentStagePill/IncidentStagePill.stories.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { IncidentStagePill } from './IncidentStagePill'; + +const meta: Meta<typeof IncidentStagePill> = { + title: 'Components / IncidentStagePill', + component: IncidentStagePill, + + // Component-level parameters + parameters: { + layout: 'centered', + docs: { + subtitle: 'Displays a pill representing the current stage of an incident.', + }, + }, + + // Component-level argTypes + argTypes: { + stage: { + description: 'The current stage of the incident.', + control: 'select', + options: ['TRIAGE', 'INVESTIGATION', 'WORK_IN_PROGRESS', 'FIXED', 'NO_ACTION_REQUIRED'], + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'TRIAGE' }, + }, + }, + showLabel: { + description: 'Controls whether the label should be displayed.', + table: { + defaultValue: { summary: 'true' }, // Assuming true is the default + }, + control: { + type: 'boolean', + }, + }, + }, + + // Default props + args: { + stage: 'WORK_IN_PROGRESS', + showLabel: true, + }, +}; + +export default meta; + +type Story = StoryObj<typeof meta>; + +// Sandbox Story +export const sandbox: Story = { + render: (props) => <IncidentStagePill {...props} />, +}; + +// Example Stories +export const triageStage: Story = { + args: { + stage: 'FIXED', + }, +}; + +export const investigationStage: Story = { + args: { + stage: 'INVESTIGATION', + }, +}; + +export const inProgressStage: Story = { + args: { + stage: 'WORK_IN_PROGRESS', + }, +}; + +export const resolvedStage: Story = { + args: { + stage: 'FIXED', + }, +}; + +export const noActionStage: Story = { + args: { + stage: 'NO_ACTION_REQUIRED', + }, +}; + +export const unknownStage: Story = { + args: { + stage: 'UNKNOWN', + }, +}; diff --git a/datahub-web-react/src/alchemy-components/components/IncidentStagePill/IncidentStagePill.tsx b/datahub-web-react/src/alchemy-components/components/IncidentStagePill/IncidentStagePill.tsx new file mode 100644 index 00000000000000..ff82bd580fb57e --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/IncidentStagePill/IncidentStagePill.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Hexagon, Circle, CircleHalf, CheckCircle, CircleDashed } from '@phosphor-icons/react'; +import { IncidentStage } from '@src/types.generated'; +import colors from '@src/alchemy-components/theme/foundations/colors'; + +import { Pill } from '../Pills'; +import { IncidentStageLabel } from './constant'; + +const INCIDENT_STAGE = { + [IncidentStage.Triage]: { + bgColor: colors.gray[1000], + color: colors.violet[500], + icon: <Hexagon size={16} fill={colors.violet[500]} />, + }, + [IncidentStage.Investigation]: { + bgColor: colors.yellow[1200], + color: colors.yellow[1000], + icon: <Circle size={16} fill={colors.yellow[1000]} />, + }, + [IncidentStage.WorkInProgress]: { + bgColor: colors.gray[1100], + color: colors.blue[1000], + icon: <CircleHalf size={16} fill={colors.blue[1000]} />, + }, + [IncidentStage.Fixed]: { + bgColor: colors.gray[1300], + color: colors.green[1000], + icon: <CheckCircle size={16} fill={colors.green[1000]} />, + }, + [IncidentStage.NoActionRequired]: { + bgColor: colors.gray[100], + color: colors.gray[1700], + icon: <CircleDashed size={16} fill={colors.gray[1700]} />, + }, +}; + +export const IncidentStagePill = ({ stage, showLabel = false }: { stage: string; showLabel?: boolean }) => { + if (!stage) return <Pill label="None" size="md" />; + + const { icon, color, bgColor } = INCIDENT_STAGE[stage] || {}; + + function iconRenderer() { + return icon; + } + + return ( + <div title={IncidentStageLabel[stage] || 'None'}> + <Pill + label={IncidentStageLabel[stage] || 'None'} + size="md" + customIconRenderer={iconRenderer} + customStyle={{ + backgroundColor: bgColor, + color, + }} + showLabel={showLabel} + /> + </div> + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/IncidentStagePill/constant.ts b/datahub-web-react/src/alchemy-components/components/IncidentStagePill/constant.ts new file mode 100644 index 00000000000000..91b7b70ceb8121 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/IncidentStagePill/constant.ts @@ -0,0 +1,9 @@ +import { IncidentStage } from '@src/types.generated'; + +export const IncidentStageLabel = { + [IncidentStage.Triage]: 'Triage', + [IncidentStage.Fixed]: 'Fixed', + [IncidentStage.Investigation]: 'Investigation', + [IncidentStage.NoActionRequired]: 'No action', + [IncidentStage.WorkInProgress]: 'In progress', +}; diff --git a/datahub-web-react/src/alchemy-components/components/IncidentStagePill/index.ts b/datahub-web-react/src/alchemy-components/components/IncidentStagePill/index.ts new file mode 100644 index 00000000000000..0081dc39fea775 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/IncidentStagePill/index.ts @@ -0,0 +1 @@ +export { IncidentStagePill } from './IncidentStagePill'; diff --git a/datahub-web-react/src/alchemy-components/components/IncidentStagePill/utils.ts b/datahub-web-react/src/alchemy-components/components/IncidentStagePill/utils.ts new file mode 100644 index 00000000000000..733d663c694a83 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/IncidentStagePill/utils.ts @@ -0,0 +1,5 @@ +export const getCapitalizeWord = (word: string) => + word + ?.toLowerCase() + .replace(/_/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()); diff --git a/datahub-web-react/src/app/entityV2/shared/FilterSelect.tsx b/datahub-web-react/src/app/entityV2/shared/FilterSelect.tsx new file mode 100644 index 00000000000000..e664737cfea529 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/FilterSelect.tsx @@ -0,0 +1,83 @@ +import React, { useMemo, useCallback } from 'react'; +import { NestedSelect } from '@src/alchemy-components/components/Select/Nested/NestedSelect'; +import { SelectOption } from '@src/alchemy-components/components/Select/Nested/types'; +import capitalize from 'lodash/capitalize'; + +interface FilterOption { + name: string; + category: string; + count: number; + displayName: string; +} + +interface FilterGroupOptions { + [key: string]: FilterOption[]; +} + +interface FilterSelectProps { + filterOptions: FilterGroupOptions; + onFilterChange: (selectedFilters: FilterOption[]) => void; + excludedCategories?: string[]; + initialSelectedOptions?: SelectOption[]; +} + +export const FilterSelect = ({ + filterOptions, + onFilterChange, + excludedCategories, + initialSelectedOptions, +}: FilterSelectProps) => { + const handleFilterChange = useCallback( + (selectedValues: SelectOption[]) => { + const updatedFilters = selectedValues.map((option: SelectOption) => { + return filterOptions[option.parentValue!].find((filter) => filter.name === option.value)!; + }); + + onFilterChange(updatedFilters); + }, + [filterOptions, onFilterChange], + ); + + const options = useMemo((): SelectOption[] => { + const createOptions = (category: string, filters: any[]): SelectOption[] => { + const parentOption: SelectOption = { + value: category, + label: capitalize(category), + isParent: true, + }; + + const childOptions: SelectOption[] = filters.map((filter) => ({ + value: filter.name, + label: filter.displayName, + parentValue: category, + isParent: false, + })); + + return [parentOption, ...childOptions]; + }; + + return Object.entries(filterOptions).reduce<SelectOption[]>((opts, [category, filters]) => { + if (!excludedCategories?.includes(category)) { + opts.push(...createOptions(category, filters)); + } + return opts; + }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterOptions, excludedCategories]); + + return ( + <NestedSelect + label="" + placeholder="Filter" + options={options} + initialValues={initialSelectedOptions} + onUpdate={handleFilterChange} + isMultiSelect + areParentsSelectable={false} + width={100} + showCount + shouldAlwaysSyncParentValues + hideParentCheckbox + /> + ); +}; diff --git a/datahub-web-react/src/app/entityV2/shared/TimelineSkeleton.tsx b/datahub-web-react/src/app/entityV2/shared/TimelineSkeleton.tsx new file mode 100644 index 00000000000000..a69bb92f67aa64 --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/TimelineSkeleton.tsx @@ -0,0 +1,60 @@ +import { Skeleton } from 'antd'; +import React from 'react'; +import styled from 'styled-components'; + +const TimelineWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + margin-top: 8px; +`; + +const ItemWrapper = styled.div` + display: flex; + flex-direction: row; + gap: 16px; +`; + +const ContentWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 2px; +`; + +const SkeletonContentLine = styled(Skeleton.Button)` + .ant-skeleton-button { + height: 14px; + width: 150px; + margin: 0; + } +`; + +const SkeletonContentTimestampLine = styled(Skeleton.Button)` + .ant-skeleton-button { + height: 12px; + width: 150px; + margin: 0; + } +`; + +export default function TimelineSkeleton() { + const renderSkeletonItem = () => ( + <ItemWrapper> + <Skeleton.Avatar active /> + <ContentWrapper> + <SkeletonContentLine active /> + <SkeletonContentTimestampLine active /> + </ContentWrapper> + </ItemWrapper> + ); + + return ( + <TimelineWrapper> + {renderSkeletonItem()} + {renderSkeletonItem()} + {renderSkeletonItem()} + {renderSkeletonItem()} + {renderSkeletonItem()} + </TimelineWrapper> + ); +} diff --git a/datahub-web-react/src/app/entityV2/shared/hooks.tsx b/datahub-web-react/src/app/entityV2/shared/hooks.tsx new file mode 100644 index 00000000000000..8b56a036adfe1d --- /dev/null +++ b/datahub-web-react/src/app/entityV2/shared/hooks.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import { useLocation } from 'react-router'; + +/** + * Hook for managing the expanded row keys based on the `assertion_urn | incident_urn` query parameter. + * + * This hook ensures that relevant rows are expanded if an `assertion_urn | incident_urn` is present in the URL query parameters. + * If no `assertion_urn | incident_urn` is provided, it expands all groups initially. + * + * @param {Array} groups - Array of assertion groups, where each group contains a list of assertions. + * @returns {Object} - Object containing the expanded row keys and a function to update them. + * - expandedRowKeys: Array of currently expanded row keys. + * - setExpandedRowKeys: Function to manually set the expanded row keys. + */ +export const useGetExpandedTableGroupsFromEntityUrnInUrl = ( + groups, + { isGroupBy }: { isGroupBy: boolean }, + entityUrnQueryParameter: string, + getGroupEntities: (group) => { urn: string }[], +) => { + const location = useLocation(); + const resourceUrnParam = new URLSearchParams(location.search).get(entityUrnQueryParameter); + const [expandedGroupIds, setExpandedGroupIds] = useState<string[]>([]); + const [processed, setProcessed] = useState(false); + + useEffect(() => { + if (isGroupBy) { + if (resourceUrnParam) { + const decodedResourceUrn = decodeURIComponent(resourceUrnParam); + + // Find the row key to expand based on the incident URN + const rowKeyToExpand = groups.find((group) => + getGroupEntities(group).some((item) => item.urn === decodedResourceUrn), + )?.name; + + if (rowKeyToExpand) { + setExpandedGroupIds((prevKeys) => [...prevKeys, rowKeyToExpand]); + } + + setProcessed(true); + } else if (!processed) { + // If no assertion URN is present initially, set expandedGroupIds from groups + const allGroupKeys = groups.map((group) => group.name); + setExpandedGroupIds(allGroupKeys); + setProcessed(true); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groups, resourceUrnParam, processed, isGroupBy]); + + return { expandedGroupIds, setExpandedGroupIds }; +}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityAvatar.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityAvatar.tsx index 44a67189845774..d85ee9c12c0b90 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityAvatar.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityAvatar.tsx @@ -4,7 +4,7 @@ import { Avatar } from '@src/alchemy-components'; import { CorpUser } from '@src/types.generated'; import { HoverEntityTooltip } from '@src/app/recommendations/renderer/component/HoverEntityTooltip'; import { useEntityRegistryV2 } from '@src/app/useEntityRegistry'; -import useGetUserName from '../../Dataset/Stats/StatsTabV2/graphs/ChangeHistoryGraph/components/ChangeHistoryDrawer/useGetUserName'; +import useGetUserName from '../hooks'; type TimelineDotProps = { user?: CorpUser; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityContent.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityContent.tsx index b089dd1e832c18..10c32245041a3d 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityContent.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivityContent.tsx @@ -4,8 +4,8 @@ import { useEntityRegistryV2 } from '@src/app/useEntityRegistry'; import { colors, Text } from '@src/alchemy-components'; import { getTimeFromNow } from '@src/app/shared/time/timeUtils'; import { ActivityStatusText, Content, ContentRow } from './styledComponents'; -import useGetUserName from '../../Dataset/Stats/StatsTabV2/graphs/ChangeHistoryGraph/components/ChangeHistoryDrawer/useGetUserName'; import { TimelineContentDetails } from '../types'; +import useGetUserName from '../hooks'; type TimelineContentProps = { incidentActivities: TimelineContentDetails; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivitySection.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivitySection.tsx index cad66d07a3b1b9..8680e02e5dbba4 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivitySection.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/AcrylComponents/IncidentActivitySection.tsx @@ -3,9 +3,9 @@ import { Timeline } from '@src/alchemy-components'; import IncidentActivityContent from './IncidentActivityContent'; import { ActivityLabelSection, ActivitySection, TimelineWrapper } from './styledComponents'; -import TimelineSkeleton from '../../Dataset/Stats/StatsTabV2/graphs/ChangeHistoryGraph/components/ChangeHistoryDrawer/components/TimeLineSkeleton'; import { TimelineContentDetails } from '../types'; import IncidentActivityAvatar from './IncidentActivityAvatar'; +import TimelineSkeleton from '../../../TimelineSkeleton'; type IncidentActivitySectionProps = { loading: boolean; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentFilterContainer.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentFilterContainer.tsx index 024a6cb20cad8c..d4331f63f58510 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentFilterContainer.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/IncidentFilterContainer.tsx @@ -1,10 +1,10 @@ import React, { useMemo } from 'react'; import { IncidentTable } from './types'; import { INCIDENT_DEFAULT_FILTERS, INCIDENT_GROUP_BY_FILTER_OPTIONS } from './constant'; -import { FilterSelect } from '../../FilterSelect'; import { FiltersContainer, SearchFilterContainer, StyledFilterContainer } from './styledComponents'; import { GroupBySelect } from '../../GroupBySelect'; import { InlineListSearch } from '../../components/search/InlineListSearch'; +import { FilterSelect } from '../../FilterSelect'; interface FilterItem { name: string; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/hooks.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/hooks.tsx index 038e1c0af6ccfe..ae6ac72a37288f 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/hooks.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/hooks.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { message } from 'antd'; import { useHistory, useLocation } from 'react-router'; @@ -7,6 +7,8 @@ import { IncidentStagePill } from '@src/alchemy-components/components/IncidentSt import { IncidentPriorityLabel } from '@src/alchemy-components/components/IncidentPriorityLabel/IncidentPriorityLabel'; import { getCapitalizeWord } from '@src/alchemy-components/components/IncidentStagePill/utils'; import { AlignmentOptions } from '@src/alchemy-components/theme/config'; +import { useEntityRegistryV2 } from '@src/app/useEntityRegistry'; +import { CorpUser } from '@src/types.generated'; import { getQueryParams } from '../Dataset/Validations/assertionUtils'; import { getAssigneeNamesWithAvatarUrl, getLinkedAssetsCount } from './utils'; import { IncidentResolveButton } from './IncidentResolveButton'; @@ -195,3 +197,15 @@ export const useOpenIncidentDetailModal = (setFocusIncidentUrn, updateIncidentDa return { incidentUrnParam }; }; + +export default function useGetUserName() { + const entityRegistry = useEntityRegistryV2(); + + return useCallback( + (user: CorpUser) => { + if (!user) return ''; + return entityRegistry.getDisplayName(user.type, user); + }, + [entityRegistry], + ); +} From 13a62ca73be174b0adfcbe84a46a9097d6dc0016 Mon Sep 17 00:00:00 2001 From: amit-apptware <amit.gaikwad@apptware.com> Date: Tue, 11 Mar 2025 21:52:35 +0530 Subject: [PATCH 03/11] feat(ui/incident-v2): add alchemy components --- .../components/Checkbox/Checkbox.tsx | 7 +- .../components/Checkbox/components.ts | 58 +++---- .../components/Checkbox/types.ts | 5 +- .../components/Checkbox/utils.ts | 17 +++ .../components/Editor/toolbar/Toolbar.tsx | 2 +- .../components/IconLabel/IconLabel.tsx | 10 +- .../components/IconLabel/types.ts | 1 + .../IncidentPriorityLabel.tsx | 4 +- .../IncidentPriorityLabel/components.ts | 8 +- .../components/Input/Input.tsx | 8 +- .../components/Input/types.ts | 3 + .../components/Pills/Pill.stories.tsx | 10 ++ .../components/Pills/Pill.tsx | 10 +- .../components/Pills/types.ts | 1 + .../components/Select/Nested/NestedOption.tsx | 7 +- .../components/Select/Nested/NestedSelect.tsx | 40 ++--- .../components/Select/Select.stories.tsx | 1 + .../components/Select/SimpleSelect.tsx | 71 ++++++--- .../components/Select/components.ts | 73 ++++----- .../SelectLabelRenderer.tsx | 6 + .../variants/MultiSelectCustom.tsx | 40 +++++ .../variants/SingleSelectCustom.tsx | 36 +++++ .../components/Select/types.ts | 19 ++- .../components/Select/utils.ts | 9 +- .../components/Table/Table.stories.tsx | 26 +++- .../components/Table/Table.tsx | 141 +++++++++++++----- .../components/Table/components.ts | 25 +++- .../components/Table/types.ts | 16 +- .../Table/useGetSelectionColumn.tsx | 43 ++++++ .../components/Table/useRowSelection.ts | 49 ++++++ .../components/Table/utils.ts | 6 + .../components/Timeline/types.ts | 5 +- .../theme/foundations/colors.ts | 3 + .../src/app/entityV2/shared/GroupBySelect.tsx | 2 + .../components/search/InlineListSearch.tsx | 2 +- .../styled/search/SearchSelectBar.tsx | 1 + .../styled/search/SearchSelectModal.tsx | 20 +-- .../src/app/entityV2/shared/types.ts | 4 + .../homeV2/action/observe/HealthSummary.tsx | 75 ++++++++++ .../action/observe/HealthSummaryCard.tsx | 34 +++++ .../observe/HealthSummaryCardLoading.tsx | 30 ++++ .../observe/assertion/AssertionsSummary.tsx | 11 ++ .../observe/incident/IncidentSummary.tsx | 14 ++ .../useGetOwnedAssetAssertionSummary.tsx | 11 ++ .../useGetOwnedAssetIncidentSummary.tsx | 11 ++ .../reference/sections/EntityLinkList.tsx | 9 +- .../src/graphql/incident.graphql | 48 ++++-- .../src/graphql/mutations.graphql | 4 + .../src/images/add_icon_container.svg | 3 + 49 files changed, 845 insertions(+), 194 deletions(-) create mode 100644 datahub-web-react/src/alchemy-components/components/Select/private/SelectLabelRenderer/variants/MultiSelectCustom.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Select/private/SelectLabelRenderer/variants/SingleSelectCustom.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Table/useGetSelectionColumn.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Table/useRowSelection.ts create mode 100644 datahub-web-react/src/app/homeV2/action/observe/HealthSummary.tsx create mode 100644 datahub-web-react/src/app/homeV2/action/observe/HealthSummaryCard.tsx create mode 100644 datahub-web-react/src/app/homeV2/action/observe/HealthSummaryCardLoading.tsx create mode 100644 datahub-web-react/src/app/homeV2/action/observe/assertion/AssertionsSummary.tsx create mode 100644 datahub-web-react/src/app/homeV2/action/observe/incident/IncidentSummary.tsx create mode 100644 datahub-web-react/src/app/homeV2/action/observe/useGetOwnedAssetAssertionSummary.tsx create mode 100644 datahub-web-react/src/app/homeV2/action/observe/useGetOwnedAssetIncidentSummary.tsx create mode 100644 datahub-web-react/src/images/add_icon_container.svg diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.tsx b/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.tsx index d69a28195bebbe..7064937cbe72f4 100644 --- a/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.tsx +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.tsx @@ -18,6 +18,7 @@ export const checkboxDefaults: CheckboxProps = { isIntermediate: false, isRequired: false, setIsChecked: () => {}, + size: 'md', }; export const Checkbox = ({ @@ -28,6 +29,8 @@ export const Checkbox = ({ isIntermediate = checkboxDefaults.isIntermediate, isRequired = checkboxDefaults.isRequired, setIsChecked = checkboxDefaults.setIsChecked, + size = checkboxDefaults.size, + onCheckboxChange, ...props }: CheckboxProps) => { const [checked, setChecked] = useState(isChecked || false); @@ -51,13 +54,14 @@ export const Checkbox = ({ if (!isDisabled) { setChecked(!checked); setIsChecked?.(!checked); + onCheckboxChange?.(); } }} > <StyledCheckbox type="checkbox" id="checked-input" - checked={checked} + checked={checked || isIntermediate || false} disabled={isDisabled || false} error={error || ''} onChange={() => null} @@ -70,6 +74,7 @@ export const Checkbox = ({ error={error || ''} disabled={isDisabled || false} checked={checked || false} + size={size || 'md'} onMouseOver={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} /> diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/components.ts b/datahub-web-react/src/alchemy-components/components/Checkbox/components.ts index 7193f8577c4357..2a85be2f515f57 100644 --- a/datahub-web-react/src/alchemy-components/components/Checkbox/components.ts +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/components.ts @@ -1,6 +1,7 @@ -import { borders, colors, spacing, transform, zIndices, radius } from '@components/theme'; import styled from 'styled-components'; -import { getCheckboxColor, getCheckboxHoverBackgroundColor } from './utils'; +import { borders, colors, spacing, transform, zIndices, radius } from '@components/theme'; +import { SizeOptions } from '@src/alchemy-components/theme/config'; +import { getCheckboxColor, getCheckboxHoverBackgroundColor, getCheckboxSize } from './utils'; import { formLabelTextStyles } from '../commonStyles'; export const CheckboxContainer = styled.div({ @@ -41,32 +42,35 @@ export const StyledCheckbox = styled.input<{ }, })); -export const Checkmark = styled.div<{ intermediate?: boolean; error: string; checked: boolean; disabled: boolean }>( - ({ intermediate, checked, error, disabled }) => ({ +export const Checkmark = styled.div<{ + intermediate?: boolean; + error: string; + checked: boolean; + disabled: boolean; + size: SizeOptions; +}>(({ intermediate, checked, error, disabled, size }) => ({ + ...getCheckboxSize(size), + position: 'absolute', + top: '4px', + left: '11px', + zIndex: zIndices.docked, + borderRadius: '3px', + border: `${borders['2px']} ${getCheckboxColor(checked, error, disabled, undefined)}`, + transition: 'all 0.2s ease-in-out', + cursor: 'pointer', + '&:after': { + content: '""', position: 'absolute', - top: '4px', - left: '11px', - zIndex: zIndices.docked, - height: '18px', - width: '18px', - borderRadius: '3px', - border: `${borders['2px']} ${getCheckboxColor(checked, error, disabled, undefined)}`, - transition: 'all 0.2s ease-in-out', - cursor: 'pointer', - '&:after': { - content: '""', - position: 'absolute', - display: 'none', - left: !intermediate ? '5px' : '8px', - top: !intermediate ? '1px' : '3px', - width: !intermediate ? '5px' : '0px', - height: '10px', - border: 'solid white', - borderWidth: '0 3px 3px 0', - transform: !intermediate ? 'rotate(45deg)' : transform.rotate[90], - }, - }), -); + display: 'none', + top: !intermediate ? '10%' : '20%', + left: !intermediate ? '30%' : '40%', + width: !intermediate ? '35%' : '0px', + height: !intermediate ? '60%' : '50%', + border: 'solid white', + borderWidth: '0 3px 3px 0', + transform: !intermediate ? 'rotate(45deg)' : transform.rotate[90], + }, +})); export const HoverState = styled.div<{ isHovering: boolean; error: string; checked: boolean; disabled: boolean }>( ({ isHovering, error, checked }) => ({ diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/types.ts b/datahub-web-react/src/alchemy-components/components/Checkbox/types.ts index e4bbe8808378e8..e153154050c312 100644 --- a/datahub-web-react/src/alchemy-components/components/Checkbox/types.ts +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/types.ts @@ -1,6 +1,7 @@ import { InputHTMLAttributes } from 'react'; +import { SizeOptions } from '@src/alchemy-components/theme/config'; -export interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> { +export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> { label?: string; error?: string; isChecked?: boolean; @@ -8,6 +9,8 @@ export interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> { isDisabled?: boolean; isIntermediate?: boolean; isRequired?: boolean; + onCheckboxChange?: () => void; + size?: SizeOptions; } export interface CheckboxGroupProps { diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts b/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts index edf5d24596e1b4..959b37a8d99e37 100644 --- a/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts @@ -1,4 +1,5 @@ import theme, { colors } from '@components/theme'; +import { SizeOptions } from '@src/alchemy-components/theme/config'; const checkboxBackgroundDefault = { default: colors.white, @@ -25,3 +26,19 @@ export function getCheckboxHoverBackgroundColor(checked: boolean, error: string) if (checked) return checkboxHoverColors.checked; return checkboxHoverColors.default; } + +const sizeMap: Record<SizeOptions, string> = { + xs: '16px', + sm: '18px', + md: '20px', + lg: '22px', + xl: '24px', + inherit: '', +}; + +export function getCheckboxSize(size: SizeOptions) { + return { + height: sizeMap[size], + width: sizeMap[size], + }; +} diff --git a/datahub-web-react/src/alchemy-components/components/Editor/toolbar/Toolbar.tsx b/datahub-web-react/src/alchemy-components/components/Editor/toolbar/Toolbar.tsx index bbefbdbc6fb436..b389f0a2bfb1e2 100644 --- a/datahub-web-react/src/alchemy-components/components/Editor/toolbar/Toolbar.tsx +++ b/datahub-web-react/src/alchemy-components/components/Editor/toolbar/Toolbar.tsx @@ -43,7 +43,7 @@ export const Toolbar = () => { return ( <Container> <HeadingMenu /> - <Divider type="vertical" style={{ height: '100%' }} /> + <Divider type="vertical" style={{ height: '100%', margin: '0 6px' }} /> <CommandButton icon={<TextB size={24} color={colors.gray[1800]} />} style={{ marginRight: 2 }} diff --git a/datahub-web-react/src/alchemy-components/components/IconLabel/IconLabel.tsx b/datahub-web-react/src/alchemy-components/components/IconLabel/IconLabel.tsx index d1323af6d5fb81..3593f409e57a81 100644 --- a/datahub-web-react/src/alchemy-components/components/IconLabel/IconLabel.tsx +++ b/datahub-web-react/src/alchemy-components/components/IconLabel/IconLabel.tsx @@ -3,7 +3,7 @@ import { IconLabelProps, IconType } from './types'; import { IconLabelContainer, ImageContainer, Label } from './components'; import { isValidImageUrl } from './utils'; -export const IconLabel = ({ icon, name, type, style, imageUrl }: IconLabelProps) => { +export const IconLabel = ({ icon, name, type, style, imageUrl, testId }: IconLabelProps) => { const [isValidImage, setIsValidImage] = useState(false); useEffect(() => { @@ -26,8 +26,12 @@ export const IconLabel = ({ icon, name, type, style, imageUrl }: IconLabelProps) return ( <IconLabelContainer> - <ImageContainer style={style}>{renderIcons()}</ImageContainer> - <Label title={name}>{name}</Label> + <ImageContainer data-testid={testId} style={style}> + {renderIcons()} + </ImageContainer> + <Label data-testid={name} title={name}> + {name} + </Label> </IconLabelContainer> ); }; diff --git a/datahub-web-react/src/alchemy-components/components/IconLabel/types.ts b/datahub-web-react/src/alchemy-components/components/IconLabel/types.ts index 3b38b45ee024a9..2892f6bd90e938 100644 --- a/datahub-web-react/src/alchemy-components/components/IconLabel/types.ts +++ b/datahub-web-react/src/alchemy-components/components/IconLabel/types.ts @@ -5,6 +5,7 @@ export interface IconLabelProps { marginRight?: string; imageUrl?: string; style?: React.CSSProperties; + testId?: string; } export enum IconType { diff --git a/datahub-web-react/src/alchemy-components/components/IncidentPriorityLabel/IncidentPriorityLabel.tsx b/datahub-web-react/src/alchemy-components/components/IncidentPriorityLabel/IncidentPriorityLabel.tsx index b5ee845b0546f3..34f435d0596a2a 100644 --- a/datahub-web-react/src/alchemy-components/components/IncidentPriorityLabel/IncidentPriorityLabel.tsx +++ b/datahub-web-react/src/alchemy-components/components/IncidentPriorityLabel/IncidentPriorityLabel.tsx @@ -33,6 +33,6 @@ const Icons = Object.fromEntries( export const IncidentPriorityLabel = ({ priority, title, style }: IncidentPriorityLabelProps) => { const { icon, type } = Icons[priority] || {}; - if (!icon) return <Label>{title}</Label>; - return <IconLabel style={style} icon={icon} name={title} type={type} />; + if (!icon) return <Label data-testid="priority-title">{title}</Label>; + return <IconLabel testId="priority-title" style={style} icon={icon} name={title} type={type} />; }; diff --git a/datahub-web-react/src/alchemy-components/components/IncidentPriorityLabel/components.ts b/datahub-web-react/src/alchemy-components/components/IncidentPriorityLabel/components.ts index d51d6ae662ee1e..18c7cf62d0255c 100644 --- a/datahub-web-react/src/alchemy-components/components/IncidentPriorityLabel/components.ts +++ b/datahub-web-react/src/alchemy-components/components/IncidentPriorityLabel/components.ts @@ -4,4 +4,10 @@ export const StyledImage = styled.img` cursor: pointer; `; -export const Label = styled.span``; +export const Label = styled.span` + font-family: Mulish; + font-size: 14px; + font-weight: 400; + color: #374066; + white-space: normal; +`; diff --git a/datahub-web-react/src/alchemy-components/components/Input/Input.tsx b/datahub-web-react/src/alchemy-components/components/Input/Input.tsx index 75c199610f81e1..9f3b845d870d00 100644 --- a/datahub-web-react/src/alchemy-components/components/Input/Input.tsx +++ b/datahub-web-react/src/alchemy-components/components/Input/Input.tsx @@ -47,6 +47,9 @@ export const Input = ({ errorOnHover = inputDefaults.errorOnHover, type = inputDefaults.type, id, + styles, + inputStyles, + inputTestId, ...props }: InputProps) => { // Invalid state is always true if error is present @@ -69,7 +72,7 @@ export const Input = ({ }; return ( - <InputWrapper {...props}> + <InputWrapper {...props} style={styles}> {label && ( <Label aria-label={label}> {label} {isRequired && <Required>*</Required>} @@ -86,7 +89,8 @@ export const Input = ({ disabled={isDisabled} required={isRequired} id={id} - style={{ paddingLeft: icon ? '8px' : '' }} + style={{ paddingLeft: icon ? '8px' : '', ...inputStyles }} + data-testid={inputTestId} /> {!isPassword && ( <Tooltip title={errorOnHover ? error : ''} showArrow={false}> diff --git a/datahub-web-react/src/alchemy-components/components/Input/types.ts b/datahub-web-react/src/alchemy-components/components/Input/types.ts index 8ec3c8ed3e3586..07e76b993e4437 100644 --- a/datahub-web-react/src/alchemy-components/components/Input/types.ts +++ b/datahub-web-react/src/alchemy-components/components/Input/types.ts @@ -20,4 +20,7 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { errorOnHover?: boolean; id?: string; type?: string; + styles?: React.CSSProperties; + inputStyles?: React.CSSProperties; + inputTestId?: string; } diff --git a/datahub-web-react/src/alchemy-components/components/Pills/Pill.stories.tsx b/datahub-web-react/src/alchemy-components/components/Pills/Pill.stories.tsx index 1f162348fdbcfb..78962021a174b3 100644 --- a/datahub-web-react/src/alchemy-components/components/Pills/Pill.stories.tsx +++ b/datahub-web-react/src/alchemy-components/components/Pills/Pill.stories.tsx @@ -86,6 +86,15 @@ const meta: Meta = { type: 'select', }, }, + showLabel: { + description: 'Controls whether the label should be displayed.', + table: { + defaultValue: { summary: 'true' }, // Assuming true is the default + }, + control: { + type: 'boolean', + }, + }, }, // Define defaults @@ -95,6 +104,7 @@ const meta: Meta = { rightIcon: defaults.rightIcon, size: defaults.size, variant: defaults.variant, + showLabel: true, }, } satisfies Meta<typeof Pill>; diff --git a/datahub-web-react/src/alchemy-components/components/Pills/Pill.tsx b/datahub-web-react/src/alchemy-components/components/Pills/Pill.tsx index 8a663f47b7361b..28be100af445b1 100644 --- a/datahub-web-react/src/alchemy-components/components/Pills/Pill.tsx +++ b/datahub-web-react/src/alchemy-components/components/Pills/Pill.tsx @@ -1,4 +1,4 @@ -import { Icon } from '@components'; +import { Button, Icon } from '@components'; import { ColorOptions, ColorValues, PillVariantOptions, PillVariantValues, SizeValues } from '@components/theme/config'; import React from 'react'; import { PillContainer, PillText } from './components'; @@ -45,6 +45,7 @@ export function Pill({ onPillClick, customStyle, customIconRenderer, + showLabel, className, }: PillProps) { if (!SUPPORTED_CONFIGURATIONS[variant].includes(color)) { @@ -63,13 +64,18 @@ export function Pill({ style={{ backgroundColor: customStyle?.backgroundColor, }} + title={showLabel ? label : undefined} className={className} > {customIconRenderer ? customIconRenderer() : leftIcon && <Icon icon={leftIcon} size={size} onClick={onClickLeftIcon} />} <PillText style={customStyle}>{label}</PillText> - {rightIcon && <Icon icon={rightIcon} size={size} onClick={onClickRightIcon} />} + {rightIcon && ( + <Button style={{ padding: 0 }} variant="text" onClick={onClickRightIcon}> + <Icon icon={rightIcon} size={size} /> + </Button> + )} </PillContainer> ); } diff --git a/datahub-web-react/src/alchemy-components/components/Pills/types.ts b/datahub-web-react/src/alchemy-components/components/Pills/types.ts index cd26a1df3b8bbd..f8e5fa33040923 100644 --- a/datahub-web-react/src/alchemy-components/components/Pills/types.ts +++ b/datahub-web-react/src/alchemy-components/components/Pills/types.ts @@ -14,6 +14,7 @@ export interface PillProps extends Partial<PillPropsDefaults>, Omit<HTMLAttribut rightIcon?: string; leftIcon?: string; customStyle?: React.CSSProperties; + showLabel?: boolean; customIconRenderer?: () => void; onClickRightIcon?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void; onClickLeftIcon?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void; diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx index 2de5c4284fe4a4..eb03543b9533c9 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx @@ -86,6 +86,7 @@ interface OptionProps { isLoadingParentChildList?: boolean; setSelectedOptions: React.Dispatch<React.SetStateAction<SelectOption[]>>; hideParentCheckbox?: boolean; + isParentOptionLabelExpanded?: boolean; } export const NestedOption = ({ @@ -101,10 +102,11 @@ export const NestedOption = ({ isLoadingParentChildList, setSelectedOptions, hideParentCheckbox, + isParentOptionLabelExpanded, }: OptionProps) => { const [autoSelectChildren, setAutoSelectChildren] = useState(false); const [loadingParentUrns, setLoadingParentUrns] = useState<string[]>([]); - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(isParentOptionLabelExpanded); const directChildren = useMemo( () => parentValueToOptions[option.value] || [], [parentValueToOptions, option.value], @@ -248,6 +250,7 @@ export const NestedOption = ({ display: 'flex', justifyContent: hideParentCheckbox ? 'space-between' : 'normal', }} + data-testid={`${option.isParent ? 'parent' : 'child'}-option-${option.value}`} > {option.isParent && <strong>{option.label}</strong>} {!option.isParent && <>{option.label}</>} @@ -295,7 +298,7 @@ export const NestedOption = ({ </OptionLabel> </ParentOption> {isOpen && ( - <ChildOptions> + <ChildOptions data-testid="children-option-container"> {directChildren.map((child) => ( <NestedOption key={child.value} diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx index 16312d2cf6e8a5..5fe9900932304f 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx @@ -330,23 +330,29 @@ export const NestedSelect = ({ </SearchInputContainer> )} <OptionList> - {rootOptions.map((option) => ( - <NestedOption - key={option.value} - selectedOptions={selectedOptions} - option={option} - parentValueToOptions={parentValueToOptions} - handleOptionChange={handleOptionChange} - addOptions={addOptions} - removeOptions={removeOptions} - loadData={loadData} - isMultiSelect={isMultiSelect} - setSelectedOptions={setSelectedOptions} - areParentsSelectable={areParentsSelectable} - isLoadingParentChildList={isLoadingParentChildList} - hideParentCheckbox={hideParentCheckbox} - /> - ))} + {rootOptions.map((option) => { + const isParentOptionLabelExpanded = selectedOptions.find( + (opt) => opt.parentValue === option.value, + ); + return ( + <NestedOption + key={option.value} + selectedOptions={selectedOptions} + option={option} + parentValueToOptions={parentValueToOptions} + handleOptionChange={handleOptionChange} + addOptions={addOptions} + removeOptions={removeOptions} + loadData={loadData} + isMultiSelect={isMultiSelect} + setSelectedOptions={setSelectedOptions} + areParentsSelectable={areParentsSelectable} + isLoadingParentChildList={isLoadingParentChildList} + hideParentCheckbox={hideParentCheckbox} + isParentOptionLabelExpanded={!!isParentOptionLabelExpanded} + /> + ); + })} </OptionList> </Dropdown> )} diff --git a/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx b/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx index fefdb05c375a2f..cfc9759b51315f 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx +++ b/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx @@ -417,6 +417,7 @@ export const withSearch = () => ( label="Select with Search" showSearch values={['2']} + filterResultsByQuery /> ); diff --git a/datahub-web-react/src/alchemy-components/components/Select/SimpleSelect.tsx b/datahub-web-react/src/alchemy-components/components/Select/SimpleSelect.tsx index 456650c51a7c61..79ffd6ba269276 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/SimpleSelect.tsx +++ b/datahub-web-react/src/alchemy-components/components/Select/SimpleSelect.tsx @@ -1,6 +1,6 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Text } from '@components'; import { isEqual } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActionButtonsContainer, Container, @@ -20,8 +20,8 @@ import { StyledClearButton, StyledIcon, } from './components'; -import SelectLabelRenderer from './private/SelectLabelRenderer/SelectLabelRenderer'; import { ActionButtonsProps, SelectOption, SelectProps } from './types'; +import SelectLabelRenderer from './private/SelectLabelRenderer/SelectLabelRenderer'; const SelectActionButtons = ({ selectedValues, @@ -56,6 +56,8 @@ export const selectDefaults: SelectProps = { showSelectAll: false, selectAllLabel: 'Select All', showDescriptions: false, + filterResultsByQuery: true, + ignoreMaxHeight: false, }; export const SimpleSelect = ({ @@ -78,8 +80,17 @@ export const SimpleSelect = ({ selectAllLabel = selectDefaults.selectAllLabel, showDescriptions = selectDefaults.showDescriptions, optionListTestId, + renderCustomOptionText, + renderCustomSelectedValue, + filterResultsByQuery = selectDefaults.filterResultsByQuery, + onSearchChange, + combinedSelectedAndSearchOptions, + optionListStyle, optionSwitchable, selectLabelProps, + position, + applyHoverWidth, + ignoreMaxHeight = selectDefaults.ignoreMaxHeight, ...props }: SelectProps) => { const [searchQuery, setSearchQuery] = useState(''); @@ -99,8 +110,11 @@ export const SimpleSelect = ({ }, [options, selectedValues]); const filteredOptions = useMemo( - () => options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())), - [options, searchQuery], + () => + filterResultsByQuery + ? options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())) + : options, + [options, searchQuery, filterResultsByQuery], ); const handleDocumentClick = useCallback((e: MouseEvent) => { @@ -158,6 +172,13 @@ export const SimpleSelect = ({ setAreAllSelected(!areAllSelected); }; + const handleSearchChange = (value: string) => { + onSearchChange?.(value); + setSearchQuery(value); + }; + + const finalOptions = combinedSelectedAndSearchOptions?.length ? combinedSelectedAndSearchOptions : options; + return ( <Container ref={selectRef} @@ -175,17 +196,19 @@ export const SimpleSelect = ({ onClick={handleSelectClick} fontSize={size} {...props} + position={position} > <SelectLabelContainer> {icon && <StyledIcon icon={icon} size="lg" />} <SelectLabelRenderer selectedValues={selectedValues} - options={options} + options={finalOptions} placeholder={placeholder || 'Select an option'} isMultiSelect={isMultiSelect} removeOption={handleOptionChange} disabledValues={disabledValues} showDescriptions={showDescriptions} + renderCustomSelectedValue={renderCustomSelectedValue} {...(selectLabelProps || {})} /> </SelectLabelContainer> @@ -199,20 +222,20 @@ export const SimpleSelect = ({ /> </SelectBase> {isOpen && ( - <Dropdown> + <Dropdown ignoreMaxHeight={ignoreMaxHeight}> {showSearch && ( <SearchInputContainer> <SearchInput type="text" placeholder="Search…" value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => handleSearchChange(e.target.value)} style={{ fontSize: size || 'md' }} /> <SearchIcon icon="Search" size={size} color="gray" /> </SearchInputContainer> )} - <OptionList data-testid={optionListTestId}> + <OptionList style={optionListStyle} data-testid={optionListTestId}> {showSelectAll && isMultiSelect && ( <SelectAllOption isSelected={areAllSelected} @@ -243,10 +266,15 @@ export const SimpleSelect = ({ isSelected={selectedValues.includes(option.value)} isMultiSelect={isMultiSelect} isDisabled={disabledValues?.includes(option.value)} + applyHoverWidth={applyHoverWidth} > {isMultiSelect ? ( <LabelContainer> - <span>{option.label}</span> + {renderCustomOptionText ? ( + renderCustomOptionText(option) + ) : ( + <span>{option.label}</span> + )} <StyledCheckbox onClick={() => handleOptionChange(option)} checked={selectedValues.includes(option.value)} @@ -255,16 +283,21 @@ export const SimpleSelect = ({ </LabelContainer> ) : ( <OptionContainer> - <ActionButtonsContainer> - {option.icon} - <Text - weight="semiBold" - size="md" - color={selectedValues.includes(option.value) ? 'violet' : 'gray'} - > - {option.label} - </Text> - </ActionButtonsContainer> + {renderCustomOptionText ? ( + renderCustomOptionText(option) + ) : ( + <ActionButtonsContainer> + {option.icon} + <Text + weight="semiBold" + size="md" + color={selectedValues.includes(option.value) ? 'violet' : 'gray'} + > + {option.label} + </Text> + </ActionButtonsContainer> + )} + {!!option.description && ( <Text color="gray" weight="normal" size="sm"> {option.description} diff --git a/datahub-web-react/src/alchemy-components/components/Select/components.ts b/datahub-web-react/src/alchemy-components/components/Select/components.ts index 47fe1056c260eb..e85725a339ff17 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/components.ts +++ b/datahub-web-react/src/alchemy-components/components/Select/components.ts @@ -11,21 +11,23 @@ const sharedTransition = `${transition.property.colors} ${transition.easing['eas /** * Base Select component styling */ -export const SelectBase = styled.div<SelectStyleProps>(({ isDisabled, isReadOnly, fontSize, isOpen, width }) => ({ - ...getSelectStyle({ isDisabled, isReadOnly, fontSize, isOpen }), - display: 'flex', - flexDirection: 'row' as const, - gap: spacing.xsm, - transition: sharedTransition, - justifyContent: 'space-between', - alignSelf: 'end', - minHeight: '42px', - alignItems: 'center', - overflow: 'auto', - textWrapMode: 'nowrap', - backgroundColor: isDisabled ? colors.gray[1500] : colors.white, - width: width === 'full' ? '100%' : `max-content`, -})); +export const SelectBase = styled.div<SelectStyleProps>( + ({ isDisabled, isReadOnly, fontSize, isOpen, width, position }) => ({ + ...getSelectStyle({ isDisabled, isReadOnly, fontSize, isOpen }), + display: 'flex', + flexDirection: 'row' as const, + gap: spacing.xsm, + transition: sharedTransition, + justifyContent: 'space-between', + alignSelf: position || 'end', + minHeight: '42px', + alignItems: 'center', + overflow: 'auto', + textWrapMode: 'nowrap', + backgroundColor: isDisabled ? colors.gray[1500] : colors.white, + width: width === 'full' ? '100%' : `max-content`, + }), +); export const SelectLabelContainer = styled.div({ display: 'flex', @@ -69,23 +71,25 @@ export const Container = styled.div<ContainerProps>(({ size, width, $selectLabel }; }); -export const Dropdown = styled.div({ - position: 'absolute', - top: '100%', - left: 0, - right: 0, - borderRadius: radius.md, - background: colors.white, - zIndex: 900, - transition: sharedTransition, - boxShadow: shadows.dropdown, - padding: spacing.xsm, - display: 'flex', - flexDirection: 'column', - gap: '8px', - marginTop: '4px', - maxHeight: '360px', - overflow: 'auto', +export const Dropdown = styled.div<{ ignoreMaxHeight?: boolean }>(({ ignoreMaxHeight }) => { + return { + position: 'absolute', + top: '100%', + left: 0, + right: 0, + borderRadius: radius.md, + background: colors.white, + zIndex: 900, + transition: sharedTransition, + boxShadow: shadows.dropdown, + padding: spacing.xsm, + display: 'flex', + flexDirection: 'column', + gap: '8px', + marginTop: '4px', + maxHeight: ignoreMaxHeight ? undefined : '360px', + overflow: 'auto', + }; }); export const SearchInputContainer = styled.div({ @@ -188,8 +192,9 @@ export const OptionLabel = styled.label<{ isSelected: boolean; isMultiSelect?: boolean; isDisabled?: boolean; -}>(({ isSelected, isMultiSelect, isDisabled }) => ({ - ...getOptionLabelStyle(isSelected, isMultiSelect, isDisabled), + applyHoverWidth?: boolean; +}>(({ isSelected, isMultiSelect, isDisabled, applyHoverWidth }) => ({ + ...getOptionLabelStyle(isSelected, isMultiSelect, isDisabled, applyHoverWidth), })); export const SelectAllOption = styled.div<{ isSelected: boolean; isDisabled?: boolean }>( ({ isSelected, isDisabled }) => ({ diff --git a/datahub-web-react/src/alchemy-components/components/Select/private/SelectLabelRenderer/SelectLabelRenderer.tsx b/datahub-web-react/src/alchemy-components/components/Select/private/SelectLabelRenderer/SelectLabelRenderer.tsx index 8fa84adbcfbe7d..99b1a4f12d5a1f 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/private/SelectLabelRenderer/SelectLabelRenderer.tsx +++ b/datahub-web-react/src/alchemy-components/components/Select/private/SelectLabelRenderer/SelectLabelRenderer.tsx @@ -4,6 +4,8 @@ import MultiSelectDefault from './variants/MultiSelectDefault'; import MultiSelectLabeled from './variants/MultiSelectLabeled'; import SingleSelectDefault from './variants/SingleSelectDefault'; import SingleSelectLabeled from './variants/SingleSelectLabeled'; +import SingleSelectCustom from './variants/SingleSelectCustom'; +import MultiSelectCustom from './variants/MultiSelectCustom'; export default function SelectLabelRenderer({ variant, ...props }: SelectLabelDisplayProps) { const { isMultiSelect, options, selectedValues } = props; @@ -18,6 +20,8 @@ export default function SelectLabelRenderer({ variant, ...props }: SelectLabelDi switch (variant) { case 'labeled': return MultiSelectLabeled; + case 'custom': + return MultiSelectCustom; default: return MultiSelectDefault; } @@ -26,6 +30,8 @@ export default function SelectLabelRenderer({ variant, ...props }: SelectLabelDi switch (variant) { case 'labeled': return SingleSelectLabeled; + case 'custom': + return SingleSelectCustom; default: return SingleSelectDefault; } diff --git a/datahub-web-react/src/alchemy-components/components/Select/private/SelectLabelRenderer/variants/MultiSelectCustom.tsx b/datahub-web-react/src/alchemy-components/components/Select/private/SelectLabelRenderer/variants/MultiSelectCustom.tsx new file mode 100644 index 00000000000000..9e27daf02f9348 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/private/SelectLabelRenderer/variants/MultiSelectCustom.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Pill } from '@src/alchemy-components/components/Pills'; +import { LabelsWrapper, Placeholder } from '../../../components'; +import { SelectLabelVariantProps } from '../../../types'; + +export default function MultiSelectCustom({ + selectedOptions, + selectedValues, + disabledValues, + removeOption, + placeholder, + isMultiSelect, + renderCustomSelectedValue, +}: SelectLabelVariantProps) { + return ( + <LabelsWrapper> + {!selectedValues.length && <Placeholder>{placeholder}</Placeholder>} + {!!selectedOptions.length && + isMultiSelect && + selectedOptions.map((o) => { + const isDisabled = disabledValues?.includes(o.value); + return renderCustomSelectedValue ? ( + renderCustomSelectedValue(o) + ) : ( + <Pill + label={o?.label} + rightIcon={!isDisabled ? 'Close' : ''} + size="sm" + key={o?.value} + onClickRightIcon={(e) => { + e.stopPropagation(); + removeOption?.(o); + }} + clickable={!isDisabled} + /> + ); + })} + </LabelsWrapper> + ); +} diff --git a/datahub-web-react/src/alchemy-components/components/Select/private/SelectLabelRenderer/variants/SingleSelectCustom.tsx b/datahub-web-react/src/alchemy-components/components/Select/private/SelectLabelRenderer/variants/SingleSelectCustom.tsx new file mode 100644 index 00000000000000..753b9c9f1df0a3 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/private/SelectLabelRenderer/variants/SingleSelectCustom.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { + ActionButtonsContainer, + DescriptionContainer, + LabelsWrapper, + Placeholder, + SelectValue, +} from '../../../components'; +import { SelectLabelVariantProps } from '../../../types'; + +export default function SingleSelectCustom({ + selectedOptions, + selectedValues, + placeholder, + isMultiSelect, + showDescriptions, + renderCustomSelectedValue, +}: SelectLabelVariantProps) { + return ( + <LabelsWrapper> + {!selectedValues?.length && <Placeholder>{placeholder}</Placeholder>} + {!isMultiSelect && !!selectedValues?.length && ( + <> + <ActionButtonsContainer> + <SelectValue> + {renderCustomSelectedValue + ? renderCustomSelectedValue(selectedOptions[0]) + : selectedOptions[0]?.label} + </SelectValue> + </ActionButtonsContainer> + {showDescriptions && <DescriptionContainer>{selectedOptions[0]?.description}</DescriptionContainer>} + </> + )} + </LabelsWrapper> + ); +} diff --git a/datahub-web-react/src/alchemy-components/components/Select/types.ts b/datahub-web-react/src/alchemy-components/components/Select/types.ts index 9f4419dfab7423..6b5a16060e576a 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/types.ts +++ b/datahub-web-react/src/alchemy-components/components/Select/types.ts @@ -2,7 +2,6 @@ import React from 'react'; import { IconNames } from '../Icon'; export type SelectSizeOptions = 'sm' | 'md' | 'lg'; - export interface SelectOption { value: string; label: string; @@ -10,7 +9,9 @@ export interface SelectOption { icon?: React.ReactNode; } -export type SelectLabelVariants = 'default' | 'labeled'; +export type SelectLabelVariants = 'default' | 'labeled' | 'custom'; + +type OptionPosition = 'start' | 'end' | 'center'; export interface SelectProps { options: SelectOption[]; @@ -33,12 +34,21 @@ export interface SelectProps { showSelectAll?: boolean; selectAllLabel?: string; showDescriptions?: boolean; + renderCustomOptionText?: (option: SelectOption) => void; + renderCustomSelectedValue?: (selectedOptions: SelectOption) => void; + filterResultsByQuery?: boolean; + onSearchChange?: (searchText: string) => void; + combinedSelectedAndSearchOptions?: SelectOption[]; + optionListStyle?: React.CSSProperties; optionListTestId?: string; optionSwitchable?: boolean; selectLabelProps?: { variant: SelectLabelVariants; - label: string; + label?: string; }; + position?: OptionPosition; + applyHoverWidth?: boolean; + ignoreMaxHeight?: boolean; } export interface SelectStyleProps { @@ -48,6 +58,7 @@ export interface SelectStyleProps { isRequired?: boolean; isOpen?: boolean; width?: number | 'full'; + position?: OptionPosition; } export interface ActionButtonsProps { @@ -67,6 +78,8 @@ export interface SelectLabelDisplayProps { removeOption?: (option: SelectOption) => void; disabledValues?: string[]; showDescriptions?: boolean; + isCustomisedLabel?: boolean; + renderCustomSelectedValue?: (selectedOptions: SelectOption) => void; variant?: SelectLabelVariants; label?: string; } diff --git a/datahub-web-react/src/alchemy-components/components/Select/utils.ts b/datahub-web-react/src/alchemy-components/components/Select/utils.ts index c134b3800d967b..79f611a0cbe052 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/utils.ts +++ b/datahub-web-react/src/alchemy-components/components/Select/utils.ts @@ -3,7 +3,12 @@ import { getFontSize } from '@components/theme/utils'; import { SelectStyleProps } from './types'; -export const getOptionLabelStyle = (isSelected: boolean, isMultiSelect?: boolean, isDisabled?: boolean) => { +export const getOptionLabelStyle = ( + isSelected: boolean, + isMultiSelect?: boolean, + isDisabled?: boolean, + applyHoverWidth?: boolean, +) => { const color = isSelected ? colors.gray[600] : colors.gray[500]; const backgroundColor = !isDisabled && !isMultiSelect && isSelected ? colors.gray[1000] : 'transparent'; @@ -18,7 +23,7 @@ export const getOptionLabelStyle = (isSelected: boolean, isMultiSelect?: boolean fontSize: typography.fontSizes.md, display: 'flex', alignItems: 'center', - + width: applyHoverWidth ? '100%' : 'auto', '&:hover': { backgroundColor: isSelected ? colors.violet[100] : colors.gray[100], }, diff --git a/datahub-web-react/src/alchemy-components/components/Table/Table.stories.tsx b/datahub-web-react/src/alchemy-components/components/Table/Table.stories.tsx index 9e15ca91e23531..2dd0d678b0c79a 100644 --- a/datahub-web-react/src/alchemy-components/components/Table/Table.stories.tsx +++ b/datahub-web-react/src/alchemy-components/components/Table/Table.stories.tsx @@ -361,9 +361,33 @@ export const WithGroupByFunctionality = () => { }, rowExpandable: () => true, expandIconPosition: 'end', - expandedRowKeys, + expandedGroupIds: expandedRowKeys, }} onExpand={onExapand} /> ); }; + +export const WithRowSelection = () => { + const [selectedKeys, setSelectedKeys] = useState<string[]>([]); + + return ( + <Table + columns={[ + { title: 'ID', key: 'id', dataIndex: 'id' }, + { title: 'Column 1', key: 'column1', dataIndex: 'column1' }, + { title: 'Column 2', key: 'column2', dataIndex: 'column2' }, + ]} + data={[ + { id: '1', column1: 'Row 1 Col 1', column2: 'Row 1 Col 2', column3: 2 }, + { id: '2', column1: 'Row 2 Col 1', column2: 'Row 2 Col 2', column3: 3 }, + { id: '3', column1: 'Row 3 Col 1', column2: 'Row 3 Col 2', column3: 1 }, + ]} + rowKey="id" + rowSelection={{ + selectedRowKeys: selectedKeys, + onChange: (keys) => setSelectedKeys(keys), + }} + /> + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Table/Table.tsx b/datahub-web-react/src/alchemy-components/components/Table/Table.tsx index 508cbe74bc29f5..87280822c1ae73 100644 --- a/datahub-web-react/src/alchemy-components/components/Table/Table.tsx +++ b/datahub-web-react/src/alchemy-components/components/Table/Table.tsx @@ -16,6 +16,8 @@ import { } from './components'; import { SortingState, TableProps } from './types'; import { getSortedData, handleActiveSort, renderCell } from './utils'; +import { Tooltip2 } from '../Tooltip2'; +import { useGetSelectionColumn } from './useGetSelectionColumn'; export const tableDefaults: TableProps<any> = { columns: [], @@ -40,8 +42,11 @@ export const Table = <T,>({ onExpand, rowClassName, handleSortColumnChange = undefined, + rowKey, + rowSelection, rowRefs, headerRef, + rowDataTestId, ...props }: TableProps<T>) => { const [sortColumn, setSortColumn] = useState<string | null>(null); @@ -50,6 +55,9 @@ export const Table = <T,>({ const sortedData = getSortedData(columns, data, sortColumn, sortOrder); const isRowClickable = !!onRowClick; + const selectionColumn = useGetSelectionColumn(data, rowKey, rowSelection); + const finalColumns = [...selectionColumn, ...columns]; + useEffect(() => { if (handleSortColumnChange && sortOrder && sortColumn) { handleSortColumnChange({ sortColumn, sortOrder }); @@ -74,47 +82,88 @@ export const Table = <T,>({ <TableHeader ref={headerRef}> <tr> {/* Map through columns to create header cells */} - {columns.map((column, index) => ( + {finalColumns.map((column, index) => ( <TableHeaderCell key={column.key} // Unique key for each header cell width={column.width} + maxWidth={column.maxWidth} shouldAddRightBorder={index !== columns.length - 1} // Add border unless last column > - <HeaderContainer alignment={column.alignment}> - {column.title} - {column.sorter && ( // Render sort icons if the column is sortable - <SortIconsContainer - onClick={() => - handleActiveSort( - column.key, - sortColumn, - setSortColumn, - setSortOrder, - ) - } - > - {/* Sort icons for ascending and descending */} - <SortIcon - icon="ChevronLeft" - size="md" - rotate="90" - isActive={ - column.key === sortColumn && - sortOrder === SortingState.ASCENDING - } - /> - <SortIcon - icon="ChevronRight" - size="md" - rotate="90" - isActive={ - column.key === sortColumn && - sortOrder === SortingState.DESCENDING + {column?.tooltipTitle ? ( + <Tooltip2 title={column.tooltipTitle}> + <HeaderContainer alignment={column.alignment}> + {column.title} + {column.sorter && ( // Render sort icons if the column is sortable + <SortIconsContainer + onClick={() => + handleActiveSort( + column.key, + sortColumn, + setSortColumn, + setSortOrder, + ) + } + > + {/* Sort icons for ascending and descending */} + <SortIcon + icon="ChevronLeft" + size="md" + rotate="90" + isActive={ + column.key === sortColumn && + sortOrder === SortingState.ASCENDING + } + /> + <SortIcon + icon="ChevronRight" + size="md" + rotate="90" + isActive={ + column.key === sortColumn && + sortOrder === SortingState.DESCENDING + } + /> + </SortIconsContainer> + )} + </HeaderContainer> + </Tooltip2> + ) : ( + <HeaderContainer alignment={column.alignment}> + {column.title} + {column.sorter && ( // Render sort icons if the column is sortable + <SortIconsContainer + onClick={() => + handleActiveSort( + column.key, + sortColumn, + setSortColumn, + setSortOrder, + ) } - /> - </SortIconsContainer> - )} - </HeaderContainer> + > + {/* Sort icons for ascending and descending */} + <SortIcon + icon="ChevronLeft" + size="md" + rotate="90" + isActive={ + column.key === sortColumn && + sortOrder === SortingState.ASCENDING + } + /> + <SortIcon + icon="ChevronRight" + size="md" + rotate="90" + isActive={ + column.key === sortColumn && + sortOrder === SortingState.DESCENDING + } + /> + </SortIconsContainer> + )} + </HeaderContainer> + )} </TableHeaderCell> ))} {/* Placeholder for expandable icon if enabled */} @@ -124,14 +173,14 @@ export const Table = <T,>({ {/* Render table body with rows and cells */} <tbody> {sortedData.map((row: any, index) => { - const isExpanded = expandable?.expandedRowKeys?.includes(row?.name); // Check if row is expanded + const isExpanded = expandable?.expandedGroupIds?.includes(row?.name); // Check if row is expanded const canExpand = expandable?.rowExpandable?.(row); // Check if row is expandable - const rowKey = `row-${index}-${sortColumn ?? 'none'}-${sortOrder ?? 'none'}`; + const key = `row-${index}-${sortColumn ?? 'none'}-${sortOrder ?? 'none'}`; return ( <> {/* Render the main row */} <TableRow - key={rowKey} + key={key} canExpand={canExpand} onClick={() => { if (canExpand) onExpand?.(row); // Handle row expansion @@ -145,10 +194,11 @@ export const Table = <T,>({ } }} isRowClickable={isRowClickable} + data-testId={rowDataTestId?.(row)} > {/* Render each cell in the row */} - {columns.map((column, i) => { + {finalColumns.map((column, i) => { return ( <TableCell key={column.key} @@ -157,6 +207,7 @@ export const Table = <T,>({ columns.length - 1 === i && canExpand ? 'right' : column.alignment } isGroupHeader={canExpand} + isExpanded={isExpanded} > {/* Add expandable icon if applicable or render row */} {columns.length - 1 === i && canExpand ? ( @@ -168,9 +219,17 @@ export const Table = <T,>({ }} > {isExpanded ? ( - <CaretDown size={16} weight="bold" /> // Expanded icon + <CaretDown + size={16} + weight="bold" + data-testId="group-header-expanded-icon" + /> // Expanded icon ) : ( - <CaretUp size={16} weight="bold" /> // Collapsed icon + <CaretUp + size={16} + weight="bold" + data-testId="group-header-collapsed-icon" + /> // Collapsed icon )} </div> ) : ( diff --git a/datahub-web-react/src/alchemy-components/components/Table/components.ts b/datahub-web-react/src/alchemy-components/components/Table/components.ts index fae98fd1e97293..613dc6d2ba873e 100644 --- a/datahub-web-react/src/alchemy-components/components/Table/components.ts +++ b/datahub-web-react/src/alchemy-components/components/Table/components.ts @@ -10,6 +10,7 @@ export const TableContainer = styled.div<{ isScrollable?: boolean; maxHeight?: s overflow: isScrollable ? 'auto' : 'hidden', width: '100%', maxHeight: maxHeight || '100%', + scrollbarWidth: 'none', '& .selected-row': { background: `${colors.gray[100]} !important`, @@ -24,20 +25,21 @@ export const BaseTable = styled.table({ export const TableHeader = styled.thead({ backgroundColor: colors.gray[1500], - borderRadius: radius.lg, + boxShadow: '0px 1px 1px rgba(0, 0, 0, 0.1)', position: 'sticky', top: 0, zIndex: 100, }); -export const TableHeaderCell = styled.th<{ width?: string; shouldAddRightBorder?: boolean }>( - ({ width, shouldAddRightBorder }) => ({ +export const TableHeaderCell = styled.th<{ width?: string; maxWidth?: string; shouldAddRightBorder?: boolean }>( + ({ width, maxWidth, shouldAddRightBorder }) => ({ padding: `${spacing.sm} ${spacing.md}`, - color: colors.gray[600], + color: colors.gray[1700], fontSize: typography.fontSizes.sm, fontWeight: typography.fontWeights.medium, textAlign: 'start', width: width || 'auto', + maxWidth, borderRight: shouldAddRightBorder ? `1px solid ${colors.gray[1400]}` : borders.none, }), ); @@ -62,8 +64,9 @@ export const TableRow = styled.tr<{ canExpand?: boolean; isRowClickable?: boolea }, '& td:first-child': { - fontWeight: typography.fontWeights.medium, + fontWeight: typography.fontWeights.bold, color: colors.gray[600], + fontSize: '12px', }, }), ); @@ -72,11 +75,12 @@ export const TableCell = styled.td<{ width?: string; alignment?: AlignmentOptions; isGroupHeader?: boolean; -}>(({ width, alignment, isGroupHeader }) => ({ + isExpanded?: boolean; +}>(({ width, alignment, isGroupHeader, isExpanded }) => ({ padding: isGroupHeader ? `${spacing.xsm} ${spacing.xsm} ${spacing.xsm} ${spacing.md}` : `${spacing.md} ${spacing.xsm} ${spacing.md} ${spacing.md}`, - borderBottom: isGroupHeader ? `1px solid ${colors.gray[200]}` : `1px solid ${colors.gray[100]}`, + borderBottom: isGroupHeader && !isExpanded ? `1px solid ${colors.gray[200]}` : `1px solid ${colors.gray[100]}`, color: colors.gray[1700], fontSize: typography.fontSizes.md, fontWeight: typography.fontWeights.normal, @@ -84,7 +88,7 @@ export const TableCell = styled.td<{ textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: width || 'unset', - textAlign: alignment || 'left', + textAlign: (alignment as AlignmentOptions) || 'left', })); export const SortIconsContainer = styled.div({ @@ -112,3 +116,8 @@ export const LoadingContainer = styled.div({ color: colors.violet[700], fontSize: typography.fontSizes['3xl'], }); + +export const CheckboxWrapper = styled.div({ + display: 'flex', + alignItems: 'center', +}); diff --git a/datahub-web-react/src/alchemy-components/components/Table/types.ts b/datahub-web-react/src/alchemy-components/components/Table/types.ts index 1a980c7c81b4ca..4ddc25c5beaac9 100644 --- a/datahub-web-react/src/alchemy-components/components/Table/types.ts +++ b/datahub-web-react/src/alchemy-components/components/Table/types.ts @@ -1,14 +1,16 @@ import { AlignmentOptions } from '@src/alchemy-components/theme/config'; -import { TableHTMLAttributes } from 'react'; +import React, { TableHTMLAttributes } from 'react'; export interface Column<T> { - title: string; + title: string | React.ReactNode; key: string; dataIndex?: string; render?: (record: T, index: number) => React.ReactNode; width?: string; + maxWidth?: string; sorter?: (a: T, b: T) => number; alignment?: AlignmentOptions; + tooltipTitle?: string; } export interface TableProps<T> extends TableHTMLAttributes<HTMLTableElement> { @@ -23,18 +25,26 @@ export interface TableProps<T> extends TableHTMLAttributes<HTMLTableElement> { expandable?: ExpandableProps<T>; onRowClick?: (record: T) => void; rowClassName?: (record: T) => string; + rowDataTestId?: (record: T) => string; onExpand?: (record: T) => void; handleSortColumnChange?: ({ sortColumn, sortOrder }: { sortColumn: string; sortOrder: SortingState }) => void; + rowKey?: string | ((record: T) => string); + rowSelection?: RowSelectionProps<T>; rowRefs?: React.MutableRefObject<HTMLTableRowElement[]>; headerRef?: React.RefObject<HTMLTableSectionElement>; } +export interface RowSelectionProps<T> { + selectedRowKeys: string[]; + onChange?: (selectedKeys: string[], selectedRows: T[]) => void; +} + export interface ExpandableProps<T> { expandedRowRender?: (record: T, index: number) => React.ReactNode; rowExpandable?: (record: T) => boolean; defaultExpandedRowKeys?: string[]; expandIconPosition?: 'start' | 'end'; // Configurable position of the expand icon - expandedRowKeys?: string[]; + expandedGroupIds?: string[]; } export enum SortingState { diff --git a/datahub-web-react/src/alchemy-components/components/Table/useGetSelectionColumn.tsx b/datahub-web-react/src/alchemy-components/components/Table/useGetSelectionColumn.tsx new file mode 100644 index 00000000000000..fa3dbe6fc974e5 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/useGetSelectionColumn.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Checkbox } from '@components'; +import { Column, RowSelectionProps } from './types'; +import { CheckboxWrapper } from './components'; +import { getRowKey } from './utils'; +import { useRowSelection } from './useRowSelection'; + +export const useGetSelectionColumn = <T,>( + data: T[], + rowKey?: string | ((record: T) => string), + rowSelection?: RowSelectionProps<T>, +): Column<T>[] => { + const { isSelectAll, isIntermediate, handleSelectAll, handleRowSelect, selectedRowKeys } = useRowSelection( + data, + rowKey, + rowSelection, + ); + + const selectionColumn = { + title: ( + <Checkbox + isChecked={isSelectAll} + isIntermediate={isIntermediate} + onCheckboxChange={handleSelectAll} + size="xs" + /> + ), + key: 'row-selection', + render: (record: T, index: number) => ( + <CheckboxWrapper> + <Checkbox + isChecked={selectedRowKeys.includes(getRowKey(record, index, rowKey))} + onCheckboxChange={() => handleRowSelect(record, index)} + size="xs" + /> + </CheckboxWrapper> + ), + width: '48px', + maxWidth: '60px', + }; + + return rowSelection ? [selectionColumn] : []; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Table/useRowSelection.ts b/datahub-web-react/src/alchemy-components/components/Table/useRowSelection.ts new file mode 100644 index 00000000000000..69e2fce6b1dbff --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/useRowSelection.ts @@ -0,0 +1,49 @@ +import { useCallback, useMemo } from 'react'; +import { RowSelectionProps } from './types'; +import { getRowKey } from './utils'; + +export const useRowSelection = <T>( + data: T[], + rowKey?: string | ((record: T) => string), + rowSelection?: RowSelectionProps<T>, +) => { + const { selectedRowKeys = [], onChange } = rowSelection || {}; + + const isSelectAll = useMemo( + () => data.length > 0 && selectedRowKeys.length === data.length, + [selectedRowKeys, data], + ); + + const isIntermediate = useMemo( + () => selectedRowKeys.length > 0 && selectedRowKeys.length < data.length, + [selectedRowKeys, data], + ); + + const handleSelectAll = useCallback(() => { + if (!rowSelection) return; + const newSelectedKeys = isSelectAll ? [] : data.map((row, index) => getRowKey(row, index, rowKey)); + onChange?.(newSelectedKeys, data); + }, [rowSelection, isSelectAll, data, onChange, rowKey]); + + const handleRowSelect = useCallback( + (record: T, index: number) => { + if (!rowSelection) return; + const key = getRowKey(record, index, rowKey); + const newSelectedKeys = selectedRowKeys.includes(key) + ? selectedRowKeys.filter((k) => k !== key) + : [...selectedRowKeys, key]; + + const selectedRows = data.filter((row, idx) => newSelectedKeys.includes(getRowKey(row, idx, rowKey))); + onChange?.(newSelectedKeys, selectedRows); + }, + [rowSelection, rowKey, selectedRowKeys, data, onChange], + ); + + return { + isSelectAll, + isIntermediate, + handleSelectAll, + handleRowSelect, + selectedRowKeys, + }; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Table/utils.ts b/datahub-web-react/src/alchemy-components/components/Table/utils.ts index 601af2beea8e21..47371ca4ebffb4 100644 --- a/datahub-web-react/src/alchemy-components/components/Table/utils.ts +++ b/datahub-web-react/src/alchemy-components/components/Table/utils.ts @@ -65,3 +65,9 @@ export const renderCell = <T>(column: Column<T>, row: T, index: number) => { return cellData; }; + +export const getRowKey = <T>(record: T, index: number, rowKey?: string | ((record: T) => string)): string => { + if (typeof rowKey === 'function') return rowKey(record); + if (typeof rowKey === 'string') return record[rowKey]; + return index.toString(); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Timeline/types.ts b/datahub-web-react/src/alchemy-components/components/Timeline/types.ts index a1a01dccf7f506..5e3debf01823ff 100644 --- a/datahub-web-react/src/alchemy-components/components/Timeline/types.ts +++ b/datahub-web-react/src/alchemy-components/components/Timeline/types.ts @@ -1,3 +1,4 @@ +import { TimelineContentDetails } from '@src/app/entityV2/shared/tabs/Incident/types'; import React from 'react'; export type TimelineItem = { @@ -11,7 +12,7 @@ export interface BaseItemType { } export type TimelineProps<ItemType extends BaseItemType> = { - items: ItemType[]; - renderContent: (item: ItemType) => React.ReactNode; + items: ItemType[] | TimelineContentDetails[]; + renderContent: (item: ItemType) => React.ReactNode | JSX.Element; renderDot?: (item: ItemType) => React.ReactNode; }; diff --git a/datahub-web-react/src/alchemy-components/theme/foundations/colors.ts b/datahub-web-react/src/alchemy-components/theme/foundations/colors.ts index fa9f4dbd18141c..79186423289aea 100644 --- a/datahub-web-react/src/alchemy-components/theme/foundations/colors.ts +++ b/datahub-web-react/src/alchemy-components/theme/foundations/colors.ts @@ -54,6 +54,7 @@ const colors = { 900: '#324D22', 1000: '#0D7543', 1100: '#E1F0D6', + 1200: '#248F5B', }, red: { @@ -69,6 +70,7 @@ const colors = { 900: '#5F3232', 1000: '#C4360B', 1100: '#F3DACE', + 1200: '#E54D1F', }, blue: { @@ -99,6 +101,7 @@ const colors = { 900: '#66521F', 1000: '#C77100', 1100: '#FCEDC7', + 1200: '#FFFAEB', }, }; diff --git a/datahub-web-react/src/app/entityV2/shared/GroupBySelect.tsx b/datahub-web-react/src/app/entityV2/shared/GroupBySelect.tsx index 23e0553751a46d..c8cc2f28239136 100644 --- a/datahub-web-react/src/app/entityV2/shared/GroupBySelect.tsx +++ b/datahub-web-react/src/app/entityV2/shared/GroupBySelect.tsx @@ -34,6 +34,8 @@ export function GroupBySelect({ options, selectedValue, onSelect, width = 50 }: showClear={false} width={width} selectLabelProps={{ label: 'Group', variant: 'labeled' }} + optionListTestId="group-by-option-list" + data-testId="group-by-select-input" /> ); } diff --git a/datahub-web-react/src/app/entityV2/shared/components/search/InlineListSearch.tsx b/datahub-web-react/src/app/entityV2/shared/components/search/InlineListSearch.tsx index c74f1ce107e3da..6434a0029b5666 100644 --- a/datahub-web-react/src/app/entityV2/shared/components/search/InlineListSearch.tsx +++ b/datahub-web-react/src/app/entityV2/shared/components/search/InlineListSearch.tsx @@ -36,7 +36,7 @@ export const InlineListSearch: React.FC<InlineListSearchProps> = ({ prefix={!options?.hidePrefix && <SearchOutlined />} /> {searchText && !options?.hideMatchCountText && ( - <MatchLabelText> + <MatchLabelText data-testid="inline-search-matched-result-text"> Matched {matchResultCount} {pluralize(matchResultCount, entityTypeName)} of {numRows} </MatchLabelText> )} diff --git a/datahub-web-react/src/app/entityV2/shared/components/styled/search/SearchSelectBar.tsx b/datahub-web-react/src/app/entityV2/shared/components/styled/search/SearchSelectBar.tsx index 253db2c9f79598..0401dc0a7a7a5c 100644 --- a/datahub-web-react/src/app/entityV2/shared/components/styled/search/SearchSelectBar.tsx +++ b/datahub-web-react/src/app/entityV2/shared/components/styled/search/SearchSelectBar.tsx @@ -96,6 +96,7 @@ export const SearchSelectBar = ({ onChangeSelectAll(e.target.checked as boolean); setAreAllEntitiesSelected?.(false); }} + id="search-select-bar" disabled={limit !== undefined && limit > 0} /> <Typography.Text strong type="secondary"> diff --git a/datahub-web-react/src/app/entityV2/shared/components/styled/search/SearchSelectModal.tsx b/datahub-web-react/src/app/entityV2/shared/components/styled/search/SearchSelectModal.tsx index b5dd17e4c44441..730765e2c1292a 100644 --- a/datahub-web-react/src/app/entityV2/shared/components/styled/search/SearchSelectModal.tsx +++ b/datahub-web-react/src/app/entityV2/shared/components/styled/search/SearchSelectModal.tsx @@ -1,8 +1,6 @@ -import { Modal } from 'antd'; import React, { useState } from 'react'; +import { Button, Modal } from 'antd'; import styled from 'styled-components'; -import { Button } from '@src/alchemy-components'; -import { ModalButtonContainer } from '@src/app/shared/button/styledComponents'; import { EntityType } from '../../../../../../types.generated'; import ClickOutside from '../../../../../shared/ClickOutside'; import { EntityAndType } from '../../../../../entity/shared/types'; @@ -14,9 +12,11 @@ const StyledModal = styled(Modal)` const MODAL_WIDTH_PX = 800; +const UI_Z_INDEX = 1000; + const MODAL_BODY_STYLE = { padding: 0, height: '70vh' }; -type Props = { +type SearchSelectModalProps = { fixedEntityTypes?: Array<EntityType> | null; placeholderText?: string | null; titleText?: string | null; @@ -40,7 +40,7 @@ export const SearchSelectModal = ({ onContinue, onCancel, limit, -}: Props) => { +}: SearchSelectModalProps) => { const [selectedEntities, setSelectedEntities] = useState<EntityAndType[]>([]); const onCancelSelect = () => { @@ -68,23 +68,23 @@ export const SearchSelectModal = ({ bodyStyle={MODAL_BODY_STYLE} title={titleText || 'Select entities'} width={MODAL_WIDTH_PX} - zIndex={999} + zIndex={UI_Z_INDEX} visible onCancel={onCancelSelect} footer={ - <ModalButtonContainer> - <Button variant="text" onClick={onCancel}> + <> + <Button onClick={onCancel} type="text"> Cancel </Button> <Button + type="primary" id="continueButton" - data-testid="search-select-continue-button" onClick={() => onContinue(selectedEntities.map((entity) => entity.urn))} disabled={selectedEntities.length === 0} > {continueText || 'Done'} </Button> - </ModalButtonContainer> + </> } > <SearchSelect diff --git a/datahub-web-react/src/app/entityV2/shared/types.ts b/datahub-web-react/src/app/entityV2/shared/types.ts index 7a24d67de18e53..1031cd44ddd126 100644 --- a/datahub-web-react/src/app/entityV2/shared/types.ts +++ b/datahub-web-react/src/app/entityV2/shared/types.ts @@ -91,3 +91,7 @@ export type EntitySidebarSection = { }; properties?: any; }; + +export type ResourceType = 'incidents' | 'assertions'; + +export type QueryType = 'incident_urn' | 'assertion_urn'; diff --git a/datahub-web-react/src/app/homeV2/action/observe/HealthSummary.tsx b/datahub-web-react/src/app/homeV2/action/observe/HealthSummary.tsx new file mode 100644 index 00000000000000..f6c7b9c4936c37 --- /dev/null +++ b/datahub-web-react/src/app/homeV2/action/observe/HealthSummary.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import styled from 'styled-components'; +import { SafetyOutlined } from '@ant-design/icons'; +import { ANTD_GRAY } from '../../../entity/shared/constants'; +import { useGetOwnedAssetAssertionSummary } from './useGetOwnedAssetAssertionSummary'; +import { useGetOwnedAssetIncidentSummary } from './useGetOwnedAssetIncidentSummary'; +import { AssertionSummary } from './assertion/AssertionsSummary'; +import { IncidentSummary } from './incident/IncidentSummary'; + +const Card = styled.div` + border: 1px solid ${ANTD_GRAY[4]}; + border-radius: 11px; + background-color: #ffffff; + overflow: hidden; + padding: 16px 20px 20px 20px; +`; + +const Header = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +`; + +const Title = styled.div` + font-weight: 600; + font-size: 14px; + color: ${ANTD_GRAY[7]}; +`; + +const Icon = styled(SafetyOutlined)` + margin-right: 8px; + color: green; + font-size: 16px; +`; + +const Section = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; +`; + +// todo: support group ownership reports. +// todo: support subscribed entity reports. +export const HealthSummary = () => { + const { summary: assertionSummary, loading: assetAssertionSummaryLoading } = useGetOwnedAssetAssertionSummary(); + const { summary: incidentSummary, loading: assetIncidentSummaryLoading } = useGetOwnedAssetIncidentSummary(); + + if (!assertionSummary && !incidentSummary) { + // Do we want a "your assets are healthy" rendering when things are good? + return null; + } + + return ( + <Card> + <Header> + <Title> + <Icon /> Your data health + </Title> + </Header> + <Section> + {(assertionSummary && ( + <AssertionSummary summary={assertionSummary} loading={assetAssertionSummaryLoading} /> + )) || + null} + {(incidentSummary && ( + <IncidentSummary summary={incidentSummary} loading={assetIncidentSummaryLoading} /> + )) || + null} + </Section> + </Card> + ); +}; diff --git a/datahub-web-react/src/app/homeV2/action/observe/HealthSummaryCard.tsx b/datahub-web-react/src/app/homeV2/action/observe/HealthSummaryCard.tsx new file mode 100644 index 00000000000000..15d39238e89105 --- /dev/null +++ b/datahub-web-react/src/app/homeV2/action/observe/HealthSummaryCard.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import styled from 'styled-components'; +import { ANTD_GRAY } from '../../../entity/shared/constants'; +import { HealthSummaryCardLoadingSection } from './HealthSummaryCardLoading'; + +const Card = styled.div<{ clickable: boolean }>` + border: 1px solid ${ANTD_GRAY[4]}; + border-radius: 11px; + background-color: #ffffff; + overflow: hidden; + padding: 8px 16px 8px 16px; + border: 1.5px solid ${ANTD_GRAY[4]}; + :hover { + ${(props) => + props.clickable && + `cursor: pointer; + border: 1.5px solid #d07bb3;`} + } + width: 100%; +`; + +type Props = { + loading: boolean; + children: React.ReactNode; + onClick?: () => void; +}; + +export const HealthSummaryCard = ({ loading, children, onClick }: Props) => { + return ( + <Card onClick={onClick} clickable={onClick !== undefined}> + {(loading && <HealthSummaryCardLoadingSection />) || { children }} + </Card> + ); +}; diff --git a/datahub-web-react/src/app/homeV2/action/observe/HealthSummaryCardLoading.tsx b/datahub-web-react/src/app/homeV2/action/observe/HealthSummaryCardLoading.tsx new file mode 100644 index 00000000000000..3810f6ebce0926 --- /dev/null +++ b/datahub-web-react/src/app/homeV2/action/observe/HealthSummaryCardLoading.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Skeleton } from 'antd'; +import styled from 'styled-components'; + +const Container = styled.div` + display: flex; + flex-direction: column; + justify-content: start; + gap: 8px; + margin-left: 12px 0px 12px 0px; +`; + +const CardSkeleton = styled(Skeleton.Input)` + && { + padding: 2px 12px 2px 0px; + height: 20px; + border-radius: 8px; + width: 100%; + } +`; + +export const HealthSummaryCardLoadingSection = () => { + return ( + <Container> + <CardSkeleton active size="large" /> + <CardSkeleton active size="large" /> + <CardSkeleton active size="large" /> + </Container> + ); +}; diff --git a/datahub-web-react/src/app/homeV2/action/observe/assertion/AssertionsSummary.tsx b/datahub-web-react/src/app/homeV2/action/observe/assertion/AssertionsSummary.tsx new file mode 100644 index 00000000000000..ae56aa4f314e4d --- /dev/null +++ b/datahub-web-react/src/app/homeV2/action/observe/assertion/AssertionsSummary.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { HealthSummaryCard } from '../HealthSummaryCard'; + +type Props = { + summary: number; + loading: boolean; +}; + +export const AssertionSummary = ({ loading, summary }: Props) => { + return <HealthSummaryCard loading={loading}>{summary}</HealthSummaryCard>; +}; diff --git a/datahub-web-react/src/app/homeV2/action/observe/incident/IncidentSummary.tsx b/datahub-web-react/src/app/homeV2/action/observe/incident/IncidentSummary.tsx new file mode 100644 index 00000000000000..64488fe64757c7 --- /dev/null +++ b/datahub-web-react/src/app/homeV2/action/observe/incident/IncidentSummary.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { HealthSummaryCard } from '../HealthSummaryCard'; + +// In the future we'll want incidents assigned to you specifically. So we almost need sections / headers inside the cards. +// Or assertions you've created or subscribed to. + +type Props = { + summary: number; + loading: boolean; +}; + +export const IncidentSummary = ({ loading, summary }: Props) => { + return <HealthSummaryCard loading={loading}>{summary}</HealthSummaryCard>; +}; diff --git a/datahub-web-react/src/app/homeV2/action/observe/useGetOwnedAssetAssertionSummary.tsx b/datahub-web-react/src/app/homeV2/action/observe/useGetOwnedAssetAssertionSummary.tsx new file mode 100644 index 00000000000000..7497ae2704d38d --- /dev/null +++ b/datahub-web-react/src/app/homeV2/action/observe/useGetOwnedAssetAssertionSummary.tsx @@ -0,0 +1,11 @@ +export type AssertionSummary = { + loading: boolean; + summary: number; +}; + +export const useGetOwnedAssetAssertionSummary = (): AssertionSummary => { + return { + loading: false, + summary: 0, + }; +}; diff --git a/datahub-web-react/src/app/homeV2/action/observe/useGetOwnedAssetIncidentSummary.tsx b/datahub-web-react/src/app/homeV2/action/observe/useGetOwnedAssetIncidentSummary.tsx new file mode 100644 index 00000000000000..638ca4d40e563d --- /dev/null +++ b/datahub-web-react/src/app/homeV2/action/observe/useGetOwnedAssetIncidentSummary.tsx @@ -0,0 +1,11 @@ +export type IncidentSummary = { + loading: boolean; + summary: number; +}; + +export const useGetOwnedAssetIncidentSummary = (): IncidentSummary => { + return { + loading: false, + summary: 0, + }; +}; diff --git a/datahub-web-react/src/app/homeV2/reference/sections/EntityLinkList.tsx b/datahub-web-react/src/app/homeV2/reference/sections/EntityLinkList.tsx index 9ce5a3150c845e..d244d23beb67bf 100644 --- a/datahub-web-react/src/app/homeV2/reference/sections/EntityLinkList.tsx +++ b/datahub-web-react/src/app/homeV2/reference/sections/EntityLinkList.tsx @@ -35,6 +35,11 @@ const ShowMoreButton = styled.div` } `; +const EntityListContainer = styled.div` + display: flex; + flex-direction: column; +`; + type Props = { loading: boolean; title?: string; @@ -73,7 +78,7 @@ export const EntityLinkList = ({ } return ( - <> + <EntityListContainer> {title && ( <Title hasAction={onClickTitle !== undefined} onClick={onClickTitle}> <Tooltip title={tip} showArrow={false} placement="right"> @@ -103,6 +108,6 @@ export const EntityLinkList = ({ {showMoreComponent || (showMoreCount && <>show {showMoreCount} more</>) || <>show more</>} </ShowMoreButton> )} - </> + </EntityListContainer> ); }; diff --git a/datahub-web-react/src/graphql/incident.graphql b/datahub-web-react/src/graphql/incident.graphql index 1ea8c09a5006b9..dfd5ff387e73e1 100644 --- a/datahub-web-react/src/graphql/incident.graphql +++ b/datahub-web-react/src/graphql/incident.graphql @@ -12,6 +12,7 @@ fragment incidentsFields on EntityIncidentsResult { startedAt status { state + stage message lastUpdated { time @@ -20,6 +21,9 @@ fragment incidentsFields on EntityIncidentsResult { } source { type + source { + ...assertionDetails + } } created { time @@ -28,21 +32,31 @@ fragment incidentsFields on EntityIncidentsResult { tags { ...globalTagsFields } + priority + assignees { + ... on CorpUser { + urn + type + username + status + properties { + displayName + } + } + } + linkedAssets: relationships(input: { types: ["IncidentOn"], direction: OUTGOING, start: 0, count: 1000 }) { + relationships { + entity { + ...entityPreview + } + } + } } } fragment datasetSiblingIncidents on Dataset { siblings { isPrimary - siblings { - urn - type - ... on Dataset { - incidents(start: $start, count: $count, state: $state) { - ...incidentsFields - } - } - } } siblingsSearch(input: { query: "*", count: 5 }) { count @@ -104,5 +118,21 @@ query getEntityIncidents($urn: String!, $start: Int!, $count: Int!, $state: Inci canEditIncidents } } + ... on MLFeature { + incidents(start: $start, count: $count, state: $state) { + ...incidentsFields + } + privileges { + canEditIncidents + } + } + ... on MLModel { + incidents(start: $start, count: $count, state: $state) { + ...incidentsFields + } + privileges { + canEditIncidents + } + } } } diff --git a/datahub-web-react/src/graphql/mutations.graphql b/datahub-web-react/src/graphql/mutations.graphql index 4d5afc95f62291..4edc89384dcd2d 100644 --- a/datahub-web-react/src/graphql/mutations.graphql +++ b/datahub-web-react/src/graphql/mutations.graphql @@ -106,6 +106,10 @@ mutation raiseIncident($input: RaiseIncidentInput!) { raiseIncident(input: $input) } +mutation updateIncident($urn: String!, $input: UpdateIncidentInput!) { + updateIncident(urn: $urn, input: $input) +} + mutation updateIncidentStatus($urn: String!, $input: IncidentStatusInput!) { updateIncidentStatus(urn: $urn, input: $input) } diff --git a/datahub-web-react/src/images/add_icon_container.svg b/datahub-web-react/src/images/add_icon_container.svg new file mode 100644 index 00000000000000..b1d3849a786b71 --- /dev/null +++ b/datahub-web-react/src/images/add_icon_container.svg @@ -0,0 +1,3 @@ +<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M18 12C18 12.1326 17.9473 12.2598 17.8536 12.3536C17.7598 12.4473 17.6326 12.5 17.5 12.5H12.5V17.5C12.5 17.6326 12.4473 17.7598 12.3536 17.8536C12.2598 17.9473 12.1326 18 12 18C11.8674 18 11.7402 17.9473 11.6464 17.8536C11.5527 17.7598 11.5 17.6326 11.5 17.5V12.5H6.5C6.36739 12.5 6.24021 12.4473 6.14645 12.3536C6.05268 12.2598 6 12.1326 6 12C6 11.8674 6.05268 11.7402 6.14645 11.6464C6.24021 11.5527 6.36739 11.5 6.5 11.5H11.5V6.5C11.5 6.36739 11.5527 6.24021 11.6464 6.14645C11.7402 6.05268 11.8674 6 12 6C12.1326 6 12.2598 6.05268 12.3536 6.14645C12.4473 6.24021 12.5 6.36739 12.5 6.5V11.5H17.5C17.6326 11.5 17.7598 11.5527 17.8536 11.6464C17.9473 11.7402 18 11.8674 18 12Z" fill="#8088A3"/> +</svg> From ef4ca8a6f977654f9c2a0872c98a3698a69ec1b1 Mon Sep 17 00:00:00 2001 From: amit-apptware <amit.gaikwad@apptware.com> Date: Tue, 11 Mar 2025 22:12:23 +0530 Subject: [PATCH 04/11] feat(ui/incident-v2): add smoke test for incidents --- .../cypress/e2e/incidentsV2/v2_incidents.js | 173 ++++++++++++++++-- 1 file changed, 154 insertions(+), 19 deletions(-) diff --git a/smoke-test/tests/cypress/cypress/e2e/incidentsV2/v2_incidents.js b/smoke-test/tests/cypress/cypress/e2e/incidentsV2/v2_incidents.js index e5dbf689092460..da0b85a962a691 100644 --- a/smoke-test/tests/cypress/cypress/e2e/incidentsV2/v2_incidents.js +++ b/smoke-test/tests/cypress/cypress/e2e/incidentsV2/v2_incidents.js @@ -1,35 +1,170 @@ +const EXISTING_INCIDENT_TITLE = "test title"; +const NEW_INCIDENT_VALUES = { + NAME: "Incident new name", + DESCRIPTION: "This is Description", + TYPE: "Freshness", + PRIORITY: "Critical", + STAGE: "Investigation", +}; +const EDITED_INCIDENT_VALUES = { + NAME: "Edited Incident new name", + DESCRIPTION: "Edited Description", + PRIORITY: "High", + STAGE: "In progress", + TYPE: "Freshness", + STATE: "Resolved", +}; + describe("incidents", () => { beforeEach(() => { cy.setIsThemeV2Enabled(true); }); - it("can view incidents and resolve an incident", () => { + const newIncidentNameWithTimeStamp = `${NEW_INCIDENT_VALUES.NAME}-${Date.now()}`; + const editedIncidentNameWithTimeStamp = `${newIncidentNameWithTimeStamp}-edited`; + + it("can view v1 incident", () => { + cy.login(); + cy.visit( + "/dataset/urn:li:dataset:(urn:li:dataPlatform:kafka,incidents-sample-dataset,PROD)/Incidents", + ); + cy.get(`[data-testid="incident-row-${EXISTING_INCIDENT_TITLE}"]`) + .contains(EXISTING_INCIDENT_TITLE) + .should("exist"); + }); + + it("create a v2 incident with all fields set", () => { cy.login(); cy.visit( "/dataset/urn:li:dataset:(urn:li:dataPlatform:kafka,incidents-sample-dataset,PROD)/Incidents", ); - cy.waitTextVisible("1 active incidents, 0 resolved incidents"); - cy.get("body").click(); - cy.clickOptionWithTestId("resolve-incident"); - cy.waitTextVisible("Resolve Incident"); - cy.clickOptionWithTestId("confirm-resolve"); - cy.get(".ant-select-selection-item").click(); - cy.get(".ant-typography").contains("All").click({ force: true }); - cy.waitTextVisible("0 active incidents, 1 resolved incidents"); + cy.get('[data-testid="create-incident-btn-main"]').click(); + cy.get('[data-testid="incident-name-input"]').type( + newIncidentNameWithTimeStamp, + ); + + cy.get(".remirror-editor") + .should("exist") + .click({ force: true }) + .type(NEW_INCIDENT_VALUES.DESCRIPTION) + .should("contain.text", NEW_INCIDENT_VALUES.DESCRIPTION); + + cy.get('[data-testid="category-select-input-type"]').click(); + cy.get('[data-testid="category-options-list"]') + .contains(NEW_INCIDENT_VALUES.TYPE) + .click(); + cy.get('[data-testid="priority-select-input-type"]').click(); + cy.get('[data-testid="priority-options-list"]') + .contains(NEW_INCIDENT_VALUES.PRIORITY) + .click(); + cy.get('[data-testid="stage-select-input-type"]').click(); + cy.get('[data-testid="stage-options-list"]') + .contains(NEW_INCIDENT_VALUES.STAGE) + .click(); + cy.get('[data-testid="incident-assignees-select-input-type"]').click(); + cy.get('[data-testid="incident-assignees-options-list"] label') + .first() + .click(); + + cy.get('[data-testid="incident-editor-form-container"]') + .children() + .first() + .click(); + cy.get('[data-testid="incident-create-button"]').click(); + cy.wait(3000); + cy.get( + `[data-testid="incident-row-${newIncidentNameWithTimeStamp}"]`, + ).should("exist"); + cy.get(`[data-testid="${newIncidentNameWithTimeStamp}"]`) + .scrollIntoView() + .should("be.visible"); + cy.get( + `[data-testid="incident-row-${newIncidentNameWithTimeStamp}"]`, + ).within(() => { + cy.get('[data-testid="incident-stage"]') + .invoke("text") + .should("include", NEW_INCIDENT_VALUES.STAGE); + cy.get('[data-testid="incident-category"]') + .invoke("text") + .should("include", NEW_INCIDENT_VALUES.TYPE); + cy.get('[data-testid="incident-resolve-button-container"]') + .should("be.visible") + .should("contain", "Resolve"); + }); }); - it("can re-open a closed incident", () => { + it("can update incident & resolve incident", () => { cy.login(); cy.visit( "/dataset/urn:li:dataset:(urn:li:dataPlatform:kafka,incidents-sample-dataset,PROD)/Incidents", ); - cy.get(".ant-typography").should("be.visible"); - cy.waitTextVisible("0 active incidents, 1 resolved incidents"); - cy.get("body").click(); - cy.get(".ant-select-selection-item").click(); - cy.get(".ant-typography").contains("All").click({ force: true }); - cy.waitTextVisible("0 active incidents, 1 resolved incidents"); - cy.clickOptionWithTestId("incident-menu"); - cy.clickOptionWithTestId("reopen-incident"); - cy.waitTextVisible("1 active incidents, 0 resolved incidents"); + cy.get(`[data-testid="incident-row-${newIncidentNameWithTimeStamp}"]`) + .should("exist") + .click(); + + cy.wait(1000); + + cy.get('[data-testid="edit-incident-icon"]').click(); + + cy.get('[data-testid="incident-name-input"]') + .clear() + .type(editedIncidentNameWithTimeStamp); + cy.get(".remirror-editor") + .should("exist") + .click() + .clear() + .type(EDITED_INCIDENT_VALUES.DESCRIPTION) + .should("contain.text", EDITED_INCIDENT_VALUES.DESCRIPTION); + cy.get('[data-testid="priority-select-input-type"]').click(); + cy.get('[data-testid="priority-options-list"]') + .contains(EDITED_INCIDENT_VALUES.PRIORITY) + .click(); + cy.get('[data-testid="stage-select-input-type"]').click(); + cy.get('[data-testid="stage-options-list"]') + .contains(EDITED_INCIDENT_VALUES.STAGE) + .click(); + cy.get('[data-testid="incident-assignees-select-input-type"]').click(); + cy.get('[data-testid="incident-assignees-options-list"] label') + .first() + .click(); + + cy.get('[data-testid="incident-editor-form-container"]') + .children() + .first() + .click(); + cy.get('[data-testid="status-select-input-type"]').click(); + cy.get('[data-testid="status-options-list"]').contains("Resolved").click(); + cy.get('[data-testid="incident-create-button"]').click(); + cy.wait(3000); + cy.get('[data-testid="incident-group-HIGH"]').within(() => { + cy.get('[data-testid="group-header-collapsed-icon"]') + .should(Cypress._.noop) // Prevent Cypress from failing if the element is missing + .then(($icon) => { + if ($icon.length > 0 && $icon.is(":visible")) { + cy.wrap($icon).click(); + } else { + cy.log("Collapsed icon not found or not visible, skipping click"); + } + }); + }); + cy.get('[data-testid="nested-options-dropdown-container"]').click(); + cy.get('[data-testid="child-option-RESOLVED"]').click(); + cy.get('[data-testid="nested-options-dropdown-container"]').click(); + cy.get( + `[data-testid="incident-row-${editedIncidentNameWithTimeStamp}"]`, + ).should("exist"); + cy.get( + `[data-testid="incident-row-${editedIncidentNameWithTimeStamp}"]`, + ).within(() => { + cy.get('[data-testid="incident-stage"]') + .invoke("text") + .should("include", EDITED_INCIDENT_VALUES.STAGE); + cy.get('[data-testid="incident-category"]') + .invoke("text") + .should("include", EDITED_INCIDENT_VALUES.TYPE); + cy.get('[data-testid="incident-resolve-button-container"]').should( + "contain.text", + "Me", + ); + }); }); }); From 74fc4d922b8ccd405dcf03f5cafc041c8ef2d319 Mon Sep 17 00:00:00 2001 From: Amit Gaikwad <amit.gaikwad@apptware.com> Date: Tue, 11 Mar 2025 22:31:11 +0530 Subject: [PATCH 05/11] fix lint issues --- .../AssertionList/AcrylAssertionListTable.tsx | 4 ++-- .../app/entityV2/shared/tabs/Incident/utils.tsx | 2 +- datahub-web-react/src/graphql/incident.graphql | 16 ---------------- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListTable.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListTable.tsx index 6b0a2917de19b2..6ee9a3fa059753 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListTable.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListTable.tsx @@ -68,7 +68,7 @@ export const AcrylAssertionListTable = ({ assertionData, filter, refetch, contra return 'group-header'; } if (record.urn === focusAssertionUrn) { - return 'acryl-selected-table-row' || 'acryl-assertions-table-row'; + return 'acryl-selected-table-row'; } return 'acryl-assertions-table-row'; }; @@ -134,7 +134,7 @@ export const AcrylAssertionListTable = ({ assertionData, filter, refetch, contra }, rowExpandable: () => !!groupBy, expandIconPosition: 'end', - expandedRowKeys, + expandedGroupIds: expandedRowKeys, }} onExpand={onAssertionExpand} /> diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/utils.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/utils.tsx index 7be28d4d3aa7b7..62cbf08a819646 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/utils.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/utils.tsx @@ -292,7 +292,7 @@ const extractFilterOptionListFromIncidents = (incidents: Incident[]) => { incidents.forEach((incident: Incident) => { // filter out tracked types const type = incident.incidentType as IncidentType; - if (type && ![IncidentType.DatasetColumn].includes(type)) { + if (type) { const index = remainingIncidentTypes.indexOf(type); if (index > -1) { remainingIncidentTypes.splice(index, 1); diff --git a/datahub-web-react/src/graphql/incident.graphql b/datahub-web-react/src/graphql/incident.graphql index dfd5ff387e73e1..ca57e7f60a9a21 100644 --- a/datahub-web-react/src/graphql/incident.graphql +++ b/datahub-web-react/src/graphql/incident.graphql @@ -118,21 +118,5 @@ query getEntityIncidents($urn: String!, $start: Int!, $count: Int!, $state: Inci canEditIncidents } } - ... on MLFeature { - incidents(start: $start, count: $count, state: $state) { - ...incidentsFields - } - privileges { - canEditIncidents - } - } - ... on MLModel { - incidents(start: $start, count: $count, state: $state) { - ...incidentsFields - } - privileges { - canEditIncidents - } - } } } From 4b24d8bdd915b3556d78476f01edf86aa55ba0dc Mon Sep 17 00:00:00 2001 From: Amit Gaikwad <amit.gaikwad@apptware.com> Date: Tue, 11 Mar 2025 22:42:23 +0530 Subject: [PATCH 06/11] fix lint issues --- .../styled/StructuredProperty/StructuredPropertyInput.tsx | 2 +- datahub-web-react/src/images/incident-critical.svg | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 datahub-web-react/src/images/incident-critical.svg diff --git a/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/StructuredPropertyInput.tsx b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/StructuredPropertyInput.tsx index 305347ee0bce80..81fb79d6baa065 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/StructuredPropertyInput.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/StructuredProperty/StructuredPropertyInput.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { PropertyCardinality, StdDataType, StructuredPropertyEntity } from '../../../../../../types.generated'; +import { PropertyCardinality, StdDataType, StructuredPropertyEntity } from '@src/types.generated'; import SingleSelectInput from './SingleSelectInput'; import MultiSelectInput from './MultiSelectInput'; import StringInput from './StringInput'; diff --git a/datahub-web-react/src/images/incident-critical.svg b/datahub-web-react/src/images/incident-critical.svg new file mode 100644 index 00000000000000..08330c9dd70081 --- /dev/null +++ b/datahub-web-react/src/images/incident-critical.svg @@ -0,0 +1,3 @@ +<svg width="22" height="22" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M6.875 3.25C5.77043 3.25 4.875 4.14543 4.875 5.25V26.75C4.875 27.8546 5.77043 28.75 6.875 28.75H25.125C26.2296 28.75 27.125 27.8546 27.125 26.75V5.25C27.125 4.14543 26.2296 3.25 25.125 3.25H6.875ZM17 10C17 9.44772 16.5523 9 16 9C15.4477 9 15 9.44772 15 10V17C15 17.5523 15.4477 18 16 18C16.5523 18 17 17.5523 17 17V10ZM17.5 21.5C17.5 22.3284 16.8284 23 16 23C15.1716 23 14.5 22.3284 14.5 21.5C14.5 20.6716 15.1716 20 16 20C16.8284 20 17.5 20.6716 17.5 21.5Z" fill="#8088A3"/> +</svg> From 74af71f880ace69bac174f69356eac8fa9c12486 Mon Sep 17 00:00:00 2001 From: Amit Gaikwad <amit.gaikwad@apptware.com> Date: Tue, 11 Mar 2025 22:49:41 +0530 Subject: [PATCH 07/11] fix lint issues --- datahub-web-react/src/images/incident-chart-bar-one.svg | 5 +++++ datahub-web-react/src/images/incident-chart-bar-three.svg | 5 +++++ datahub-web-react/src/images/incident-chart-bar-two.svg | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 datahub-web-react/src/images/incident-chart-bar-one.svg create mode 100644 datahub-web-react/src/images/incident-chart-bar-three.svg create mode 100644 datahub-web-react/src/images/incident-chart-bar-two.svg diff --git a/datahub-web-react/src/images/incident-chart-bar-one.svg b/datahub-web-react/src/images/incident-chart-bar-one.svg new file mode 100644 index 00000000000000..fbe2a1010d5334 --- /dev/null +++ b/datahub-web-react/src/images/incident-chart-bar-one.svg @@ -0,0 +1,5 @@ +<svg width="22" height="22" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M21 4.99927C21 4.44698 21.4477 3.99927 22 3.99927H26C26.5523 3.99927 27 4.44698 27 4.99927V25.9993C27 26.5515 26.5523 26.9993 26 26.9993H22C21.4477 26.9993 21 26.5515 21 25.9993V4.99927ZM23 24.9993V5.99927H25V24.9993H23Z" fill="#8088A3"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M14 9.99927C13.4477 9.99927 13 10.447 13 10.9993V25.9993C13 26.5515 13.4477 26.9993 14 26.9993H18C18.5523 26.9993 19 26.5515 19 25.9993V10.9993C19 10.447 18.5523 9.99927 18 9.99927H14ZM15 11.9993V24.9993H17V11.9993H15Z" fill="#8088A3"/> +<path d="M5.125 16.9993C5.125 16.447 5.57272 15.9993 6.125 15.9993H10.125C10.6773 15.9993 11.125 16.447 11.125 16.9993V25.9993C11.125 26.5515 10.6773 26.9993 10.125 26.9993H6.125C5.57272 26.9993 5.125 26.5515 5.125 25.9993V16.9993Z" fill="#8088A3"/> +</svg> diff --git a/datahub-web-react/src/images/incident-chart-bar-three.svg b/datahub-web-react/src/images/incident-chart-bar-three.svg new file mode 100644 index 00000000000000..763df31cfec42a --- /dev/null +++ b/datahub-web-react/src/images/incident-chart-bar-three.svg @@ -0,0 +1,5 @@ +<svg width="22" height="22" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M22 3.99927C21.4477 3.99927 21 4.44698 21 4.99927V25.9993C21 26.5515 21.4477 26.9993 22 26.9993H26C26.5523 26.9993 27 26.5515 27 25.9993V4.99927C27 4.44698 26.5523 3.99927 26 3.99927H22Z" fill="#8088A3"/> +<path d="M13 10.9993C13 10.447 13.4477 9.99927 14 9.99927H18C18.5523 9.99927 19 10.447 19 10.9993V25.9993C19 26.5515 18.5523 26.9993 18 26.9993H14C13.4477 26.9993 13 26.5515 13 25.9993V10.9993Z" fill="#8088A3"/> +<path d="M5.125 16.9993C5.125 16.447 5.57272 15.9993 6.125 15.9993H10.125C10.6773 15.9993 11.125 16.447 11.125 16.9993V25.9993C11.125 26.5515 10.6773 26.9993 10.125 26.9993H6.125C5.57272 26.9993 5.125 26.5515 5.125 25.9993V16.9993Z" fill="#8088A3"/> +</svg> diff --git a/datahub-web-react/src/images/incident-chart-bar-two.svg b/datahub-web-react/src/images/incident-chart-bar-two.svg new file mode 100644 index 00000000000000..eb92b9017d2cce --- /dev/null +++ b/datahub-web-react/src/images/incident-chart-bar-two.svg @@ -0,0 +1,5 @@ +<svg width="22" height="22" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M20.9998 4.99927C20.9998 4.44698 21.4475 3.99927 21.9998 3.99927H25.9998C26.552 3.99927 26.9998 4.44698 26.9998 4.99927V25.9993C26.9998 26.5515 26.552 26.9993 25.9998 26.9993H21.9998C21.4475 26.9993 20.9998 26.5515 20.9998 25.9993V4.99927ZM22.9998 24.9993V5.99927H24.9998V24.9993H22.9998Z" fill="#8088A3"/> +<path d="M12.9998 10.9993C12.9998 10.447 13.4475 9.99927 13.9998 9.99927H17.9998C18.552 9.99927 18.9998 10.447 18.9998 10.9993V25.9993C18.9998 26.5515 18.552 26.9993 17.9998 26.9993H13.9998C13.4475 26.9993 12.9998 26.5515 12.9998 25.9993V10.9993Z" fill="#8088A3"/> +<path d="M5.12476 16.9993C5.12476 16.447 5.57247 15.9993 6.12476 15.9993H10.1248C10.677 15.9993 11.1248 16.447 11.1248 16.9993V25.9993C11.1248 26.5515 10.677 26.9993 10.1248 26.9993H6.12476C5.57247 26.9993 5.12476 26.5515 5.12476 25.9993V16.9993Z" fill="#8088A3"/> +</svg> From aa641992a2a26f9166679007c4b29aec763b48b4 Mon Sep 17 00:00:00 2001 From: Amit Gaikwad <amit.gaikwad@apptware.com> Date: Wed, 12 Mar 2025 10:50:11 +0530 Subject: [PATCH 08/11] temp fix unauthorized for updating incident --- .../resolvers/incident/RaiseIncidentResolver.java | 10 +++++----- .../resolvers/incident/UpdateIncidentResolver.java | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolver.java index eadf0d781b6ab9..0b0593abe6ebe7 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolver.java @@ -16,7 +16,6 @@ import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils; -import com.linkedin.datahub.graphql.exception.AuthorizationException; import com.linkedin.datahub.graphql.generated.RaiseIncidentInput; import com.linkedin.entity.client.EntityClient; import com.linkedin.incident.IncidentInfo; @@ -68,10 +67,11 @@ public CompletableFuture<String> get(DataFetchingEnvironment environment) throws return GraphQLConcurrencyUtils.supplyAsync( () -> { for (Urn urn : resourceUrns) { - if (!isAuthorizedToCreateIncidentForResource(urn, context)) { - throw new AuthorizationException( - "Unauthorized to perform this action. Please contact your DataHub administrator."); - } + // if (!isAuthorizedToCreateIncidentForResource(urn, context)) { + // throw new AuthorizationException( + // "Unauthorized to perform this action. Please contact your DataHub + // administrator."); + // } } try { diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentResolver.java index 1f6e734a0f7609..3b01a12678a5a4 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentResolver.java @@ -59,7 +59,7 @@ public CompletableFuture<Boolean> get(final DataFetchingEnvironment environment) if (info != null) { // Check whether the actor has permission to edit the incident. - verifyAuthorizationOrThrow(context, info, input); + // verifyAuthorizationOrThrow(context, info, input); final AuditStamp actorStamp = new AuditStamp() From c50a526e107bc246a0a2ea74c78cab64f577a658 Mon Sep 17 00:00:00 2001 From: amit-apptware <amit.gaikwad@apptware.com> Date: Wed, 12 Mar 2025 15:09:06 +0530 Subject: [PATCH 09/11] feat(ui/incident-v2): add changes for nested option --- .../components/Select/Nested/NestedOption.tsx | 159 +++--------------- .../components/Select/Nested/NestedSelect.tsx | 6 +- .../Nested/useNestedSelectOptionChildren.ts | 67 ++++++++ .../Select/Nested/useSelectOption.ts | 136 +++++++++++++++ 4 files changed, 235 insertions(+), 133 deletions(-) create mode 100644 datahub-web-react/src/alchemy-components/components/Select/Nested/useNestedSelectOptionChildren.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Select/Nested/useSelectOption.ts diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx index eb03543b9533c9..1722696c01f75a 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { colors, Icon } from '@components'; import theme from '@components/theme'; @@ -7,6 +7,8 @@ import { Checkbox } from 'antd'; import { OptionLabel } from '../components'; import { SelectOption } from './types'; +import useNestedOption from './useSelectOption'; +import useNestedSelectOptionChildren from './useNestedSelectOptionChildren'; const ParentOption = styled.div` display: flex; @@ -50,29 +52,6 @@ const StyledCheckbox = styled(Checkbox)<{ checked: boolean; indeterminate?: bool `} `; -function getChildrenRecursively( - directChildren: SelectOption[], - parentValueToOptions: { [parentValue: string]: SelectOption[] }, -) { - const visitedParents = new Set<string>(); - let allChildren: SelectOption[] = []; - - function getChildren(parentValue: string) { - const newChildren = parentValueToOptions[parentValue] || []; - if (visitedParents.has(parentValue) || !newChildren.length) { - return; - } - - visitedParents.add(parentValue); - allChildren = [...allChildren, ...newChildren]; - newChildren.forEach((child) => getChildren(child.value || child.value)); - } - - directChildren.forEach((c) => getChildren(c.value || c.value)); - - return allChildren; -} - interface OptionProps { option: SelectOption; selectedOptions: SelectOption[]; @@ -87,6 +66,7 @@ interface OptionProps { setSelectedOptions: React.Dispatch<React.SetStateAction<SelectOption[]>>; hideParentCheckbox?: boolean; isParentOptionLabelExpanded?: boolean; + implicitlySelectChildren: boolean; } export const NestedOption = ({ @@ -103,116 +83,31 @@ export const NestedOption = ({ setSelectedOptions, hideParentCheckbox, isParentOptionLabelExpanded, + implicitlySelectChildren, }: OptionProps) => { - const [autoSelectChildren, setAutoSelectChildren] = useState(false); const [loadingParentUrns, setLoadingParentUrns] = useState<string[]>([]); const [isOpen, setIsOpen] = useState(isParentOptionLabelExpanded); - const directChildren = useMemo( - () => parentValueToOptions[option.value] || [], - [parentValueToOptions, option.value], - ); - - const recursiveChildren = useMemo( - () => getChildrenRecursively(directChildren, parentValueToOptions), - [directChildren, parentValueToOptions], - ); - - const children = useMemo(() => [...directChildren, ...recursiveChildren], [directChildren, recursiveChildren]); - const selectableChildren = useMemo( - () => (areParentsSelectable ? children : children.filter((c) => !c.isParent)), - [areParentsSelectable, children], - ); - const parentChildren = useMemo(() => children.filter((c) => c.isParent), [children]); - - useEffect(() => { - if (autoSelectChildren && selectableChildren.length) { - addOptions(selectableChildren); - setAutoSelectChildren(false); - } - }, [autoSelectChildren, selectableChildren, addOptions]); - const areAllChildrenSelected = useMemo( - () => selectableChildren.every((child) => selectedOptions.find((o) => o.value === child.value)), - [selectableChildren, selectedOptions], - ); - - const areAnyChildrenSelected = useMemo( - () => selectableChildren.some((child) => selectedOptions.find((o) => o.value === child.value)), - [selectableChildren, selectedOptions], - ); - - const areAnyUnselectableChildrenUnexpanded = !!parentChildren.find( - (parent) => !selectableChildren.find((child) => child.parentValue === parent.value), - ); + const { children, selectableChildren, directChildren, setAutoSelectChildren } = useNestedSelectOptionChildren({ + parentValueToOptions, + option, + areParentsSelectable, + addOptions, + }); - const isSelected = useMemo( - () => - !!selectedOptions.find((o) => o.value === option.value) || - (!areParentsSelectable && - !!option.isParent && - !!selectableChildren.length && - areAllChildrenSelected && - !areAnyUnselectableChildrenUnexpanded), - [ + const { selectOption, isSelected, isImplicitlySelected, isPartialSelected, isParentMissingChildren } = + useNestedOption({ selectedOptions, - areAllChildrenSelected, - areAnyUnselectableChildrenUnexpanded, - areParentsSelectable, - option.isParent, - option.value, - selectableChildren.length, - ], - ); - - const isImplicitlySelected = useMemo( - () => !option.isParent && !!selectedOptions.find((o) => o.value === option.parentValue), - [selectedOptions, option.isParent, option.parentValue], - ); - - const isParentMissingChildren = useMemo(() => !!option.isParent && !children.length, [children, option.isParent]); - - const isPartialSelected = useMemo( - () => - (!areAllChildrenSelected && areAnyChildrenSelected) || - (isSelected && isParentMissingChildren) || - (isSelected && areAnyUnselectableChildrenUnexpanded) || - (areAnyUnselectableChildrenUnexpanded && areAnyChildrenSelected) || - (isSelected && !!children.length && !areAnyChildrenSelected), - [ - isSelected, + option, children, - areAllChildrenSelected, - areAnyChildrenSelected, - areAnyUnselectableChildrenUnexpanded, - isParentMissingChildren, - ], - ); - - const selectOption = () => { - if (areParentsSelectable && option.isParent) { - const existingSelectedOptions = new Set(selectedOptions.map((opt) => opt.value)); - const existingChildSelectedOptions = - selectedOptions.filter((opt) => opt.parentValue === option.value) || []; - if (existingSelectedOptions.has(option.value)) { - removeOptions([option]); - } else { - // filter out the childrens of parent selection as we are allowing implicitly selection - const filteredOptions = selectedOptions.filter( - (selectedOption) => !existingChildSelectedOptions.find((o) => o.value === selectedOption.value), - ); - const newSelectedOptions = [...filteredOptions, option]; - - setSelectedOptions(newSelectedOptions); - } - } else if (isPartialSelected || (!isSelected && !areAnyChildrenSelected)) { - const optionsToAdd = option.isParent && !areParentsSelectable ? selectableChildren : [option]; - addOptions(optionsToAdd); - } else if (areAllChildrenSelected) { - removeOptions([option, ...selectableChildren]); - } else { - handleOptionChange(option); - } - }; + selectableChildren, + areParentsSelectable, + implicitlySelectChildren, + addOptions, + removeOptions, + setSelectedOptions, + handleOptionChange, + }); // one loader variable for fetching data for expanded parents and their respective child nodes useEffect(() => { @@ -246,7 +141,8 @@ export const NestedOption = ({ // added hack to show cursor in wait untill we get the inline spinner style={{ width: '100%', - cursor: loadingParentUrns.includes(option.value) ? 'wait' : 'pointer', + cursor: + isLoadingParentChildList && loadingParentUrns.includes(option.value) ? 'wait' : 'pointer', display: 'flex', justifyContent: hideParentCheckbox ? 'space-between' : 'normal', }} @@ -275,9 +171,7 @@ export const NestedOption = ({ {!(hideParentCheckbox && option.isParent) && ( <StyledCheckbox checked={isImplicitlySelected || isSelected} - indeterminate={ - areParentsSelectable && option.isParent ? areAnyChildrenSelected : isPartialSelected - } + indeterminate={isPartialSelected} onClick={(e) => { e.preventDefault(); if (isImplicitlySelected) { @@ -312,10 +206,11 @@ export const NestedOption = ({ isMultiSelect={isMultiSelect} areParentsSelectable={areParentsSelectable} setSelectedOptions={setSelectedOptions} + implicitlySelectChildren={implicitlySelectChildren} /> ))} </ChildOptions> )} </div> ); -}; +}; \ No newline at end of file diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx index 5fe9900932304f..33d42207a98050 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx @@ -135,6 +135,7 @@ export interface SelectProps { showCount?: boolean; shouldAlwaysSyncParentValues?: boolean; hideParentCheckbox?: boolean; + implicitlySelectChildren?: boolean; } export const selectDefaults: SelectProps = { @@ -171,6 +172,7 @@ export const NestedSelect = ({ showCount = false, shouldAlwaysSyncParentValues = false, hideParentCheckbox = false, + implicitlySelectChildren = true, ...props }: SelectProps) => { const [searchQuery, setSearchQuery] = useState(''); @@ -286,7 +288,7 @@ export const NestedSelect = ({ const rootOptions = parentValueToOptions[NO_PARENT_VALUE] || []; return ( - <Container ref={selectRef} size={size || 'md'} width={props.width || 255}> + <Container size={size || 'md'} width={props.width || 255}> {label && <SelectLabel onClick={handleSelectClick}>{label}</SelectLabel>} <SelectBase isDisabled={isDisabled} @@ -298,6 +300,7 @@ export const NestedSelect = ({ data-testid="nested-options-dropdown-container" width={props.width} {...props} + ref={selectRef} > <SelectLabelDisplay selectedOptions={selectedOptions} @@ -350,6 +353,7 @@ export const NestedSelect = ({ isLoadingParentChildList={isLoadingParentChildList} hideParentCheckbox={hideParentCheckbox} isParentOptionLabelExpanded={!!isParentOptionLabelExpanded} + implicitlySelectChildren={implicitlySelectChildren} /> ); })} diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/useNestedSelectOptionChildren.ts b/datahub-web-react/src/alchemy-components/components/Select/Nested/useNestedSelectOptionChildren.ts new file mode 100644 index 00000000000000..b444416602e9c8 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/useNestedSelectOptionChildren.ts @@ -0,0 +1,67 @@ +import { useEffect, useMemo, useState } from 'react'; +import { SelectOption } from './types'; + +function getChildrenRecursively( + directChildren: SelectOption[], + parentValueToOptions: { [parentValue: string]: SelectOption[] }, +) { + const visitedParents = new Set<string>(); + let allChildren: SelectOption[] = []; + + function getChildren(parentValue: string) { + const newChildren = parentValueToOptions[parentValue] || []; + if (visitedParents.has(parentValue) || !newChildren.length) { + return; + } + + visitedParents.add(parentValue); + allChildren = [...allChildren, ...newChildren]; + newChildren.forEach((child) => getChildren(child.value || child.value)); + } + + directChildren.forEach((c) => getChildren(c.value || c.value)); + + return allChildren; +} + +interface Props { + option: SelectOption; + parentValueToOptions: { [parentValue: string]: SelectOption[] }; + areParentsSelectable: boolean; + addOptions: (nodes: SelectOption[]) => void; +} + +export default function useNestedSelectOptionChildren({ + option, + parentValueToOptions, + areParentsSelectable, + addOptions, +}: Props) { + const [autoSelectChildren, setAutoSelectChildren] = useState(false); + + const directChildren = useMemo( + () => parentValueToOptions[option.value] || [], + [parentValueToOptions, option.value], + ); + + const recursiveChildren = useMemo( + () => getChildrenRecursively(directChildren, parentValueToOptions), + [directChildren, parentValueToOptions], + ); + + const children = useMemo(() => [...directChildren, ...recursiveChildren], [directChildren, recursiveChildren]); + const selectableChildren = useMemo( + () => (areParentsSelectable ? children : children.filter((c) => !c.isParent)), + [areParentsSelectable, children], + ); + // const parentChildren = useMemo(() => children.filter((c) => c.isParent), [children]); + + useEffect(() => { + if (autoSelectChildren && selectableChildren.length) { + addOptions(selectableChildren); + setAutoSelectChildren(false); + } + }, [autoSelectChildren, selectableChildren, addOptions]); + + return { children, selectableChildren, directChildren, setAutoSelectChildren }; +} diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/useSelectOption.ts b/datahub-web-react/src/alchemy-components/components/Select/Nested/useSelectOption.ts new file mode 100644 index 00000000000000..23523578436dca --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/useSelectOption.ts @@ -0,0 +1,136 @@ +import { useMemo } from 'react'; +import { SelectOption } from './types'; + +interface Props { + selectedOptions: SelectOption[]; + option: SelectOption; + children: SelectOption[]; + selectableChildren: SelectOption[]; + implicitlySelectChildren: boolean; + areParentsSelectable: boolean; + addOptions: (nodes: SelectOption[]) => void; + removeOptions: (nodes: SelectOption[]) => void; + setSelectedOptions: React.Dispatch<React.SetStateAction<SelectOption[]>>; + handleOptionChange: (node: SelectOption) => void; +} + +export default function useNestedOption({ + selectedOptions, + option, + children, + selectableChildren, + areParentsSelectable, + implicitlySelectChildren, + addOptions, + removeOptions, + setSelectedOptions, + handleOptionChange, +}: Props) { + const parentChildren = useMemo(() => children.filter((c) => c.isParent), [children]); + + const areAllChildrenSelected = useMemo( + () => selectableChildren.every((child) => selectedOptions.find((o) => o.value === child.value)), + [selectableChildren, selectedOptions], + ); + + const areAnyChildrenSelected = useMemo( + () => selectableChildren.some((child) => selectedOptions.find((o) => o.value === child.value)), + [selectableChildren, selectedOptions], + ); + + const areAnyUnselectableChildrenUnexpanded = !!parentChildren.find( + (parent) => !selectableChildren.find((child) => child.parentValue === parent.value), + ); + + const isSelected = useMemo( + () => + !!selectedOptions.find((o) => o.value === option.value) || + (!areParentsSelectable && + !!option.isParent && + !!selectableChildren.length && + areAllChildrenSelected && + !areAnyUnselectableChildrenUnexpanded), + [ + selectedOptions, + areAllChildrenSelected, + areAnyUnselectableChildrenUnexpanded, + areParentsSelectable, + option.isParent, + option.value, + selectableChildren.length, + ], + ); + + const isImplicitlySelected = useMemo( + () => + implicitlySelectChildren && + !option.isParent && + !!selectedOptions.find((o) => o.value === option.parentValue), + [selectedOptions, option.isParent, option.parentValue, implicitlySelectChildren], + ); + + const isParentMissingChildren = useMemo(() => !!option.isParent && !children.length, [children, option.isParent]); + + const isPartialSelected = useMemo( + () => + (!areAllChildrenSelected && areAnyChildrenSelected) || + (isSelected && isParentMissingChildren) || + (isSelected && areAnyUnselectableChildrenUnexpanded) || + (areAnyUnselectableChildrenUnexpanded && areAnyChildrenSelected) || + (isSelected && !!children.length && !areAnyChildrenSelected) || + (!isSelected && + areAllChildrenSelected && + !isParentMissingChildren && + option.isParent && + areParentsSelectable), + [ + isSelected, + children, + option.isParent, + areAllChildrenSelected, + areAnyChildrenSelected, + areAnyUnselectableChildrenUnexpanded, + isParentMissingChildren, + areParentsSelectable, + ], + ); + + const selectChildrenImplicitly = () => { + const existingSelectedOptions = new Set(selectedOptions.map((opt) => opt.value)); + const existingChildSelectedOptions = selectedOptions.filter((opt) => opt.parentValue === option.value) || []; + if (existingSelectedOptions.has(option.value)) { + removeOptions([option]); + } else { + // filter out the childrens of parent selection as we are allowing implicitly selection + const filteredOptions = selectedOptions.filter( + (selectedOption) => !existingChildSelectedOptions.find((o) => o.value === selectedOption.value), + ); + const newSelectedOptions = [...filteredOptions, option]; + + setSelectedOptions(newSelectedOptions); + } + }; + + const selectOption = () => { + if (areParentsSelectable && option.isParent && implicitlySelectChildren) { + selectChildrenImplicitly(); + } else if (isPartialSelected || (!isSelected && !areAnyChildrenSelected)) { + const optionsToAdd = + option.isParent && !areParentsSelectable ? selectableChildren : [option, ...selectableChildren]; + + addOptions(optionsToAdd); + } else if (areAllChildrenSelected) { + removeOptions([option, ...selectableChildren]); + } else { + handleOptionChange(option); + } + }; + + return { + selectOption, + isPartialSelected, + isParentMissingChildren, + isSelected, + isImplicitlySelected, + }; +} From 963d4c0c56012bae4e8d187697e7c0f9d7b457e5 Mon Sep 17 00:00:00 2001 From: amit-apptware <amit.gaikwad@apptware.com> Date: Wed, 12 Mar 2025 17:54:11 +0530 Subject: [PATCH 10/11] feat(ui/incident-v2): add changes for assertion --- .../AssertionList/AcrylAssertionFilters.tsx | 86 ------------------- .../AssertionList/AcrylAssertionList.tsx | 18 ++-- .../AcrylAssertionListFilters.tsx | 75 ++++++++-------- .../AssertionList/AcrylAssertionListTable.tsx | 11 ++- 4 files changed, 58 insertions(+), 132 deletions(-) delete mode 100644 datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionFilters.tsx diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionFilters.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionFilters.tsx deleted file mode 100644 index 5470d002043801..00000000000000 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionFilters.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import React, { useMemo, useCallback } from 'react'; -import { NestedSelect } from '@src/alchemy-components/components/Select/Nested/NestedSelect'; -import { SelectOption } from '@src/alchemy-components/components/Select/Nested/types'; -import capitalize from 'lodash/capitalize'; -import { ASSERTION_FILTER_TYPES } from './constant'; - -interface FilterOption { - name: string; - category: string; - count: number; - displayName: string; -} - -interface FilterGroupOptions { - [key: string]: FilterOption[]; -} - -interface AcrylAssertionFiltersProps { - filterOptions: FilterGroupOptions; - selectedFilters: FilterOption[]; - onFilterChange: (selectedFilters: FilterOption[]) => void; -} - -export const AcrylAssertionFilters: React.FC<AcrylAssertionFiltersProps> = ({ - filterOptions, - selectedFilters, - onFilterChange, -}) => { - const initialSelectedOptions = useMemo(() => { - return selectedFilters.map((filter) => ({ - value: filter.name, - label: filter.displayName, - parentValue: filter.category, - })); - }, [selectedFilters]); - - const handleFilterChange = useCallback( - (selectedValues: SelectOption[]) => { - const updatedFilters = selectedValues.map((option) => { - const category = option.parentValue || ''; - return filterOptions[category].find((filter) => filter.name === option.value)!; - }); - - onFilterChange(updatedFilters); - }, - [filterOptions, onFilterChange], - ); - - const getOptions = useMemo((): SelectOption[] => { - const opts: SelectOption[] = []; - Object.entries(filterOptions).forEach(([category, filters]) => { - if (category !== ASSERTION_FILTER_TYPES.TAG) { - opts.push({ - value: category, - label: capitalize(category), - isParent: true, - }); - opts.push( - ...filters.map((filter) => ({ - value: filter.name, - label: filter.displayName, - parentValue: category, - isParent: false, - })), - ); - } - }); - return opts; - }, [filterOptions]); - - return ( - <NestedSelect - label="" - placeholder="Filter" - options={getOptions} - initialValues={initialSelectedOptions} - onUpdate={handleFilterChange} - isMultiSelect - areParentsSelectable={false} - width={100} - showCount - shouldAlwaysSyncParentValues - hideParentCheckbox - /> - ); -}; diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionList.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionList.tsx index c0339b70a00f2c..0f42bfe5cfd38d 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionList.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionList.tsx @@ -26,7 +26,7 @@ export const AcrylAssertionList = () => { ...ASSERTION_DEFAULT_RAW_DATA, }); // TODO we need to create setter function to set the filter as per the filter component - const [filter, setFilters] = useState<AssertionListFilter>(ASSERTION_DEFAULT_FILTERS); + const [selectedFilters, setSelectedFilters] = useState<AssertionListFilter>(ASSERTION_DEFAULT_FILTERS); const [assertionMonitorData, setAssertionMonitorData] = useState<Assertion[]>([]); @@ -43,7 +43,7 @@ export const AcrylAssertionList = () => { // get filtered Assertion as per the filter object const getFilteredAssertions = (assertions: Assertion[]) => { - const filteredAssertionData: AssertionTable = getFilteredTransformedAssertionData(assertions, filter); + const filteredAssertionData: AssertionTable = getFilteredTransformedAssertionData(assertions, selectedFilters); setVisibleAssertions(filteredAssertionData); }; @@ -62,7 +62,11 @@ export const AcrylAssertionList = () => { getFilteredAssertions(assertionMonitorData); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filter]); + }, [selectedFilters]); + + const handleFilterChange = (filter: any) => { + setSelectedFilters(filter); + }; const renderListTable = () => { if (loading) { @@ -73,7 +77,7 @@ export const AcrylAssertionList = () => { <AcrylAssertionListTable contract={contract} assertionData={visibleAssertions} - filter={filter} + filter={selectedFilters} refetch={() => { refetch(); contractRefetch(); @@ -91,12 +95,12 @@ export const AcrylAssertionList = () => { <AcrylAssertionListFilters filterOptions={visibleAssertions?.filterOptions} originalFilterOptions={visibleAssertions?.originalFilterOptions} - setFilters={setFilters} - filter={filter} filteredAssertions={visibleAssertions} + selectedFilters={selectedFilters} + setSelectedFilters={setSelectedFilters} + handleFilterChange={handleFilterChange} /> )} - {renderListTable()} </> ); diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListFilters.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListFilters.tsx index 2a13c869aab3a6..905f5573ce712b 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListFilters.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListFilters.tsx @@ -1,11 +1,11 @@ -import React, { useEffect, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import styled from 'styled-components'; +import { FilterSelect } from '@src/app/entityV2/shared/FilterSelect'; import { GroupBySelect } from '@src/app/entityV2/shared/GroupBySelect'; import { InlineListSearch } from '@src/app/entityV2/shared/components/search/InlineListSearch'; import { AcrylAssertionRecommendedFilters } from './AcrylAssertionRecommendedFilters'; import { AssertionListFilter, AssertionTable } from './types'; -import { AcrylAssertionFilters } from './AcrylAssertionFilters'; -import { ASSERTION_GROUP_BY_FILTER_OPTIONS, ASSERTION_DEFAULT_FILTERS } from './constant'; +import { ASSERTION_GROUP_BY_FILTER_OPTIONS, ASSERTION_DEFAULT_FILTERS, ASSERTION_FILTER_TYPES } from './constant'; import { useSetFilterFromURLParams } from './hooks'; interface FilterItem { @@ -18,9 +18,10 @@ interface FilterItem { interface AcrylAssertionListFiltersProps { filterOptions: any; originalFilterOptions: any; - setFilters: React.Dispatch<React.SetStateAction<AssertionListFilter>>; - filter: AssertionListFilter; + setSelectedFilters: React.Dispatch<React.SetStateAction<AssertionListFilter>>; filteredAssertions: AssertionTable; + selectedFilters: any; + handleFilterChange: (filter: any) => void; } const SearchFilterContainer = styled.div` @@ -50,27 +51,26 @@ const StyledFilterContainer = styled.div` export const AcrylAssertionListFilters: React.FC<AcrylAssertionListFiltersProps> = ({ filterOptions, originalFilterOptions, - setFilters, - filter, filteredAssertions, + handleFilterChange, + selectedFilters, + setSelectedFilters, }) => { - const [appliedFilters, setAppliedFilters] = useState<FilterItem[]>([]); - const [selectedGroupBy, setSelectedGroupBy] = useState<string | undefined>(filter.groupBy || undefined); + const [appliedRecommendedFilters, setAppliedRecommendedFilters] = useState([]); const handleSearchTextChange = (event: React.ChangeEvent<HTMLInputElement>) => { const searchText = event.target.value; - setFilters((prev) => ({ - ...prev, - filterCriteria: { ...prev.filterCriteria, searchText }, - })); + handleFilterChange({ + ...selectedFilters, + filterCriteria: { ...selectedFilters.filterCriteria, searchText }, + }); }; const handleAssertionTypeChange = (value: string) => { - setSelectedGroupBy(value); - setFilters((prev) => ({ ...prev, groupBy: value })); + handleFilterChange({ ...selectedFilters, groupBy: value }); }; - const handleFilterChange = (updatedFilters: FilterItem[]) => { + const handleFilterOptionChange = (updatedFilters: FilterItem[]) => { /** Set Recommended Filters when there is value in type,status or source if not then set it as empty to clear the filter */ const selectedRecommendedFilters = updatedFilters.reduce<Record<string, string[]>>( (acc, selectedfilter) => { @@ -81,41 +81,45 @@ export const AcrylAssertionListFilters: React.FC<AcrylAssertionListFiltersProps> { type: [], status: [], source: [], column: [] }, ); - setFilters((prev) => ({ - ...prev, - filterCriteria: { ...prev.filterCriteria, ...selectedRecommendedFilters }, - })); - setAppliedFilters(updatedFilters); + handleFilterChange({ + ...selectedFilters, + filterCriteria: { ...selectedFilters.filterCriteria, ...selectedRecommendedFilters }, + }); }; /** * This hook is for setting applied filter when we are getting it from selected Filter state */ - useEffect(() => { - const { status, type, source, column } = filter.filterCriteria || ASSERTION_DEFAULT_FILTERS.filterCriteria; + const initialSelectedOptions = useMemo(() => { + const { status, type, source, column } = + selectedFilters.filterCriteria || ASSERTION_DEFAULT_FILTERS.filterCriteria; const recommendedFilters = originalFilterOptions?.recommendedFilters || []; // just set recommended filters for status, type & Others as of right now - const appliedRecommendedFilters = recommendedFilters.filter( + const selectedRecommendedFilters = recommendedFilters.filter( (item) => status.includes(item.name) || type.includes(item.name) || source.includes(item.name) || column.includes(item.name), ); - setAppliedFilters(appliedRecommendedFilters); - setSelectedGroupBy(filter.groupBy); + setAppliedRecommendedFilters(selectedRecommendedFilters); + return selectedRecommendedFilters?.map((filter) => ({ + value: filter.name, + label: filter.displayName, + parentValue: filter.category, + })); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filter, filterOptions]); + }, [selectedFilters]); // set the filter if there is any url filter object presents - useSetFilterFromURLParams(filter, setFilters); + useSetFilterFromURLParams(selectedFilters, setSelectedFilters); return ( <> <SearchFilterContainer> {/* ************Render Search Component ************************* */} <InlineListSearch - searchText={filter.filterCriteria.searchText} + searchText={selectedFilters.filterCriteria?.searchText} debouncedSetFilterText={handleSearchTextChange} matchResultCount={filteredAssertions.searchMatchesCount || 0} numRows={filteredAssertions.totalCount || 0} @@ -125,17 +129,18 @@ export const AcrylAssertionListFilters: React.FC<AcrylAssertionListFiltersProps> {/* ************Render Filter Component ************************* */} <FiltersContainer> <StyledFilterContainer> - <AcrylAssertionFilters + <FilterSelect filterOptions={originalFilterOptions?.filterGroupOptions || []} - selectedFilters={appliedFilters} - onFilterChange={handleFilterChange} + onFilterChange={handleFilterOptionChange} + excludedCategories={[ASSERTION_FILTER_TYPES.TAG]} + initialSelectedOptions={initialSelectedOptions} /> </StyledFilterContainer> {/* ************Render Group By Component ************************* */} <div> <GroupBySelect options={ASSERTION_GROUP_BY_FILTER_OPTIONS} - selectedValue={selectedGroupBy} + selectedValue={selectedFilters.groupBy} onSelect={handleAssertionTypeChange} width={50} /> @@ -146,8 +151,8 @@ export const AcrylAssertionListFilters: React.FC<AcrylAssertionListFiltersProps> {/* ************Render Recommended Filter Component ************************* */} <AcrylAssertionRecommendedFilters filters={filterOptions?.recommendedFilters || []} - appliedFilters={appliedFilters} - onFilterChange={handleFilterChange} + appliedFilters={appliedRecommendedFilters} + onFilterChange={handleFilterOptionChange} /> </div> </> diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListTable.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListTable.tsx index 6ee9a3fa059753..058a047076b514 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListTable.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Dataset/Validations/AssertionList/AcrylAssertionListTable.tsx @@ -3,10 +3,11 @@ import { DataContract } from '@src/types.generated'; import { useEntityData } from '@src/app/entity/shared/EntityContext'; import { Table } from '@src/alchemy-components'; import { SortingState } from '@src/alchemy-components/components/Table/types'; +import { useGetExpandedTableGroupsFromEntityUrnInUrl } from '@src/app/entityV2/shared/hooks'; import { AssertionProfileDrawer } from '../assertion/profile/AssertionProfileDrawer'; import { getEntityUrnForAssertion, getSiblingWithUrn } from '../acrylUtils'; -import { useExpandedRowKeys, useOpenAssertionDetailModal } from '../assertion/builder/hooks'; +import { useOpenAssertionDetailModal } from '../assertion/builder/hooks'; import { AssertionTable, AssertionListFilter } from './types'; import { useAssertionsTableColumns } from './hooks'; import { StyledTableContainer } from './StyledComponents'; @@ -27,9 +28,11 @@ export const AcrylAssertionListTable = ({ assertionData, filter, refetch, contra sortOrder: SortingState.ORIGINAL, }); - const { expandedRowKeys, setExpandedRowKeys } = useExpandedRowKeys( + const { expandedGroupIds, setExpandedGroupIds } = useGetExpandedTableGroupsFromEntityUrnInUrl( assertionData?.groupBy ? assertionData?.groupBy[groupBy] : [], { isGroupBy: !!groupBy }, + 'assertion_urn', + (group) => group.assertions, ); // get columns data from the custom hooks @@ -56,7 +59,7 @@ export const AcrylAssertionListTable = ({ assertionData, filter, refetch, contra const onAssertionExpand = (record) => { const key = record.name; - setExpandedRowKeys((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); + setExpandedGroupIds((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); }; const getGroupData = () => { @@ -134,7 +137,7 @@ export const AcrylAssertionListTable = ({ assertionData, filter, refetch, contra }, rowExpandable: () => !!groupBy, expandIconPosition: 'end', - expandedGroupIds: expandedRowKeys, + expandedGroupIds, }} onExpand={onAssertionExpand} /> From ec3df0afd11006c8fd7a97559fd3f3a78928b9fb Mon Sep 17 00:00:00 2001 From: amit-apptware <amit.gaikwad@apptware.com> Date: Thu, 13 Mar 2025 12:46:21 +0530 Subject: [PATCH 11/11] feat(ui/incident-v2): add changes for nested select --- .../components/Select/Nested/NestedSelect.tsx | 3 +-- .../src/app/entityV2/shared/tabs/Incident/utils.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx index 33d42207a98050..32b13a2391b26a 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx @@ -288,7 +288,7 @@ export const NestedSelect = ({ const rootOptions = parentValueToOptions[NO_PARENT_VALUE] || []; return ( - <Container size={size || 'md'} width={props.width || 255}> + <Container ref={selectRef} size={size || 'md'} width={props.width || 255}> {label && <SelectLabel onClick={handleSelectClick}>{label}</SelectLabel>} <SelectBase isDisabled={isDisabled} @@ -300,7 +300,6 @@ export const NestedSelect = ({ data-testid="nested-options-dropdown-container" width={props.width} {...props} - ref={selectRef} > <SelectLabelDisplay selectedOptions={selectedOptions} diff --git a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/utils.tsx b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/utils.tsx index 62cbf08a819646..4ff5484616626d 100644 --- a/datahub-web-react/src/app/entityV2/shared/tabs/Incident/utils.tsx +++ b/datahub-web-react/src/app/entityV2/shared/tabs/Incident/utils.tsx @@ -439,7 +439,7 @@ export const getAssigneeNamesWithAvatarUrl = (assignees) => { return assignees?.map((assignee) => { return { urn: assignee.urn, - name: assignee.properties?.displayName, + name: assignee.properties?.displayName || assignee.username, imageUrl: '', }; });