mirror of
https://github.com/datahub-project/datahub.git
synced 2025-10-07 15:05:08 +00:00
feat(ui) Add structured properties support in the UI (#9695)
This commit is contained in:
parent
f83a2fab44
commit
a78c6899a2
@ -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: '',
|
||||
|
@ -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
|
||||
|
@ -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>}>
|
||||
|
@ -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}</>;
|
||||
}
|
@ -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>
|
||||
|
@ -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} />
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
</>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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} />;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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)} />;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
`;
|
@ -48,8 +48,8 @@ export default function useDescriptionRenderer(editableSchemaMetadata: EditableS
|
||||
},
|
||||
}).then(refresh)
|
||||
}
|
||||
isReadOnly
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
//
|
||||
|
@ -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
|
||||
buttonProps={{ size: 'small' }}
|
||||
canAddTag={tagHoveredIndex === record.fieldPath && options.showTags}
|
||||
canAddTerm={tagHoveredIndex === record.fieldPath && options.showTerms}
|
||||
onOpenModal={() => setTagHoveredIndex(undefined)}
|
||||
entityUrn={urn}
|
||||
entityType={EntityType.Dataset}
|
||||
entitySubresource={record.fieldPath}
|
||||
highlightText={filterText}
|
||||
refetch={refresh}
|
||||
/>
|
||||
</div>
|
||||
<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={canEdit}
|
||||
buttonProps={{ size: 'small' }}
|
||||
canAddTag={canEdit && options.showTags}
|
||||
canAddTerm={canEdit && options.showTerms}
|
||||
entityUrn={urn}
|
||||
entityType={EntityType.Dataset}
|
||||
entitySubresource={record.fieldPath}
|
||||
highlightText={filterText}
|
||||
refetch={refresh}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return tagAndTermRender;
|
||||
|
@ -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]};
|
||||
|
@ -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 && (
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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 (
|
||||
<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}
|
||||
/>
|
||||
<>
|
||||
<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={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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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']));
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
@ -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>,
|
||||
};
|
||||
}
|
@ -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]);
|
||||
}
|
@ -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 };
|
||||
}
|
@ -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>;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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'));
|
||||
});
|
||||
|
@ -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!");
|
||||
|
@ -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");
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
})
|
||||
|
Loading…
x
Reference in New Issue
Block a user