/);
+
+const LinkedEntityMetadataSelectorStep = ({ currentStep, onNavigate }) => {
+ const initialText = i18n.t('New TEI Relationship');
+ return (currentStep.value > NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.SELECT_LINKED_ENTITY_METADATA.value ?
+
{initialText} :
+
{initialText});
+};
+
+const RetrieverModeStep = ({ currentStep, onNavigate, linkedEntityMetadataName }) => {
+ if (currentStep.value < NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.SELECT_RETRIEVER_MODE.value) {
+ return null;
+ }
+
+ return (
+ <>
+
+ {currentStep.value > NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.SELECT_RETRIEVER_MODE.value ?
+
{linkedEntityMetadataName} :
+
{linkedEntityMetadataName}}
+ >
+ );
+};
+
+const FindExistingStep = ({ currentStep }) => {
+ const stepText = useMemo(() => {
+ if (currentStep.id === NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.NEW_LINKED_ENTITY.id) {
+ return i18n.t('Register');
+ }
+ if (currentStep.id === NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.FIND_EXISTING_LINKED_ENTITY.id) {
+ return i18n.t('Search');
+ }
+ return null;
+ }, [currentStep.id]);
+
+ if (!stepText) {
+ return null;
+ }
+
+ return (
+ <>
+
+
{stepText}
+ >
+ );
+};
+
+const BreadcrumbsPlain = ({
+ currentStep,
+ onNavigate,
+ linkedEntityMetadataName,
+ classes,
+}: PlainProps) => (
+
+
+ onNavigate(NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.SELECT_LINKED_ENTITY_METADATA)}
+ />
+ onNavigate(NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.SELECT_RETRIEVER_MODE)}
+ />
+
+
+);
+
+export const Breadcrumbs: ComponentType
= withStyles(styles)(BreadcrumbsPlain);
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/Breadcrumbs/breadcrumbs.types.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/Breadcrumbs/breadcrumbs.types.js
new file mode 100644
index 0000000000..5f59de427b
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/Breadcrumbs/breadcrumbs.types.js
@@ -0,0 +1,13 @@
+// @flow
+import { NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS } from '../wizardSteps.const';
+
+export type Props = {|
+ currentStep: $Values,
+ onNavigate: ($Values) => void,
+ linkedEntityMetadataName?: string,
+|};
+
+export type PlainProps = {|
+ ...Props,
+ ...CssClasses,
+|};
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/Breadcrumbs/index.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/Breadcrumbs/index.js
new file mode 100644
index 0000000000..fb78354934
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/Breadcrumbs/index.js
@@ -0,0 +1,2 @@
+// @flow
+export { Breadcrumbs } from './Breadcrumbs.component';
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/LinkedEntityMetadataSelector/LinkedEntityMetadataSelector.component.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/LinkedEntityMetadataSelector/LinkedEntityMetadataSelector.component.js
new file mode 100644
index 0000000000..286f7ea49e
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/LinkedEntityMetadataSelector/LinkedEntityMetadataSelector.component.js
@@ -0,0 +1,28 @@
+// @flow
+import React from 'react';
+
+import { useApplicableTypesAndSides } from './useApplicableTypesAndSides';
+import {
+ LinkedEntityMetadataSelector,
+ type LinkedEntityMetadataSelectorType,
+} from '../../../common/LinkedEntityMetadataSelector';
+import type { Props, Side, LinkedEntityMetadata } from './linkedEntityMetadataSelector.types';
+
+export const LinkedEntityMetadataSelectorFromTrackedEntity = ({
+ relationshipTypes,
+ trackedEntityTypeId,
+ programId,
+ onSelectLinkedEntityMetadata,
+}: Props) => {
+ const applicableTypesInfo = useApplicableTypesAndSides(relationshipTypes, trackedEntityTypeId, [programId]);
+
+ const LinkedEntityMetadataSelectorCommon: LinkedEntityMetadataSelectorType =
+ LinkedEntityMetadataSelector;
+
+ return (
+
+ );
+};
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/LinkedEntityMetadataSelector/index.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/LinkedEntityMetadataSelector/index.js
new file mode 100644
index 0000000000..f4da8e02ea
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/LinkedEntityMetadataSelector/index.js
@@ -0,0 +1,3 @@
+// @flow
+export { LinkedEntityMetadataSelectorFromTrackedEntity } from './LinkedEntityMetadataSelector.component';
+export type { LinkedEntityMetadata, Side } from './linkedEntityMetadataSelector.types';
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/LinkedEntityMetadataSelector/linkedEntityMetadataSelector.types.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/LinkedEntityMetadataSelector/linkedEntityMetadataSelector.types.js
new file mode 100644
index 0000000000..7d089dbfa9
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/LinkedEntityMetadataSelector/linkedEntityMetadataSelector.types.js
@@ -0,0 +1,37 @@
+// @flow
+import type { RelationshipTypes } from '../../../common/Types';
+import type { TargetSides } from '../../../common/LinkedEntityMetadataSelector';
+
+
+export type Side = $ReadOnly<{|
+ trackedEntityTypeId: string,
+ trackedEntityName: string,
+ programId?: string,
+ name: string,
+ targetSide: TargetSides,
+|}>;
+
+export type ApplicableTypeInfo = $ReadOnly<{|
+ id: string,
+ name: string,
+ sides: $ReadOnlyArray,
+|}>;
+
+export type ApplicableTypesInfo = $ReadOnlyArray;
+
+export type LinkedEntityMetadata = $ReadOnly<{|
+ trackedEntityTypeId: string,
+ programId: string,
+ name: string,
+ targetSide: TargetSides,
+ relationshipId: string,
+ trackedEntityName: string,
+|}>;
+
+export type Props = $ReadOnly<{|
+ relationshipTypes: RelationshipTypes,
+ trackedEntityTypeId: string,
+ programId: string,
+ onSelectLinkedEntityMetadata: (linkedEntityMetadata: LinkedEntityMetadata) => void,
+
+|}>;
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/LinkedEntityMetadataSelector/useApplicableTypesAndSides.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/LinkedEntityMetadataSelector/useApplicableTypesAndSides.js
new file mode 100644
index 0000000000..60f6ab77f8
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/LinkedEntityMetadataSelector/useApplicableTypesAndSides.js
@@ -0,0 +1,179 @@
+// @flow
+import { useMemo } from 'react';
+import { TARGET_SIDES } from '../common';
+import type { ApplicableTypesInfo } from './linkedEntityMetadataSelector.types';
+import type { RelationshipType } from '../../../common/Types';
+import { RELATIONSHIP_ENTITIES } from '../../../common/constants';
+import type { TargetSides } from '../../../common/LinkedEntityMetadataSelector';
+
+const isApplicableProgram = (programId, sourceProgramIds) =>
+ (!sourceProgramIds || !programId || sourceProgramIds.includes(programId));
+
+const isApplicableUnidirectionalRelationshipType = (
+ { trackedEntityType, program },
+ sourceTrackedEntityTypeId,
+ sourceProgramIds,
+) => {
+ const trackedEntityTypeId = trackedEntityType.id;
+ const programId = program?.id;
+
+ return Boolean(
+ sourceTrackedEntityTypeId === trackedEntityTypeId &&
+ isApplicableProgram(programId, sourceProgramIds),
+ );
+};
+
+const computeTargetSidesDualMatchingTET = (() => {
+ const getProgramMatchInfo = (sourceProgramIds, programId) => {
+ if (!programId) {
+ return { noProgram: true, programMatch: false };
+ }
+
+ if (sourceProgramIds.includes(programId)) {
+ return { programMatch: true, noProgram: false };
+ }
+
+ return { programMatch: false, noProgram: false };
+ };
+
+ return (sourceProgramIds, fromProgramId, toProgramId): Array => {
+ if (!sourceProgramIds) {
+ return [TARGET_SIDES.FROM, TARGET_SIDES.TO];
+ }
+ const { programMatch: fromProgramMatch, noProgram: fromNoProgram } =
+ getProgramMatchInfo(sourceProgramIds, fromProgramId);
+ const { programMatch: toProgramMatch, noProgram: toNoProgram } =
+ getProgramMatchInfo(sourceProgramIds, toProgramId);
+
+ if (fromProgramMatch) {
+ return [TARGET_SIDES.TO];
+ } else if (toProgramMatch) {
+ return [TARGET_SIDES.FROM];
+ } else if (fromNoProgram && toNoProgram) {
+ return [TARGET_SIDES.FROM, TARGET_SIDES.TO];
+ } else if (fromNoProgram) {
+ return [TARGET_SIDES.TO];
+ } else if (toNoProgram) {
+ return [TARGET_SIDES.FROM];
+ }
+
+ return [];
+ };
+})();
+
+const getApplicableTargetSidesForBidirectionalRelationshipType = ({
+ fromConstraint,
+ toConstraint,
+}, sourceTrackedEntityTypeId, sourceProgramIds): Array => {
+ const { trackedEntityType: fromTrackedEntityType } = fromConstraint;
+ const { trackedEntityType: toTrackedEntityType } = toConstraint;
+
+ if (fromTrackedEntityType.id === sourceTrackedEntityTypeId &&
+ toTrackedEntityType.id === sourceTrackedEntityTypeId) {
+ return computeTargetSidesDualMatchingTET(
+ sourceProgramIds,
+ fromConstraint.program?.id,
+ toConstraint.program?.id,
+ );
+ }
+
+ if (fromTrackedEntityType.id === sourceTrackedEntityTypeId ||
+ toTrackedEntityType.id === sourceTrackedEntityTypeId) {
+ const { programId, targetSide } = fromTrackedEntityType.id === sourceTrackedEntityTypeId ?
+ { programId: fromConstraint.program?.id, targetSide: TARGET_SIDES.TO } :
+ { programId: toConstraint.program?.id, targetSide: TARGET_SIDES.FROM };
+ return isApplicableProgram(programId, sourceProgramIds) ? [targetSide] : [];
+ }
+
+ return [];
+};
+
+export const useApplicableTypesAndSides = (
+ relationshipTypes: $ReadOnlyArray,
+ sourceTrackedEntityTypeId: string,
+ sourceProgramIds: $ReadOnlyArray,
+): ApplicableTypesInfo => useMemo(() =>
+ relationshipTypes.map(({
+ fromConstraint,
+ toConstraint,
+ bidirectional,
+ id,
+ displayName,
+ fromToName,
+ toFromName,
+ }) => {
+ if (fromConstraint.relationshipEntity === RELATIONSHIP_ENTITIES.TRACKED_ENTITY_INSTANCE &&
+ toConstraint.relationshipEntity === RELATIONSHIP_ENTITIES.TRACKED_ENTITY_INSTANCE) {
+ if (!bidirectional) {
+ const applicable = isApplicableUnidirectionalRelationshipType(
+ fromConstraint,
+ sourceTrackedEntityTypeId,
+ sourceProgramIds,
+ );
+
+ if (!applicable) {
+ // $FlowFixMe filter
+ return null;
+ }
+ const { trackedEntityType, program } = toConstraint;
+
+ return {
+ id,
+ name: displayName,
+ sides: [{
+ programId: program?.id,
+ trackedEntityTypeId: trackedEntityType.id,
+ trackedEntityName: trackedEntityType.name.toLowerCase(),
+ targetSide: TARGET_SIDES.TO,
+ name: fromToName ?? displayName,
+ }],
+ };
+ }
+
+ const targetSides = getApplicableTargetSidesForBidirectionalRelationshipType(
+ { fromConstraint, toConstraint },
+ sourceTrackedEntityTypeId,
+ sourceProgramIds,
+ );
+
+ if (!targetSides.length) {
+ // $FlowFixMe filter
+ return null;
+ }
+
+ return {
+ id,
+ name: displayName,
+ sides: targetSides.map((targetSide) => {
+ const {
+ trackedEntityTypeId,
+ trackedEntityName,
+ programId,
+ name,
+ } = targetSide === TARGET_SIDES.TO ? {
+ trackedEntityTypeId: toConstraint.trackedEntityType.id,
+ trackedEntityName: toConstraint.trackedEntityType.name.toLowerCase(),
+ programId: toConstraint.program?.id,
+ name: fromToName,
+ } : {
+ trackedEntityTypeId: fromConstraint.trackedEntityType.id,
+ trackedEntityName: fromConstraint.trackedEntityType.name.toLowerCase(),
+ programId: fromConstraint.program?.id,
+ name: toFromName,
+ };
+
+ return {
+ trackedEntityTypeId,
+ trackedEntityName,
+ programId,
+ targetSide,
+ // $FlowFixMe
+ name,
+ };
+ }),
+ };
+ }
+ // $FlowFixMe filter
+ return null;
+ }).filter(applicableType => applicableType),
+[relationshipTypes, sourceTrackedEntityTypeId, sourceProgramIds]);
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.component.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.component.js
new file mode 100644
index 0000000000..66eae92b47
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.component.js
@@ -0,0 +1,286 @@
+// @flow
+import React, { useCallback, useState, type ComponentType, useMemo } from 'react';
+import i18n from '@dhis2/d2-i18n';
+import { withStyles } from '@material-ui/core';
+import { Widget } from '../../../Widget';
+import { LinkButton } from '../../../Buttons/LinkButton.component';
+import { Breadcrumbs } from './Breadcrumbs';
+import { NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS } from './wizardSteps.const';
+import {
+ LinkedEntityMetadataSelectorFromTrackedEntity,
+ type LinkedEntityMetadata,
+} from './LinkedEntityMetadataSelector';
+import { RetrieverModeSelector } from './RetrieverModeSelector';
+import type { ComponentProps, StyledComponentProps } from './NewTrackedEntityRelationship.types';
+import { useAddRelationship } from './hooks/useAddRelationship';
+import { TARGET_SIDES } from './common';
+import { generateUID } from '../../../../utils/uid/generateUID';
+
+const styles = {
+ container: {
+ backgroundColor: '#FAFAFA',
+ maxWidth: 1200,
+ },
+ bar: {
+ color: '#494949',
+ padding: '8px',
+ display: 'inline-block',
+ fontSize: '14px',
+ borderRadius: '4px',
+ marginBottom: '10px',
+ backgroundColor: '#E9EEF4',
+ },
+ linkText: {
+ backgroundColor: 'transparent',
+ fontSize: 'inherit',
+ color: 'inherit',
+ },
+};
+
+const NewTrackedEntityRelationshipPlain = ({
+ relationshipTypes,
+ trackedEntityTypeId,
+ programId,
+ teiId,
+ orgUnitId,
+ onCancel,
+ onSave,
+ renderTrackedEntitySearch,
+ renderTrackedEntityRegistration,
+ onSelectFindMode,
+ classes,
+}: StyledComponentProps) => {
+ const [currentStep, setCurrentStep] =
+ useState(NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.SELECT_LINKED_ENTITY_METADATA);
+ const [selectedLinkedEntityMetadata: LinkedEntityMetadata, setSelectedLinkedEntityMetadata] = useState(undefined);
+ const { addRelationship } = useAddRelationship({
+ teiId,
+ onMutate: () => onSave && onSave(),
+ });
+
+
+ const onLinkToTrackedEntityFromSearch = useCallback(
+ (linkedTrackedEntityId: string, attributes?: { [attributeId: string]: string }) => {
+ if (!selectedLinkedEntityMetadata) return;
+ const { relationshipId: relationshipTypeId, targetSide } = selectedLinkedEntityMetadata;
+ const relationshipId = generateUID();
+
+ const apiData = targetSide === TARGET_SIDES.TO ?
+ { from: { trackedEntity: { trackedEntity: teiId } }, to: { trackedEntity: { trackedEntity: linkedTrackedEntityId } } } :
+ { from: { trackedEntity: { trackedEntity: linkedTrackedEntityId } }, to: { trackedEntity: { trackedEntity: teiId } } };
+
+ const clientData = {
+ createdAt: new Date().toISOString(),
+ pendingApiResponse: true,
+ ...apiData,
+ };
+
+ if (attributes) {
+ const targetSideLC = targetSide.toLowerCase();
+ clientData[targetSideLC].trackedEntity = {
+ ...clientData[targetSideLC].trackedEntity,
+ attributes: Object.keys(attributes).map(attributeId => ({
+ attribute: attributeId,
+ value: attributes[attributeId],
+ })),
+ };
+ }
+
+ addRelationship({
+ apiData: {
+ relationships: [{
+ relationshipType: relationshipTypeId,
+ relationship: relationshipId,
+ ...apiData,
+ }],
+ },
+ clientRelationship: {
+ relationshipType: relationshipTypeId,
+ relationship: relationshipId,
+ ...clientData,
+ },
+ });
+ }, [addRelationship, selectedLinkedEntityMetadata, teiId]);
+
+ const onLinkToTrackedEntityFromRegistration = useCallback((trackedEntity: Object) => {
+ if (!selectedLinkedEntityMetadata) return;
+ const { relationshipId: relationshipTypeId, targetSide } = selectedLinkedEntityMetadata;
+ const relationshipId = generateUID();
+
+ const relationshipData = targetSide === TARGET_SIDES.TO ?
+ { from: { trackedEntity: { trackedEntity: teiId } }, to: { trackedEntity: { trackedEntity: trackedEntity.trackedEntity } } } :
+ { from: { trackedEntity: { trackedEntity: trackedEntity.trackedEntity } }, to: { trackedEntity: { trackedEntity: teiId } } };
+
+ const clientData = {
+ relationship: relationshipId,
+ relationshipType: relationshipTypeId,
+ createdAt: new Date().toISOString(),
+ pendingApiResponse: true,
+ ...relationshipData,
+ [targetSide.toLowerCase()]: {
+ trackedEntity: {
+ attributes: trackedEntity.attributes ?? trackedEntity.enrollments?.[0]?.attributes,
+ orgUnitId: trackedEntity.orgUnit,
+ trackedEntity: trackedEntity.trackedEntity,
+ trackedEntityType: trackedEntity.trackedEntityType,
+ },
+ },
+ };
+
+ const payload = {
+ apiData: {
+ trackedEntities: [trackedEntity],
+ relationships: [{
+ relationship: relationshipId,
+ relationshipType: relationshipTypeId,
+ ...relationshipData,
+ }],
+ },
+ clientRelationship: clientData,
+ };
+
+ addRelationship(payload);
+ }, [addRelationship, selectedLinkedEntityMetadata, teiId]);
+
+ const handleNavigation = useCallback(
+ (destination: $Values) => {
+ setCurrentStep(destination);
+ }, []);
+
+ const handleLinkedEntityMetadataSelection = useCallback((linkedEntityMetadata: LinkedEntityMetadata) => {
+ setSelectedLinkedEntityMetadata(linkedEntityMetadata);
+ setCurrentStep(NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.SELECT_RETRIEVER_MODE);
+ }, []);
+
+ const handleSearchRetrieverModeSelected = useCallback(() => {
+ if (selectedLinkedEntityMetadata) {
+ onSelectFindMode && onSelectFindMode({
+ findMode: 'TEI_SEARCH',
+ orgUnitId,
+ relationshipConstraint: {
+ programId: selectedLinkedEntityMetadata?.programId,
+ trackedEntityTypeId: selectedLinkedEntityMetadata.trackedEntityTypeId,
+ },
+ });
+ }
+ setCurrentStep(NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.FIND_EXISTING_LINKED_ENTITY);
+ }, [onSelectFindMode, orgUnitId, selectedLinkedEntityMetadata]);
+
+ const handleNewRetrieverModeSelected = useCallback(() => {
+ if (selectedLinkedEntityMetadata) {
+ onSelectFindMode && onSelectFindMode({
+ findMode: 'TEI_REGISTER',
+ orgUnitId,
+ relationshipConstraint: {
+ programId: selectedLinkedEntityMetadata?.programId,
+ trackedEntityTypeId: selectedLinkedEntityMetadata.trackedEntityTypeId,
+ },
+ });
+ }
+ setCurrentStep(NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.NEW_LINKED_ENTITY);
+ }, [onSelectFindMode, orgUnitId, selectedLinkedEntityMetadata]);
+
+
+ const stepContents = useMemo(() => {
+ if (currentStep.id === NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.SELECT_LINKED_ENTITY_METADATA.id) {
+ return (
+
+ );
+ }
+ if (
+ currentStep.id === NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.SELECT_RETRIEVER_MODE.id
+ && selectedLinkedEntityMetadata?.trackedEntityName
+ ) {
+ return (
+
+ );
+ }
+
+ // Steps below will be implemented by new PR
+ if (currentStep.id === NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.FIND_EXISTING_LINKED_ENTITY.id) {
+ const {
+ trackedEntityTypeId: linkedEntityTrackedEntityTypeId,
+ programId: linkedEntityProgramId,
+ // $FlowFixMe business logic dictates that we will have the linkedEntityMetadata at this step
+ }: LinkedEntityMetadata = selectedLinkedEntityMetadata;
+
+ if (renderTrackedEntitySearch) {
+ return renderTrackedEntitySearch(
+ linkedEntityTrackedEntityTypeId,
+ linkedEntityProgramId,
+ onLinkToTrackedEntityFromSearch,
+ );
+ }
+ }
+
+ if (currentStep.id === NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS.NEW_LINKED_ENTITY.id) {
+ const {
+ trackedEntityTypeId: linkedEntityTrackedEntityTypeId,
+ programId: linkedEntityProgramId,
+ // $FlowFixMe business logic dictates that we will have the linkedEntityMetadata at this step
+ }: LinkedEntityMetadata = selectedLinkedEntityMetadata;
+
+ if (renderTrackedEntityRegistration) {
+ return renderTrackedEntityRegistration(
+ linkedEntityTrackedEntityTypeId,
+ linkedEntityProgramId,
+ onLinkToTrackedEntityFromRegistration,
+ onLinkToTrackedEntityFromSearch,
+ );
+ }
+ }
+
+ return (
+
+ {i18n.t('Missing implementation step')}
+
+ );
+ }, [
+ currentStep.id,
+ handleLinkedEntityMetadataSelection,
+ handleNewRetrieverModeSelected,
+ handleSearchRetrieverModeSelected,
+ onLinkToTrackedEntityFromRegistration,
+ onLinkToTrackedEntityFromSearch,
+ programId,
+ relationshipTypes,
+ renderTrackedEntityRegistration,
+ renderTrackedEntitySearch,
+ selectedLinkedEntityMetadata,
+ trackedEntityTypeId,
+ ]);
+
+ return (
+
+
+
+ {i18n.t('Go back without saving relationship')}
+
+
+
+ )}
+ >
+ {stepContents}
+
+
+ );
+};
+
+export const NewTrackedEntityRelationshipComponent: ComponentType =
+ withStyles(styles)(NewTrackedEntityRelationshipPlain);
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.const.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.const.js
new file mode 100644
index 0000000000..5b0ee59804
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.const.js
@@ -0,0 +1,5 @@
+
+export const creationModeStatuses = Object.freeze({
+ SEARCH: 'search',
+ NEW: 'new',
+});
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.container.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.container.js
new file mode 100644
index 0000000000..03a05eb491
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.container.js
@@ -0,0 +1,73 @@
+// @flow
+import React, { useCallback, useState, type ComponentType } from 'react';
+import { Button, spacers } from '@dhis2/ui';
+import { withStyles } from '@material-ui/core';
+import i18n from '@dhis2/d2-i18n';
+import { NewTrackedEntityRelationshipPortal } from './NewTrackedEntityRelationship.portal';
+import type { ContainerProps, StyledContainerProps } from './NewTrackedEntityRelationship.types';
+
+const styles = {
+ container: {
+ padding: `0 ${spacers.dp16} ${spacers.dp24} ${spacers.dp16}`,
+ },
+};
+
+export const NewTrackedEntityRelationshipPlain = ({
+ renderElement,
+ teiId,
+ orgUnitId,
+ programId,
+ relationshipTypes,
+ trackedEntityTypeId,
+ onCloseAddRelationship,
+ onOpenAddRelationship,
+ renderTrackedEntitySearch,
+ renderTrackedEntityRegistration,
+ onSelectFindMode,
+ classes,
+}: StyledContainerProps) => {
+ const [addWizardVisible, setAddWizardVisibility] = useState(false);
+
+ const closeAddWizard = useCallback(() => {
+ setAddWizardVisibility(false);
+ onCloseAddRelationship && onCloseAddRelationship();
+ }, [onCloseAddRelationship]);
+
+ const openAddWizard = useCallback(() => {
+ setAddWizardVisibility(true);
+ onOpenAddRelationship && onOpenAddRelationship();
+ }, [onOpenAddRelationship]);
+
+ return (
+
+
+
+ {
+ addWizardVisible && (
+
+ )
+ }
+
+ );
+};
+
+export const NewTrackedEntityRelationship: ComponentType =
+ withStyles(styles)(NewTrackedEntityRelationshipPlain);
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.portal.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.portal.js
new file mode 100644
index 0000000000..eec749787f
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.portal.js
@@ -0,0 +1,10 @@
+// @flow
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { NewTrackedEntityRelationshipComponent } from './NewTrackedEntityRelationship.component';
+import type { PortalProps } from './NewTrackedEntityRelationship.types';
+
+export const NewTrackedEntityRelationshipPortal = ({ renderElement, ...passOnProps }: PortalProps) =>
+ ReactDOM.createPortal((
+
+ ), renderElement);
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.types.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.types.js
new file mode 100644
index 0000000000..95f24c4882
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.types.js
@@ -0,0 +1,76 @@
+// @flow
+import * as React from 'react';
+import type { RelationshipTypes } from '../../common/Types';
+import type {
+ OnLinkToTrackedEntityFromSearch,
+ OnLinkToTrackedEntityFromRegistration,
+ OnSelectFindMode,
+} from '../WidgetTrackedEntityRelationship.types';
+
+type RenderTrackedEntitySearch =
+ (trackedEntityTypeId: string, programId: string, onLinkToTrackedEntity: OnLinkToTrackedEntityFromSearch) => React.Element
+
+type RenderTrackedEntityRegistration =
+ (
+ trackedEntityTypeId: string,
+ programId: string,
+ onLinkToTrackedEntityFromRegistration: OnLinkToTrackedEntityFromRegistration,
+ onLinkToTrackedEntity: OnLinkToTrackedEntityFromSearch,
+ ) => React.Element
+
+export type ContainerProps = $ReadOnly<{|
+ teiId: string,
+ orgUnitId: string,
+ renderElement: HTMLElement,
+ relationshipTypes: RelationshipTypes,
+ trackedEntityTypeId: string,
+ programId: string,
+ renderTrackedEntitySearch?: RenderTrackedEntitySearch,
+ renderTrackedEntityRegistration: RenderTrackedEntityRegistration,
+ onCloseAddRelationship?: () => void,
+ onOpenAddRelationship?: () => void,
+ onSelectFindMode?: OnSelectFindMode,
+|}>;
+
+export type StyledContainerProps = $ReadOnly<{|
+ ...ContainerProps,
+ ...CssClasses,
+|}>;
+
+export type PortalProps = $ReadOnly<{|
+ renderElement: HTMLElement,
+ teiId: string,
+ orgUnitId: string,
+ relationshipTypes: RelationshipTypes,
+ trackedEntityTypeId: string,
+ programId: string,
+ onSave: () => void,
+ onCancel: () => void,
+ onSelectFindMode?: OnSelectFindMode,
+ renderTrackedEntitySearch?: RenderTrackedEntitySearch,
+ renderTrackedEntityRegistration?: RenderTrackedEntityRegistration,
+|}>;
+
+export type StyledPortalProps = $ReadOnly<{|
+ ...PortalProps,
+ ...CssClasses,
+|}>;
+
+
+export type ComponentProps = $ReadOnly<{|
+ teiId: string,
+ orgUnitId: string,
+ relationshipTypes: RelationshipTypes,
+ trackedEntityTypeId: string,
+ programId: string,
+ onSave: () => void,
+ onCancel: () => void,
+ renderTrackedEntitySearch?: RenderTrackedEntitySearch,
+ renderTrackedEntityRegistration?: RenderTrackedEntityRegistration,
+ onSelectFindMode?: OnSelectFindMode,
+|}>;
+
+export type StyledComponentProps = $ReadOnly<{|
+ ...ComponentProps,
+ ...CssClasses,
+|}>;
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/RetrieverModeSelector/RetrieverModeSelector.component.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/RetrieverModeSelector/RetrieverModeSelector.component.js
new file mode 100644
index 0000000000..a775fed023
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/RetrieverModeSelector/RetrieverModeSelector.component.js
@@ -0,0 +1,45 @@
+// @flow
+import React, { type ComponentType } from 'react';
+import { Button, IconSearch16, IconAdd16, spacersNum, spacers } from '@dhis2/ui';
+import i18n from '@dhis2/d2-i18n';
+import { withStyles } from '@material-ui/core';
+import type { PlainProps, Props } from './retrieverModeSelector.types';
+
+const styles = {
+ container: {
+ padding: spacersNum.dp16,
+ paddingTop: 0,
+ },
+ retrieverSelector: {
+ display: 'flex',
+ gap: spacers.dp8,
+ },
+};
+
+const RetrieverModeSelectorPlain = ({
+ classes,
+ onSearchSelected,
+ onNewSelected,
+ trackedEntityName,
+}: PlainProps) => (
+
+
+
+
+
+
+);
+
+export const RetrieverModeSelector: ComponentType = withStyles(styles)(RetrieverModeSelectorPlain);
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/RetrieverModeSelector/index.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/RetrieverModeSelector/index.js
new file mode 100644
index 0000000000..f0d9af5395
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/RetrieverModeSelector/index.js
@@ -0,0 +1,2 @@
+// @flow
+export { RetrieverModeSelector } from './RetrieverModeSelector.component';
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/RetrieverModeSelector/retrieverModeSelector.types.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/RetrieverModeSelector/retrieverModeSelector.types.js
new file mode 100644
index 0000000000..8bf6c93de9
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/RetrieverModeSelector/retrieverModeSelector.types.js
@@ -0,0 +1,12 @@
+// @flow
+
+export type Props = $ReadOnly<{|
+ onSearchSelected: () => void,
+ onNewSelected: () => void,
+ trackedEntityName: string,
+|}>;
+
+export type PlainProps = $ReadOnly<{|
+ ...Props,
+ ...CssClasses,
+|}>;
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/common/index.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/common/index.js
new file mode 100644
index 0000000000..e5ad1f726a
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/common/index.js
@@ -0,0 +1,2 @@
+// @flow
+export { TARGET_SIDES } from './targetSides';
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/common/targetSides.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/common/targetSides.js
new file mode 100644
index 0000000000..3880543dc2
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/common/targetSides.js
@@ -0,0 +1,9 @@
+// @flow
+
+export const TARGET_SIDES: {|
+ FROM: 'FROM',
+ TO: 'TO',
+|} = Object.freeze({
+ FROM: 'FROM',
+ TO: 'TO',
+});
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/hooks/useAddRelationship.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/hooks/useAddRelationship.js
new file mode 100644
index 0000000000..9bdfd4b14d
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/hooks/useAddRelationship.js
@@ -0,0 +1,94 @@
+// @flow
+import i18n from '@dhis2/d2-i18n';
+import { useDataEngine, useAlert } from '@dhis2/app-runtime';
+import { useMutation, useQueryClient } from 'react-query';
+
+type Props = {
+ teiId: string;
+ onMutate?: () => void;
+ onSuccess?: (apiResponse: Object, requestData: Object) => void;
+}
+
+const ReactQueryAppNamespace = 'capture';
+
+const addRelationshipMutation = {
+ resource: '/tracker?async=false&importStrategy=CREATE',
+ type: 'create',
+ data: ({ apiData }) => apiData,
+};
+
+export const useAddRelationship = ({ teiId, onMutate, onSuccess }: Props) => {
+ const queryClient = useQueryClient();
+ const dataEngine = useDataEngine();
+ const { show: showSnackbar } = useAlert(
+ i18n.t('An error occurred while adding the relationship'),
+ { critical: true },
+ );
+
+ // $FlowFixMe
+ const { mutate } = useMutation(
+ ({ apiData }) => dataEngine.mutate(addRelationshipMutation, {
+ variables: {
+ apiData,
+ },
+ }),
+ {
+ onError: (_, requestData) => {
+ showSnackbar();
+ const apiRelationshipId = requestData.clientRelationship.relationship;
+ const currentRelationships = queryClient.getQueryData([ReactQueryAppNamespace, 'relationships', teiId]);
+
+ if (!currentRelationships?.instances) return;
+
+ const newRelationships = currentRelationships.instances.reduce((acc, relationship) => {
+ if (relationship.relationship === apiRelationshipId) {
+ return acc;
+ }
+ acc.push(relationship);
+ return acc;
+ }, []);
+
+ queryClient.setQueryData(
+ [ReactQueryAppNamespace, 'relationships', teiId],
+ { instances: newRelationships },
+ );
+ },
+ onMutate: (...props) => {
+ onMutate && onMutate(...props);
+ const { clientRelationship } = props[0];
+ if (!clientRelationship) return;
+
+ queryClient.setQueryData([ReactQueryAppNamespace, 'relationships', teiId], (oldData) => {
+ const instances = oldData?.instances || [];
+ const updatedInstances = [clientRelationship, ...instances];
+ return { instances: updatedInstances };
+ });
+ },
+ onSuccess: async (apiResponse, requestData) => {
+ const apiRelationshipId = apiResponse.bundleReport.typeReportMap.RELATIONSHIP.objectReports[0].uid;
+ const currentRelationships = queryClient.getQueryData([ReactQueryAppNamespace, 'relationships', teiId]);
+ if (!currentRelationships?.instances) return;
+
+ const newRelationships = currentRelationships.instances.map((relationship) => {
+ if (relationship.relationship === apiRelationshipId) {
+ return {
+ ...relationship,
+ pendingApiResponse: false,
+ };
+ }
+ return relationship;
+ });
+
+ queryClient.setQueryData(
+ [ReactQueryAppNamespace, 'relationships', teiId],
+ { instances: newRelationships },
+ );
+ onSuccess && onSuccess(apiResponse, requestData);
+ },
+ },
+ );
+
+ return {
+ addRelationship: mutate,
+ };
+};
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/index.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/index.js
new file mode 100644
index 0000000000..18ce242d75
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/index.js
@@ -0,0 +1,2 @@
+// @flow
+export { NewTrackedEntityRelationship } from './NewTrackedEntityRelationship.container';
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/wizardSteps.const.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/wizardSteps.const.js
new file mode 100644
index 0000000000..55c01e50de
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/wizardSteps.const.js
@@ -0,0 +1,6 @@
+export const NEW_TRACKED_ENTITY_RELATIONSHIP_WIZARD_STEPS = Object.freeze({
+ SELECT_LINKED_ENTITY_METADATA: Object.freeze({ value: 1, id: 'selectLinkedEntityMetadata' }),
+ SELECT_RETRIEVER_MODE: Object.freeze({ value: 2, id: 'selectRetrieverMode' }),
+ FIND_EXISTING_LINKED_ENTITY: Object.freeze({ value: 3, id: 'findExistingLinkedEntity' }),
+ NEW_LINKED_ENTITY: Object.freeze({ value: 4, id: 'newLinkedEntity' }),
+});
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.component.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.component.js
new file mode 100644
index 0000000000..6037269107
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.component.js
@@ -0,0 +1,70 @@
+// @flow
+import React, { useMemo } from 'react';
+import i18n from '@dhis2/d2-i18n';
+import type { WidgetTrackedEntityRelationshipProps } from './WidgetTrackedEntityRelationship.types';
+import { RelationshipsWidget } from '../common/RelationshipsWidget';
+import { RelationshipSearchEntities, useRelationships } from '../common/useRelationships';
+import { NewTrackedEntityRelationship } from './NewTrackedEntityRelationship';
+import { useTrackedEntityTypeName } from './hooks/useTrackedEntityTypeName';
+
+export const WidgetTrackedEntityRelationship = ({
+ relationshipTypes: cachedRelationshipTypes,
+ teiId,
+ trackedEntityTypeId,
+ programId,
+ orgUnitId,
+ addRelationshipRenderElement,
+ onLinkedRecordClick,
+ onOpenAddRelationship,
+ onCloseAddRelationship,
+ onSelectFindMode,
+ renderTrackedEntitySearch,
+ renderTrackedEntityRegistration,
+}: WidgetTrackedEntityRelationshipProps) => {
+ const { data: relationships, isError, isLoading: isLoadingRelationships } = useRelationships(teiId, RelationshipSearchEntities.TRACKED_ENTITY);
+ const { data: trackedEntityTypeName, isLoading: isLoadingTEType } = useTrackedEntityTypeName(trackedEntityTypeId);
+
+ const isLoading = useMemo(() => isLoadingRelationships || isLoadingTEType,
+ [isLoadingRelationships, isLoadingTEType],
+ );
+
+ if (isError) {
+ return (
+
+ {i18n.t('Something went wrong while loading relationships. Please try again later.')}
+
+ );
+ }
+
+ return (
+
+ {
+ relationshipTypes => (
+
+ )
+ }
+
+ );
+};
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.types.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.types.js
new file mode 100644
index 0000000000..da87a34f42
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.types.js
@@ -0,0 +1,47 @@
+// @flow
+import * as React from 'react';
+import type { RelationshipTypes } from '../common/Types';
+import type { LinkedRecordClick } from '../common/RelationshipsWidget';
+
+export type RelationshipConstraint = {|
+ trackedEntityTypeId: string,
+ programId: ?string,
+|}
+
+export type OnLinkToTrackedEntityFromSearch =
+ (linkedTrackedEntityId: string, attributes?: { [attributeId: string]: string }) => void;
+
+export type OnLinkToTrackedEntityFromRegistration =
+ (teiPayload: Object) => void;
+
+export type OnSelectFindModeProps = {|
+ findMode: string,
+ orgUnitId: string,
+ relationshipConstraint: RelationshipConstraint,
+|}
+
+export type OnSelectFindMode = (OnSelectFindModeProps) => void
+
+export type WidgetTrackedEntityRelationshipProps = {|
+ trackedEntityTypeId: string,
+ teiId: string,
+ programId: string,
+ orgUnitId: string,
+ addRelationshipRenderElement: HTMLElement,
+ onLinkedRecordClick: LinkedRecordClick,
+ onOpenAddRelationship?: () => void,
+ onCloseAddRelationship?: () => void,
+ relationshipTypes?: RelationshipTypes,
+ onSelectFindMode?: OnSelectFindMode,
+ renderTrackedEntityRegistration: (
+ trackedEntityTypeId: string,
+ programId: string,
+ onLinkToTrackedEntityFromRegistration: OnLinkToTrackedEntityFromRegistration,
+ onLinkToTrackedEntityFromSearch: OnLinkToTrackedEntityFromSearch,
+ ) => React.Element,
+ renderTrackedEntitySearch?: (
+ trackedEntityTypeId: string,
+ programId: string,
+ onLinkToTrackedEntityFromSearch: OnLinkToTrackedEntityFromSearch,
+ ) => React.Element,
+|};
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/hooks/useTrackedEntityTypeName.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/hooks/useTrackedEntityTypeName.js
new file mode 100644
index 0000000000..4ab4b8afb9
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/hooks/useTrackedEntityTypeName.js
@@ -0,0 +1,31 @@
+// @flow
+import { useMemo } from 'react';
+import { useApiDataQuery } from '../../../../utils/reactQueryHelpers';
+
+type ReturnData = {
+ displayName: string,
+}
+
+export const useTrackedEntityTypeName = (tetId: string) => {
+ const query = useMemo(() => ({
+ resource: 'trackedEntityTypes',
+ id: tetId,
+ params: {
+ fields: 'displayName',
+ },
+ }), [tetId]);
+
+ const { data, isLoading, error } = useApiDataQuery(
+ ['trackedEntityTypeName', tetId],
+ query,
+ {
+ enabled: !!tetId,
+ select: ({ displayName }: any) => displayName,
+ });
+
+ return {
+ data,
+ isLoading,
+ error,
+ };
+};
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/index.js b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/index.js
new file mode 100644
index 0000000000..a1a65f2107
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/index.js
@@ -0,0 +1,16 @@
+// @flow
+export { WidgetTrackedEntityRelationship } from './WidgetTrackedEntityRelationship.component';
+export type {
+ WidgetTrackedEntityRelationshipProps,
+ RelationshipConstraint,
+ OnSelectFindModeProps,
+ OnSelectFindMode,
+} from './WidgetTrackedEntityRelationship.types';
+export type { RelationshipTypes } from '../common/Types';
+export type {
+ LinkedRecordClick,
+ NavigationArgs,
+ NavigationArgsEvent,
+ NavigationArgsTrackedEntity,
+} from '../common/RelationshipsWidget';
+export { useAddRelationship } from './NewTrackedEntityRelationship/hooks/useAddRelationship';
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/LinkedEntityMetadataSelector/LinkedEntityMetadataSelector.component.js b/src/core_modules/capture-core/components/WidgetsRelationship/common/LinkedEntityMetadataSelector/LinkedEntityMetadataSelector.component.js
new file mode 100644
index 0000000000..98c34a8cb6
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/LinkedEntityMetadataSelector/LinkedEntityMetadataSelector.component.js
@@ -0,0 +1,73 @@
+// @flow
+import React from 'react';
+import { Button, spacers } from '@dhis2/ui';
+import { withStyles } from '@material-ui/core';
+import type { PlainProps, LinkedEntityMetadata, Side } from './linkedEntityMetadataSelector.types';
+
+const styles = {
+ container: {
+ padding: spacers.dp16,
+ paddingTop: 0,
+ },
+ typeSelector: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: spacers.dp8,
+ marginBottom: spacers.dp16,
+ },
+ selectorButton: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: spacers.dp4,
+ marginBottom: spacers.dp8,
+ },
+ title: {
+ fontWeight: 500,
+ marginBottom: spacers.dp4,
+ },
+ buttonContainer: {
+ display: 'flex',
+ gap: spacers.dp8,
+ },
+};
+
+export const LinkedEntityMetadataSelectorPlain = ({
+ applicableTypesInfo,
+ onSelectLinkedEntityMetadata,
+ classes }: PlainProps) => (
+
+
+ {applicableTypesInfo.map(({ id, name, sides }) => (
+
+
+ {name}
+
+
+
+ {sides.map((side: TSide) => (
+
+ ))}
+
+
+
+ ))}
+
+
+ );
+
+
+export const LinkedEntityMetadataSelector =
+ withStyles(styles)(LinkedEntityMetadataSelectorPlain);
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/LinkedEntityMetadataSelector/index.js b/src/core_modules/capture-core/components/WidgetsRelationship/common/LinkedEntityMetadataSelector/index.js
new file mode 100644
index 0000000000..3468a522b9
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/LinkedEntityMetadataSelector/index.js
@@ -0,0 +1,3 @@
+// @flow
+export { LinkedEntityMetadataSelector } from './LinkedEntityMetadataSelector.component';
+export type { TargetSides, LinkedEntityMetadataSelectorType } from './linkedEntityMetadataSelector.types';
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/LinkedEntityMetadataSelector/linkedEntityMetadataSelector.types.js b/src/core_modules/capture-core/components/WidgetsRelationship/common/LinkedEntityMetadataSelector/linkedEntityMetadataSelector.types.js
new file mode 100644
index 0000000000..d23c3d34ec
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/LinkedEntityMetadataSelector/linkedEntityMetadataSelector.types.js
@@ -0,0 +1,36 @@
+// @flow
+import type { ComponentType } from 'react';
+
+export type TargetSides = 'FROM' | 'TO';
+
+export type Side = $ReadOnly<{
+ name: string,
+ targetSide: TargetSides,
+}>;
+
+type ApplicableTypeInfo = $ReadOnly<{
+ id: string,
+ name: string,
+ sides: $ReadOnlyArray,
+}>;
+
+type ApplicableTypesInfo = $ReadOnlyArray>;
+
+export type LinkedEntityMetadata = $ReadOnly<{
+ ...Side,
+ relationshipId: string,
+}>;
+
+export type Props = $ReadOnly<{|
+ applicableTypesInfo: ApplicableTypesInfo,
+ onSelectLinkedEntityMetadata: (linkedEntityMetadata: TLinkedEntityMetadata) => void,
+|}>;
+
+export type PlainProps = $ReadOnly<{|
+ applicableTypesInfo: ApplicableTypesInfo,
+ onSelectLinkedEntityMetadata: (linkedEntityMetadata: TLinkedEntityMetadata) => void,
+ ...CssClasses,
+|}>;
+
+export type LinkedEntityMetadataSelectorType =
+ ComponentType>;
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntitiesViewer.component.js b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntitiesViewer.component.js
new file mode 100644
index 0000000000..072177e943
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntitiesViewer.component.js
@@ -0,0 +1,45 @@
+// @flow
+import React, { type ComponentType } from 'react';
+import { withStyles } from '@material-ui/core';
+import { spacersNum, spacers, colors } from '@dhis2/ui';
+import { LinkedEntityTable } from './LinkedEntityTable.component';
+import type { Props, StyledProps } from './linkedEntitiesViewer.types';
+
+const styles = {
+ container: {
+ padding: `0 ${spacers.dp16} ${spacers.dp12} ${spacers.dp16}`,
+ },
+ title: {
+ fontWeight: 500,
+ fontSize: 16,
+ color: colors.grey800,
+ paddingBottom: spacersNum.dp16,
+ },
+ wrapper: {
+ paddingBottom: spacersNum.dp16,
+ },
+};
+
+
+const LinkedEntitiesViewerPlain = ({ groupedLinkedEntities, onLinkedRecordClick, classes }: StyledProps) => (
+
+ {groupedLinkedEntities?.map((linkedEntityGroup) => {
+ const { id, name, linkedEntities, columns, context } = linkedEntityGroup;
+ return (
+
+ );
+ })}
+
);
+
+export const LinkedEntitiesViewer: ComponentType = withStyles(styles)(LinkedEntitiesViewerPlain);
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntityTable.component.js b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntityTable.component.js
new file mode 100644
index 0000000000..a286b51ff4
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntityTable.component.js
@@ -0,0 +1,71 @@
+// @flow
+import React, { useState, useMemo, type ComponentType } from 'react';
+import { withStyles } from '@material-ui/core';
+import {
+ DataTable,
+ Button,
+ spacers,
+} from '@dhis2/ui';
+import i18n from '@dhis2/d2-i18n';
+import { LinkedEntityTableHeader } from './LinkedEntityTableHeader.component';
+import { LinkedEntityTableBody } from './LinkedEntityTableBody.component';
+import type { Props, StyledProps } from './linkedEntityTable.types';
+
+const DEFAULT_VISIBLE_ROWS_COUNT = 5;
+
+const styles = {
+ button: {
+ marginTop: `${spacers.dp8}`,
+ },
+ dataTableWrapper: {
+ overflowY: 'auto',
+ whiteSpace: 'nowrap',
+ },
+};
+
+const LinkedEntityTablePlain = ({ linkedEntities, columns, onLinkedRecordClick, context, classes }: StyledProps) => {
+ const [visibleRowsCount, setVisibleRowsCount] = useState(DEFAULT_VISIBLE_ROWS_COUNT);
+
+ const visibleLinkedEntities = useMemo(() =>
+ linkedEntities.slice(0, visibleRowsCount),
+ [linkedEntities, visibleRowsCount]);
+
+ const showMoreButtonVisible = linkedEntities.length > visibleRowsCount;
+
+ return (
+
+
+
+
+
+ {showMoreButtonVisible && (
+
+ )}
+
+ );
+};
+
+export const LinkedEntityTable: ComponentType = withStyles(styles)(LinkedEntityTablePlain);
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntityTableBody.component.js b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntityTableBody.component.js
new file mode 100644
index 0000000000..898e2fec0a
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntityTableBody.component.js
@@ -0,0 +1,86 @@
+// @flow
+import React, { type ComponentType } from 'react';
+import { withStyles } from '@material-ui/core';
+import {
+ DataTableBody,
+ DataTableRow,
+ DataTableCell,
+ Tooltip,
+} from '@dhis2/ui';
+import i18n from '@dhis2/d2-i18n';
+import { convertServerToClient, convertClientToList } from '../../../../converters';
+import type { Props, StyledProps } from './linkedEntityTableBody.types';
+
+const styles = {
+ row: {
+ cursor: 'pointer',
+ },
+ rowDisabled: {
+ cursor: 'not-allowed',
+ opacity: 0.5,
+ },
+};
+
+const LinkedEntityTableBodyPlain = ({
+ linkedEntities,
+ columns,
+ onLinkedRecordClick,
+ context,
+ classes,
+}: StyledProps) => (
+
+ {
+ linkedEntities
+ .map(({ id: entityId, values, baseValues, navigation }) => {
+ const { pendingApiResponse } = baseValues || {};
+ return (
+
+ {
+ // $FlowFixMe flow doesn't like destructering
+ columns.map(({ id, type, convertValue }) => {
+ const value = type ?
+ convertClientToList(convertServerToClient(values[id], type), type) :
+ convertValue(baseValues?.[id] ?? context.display[id]);
+
+ return (
+
+ {({ onMouseOver, onMouseOut, ref }) => (
+ !pendingApiResponse && onLinkedRecordClick({ ...context.navigation, ...navigation })}
+ ref={(tableCell) => {
+ if (tableCell) {
+ if (pendingApiResponse) {
+ tableCell.onmouseover = onMouseOver;
+ tableCell.onmouseout = onMouseOut;
+ ref.current = tableCell;
+ } else {
+ tableCell.onmouseover = null;
+ tableCell.onmouseout = null;
+ }
+ }
+ }}
+ >
+ {value}
+
+ )}
+
+ );
+ })}
+
+ );
+ })
+ }
+
+);
+
+export const LinkedEntityTableBody: ComponentType = withStyles(styles)(LinkedEntityTableBodyPlain);
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntityTableHeader.component.js b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntityTableHeader.component.js
new file mode 100644
index 0000000000..e7681a6867
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntityTableHeader.component.js
@@ -0,0 +1,25 @@
+// @flow
+import React from 'react';
+import {
+ DataTableHead,
+ DataTableRow,
+ DataTableColumnHeader,
+} from '@dhis2/ui';
+import type { Props } from './linkedEntityTableHeader.types';
+
+export const LinkedEntityTableHeader = ({ columns }: Props) => (
+
+
+ {
+ columns
+ .map(({ id, displayName }) => (
+
+ {displayName}
+
+ ))
+ }
+
+
+);
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/RelationshipsWidget.component.js b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/RelationshipsWidget.component.js
new file mode 100644
index 0000000000..c0ee94c29d
--- /dev/null
+++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/RelationshipsWidget.component.js
@@ -0,0 +1,94 @@
+// @flow
+import React, { type ComponentType, useState } from 'react';
+import { Chip, IconLink24, spacers } from '@dhis2/ui';
+import { withStyles } from '@material-ui/core';
+import { Widget } from '../../../Widget';
+import { useGroupedLinkedEntities } from './useGroupedLinkedEntities';
+import { useRelationshipTypes } from './useRelationshipTypes';
+import { LinkedEntitiesViewer } from './LinkedEntitiesViewer.component';
+import type { Props, StyledProps } from './relationshipsWidget.types';
+import { LoadingMaskElementCenter } from '../../../LoadingMasks';
+
+const styles = {
+ header: {
+ display: 'flex',
+ alignItems: 'center',
+ },
+ icon: {
+ paddingRight: spacers.dp8,
+ },
+};
+
+const RelationshipsWidgetPlain = ({
+ title,
+ relationships,
+ isLoading,
+ cachedRelationshipTypes,
+ sourceId,
+ onLinkedRecordClick,
+ children,
+ classes,
+}: StyledProps) => {
+ const [open, setOpenStatus] = useState(true);
+ const { data: relationshipTypes } = useRelationshipTypes(cachedRelationshipTypes);
+ const groupedLinkedEntities = useGroupedLinkedEntities(sourceId, relationshipTypes, relationships);
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ )}
+ onOpen={() => setOpenStatus(true)}
+ onClose={() => setOpenStatus(false)}
+ open={open}
+ >
+
+ )}
+ onOpen={() => setOpenStatus(true)}
+ onClose={() => setOpenStatus(false)}
+ open={open}
+ >
+ {
+ groupedLinkedEntities && (
+
+ )
+ }{
+ relationshipTypes && children(relationshipTypes)
+ }
+
+