Menu

Change language

GitHub repo

Dbt Model Graph

blockdevops

dbt Cloud style data modeling DAG dependency graph, compiled SQL view, and model documentation with lineage tree, Jinja/SQL source editor, compiled warehouse query, and column contract test suite.

Installation

$npx uipkge-ng@latest add dbt-model-graph
Manifest:https://uipkge.dev/r/angular/dbt-model-graph.jsonInstalls to:src/app/components/blocks/dbt-model-graph/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

ModelColumn
interface ModelColumn {
  name: string
  dataType: string
  nullable: boolean
  isPrimaryKey?: boolean
  isForeignKey?: boolean
  foreignKeyRef?: string
  isClusterKey?: boolean
  tests: Array<{
    name: 'unique' | 'not_null' | 'relationships' | 'accepted_values'
    status: 'pass' | 'warn' | 'fail'
    detail?: string
  }>
  testDuration: string
  description: string
}
UpstreamNode
interface UpstreamNode {
  id: string
  name: string
  type: 'view' | 'table' | 'source'
  package: string
  rowCount: string
  freshness: string
  freshnessStatus: 'pass' | 'warn'
  description: string
}
DownstreamNode
interface DownstreamNode {
  id: string
  name: string
  type: 'table' | 'incremental' | 'exposure'
  package: string
  consumers: string
  sla: string
  description: string
}

