diff --git a/i18n/en.pot b/i18n/en.pot index 2915ffb..8c7e099 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-04-13T07:00:14.840Z\n" -"PO-Revision-Date: 2026-04-13T07:00:14.840Z\n" +"POT-Creation-Date: 2026-04-14T15:22:40.054Z\n" +"PO-Revision-Date: 2026-04-14T15:22:40.054Z\n" msgid "Select a row to view relationships." msgstr "" @@ -43,6 +43,12 @@ msgstr "" msgid "Load more category option combos" msgstr "" +msgid "Open API" +msgstr "" + +msgid "Focus in graph" +msgstr "" + msgid "Colors" msgstr "" @@ -52,12 +58,102 @@ msgstr "" msgid "Click to focus - Right click opens the API" msgstr "" +msgid "Resource type" +msgstr "" + +msgid "Fields" +msgstr "" + +msgid "Filters (one per line or separated by ;)" +msgstr "" + +msgid "Page" +msgstr "" + +msgid "Page size" +msgstr "" + +msgid "Fetch all (paging=false)" +msgstr "" + +msgid "Fetch" +msgstr "" + +msgid "No results" +msgstr "" + +msgid "Avatar" +msgstr "" + msgid "Back" msgstr "" msgid "Help" msgstr "" +msgid "Metadata Visualizer" +msgstr "" + +msgid "Login {{baseUrl}}" +msgstr "" + +msgid "Base URL not found in manifest.webapp (see DHIS2-19708)" +msgstr "" + +msgid "Unknown JSON parsing error" +msgstr "" + +msgid "Metadata package JSON" +msgstr "" + +msgid "Metadata type" +msgstr "" + +msgid "Search" +msgstr "" + +msgid "Filter by id or name" +msgstr "" + +msgid "Select a metadata package JSON file to start" +msgstr "" + +msgid "Loading package..." +msgstr "" + +msgid "{{types}} types" +msgstr "" + +msgid "{{items}} items" +msgstr "" + +msgid "No results for this type/filter" +msgstr "" + +msgid "Relationships" +msgstr "" + +msgid "Direct only" +msgstr "" + +msgid "Expanded" +msgstr "" + +msgid "Select a row to view dependencies" +msgstr "" + +msgid "Selected metadata JSON" +msgstr "" + +msgid "Metadata views" +msgstr "" + +msgid "Instance Metadata" +msgstr "" + +msgid "JSON Package" +msgstr "" + msgid "Loading results..." msgstr "" diff --git a/i18n/es.po b/i18n/es.po index b31d0e8..2f12eae 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2026-04-13T07:00:14.840Z\n" +"POT-Creation-Date: 2026-04-14T15:22:40.054Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -43,6 +43,12 @@ msgstr "" msgid "Load more category option combos" msgstr "" +msgid "Open API" +msgstr "" + +msgid "Focus in graph" +msgstr "" + msgid "Colors" msgstr "Colores" @@ -52,12 +58,102 @@ msgstr "Texturas" msgid "Click to focus - Right click opens the API" msgstr "Click para enfocar - Boton derecho abre la API" +msgid "Resource type" +msgstr "" + +msgid "Fields" +msgstr "" + +msgid "Filters (one per line or separated by ;)" +msgstr "" + +msgid "Page" +msgstr "" + +msgid "Page size" +msgstr "" + +msgid "Fetch all (paging=false)" +msgstr "" + +msgid "Fetch" +msgstr "" + +msgid "No results" +msgstr "" + +msgid "Avatar" +msgstr "" + msgid "Back" msgstr "Volver" msgid "Help" msgstr "Ayuda" +msgid "Metadata Visualizer" +msgstr "" + +msgid "Login {{baseUrl}}" +msgstr "" + +msgid "Base URL not found in manifest.webapp (see DHIS2-19708)" +msgstr "" + +msgid "Unknown JSON parsing error" +msgstr "" + +msgid "Metadata package JSON" +msgstr "" + +msgid "Metadata type" +msgstr "" + +msgid "Search" +msgstr "" + +msgid "Filter by id or name" +msgstr "" + +msgid "Select a metadata package JSON file to start" +msgstr "" + +msgid "Loading package..." +msgstr "" + +msgid "{{types}} types" +msgstr "" + +msgid "{{items}} items" +msgstr "" + +msgid "No results for this type/filter" +msgstr "" + +msgid "Relationships" +msgstr "" + +msgid "Direct only" +msgstr "" + +msgid "Expanded" +msgstr "" + +msgid "Select a row to view dependencies" +msgstr "" + +msgid "Selected metadata JSON" +msgstr "" + +msgid "Metadata views" +msgstr "" + +msgid "Instance Metadata" +msgstr "" + +msgid "JSON Package" +msgstr "" + msgid "Loading results..." msgstr "" diff --git a/index.html b/index.html index 283b7f0..235580a 100644 --- a/index.html +++ b/index.html @@ -13,7 +13,7 @@ - Vite + React + TS + %APP_TITLE% diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 14b7515..6d6223d 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -8,6 +8,7 @@ import { UserTestRepository } from "$/data/repositories/UserTestRepository"; import { MetadataRepository } from "$/domain/repositories/MetadataRepository"; import { SystemRepository } from "$/domain/repositories/SystemRepository"; import { UserRepository } from "$/domain/repositories/UserRepository"; +import { BuildJsonPackageDependencyGraphUseCase } from "$/domain/usecases/metadata/BuildJsonPackageDependencyGraphUseCase"; import { BuildMetadataGraphUseCase } from "$/domain/usecases/metadata/BuildMetadataGraphUseCase"; import { ListCategoryOptionCombosUseCase } from "$/domain/usecases/metadata/ListCategoryOptionCombosUseCase"; import { ListMetadataUseCase } from "$/domain/usecases/metadata/ListMetadataUseCase"; @@ -33,6 +34,7 @@ function getCompositionRoot(repositories: Repositories) { metadata: { list: new ListMetadataUseCase(repositories), graph: new BuildMetadataGraphUseCase(repositories), + jsonPackageGraph: new BuildJsonPackageDependencyGraphUseCase(), listCategoryOptionCombos: new ListCategoryOptionCombosUseCase(repositories), }, }; diff --git a/src/domain/metadata/Identicon.ts b/src/domain/metadata/Identicon.ts index c198176..c005743 100644 --- a/src/domain/metadata/Identicon.ts +++ b/src/domain/metadata/Identicon.ts @@ -1,4 +1,3 @@ -import { ResourceType } from "$/domain/metadata/ResourceType"; import { sha256 } from "@noble/hashes/sha256"; import { bytesToHex } from "@noble/hashes/utils"; @@ -26,7 +25,7 @@ export type IdenticonResult = Readonly<{ background: string; }>; -export function identiconSeed(type: ResourceType, uid: string): string { +export function identiconSeed(type: string, uid: string): string { return `${type}:${uid}:v1`; } diff --git a/src/domain/metadata/JsonPackageIndex.ts b/src/domain/metadata/JsonPackageIndex.ts new file mode 100644 index 0000000..bb305cb --- /dev/null +++ b/src/domain/metadata/JsonPackageIndex.ts @@ -0,0 +1,387 @@ +type JsonRecord = Record; + +const typePriorityOrder = [ + "attributes", + "categoryOptionGroupSets", + "categoryOptionGroups", + "categoryOptionCombos", + "categoryOptions", + "categoryCombos", + "categories", + "dataElementGroups", + "dataElements", + "indicatorTypes", + "indicatorGroups", + "indicators", + "sections", + "dataSets", + "validationRuleGroups", + "visualizations", + "dashboards", + "maps", + "legendSets", + "userGroups", + "userRoles", + "users", +] as const; + +const coreMetadataTypes = [ + "categories", + "categoryCombos", + "categoryOptions", + "categoryOptionCombos", + "categoryOptionGroups", + "categoryOptionGroupSets", + "dataElements", + "dataElementGroups", + "dataSets", + "sections", + "indicatorTypes", + "indicatorGroups", + "indicators", + "legendSets", + "visualizations", + "dashboards", + "maps", + "validationRuleGroups", +] as const; + +const securityMetadataTypes = ["userGroups", "userRoles", "users"] as const; + +export type JsonTypeGraphPolicy = Readonly<{ + relatedTypes: readonly string[]; + noIncomingFromTypes?: readonly string[]; +}>; + +export const graphPolicyByCenterType: Readonly> = { + attributes: { + relatedTypes: unique([ + ...coreMetadataTypes, + ...securityMetadataTypes, + "attributes", + ]), + }, + categories: { + relatedTypes: [ + "categoryCombos", + "categoryOptions", + "categoryOptionCombos", + "categoryOptionGroups", + "categoryOptionGroupSets", + ], + }, + categoryCombos: { + relatedTypes: [ + "dataSets", + "dataElements", + "categoryOptionCombos", + "categoryOptions", + "categories", + ], + }, + categoryOptionCombos: { + relatedTypes: ["categoryCombos", "categories", "categoryOptions", "dataElements"], + }, + categoryOptionGroupSets: { + relatedTypes: ["categoryOptions", "categories", "categoryCombos"], + }, + categoryOptionGroups: { + relatedTypes: ["categoryOptions", "categories", "categoryOptionGroupSets"], + }, + categoryOptions: { + relatedTypes: [ + "categoryCombos", + "categories", + "categoryOptionCombos", + "categoryOptionGroups", + "categoryOptionGroupSets", + "dataElements", + ], + }, + dashboards: { + relatedTypes: ["visualizations", "maps", "indicators", "indicatorGroups"], + noIncomingFromTypes: ["indicatorTypes"], + }, + dataElementGroups: { + relatedTypes: ["dataElements"], + }, + dataElements: { + relatedTypes: [ + "dataElementGroups", + "dataSets", + "sections", + "programs", + "programStages", + "categoryCombos", + "categories", + ], + }, + dataSets: { + relatedTypes: [ + "sections", + "dataElements", + "dataElementGroups", + "categoryCombos", + "categories", + ], + noIncomingFromTypes: [ + "categoryCombos", + "categories", + "categoryOptions", + "categoryOptionCombos", + ], + }, + indicatorGroups: { + relatedTypes: ["indicators", "indicatorTypes"], + }, + indicatorTypes: { + relatedTypes: ["indicators", "indicatorGroups", "visualizations", "maps", "dashboards"], + }, + indicators: { + relatedTypes: [ + "indicatorTypes", + "indicatorGroups", + "visualizations", + "maps", + "dashboards", + ], + }, + legendSets: { + relatedTypes: ["visualizations", "maps"], + }, + maps: { + relatedTypes: ["legendSets", "visualizations", "dashboards"], + }, + sections: { + relatedTypes: [ + "dataSets", + "dataElements", + "dataElementGroups", + "categoryCombos", + "categories", + "categoryOptions", + "categoryOptionCombos", + "indicators", + ], + }, + userGroups: { + relatedTypes: unique([...securityMetadataTypes, "users", "userRoles"]), + }, + userRoles: { + relatedTypes: unique([...securityMetadataTypes, "users", "userGroups"]), + }, + users: { + relatedTypes: unique([...securityMetadataTypes, "userRoles", "userGroups"]), + }, + validationRuleGroups: { + relatedTypes: [ + "dataElements", + "sections", + "dataSets", + "programStages", + "programs", + "indicators", + "categoryOptions", + "categoryOptionCombos", + ], + }, + visualizations: { + relatedTypes: ["dashboards", "maps", "legendSets", "indicators", "dataElements"], + }, +}; + +const ignoredTopLevelReferenceFields = new Set([ + "sharing", + "translations", + "lastUpdatedBy", + "createdBy", + "href", +]); + +export type JsonPackageEntry = Readonly<{ + key: string; + type: string; + id: string; + displayName: string; + raw: JsonRecord; +}>; + +export type JsonPackageReference = Readonly<{ + toKey: string; + via: string; +}>; + +export type JsonPackageIncomingReference = Readonly<{ + fromKey: string; + via: string; +}>; + +export type JsonPackageIndex = Readonly<{ + types: string[]; + entriesByType: Record; + entriesByKey: Map; + refsByKey: Map; + incomingRefsByKey: Map; +}>; + +const jsonPackageGraphModes = ["expanded", "direct"] as const; +export type JsonPackageGraphMode = (typeof jsonPackageGraphModes)[number]; + +export function isJsonPackageGraphMode(value: string): value is JsonPackageGraphMode { + return (jsonPackageGraphModes as readonly string[]).includes(value); +} + +export function getTypePriority(type: string): number { + const index = (typePriorityOrder as readonly string[]).indexOf(type); + return index === -1 ? typePriorityOrder.length + 1 : index; +} + +export function compareTypeNames(a: string, b: string): number { + const diff = getTypePriority(a) - getTypePriority(b); + if (diff !== 0) return diff; + return a.localeCompare(b); +} + +export function indexJsonPackage(input: unknown): JsonPackageIndex { + if (!isRecord(input)) { + throw new Error("Invalid package format: expected a JSON object"); + } + + const entriesByType: Record = {}; + const entriesByKey = new Map(); + const idsToKeys = new Map(); + + Object.entries(input).forEach(([type, value]) => { + if (!Array.isArray(value)) return; + + const entries = value + .map((item, index) => createPackageEntry(type, item, index)) + .filter((entry): entry is JsonPackageEntry => Boolean(entry)); + + if (!entries.length) return; + + entriesByType[type] = entries; + + entries.forEach(entry => { + entriesByKey.set(entry.key, entry); + const lookupId = getString(entry.raw.id); + if (!lookupId) return; + const keysForId = idsToKeys.get(lookupId) ?? []; + idsToKeys.set(lookupId, [...keysForId, entry.key]); + }); + }); + + const refsByKey = new Map(); + const incomingRefsByKey = new Map(); + entriesByKey.forEach(entry => { + const references = collectEntryReferences(entry.raw); + const resolved = resolveReferences(entry.key, references, idsToKeys); + refsByKey.set(entry.key, resolved); + + resolved.forEach(reference => { + const incoming = incomingRefsByKey.get(reference.toKey) ?? []; + incomingRefsByKey.set(reference.toKey, [ + ...incoming, + { fromKey: entry.key, via: reference.via }, + ]); + }); + }); + + const types = Object.keys(entriesByType).sort(compareTypeNames); + return { types, entriesByType, entriesByKey, refsByKey, incomingRefsByKey }; +} + +function createPackageEntry( + type: string, + item: unknown, + index: number +): JsonPackageEntry | null { + if (!isRecord(item)) return null; + + const id = getString(item.id) || `${type}#${index + 1}`; + const displayName = + getString(item.displayName) || + getString(item.name) || + getString(item.shortName) || + id; + + const baseKey = `${type}:${id}`; + const key = `${baseKey}#${index}`; + + return { key, type, id, displayName, raw: item }; +} + +function collectEntryReferences(entry: JsonRecord): Array<{ id: string; via: string }> { + return Object.entries(entry) + .filter(([field]) => field !== "id" && !ignoredTopLevelReferenceFields.has(field)) + .flatMap(([field, value]) => collectFirstLevelReferences(field, value)) + .filter(reference => Boolean(reference.id)); +} + +function collectFirstLevelReferences( + field: string, + value: unknown +): Array<{ id: string; via: string }> { + const ids = collectNestedReferenceIds(value); + return ids.map(id => ({ id, via: field })); +} + +function resolveReferences( + fromKey: string, + refs: Array<{ id: string; via: string }>, + idsToKeys: Map +): JsonPackageReference[] { + const dedupe = new Map(); + + refs.forEach(ref => { + const toKeys = idsToKeys.get(ref.id) ?? []; + toKeys.forEach(toKey => { + if (toKey === fromKey) return; + const key = `${toKey}|${ref.via}`; + if (dedupe.has(key)) return; + dedupe.set(key, { toKey, via: ref.via }); + }); + }); + + return Array.from(dedupe.values()); +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function getReferenceId(value: unknown): string | undefined { + if (!isRecord(value)) return undefined; + return getString(value.id); +} + +function collectNestedReferenceIds(value: unknown): string[] { + const seen = new Set(); + collectNestedReferenceIdsRec(value, seen); + return Array.from(seen); +} + +function collectNestedReferenceIdsRec(value: unknown, seen: Set) { + if (Array.isArray(value)) { + value.forEach(item => collectNestedReferenceIdsRec(item, seen)); + return; + } + + if (!isRecord(value)) return; + + const id = getReferenceId(value); + if (id) seen.add(id); + + Object.entries(value).forEach(([key, nested]) => { + if (key === "id") return; + collectNestedReferenceIdsRec(nested, seen); + }); +} + +function unique(values: readonly string[]): string[] { + return Array.from(new Set(values)); +} diff --git a/src/domain/metadata/MetadataGraph.ts b/src/domain/metadata/MetadataGraph.ts index 59a0139..9a5088e 100644 --- a/src/domain/metadata/MetadataGraph.ts +++ b/src/domain/metadata/MetadataGraph.ts @@ -1,9 +1,8 @@ import { Id } from "$/domain/entities/Ref"; -import { ResourceType } from "$/domain/metadata/ResourceType"; export type GraphNode = { key: string; - type: ResourceType; + type: string; id: Id; displayName: string; }; @@ -33,6 +32,6 @@ export type MetadataGraph = { }; }; -export function graphNodeKey(type: ResourceType, id: Id): string { +export function graphNodeKey(type: string, id: Id): string { return `${type}:${id}`; } diff --git a/src/domain/metadata/ResourceType.ts b/src/domain/metadata/ResourceType.ts index 5562e04..bfa50d5 100644 --- a/src/domain/metadata/ResourceType.ts +++ b/src/domain/metadata/ResourceType.ts @@ -22,6 +22,30 @@ export const resourceTypeLabels: Record = { categoryOptionCombos: "Category option combos", }; +export function getMetadataTypeLabel(type: string): string { + const normalized = type + .replace(/[_-\s]+/g, " ") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .trim(); + + if (!normalized) return type; + + const words = normalized + .split(/\s+/) + .map(word => word.toLowerCase()) + .filter(Boolean); + + if (!words.length) return type; + + const [first = "", ...rest] = words; + return [capitalizeWord(first), ...rest].join(" "); +} + +function capitalizeWord(word: string): string { + if (!word) return word; + return `${word.charAt(0).toUpperCase()}${word.slice(1)}`; +} + export const selectableResourceTypes = [ "dataElements", "dataSets", diff --git a/src/domain/usecases/metadata/BuildJsonPackageDependencyGraphUseCase.ts b/src/domain/usecases/metadata/BuildJsonPackageDependencyGraphUseCase.ts new file mode 100644 index 0000000..62ab5c2 --- /dev/null +++ b/src/domain/usecases/metadata/BuildJsonPackageDependencyGraphUseCase.ts @@ -0,0 +1,256 @@ +import { getMetadataTypeLabel } from "$/domain/metadata/ResourceType"; +import { MetadataGraph } from "$/domain/metadata/MetadataGraph"; +import { + JsonPackageEntry, + JsonPackageGraphMode, + JsonPackageIncomingReference, + JsonPackageIndex, + JsonPackageReference, + compareTypeNames, + getTypePriority, + graphPolicyByCenterType, +} from "$/domain/metadata/JsonPackageIndex"; + +type BuildJsonPackageGraphOptions = Readonly<{ + maxNodes?: number; + mode?: JsonPackageGraphMode; +}>; + +type TraversalOutgoingRef = { + ref: JsonPackageReference; + toEntry: JsonPackageEntry; +}; + +type TraversalIncomingRef = { + ref: JsonPackageIncomingReference; + fromEntry: JsonPackageEntry; +}; + +export class BuildJsonPackageDependencyGraphUseCase { + execute( + index: JsonPackageIndex, + centerKey: string, + options: number | BuildJsonPackageGraphOptions = Number.POSITIVE_INFINITY + ): MetadataGraph { + const centerEntry = index.entriesByKey.get(centerKey); + if (!centerEntry) { + throw new Error(`Metadata item not found: ${centerKey}`); + } + const centerType = centerEntry.type; + const maxNodes = + typeof options === "number" ? options : options.maxNodes ?? Number.POSITIVE_INFINITY; + const mode: JsonPackageGraphMode = + typeof options === "number" ? "expanded" : options.mode ?? "expanded"; + + const visitedKeys = new Set([centerKey]); + const graphEdges = new Map(); + + const addNodeToGraph = (nextKey: string): boolean => { + if (visitedKeys.size >= maxNodes) return false; + if (visitedKeys.has(nextKey)) return false; + visitedKeys.add(nextKey); + return true; + }; + + const addRefsForNode = (fromKey: string, onNewNode?: (key: string) => void) => { + const refs = sortOutgoingRefsForTraversal( + index.refsByKey.get(fromKey) ?? [], + index, + centerType, + centerKey + ); + refs.forEach(({ ref }) => { + const edgeKey = `${fromKey}|${ref.toKey}|${ref.via}`; + if (!graphEdges.has(edgeKey)) { + graphEdges.set(edgeKey, { from: fromKey, to: ref.toKey, label: ref.via }); + } + if (addNodeToGraph(ref.toKey)) { + onNewNode?.(ref.toKey); + } + }); + + const currentType = index.entriesByKey.get(fromKey)?.type; + if (currentType && shouldSkipIncomingForCurrentType(centerType, currentType)) { + return; + } + + sortIncomingRefsForTraversal( + index.incomingRefsByKey.get(fromKey) ?? [], + index, + centerType, + centerKey + ).forEach(({ ref }) => { + const edgeKey = `${ref.fromKey}|${fromKey}|${ref.via}`; + if (!graphEdges.has(edgeKey)) { + graphEdges.set(edgeKey, { from: ref.fromKey, to: fromKey, label: ref.via }); + } + if (addNodeToGraph(ref.fromKey)) { + onNewNode?.(ref.fromKey); + } + }); + }; + + if (mode === "direct") { + addRefsForNode(centerKey); + } else { + const queue = [centerKey]; + + while (queue.length > 0) { + const fromKey = queue.shift(); + if (!fromKey) continue; + addRefsForNode(fromKey, key => { + if (key === centerKey) return; + queue.push(key); + }); + } + } + + const nodes = Array.from(visitedKeys) + .map(key => index.entriesByKey.get(key)) + .filter((entry): entry is JsonPackageEntry => Boolean(entry)) + .map(entry => ({ + key: entry.key, + id: entry.id, + type: entry.type, + displayName: entry.displayName, + })); + + const keyToNodeKey = new Map(); + Array.from(visitedKeys).forEach(key => { + const entry = index.entriesByKey.get(key); + if (!entry) return; + keyToNodeKey.set(key, entry.key); + }); + + const edges = Array.from(graphEdges.values()) + .map(edge => { + const from = keyToNodeKey.get(edge.from); + const to = keyToNodeKey.get(edge.to); + if (!from || !to) return null; + return { from, to, label: edge.label }; + }) + .filter((edge): edge is NonNullable => Boolean(edge)); + + const centerNodeKey = keyToNodeKey.get(centerKey) ?? centerEntry.key; + const groups = buildTypeGroups(index, visitedKeys, centerKey, keyToNodeKey); + + return { center: centerNodeKey, nodes, edges, groups }; + } +} + +function sortOutgoingRefsForTraversal( + refs: JsonPackageReference[], + index: JsonPackageIndex, + centerType: string, + centerKey: string +): TraversalOutgoingRef[] { + return refs + .map(ref => { + const toEntry = index.entriesByKey.get(ref.toKey); + if (!toEntry) return null; + if (!shouldIncludeTypeForCenter(centerType, toEntry.type)) return null; + if (toEntry.type === centerType && ref.toKey !== centerKey) return null; + return { ref, toEntry }; + }) + .filter((item): item is TraversalOutgoingRef => Boolean(item)) + .sort((a, b) => compareEntriesForTraversal(a.toEntry, b.toEntry, centerType)); +} + +function sortIncomingRefsForTraversal( + refs: JsonPackageIncomingReference[], + index: JsonPackageIndex, + centerType: string, + centerKey: string +): TraversalIncomingRef[] { + return refs + .map(ref => { + const fromEntry = index.entriesByKey.get(ref.fromKey); + if (!fromEntry) return null; + if (!shouldIncludeTypeForCenter(centerType, fromEntry.type)) return null; + if (fromEntry.type === centerType && ref.fromKey !== centerKey) return null; + return { ref, fromEntry }; + }) + .filter((item): item is TraversalIncomingRef => Boolean(item)) + .sort((a, b) => compareEntriesForTraversal(a.fromEntry, b.fromEntry, centerType)); +} + +function buildTypeGroups( + index: JsonPackageIndex, + visitedKeys: Set, + centerKey: string, + keyToNodeKey: Map +) { + const centerType = index.entriesByKey.get(centerKey)?.type; + const groupsByType = new Map(); + + Array.from(visitedKeys).forEach(key => { + if (key === centerKey) return; + const entry = index.entriesByKey.get(key); + const nodeKey = keyToNodeKey.get(key); + if (!entry || !nodeKey) return; + if (entry.type === centerType) return; + + const nodes = groupsByType.get(entry.type) ?? []; + groupsByType.set(entry.type, [...nodes, nodeKey]); + }); + + return Array.from(groupsByType.entries()) + .sort(([a], [b]) => compareTypeNamesForCenter(a, b, centerType)) + .map(([type, nodeKeys]) => ({ + id: `json-type:${type}`, + title: getMetadataTypeLabel(type), + nodeKeys, + direction: resolveGroupDirection(type, centerType), + })); +} + +function resolveGroupDirection(type: string, centerType?: string): "parent" | "child" { + if (!centerType) return "child"; + + const typePriority = getTypePriority(type); + const centerPriority = getTypePriority(centerType); + return typePriority < centerPriority ? "parent" : "child"; +} + +function compareEntriesForTraversal( + a: JsonPackageEntry, + b: JsonPackageEntry, + centerType: string +): number { + const typeDiff = compareTypeNamesForCenter(a.type, b.type, centerType); + if (typeDiff !== 0) return typeDiff; + + const nameDiff = a.displayName.localeCompare(b.displayName); + if (nameDiff !== 0) return nameDiff; + + return a.id.localeCompare(b.id); +} + +function compareTypeNamesForCenter(a: string, b: string, centerType?: string): number { + const related = centerType ? getRelatedTypesForCenter(centerType) : []; + const aIndex = related.indexOf(a); + const bIndex = related.indexOf(b); + + if (aIndex !== -1 || bIndex !== -1) { + if (aIndex === -1) return 1; + if (bIndex === -1) return -1; + if (aIndex !== bIndex) return aIndex - bIndex; + } + + return compareTypeNames(a, b); +} + +function shouldIncludeTypeForCenter(centerType: string, candidateType: string): boolean { + const related = getRelatedTypesForCenter(centerType); + if (!related.length) return true; + return candidateType === centerType || related.includes(candidateType); +} + +function shouldSkipIncomingForCurrentType(centerType: string, currentType: string): boolean { + const skipIncoming = graphPolicyByCenterType[centerType]?.noIncomingFromTypes ?? []; + return skipIncoming.includes(currentType); +} + +function getRelatedTypesForCenter(centerType: string): readonly string[] { + return graphPolicyByCenterType[centerType]?.relatedTypes ?? []; +} diff --git a/src/domain/usecases/metadata/__tests__/BuildJsonPackageDependencyGraphUseCase.spec.ts b/src/domain/usecases/metadata/__tests__/BuildJsonPackageDependencyGraphUseCase.spec.ts new file mode 100644 index 0000000..01dfff2 --- /dev/null +++ b/src/domain/usecases/metadata/__tests__/BuildJsonPackageDependencyGraphUseCase.spec.ts @@ -0,0 +1,482 @@ +import { describe, expect, it } from "vitest"; +import { indexJsonPackage } from "$/domain/metadata/JsonPackageIndex"; +import { BuildJsonPackageDependencyGraphUseCase } from "$/domain/usecases/metadata/BuildJsonPackageDependencyGraphUseCase"; + +const buildJsonPackageDependencyGraph = new BuildJsonPackageDependencyGraphUseCase(); + +describe("BuildJsonPackageDependencyGraphUseCase", () => { + it("indexes metadata arrays by type and builds dependency graph transitively", () => { + const metadataPackage = { + dataElements: [ + { + id: "de1", + displayName: "Data Element 1", + categoryCombo: { id: "cc1", displayName: "Default combo" }, + legendSets: [{ id: "ls1" }], + }, + ], + categoryCombos: [ + { + id: "cc1", + displayName: "Default combo", + categories: [{ id: "cat1" }], + }, + ], + categories: [{ id: "cat1", displayName: "Category 1" }], + legendSets: [{ id: "ls1", name: "Legend set 1" }], + }; + + const index = indexJsonPackage(metadataPackage); + expect(index.types).toEqual(["categoryCombos", "categories", "dataElements", "legendSets"]); + expect(index.entriesByType.dataElements).toHaveLength(1); + + const dataElement = requireFirst(index.entriesByType.dataElements, "dataElements"); + const graph = buildJsonPackageDependencyGraph.execute(index, dataElement.key); + + const nodeTypes = graph.nodes.map(node => node.type).sort(); + expect(nodeTypes).toEqual(["categories", "categoryCombos", "dataElements"]); + + const edgeLabels = graph.edges.map(edge => edge.label).sort(); + expect(edgeLabels).toEqual(["categories", "categoryCombo"]); + }); + + it("fails with invalid package root", () => { + expect(() => indexJsonPackage([])).toThrow("Invalid package format: expected a JSON object"); + }); + + it("ignores nested references outside first-level fields (e.g. sharing settings)", () => { + const metadataPackage = { + categories: [ + { + id: "cat1", + displayName: "Category 1", + sharing: { + users: { + abc123: { access: "rw------" }, + }, + userGroups: { + grp1: { access: "r-------", id: "ug1" }, + }, + }, + categoryOptions: [{ id: "co1" }], + }, + ], + categoryOptions: [{ id: "co1", displayName: "Option 1" }], + userGroups: [{ id: "ug1", name: "Should not be linked via sharing" }], + }; + + const index = indexJsonPackage(metadataPackage); + const category = requireFirst(index.entriesByType.categories, "categories"); + const graph = buildJsonPackageDependencyGraph.execute(index, category.key); + + const linkedTypes = graph.nodes + .filter(node => node.key !== category.key) + .map(node => node.type) + .sort(); + + expect(linkedTypes).toEqual(["categoryOptions"]); + }); + + it("includes nested ids inside first-level relation fields (e.g. dataSetElements -> dataElement)", () => { + const metadataPackage = { + dataSets: [ + { + id: "ds1", + displayName: "Data set 1", + dataSetElements: [{ dataElement: { id: "de1" } }], + }, + ], + dataElements: [{ id: "de1", displayName: "Data element 1" }], + }; + + const index = indexJsonPackage(metadataPackage); + const dataSet = requireFirst(index.entriesByType.dataSets, "dataSets"); + const graph = buildJsonPackageDependencyGraph.execute(index, dataSet.key); + + const linkedTypes = graph.nodes + .filter(node => node.key !== dataSet.key) + .map(node => node.type); + + expect(linkedTypes).toContain("dataElements"); + }); + + it("includes reverse references (category -> categoryOptions <- categoryOptionCombos)", () => { + const metadataPackage = { + categories: [ + { + id: "cat1", + displayName: "Category 1", + categoryOptions: [{ id: "co1" }], + }, + ], + categoryOptions: [{ id: "co1", displayName: "Option 1" }], + categoryOptionCombos: [ + { + id: "coc1", + displayName: "Option combo 1", + categoryOptions: [{ id: "co1" }], + }, + ], + }; + + const index = indexJsonPackage(metadataPackage); + const category = requireFirst(index.entriesByType.categories, "categories"); + const graph = buildJsonPackageDependencyGraph.execute(index, category.key); + + const linkedTypes = graph.nodes + .filter(node => node.key !== category.key) + .map(node => node.type); + + expect(linkedTypes).toContain("categoryOptions"); + expect(linkedTypes).toContain("categoryOptionCombos"); + }); + + it("links users, userRoles and userGroups only through explicit membership fields", () => { + const metadataPackage = { + users: [ + { + id: "u1", + displayName: "User 1", + userRoles: [{ id: "ur1" }], + }, + ], + userRoles: [{ id: "ur1", name: "Role 1" }], + userGroups: [{ id: "ug1", name: "Group 1", users: [{ id: "u1" }] }], + }; + + const index = indexJsonPackage(metadataPackage); + const user = requireFirst(index.entriesByType.users, "users"); + const userRole = requireFirst(index.entriesByType.userRoles, "userRoles"); + const userGroup = requireFirst(index.entriesByType.userGroups, "userGroups"); + + [user.key, userRole.key, userGroup.key].forEach(centerKey => { + const graph = buildJsonPackageDependencyGraph.execute(index, centerKey); + const types = new Set(graph.nodes.map(node => node.type)); + + expect(types.has("users")).toBe(true); + expect(types.has("userRoles")).toBe(true); + expect(types.has("userGroups")).toBe(true); + }); + }); + + it("does not create security links from sharing permissions", () => { + const metadataPackage = { + users: [{ id: "u1", displayName: "User 1", userRoles: [{ id: "ur1" }] }], + userRoles: [ + { + id: "ur1", + name: "Role 1", + sharing: { + owner: "u1", + userGroups: { + ug1: { id: "ug1" }, + }, + users: {}, + }, + }, + ], + userGroups: [{ id: "ug1", name: "Group 1", users: [] }], + }; + + const index = indexJsonPackage(metadataPackage); + const userRole = requireFirst(index.entriesByType.userRoles, "userRoles"); + const graph = buildJsonPackageDependencyGraph.execute(index, userRole.key); + + const types = new Set(graph.nodes.map(node => node.type)); + expect(types.has("users")).toBe(true); + expect(types.has("userRoles")).toBe(true); + expect(types.has("userGroups")).toBe(false); + }); + + it("avoids same-type sibling expansion for indicators", () => { + const metadataPackage = { + indicators: [ + { + id: "ind1", + displayName: "Indicator 1", + indicatorGroups: [{ id: "ig1" }], + indicatorType: { id: "it1" }, + }, + { + id: "ind2", + displayName: "Indicator 2", + indicatorGroups: [{ id: "ig1" }], + indicatorType: { id: "it1" }, + }, + ], + indicatorGroups: [{ id: "ig1", displayName: "Indicator group 1" }], + indicatorTypes: [{ id: "it1", displayName: "Indicator type 1" }], + }; + + const index = indexJsonPackage(metadataPackage); + const indicator = requireFirst(index.entriesByType.indicators, "indicators"); + const graph = buildJsonPackageDependencyGraph.execute(index, indicator.key); + + const indicatorNodes = graph.nodes.filter(node => node.type === "indicators"); + expect(indicatorNodes).toHaveLength(1); + + const linkedTypes = graph.nodes + .filter(node => node.key !== indicator.key) + .map(node => node.type); + expect(linkedTypes).toContain("indicatorGroups"); + expect(linkedTypes).toContain("indicatorTypes"); + }); + + it("does not show sibling dataSets when dataSets is the selected center type", () => { + const metadataPackage = { + dataSets: [ + { + id: "ds1", + displayName: "DataSet 1", + dataSetElements: [{ dataElement: { id: "de1" } }], + }, + { + id: "ds2", + displayName: "DataSet 2", + dataSetElements: [{ dataElement: { id: "de1" } }], + }, + ], + dataElements: [ + { + id: "de1", + displayName: "DE 1", + dataSets: [{ id: "ds1" }, { id: "ds2" }], + }, + ], + }; + + const index = indexJsonPackage(metadataPackage); + const dataSet = requireFirst(index.entriesByType.dataSets, "dataSets"); + const graph = buildJsonPackageDependencyGraph.execute(index, dataSet.key); + + const dataSetNodes = graph.nodes.filter(node => node.type === "dataSets"); + expect(dataSetNodes).toHaveLength(1); + expect(dataSetNodes[0]?.id).toBe("ds1"); + expect(graph.groups.some(group => group.id === "json-type:dataSets")).toBe(false); + }); + + it("keeps categoryOptionGroups only in the center column and formats group labels", () => { + const metadataPackage = { + categoryOptionGroups: [ + { + id: "cog1", + displayName: "15-24 years", + categoryOptions: [{ id: "co1" }], + }, + { + id: "cog2", + displayName: "25-49 years", + categoryOptions: [{ id: "co1" }], + }, + ], + categoryOptionGroupSets: [ + { + id: "cogs1", + displayName: "Age groups", + categoryOptionGroups: [{ id: "cog1" }, { id: "cog2" }], + }, + ], + categories: [ + { + id: "cat1", + displayName: "Age", + categoryOptions: [{ id: "co1" }], + }, + ], + categoryOptions: [ + { + id: "co1", + displayName: "All ages", + categoryOptionGroups: [{ id: "cog1" }, { id: "cog2" }], + }, + ], + }; + + const index = indexJsonPackage(metadataPackage); + const group = requireFirst(index.entriesByType.categoryOptionGroups, "categoryOptionGroups"); + const graph = buildJsonPackageDependencyGraph.execute(index, group.key); + + const selectedTypeNodes = graph.nodes.filter(node => node.type === "categoryOptionGroups"); + expect(selectedTypeNodes).toHaveLength(1); + expect(selectedTypeNodes[0]?.id).toBe("cog1"); + expect(graph.groups.some(item => item.id === "json-type:categoryOptionGroups")).toBe(false); + + const groupSetBand = graph.groups.find(item => item.id === "json-type:categoryOptionGroupSets"); + expect(groupSetBand?.title).toBe("Category option group sets"); + }); + + it("keeps dataSets graph focused while still resolving multiple category paths", () => { + const metadataPackage = { + dataSets: [ + { + id: "ds1", + displayName: "DataSet 1", + categoryCombo: { id: "cc1" }, + dataSetElements: [{ dataElement: { id: "de1" } }], + }, + ], + sections: [ + { + id: "sec1", + displayName: "Section 1", + dataSet: { id: "ds1" }, + dataElements: [{ id: "de1" }], + }, + ], + dataElements: [ + { id: "de1", displayName: "DE 1", categoryCombo: { id: "cc2" } }, + { id: "de2", displayName: "DE 2", categoryCombo: { id: "cc1" } }, + ], + categoryCombos: [ + { id: "cc1", displayName: "Combo 1", categories: [{ id: "cat1" }] }, + { id: "cc2", displayName: "Combo 2", categories: [{ id: "cat2" }] }, + ], + categories: [ + { id: "cat1", displayName: "Category 1" }, + { id: "cat2", displayName: "Category 2" }, + ], + }; + + const index = indexJsonPackage(metadataPackage); + const dataSet = requireFirst(index.entriesByType.dataSets, "dataSets"); + const graph = buildJsonPackageDependencyGraph.execute(index, dataSet.key); + + const dataElementIds = graph.nodes + .filter(node => node.type === "dataElements") + .map(node => node.id); + expect(dataElementIds).toContain("de1"); + expect(dataElementIds).not.toContain("de2"); + + const categoryIds = graph.nodes + .filter(node => node.type === "categories") + .map(node => node.id) + .sort(); + expect(categoryIds).toEqual(["cat1", "cat2"]); + }); + + it("supports direct mode without transitive categoryCombos expansion", () => { + const metadataPackage = { + dataElements: [ + { + id: "de1", + displayName: "DE 1", + categoryCombo: { id: "ccDirect" }, + }, + { + id: "de2", + displayName: "DE 2", + categoryCombo: { id: "ccOverride" }, + }, + ], + dataSets: [ + { + id: "ds1", + displayName: "DS 1", + categoryCombo: { id: "ccDefault" }, + dataSetElements: [ + { dataElement: { id: "de1" } }, + { dataElement: { id: "de2" }, categoryCombo: { id: "ccOverride" } }, + ], + }, + ], + categoryCombos: [ + { id: "ccDirect", displayName: "Direct combo" }, + { id: "ccDefault", displayName: "Default combo" }, + { id: "ccOverride", displayName: "Override combo" }, + ], + }; + + const index = indexJsonPackage(metadataPackage); + const dataElement = requireFirst(index.entriesByType.dataElements, "dataElements"); + const graph = buildJsonPackageDependencyGraph.execute(index, dataElement.key, { mode: "direct" }); + + const comboIds = graph.nodes + .filter(node => node.type === "categoryCombos") + .map(node => node.id) + .sort(); + + expect(comboIds).toEqual(["ccDirect"]); + }); + + it("prioritizes sections over dataElements when node limit is reached", () => { + const dataElementCount = 400; + const metadataPackage = { + dataSets: [ + { + id: "ds1", + displayName: "DataSet 1", + dataSetElements: Array.from({ length: dataElementCount }, (_, index) => ({ + dataElement: { id: `de${index}` }, + })), + sections: [{ id: "sec1" }, { id: "sec2" }], + }, + ], + dataElements: Array.from({ length: dataElementCount }, (_, index) => ({ + id: `de${index}`, + displayName: `Data element ${index}`, + })), + sections: [ + { id: "sec1", displayName: "Section 1" }, + { id: "sec2", displayName: "Section 2" }, + ], + }; + + const index = indexJsonPackage(metadataPackage); + const dataSet = requireFirst(index.entriesByType.dataSets, "dataSets"); + const graph = buildJsonPackageDependencyGraph.execute(index, dataSet.key, 250); + + const sectionIds = graph.nodes + .filter(node => node.type === "sections") + .map(node => node.id) + .sort(); + expect(sectionIds).toEqual(["sec1", "sec2"]); + }); + + it("keeps dashboard maps focused and avoids map expansion through indicator type hubs", () => { + const metadataPackage = { + dashboards: [ + { + id: "d1", + displayName: "Dashboard 1", + dashboardItems: [{ visualization: { id: "v1" } }, { map: { id: "m1" } }], + }, + ], + visualizations: [ + { + id: "v1", + displayName: "Visualization 1", + dataDimensionItems: [{ id: "i1" }], + }, + ], + indicators: [ + { id: "i1", displayName: "Indicator 1", indicatorType: { id: "it1" } }, + { id: "i2", displayName: "Indicator 2", indicatorType: { id: "it1" } }, + ], + indicatorTypes: [{ id: "it1", displayName: "Indicator type 1" }], + maps: [ + { id: "m1", displayName: "Map 1" }, + { id: "m2", displayName: "Map 2", mapViews: [{ id: "i2" }] }, + ], + }; + + const index = indexJsonPackage(metadataPackage); + const dashboard = requireFirst(index.entriesByType.dashboards, "dashboards"); + const graph = buildJsonPackageDependencyGraph.execute(index, dashboard.key); + + const mapIds = graph.nodes + .filter(node => node.type === "maps") + .map(node => node.id) + .sort(); + + expect(mapIds).toEqual(["m1"]); + }); +}); + +function requireFirst(items: T[] | undefined, label: string): T { + const first = items?.[0]; + expect(first).toBeDefined(); + if (!first) { + throw new Error(`${label} should contain at least one item in test fixture`); + } + return first; +} diff --git a/src/webapp/components/metadata/IdenticonAvatar.tsx b/src/webapp/components/metadata/IdenticonAvatar.tsx index bca6480..f722678 100644 --- a/src/webapp/components/metadata/IdenticonAvatar.tsx +++ b/src/webapp/components/metadata/IdenticonAvatar.tsx @@ -5,10 +5,9 @@ import { identiconSeed, sha256Hex, } from "$/domain/metadata/Identicon"; -import { ResourceType } from "$/domain/metadata/ResourceType"; type IdenticonAvatarProps = { - type: ResourceType; + type: string; uid: string; size?: number; className?: string; diff --git a/src/webapp/components/metadata/MetadataGraphPanel.tsx b/src/webapp/components/metadata/MetadataGraphPanel.tsx index 0954484..aed4554 100644 --- a/src/webapp/components/metadata/MetadataGraphPanel.tsx +++ b/src/webapp/components/metadata/MetadataGraphPanel.tsx @@ -7,6 +7,7 @@ import { MetadataGraph, graphNodeKey, } from "$/domain/metadata/MetadataGraph"; +import { isResourceType, resourceTypeLabels } from "$/domain/metadata/ResourceType"; import { MetadataItem, MetadataList } from "$/domain/metadata/MetadataItem"; import { useAppContext } from "$/webapp/contexts/app-context"; import { MetadataGraphView } from "$/webapp/components/metadata/MetadataGraphView"; @@ -167,6 +168,7 @@ export const MetadataGraphPanel: React.FC = ({ }; const handleFocus = (node: GraphNode) => { + if (!isResourceType(node.type)) return; onFocusItem({ id: node.id, type: node.type, displayName: node.displayName }); }; @@ -309,7 +311,7 @@ function mergeCategoryOptionCombos(graph: MetadataGraph, combos: MetadataItem[]) const filteredGroups = graph.groups.filter(group => group.id !== groupId); const group: GraphGroup = { id: groupId, - title: "Category option combos", + title: resourceTypeLabels.categoryOptionCombos, nodeKeys: newNodes.map(node => node.key), direction: "child", }; diff --git a/src/webapp/components/metadata/MetadataGraphView.tsx b/src/webapp/components/metadata/MetadataGraphView.tsx index 6d5838d..325ff36 100644 --- a/src/webapp/components/metadata/MetadataGraphView.tsx +++ b/src/webapp/components/metadata/MetadataGraphView.tsx @@ -1,9 +1,10 @@ import React from "react"; import { GraphGroup, GraphNode, MetadataGraph } from "$/domain/metadata/MetadataGraph"; -import { resourceTypeLabels } from "$/domain/metadata/ResourceType"; +import { getMetadataTypeLabel } from "$/domain/metadata/ResourceType"; import { IdenticonAvatar } from "$/webapp/components/metadata/IdenticonAvatar"; import OpenInNewIcon from "@material-ui/icons/OpenInNew"; import CenterFocusStrongIcon from "@material-ui/icons/CenterFocusStrong"; +import i18n from "$/utils/i18n"; type MetadataGraphViewProps = { graph: MetadataGraph; @@ -25,8 +26,11 @@ export const MetadataGraphView: React.FC = ({ onFocus, }) => { const containerRef = React.useRef(null); + const dragRef = React.useRef(null); + const didDragRef = React.useRef(false); const nodeRefs = React.useRef>({}); const [lines, setLines] = React.useState([]); + const [isDragging, setIsDragging] = React.useState(false); const [canvasSize, setCanvasSize] = React.useState<{ width: number; height: number }>({ width: 0, height: 0, @@ -36,10 +40,14 @@ export const MetadataGraphView: React.FC = ({ return new Map(graph.nodes.map(node => [node.key, node])); }, [graph.nodes]); - const parentGroups = graph.groups.filter(group => group.direction === "parent"); - const childGroups = graph.groups.filter(group => group.direction === "child"); - const leftGroups = parentGroups.length > 0 ? parentGroups : childGroups; - const rightGroups = parentGroups.length > 0 ? childGroups : []; + const orderedGroups = React.useMemo(() => { + const parentGroups = graph.groups.filter(group => group.direction === "parent"); + const childGroups = graph.groups.filter(group => group.direction === "child"); + if (parentGroups.length > 0 && childGroups.length > 0) { + return [...parentGroups, ...childGroups]; + } + return graph.groups; + }, [graph.groups]); // Cache per-key ref callbacks so each receives a stable callback // across renders. Without this, React would remount the ref callback every render @@ -94,9 +102,60 @@ export const MetadataGraphView: React.FC = ({ }, [graph.edges, graph.nodes, graph.groups]); const centerNode = nodeMap.get(graph.center); + const centerTypeLabel = centerNode ? getMetadataTypeLabel(centerNode.type) : ""; + + const handlePointerDown = React.useCallback((event: React.PointerEvent) => { + if (!event.isPrimary || event.button !== 0) return; + if (event.target instanceof Element && event.target.closest(".graph-node")) return; + + didDragRef.current = false; + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startScrollLeft: event.currentTarget.scrollLeft, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, []); + + const handlePointerMove = React.useCallback((event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + const deltaX = event.clientX - drag.startX; + if (Math.abs(deltaX) > 2) { + didDragRef.current = true; + setIsDragging(true); + event.preventDefault(); + } + + event.currentTarget.scrollLeft = drag.startScrollLeft - deltaX; + }, []); + + const handlePointerEnd = React.useCallback((event: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + event.currentTarget.releasePointerCapture(event.pointerId); + dragRef.current = null; + setIsDragging(false); + }, []); + + const handleClickCapture = React.useCallback((event: React.MouseEvent) => { + if (!didDragRef.current) return; + event.preventDefault(); + event.stopPropagation(); + didDragRef.current = false; + }, []); return ( -
+
= ({
- {leftGroups.map(group => ( - - ))} -
{centerNode && ( <>
- {resourceTypeLabels[centerNode.type]} (1) + {centerTypeLabel} (1)
= ({ )}
- {rightGroups.map(group => ( + {orderedGroups.map(group => ( = ({ ); }; +type DragState = { + pointerId: number; + startX: number; + startScrollLeft: number; +} | null; + const GraphGroupColumn: React.FC<{ group: GraphGroup; nodeMap: Map; @@ -222,22 +276,26 @@ const GraphNodeCard: React.FC<{ {node.id} - - + {onOpenApi && ( + + )} + {onFocus && ( + + )}
diff --git a/src/webapp/components/metadata/MetadataGraphView3D.tsx b/src/webapp/components/metadata/MetadataGraphView3D.tsx index 2922400..5744460 100644 --- a/src/webapp/components/metadata/MetadataGraphView3D.tsx +++ b/src/webapp/components/metadata/MetadataGraphView3D.tsx @@ -12,7 +12,7 @@ import ForceGraph3D, { import * as THREE from "three"; import { GraphEdge, GraphNode, MetadataGraph } from "$/domain/metadata/MetadataGraph"; import { buildIdenticonSvg, identiconSeed, sha256Hex } from "$/domain/metadata/Identicon"; -import { resourceTypeLabels } from "$/domain/metadata/ResourceType"; +import { getMetadataTypeLabel } from "$/domain/metadata/ResourceType"; import i18n from "$/utils/i18n"; type ForceNode = NodeObject & { @@ -174,7 +174,9 @@ const MetadataGraphView3D: React.FC = ({ nodeId="id" linkSource="source" linkTarget="target" - nodeLabel={node => `${resourceTypeLabels[node.type] ?? node.type}: ${node.name}`} + nodeLabel={node => + `${getMetadataTypeLabel(node.type)}: ${node.name}` + } linkLabel={link => link.label} nodeAutoColorBy={useTexture ? undefined : "type"} backgroundColor="#0f172a" diff --git a/src/webapp/components/metadata/MetadataQueryBuilder.tsx b/src/webapp/components/metadata/MetadataQueryBuilder.tsx index 8848979..b7ba57e 100644 --- a/src/webapp/components/metadata/MetadataQueryBuilder.tsx +++ b/src/webapp/components/metadata/MetadataQueryBuilder.tsx @@ -5,6 +5,7 @@ import { ResourceType, } from "$/domain/metadata/ResourceType"; import { MAX_PAGE_SIZE } from "$/domain/metadata/pagination"; +import i18n from "$/utils/i18n"; export type MetadataQueryState = { type: ResourceType; @@ -32,7 +33,7 @@ export const MetadataQueryBuilder: React.FC = ({