Skip to content

Commit

Permalink
New component to load Custom Views (#3151)
Browse files Browse the repository at this point in the history
* feat(application-components): add custom panel component

* feat(application-components): added visual tests for custom-panel

* refactor(application-components): changed demo text

* refactor(application-components): rename custom-panel export name

* refactor(application-components): adjust custom-panel small size width

* fix(application-components): adjust import

* feat(application-components): add first version for loader component

* refactor(application-components): refactored custom-view-loader and added tests

* fix(application-components): fix type errors

* refactor(playground): use usemodalstate hook

* refactor(i18n): new message for custom views loader

* refactor(application-components): use more explicit event data when initializing the iframe

* fix(application-components): fix custom views type values

* refactor(application-components): rename variable to avoid confusion

* fix(application-components): fix type name

* chore(application-components): added test comment

* refactor(playground): remove commented code

* refactor(application-components): lowercase custom view size value

* refactor(application-components): add index file

* refactor(application-components): add index file

* refactor(application-components): improve events naming

* refactor(application-components): improve error message
  • Loading branch information
CarlosCortizasCT authored Aug 7, 2023
1 parent 064bb2f commit f47e404
Show file tree
Hide file tree
Showing 9 changed files with 314 additions and 0 deletions.
2 changes: 2 additions & 0 deletions packages/application-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@
"dependencies": {
"@babel/runtime": "^7.20.13",
"@babel/runtime-corejs3": "^7.20.13",
"@commercetools-frontend/actions-global": "22.4.0",
"@commercetools-frontend/application-shell-connectors": "22.4.0",
"@commercetools-frontend/assets": "22.4.0",
"@commercetools-frontend/constants": "22.4.0",
"@commercetools-frontend/i18n": "22.4.0",
"@commercetools-frontend/l10n": "22.4.0",
"@commercetools-frontend/sentry": "22.4.0",
"@commercetools-uikit/card": "^16.4.0",
"@commercetools-uikit/constraints": "^16.4.0",
"@commercetools-uikit/design-system": "^16.4.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const CUSTOM_VIEWS_EVENTS_NAMES = {
CUSTOM_VIEW_BOOTSTRAP: 'custom-view-bootstrap',
CUSTOM_VIEW_INITIALIZATION: 'custom-view-initialization',
};

export const CUSTOM_VIEWS_EVENTS_META = {
SOURCE: 'mc-host-application',
DESTINATION_PREFIX: 'custom-view-',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { reportErrorToSentry } from '@commercetools-frontend/sentry';
import {
screen,
renderComponent,
waitFor,
fireEvent,
} from '../../../test-utils';
import CustomViewLoader, { type TCustomView } from './custom-view-loader';

const mockShowNotification = jest.fn();

jest.mock('@commercetools-frontend/sentry');
jest.mock('@commercetools-frontend/actions-global', () => ({
useShowNotification: () => mockShowNotification,
}));

// TODO: We must add this entity to the test data repository
const TEST_CUSTOM_VIEW: TCustomView = {
id: 'd8eafca6-1f89-4a84-b93f-ef94f869abcf',
defaultLabel: 'Test Custom View',
labelAllLocales: {},
url: '/',
type: 'CustomPanel',
typeConfig: {
size: 'SMALL',
},
locators: ['customers.customer-detail.addresses'],
};

describe('CustomViewLoader', () => {
beforeAll(() => {
window.MessageChannel = jest.fn(() => ({
port1: {
onmessage: jest.fn(),
postMessage: jest.fn(),
close: jest.fn(),
onmessageerror: jest.fn(),
start: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
},
port2: {
onmessage: jest.fn(),
postMessage: jest.fn(),
close: jest.fn(),
onmessageerror: jest.fn(),
start: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
},
}));
});

beforeEach(() => {
jest.clearAllMocks();
});

it('should render a custom view', async () => {
renderComponent(
<CustomViewLoader customView={TEST_CUSTOM_VIEW} onClose={jest.fn()} />
);

const iFrame = screen.getByTitle(
`Custom View: ${TEST_CUSTOM_VIEW.defaultLabel}`
) as HTMLIFrameElement;
expect(iFrame.getAttribute('id')).toBe(
`custom-view-${TEST_CUSTOM_VIEW.id}`
);
expect(iFrame.getAttribute('src')).toBe(TEST_CUSTOM_VIEW.url);
});

it('should show a notification when the custom view fails to load', async () => {
const customView = {
...TEST_CUSTOM_VIEW,
url: 'https://example.com/',
};

renderComponent(
<CustomViewLoader customView={customView} onClose={jest.fn()} />
);

fireEvent.load(
screen.getByTitle(`Custom View: ${TEST_CUSTOM_VIEW.defaultLabel}`)
);

expect(mockShowNotification).toHaveBeenCalledWith({
domain: 'page',
kind: 'error',
text: 'We could not load the Custom View. Please contact your administrator to check its configuration.',
});
});

it('should render nothing when the custom view type is not known', () => {
const customView = {
...TEST_CUSTOM_VIEW,
type: 'InvalidType',
};

renderComponent(
// Ignore the TS error because we want to test an unknown type
// @ts-ignore
<CustomViewLoader customView={customView} onClose={jest.fn()} />
);

expect(
screen.queryByTitle(`Custom View: ${TEST_CUSTOM_VIEW.defaultLabel}`)
).not.toBeInTheDocument();
expect(reportErrorToSentry).toHaveBeenCalledWith(
new Error(
`CustomViewLoader: Provided Custom View has an unsupported type: ${customView.type}. Supported types: ['CustomPanel'].`
)
);
});

it('should call onClose when the custom view is closed', async () => {
const onCloseMock = jest.fn();

renderComponent(
<CustomViewLoader customView={TEST_CUSTOM_VIEW} onClose={onCloseMock} />
);

const closeButton = screen.getByLabelText('Close icon');
fireEvent.click(closeButton);

await waitFor(() => expect(onCloseMock).toHaveBeenCalled());
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { useCallback, useRef } from 'react';
import styled from '@emotion/styled';
import { useIntl } from 'react-intl';
import { useShowNotification } from '@commercetools-frontend/actions-global';
import { useApplicationContext } from '@commercetools-frontend/application-shell-connectors';
import {
DOMAINS,
NOTIFICATION_KINDS_PAGE,
} from '@commercetools-frontend/constants';
import { reportErrorToSentry } from '@commercetools-frontend/sentry';
import CustomPanel from '../custom-panel/custom-panel';
import {
CUSTOM_VIEWS_EVENTS_NAMES,
CUSTOM_VIEWS_EVENTS_META,
} from './constants';
import messages from './messages';

type TCustomViewIframeMessage = {
source: string;
destination: string;
eventName: string;
eventData: Record<string, unknown>;
};

/*
TODO: These types are temporary until we have the proper
ones generated from the Settings schema
*/
export type TPermissionGroup = {
name: string;
oAuthScopes: string[];
};

export type TCustomView = {
id: string;
defaultLabel: string;
labelAllLocales: Record<string, string>;
url: string;
type: 'CustomPanel' | 'CustomTab';
typeConfig?: {
size?: 'SMALL' | 'LARGE';
};
locators: string[];
};

type TCustomViewLoaderProps = {
customView: TCustomView;
onClose: () => void;
};

const isIframeReady = (iFrameElementRef: HTMLIFrameElement) => {
try {
return iFrameElementRef?.contentWindow?.document.readyState === 'complete';
} catch {
// Trying to access the contentWindow of a cross-origin iFrame will throw an error.
// We are not supposed to even get here because the iFrame must use
// a URL from our very same domain (the custom view is proxied through our http-proxy service).
return false;
}
};

const CustomPanelIframe = styled.iframe`
height: 100%;
width: 100%;
border: none;
`;

function CustomViewLoader(props: TCustomViewLoaderProps) {
const iFrameElementRef = useRef<HTMLIFrameElement>(null);
const appContext = useApplicationContext();
const iFrameCommunicationChannel = useRef(new MessageChannel());
const showNotification = useShowNotification();
const intl = useIntl();
const panelSize = (props.customView.typeConfig?.size?.toLocaleLowerCase() ||
'large') as Lowercase<'SMALL' | 'LARGE'>;

const messageFromIFrameHandler = useCallback((event: MessageEvent) => {
if (event.data.origin === window.location.origin) {
console.log('message received from iframe port: ', event);
}
}, []);

// onLoad handler is called from the iFrame even where the URL is not valid
// (blocked by CORS, 404, etc.) so we need to make sure the iFrame is ready
const onLoadSuccessHandler = useCallback(() => {
// Show error and block if the iFrame is not ready
// (error loading it)
if (!isIframeReady(iFrameElementRef.current!)) {
showNotification({
domain: DOMAINS.PAGE,
kind: NOTIFICATION_KINDS_PAGE.error,
text: intl.formatMessage(messages.loadError),
});
return;
}

// Listen for messages from the iFrame
iFrameCommunicationChannel.current.port1.onmessage =
messageFromIFrameHandler;

// Transfer port2 to the iFrame so it can send messages back privately
iFrameElementRef.current?.contentWindow?.postMessage(
CUSTOM_VIEWS_EVENTS_NAMES.CUSTOM_VIEW_BOOTSTRAP,
window.location.href,
[iFrameCommunicationChannel.current.port2]
);

// Send the initialization message to the iFrame
iFrameCommunicationChannel.current.port1.postMessage({
source: CUSTOM_VIEWS_EVENTS_META.SOURCE,
destination: `${CUSTOM_VIEWS_EVENTS_META.DESTINATION_PREFIX}${props.customView.id}`,
eventName: CUSTOM_VIEWS_EVENTS_NAMES.CUSTOM_VIEW_INITIALIZATION,
eventData: {
context: {
userLocale: appContext.user?.locale,
dataLocale: appContext.dataLocale,
projectKey: appContext.project?.key,
},
},
} as TCustomViewIframeMessage);

// We want the effect to run only once so we don't need the dependencies array.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// Currently we only support custom panels
if (props.customView.type !== 'CustomPanel') {
reportErrorToSentry(
new Error(
`CustomViewLoader: Provided Custom View has an unsupported type: ${props.customView.type}. Supported types: ['CustomPanel'].`
)
);
return null;
}

return (
<CustomPanel
title={`Custom View: ${props.customView.defaultLabel}`}
onClose={props.onClose}
size={panelSize}
>
<CustomPanelIframe
id={`custom-view-${props.customView.id}`}
key={`custom-view-${props.customView.id}`}
ref={iFrameElementRef}
title={`Custom View: ${props.customView.defaultLabel}`}
src={props.customView.url}
onLoad={onLoadSuccessHandler}
/>
</CustomPanel>
);
}

export default CustomViewLoader;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './custom-view-loader';
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineMessages } from 'react-intl';

const messages = defineMessages({
loadError: {
id: 'Components.CustomViewLoader.error.load',
defaultMessage:
'We could not load the Custom View. Please contact your administrator to check its configuration.',
},
});

export default messages;
1 change: 1 addition & 0 deletions packages/application-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type { TPageContentFull } from './components/page-content-containers/page

// Custom views
export { default as CustomPanel } from './components/custom-views/custom-panel';
export { default as CustomViewLoader } from './components/custom-views/custom-view-loader';

// Utilities
export { default as PortalsContainer } from './components/portals-container';
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/data/core.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"AppKit.Shared.revert": "Revert",
"AppKit.Shared.save": "Save",
"AppKit.Shared.update": "Update",
"Components.CustomViewLoader.error.load": "We could not load the Custom View. Please contact your administrator to check its configuration.",
"Components.ModalPage.TopBar.Back": "Go Back",
"Components.ModalPage.TopBar.Close": "Close Modal Page",
"ErrorApologizer.notifiedTeam": "Our team has been notified about this issue.",
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 comment on commit f47e404

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deploy preview for merchant-center-application-kit ready!

✅ Preview
https://merchant-center-application-ewgam28p0-commercetools.vercel.app

Built with commit f47e404.
This pull request is being automatically deployed with vercel-action

Please sign in to comment.