Files installed (2)

  • src/app/components/blocks/dbt-model-graph/dbt-model-graph.component.ts52.2 kB
    import { Component, DestroyRef, Input, computed, inject, signal, ChangeDetectionStrategy } from '@angular/core'
    import {
      ArrowUpRight,
      Check,
      CheckCircle2,
      Clock,
      Code2,
      Copy,
      Database,
      FileCode,
      GitBranch,
      HardDrive,
      Info,
      Key,
      Layers,
      Link2,
      Loader2,
      LucideAngularModule,
      Play,
      Search,
      ShieldCheck,
      Table2,
      Tag,
      User,
      Workflow,
      Zap,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiCardComponent } from '@/ui/card/card.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    import {
      UiTabsComponent,
      UiTabsContentComponent,
      UiTabsListComponent,
      UiTabsTriggerComponent,
    } from '@/ui/tabs/tabs.component'
    
    export type DbtModelGraphTab = 'model-sql' | 'compiled-sql' | 'contract'
    
    export interface ModelColumn {
      name: string
      dataType: string
      nullable: boolean
      isPrimaryKey?: boolean
      isForeignKey?: boolean
      foreignKeyRef?: string
      isClusterKey?: boolean
      tests: Array<{
        name: 'unique' | 'not_null' | 'relationships' | 'accepted_values'
        status: 'pass' | 'warn' | 'fail'
        detail?: string
      }>
      testDuration: string
      description: string
    }
    
    export interface UpstreamNode {
      id: string
      name: string
      type: 'view' | 'table' | 'source'
      package: string
      rowCount: string
      freshness: string
      freshnessStatus: 'pass' | 'warn'
      description: string
    }
    
    export interface DownstreamNode {
      id: string
      name: string
      type: 'table' | 'incremental' | 'exposure'
      package: string
      consumers: string
      sla: string
      description: string
    }
    
    const modelSqlSource = `{{
      config(
        materialized = 'incremental',
        unique_key = 'order_id',
        on_schema_change = 'sync_all_columns',
        incremental_strategy = 'merge',
        cluster_by = ['order_date', 'customer_id'],
        tags = ['finance', 'core', 'daily_sla']
      )
    }}
    
    WITH payments AS (
      SELECT
        payment_id,
        order_id,
        payment_method,
        amount_usd,
        gateway_fee_usd,
        status AS payment_status,
        created_at AS payment_created_at
      FROM {{ ref('stg_stripe__payments') }}
      {% if is_incremental() %}
        WHERE updated_at >= (SELECT coalesce(max(payment_created_at), '1970-01-01') FROM {{ this }})
      {% endif %}
    ),
    
    orders_source AS (
      SELECT
        order_id,
        customer_id,
        order_number,
        order_status,
        total_amount_usd,
        tax_amount_usd,
        discount_amount_usd,
        currency_code,
        order_date,
        created_at,
        updated_at
      FROM {{ ref('stg_shopify__orders') }}
      {% if is_incremental() %}
        WHERE updated_at >= (SELECT coalesce(max(updated_at), '1970-01-01') FROM {{ this }})
      {% endif %}
    ),
    
    customers AS (
      SELECT
        customer_id,
        customer_tier,
        country_code,
        lifetime_order_count
      FROM {{ ref('dim_customers') }}
    ),
    
    aggregated_payments AS (
      SELECT
        order_id,
        sum(case when payment_status = 'succeeded' then amount_usd else 0 end) as total_paid_usd,
        sum(gateway_fee_usd) as total_fees_usd,
        count(payment_id) as payment_attempts_count,
        max(payment_created_at) as last_payment_at
      FROM payments
      GROUP BY 1
    ),
    
    final_model AS (
      SELECT
        ord.order_id,
        ord.order_number,
        ord.customer_id,
        cust.customer_tier,
        cust.country_code,
        ord.order_status,
        ord.order_date,
        ord.currency_code,
        ord.total_amount_usd,
        ord.tax_amount_usd,
        ord.discount_amount_usd,
        coalesce(pay.total_paid_usd, 0.00) as total_paid_usd,
        coalesce(pay.total_fees_usd, 0.00) as payment_fees_usd,
        round(ord.total_amount_usd - coalesce(pay.total_fees_usd, 0.00), 2) as net_revenue_usd,
        case
          when pay.total_paid_usd >= ord.total_amount_usd then 'fully_paid'
          when pay.total_paid_usd > 0 then 'partially_paid'
          else 'unpaid'
        end as payment_settlement_status,
        coalesce(pay.payment_attempts_count, 0) as payment_attempts,
        ord.created_at,
        ord.updated_at
      FROM orders_source ord
      LEFT JOIN aggregated_payments pay ON ord.order_id = pay.order_id
      LEFT JOIN customers cust ON ord.customer_id = cust.customer_id
    )
    
    SELECT * FROM final_model`
    
    const compiledSqlSource = `-- Compiled target warehouse query: Snowflake / AWS us-east-1
    -- Execution target: "ANALYTICS_PROD"."MARTS"."fct_orders"
    -- Incremental Merge Strategy with cluster keys [order_date, customer_id]
    
    MERGE INTO "ANALYTICS_PROD"."MARTS"."fct_orders" AS target
    USING (
      WITH payments AS (
        SELECT
          payment_id,
          order_id,
          payment_method,
          amount_usd,
          gateway_fee_usd,
          status AS payment_status,
          created_at AS payment_created_at
        FROM "ANALYTICS_PROD"."STAGING"."stg_stripe__payments"
        WHERE updated_at >= (SELECT coalesce(max(payment_created_at), '1970-01-01') FROM "ANALYTICS_PROD"."MARTS"."fct_orders")
      ),
    
      orders_source AS (
        SELECT
          order_id,
          customer_id,
          order_number,
          order_status,
          total_amount_usd,
          tax_amount_usd,
          discount_amount_usd,
          currency_code,
          order_date,
          created_at,
          updated_at
        FROM "ANALYTICS_PROD"."STAGING"."stg_shopify__orders"
        WHERE updated_at >= (SELECT coalesce(max(updated_at), '1970-01-01') FROM "ANALYTICS_PROD"."MARTS"."fct_orders")
      ),
    
      customers AS (
        SELECT
          customer_id,
          customer_tier,
          country_code,
          lifetime_order_count
        FROM "ANALYTICS_PROD"."CORE"."dim_customers"
      ),
    
      aggregated_payments AS (
        SELECT
          order_id,
          sum(case when payment_status = 'succeeded' then amount_usd else 0 end) as total_paid_usd,
          sum(gateway_fee_usd) as total_fees_usd,
          count(payment_id) as payment_attempts_count,
          max(payment_created_at) as last_payment_at
        FROM payments
        GROUP BY 1
      ),
    
      final_model AS (
        SELECT
          ord.order_id,
          ord.order_number,
          ord.customer_id,
          cust.customer_tier,
          cust.country_code,
          ord.order_status,
          ord.order_date,
          ord.currency_code,
          ord.total_amount_usd,
          ord.tax_amount_usd,
          ord.discount_amount_usd,
          coalesce(pay.total_paid_usd, 0.00) as total_paid_usd,
          coalesce(pay.total_fees_usd, 0.00) as payment_fees_usd,
          round(ord.total_amount_usd - coalesce(pay.total_fees_usd, 0.00), 2) as net_revenue_usd,
          case
            when pay.total_paid_usd >= ord.total_amount_usd then 'fully_paid'
            when pay.total_paid_usd > 0 then 'partially_paid'
            else 'unpaid'
          end as payment_settlement_status,
          coalesce(pay.payment_attempts_count, 0) as payment_attempts,
          ord.created_at,
          ord.updated_at
        FROM orders_source ord
        LEFT JOIN aggregated_payments pay ON ord.order_id = pay.order_id
        LEFT JOIN customers cust ON ord.customer_id = cust.customer_id
      )
    
      SELECT * FROM final_model
    ) AS source
    ON target.order_id = source.order_id
    WHEN MATCHED THEN UPDATE SET
      order_number = source.order_number,
      customer_id = source.customer_id,
      customer_tier = source.customer_tier,
      country_code = source.country_code,
      order_status = source.order_status,
      order_date = source.order_date,
      total_amount_usd = source.total_amount_usd,
      tax_amount_usd = source.tax_amount_usd,
      discount_amount_usd = source.discount_amount_usd,
      total_paid_usd = source.total_paid_usd,
      payment_fees_usd = source.payment_fees_usd,
      net_revenue_usd = source.net_revenue_usd,
      payment_settlement_status = source.payment_settlement_status,
      payment_attempts = source.payment_attempts,
      updated_at = source.updated_at
    WHEN NOT MATCHED THEN INSERT (
      order_id, order_number, customer_id, customer_tier, country_code,
      order_status, order_date, currency_code, total_amount_usd, tax_amount_usd,
      discount_amount_usd, total_paid_usd, payment_fees_usd, net_revenue_usd,
      payment_settlement_status, payment_attempts, created_at, updated_at
    ) VALUES (
      source.order_id, source.order_number, source.customer_id, source.customer_tier, source.country_code,
      source.order_status, source.order_date, source.currency_code, source.total_amount_usd, source.tax_amount_usd,
      source.discount_amount_usd, source.total_paid_usd, source.payment_fees_usd, source.net_revenue_usd,
      source.payment_settlement_status, source.payment_attempts, source.created_at, source.updated_at
    );`
    
    const upstreamNodes: UpstreamNode[] = [
      {
        id: 'stg_stripe__payments',
        name: 'stg_stripe__payments',
        type: 'view',
        package: 'analytics_dw.staging.stripe',
        rowCount: '1,420,800 rows',
        freshness: 'Pass · 12m ago',
        freshnessStatus: 'pass',
        description: 'Raw webhook payment charges, refunds, and gateway fee captures from Stripe API.',
      },
      {
        id: 'stg_shopify__orders',
        name: 'stg_shopify__orders',
        type: 'view',
        package: 'analytics_dw.staging.shopify',
        rowCount: '984,200 rows',
        freshness: 'Pass · 5m ago',
        freshnessStatus: 'pass',
        description: 'E-commerce transactional headers, tax summaries, discount allocations, and checkout states.',
      },
      {
        id: 'dim_customers',
        name: 'dim_customers',
        type: 'table',
        package: 'analytics_dw.core',
        rowCount: '248,500 rows',
        freshness: 'Pass · 1h ago',
        freshnessStatus: 'pass',
        description: 'Type-2 slowly changing dimension master record for authenticated customer accounts.',
      },
    ]
    
    const downstreamNodes: DownstreamNode[] = [
      {
        id: 'mart_finance_mrr',
        name: 'mart_finance_mrr',
        type: 'table',
        package: 'analytics_dw.marts.finance',
        consumers: 'Finance Team & NetSuite Sync',
        sla: 'Tier 1 · Daily 06:00 UTC',
        description: 'Monthly recurring revenue waterfall and cohort retention analysis table.',
      },
      {
        id: 'mart_executive_kpis',
        name: 'mart_executive_kpis',
        type: 'table',
        package: 'analytics_dw.marts.executive',
        consumers: 'Executive Dashboard & Board Reports',
        sla: 'Tier 1 · Daily 07:00 UTC',
        description: 'High-level business health rollup metrics: GMV, net revenue, CAC payback, and refund rates.',
      },
      {
        id: 'tableau_revenue_dashboard',
        name: 'tableau_revenue_dashboard',
        type: 'exposure',
        package: 'exposures.bi.tableau',
        consumers: '120 Active BI Users',
        sla: 'Hourly Sync',
        description: 'Production executive revenue tracking workbook and daily pacing alerts.',
      },
    ]
    
    const columns: ModelColumn[] = [
      {
        name: 'order_id',
        dataType: 'STRING (VARCHAR)',
        nullable: false,
        isPrimaryKey: true,
        tests: [
          { name: 'unique', status: 'pass' },
          { name: 'not_null', status: 'pass' },
        ],
        testDuration: '12.4ms',
        description: 'Surrogate primary key generated for each transactional order record.',
      },
      {
        name: 'order_number',
        dataType: 'STRING (VARCHAR)',
        nullable: false,
        tests: [
          { name: 'unique', status: 'pass' },
          { name: 'not_null', status: 'pass' },
        ],
        testDuration: '14.1ms',
        description: 'Human-readable sequential invoice reference number from checkout.',
      },
      {
        name: 'customer_id',
        dataType: 'STRING (VARCHAR)',
        nullable: false,
        isForeignKey: true,
        foreignKeyRef: 'dim_customers.customer_id',
        tests: [
          { name: 'not_null', status: 'pass' },
          { name: 'relationships', status: 'pass', detail: 'dim_customers' },
        ],
        testDuration: '28.6ms',
        description: 'Foreign key reference linking order to the verified master customer account.',
      },
      {
        name: 'customer_tier',
        dataType: 'STRING (VARCHAR)',
        nullable: true,
        tests: [
          {
            name: 'accepted_values',
            status: 'pass',
            detail: 'enterprise, pro, starter, free',
          },
        ],
        testDuration: '9.2ms',
        description: 'Customer loyalty and billing subscription tier at the time of purchase.',
      },
      {
        name: 'country_code',
        dataType: 'STRING (CHAR(2))',
        nullable: false,
        tests: [{ name: 'not_null', status: 'pass' }],
        testDuration: '8.4ms',
        description: 'ISO 3166-1 alpha-2 two-letter billing destination country code.',
      },
      {
        name: 'order_status',
        dataType: 'STRING (VARCHAR)',
        nullable: false,
        tests: [
          {
            name: 'accepted_values',
            status: 'pass',
            detail: 'completed, processing, shipped, cancelled, refunded',
          },
          { name: 'not_null', status: 'pass' },
        ],
        testDuration: '15.3ms',
        description: 'Current order fulfillment and delivery lifecycle state.',
      },
      {
        name: 'order_date',
        dataType: 'DATE',
        nullable: false,
        isClusterKey: true,
        tests: [{ name: 'not_null', status: 'pass' }],
        testDuration: '7.9ms',
        description: 'Calendar transaction date used as primary cluster and pruning key.',
      },
      {
        name: 'currency_code',
        dataType: 'STRING (CHAR(3))',
        nullable: false,
        tests: [
          {
            name: 'accepted_values',
            status: 'pass',
            detail: 'USD, EUR, GBP, CAD',
          },
        ],
        testDuration: '6.8ms',
        description: 'ISO 4217 three-letter currency code in which the charge was denominated.',
      },
      {
        name: 'total_amount_usd',
        dataType: 'DECIMAL(12,2)',
        nullable: false,
        tests: [{ name: 'not_null', status: 'pass' }],
        testDuration: '10.1ms',
        description: 'Gross order amount in base USD currency including tax and discounts.',
      },
      {
        name: 'tax_amount_usd',
        dataType: 'DECIMAL(10,2)',
        nullable: false,
        tests: [{ name: 'not_null', status: 'pass' }],
        testDuration: '9.0ms',
        description: 'Total sales tax and VAT amount captured for remittance.',
      },
      {
        name: 'discount_amount_usd',
        dataType: 'DECIMAL(10,2)',
        nullable: false,
        tests: [{ name: 'not_null', status: 'pass' }],
        testDuration: '8.1ms',
        description: 'Promotional discount and coupon deductions applied at checkout.',
      },
      {
        name: 'total_paid_usd',
        dataType: 'DECIMAL(12,2)',
        nullable: false,
        tests: [{ name: 'not_null', status: 'pass' }],
        testDuration: '11.3ms',
        description: 'Sum of all verified cleared customer payments from Stripe ledger.',
      },
      {
        name: 'payment_fees_usd',
        dataType: 'DECIMAL(10,2)',
        nullable: false,
        tests: [{ name: 'not_null', status: 'pass' }],
        testDuration: '9.8ms',
        description: 'Merchant interchange and gateway transaction processing fees.',
      },
      {
        name: 'net_revenue_usd',
        dataType: 'DECIMAL(12,2)',
        nullable: false,
        tests: [{ name: 'not_null', status: 'pass' }],
        testDuration: '10.5ms',
        description: 'Net recognized revenue after deducting gateway processing fees.',
      },
      {
        name: 'payment_settlement_status',
        dataType: 'STRING (VARCHAR)',
        nullable: false,
        tests: [
          {
            name: 'accepted_values',
            status: 'pass',
            detail: 'fully_paid, partially_paid, unpaid',
          },
          { name: 'not_null', status: 'pass' },
        ],
        testDuration: '8.7ms',
        description: 'Payment clearing and reconciliation flag calculated against order total.',
      },
      {
        name: 'payment_attempts',
        dataType: 'INTEGER',
        nullable: false,
        tests: [{ name: 'not_null', status: 'pass' }],
        testDuration: '6.2ms',
        description: 'Number of authorization attempts recorded in the payment gateway.',
      },
      {
        name: 'created_at',
        dataType: 'TIMESTAMP_TZ',
        nullable: false,
        tests: [{ name: 'not_null', status: 'pass' }],
        testDuration: '8.0ms',
        description: 'Initial order creation timestamp in UTC timezone.',
      },
      {
        name: 'updated_at',
        dataType: 'TIMESTAMP_TZ',
        nullable: false,
        tests: [{ name: 'not_null', status: 'pass' }],
        testDuration: '8.3ms',
        description: 'Last modified timestamp used as incremental watermark boundary.',
      },
    ]
    
    function escapeHtml(str: string): string {
      return str
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#039;')
    }
    
    export function highlightJinjaSqlLine(line: string): string {
      if (!line) return ''
      const escaped = escapeHtml(line)
    
      // Comments
      if (escaped.trim().startsWith('--') || escaped.trim().startsWith('{#')) {
        return `<span class="text-muted-foreground/60 italic">${escaped}</span>`
      }
    
      // Highlight Jinja tags: {{ ... }} and {% ... %}
      let result = escaped.replace(/(\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\})/g, (jinjaMatch) => {
        const inner = jinjaMatch
          .replace(
            /\b(config|ref|source|is_incremental|this|var|materialized|unique_key|on_schema_change|incremental_strategy|cluster_by|tags)\b/g,
            '<span class="text-warning font-semibold">$1</span>',
          )
          .replace(/(\{\{|\}\}|\{%|%\})/g, '<span class="text-chart-1 font-bold">$1</span>')
        return `<span class="bg-chart-1/10 px-1 py-0.5 rounded text-chart-1">${inner}</span>`
      })
    
      // SQL Keywords
      result = result.replace(
        /\b(WITH|SELECT|FROM|JOIN|LEFT JOIN|INNER JOIN|RIGHT JOIN|FULL JOIN|ON|WHERE|GROUP BY|ORDER BY|AND|OR|NOT|AS|CASE|WHEN|THEN|ELSE|END|MERGE INTO|USING|WHEN MATCHED|WHEN NOT MATCHED|INSERT|UPDATE|SET|VALUES|OVER|PARTITION BY)\b/g,
        '<span class="text-info font-semibold">$1</span>',
      )
    
      // SQL Functions
      result = result.replace(
        /\b(coalesce|sum|count|max|min|avg|round|cast|date_trunc|concat)\b/gi,
        '<span class="text-chart-2 font-medium">$1</span>',
      )
    
      // Strings
      result = result.replace(/(&#039;[^&#039;]*&#039;)/g, '<span class="text-success">$1</span>')
    
      return result
    }
    
    export function highlightCompiledSqlLine(line: string): string {
      if (!line) return ''
      const escaped = escapeHtml(line)
    
      // Comments
      if (escaped.trim().startsWith('--')) {
        return `<span class="text-muted-foreground/60 italic">${escaped}</span>`
      }
    
      let result = escaped.replace(
        /\b(MERGE INTO|USING|WHEN MATCHED|WHEN NOT MATCHED|THEN UPDATE SET|THEN INSERT|VALUES|WITH|SELECT|FROM|LEFT JOIN|INNER JOIN|RIGHT JOIN|JOIN|ON|WHERE|GROUP BY|ORDER BY|AND|OR|NOT|AS|CASE|WHEN|THEN|ELSE|END)\b/g,
        '<span class="text-info font-semibold">$1</span>',
      )
    
      result = result.replace(
        /\b(coalesce|sum|count|max|min|avg|round|cast)\b/gi,
        '<span class="text-chart-2 font-medium">$1</span>',
      )
    
      // Identifiers in quotes
      result = result.replace(/(&quot;[A-Z0-9_]+&quot;)/g, '<span class="text-warning font-medium">$1</span>')
    
      // Strings
      result = result.replace(/(&#039;[^&#039;]*&#039;)/g, '<span class="text-success">$1</span>')
    
      return result
    }
    
    const NODE_BASE =
      'focus-visible:ring-ring flex w-full cursor-pointer flex-col rounded-md border p-1.5 text-left transition-[background-color,box-shadow] focus-visible:ring-2 focus-visible:outline-none'
    
    /**
     * dbt Cloud style model studio: DAG lineage, model docs, Jinja / compiled SQL viewers and
     * the column contract test suite. Port of the React `DbtModelGraph` block, 1:1.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-dbt-model-graph, [ui-dbt-model-graph]',
      standalone: true,
      // React renders the root <div> itself: keep this wrapper out of the layout.
      host: { class: 'contents' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiInputComponent,
        UiTableComponent,
        UiTableHeaderComponent,
        UiTableBodyComponent,
        UiTableRowComponent,
        UiTableHeadComponent,
        UiTableCellComponent,
        UiTabsComponent,
        UiTabsListComponent,
        UiTabsTriggerComponent,
        UiTabsContentComponent,
      ],
      template: `
        <div data-slot="dbt-model-graph" [class]="rootClass">
          <!-- TOP HEADER -->
          <header class="border-border bg-card/70 border-b px-4 py-3 sm:px-6">
            <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
              <!-- Left: Model Identity & Badges -->
              <div class="flex flex-wrap items-center gap-2.5">
                <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg shadow-xs">
                  <lucide-icon [img]="Workflow" class="size-4" />
                </div>
    
                <div class="flex flex-wrap items-center gap-2">
                  <div class="flex items-center gap-1.5 font-mono text-sm font-semibold tracking-tight">
                    <span class="text-muted-foreground font-normal">model.analytics.</span>
                    <span class="text-foreground">fct_orders</span>
                  </div>
    
                  <!-- Copy ref pill -->
                  <button
                    type="button"
                    class="border-border bg-muted/50 text-muted-foreground hover:text-foreground hover:bg-muted inline-flex min-h-6 items-center gap-1 rounded-md border px-2 py-0.5 font-mono text-xs transition-colors"
                    title="Copy dbt ref() macro"
                    (click)="copyModelRef()"
                  >
                    @if (copiedRef()) {
                      <lucide-icon [img]="Check" class="text-success size-3" />
                    } @else {
                      <lucide-icon [img]="Copy" class="size-3" />
                    }
                    <span>{{ copiedRef() ? 'Copied Ref!' : dbtMacroRef }}</span>
                  </button>
                </div>
    
                <div class="flex flex-wrap items-center gap-1.5">
                  <ui-badge wrap variant="outline" class="gap-1 font-mono text-xs font-normal">
                    <lucide-icon [img]="Layers" class="text-info size-3" />
                    Table · Incremental
                  </ui-badge>
    
                  <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">analytics_dw v1.8</ui-badge>
    
                  <ui-badge
                    wrap
                    variant="outline"
                    class="border-success/30 bg-success/10 text-success gap-1.5 font-mono text-xs font-medium"
                  >
                    <span class="bg-success size-1.5 animate-pulse rounded-full"></span>
                    {{ buildStatusText() }}
                  </ui-badge>
                </div>
              </div>
    
              <!-- Right: Primary & Secondary Actions -->
              <div class="flex flex-wrap items-center gap-2">
                <button
                  ui-button
                  variant="outline"
                  size="sm"
                  class="h-8 gap-1.5 text-xs font-medium"
                  [disabled]="isTesting()"
                  (click)="handleTestModel()"
                >
                  @if (isTesting()) {
                    <lucide-icon [img]="Loader2" class="size-3.5 animate-spin" />
                  } @else {
                    <lucide-icon [img]="CheckCircle2" class="text-success size-3.5" />
                  }
                  {{ isTesting() ? 'Running 7 Tests...' : 'Test Model (dbt test)' }}
                </button>
    
                <button
                  ui-button
                  variant="default"
                  size="sm"
                  class="bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-1.5 text-xs font-semibold shadow-xs"
                  [disabled]="isBuilding()"
                  (click)="handleBuildModel()"
                >
                  @if (isBuilding()) {
                    <lucide-icon [img]="Loader2" class="size-3.5 animate-spin" />
                  } @else {
                    <lucide-icon [img]="Play" class="size-3.5 fill-current" />
                  }
                  {{ isBuilding() ? 'Building (dbt run)...' : 'Build Model (dbt run)' }}
                </button>
              </div>
            </div>
    
            <!-- Secondary Metadata Strip -->
            <div
              class="border-border/60 text-muted-foreground mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t pt-2.5 font-mono text-xs"
            >
              <div class="flex items-center gap-1.5">
                <lucide-icon [img]="Database" class="text-info size-3" />
                <span>Warehouse:</span>
                <span class="text-foreground font-medium">Snowflake PROD_WH (XS)</span>
              </div>
              <div class="flex items-center gap-1.5">
                <lucide-icon [img]="HardDrive" class="text-warning size-3" />
                <span>Target Schema:</span>
                <span class="text-foreground font-medium">ANALYTICS_PROD.MARTS</span>
              </div>
              <div class="flex items-center gap-1.5">
                <lucide-icon [img]="Clock" class="text-muted-foreground size-3" />
                <span>Last Built:</span>
                <span class="text-foreground">{{ lastBuildTime() }}</span>
              </div>
              <div class="flex items-center gap-1.5">
                <lucide-icon [img]="ShieldCheck" class="text-success size-3" />
                <span>Contract Tests:</span>
                <span class="text-success font-medium">7/7 Passing</span>
              </div>
              <div class="flex items-center gap-1.5">
                <lucide-icon [img]="Table2" class="text-chart-1 size-3" />
                <span>Row Count:</span>
                <span class="text-foreground">2,840,190 rows</span>
              </div>
            </div>
          </header>
    
          <!-- 2-COLUMN MODEL STUDIO LAYOUT -->
          <div class="divide-border grid flex-1 grid-cols-1 divide-y lg:grid-cols-12 lg:divide-x lg:divide-y-0">
            <!-- LEFT PANEL: METADATA & DAG LINEAGE -->
            <aside class="bg-muted/15 flex flex-col space-y-4 p-4 sm:p-5 lg:col-span-5">
              <!-- Interactive Lineage DAG Card -->
              <ui-card class="border-border bg-card overflow-hidden border p-3.5 shadow-none">
                <div class="flex flex-wrap items-center justify-between pb-2.5">
                  <div class="flex items-center gap-1.5 text-xs font-semibold">
                    <lucide-icon [img]="GitBranch" class="text-primary size-3.5" />
                    <span>DAG Lineage Dependency Graph</span>
                  </div>
                  <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">
                    -1 Upstream · +1 Downstream
                  </ui-badge>
                </div>
    
                <!-- Visual DAG Node Canvas -->
                <div class="border-border/70 bg-muted/30 relative rounded-lg border p-3">
                  <div class="grid grid-cols-3 items-center gap-2 text-xs">
                    <!-- Upstream Column -->
                    <div class="space-y-1.5">
                      <span class="text-muted-foreground block text-center font-mono text-xs font-medium">
                        Upstream (3)
                      </span>
                      @for (node of upstreamNodes; track node.id) {
                        <button
                          type="button"
                          [class]="nodeClass(node.id)"
                          [attr.aria-pressed]="selectedNodeId() === node.id"
                          (click)="selectedNodeId.set(node.id)"
                        >
                          <div class="flex flex-wrap items-center justify-between">
                            <span class="truncate font-mono text-xs font-medium">{{ node.name }}</span>
                          </div>
                          <div
                            class="text-muted-foreground flex flex-wrap items-center justify-between pt-0.5 font-mono text-xs"
                          >
                            <span class="uppercase">{{ node.type }}</span>
                            <span class="bg-success size-1.5 rounded-full"></span>
                          </div>
                        </button>
                      }
                    </div>
    
                    <!-- Center Current Node -->
                    <div class="flex flex-col items-center justify-center space-y-1 px-1">
                      <span class="text-primary font-mono text-xs font-semibold">Current Model</span>
                      <button
                        type="button"
                        [class]="currentNodeClass()"
                        [attr.aria-pressed]="selectedNodeId() === 'fct_orders'"
                        (click)="selectedNodeId.set('fct_orders')"
                      >
                        <lucide-icon [img]="Workflow" class="text-primary mb-1 size-4" />
                        <span class="text-foreground font-mono text-xs font-bold">fct_orders</span>
                        <ui-badge wrap variant="outline" class="border-primary/40 mt-1 font-mono text-xs">
                          incremental
                        </ui-badge>
                      </button>
                    </div>
    
                    <!-- Downstream Column -->
                    <div class="space-y-1.5">
                      <span class="text-muted-foreground block text-center font-mono text-xs font-medium">
                        Downstream (3)
                      </span>
                      @for (node of downstreamNodes; track node.id) {
                        <button
                          type="button"
                          [class]="nodeClass(node.id)"
                          [attr.aria-pressed]="selectedNodeId() === node.id"
                          (click)="selectedNodeId.set(node.id)"
                        >
                          <div class="flex flex-wrap items-center justify-between">
                            <span class="truncate font-mono text-xs font-medium">{{ node.name }}</span>
                          </div>
                          <div
                            class="text-muted-foreground flex flex-wrap items-center justify-between pt-0.5 font-mono text-xs"
                          >
                            <span class="uppercase">{{ node.type }}</span>
                            <span class="bg-success size-1.5 rounded-full"></span>
                          </div>
                        </button>
                      }
                    </div>
                  </div>
                </div>
              </ui-card>
    
              <!-- Model Documentation & Metadata Card -->
              <ui-card class="border-border bg-card space-y-3 border p-4 shadow-none">
                <div class="border-border flex flex-wrap items-center justify-between border-b pb-2">
                  <div class="flex items-center gap-1.5 text-xs font-semibold">
                    <lucide-icon [img]="Info" class="text-primary size-3.5" />
                    <span>Model Documentation &amp; Metadata</span>
                  </div>
                  <ui-badge wrap variant="outline" class="font-mono text-xs">Contract Enforced</ui-badge>
                </div>
    
                <p class="text-muted-foreground text-xs leading-relaxed">
                  Core transactional orders fact table at individual order grain. Captures gross revenue metrics, sales tax
                  breakdowns, promotional discount allocations, payment gateway fees, and settlement lifecycle stages.
                </p>
    
                <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
                  <div class="border-border/60 bg-muted/20 space-y-0.5 rounded-md border p-2 text-xs">
                    <span class="text-muted-foreground text-xs">Model Owner</span>
                    <div class="flex items-center gap-1.5 font-medium">
                      <lucide-icon [img]="User" class="text-primary size-3" />
                      <span>Analytics Engineering</span>
                    </div>
                  </div>
    
                  <div class="border-border/60 bg-muted/20 space-y-0.5 rounded-md border p-2 text-xs">
                    <span class="text-muted-foreground text-xs">Freshness SLA</span>
                    <div class="text-success flex items-center gap-1.5 font-medium">
                      <lucide-icon [img]="Zap" class="text-warning size-3" />
                      <span>&lt; 2 hours (Compliant)</span>
                    </div>
                  </div>
                </div>
    
                <!-- Tags -->
                <div class="space-y-1.5 pt-1">
                  <span class="text-muted-foreground flex items-center gap-1 text-xs">
                    <lucide-icon [img]="Tag" class="size-3" />
                    Model Tags
                  </span>
                  <div class="flex flex-wrap items-center gap-1.5">
                    <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">#finance</ui-badge>
                    <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">#core</ui-badge>
                    <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">#daily_sla</ui-badge>
                    <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">#tier-1-kpi</ui-badge>
                  </div>
                </div>
    
                <!-- Cluster Keys & Strategy -->
                <div class="border-border/60 bg-muted/30 space-y-1.5 rounded-lg border p-2.5 font-mono text-xs">
                  <div class="flex justify-between">
                    <span class="text-muted-foreground">Incremental Strategy:</span>
                    <span class="text-foreground font-semibold">Merge (unique_key: order_id)</span>
                  </div>
                  <div class="flex justify-between">
                    <span class="text-muted-foreground">Cluster Keys:</span>
                    <span class="text-foreground font-semibold">order_date, customer_id</span>
                  </div>
                </div>
              </ui-card>
    
              <!-- Upstream Sources & References List -->
              <ui-card class="border-border bg-card space-y-2.5 border p-4 shadow-none">
                <div class="flex flex-wrap items-center justify-between">
                  <div class="flex items-center gap-1.5 text-xs font-semibold">
                    <lucide-icon [img]="Link2" class="text-info size-3.5" />
                    <span>Upstream Sources &amp; References ({{ upstreamNodes.length }})</span>
                  </div>
                  <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">
                    {{ upstreamNodes.length }} models
                  </ui-badge>
                </div>
    
                <div class="space-y-2">
                  @for (node of upstreamNodes; track node.id) {
                    <div
                      class="border-border/70 bg-muted/20 hover:bg-muted/40 rounded-lg border p-2.5 text-xs transition-colors"
                    >
                      <div class="flex flex-wrap items-center justify-between">
                        <span class="text-foreground font-mono font-semibold">{{ node.name }}</span>
                        <ui-badge wrap variant="outline" class="font-mono text-xs uppercase">{{ node.type }}</ui-badge>
                      </div>
                      <p class="text-muted-foreground mt-1 text-xs">{{ node.description }}</p>
                      <div class="text-muted-foreground mt-2 flex flex-wrap items-center justify-between font-mono text-xs">
                        <span>{{ node.rowCount }}</span>
                        <span class="text-success font-medium">{{ node.freshness }}</span>
                      </div>
                    </div>
                  }
                </div>
              </ui-card>
    
              <!-- Downstream Marts & BI Exposures List -->
              <ui-card class="border-border bg-card space-y-2.5 border p-4 shadow-none">
                <div class="flex flex-wrap items-center justify-between">
                  <div class="flex items-center gap-1.5 text-xs font-semibold">
                    <lucide-icon [img]="ArrowUpRight" class="text-warning size-3.5" />
                    <span>Downstream Marts &amp; BI Exposures ({{ downstreamNodes.length }})</span>
                  </div>
                  <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">
                    {{ downstreamNodes.length }} targets
                  </ui-badge>
                </div>
    
                <div class="space-y-2">
                  @for (node of downstreamNodes; track node.id) {
                    <div
                      class="border-border/70 bg-muted/20 hover:bg-muted/40 rounded-lg border p-2.5 text-xs transition-colors"
                    >
                      <div class="flex flex-wrap items-center justify-between">
                        <span class="text-foreground font-mono font-semibold">{{ node.name }}</span>
                        <ui-badge wrap variant="secondary" class="font-mono text-xs uppercase">{{ node.type }}</ui-badge>
                      </div>
                      <p class="text-muted-foreground mt-1 text-xs">{{ node.description }}</p>
                      <div class="text-muted-foreground mt-2 flex flex-wrap items-center justify-between font-mono text-xs">
                        <span class="truncate pr-2">Consumers: {{ node.consumers }}</span>
                        <span class="text-foreground shrink-0 font-medium">{{ node.sla }}</span>
                      </div>
                    </div>
                  }
                </div>
              </ui-card>
            </aside>
    
            <!-- RIGHT PANEL: TABS STUDIO -->
            <main class="bg-card flex flex-col overflow-hidden lg:col-span-7">
              <ui-tabs [value]="activeTab()" (valueChange)="setActiveTab($event)" class="flex flex-1 flex-col">
                <!-- Right Tab Header -->
                <div class="border-border bg-muted/30 flex flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
                  <div ui-tabs-list class="grid h-8 grid-cols-3">
                    <button ui-tabs-trigger value="model-sql" class="gap-1.5 text-xs">
                      <lucide-icon [img]="Code2" class="text-primary size-3.5" />
                      <span>Model SQL</span>
                    </button>
                    <button ui-tabs-trigger value="compiled-sql" class="gap-1.5 text-xs">
                      <lucide-icon [img]="FileCode" class="text-info size-3.5" />
                      <span>Compiled SQL</span>
                    </button>
                    <button ui-tabs-trigger value="contract" class="gap-1.5 text-xs">
                      <lucide-icon [img]="ShieldCheck" class="text-success size-3.5" />
                      <span>Model Contract &amp; Tests</span>
                    </button>
                  </div>
    
                  <!-- Actions per active tab -->
                  <div class="flex items-center gap-2">
                    @if (activeTab() === 'model-sql') {
                      <button ui-button variant="ghost" size="sm" class="h-7 gap-1 text-xs" (click)="copySourceSql()">
                        @if (copiedSql()) {
                          <lucide-icon [img]="Check" class="text-success size-3" />
                        } @else {
                          <lucide-icon [img]="Copy" class="size-3" />
                        }
                        <span>{{ copiedSql() ? 'Copied' : 'Copy SQL' }}</span>
                      </button>
                    }
    
                    @if (activeTab() === 'compiled-sql') {
                      <button ui-button variant="ghost" size="sm" class="h-7 gap-1 text-xs" (click)="copyCompiledSql()">
                        @if (copiedCompiled()) {
                          <lucide-icon [img]="Check" class="text-success size-3" />
                        } @else {
                          <lucide-icon [img]="Copy" class="size-3" />
                        }
                        <span>{{ copiedCompiled() ? 'Copied' : 'Copy Compiled' }}</span>
                      </button>
                    }
    
                    @if (activeTab() === 'contract') {
                      <ui-badge wrap variant="outline" class="text-success font-mono text-xs">
                        {{ passingTestsCount }} Tests Passing
                      </ui-badge>
                    }
                  </div>
                </div>
    
                <!-- TAB 1: MODEL SQL -->
                <ui-tabs-content value="model-sql" class="mt-0 flex flex-1 flex-col overflow-hidden">
                  <div
                    class="border-border bg-muted/15 text-muted-foreground flex flex-wrap items-center justify-between border-b px-4 py-1.5 font-mono text-xs"
                  >
                    <div class="flex items-center gap-2">
                      <span class="text-foreground font-medium">fct_orders.sql</span>
                      <span>·</span>
                      <span>Jinja + Snowflake Dialect</span>
                    </div>
                    <div class="flex items-center gap-3">
                      <span>{{ modelSqlLines.length }} lines · {{ modelSqlSource.length }} chars</span>
                    </div>
                  </div>
    
                  <div class="relative flex flex-1 overflow-auto bg-neutral-950 text-neutral-100 dark:bg-neutral-950">
                    <div
                      class="w-11 shrink-0 overflow-hidden border-r border-neutral-800 bg-neutral-900/60 py-3 pr-2 text-right font-mono text-xs leading-relaxed text-neutral-500 select-none"
                    >
                      @for (line of modelSqlHtml; track $index) {
                        <div>{{ $index + 1 }}</div>
                      }
                    </div>
                    <div class="flex-1 overflow-auto p-3 font-mono text-xs leading-relaxed">
                      @for (html of modelSqlHtml; track $index) {
                        <div class="font-mono whitespace-pre" [innerHTML]="html"></div>
                      }
                    </div>
                  </div>
                </ui-tabs-content>
    
                <!-- TAB 2: COMPILED SQL -->
                <ui-tabs-content value="compiled-sql" class="mt-0 flex flex-1 flex-col overflow-hidden">
                  <div
                    class="border-border bg-muted/15 text-muted-foreground flex flex-wrap items-center justify-between border-b px-4 py-1.5 font-mono text-xs"
                  >
                    <div class="flex items-center gap-2">
                      <span class="text-foreground font-medium">
                        target/compiled/analytics_dw/models/marts/fct_orders.sql
                      </span>
                    </div>
                    <div class="flex items-center gap-2">
                      <ui-badge wrap variant="outline" class="font-mono text-xs">
                        Est. Cost: 0.14 Credits · 1.4 GB Scan
                      </ui-badge>
                    </div>
                  </div>
    
                  <div class="relative flex flex-1 overflow-auto bg-neutral-950 text-neutral-100 dark:bg-neutral-950">
                    <div
                      class="w-11 shrink-0 overflow-hidden border-r border-neutral-800 bg-neutral-900/60 py-3 pr-2 text-right font-mono text-xs leading-relaxed text-neutral-500 select-none"
                    >
                      @for (line of compiledSqlHtml; track $index) {
                        <div>{{ $index + 1 }}</div>
                      }
                    </div>
                    <div class="flex-1 overflow-auto p-3 font-mono text-xs leading-relaxed">
                      @for (html of compiledSqlHtml; track $index) {
                        <div class="font-mono whitespace-pre" [innerHTML]="html"></div>
                      }
                    </div>
                  </div>
                </ui-tabs-content>
    
                <!-- TAB 3: MODEL CONTRACT & TESTS -->
                <ui-tabs-content value="contract" class="mt-0 flex flex-1 flex-col space-y-4 overflow-hidden p-4 sm:p-5">
                  <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                    <div class="relative w-full sm:w-72">
                      <lucide-icon
                        [img]="Search"
                        class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
                      />
                      <ui-input
                        [value]="columnSearch()"
                        (valueChange)="columnSearch.set($event)"
                        placeholder="Filter columns or tests..."
                        class="h-8 pl-8 font-mono text-xs"
                      />
                    </div>
    
                    <div class="flex flex-wrap items-center gap-2">
                      <ui-badge wrap variant="outline" class="text-success gap-1 font-mono text-xs">
                        <lucide-icon [img]="ShieldCheck" class="size-3" />
                        contract.enforced: true
                      </ui-badge>
                      <ui-badge wrap variant="secondary" class="font-mono text-xs">
                        {{ filteredColumns().length }} of {{ columns.length }} columns
                      </ui-badge>
                    </div>
                  </div>
    
                  <ui-card class="border-border flex-1 overflow-hidden border shadow-none">
                    <div class="overflow-x-auto">
                      <div ui-table>
                        <thead ui-table-header>
                          <tr ui-table-row class="bg-muted/40 hover:bg-muted/40">
                            <th ui-table-head class="text-xs font-semibold">Column Name</th>
                            <th ui-table-head class="text-xs font-semibold">Data Type</th>
                            <th ui-table-head class="text-xs font-semibold">Nullability</th>
                            <th ui-table-head class="text-xs font-semibold">Active Tests &amp; Constraints</th>
                            <th ui-table-head class="text-xs font-semibold">Latency</th>
                            <th ui-table-head class="text-xs font-semibold">Description</th>
                          </tr>
                        </thead>
                        <tbody ui-table-body>
                          @for (col of filteredColumns(); track col.name) {
                            <tr ui-table-row class="text-xs">
                              <td ui-table-cell class="font-mono font-medium">
                                <div class="flex items-center gap-1.5">
                                  @if (col.isPrimaryKey) {
                                    <lucide-icon [img]="Key" class="text-warning size-3.5 shrink-0" />
                                  } @else if (col.isForeignKey) {
                                    <lucide-icon [img]="Link2" class="text-info size-3.5 shrink-0" />
                                  }
                                  <span [class]="col.isPrimaryKey ? 'text-warning font-semibold' : ''">{{ col.name }}</span>
                                </div>
                              </td>
                              <td ui-table-cell>
                                <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">
                                  {{ col.dataType }}
                                </ui-badge>
                              </td>
                              <td ui-table-cell>
                                @if (!col.nullable) {
                                  <ui-badge wrap variant="outline" class="text-muted-foreground font-mono text-xs">
                                    NOT NULL
                                  </ui-badge>
                                } @else {
                                  <span class="text-muted-foreground font-mono text-xs">NULLABLE</span>
                                }
                              </td>
                              <td ui-table-cell>
                                <div class="flex flex-wrap items-center gap-1">
                                  @if (col.isPrimaryKey) {
                                    <ui-badge wrap class="border-warning/30 bg-warning/15 text-warning font-mono text-xs">
                                      PK
                                    </ui-badge>
                                  }
                                  @if (col.isClusterKey) {
                                    <ui-badge
                                      wrap
                                      variant="secondary"
                                      class="border-chart-1/30 bg-chart-1/15 text-chart-1 font-mono text-xs"
                                    >
                                      CLUSTER
                                    </ui-badge>
                                  }
                                  @for (test of col.tests; track test.name) {
                                    <ui-badge wrap variant="secondary" class="gap-1 font-mono text-xs">
                                      <span class="bg-success size-1.5 rounded-full"></span>
                                      {{ test.name }}{{ test.detail ? ' (' + test.detail + ')' : '' }}
                                    </ui-badge>
                                  }
                                </div>
                              </td>
                              <td ui-table-cell class="text-muted-foreground font-mono text-xs">{{ col.testDuration }}</td>
                              <td ui-table-cell class="text-muted-foreground max-w-[220px] truncate text-xs">
                                {{ col.description }}
                              </td>
                            </tr>
                          }
                        </tbody>
                      </div>
                    </div>
                  </ui-card>
    
                  <!-- Bottom Summary Banner -->
                  <div
                    class="border-border bg-muted/20 flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3 text-xs"
                  >
                    <div class="flex items-center gap-2">
                      <lucide-icon [img]="ShieldCheck" class="text-success size-4" />
                      <span class="text-foreground font-medium">Model Contract Coverage: 100% Enforced</span>
                    </div>
                    <div class="text-muted-foreground flex items-center gap-4 font-mono text-xs">
                      <span>18 Columns</span>
                      <span>·</span>
                      <span>21 Assertions</span>
                      <span>·</span>
                      <span class="text-success font-semibold">0 Failures</span>
                    </div>
                  </div>
                </ui-tabs-content>
              </ui-tabs>
            </main>
          </div>
        </div>
      `,
    })
    export class UiDbtModelGraphComponent {
      @Input('class') className?: string
    
      readonly activeTab = signal<DbtModelGraphTab>('model-sql')
      readonly columnSearch = signal('')
      readonly isBuilding = signal(false)
      readonly isTesting = signal(false)
      readonly copiedRef = signal(false)
      readonly copiedSql = signal(false)
      readonly copiedCompiled = signal(false)
      readonly buildStatusText = signal('Success · Built in 14.2s')
      readonly lastBuildTime = signal('12 mins ago')
      readonly selectedNodeId = signal('fct_orders')
    
      readonly dbtMacroRef = "{{ ref('fct_orders') }}"
      readonly modelSqlSource = modelSqlSource
      readonly modelSqlLines = modelSqlSource.split('\n')
      readonly modelSqlHtml = this.modelSqlLines.map(highlightJinjaSqlLine)
      readonly compiledSqlHtml = compiledSqlSource.split('\n').map(highlightCompiledSqlLine)
      readonly upstreamNodes = upstreamNodes
      readonly downstreamNodes = downstreamNodes
      readonly columns = columns
      readonly passingTestsCount = columns.reduce(
        (acc, col) => acc + col.tests.filter((t) => t.status === 'pass').length,
        0,
      )
    
      readonly filteredColumns = computed(() => {
        const query = this.columnSearch().trim().toLowerCase()
        if (!query) return columns
        return columns.filter(
          (col) =>
            col.name.toLowerCase().includes(query) ||
            col.dataType.toLowerCase().includes(query) ||
            col.description.toLowerCase().includes(query) ||
            col.tests.some(
              (t) => t.name.toLowerCase().includes(query) || (t.detail && t.detail.toLowerCase().includes(query)),
            ),
        )
      })
    
      protected readonly ArrowUpRight = ArrowUpRight
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Clock = Clock
      protected readonly Code2 = Code2
      protected readonly Copy = Copy
      protected readonly Database = Database
      protected readonly FileCode = FileCode
      protected readonly GitBranch = GitBranch
      protected readonly HardDrive = HardDrive
      protected readonly Info = Info
      protected readonly Key = Key
      protected readonly Layers = Layers
      protected readonly Link2 = Link2
      protected readonly Loader2 = Loader2
      protected readonly Play = Play
      protected readonly Search = Search
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Table2 = Table2
      protected readonly Tag = Tag
      protected readonly User = User
      protected readonly Workflow = Workflow
      protected readonly Zap = Zap
    
      private readonly timers = new Set<ReturnType<typeof setTimeout>>()
    
      constructor() {
        inject(DestroyRef).onDestroy(() => this.timers.forEach((t) => clearTimeout(t)))
      }
    
      get rootClass(): string {
        return cn(
          'bg-background text-foreground border-border flex w-full flex-col overflow-hidden rounded-xl border shadow-xs',
          this.className,
        )
      }
    
      nodeClass(id: string): string {
        return cn(
          NODE_BASE,
          this.selectedNodeId() === id
            ? 'border-primary bg-primary/10 text-primary shadow-xs'
            : 'border-border bg-background hover:bg-muted/80 text-foreground',
        )
      }
    
      currentNodeClass(): string {
        return cn(
          'border-primary bg-primary/10 ring-primary/20 focus-visible:ring-ring flex w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 p-2.5 text-center shadow-xs ring-2 focus-visible:ring-2 focus-visible:outline-none',
          this.selectedNodeId() === 'fct_orders' && 'ring-primary/40',
        )
      }
    
      setActiveTab(value: string): void {
        if (value === 'model-sql' || value === 'compiled-sql' || value === 'contract') this.activeTab.set(value)
      }
    
      handleBuildModel(): void {
        if (this.isBuilding()) return
        this.isBuilding.set(true)
        this.later(1200, () => {
          this.isBuilding.set(false)
          this.buildStatusText.set('Success · Built in 14.2s (Just now)')
          this.lastBuildTime.set('Just now')
        })
      }
    
      handleTestModel(): void {
        if (this.isTesting()) return
        this.isTesting.set(true)
        this.later(900, () => this.isTesting.set(false))
      }
    
      copyModelRef(): void {
        this.copy(this.dbtMacroRef, this.copiedRef)
      }
    
      copySourceSql(): void {
        this.copy(modelSqlSource, this.copiedSql)
      }
    
      copyCompiledSql(): void {
        this.copy(compiledSqlSource, this.copiedCompiled)
      }
    
      private copy(text: string, flag: { set(v: boolean): void }): void {
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          navigator.clipboard.writeText(text).catch(() => {})
          flag.set(true)
          this.later(2000, () => flag.set(false))
        }
      }
    
      private later(ms: number, fn: () => void): void {
        const t = setTimeout(() => {
          this.timers.delete(t)
          fn()
        }, ms)
        this.timers.add(t)
      }
    }
    
  • src/app/components/blocks/dbt-model-graph/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/dbt-model-graph.json