From b0a2fec517bd8a7280f2b3c29f0a8bf2c581b528 Mon Sep 17 00:00:00 2001 From: Bilal ABBAD Date: Sun, 23 Feb 2025 13:44:59 +0100 Subject: [PATCH] Added support for template schema (#5812) - load template schema in frontend UI - display template schema in schema visualizer - updated types --- .../entities/nodes/hooks/useObjectItems.ts | 16 +- .../object-items/getSchemaObjectColumns.ts | 83 ------- frontend/app/src/entities/nodes/types.ts | 11 +- frontend/app/src/entities/nodes/utils.ts | 17 +- .../entities/schema/domain/get-schema.test.ts | 22 +- .../src/entities/schema/domain/get-schema.ts | 2 + .../src/entities/schema/stores/schema.atom.ts | 9 +- frontend/app/src/entities/schema/types.ts | 3 +- .../ui/decorators/withSchemaContext.tsx | 19 +- .../src/entities/schema/ui/hooks/useSchema.ts | 3 + .../entities/schema/ui/schema-selector.tsx | 6 +- .../src/entities/schema/ui/schema-viewer.tsx | 4 +- .../schema/utils/resole-schema.test.ts | 42 +++- .../entities/schema/utils/resolve-schema.ts | 30 ++- frontend/app/src/pages/objects/layout.tsx | 13 +- .../src/shared/api/rest/types.generated.ts | 132 ++++++++++- .../form/fields/relationship.field.tsx | 6 +- .../e2e/objects/profiles/profiles.spec.ts | 2 +- frontend/app/tests/fake/schema.ts | 223 +++++++++++++++--- 19 files changed, 462 insertions(+), 181 deletions(-) diff --git a/frontend/app/src/entities/nodes/hooks/useObjectItems.ts b/frontend/app/src/entities/nodes/hooks/useObjectItems.ts index d4b36900d9..88f05bd0c0 100644 --- a/frontend/app/src/entities/nodes/hooks/useObjectItems.ts +++ b/frontend/app/src/entities/nodes/hooks/useObjectItems.ts @@ -5,17 +5,12 @@ import { getObjectRelationships, } from "@/entities/nodes/object-items/getSchemaObjectColumns"; import { getPermission } from "@/entities/permission/utils"; -import { - genericSchemasAtom, - nodeSchemasAtom, - profileSchemasAtom, -} from "@/entities/schema/stores/schema.atom"; +import { getSchema } from "@/entities/schema/domain/get-schema"; import { ModelSchema } from "@/entities/schema/types"; import { getTokens } from "@/entities/user-profile/api/getTokens"; import useQuery from "@/shared/api/graphql/useQuery"; import { Filter } from "@/shared/hooks/useFilters"; import { gql } from "@apollo/client"; -import { useAtomValue } from "jotai"; const getQuery = (schema?: ModelSchema, filters?: Array) => { if (!schema) return "query {ok}"; @@ -24,15 +19,8 @@ const getQuery = (schema?: ModelSchema, filters?: Array) => { return getTokens; } - const nodes = useAtomValue(nodeSchemasAtom); - const generics = useAtomValue(genericSchemasAtom); - const profiles = useAtomValue(profileSchemasAtom); - const kindFilter = filters?.find((filter) => filter.name === "kind__value"); - - const kindFilterSchema = [...nodes, ...generics, ...profiles].find( - ({ kind }) => kind === kindFilter?.value - ); + const { schema: kindFilterSchema } = getSchema(kindFilter?.value); // All the filter values are being sent out as strings inside quotes. // This will not work if the type of filter value is not string. diff --git a/frontend/app/src/entities/nodes/object-items/getSchemaObjectColumns.ts b/frontend/app/src/entities/nodes/object-items/getSchemaObjectColumns.ts index 694d523479..2edcd3b80f 100644 --- a/frontend/app/src/entities/nodes/object-items/getSchemaObjectColumns.ts +++ b/frontend/app/src/entities/nodes/object-items/getSchemaObjectColumns.ts @@ -5,10 +5,8 @@ import { relationshipsForTabs, } from "@/config/constants"; import { ATTRIBUTE_KINDS_FOR_LIST_VIEW } from "@/entities/schema/constants"; -import { profileSchemasAtom } from "@/entities/schema/stores/schema.atom"; import { AttributeKind, GenericSchema, ModelSchema, NodeSchema } from "@/entities/schema/types"; import { isGenericSchema } from "@/entities/schema/utils/is-generic-schema"; -import { store } from "@/shared/stores"; import { sortByOrderWeight } from "@/shared/utils/common"; import * as R from "ramda"; @@ -142,84 +140,3 @@ export const getObjectTabs = (tabs: any[], data: any) => { count: data[tab.name]?.count, })); }; - -// Include current value in the options to make it available in the select component -export const getRelationshipOptions = (row: any, field: any, schemas: any[], generics: any[]) => { - const value = row && (row[field.name]?.node ?? row[field.name]); - - if (value?.edges) { - return value.edges.map((edge: any) => ({ - name: edge.node.display_label, - id: edge.node.id, - })); - } - - const generic = generics.find((generic: any) => generic.kind === field.peer); - - if (generic) { - const options = (generic.used_by || []).map((name: string) => { - const profiles = store.get(profileSchemasAtom); - - const relatedSchema = [...schemas, ...profiles].find((s: any) => s.kind === name); - - if (relatedSchema) { - return { - id: name, - name: relatedSchema.name, - }; - } - }); - - return options; - } - - if (!value) { - return []; - } - - const option = { - name: value.display_label, - id: value.id, - }; - - // Initial option for relationships to make the current value available - return [option]; -}; - -type tgetOptionsFromRelationship = { - options: any[]; - schemas?: any; - generic?: any; - peerField?: string; -}; - -export const getOptionsFromRelationship = ({ - options, - schemas, - generic, - peerField, -}: tgetOptionsFromRelationship) => { - if (!generic) { - return options.map((option: any) => ({ - name: peerField ? (option[peerField]?.value ?? option[peerField]) : option.display_label, - id: option.id, - kind: option.__typename, - })); - } - - if (generic) { - return (generic.used_by || []).map((name: string) => { - const relatedSchema = schemas.find((s: any) => s.kind === name); - - if (relatedSchema) { - return { - name: relatedSchema.name, - id: name, - kind: relatedSchema.kind, - }; - } - }); - } - - return []; -}; diff --git a/frontend/app/src/entities/nodes/types.ts b/frontend/app/src/entities/nodes/types.ts index f7b9fecd69..3a3c7eedb5 100644 --- a/frontend/app/src/entities/nodes/types.ts +++ b/frontend/app/src/entities/nodes/types.ts @@ -1,12 +1,7 @@ +import { components } from "@/shared/api/rest/types.generated"; + // https://docs.infrahub.app/reference/schema/relationship/#kind -export type RelationshipKind = - | "Generic" - | "Attribute" - | "Component" - | "Parent" - | "Group" - | "Hierarchy" - | "Profile"; +export type RelationshipKind = components["schemas"]["RelationshipKind"]; export type NodeCore = { id: string; diff --git a/frontend/app/src/entities/nodes/utils.ts b/frontend/app/src/entities/nodes/utils.ts index 9e7e18ed80..637c54706b 100644 --- a/frontend/app/src/entities/nodes/utils.ts +++ b/frontend/app/src/entities/nodes/utils.ts @@ -6,14 +6,8 @@ import { IP_PREFIX_GENERIC, } from "@/entities/ipam/constants"; import { RESOURCE_GENERIC_KIND } from "@/entities/resource-manager/constants"; -import { - genericSchemasAtom, - nodeSchemasAtom, - profileSchemasAtom, -} from "@/entities/schema/stores/schema.atom"; -import { isGenericSchema } from "@/entities/schema/utils/is-generic-schema"; -import { store } from "@/shared/stores"; -import { constructPath, overrideQueryParams } from "../../shared/api/rest/fetch"; +import { getSchema } from "@/entities/schema/domain/get-schema"; +import { constructPath, overrideQueryParams } from "@/shared/api/rest/fetch"; const regex = /^Related/; // starts with Related @@ -39,13 +33,10 @@ export const getObjectDetailsUrl2 = ( ]); } - const nodes = store.get(nodeSchemasAtom); - const generics = store.get(genericSchemasAtom); - const profiles = store.get(profileSchemasAtom); - const schema = [...nodes, ...generics, ...profiles].find(({ kind }) => kind === objectKind); + const { schema, isGeneric } = getSchema(objectKind); if (!schema) return "#"; - if (!isGenericSchema(schema)) { + if (!isGeneric) { const inheritFrom = schema.inherit_from; if (inheritFrom?.includes(IP_PREFIX_GENERIC)) { diff --git a/frontend/app/src/entities/schema/domain/get-schema.test.ts b/frontend/app/src/entities/schema/domain/get-schema.test.ts index efa65e47e2..d11f049696 100644 --- a/frontend/app/src/entities/schema/domain/get-schema.test.ts +++ b/frontend/app/src/entities/schema/domain/get-schema.test.ts @@ -2,6 +2,7 @@ import { genericSchemasAtom, nodeSchemasAtom, profileSchemasAtom, + templateSchemasAtom, } from "@/entities/schema/stores/schema.atom"; import { store } from "@/shared/stores"; import { beforeEach, describe, expect, it } from "vitest"; @@ -9,6 +10,7 @@ import { generateGenericSchema, generateNodeSchema, generateProfileSchema, + generateTemplateSchema, } from "../../../../tests/fake/schema"; import { getSchema } from "./get-schema"; @@ -16,13 +18,13 @@ describe("getSchema", () => { const nodeSchema = generateNodeSchema({ kind: "Node" }); const genericSchema = generateGenericSchema({ kind: "Generic" }); const profileSchema = generateProfileSchema({ kind: "Profile" }); + const templateSchema = generateTemplateSchema({ kind: "Template" }); beforeEach(() => { store.set(nodeSchemasAtom, [nodeSchema]); - store.set(genericSchemasAtom, [genericSchema]); - store.set(profileSchemasAtom, [profileSchema]); + store.set(templateSchemasAtom, [templateSchema]); }); it("should return null schema when no kind is provided", () => { @@ -32,6 +34,7 @@ describe("getSchema", () => { isGeneric: false, isNode: false, isProfile: false, + isTemplate: false, }); }); @@ -42,6 +45,7 @@ describe("getSchema", () => { isGeneric: false, isNode: true, isProfile: false, + isTemplate: false, }); }); @@ -52,6 +56,7 @@ describe("getSchema", () => { isGeneric: true, isNode: false, isProfile: false, + isTemplate: false, }); }); @@ -62,6 +67,18 @@ describe("getSchema", () => { isGeneric: false, isNode: false, isProfile: true, + isTemplate: false, + }); + }); + + it("should return template schema when kind matches a template", () => { + const result = getSchema("Template"); + expect(result).toEqual({ + schema: templateSchema, + isGeneric: false, + isNode: false, + isProfile: false, + isTemplate: true, }); }); @@ -72,6 +89,7 @@ describe("getSchema", () => { isGeneric: false, isNode: false, isProfile: false, + isTemplate: false, }); }); }); diff --git a/frontend/app/src/entities/schema/domain/get-schema.ts b/frontend/app/src/entities/schema/domain/get-schema.ts index cb7ad94695..590cd2ed75 100644 --- a/frontend/app/src/entities/schema/domain/get-schema.ts +++ b/frontend/app/src/entities/schema/domain/get-schema.ts @@ -2,6 +2,7 @@ import { genericSchemasAtom, nodeSchemasAtom, profileSchemasAtom, + templateSchemasAtom, } from "@/entities/schema/stores/schema.atom"; import { SchemaResult, resolveSchema } from "@/entities/schema/utils/resolve-schema"; import { store } from "@/shared/stores"; @@ -11,5 +12,6 @@ export const getSchema = (kind?: string | null): SchemaResult => { nodeSchemas: store.get(nodeSchemasAtom), genericSchemas: store.get(genericSchemasAtom), profileSchemas: store.get(profileSchemasAtom), + templateSchemas: store.get(templateSchemasAtom), }); }; diff --git a/frontend/app/src/entities/schema/stores/schema.atom.ts b/frontend/app/src/entities/schema/stores/schema.atom.ts index 8f5638f04f..738a58e5d1 100644 --- a/frontend/app/src/entities/schema/stores/schema.atom.ts +++ b/frontend/app/src/entities/schema/stores/schema.atom.ts @@ -1,9 +1,16 @@ -import { GenericSchema, Namespace, NodeSchema, ProfileSchema } from "@/entities/schema/types"; +import { + GenericSchema, + Namespace, + NodeSchema, + ProfileSchema, + TemplateSchema, +} from "@/entities/schema/types"; import { atom } from "jotai"; export const nodeSchemasAtom = atom([]); export const genericSchemasAtom = atom([]); export const profileSchemasAtom = atom([]); +export const templateSchemasAtom = atom([]); export const namespacesAtom = atom([]); // Current schema hash for tracking changes diff --git a/frontend/app/src/entities/schema/types.ts b/frontend/app/src/entities/schema/types.ts index 3afdcec584..40cc3a5151 100644 --- a/frontend/app/src/entities/schema/types.ts +++ b/frontend/app/src/entities/schema/types.ts @@ -4,8 +4,9 @@ import { components } from "@/shared/api/rest/types.generated"; export type NodeSchema = components["schemas"]["APINodeSchema"]; export type GenericSchema = components["schemas"]["APIGenericSchema"]; export type ProfileSchema = components["schemas"]["APIProfileSchema"]; +export type TemplateSchema = components["schemas"]["APITemplateSchema"]; -export type ModelSchema = GenericSchema | NodeSchema | ProfileSchema; +export type ModelSchema = GenericSchema | NodeSchema | ProfileSchema | TemplateSchema; export type RelationshipSchema = components["schemas"]["RelationshipSchema"]; diff --git a/frontend/app/src/entities/schema/ui/decorators/withSchemaContext.tsx b/frontend/app/src/entities/schema/ui/decorators/withSchemaContext.tsx index c4ed6a1da4..6c6e8e5058 100644 --- a/frontend/app/src/entities/schema/ui/decorators/withSchemaContext.tsx +++ b/frontend/app/src/entities/schema/ui/decorators/withSchemaContext.tsx @@ -8,10 +8,17 @@ import { namespacesAtom, nodeSchemasAtom, profileSchemasAtom, + templateSchemasAtom, } from "@/entities/schema/stores/schema.atom"; import { schemaKindLabelState } from "@/entities/schema/stores/schemaKindLabel.atom"; import { schemaKindNameState } from "@/entities/schema/stores/schemaKindName.atom"; -import { GenericSchema, Namespace, NodeSchema, ProfileSchema } from "@/entities/schema/types"; +import { + GenericSchema, + Namespace, + NodeSchema, + ProfileSchema, + TemplateSchema, +} from "@/entities/schema/types"; import { tokenSchema } from "@/entities/user-profile/ui/token-schema"; import { Branch } from "@/shared/api/graphql/generated/graphql"; import { fetchUrl } from "@/shared/api/rest/fetch"; @@ -41,6 +48,7 @@ export const withSchemaContext = (AppComponent: any) => (props: any) => { const setGenerics = useSetAtom(genericSchemasAtom); const setNamespaces = useSetAtom(namespacesAtom); const setProfiles = useSetAtom(profileSchemasAtom); + const setTemplates = useSetAtom(templateSchemasAtom); const setState = useSetAtom(stateAtom); const branches = useAtomValue(branchesState); const [branchInQueryString] = useQueryParam(QSP.BRANCH, StringParam); @@ -54,15 +62,17 @@ export const withSchemaContext = (AppComponent: any) => (props: any) => { main: string; nodes: NodeSchema[]; generics: GenericSchema[]; - namespaces: Namespace[]; profiles: ProfileSchema[]; + templates: TemplateSchema[]; + namespaces: Namespace[]; } = await fetchUrl(CONFIG.SCHEMA_URL(branch?.name)); const hash = schemaData.main; const schema = sortByName([...schemaData.nodes, tokenSchema]); const generics = sortByName(schemaData.generics || []); - const namespaces = sortByName(schemaData.namespaces || []); const profiles = sortByName(schemaData.profiles || []); + const templates = sortByName(schemaData.templates || []); + const namespaces = sortByName(schemaData.namespaces || []); schema.forEach((s) => { s.attributes = sortByOrderWeight(s.attributes || []); @@ -73,12 +83,14 @@ export const withSchemaContext = (AppComponent: any) => (props: any) => { ...schema.map((s) => s.kind), ...generics.map((s) => s.kind), ...profiles.map((s) => s.kind), + ...templates.map((s) => s.kind), ]; const schemaNames = [ ...schema.map((s) => s.label), ...generics.map((s) => s.label), ...profiles.map((s) => s.label), + ...templates.map((s) => s.label), ]; const schemaKindNameTuples = R.zip(schemaKinds, schemaNames); const schemaKindNameMap = { @@ -99,6 +111,7 @@ export const withSchemaContext = (AppComponent: any) => (props: any) => { setSchemaKindLabelState(schemaKindLabelMap); setNamespaces(namespaces); setProfiles(profiles); + setTemplates(templates); setState({ isReady: true }); } catch (error) { toast( diff --git a/frontend/app/src/entities/schema/ui/hooks/useSchema.ts b/frontend/app/src/entities/schema/ui/hooks/useSchema.ts index 37bffe9945..52e40d1a42 100644 --- a/frontend/app/src/entities/schema/ui/hooks/useSchema.ts +++ b/frontend/app/src/entities/schema/ui/hooks/useSchema.ts @@ -2,6 +2,7 @@ import { genericSchemasAtom, nodeSchemasAtom, profileSchemasAtom, + templateSchemasAtom, } from "@/entities/schema/stores/schema.atom"; import { SchemaResult, resolveSchema } from "@/entities/schema/utils/resolve-schema"; import { useAtomValue } from "jotai/index"; @@ -10,10 +11,12 @@ export const useSchema = (kind: string | null | undefined): SchemaResult => { const nodeSchemas = useAtomValue(nodeSchemasAtom); const profileSchemas = useAtomValue(profileSchemasAtom); const genericSchemas = useAtomValue(genericSchemasAtom); + const templateSchemas = useAtomValue(templateSchemasAtom); return resolveSchema(kind, { nodeSchemas, genericSchemas, profileSchemas, + templateSchemas, }); }; diff --git a/frontend/app/src/entities/schema/ui/schema-selector.tsx b/frontend/app/src/entities/schema/ui/schema-selector.tsx index 4bb2c0fbe1..69cecb28ae 100644 --- a/frontend/app/src/entities/schema/ui/schema-selector.tsx +++ b/frontend/app/src/entities/schema/ui/schema-selector.tsx @@ -3,6 +3,7 @@ import { genericSchemasAtom, nodeSchemasAtom, profileSchemasAtom, + templateSchemasAtom, } from "@/entities/schema/stores/schema.atom"; import { ModelSchema } from "@/entities/schema/types"; import { isGenericSchema } from "@/entities/schema/utils/is-generic-schema"; @@ -24,6 +25,7 @@ export const SchemaSelector = ({ className = "" }: SchemaSelectorProps) => { const nodes = useAtomValue(nodeSchemasAtom); const generics = useAtomValue(genericSchemasAtom); const profiles = useAtomValue(profileSchemasAtom); + const templates = useAtomValue(templateSchemasAtom); const [search, setSearch] = useState(""); const ref = useRef(null); @@ -33,8 +35,8 @@ export const SchemaSelector = ({ className = "" }: SchemaSelectorProps) => { ref.current.scrollIntoView({ behavior: "smooth", block: "nearest" }); }, [selectedKind?.length]); - const schemas: ModelSchema[] = [...nodes, ...generics, ...profiles].filter(({ kind }) => - kind?.toLowerCase().includes(search.toLowerCase()) + const schemas: ModelSchema[] = [...nodes, ...generics, ...profiles, ...templates].filter( + ({ kind }) => kind?.toLowerCase().includes(search.toLowerCase()) ); const schemasPerNamespace = R.pipe( diff --git a/frontend/app/src/entities/schema/ui/schema-viewer.tsx b/frontend/app/src/entities/schema/ui/schema-viewer.tsx index cd5e7acb77..c9654f78a5 100644 --- a/frontend/app/src/entities/schema/ui/schema-viewer.tsx +++ b/frontend/app/src/entities/schema/ui/schema-viewer.tsx @@ -3,6 +3,7 @@ import { genericSchemasAtom, nodeSchemasAtom, profileSchemasAtom, + templateSchemasAtom, } from "@/entities/schema/stores/schema.atom"; import { ModelSchema } from "@/entities/schema/types"; import { isGenericSchema } from "@/entities/schema/utils/is-generic-schema"; @@ -26,10 +27,11 @@ export const SchemaViewerStack = ({ className = "" }: { className: string }) => const nodes = useAtomValue(nodeSchemasAtom); const generics = useAtomValue(genericSchemasAtom); const profiles = useAtomValue(profileSchemasAtom); + const templates = useAtomValue(templateSchemasAtom); if (!selectedKind) return null; - const schemas = [...nodes, ...generics, ...profiles]; + const schemas = [...nodes, ...generics, ...profiles, ...templates]; return (
diff --git a/frontend/app/src/entities/schema/utils/resole-schema.test.ts b/frontend/app/src/entities/schema/utils/resole-schema.test.ts index 50272e8f07..749ef9e6a1 100644 --- a/frontend/app/src/entities/schema/utils/resole-schema.test.ts +++ b/frontend/app/src/entities/schema/utils/resole-schema.test.ts @@ -1,9 +1,10 @@ -import { GenericSchema, NodeSchema, ProfileSchema } from "@/entities/schema/types"; +import { GenericSchema, NodeSchema, ProfileSchema, TemplateSchema } from "@/entities/schema/types"; import { describe, expect, it } from "vitest"; import { generateGenericSchema, generateNodeSchema, generateProfileSchema, + generateTemplateSchema, } from "../../../../tests/fake/schema"; import { resolveSchema } from "./resolve-schema"; @@ -11,6 +12,7 @@ describe("resolveSchema", () => { const baseNodeSchema: NodeSchema = generateNodeSchema(); const baseGenericSchema: GenericSchema = generateGenericSchema(); const baseProfileSchema: ProfileSchema = generateProfileSchema(); + const baseTemplateSchema: TemplateSchema = generateTemplateSchema(); it("should return null schema when kind is null", () => { // GIVEN @@ -18,6 +20,7 @@ describe("resolveSchema", () => { nodeSchemas: [baseNodeSchema], genericSchemas: [baseGenericSchema], profileSchemas: [baseProfileSchema], + templateSchemas: [baseTemplateSchema], }; // WHEN @@ -29,6 +32,7 @@ describe("resolveSchema", () => { isGeneric: false, isNode: false, isProfile: false, + isTemplate: false, }); }); @@ -38,6 +42,7 @@ describe("resolveSchema", () => { nodeSchemas: [baseNodeSchema], genericSchemas: [baseGenericSchema], profileSchemas: [baseProfileSchema], + templateSchemas: [baseTemplateSchema], }; // WHEN @@ -49,6 +54,7 @@ describe("resolveSchema", () => { isGeneric: false, isNode: false, isProfile: false, + isTemplate: false, }); }); @@ -62,6 +68,7 @@ describe("resolveSchema", () => { nodeSchemas: [nodeSchema], genericSchemas: [baseGenericSchema], profileSchemas: [baseProfileSchema], + templateSchemas: [baseTemplateSchema], }; // WHEN @@ -73,6 +80,7 @@ describe("resolveSchema", () => { isGeneric: false, isNode: true, isProfile: false, + isTemplate: false, }); }); @@ -86,6 +94,7 @@ describe("resolveSchema", () => { nodeSchemas: [], genericSchemas: [genericSchema], profileSchemas: [baseProfileSchema], + templateSchemas: [baseTemplateSchema], }; // WHEN @@ -97,6 +106,7 @@ describe("resolveSchema", () => { isGeneric: true, isNode: false, isProfile: false, + isTemplate: false, }); }); @@ -110,6 +120,7 @@ describe("resolveSchema", () => { nodeSchemas: [], genericSchemas: [], profileSchemas: [profileSchema], + templateSchemas: [baseTemplateSchema], }; // WHEN @@ -121,6 +132,33 @@ describe("resolveSchema", () => { isGeneric: false, isNode: false, isProfile: true, + isTemplate: false, + }); + }); + + it("should return template schema when kind matches a template schema", () => { + // GIVEN + const templateSchema = { + ...baseTemplateSchema, + kind: "SpecificTemplate", + }; + const schemas = { + nodeSchemas: [], + genericSchemas: [], + profileSchemas: [], + templateSchemas: [templateSchema], + }; + + // WHEN + const result = resolveSchema("SpecificTemplate", schemas); + + // THEN + expect(result).toEqual({ + schema: templateSchema, + isGeneric: false, + isNode: false, + isProfile: false, + isTemplate: true, }); }); @@ -130,6 +168,7 @@ describe("resolveSchema", () => { nodeSchemas: [{ ...baseNodeSchema, kind: "ExistingNode" }], genericSchemas: [{ ...baseGenericSchema, kind: "ExistingGeneric" }], profileSchemas: [{ ...baseProfileSchema, kind: "ExistingProfile" }], + templateSchemas: [{ ...baseTemplateSchema, kind: "ExistingTemplate" }], }; // WHEN @@ -141,6 +180,7 @@ describe("resolveSchema", () => { isGeneric: false, isNode: false, isProfile: false, + isTemplate: false, }); }); }); diff --git a/frontend/app/src/entities/schema/utils/resolve-schema.ts b/frontend/app/src/entities/schema/utils/resolve-schema.ts index d1411046df..f895afad27 100644 --- a/frontend/app/src/entities/schema/utils/resolve-schema.ts +++ b/frontend/app/src/entities/schema/utils/resolve-schema.ts @@ -1,4 +1,4 @@ -import { GenericSchema, NodeSchema, ProfileSchema } from "@/entities/schema/types"; +import { GenericSchema, NodeSchema, ProfileSchema, TemplateSchema } from "@/entities/schema/types"; export type SchemaResult = | { @@ -6,24 +6,35 @@ export type SchemaResult = isGeneric: false; isNode: true; isProfile: false; + isTemplate: false; } | { schema: GenericSchema; isGeneric: true; isNode: false; isProfile: false; + isTemplate: false; } | { schema: ProfileSchema; isGeneric: false; isNode: false; isProfile: true; + isTemplate: false; + } + | { + schema: TemplateSchema; + isGeneric: false; + isNode: false; + isProfile: false; + isTemplate: true; } | { schema: null; isGeneric: false; isNode: false; isProfile: false; + isTemplate: false; }; export function resolveSchema( @@ -32,6 +43,7 @@ export function resolveSchema( nodeSchemas: NodeSchema[]; genericSchemas: GenericSchema[]; profileSchemas: ProfileSchema[]; + templateSchemas: TemplateSchema[]; } ): SchemaResult { if (!kind) { @@ -40,6 +52,7 @@ export function resolveSchema( isGeneric: false, isNode: false, isProfile: false, + isTemplate: false, }; } @@ -50,6 +63,7 @@ export function resolveSchema( isGeneric: false, isNode: true, isProfile: false, + isTemplate: false, }; } @@ -60,6 +74,7 @@ export function resolveSchema( isGeneric: true, isNode: false, isProfile: false, + isTemplate: false, }; } @@ -70,6 +85,18 @@ export function resolveSchema( isGeneric: false, isNode: false, isProfile: true, + isTemplate: false, + }; + } + + const template = schemas.templateSchemas.find((schema) => schema.kind === kind); + if (template) { + return { + schema: template, + isGeneric: false, + isNode: false, + isProfile: false, + isTemplate: true, }; } @@ -78,5 +105,6 @@ export function resolveSchema( isGeneric: false, isNode: false, isProfile: false, + isTemplate: false, }; } diff --git a/frontend/app/src/pages/objects/layout.tsx b/frontend/app/src/pages/objects/layout.tsx index 1267934245..881cea49eb 100644 --- a/frontend/app/src/pages/objects/layout.tsx +++ b/frontend/app/src/pages/objects/layout.tsx @@ -1,10 +1,7 @@ import { HierarchicalTree } from "@/entities/nodes/hierarchical-tree"; import ObjectHeader from "@/entities/nodes/object-header"; -import { - genericSchemasAtom, - nodeSchemasAtom, - profileSchemasAtom, -} from "@/entities/schema/stores/schema.atom"; +import { genericSchemasAtom } from "@/entities/schema/stores/schema.atom"; +import { useSchema } from "@/entities/schema/ui/hooks/useSchema"; import NoDataFound from "@/shared/components/errors/no-data-found"; import Content from "@/shared/components/layout/content"; import { InfrahubLoading } from "@/shared/components/loading/infrahub-loading"; @@ -21,17 +18,15 @@ import { Outlet, useParams } from "react-router"; const ObjectPageLayout = () => { const { objectKind, objectid } = useParams(); - const nodes = useAtomValue(nodeSchemasAtom); const generics = useAtomValue(genericSchemasAtom); - const profiles = useAtomValue(profileSchemasAtom); + const { schema } = useSchema(objectKind); const state = useAtomValue(stateAtom); - const schema = [...nodes, ...generics, ...profiles].find(({ kind }) => kind === objectKind); if (!state.isReady) { return Loading config...; } - if (!schema) return ; + if (!schema) return ; const isHierarchicalModel = "hierarchical" in schema && schema.hierarchical; const inheritFormHierarchicalModel = "hierarchy" in schema && schema.hierarchy; diff --git a/frontend/app/src/shared/api/rest/types.generated.ts b/frontend/app/src/shared/api/rest/types.generated.ts index 15c5a7664b..1fe15f14e6 100644 --- a/frontend/app/src/shared/api/rest/types.generated.ts +++ b/frontend/app/src/shared/api/rest/types.generated.ts @@ -575,6 +575,12 @@ export interface components { * @default true */ generate_profile: boolean; + /** + * Generate Template + * @description Indicate if an object template schema should be generated for this schema + * @default false + */ + generate_template: boolean; /** * Used By * @description List of Nodes that are referencing this Generic @@ -688,6 +694,12 @@ export interface components { * @default true */ generate_profile: boolean; + /** + * Generate Template + * @description Indicate if an object template schema should be generated for this schema + * @default false + */ + generate_template: boolean; /** * Hierarchy * @description Internal value to track the name of the Hierarchy, must match the name of a Generic supporting hierarchical mode @@ -810,6 +822,108 @@ export interface components { /** Hash */ hash: string; }; + /** APITemplateSchema */ + APITemplateSchema: { + /** + * Id + * @description The ID of the node + */ + id?: string | null; + /** + * @description Expected state of the node/generic after loading the schema + * @default present + */ + state: components["schemas"]["HashableModelState"]; + /** + * Name + * @description Node name, must be unique within a namespace and must start with an uppercase letter. + */ + name: string; + /** + * Namespace + * @description Node Namespace, Namespaces are used to organize models into logical groups and to prevent name collisions. + */ + namespace: string; + /** + * Description + * @description Short description of the model, will be visible in the frontend. + */ + description?: string | null; + /** + * Label + * @description Human friendly representation of the name/kind + */ + label?: string | null; + /** + * @description Type of branch support for the model. + * @default aware + */ + branch: components["schemas"]["BranchSupportType"]; + /** + * Default Filter + * @description Default filter used to search for a node in addition to its ID. (deprecated: please use human_friendly_id instead) + */ + default_filter?: string | null; + /** + * Human Friendly Id + * @description Human friendly and unique identifier for the object. + */ + human_friendly_id?: string[] | null; + /** + * Display Labels + * @description List of attributes to use to generate the display label + */ + display_labels?: string[] | null; + /** + * Include In Menu + * @description Defines if objects of this kind should be included in the menu. + */ + include_in_menu?: boolean | null; + /** + * Menu Placement + * @description Defines where in the menu this object should be placed. + */ + menu_placement?: string | null; + /** + * Icon + * @description Defines the icon to use in the menu. Must be a valid value from the MDI library https://icon-sets.iconify.design/mdi/ + */ + icon?: string | null; + /** + * Order By + * @description List of attributes to use to order the results by default + */ + order_by?: string[] | null; + /** + * Uniqueness Constraints + * @description List of multi-element uniqueness constraints that can combine relationships and attributes + */ + uniqueness_constraints?: string[][] | null; + /** + * Documentation + * @description Link to a documentation associated with this object, can be internal or external. + */ + documentation?: string | null; + /** + * Attributes + * @description Node attributes + */ + attributes?: components["schemas"]["AttributeSchema-Output"][]; + /** + * Relationships + * @description Node Relationships + */ + relationships?: components["schemas"]["RelationshipSchema"][]; + /** + * Inherit From + * @description List of Generic Kind that this template is inheriting from + */ + inherit_from?: string[]; + /** Kind */ + kind?: string | null; + /** Hash */ + hash: string; + }; /** AccessTokenResponse */ AccessTokenResponse: { /** @@ -1285,6 +1399,12 @@ export interface components { * @default true */ generate_profile: boolean; + /** + * Generate Template + * @description Indicate if an object template schema should be generated for this schema + * @default false + */ + generate_template: boolean; /** * Used By * @description List of Nodes that are referencing this Generic @@ -1594,6 +1714,12 @@ export interface components { * @default true */ generate_profile: boolean; + /** + * Generate Template + * @description Indicate if an object template schema should be generated for this schema + * @default false + */ + generate_template: boolean; /** * Hierarchy * @description Internal value to track the name of the Hierarchy, must match the name of a Generic supporting hierarchical mode @@ -1649,7 +1775,7 @@ export interface components { * RelationshipKind * @enum {string} */ - RelationshipKind: "Generic" | "Attribute" | "Component" | "Parent" | "Group" | "Hierarchy" | "Profile"; + RelationshipKind: "Generic" | "Attribute" | "Component" | "Parent" | "Group" | "Hierarchy" | "Profile" | "Template"; /** RelationshipSchema */ RelationshipSchema: { /** @@ -1868,6 +1994,8 @@ export interface components { generics?: components["schemas"]["APIGenericSchema"][]; /** Profiles */ profiles?: components["schemas"]["APIProfileSchema"][]; + /** Templates */ + templates?: components["schemas"]["APITemplateSchema"][]; /** Namespaces */ namespaces?: components["schemas"]["SchemaNamespace"][]; }; @@ -2596,7 +2724,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["APIProfileSchema"] | components["schemas"]["APINodeSchema"] | components["schemas"]["APIGenericSchema"]; + "application/json": components["schemas"]["APIProfileSchema"] | components["schemas"]["APINodeSchema"] | components["schemas"]["APIGenericSchema"] | components["schemas"]["APITemplateSchema"]; }; }; /** @description Validation Error */ diff --git a/frontend/app/src/shared/components/form/fields/relationship.field.tsx b/frontend/app/src/shared/components/form/fields/relationship.field.tsx index 73b49a29d1..b2ce6ea5ba 100644 --- a/frontend/app/src/shared/components/form/fields/relationship.field.tsx +++ b/frontend/app/src/shared/components/form/fields/relationship.field.tsx @@ -4,6 +4,7 @@ import { genericSchemasAtom, nodeSchemasAtom, profileSchemasAtom, + templateSchemasAtom, } from "@/entities/schema/stores/schema.atom"; import useQuery from "@/shared/api/graphql/useQuery"; import { LabelFormField } from "@/shared/components/form/fields/common"; @@ -121,9 +122,12 @@ const RelationshipField = ({ if (generic) { const profiles = store.get(profileSchemasAtom); + const templates = store.get(templateSchemasAtom); const genericOptions = (generic.used_by || []) .map((name: string) => { - const relatedSchema = [...schemaList, ...profiles].find((s) => s.kind === name); + const relatedSchema = [...schemaList, ...profiles, ...templates].find( + (s) => s.kind === name + ); if (relatedSchema) { return { diff --git a/frontend/app/tests/e2e/objects/profiles/profiles.spec.ts b/frontend/app/tests/e2e/objects/profiles/profiles.spec.ts index 76ac3b7d31..e765525cab 100644 --- a/frontend/app/tests/e2e/objects/profiles/profiles.spec.ts +++ b/frontend/app/tests/e2e/objects/profiles/profiles.spec.ts @@ -131,7 +131,7 @@ test.describe("/objects/CoreProfile - Profiles page", () => { await test.step("Verify the changes in an object using the edited profile", async () => { await page.goto("/objects/BuiltinTag"); await page.getByRole("link", { name: "tag with profile" }).click(); - await expect(page.getByText("DescriptionA profile for E2E test")).toBeVisible(); + await expect(page.getByText("DescriptionA profile for E2E test edited")).toBeVisible(); }); }); diff --git a/frontend/app/tests/fake/schema.ts b/frontend/app/tests/fake/schema.ts index 52d254f7e7..e70a2e19ed 100644 --- a/frontend/app/tests/fake/schema.ts +++ b/frontend/app/tests/fake/schema.ts @@ -4,13 +4,14 @@ import { NodeSchema, ProfileSchema, RelationshipSchema, + TemplateSchema, } from "@/entities/schema/types"; export const generateNodeSchema = ( - overrides?: Partial + overrides?: Partial ): NodeSchema & { kind: string } => { return { - id: "18102d49-2480-33e9-3cbd-c51702dfa1e2", + id: "18262e04-2168-8370-3f45-c51733049c99", state: "present", name: "Tag", namespace: "Builtin", @@ -28,7 +29,7 @@ export const generateNodeSchema = ( documentation: null, attributes: [ { - id: "18102d49-2528-9c9f-3cb7-c51b530bfb5f", + id: "18262e04-21f2-2762-3f4d-c515c0bc4f6c", state: "present", name: "name", kind: "Text", @@ -51,7 +52,7 @@ export const generateNodeSchema = ( deprecation: null, }, { - id: "18102d49-25b6-e556-3cbf-c51ebba50701", + id: "18262e04-2276-99bd-3f45-c514f0b9917c", state: "present", name: "description", kind: "Text", @@ -76,18 +77,18 @@ export const generateNodeSchema = ( ], relationships: [ { - id: "18102d49-2631-08cf-3cbf-c5178fdcf302", + id: "18262e04-236f-aad5-3f44-c51d19f95474", state: "present", - name: "profiles", - peer: "CoreProfile", - kind: "Profile", - label: "Profiles", + name: "member_of_groups", + peer: "CoreGroup", + kind: "Group", + label: "Member Of Groups", description: null, - identifier: "node__profile", + identifier: "group_member", cardinality: "many", min_count: 0, max_count: 0, - order_weight: 3000, + order_weight: 4000, optional: true, branch: "aware", inherited: false, @@ -99,18 +100,18 @@ export const generateNodeSchema = ( deprecation: null, }, { - id: "18102d49-26a9-2d8b-3cb5-c514cff99737", + id: "18262e04-23e8-2be3-3f40-c51614e2f9a8", state: "present", - name: "member_of_groups", + name: "subscriber_of_groups", peer: "CoreGroup", kind: "Group", - label: "Member Of Groups", + label: "Subscriber Of Groups", description: null, - identifier: "group_member", + identifier: "group_subscriber", cardinality: "many", min_count: 0, max_count: 0, - order_weight: 4000, + order_weight: 5000, optional: true, branch: "aware", inherited: false, @@ -122,18 +123,18 @@ export const generateNodeSchema = ( deprecation: null, }, { - id: "18102d49-272a-ae1d-3cb3-c51499c4b693", + id: "18262e04-22f2-5a7f-3f49-c5125233994e", state: "present", - name: "subscriber_of_groups", - peer: "CoreGroup", - kind: "Group", - label: "Subscriber Of Groups", + name: "profiles", + peer: "CoreProfile", + kind: "Profile", + label: "Profiles", description: null, - identifier: "group_subscriber", + identifier: "node__profile", cardinality: "many", min_count: 0, max_count: 0, - order_weight: 5000, + order_weight: 3000, optional: true, branch: "aware", inherited: false, @@ -147,18 +148,19 @@ export const generateNodeSchema = ( ], inherit_from: [], generate_profile: true, + generate_template: false, hierarchy: null, parent: null, children: null, kind: "BuiltinTag", - hash: "1d7949127b4d0b13f7df8169e34152aa", + hash: "b4169708f7c88ab351fdde0164591bd9", ...overrides, }; }; export const generateGenericSchema = (overrides?: Partial): GenericSchema => { return { - id: "18102fa4-23c7-dbe0-3cb6-c515f0d8bc92", + id: "18262e0c-e58e-4ca4-3f41-c5185b7c4b5a", state: "present", name: "Interface", namespace: "Infra", @@ -176,7 +178,7 @@ export const generateGenericSchema = (overrides?: Partial): Gener documentation: null, attributes: [ { - id: "18102fa4-2449-45ed-3cb6-c512216580db", + id: "18262e0c-e615-d315-3f49-c5164b705df4", state: "present", name: "name", kind: "Text", @@ -199,7 +201,7 @@ export const generateGenericSchema = (overrides?: Partial): Gener deprecation: null, }, { - id: "18102fa4-24d2-f995-3cb5-c51c563be4e9", + id: "18262e0c-e696-c205-3f4e-c514d289fce4", state: "present", name: "description", kind: "Text", @@ -222,7 +224,7 @@ export const generateGenericSchema = (overrides?: Partial): Gener deprecation: null, }, { - id: "18102fa4-2556-10ad-3cb7-c518561f9b46", + id: "18262e0c-e716-60da-3f40-c511e7f45973", state: "present", name: "speed", kind: "Number", @@ -245,7 +247,7 @@ export const generateGenericSchema = (overrides?: Partial): Gener deprecation: null, }, { - id: "18102fa4-25d8-cf3a-3cb8-c5107d79388c", + id: "18262e0c-e79a-c99a-3f4a-c51ed0981745", state: "present", name: "mtu", kind: "Number", @@ -268,7 +270,7 @@ export const generateGenericSchema = (overrides?: Partial): Gener deprecation: null, }, { - id: "18102fa4-2652-aa33-3cb4-c51533e459aa", + id: "18262e0c-e848-9fb0-3f47-c5121b1979e2", state: "present", name: "enabled", kind: "Boolean", @@ -291,7 +293,7 @@ export const generateGenericSchema = (overrides?: Partial): Gener deprecation: null, }, { - id: "18102fa4-26d9-2812-3cb5-c51c9353d48a", + id: "18262e0c-e8ea-45d0-3f46-c518da52f7cd", state: "present", name: "status", kind: "Dropdown", @@ -347,7 +349,7 @@ export const generateGenericSchema = (overrides?: Partial): Gener deprecation: null, }, { - id: "18102fa4-2762-1c7b-3cb9-c5157efcdef5", + id: "18262e0c-e97c-3041-3f47-c511345dcdc2", state: "present", name: "role", kind: "Dropdown", @@ -453,7 +455,7 @@ export const generateGenericSchema = (overrides?: Partial): Gener ], relationships: [ { - id: "18102fa4-27e7-bcd5-3cb4-c51b6f10b79b", + id: "18262e0c-ea16-1311-3f42-c51c950a2d97", state: "present", name: "device", peer: "InfraDevice", @@ -476,7 +478,7 @@ export const generateGenericSchema = (overrides?: Partial): Gener deprecation: null, }, { - id: "18102fa4-2867-f879-3cb1-c51601070828", + id: "18262e0c-eaaf-4a7e-3f42-c51d6f43f5f4", state: "present", name: "tags", peer: "BuiltinTag", @@ -499,7 +501,7 @@ export const generateGenericSchema = (overrides?: Partial): Gener deprecation: null, }, { - id: "18102fa4-28dc-230d-3cb5-c515463abaee", + id: "18262e0c-eb40-fb51-3f48-c518d22f9a5c", state: "present", name: "profiles", peer: "CoreProfile", @@ -522,7 +524,7 @@ export const generateGenericSchema = (overrides?: Partial): Gener deprecation: null, }, { - id: "18102fa4-2951-bc2b-3cb8-c5136d49ec8d", + id: "18262e0c-ebe3-f549-3f4c-c51f812de7c6", state: "present", name: "member_of_groups", peer: "CoreGroup", @@ -545,7 +547,7 @@ export const generateGenericSchema = (overrides?: Partial): Gener deprecation: null, }, { - id: "18102fa4-29d2-20fd-3cbb-c512236a96b9", + id: "18262e0c-ec78-66b4-3f44-c51764fbe306", state: "present", name: "subscriber_of_groups", peer: "CoreGroup", @@ -570,9 +572,10 @@ export const generateGenericSchema = (overrides?: Partial): Gener ], hierarchical: false, generate_profile: true, + generate_template: false, used_by: ["InfraInterfaceL2", "InfraInterfaceL3", "InfraLagInterfaceL2", "InfraLagInterfaceL3"], kind: "InfraInterface", - hash: "0bcda5f40962693143c8f74347b58890", + hash: "46e4f878e399f34035224421ab419517", ...overrides, }; }; @@ -744,6 +747,150 @@ export const generateProfileSchema = (overrides?: Partial): Profi }; }; +export const generateTemplateSchema = (overrides?: Partial): TemplateSchema => { + return { + id: null, + state: "present", + name: "BuiltinTag", + namespace: "Template", + description: "Object template for BuiltinTag", + label: "Object template Tag", + branch: "aware", + default_filter: "template_name__value", + human_friendly_id: ["template_name__value"], + display_labels: ["template_name__value"], + include_in_menu: true, + menu_placement: null, + icon: null, + order_by: null, + uniqueness_constraints: null, + documentation: null, + attributes: [ + { + id: null, + state: "present", + name: "template_name", + kind: "Text", + enum: null, + computed_attribute: null, + choices: null, + regex: null, + max_length: null, + min_length: null, + label: "Template Name", + description: null, + read_only: false, + unique: true, + optional: false, + branch: "aware", + order_weight: 1000, + default_value: null, + inherited: false, + allow_override: "any", + deprecation: null, + }, + { + id: null, + state: "present", + name: "description", + kind: "Text", + enum: null, + computed_attribute: null, + choices: null, + regex: null, + max_length: null, + min_length: null, + label: "Description", + description: null, + read_only: false, + unique: false, + optional: true, + branch: "aware", + order_weight: 2000, + default_value: null, + inherited: false, + allow_override: "any", + deprecation: null, + }, + ], + relationships: [ + { + id: null, + state: "present", + name: "related_nodes", + peer: "BuiltinTag", + kind: "Template", + label: "Related Nodes", + description: null, + identifier: "node__objecttemplate", + cardinality: "many", + min_count: 0, + max_count: 0, + order_weight: 3000, + optional: true, + branch: "aware", + inherited: false, + direction: "bidirectional", + hierarchical: null, + on_delete: "no-action", + allow_override: "any", + read_only: false, + deprecation: null, + }, + { + id: null, + state: "present", + name: "member_of_groups", + peer: "CoreGroup", + kind: "Group", + label: "Member Of Groups", + description: null, + identifier: "group_member", + cardinality: "many", + min_count: 0, + max_count: 0, + order_weight: 4000, + optional: true, + branch: "aware", + inherited: false, + direction: "bidirectional", + hierarchical: null, + on_delete: "no-action", + allow_override: "any", + read_only: false, + deprecation: null, + }, + { + id: null, + state: "present", + name: "subscriber_of_groups", + peer: "CoreGroup", + kind: "Group", + label: "Subscriber Of Groups", + description: null, + identifier: "group_subscriber", + cardinality: "many", + min_count: 0, + max_count: 0, + order_weight: 5000, + optional: true, + branch: "aware", + inherited: false, + direction: "bidirectional", + hierarchical: null, + on_delete: "no-action", + allow_override: "any", + read_only: false, + deprecation: null, + }, + ], + inherit_from: ["LineageSource", "CoreObjectTemplate", "CoreNode"], + kind: "TemplateBuiltinTag", + hash: "786abdfef996bd6e6c4900005ce4c384", + ...overrides, + }; +}; + export const generateAttributeSchema = (overrides?: Partial): AttributeSchema => { return { id: null,