Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion web/marmot/src/components/asset/GovernedFieldRows.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 () => {
Expand Down
6 changes: 3 additions & 3 deletions web/marmot/src/components/extensions/EntityPanels.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
</script>

Expand Down
33 changes: 28 additions & 5 deletions web/marmot/src/lib/extensions/entity-panels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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 };
Expand All @@ -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'
Expand Down
13 changes: 11 additions & 2 deletions web/marmot/src/lib/metamodel/values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown> | 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,7 +76,14 @@
let canManageAssets = $derived(auth.hasPermission('assets', 'manage') && $domainWrite);
let metamodel = $state<MetamodelSchema | null>(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');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -544,7 +556,7 @@
</div>
<div class="hidden w-72 flex-shrink-0 lg:block">
<EntityPanels
entity={{ kind: 'asset', id: asset.id, mrn: asset.mrn }}
entity={{ kind: 'asset', id: asset.id, mrn: asset.mrn, assetType: governedType }}
placement="header"
/>
</div>
Expand Down Expand Up @@ -597,6 +609,7 @@
bind:asset
schema={metamodel}
fields={governed}
hiddenIncoming={panelOwned}
{editable}
onConflict={fetchAsset}
/>
Expand Down Expand Up @@ -668,7 +681,7 @@
<div class="mt-6">
<EntityTab
tab={activeTab}
entity={{ kind: 'asset', id: asset.id, mrn: asset.mrn }}
entity={{ kind: 'asset', id: asset.id, mrn: asset.mrn, assetType: governedType }}
/>
</div>
{:else}
Expand Down
38 changes: 38 additions & 0 deletions web/marmot/tests/entity-panels.test.ts
Original file line number Diff line number Diff line change
@@ -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'), []);
});
Loading