diff --git a/web/marmot/src/components/asset/GovernedFieldRows.svelte b/web/marmot/src/components/asset/GovernedFieldRows.svelte index 08e0489c..82de982b 100644 --- a/web/marmot/src/components/asset/GovernedFieldRows.svelte +++ b/web/marmot/src/components/asset/GovernedFieldRows.svelte @@ -48,12 +48,15 @@ schema, fields, editable = false, + hiddenIncoming = [], onConflict }: { asset: Asset; schema: MetamodelSchema; fields: MetamodelField[]; editable?: boolean; + /** Fields whose incoming links are shown elsewhere on the page. */ + hiddenIncoming?: string[]; onConflict?: () => void; } = $props(); @@ -123,7 +126,7 @@ .then((found) => { if (cancelled) return; for (const group of found) group.assets.forEach(rememberAsset); - incoming = found; + incoming = found.filter((group) => !hiddenIncoming.includes(group.field)); }) .catch(() => {}); return () => { diff --git a/web/marmot/src/components/extensions/EntityPanels.svelte b/web/marmot/src/components/extensions/EntityPanels.svelte index aef23d79..0bdf5b77 100644 --- a/web/marmot/src/components/extensions/EntityPanels.svelte +++ b/web/marmot/src/components/extensions/EntityPanels.svelte @@ -23,10 +23,10 @@ const shown = $derived( placement === 'side' ? [ - ...panelsFor($entityPanels, entity.kind, 'side'), - ...(withTabs ? panelsFor($entityPanels, entity.kind, 'tab') : []) + ...panelsFor($entityPanels, entity.kind, 'side', entity.assetType), + ...(withTabs ? panelsFor($entityPanels, entity.kind, 'tab', entity.assetType) : []) ] - : panelsFor($entityPanels, entity.kind, placement) + : panelsFor($entityPanels, entity.kind, placement, entity.assetType) ); diff --git a/web/marmot/src/lib/extensions/entity-panels.ts b/web/marmot/src/lib/extensions/entity-panels.ts index ee6c96eb..78c9c0f9 100644 --- a/web/marmot/src/lib/extensions/entity-panels.ts +++ b/web/marmot/src/lib/extensions/entity-panels.ts @@ -8,6 +8,8 @@ export interface EntityRef { kind: EntityKind; id: string; mrn?: string; + /** The profile's asset type of an asset, when it has one. */ + assetType?: string; } /** @@ -25,6 +27,10 @@ export type PanelPlacement = 'side' | 'tab' | 'header' | 'references'; export interface EntityPanel { id: string; kinds?: EntityKind[]; + /** Profile asset types the panel is for; empty shows it on every asset. */ + assetTypes?: string[]; + /** Profile fields the panel edits itself: the metadata sheet leaves them out where it shows. */ + fields?: string[]; placement?: PanelPlacement; /** Label and icon of the tab; `label` is read on render so it follows the locale. */ tab?: { label: () => string; icon: string }; @@ -41,16 +47,33 @@ export function registerEntityPanel(panel: EntityPanel): () => void { return () => entityPanels.update((panels) => panels.filter((item) => item !== panel)); } -export function panelsFor(panels: EntityPanel[], kind: EntityKind, placement: PanelPlacement) { +function applies(panel: EntityPanel, kind: EntityKind, assetType?: string): boolean { + if (panel.kinds && !panel.kinds.includes(kind)) return false; + if (!panel.assetTypes?.length) return true; + return kind === 'asset' && !!assetType && panel.assetTypes.includes(assetType); +} + +export function panelsFor( + panels: EntityPanel[], + kind: EntityKind, + placement: PanelPlacement, + assetType?: string +) { return panels.filter( - (panel) => - (!panel.kinds || panel.kinds.includes(kind)) && (panel.placement ?? 'side') === placement + (panel) => applies(panel, kind, assetType) && (panel.placement ?? 'side') === placement ); } +/** Ids of the profile fields that the panels shown for this entity edit themselves. */ +export function panelFields(panels: EntityPanel[], kind: EntityKind, assetType?: string): string[] { + return panels + .filter((panel) => applies(panel, kind, assetType)) + .flatMap((panel) => panel.fields ?? []); +} + /** Tab entries for a page's tab bar; their ids start with `ext-`. */ -export function panelTabs(panels: EntityPanel[], kind: EntityKind) { - return panelsFor(panels, kind, 'tab').map((panel) => ({ +export function panelTabs(panels: EntityPanel[], kind: EntityKind, assetType?: string) { + return panelsFor(panels, kind, 'tab', assetType).map((panel) => ({ id: `ext-${panel.id}`, label: panel.tab?.label() ?? panel.id, icon: panel.tab?.icon ?? 'material-symbols:extension' diff --git a/web/marmot/src/lib/metamodel/values.ts b/web/marmot/src/lib/metamodel/values.ts index 9666a856..14ead9d0 100644 --- a/web/marmot/src/lib/metamodel/values.ts +++ b/web/marmot/src/lib/metamodel/values.ts @@ -29,12 +29,21 @@ export function metadataPath(storage: string): string[] { * Keeps the fields that apply to the asset's type. A field scoped by `appliesTo.assetTypes` * disappears from an untyped asset or one of another type, as the server ignores it there. */ +/** The value of the profile's `asset_type` field on an asset, if the profile has one. */ +export function assetTypeOf( + fields: MetamodelField[], + metadata: Record | undefined +): string | undefined { + const typeField = fields.find((field) => field.id === 'asset_type'); + const value = typeField ? readMetadataValue(metadata, typeField.storage) : undefined; + return typeof value === 'string' ? value : undefined; +} + export function fieldsForAssetType( fields: MetamodelField[], metadata: Record | undefined ): MetamodelField[] { - const typeField = fields.find((field) => field.id === 'asset_type'); - const assetType = typeField ? readMetadataValue(metadata, typeField.storage) : undefined; + const assetType = assetTypeOf(fields, metadata); return fields.filter((field) => { const scope = field.appliesTo?.assetTypes; return !scope?.length || (typeof assetType === 'string' && scope.includes(assetType)); diff --git a/web/marmot/src/routes/discover/[type]/[service]/[name]/+page.svelte b/web/marmot/src/routes/discover/[type]/[service]/[name]/+page.svelte index 29a04da7..7982679b 100644 --- a/web/marmot/src/routes/discover/[type]/[service]/[name]/+page.svelte +++ b/web/marmot/src/routes/discover/[type]/[service]/[name]/+page.svelte @@ -12,11 +12,16 @@ import { fetchMetamodel } from '$lib/metamodel/api'; import type { MetamodelSchema } from '$lib/metamodel/types'; import FieldBadges from '$components/metamodel/FieldBadges.svelte'; - import { fieldsForAssetType, governedFields, governedPaths } from '$lib/metamodel/values'; + import { + assetTypeOf, + fieldsForAssetType, + governedFields, + governedPaths + } from '$lib/metamodel/values'; import Lineage from '$components/lineage/Lineage.svelte'; import EntityPanels from '$components/extensions/EntityPanels.svelte'; import EntityTab from '$components/extensions/EntityTab.svelte'; - import { entityPanels, panelTabs } from '$lib/extensions/entity-panels'; + import { entityPanels, panelFields, panelTabs } from '$lib/extensions/entity-panels'; import AssetContents from '$components/asset/AssetContents.svelte'; import SchemaEditor from '$components/schema/SchemaEditor.svelte'; import AssetEnvironmentsView from '$components/asset/AssetEnvironmentsView.svelte'; @@ -71,7 +76,14 @@ let canManageAssets = $derived(auth.hasPermission('assets', 'manage') && $domainWrite); let metamodel = $state(null); let allGoverned = $derived(metamodel?.enabled ? governedFields(metamodel.fields) : []); - let governed = $derived(fieldsForAssetType(allGoverned, asset?.metadata)); + let governedType = $derived(assetTypeOf(allGoverned, asset?.metadata)); + // A panel that edits fields of its own takes them out of the sheet. + let panelOwned = $derived(panelFields($entityPanels, 'asset', governedType)); + let governed = $derived( + fieldsForAssetType(allGoverned, asset?.metadata).filter( + (field) => !panelOwned.includes(field.id) + ) + ); let governedHidePaths = $derived(governedPaths(allGoverned)); let activeTab = $derived($page.url.searchParams.get('tab') || 'metadata'); @@ -316,7 +328,7 @@ return true; }) ); - let pageTabs = $derived([...visibleTabs, ...panelTabs($entityPanels, 'asset')]); + let pageTabs = $derived([...visibleTabs, ...panelTabs($entityPanels, 'asset', governedType)]); $effect(() => { if (assetType && assetService && assetName) { @@ -544,7 +556,7 @@ @@ -597,6 +609,7 @@ bind:asset schema={metamodel} fields={governed} + hiddenIncoming={panelOwned} {editable} onConflict={fetchAsset} /> @@ -668,7 +681,7 @@
{:else} diff --git a/web/marmot/tests/entity-panels.test.ts b/web/marmot/tests/entity-panels.test.ts new file mode 100644 index 00000000..2930f848 --- /dev/null +++ b/web/marmot/tests/entity-panels.test.ts @@ -0,0 +1,38 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + panelFields, + panelTabs, + panelsFor, + type EntityPanel +} from '../src/lib/extensions/entity-panels.ts'; + +const load = () => Promise.reject(new Error('not loaded in tests')); +const panels: EntityPanel[] = [ + { id: 'graph', placement: 'tab', load }, + { + id: 'org', + placement: 'tab', + assetTypes: ['line_of_business'], + fields: ['parent_area', 'related_areas'], + load + } +]; + +test('a panel scoped to asset types shows only on assets of those types', () => { + const ids = (kind: 'asset' | 'data_product', type?: string) => + panelTabs(panels, kind, type).map((tab) => tab.id); + assert.deepEqual(ids('asset', 'line_of_business'), ['ext-graph', 'ext-org']); + assert.deepEqual(ids('asset', 'table'), ['ext-graph']); + assert.deepEqual(ids('asset'), ['ext-graph']); + assert.deepEqual(ids('data_product', 'line_of_business'), ['ext-graph']); + assert.equal(panelsFor(panels, 'asset', 'side', 'line_of_business').length, 0); +}); + +test('the fields a panel edits are taken from the sheet only where the panel shows', () => { + assert.deepEqual(panelFields(panels, 'asset', 'line_of_business'), [ + 'parent_area', + 'related_areas' + ]); + assert.deepEqual(panelFields(panels, 'asset', 'table'), []); +});