mirror of
https://github.com/datahub-project/datahub.git
synced 2025-10-08 07:26:25 +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,
|
embed: null,
|
||||||
browsePathV2: { path: [{ name: 'test', entity: null }], __typename: 'BrowsePathV2' },
|
browsePathV2: { path: [{ name: 'test', entity: null }], __typename: 'BrowsePathV2' },
|
||||||
autoRenderAspects: [],
|
autoRenderAspects: [],
|
||||||
|
structuredProperties: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dataset2 = {
|
export const dataset2 = {
|
||||||
@ -393,6 +394,7 @@ export const dataset2 = {
|
|||||||
embed: null,
|
embed: null,
|
||||||
browsePathV2: { path: [{ name: 'test', entity: null }], __typename: 'BrowsePathV2' },
|
browsePathV2: { path: [{ name: 'test', entity: null }], __typename: 'BrowsePathV2' },
|
||||||
autoRenderAspects: [],
|
autoRenderAspects: [],
|
||||||
|
structuredProperties: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dataset3 = {
|
export const dataset3 = {
|
||||||
@ -626,6 +628,7 @@ export const dataset3 = {
|
|||||||
dataProduct: null,
|
dataProduct: null,
|
||||||
lastProfile: null,
|
lastProfile: null,
|
||||||
lastOperation: null,
|
lastOperation: null,
|
||||||
|
structuredProperties: null,
|
||||||
} as Dataset;
|
} as Dataset;
|
||||||
|
|
||||||
export const dataset3WithSchema = {
|
export const dataset3WithSchema = {
|
||||||
@ -650,6 +653,7 @@ export const dataset3WithSchema = {
|
|||||||
globalTags: null,
|
globalTags: null,
|
||||||
glossaryTerms: null,
|
glossaryTerms: null,
|
||||||
label: 'hi',
|
label: 'hi',
|
||||||
|
schemaFieldEntity: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
__typename: 'SchemaField',
|
__typename: 'SchemaField',
|
||||||
@ -665,6 +669,7 @@ export const dataset3WithSchema = {
|
|||||||
globalTags: null,
|
globalTags: null,
|
||||||
glossaryTerms: null,
|
glossaryTerms: null,
|
||||||
label: 'hi',
|
label: 'hi',
|
||||||
|
schemaFieldEntity: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
hash: '',
|
hash: '',
|
||||||
|
@ -86,6 +86,7 @@ type Props = {
|
|||||||
description: string,
|
description: string,
|
||||||
) => Promise<FetchResult<UpdateDatasetMutation, Record<string, any>, Record<string, any>> | void>;
|
) => Promise<FetchResult<UpdateDatasetMutation, Record<string, any>, Record<string, any>> | void>;
|
||||||
isEdited?: boolean;
|
isEdited?: boolean;
|
||||||
|
isReadOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ABBREVIATED_LIMIT = 80;
|
const ABBREVIATED_LIMIT = 80;
|
||||||
@ -97,10 +98,11 @@ export default function DescriptionField({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
isEdited = false,
|
isEdited = false,
|
||||||
original,
|
original,
|
||||||
|
isReadOnly,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
const overLimit = removeMarkdown(description).length > 80;
|
const overLimit = removeMarkdown(description).length > 80;
|
||||||
const isSchemaEditable = React.useContext(SchemaEditableContext);
|
const isSchemaEditable = React.useContext(SchemaEditableContext) && !isReadOnly;
|
||||||
const onCloseModal = () => setShowAddModal(false);
|
const onCloseModal = () => setShowAddModal(false);
|
||||||
const { urn, entityType } = useEntityData();
|
const { urn, entityType } = useEntityData();
|
||||||
|
|
||||||
@ -140,11 +142,12 @@ export default function DescriptionField({
|
|||||||
{expanded || !overLimit ? (
|
{expanded || !overLimit ? (
|
||||||
<>
|
<>
|
||||||
{!!description && <StyledViewer content={description} readOnly />}
|
{!!description && <StyledViewer content={description} readOnly />}
|
||||||
{!!description && (
|
{!!description && (EditButton || overLimit) && (
|
||||||
<ExpandedActions>
|
<ExpandedActions>
|
||||||
{overLimit && (
|
{overLimit && (
|
||||||
<ReadLessText
|
<ReadLessText
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
handleExpanded(false);
|
handleExpanded(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -162,7 +165,8 @@ export default function DescriptionField({
|
|||||||
readMore={
|
readMore={
|
||||||
<>
|
<>
|
||||||
<Typography.Link
|
<Typography.Link
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
handleExpanded(true);
|
handleExpanded(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -177,7 +181,7 @@ export default function DescriptionField({
|
|||||||
</StripMarkdownText>
|
</StripMarkdownText>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isSchemaEditable && isEdited && <EditedLabel>(edited)</EditedLabel>}
|
{isEdited && <EditedLabel>(edited)</EditedLabel>}
|
||||||
{showAddModal && (
|
{showAddModal && (
|
||||||
<div>
|
<div>
|
||||||
<UpdateDescriptionModal
|
<UpdateDescriptionModal
|
||||||
|
@ -53,7 +53,7 @@ export default function UpdateDescriptionModal({ title, description, original, o
|
|||||||
>
|
>
|
||||||
<Form layout="vertical">
|
<Form layout="vertical">
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<StyledEditor content={updatedDesc} onChange={setDesc} />
|
<StyledEditor content={updatedDesc} onChange={setDesc} dataTestId="description-editor" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{!isAddDesc && description && original && (
|
{!isAddDesc && description && original && (
|
||||||
<Form.Item label={<FormLabel>Original:</FormLabel>}>
|
<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 CompactContext from '../../../../shared/CompactContext';
|
||||||
import DynamicTab from '../../tabs/Entity/weaklyTypedAspects/DynamicTab';
|
import DynamicTab from '../../tabs/Entity/weaklyTypedAspects/DynamicTab';
|
||||||
import analytics, { EventType } from '../../../../analytics';
|
import analytics, { EventType } from '../../../../analytics';
|
||||||
import { ProfileSidebarResizer } from './sidebar/ProfileSidebarResizer';
|
|
||||||
import { EntityMenuItems } from '../../EntityDropdown/EntityDropdown';
|
import { EntityMenuItems } from '../../EntityDropdown/EntityDropdown';
|
||||||
import { useIsSeparateSiblingsMode } from '../../siblingUtils';
|
import { useIsSeparateSiblingsMode } from '../../siblingUtils';
|
||||||
import { EntityActionItem } from '../../entity/EntityActions';
|
import { EntityActionItem } from '../../entity/EntityActions';
|
||||||
@ -45,6 +44,7 @@ import {
|
|||||||
} from '../../../../onboarding/config/LineageGraphOnboardingConfig';
|
} from '../../../../onboarding/config/LineageGraphOnboardingConfig';
|
||||||
import { useAppConfig } from '../../../../useAppConfig';
|
import { useAppConfig } from '../../../../useAppConfig';
|
||||||
import { useUpdateDomainEntityDataOnChange } from '../../../../domain/utils';
|
import { useUpdateDomainEntityDataOnChange } from '../../../../domain/utils';
|
||||||
|
import ProfileSidebar from './sidebar/ProfileSidebar';
|
||||||
|
|
||||||
type Props<T, U> = {
|
type Props<T, U> = {
|
||||||
urn: string;
|
urn: string;
|
||||||
@ -75,8 +75,6 @@ type Props<T, U> = {
|
|||||||
isNameEditable?: boolean;
|
isNameEditable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_SIDEBAR_WIDTH = 800;
|
|
||||||
const MIN_SIDEBAR_WIDTH = 200;
|
|
||||||
const MAX_COMPACT_WIDTH = 490 - 24 * 2;
|
const MAX_COMPACT_WIDTH = 490 - 24 * 2;
|
||||||
|
|
||||||
const ContentContainer = styled.div`
|
const ContentContainer = styled.div`
|
||||||
@ -85,6 +83,7 @@ const ContentContainer = styled.div`
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const HeaderAndTabs = styled.div`
|
const HeaderAndTabs = styled.div`
|
||||||
@ -113,15 +112,6 @@ const HeaderAndTabsFlex = styled.div`
|
|||||||
-webkit-box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.75);
|
-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`
|
const Header = styled.div`
|
||||||
border-bottom: 1px solid ${ANTD_GRAY[4.5]};
|
border-bottom: 1px solid ${ANTD_GRAY[4.5]};
|
||||||
@ -145,7 +135,7 @@ const defaultTabDisplayConfig = {
|
|||||||
enabled: (_, _1) => true,
|
enabled: (_, _1) => true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultSidebarSection = {
|
export const DEFAULT_SIDEBAR_SECTION = {
|
||||||
visible: (_, _1) => true,
|
visible: (_, _1) => true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -176,11 +166,10 @@ export const EntityProfile = <T, U>({
|
|||||||
const sortedTabs = sortEntityProfileTabs(appConfig.config, entityType, tabsWithDefaults);
|
const sortedTabs = sortEntityProfileTabs(appConfig.config, entityType, tabsWithDefaults);
|
||||||
const sideBarSectionsWithDefaults = sidebarSections.map((sidebarSection) => ({
|
const sideBarSectionsWithDefaults = sidebarSections.map((sidebarSection) => ({
|
||||||
...sidebarSection,
|
...sidebarSection,
|
||||||
display: { ...defaultSidebarSection, ...sidebarSection.display },
|
display: { ...DEFAULT_SIDEBAR_SECTION, ...sidebarSection.display },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const [shouldRefetchEmbeddedListSearch, setShouldRefetchEmbeddedListSearch] = useState(false);
|
const [shouldRefetchEmbeddedListSearch, setShouldRefetchEmbeddedListSearch] = useState(false);
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(window.innerWidth * 0.25);
|
|
||||||
const entityStepIds: string[] = getOnboardingStepIdsForEntityType(entityType);
|
const entityStepIds: string[] = getOnboardingStepIdsForEntityType(entityType);
|
||||||
const lineageGraphStepIds: string[] = [LINEAGE_GRAPH_INTRO_ID, LINEAGE_GRAPH_TIME_FILTER_ID];
|
const lineageGraphStepIds: string[] = [LINEAGE_GRAPH_INTRO_ID, LINEAGE_GRAPH_TIME_FILTER_ID];
|
||||||
const stepIds = isLineageMode ? lineageGraphStepIds : entityStepIds;
|
const stepIds = isLineageMode ? lineageGraphStepIds : entityStepIds;
|
||||||
@ -344,15 +333,7 @@ export const EntityProfile = <T, U>({
|
|||||||
</TabContent>
|
</TabContent>
|
||||||
</HeaderAndTabsFlex>
|
</HeaderAndTabsFlex>
|
||||||
</HeaderAndTabs>
|
</HeaderAndTabs>
|
||||||
<ProfileSidebarResizer
|
<ProfileSidebar sidebarSections={sidebarSections} />
|
||||||
setSidePanelWidth={(width) =>
|
|
||||||
setSidebarWidth(Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), MAX_SIDEBAR_WIDTH))
|
|
||||||
}
|
|
||||||
initialSize={sidebarWidth}
|
|
||||||
/>
|
|
||||||
<Sidebar $width={sidebarWidth}>
|
|
||||||
<EntitySidebar sidebarSections={sideBarSectionsWithDefaults} />
|
|
||||||
</Sidebar>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ContentContainer>
|
</ContentContainer>
|
||||||
|
@ -36,14 +36,16 @@ const LastIngestedSection = styled.div`
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
sidebarSections: EntitySidebarSection[];
|
sidebarSections: EntitySidebarSection[];
|
||||||
|
topSection?: EntitySidebarSection;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EntitySidebar = <T,>({ sidebarSections }: Props) => {
|
export const EntitySidebar = <T,>({ sidebarSections, topSection }: Props) => {
|
||||||
const { entityData } = useEntityData();
|
const { entityData } = useEntityData();
|
||||||
const baseEntity = useBaseEntity<T>();
|
const baseEntity = useBaseEntity<T>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{topSection && <topSection.component key={`${topSection.component}`} properties={topSection.properties} />}
|
||||||
{entityData?.lastIngested && (
|
{entityData?.lastIngested && (
|
||||||
<LastIngestedSection>
|
<LastIngestedSection>
|
||||||
<LastIngested lastIngested={entityData.lastIngested} />
|
<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],
|
[schemaMetadata],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasProperties = useMemo(
|
||||||
|
() =>
|
||||||
|
entityWithSchema?.schemaMetadata?.fields.some(
|
||||||
|
(schemaField) => !!schemaField.schemaFieldEntity?.structuredProperties?.properties?.length,
|
||||||
|
),
|
||||||
|
[entityWithSchema],
|
||||||
|
);
|
||||||
|
|
||||||
const [showKeySchema, setShowKeySchema] = useState(false);
|
const [showKeySchema, setShowKeySchema] = useState(false);
|
||||||
const [showSchemaAuditView, setShowSchemaAuditView] = useState(false);
|
const [showSchemaAuditView, setShowSchemaAuditView] = useState(false);
|
||||||
|
|
||||||
@ -190,13 +198,13 @@ export const SchemaTab = ({ properties }: { properties?: any }) => {
|
|||||||
<SchemaTable
|
<SchemaTable
|
||||||
schemaMetadata={schemaMetadata}
|
schemaMetadata={schemaMetadata}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
editMode={editMode}
|
|
||||||
editableSchemaMetadata={editableSchemaMetadata}
|
editableSchemaMetadata={editableSchemaMetadata}
|
||||||
usageStats={usageStats}
|
usageStats={usageStats}
|
||||||
schemaFieldBlameList={schemaFieldBlameList}
|
schemaFieldBlameList={schemaFieldBlameList}
|
||||||
showSchemaAuditView={showSchemaAuditView}
|
showSchemaAuditView={showSchemaAuditView}
|
||||||
expandedRowsFromFilter={expandedRowsFromFilter as any}
|
expandedRowsFromFilter={expandedRowsFromFilter as any}
|
||||||
filterText={filterText as any}
|
filterText={filterText as any}
|
||||||
|
hasProperties={hasProperties}
|
||||||
/>
|
/>
|
||||||
</SchemaEditableContext.Provider>
|
</SchemaEditableContext.Provider>
|
||||||
</>
|
</>
|
||||||
|
@ -21,9 +21,10 @@ import { StyledTable } from '../../../components/styled/StyledTable';
|
|||||||
import { SchemaRow } from './components/SchemaRow';
|
import { SchemaRow } from './components/SchemaRow';
|
||||||
import { FkContext } from './utils/selectedFkContext';
|
import { FkContext } from './utils/selectedFkContext';
|
||||||
import useSchemaBlameRenderer from './utils/useSchemaBlameRenderer';
|
import useSchemaBlameRenderer from './utils/useSchemaBlameRenderer';
|
||||||
import { ANTD_GRAY } from '../../../constants';
|
import { ANTD_GRAY, ANTD_GRAY_V2 } from '../../../constants';
|
||||||
import MenuColumn from './components/MenuColumn';
|
|
||||||
import translateFieldPath from '../../../../dataset/profile/schema/utils/translateFieldPath';
|
import translateFieldPath from '../../../../dataset/profile/schema/utils/translateFieldPath';
|
||||||
|
import PropertiesColumn from './components/PropertiesColumn';
|
||||||
|
import SchemaFieldDrawer from './components/SchemaFieldDrawer/SchemaFieldDrawer';
|
||||||
|
|
||||||
const TableContainer = styled.div`
|
const TableContainer = styled.div`
|
||||||
overflow: inherit;
|
overflow: inherit;
|
||||||
@ -41,18 +42,36 @@ const TableContainer = styled.div`
|
|||||||
padding-bottom: 600px;
|
padding-bottom: 600px;
|
||||||
vertical-align: top;
|
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 = {
|
export type Props = {
|
||||||
rows: Array<ExtendedSchemaFields>;
|
rows: Array<ExtendedSchemaFields>;
|
||||||
schemaMetadata: SchemaMetadata | undefined | null;
|
schemaMetadata: SchemaMetadata | undefined | null;
|
||||||
editableSchemaMetadata?: EditableSchemaMetadata | null;
|
editableSchemaMetadata?: EditableSchemaMetadata | null;
|
||||||
editMode?: boolean;
|
|
||||||
usageStats?: UsageQueryResult | null;
|
usageStats?: UsageQueryResult | null;
|
||||||
schemaFieldBlameList?: Array<SchemaFieldBlame> | null;
|
schemaFieldBlameList?: Array<SchemaFieldBlame> | null;
|
||||||
showSchemaAuditView: boolean;
|
showSchemaAuditView: boolean;
|
||||||
expandedRowsFromFilter?: Set<string>;
|
expandedRowsFromFilter?: Set<string>;
|
||||||
filterText?: string;
|
filterText?: string;
|
||||||
|
hasProperties?: boolean;
|
||||||
|
inputFields?: SchemaField[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_SET: Set<string> = new Set();
|
const EMPTY_SET: Set<string> = new Set();
|
||||||
@ -63,56 +82,46 @@ export default function SchemaTable({
|
|||||||
schemaMetadata,
|
schemaMetadata,
|
||||||
editableSchemaMetadata,
|
editableSchemaMetadata,
|
||||||
usageStats,
|
usageStats,
|
||||||
editMode = true,
|
|
||||||
schemaFieldBlameList,
|
schemaFieldBlameList,
|
||||||
showSchemaAuditView,
|
showSchemaAuditView,
|
||||||
expandedRowsFromFilter = EMPTY_SET,
|
expandedRowsFromFilter = EMPTY_SET,
|
||||||
filterText = '',
|
filterText = '',
|
||||||
|
hasProperties,
|
||||||
|
inputFields,
|
||||||
}: Props): JSX.Element {
|
}: Props): JSX.Element {
|
||||||
const hasUsageStats = useMemo(() => (usageStats?.aggregations?.fields?.length || 0) > 0, [usageStats]);
|
const hasUsageStats = useMemo(() => (usageStats?.aggregations?.fields?.length || 0) > 0, [usageStats]);
|
||||||
const [tableHeight, setTableHeight] = useState(0);
|
const [tableHeight, setTableHeight] = useState(0);
|
||||||
const [tagHoveredIndex, setTagHoveredIndex] = useState<string | undefined>(undefined);
|
const [selectedFkFieldPath, setSelectedFkFieldPath] = useState<null | {
|
||||||
const [selectedFkFieldPath, setSelectedFkFieldPath] =
|
fieldPath: string;
|
||||||
useState<null | { fieldPath: string; constraint?: ForeignKeyConstraint | null }>(null);
|
constraint?: ForeignKeyConstraint | null;
|
||||||
|
}>(null);
|
||||||
|
const [expandedDrawerFieldPath, setExpandedDrawerFieldPath] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const schemaFields = schemaMetadata ? schemaMetadata.fields : inputFields;
|
||||||
|
|
||||||
const descriptionRender = useDescriptionRenderer(editableSchemaMetadata);
|
const descriptionRender = useDescriptionRenderer(editableSchemaMetadata);
|
||||||
const usageStatsRenderer = useUsageStatsRenderer(usageStats);
|
const usageStatsRenderer = useUsageStatsRenderer(usageStats);
|
||||||
const tagRenderer = useTagsAndTermsRenderer(
|
const tagRenderer = useTagsAndTermsRenderer(
|
||||||
editableSchemaMetadata,
|
editableSchemaMetadata,
|
||||||
tagHoveredIndex,
|
|
||||||
setTagHoveredIndex,
|
|
||||||
{
|
{
|
||||||
showTags: true,
|
showTags: true,
|
||||||
showTerms: false,
|
showTerms: false,
|
||||||
},
|
},
|
||||||
filterText,
|
filterText,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
const termRenderer = useTagsAndTermsRenderer(
|
const termRenderer = useTagsAndTermsRenderer(
|
||||||
editableSchemaMetadata,
|
editableSchemaMetadata,
|
||||||
tagHoveredIndex,
|
|
||||||
setTagHoveredIndex,
|
|
||||||
{
|
{
|
||||||
showTags: false,
|
showTags: false,
|
||||||
showTerms: true,
|
showTerms: true,
|
||||||
},
|
},
|
||||||
filterText,
|
filterText,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
const schemaTitleRenderer = useSchemaTitleRenderer(schemaMetadata, setSelectedFkFieldPath, filterText);
|
const schemaTitleRenderer = useSchemaTitleRenderer(schemaMetadata, setSelectedFkFieldPath, filterText);
|
||||||
const schemaBlameRenderer = useSchemaBlameRenderer(schemaFieldBlameList);
|
const schemaBlameRenderer = useSchemaBlameRenderer(schemaFieldBlameList);
|
||||||
|
|
||||||
const onTagTermCell = (record: SchemaField) => ({
|
|
||||||
onMouseEnter: () => {
|
|
||||||
if (editMode) {
|
|
||||||
setTagHoveredIndex(record.fieldPath);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMouseLeave: () => {
|
|
||||||
if (editMode) {
|
|
||||||
setTagHoveredIndex(undefined);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const fieldColumn = {
|
const fieldColumn = {
|
||||||
width: '22%',
|
width: '22%',
|
||||||
title: 'Field',
|
title: 'Field',
|
||||||
@ -139,7 +148,6 @@ export default function SchemaTable({
|
|||||||
dataIndex: 'globalTags',
|
dataIndex: 'globalTags',
|
||||||
key: 'tag',
|
key: 'tag',
|
||||||
render: tagRenderer,
|
render: tagRenderer,
|
||||||
onCell: onTagTermCell,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const termColumn = {
|
const termColumn = {
|
||||||
@ -148,7 +156,6 @@ export default function SchemaTable({
|
|||||||
dataIndex: 'globalTags',
|
dataIndex: 'globalTags',
|
||||||
key: 'tag',
|
key: 'tag',
|
||||||
render: termRenderer,
|
render: termRenderer,
|
||||||
onCell: onTagTermCell,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const blameColumn = {
|
const blameColumn = {
|
||||||
@ -184,16 +191,20 @@ export default function SchemaTable({
|
|||||||
sorter: (sourceA, sourceB) => getCount(sourceA.fieldPath) - getCount(sourceB.fieldPath),
|
sorter: (sourceA, sourceB) => getCount(sourceA.fieldPath) - getCount(sourceB.fieldPath),
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuColumn = {
|
const propertiesColumn = {
|
||||||
width: '5%',
|
width: '13%',
|
||||||
title: '',
|
title: 'Properties',
|
||||||
dataIndex: '',
|
dataIndex: '',
|
||||||
key: 'menu',
|
key: 'menu',
|
||||||
render: (field: SchemaField) => <MenuColumn field={field} />,
|
render: (field: SchemaField) => <PropertiesColumn field={field} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
let allColumns: ColumnsType<ExtendedSchemaFields> = [fieldColumn, descriptionColumn, tagColumn, termColumn];
|
let allColumns: ColumnsType<ExtendedSchemaFields> = [fieldColumn, descriptionColumn, tagColumn, termColumn];
|
||||||
|
|
||||||
|
if (hasProperties) {
|
||||||
|
allColumns = [...allColumns, propertiesColumn];
|
||||||
|
}
|
||||||
|
|
||||||
if (hasUsageStats) {
|
if (hasUsageStats) {
|
||||||
allColumns = [...allColumns, usageColumn];
|
allColumns = [...allColumns, usageColumn];
|
||||||
}
|
}
|
||||||
@ -202,8 +213,6 @@ export default function SchemaTable({
|
|||||||
allColumns = [...allColumns, blameColumn];
|
allColumns = [...allColumns, blameColumn];
|
||||||
}
|
}
|
||||||
|
|
||||||
allColumns = [...allColumns, menuColumn];
|
|
||||||
|
|
||||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -224,9 +233,15 @@ export default function SchemaTable({
|
|||||||
<TableContainer>
|
<TableContainer>
|
||||||
<ResizeObserver onResize={(dimensions) => setTableHeight(dimensions.height - TABLE_HEADER_HEIGHT)}>
|
<ResizeObserver onResize={(dimensions) => setTableHeight(dimensions.height - TABLE_HEADER_HEIGHT)}>
|
||||||
<StyledTable
|
<StyledTable
|
||||||
rowClassName={(record) =>
|
rowClassName={(record) => {
|
||||||
record.fieldPath === selectedFkFieldPath?.fieldPath ? 'open-fk-row' : ''
|
if (record.fieldPath === selectedFkFieldPath?.fieldPath) {
|
||||||
|
return 'open-fk-row';
|
||||||
}
|
}
|
||||||
|
if (expandedDrawerFieldPath === record.fieldPath) {
|
||||||
|
return 'expanded-row';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}}
|
||||||
columns={allColumns}
|
columns={allColumns}
|
||||||
dataSource={rows}
|
dataSource={rows}
|
||||||
rowKey="fieldPath"
|
rowKey="fieldPath"
|
||||||
@ -250,9 +265,27 @@ export default function SchemaTable({
|
|||||||
indentSize: 0,
|
indentSize: 0,
|
||||||
}}
|
}}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
onRow={(record) => ({
|
||||||
|
onClick: () => {
|
||||||
|
setExpandedDrawerFieldPath(
|
||||||
|
expandedDrawerFieldPath === record.fieldPath ? null : record.fieldPath,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
backgroundColor: expandedDrawerFieldPath === record.fieldPath ? `` : 'white',
|
||||||
|
},
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</ResizeObserver>
|
</ResizeObserver>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
{!!schemaFields && (
|
||||||
|
<SchemaFieldDrawer
|
||||||
|
schemaFields={schemaFields}
|
||||||
|
expandedDrawerFieldPath={expandedDrawerFieldPath}
|
||||||
|
editableSchemaMetadata={editableSchemaMetadata}
|
||||||
|
setExpandedDrawerFieldPath={setExpandedDrawerFieldPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</FkContext.Provider>
|
</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)
|
}).then(refresh)
|
||||||
}
|
}
|
||||||
|
isReadOnly
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
//
|
|
||||||
|
@ -2,15 +2,14 @@ import React from 'react';
|
|||||||
import { EditableSchemaMetadata, EntityType, GlobalTags, SchemaField } from '../../../../../../../types.generated';
|
import { EditableSchemaMetadata, EntityType, GlobalTags, SchemaField } from '../../../../../../../types.generated';
|
||||||
import TagTermGroup from '../../../../../../shared/tags/TagTermGroup';
|
import TagTermGroup from '../../../../../../shared/tags/TagTermGroup';
|
||||||
import { pathMatchesNewPath } from '../../../../../dataset/profile/schema/utils/utils';
|
import { pathMatchesNewPath } from '../../../../../dataset/profile/schema/utils/utils';
|
||||||
import { useMutationUrn, useRefetch } from '../../../../EntityContext';
|
|
||||||
import { useSchemaRefetch } from '../SchemaContext';
|
import { useSchemaRefetch } from '../SchemaContext';
|
||||||
|
import { useMutationUrn, useRefetch } from '../../../../EntityContext';
|
||||||
|
|
||||||
export default function useTagsAndTermsRenderer(
|
export default function useTagsAndTermsRenderer(
|
||||||
editableSchemaMetadata: EditableSchemaMetadata | null | undefined,
|
editableSchemaMetadata: EditableSchemaMetadata | null | undefined,
|
||||||
tagHoveredIndex: string | undefined,
|
|
||||||
setTagHoveredIndex: (index: string | undefined) => void,
|
|
||||||
options: { showTags: boolean; showTerms: boolean },
|
options: { showTags: boolean; showTerms: boolean },
|
||||||
filterText: string,
|
filterText: string,
|
||||||
|
canEdit: boolean,
|
||||||
) {
|
) {
|
||||||
const urn = useMutationUrn();
|
const urn = useMutationUrn();
|
||||||
const refetch = useRefetch();
|
const refetch = useRefetch();
|
||||||
@ -27,24 +26,21 @@ export default function useTagsAndTermsRenderer(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid={`schema-field-${record.fieldPath}-${options.showTags ? 'tags' : 'terms'}`}>
|
|
||||||
<TagTermGroup
|
<TagTermGroup
|
||||||
uneditableTags={options.showTags ? tags : null}
|
uneditableTags={options.showTags ? tags : null}
|
||||||
editableTags={options.showTags ? relevantEditableFieldInfo?.globalTags : null}
|
editableTags={options.showTags ? relevantEditableFieldInfo?.globalTags : null}
|
||||||
uneditableGlossaryTerms={options.showTerms ? record.glossaryTerms : null}
|
uneditableGlossaryTerms={options.showTerms ? record.glossaryTerms : null}
|
||||||
editableGlossaryTerms={options.showTerms ? relevantEditableFieldInfo?.glossaryTerms : null}
|
editableGlossaryTerms={options.showTerms ? relevantEditableFieldInfo?.glossaryTerms : null}
|
||||||
canRemove
|
canRemove={canEdit}
|
||||||
buttonProps={{ size: 'small' }}
|
buttonProps={{ size: 'small' }}
|
||||||
canAddTag={tagHoveredIndex === record.fieldPath && options.showTags}
|
canAddTag={canEdit && options.showTags}
|
||||||
canAddTerm={tagHoveredIndex === record.fieldPath && options.showTerms}
|
canAddTerm={canEdit && options.showTerms}
|
||||||
onOpenModal={() => setTagHoveredIndex(undefined)}
|
|
||||||
entityUrn={urn}
|
entityUrn={urn}
|
||||||
entityType={EntityType.Dataset}
|
entityType={EntityType.Dataset}
|
||||||
entitySubresource={record.fieldPath}
|
entitySubresource={record.fieldPath}
|
||||||
highlightText={filterText}
|
highlightText={filterText}
|
||||||
refetch={refresh}
|
refetch={refresh}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
return tagAndTermRender;
|
return tagAndTermRender;
|
||||||
|
@ -7,7 +7,7 @@ import { pathMatchesNewPath } from '../../../../../dataset/profile/schema/utils/
|
|||||||
|
|
||||||
const USAGE_BAR_MAX_WIDTH = 50;
|
const USAGE_BAR_MAX_WIDTH = 50;
|
||||||
|
|
||||||
const UsageBar = styled.div<{ width: number }>`
|
export const UsageBar = styled.div<{ width: number }>`
|
||||||
width: ${(props) => props.width}px;
|
width: ${(props) => props.width}px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background-color: ${geekblue[3]};
|
background-color: ${geekblue[3]};
|
||||||
|
@ -40,10 +40,11 @@ type EditorProps = {
|
|||||||
onChange?: (md: string) => void;
|
onChange?: (md: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
doNotFocus?: boolean;
|
doNotFocus?: boolean;
|
||||||
|
dataTestId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Editor = forwardRef((props: EditorProps, ref) => {
|
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({
|
const { manager, state, getContext } = useRemirror({
|
||||||
extensions: () => [
|
extensions: () => [
|
||||||
new BlockquoteExtension(),
|
new BlockquoteExtension(),
|
||||||
@ -98,7 +99,7 @@ export const Editor = forwardRef((props: EditorProps, ref) => {
|
|||||||
}, [readOnly, content]);
|
}, [readOnly, content]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorContainer className={className}>
|
<EditorContainer className={className} data-testid={dataTestId}>
|
||||||
<ThemeProvider theme={EditorTheme}>
|
<ThemeProvider theme={EditorTheme}>
|
||||||
<Remirror classNames={['ant-typography']} editable={!readOnly} manager={manager} initialContent={state}>
|
<Remirror classNames={['ant-typography']} editable={!readOnly} manager={manager} initialContent={state}>
|
||||||
{!readOnly && (
|
{!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 styled from 'styled-components';
|
||||||
|
import React, { useState } from 'react';
|
||||||
import { ANTD_GRAY } from '../../constants';
|
import ExpandIcon from '../Dataset/Schema/components/ExpandIcon';
|
||||||
import { StyledTable } from '../../components/styled/StyledTable';
|
import { StyledTable as Table } from '../../components/styled/StyledTable';
|
||||||
import { useEntityData } from '../../EntityContext';
|
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)`
|
const StyledTable = styled(Table)`
|
||||||
font-family: 'Roboto Mono', monospace;
|
&&& .ant-table-cell-with-append {
|
||||||
font-weight: 600;
|
padding: 4px;
|
||||||
font-size: 12px;
|
}
|
||||||
color: ${ANTD_GRAY[9]};
|
` as typeof Table;
|
||||||
`;
|
|
||||||
|
|
||||||
const ValueText = styled(Typography.Text)`
|
|
||||||
font-family: 'Roboto Mono', monospace;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 12px;
|
|
||||||
color: ${ANTD_GRAY[8]};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const PropertiesTab = () => {
|
export const PropertiesTab = () => {
|
||||||
|
const [filterText, setFilterText] = useState('');
|
||||||
const { entityData } = useEntityData();
|
const { entityData } = useEntityData();
|
||||||
|
const entityRegistry = useEntityRegistry();
|
||||||
|
|
||||||
const propertyTableColumns = [
|
const propertyTableColumns = [
|
||||||
{
|
{
|
||||||
width: 210,
|
width: '40%',
|
||||||
title: 'Name',
|
title: 'Name',
|
||||||
dataIndex: 'key',
|
|
||||||
sorter: (a, b) => a?.key.localeCompare(b?.key || '') || 0,
|
|
||||||
defaultSortOrder: 'ascend',
|
defaultSortOrder: 'ascend',
|
||||||
render: (name: string) => <NameText>{name}</NameText>,
|
render: (propertyRow: PropertyRow) => <NameColumn propertyRow={propertyRow} filterText={filterText} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Value',
|
title: 'Value',
|
||||||
dataIndex: 'value',
|
render: (propertyRow: PropertyRow) => <ValuesColumn propertyRow={propertyRow} filterText={filterText} />,
|
||||||
render: (value: string) => <ValueText>{value}</ValueText>,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
|
<>
|
||||||
|
<TabHeader setFilterText={setFilterText} />
|
||||||
<StyledTable
|
<StyledTable
|
||||||
pagination={false}
|
pagination={false}
|
||||||
// typescript is complaining that default sort order is not a valid column field- overriding this here
|
// 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
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
columns={propertyTableColumns}
|
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,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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,
|
BrowsePathV2,
|
||||||
DataJobInputOutput,
|
DataJobInputOutput,
|
||||||
ParentDomainsResult,
|
ParentDomainsResult,
|
||||||
|
StructuredProperties,
|
||||||
} from '../../../types.generated';
|
} from '../../../types.generated';
|
||||||
import { FetchedEntity } from '../../lineage/types';
|
import { FetchedEntity } from '../../lineage/types';
|
||||||
|
|
||||||
@ -84,6 +85,7 @@ export type GenericEntityProperties = {
|
|||||||
platform?: Maybe<DataPlatform>;
|
platform?: Maybe<DataPlatform>;
|
||||||
dataPlatformInstance?: Maybe<DataPlatformInstance>;
|
dataPlatformInstance?: Maybe<DataPlatformInstance>;
|
||||||
customProperties?: Maybe<CustomPropertiesEntry[]>;
|
customProperties?: Maybe<CustomPropertiesEntry[]>;
|
||||||
|
structuredProperties?: Maybe<StructuredProperties>;
|
||||||
institutionalMemory?: Maybe<InstitutionalMemory>;
|
institutionalMemory?: Maybe<InstitutionalMemory>;
|
||||||
schemaMetadata?: Maybe<SchemaMetadata>;
|
schemaMetadata?: Maybe<SchemaMetadata>;
|
||||||
externalUrl?: Maybe<string>;
|
externalUrl?: Maybe<string>;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Maybe } from 'graphql/jsutils/Maybe';
|
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 { capitalizeFirstLetterOnly } from '../../shared/textUtil';
|
||||||
import { GenericEntityProperties } from './types';
|
import { GenericEntityProperties } from './types';
|
||||||
|
|
||||||
@ -130,3 +130,13 @@ export function getDataProduct(dataProductResult: Maybe<EntityRelationshipsResul
|
|||||||
}
|
}
|
||||||
return null;
|
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
|
actor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
structuredProperties {
|
||||||
|
properties {
|
||||||
|
...structuredPropertiesFields
|
||||||
|
}
|
||||||
|
}
|
||||||
editableProperties {
|
editableProperties {
|
||||||
description
|
description
|
||||||
}
|
}
|
||||||
@ -709,6 +714,15 @@ fragment schemaFieldFields on SchemaField {
|
|||||||
glossaryTerms {
|
glossaryTerms {
|
||||||
...glossaryTerms
|
...glossaryTerms
|
||||||
}
|
}
|
||||||
|
schemaFieldEntity {
|
||||||
|
urn
|
||||||
|
type
|
||||||
|
structuredProperties {
|
||||||
|
properties {
|
||||||
|
...structuredPropertiesFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment schemaMetadataFields on SchemaMetadata {
|
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 {
|
fragment autoRenderAspectFields on RawAspect {
|
||||||
aspectName
|
aspectName
|
||||||
payload
|
payload
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('logs in', () => {
|
it('logs in', () => {
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
cy.get('input[data-testid=username]').type(Cypress.env('ADMIN_USERNAME'));
|
cy.get('input[data-testid=username]').type('datahub');
|
||||||
cy.get('input[data-testid=password]').type(Cypress.env('ADMIN_PASSWORD'));
|
cy.get('input[data-testid=password]').type('datahub');
|
||||||
cy.contains('Sign In').click();
|
cy.contains('Sign In').click();
|
||||||
cy.contains('Welcome back, ' + Cypress.env('ADMIN_DISPLAYNAME'));
|
cy.contains('Welcome back, ' + Cypress.env('ADMIN_DISPLAYNAME'));
|
||||||
});
|
});
|
||||||
|
@ -78,17 +78,18 @@ describe("edit documentation and link to dataset", () => {
|
|||||||
cy.visit(
|
cy.visit(
|
||||||
"/dataset/urn:li:dataset:(urn:li:dataPlatform:hive,SampleCypressHiveDataset,PROD)/Schema"
|
"/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("Update description");
|
||||||
cy.waitTextVisible("Foo field description has changed");
|
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.focused().type(documentation_edited);
|
||||||
cy.clickOptionWithTestId("description-modal-update-button");
|
cy.clickOptionWithTestId("description-modal-update-button");
|
||||||
cy.waitTextVisible("Updated!");
|
cy.waitTextVisible("Updated!");
|
||||||
cy.waitTextVisible(documentation_edited);
|
cy.waitTextVisible(documentation_edited);
|
||||||
cy.waitTextVisible("(edited)");
|
cy.waitTextVisible("(edited)");
|
||||||
cy.get("tbody [data-icon='edit']").first().click({ force: true });
|
cy.clickOptionWithTestId("edit-field-description");
|
||||||
cy.focused().clear().wait(1000);
|
cy.getWithTestId("description-editor").clear().wait(1000);
|
||||||
cy.focused().type("Foo field description has changed");
|
cy.focused().type("Foo field description has changed");
|
||||||
cy.clickOptionWithTestId("description-modal-update-button");
|
cy.clickOptionWithTestId("description-modal-update-button");
|
||||||
cy.waitTextVisible("Updated!");
|
cy.waitTextVisible("Updated!");
|
||||||
|
@ -77,7 +77,7 @@ describe("mutations", () => {
|
|||||||
cy.login();
|
cy.login();
|
||||||
cy.viewport(2000, 800);
|
cy.viewport(2000, 800);
|
||||||
cy.goToDataset("urn:li:dataset:(urn:li:dataPlatform:hive,cypress_logging_events,PROD)", "cypress_logging_events");
|
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.get('[data-testid="schema-field-event_name-tags"]').within(() =>
|
||||||
cy.contains("Add Tag").click()
|
cy.contains("Add Tag").click()
|
||||||
);
|
);
|
||||||
@ -116,7 +116,8 @@ describe("mutations", () => {
|
|||||||
// verify dataset shows up in search now
|
// verify dataset shows up in search now
|
||||||
cy.contains("of 1 result").click();
|
cy.contains("of 1 result").click();
|
||||||
cy.contains("cypress_logging_events").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
|
cy
|
||||||
.get("span[aria-label=close]")
|
.get("span[aria-label=close]")
|
||||||
.trigger("mouseover", { force: true })
|
.trigger("mouseover", { force: true })
|
||||||
@ -134,10 +135,7 @@ describe("mutations", () => {
|
|||||||
// make space for the glossary term column
|
// make space for the glossary term column
|
||||||
cy.viewport(2000, 800);
|
cy.viewport(2000, 800);
|
||||||
cy.goToDataset("urn:li:dataset:(urn:li:dataPlatform:hive,cypress_logging_events,PROD)", "cypress_logging_events");
|
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(
|
cy.clickOptionWithText("event_name");
|
||||||
"mouseover",
|
|
||||||
{ force: true }
|
|
||||||
);
|
|
||||||
cy.get('[data-testid="schema-field-event_name-terms"]').within(() =>
|
cy.get('[data-testid="schema-field-event_name-terms"]').within(() =>
|
||||||
cy.contains("Add Term").click({ force: true })
|
cy.contains("Add Term").click({ force: true })
|
||||||
);
|
);
|
||||||
@ -146,9 +144,12 @@ describe("mutations", () => {
|
|||||||
|
|
||||||
cy.contains("CypressTerm");
|
cy.contains("CypressTerm");
|
||||||
|
|
||||||
cy.get(
|
cy.get('[data-testid="schema-field-event_name-terms"]').within(() =>
|
||||||
'a[href="/glossaryTerm/urn:li:glossaryTerm:CypressNode.CypressTerm"]'
|
cy
|
||||||
).within(() => cy.get("span[aria-label=close]").click({ force: true }));
|
.get("span[aria-label=close]")
|
||||||
|
.trigger("mouseover", { force: true })
|
||||||
|
.click({ force: true })
|
||||||
|
);
|
||||||
cy.contains("Yes").click({ force: true });
|
cy.contains("Yes").click({ force: true });
|
||||||
|
|
||||||
cy.contains("CypressTerm").should("not.exist");
|
cy.contains("CypressTerm").should("not.exist");
|
||||||
|
@ -14,6 +14,7 @@ describe('schema blame', () => {
|
|||||||
cy.contains('field_bar').should('not.exist');
|
cy.contains('field_bar').should('not.exist');
|
||||||
cy.contains('Foo field description has changed');
|
cy.contains('Foo field description has changed');
|
||||||
cy.contains('Baz field description');
|
cy.contains('Baz field description');
|
||||||
|
cy.clickOptionWithText("field_foo");
|
||||||
cy.get('[data-testid="schema-field-field_foo-tags"]').contains('Legacy');
|
cy.get('[data-testid="schema-field-field_foo-tags"]').contains('Legacy');
|
||||||
|
|
||||||
// Make sure the schema blame is accurate
|
// Make sure the schema blame is accurate
|
||||||
@ -41,6 +42,7 @@ describe('schema blame', () => {
|
|||||||
cy.contains('field_baz').should('not.exist');
|
cy.contains('field_baz').should('not.exist');
|
||||||
cy.contains('Foo field description');
|
cy.contains('Foo field description');
|
||||||
cy.contains('Bar 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');
|
cy.get('[data-testid="schema-field-field_foo-tags"]').contains('Legacy').should('not.exist');
|
||||||
|
|
||||||
// Make sure the schema blame is accurate
|
// Make sure the schema blame is accurate
|
||||||
|
@ -218,6 +218,10 @@ Cypress.Commands.add( 'multiSelect', (within_data_id , text) => {
|
|||||||
cy.clickOptionWithText(text);
|
cy.clickOptionWithText(text);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add("getWithTestId", (id) => {
|
||||||
|
return cy.get(selectorWithtestId(id));
|
||||||
|
});
|
||||||
|
|
||||||
Cypress.Commands.add("enterTextInTestId", (id, text) => {
|
Cypress.Commands.add("enterTextInTestId", (id, text) => {
|
||||||
cy.get(selectorWithtestId(id)).type(text);
|
cy.get(selectorWithtestId(id)).type(text);
|
||||||
})
|
})
|
||||||
|
Loading…
x
Reference in New Issue
Block a user