/** * Copyright (c) 2020-present, Goldman Sachs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { observer } from 'mobx-react-lite'; import { useState } from 'react'; import { ChevronDownIcon, ChevronRightIcon, CompressIcon, CopyIcon, ExpandAllIcon, ExternalLinkIcon, EyeIcon, FilterIcon, KeyIcon, PURE_DatabaseIcon, PURE_DatabaseSchemaIcon, PURE_DatabaseTableIcon, PURE_DatabaseTableJoinIcon, PURE_DataProductIcon, SearchIcon, TimesIcon, clsx, } from '@finos/legend-art'; import { type Column, type FilterMapping, type GroupByMapping, type IncludeStore, type Schema, type Table, type View, } from '@finos/legend-graph'; import { DatabaseAnnotationDisplay } from './DatabaseAnnotationDisplay.js'; import { type DatabaseEditorState, getRelationNodeId, getSchemaNodeId, } from '../../../../stores/editor/editor-state/element-editor-state/DatabaseEditorState.js'; import { getColumnTypeLabel, getTableColumns, isCrossDatabaseJoin, isPrimaryKey, isSelfJoin, matchesSearch, resolveFilterFormula, resolveJoinFormula, resolveViewColumnFormula, resolveViewGroupByFormula, summarizeMilestoning, } from './DatabaseDiagramHelper.js'; // `ColumnMapping` isn't re-exported from `@finos/legend-graph` (it's only // useful in the context of a Table/View, never standalone). Recover it via // indexed access on `View` so we stay in sync without a deep import path. type ColumnMapping = View['columnMappings'][number]; /** * Small inline copy-to-clipboard button rendered next to rendered Pure code * (filter/join/view-column/groupBy formulas). Two-state visual: shows the * default copy icon, briefly flips to a "Copied!" label after a successful * write, then resets. Click stops propagation so the surrounding row's * click handler (which usually selects/focuses the row) doesn't fire. * * NOTE: rendered as a `` rather than a ` ); }, ); /** * Renders one column-mapping under an expanded View. Visually similar to a * table-column row but the secondary text is the formula placeholder rather * than a SQL type. Not selectable individually in the MVP — clicking just * focuses the parent view. */ const DatabaseTreeViewColumnRow = observer( (props: { editorState: DatabaseEditorState; view: View; columnMapping: ColumnMapping; }) => { const { editorState, view, columnMapping } = props; const isPK = isPrimaryKey(view, columnMapping.columnName); const isSelected = editorState.selectedRelation === view && editorState.selectedViewColumnName === columnMapping.columnName; // Pull the live formula from the editor state. Re-renders when the // batched engine call resolves and updates `viewColumnFormulas`. const formula = resolveViewColumnFormula( editorState.viewColumnFormulas, view.schema.name, view.name, columnMapping.columnName, ); const isLoading = editorState.isLoadingViewColumnFormulas && !editorState.viewColumnFormulas.has( `${view.schema.name}.${view.name}.${columnMapping.columnName}`, ); return ( ); }, ); /** * Renders one Table row plus (when expanded) its Column children. Clicking * the row both *selects the table on the canvas* (with a pan-to) AND toggles * expansion. Bundling these is intentional — when a user navigates to a * relation, they almost always want to see its columns too. */ const DatabaseTreeTableRow = observer( (props: { editorState: DatabaseEditorState; table: Table }) => { const { editorState, table } = props; const query = editorState.searchText.trim().toLowerCase(); if (!relationMatchesSearch(table, query)) { return null; } const id = getRelationNodeId(table.schema.name, table.name); // When a filter is active force-expand the row so matches deeper in // the tree are immediately visible without an extra click. const isExpanded = query !== '' || editorState.expandedRelationIds.has(id); const isSelected = editorState.selectedRelation === table; const columns = getTableColumns(table); // When filtering, only show columns that match the query (or all // columns if the table itself matched by name). const tableNameMatches = matchesSearch(table.name, query); const visibleColumns = query === '' || tableNameMatches ? columns : columns.filter((c) => matchesSearch(c.name, query)); return (
{isExpanded && (
{/* * Milestoning meta-rows: one per `Milestoning` declaration. * Read-only — click does nothing because there is no canvas * sub-element to focus on (the tag is rendered on the table * node header, which is already shown when the parent row is * selected). */} {table.milestoning.map((milestoning) => { const summary = summarizeMilestoning(milestoning); return (
milestoning {summary.label}
); })} {visibleColumns.map((column) => ( ))}
)}
); }, ); /** * Renders the View → Filter mapping reference under an expanded view. Shows * `.`. When the referenced filter belongs to * the database currently being edited, clicking navigates to it via * `focusOnFilter`; cross-database references are non-interactive (filters * from other databases aren't reachable from this editor). */ const DatabaseTreeViewFilterRow = observer( (props: { editorState: DatabaseEditorState; view: View; filterMapping: FilterMapping; }) => { const { editorState, view, filterMapping } = props; const ownerPath = filterMapping.filter.ownerReference.valueForSerialization ?? ''; const filterValue = filterMapping.filter.value; // Filters live on a Database; if the owning DB matches the one we're // editing we can navigate. Otherwise the row is informational. const isLocal = filterValue.owner === editorState.database; const display = `${ownerPath}.${filterMapping.filterName}`; const formula = isLocal ? resolveFilterFormula(editorState.filterFormulas, filterValue.name) : undefined; return ( ); }, ); /** * Renders one row per `View.groupBy.columns[i]` Pure expression. Each row * lazily reads its rendered formula from `viewGroupByFormulas` — until the * batched engine call resolves it falls back to a static placeholder. * * Rows aren't clickable: the underlying RelationalOperationElement isn't a * navigable graph node and the parent view is already focused via the * surrounding tree row. */ const DatabaseTreeViewGroupByRow = observer( (props: { editorState: DatabaseEditorState; view: View; index: number }) => { const { editorState, view, index } = props; const formula = resolveViewGroupByFormula( editorState.viewGroupByFormulas, view.schema.name, view.name, index, ); const isLoading = editorState.isLoadingViewGroupByFormulas && !editorState.viewGroupByFormulas.has( `${view.schema.name}.${view.name}.groupBy[${index}]`, ); return (
· {`[${index}]`} {isLoading ? : formula} {!isLoading && ( )}
); }, ); /** * Renders one View row plus (when expanded) its column-mapping children. * Visually distinct from the table row via the eye icon. Same click semantics * as tables (select + expand). */ const DatabaseTreeViewRow = observer( (props: { editorState: DatabaseEditorState; view: View }) => { const { editorState, view } = props; const query = editorState.searchText.trim().toLowerCase(); if (!relationMatchesSearch(view, query)) { return null; } const id = getRelationNodeId(view.schema.name, view.name); // Same auto-expand-on-filter behavior as tables. const isExpanded = query !== '' || editorState.expandedRelationIds.has(id); const isSelected = editorState.selectedRelation === view; const groupBy: GroupByMapping | undefined = view.groupBy; const groupByCount = groupBy?.columns.length ?? 0; const viewNameMatches = matchesSearch(view.name, query); const visibleMappings = query === '' || viewNameMatches ? view.columnMappings : view.columnMappings.filter((m) => matchesSearch(m.columnName, query)); return (
{isExpanded && (
{visibleMappings.map((mapping) => ( ))} {/* Filter / groupBy meta-rows hidden under search to keep the * filtered result set tightly scoped to name matches. */} {query === '' && view.filter && ( )} {query === '' && groupBy?.columns.map((_, index) => ( ))}
)}
); }, ); /** * Renders one Schema header plus (when expanded) its Table and View children. * Tables come before views within each schema — both kinds are siblings in * the tree, distinguished only by icon and the column-row content. * * Schemas don't have a "selected" state — they only toggle. */ const DatabaseTreeSchemaRow = observer( (props: { editorState: DatabaseEditorState; schema: Schema }) => { const { editorState, schema } = props; const query = editorState.searchText.trim().toLowerCase(); if (!schemaMatchesSearch(schema, query)) { return null; } const schemaId = getSchemaNodeId(schema.name); // Force-expand under search so matches are immediately visible. const isExpanded = query !== '' || editorState.expandedSchemaIds.has(schemaId); const totalChildren = schema.tables.length + schema.views.length; return (
{isExpanded && (
{schema.tables.map((table) => ( ))} {schema.views.map((view) => ( ))}
)}
); }, ); /** * Renders one Lakehouse `IncludeStore` reference: an `ExternalLinkIcon`- * suffixed button whose body shows the generator element path and a small * `storeType` badge to differentiate the kinds of generator (DataProduct vs * IngestDefinition vs whatever future types appear). Clicking opens the * generator element in a new tab — same affordance as the classic * "Included Stores" rows above. Kept as a named sub-component (not an * inline IIFE) so the JSX in `DatabaseSchemaTree` stays readable. */ const LakehouseStoreRow = observer( (props: { editorState: DatabaseEditorState; spec: IncludeStore }) => { const { editorState, spec } = props; const path = spec.packageableElementPointer.valueForSerialization ?? spec.packageableElementPointer.value.path; return ( ); }, ); /** * The full side-panel tree. Renders schemas → (tables, views) → columns plus * a flat "Joins" section at the bottom (joins are cross-relation * relationships and don't naturally fit in the tree hierarchy). * * State (selection, expansion) lives entirely in `DatabaseEditorState` so it * survives reprocessing and so other components (the canvas) can react to it. */ export const DatabaseSchemaTree = observer( (props: { editorState: DatabaseEditorState }) => { const { editorState } = props; const { database } = editorState; const query = editorState.searchText.trim().toLowerCase(); // Pre-compute filtered counts so each section header surfaces "showing // N of M" feedback when the user is filtering. Cheap walks on the // already-typed query \u2014 we don't memoize because the per-render cost // is dominated by row JSX, not these filters. const visibleSchemas = query === '' ? database.schemas : database.schemas.filter((s) => schemaMatchesSearch(s, query)); const visibleJoins = query === '' ? database.joins : database.joins.filter((j) => matchesSearch(j.name, query)); const visibleFilters = query === '' ? database.filters : database.filters.filter((f) => matchesSearch(f.name, query)); return (
{/* * Toolbar: search box + expand-all / collapse-all buttons. Sits at * the top of the panel above all sections so it stays reachable * even when the panel is scrolled deep into a large schema list. */}
editorState.setSearchText(e.target.value)} spellCheck={false} /> {editorState.searchText !== '' && ( )}
{(database.stereotypes.length > 0 || database.taggedValues.length > 0) && (
Annotations
)}
Schemas{' '} {query === '' ? `(${database.schemas.length})` : `(${visibleSchemas.length}/${database.schemas.length})`}
{database.schemas.length === 0 ? ( ) : visibleSchemas.length === 0 ? ( ) : ( database.schemas.map((schema) => ( )) )}
Joins{' '} {query === '' ? `(${database.joins.length})` : `(${visibleJoins.length}/${database.joins.length})`}
{database.joins.length === 0 ? ( ) : visibleJoins.length === 0 ? ( ) : ( visibleJoins.map((join) => { const isSelected = editorState.selectedJoin === join; const formula = resolveJoinFormula( editorState.joinFormulas, join.name, ); const isLoading = editorState.isLoadingJoinFormulas && !editorState.joinFormulas.has(join.name); const selfJoin = isSelfJoin(join); const crossDb = !selfJoin && isCrossDatabaseJoin(join, database); return ( ); }) )}
{/* * Filters live at the database level (a flat `Database.filters[]`), * so they get their own bottom section — same shape as Joins. Each * row also surfaces the rendered Pure code below the name (italic * secondary line) so the operation logic is visible at a glance, * not just the filter's identifier. The formula is loaded async by * `loadFilterFormulas` and falls back to a placeholder until ready. */}
Filters{' '} {query === '' ? `(${database.filters.length})` : `(${visibleFilters.length}/${database.filters.length})`}
{database.filters.length === 0 ? ( ) : visibleFilters.length === 0 ? ( ) : ( visibleFilters.map((filter) => { const isSelected = editorState.selectedFilter === filter; const formula = resolveFilterFormula( editorState.filterFormulas, filter.name, ); const isLoading = editorState.isLoadingFilterFormulas && !editorState.filterFormulas.has(filter.name); return ( ); }) )}
{/* * `database.includes` lists other Database elements whose schemas, * joins, and filters are reachable from this one. Clicking a row * opens that included database as its own element editor (a new * tab in the tab manager) — the user can then explore it with the * same form-mode editor. We surface that "opens elsewhere" intent * with the `ExternalLinkIcon` on the right; the rest of the side * panel selects in-place, so the icon is the visual cue that this * row behaves differently. */} {database.includes.length > 0 && (
Included Stores{' '} ({database.includes.length})
{database.includes.map((ref) => { const path = ref.valueForSerialization ?? ref.value.path; return ( ); })}
)} {/* * `database.includedStoreSpecifications` is the newer Lakehouse- * oriented include list — references to a `DataProduct` or * `IngestDefinition` whose generated database schemas/joins/filters * become reachable from this one. Distinct from `database.includes` * (classic DB→DB inclusion) so it gets its own section. Each row * shows the generator element path and tags it with `storeType` so * users can tell DataProduct-backed includes from ingest-backed * ones at a glance. Clicking opens the generator element editor. */} {database.includedStoreSpecifications.length > 0 && (
Lakehouse Stores{' '} ({database.includedStoreSpecifications.length})
{database.includedStoreSpecifications.map((spec) => ( ))}
)}
); }, );