From c390ad873fec599209dcfffda304fc09a7c20001 Mon Sep 17 00:00:00 2001 From: Danyel Fisher Date: Mon, 23 Mar 2026 17:16:45 -0700 Subject: [PATCH 1/6] feat(lineage): add materialization badge for model nodes Show the materialization strategy (table, view, incremental, ephemeral, materialized_view) on model nodes instead of the generic resource type icon. Each materialization type gets a distinct icon from the cube family: - table: solid cube (reuses existing model icon) - view: eye icon - incremental: 2/3 solid + 1/3 dashed cube - ephemeral: fully dashed cube - materialized_view: solid cube with small eye overlay The badge appears in both the canvas graph nodes and the sidebar detail view. Non-model nodes continue to show the resource type tag. Also adds ResourceTypeTag stories and MaterializationTag stories, and updates LineageCanvas/NodeView fixtures with realistic materialization data across all dbt layers (staging=view, intermediate=ephemeral, fact=incremental, dimension=table, mart=materialized_view). Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Danyel Fisher --- .../lineage/MaterializationTag.stories.tsx | 89 ++++++++++ .../stories/lineage/NodeView.stories.tsx | 74 ++++++++- .../lineage/ResourceTypeTag.stories.tsx | 102 ++++++++++++ .../storybook/stories/lineage/fixtures.ts | 80 +++++++-- js/packages/ui/src/api/info.ts | 3 + .../src/components/lineage/GraphNodeOss.tsx | 3 + .../ui/src/components/lineage/NodeView.tsx | 2 + .../ui/src/components/lineage/NodeViewOss.tsx | 21 ++- .../components/lineage/nodes/LineageNode.tsx | 14 +- .../ui/src/components/lineage/styles.tsx | 152 ++++++++++++++++++ .../lineage/tags/MaterializationTag.tsx | 100 ++++++++++++ .../ui/src/components/lineage/tags/index.ts | 5 + js/packages/ui/src/primitives.ts | 20 +++ 13 files changed, 647 insertions(+), 18 deletions(-) create mode 100644 js/packages/storybook/stories/lineage/MaterializationTag.stories.tsx create mode 100644 js/packages/storybook/stories/lineage/ResourceTypeTag.stories.tsx create mode 100644 js/packages/ui/src/components/lineage/tags/MaterializationTag.tsx diff --git a/js/packages/storybook/stories/lineage/MaterializationTag.stories.tsx b/js/packages/storybook/stories/lineage/MaterializationTag.stories.tsx new file mode 100644 index 000000000..cf28d6fad --- /dev/null +++ b/js/packages/storybook/stories/lineage/MaterializationTag.stories.tsx @@ -0,0 +1,89 @@ +import { MaterializationTag } from "@datarecce/ui/primitives"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta: Meta = { + title: "Lineage/MaterializationTag", + component: MaterializationTag, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: + "Displays the dbt materialization strategy as a pill-shaped tag with an icon. Used on model nodes to indicate whether a model is materialized as a table, view, incremental, ephemeral, or materialized view.", + }, + }, + layout: "centered", + }, +}; + +export default meta; +type Story = StoryObj; + +// ============================================ +// Individual Materialization Types +// ============================================ + +export const Table: Story = { + args: { data: { materialized: "table" } }, +}; + +export const View: Story = { + args: { data: { materialized: "view" } }, +}; + +export const Incremental: Story = { + args: { data: { materialized: "incremental" } }, +}; + +export const Ephemeral: Story = { + args: { data: { materialized: "ephemeral" } }, +}; + +export const MaterializedView: Story = { + name: "Materialized View", + args: { data: { materialized: "materialized_view" } }, +}; + +// ============================================ +// Edge Cases +// ============================================ + +export const Unknown: Story = { + name: "Unknown Type", + args: { data: { materialized: "custom_materialization" } }, +}; + +export const Undefined: Story = { + name: "Undefined", + args: { data: {} }, +}; + +// ============================================ +// All Types Overview +// ============================================ + +export const AllTypes: Story = { + name: "All Materialization Types", + parameters: { + docs: { + description: { + story: + "Side-by-side comparison of all supported materialization types.", + }, + }, + }, + render: () => ( +
+ {["table", "view", "incremental", "ephemeral", "materialized_view"].map( + (type) => ( +
+ +
+ ), + )} +
+ ), +}; diff --git a/js/packages/storybook/stories/lineage/NodeView.stories.tsx b/js/packages/storybook/stories/lineage/NodeView.stories.tsx index c1849e62f..50cf69494 100644 --- a/js/packages/storybook/stories/lineage/NodeView.stories.tsx +++ b/js/packages/storybook/stories/lineage/NodeView.stories.tsx @@ -1,5 +1,10 @@ import type { NodeViewNodeData, NodeViewProps } from "@datarecce/ui/advanced"; import { NodeView } from "@datarecce/ui/advanced"; +import type { LineageGraphNode } from "@datarecce/ui/contexts"; +import { + MaterializationTag as MaterializationTagBase, + ResourceTypeTag as ResourceTypeTagBase, +} from "@datarecce/ui/primitives"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -49,6 +54,24 @@ function StubNodeSqlView({ node }: { node: NodeViewNodeData }) { ); } +/** + * Tag component that shows MaterializationTag for models, ResourceTypeTag otherwise. + * Mirrors the logic in NodeViewOss.tsx. + */ +function ResourceTypeTag({ node }: { node: LineageGraphNode }) { + const materialized = + node.data.data.current?.config?.materialized ?? + node.data.data.base?.config?.materialized; + + if (node.data.resourceType === "model" && materialized) { + return ; + } + + return ( + + ); +} + // ============================================================================= // FIXTURE FACTORIES // ============================================================================= @@ -62,8 +85,13 @@ function createNode( name?: string; resourceType?: string; changeStatus?: string; + materialized?: string; } = {}, ): NodeViewNodeData { + const config = overrides.materialized + ? { materialized: overrides.materialized } + : undefined; + return { id: "model.jaffle_shop.stg_orders", data: { @@ -79,6 +107,7 @@ function createNode( customer_id: { name: "customer_id", type: "integer" }, order_date: { name: "order_date", type: "date" }, }, + config, }, current: { name: "stg_orders", @@ -88,6 +117,7 @@ function createNode( customer_id: { name: "customer_id", type: "integer" }, order_date: { name: "order_date", type: "date" }, }, + config, }, }, }, @@ -122,6 +152,7 @@ const meta: Meta = { isSingleEnv: false, SchemaView: StubSchemaView, NodeSqlView: StubNodeSqlView, + ResourceTypeTag, }, }; @@ -135,7 +166,7 @@ type Story = StoryObj; /** No differences in schema or code — no dots shown on either tab. */ export const NoDifferences: Story = { args: { - node: createNode(), + node: createNode({ materialized: "view" }), }, }; @@ -204,6 +235,7 @@ export const SingleEnvMode: Story = { args: { isSingleEnv: true, node: createNode({ + materialized: "table", baseCode: "SELECT 1", currentCode: "SELECT 2", baseColumns: { @@ -216,3 +248,43 @@ export const SingleEnvMode: Story = { }), }, }; + +// ============================================================================= +// MATERIALIZATION TAG STORIES +// ============================================================================= + +/** Model materialized as incremental — shows incremental icon in tag row. */ +export const IncrementalModel: Story = { + args: { + node: createNode({ materialized: "incremental" }), + }, +}; + +/** Model materialized as table — shows solid cube icon in tag row. */ +export const TableModel: Story = { + args: { + node: createNode({ materialized: "table" }), + }, +}; + +/** Model materialized as ephemeral — shows dashed cube icon in tag row. */ +export const EphemeralModel: Story = { + args: { + node: createNode({ materialized: "ephemeral" }), + }, +}; + +/** Model materialized as materialized_view — shows cube+eye icon in tag row. */ +export const MaterializedViewModel: Story = { + name: "Materialized View Model", + args: { + node: createNode({ materialized: "materialized_view" }), + }, +}; + +/** Source node — shows resource type tag (no materialization). */ +export const SourceNode: Story = { + args: { + node: createNode({ resourceType: "source", name: "raw_orders" }), + }, +}; diff --git a/js/packages/storybook/stories/lineage/ResourceTypeTag.stories.tsx b/js/packages/storybook/stories/lineage/ResourceTypeTag.stories.tsx new file mode 100644 index 000000000..a23477ac1 --- /dev/null +++ b/js/packages/storybook/stories/lineage/ResourceTypeTag.stories.tsx @@ -0,0 +1,102 @@ +import { ResourceTypeTag } from "@datarecce/ui/primitives"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta: Meta = { + title: "Lineage/ResourceTypeTag", + component: ResourceTypeTag, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: + "Displays the dbt resource type as a pill-shaped tag with an icon. Used in lineage graph nodes to indicate whether a node is a model, source, seed, snapshot, etc.", + }, + }, + layout: "centered", + }, +}; + +export default meta; +type Story = StoryObj; + +// ============================================ +// Individual Resource Types +// ============================================ + +export const Model: Story = { + args: { data: { resourceType: "model" } }, +}; + +export const Source: Story = { + args: { data: { resourceType: "source" } }, +}; + +export const Seed: Story = { + args: { data: { resourceType: "seed" } }, +}; + +export const Snapshot: Story = { + args: { data: { resourceType: "snapshot" } }, +}; + +export const Metric: Story = { + args: { data: { resourceType: "metric" } }, +}; + +export const Exposure: Story = { + args: { data: { resourceType: "exposure" } }, +}; + +export const SemanticModel: Story = { + name: "Semantic Model", + args: { data: { resourceType: "semantic_model" } }, +}; + +// ============================================ +// Edge Cases +// ============================================ + +export const Unknown: Story = { + name: "Unknown Type", + args: { data: { resourceType: "unknown_type" } }, +}; + +export const Undefined: Story = { + name: "Undefined", + args: { data: {} }, +}; + +// ============================================ +// All Types Overview +// ============================================ + +export const AllTypes: Story = { + name: "All Resource Types", + parameters: { + docs: { + description: { + story: "Side-by-side comparison of all supported resource types.", + }, + }, + }, + render: () => ( +
+ {[ + "model", + "source", + "seed", + "snapshot", + "metric", + "exposure", + "semantic_model", + ].map((type) => ( +
+ +
+ ))} +
+ ), +}; diff --git a/js/packages/storybook/stories/lineage/fixtures.ts b/js/packages/storybook/stories/lineage/fixtures.ts index ef0a74d40..338a02fc5 100644 --- a/js/packages/storybook/stories/lineage/fixtures.ts +++ b/js/packages/storybook/stories/lineage/fixtures.ts @@ -27,6 +27,7 @@ export interface LineageNodeData extends Record { changeStatus?: NodeChangeStatus; isSelected?: boolean; resourceType?: string; + materialized?: string; packageName?: string; showColumns?: boolean; columns?: Array<{ @@ -51,6 +52,7 @@ interface CreateNodeOptions { position: { x: number; y: number }; changeStatus?: NodeChangeStatus; resourceType?: string; + materialized?: string; showColumns?: boolean; columnCount?: number; data?: Partial; @@ -79,6 +81,7 @@ export function createNode({ position, changeStatus = "unchanged", resourceType = "model", + materialized, showColumns = false, columnCount = 0, data = {}, @@ -92,6 +95,7 @@ export function createNode({ nodeType: resourceType, changeStatus, resourceType, + materialized, showColumns, ...data, }, @@ -140,18 +144,21 @@ export function createDiamondNodes(): Node[] { label: "Model A", position: { x: 400, y: 150 }, changeStatus: "modified", + materialized: "view", }), createNode({ id: "model_b", label: "Model B", position: { x: 400, y: 350 }, changeStatus: "added", + materialized: "incremental", }), createNode({ id: "final", label: "Final Model", position: { x: 800, y: 250 }, changeStatus: "unchanged", + materialized: "table", }), ]; } @@ -278,14 +285,50 @@ export function largeGraph() { "unchanged", ]; - // Layer configuration: x position, node count, prefix + // Layer configuration: x position, node count, prefix, materialization const layers = [ - { x: 0, count: 8, prefix: "src", resourceType: "source" }, - { x: 400, count: 12, prefix: "stg", resourceType: "model" }, - { x: 800, count: 15, prefix: "int", resourceType: "model" }, - { x: 1200, count: 18, prefix: "fct", resourceType: "model" }, - { x: 1600, count: 12, prefix: "dim", resourceType: "model" }, - { x: 2000, count: 5, prefix: "mart", resourceType: "model" }, + { + x: 0, + count: 8, + prefix: "src", + resourceType: "source", + materialized: undefined, + }, + { + x: 400, + count: 12, + prefix: "stg", + resourceType: "model", + materialized: "view" as const, + }, + { + x: 800, + count: 15, + prefix: "int", + resourceType: "model", + materialized: "ephemeral" as const, + }, + { + x: 1200, + count: 18, + prefix: "fct", + resourceType: "model", + materialized: "incremental" as const, + }, + { + x: 1600, + count: 12, + prefix: "dim", + resourceType: "model", + materialized: "table" as const, + }, + { + x: 2000, + count: 5, + prefix: "mart", + resourceType: "model", + materialized: "materialized_view" as const, + }, ]; let nodeIndex = 0; @@ -304,6 +347,7 @@ export function largeGraph() { position: { x: layer.x, y: startY + i * verticalSpacing }, changeStatus: statuses[nodeIndex % statuses.length], resourceType: layer.resourceType, + materialized: layer.materialized, }), ); nodeIndex++; @@ -376,12 +420,15 @@ function createLineageGraphNode( resourceType: string, columns: ColumnDef[], changeStatus?: "added" | "removed" | "modified", + materialized?: string, ): LineageGraphNode { const columnData: Record = {}; for (const col of columns) { columnData[col.name] = { name: col.name, type: col.type }; } + const config = materialized ? { materialized } : undefined; + return { id, type: "lineageGraphNode", @@ -401,6 +448,7 @@ function createLineageGraphNode( resource_type: resourceType, package_name: "demo", columns: columnData, + config, }, current: { id, @@ -409,6 +457,7 @@ function createLineageGraphNode( resource_type: resourceType, package_name: "demo", columns: columnData, + config, }, }, parents: {}, @@ -456,7 +505,7 @@ export function createCllLineageGraph(): LineageGraph { ], ), - // Staging + // Staging (views — lightweight transformations) "model.demo.stg_users": createLineageGraphNode( "model.demo.stg_users", "stg_users", @@ -477,6 +526,7 @@ export function createCllLineageGraph(): LineageGraph { }, ], "modified", + "view", ), "model.demo.stg_orders": createLineageGraphNode( "model.demo.stg_orders", @@ -505,9 +555,11 @@ export function createCllLineageGraph(): LineageGraph { transformationType: "passthrough", }, ], + undefined, + "view", ), - // Dimension + // Dimension (table — pre-built for fast joins) "model.demo.dim_users": createLineageGraphNode( "model.demo.dim_users", "dim_users", @@ -528,9 +580,11 @@ export function createCllLineageGraph(): LineageGraph { changeStatus: "added", }, ], + undefined, + "table", ), - // Fact + // Fact (incremental — append new orders) "model.demo.fct_orders": createLineageGraphNode( "model.demo.fct_orders", "fct_orders", @@ -559,9 +613,11 @@ export function createCllLineageGraph(): LineageGraph { transformationType: "passthrough", }, ], + undefined, + "incremental", ), - // Mart + // Mart (materialized view — refreshed by the warehouse) "model.demo.mart_customer_orders": createLineageGraphNode( "model.demo.mart_customer_orders", "mart_customer_orders", @@ -594,6 +650,8 @@ export function createCllLineageGraph(): LineageGraph { transformationType: "derived", }, ], + undefined, + "materialized_view", ), }; diff --git a/js/packages/ui/src/api/info.ts b/js/packages/ui/src/api/info.ts index 1df602e72..55894eedd 100644 --- a/js/packages/ui/src/api/info.ts +++ b/js/packages/ui/src/api/info.ts @@ -29,6 +29,9 @@ export interface NodeData { package_name?: string; columns?: Record; primary_key?: string; + config?: { + materialized?: string; + }; } /** diff --git a/js/packages/ui/src/components/lineage/GraphNodeOss.tsx b/js/packages/ui/src/components/lineage/GraphNodeOss.tsx index b9e4f4aea..e52d85189 100644 --- a/js/packages/ui/src/components/lineage/GraphNodeOss.tsx +++ b/js/packages/ui/src/components/lineage/GraphNodeOss.tsx @@ -362,6 +362,9 @@ function GraphNodeComponent(nodeProps: GraphNodeProps) { label: name, changeStatus: nodeChangeStatus, resourceType, + materialized: + data.data?.current?.config?.materialized ?? + data.data?.base?.config?.materialized, }} // Interactive props interactive={interactive} diff --git a/js/packages/ui/src/components/lineage/NodeView.tsx b/js/packages/ui/src/components/lineage/NodeView.tsx index c7e72c7c1..097b604c7 100644 --- a/js/packages/ui/src/components/lineage/NodeView.tsx +++ b/js/packages/ui/src/components/lineage/NodeView.tsx @@ -41,11 +41,13 @@ export interface NodeViewNodeData { raw_code?: string; name?: string; columns?: Record; + config?: { materialized?: string }; }; current?: { raw_code?: string; name?: string; columns?: Record; + config?: { materialized?: string }; }; }; change?: { diff --git a/js/packages/ui/src/components/lineage/NodeViewOss.tsx b/js/packages/ui/src/components/lineage/NodeViewOss.tsx index 6150fb218..767cb64f8 100644 --- a/js/packages/ui/src/components/lineage/NodeViewOss.tsx +++ b/js/packages/ui/src/components/lineage/NodeViewOss.tsx @@ -46,7 +46,10 @@ import { type RunTypeIconMap, } from "./NodeView"; import { SandboxViewOss } from "./SandboxViewOss"; -import { ResourceTypeTag as ResourceTypeTagBase } from "./tags"; +import { + MaterializationTag as MaterializationTagBase, + ResourceTypeTag as ResourceTypeTagBase, +} from "./tags"; // ============================================================================= // TYPES @@ -57,9 +60,19 @@ interface NodeViewProps { onCloseNode: () => void; } -const ResourceTypeTag = ({ node }: { node: LineageGraphNode }) => ( - -); +const ResourceTypeTag = ({ node }: { node: LineageGraphNode }) => { + const materialized = + node.data.data.current?.config?.materialized ?? + node.data.data.base?.config?.materialized; + + if (node.data.resourceType === "model" && materialized) { + return ; + } + + return ( + + ); +}; // ============================================================================= // OSS-SPECIFIC WRAPPER COMPONENTS diff --git a/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx b/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx index fdb8eaa52..6761876b9 100644 --- a/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx +++ b/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx @@ -26,7 +26,11 @@ import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { Handle, Position } from "@xyflow/react"; import { type MouseEvent, memo, type ReactNode, useState } from "react"; -import { getIconForChangeStatus, getIconForResourceType } from "../styles"; +import { + getIconForChangeStatus, + getIconForMaterialization, + getIconForResourceType, +} from "../styles"; // ============================================================================= // TYPES @@ -65,6 +69,8 @@ export interface LineageNodeData extends Record { isSelected?: boolean; /** Resource type for icon display */ resourceType?: string; + /** Materialization strategy (table, view, incremental, etc.) */ + materialized?: string; /** Package name */ packageName?: string; /** Whether to show column-level details */ @@ -310,6 +316,7 @@ function LineageNodeComponent({ changeStatus = "unchanged", isSelected: dataIsSelected, resourceType, + materialized, } = data; // Use isNodeSelected prop, fall back to data.isSelected, then to selected @@ -323,7 +330,10 @@ function LineageNodeComponent({ color: colorChangeStatus, backgroundColor: backgroundColorChangeStatus, } = getIconForChangeStatus(changeStatus, isDark); - const { icon: ResourceIcon } = getIconForResourceType(resourceType); + const { icon: ResourceIcon } = + resourceType === "model" && materialized + ? getIconForMaterialization(materialized) + : getIconForResourceType(resourceType); // Calculate styles based on state const borderWidth = "2px"; diff --git a/js/packages/ui/src/components/lineage/styles.tsx b/js/packages/ui/src/components/lineage/styles.tsx index f286421cb..a26b78d6d 100644 --- a/js/packages/ui/src/components/lineage/styles.tsx +++ b/js/packages/ui/src/components/lineage/styles.tsx @@ -315,6 +315,117 @@ export const IconSemanticModel: IconComponent = (props) => ( ); +// ============================================================================= +// MATERIALIZATION ICONS +// ============================================================================= + +/** + * Eye icon for "view" materialization type + */ +export const IconViewMat: IconComponent = (props) => ( + + + +); + +/** + * Cube icon with dashed top portion for "incremental" materialization type + * Bottom 2/3 is solid fill, top 1/3 is dashed strokes + */ +export const IconIncremental: IconComponent = (props) => ( + + + + + + + + + + {/* Bottom 2/3: solid fill */} + + {/* Top 1/3: dashed strokes */} + + +); + +/** + * Fully dashed cube icon for "ephemeral" materialization type + */ +export const IconEphemeral: IconComponent = (props) => ( + + + +); + +/** + * Solid cube with small eye overlay for "materialized_view" materialization type + */ +export const IconMaterializedView: IconComponent = (props) => ( + + {/* Scaled-down cube at ~75% */} + + + + {/* Eye icon overlay in bottom-right (~50% size) */} + + + + +); + // ============================================================================= // STYLING FUNCTIONS // ============================================================================= @@ -435,6 +546,47 @@ export function getIconForResourceType( } } +/** + * Materialization types supported by dbt + */ +export type MaterializationType = + | "table" + | "view" + | "incremental" + | "ephemeral" + | "materialized_view"; + +/** + * Get icon and color for a materialization type + * + * @param materialization - The materialization type (table, view, incremental, etc.) + * @returns Object containing color and icon component + * + * @example + * ```tsx + * const { color, icon: Icon } = getIconForMaterialization("view"); + * return Icon ? : null; + * ``` + */ +export function getIconForMaterialization( + materialization?: string, +): ResourceTypeStyle { + switch (materialization) { + case "table": + return { color: colors.cyan[200], icon: IconModel }; + case "view": + return { color: colors.fuchsia[300], icon: IconViewMat }; + case "incremental": + return { color: colors.iochmara[300], icon: IconIncremental }; + case "ephemeral": + return { color: colors.neutral[400], icon: IconEphemeral }; + case "materialized_view": + return { color: colors.fuchsia[200], icon: IconMaterializedView }; + default: + return { color: "inherit", icon: undefined }; + } +} + // ============================================================================= // STYLE CONSTANTS // ============================================================================= diff --git a/js/packages/ui/src/components/lineage/tags/MaterializationTag.tsx b/js/packages/ui/src/components/lineage/tags/MaterializationTag.tsx new file mode 100644 index 000000000..9fbf05e34 --- /dev/null +++ b/js/packages/ui/src/components/lineage/tags/MaterializationTag.tsx @@ -0,0 +1,100 @@ +"use client"; + +/** + * @file MaterializationTag.tsx + * @description Pure presentation component for displaying materialization strategy with icon + * + * Shows the materialization type (table, view, incremental, etc.) as a tag with an icon. + * Uses theme-aware styling for light/dark mode support. + */ + +import Box from "@mui/material/Box"; +import Tooltip from "@mui/material/Tooltip"; +import { memo } from "react"; +import { useIsDark } from "../../../hooks/useIsDark"; +import { getIconForMaterialization } from "../styles"; +import { getTagRootSx, tagStartElementSx } from "./tagStyles"; + +// ============================================================================= +// TYPES +// ============================================================================= + +/** + * Data required for MaterializationTag + */ +export interface MaterializationTagData { + /** The materialization strategy (table, view, incremental, ephemeral, etc.) */ + materialized?: string; +} + +/** + * Props for MaterializationTag component + */ +export interface MaterializationTagProps { + /** Node data containing the materialization strategy */ + data: MaterializationTagData; + /** Test ID for testing */ + "data-testid"?: string; +} + +// ============================================================================= +// HELPERS +// ============================================================================= + +const displayLabels: Record = { + table: "table", + view: "view", + incremental: "incremental", + ephemeral: "ephemeral", + materialized_view: "mat. view", +}; + +function getDisplayLabel(materialized?: string): string | undefined { + if (!materialized) return undefined; + return displayLabels[materialized] ?? materialized; +} + +// ============================================================================= +// COMPONENT +// ============================================================================= + +/** + * MaterializationTag - Displays the materialization strategy with an icon + * + * Shows a pill-shaped tag with an icon representing the materialization strategy + * (table, view, incremental, ephemeral, etc.). + * + * @example + * ```tsx + * // Basic usage + * + * + * // With incremental type + * + * ``` + */ +function MaterializationTagComponent({ + data, + "data-testid": testId, +}: MaterializationTagProps) { + const isDark = useIsDark(); + const { icon: MaterializationIcon } = getIconForMaterialization( + data.materialized, + ); + + return ( + + + {MaterializationIcon && ( + + + + )} + {getDisplayLabel(data.materialized)} + + + ); +} + +export const MaterializationTag = memo(MaterializationTagComponent); +MaterializationTag.displayName = "MaterializationTag"; diff --git a/js/packages/ui/src/components/lineage/tags/index.ts b/js/packages/ui/src/components/lineage/tags/index.ts index 78d0caef8..df5de5bce 100644 --- a/js/packages/ui/src/components/lineage/tags/index.ts +++ b/js/packages/ui/src/components/lineage/tags/index.ts @@ -6,6 +6,11 @@ * to show metadata like resource type, row counts, etc. */ +export { + MaterializationTag, + type MaterializationTagData, + type MaterializationTagProps, +} from "./MaterializationTag"; export { ResourceTypeTag, type ResourceTypeTagData, diff --git a/js/packages/ui/src/primitives.ts b/js/packages/ui/src/primitives.ts index e1bd4f9c6..244dd3953 100644 --- a/js/packages/ui/src/primitives.ts +++ b/js/packages/ui/src/primitives.ts @@ -64,6 +64,26 @@ export { type LineageNodeProps, type NodeChangeStatus, } from "./components/lineage/nodes"; +/** + * Resource type tag for lineage nodes. + * + * @remarks + * Exports: ResourceTypeTag, ResourceTypeTagData, ResourceTypeTagProps. + */ +/** + * Materialization tag for lineage nodes. + * + * @remarks + * Exports: MaterializationTag, MaterializationTagData, MaterializationTagProps. + */ +export { + MaterializationTag, + type MaterializationTagData, + type MaterializationTagProps, + ResourceTypeTag, + type ResourceTypeTagData, + type ResourceTypeTagProps, +} from "./components/lineage/tags"; // ============================================================================= // CHECK PRIMITIVES From e5e3d4b46bb07f5ce510737972c2f7cd5d3b5fdf Mon Sep 17 00:00:00 2001 From: Danyel Fisher Date: Mon, 23 Mar 2026 18:49:33 -0700 Subject: [PATCH 2/6] fix(lineage): use unique IDs for IconIncremental SVG clipPaths Hardcoded clipPath IDs caused collisions when multiple incremental model nodes rendered in the same graph. Use React useId() to generate unique IDs per instance. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Danyel Fisher --- .../ui/src/components/lineage/styles.tsx | 76 ++++++++++--------- 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/js/packages/ui/src/components/lineage/styles.tsx b/js/packages/ui/src/components/lineage/styles.tsx index a26b78d6d..916eb98b9 100644 --- a/js/packages/ui/src/components/lineage/styles.tsx +++ b/js/packages/ui/src/components/lineage/styles.tsx @@ -11,7 +11,7 @@ * Source: Ported from OSS js/src/components/lineage/styles.tsx */ -import type { ComponentType, SVGProps } from "react"; +import { type ComponentType, type SVGProps, useId } from "react"; import { colors } from "../../theme/colors"; // ============================================================================= @@ -341,41 +341,47 @@ export const IconViewMat: IconComponent = (props) => ( * Cube icon with dashed top portion for "incremental" materialization type * Bottom 2/3 is solid fill, top 1/3 is dashed strokes */ -export const IconIncremental: IconComponent = (props) => ( - - - - - - - - - - {/* Bottom 2/3: solid fill */} - - {/* Top 1/3: dashed strokes */} - { + const id = useId(); + const bottomId = `inc-bottom-${id}`; + const topId = `inc-top-${id}`; + + return ( + - -); + fill="currentColor" + strokeWidth="0" + viewBox="0 0 512 512" + height="1em" + width="1em" + xmlns="http://www.w3.org/2000/svg" + {...props} + > + + + + + + + + + {/* Bottom 2/3: solid fill */} + + {/* Top 1/3: dashed strokes */} + + + ); +}; /** * Fully dashed cube icon for "ephemeral" materialization type From 4e29216f9f43d47d6a319f77437b361694255a39 Mon Sep 17 00:00:00 2001 From: Danyel Fisher Date: Tue, 24 Mar 2026 00:16:13 -0700 Subject: [PATCH 3/6] refactor(lineage): unify ResourceTypeTag and MaterializationTag into NodeTag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materialization is only meaningful for model resources, so these two concepts belong in a single component. NodeTag takes resourceType and optional materialized — when resourceType is "model" with a materialization, it shows the materialization icon/label; otherwise it shows the resource type. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Danyel Fisher --- .../lineage/MaterializationTag.stories.tsx | 89 ----------- .../stories/lineage/NodeTag.stories.tsx | 147 ++++++++++++++++++ .../stories/lineage/NodeView.stories.tsx | 18 +-- .../lineage/ResourceTypeTag.stories.tsx | 102 ------------ .../ui/src/components/lineage/NodeViewOss.tsx | 14 +- .../lineage/tags/MaterializationTag.tsx | 100 ------------ .../src/components/lineage/tags/NodeTag.tsx | 64 ++++++++ .../lineage/tags/ResourceTypeTag.tsx | 83 ---------- .../lineage/tags/__tests__/NodeTag.test.tsx | 114 ++++++++++++++ .../tags/__tests__/ResourceTypeTag.test.tsx | 122 --------------- .../ui/src/components/lineage/tags/index.ts | 19 +-- .../src/components/summary/SchemaSummary.tsx | 4 +- js/packages/ui/src/primitives.ts | 21 +-- .../summary/__tests__/SchemaSummary.test.tsx | 4 +- 14 files changed, 341 insertions(+), 560 deletions(-) delete mode 100644 js/packages/storybook/stories/lineage/MaterializationTag.stories.tsx create mode 100644 js/packages/storybook/stories/lineage/NodeTag.stories.tsx delete mode 100644 js/packages/storybook/stories/lineage/ResourceTypeTag.stories.tsx delete mode 100644 js/packages/ui/src/components/lineage/tags/MaterializationTag.tsx create mode 100644 js/packages/ui/src/components/lineage/tags/NodeTag.tsx delete mode 100644 js/packages/ui/src/components/lineage/tags/ResourceTypeTag.tsx create mode 100644 js/packages/ui/src/components/lineage/tags/__tests__/NodeTag.test.tsx delete mode 100644 js/packages/ui/src/components/lineage/tags/__tests__/ResourceTypeTag.test.tsx diff --git a/js/packages/storybook/stories/lineage/MaterializationTag.stories.tsx b/js/packages/storybook/stories/lineage/MaterializationTag.stories.tsx deleted file mode 100644 index cf28d6fad..000000000 --- a/js/packages/storybook/stories/lineage/MaterializationTag.stories.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { MaterializationTag } from "@datarecce/ui/primitives"; -import type { Meta, StoryObj } from "@storybook/react-vite"; - -const meta: Meta = { - title: "Lineage/MaterializationTag", - component: MaterializationTag, - tags: ["autodocs"], - parameters: { - docs: { - description: { - component: - "Displays the dbt materialization strategy as a pill-shaped tag with an icon. Used on model nodes to indicate whether a model is materialized as a table, view, incremental, ephemeral, or materialized view.", - }, - }, - layout: "centered", - }, -}; - -export default meta; -type Story = StoryObj; - -// ============================================ -// Individual Materialization Types -// ============================================ - -export const Table: Story = { - args: { data: { materialized: "table" } }, -}; - -export const View: Story = { - args: { data: { materialized: "view" } }, -}; - -export const Incremental: Story = { - args: { data: { materialized: "incremental" } }, -}; - -export const Ephemeral: Story = { - args: { data: { materialized: "ephemeral" } }, -}; - -export const MaterializedView: Story = { - name: "Materialized View", - args: { data: { materialized: "materialized_view" } }, -}; - -// ============================================ -// Edge Cases -// ============================================ - -export const Unknown: Story = { - name: "Unknown Type", - args: { data: { materialized: "custom_materialization" } }, -}; - -export const Undefined: Story = { - name: "Undefined", - args: { data: {} }, -}; - -// ============================================ -// All Types Overview -// ============================================ - -export const AllTypes: Story = { - name: "All Materialization Types", - parameters: { - docs: { - description: { - story: - "Side-by-side comparison of all supported materialization types.", - }, - }, - }, - render: () => ( -
- {["table", "view", "incremental", "ephemeral", "materialized_view"].map( - (type) => ( -
- -
- ), - )} -
- ), -}; diff --git a/js/packages/storybook/stories/lineage/NodeTag.stories.tsx b/js/packages/storybook/stories/lineage/NodeTag.stories.tsx new file mode 100644 index 000000000..4dd5fd0cb --- /dev/null +++ b/js/packages/storybook/stories/lineage/NodeTag.stories.tsx @@ -0,0 +1,147 @@ +import { NodeTag } from "@datarecce/ui/primitives"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta: Meta = { + title: "Lineage/NodeTag", + component: NodeTag, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: + "Displays the dbt resource type or materialization strategy as a pill-shaped tag with an icon. For model nodes with a materialization, shows the materialization (table, view, incremental, etc.). For all other resource types, shows the resource type.", + }, + }, + layout: "centered", + }, +}; + +export default meta; +type Story = StoryObj; + +// ============================================ +// Resource Types +// ============================================ + +export const Model: Story = { + args: { resourceType: "model" }, +}; + +export const Source: Story = { + args: { resourceType: "source" }, +}; + +export const Seed: Story = { + args: { resourceType: "seed" }, +}; + +export const Snapshot: Story = { + args: { resourceType: "snapshot" }, +}; + +export const Metric: Story = { + args: { resourceType: "metric" }, +}; + +export const Exposure: Story = { + args: { resourceType: "exposure" }, +}; + +export const SemanticModel: Story = { + name: "Semantic Model", + args: { resourceType: "semantic_model" }, +}; + +// ============================================ +// Model with Materialization +// ============================================ + +export const ModelTable: Story = { + name: "Model — table", + args: { resourceType: "model", materialized: "table" }, +}; + +export const ModelView: Story = { + name: "Model — view", + args: { resourceType: "model", materialized: "view" }, +}; + +export const ModelIncremental: Story = { + name: "Model — incremental", + args: { resourceType: "model", materialized: "incremental" }, +}; + +export const ModelEphemeral: Story = { + name: "Model — ephemeral", + args: { resourceType: "model", materialized: "ephemeral" }, +}; + +export const ModelMaterializedView: Story = { + name: "Model — materialized view", + args: { resourceType: "model", materialized: "materialized_view" }, +}; + +// ============================================ +// Edge Cases +// ============================================ + +export const UnknownResourceType: Story = { + name: "Unknown Resource Type", + args: { resourceType: "unknown_type" }, +}; + +export const UnknownMaterialization: Story = { + name: "Unknown Materialization", + args: { resourceType: "model", materialized: "custom_materialization" }, +}; + +export const Undefined: Story = { + name: "Undefined", + args: {}, +}; + +// ============================================ +// All Types Overview +// ============================================ + +export const AllResourceTypes: Story = { + name: "All Resource Types", + render: () => ( +
+ {[ + "model", + "source", + "seed", + "snapshot", + "metric", + "exposure", + "semantic_model", + ].map((type) => ( +
+ +
+ ))} +
+ ), +}; + +export const AllMaterializations: Story = { + name: "All Materializations (model)", + render: () => ( +
+ {["table", "view", "incremental", "ephemeral", "materialized_view"].map( + (type) => ( +
+ +
+ ), + )} +
+ ), +}; diff --git a/js/packages/storybook/stories/lineage/NodeView.stories.tsx b/js/packages/storybook/stories/lineage/NodeView.stories.tsx index 50cf69494..51e07f85f 100644 --- a/js/packages/storybook/stories/lineage/NodeView.stories.tsx +++ b/js/packages/storybook/stories/lineage/NodeView.stories.tsx @@ -1,10 +1,7 @@ import type { NodeViewNodeData, NodeViewProps } from "@datarecce/ui/advanced"; import { NodeView } from "@datarecce/ui/advanced"; import type { LineageGraphNode } from "@datarecce/ui/contexts"; -import { - MaterializationTag as MaterializationTagBase, - ResourceTypeTag as ResourceTypeTagBase, -} from "@datarecce/ui/primitives"; +import { NodeTag } from "@datarecce/ui/primitives"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -54,21 +51,16 @@ function StubNodeSqlView({ node }: { node: NodeViewNodeData }) { ); } -/** - * Tag component that shows MaterializationTag for models, ResourceTypeTag otherwise. - * Mirrors the logic in NodeViewOss.tsx. - */ function ResourceTypeTag({ node }: { node: LineageGraphNode }) { const materialized = node.data.data.current?.config?.materialized ?? node.data.data.base?.config?.materialized; - if (node.data.resourceType === "model" && materialized) { - return ; - } - return ( - + ); } diff --git a/js/packages/storybook/stories/lineage/ResourceTypeTag.stories.tsx b/js/packages/storybook/stories/lineage/ResourceTypeTag.stories.tsx deleted file mode 100644 index a23477ac1..000000000 --- a/js/packages/storybook/stories/lineage/ResourceTypeTag.stories.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { ResourceTypeTag } from "@datarecce/ui/primitives"; -import type { Meta, StoryObj } from "@storybook/react-vite"; - -const meta: Meta = { - title: "Lineage/ResourceTypeTag", - component: ResourceTypeTag, - tags: ["autodocs"], - parameters: { - docs: { - description: { - component: - "Displays the dbt resource type as a pill-shaped tag with an icon. Used in lineage graph nodes to indicate whether a node is a model, source, seed, snapshot, etc.", - }, - }, - layout: "centered", - }, -}; - -export default meta; -type Story = StoryObj; - -// ============================================ -// Individual Resource Types -// ============================================ - -export const Model: Story = { - args: { data: { resourceType: "model" } }, -}; - -export const Source: Story = { - args: { data: { resourceType: "source" } }, -}; - -export const Seed: Story = { - args: { data: { resourceType: "seed" } }, -}; - -export const Snapshot: Story = { - args: { data: { resourceType: "snapshot" } }, -}; - -export const Metric: Story = { - args: { data: { resourceType: "metric" } }, -}; - -export const Exposure: Story = { - args: { data: { resourceType: "exposure" } }, -}; - -export const SemanticModel: Story = { - name: "Semantic Model", - args: { data: { resourceType: "semantic_model" } }, -}; - -// ============================================ -// Edge Cases -// ============================================ - -export const Unknown: Story = { - name: "Unknown Type", - args: { data: { resourceType: "unknown_type" } }, -}; - -export const Undefined: Story = { - name: "Undefined", - args: { data: {} }, -}; - -// ============================================ -// All Types Overview -// ============================================ - -export const AllTypes: Story = { - name: "All Resource Types", - parameters: { - docs: { - description: { - story: "Side-by-side comparison of all supported resource types.", - }, - }, - }, - render: () => ( -
- {[ - "model", - "source", - "seed", - "snapshot", - "metric", - "exposure", - "semantic_model", - ].map((type) => ( -
- -
- ))} -
- ), -}; diff --git a/js/packages/ui/src/components/lineage/NodeViewOss.tsx b/js/packages/ui/src/components/lineage/NodeViewOss.tsx index 767cb64f8..eda85ab99 100644 --- a/js/packages/ui/src/components/lineage/NodeViewOss.tsx +++ b/js/packages/ui/src/components/lineage/NodeViewOss.tsx @@ -46,10 +46,7 @@ import { type RunTypeIconMap, } from "./NodeView"; import { SandboxViewOss } from "./SandboxViewOss"; -import { - MaterializationTag as MaterializationTagBase, - ResourceTypeTag as ResourceTypeTagBase, -} from "./tags"; +import { NodeTag } from "./tags"; // ============================================================================= // TYPES @@ -65,12 +62,11 @@ const ResourceTypeTag = ({ node }: { node: LineageGraphNode }) => { node.data.data.current?.config?.materialized ?? node.data.data.base?.config?.materialized; - if (node.data.resourceType === "model" && materialized) { - return ; - } - return ( - + ); }; diff --git a/js/packages/ui/src/components/lineage/tags/MaterializationTag.tsx b/js/packages/ui/src/components/lineage/tags/MaterializationTag.tsx deleted file mode 100644 index 9fbf05e34..000000000 --- a/js/packages/ui/src/components/lineage/tags/MaterializationTag.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"use client"; - -/** - * @file MaterializationTag.tsx - * @description Pure presentation component for displaying materialization strategy with icon - * - * Shows the materialization type (table, view, incremental, etc.) as a tag with an icon. - * Uses theme-aware styling for light/dark mode support. - */ - -import Box from "@mui/material/Box"; -import Tooltip from "@mui/material/Tooltip"; -import { memo } from "react"; -import { useIsDark } from "../../../hooks/useIsDark"; -import { getIconForMaterialization } from "../styles"; -import { getTagRootSx, tagStartElementSx } from "./tagStyles"; - -// ============================================================================= -// TYPES -// ============================================================================= - -/** - * Data required for MaterializationTag - */ -export interface MaterializationTagData { - /** The materialization strategy (table, view, incremental, ephemeral, etc.) */ - materialized?: string; -} - -/** - * Props for MaterializationTag component - */ -export interface MaterializationTagProps { - /** Node data containing the materialization strategy */ - data: MaterializationTagData; - /** Test ID for testing */ - "data-testid"?: string; -} - -// ============================================================================= -// HELPERS -// ============================================================================= - -const displayLabels: Record = { - table: "table", - view: "view", - incremental: "incremental", - ephemeral: "ephemeral", - materialized_view: "mat. view", -}; - -function getDisplayLabel(materialized?: string): string | undefined { - if (!materialized) return undefined; - return displayLabels[materialized] ?? materialized; -} - -// ============================================================================= -// COMPONENT -// ============================================================================= - -/** - * MaterializationTag - Displays the materialization strategy with an icon - * - * Shows a pill-shaped tag with an icon representing the materialization strategy - * (table, view, incremental, ephemeral, etc.). - * - * @example - * ```tsx - * // Basic usage - * - * - * // With incremental type - * - * ``` - */ -function MaterializationTagComponent({ - data, - "data-testid": testId, -}: MaterializationTagProps) { - const isDark = useIsDark(); - const { icon: MaterializationIcon } = getIconForMaterialization( - data.materialized, - ); - - return ( - - - {MaterializationIcon && ( - - - - )} - {getDisplayLabel(data.materialized)} - - - ); -} - -export const MaterializationTag = memo(MaterializationTagComponent); -MaterializationTag.displayName = "MaterializationTag"; diff --git a/js/packages/ui/src/components/lineage/tags/NodeTag.tsx b/js/packages/ui/src/components/lineage/tags/NodeTag.tsx new file mode 100644 index 000000000..3cc55f57c --- /dev/null +++ b/js/packages/ui/src/components/lineage/tags/NodeTag.tsx @@ -0,0 +1,64 @@ +"use client"; + +import Box from "@mui/material/Box"; +import Tooltip from "@mui/material/Tooltip"; +import { memo } from "react"; +import { useIsDark } from "../../../hooks/useIsDark"; +import { getIconForMaterialization, getIconForResourceType } from "../styles"; +import { getTagRootSx, tagStartElementSx } from "./tagStyles"; + +export interface NodeTagProps { + resourceType?: string; + materialized?: string; + "data-testid"?: string; +} + +const materializationLabels: Record = { + table: "table", + view: "view", + incremental: "incremental", + ephemeral: "ephemeral", + materialized_view: "mat. view", +}; + +function getMaterializationLabel(materialized: string): string { + return materializationLabels[materialized] ?? materialized; +} + +function NodeTagComponent({ + resourceType, + materialized, + "data-testid": testId, +}: NodeTagProps) { + const isDark = useIsDark(); + + const showMaterialization = resourceType === "model" && materialized; + + const { icon: Icon } = showMaterialization + ? getIconForMaterialization(materialized) + : getIconForResourceType(resourceType); + + const label = showMaterialization + ? getMaterializationLabel(materialized) + : resourceType; + + const tooltip = showMaterialization + ? "Materialization strategy" + : "Type of resource"; + + return ( + + + {Icon && ( + + + + )} + {label} + + + ); +} + +export const NodeTag = memo(NodeTagComponent); +NodeTag.displayName = "NodeTag"; diff --git a/js/packages/ui/src/components/lineage/tags/ResourceTypeTag.tsx b/js/packages/ui/src/components/lineage/tags/ResourceTypeTag.tsx deleted file mode 100644 index f4103827f..000000000 --- a/js/packages/ui/src/components/lineage/tags/ResourceTypeTag.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -/** - * @file ResourceTypeTag.tsx - * @description Pure presentation component for displaying resource type with icon - * - * Shows the resource type (model, source, seed, etc.) as a tag with an icon. - * Uses theme-aware styling for light/dark mode support. - * - * Source: Migrated from OSS js/src/components/lineage/NodeTag.tsx - */ - -import Box from "@mui/material/Box"; -import Tooltip from "@mui/material/Tooltip"; -import { memo } from "react"; -import { useIsDark } from "../../../hooks/useIsDark"; -import { getIconForResourceType } from "../styles"; -import { getTagRootSx, tagStartElementSx } from "./tagStyles"; - -// ============================================================================= -// TYPES -// ============================================================================= - -/** - * Data required for ResourceTypeTag - */ -export interface ResourceTypeTagData { - /** The resource type to display (model, source, seed, snapshot, etc.) */ - resourceType?: string; -} - -/** - * Props for ResourceTypeTag component - */ -export interface ResourceTypeTagProps { - /** Node data containing the resource type */ - data: ResourceTypeTagData; - /** Test ID for testing */ - "data-testid"?: string; -} - -// ============================================================================= -// COMPONENT -// ============================================================================= - -/** - * ResourceTypeTag - Displays the resource type with an icon - * - * Shows a pill-shaped tag with an icon representing the resource type - * (model, source, seed, snapshot, metric, exposure, semantic_model). - * - * @example - * ```tsx - * // Basic usage - * - * - * // With snapshot type - * - * ``` - */ -function ResourceTypeTagComponent({ - data, - "data-testid": testId, -}: ResourceTypeTagProps) { - const isDark = useIsDark(); - const { icon: ResourceTypeIcon } = getIconForResourceType(data.resourceType); - - return ( - - - {ResourceTypeIcon && ( - - - - )} - {data.resourceType} - - - ); -} - -export const ResourceTypeTag = memo(ResourceTypeTagComponent); -ResourceTypeTag.displayName = "ResourceTypeTag"; diff --git a/js/packages/ui/src/components/lineage/tags/__tests__/NodeTag.test.tsx b/js/packages/ui/src/components/lineage/tags/__tests__/NodeTag.test.tsx new file mode 100644 index 000000000..3fa2d5b8f --- /dev/null +++ b/js/packages/ui/src/components/lineage/tags/__tests__/NodeTag.test.tsx @@ -0,0 +1,114 @@ +import { render, screen } from "@testing-library/react"; +import { vi } from "vitest"; +import { NodeTag } from "../NodeTag"; + +const mockIsDark = vi.fn(() => false); +vi.mock("../../../../hooks/useIsDark", () => ({ + useIsDark: () => mockIsDark(), +})); + +describe("NodeTag", () => { + beforeEach(() => { + mockIsDark.mockReturnValue(false); + }); + + describe("resource type display", () => { + it("renders resource type text", () => { + render(); + expect(screen.getByText("source")).toBeInTheDocument(); + }); + + it("renders different resource types", () => { + const { rerender } = render(); + expect(screen.getByText("source")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("seed")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("snapshot")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("metric")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("exposure")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("semantic_model")).toBeInTheDocument(); + }); + + it("renders model as resource type when no materialization", () => { + render(); + expect(screen.getByText("model")).toBeInTheDocument(); + }); + + it("handles undefined resource type", () => { + render(); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + }); + + describe("materialization display", () => { + it("shows materialization for models", () => { + render(); + expect(screen.getByText("table")).toBeInTheDocument(); + }); + + it("shows display labels for known materializations", () => { + render(); + expect(screen.getByText("mat. view")).toBeInTheDocument(); + }); + + it("ignores materialization for non-model resources", () => { + render(); + expect(screen.getByText("source")).toBeInTheDocument(); + expect(screen.queryByText("table")).not.toBeInTheDocument(); + }); + }); + + describe("data-testid", () => { + it("renders with data-testid", () => { + render(); + expect(screen.getByTestId("test-tag")).toBeInTheDocument(); + }); + }); + + describe("styling", () => { + it("applies light mode styling", () => { + mockIsDark.mockReturnValue(false); + render(); + expect(screen.getByTestId("light-tag")).toBeInTheDocument(); + }); + + it("applies dark mode styling", () => { + mockIsDark.mockReturnValue(true); + render(); + expect(screen.getByTestId("dark-tag")).toBeInTheDocument(); + }); + }); + + describe("icon display", () => { + it("renders icon for model type", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("renders icon for source type", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("does not render icon for unknown resource type", () => { + const { container } = render(); + expect(container.querySelector("svg")).not.toBeInTheDocument(); + }); + + it("renders materialization icon for model with materialization", () => { + const { container } = render( + , + ); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + }); +}); diff --git a/js/packages/ui/src/components/lineage/tags/__tests__/ResourceTypeTag.test.tsx b/js/packages/ui/src/components/lineage/tags/__tests__/ResourceTypeTag.test.tsx deleted file mode 100644 index a98ec89be..000000000 --- a/js/packages/ui/src/components/lineage/tags/__tests__/ResourceTypeTag.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -/** - * @file ResourceTypeTag.test.tsx - * @description Tests for ResourceTypeTag component - * - * Tests verify: - * - Component renders resource type text - * - Icon is displayed for known resource types - * - Tooltip is present - * - Dark mode styling works correctly - */ - -import { render, screen } from "@testing-library/react"; -import { vi } from "vitest"; -import { ResourceTypeTag } from "../ResourceTypeTag"; - -// Mock useIsDark hook -const mockIsDark = vi.fn(() => false); -vi.mock("../../../../hooks/useIsDark", () => ({ - useIsDark: () => mockIsDark(), -})); - -describe("ResourceTypeTag", () => { - beforeEach(() => { - mockIsDark.mockReturnValue(false); - }); - - describe("rendering", () => { - it("renders resource type text", () => { - render(); - expect(screen.getByText("model")).toBeInTheDocument(); - }); - - it("renders different resource types", () => { - const { rerender } = render( - , - ); - expect(screen.getByText("source")).toBeInTheDocument(); - - rerender(); - expect(screen.getByText("seed")).toBeInTheDocument(); - - rerender(); - expect(screen.getByText("snapshot")).toBeInTheDocument(); - - rerender(); - expect(screen.getByText("metric")).toBeInTheDocument(); - - rerender(); - expect(screen.getByText("exposure")).toBeInTheDocument(); - - rerender(); - expect(screen.getByText("semantic_model")).toBeInTheDocument(); - }); - - it("handles undefined resource type", () => { - render(); - // Should not crash, will just render empty text - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - }); - - it("renders with data-testid", () => { - render( - , - ); - expect(screen.getByTestId("test-tag")).toBeInTheDocument(); - }); - }); - - describe("styling", () => { - it("applies light mode styling", () => { - mockIsDark.mockReturnValue(false); - render( - , - ); - // Component renders - styling is applied via MUI sx prop - expect(screen.getByTestId("light-tag")).toBeInTheDocument(); - }); - - it("applies dark mode styling", () => { - mockIsDark.mockReturnValue(true); - render( - , - ); - // Component renders - styling is applied via MUI sx prop - expect(screen.getByTestId("dark-tag")).toBeInTheDocument(); - }); - }); - - describe("icon display", () => { - it("renders icon for model type", () => { - const { container } = render( - , - ); - // Icon is rendered as SVG - expect(container.querySelector("svg")).toBeInTheDocument(); - }); - - it("renders icon for source type", () => { - const { container } = render( - , - ); - expect(container.querySelector("svg")).toBeInTheDocument(); - }); - - it("does not render icon for unknown resource type", () => { - const { container } = render( - , - ); - // Unknown types don't have icons - expect(container.querySelector("svg")).not.toBeInTheDocument(); - }); - }); -}); diff --git a/js/packages/ui/src/components/lineage/tags/index.ts b/js/packages/ui/src/components/lineage/tags/index.ts index df5de5bce..9d58f5803 100644 --- a/js/packages/ui/src/components/lineage/tags/index.ts +++ b/js/packages/ui/src/components/lineage/tags/index.ts @@ -1,19 +1,2 @@ -/** - * @file index.ts - * @description Exports for lineage tag components - * - * Tag components are small visual indicators used within lineage graph nodes - * to show metadata like resource type, row counts, etc. - */ - -export { - MaterializationTag, - type MaterializationTagData, - type MaterializationTagProps, -} from "./MaterializationTag"; -export { - ResourceTypeTag, - type ResourceTypeTagData, - type ResourceTypeTagProps, -} from "./ResourceTypeTag"; +export { NodeTag, type NodeTagProps } from "./NodeTag"; export { getTagRootSx, tagStartElementSx } from "./tagStyles"; diff --git a/js/packages/ui/src/components/summary/SchemaSummary.tsx b/js/packages/ui/src/components/summary/SchemaSummary.tsx index 820645e36..9ee1a1c31 100644 --- a/js/packages/ui/src/components/summary/SchemaSummary.tsx +++ b/js/packages/ui/src/components/summary/SchemaSummary.tsx @@ -9,7 +9,7 @@ import Typography from "@mui/material/Typography"; import { useEffect, useState } from "react"; import type { LineageGraph, LineageGraphNode } from "../../contexts"; import { mergeKeysWithStatus } from "../../utils"; -import { ResourceTypeTag, RowCountDiffTag } from "../lineage"; +import { NodeTag, RowCountDiffTag } from "../lineage"; import { SchemaView } from "../schema"; interface SchemaDiffCardProps { @@ -28,7 +28,7 @@ function SchemaDiffCard({ node, ...props }: SchemaDiffCardProps) { } subheader={ - + {node.data.resourceType === "model" && ( )} diff --git a/js/packages/ui/src/primitives.ts b/js/packages/ui/src/primitives.ts index 244dd3953..b8ce41d71 100644 --- a/js/packages/ui/src/primitives.ts +++ b/js/packages/ui/src/primitives.ts @@ -64,26 +64,7 @@ export { type LineageNodeProps, type NodeChangeStatus, } from "./components/lineage/nodes"; -/** - * Resource type tag for lineage nodes. - * - * @remarks - * Exports: ResourceTypeTag, ResourceTypeTagData, ResourceTypeTagProps. - */ -/** - * Materialization tag for lineage nodes. - * - * @remarks - * Exports: MaterializationTag, MaterializationTagData, MaterializationTagProps. - */ -export { - MaterializationTag, - type MaterializationTagData, - type MaterializationTagProps, - ResourceTypeTag, - type ResourceTypeTagData, - type ResourceTypeTagProps, -} from "./components/lineage/tags"; +export { NodeTag, type NodeTagProps } from "./components/lineage/tags"; // ============================================================================= // CHECK PRIMITIVES diff --git a/js/src/components/summary/__tests__/SchemaSummary.test.tsx b/js/src/components/summary/__tests__/SchemaSummary.test.tsx index 424318ed3..246738565 100644 --- a/js/src/components/summary/__tests__/SchemaSummary.test.tsx +++ b/js/src/components/summary/__tests__/SchemaSummary.test.tsx @@ -62,8 +62,8 @@ vi.mock("@datarecce/ui/components/schema", () => ({ })); vi.mock("@datarecce/ui/components/lineage", () => ({ - ResourceTypeTag: ({ data }: { data: { resourceType: string } }) => ( - {data.resourceType} + NodeTag: ({ resourceType }: { resourceType: string }) => ( + {resourceType} ), RowCountDiffTag: () => RowCount, })); From 5f9f0e622b0ef497e93da991c8ff3c1859939fcf Mon Sep 17 00:00:00 2001 From: Danyel Fisher Date: Tue, 24 Mar 2026 10:18:18 -0700 Subject: [PATCH 4/6] fix(ui): address code review feedback on materialization badges - Fix SchemaSummary test to assert on rendered text instead of mock's data-testid - Add dynamic_table and streaming_table materialization types (Snowflake, Databricks) - Add materialization-change story (base=view, current=table) - Add aria-hidden to decorative SVG icons in NodeTag and LineageNode - Improve style tests to verify useIsDark is called - Consolidate NodeTag stories into unified AllTypes view - Remove redundant individual materialization substories from NodeView Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Danyel Fisher --- .../stories/lineage/NodeTag.stories.tsx | 148 ++++++------------ .../stories/lineage/NodeView.stories.tsx | 50 ++---- .../components/lineage/nodes/LineageNode.tsx | 4 +- .../ui/src/components/lineage/styles.tsx | 6 +- .../src/components/lineage/tags/NodeTag.tsx | 4 +- .../lineage/tags/__tests__/NodeTag.test.tsx | 14 +- .../summary/__tests__/SchemaSummary.test.tsx | 6 +- 7 files changed, 80 insertions(+), 152 deletions(-) diff --git a/js/packages/storybook/stories/lineage/NodeTag.stories.tsx b/js/packages/storybook/stories/lineage/NodeTag.stories.tsx index 4dd5fd0cb..cf2e2f155 100644 --- a/js/packages/storybook/stories/lineage/NodeTag.stories.tsx +++ b/js/packages/storybook/stories/lineage/NodeTag.stories.tsx @@ -20,65 +20,53 @@ export default meta; type Story = StoryObj; // ============================================ -// Resource Types -// ============================================ - -export const Model: Story = { - args: { resourceType: "model" }, -}; - -export const Source: Story = { - args: { resourceType: "source" }, -}; - -export const Seed: Story = { - args: { resourceType: "seed" }, -}; - -export const Snapshot: Story = { - args: { resourceType: "snapshot" }, -}; - -export const Metric: Story = { - args: { resourceType: "metric" }, -}; - -export const Exposure: Story = { - args: { resourceType: "exposure" }, -}; - -export const SemanticModel: Story = { - name: "Semantic Model", - args: { resourceType: "semantic_model" }, -}; - -// ============================================ -// Model with Materialization +// All Types Overview // ============================================ -export const ModelTable: Story = { - name: "Model — table", - args: { resourceType: "model", materialized: "table" }, -}; - -export const ModelView: Story = { - name: "Model — view", - args: { resourceType: "model", materialized: "view" }, -}; - -export const ModelIncremental: Story = { - name: "Model — incremental", - args: { resourceType: "model", materialized: "incremental" }, -}; - -export const ModelEphemeral: Story = { - name: "Model — ephemeral", - args: { resourceType: "model", materialized: "ephemeral" }, -}; - -export const ModelMaterializedView: Story = { - name: "Model — materialized view", - args: { resourceType: "model", materialized: "materialized_view" }, +export const AllTypes: Story = { + name: "All Types", + render: () => ( +
+
+
Resource Types
+
+ {[ + "model", + "source", + "seed", + "snapshot", + "metric", + "exposure", + "semantic_model", + ].map((type) => ( +
+ +
+ ))} +
+
+
+
+ Materializations (model) +
+
+ {[ + "table", + "view", + "incremental", + "ephemeral", + "materialized_view", + "dynamic_table", + "streaming_table", + ].map((type) => ( +
+ +
+ ))} +
+
+
+ ), }; // ============================================ @@ -99,49 +87,3 @@ export const Undefined: Story = { name: "Undefined", args: {}, }; - -// ============================================ -// All Types Overview -// ============================================ - -export const AllResourceTypes: Story = { - name: "All Resource Types", - render: () => ( -
- {[ - "model", - "source", - "seed", - "snapshot", - "metric", - "exposure", - "semantic_model", - ].map((type) => ( -
- -
- ))} -
- ), -}; - -export const AllMaterializations: Story = { - name: "All Materializations (model)", - render: () => ( -
- {["table", "view", "incremental", "ephemeral", "materialized_view"].map( - (type) => ( -
- -
- ), - )} -
- ), -}; diff --git a/js/packages/storybook/stories/lineage/NodeView.stories.tsx b/js/packages/storybook/stories/lineage/NodeView.stories.tsx index 579514285..98f8613b0 100644 --- a/js/packages/storybook/stories/lineage/NodeView.stories.tsx +++ b/js/packages/storybook/stories/lineage/NodeView.stories.tsx @@ -76,9 +76,12 @@ function createNode( resourceType?: string; changeStatus?: string; materialized?: string; + baseMaterialized?: string; } = {}, ): NodeViewNodeData { - const config = overrides.materialized + const baseMat = overrides.baseMaterialized ?? overrides.materialized; + const baseConfig = baseMat ? { materialized: baseMat } : undefined; + const currentConfig = overrides.materialized ? { materialized: overrides.materialized } : undefined; @@ -99,7 +102,7 @@ function createNode( customer_id: { name: "customer_id", type: "integer" }, order_date: { name: "order_date", type: "date" }, }, - config, + config: baseConfig, }, current: { id: "stg_orders", @@ -111,7 +114,7 @@ function createNode( customer_id: { name: "customer_id", type: "integer" }, order_date: { name: "order_date", type: "date" }, }, - config, + config: currentConfig, }, }, }, @@ -244,41 +247,16 @@ export const SingleEnvMode: Story = { }; // ============================================================================= -// MATERIALIZATION TAG STORIES +// MATERIALIZATION CHANGE STORY // ============================================================================= -/** Model materialized as incremental — shows incremental icon in tag row. */ -export const IncrementalModel: Story = { +/** Materialization changed from view to table between base and current. */ +export const MaterializationChanged: Story = { args: { - node: createNode({ materialized: "incremental" }), - }, -}; - -/** Model materialized as table — shows solid cube icon in tag row. */ -export const TableModel: Story = { - args: { - node: createNode({ materialized: "table" }), - }, -}; - -/** Model materialized as ephemeral — shows dashed cube icon in tag row. */ -export const EphemeralModel: Story = { - args: { - node: createNode({ materialized: "ephemeral" }), - }, -}; - -/** Model materialized as materialized_view — shows cube+eye icon in tag row. */ -export const MaterializedViewModel: Story = { - name: "Materialized View Model", - args: { - node: createNode({ materialized: "materialized_view" }), - }, -}; - -/** Source node — shows resource type tag (no materialization). */ -export const SourceNode: Story = { - args: { - node: createNode({ resourceType: "source", name: "raw_orders" }), + node: createNode({ + baseMaterialized: "view", + materialized: "table", + changeStatus: "modified", + }), }, }; diff --git a/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx b/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx index 6761876b9..ebf584e4e 100644 --- a/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx +++ b/js/packages/ui/src/components/lineage/nodes/LineageNode.tsx @@ -559,12 +559,12 @@ function LineageNodeComponent({ <> {ResourceIcon && ( - + )} {changeStatus && IconChangeStatus && ( - + )} diff --git a/js/packages/ui/src/components/lineage/styles.tsx b/js/packages/ui/src/components/lineage/styles.tsx index 916eb98b9..a304c5b31 100644 --- a/js/packages/ui/src/components/lineage/styles.tsx +++ b/js/packages/ui/src/components/lineage/styles.tsx @@ -560,7 +560,9 @@ export type MaterializationType = | "view" | "incremental" | "ephemeral" - | "materialized_view"; + | "materialized_view" + | "dynamic_table" + | "streaming_table"; /** * Get icon and color for a materialization type @@ -587,6 +589,8 @@ export function getIconForMaterialization( case "ephemeral": return { color: colors.neutral[400], icon: IconEphemeral }; case "materialized_view": + case "dynamic_table": + case "streaming_table": return { color: colors.fuchsia[200], icon: IconMaterializedView }; default: return { color: "inherit", icon: undefined }; diff --git a/js/packages/ui/src/components/lineage/tags/NodeTag.tsx b/js/packages/ui/src/components/lineage/tags/NodeTag.tsx index 3cc55f57c..fe424938a 100644 --- a/js/packages/ui/src/components/lineage/tags/NodeTag.tsx +++ b/js/packages/ui/src/components/lineage/tags/NodeTag.tsx @@ -19,6 +19,8 @@ const materializationLabels: Record = { incremental: "incremental", ephemeral: "ephemeral", materialized_view: "mat. view", + dynamic_table: "dyn. table", + streaming_table: "stream. table", }; function getMaterializationLabel(materialized: string): string { @@ -51,7 +53,7 @@ function NodeTagComponent({ {Icon && ( - + )} {label} diff --git a/js/packages/ui/src/components/lineage/tags/__tests__/NodeTag.test.tsx b/js/packages/ui/src/components/lineage/tags/__tests__/NodeTag.test.tsx index 3fa2d5b8f..ea058357f 100644 --- a/js/packages/ui/src/components/lineage/tags/__tests__/NodeTag.test.tsx +++ b/js/packages/ui/src/components/lineage/tags/__tests__/NodeTag.test.tsx @@ -75,16 +75,18 @@ describe("NodeTag", () => { }); describe("styling", () => { - it("applies light mode styling", () => { + it("calls useIsDark and renders in light mode", () => { mockIsDark.mockReturnValue(false); - render(); - expect(screen.getByTestId("light-tag")).toBeInTheDocument(); + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + expect(mockIsDark).toHaveBeenCalled(); }); - it("applies dark mode styling", () => { + it("calls useIsDark and renders in dark mode", () => { mockIsDark.mockReturnValue(true); - render(); - expect(screen.getByTestId("dark-tag")).toBeInTheDocument(); + const { container } = render(); + expect(container.firstChild).toBeInTheDocument(); + expect(mockIsDark).toHaveBeenCalled(); }); }); diff --git a/js/src/components/summary/__tests__/SchemaSummary.test.tsx b/js/src/components/summary/__tests__/SchemaSummary.test.tsx index 246738565..7852fba55 100644 --- a/js/src/components/summary/__tests__/SchemaSummary.test.tsx +++ b/js/src/components/summary/__tests__/SchemaSummary.test.tsx @@ -63,7 +63,7 @@ vi.mock("@datarecce/ui/components/schema", () => ({ vi.mock("@datarecce/ui/components/lineage", () => ({ NodeTag: ({ resourceType }: { resourceType: string }) => ( - {resourceType} + {resourceType} ), RowCountDiffTag: () => RowCount, })); @@ -269,7 +269,7 @@ describe("SchemaSummary (Simplified)", () => { }); describe("integration", () => { - it("displays ResourceTypeTag", async () => { + it("displays NodeTag with resource type", async () => { const lineageGraph = createMockLineageGraph([ createMockNode( "node1", @@ -282,7 +282,7 @@ describe("SchemaSummary (Simplified)", () => { render(); await waitFor(() => { - expect(screen.getByTestId("resource-type-tag")).toBeInTheDocument(); + expect(screen.getByText("model")).toBeInTheDocument(); }); }); From cc921394ae71bc7515eeae8180faeb3c0505950d Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Wed, 25 Mar 2026 14:45:46 +0800 Subject: [PATCH 5/6] fix(ui): pass materialized prop in SchemaSummary and remove dead type SchemaSummary was missing the materialized prop on NodeTag, causing models to show "model" instead of their materialization strategy. Also removes unused MaterializationType export from styles. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Jared Scott --- js/packages/ui/src/components/lineage/styles.tsx | 12 ------------ .../ui/src/components/summary/SchemaSummary.tsx | 8 +++++++- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/js/packages/ui/src/components/lineage/styles.tsx b/js/packages/ui/src/components/lineage/styles.tsx index a304c5b31..39efbb607 100644 --- a/js/packages/ui/src/components/lineage/styles.tsx +++ b/js/packages/ui/src/components/lineage/styles.tsx @@ -552,18 +552,6 @@ export function getIconForResourceType( } } -/** - * Materialization types supported by dbt - */ -export type MaterializationType = - | "table" - | "view" - | "incremental" - | "ephemeral" - | "materialized_view" - | "dynamic_table" - | "streaming_table"; - /** * Get icon and color for a materialization type * diff --git a/js/packages/ui/src/components/summary/SchemaSummary.tsx b/js/packages/ui/src/components/summary/SchemaSummary.tsx index 9ee1a1c31..2ea79359d 100644 --- a/js/packages/ui/src/components/summary/SchemaSummary.tsx +++ b/js/packages/ui/src/components/summary/SchemaSummary.tsx @@ -28,7 +28,13 @@ function SchemaDiffCard({ node, ...props }: SchemaDiffCardProps) { } subheader={ - + {node.data.resourceType === "model" && ( )} From 9322a65ade155abffd0765d38a24b83c42108029 Mon Sep 17 00:00:00 2001 From: Jared Scott Date: Wed, 25 Mar 2026 14:48:59 +0800 Subject: [PATCH 6/6] fix(ui): use explicit null check for materialization narrowing Use `!= null` instead of truthy check for showMaterialization so TypeScript explicitly narrows `materialized` to `string` in the truthy branch. Addresses Copilot review feedback. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Jared Scott --- js/packages/ui/src/components/lineage/tags/NodeTag.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/packages/ui/src/components/lineage/tags/NodeTag.tsx b/js/packages/ui/src/components/lineage/tags/NodeTag.tsx index fe424938a..d0c991816 100644 --- a/js/packages/ui/src/components/lineage/tags/NodeTag.tsx +++ b/js/packages/ui/src/components/lineage/tags/NodeTag.tsx @@ -34,7 +34,7 @@ function NodeTagComponent({ }: NodeTagProps) { const isDark = useIsDark(); - const showMaterialization = resourceType === "model" && materialized; + const showMaterialization = resourceType === "model" && materialized != null; const { icon: Icon } = showMaterialization ? getIconForMaterialization(materialized)