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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sap-ux/preview-middleware": patch
---

BUMP: Update pinned version of @sap-ux-private/preview-middleware-client
5 changes: 5 additions & 0 deletions .changeset/adp-fix-add-subpage-multi-nav-same-entity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sap-ux-private/preview-middleware-client": patch
---

FIX: Add Subpage greyed out when multiple navigation properties target the same entity set
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export default class AddSubpage extends BaseDialog<AddSubpageModel> {
},
targetPage: {
type: 'Component',
id: `${targetEntitySet}ObjectPage`,
id: `${navProperty}|${targetEntitySet}ObjectPage`,
name: 'sap.fe.templates.ObjectPage',
routePattern,
settings: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import FEObjectPageComponent from 'sap/fe/templates/ObjectPage/Component';
import FEListReportComponent from 'sap/fe/templates/ListReport/Component';
import { getUi5Version, isLowerThanMinimalUi5Version } from '../../../utils/version.js';
import { PageDescriptorV4 } from '../../controllers/types.js';
import { getPageId } from './utils.js';
import { getPageId, hasRouteForNavProperty } from './utils.js';

export const OBJECT_PAGE_COMPONENT_NAME_V4 = 'sap.fe.templates.ObjectPage.ObjectPage';

Expand Down Expand Up @@ -142,16 +142,22 @@ export class AddNewSubpage extends AddNewSubpageBase<ODataMetaModelV4> {
}
const entityTypePath = entitySet.$Type;
const entitySetNavigationKeys = Object.keys(entitySet.$NavigationPropertyBinding);
const pageId = getPageId(this.context);
if (!pageId) {
return;
}

for (const navigationProperty of entitySetNavigationKeys) {
const associationEnd = (await metaModel.requestObject(`/${entityTypePath}/${navigationProperty}`)) as {
$Type: string;
$isCollection: boolean;
$kind: 'NavigationProperty';
};
}; // NO SONAR;
if (associationEnd?.$isCollection) {
const targetEntitySet = entitySet.$NavigationPropertyBinding[navigationProperty];
await this.addNavigationOptionIfAvailable(metaModel, targetEntitySet, navigationProperty);
if (targetEntitySet && !hasRouteForNavProperty(this.context.manifest, pageId, navigationProperty)) {
this.navProperties.push({ entitySet: targetEntitySet, navProperty: navigationProperty });
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import CommandFactory from 'sap/ui/rta/command/CommandFactory';
import { getV4AppComponent, getPageName, getReference, isMacroTable } from '../../../utils/fe-v4.js';
import UI5Element from 'sap/ui/core/Element';
import type AppComponent from 'sap/fe/core/AppComponent';
import { Manifest } from 'sap/ui/rta/RuntimeAuthoring';

interface ViewDataType {
stableId: string;
Expand Down Expand Up @@ -165,6 +166,18 @@ export function getPropertyPath(table: UI5Element, property: 'actions' | 'column
return undefined;
}

export function hasRouteForNavProperty(
manifest: Manifest,
sourcePageId: string,
navigationProperty: string
): boolean {
const targets = manifest['sap.ui5']?.routing?.targets ?? {};
const sourceTarget = targets[sourcePageId];

// Check if a route exists from the source page for the specified navigation property.
return !!sourceTarget?.options?.settings?.navigation?.[navigationProperty]?.detail?.route;
}

/**
* Return the line item annotation that defines the table.
* This may come from a Presentation Variant, a Selection Presentation Variant or the default.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ describe('AddSubpage controller', () => {
},
'targetPage': {
'type': 'Component',
'id': 'BookingsObjectPage',
'id': 'to_Booking|BookingsObjectPage',
'name': 'sap.fe.templates.ObjectPage',
'routePattern': testCase.expectedPattern,
'settings': {
Expand All @@ -567,5 +567,82 @@ describe('AddSubpage controller', () => {
}
);
});

test('targetPage id uses navProperty|entitySet format to distinguish multiple nav properties targeting the same entity set', async () => {
CommandFactory.getCommandFor.mockClear();
const rtaMock = new RuntimeAuthoringMock({} as RTAOptions);
const executeSpy = jest.fn();
rtaMock.getCommandStack.mockReturnValue({ pushAndExecute: executeSpy });
rtaMock.getFlexSettings.mockReturnValue({ projectId: 'adp.app' });

const testModel = {
getProperty: jest.fn().mockImplementation((name) => {
const props: Record<string, any> = {
'/navigationData': [
{ entitySet: 'Child01', navProperty: '_Subtype1' },
{ entitySet: 'Child01', navProperty: '_NewSubtype' }
],
'/selectedNavigation/key': '_NewSubtype'
};
return props[name];
}),
setProperty: jest.fn()
} as unknown as JSONModel;

const runtimeControlMock = {
getMetadata: jest.fn().mockReturnValue({
getName: jest.fn().mockReturnValue('sap.uxap.ObjectPageLayout'),
getAllAggregations: jest.fn().mockReturnValue([])
})
} as unknown as ManagedObject;
jest.spyOn(ControlUtils, 'getRuntimeControl').mockReturnValue(runtimeControlMock);
sapCoreMock.byId.mockReturnValue({});

const addSubpage = new AddSubpage(
'adp.extension.controllers.AddSubpage',
{ getId: jest.fn().mockReturnValue('some-id') } as unknown as UI5Element,
rtaMock as unknown as RuntimeAuthoring,
{
title: 'QUICK_ACTION_ADD_SUBPAGE',
appReference: 'dummyApp',
navProperties: [
{ entitySet: 'Child01', navProperty: '_Subtype1' },
{ entitySet: 'Child01', navProperty: '_NewSubtype' }
],
pageDescriptor: {
appType: 'fe-v4',
appComponent: {} as unknown as AppComponentV4,
pageId: 'ParentSetObjectPage',
routePattern: '/ParentSet({key}):?query:'
}
}
);
addSubpage.model = testModel;
addSubpage.dialog = {
getBeginButton: jest.fn().mockReturnValue({ setEnabled: jest.fn() }),
getContent: jest.fn().mockReturnValue([{ getContent: jest.fn().mockReturnValue([]) }])
} as unknown as Dialog;

addSubpage.handleDialogClose = jest.fn();

await addSubpage.setup({
setEscapeHandler: jest.fn(),
destroy: jest.fn(),
setModel: jest.fn(),
open: jest.fn(),
close: jest.fn()
} as unknown as Dialog);

const event = {
getSource: jest.fn().mockReturnValue({ setEnabled: jest.fn() })
} as unknown as Event;

await addSubpage.onCreateBtnPress(event);

const commandCall = CommandFactory.getCommandFor.mock.calls[0];
// id must include the navProperty so two nav props targeting the same entity set
// produce distinct page ids (_Subtype1|Child01ObjectPage vs _NewSubtype|Child01ObjectPage)
expect(commandCall[2].parameters.targetPage.id).toBe('_NewSubtype|Child01ObjectPage');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4595,13 +4595,12 @@ describe('FE V4 quick actions', () => {
'id': 'TravelList',
'name': 'sap.fe.templates.ListReport',
'options': {
'settings': testCase.isContextPathDefined
? {
'contextPath': '/Travel'
}
: {
'entitySet': 'Travel'
}
'settings': {
...(testCase.isContextPathDefined ? { 'contextPath': '/Travel' } : { 'entitySet': 'Travel' }),
...(testCase.isListReport && testCase.isNewPageUnavailable
? { navigation: { _Booking: { detail: { route: 'TravelObjectPage' } } } }
: {})
}
}
},
...(testCase.isListReport && testCase.isNewPageUnavailable
Expand All @@ -4626,7 +4625,10 @@ describe('FE V4 quick actions', () => {
'name': 'sap.fe.templates.ObjectPage',
'options': {
'settings': {
'entitySet': 'Booking'
'entitySet': 'Booking',
...(!testCase.isListReport && testCase.isNewPageUnavailable
? { navigation: { _BookSupplement: { detail: { route: 'BookSupplementObjectPage' } } } }
: {})
}
}
},
Expand Down Expand Up @@ -4668,7 +4670,16 @@ describe('FE V4 quick actions', () => {
'pattern': '/Travel({key})/_Booking({key1}):?query:',
'name': testCase.isNoRouteFound ? 'unknown' : 'BookingObjectPage',
'target': 'BookingObjectPage'
}
},
...(!testCase.isListReport && testCase.isNewPageUnavailable
? [
{
'pattern': '/Travel({key})/_Booking({key1})/_BookSupplement({key2}):?query:',
'name': 'BookSupplementObjectPage',
'target': 'BookSupplementObjectPage'
}
]
: [])
];
jest.spyOn(rtaMock.getRootControlInstance(), 'getManifest').mockReturnValue({
'sap.ui5': {
Expand Down Expand Up @@ -4836,6 +4847,160 @@ describe('FE V4 quick actions', () => {
);
}
});

test('multiple nav properties pointing to same entity set - only those without a navigation route are offered', async () => {
// Regression test for: "Add Subpage" greyed out when a CDS extension adds a new nav property
// that maps to an entity set already used by other nav properties which do have navigation routes.
// hasRouteForNavProperty checks targets[pageId].options.settings.navigation[navProp].detail.route.
mockTelemetryEventIdentifier();
getUi5VersionMock.mockResolvedValue({ major: 1, minor: 135 });

const pageView = new XMLView();
jest.spyOn(ComponentMock, 'getOwnerComponentFor').mockImplementation(() => {
return {
isA: (type: string) => type === 'sap.fe.templates.ListReport.Component',
getEntitySet: jest.fn().mockReturnValue('ParentSet'),
getContextPath: jest.fn().mockReturnValue(undefined)
} as unknown as UIComponent;
});

sapCoreMock.byId.mockImplementation((id) => {
if (id === 'ObjectPage') {
return {
isA: (type: string) => type === 'sap.fe.templates.ObjectPage.Component',
getId: () => id,
getDomRef: () => ({ ref: 'OP' }),
getParent: () => pageView
};
}
if (id === 'NavContainer') {
const container = new NavContainer();
const component = new ComponentMock();
const view = new XMLView();
pageView.getDomRef.mockImplementation(() => ({
contains: (domRef: { ref: string }) => domRef.ref === 'OP'
}));
pageView.getViewName.mockImplementation(() => 'sap.fe.templates.ObjectPage.ObjectPage');
pageView.getViewData.mockImplementation(() => ({ stableId: 'appId::ParentSetObjectPage' }));
jest.spyOn(view, 'getComponent').mockReturnValue('component-id');
jest.spyOn(Component, 'getComponentById').mockImplementation((cid) => {
if (cid === 'component-id') return component;
});
container.getCurrentPage.mockImplementation(() => view);
jest.spyOn(component, 'getRootControl').mockImplementation(() => pageView);
return container;
}
});

const rtaMock = new RuntimeAuthoringMock({} as RTAOptions) as unknown as RuntimeAuthoring;

// Three nav properties all target 'Child01'; Subtype1 and Subtype2 already have navigation routes.
// NewSubtype (the CDS extension) has no navigation route yet and must be offered.
const routes = [
{ pattern: ':?query:', name: 'ParentSetList', target: 'ParentSetList' },
{ pattern: '/ParentSet({key}):?query:', name: 'ParentSetObjectPage', target: 'ParentSetObjectPage' },
{ pattern: '/ParentSet({key})/_Subtype1({key1}):?query:', name: 'Subtype1ObjectPage', target: 'Subtype1ObjectPage' },
{ pattern: '/ParentSet({key})/_Subtype2({key1}):?query:', name: 'Subtype2ObjectPage', target: 'Subtype2ObjectPage' }
];
const targets = {
ParentSetObjectPage: {
id: 'ParentSetObjectPage',
name: 'sap.fe.templates.ObjectPage',
options: {
settings: {
entitySet: 'ParentSet',
// _Subtype1 and _Subtype2 already have navigation routes; _NewSubtype does not
navigation: {
_Subtype1: { detail: { route: 'Subtype1ObjectPage' } },
_Subtype2: { detail: { route: 'Subtype2ObjectPage' } }
}
}
}
},
Subtype1ObjectPage: {
id: 'Subtype1ObjectPage',
name: 'sap.fe.templates.ObjectPage',
options: { settings: { entitySet: 'Child01' } }
},
Subtype2ObjectPage: {
id: 'Subtype2ObjectPage',
name: 'sap.fe.templates.ObjectPage',
options: { settings: { entitySet: 'Child01' } }
}
};

jest.spyOn(rtaMock.getRootControlInstance(), 'getManifest').mockReturnValue({
'sap.ui5': { routing: { routes, targets } }
});
jest.spyOn(rtaMock, 'getFlexSettings').mockImplementation(
() => ({ projectId: 'dummyProjectId' }) as FlexSettings
);

const dummyAppComponent = {} as unknown as AppComponentV4;
getV4AppComponentMock.mockReturnValue(dummyAppComponent);

const metaModelMock = {
requestObject: jest.fn().mockImplementation((path: string) => {
switch (path) {
case '/ParentSet':
return {
$Type: 'ParentType',
$NavigationPropertyBinding: {
_Subtype1: 'Child01',
_Subtype2: 'Child01',
_NewSubtype: 'Child01'
}
};
case '/ParentType/_Subtype1':
case '/ParentType/_Subtype2':
case '/ParentType/_NewSubtype':
return { $isCollection: true };
default:
return { $isCollection: false };
}
})
};
jest.spyOn(rtaMock.getRootControlInstance(), 'getModel').mockReturnValue({
getMetaModel: () => metaModelMock
} as unknown as ODataModelV4);

const registry = new FEV4QuickActionRegistry();
const service = new QuickActionService(
rtaMock,
new OutlineService(rtaMock, mockChangeService),
[registry],
{ onStackChange: jest.fn(), getConfigurationPropertyValue: jest.fn() } as any
);

CommandFactory.getCommandFor.mockImplementation((control, type, value, _, settings) => ({
type,
value,
settings
}));

await service.init(sendActionMock, subscribeMock);
await service.reloadQuickActions({
'sap.uxap.ObjectPageLayout': [{ controlId: 'ObjectPage' } as any],
'sap.f.DynamicPage': [],
'sap.m.NavContainer': [{ controlId: 'NavContainer' } as any]
});

await subscribeMock.mock.calls[0][0](
executeQuickAction({ id: 'objectPage0-add-new-subpage', kind: 'simple' })
);

// Only _NewSubtype (no navigation route entry) must be offered; _Subtype1 and _Subtype2 are blocked.
expect(DialogFactory.createDialog).toHaveBeenCalledWith(
mockOverlay,
rtaMock,
'AddSubpage',
undefined,
expect.objectContaining({
navProperties: [{ entitySet: 'Child01', navProperty: '_NewSubtype' }]
}),
expect.anything()
);
});
});

describe('change table actions', () => {
Expand Down
Loading
Loading