diff --git a/changelog/+common-parent-relationship-filter.fixed.md b/changelog/+common-parent-relationship-filter.fixed.md new file mode 100644 index 00000000000..b5cb214e4b0 --- /dev/null +++ b/changelog/+common-parent-relationship-filter.fixed.md @@ -0,0 +1 @@ +Fixed relationship selectors in object forms not honoring the `common_parent` schema property. The options are now filtered to peers that share the same parent as the value picked for the referenced relationship in the same form, instead of listing every peer. Changing that parent clears a now-invalid selection, and the inline "Add new" form pre-fills the parent when one is already selected so a created peer stays valid. diff --git a/frontend/app/.betterer.results b/frontend/app/.betterer.results index ac4f652d51d..0d429602f86 100644 --- a/frontend/app/.betterer.results +++ b/frontend/app/.betterer.results @@ -125,9 +125,9 @@ exports[`fix ts error`] = { "src/entities/nodes/relationships/ui/queries/get-default-parent.query.ts:2275135453": [ [18, 34, 2, "tsc: Property \'id\' does not exist on type \'NodeCore | NodeCore[] | { from_pool: { id: string; }; }\'.\\n Property \'id\' does not exist on type \'NodeCore[]\'.", "5861160"] ], - "src/entities/nodes/relationships/ui/relationship-combobox-list.tsx:1067691755": [ - [69, 59, 10, "tsc: No overload matches this call.\\n Overload 1 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.\\n Overload 2 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => unknown, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => unknown\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2021508816"], - [76, 41, 4, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2087865285"] + "src/entities/nodes/relationships/ui/relationship-combobox-list.tsx:2789186293": [ + [76, 59, 10, "tsc: No overload matches this call.\\n Overload 1 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.\\n Overload 2 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => unknown, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => unknown\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2021508816"], + [83, 41, 4, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2087865285"] ], "src/entities/nodes/relationships/ui/relationship-hierarchical-combobox-list.tsx:3738094152": [ [156, 61, 10, "tsc: No overload matches this call.\\n Overload 1 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.\\n Overload 2 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => unknown, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => unknown\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2021508816"], @@ -320,8 +320,8 @@ exports[`fix ts error`] = { [74, 9, 21, "tsc: Property \'type\' is missing in type \'\\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"agnostic\\" | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"cascade\\" | \\"extra\\"; }; filterQuery?: Record | \\"valueAsNumber\\" | boolean | null | null | null | null | null | null | null | null | null | null | null | number | string[]> | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; common_relatives?: string[] | undefined; description?: string | undefined; description?: string | undefined; disabled?: boolean | undefined; display: \\"default\\" | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; isBulkUpdate?: boolean | undefined; name: string; label?: string | undefined; onChange?: ((value: FormFieldValue) => void) | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; options?: SelectOption[] | undefined; order_weight?: number | undefined; parent?: string | undefined; peer?: string | undefined; peerField?: string | undefined; placeholder?: string | undefined; pool?: { kind: string; defaultAllocatedObjectKind: string; fromPoolRelationshipName?: string | undefined; relationship: { id?: string | undefined; rules?: Omit, \\"disabled\\" | undefined; shouldUnregister?: boolean | undefined; state: \\"present\\" | undefined; } | undefined; } | { unique: true; defaultValue?: FormRelationshipValue\' but required in type \'DynamicRelationshipFieldProps\'.", "2975306552"], [86, 9, 21, "tsc: Property \'type\' is missing in type \'\\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"agnostic\\" | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"cascade\\" | \\"extra\\"; }; filterQuery?: Record | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; common_relatives?: string[] | undefined; description?: string | undefined; description?: string | undefined; disabled?: boolean | undefined; display: \\"default\\" | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; isBulkUpdate?: boolean | undefined; name: string; label?: string | undefined; onChange?: ((value: FormFieldValue) => void) | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; options?: SelectOption[] | undefined; order_weight?: number | undefined; parent?: string | undefined; peer?: string | undefined; peerField?: string | undefined; placeholder?: string | undefined; pool?: { kind: string; defaultAllocatedObjectKind: string; fromPoolRelationshipName?: string | undefined; relationship: { id?: string | undefined; shouldUnregister?: boolean | undefined; state: \\"present\\" | undefined; unique?: boolean | undefined; } | undefined; } | { rules: { required: true; }; defaultValue?: FormRelationshipValue\' but required in type \'DynamicRelationshipFieldProps\'.", "2975306552"] ], - "src/shared/components/form/fields/relationships/generic-relationship.field.tsx:3433690233": [ - [222, 56, 4, "tsc: Property \'name\' does not exist on type \'\\"\\" | \\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"agnostic\\" | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"cascade\\" | \\"extra\\"; } | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; common_relatives?: string[] | undefined; description?: string | undefined; display: \\"default\\" | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_weight?: number | undefined; state: \\"present\\" | { id?: string\'.\\n Property \'name\' does not exist on type \'\\"\\"\'.", "2087876002"] + "src/shared/components/form/fields/relationships/generic-relationship.field.tsx:39778637": [ + [231, 54, 4, "tsc: Property \'name\' does not exist on type \'\\"\\" | \\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"agnostic\\" | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"cascade\\" | \\"extra\\"; } | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; common_relatives?: string[] | undefined; description?: string | undefined; display: \\"default\\" | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_weight?: number | undefined; state: \\"present\\" | { id?: string\'.\\n Property \'name\' does not exist on type \'\\"\\"\'.", "2087876002"] ], "src/shared/components/form/file-with-profile-form.tsx:3973907063": [ [13, 8, 6, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Component\\" | \\"Component\\" | \\"Group\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Parent\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"absent\\"; end_range: number; start_range: number; number_pool_id?: string | \\"absent\\"; end_range: number; start_range: number; number_pool_id?: string | \\"absent\\"; min_value?: number | \\"absent\\"; min_value?: number | \\"absent\\"; name: string; description?: string | \\"absent\\"; name: string; description?: string | \\"absent\\"; name: string; kind: string; enum?: unknown[] | \\"absent\\"; name: string; kind: string; enum?: unknown[] | \\"absent\\"; name: string; namespace: string; description?: string | \\"absent\\"; name: string; namespace: string; description?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"absent\\"; regex?: string | \\"absent\\"; regex?: string | \\"absent\\"; regex?: string | \\"absent\\"; regex?: string | \\"absent\\"; } | \\"absent\\"; } | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; parameters?: { id?: string | \\"any\\"; parameters?: { id?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; attributes?: { id?: string | undefined; attributes?: { id?: string | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { id?: string | undefined; choices?: { id?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { [key: string]: unknown; } | undefined; computed_attribute?: { [key: string]: unknown; } | undefined; default_value?: unknown; inherited: boolean; allow_override: \\"none\\" | undefined; default_value?: unknown; inherited: boolean; allow_override: \\"none\\" | undefined; deprecation?: string | undefined; deprecation?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash: string; } | undefined; hash: string; } | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; kind?: string | undefined; kind?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; on_delete?: \\"no-action\\" | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string\' is not assignable to type \'\\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; end_range: number; start_range: number; number_pool_id?: string | \\"absent\\"; min_value?: number | \\"absent\\"; name: string; description?: string | \\"absent\\"; name: string; kind: string; enum?: unknown[] | \\"absent\\"; name: string; namespace: string; description?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"absent\\"; regex?: string | \\"absent\\"; regex?: string | \\"absent\\"; } | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; parameters?: { id?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; attributes?: { id?: string | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { id?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { [key: string]: unknown; } | undefined; default_value?: unknown; inherited: boolean; allow_override: \\"none\\" | undefined; deprecation?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash: string; } | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; kind?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string\'.\\n Type \'\\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; end_range: number; start_range: number; number_pool_id?: string | \\"absent\\"; min_value?: number | \\"absent\\"; name: string; description?: string | \\"absent\\"; name: string; kind: string; enum?: unknown[] | \\"absent\\"; name: string; namespace: string; description?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"absent\\"; regex?: string | \\"absent\\"; regex?: string | \\"absent\\"; } | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; parameters?: { id?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; attributes?: { id?: string | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { id?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { [key: string]: unknown; } | undefined; default_value?: unknown; inherited: boolean; allow_override: \\"none\\" | undefined; deprecation?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash: string; } | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; kind?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string\' is missing 2 properties from type \'\\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; end_range: number; start_range: number; number_pool_id?: string | \\"absent\\"; min_value?: number | \\"absent\\"; name: string; description?: string | \\"absent\\"; name: string; kind: string; enum?: unknown[] | \\"absent\\"; name: string; namespace: string; description?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"absent\\"; regex?: string | \\"absent\\"; regex?: string | \\"absent\\"; } | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; parameters?: { id?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; attributes?: { id?: string | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { id?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { [key: string]: unknown; } | undefined; default_value?: unknown; inherited: boolean; allow_override: \\"none\\" | undefined; deprecation?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash: string; } | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; kind?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string\'", "2166186324"] @@ -363,10 +363,10 @@ exports[`fix ts error`] = { "src/shared/components/inputs/peer.tsx:3616063315": [ [66, 23, 5, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'Node\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "189936718"] ], - "src/shared/components/inputs/relationship-one.tsx:3286424152": [ - [97, 38, 2, "tsc: Property \'id\' does not exist on type \'Node | PoolValue\'.\\n Property \'id\' does not exist on type \'PoolValue\'.", "5861160"], - [99, 54, 2, "tsc: Property \'id\' does not exist on type \'Node | PoolValue\'.\\n Property \'id\' does not exist on type \'PoolValue\'.", "5861160"], - [146, 23, 5, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'Node | PoolValue | null\'.\\n Type \'NodeCore\' is not assignable to type \'Node\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "189936718"] + "src/shared/components/inputs/relationship-one.tsx:3115716616": [ + [100, 38, 2, "tsc: Property \'id\' does not exist on type \'Node | PoolValue\'.\\n Property \'id\' does not exist on type \'PoolValue\'.", "5861160"], + [102, 54, 2, "tsc: Property \'id\' does not exist on type \'Node | PoolValue\'.\\n Property \'id\' does not exist on type \'PoolValue\'.", "5861160"], + [150, 23, 5, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'Node | PoolValue | null\'.\\n Type \'NodeCore\' is not assignable to type \'Node\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "189936718"] ], "src/shared/components/table/table.tsx:2590261269": [ [80, 40, 23, "tsc: Argument of type \'number | string | tRowValue | undefined\' is not assignable to parameter of type \'number | string | tRowValue\'.\\n Type \'undefined\' is not assignable to type \'number | string | tRowValue\'.", "1426455104"], diff --git a/frontend/app/src/entities/nodes/relationships/ui/add-relationship-action.tsx b/frontend/app/src/entities/nodes/relationships/ui/add-relationship-action.tsx index c1cc8ad2fa1..eea0883eb5a 100644 --- a/frontend/app/src/entities/nodes/relationships/ui/add-relationship-action.tsx +++ b/frontend/app/src/entities/nodes/relationships/ui/add-relationship-action.tsx @@ -5,16 +5,20 @@ import { useState } from "react"; import SlideOver, { SlideOverTitle } from "@/shared/components/display/slide-over"; import ObjectForm, { type ObjectFormProps } from "@/shared/components/form/object-form"; +import type { NodeFieldsWithMetadata } from "@/entities/nodes/types"; import { useSchema } from "@/entities/schema/ui/hooks/useSchema"; export interface AddRelationshipActionProps { peer: string; onSuccess?: ObjectFormProps["onSuccess"]; + // Pre-fills the create form (e.g. the common_parent so a created peer satisfies the constraint). + initialObject?: NodeFieldsWithMetadata; } export const AddRelationshipAction: React.FC = ({ peer, onSuccess, + initialObject, }) => { const { schema } = useSchema(peer); const [open, setOpen] = useState(false); @@ -45,6 +49,7 @@ export const AddRelationshipAction: React.FC = ({ > { setOpen(false); if (!onSuccess) return; diff --git a/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.test.tsx b/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.test.tsx index 8b876e306ad..2926202becc 100644 --- a/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.test.tsx +++ b/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.test.tsx @@ -105,4 +105,34 @@ describe("RelationshipComboboxList", () => { const lastCall = useRelationshipsMock.mock.calls.at(-1)?.[0]; expect(lastCall.filterQuery).toEqual({ ids: ["17a4cdef-1234-4abc-8def-0123456789ab"] }); }); + + test("keeps the caller filterQuery on a UUID search when enforceFilterQueryOnIdSearch is set", async () => { + useRelationshipsMock.mockReturnValue(setupReturn()); + useSchemaMock.mockReturnValue({ schema: { label: "Device" } }); + + const component = await render( + + ); + + // Clear previous calls from initial render + useRelationshipsMock.mockClear(); + + const input = component.getByRole("combobox"); + await input.click(); + await input.fill("17a4cdef-1234-4abc-8def-0123456789ab"); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + // The id restriction and the enforced parent filter are both applied. + const lastCall = useRelationshipsMock.mock.calls.at(-1)?.[0]; + expect(lastCall.filterQuery).toEqual({ + device__ids: ["dev-1"], + ids: ["17a4cdef-1234-4abc-8def-0123456789ab"], + }); + }); }); diff --git a/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.tsx b/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.tsx index 5d0ad903c32..4dda3eceb26 100644 --- a/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.tsx +++ b/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.tsx @@ -24,6 +24,9 @@ export interface RelationshipComboboxListProps value?: RelationshipNode | null; filterItem?: (relationshipNode: RelationshipNode) => boolean; filterQuery?: Record; + // Keep filterQuery applied even on a UUID search. Used when the filter is a hard constraint + // (e.g. common_parent) that a UUID lookup must not bypass. + enforceFilterQueryOnIdSearch?: boolean; } export const RelationshipComboboxList = ({ @@ -33,19 +36,23 @@ export const RelationshipComboboxList = ({ onSelect, filterItem, filterQuery, + enforceFilterQueryOnIdSearch, ...props }: RelationshipComboboxListProps) => { const [search, setSearch] = React.useState(""); const { schema } = useSchema(peer); - // When the user types or pastes a UUID, switch the underlying query from a - // label search to an ids filter. UUID is a maximally specific match, so it - // intentionally overrides any caller-provided filterQuery. + // When the user types or pastes a UUID, switch the underlying query from a label search to an + // ids filter. UUID is a maximally specific match, so it overrides a caller-provided filterQuery + // by default — unless enforceFilterQueryOnIdSearch keeps the filter as a hard constraint. const isUuidSearch = search.length > 0 && isUuid(search); + const idSearchFilterQuery = enforceFilterQueryOnIdSearch + ? { ...filterQuery, ids: [search.trim()] } + : { ids: [search.trim()] }; const { isPending, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useRelationships({ peer, search: isUuidSearch ? undefined : search, - filterQuery: isUuidSearch ? { ids: [search.trim()] } : filterQuery, + filterQuery: isUuidSearch ? idSearchFilterQuery : filterQuery, }); if (error) return ; diff --git a/frontend/app/src/entities/nodes/relationships/ui/relationship-hierarchical-input.tsx b/frontend/app/src/entities/nodes/relationships/ui/relationship-hierarchical-input.tsx index fb214d3a130..45fc3305b03 100644 --- a/frontend/app/src/entities/nodes/relationships/ui/relationship-hierarchical-input.tsx +++ b/frontend/app/src/entities/nodes/relationships/ui/relationship-hierarchical-input.tsx @@ -28,12 +28,30 @@ import { type RelationshipComboboxListProps, } from "@/entities/nodes/relationships/ui/relationship-combobox-list"; import { RelationshipHierarchicalComboboxList } from "@/entities/nodes/relationships/ui/relationship-hierarchical-combobox-list"; - -export interface RelationshipHierarchicalContentProps extends RelationshipComboboxListProps {} +import type { NodeFieldsWithMetadata } from "@/entities/nodes/types"; + +export interface RelationshipHierarchicalContentProps extends RelationshipComboboxListProps { + // The tree explorer browses the peer's own hierarchy and cannot honor an external filterQuery, + // so it is dropped when a filter must be enforced (e.g. common_parent). + hideExplore?: boolean; + // Pre-fills the "Add new" create form so a created peer satisfies an enforced filter. + addNewInitialObject?: NodeFieldsWithMetadata; +} export const RelationshipHierarchicalContent = ({ + hideExplore, + addNewInitialObject, ...props }: RelationshipHierarchicalContentProps) => { + if (hideExplore) { + return ( + + + + + ); + } + return ( @@ -44,7 +62,7 @@ export const RelationshipHierarchicalContent = ({ - + @@ -61,6 +79,10 @@ export interface RelationshipHierarchicalInputProps onChange?: (value: RelationshipNode | null) => void; value?: RelationshipNode | null; peer: string; + filterQuery?: Record; + hideExplore?: boolean; + addNewInitialObject?: NodeFieldsWithMetadata; + enforceFilterQueryOnIdSearch?: boolean; } export const RelationshipHierarchicalInput = ({ @@ -68,6 +90,10 @@ export const RelationshipHierarchicalInput = ({ value, onChange, peer, + filterQuery, + hideExplore, + addNewInitialObject, + enforceFilterQueryOnIdSearch, ...props }: RelationshipHierarchicalInputProps) => { const [open, setOpen] = React.useState(false); @@ -83,7 +109,15 @@ export const RelationshipHierarchicalInput = ({ {value ? getNodeLabel(value) : ""} - + ); }; @@ -94,6 +128,10 @@ export interface RelationshipHierarchicalManyInputProps onChange: (value: RelationshipNode[]) => void; value?: RelationshipNode[] | null; peer: string; + filterQuery?: Record; + hideExplore?: boolean; + addNewInitialObject?: NodeFieldsWithMetadata; + enforceFilterQueryOnIdSearch?: boolean; } export const RelationshipHierarchicalManyInput = ({ @@ -102,6 +140,10 @@ export const RelationshipHierarchicalManyInput = ({ onChange, peer, className, + filterQuery, + hideExplore, + addNewInitialObject, + enforceFilterQueryOnIdSearch, ...props }: RelationshipHierarchicalManyInputProps) => { const [open, setOpen] = React.useState(false); @@ -159,6 +201,10 @@ export const RelationshipHierarchicalManyInput = ({ peer={peer} onSelect={handleSelect} filterItem={(node) => !value?.some((v) => v.id === node.id)} + filterQuery={filterQuery} + hideExplore={hideExplore} + addNewInitialObject={addNewInitialObject} + enforceFilterQueryOnIdSearch={enforceFilterQueryOnIdSearch} /> ); diff --git a/frontend/app/src/shared/components/form/fields/relationship-many.common-parent.test.tsx b/frontend/app/src/shared/components/form/fields/relationship-many.common-parent.test.tsx new file mode 100644 index 00000000000..026c4476b36 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationship-many.common-parent.test.tsx @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { TestForm } from "../../../../../tests/components/form.story"; +import { render } from "../../../../../tests/components/render"; +import { generateRelationshipSchema } from "../../../../../tests/fake/schema"; +import RelationshipManyField from "./relationships/relationship-many.field"; + +// Capture the props handed to the input so we can assert the common_parent wiring +// without driving the whole combobox/query stack. +let lastFilterQuery: unknown; +let lastAddNewInitialObject: unknown; +let lastEnforceOnIdSearch: unknown; +vi.mock("@/shared/components/inputs/relationship-many", () => ({ + RelationshipManyInput: (props: { + filterQuery?: unknown; + addNewInitialObject?: unknown; + enforceFilterQueryOnIdSearch?: unknown; + }) => { + lastFilterQuery = props.filterQuery; + lastAddNewInitialObject = props.addNewInitialObject; + lastEnforceOnIdSearch = props.enforceFilterQueryOnIdSearch; + return ; + }, +})); + +describe("RelationshipManyField - common_parent filtering", () => { + afterEach(() => { + lastFilterQuery = undefined; + lastAddNewInitialObject = undefined; + lastEnforceOnIdSearch = undefined; + vi.clearAllMocks(); + }); + + test("filters the peer options by the common_parent chosen in a sibling field", async () => { + // GIVEN a relationship declaring common_parent: device, with the sibling device picked + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestProfile", + common_parent: "device", + }); + const deviceValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" }, + }; + + // WHEN the field renders with that sibling value seeded into the form + await render( + + + + ); + + // THEN the input receives a single-hop filter on the chosen parent; the UUID-search override + // is closed, and "Add new" is pre-filled with that parent so a created peer stays valid. + await expect.poll(() => lastFilterQuery).toEqual({ device__ids: ["dev-1"] }); + expect(lastEnforceOnIdSearch).toBe(true); + expect(lastAddNewInitialObject).toEqual({ + device: { node: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" } }, + }); + }); + + test("passes no filter when the sibling common_parent field is empty", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestProfile", + common_parent: "device", + }); + + await render( + + + + ); + + await expect.poll(() => lastFilterQuery).toBeUndefined(); + }); + + test("passes no filter when the schema declares no common_parent", async () => { + const relationship = generateRelationshipSchema({ name: "tags", peer: "TestProfile" }); + + await render( + + + + ); + + await expect.poll(() => lastFilterQuery).toBeUndefined(); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.common-parent.test.tsx b/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.common-parent.test.tsx new file mode 100644 index 00000000000..1e1eeba3813 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.common-parent.test.tsx @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { store } from "@/shared/stores"; + +import { genericSchemasAtom, nodeSchemasAtom } from "@/entities/schema/stores/schema.atom"; + +import { TestForm } from "../../../../../../tests/components/form.story"; +import { render } from "../../../../../../tests/components/render"; +import { + generateGenericSchema, + generateNodeSchema, + generateRelationshipSchema, +} from "../../../../../../tests/fake/schema"; +import { GenericRelationshipField } from "./generic-relationship.field"; + +// Capture the props handed to the peer picker. +let lastParent: unknown; +let lastAddNewInitialObject: unknown; +vi.mock("@/shared/components/inputs/relationship-one", () => ({ + RelationshipInput: (props: { parent?: unknown; addNewInitialObject?: unknown }) => { + lastParent = props.parent; + lastAddNewInitialObject = props.addNewInitialObject; + return ; + }, +})); + +// A generic peer with a single concrete implementation (auto-selected). The concrete kind has a +// Parent relationship named "device", so the manual picker would show by default. +const concretePeer = generateNodeSchema({ + kind: "TestProfileOne", + name: "ProfileOne", + relationships: [ + generateRelationshipSchema({ + name: "device", + peer: "TestDevice", + kind: "Parent", + cardinality: "one", + optional: false, + }), + ], +}); + +const genericPeer = generateGenericSchema({ + kind: "TestGenericProfile", + name: "GenericProfile", + relationships: [], + used_by: ["TestProfileOne"], +}); + +const deviceValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" }, +}; + +describe("GenericRelationshipField - common_parent", () => { + beforeEach(() => { + store.set(nodeSchemasAtom, [concretePeer]); + store.set(genericSchemasAtom, [genericPeer]); + }); + afterEach(() => { + lastParent = undefined; + lastAddNewInitialObject = undefined; + vi.clearAllMocks(); + }); + + test("hides the manual parent picker and filters by the sibling when common_parent is set", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestGenericProfile", + cardinality: "one", + common_parent: "device", + }); + + await render( + + + + ); + + await expect.poll(() => lastParent).toEqual({ name: "device", value: "dev-1" }); + expect(document.querySelectorAll('[data-testid="rel-input"]')).toHaveLength(1); + expect(lastAddNewInitialObject).toEqual({ + device: { node: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" } }, + }); + }); + + test("shows the manual parent picker when common_parent is not set", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestGenericProfile", + cardinality: "one", + }); + + await render( + + + + ); + + // Manual parent picker present in addition to the peer picker → two inputs. + await expect.poll(() => document.querySelectorAll('[data-testid="rel-input"]').length).toBe(2); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.field.tsx b/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.field.tsx index f4a9aa813d8..6fc933c6f48 100644 --- a/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.field.tsx +++ b/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.field.tsx @@ -24,6 +24,8 @@ import type { Node } from "@/entities/nodes/getObjectItemDisplayValue"; import { useDefaultParent } from "@/entities/nodes/relationships/ui/queries/get-default-parent.query"; import { useSchema } from "@/entities/schema/ui/hooks/useSchema"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + interface GenericOption extends Node { id: string; display_label: string; @@ -57,6 +59,10 @@ export const GenericRelationshipField = ({ ); const parentRelationship = selectedGeneric?.id && getParentRelationship(selectedGeneric.id); + const commonParent = useCommonParentFilter(relationship, name); + // When common_parent drives the filter from a sibling field, the manual "Parent" picker + // is redundant — hide it and source the peer filter from the sibling value instead. + const showManualParent = !commonParent.isActive && !!parentRelationship; const { data: defaultParent } = useDefaultParent({ defaultValue, @@ -156,7 +162,7 @@ export const GenericRelationshipField = ({ setSelectedGeneric={handleKindChange} /> - {parentRelationship && ( + {showManualParent && parentRelationship && ( diff --git a/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.common-parent.test.tsx b/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.common-parent.test.tsx new file mode 100644 index 00000000000..db50b084221 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.common-parent.test.tsx @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { store } from "@/shared/stores"; + +import { nodeSchemasAtom } from "@/entities/schema/stores/schema.atom"; + +import { TestForm } from "../../../../../../tests/components/form.story"; +import { render } from "../../../../../../tests/components/render"; +import { + generateNodeSchema, + generateRelationshipSchema, +} from "../../../../../../tests/fake/schema"; +import { NodeRelationshipField } from "./regular-relationship.field"; + +// Capture the props handed to the peer picker. +let lastParent: unknown; +let lastAddNewInitialObject: unknown; +vi.mock("@/shared/components/inputs/relationship-one", () => ({ + RelationshipInput: (props: { parent?: unknown; addNewInitialObject?: unknown }) => { + lastParent = props.parent; + lastAddNewInitialObject = props.addNewInitialObject; + return ; + }, +})); + +// Peer has a Parent relationship named "device", so the manual picker would show by default. +const peerSchema = generateNodeSchema({ + kind: "TestProfile", + name: "Profile", + relationships: [ + generateRelationshipSchema({ + name: "device", + peer: "TestDevice", + kind: "Parent", + cardinality: "one", + optional: false, + }), + ], +}); + +const deviceValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" }, +}; + +describe("NodeRelationshipField - common_parent", () => { + beforeEach(() => { + store.set(nodeSchemasAtom, [peerSchema]); + }); + afterEach(() => { + lastParent = undefined; + lastAddNewInitialObject = undefined; + vi.clearAllMocks(); + }); + + test("hides the manual parent picker and filters by the sibling when common_parent is set", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestProfile", + cardinality: "one", + common_parent: "device", + }); + + await render( + + + + ); + + // Only the peer picker renders (manual parent picker hidden), filtered by the sibling, + // with "Add new" pre-filled so a created peer stays valid. + await expect.poll(() => lastParent).toEqual({ name: "device", value: "dev-1" }); + expect(document.querySelectorAll('[data-testid="rel-input"]')).toHaveLength(1); + expect(lastAddNewInitialObject).toEqual({ + device: { node: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" } }, + }); + }); + + test("shows the manual parent picker when common_parent is not set", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestProfile", + cardinality: "one", + }); + + await render( + + + + ); + + // Manual parent picker present in addition to the peer picker → two inputs. + await expect.poll(() => document.querySelectorAll('[data-testid="rel-input"]').length).toBe(2); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.field.tsx b/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.field.tsx index 20365282f98..f735008e411 100644 --- a/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.field.tsx +++ b/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.field.tsx @@ -17,6 +17,8 @@ import { FormField, FormInput, FormMessage } from "@/shared/components/ui/form"; import type { Node } from "@/entities/nodes/getObjectItemDisplayValue"; import { useDefaultParent } from "@/entities/nodes/relationships/ui/queries/get-default-parent.query"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + export interface RegularRelationshipFieldProps extends DynamicRelationshipFieldProps { parentDisabled?: boolean; defaultParent?: Node | null; @@ -39,6 +41,10 @@ export const NodeRelationshipField = ({ ...props }: RegularRelationshipFieldProps) => { const parentRelationship = getParentRelationship(relationship.peer); + const commonParent = useCommonParentFilter(relationship, name); + // When common_parent drives the filter from a sibling field, the manual "Parent" picker + // is redundant — hide it and source the peer filter from the sibling value instead. + const showManualParent = !commonParent.isActive && !!parentRelationship; const { data: defaultParent } = useDefaultParent({ defaultValue, @@ -61,7 +67,7 @@ export const NodeRelationshipField = ({ return (
- {parentRelationship && ( + {showManualParent && ( )} - {parentRelationship && ( + {showManualParent && ( @@ -143,7 +149,12 @@ export const NodeRelationshipField = ({ value={value} onChange={onChange} peer={peer} - parent={{ name: parentRelationship?.name, value: selectedParent?.id }} + parent={ + commonParent.isActive + ? commonParent.parent + : { name: parentRelationship?.name, value: selectedParent?.id } + } + addNewInitialObject={commonParent.addNewInitialObject} /> diff --git a/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.common-parent.test.tsx b/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.common-parent.test.tsx new file mode 100644 index 00000000000..a1ba7c1f030 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.common-parent.test.tsx @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { TestForm } from "../../../../../../tests/components/form.story"; +import { render } from "../../../../../../tests/components/render"; +import { generateRelationshipSchema } from "../../../../../../tests/fake/schema"; +import RelationshipHierarchicalField from "./relationship-hierarchical.field"; + +// Capture the props handed to the hierarchical inputs. +let lastFilterQuery: unknown; +let lastHideExplore: unknown; +vi.mock("@/entities/nodes/relationships/ui/relationship-hierarchical-input", () => ({ + RelationshipHierarchicalInput: (props: { filterQuery?: unknown; hideExplore?: unknown }) => { + lastFilterQuery = props.filterQuery; + lastHideExplore = props.hideExplore; + return ; + }, + RelationshipHierarchicalManyInput: (props: { filterQuery?: unknown; hideExplore?: unknown }) => { + lastFilterQuery = props.filterQuery; + lastHideExplore = props.hideExplore; + return ; + }, +})); + +const deviceValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" }, +}; + +describe("RelationshipHierarchicalField - common_parent", () => { + afterEach(() => { + lastFilterQuery = undefined; + lastHideExplore = undefined; + vi.clearAllMocks(); + }); + + test("filters by the sibling common_parent value", async () => { + const relationship = generateRelationshipSchema({ + name: "children", + peer: "TestNode", + cardinality: "one", + hierarchical: "TestNode", + common_parent: "device", + }); + + await render( + + + + ); + + await expect.poll(() => lastFilterQuery).toEqual({ device__ids: ["dev-1"] }); + // The tree explorer can't honor the filter, so it is dropped when common_parent applies. + expect(lastHideExplore).toBe(true); + }); + + test("passes no filter when the schema declares no common_parent", async () => { + const relationship = generateRelationshipSchema({ + name: "children", + peer: "TestNode", + cardinality: "one", + hierarchical: "TestNode", + }); + + await render( + + + + ); + + await expect.poll(() => lastFilterQuery).toBeUndefined(); + expect(lastHideExplore).toBe(false); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.field.tsx b/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.field.tsx index 9ed782918b1..18707f66e81 100644 --- a/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.field.tsx +++ b/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.field.tsx @@ -18,6 +18,8 @@ import { } from "@/entities/nodes/relationships/ui/relationship-hierarchical-input"; import type { NodeCore } from "@/entities/nodes/types"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + export interface RelationshipHierarchicalFieldProps extends Omit {} @@ -33,6 +35,8 @@ export default function RelationshipHierarchicalField({ shouldUnregister, pool, }: RelationshipHierarchicalFieldProps) { + const commonParent = useCommonParentFilter(relationship, name); + return ( ) : ( )} diff --git a/frontend/app/src/shared/components/form/fields/relationships/relationship-many.field.tsx b/frontend/app/src/shared/components/form/fields/relationships/relationship-many.field.tsx index 90394c6a980..0bc2b9ce5d5 100644 --- a/frontend/app/src/shared/components/form/fields/relationships/relationship-many.field.tsx +++ b/frontend/app/src/shared/components/form/fields/relationships/relationship-many.field.tsx @@ -12,6 +12,8 @@ import { classNames } from "@/shared/utils/common"; import type { NodeCore } from "@/entities/nodes/types"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + export interface RelationshipManyInputProps extends DynamicRelationshipFieldProps {} export default function RelationshipManyField({ @@ -28,6 +30,8 @@ export default function RelationshipManyField({ filterQuery, ...props }: RelationshipManyInputProps) { + const commonParent = useCommonParentFilter(relationship, name); + return ( :last-child:focus]:border-red-500 has-[>:last-child:focus]:ring-red-500/25" )} peer={relationship.peer} - filterQuery={filterQuery} + filterQuery={ + commonParent.filterQuery + ? { ...filterQuery, ...commonParent.filterQuery } + : filterQuery + } + enforceFilterQueryOnIdSearch={commonParent.isActive} + addNewInitialObject={commonParent.addNewInitialObject} value={fieldData.value as NodeCore[] | null} onChange={(newValue) => { field.onChange( diff --git a/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.test.tsx b/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.test.tsx new file mode 100644 index 00000000000..a38ca145d59 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.test.tsx @@ -0,0 +1,112 @@ +import { useFormContext, useWatch } from "react-hook-form"; +import { describe, expect, test } from "vitest"; + +import { TestForm } from "../../../../../../tests/components/form.story"; +import { render } from "../../../../../../tests/components/render"; +import { generateRelationshipSchema } from "../../../../../../tests/fake/schema"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + +const Probe = ({ commonParent }: { commonParent?: string | null }) => { + const relationship = generateRelationshipSchema({ common_parent: commonParent ?? null }); + const result = useCommonParentFilter(relationship, "dependent"); + return
{JSON.stringify(result)}
; +}; + +const parentValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "atl1-edge", __typename: "InfraDevice" }, +}; + +describe("useCommonParentFilter", () => { + test("is inactive when the relationship declares no common_parent", async () => { + const component = await render( + + + + ); + + await expect + .element(component.getByTestId("result")) + .toHaveTextContent(JSON.stringify({ isActive: false })); + }); + + test("returns no filter while the sibling field is empty", async () => { + const component = await render( + + + + ); + + await expect + .element(component.getByTestId("result")) + .toHaveTextContent(JSON.stringify({ isActive: true, parent: { name: "device" } })); + }); + + test("builds the single-hop filter from the picked sibling parent", async () => { + const component = await render( + + + + ); + + await expect.element(component.getByTestId("result")).toHaveTextContent( + JSON.stringify({ + isActive: true, + filterQuery: { device__ids: ["dev-1"] }, + parent: { name: "device", value: "dev-1" }, + addNewInitialObject: { + device: { node: { id: "dev-1", display_label: "atl1-edge", __typename: "InfraDevice" } }, + }, + }) + ); + }); +}); + +// Harness that reads the dependent field value and lets the test change the parent. +const ClearHarness = () => { + const relationship = generateRelationshipSchema({ name: "profile", common_parent: "device" }); + useCommonParentFilter(relationship, "profile"); + const form = useFormContext(); + const dependent = useWatch({ name: "profile" }); + + return ( +
+
{JSON.stringify(dependent)}
+ +
+ ); +}; + +describe("useCommonParentFilter - clears the selection on parent change", () => { + test("keeps the pre-filled selection on mount but clears it when the parent changes", async () => { + const selected = { + source: { type: "user" as const }, + value: { id: "profile-1", display_label: "p1-alpha-dc1", __typename: "TestProfile" }, + }; + + const component = await render( + + + + ); + + // Preserved on mount. + await expect.element(component.getByTestId("dependent")).toHaveTextContent("profile-1"); + + // Changing the parent clears the now out-of-filter selection. + await component.getByRole("button", { name: "change parent" }).click(); + await expect + .element(component.getByTestId("dependent")) + .toHaveTextContent(JSON.stringify({ source: null, value: null })); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.ts b/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.ts new file mode 100644 index 00000000000..0e1dfe4afa9 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.ts @@ -0,0 +1,69 @@ +import { useEffect, useRef } from "react"; +import { useFormContext, useWatch } from "react-hook-form"; + +import { DEFAULT_FORM_FIELD_VALUE } from "@/shared/components/form/constants"; +import type { FormRelationshipValue } from "@/shared/components/form/type"; + +import type { NodeFieldsWithMetadata } from "@/entities/nodes/types"; +import type { RelationshipSchema } from "@/entities/schema/types"; + +// Matches no field, so the useWatch call stays unconditional without subscribing to the whole +// form when the relationship declares no common_parent. +const NO_COMMON_PARENT = "__no_common_parent__"; + +export interface CommonParentFilter { + isActive: boolean; + // Filter for the record-shape consumers (many / hierarchical). + filterQuery?: Record; + // Filter for RelationshipInput's `parent` prop (cardinality one). + parent?: { name: string; value?: string }; + // Seed pre-filling the inline "Add new" form's parent so a created peer stays valid. + addNewInitialObject?: NodeFieldsWithMetadata; +} + +/** + * For a relationship declaring `common_parent: `, filter the peer options to those sharing the + * `` parent picked for the sibling `` field, via a single-hop `__ids` filter. + */ +export const useCommonParentFilter = ( + relationship: RelationshipSchema, + name: string +): CommonParentFilter => { + const commonParent = relationship.common_parent ?? undefined; + const watched = useWatch({ name: commonParent ?? NO_COMMON_PARENT }) as + | FormRelationshipValue + | undefined; + const form = useFormContext(); + + const value = watched?.value; + const parentNode = value && !Array.isArray(value) && "id" in value ? value : undefined; + const chosenParentId = parentNode?.id; + + // A peer picked under one parent no longer satisfies the constraint once the parent changes, so + // clear it — but skip the first observed value so a pre-filled (edit) selection survives mount. + const previousParentId = useRef(chosenParentId); + const isFirstRun = useRef(true); + useEffect(() => { + if (!commonParent) return; + if (isFirstRun.current) { + isFirstRun.current = false; + previousParentId.current = chosenParentId; + return; + } + if (previousParentId.current !== chosenParentId) { + previousParentId.current = chosenParentId; + form.setValue(name, DEFAULT_FORM_FIELD_VALUE, { shouldDirty: true }); + } + }, [chosenParentId, commonParent, name, form]); + + if (!commonParent) return { isActive: false }; + + return { + isActive: true, + filterQuery: chosenParentId ? { [`${commonParent}__ids`]: [chosenParentId] } : undefined, + parent: { name: commonParent, value: chosenParentId }, + addNewInitialObject: parentNode + ? ({ [commonParent]: { node: parentNode } } as NodeFieldsWithMetadata) + : undefined, + }; +}; diff --git a/frontend/app/src/shared/components/inputs/relationship-many.tsx b/frontend/app/src/shared/components/inputs/relationship-many.tsx index 61031129584..ece77d63174 100644 --- a/frontend/app/src/shared/components/inputs/relationship-many.tsx +++ b/frontend/app/src/shared/components/inputs/relationship-many.tsx @@ -12,7 +12,7 @@ import { classNames } from "@/shared/utils/common"; import { getNodeLabel } from "@/entities/nodes/object/utils/get-node-label"; import { AddRelationshipAction } from "@/entities/nodes/relationships/ui/add-relationship-action"; import { RelationshipComboboxList } from "@/entities/nodes/relationships/ui/relationship-combobox-list"; -import type { NodeCore } from "@/entities/nodes/types"; +import type { NodeCore, NodeFieldsWithMetadata } from "@/entities/nodes/types"; export interface RelationshipManyInputProps extends Omit { @@ -21,6 +21,8 @@ export interface RelationshipManyInputProps peer: string; value: Array | null; filterQuery?: Record; + enforceFilterQueryOnIdSearch?: boolean; + addNewInitialObject?: NodeFieldsWithMetadata; ref?: React.Ref; } @@ -30,6 +32,8 @@ export function RelationshipManyInput({ value, onChange, filterQuery, + enforceFilterQueryOnIdSearch, + addNewInitialObject, ref, ...props }: RelationshipManyInputProps) { @@ -89,8 +93,13 @@ export function RelationshipManyInput({ onSelect={handleSelect} filterItem={(node) => !value?.some((v) => v.id === node.id)} filterQuery={filterQuery} + enforceFilterQueryOnIdSearch={enforceFilterQueryOnIdSearch} + /> + - ); diff --git a/frontend/app/src/shared/components/inputs/relationship-one.tsx b/frontend/app/src/shared/components/inputs/relationship-one.tsx index 5c4106eb521..e909669d80f 100644 --- a/frontend/app/src/shared/components/inputs/relationship-one.tsx +++ b/frontend/app/src/shared/components/inputs/relationship-one.tsx @@ -21,6 +21,7 @@ import type { Node } from "@/entities/nodes/getObjectItemDisplayValue"; import { getNodeLabel } from "@/entities/nodes/object/utils/get-node-label"; import { AddRelationshipAction } from "@/entities/nodes/relationships/ui/add-relationship-action"; import { useRelationships } from "@/entities/nodes/relationships/ui/queries/get-relationships.query"; +import type { NodeFieldsWithMetadata } from "@/entities/nodes/types"; export interface RelationshipInputProps extends Omit { className?: string; @@ -29,6 +30,7 @@ export interface RelationshipInputProps extends Omit; parent?: { name?: string; value?: string }; + addNewInitialObject?: NodeFieldsWithMetadata; ref?: React.Ref>; } @@ -39,6 +41,7 @@ export const RelationshipInput = ({ options, peer, parent, + addNewInitialObject, ref, ...props }: RelationshipInputProps) => { @@ -143,6 +146,7 @@ export const RelationshipInput = ({ {!options && ( { onChange(value); setOpen(false);