feat(ui) Add structured properties support in the UI (#9695)

This commit is contained in:
Chris Collins 2024-01-25 10:12:01 -05:00 committed by GitHub
parent f83a2fab44
commit a78c6899a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1772 additions and 140 deletions

View File

@ -297,6 +297,7 @@ export const dataset1 = {
embed: null,
browsePathV2: { path: [{ name: 'test', entity: null }], __typename: 'BrowsePathV2' },
autoRenderAspects: [],
structuredProperties: null,
};
export const dataset2 = {
@ -393,6 +394,7 @@ export const dataset2 = {
embed: null,
browsePathV2: { path: [{ name: 'test', entity: null }], __typename: 'BrowsePathV2' },
autoRenderAspects: [],
structuredProperties: null,
};
export const dataset3 = {
@ -626,6 +628,7 @@ export const dataset3 = {
dataProduct: null,
lastProfile: null,
lastOperation: null,
structuredProperties: null,
} as Dataset;
export const dataset3WithSchema = {
@ -650,6 +653,7 @@ export const dataset3WithSchema = {
globalTags: null,
glossaryTerms: null,
label: 'hi',
schemaFieldEntity: null,
},
{
__typename: 'SchemaField',
@ -665,6 +669,7 @@ export const dataset3WithSchema = {
globalTags: null,
glossaryTerms: null,
label: 'hi',
schemaFieldEntity: null,
},
],
hash: '',

View File

@ -86,6 +86,7 @@ type Props = {
description: string,
) => Promise<FetchResult<UpdateDatasetMutation, Record<string, any>, Record<string, any>> | void>;
isEdited?: boolean;
isReadOnly?: boolean;
};
const ABBREVIATED_LIMIT = 80;
@ -97,10 +98,11 @@ export default function DescriptionField({
onUpdate,
isEdited = false,
original,
isReadOnly,
}: Props) {
const [showAddModal, setShowAddModal] = useState(false);
const overLimit = removeMarkdown(description).length > 80;
const isSchemaEditable = React.useContext(SchemaEditableContext);
const isSchemaEditable = React.useContext(SchemaEditableContext) && !isReadOnly;
const onCloseModal = () => setShowAddModal(false);
const { urn, entityType } = useEntityData();
@ -140,11 +142,12 @@ export default function DescriptionField({
{expanded || !overLimit ? (
<>
{!!description && <StyledViewer content={description} readOnly />}
{!!description && (
{!!description && (EditButton || overLimit) && (
<ExpandedActions>
{overLimit && (
<ReadLessText
onClick={() => {
onClick={(e) => {
e.stopPropagation();
handleExpanded(false);
}}
>
@ -162,7 +165,8 @@ export default function DescriptionField({
readMore={
<>
<Typography.Link
onClick={() => {
onClick={(e) => {
e.stopPropagation();
handleExpanded(true);
}}
>
@ -177,7 +181,7 @@ export default function DescriptionField({
</StripMarkdownText>
</>
)}
{isSchemaEditable && isEdited && <EditedLabel>(edited)</EditedLabel>}
{isEdited && <EditedLabel>(edited)</EditedLabel>}
{showAddModal && (
<div>
<UpdateDescriptionModal

View File

@ -53,7 +53,7 @@ export default function UpdateDescriptionModal({ title, description, original, o
>
<Form layout="vertical">
<Form.Item>
<StyledEditor content={updatedDesc} onChange={setDesc} />
<StyledEditor content={updatedDesc} onChange={setDesc} dataTestId="description-editor" />
</Form.Item>
{!isAddDesc && description && original && (
<Form.Item label={<FormLabel>Original:</FormLabel>}>

View File

@ -0,0 +1,24 @@
import React from 'react';
import { useEntityRegistry } from '../../../../useEntityRegistry';
import { PlatformIcon } from '../../../../search/filters/utils';
import { Entity } from '../../../../../types.generated';
import { IconStyleType } from '../../../Entity';
import { ANTD_GRAY } from '../../constants';
interface Props {
entity: Entity;
size?: number;
}
export default function EntityIcon({ entity, size = 14 }: Props) {
const entityRegistry = useEntityRegistry();
const genericEntityProps = entityRegistry.getGenericEntityProperties(entity.type, entity);
const logoUrl = genericEntityProps?.platform?.properties?.logoUrl;
const icon = logoUrl ? (
<PlatformIcon src={logoUrl} size={size} />
) : (
entityRegistry.getIcon(entity.type, size, IconStyleType.ACCENT, ANTD_GRAY[9])
);
return <>{icon}</>;
}

View File

@ -30,7 +30,6 @@ import LineageExplorer from '../../../../lineage/LineageExplorer';
import CompactContext from '../../../../shared/CompactContext';
import DynamicTab from '../../tabs/Entity/weaklyTypedAspects/DynamicTab';
import analytics, { EventType } from '../../../../analytics';
import { ProfileSidebarResizer } from './sidebar/ProfileSidebarResizer';
import { EntityMenuItems } from '../../EntityDropdown/EntityDropdown';
import { useIsSeparateSiblingsMode } from '../../siblingUtils';
import { EntityActionItem } from '../../entity/EntityActions';
@ -45,6 +44,7 @@ import {
} from '../../../../onboarding/config/LineageGraphOnboardingConfig';
import { useAppConfig } from '../../../../useAppConfig';
import { useUpdateDomainEntityDataOnChange } from '../../../../domain/utils';
import ProfileSidebar from './sidebar/ProfileSidebar';
type Props<T, U> = {
urn: string;
@ -75,8 +75,6 @@ type Props<T, U> = {
isNameEditable?: boolean;
};
const MAX_SIDEBAR_WIDTH = 800;
const MIN_SIDEBAR_WIDTH = 200;
const MAX_COMPACT_WIDTH = 490 - 24 * 2;
const ContentContainer = styled.div`
@ -85,6 +83,7 @@ const ContentContainer = styled.div`
min-height: 100%;
flex: 1;
min-width: 0;
overflow: hidden;
`;
const HeaderAndTabs = styled.div`
@ -113,15 +112,6 @@ const HeaderAndTabsFlex = styled.div`
-webkit-box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.75);
}
`;
const Sidebar = styled.div<{ $width: number }>`
max-height: 100%;
overflow: auto;
width: ${(props) => props.$width}px;
min-width: ${(props) => props.$width}px;
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
`;
const Header = styled.div`
border-bottom: 1px solid ${ANTD_GRAY[4.5]};
@ -145,7 +135,7 @@ const defaultTabDisplayConfig = {
enabled: (_, _1) => true,
};
const defaultSidebarSection = {
export const DEFAULT_SIDEBAR_SECTION = {
visible: (_, _1) => true,
};
@ -176,11 +166,10 @@ export const EntityProfile = <T, U>({
const sortedTabs = sortEntityProfileTabs(appConfig.config, entityType, tabsWithDefaults);
const sideBarSectionsWithDefaults = sidebarSections.map((sidebarSection) => ({
...sidebarSection,
display: { ...defaultSidebarSection, ...sidebarSection.display },
display: { ...DEFAULT_SIDEBAR_SECTION, ...sidebarSection.display },
}));
const [shouldRefetchEmbeddedListSearch, setShouldRefetchEmbeddedListSearch] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(window.innerWidth * 0.25);
const entityStepIds: string[] = getOnboardingStepIdsForEntityType(entityType);
const lineageGraphStepIds: string[] = [LINEAGE_GRAPH_INTRO_ID, LINEAGE_GRAPH_TIME_FILTER_ID];
const stepIds = isLineageMode ? lineageGraphStepIds : entityStepIds;
@ -344,15 +333,7 @@ export const EntityProfile = <T, U>({
</TabContent>
</HeaderAndTabsFlex>
</HeaderAndTabs>
<ProfileSidebarResizer
setSidePanelWidth={(width) =>
setSidebarWidth(Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), MAX_SIDEBAR_WIDTH))
}
initialSize={sidebarWidth}
/>
<Sidebar $width={sidebarWidth}>
<EntitySidebar sidebarSections={sideBarSectionsWithDefaults} />
</Sidebar>
<ProfileSidebar sidebarSections={sidebarSections} />
</>
)}
</ContentContainer>

View File

@ -36,14 +36,16 @@ const LastIngestedSection = styled.div`
type Props = {
sidebarSections: EntitySidebarSection[];
topSection?: EntitySidebarSection;
};
export const EntitySidebar = <T,>({ sidebarSections }: Props) => {
export const EntitySidebar = <T,>({ sidebarSections, topSection }: Props) => {
const { entityData } = useEntityData();
const baseEntity = useBaseEntity<T>();
return (
<>
{topSection && <topSection.component key={`${topSection.component}`} properties={topSection.properties} />}
{entityData?.lastIngested && (
<LastIngestedSection>
<LastIngested lastIngested={entityData.lastIngested} />

View File

@ -0,0 +1,77 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { ProfileSidebarResizer } from './ProfileSidebarResizer';
import { EntitySidebar } from './EntitySidebar';
import { EntitySidebarSection } from '../../../types';
export const MAX_SIDEBAR_WIDTH = 800;
export const MIN_SIDEBAR_WIDTH = 200;
const Sidebar = styled.div<{ $width: number; backgroundColor?: string }>`
max-height: 100%;
position: relative;
width: ${(props) => props.$width}px;
min-width: ${(props) => props.$width}px;
${(props) => props.backgroundColor && `background-color: ${props.backgroundColor};`}
`;
const ScrollWrapper = styled.div`
overflow: auto;
max-height: 100%;
padding: 0 20px 20px 20px;
`;
const DEFAULT_SIDEBAR_SECTION = {
visible: (_, _1) => true,
};
interface Props {
sidebarSections: EntitySidebarSection[];
backgroundColor?: string;
topSection?: EntitySidebarSection;
alignLeft?: boolean;
}
export default function ProfileSidebar({ sidebarSections, backgroundColor, topSection, alignLeft }: Props) {
const sideBarSectionsWithDefaults = sidebarSections.map((sidebarSection) => ({
...sidebarSection,
display: { ...DEFAULT_SIDEBAR_SECTION, ...sidebarSection.display },
}));
const [sidebarWidth, setSidebarWidth] = useState(window.innerWidth * 0.25);
if (alignLeft) {
return (
<>
<Sidebar $width={sidebarWidth} backgroundColor={backgroundColor} id="entity-profile-sidebar">
<ScrollWrapper>
<EntitySidebar sidebarSections={sideBarSectionsWithDefaults} topSection={topSection} />
</ScrollWrapper>
</Sidebar>
<ProfileSidebarResizer
setSidePanelWidth={(width) =>
setSidebarWidth(Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), MAX_SIDEBAR_WIDTH))
}
initialSize={sidebarWidth}
isSidebarOnLeft
/>
</>
);
}
return (
<>
<ProfileSidebarResizer
setSidePanelWidth={(width) =>
setSidebarWidth(Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), MAX_SIDEBAR_WIDTH))
}
initialSize={sidebarWidth}
/>
<Sidebar $width={sidebarWidth} backgroundColor={backgroundColor} id="entity-profile-sidebar">
<ScrollWrapper>
<EntitySidebar sidebarSections={sideBarSectionsWithDefaults} topSection={topSection} />
</ScrollWrapper>
</Sidebar>
</>
);
}

View File

@ -76,6 +76,14 @@ export const SchemaTab = ({ properties }: { properties?: any }) => {
[schemaMetadata],
);
const hasProperties = useMemo(
() =>
entityWithSchema?.schemaMetadata?.fields.some(
(schemaField) => !!schemaField.schemaFieldEntity?.structuredProperties?.properties?.length,
),
[entityWithSchema],
);
const [showKeySchema, setShowKeySchema] = useState(false);
const [showSchemaAuditView, setShowSchemaAuditView] = useState(false);
@ -190,13 +198,13 @@ export const SchemaTab = ({ properties }: { properties?: any }) => {
<SchemaTable
schemaMetadata={schemaMetadata}
rows={rows}
editMode={editMode}
editableSchemaMetadata={editableSchemaMetadata}
usageStats={usageStats}
schemaFieldBlameList={schemaFieldBlameList}
showSchemaAuditView={showSchemaAuditView}
expandedRowsFromFilter={expandedRowsFromFilter as any}
filterText={filterText as any}
hasProperties={hasProperties}
/>
</SchemaEditableContext.Provider>
</>

View File

@ -21,9 +21,10 @@ import { StyledTable } from '../../../components/styled/StyledTable';
import { SchemaRow } from './components/SchemaRow';
import { FkContext } from './utils/selectedFkContext';
import useSchemaBlameRenderer from './utils/useSchemaBlameRenderer';
import { ANTD_GRAY } from '../../../constants';
import MenuColumn from './components/MenuColumn';
import { ANTD_GRAY, ANTD_GRAY_V2 } from '../../../constants';
import translateFieldPath from '../../../../dataset/profile/schema/utils/translateFieldPath';
import PropertiesColumn from './components/PropertiesColumn';
import SchemaFieldDrawer from './components/SchemaFieldDrawer/SchemaFieldDrawer';
const TableContainer = styled.div`
overflow: inherit;
@ -41,18 +42,36 @@ const TableContainer = styled.div`
padding-bottom: 600px;
vertical-align: top;
}
&&& .ant-table-cell {
background-color: inherit;
cursor: pointer;
}
&&& tbody > tr:hover > td {
background-color: ${ANTD_GRAY_V2[2]};
}
&&& .expanded-row {
background-color: ${(props) => props.theme.styles['highlight-color']} !important;
td {
background-color: ${(props) => props.theme.styles['highlight-color']} !important;
}
}
`;
export type Props = {
rows: Array<ExtendedSchemaFields>;
schemaMetadata: SchemaMetadata | undefined | null;
editableSchemaMetadata?: EditableSchemaMetadata | null;
editMode?: boolean;
usageStats?: UsageQueryResult | null;
schemaFieldBlameList?: Array<SchemaFieldBlame> | null;
showSchemaAuditView: boolean;
expandedRowsFromFilter?: Set<string>;
filterText?: string;
hasProperties?: boolean;
inputFields?: SchemaField[];
};
const EMPTY_SET: Set<string> = new Set();
@ -63,56 +82,46 @@ export default function SchemaTable({
schemaMetadata,
editableSchemaMetadata,
usageStats,
editMode = true,
schemaFieldBlameList,
showSchemaAuditView,
expandedRowsFromFilter = EMPTY_SET,
filterText = '',
hasProperties,
inputFields,
}: Props): JSX.Element {
const hasUsageStats = useMemo(() => (usageStats?.aggregations?.fields?.length || 0) > 0, [usageStats]);
const [tableHeight, setTableHeight] = useState(0);
const [tagHoveredIndex, setTagHoveredIndex] = useState<string | undefined>(undefined);
const [selectedFkFieldPath, setSelectedFkFieldPath] =
useState<null | { fieldPath: string; constraint?: ForeignKeyConstraint | null }>(null);
const [selectedFkFieldPath, setSelectedFkFieldPath] = useState<null | {
fieldPath: string;
constraint?: ForeignKeyConstraint | null;
}>(null);
const [expandedDrawerFieldPath, setExpandedDrawerFieldPath] = useState<string | null>(null);
const schemaFields = schemaMetadata ? schemaMetadata.fields : inputFields;
const descriptionRender = useDescriptionRenderer(editableSchemaMetadata);
const usageStatsRenderer = useUsageStatsRenderer(usageStats);
const tagRenderer = useTagsAndTermsRenderer(
editableSchemaMetadata,
tagHoveredIndex,
setTagHoveredIndex,
{
showTags: true,
showTerms: false,
},
filterText,
false,
);
const termRenderer = useTagsAndTermsRenderer(
editableSchemaMetadata,
tagHoveredIndex,
setTagHoveredIndex,
{
showTags: false,
showTerms: true,
},
filterText,
false,
);
const schemaTitleRenderer = useSchemaTitleRenderer(schemaMetadata, setSelectedFkFieldPath, filterText);
const schemaBlameRenderer = useSchemaBlameRenderer(schemaFieldBlameList);
const onTagTermCell = (record: SchemaField) => ({
onMouseEnter: () => {
if (editMode) {
setTagHoveredIndex(record.fieldPath);
}
},
onMouseLeave: () => {
if (editMode) {
setTagHoveredIndex(undefined);
}
},
});
const fieldColumn = {
width: '22%',
title: 'Field',
@ -139,7 +148,6 @@ export default function SchemaTable({
dataIndex: 'globalTags',
key: 'tag',
render: tagRenderer,
onCell: onTagTermCell,
};
const termColumn = {
@ -148,7 +156,6 @@ export default function SchemaTable({
dataIndex: 'globalTags',
key: 'tag',
render: termRenderer,
onCell: onTagTermCell,
};
const blameColumn = {
@ -184,16 +191,20 @@ export default function SchemaTable({
sorter: (sourceA, sourceB) => getCount(sourceA.fieldPath) - getCount(sourceB.fieldPath),
};
const menuColumn = {
width: '5%',
title: '',
const propertiesColumn = {
width: '13%',
title: 'Properties',
dataIndex: '',
key: 'menu',
render: (field: SchemaField) => <MenuColumn field={field} />,
render: (field: SchemaField) => <PropertiesColumn field={field} />,
};
let allColumns: ColumnsType<ExtendedSchemaFields> = [fieldColumn, descriptionColumn, tagColumn, termColumn];
if (hasProperties) {
allColumns = [...allColumns, propertiesColumn];
}
if (hasUsageStats) {
allColumns = [...allColumns, usageColumn];
}
@ -202,8 +213,6 @@ export default function SchemaTable({
allColumns = [...allColumns, blameColumn];
}
allColumns = [...allColumns, menuColumn];
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
useEffect(() => {
@ -224,9 +233,15 @@ export default function SchemaTable({
<TableContainer>
<ResizeObserver onResize={(dimensions) => setTableHeight(dimensions.height - TABLE_HEADER_HEIGHT)}>
<StyledTable
rowClassName={(record) =>
record.fieldPath === selectedFkFieldPath?.fieldPath ? 'open-fk-row' : ''
rowClassName={(record) => {
if (record.fieldPath === selectedFkFieldPath?.fieldPath) {
return 'open-fk-row';
}
if (expandedDrawerFieldPath === record.fieldPath) {
return 'expanded-row';
}
return '';
}}
columns={allColumns}
dataSource={rows}
rowKey="fieldPath"
@ -250,9 +265,27 @@ export default function SchemaTable({
indentSize: 0,
}}
pagination={false}
onRow={(record) => ({
onClick: () => {
setExpandedDrawerFieldPath(
expandedDrawerFieldPath === record.fieldPath ? null : record.fieldPath,
);
},
style: {
backgroundColor: expandedDrawerFieldPath === record.fieldPath ? `` : 'white',
},
})}
/>
</ResizeObserver>
</TableContainer>
{!!schemaFields && (
<SchemaFieldDrawer
schemaFields={schemaFields}
expandedDrawerFieldPath={expandedDrawerFieldPath}
editableSchemaMetadata={editableSchemaMetadata}
setExpandedDrawerFieldPath={setExpandedDrawerFieldPath}
/>
)}
</FkContext.Provider>
);
}

View File

@ -0,0 +1,32 @@
import React from 'react';
import { Badge } from 'antd';
import styled from 'styled-components';
import { ANTD_GRAY_V2 } from '../../../../constants';
type Props = {
count: number;
};
const ChildCountBadge = styled(Badge)`
margin-left: 10px;
margin-top: 16px;
margin-bottom: 16px;
&&& .ant-badge-count {
background-color: ${ANTD_GRAY_V2[1]};
color: ${ANTD_GRAY_V2[8]};
box-shadow: 0 2px 1px -1px ${ANTD_GRAY_V2[6]};
border-radius: 4px 4px 4px 4px;
font-size: 12px;
font-weight: 500;
height: 22px;
font-family: 'Manrope';
}
`;
export default function ChildCountLabel({ count }: Props) {
const propertyString = count > 1 ? ' properties' : ' property';
// eslint-disable-next-line
return <ChildCountBadge count={count.toString() + propertyString} />;
}

View File

@ -0,0 +1,30 @@
import { ControlOutlined } from '@ant-design/icons';
import React from 'react';
import styled from 'styled-components';
import { SchemaField } from '../../../../../../../types.generated';
const ColumnWrapper = styled.div`
font-size: 14px;
`;
const StyledIcon = styled(ControlOutlined)`
margin-right: 4px;
`;
interface Props {
field: SchemaField;
}
export default function PropertiesColumn({ field }: Props) {
const { schemaFieldEntity } = field;
const numProperties = schemaFieldEntity?.structuredProperties?.properties?.length;
if (!schemaFieldEntity || !numProperties) return null;
return (
<ColumnWrapper>
<StyledIcon />
{numProperties} {numProperties === 1 ? 'property' : 'properties'}
</ColumnWrapper>
);
}

View File

@ -0,0 +1,39 @@
import React from 'react';
import { Badge } from 'antd';
import styled from 'styled-components';
import { capitalizeFirstLetterOnly } from '../../../../../../shared/textUtil';
import { DataTypeEntity, SchemaFieldDataType } from '../../../../../../../types.generated';
import { truncate } from '../../../../utils';
import { ANTD_GRAY, ANTD_GRAY_V2 } from '../../../../constants';
import { TypeData } from '../../../Properties/types';
type Props = {
type: TypeData;
dataType?: DataTypeEntity;
};
export const PropertyTypeBadge = styled(Badge)`
margin: 4px 0 4px 8px;
&&& .ant-badge-count {
background-color: ${ANTD_GRAY[1]};
color: ${ANTD_GRAY_V2[8]};
border: 1px solid ${ANTD_GRAY_V2[6]};
font-size: 12px;
font-weight: 500;
height: 22px;
font-family: 'Manrope';
}
`;
export default function PropertyTypeLabel({ type, dataType }: Props) {
// if unable to match type to DataHub, display native type info by default
const { nativeDataType } = type;
const nativeFallback = type.type === SchemaFieldDataType.Null;
const typeText =
dataType?.info.displayName ||
dataType?.info.type ||
(nativeFallback ? truncate(250, nativeDataType) : type.type);
return <PropertyTypeBadge count={capitalizeFirstLetterOnly(typeText)} />;
}

View File

@ -0,0 +1,106 @@
import { CaretLeftOutlined, CaretRightOutlined, CloseOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import React, { useEffect } from 'react';
import styled from 'styled-components';
import { ANTD_GRAY_V2 } from '../../../../../constants';
import { SchemaField } from '../../../../../../../../types.generated';
import { pluralize } from '../../../../../../../shared/textUtil';
const HeaderWrapper = styled.div`
border-bottom: 1px solid ${ANTD_GRAY_V2[4]};
display: flex;
justify-content: space-between;
padding: 8px 16px;
`;
const StyledButton = styled(Button)`
font-size: 12px;
padding: 0;
height: 26px;
width: 26px;
display: flex;
align-items: center;
justify-content: center;
svg {
height: 10px;
width: 10px;
}
`;
const FieldIndexText = styled.span`
font-size: 14px;
color: ${ANTD_GRAY_V2[8]};
margin: 0 8px;
`;
const ButtonsWrapper = styled.div`
display: flex;
align-items: center;
`;
interface Props {
schemaFields?: SchemaField[];
expandedFieldIndex?: number;
setExpandedDrawerFieldPath: (fieldPath: string | null) => void;
}
export default function DrawerHeader({ schemaFields = [], expandedFieldIndex = 0, setExpandedDrawerFieldPath }: Props) {
function showNextField() {
if (expandedFieldIndex !== undefined && expandedFieldIndex !== -1) {
if (expandedFieldIndex === schemaFields.length - 1) {
const newField = schemaFields[0];
setExpandedDrawerFieldPath(newField.fieldPath);
} else {
const newField = schemaFields[expandedFieldIndex + 1];
const { fieldPath } = newField;
setExpandedDrawerFieldPath(fieldPath);
}
}
}
function showPreviousField() {
if (expandedFieldIndex !== undefined && expandedFieldIndex !== -1) {
if (expandedFieldIndex === 0) {
const newField = schemaFields[schemaFields.length - 1];
setExpandedDrawerFieldPath(newField.fieldPath);
} else {
const newField = schemaFields[expandedFieldIndex - 1];
setExpandedDrawerFieldPath(newField.fieldPath);
}
}
}
function handleArrowKeys(event: KeyboardEvent) {
if (event.code === 'ArrowUp' || event.code === 'ArrowLeft') {
showPreviousField();
} else if (event.code === 'ArrowDown' || event.code === 'ArrowRight') {
showNextField();
}
}
useEffect(() => {
document.addEventListener('keydown', handleArrowKeys);
return () => document.removeEventListener('keydown', handleArrowKeys);
});
return (
<HeaderWrapper>
<ButtonsWrapper>
<StyledButton onClick={showPreviousField}>
<CaretLeftOutlined />
</StyledButton>
<FieldIndexText>
{expandedFieldIndex + 1} of {schemaFields.length} {pluralize(schemaFields.length, 'field')}
</FieldIndexText>
<StyledButton onClick={showNextField}>
<CaretRightOutlined />
</StyledButton>
</ButtonsWrapper>
<StyledButton onClick={() => setExpandedDrawerFieldPath(null)}>
<CloseOutlined />
</StyledButton>
</HeaderWrapper>
);
}

View File

@ -0,0 +1,115 @@
import { EditOutlined } from '@ant-design/icons';
import { Button, message } from 'antd';
import DOMPurify from 'dompurify';
import React, { useState } from 'react';
import styled from 'styled-components';
import { SectionHeader, StyledDivider } from './components';
import UpdateDescriptionModal from '../../../../../components/legacy/DescriptionModal';
import { EditableSchemaFieldInfo, SchemaField, SubResourceType } from '../../../../../../../../types.generated';
import DescriptionSection from '../../../../../containers/profile/sidebar/AboutSection/DescriptionSection';
import { useEntityData, useMutationUrn, useRefetch } from '../../../../../EntityContext';
import { useSchemaRefetch } from '../../SchemaContext';
import { useUpdateDescriptionMutation } from '../../../../../../../../graphql/mutations.generated';
import analytics, { EntityActionType, EventType } from '../../../../../../../analytics';
import SchemaEditableContext from '../../../../../../../shared/SchemaEditableContext';
const DescriptionWrapper = styled.div`
display: flex;
justify-content: space-between;
`;
const EditIcon = styled(Button)`
border: none;
box-shadow: none;
height: 20px;
width: 20px;
`;
interface Props {
expandedField: SchemaField;
editableFieldInfo?: EditableSchemaFieldInfo;
}
export default function FieldDescription({ expandedField, editableFieldInfo }: Props) {
const isSchemaEditable = React.useContext(SchemaEditableContext);
const urn = useMutationUrn();
const refetch = useRefetch();
const schemaRefetch = useSchemaRefetch();
const [updateDescription] = useUpdateDescriptionMutation();
const [isModalVisible, setIsModalVisible] = useState(false);
const { entityType } = useEntityData();
const sendAnalytics = () => {
analytics.event({
type: EventType.EntityActionEvent,
actionType: EntityActionType.UpdateSchemaDescription,
entityType,
entityUrn: urn,
});
};
const refresh: any = () => {
refetch?.();
schemaRefetch?.();
};
const onSuccessfulMutation = () => {
refresh();
sendAnalytics();
message.destroy();
message.success({ content: 'Updated!', duration: 2 });
};
const onFailMutation = (e) => {
message.destroy();
if (e instanceof Error) message.error({ content: `Proposal Failed! \n ${e.message || ''}`, duration: 2 });
};
const generateMutationVariables = (updatedDescription: string) => ({
variables: {
input: {
description: DOMPurify.sanitize(updatedDescription),
resourceUrn: urn,
subResource: expandedField.fieldPath,
subResourceType: SubResourceType.DatasetField,
},
},
});
const displayedDescription = editableFieldInfo?.description || expandedField.description;
return (
<>
<DescriptionWrapper>
<div>
<SectionHeader>Description</SectionHeader>
<DescriptionSection description={displayedDescription || ''} isExpandable />
</div>
{isSchemaEditable && (
<EditIcon
data-testid="edit-field-description"
onClick={() => setIsModalVisible(true)}
icon={<EditOutlined />}
/>
)}
{isModalVisible && (
<UpdateDescriptionModal
title={displayedDescription ? 'Update description' : 'Add description'}
description={displayedDescription || ''}
original={expandedField.description || ''}
onClose={() => setIsModalVisible(false)}
onSubmit={(updatedDescription: string) => {
message.loading({ content: 'Updating...' });
updateDescription(generateMutationVariables(updatedDescription))
.then(onSuccessfulMutation)
.catch(onFailMutation);
setIsModalVisible(false);
}}
isAddDesc={!displayedDescription}
/>
)}
</DescriptionWrapper>
<StyledDivider />
</>
);
}

View File

@ -0,0 +1,60 @@
import { Typography } from 'antd';
import React from 'react';
import styled from 'styled-components';
import translateFieldPath from '../../../../../../dataset/profile/schema/utils/translateFieldPath';
import TypeLabel from '../TypeLabel';
import PrimaryKeyLabel from '../PrimaryKeyLabel';
import PartitioningKeyLabel from '../PartitioningKeyLabel';
import NullableLabel from '../NullableLabel';
import MenuColumn from '../MenuColumn';
import { ANTD_GRAY_V2 } from '../../../../../constants';
import { SchemaField } from '../../../../../../../../types.generated';
const FieldHeaderWrapper = styled.div`
padding: 16px;
display: flex;
justify-content: space-between;
border-bottom: 1px solid ${ANTD_GRAY_V2[4]};
`;
const FieldName = styled(Typography.Text)`
font-size: 16px;
font-family: 'Roboto Mono', monospace;
`;
const TypesSection = styled.div`
margin-left: -4px;
margin-top: 8px;
`;
const NameTypesWrapper = styled.div`
overflow: hidden;
`;
const MenuWrapper = styled.div`
margin-right: 5px;
`;
interface Props {
expandedField: SchemaField;
}
export default function FieldHeader({ expandedField }: Props) {
const displayName = translateFieldPath(expandedField.fieldPath || '');
return (
<FieldHeaderWrapper>
<NameTypesWrapper>
<FieldName>{displayName}</FieldName>
<TypesSection>
<TypeLabel type={expandedField.type} nativeDataType={expandedField.nativeDataType} />
{expandedField.isPartOfKey && <PrimaryKeyLabel />}
{expandedField.isPartitioningKey && <PartitioningKeyLabel />}
{expandedField.nullable && <NullableLabel />}
</TypesSection>
</NameTypesWrapper>
<MenuWrapper>
<MenuColumn field={expandedField} />
</MenuWrapper>
</FieldHeaderWrapper>
);
}

View File

@ -0,0 +1,70 @@
import React from 'react';
import styled from 'styled-components';
import { SchemaField, StdDataType } from '../../../../../../../../types.generated';
import { SectionHeader, StyledDivider } from './components';
import { mapStructuredPropertyValues } from '../../../../Properties/useStructuredProperties';
import StructuredPropertyValue from '../../../../Properties/StructuredPropertyValue';
const PropertyTitle = styled.div`
font-size: 14px;
font-weight: 700;
margin-bottom: 4px;
`;
const PropertyWrapper = styled.div`
margin-bottom: 12px;
`;
const PropertiesWrapper = styled.div`
padding-left: 16px;
`;
const StyledList = styled.ul`
padding-left: 24px;
`;
interface Props {
expandedField: SchemaField;
}
export default function FieldProperties({ expandedField }: Props) {
const { schemaFieldEntity } = expandedField;
if (!schemaFieldEntity?.structuredProperties?.properties?.length) return null;
return (
<>
<SectionHeader>Properties</SectionHeader>
<PropertiesWrapper>
{schemaFieldEntity.structuredProperties.properties.map((structuredProp) => {
const isRichText =
structuredProp.structuredProperty.definition.valueType?.info.type === StdDataType.RichText;
const valuesData = mapStructuredPropertyValues(structuredProp);
const hasMultipleValues = valuesData.length > 1;
return (
<PropertyWrapper>
<PropertyTitle>{structuredProp.structuredProperty.definition.displayName}</PropertyTitle>
{hasMultipleValues ? (
<StyledList>
{valuesData.map((value) => (
<li>
<StructuredPropertyValue value={value} isRichText={isRichText} />
</li>
))}
</StyledList>
) : (
<>
{valuesData.map((value) => (
<StructuredPropertyValue value={value} isRichText={isRichText} />
))}
</>
)}
</PropertyWrapper>
);
})}
</PropertiesWrapper>
<StyledDivider />
</>
);
}

View File

@ -0,0 +1,33 @@
import React from 'react';
import { EditableSchemaMetadata, GlobalTags, SchemaField } from '../../../../../../../../types.generated';
import useTagsAndTermsRenderer from '../../utils/useTagsAndTermsRenderer';
import { SectionHeader, StyledDivider } from './components';
import SchemaEditableContext from '../../../../../../../shared/SchemaEditableContext';
interface Props {
expandedField: SchemaField;
editableSchemaMetadata?: EditableSchemaMetadata | null;
}
export default function FieldTags({ expandedField, editableSchemaMetadata }: Props) {
const isSchemaEditable = React.useContext(SchemaEditableContext);
const tagRenderer = useTagsAndTermsRenderer(
editableSchemaMetadata,
{
showTags: true,
showTerms: false,
},
'',
isSchemaEditable,
);
return (
<>
<SectionHeader>Tags</SectionHeader>
<div data-testid={`schema-field-${expandedField.fieldPath}-tags`}>
{tagRenderer(expandedField.globalTags as GlobalTags, expandedField)}
</div>
<StyledDivider />
</>
);
}

View File

@ -0,0 +1,34 @@
import React from 'react';
import { EditableSchemaMetadata, GlobalTags, SchemaField } from '../../../../../../../../types.generated';
import useTagsAndTermsRenderer from '../../utils/useTagsAndTermsRenderer';
import { SectionHeader, StyledDivider } from './components';
import SchemaEditableContext from '../../../../../../../shared/SchemaEditableContext';
interface Props {
expandedField: SchemaField;
editableSchemaMetadata?: EditableSchemaMetadata | null;
}
export default function FieldTerms({ expandedField, editableSchemaMetadata }: Props) {
const isSchemaEditable = React.useContext(SchemaEditableContext);
const termRenderer = useTagsAndTermsRenderer(
editableSchemaMetadata,
{
showTags: false,
showTerms: true,
},
'',
isSchemaEditable,
);
return (
<>
<SectionHeader>Glossary Terms</SectionHeader>
{/* pass in globalTags since this is a shared component, tags will not be shown or used */}
<div data-testid={`schema-field-${expandedField.fieldPath}-terms`}>
{termRenderer(expandedField.globalTags as GlobalTags, expandedField)}
</div>
<StyledDivider />
</>
);
}

View File

@ -0,0 +1,59 @@
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { GetDatasetQuery } from '../../../../../../../../graphql/dataset.generated';
import { useBaseEntity } from '../../../../../EntityContext';
import { ANTD_GRAY_V2 } from '../../../../../constants';
import { SectionHeader, StyledDivider } from './components';
import { pathMatchesNewPath } from '../../../../../../dataset/profile/schema/utils/utils';
import { UsageBar } from '../../utils/useUsageStatsRenderer';
import { SchemaField } from '../../../../../../../../types.generated';
const USAGE_BAR_MAX_WIDTH = 100;
const UsageBarWrapper = styled.div`
display: flex;
align-items: center;
`;
const UsageBarBackground = styled.div`
background-color: ${ANTD_GRAY_V2[3]};
border-radius: 2px;
height: 4px;
width: ${USAGE_BAR_MAX_WIDTH}px;
`;
const UsageTextWrapper = styled.span`
margin-left: 8px;
`;
interface Props {
expandedField: SchemaField;
}
export default function FieldUsageStats({ expandedField }: Props) {
const baseEntity = useBaseEntity<GetDatasetQuery>();
const usageStats = baseEntity?.dataset?.usageStats;
const hasUsageStats = useMemo(() => (usageStats?.aggregations?.fields?.length || 0) > 0, [usageStats]);
const maxFieldUsageCount = useMemo(
() => Math.max(...(usageStats?.aggregations?.fields?.map((field) => field?.count || 0) || [])),
[usageStats],
);
const relevantUsageStats = usageStats?.aggregations?.fields?.find((fieldStats) =>
pathMatchesNewPath(fieldStats?.fieldName, expandedField.fieldPath),
);
if (!hasUsageStats || !relevantUsageStats) return null;
return (
<>
<SectionHeader>Usage</SectionHeader>
<UsageBarWrapper>
<UsageBarBackground>
<UsageBar width={((relevantUsageStats.count || 0) / maxFieldUsageCount) * USAGE_BAR_MAX_WIDTH} />
</UsageBarBackground>
<UsageTextWrapper>{relevantUsageStats.count || 0} queries / month</UsageTextWrapper>
</UsageBarWrapper>
<StyledDivider />
</>
);
}

View File

@ -0,0 +1,83 @@
import { Drawer } from 'antd';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import DrawerHeader from './DrawerHeader';
import FieldHeader from './FieldHeader';
import FieldDescription from './FieldDescription';
import { EditableSchemaMetadata, SchemaField } from '../../../../../../../../types.generated';
import { pathMatchesNewPath } from '../../../../../../dataset/profile/schema/utils/utils';
import FieldUsageStats from './FieldUsageStats';
import FieldTags from './FieldTags';
import FieldTerms from './FieldTerms';
import FieldProperties from './FieldProperties';
const StyledDrawer = styled(Drawer)`
position: absolute;
&&& .ant-drawer-body {
padding: 0;
}
&&& .ant-drawer-content-wrapper {
border-left: 3px solid ${(props) => props.theme.styles['primary-color']};
}
`;
const MetadataSections = styled.div`
padding: 16px 24px;
`;
interface Props {
schemaFields: SchemaField[];
editableSchemaMetadata?: EditableSchemaMetadata | null;
expandedDrawerFieldPath: string | null;
setExpandedDrawerFieldPath: (fieldPath: string | null) => void;
}
export default function SchemaFieldDrawer({
schemaFields,
editableSchemaMetadata,
expandedDrawerFieldPath,
setExpandedDrawerFieldPath,
}: Props) {
const expandedFieldIndex = useMemo(
() => schemaFields.findIndex((row) => row.fieldPath === expandedDrawerFieldPath),
[expandedDrawerFieldPath, schemaFields],
);
const expandedField =
expandedFieldIndex !== undefined && expandedFieldIndex !== -1 ? schemaFields[expandedFieldIndex] : undefined;
const editableFieldInfo = editableSchemaMetadata?.editableSchemaFieldInfo.find((candidateEditableFieldInfo) =>
pathMatchesNewPath(candidateEditableFieldInfo.fieldPath, expandedField?.fieldPath),
);
return (
<StyledDrawer
open={!!expandedDrawerFieldPath}
onClose={() => setExpandedDrawerFieldPath(null)}
getContainer={() => document.getElementById('entity-profile-sidebar') as HTMLElement}
contentWrapperStyle={{ width: '100%', boxShadow: 'none' }}
mask={false}
maskClosable={false}
placement="right"
closable={false}
>
<DrawerHeader
setExpandedDrawerFieldPath={setExpandedDrawerFieldPath}
schemaFields={schemaFields}
expandedFieldIndex={expandedFieldIndex}
/>
{expandedField && (
<>
<FieldHeader expandedField={expandedField} />
<MetadataSections>
<FieldDescription expandedField={expandedField} editableFieldInfo={editableFieldInfo} />
<FieldUsageStats expandedField={expandedField} />
<FieldTags expandedField={expandedField} editableSchemaMetadata={editableSchemaMetadata} />
<FieldTerms expandedField={expandedField} editableSchemaMetadata={editableSchemaMetadata} />
<FieldProperties expandedField={expandedField} />
</MetadataSections>
</>
)}
</StyledDrawer>
);
}

View File

@ -0,0 +1,12 @@
import { Divider } from 'antd';
import styled from 'styled-components';
export const SectionHeader = styled.div`
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
`;
export const StyledDivider = styled(Divider)`
margin: 12px 0;
`;

View File

@ -48,8 +48,8 @@ export default function useDescriptionRenderer(editableSchemaMetadata: EditableS
},
}).then(refresh)
}
isReadOnly
/>
);
};
}
//

View File

@ -2,15 +2,14 @@ import React from 'react';
import { EditableSchemaMetadata, EntityType, GlobalTags, SchemaField } from '../../../../../../../types.generated';
import TagTermGroup from '../../../../../../shared/tags/TagTermGroup';
import { pathMatchesNewPath } from '../../../../../dataset/profile/schema/utils/utils';
import { useMutationUrn, useRefetch } from '../../../../EntityContext';
import { useSchemaRefetch } from '../SchemaContext';
import { useMutationUrn, useRefetch } from '../../../../EntityContext';
export default function useTagsAndTermsRenderer(
editableSchemaMetadata: EditableSchemaMetadata | null | undefined,
tagHoveredIndex: string | undefined,
setTagHoveredIndex: (index: string | undefined) => void,
options: { showTags: boolean; showTerms: boolean },
filterText: string,
canEdit: boolean,
) {
const urn = useMutationUrn();
const refetch = useRefetch();
@ -27,24 +26,21 @@ export default function useTagsAndTermsRenderer(
);
return (
<div data-testid={`schema-field-${record.fieldPath}-${options.showTags ? 'tags' : 'terms'}`}>
<TagTermGroup
uneditableTags={options.showTags ? tags : null}
editableTags={options.showTags ? relevantEditableFieldInfo?.globalTags : null}
uneditableGlossaryTerms={options.showTerms ? record.glossaryTerms : null}
editableGlossaryTerms={options.showTerms ? relevantEditableFieldInfo?.glossaryTerms : null}
canRemove
canRemove={canEdit}
buttonProps={{ size: 'small' }}
canAddTag={tagHoveredIndex === record.fieldPath && options.showTags}
canAddTerm={tagHoveredIndex === record.fieldPath && options.showTerms}
onOpenModal={() => setTagHoveredIndex(undefined)}
canAddTag={canEdit && options.showTags}
canAddTerm={canEdit && options.showTerms}
entityUrn={urn}
entityType={EntityType.Dataset}
entitySubresource={record.fieldPath}
highlightText={filterText}
refetch={refresh}
/>
</div>
);
};
return tagAndTermRender;

View File

@ -7,7 +7,7 @@ import { pathMatchesNewPath } from '../../../../../dataset/profile/schema/utils/
const USAGE_BAR_MAX_WIDTH = 50;
const UsageBar = styled.div<{ width: number }>`
export const UsageBar = styled.div<{ width: number }>`
width: ${(props) => props.width}px;
height: 4px;
background-color: ${geekblue[3]};

View File

@ -40,10 +40,11 @@ type EditorProps = {
onChange?: (md: string) => void;
className?: string;
doNotFocus?: boolean;
dataTestId?: string;
};
export const Editor = forwardRef((props: EditorProps, ref) => {
const { content, readOnly, onChange, className } = props;
const { content, readOnly, onChange, className, dataTestId } = props;
const { manager, state, getContext } = useRemirror({
extensions: () => [
new BlockquoteExtension(),
@ -98,7 +99,7 @@ export const Editor = forwardRef((props: EditorProps, ref) => {
}, [readOnly, content]);
return (
<EditorContainer className={className}>
<EditorContainer className={className} data-testid={dataTestId}>
<ThemeProvider theme={EditorTheme}>
<Remirror classNames={['ant-typography']} editable={!readOnly} manager={manager} initialContent={state}>
{!readOnly && (

View File

@ -0,0 +1,43 @@
import { Tooltip } from 'antd';
import React from 'react';
import styled from 'styled-components';
import { PropertyCardinality, StructuredPropertyEntity } from '../../../../../types.generated';
import { PropertyTypeBadge } from '../Dataset/Schema/components/PropertyTypeLabel';
import { getStructuredPropertyValue } from '../../utils';
const Header = styled.div`
font-size: 10px;
`;
const List = styled.ul`
padding: 0 24px;
max-height: 500px;
overflow: auto;
`;
interface Props {
structuredProperty: StructuredPropertyEntity;
}
export default function CardinalityLabel({ structuredProperty }: Props) {
const labelText =
structuredProperty.definition.cardinality === PropertyCardinality.Single ? 'Single-Select' : 'Multi-Select';
return (
<Tooltip
color="#373D44"
title={
<>
<Header>Property Options</Header>
<List>
{structuredProperty.definition.allowedValues?.map((value) => (
<li>{getStructuredPropertyValue(value.value)}</li>
))}
</List>
</>
}
>
<PropertyTypeBadge count={labelText} />
</Tooltip>
);
}

View File

@ -0,0 +1,87 @@
import { Tooltip, Typography } from 'antd';
import React from 'react';
import styled from 'styled-components';
import Highlight from 'react-highlighter';
import { PropertyRow } from './types';
import ChildCountLabel from '../Dataset/Schema/components/ChildCountLabel';
import PropertyTypeLabel from '../Dataset/Schema/components/PropertyTypeLabel';
import StructuredPropertyTooltip from './StructuredPropertyTooltip';
import CardinalityLabel from './CardinalityLabel';
const ParentNameText = styled(Typography.Text)`
color: #373d44;
font-size: 16px;
font-family: Manrope;
font-weight: 600;
line-height: 20px;
word-wrap: break-word;
padding-left: 16px;
display: flex;
align-items: center;
`;
const ChildNameText = styled(Typography.Text)`
align-self: stretch;
color: #373d44;
font-size: 14px;
font-family: Manrope;
font-weight: 500;
line-height: 18px;
word-wrap: break-word;
padding-left: 16px;
display: flex;
align-items: center;
`;
const NameLabelWrapper = styled.span`
display: inline-flex;
align-items: center;
flex-wrap: wrap;
`;
interface Props {
propertyRow: PropertyRow;
filterText?: string;
}
export default function NameColumn({ propertyRow, filterText }: Props) {
const { structuredProperty } = propertyRow;
return (
<>
{propertyRow.children ? (
<NameLabelWrapper>
<ParentNameText>
<Highlight search={filterText}>{propertyRow.displayName}</Highlight>
</ParentNameText>
{propertyRow.childrenCount ? <ChildCountLabel count={propertyRow.childrenCount} /> : <span />}
</NameLabelWrapper>
) : (
<NameLabelWrapper>
<Tooltip
color="#373D44"
placement="topRight"
title={
structuredProperty ? (
<StructuredPropertyTooltip structuredProperty={structuredProperty} />
) : (
''
)
}
>
<ChildNameText>
<Highlight search={filterText}>{propertyRow.displayName}</Highlight>
</ChildNameText>
</Tooltip>
{propertyRow.type ? (
<PropertyTypeLabel type={propertyRow.type} dataType={propertyRow.dataType} />
) : (
<span />
)}
{structuredProperty?.definition.allowedValues && (
<CardinalityLabel structuredProperty={structuredProperty} />
)}
</NameLabelWrapper>
)}
</>
);
}

View File

@ -1,52 +1,79 @@
import React from 'react';
import { Typography } from 'antd';
import styled from 'styled-components';
import { ANTD_GRAY } from '../../constants';
import { StyledTable } from '../../components/styled/StyledTable';
import React, { useState } from 'react';
import ExpandIcon from '../Dataset/Schema/components/ExpandIcon';
import { StyledTable as Table } from '../../components/styled/StyledTable';
import { useEntityData } from '../../EntityContext';
import { PropertyRow } from './types';
import useStructuredProperties from './useStructuredProperties';
import { getFilteredCustomProperties, mapCustomPropertiesToPropertyRows } from './utils';
import ValuesColumn from './ValuesColumn';
import NameColumn from './NameColumn';
import TabHeader from './TabHeader';
import useUpdateExpandedRowsFromFilter from './useUpdateExpandedRowsFromFilter';
import { useEntityRegistry } from '../../../../useEntityRegistry';
const NameText = styled(Typography.Text)`
font-family: 'Roboto Mono', monospace;
font-weight: 600;
font-size: 12px;
color: ${ANTD_GRAY[9]};
`;
const ValueText = styled(Typography.Text)`
font-family: 'Roboto Mono', monospace;
font-weight: 400;
font-size: 12px;
color: ${ANTD_GRAY[8]};
`;
const StyledTable = styled(Table)`
&&& .ant-table-cell-with-append {
padding: 4px;
}
` as typeof Table;
export const PropertiesTab = () => {
const [filterText, setFilterText] = useState('');
const { entityData } = useEntityData();
const entityRegistry = useEntityRegistry();
const propertyTableColumns = [
{
width: 210,
width: '40%',
title: 'Name',
dataIndex: 'key',
sorter: (a, b) => a?.key.localeCompare(b?.key || '') || 0,
defaultSortOrder: 'ascend',
render: (name: string) => <NameText>{name}</NameText>,
render: (propertyRow: PropertyRow) => <NameColumn propertyRow={propertyRow} filterText={filterText} />,
},
{
title: 'Value',
dataIndex: 'value',
render: (value: string) => <ValueText>{value}</ValueText>,
render: (propertyRow: PropertyRow) => <ValuesColumn propertyRow={propertyRow} filterText={filterText} />,
},
];
const { structuredPropertyRows, expandedRowsFromFilter } = useStructuredProperties(entityRegistry, filterText);
const customProperties = getFilteredCustomProperties(filterText, entityData) || [];
const customPropertyRows = mapCustomPropertiesToPropertyRows(customProperties);
const dataSource: PropertyRow[] = structuredPropertyRows.concat(customPropertyRows);
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
useUpdateExpandedRowsFromFilter({ expandedRowsFromFilter, setExpandedRows });
return (
<>
<TabHeader setFilterText={setFilterText} />
<StyledTable
pagination={false}
// typescript is complaining that default sort order is not a valid column field- overriding this here
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
columns={propertyTableColumns}
dataSource={entityData?.customProperties || undefined}
dataSource={dataSource}
rowKey="qualifiedName"
expandable={{
expandedRowKeys: [...Array.from(expandedRows)],
defaultExpandAllRows: false,
expandRowByClick: false,
expandIcon: ExpandIcon,
onExpand: (expanded, record) => {
if (expanded) {
setExpandedRows((previousRows) => new Set(previousRows.add(record.qualifiedName)));
} else {
setExpandedRows((previousRows) => {
previousRows.delete(record.qualifiedName);
return new Set(previousRows);
});
}
},
indentSize: 0,
}}
/>
</>
);
};

View File

@ -0,0 +1,31 @@
import React from 'react';
import styled from 'styled-components';
import { StructuredPropertyEntity } from '../../../../../types.generated';
const ContentWrapper = styled.div`
font-size: 12px;
`;
const Header = styled.div`
font-size: 10px;
`;
const Description = styled.div`
padding-left: 16px;
`;
interface Props {
structuredProperty: StructuredPropertyEntity;
}
export default function StructuredPropertyTooltip({ structuredProperty }: Props) {
return (
<ContentWrapper>
<Header>Structured Property</Header>
<div>{structuredProperty.definition.displayName || structuredProperty.definition.qualifiedName}</div>
{structuredProperty.definition.description && (
<Description>{structuredProperty.definition.description}</Description>
)}
</ContentWrapper>
);
}

View File

@ -0,0 +1,69 @@
import Icon from '@ant-design/icons/lib/components/Icon';
import React from 'react';
import Highlight from 'react-highlighter';
import { Typography } from 'antd';
import styled from 'styled-components';
import { ValueColumnData } from './types';
import { ANTD_GRAY } from '../../constants';
import { useEntityRegistry } from '../../../../useEntityRegistry';
import ExternalLink from '../../../../../images/link-out.svg?react';
import MarkdownViewer, { MarkdownView } from '../../components/legacy/MarkdownViewer';
import EntityIcon from '../../components/styled/EntityIcon';
const ValueText = styled(Typography.Text)`
font-family: 'Manrope';
font-weight: 400;
font-size: 14px;
color: ${ANTD_GRAY[9]};
display: block;
${MarkdownView} {
font-size: 14px;
}
`;
const StyledIcon = styled(Icon)`
margin-left: 6px;
`;
const IconWrapper = styled.span`
margin-right: 4px;
`;
interface Props {
value: ValueColumnData;
isRichText?: boolean;
filterText?: string;
}
export default function StructuredPropertyValue({ value, isRichText, filterText }: Props) {
const entityRegistry = useEntityRegistry();
return (
<ValueText>
{value.entity ? (
<>
<IconWrapper>
<EntityIcon entity={value.entity} />
</IconWrapper>
{entityRegistry.getDisplayName(value.entity.type, value.entity)}
<Typography.Link
href={entityRegistry.getEntityUrl(value.entity.type, value.entity.urn)}
target="_blank"
rel="noopener noreferrer"
>
<StyledIcon component={ExternalLink} />
</Typography.Link>
</>
) : (
<>
{isRichText ? (
<MarkdownViewer source={value.value as string} />
) : (
<Highlight search={filterText}>{value.value?.toString()}</Highlight>
)}
</>
)}
</ValueText>
);
}

View File

@ -0,0 +1,32 @@
import { SearchOutlined } from '@ant-design/icons';
import { Input } from 'antd';
import React from 'react';
import styled from 'styled-components';
import { ANTD_GRAY } from '../../constants';
const StyledInput = styled(Input)`
border-radius: 70px;
max-width: 300px;
`;
const TableHeader = styled.div`
padding: 8px 16px;
border-bottom: 1px solid ${ANTD_GRAY[4.5]};
`;
interface Props {
setFilterText: (text: string) => void;
}
export default function TabHeader({ setFilterText }: Props) {
return (
<TableHeader>
<StyledInput
placeholder="Search in properties..."
onChange={(e) => setFilterText(e.target.value)}
allowClear
prefix={<SearchOutlined />}
/>
</TableHeader>
);
}

View File

@ -0,0 +1,24 @@
import React from 'react';
import { PropertyRow } from './types';
import { StdDataType } from '../../../../../types.generated';
import StructuredPropertyValue from './StructuredPropertyValue';
interface Props {
propertyRow: PropertyRow;
filterText?: string;
}
export default function ValuesColumn({ propertyRow, filterText }: Props) {
const { values } = propertyRow;
const isRichText = propertyRow.dataType?.info.type === StdDataType.RichText;
return (
<>
{values ? (
values.map((v) => <StructuredPropertyValue value={v} isRichText={isRichText} filterText={filterText} />)
) : (
<span />
)}
</>
);
}

View File

@ -0,0 +1,87 @@
import { getTestEntityRegistry } from '../../../../../../utils/test-utils/TestPageContainer';
import { PropertyRow } from '../types';
import { filterStructuredProperties } from '../utils';
describe('filterSchemaRows', () => {
const testEntityRegistry = getTestEntityRegistry();
const rows = [
{
displayName: 'Has PII',
qualifiedName: 'io.acryl.ads.data_protection.has_pii',
values: [{ value: 'yes', entity: null }],
},
{
displayName: 'Discovery Date Utc',
qualifiedName: 'io.acryl.ads.change_management.discovery_date_utc',
values: [{ value: '2023-10-31', entity: null }],
},
{
displayName: 'Link Data Location',
qualifiedName: 'io.acryl.ads.context.data_location',
values: [{ value: 'New York City', entity: null }],
},
{
displayName: 'Number Prop',
qualifiedName: 'io.acryl.ads.number',
values: [{ value: 100, entity: null }],
},
] as PropertyRow[];
it('should properly filter structured properties based on field name', () => {
const filterText = 'has pi';
const { filteredRows, expandedRowsFromFilter } = filterStructuredProperties(
testEntityRegistry,
rows,
filterText,
);
expect(filteredRows).toMatchObject([
{
displayName: 'Has PII',
qualifiedName: 'io.acryl.ads.data_protection.has_pii',
values: [{ value: 'yes', entity: null }],
},
]);
expect(expandedRowsFromFilter).toMatchObject(
new Set(['io', 'io.acryl', 'io.acryl.ads', 'io.acryl.ads.data_protection']),
);
});
it('should properly filter structured properties based on field value', () => {
const filterText = 'new york';
const { filteredRows, expandedRowsFromFilter } = filterStructuredProperties(
testEntityRegistry,
rows,
filterText,
);
expect(filteredRows).toMatchObject([
{
displayName: 'Link Data Location',
qualifiedName: 'io.acryl.ads.context.data_location',
values: [{ value: 'New York City', entity: null }],
},
]);
expect(expandedRowsFromFilter).toMatchObject(
new Set(['io', 'io.acryl', 'io.acryl.ads', 'io.acryl.ads.context']),
);
});
it('should properly filter structured properties based on field value even for numbers', () => {
const filterText = '100';
const { filteredRows, expandedRowsFromFilter } = filterStructuredProperties(
testEntityRegistry,
rows,
filterText,
);
expect(filteredRows).toMatchObject([
{
displayName: 'Number Prop',
qualifiedName: 'io.acryl.ads.number',
values: [{ value: 100, entity: null }],
},
]);
expect(expandedRowsFromFilter).toMatchObject(new Set(['io', 'io.acryl', 'io.acryl.ads']));
});
});

View File

@ -0,0 +1,25 @@
import { DataTypeEntity, Entity, StructuredPropertyEntity } from '../../../../../types.generated';
export interface ValueColumnData {
value: string | number | null;
entity: Entity | null;
}
export interface TypeData {
type: string;
nativeDataType: string;
}
export interface PropertyRow {
displayName: string;
qualifiedName: string;
values?: ValueColumnData[];
children?: PropertyRow[];
childrenCount?: number;
parent?: PropertyRow;
depth?: number;
type?: TypeData;
dataType?: DataTypeEntity;
isParentRow?: boolean;
structuredProperty?: StructuredPropertyEntity;
}

View File

@ -0,0 +1,215 @@
import { PropertyValue, StructuredPropertiesEntry } from '../../../../../types.generated';
import EntityRegistry from '../../../EntityRegistry';
import { useEntityData } from '../../EntityContext';
import { GenericEntityProperties } from '../../types';
import { getStructuredPropertyValue } from '../../utils';
import { PropertyRow } from './types';
import { filterStructuredProperties } from './utils';
const typeNameToType = {
StringValue: { type: 'string', nativeDataType: 'text' },
NumberValue: { type: 'number', nativeDataType: 'float' },
};
export function mapStructuredPropertyValues(structuredPropertiesEntry: StructuredPropertiesEntry) {
return structuredPropertiesEntry.values
.filter((value) => !!value)
.map((value) => ({
value: getStructuredPropertyValue(value as PropertyValue),
entity:
structuredPropertiesEntry.valueEntities?.find(
(entity) => entity?.urn === getStructuredPropertyValue(value as PropertyValue),
) || null,
}));
}
// map the properties map into a list of PropertyRow objects to render in a table
function getStructuredPropertyRows(entityData?: GenericEntityProperties | null) {
const structuredPropertyRows: PropertyRow[] = [];
entityData?.structuredProperties?.properties?.forEach((structuredPropertiesEntry) => {
const { displayName, qualifiedName } = structuredPropertiesEntry.structuredProperty.definition;
structuredPropertyRows.push({
displayName: displayName || qualifiedName,
qualifiedName,
values: mapStructuredPropertyValues(structuredPropertiesEntry),
dataType: structuredPropertiesEntry.structuredProperty.definition.valueType,
structuredProperty: structuredPropertiesEntry.structuredProperty,
type:
structuredPropertiesEntry.values[0] && structuredPropertiesEntry.values[0].__typename
? {
type: typeNameToType[structuredPropertiesEntry.values[0].__typename].type,
nativeDataType: typeNameToType[structuredPropertiesEntry.values[0].__typename].nativeDataType,
}
: undefined,
});
});
return structuredPropertyRows;
}
export function findAllSubstrings(s: string): Array<string> {
const substrings: Array<string> = [];
for (let i = 0; i < s.length; i++) {
if (s[i] === '.') {
substrings.push(s.substring(0, i));
}
}
substrings.push(s);
return substrings;
}
export function createParentPropertyRow(displayName: string, qualifiedName: string): PropertyRow {
return {
displayName,
qualifiedName,
isParentRow: true,
};
}
export function identifyAndAddParentRows(rows?: Array<PropertyRow>): Array<PropertyRow> {
/**
* This function takes in an array of PropertyRow objects and determines which rows are parents. These parents need
* to be extracted in order to organize the rows into a properly nested structure later on. The final product returned
* is a list of parent rows, without values or children assigned.
*/
const qualifiedNames: Array<string> = [];
// Get list of fqns
if (rows) {
rows.forEach((row) => {
qualifiedNames.push(row.qualifiedName);
});
}
const finalParents: PropertyRow[] = [];
const finalParentNames = new Set();
// Loop through list of fqns and find all substrings.
// e.g. a.b.c.d becomes a, a.b, a.b.c, a.b.c.d
qualifiedNames.forEach((fqn) => {
let previousCount: number | null = null;
let previousParentName = '';
const substrings = findAllSubstrings(fqn);
// Loop through substrings and count how many other fqns have that substring in them. Use this to determine
// if a property should be nested. If the count is equal then we should not nest, because there's no split
// that would tell us to nest. If the count is not equal, we should nest the child properties.
for (let index = 0; index < substrings.length; index++) {
const token = substrings[index];
const currentCount = qualifiedNames.filter((name) => name.startsWith(token)).length;
// If we're at the beginning of the path and there is no nesting, break
if (index === 0 && currentCount === 1) {
break;
}
// Add previous fqn, or,previousParentName, if we have found a viable parent path
if (previousCount !== null && previousCount !== currentCount) {
if (!finalParentNames.has(previousParentName)) {
const parent: PropertyRow = createParentPropertyRow(previousParentName, previousParentName);
parent.childrenCount = previousCount;
finalParentNames.add(previousParentName);
finalParents.push(parent);
}
}
previousCount = currentCount;
previousParentName = token;
}
});
return finalParents;
}
export function groupByParentProperty(rows?: Array<PropertyRow>): Array<PropertyRow> {
/**
* This function takes in an array of PropertyRow objects, representing parent and child properties. Parent properties
* will not have values, but child properties will. It organizes the rows into the parent and child structure and
* returns a list of PropertyRow objects representing it.
*/
const outputRows: Array<PropertyRow> = [];
const outputRowByPath = {};
if (rows) {
// Iterate through all rows
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
let parentRow: null | PropertyRow = null;
const row = { children: undefined, ...rows[rowIndex], depth: 0 };
// Iterate through a row's characters, and split the row's path into tokens
// e.g. a, b, c for the example a.b.c
for (let j = rowIndex - 1; j >= 0; j--) {
const rowTokens = row.qualifiedName.split('.');
let parentPath: null | string = null;
let previousParentPath = rowTokens.slice(0, rowTokens.length - 1).join('.');
// Iterate through a row's path backwards, and check if the previous row's path has been seen. If it has,
// populate parentRow. If not, move on to the next path token.
// e.g. for a.b.c.d, first evaluate a.b.c to see if it has been seen. If it hasn't, move to a.b
for (
let lastParentTokenIndex = rowTokens.length - 2;
lastParentTokenIndex >= 0;
--lastParentTokenIndex
) {
const lastParentToken: string = rowTokens[lastParentTokenIndex];
if (lastParentToken && Object.keys(outputRowByPath).includes(previousParentPath)) {
parentPath = rowTokens.slice(0, lastParentTokenIndex + 1).join('.');
break;
}
previousParentPath = rowTokens.slice(0, lastParentTokenIndex).join('.');
}
if (parentPath && rows[j].qualifiedName === parentPath) {
parentRow = outputRowByPath[rows[j].qualifiedName];
break;
}
}
// If the parent row exists in the ouput, add the current row as a child. If not, add the current row
// to the final output.
if (parentRow) {
row.depth = (parentRow.depth || 0) + 1;
row.parent = parentRow;
if (row.isParentRow) {
row.displayName = row.displayName.replace(`${parentRow.displayName}.`, '');
}
parentRow.children = [...(parentRow.children || []), row];
} else {
outputRows.push(row);
}
outputRowByPath[row.qualifiedName] = row;
}
}
return outputRows;
}
export default function useStructuredProperties(entityRegistry: EntityRegistry, filterText?: string) {
const { entityData } = useEntityData();
let structuredPropertyRowsRaw = getStructuredPropertyRows(entityData);
const parentRows = identifyAndAddParentRows(structuredPropertyRowsRaw);
structuredPropertyRowsRaw = [...structuredPropertyRowsRaw, ...parentRows];
const { filteredRows, expandedRowsFromFilter } = filterStructuredProperties(
entityRegistry,
structuredPropertyRowsRaw,
filterText,
);
// Sort by fqn before nesting algorithm
const copy = [...filteredRows].sort((a, b) => {
return a.qualifiedName.localeCompare(b.qualifiedName);
});
// group properties by path
const structuredPropertyRows = groupByParentProperty(copy);
return {
structuredPropertyRows,
expandedRowsFromFilter: expandedRowsFromFilter as Set<string>,
};
}

View File

@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { isEqual } from 'lodash';
import usePrevious from '../../../../shared/usePrevious';
interface Props {
expandedRowsFromFilter: Set<string>;
setExpandedRows: React.Dispatch<React.SetStateAction<Set<string>>>;
}
export default function useUpdateExpandedRowsFromFilter({ expandedRowsFromFilter, setExpandedRows }: Props) {
const previousExpandedRowsFromFilter = usePrevious(expandedRowsFromFilter);
useEffect(() => {
if (!isEqual(expandedRowsFromFilter, previousExpandedRowsFromFilter)) {
setExpandedRows((previousRows) => {
const finalRowsSet = new Set();
expandedRowsFromFilter.forEach((row) => finalRowsSet.add(row));
previousRows.forEach((row) => finalRowsSet.add(row));
return finalRowsSet as Set<string>;
});
}
}, [expandedRowsFromFilter, previousExpandedRowsFromFilter, setExpandedRows]);
}

View File

@ -0,0 +1,68 @@
import { CustomPropertiesEntry } from '../../../../../types.generated';
import EntityRegistry from '../../../EntityRegistry';
import { GenericEntityProperties } from '../../types';
import { PropertyRow, ValueColumnData } from './types';
export function mapCustomPropertiesToPropertyRows(customProperties: CustomPropertiesEntry[]) {
return (customProperties?.map((customProp) => ({
displayName: customProp.key,
values: [{ value: customProp.value || '' }],
type: { type: 'string', nativeDataType: 'string' },
})) || []) as PropertyRow[];
}
function matchesName(name: string, filterText: string) {
return name.toLocaleLowerCase().includes(filterText.toLocaleLowerCase());
}
function matchesAnyFromValues(values: ValueColumnData[], filterText: string, entityRegistry: EntityRegistry) {
return values.some(
(value) =>
matchesName(value.value?.toString() || '', filterText) ||
matchesName(value.entity ? entityRegistry.getDisplayName(value.entity.type, value.entity) : '', filterText),
);
}
export function getFilteredCustomProperties(filterText: string, entityData?: GenericEntityProperties | null) {
return entityData?.customProperties?.filter(
(property) => matchesName(property.key, filterText) || matchesName(property.value || '', filterText),
);
}
export function filterStructuredProperties(
entityRegistry: EntityRegistry,
propertyRows: PropertyRow[],
filterText?: string,
) {
if (!propertyRows) return { filteredRows: [], expandedRowsFromFilter: new Set() };
if (!filterText) return { filteredRows: propertyRows, expandedRowsFromFilter: new Set() };
const formattedFilterText = filterText.toLocaleLowerCase();
const finalQualifiedNames = new Set<string>();
const expandedRowsFromFilter = new Set<string>();
propertyRows.forEach((row) => {
// if we match on the qualified name (maybe from a parent) do not filter out
if (matchesName(row.qualifiedName, formattedFilterText)) {
finalQualifiedNames.add(row.qualifiedName);
}
// if we match specifically on this property (not just its parent), add and expand all parents
if (
matchesName(row.displayName, formattedFilterText) ||
matchesAnyFromValues(row.values || [], formattedFilterText, entityRegistry)
) {
finalQualifiedNames.add(row.qualifiedName);
const splitFieldPath = row.qualifiedName.split('.');
splitFieldPath.reduce((previous, current) => {
finalQualifiedNames.add(previous);
expandedRowsFromFilter.add(previous);
return `${previous}.${current}`;
});
}
});
const filteredRows = propertyRows.filter((row) => finalQualifiedNames.has(row.qualifiedName));
return { filteredRows, expandedRowsFromFilter };
}

View File

@ -38,6 +38,7 @@ import {
BrowsePathV2,
DataJobInputOutput,
ParentDomainsResult,
StructuredProperties,
} from '../../../types.generated';
import { FetchedEntity } from '../../lineage/types';
@ -84,6 +85,7 @@ export type GenericEntityProperties = {
platform?: Maybe<DataPlatform>;
dataPlatformInstance?: Maybe<DataPlatformInstance>;
customProperties?: Maybe<CustomPropertiesEntry[]>;
structuredProperties?: Maybe<StructuredProperties>;
institutionalMemory?: Maybe<InstitutionalMemory>;
schemaMetadata?: Maybe<SchemaMetadata>;
externalUrl?: Maybe<string>;

View File

@ -1,6 +1,6 @@
import { Maybe } from 'graphql/jsutils/Maybe';
import { Entity, EntityType, EntityRelationshipsResult, DataProduct } from '../../../types.generated';
import { Entity, EntityType, EntityRelationshipsResult, DataProduct, PropertyValue } from '../../../types.generated';
import { capitalizeFirstLetterOnly } from '../../shared/textUtil';
import { GenericEntityProperties } from './types';
@ -130,3 +130,13 @@ export function getDataProduct(dataProductResult: Maybe<EntityRelationshipsResul
}
return null;
}
export function getStructuredPropertyValue(value: PropertyValue) {
if (value.__typename === 'StringValue') {
return value.stringValue;
}
if (value.__typename === 'NumberValue') {
return value.numberValue;
}
return null;
}

View File

@ -245,6 +245,11 @@ fragment nonRecursiveDatasetFields on Dataset {
actor
}
}
structuredProperties {
properties {
...structuredPropertiesFields
}
}
editableProperties {
description
}
@ -709,6 +714,15 @@ fragment schemaFieldFields on SchemaField {
glossaryTerms {
...glossaryTerms
}
schemaFieldEntity {
urn
type
structuredProperties {
properties {
...structuredPropertiesFields
}
}
}
}
fragment schemaMetadataFields on SchemaMetadata {
@ -1163,6 +1177,69 @@ fragment entityDisplayNameFields on Entity {
}
}
fragment structuredPropertyFields on StructuredPropertyEntity {
urn
type
definition {
displayName
qualifiedName
description
cardinality
valueType {
info {
type
displayName
}
}
entityTypes {
info {
type
}
}
cardinality
typeQualifier {
allowedTypes {
urn
type
info {
type
displayName
}
}
}
allowedValues {
value {
... on StringValue {
stringValue
}
... on NumberValue {
numberValue
}
}
description
}
}
}
fragment structuredPropertiesFields on StructuredPropertiesEntry {
structuredProperty {
...structuredPropertyFields
}
values {
... on StringValue {
stringValue
}
... on NumberValue {
numberValue
}
}
valueEntities {
urn
type
...entityDisplayNameFields
}
}
fragment autoRenderAspectFields on RawAspect {
aspectName
payload

View File

@ -1,8 +1,8 @@
describe('login', () => {
it('logs in', () => {
cy.visit('/');
cy.get('input[data-testid=username]').type(Cypress.env('ADMIN_USERNAME'));
cy.get('input[data-testid=password]').type(Cypress.env('ADMIN_PASSWORD'));
cy.get('input[data-testid=username]').type('datahub');
cy.get('input[data-testid=password]').type('datahub');
cy.contains('Sign In').click();
cy.contains('Welcome back, ' + Cypress.env('ADMIN_DISPLAYNAME'));
});

View File

@ -78,17 +78,18 @@ describe("edit documentation and link to dataset", () => {
cy.visit(
"/dataset/urn:li:dataset:(urn:li:dataPlatform:hive,SampleCypressHiveDataset,PROD)/Schema"
);
cy.get("tbody [data-icon='edit']").first().click({ force: true });
cy.clickOptionWithText("field_foo");
cy.clickOptionWithTestId("edit-field-description");
cy.waitTextVisible("Update description");
cy.waitTextVisible("Foo field description has changed");
cy.focused().clear().wait(1000);
cy.getWithTestId("description-editor").clear().wait(1000);
cy.focused().type(documentation_edited);
cy.clickOptionWithTestId("description-modal-update-button");
cy.waitTextVisible("Updated!");
cy.waitTextVisible(documentation_edited);
cy.waitTextVisible("(edited)");
cy.get("tbody [data-icon='edit']").first().click({ force: true });
cy.focused().clear().wait(1000);
cy.clickOptionWithTestId("edit-field-description");
cy.getWithTestId("description-editor").clear().wait(1000);
cy.focused().type("Foo field description has changed");
cy.clickOptionWithTestId("description-modal-update-button");
cy.waitTextVisible("Updated!");

View File

@ -77,7 +77,7 @@ describe("mutations", () => {
cy.login();
cy.viewport(2000, 800);
cy.goToDataset("urn:li:dataset:(urn:li:dataPlatform:hive,cypress_logging_events,PROD)", "cypress_logging_events");
cy.mouseover('[data-testid="schema-field-event_name-tags"]');
cy.clickOptionWithText("event_name");
cy.get('[data-testid="schema-field-event_name-tags"]').within(() =>
cy.contains("Add Tag").click()
);
@ -116,7 +116,8 @@ describe("mutations", () => {
// verify dataset shows up in search now
cy.contains("of 1 result").click();
cy.contains("cypress_logging_events").click();
cy.get('[data-testid="tag-CypressTestAddTag2"]').within(() =>
cy.clickOptionWithText("event_name");
cy.get('[data-testid="schema-field-event_name-tags"]').within(() =>
cy
.get("span[aria-label=close]")
.trigger("mouseover", { force: true })
@ -134,10 +135,7 @@ describe("mutations", () => {
// make space for the glossary term column
cy.viewport(2000, 800);
cy.goToDataset("urn:li:dataset:(urn:li:dataPlatform:hive,cypress_logging_events,PROD)", "cypress_logging_events");
cy.get('[data-testid="schema-field-event_name-terms"]').trigger(
"mouseover",
{ force: true }
);
cy.clickOptionWithText("event_name");
cy.get('[data-testid="schema-field-event_name-terms"]').within(() =>
cy.contains("Add Term").click({ force: true })
);
@ -146,9 +144,12 @@ describe("mutations", () => {
cy.contains("CypressTerm");
cy.get(
'a[href="/glossaryTerm/urn:li:glossaryTerm:CypressNode.CypressTerm"]'
).within(() => cy.get("span[aria-label=close]").click({ force: true }));
cy.get('[data-testid="schema-field-event_name-terms"]').within(() =>
cy
.get("span[aria-label=close]")
.trigger("mouseover", { force: true })
.click({ force: true })
);
cy.contains("Yes").click({ force: true });
cy.contains("CypressTerm").should("not.exist");

View File

@ -14,6 +14,7 @@ describe('schema blame', () => {
cy.contains('field_bar').should('not.exist');
cy.contains('Foo field description has changed');
cy.contains('Baz field description');
cy.clickOptionWithText("field_foo");
cy.get('[data-testid="schema-field-field_foo-tags"]').contains('Legacy');
// Make sure the schema blame is accurate
@ -41,6 +42,7 @@ describe('schema blame', () => {
cy.contains('field_baz').should('not.exist');
cy.contains('Foo field description');
cy.contains('Bar field description');
cy.clickOptionWithText("field_foo");
cy.get('[data-testid="schema-field-field_foo-tags"]').contains('Legacy').should('not.exist');
// Make sure the schema blame is accurate

View File

@ -218,6 +218,10 @@ Cypress.Commands.add( 'multiSelect', (within_data_id , text) => {
cy.clickOptionWithText(text);
});
Cypress.Commands.add("getWithTestId", (id) => {
return cy.get(selectorWithtestId(id));
});
Cypress.Commands.add("enterTextInTestId", (id, text) => {
cy.get(selectorWithtestId(id)).type(text);
})