diff --git a/packages/elements/src/components/DropdownSelect/index.tsx b/packages/elements/src/components/DropdownSelect/index.tsx index 9ec880c41c..45748fda8b 100644 --- a/packages/elements/src/components/DropdownSelect/index.tsx +++ b/packages/elements/src/components/DropdownSelect/index.tsx @@ -34,7 +34,7 @@ export const DropdownSelect: React.FC = ({ const handleRenderTags = (props: CustomTagProps) => { const { value, onClose } = props const option = options.find(option => option.value === value) as SelectOption - return + return } const handleChangeOption = field => value => { diff --git a/packages/marketplace/src/actions/__tests__/client.ts b/packages/marketplace/src/actions/__tests__/client.ts index 9a1fafef00..47493d9942 100644 --- a/packages/marketplace/src/actions/__tests__/client.ts +++ b/packages/marketplace/src/actions/__tests__/client.ts @@ -1,28 +1,32 @@ -import { clientLoading, clientReceiveData, clientRequestData, clientClearData } from '../client' +import { + clientFetchAppSummary, + clientFetchAppSummarySuccess, + clientFetchAppSummaryFailed, + clientClearAppSummary, +} from '../client' import ActionTypes from '../../constants/action-types' import { appsDataStub, featuredAppsDataStub } from '../../sagas/__stubs__/apps' describe('client actions', () => { - it('should create a clientLoading action', () => { - expect(clientLoading.type).toEqual(ActionTypes.CLIENT_LOADING) - expect(clientLoading(true).data).toEqual(true) + it('should create a clientFetchAppSummary action', () => { + expect(clientFetchAppSummary.type).toEqual(ActionTypes.CLIENT_FETCH_APP_SUMMARY) + expect(clientFetchAppSummary({ page: 1 }).data).toEqual({ page: 1 }) }) - - it('should create a clientReceiveData action', () => { - expect(clientReceiveData.type).toEqual(ActionTypes.CLIENT_RECEIVE_DATA) - expect(clientReceiveData({ featuredApps: featuredAppsDataStub.data.data, apps: appsDataStub.data }).data).toEqual({ + it('should create a clientFetchAppSummarySuccess action', () => { + expect(clientFetchAppSummarySuccess.type).toEqual(ActionTypes.CLIENT_FETCH_APP_SUMMARY_SUCCESS) + expect( + clientFetchAppSummarySuccess({ featuredApps: featuredAppsDataStub.data.data, apps: appsDataStub.data }).data, + ).toEqual({ featuredApps: featuredAppsDataStub.data.data, apps: appsDataStub.data, }) }) - - it('should create a clientRequestData action', () => { - expect(clientRequestData.type).toEqual(ActionTypes.CLIENT_REQUEST_DATA) - expect(clientRequestData({ page: 1 }).data).toEqual({ page: 1 }) + it('should create a clientFetchAppSummaryFailed action', () => { + expect(clientFetchAppSummaryFailed.type).toEqual(ActionTypes.CLIENT_FETCH_APP_SUMMARY_FAILED) + expect(clientFetchAppSummaryFailed('error').data).toEqual('error') }) - it('should create a clientClearData action', () => { - expect(clientClearData.type).toEqual(ActionTypes.CLIENT_CLEAR_DATA) - expect(clientClearData(null).data).toEqual(null) + expect(clientClearAppSummary.type).toEqual(ActionTypes.CLIENT_CLEAR_APP_SUMMARY) + expect(clientClearAppSummary(null).data).toEqual(null) }) }) diff --git a/packages/marketplace/src/actions/app-installations.ts b/packages/marketplace/src/actions/app-installations.ts index 14556a0b73..3724af3537 100644 --- a/packages/marketplace/src/actions/app-installations.ts +++ b/packages/marketplace/src/actions/app-installations.ts @@ -20,10 +20,11 @@ export interface InstallationParams { pageNumber?: number } -export type InstallParams = CreateInstallationModel +export type InstallParams = CreateInstallationModel & { callback?: () => void } export type UninstallParams = { installationId: string + callback?: () => void } & TerminateInstallationModel export const appInstallationsRequestData = actionCreator(ActionTypes.APP_INSTALLATIONS_REQUEST_DATA) diff --git a/packages/marketplace/src/actions/client.ts b/packages/marketplace/src/actions/client.ts index 630790632f..3bbd90e476 100644 --- a/packages/marketplace/src/actions/client.ts +++ b/packages/marketplace/src/actions/client.ts @@ -1,9 +1,17 @@ -import { actionCreator } from '../utils/actions' -import ActionTypes from '../constants/action-types' -import { ClientItem, ClientParams } from '../reducers/client' +import { actionCreator } from '@/utils/actions' +import ActionTypes from '@/constants/action-types' +import { ClientAppSummary, ClientAppSummaryParams } from '@/reducers/client/app-summary' +import { AppDetailData } from '@/reducers/client/app-detail' +import { FetchAppDetailParams } from '@/services/apps' -export const clientRequestData = actionCreator(ActionTypes.CLIENT_REQUEST_DATA) -export const clientRequestDataFailure = actionCreator(ActionTypes.CLIENT_REQUEST_FAILURE) -export const clientLoading = actionCreator(ActionTypes.CLIENT_LOADING) -export const clientReceiveData = actionCreator(ActionTypes.CLIENT_RECEIVE_DATA) -export const clientClearData = actionCreator(ActionTypes.CLIENT_CLEAR_DATA) +export const clientFetchAppSummary = actionCreator(ActionTypes.CLIENT_FETCH_APP_SUMMARY) +export const clientFetchAppSummarySuccess = actionCreator( + ActionTypes.CLIENT_FETCH_APP_SUMMARY_SUCCESS, +) +export const clientFetchAppSummaryFailed = actionCreator(ActionTypes.CLIENT_FETCH_APP_SUMMARY_FAILED) +export const clientClearAppSummary = actionCreator(ActionTypes.CLIENT_CLEAR_APP_SUMMARY) + +// Client App Detail +export const clientFetchAppDetail = actionCreator(ActionTypes.CLIENT_FETCH_APP_DETAIL) +export const clientFetchAppDetailSuccess = actionCreator(ActionTypes.CLIENT_FETCH_APP_DETAIL_SUCCESS) +export const clientFetchAppDetailFailed = actionCreator(ActionTypes.CLIENT_FETCH_APP_DETAIL_FAILED) diff --git a/packages/marketplace/src/actions/developer.ts b/packages/marketplace/src/actions/developer.ts index 94f8e6e0fb..faddbb841f 100644 --- a/packages/marketplace/src/actions/developer.ts +++ b/packages/marketplace/src/actions/developer.ts @@ -2,6 +2,7 @@ import { actionCreator } from '../utils/actions' import ActionTypes from '../constants/action-types' import { + AppDetailData, DeveloperItem, DeveloperRequestParams, Billing, @@ -11,9 +12,17 @@ import { import { CreateDeveloperModel, DeveloperModel } from '@reapit/foundations-ts-definitions' import { FormState } from '@/types/core' import { FetchBillingParams } from '@/sagas/api' +import { FetchAppDetailParams } from '@/services/apps' import { FetchMonthlyBillingParams } from '@/services/billings' import { WebhookPingTestParams } from '@/services/subscriptions' +// Developer App Detail +export const developerFetchAppDetail = actionCreator(ActionTypes.DEVELOPER_FETCH_APP_DETAIL) +export const developerFetchAppDetailSuccess = actionCreator( + ActionTypes.DEVELOPER_FETCH_APP_DETAIL_SUCCESS, +) +export const developerFetchAppDetailFailed = actionCreator(ActionTypes.DEVELOPER_FETCH_APP_DETAIL_FAILED) + export const developerRequestData = actionCreator(ActionTypes.DEVELOPER_REQUEST_DATA) export const developerRequestDataFailure = actionCreator(ActionTypes.DEVELOPER_REQUEST_DATA_FAILURE) export const developerLoading = actionCreator(ActionTypes.DEVELOPER_LOADING) diff --git a/packages/marketplace/src/actions/revision-detail.ts b/packages/marketplace/src/actions/revision-detail.ts index 4a42de73bd..d9dcb2b209 100644 --- a/packages/marketplace/src/actions/revision-detail.ts +++ b/packages/marketplace/src/actions/revision-detail.ts @@ -7,6 +7,7 @@ import { FormState } from '@/types/core' export interface RevisionDetailRequestParams { appId: string appRevisionId: string + callback?: () => void } export interface RevisionReceiveDataParams extends RevisionDetailItem { diff --git a/packages/marketplace/src/components/pages/__tests__/__snapshots__/developer-home.tsx.snap b/packages/marketplace/src/components/pages/__tests__/__snapshots__/developer-home.tsx.snap index 46ba7e35eb..368886448b 100644 --- a/packages/marketplace/src/components/pages/__tests__/__snapshots__/developer-home.tsx.snap +++ b/packages/marketplace/src/components/pages/__tests__/__snapshots__/developer-home.tsx.snap @@ -60,7 +60,7 @@ exports[`DeveloperHome should match a snapshot 1`] = ` pagination={ Object { "onChange": [Function], - "pageNumber": 2, + "pageNumber": 1, "pageSize": 2, "totalCount": 6, } @@ -139,7 +139,7 @@ exports[`DeveloperHome should match a snapshot 3`] = ` pagination={ Object { "onChange": [Function], - "pageNumber": 2, + "pageNumber": 1, "pageSize": 2, "totalCount": 6, } diff --git a/packages/marketplace/src/components/pages/__tests__/client.tsx b/packages/marketplace/src/components/pages/__tests__/client.tsx index 7121d14256..e71f18348f 100644 --- a/packages/marketplace/src/components/pages/__tests__/client.tsx +++ b/packages/marketplace/src/components/pages/__tests__/client.tsx @@ -3,7 +3,7 @@ import { Provider } from 'react-redux' import { shallow, mount } from 'enzyme' import { appsDataStub, featuredAppsDataStub } from '@/sagas/__stubs__/apps' import { ReduxState } from '@/types/core' -import { ClientItem } from '@/reducers/client' +import { ClientAppSummary } from '@/reducers/client/app-summary' import { Client, ClientProps, @@ -34,12 +34,12 @@ const routerProps = { } as RouteComponentProps const props = (loading: boolean): ClientProps => ({ - clientState: { - loading: loading, - clientData: { + appSummaryState: { + isAppSummaryLoading: loading, + data: { featuredApps: featuredAppsDataStub.data, apps: appsDataStub, - } as ClientItem, + } as ClientAppSummary, }, appDetail: { appDetailData: appDetailDataStub, @@ -81,12 +81,12 @@ describe('Client', () => { } as RouteComponentProps const props: ClientProps = { - clientState: { - loading: false, - clientData: { + appSummaryState: { + isAppSummaryLoading: false, + data: { featuredApps: [] as AppSummaryModel[], apps: appsDataStub, - } as ClientItem, + } as ClientAppSummary, }, appDetail: { appDetailData: appDetailDataStub, @@ -111,12 +111,12 @@ describe('Client', () => { it('should match a snapshot when featured apps is undefined', () => { const props: ClientProps = { - clientState: { - loading: false, - clientData: { + appSummaryState: { + isAppSummaryLoading: false, + data: { featuredApps: undefined, apps: appsDataStub, - } as ClientItem, + } as ClientAppSummary, }, appDetail: { appDetailData: appDetailDataStub, @@ -143,9 +143,18 @@ describe('Client', () => { it('should return correctly', () => { const mockState = { client: { - clientData: { - featuredApps: featuredAppsDataStub.data, - apps: appsDataStub, + appSummary: { + data: { + apps: appsDataStub.data, + featuredApps: featuredAppsDataStub.data, + }, + isAppSummaryLoading: false, + error: 'error', + }, + appDetail: { + data: appDetailDataStub.data, + isAppDetailLoading: false, + error: '', }, }, appDetail: { @@ -165,7 +174,7 @@ describe('Client', () => { }, } as ReduxState const output = { - clientState: mockState.client, + appSummaryState: mockState.client.appSummary, appDetail: mockState.appDetail, clientId: 'ABC', installationsFormState: 'PENDING', diff --git a/packages/marketplace/src/components/pages/__tests__/developer-home.tsx b/packages/marketplace/src/components/pages/__tests__/developer-home.tsx index 9d138b16d5..a16f26ef1e 100644 --- a/packages/marketplace/src/components/pages/__tests__/developer-home.tsx +++ b/packages/marketplace/src/components/pages/__tests__/developer-home.tsx @@ -31,6 +31,10 @@ describe('DeveloperHome', () => { webhookPingTestStatus: null, loading: false, isVisible: false, + developerAppDetail: { + data: null, + isAppDetailLoading: false, + }, developerData: { ...appsDataStub, scopes: appPermissionStub, @@ -76,6 +80,10 @@ describe('DeveloperHome', () => { }, loading: false, isVisible: true, + developerAppDetail: { + data: null, + isAppDetailLoading: false, + }, developerData: { ...appsDataStub, scopes: appPermissionStub, @@ -125,10 +133,10 @@ describe('DeveloperHome', () => { it('should call push correctly', () => { const mockHistory = { push: jest.fn(), - } + } as any const fn = handleOnChange(mockHistory) fn(1) - expect(mockHistory.push).toBeCalledWith(`${routes.DEVELOPER_MY_APPS}/${1}`) + expect(mockHistory.push).toBeCalledWith(`${routes.DEVELOPER_MY_APPS}?page=1`) }) }) diff --git a/packages/marketplace/src/components/pages/client-app-detail-manage/__test__/__snapshots__/client-app-detail-manage.test.tsx.snap b/packages/marketplace/src/components/pages/client-app-detail-manage/__test__/__snapshots__/client-app-detail-manage.test.tsx.snap new file mode 100644 index 0000000000..29a83e64a5 --- /dev/null +++ b/packages/marketplace/src/components/pages/client-app-detail-manage/__test__/__snapshots__/client-app-detail-manage.test.tsx.snap @@ -0,0 +1,169 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ClientAppDetailManage renderAppHeaderButtonGroup should match snapshot 1`] = ` +
+ + Uninstall App + +
+`; + +exports[`ClientAppDetailManage should match a snapshot 1`] = ` + + + + +
+ + + + } + > + +
+ +
+
+ +
+
+
+ +
+ +

+ +

+
+
+
+
+ + +
+ +
+ + +
+ + +
+ + +
+
+ +
+ +
+
+
+
+ +
+ + + + +`; diff --git a/packages/marketplace/src/components/pages/client-app-detail-manage/__test__/client-app-detail-manage.test.tsx b/packages/marketplace/src/components/pages/client-app-detail-manage/__test__/client-app-detail-manage.test.tsx new file mode 100644 index 0000000000..32c3f036f2 --- /dev/null +++ b/packages/marketplace/src/components/pages/client-app-detail-manage/__test__/client-app-detail-manage.test.tsx @@ -0,0 +1,61 @@ +import * as React from 'react' +import * as ReactRedux from 'react-redux' +import TestRenderer from 'react-test-renderer' +import { shallow, mount } from 'enzyme' +import configureStore from 'redux-mock-store' +import { MemoryRouter } from 'react-router' +import ClientAppDetailManage, { + handleCloseUninstallConfirmationModal, + handleUninstallAppButtonClick, + renderAppHeaderButtonGroup, +} from '../client-app-detail-manage' +import { Button } from '@reapit/elements' +import Routes from '@/constants/routes' +import appState from '@/reducers/__stubs__/app-state' + +describe('ClientAppDetailManage', () => { + let store + beforeEach(() => { + /* mocking store */ + const mockStore = configureStore() + store = mockStore(appState) + }) + it('should match a snapshot', () => { + expect( + mount( + + + + + , + ), + ).toMatchSnapshot() + }) + + describe('renderAppHeaderButtonGroup', () => { + const mockInstalledOn = '2020-2-20' + it('should match snapshot', () => { + const wrapper = shallow(
{renderAppHeaderButtonGroup(mockInstalledOn, jest.fn())}
) + expect(wrapper).toMatchSnapshot() + }) + it('should render install app button if installedOn is existed', () => { + const testRenderer = TestRenderer.create(renderAppHeaderButtonGroup(mockInstalledOn, jest.fn())) + const testInstance = testRenderer.root + expect(testInstance.findByType(Button).props.children).toBe('Uninstall App') + }) + }) + describe('handleCloseUninstallConfirmationModal', () => { + const mockFunction = jest.fn() + const fn = handleCloseUninstallConfirmationModal(mockFunction) + fn() + expect(mockFunction).toBeCalledWith(false) + }) + describe('handleUninstallAppButtonClick', () => { + const mockFunction = jest.fn() + const fn = handleUninstallAppButtonClick(mockFunction) + fn() + expect(mockFunction).toBeCalledWith(true) + }) +}) diff --git a/packages/marketplace/src/components/pages/client-app-detail-manage/client-app-detail-manage.tsx b/packages/marketplace/src/components/pages/client-app-detail-manage/client-app-detail-manage.tsx new file mode 100644 index 0000000000..436d5b7013 --- /dev/null +++ b/packages/marketplace/src/components/pages/client-app-detail-manage/client-app-detail-manage.tsx @@ -0,0 +1,79 @@ +import * as React from 'react' +import { useSelector } from 'react-redux' +import { selectAppDetailData, selectAppDetailLoading } from '@/selector/client-app-detail' +import { selectLoginType } from '@/selector/auth' +import AppHeader from '@/components/ui/app-detail/app-header' +import AppContent from '@/components/ui/app-detail/app-content' + +import { Loader, Button } from '@reapit/elements' +import styles from '@/styles/pages/developer-app-detail.scss?mod' +import ClientAppUninstallConfirmation from '@/components/ui/client-app-detail/client-app-uninstall-confirmation' + +export type ClientAppDetailManageProps = {} + +export const handleCloseUninstallConfirmationModal = ( + setIsVisibleUninstallConfirmation: (isVisible: boolean) => void, +) => { + return () => { + setIsVisibleUninstallConfirmation(false) + } +} + +export const handleUninstallAppButtonClick = (setIsVisibleUninstallConfirmation: (isVisible: boolean) => void) => { + return () => { + setIsVisibleUninstallConfirmation(true) + } +} + +export const renderAppHeaderButtonGroup = (installedOn: string, onUninstallConfirmationModal: () => void) => { + return ( + <> + {installedOn && ( + + )} + + ) +} + +const ClientAppDetailManage: React.FC = () => { + const [isVisibleUninstallConfirmation, setIsVisibleUninstallConfirmation] = React.useState(false) + const closeUninstallConfirmationModal = React.useCallback( + handleCloseUninstallConfirmationModal(setIsVisibleUninstallConfirmation), + [], + ) + const onUninstallConfirmationModal = React.useCallback( + handleUninstallAppButtonClick(setIsVisibleUninstallConfirmation), + [], + ) + + const appDetailData = useSelector(selectAppDetailData) + const isLoadingAppDetail = useSelector(selectAppDetailLoading) + const loginType = useSelector(selectLoginType) + + return ( +
+ + + {isVisibleUninstallConfirmation && ( + + )} + {isLoadingAppDetail && } +
+ ) +} + +export default ClientAppDetailManage diff --git a/packages/marketplace/src/components/pages/client-app-detail-manage/index.ts b/packages/marketplace/src/components/pages/client-app-detail-manage/index.ts new file mode 100644 index 0000000000..dfb7d1afd9 --- /dev/null +++ b/packages/marketplace/src/components/pages/client-app-detail-manage/index.ts @@ -0,0 +1,2 @@ +import ClientAppDetailManage from './client-app-detail-manage' +export default ClientAppDetailManage diff --git a/packages/marketplace/src/components/pages/client-app-detail/__test__/__snapshots__/client-app-detail.test.tsx.snap b/packages/marketplace/src/components/pages/client-app-detail/__test__/__snapshots__/client-app-detail.test.tsx.snap new file mode 100644 index 0000000000..5cb1726154 --- /dev/null +++ b/packages/marketplace/src/components/pages/client-app-detail/__test__/__snapshots__/client-app-detail.test.tsx.snap @@ -0,0 +1,171 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ClientAppDetail renderAppHeaderButtonGroup should match snapshot 1`] = ` +
+
+
+ + + Installed + +
+
+
+`; + +exports[`ClientAppDetail should match a snapshot 1`] = ` + + + + +
+ + + + } + > + +
+ +
+
+ +
+
+
+ +
+ +

+ +

+
+
+
+
+ + +
+ +
+ + +
+ + +
+ + +
+
+ +
+ +
+
+
+
+ +
+ + + + +`; diff --git a/packages/marketplace/src/components/pages/client-app-detail/__test__/client-app-detail.test.tsx b/packages/marketplace/src/components/pages/client-app-detail/__test__/client-app-detail.test.tsx new file mode 100644 index 0000000000..77027721bc --- /dev/null +++ b/packages/marketplace/src/components/pages/client-app-detail/__test__/client-app-detail.test.tsx @@ -0,0 +1,84 @@ +import * as React from 'react' +import * as ReactRedux from 'react-redux' +import TestRenderer from 'react-test-renderer' +import { MemoryRouter } from 'react-router' +import { mount, shallow } from 'enzyme' +import configureStore from 'redux-mock-store' +import ClientAppDetail, { + handleCloseInstallConfirmationModal, + handleInstallAppButtonClick, + renderAppHeaderButtonGroup, +} from '../client-app-detail' +import { Button } from '@reapit/elements' +import Routes from '@/constants/routes' +import appState from '@/reducers/__stubs__/app-state' + +describe('ClientAppDetail', () => { + let store + beforeEach(() => { + /* mocking store */ + const mockStore = configureStore() + store = mockStore(appState) + }) + it('should match a snapshot', () => { + expect( + mount( + + + + + , + ), + ).toMatchSnapshot() + }) + + describe('renderAppHeaderButtonGroup', () => { + const mockAppId = 'test' + const mockInstalledOn = '2020-2-20' + it('should match snapshot', () => { + const wrapper = shallow(
{renderAppHeaderButtonGroup(mockAppId, mockInstalledOn, jest.fn())}
) + expect(wrapper).toMatchSnapshot() + }) + it('should render header button group when appId is existed', () => { + const testRenderer = TestRenderer.create(renderAppHeaderButtonGroup(mockAppId, mockInstalledOn, jest.fn())) + const testInstance = testRenderer.root + expect(testInstance.children.length).toBe(1) + }) + it('should render install app button if installedOn is empty', () => { + const testRenderer = TestRenderer.create(renderAppHeaderButtonGroup(mockAppId, '', jest.fn())) + const testInstance = testRenderer.root + expect(testInstance.findByType(Button).props.children).toBe('Install App') + }) + it('should render installed label if installedOn is existed', () => { + const testRenderer = TestRenderer.create(renderAppHeaderButtonGroup(mockAppId, mockInstalledOn, jest.fn())) + const testInstance = testRenderer.root + expect( + testInstance.findByProps({ + id: 'installed-label-container', + }).children.length, + ).toBeGreaterThan(0) + }) + it('should not render header button group when appId is empty', () => { + const testRenderer = TestRenderer.create(renderAppHeaderButtonGroup('', mockInstalledOn, jest.fn())) + const testInstance = testRenderer.getInstance + expect(testInstance).toHaveLength(0) + }) + }) + + describe('handleCloseInstallConfirmationModal', () => { + it('should run correctly', () => { + const mockFunction = jest.fn() + const fn = handleCloseInstallConfirmationModal(mockFunction) + fn() + expect(mockFunction).toBeCalledWith(false) + }) + }) + describe('handleInstallAppButtonClick', () => { + it('should run correctly', () => { + const mockFunction = jest.fn() + const fn = handleInstallAppButtonClick(mockFunction) + fn() + expect(mockFunction).toBeCalledWith(true) + }) + }) +}) diff --git a/packages/marketplace/src/components/pages/client-app-detail/client-app-detail.tsx b/packages/marketplace/src/components/pages/client-app-detail/client-app-detail.tsx new file mode 100644 index 0000000000..68f8d44a56 --- /dev/null +++ b/packages/marketplace/src/components/pages/client-app-detail/client-app-detail.tsx @@ -0,0 +1,86 @@ +import * as React from 'react' +import { FaCheck } from 'react-icons/fa' +import { useSelector } from 'react-redux' +import { selectAppDetailData, selectAppDetailLoading } from '@/selector/client-app-detail' +import { selectLoginType } from '@/selector/auth' +import AppHeader from '@/components/ui/app-detail/app-header' +import AppContent from '@/components/ui/app-detail/app-content' + +import { Loader, Button } from '@reapit/elements' +import styles from '@/styles/pages/developer-app-detail.scss?mod' +import ClientAppInstallConfirmation from '@/components/ui/client-app-detail/client-app-install-confirmation' + +export type ClientAppDetailProps = {} + +export const handleCloseInstallConfirmationModal = (setIsVisibleInstallConfirmation: (isVisible: boolean) => void) => { + return () => { + setIsVisibleInstallConfirmation(false) + } +} + +export const handleInstallAppButtonClick = (setIsVisibleInstallConfirmation: (isVisible: boolean) => void) => { + return () => { + setIsVisibleInstallConfirmation(true) + } +} + +export const renderAppHeaderButtonGroup = (id: string, installedOn: string, onInstallConfirmationModal: () => void) => { + return ( + <> + {id && ( +
+ {installedOn ? ( +
+ + Installed +
+ ) : ( + + )} +
+ )} + + ) +} + +const ClientAppDetail: React.FC = () => { + const [isVisibleInstallConfirmation, setIsVisibleInstallConfirmation] = React.useState(false) + const closeInstallConfirmationModal = React.useCallback( + handleCloseInstallConfirmationModal(setIsVisibleInstallConfirmation), + [], + ) + const onInstallConfirmationModal = React.useCallback(handleInstallAppButtonClick(setIsVisibleInstallConfirmation), []) + + const appDetailData = useSelector(selectAppDetailData) + const isLoadingAppDetail = useSelector(selectAppDetailLoading) + const loginType = useSelector(selectLoginType) + + const { id = '', installedOn = '' } = appDetailData + + return ( +
+ + + {isVisibleInstallConfirmation && ( + + )} + {isLoadingAppDetail && } +
+ ) +} + +export default ClientAppDetail diff --git a/packages/marketplace/src/components/pages/client-app-detail/index.ts b/packages/marketplace/src/components/pages/client-app-detail/index.ts new file mode 100644 index 0000000000..38d087fde0 --- /dev/null +++ b/packages/marketplace/src/components/pages/client-app-detail/index.ts @@ -0,0 +1,2 @@ +import ClientAppDetail from './client-app-detail' +export default ClientAppDetail diff --git a/packages/marketplace/src/components/pages/client.tsx b/packages/marketplace/src/components/pages/client.tsx index a5ee30d3f7..2814bd2cc6 100644 --- a/packages/marketplace/src/components/pages/client.tsx +++ b/packages/marketplace/src/components/pages/client.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import { connect } from 'react-redux' import { ReduxState, FormState } from '@/types/core' -import { ClientState } from '@/reducers/client' +import { ClientAppSummaryState } from '@/reducers/client/app-summary' import { Loader } from '@reapit/elements' import ErrorBoundary from '@/components/hocs/error-boundary' import { withRouter, RouteComponentProps } from 'react-router' @@ -10,7 +10,7 @@ import AppSidebar from '@/components/ui/app-sidebar' import { appDetailRequestData } from '@/actions/app-detail' import { AppDetailState } from '@/reducers/app-detail' import AppDetailModal from '@/components/ui/app-detail-modal' -import { selectClientId } from '@/selector/client' +import { selectClientId, selectAppSummary } from '@/selector/client' import { AppSummaryModel } from '@reapit/foundations-ts-definitions' import styles from '@/styles/pages/client.scss?mod' import { appInstallationsSetFormState } from '@/actions/app-installations' @@ -24,7 +24,7 @@ export interface ClientMappedActions { } export interface ClientMappedProps { - clientState: ClientState + appSummaryState: ClientAppSummaryState appDetail: AppDetailState clientId: string installationsFormState: FormState @@ -77,7 +77,7 @@ export const handleInstallationDone = ({ export type ClientProps = ClientMappedActions & ClientMappedProps & RouteComponentProps<{ page?: any }> export const Client: React.FunctionComponent = ({ - clientState, + appSummaryState, history, location, fetchAppDetail, @@ -93,11 +93,11 @@ export const Client: React.FunctionComponent = ({ ? Number(getParamValueFromPath(location.search, 'page')) : 1 const hasParams = hasFilterParams(location.search) - const unfetched = !clientState.clientData - const loading = clientState.loading - const apps = clientState?.clientData?.apps?.data || [] - const featuredApps = clientState?.clientData?.featuredApps || [] - const { totalCount, pageSize } = clientState?.clientData?.apps || {} + const unfetched = !appSummaryState.data + const loading = appSummaryState.isAppSummaryLoading + const apps = appSummaryState?.data?.apps?.data || [] + const featuredApps = appSummaryState?.data?.featuredApps || [] + const { totalCount, pageSize } = appSummaryState?.data?.apps || {} const [visible, setVisible] = React.useState(false) const isDone = installationsFormState === 'DONE' @@ -155,7 +155,7 @@ export const Client: React.FunctionComponent = ({ } export const mapStateToProps = (state: ReduxState): ClientMappedProps => ({ - clientState: state.client, + appSummaryState: selectAppSummary(state), appDetail: state.appDetail, clientId: selectClientId(state), installationsFormState: state.installations.formState, diff --git a/packages/marketplace/src/components/pages/developer-app-detail/__test__/__snapshots__/developer-app-detail.test.tsx.snap b/packages/marketplace/src/components/pages/developer-app-detail/__test__/__snapshots__/developer-app-detail.test.tsx.snap new file mode 100644 index 0000000000..dfeab951b7 --- /dev/null +++ b/packages/marketplace/src/components/pages/developer-app-detail/__test__/__snapshots__/developer-app-detail.test.tsx.snap @@ -0,0 +1,229 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DeveloperAppDetail renderAppHeaderButtonGroup should match snapshot 1`] = ` +
+ +
+`; + +exports[`DeveloperAppDetail should match a snapshot 1`] = ` + + + + +
+ + + + } + > + +
+ +
+
+ +
+
+
+ +
+ +

+ +

+
+
+
+
+ + +
+ +
+
+

+ Developer: +

+

+

+
+

+ Client ID: +

+

+

+
+

+ Status: +

+

+ Not listed +

+ + + + + + + +
+
+
+ +
+ + +
+ + +
+
+ +
+ +
+
+
+
+ +
+ + + + +`; diff --git a/packages/marketplace/src/components/pages/developer-app-detail/__test__/developer-app-detail.test.tsx b/packages/marketplace/src/components/pages/developer-app-detail/__test__/developer-app-detail.test.tsx new file mode 100644 index 0000000000..7d117969f2 --- /dev/null +++ b/packages/marketplace/src/components/pages/developer-app-detail/__test__/developer-app-detail.test.tsx @@ -0,0 +1,86 @@ +import * as React from 'react' +import * as ReactRedux from 'react-redux' +import { ReduxState } from '@/types/core' +import { mount, shallow } from 'enzyme' +import configureStore from 'redux-mock-store' +import { MemoryRouter } from 'react-router' +import DeveloperAppDetail, { + handleOnDeleteAppSuccess, + renderAppHeaderButtonGroup, + closeInstallationsModal, + closeAppRevisionComparisionModal, + closeDeleteAppModal, +} from '../developer-app-detail' +import routes from '@/constants/routes' +import Routes from '@/constants/routes' +import appState from '@/reducers/__stubs__/app-state' + +const mockState = { + ...appState, + auth: { + loginType: 'DEVELOPER', + }, +} as ReduxState + +describe('DeveloperAppDetail', () => { + let store + beforeEach(() => { + /* mocking store */ + const mockStore = configureStore() + store = mockStore(mockState) + }) + it('should match a snapshot', () => { + expect( + mount( + + + + + , + ), + ).toMatchSnapshot() + }) + describe('renderAppHeaderButtonGroup', () => { + const mockAppId = 'testAppId' + it('should match snapshot', () => { + const wrapper = shallow( +
+ {renderAppHeaderButtonGroup( + mockAppId, + mockState.developer.developerAppDetail, + jest.fn(), + jest.fn(), + jest.fn(), + )} +
, + ) + expect(wrapper).toMatchSnapshot() + }) + }) + describe('handleOnDeleteAppSuccess', () => { + const history = { + replace: jest.fn(), + } as any + const fn = handleOnDeleteAppSuccess(history) + fn() + expect(history.replace).toBeCalledWith(routes.DEVELOPER_MY_APPS) + }) + describe('handleOnDeleteAppSuccess', () => { + const mockFunction = jest.fn() + const fn = closeInstallationsModal(mockFunction) + fn() + expect(mockFunction).toBeCalledWith(false) + }) + describe('closeAppRevisionComparisionModal', () => { + const mockFunction = jest.fn() + const fn = closeAppRevisionComparisionModal(mockFunction) + fn() + expect(mockFunction).toBeCalledWith(false) + }) + describe('closeDeleteAppModal', () => { + const mockFunction = jest.fn() + const fn = closeDeleteAppModal(mockFunction) + fn() + expect(mockFunction).toBeCalledWith(false) + }) +}) diff --git a/packages/marketplace/src/components/pages/developer-app-detail/developer-app-detail.tsx b/packages/marketplace/src/components/pages/developer-app-detail/developer-app-detail.tsx new file mode 100644 index 0000000000..d7671860e9 --- /dev/null +++ b/packages/marketplace/src/components/pages/developer-app-detail/developer-app-detail.tsx @@ -0,0 +1,128 @@ +import * as React from 'react' +import { useSelector } from 'react-redux' +import { History } from 'history' +import { selectAppDetailState, selectAppDetailData, selectAppDetailLoading } from '@/selector/developer-app-detail' +import { selectLoginType } from '@/selector/auth' +import { Loader } from '@reapit/elements' +import AppHeader from '@/components/ui/app-detail/app-header' +import AppContent from '@/components/ui/app-detail/app-content' +import DeveloperAppDetailButtonGroup from '@/components/ui/developer-app-detail/developer-app-detail-button-group' +import AppDelete from '@/components/ui/app-delete' +import AppInstallations from '@/components/ui/app-installations/app-installations-modal' + +import routes from '@/constants/routes' +import styles from '@/styles/pages/developer-app-detail.scss?mod' +import AppRevisionModal from '@/components/ui/developer-app-detail/app-revision-modal' +import { useHistory } from 'react-router' +import { DeveloperAppDetailState } from '@/reducers/developer' + +export type DeveloperAppDetailProps = {} + +export const handleOnDeleteAppSuccess = (history: History) => { + return () => { + history.replace(routes.DEVELOPER_MY_APPS) + } +} + +export const renderAppHeaderButtonGroup = ( + id: string, + appDetailState: DeveloperAppDetailState, + setIsAppRevisionComparisionModalOpen: (isVisible: boolean) => void, + setIsDeleteModalOpen: (isVisible: boolean) => void, + setIsInstallationsModalOpen: (isVisible: boolean) => void, +) => { + return ( + <> + {id && ( + + )} + + ) +} + +export const closeInstallationsModal = (setIsInstallationsModalOpen: (isVisible: boolean) => void) => { + return () => { + setIsInstallationsModalOpen(false) + } +} + +export const closeAppRevisionComparisionModal = ( + setIsAppRevisionComparisionModalOpen: (isVisible: boolean) => void, +) => { + return () => { + setIsAppRevisionComparisionModalOpen(false) + } +} + +export const closeDeleteAppModal = (setIsDeleteModalOpen: (isVisible: boolean) => void) => { + return () => { + setIsDeleteModalOpen(false) + } +} + +const DeveloperAppDetail: React.FC = () => { + const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false) + const [isInstallationsModalOpen, setIsInstallationsModalOpen] = React.useState(false) + const [isAppRevisionComparisionModalOpen, setIsAppRevisionComparisionModalOpen] = React.useState(false) + + const history = useHistory() + const appDetailState = useSelector(selectAppDetailState) + const appDetailData = useSelector(selectAppDetailData) + const isLoadingAppDetail = useSelector(selectAppDetailLoading) + const loginType = useSelector(selectLoginType) + const { id = '', name = '' } = appDetailData + + return ( +
+ + + + {isDeleteModalOpen && ( + + )} + + {isInstallationsModalOpen && ( + + )} + + {isAppRevisionComparisionModalOpen && ( + + )} + + {isLoadingAppDetail && } +
+ ) +} + +export default DeveloperAppDetail diff --git a/packages/marketplace/src/components/pages/developer-app-detail/index.ts b/packages/marketplace/src/components/pages/developer-app-detail/index.ts new file mode 100644 index 0000000000..76a7bf6dfb --- /dev/null +++ b/packages/marketplace/src/components/pages/developer-app-detail/index.ts @@ -0,0 +1,2 @@ +import DeveloperAppDetail from './developer-app-detail' +export default DeveloperAppDetail diff --git a/packages/marketplace/src/components/pages/developer-home.tsx b/packages/marketplace/src/components/pages/developer-home.tsx index 1fdc769974..af8dbe93f6 100644 --- a/packages/marketplace/src/components/pages/developer-home.tsx +++ b/packages/marketplace/src/components/pages/developer-home.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { connect } from 'react-redux' +import { History } from 'history' import { ReduxState } from '@/types/core' import { Loader } from '@reapit/elements' import ErrorBoundary from '@/components/hocs/error-boundary' @@ -12,11 +13,14 @@ import { appDetailRequestData, removeAuthenticationCode } from '@/actions/app-de import DeveloperAppModal from '../ui/developer-app-modal' import { setDeveloperAppModalStateViewDetail, developerAppShowModal } from '@/actions/developer-app-modal' import { appDeleteSetInitFormState } from '@/actions/app-delete' +import { developerRequestData } from '@/actions/developer' import { AppSummaryModel } from '@reapit/foundations-ts-definitions' import { SandboxPopUp } from '../ui/sandbox-pop-up' +import { getParamValueFromPath } from '@/utils/client-url-params' export interface DeveloperMappedActions { fetchAppDetail: (id: string) => void + fetchDeveloperApps: (page: number) => void setDeveloperAppModalStateViewDetail: () => void appDeleteSetInitFormState: () => void setVisible: (isVisible: boolean) => void @@ -49,14 +53,15 @@ export const handleAfterClose = ({ setVisible, removeAuthenticationCode }) => () setVisible(false) } -export const handleOnChange = history => (page: number) => history.push(`${routes.DEVELOPER_MY_APPS}/${page}`) +export const handleOnChange = (history: History) => (page: number) => + history.push(`${routes.DEVELOPER_MY_APPS}?page=${page}`) export type DeveloperProps = DeveloperMappedActions & DeveloperMappedProps & RouteComponentProps<{ page?: any }> export const DeveloperHome: React.FunctionComponent = ({ developerState, - match, fetchAppDetail, + fetchDeveloperApps, setDeveloperAppModalStateViewDetail, appDeleteSetInitFormState, appDetail, @@ -64,13 +69,29 @@ export const DeveloperHome: React.FunctionComponent = ({ isVisible, setVisible, removeAuthenticationCode, + location, }) => { - const pageNumber = match.params && !isNaN(match.params.page) ? Number(match.params.page) : 1 + let pageNumber = 1 + + if (location && location.search) { + const pageQueryString = getParamValueFromPath(location.search, 'page') + if (pageQueryString) { + pageNumber = Number(pageQueryString) + } + } + const unfetched = !developerState.developerData const loading = developerState.loading const list = developerState?.developerData?.data?.data || [] const { totalCount, pageSize } = developerState?.developerData?.data || {} + React.useEffect(() => { + if (unfetched) { + return + } + fetchDeveloperApps(pageNumber) + }, [pageNumber, unfetched]) + if (unfetched || loading) { return } @@ -115,6 +136,7 @@ export const mapStateToProps = (state: ReduxState): DeveloperMappedProps => ({ export const mapDispatchToProps = (dispatch: any): DeveloperMappedActions => ({ fetchAppDetail: (id: string) => dispatch(appDetailRequestData({ id })), + fetchDeveloperApps: (page: number) => dispatch(developerRequestData({ page })), setDeveloperAppModalStateViewDetail: () => dispatch(setDeveloperAppModalStateViewDetail()), appDeleteSetInitFormState: () => dispatch(appDeleteSetInitFormState()), setVisible: (isVisible: boolean) => dispatch(developerAppShowModal(isVisible)), diff --git a/packages/marketplace/src/components/ui/app-authentication-detail.tsx b/packages/marketplace/src/components/ui/app-authentication-detail.tsx index 2abdadae45..55020433a7 100644 --- a/packages/marketplace/src/components/ui/app-authentication-detail.tsx +++ b/packages/marketplace/src/components/ui/app-authentication-detail.tsx @@ -72,7 +72,7 @@ export const AppAuthenticationDetail: React.FunctionComponent} {!loading && code && (
-

{code}

+

{code}

{tooltipMessage} diff --git a/packages/marketplace/src/components/ui/app-detail/app-content/__test__/__snapshots__/app-content.test.tsx.snap b/packages/marketplace/src/components/ui/app-detail/app-content/__test__/__snapshots__/app-content.test.tsx.snap new file mode 100644 index 0000000000..7fc2e78c9f --- /dev/null +++ b/packages/marketplace/src/components/ui/app-detail/app-content/__test__/__snapshots__/app-content.test.tsx.snap @@ -0,0 +1,169 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AppContent SlickButtonNav should match snapshot 1`] = ` + +`; + +exports[`AppContent renderShowApiKeyForWebComponent should match snapshot and return null 1`] = `
`; + +exports[`AppContent renderShowApiKeyForWebComponent should match snapshot and return null 2`] = `
`; + +exports[`AppContent renderShowApiKeyForWebComponent should match snapshot and return null 3`] = `
`; + +exports[`AppContent renderShowApiKeyForWebComponent should match snapshot not show api key 1`] = ` +
+ + API key + +

+ To obtain your unique API key, click on 'Show API Key' below. For installation instructions, please click here +

+ + Authentication: + +   + + + + Show + API key + + + +
+`; + +exports[`AppContent renderShowApiKeyForWebComponent should match snapshot return null 1`] = `
`; + +exports[`AppContent renderShowApiKeyForWebComponent should match snapshot when isWebComponent 1`] = ` +
+ + API key + +

+ To obtain your unique API key, click on 'Show API Key' below. For installation instructions, please click here +

+ + Authentication: + +   + + + + Hide + API key + + + + +
+ + + + +
+
+
+`; + +exports[`AppContent should match a snapshot 1`] = ` + + + + +
+
+ + + + } + prevArrow={ + + + + } + speed={500} + variableWidth={true} + > +
+ +
+
+ +
+
+
+
+ + +
  • + Read data about developers +
  • +
    + +
  • + Write data about developers +
  • +
    +
    +
    +
    +`; diff --git a/packages/marketplace/src/components/ui/app-detail/app-content/__test__/app-content.test.tsx b/packages/marketplace/src/components/ui/app-detail/app-content/__test__/app-content.test.tsx new file mode 100644 index 0000000000..4da7478548 --- /dev/null +++ b/packages/marketplace/src/components/ui/app-detail/app-content/__test__/app-content.test.tsx @@ -0,0 +1,136 @@ +import * as React from 'react' +import { shallow } from 'enzyme' +import AppContent, { + AppContentProps, + SlickButtonNav, + handleShowApiKey, + handleCopyAlert, + renderShowApiKeyForWebComponent, +} from '../app-content' +import { appDetailDataStub } from '@/sagas/__stubs__/app-detail' + +const mockProps: AppContentProps = { + appDetailData: { + ...appDetailDataStub.data, + apiKey: '', + }, + loginType: 'CLIENT', +} + +describe('AppContent', () => { + it('should match a snapshot', () => { + expect(shallow()).toMatchSnapshot() + }) + describe('SlickButtonNav', () => { + it('should match snapshot', () => { + const mockProps = { + currentSlide: '', + setAppDetailModalStateInstall: jest.fn(), + slideCount: jest.fn(), + } + const wrapper = shallow( + +
    mockComponent
    +
    , + ) + expect(wrapper).toMatchSnapshot() + }) + }) + + describe('handleShowApiKey', () => { + it('should run correctly', () => { + const input = { setIsShowApikey: jest.fn(), isShowApiKey: true } + const fn = handleShowApiKey(input) + fn() + expect(input.setIsShowApikey).toBeCalledWith(!input.isShowApiKey) + }) + }) + + describe('handleCopyAlert', () => { + it('should run correctly', () => { + window.alert = jest.fn() + handleCopyAlert() + expect(window.alert).toBeCalledWith('Copied') + }) + }) + + describe('renderShowApiKeyForWebComponent', () => { + it('should match snapshot when isWebComponent', () => { + const input = { + isWebComponent: true, + setIsShowApikey: jest.fn(), + isShowApiKey: true, + apiKey: 'mockApiKey', + isCurrentLoggedUserDeveloper: false, + } + const wrapper = shallow(
    {renderShowApiKeyForWebComponent(input)}
    ) + const apiInput = wrapper.find('[id="apiKey"]') + expect(apiInput).toHaveLength(1) + expect(wrapper).toMatchSnapshot() + }) + + it('should match snapshot return null', () => { + const input = { + isWebComponent: false, + setIsShowApikey: jest.fn(), + isShowApiKey: true, + apiKey: 'mockApiKey', + isCurrentLoggedUserDeveloper: false, + } + const wrapper = shallow(
    {renderShowApiKeyForWebComponent(input)}
    ) + const apiInput = wrapper.find('[id="apiKey"]') + expect(apiInput).toHaveLength(0) + expect(wrapper).toMatchSnapshot() + }) + + it('should match snapshot not show api key', () => { + const input = { + isWebComponent: true, + setIsShowApikey: jest.fn(), + isShowApiKey: false, + apiKey: 'mockApiKey', + isCurrentLoggedUserDeveloper: false, + } + const wrapper = shallow(
    {renderShowApiKeyForWebComponent(input)}
    ) + const apiInput = wrapper.find('[id="apiKey"]') + expect(apiInput).toHaveLength(0) + expect(wrapper).toMatchSnapshot() + }) + + it('should match snapshot and return null', () => { + const input = { + isWebComponent: true, + setIsShowApikey: jest.fn(), + isShowApiKey: false, + apiKey: 'mockApiKey', + isCurrentLoggedUserDeveloper: true, + } + const wrapper = shallow(
    {renderShowApiKeyForWebComponent(input)}
    ) + expect(wrapper).toMatchSnapshot() + }) + + it('should match snapshot and return null', () => { + const input = { + isWebComponent: false, + setIsShowApikey: jest.fn(), + isShowApiKey: false, + apiKey: 'mockApiKey', + isCurrentLoggedUserDeveloper: true, + } + const wrapper = shallow(
    {renderShowApiKeyForWebComponent(input)}
    ) + expect(wrapper).toMatchSnapshot() + }) + + it('should match snapshot and return null', () => { + const input = { + isWebComponent: true, + setIsShowApikey: jest.fn(), + isShowApiKey: true, + apiKey: undefined, + isCurrentLoggedUserDeveloper: true, + } + const wrapper = shallow(
    {renderShowApiKeyForWebComponent(input)}
    ) + expect(wrapper).toMatchSnapshot() + }) + }) +}) diff --git a/packages/marketplace/src/components/ui/app-detail/app-content/app-content.tsx b/packages/marketplace/src/components/ui/app-detail/app-content/app-content.tsx new file mode 100644 index 0000000000..e221f16cc3 --- /dev/null +++ b/packages/marketplace/src/components/ui/app-detail/app-content/app-content.tsx @@ -0,0 +1,167 @@ +import * as React from 'react' +import { CopyToClipboard } from 'react-copy-to-clipboard' +import Slider, { Settings } from 'react-slick' +import ChevronLeftIcon from '@/components/svg/chevron-left' +import { FaCheck, FaTimes, FaCopy } from 'react-icons/fa' +import { Grid, GridItem, SubTitleH6, GridThreeColItem, HTMLRender } from '@reapit/elements' +import { AppDetailModel } from '@reapit/foundations-ts-definitions' +import AuthFlow from '@/constants/app-auth-flow' +import AppAuthenticationDetail from '../../app-authentication-detail' +import styles from '@/styles/blocks/app-detail.scss?mod' +import carouselStyles from '@/styles/elements/carousel.scss?mod' +import '@/styles/vendor/slick.scss' + +export type AppContentProps = { + appDetailData: AppDetailModel & { + apiKey?: string | undefined + } + loginType: string +} + +export const SlickButtonNav = ({ children, ...props }) => + +export type HandleShowApiKey = { + setIsShowApikey: React.Dispatch> + isShowApiKey: boolean +} + +export const handleShowApiKey = ({ setIsShowApikey, isShowApiKey }: HandleShowApiKey) => () => { + setIsShowApikey(!isShowApiKey) +} + +export const handleCopyAlert = () => alert('Copied') + +export type RenderShowApiKeyForWebComponent = HandleShowApiKey & { + isWebComponent?: boolean + isCurrentLoggedUserDeveloper: boolean + apiKey?: string +} + +export const renderShowApiKeyForWebComponent = ({ + isWebComponent, + setIsShowApikey, + isShowApiKey, + apiKey, + isCurrentLoggedUserDeveloper, +}: RenderShowApiKeyForWebComponent) => { + const isShow = isWebComponent && !isCurrentLoggedUserDeveloper + if (!isShow) { + return null + } + return ( + <> + API key +

    + To obtain your unique API key, click on 'Show API Key' below. For installation instructions, please + click here +

    + Authentication:  + + + {!isShowApiKey ? 'Show' : 'Hide'} API key + + + {isShowApiKey && ( + +
    + + + + +
    +
    + )} + + ) +} + +const AppContent: React.FC = ({ appDetailData, loginType }) => { + const { + externalId, + isListed, + id, + developer, + authFlow, + isWebComponent, + apiKey, + media = [], + scopes = [], + description = '', + } = appDetailData + const [isShowApiKey, setIsShowApikey] = React.useState(false) + + const isCurrentLoggedUserDeveloper = loginType === 'DEVELOPER' + const carouselImages = media.filter(({ type }) => type === 'image') + const settings: Settings = { + dots: false, + speed: 500, + variableWidth: true, + prevArrow: ( + + + + ), + nextArrow: ( + + + + ), + } + + return ( + + + {isCurrentLoggedUserDeveloper && ( + <> +
    +

    Developer:

    +

    {developer}

    +
    +
    +

    Client ID:

    +

    {externalId}

    +
    +
    +

    Status:

    +

    {isListed ? 'Listed' : 'Not listed'}

    + {isListed ? : } +
    + {authFlow === AuthFlow.CLIENT_SECRET && id && } + + )} + {renderShowApiKeyForWebComponent({ + isWebComponent, + isShowApiKey, + setIsShowApikey, + apiKey: apiKey, + isCurrentLoggedUserDeveloper, + })} +
    + + +
    + {carouselImages.length > 0 && ( +
    + + {carouselImages.map(({ uri }, index) => ( +
    + +
    + ))} +
    +
    + )} +
    + + {scopes.map(item => ( + +
  • {item.description}
  • +
    + ))} +
    +
    +
    + ) +} + +export default AppContent diff --git a/packages/marketplace/src/components/ui/app-detail/app-content/index.ts b/packages/marketplace/src/components/ui/app-detail/app-content/index.ts new file mode 100644 index 0000000000..67df188973 --- /dev/null +++ b/packages/marketplace/src/components/ui/app-detail/app-content/index.ts @@ -0,0 +1,2 @@ +import AppContent from './app-content' +export default AppContent diff --git a/packages/marketplace/src/components/ui/app-detail/app-header/__test__/__snapshots__/app-header.test.tsx.snap b/packages/marketplace/src/components/ui/app-detail/app-header/__test__/__snapshots__/app-header.test.tsx.snap new file mode 100644 index 0000000000..8935525f50 --- /dev/null +++ b/packages/marketplace/src/components/ui/app-detail/app-header/__test__/__snapshots__/app-header.test.tsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AppContent should match a snapshot 1`] = ` + + + +
    + Peter's Properties +
    +
    + + + Peter's Properties + + +
    +
    +`; diff --git a/packages/marketplace/src/components/ui/app-detail/app-header/__test__/app-header.test.tsx b/packages/marketplace/src/components/ui/app-detail/app-header/__test__/app-header.test.tsx new file mode 100644 index 0000000000..2ed1dd3ffc --- /dev/null +++ b/packages/marketplace/src/components/ui/app-detail/app-header/__test__/app-header.test.tsx @@ -0,0 +1,17 @@ +import * as React from 'react' +import { shallow } from 'enzyme' +import { appDetailDataStub } from '@/sagas/__stubs__/app-detail' +import AppHeader, { AppHeaderProps } from '../app-header' + +const mockProps: AppHeaderProps = { + appDetailData: { + ...appDetailDataStub.data, + apiKey: '', + }, +} + +describe('AppContent', () => { + it('should match a snapshot', () => { + expect(shallow()).toMatchSnapshot() + }) +}) diff --git a/packages/marketplace/src/components/ui/app-detail/app-header/app-header.tsx b/packages/marketplace/src/components/ui/app-detail/app-header/app-header.tsx new file mode 100644 index 0000000000..1076d3129e --- /dev/null +++ b/packages/marketplace/src/components/ui/app-detail/app-header/app-header.tsx @@ -0,0 +1,39 @@ +import * as React from 'react' +import { Grid, GridItem, H3 } from '@reapit/elements' + +import styles from '@/styles/pages/developer-app-detail.scss?mod' +import { AppDetailModel } from '@reapit/foundations-ts-definitions' + +export type AppHeaderProps = { + appDetailData: AppDetailModel & { + apiKey?: string | undefined + } + buttonGroup?: React.ReactNode +} + +const AppHeader: React.FC = ({ appDetailData, buttonGroup }) => { + const { media, name } = appDetailData + const appIcon = media?.filter(({ type }) => type === 'icon')[0] + + return ( + <> + + +
    + {name} +
    +
    + +

    {name}

    + {buttonGroup} +
    +
    + + ) +} + +export default AppHeader diff --git a/packages/marketplace/src/components/ui/app-detail/app-header/index.ts b/packages/marketplace/src/components/ui/app-detail/app-header/index.ts new file mode 100644 index 0000000000..15d550d48c --- /dev/null +++ b/packages/marketplace/src/components/ui/app-detail/app-header/index.ts @@ -0,0 +1,2 @@ +import AppHeader from './app-header' +export default AppHeader diff --git a/packages/marketplace/src/components/ui/client-app-detail/__test__/__snapshots__/client-app-install-confirmation.test.tsx.snap b/packages/marketplace/src/components/ui/client-app-detail/__test__/__snapshots__/client-app-install-confirmation.test.tsx.snap new file mode 100644 index 0000000000..6f29d9b407 --- /dev/null +++ b/packages/marketplace/src/components/ui/client-app-detail/__test__/__snapshots__/client-app-install-confirmation.test.tsx.snap @@ -0,0 +1,137 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ClientAppInstallConfirmation should match a snapshot 1`] = ` + + + + + + + Confirm + + + Cancel + + + } + title="Confirm Peter's Properties installation" + visible={true} + /> + + + + +`; diff --git a/packages/marketplace/src/components/ui/client-app-detail/__test__/__snapshots__/client-app-uninstall-confirmation.test.tsx.snap b/packages/marketplace/src/components/ui/client-app-detail/__test__/__snapshots__/client-app-uninstall-confirmation.test.tsx.snap new file mode 100644 index 0000000000..037c83f1cc --- /dev/null +++ b/packages/marketplace/src/components/ui/client-app-detail/__test__/__snapshots__/client-app-uninstall-confirmation.test.tsx.snap @@ -0,0 +1,164 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ClientAppUninstallConfirmation renderUninstallConfirmationModalFooter should match snapshot 1`] = ` +
    + + Confirm + + + Cancel + +
    +`; + +exports[`ClientAppUninstallConfirmation should match a snapshot 1`] = ` + + + + + + + Confirm + + + Cancel + + + } + title="Confirm Peter's Properties installation" + visible={true} + /> + + + + + +`; diff --git a/packages/marketplace/src/components/ui/client-app-detail/__test__/client-app-install-confirmation.test.tsx b/packages/marketplace/src/components/ui/client-app-detail/__test__/client-app-install-confirmation.test.tsx new file mode 100644 index 0000000000..fd05f09677 --- /dev/null +++ b/packages/marketplace/src/components/ui/client-app-detail/__test__/client-app-install-confirmation.test.tsx @@ -0,0 +1,103 @@ +import * as React from 'react' +import * as ReactRedux from 'react-redux' +import { mount } from 'enzyme' +import configureStore from 'redux-mock-store' +import { appDetailDataStub } from '@/sagas/__stubs__/app-detail' +import { MemoryRouter } from 'react-router' +import ClientAppInstallConfirmation, { + ClientAppInstallConfirmationProps, + handleInstallButtonClick, + handleInstallAppSuccessCallback, + handleSuccessAlertButtonClick, + handleSuccessAlertMessageAfterClose, +} from '../client-app-install-confirmation' +import { appInstallationsRequestInstall } from '@/actions/app-installations' +import { clientFetchAppDetail } from '@/actions/client' +import routes from '@/constants/routes' +import Routes from '@/constants/routes' +import appState from '@/reducers/__stubs__/app-state' + +const mockProps: ClientAppInstallConfirmationProps = { + appDetailData: appDetailDataStub.data, + visible: true, + closeInstallConfirmationModal: jest.fn(), +} + +describe('ClientAppInstallConfirmation', () => { + let store + let spyDispatch + const appId = mockProps.appDetailData?.id || '' + const clientId = appState.auth.loginSession?.loginIdentity.clientId || '' + beforeEach(() => { + /* mocking store */ + const mockStore = configureStore() + store = mockStore(appState) + /* mocking useDispatch on our mock store */ + spyDispatch = jest.spyOn(ReactRedux, 'useDispatch').mockImplementation(() => store.dispatch) + }) + it('should match a snapshot', () => { + expect( + mount( + + + + + , + ), + ).toMatchSnapshot() + }) + describe('handleInstallButtonClick', () => { + it('should run correctly', () => { + const mockFunction = jest.fn() + const fn = handleInstallButtonClick( + appId, + clientId, + spyDispatch, + mockFunction, + mockProps.closeInstallConfirmationModal, + ) + fn() + expect(spyDispatch).toBeCalledWith( + appInstallationsRequestInstall({ + appId, + callback: expect.any(Function), + }), + ) + }) + }) + describe('handleInstallAppSuccessCallback', () => { + it('should run correctly', () => { + const mockFunction = jest.fn() + const fn = handleInstallAppSuccessCallback( + appId, + clientId, + spyDispatch, + mockFunction, + mockProps.closeInstallConfirmationModal, + ) + fn() + expect(spyDispatch).toBeCalledWith( + clientFetchAppDetail({ + id: appId, + clientId, + }), + ) + expect(mockProps.closeInstallConfirmationModal).toBeCalled() + expect(mockFunction).toBeCalledWith(true) + }) + }) + describe('handleSuccessAlertButtonClick', () => { + const history = { + replace: jest.fn(), + } as any + const fn = handleSuccessAlertButtonClick(history) + fn() + expect(history.replace).toBeCalledWith(routes.CLIENT) + }) + describe('handleSuccessAlertMessageAfterClose', () => { + const mockFunction = jest.fn() + const fn = handleSuccessAlertMessageAfterClose(mockFunction) + fn() + expect(mockFunction).toBeCalledWith(false) + }) +}) diff --git a/packages/marketplace/src/components/ui/client-app-detail/__test__/client-app-uninstall-confirmation.test.tsx b/packages/marketplace/src/components/ui/client-app-detail/__test__/client-app-uninstall-confirmation.test.tsx new file mode 100644 index 0000000000..ad75329955 --- /dev/null +++ b/packages/marketplace/src/components/ui/client-app-detail/__test__/client-app-uninstall-confirmation.test.tsx @@ -0,0 +1,126 @@ +import * as React from 'react' +import * as ReactRedux from 'react-redux' +import { mount, shallow } from 'enzyme' +import configureStore from 'redux-mock-store' +import { MemoryRouter } from 'react-router' +import { appDetailDataStub } from '@/sagas/__stubs__/app-detail' +import { appInstallationsRequestUninstall } from '@/actions/app-installations' +import { clientFetchAppDetail } from '@/actions/client' +import routes from '@/constants/routes' +import ClientAppUninstallConfirmation, { + ClientAppUninstallConfirmationProps, + onUninstallButtonClick, + handleUninstallAppSuccessCallback, + handleSuccessAlertButtonClick, + handleSuccessAlertMessageAfterClose, + renderUninstallConfirmationModalFooter, +} from '../client-app-uninstall-confirmation' +import Routes from '@/constants/routes' +import appState from '@/reducers/__stubs__/app-state' + +const mockProps: ClientAppUninstallConfirmationProps = { + appDetailData: appDetailDataStub.data, + visible: true, + closeUninstallConfirmationModal: jest.fn(), +} + +describe('ClientAppUninstallConfirmation', () => { + let store + let spyDispatch + const appId = mockProps.appDetailData?.id || '' + const installationId = mockProps.appDetailData?.installationId || '' + const clientId = appState.auth.loginSession?.loginIdentity.clientId || '' + beforeEach(() => { + /* mocking store */ + const mockStore = configureStore() + store = mockStore(appState) + /* mocking useDispatch on our mock store */ + spyDispatch = jest.spyOn(ReactRedux, 'useDispatch').mockImplementation(() => store.dispatch) + }) + it('should match a snapshot', () => { + expect( + mount( + + + + + , + ), + ).toMatchSnapshot() + }) + describe('onUninstallButtonClick', () => { + it('should run correctly', () => { + const mockFunction = jest.fn() + const fn = onUninstallButtonClick( + appId, + clientId, + installationId, + spyDispatch, + mockFunction, + mockProps.closeUninstallConfirmationModal, + ) + fn() + expect(spyDispatch).toBeCalledWith( + appInstallationsRequestUninstall({ + appId, + installationId, + terminatedReason: 'User uninstall', + callback: expect.any(Function), + }), + ) + }) + }) + describe('handleUninstallAppSuccessCallback', () => { + it('should run correctly', () => { + const mockFunction = jest.fn() + const fn = handleUninstallAppSuccessCallback( + appId, + clientId, + spyDispatch, + mockFunction, + mockProps.closeUninstallConfirmationModal, + ) + fn() + expect(spyDispatch).toBeCalledWith( + clientFetchAppDetail({ + id: appId, + clientId, + }), + ) + expect(mockProps.closeUninstallConfirmationModal).toBeCalled() + expect(mockFunction).toBeCalledWith(true) + }) + }) + describe('handleSuccessAlertButtonClick', () => { + const history = { + replace: jest.fn(), + } as any + const fn = handleSuccessAlertButtonClick(history) + fn() + expect(history.replace).toBeCalledWith(routes.CLIENT) + }) + describe('handleSuccessAlertMessageAfterClose', () => { + const mockFunction = jest.fn() + const fn = handleSuccessAlertMessageAfterClose(mockFunction) + fn() + expect(mockFunction).toBeCalledWith(false) + }) + describe('renderUninstallConfirmationModalFooter', () => { + it('should match snapshot', () => { + const wrapper = shallow( +
    + {renderUninstallConfirmationModalFooter( + false, + appId, + clientId, + installationId, + spyDispatch, + jest.fn(), + jest.fn(), + )} +
    , + ) + expect(wrapper).toMatchSnapshot() + }) + }) +}) diff --git a/packages/marketplace/src/components/ui/client-app-detail/client-app-install-confirmation.tsx b/packages/marketplace/src/components/ui/client-app-detail/client-app-install-confirmation.tsx new file mode 100644 index 0000000000..116296be2d --- /dev/null +++ b/packages/marketplace/src/components/ui/client-app-detail/client-app-install-confirmation.tsx @@ -0,0 +1,173 @@ +import * as React from 'react' +import { useHistory } from 'react-router' +import { History } from 'history' +import { useDispatch, useSelector } from 'react-redux' +import { AppDetailModel } from '@reapit/foundations-ts-definitions' +import appPermissionContentStyles from '@/styles/pages/app-permission-content.scss?mod' +import { Button, Modal } from '@reapit/elements' +import { appInstallationsRequestInstall } from '@/actions/app-installations' +import { clientFetchAppDetail } from '@/actions/client' +import { Dispatch } from 'redux' +import CallToAction from '../call-to-action' +import { selectClientId } from '@/selector/client' +import routes from '@/constants/routes' +import { selectInstallationFormState } from '@/selector/installations' + +export type ClientAppInstallConfirmationProps = { + appDetailData?: AppDetailModel + visible: boolean + closeInstallConfirmationModal: () => void +} + +export const handleInstallAppSuccessCallback = ( + appId: string, + clientId: string, + dispatch: Dispatch, + setIsSuccessAlertVisible: (isVisible: boolean) => void, + closeInstallConfirmationModal: () => void, +) => { + return () => { + dispatch( + clientFetchAppDetail({ + id: appId, + clientId, + }), + ) + closeInstallConfirmationModal() + setIsSuccessAlertVisible(true) + } +} + +export const handleInstallButtonClick = ( + appId: string, + clientId: string, + dispatch: Dispatch, + setIsSuccessAlertVisible: (isVisible: boolean) => void, + closeInstallConfirmationModal: () => void, +) => { + return () => { + dispatch( + appInstallationsRequestInstall({ + appId, + callback: handleInstallAppSuccessCallback( + appId, + clientId, + dispatch, + setIsSuccessAlertVisible, + closeInstallConfirmationModal, + ), + }), + ) + } +} + +export const handleSuccessAlertButtonClick = (history: History) => { + return () => { + history.replace(routes.CLIENT) + } +} + +export const handleSuccessAlertMessageAfterClose = (setIsSuccessAlertVisible: (isVisible: boolean) => void) => { + return () => { + setIsSuccessAlertVisible(false) + } +} + +const ClientAppInstallConfirmation: React.FC = ({ + appDetailData, + visible, + closeInstallConfirmationModal, +}) => { + const history = useHistory() + const [isSuccessAlertVisible, setIsSuccessAlertVisible] = React.useState(false) + const clientId = useSelector(selectClientId) + const installationFormState = useSelector(selectInstallationFormState) + const isSubmitting = installationFormState === 'SUBMITTING' + + const { name, id = '', scopes = [] } = appDetailData || {} + + const dispatch = useDispatch() + const onSuccessAlertButtonClick = React.useCallback(handleSuccessAlertButtonClick(history), [history]) + + return ( + <> + + + + + } + > + <> + {scopes.length ? ( +
    +

    + This action will install the app for ALL platform users. +
    + {name} requires the permissions below. By installing you are granting permission to your data. +

    +
      + {scopes.map(({ description, name }) => ( +
    • + {description} +
    • + ))} +
    +
    + ) : ( +

    This action will install the app for ALL platform users.

    + )} + +
    + {isSuccessAlertVisible && ( + + <> + + {name} has been successfully installed + + + + )} + + ) +} + +export default ClientAppInstallConfirmation diff --git a/packages/marketplace/src/components/ui/client-app-detail/client-app-uninstall-confirmation.tsx b/packages/marketplace/src/components/ui/client-app-detail/client-app-uninstall-confirmation.tsx new file mode 100644 index 0000000000..2315fca601 --- /dev/null +++ b/packages/marketplace/src/components/ui/client-app-detail/client-app-uninstall-confirmation.tsx @@ -0,0 +1,171 @@ +import * as React from 'react' +import { useHistory } from 'react-router' +import { History } from 'history' +import { Dispatch } from 'redux' +import { useDispatch, useSelector } from 'react-redux' +import { AppDetailModel } from '@reapit/foundations-ts-definitions' +import appPermissionContentStyles from '@/styles/pages/app-permission-content.scss?mod' +import { Button, Modal } from '@reapit/elements' +import { clientFetchAppDetail } from '@/actions/client' +import { appInstallationsRequestUninstall } from '@/actions/app-installations' +import CallToAction from '../call-to-action' +import { selectClientId } from '@/selector/client' +import { selectInstallationFormState } from '@/selector/installations' +import routes from '@/constants/routes' + +export type ClientAppUninstallConfirmationProps = { + appDetailData?: AppDetailModel + visible: boolean + closeUninstallConfirmationModal: () => void +} + +export const handleUninstallAppSuccessCallback = ( + appId: string, + clientId: string, + dispatch: Dispatch, + setIsSuccessAlertVisible: (isVisible: boolean) => void, + closeUninstallConfirmationModal: () => void, +) => { + return () => { + dispatch( + clientFetchAppDetail({ + id: appId, + clientId, + }), + ) + closeUninstallConfirmationModal() + setIsSuccessAlertVisible(true) + } +} + +export const onUninstallButtonClick = ( + appId: string, + clientId: string, + installationId: string, + dispatch: Dispatch, + setIsSuccessAlertVisible: (isVisible: boolean) => void, + closeUninstallConfirmationModal: () => void, +) => { + return () => { + dispatch( + appInstallationsRequestUninstall({ + appId, + installationId, + terminatedReason: 'User uninstall', + callback: handleUninstallAppSuccessCallback( + appId, + clientId, + dispatch, + setIsSuccessAlertVisible, + closeUninstallConfirmationModal, + ), + }), + ) + } +} + +export const handleSuccessAlertButtonClick = (history: History) => { + return () => { + history.replace(routes.CLIENT) + } +} + +export const handleSuccessAlertMessageAfterClose = (setIsSuccessAlertVisible: (isVisible: boolean) => void) => { + return () => { + setIsSuccessAlertVisible(false) + } +} + +export const renderUninstallConfirmationModalFooter = ( + isSubmitting: boolean, + id: string, + clientId: string, + installationId: string, + dispatch: Dispatch, + setIsSuccessAlertVisible: (isVisible: boolean) => void, + closeUninstallConfirmationModal: () => void, +) => { + return ( + <> + + + + ) +} + +const ClientAppUninstallConfirmation: React.FC = ({ + appDetailData, + visible, + closeUninstallConfirmationModal, +}) => { + const [isSuccessAlertVisible, setIsSuccessAlertVisible] = React.useState(false) + const history = useHistory() + const clientId = useSelector(selectClientId) + const installationFormState = useSelector(selectInstallationFormState) + const isSubmitting = installationFormState === 'SUBMITTING' + const { name, id = '', installationId = '' } = appDetailData || {} + const dispatch = useDispatch() + const onSuccessAlertButtonClick = React.useCallback(handleSuccessAlertButtonClick(history), [history]) + + return ( + <> + + <>Are you sure you wish to uninstall {name}? This action will uninstall the app for ALL platform users + + + <> + + {name} has been successfully uninstalled + + + + + ) +} + +export default ClientAppUninstallConfirmation diff --git a/packages/marketplace/src/components/ui/developer-analytics/billing/service-chart/__tests__/__snapshots__/service-chart.test.tsx.snap b/packages/marketplace/src/components/ui/developer-analytics/billing/service-chart/__tests__/__snapshots__/service-chart.test.tsx.snap index 3506ce587d..5109e893c7 100644 --- a/packages/marketplace/src/components/ui/developer-analytics/billing/service-chart/__tests__/__snapshots__/service-chart.test.tsx.snap +++ b/packages/marketplace/src/components/ui/developer-analytics/billing/service-chart/__tests__/__snapshots__/service-chart.test.tsx.snap @@ -55,37 +55,153 @@ exports[`ServiceChart handleUseEffect should render Loader 1`] = ` `; exports[`ServiceChart should match a snapshot 1`] = ` - - - + + +
    +
    + +

    + Services +

    +
    + + + + + +
    +
    +
    +
    + `; diff --git a/packages/marketplace/src/components/ui/developer-analytics/billing/service-chart/__tests__/service-chart.test.tsx b/packages/marketplace/src/components/ui/developer-analytics/billing/service-chart/__tests__/service-chart.test.tsx index d081f17be8..18ad54708f 100644 --- a/packages/marketplace/src/components/ui/developer-analytics/billing/service-chart/__tests__/service-chart.test.tsx +++ b/packages/marketplace/src/components/ui/developer-analytics/billing/service-chart/__tests__/service-chart.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import * as ReactRedux from 'react-redux' import { ReduxState } from '@/types/core' -import { shallow } from 'enzyme' +import { shallow, mount } from 'enzyme' import configureStore from 'redux-mock-store' import { ServiceChart, @@ -31,7 +31,7 @@ describe('ServiceChart', () => { }) it('should match a snapshot', () => { expect( - shallow( + mount( , diff --git a/packages/marketplace/src/components/ui/developer-app-detail/__test__/__snapshots__/app-revision-comparision.test.tsx.snap b/packages/marketplace/src/components/ui/developer-app-detail/__test__/__snapshots__/app-revision-comparision.test.tsx.snap new file mode 100644 index 0000000000..0bfe86a801 --- /dev/null +++ b/packages/marketplace/src/components/ui/developer-app-detail/__test__/__snapshots__/app-revision-comparision.test.tsx.snap @@ -0,0 +1,276 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AdminRevisionModalInner should match a snapshot 1`] = ` +
    +
    +

    + Name +

    + +
    +
    +

    + Category +

    + +
    +
    +

    + Home page +

    + +
    +
    +

    + Launch URI +

    + +
    +
    +

    + Support Email +

    + +
    +
    +

    + Telephone +

    + +
    +
    +

    + Summary +

    + +
    +
    +

    + Description +

    + +
    +
    +

    + Redirect URIs +

    + +
    +
    +

    + Signout URIs +

    + +
    +
    +

    + Private Apps +

    + +
    +
    +

    + Integration Type +

    + +
    +
    +

    + Read data about developers +

    + +
    +
    +

    + Write data about developers +

    + +
    +
    +

    + Is listed +

    + +
    +
    +

    + Is Direct API +

    + +
    +
    +

    + media + +

    + +
    +
    +`; diff --git a/packages/marketplace/src/components/ui/developer-app-detail/__test__/__snapshots__/app-revision-modal.test.tsx.snap b/packages/marketplace/src/components/ui/developer-app-detail/__test__/__snapshots__/app-revision-modal.test.tsx.snap new file mode 100644 index 0000000000..c3d35d46ee --- /dev/null +++ b/packages/marketplace/src/components/ui/developer-app-detail/__test__/__snapshots__/app-revision-modal.test.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ClientAppInstallConfirmation should match a snapshot 1`] = ` + + + +`; diff --git a/packages/marketplace/src/components/ui/developer-app-detail/__test__/__snapshots__/developer-app-detail-button-group.test.tsx.snap b/packages/marketplace/src/components/ui/developer-app-detail/__test__/__snapshots__/developer-app-detail-button-group.test.tsx.snap new file mode 100644 index 0000000000..ea8996da73 --- /dev/null +++ b/packages/marketplace/src/components/ui/developer-app-detail/__test__/__snapshots__/developer-app-detail-button-group.test.tsx.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ClientAppInstallConfirmation should match a snapshot 1`] = ` + + + +`; diff --git a/packages/marketplace/src/components/ui/developer-app-detail/__test__/app-revision-comparision.test.tsx b/packages/marketplace/src/components/ui/developer-app-detail/__test__/app-revision-comparision.test.tsx new file mode 100644 index 0000000000..2033646f20 --- /dev/null +++ b/packages/marketplace/src/components/ui/developer-app-detail/__test__/app-revision-comparision.test.tsx @@ -0,0 +1,389 @@ +import * as React from 'react' +import { shallow } from 'enzyme' + +import { + AppRevisionComparision, + AppRevisionComparisionProps, + isAppearInScope, + renderCheckboxesDiff, + getChangedMediaList, + mapIntegrationIdArrayToNameArray, +} from '../app-revision-comparision' +import { appDetailDataStub } from '@/sagas/__stubs__/app-detail' +import { revisionDetailDataStub } from '@/sagas/__stubs__/revision-detail' +import { appPermissionStub } from '@/sagas/__stubs__/app-permission' +import { integrationTypesStub } from '@/sagas/__stubs__/integration-types' + +const props = (loading: boolean, error: boolean): AppRevisionComparisionProps => ({ + appDetailData: appDetailDataStub.data, + revisionDetailState: { + loading, + error, + revisionDetailData: { + data: revisionDetailDataStub.data, + scopes: appPermissionStub, + desktopIntegrationTypes: integrationTypesStub, + }, + approveFormState: 'PENDING', + declineFormState: 'PENDING', + }, +}) + +describe('AdminRevisionModalInner', () => { + it('should match a snapshot', () => { + expect(shallow()).toMatchSnapshot() + }) +}) + +describe('isAppearInScope', () => { + it('should return true', () => { + const input = 'Marketplace/developers.read' + const output = true + const result = isAppearInScope(input, appPermissionStub) + expect(result).toEqual(output) + }) + it('should return false when cannot find permission', () => { + const input = 'Marketplace/developers.test' + const output = false + const result = isAppearInScope(input, appPermissionStub) + expect(result).toEqual(output) + }) + it('should return false when !nameNeedToFind || scopes.length === 0', () => { + const input = undefined + const output = false + const result = isAppearInScope(input, []) + expect(result).toEqual(output) + }) + it('should return false when !nameNeedToFind', () => { + const input = undefined + const output = false + const result = isAppearInScope(input, appPermissionStub) + expect(result).toEqual(output) + }) + it('should return false when scopes.length === 0', () => { + const input = 'Marketplace/developers.test' + const output = false + const result = isAppearInScope(input, []) + expect(result).toEqual(output) + }) +}) + +// scopes checkboxes +describe('renderCheckboxesDiff', () => { + it('should render checkboxes', () => { + const scopes = [ + ...appPermissionStub, + { + name: 'Marketplace/developers.test', + description: 'Test data about developers', + }, + ] + const checkboxes = renderCheckboxesDiff({ + scopes, + appScopes: appDetailDataStub.data.scopes, + revisionScopes: scopes, + }) + expect(checkboxes).toHaveLength(3) + }) +}) + +describe('renderAdditionalCheckboxes', () => { + it('should render Is Listed checkbox', () => { + const wrapper = shallow() + expect(wrapper.find('h4[data-test="chkIsListed"]')).toHaveLength(1) + }) + + it('should render Is Direct API checkbox', () => { + const wrapper = shallow() + expect(wrapper.find('h4[data-test="chkIsDirectApi"]')).toHaveLength(1) + }) +}) + +describe('getChangedMediaList', () => { + const app = { + id: '3a4fbd46-fb26-495a-b7df-932a310f5842', + appId: '3d2c9bb6-fc76-4ba8-a4c0-71bde64824fc', + developerId: '7a96e6b2-3778-4118-9c9b-6450851e5608', + name: 'Grab', + summary: 'Grab Holdings Inc., formerly known as MyTeksi and GrabTaxi, is a Singapore based ridesharing company.', + description: + 'Grab Holdings Inc., formerly known as MyTeksi and GrabTaxi, is a Singapore based ridesharing company. ' + + 'In addition to transportation, the company offers food delivery and digital payments services via mobile app. ' + + 'The company was originally founded in Malaysia and later moved its headquarters to Singapore', + supportEmail: 'tanphamhaiduong@gmail.com', + telephone: '0978100461', + homePage: 'https://grab.com', + launchUri: 'https://grab.com', + isListed: true, + isDirectApi: false, + category: { + id: '1', + name: 'education', + description: 'apps for education', + }, + scopes: [ + { + name: 'agencyCloud/applicants.read', + description: 'Read applicants', + }, + { + name: 'agencyCloud/applicants.write', + description: 'Write applicants', + }, + { + name: 'agencyCloud/appointments.write', + description: 'Write appointments', + }, + { + name: 'agencyCloud/companies.write', + description: 'Write companies', + }, + { + name: 'agencyCloud/offers.read', + description: 'Read offers', + }, + { + name: 'agencyCloud/worksorders.write', + description: 'Write works orders', + }, + ], + media: [ + { + id: '0453b244-4db6-4fb3-afe1-313c7428e352', + uri: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-icon.png', + description: 'Application Icon', + type: 'icon', + order: 0, + }, + { + id: '9cf52f2d-44da-4b2d-8e36-7d89c7d01e85', + uri: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen1ImageUrl.png', + description: 'Application Image', + type: 'image', + order: 1, + }, + { + id: '59308d52-7e36-43c0-93b8-650f52942f17', + uri: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen3ImageUrl.jpg', + description: 'Application Image', + type: 'image', + order: 2, + }, + ], + links: [ + { + rel: 'self', + href: + 'http://dev.platformmarketplace.reapit.net/apps/3d2c9bb6-fc76-4ba8-a4c0-71bde64824fc/revisions/3a4fbd46-fb26-495a-b7df-932a310f5842', + action: 'GET', + }, + { + rel: 'app', + href: 'http://dev.platformmarketplace.reapit.net/apps/3d2c9bb6-fc76-4ba8-a4c0-71bde64824fc', + action: 'GET', + }, + { + rel: 'developer', + href: 'http://dev.platformmarketplace.reapit.net/developers/7a96e6b2-3778-4118-9c9b-6450851e5608', + action: 'GET', + }, + ], + } + const revision = { + id: '3d2c9bb6-fc76-4ba8-a4c0-71bde64824fc', + created: '2020-01-16T04:09:56', + developerId: '7a96e6b2-3778-4118-9c9b-6450851e5608', + externalId: '6l75fob6sf4maq1cq6nb184khn', + name: 'Grab', + summary: 'Grab Holdings Inc., formerly known as MyTeksi and GrabTaxi, is a Singapore based ridesharing company.', + description: + 'Grab Holdings Inc., formerly known as MyTeksi and GrabTaxi, is a Singapore based ridesharing company. ' + + 'In addition to transportation, the company offers food delivery and digital payments services via mobile app. ' + + 'The company was originally founded in Malaysia and later moved its headquarters to Singapore', + developer: 'Dwarves Foundation', + supportEmail: 'tanphamhaiduong@gmail.com', + telephone: '0978100461', + homePage: 'https://grab.com', + launchUri: 'https://grab.com', + authFlow: 'authorisationCode', + isListed: true, + isDirectApi: false, + isSandbox: false, + isFeatured: false, + pendingRevisions: true, + category: { + id: '1', + name: 'education', + description: 'apps for education', + }, + scopes: [ + { + name: 'agencyCloud/applicants.read', + description: 'Read applicants', + }, + { + name: 'agencyCloud/applicants.write', + description: 'Write applicants', + }, + { + name: 'agencyCloud/appointments.write', + description: 'Write appointments', + }, + { + name: 'agencyCloud/companies.write', + description: 'Write companies', + }, + { + name: 'agencyCloud/offers.read', + description: 'Read offers', + }, + { + name: 'agencyCloud/worksorders.write', + description: 'Write works orders', + }, + ], + media: [ + { + id: '0479ff17-bd16-46a9-bf12-1ef517a762d8', + uri: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-icon.png', + description: 'Application Icon', + type: 'icon', + order: 0, + }, + { + id: 'ad1291e4-861e-4fab-9c9a-822d5f095d64', + uri: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen1ImageUrl.png', + description: 'Application Image', + type: 'image', + order: 0, + }, + { + id: 'fe1ed265-c596-4b59-b492-49a1a51de1b2', + uri: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen3ImageUrl.jpg', + description: 'Application Image', + type: 'image', + order: 0, + }, + { + id: 'd8fc0d52-ee61-4ece-af02-f96ea8650af9', + uri: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen4ImageUrl.png', + description: 'Application Image', + type: 'image', + order: 0, + }, + ], + links: [ + { + rel: 'self', + href: 'http://dev.platformmarketplace.reapit.net/apps/3d2c9bb6-fc76-4ba8-a4c0-71bde64824fc', + action: 'GET', + }, + { + rel: 'developer', + href: 'http://dev.platformmarketplace.reapit.net/developers/7a96e6b2-3778-4118-9c9b-6450851e5608', + action: 'GET', + }, + { + rel: 'revisions', + href: 'http://dev.platformmarketplace.reapit.net/apps/3d2c9bb6-fc76-4ba8-a4c0-71bde64824fc/revisions', + action: 'GET', + }, + { + rel: 'installations', + href: 'http://dev.platformmarketplace.reapit.net/installations?AppIds=3d2c9bb6-fc76-4ba8-a4c0-71bde64824fc', + action: 'GET', + }, + ], + } + it('should run correctly', () => { + const result = getChangedMediaList({ app, revision }) + const output = [ + { + changedMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-icon.png', + currentMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-icon.png', + order: 0, + type: 'icon', + }, + { + changedMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen1ImageUrl.png', + currentMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen1ImageUrl.png', + order: 0, + type: 'image', + }, + { + changedMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen3ImageUrl.jpg', + currentMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen3ImageUrl.jpg', + order: 0, + type: 'image', + }, + { + changedMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen4ImageUrl.png', + currentMedia: undefined, + order: 0, + type: 'image', + }, + ] + expect(result).toEqual(output) + }) + + it('should run correctly', () => { + const result = getChangedMediaList({ app: revision, revision: app }) + const output = [ + { + changedMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-icon.png', + currentMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-icon.png', + order: 0, + type: 'icon', + }, + { + changedMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen1ImageUrl.png', + currentMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen1ImageUrl.png', + order: 0, + type: 'image', + }, + { + changedMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen3ImageUrl.jpg', + currentMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen3ImageUrl.jpg', + order: 0, + type: 'image', + }, + { + changedMedia: undefined, + currentMedia: 'https://reapit-dev-app-store-media.s3.eu-west-2.amazonaws.com/Grab-screen4ImageUrl.png', + order: 0, + type: 'image', + }, + ] + expect(result).toEqual(output) + }) +}) + +describe('mapIntegrationIdArrayToNameArray', () => { + it('should return correctly', () => { + const ids = ['IdCheck', 'PrpMarketing'] + const result = mapIntegrationIdArrayToNameArray(ids, integrationTypesStub.data) + expect(result).toEqual(['Identity Check', 'Property Marketing Information']) + }) + it('should return correctly with undefined', () => { + const result = mapIntegrationIdArrayToNameArray(undefined, undefined) + expect(result).toEqual([]) + }) + it('should return correctly with noname', () => { + const ids = ['IdCheck', 'PrpMarketing'] + const desktopIntegrationTypesArray = [ + { + id: 'IdCheck', + description: 'Replaces the standard ID check screen', + url: 'https://foundations-documentation.reapit.cloud/api/desktop-api#id-check', + }, + { + id: 'PrpMarketing', + name: 'Property Marketing Information', + description: 'Replaces the standard property marketing screen', + url: 'https://foundations-documentation.reapit.cloud/api/desktop-api#property-marketing-information', + }, + ] + const result = mapIntegrationIdArrayToNameArray(ids, desktopIntegrationTypesArray) + expect(result).toEqual(['Property Marketing Information']) + }) +}) diff --git a/packages/marketplace/src/components/ui/developer-app-detail/__test__/app-revision-modal.test.tsx b/packages/marketplace/src/components/ui/developer-app-detail/__test__/app-revision-modal.test.tsx new file mode 100644 index 0000000000..a0610ad889 --- /dev/null +++ b/packages/marketplace/src/components/ui/developer-app-detail/__test__/app-revision-modal.test.tsx @@ -0,0 +1,96 @@ +import * as React from 'react' +import * as ReactRedux from 'react-redux' +import { shallow } from 'enzyme' +import configureStore from 'redux-mock-store' + +import { appDetailDataStub } from '@/sagas/__stubs__/app-detail' +import AppRevisionModal, { + AppRevisionModalProps, + handleUseEffectToFetchAppRevisions, + handleUseEffectToFetchAppRevisionDetail, + handleCancelPendingRevisionsSuccessCallback, +} from '../app-revision-modal' +import { revisionsRequestData } from '@/actions/revisions' +import { revisionDetailRequestData } from '@/actions/revision-detail' +import { developerFetchAppDetail } from '@/actions/developer' +import appState from '@/reducers/__stubs__/app-state' + +const mockProps: AppRevisionModalProps = { + appDetailState: { + data: appDetailDataStub.data, + isAppDetailLoading: false, + }, + visible: true, + appId: appDetailDataStub.data.id || '', + afterClose: jest.fn(), +} + +describe('ClientAppInstallConfirmation', () => { + let store + let spyDispatch + const appId = mockProps.appDetailState.data?.id || '' + beforeEach(() => { + /* mocking store */ + const mockStore = configureStore() + store = mockStore(appState) + /* mocking useDispatch on our mock store */ + spyDispatch = jest.spyOn(ReactRedux, 'useDispatch').mockImplementation(() => store.dispatch) + }) + it('should match a snapshot', () => { + expect( + shallow( + + + , + ), + ).toMatchSnapshot() + }) + describe('handleUseEffectToFetchAppRevisions', () => { + it('should run correctly when the modal is visible', () => { + const fn = handleUseEffectToFetchAppRevisions(appId, true, spyDispatch) + fn() + if (appId) { + expect(spyDispatch).toBeCalledWith( + revisionsRequestData({ + appId, + }), + ) + } + }) + }) + describe('handleUseEffectToFetchAppRevisionDetail', () => { + it('should run correctly when the modal is visible', () => { + const revisions = appState.revisions.revisions?.data + const appRevisionId = revisions && revisions[0].id + const fn = handleUseEffectToFetchAppRevisionDetail(appId, true, spyDispatch, appRevisionId) + fn() + if (appId && appRevisionId) { + expect(spyDispatch).toBeCalledWith( + revisionDetailRequestData({ + appId, + appRevisionId, + }), + ) + } + }) + }) + describe('handleCancelPendingRevisionsSuccessCallback', () => { + it('should run correctly when the modal is visible', () => { + const setIsConfirmationModalVisible = jest.fn() + const clientId = appState.auth.loginSession?.loginIdentity.clientId || '' + const fn = handleCancelPendingRevisionsSuccessCallback( + appId, + clientId, + spyDispatch, + setIsConfirmationModalVisible, + ) + fn() + expect(spyDispatch).toBeCalledWith( + developerFetchAppDetail({ + id: appId, + clientId, + }), + ) + }) + }) +}) diff --git a/packages/marketplace/src/components/ui/developer-app-detail/__test__/developer-app-detail-button-group.test.tsx b/packages/marketplace/src/components/ui/developer-app-detail/__test__/developer-app-detail-button-group.test.tsx new file mode 100644 index 0000000000..a435b96692 --- /dev/null +++ b/packages/marketplace/src/components/ui/developer-app-detail/__test__/developer-app-detail-button-group.test.tsx @@ -0,0 +1,82 @@ +import * as React from 'react' +import * as ReactRedux from 'react-redux' +import { shallow } from 'enzyme' +import configureStore from 'redux-mock-store' +import DeveloperAppDetailButtonGroup, { + DeveloperAppDetailButtonGroupProps, + handleEditDetailButtonClick, + handlenDeleteAppButtonClick, + handlePendingRevisionButtonClick, + handleInstallationButtonClick, +} from '../developer-app-detail-button-group' +import { appDetailDataStub } from '@/sagas/__stubs__/app-detail' +import { removeAuthenticationCode } from '@/actions/app-detail' +import routes from '@/constants/routes' +import appState from '@/reducers/__stubs__/app-state' + +const mockProps: DeveloperAppDetailButtonGroupProps = { + appDetailState: { + data: appDetailDataStub.data, + isAppDetailLoading: false, + }, + setIsInstallationsModalOpen: jest.fn(), + setIsAppRevisionComparisionModalOpen: jest.fn(), + setIsDeleteModalOpen: jest.fn(), +} + +describe('ClientAppInstallConfirmation', () => { + let store + let spyDispatch + const appId = mockProps.appDetailState.data?.id || '' + beforeEach(() => { + /* mocking store */ + const mockStore = configureStore() + store = mockStore(appState) + /* mocking useDispatch on our mock store */ + spyDispatch = jest.spyOn(ReactRedux, 'useDispatch').mockImplementation(() => store.dispatch) + }) + it('should match a snapshot', () => { + expect( + shallow( + + + , + ), + ).toMatchSnapshot() + }) + describe('handleEditDetailButtonClick', () => { + it('should run correctly', () => { + const history = { + push: jest.fn(), + } + const fn = handleEditDetailButtonClick(history, spyDispatch, appId) + fn() + expect(spyDispatch).toBeCalledWith(removeAuthenticationCode()) + expect(history.push).toBeCalledWith(`${routes.DEVELOPER_MY_APPS}/${appId}/edit`) + }) + }) + describe('handlenDeleteAppButtonClick', () => { + it('should run correctly', () => { + const setIsDeleteModalOpen = jest.fn() + const fn = handlenDeleteAppButtonClick(setIsDeleteModalOpen) + fn() + expect(setIsDeleteModalOpen).toBeCalledWith(true) + }) + }) + describe('handlePendingRevisionButtonClick', () => { + it('should run correctly', () => { + const setIsAppRevisionComparisionModalOpen = jest.fn() + const fn = handlePendingRevisionButtonClick(setIsAppRevisionComparisionModalOpen) + fn() + expect(setIsAppRevisionComparisionModalOpen).toBeCalledWith(true) + }) + }) + describe('handleInstallationButtonClick', () => { + it('should run correctly', () => { + const setIsInstallationsModalOpen = jest.fn() + const fn = handleInstallationButtonClick(setIsInstallationsModalOpen) + fn() + expect(setIsInstallationsModalOpen).toBeCalledWith(true) + }) + }) +}) diff --git a/packages/marketplace/src/components/ui/developer-app-detail/app-revision-comparision.tsx b/packages/marketplace/src/components/ui/developer-app-detail/app-revision-comparision.tsx new file mode 100644 index 0000000000..a13286b18a --- /dev/null +++ b/packages/marketplace/src/components/ui/developer-app-detail/app-revision-comparision.tsx @@ -0,0 +1,212 @@ +import * as React from 'react' +import { RevisionDetailState } from '@/reducers/revision-detail' +import { AppRevisionModel, MediaModel, ScopeModel } from '@reapit/foundations-ts-definitions' +import DiffMedia from '@/components/ui/diff-media' +import { AppDetailModel } from '@/types/marketplace-api-schema' +import { DesktopIntegrationTypeModel, PagedResultDesktopIntegrationTypeModel_ } from '@/actions/app-integration-types' +import DiffCheckbox from '../diff-checkbox' +import DiffViewer from '../diff-viewer' +import DiffRenderHTML from '../diff-render-html' +import { AppDetailData } from '@/reducers/developer' + +export type AppRevisionComparisionProps = { + revisionDetailState: RevisionDetailState + appDetailData: AppDetailData | null +} + +export type DiffMediaModel = { + currentMedia?: string + changedMedia?: string + order: number + type: string +} + +const diffStringList: { [k in keyof AppRevisionModel]: string } = { + name: 'Name', + category: 'Category', + homePage: 'Home page', + launchUri: 'Launch URI', + supportEmail: 'Support Email', + telephone: 'Telephone', + summary: 'Summary', + description: 'Description', + redirectUris: 'Redirect URIs', + signoutUris: 'Signout URIs', + limitToClientIds: 'Private Apps', + desktopIntegrationTypeIds: 'Integration Type', +} + +export const isAppearInScope = (nameNeedToFind: string | undefined, scopes: ScopeModel[] = []): boolean => { + if (!nameNeedToFind || scopes.length === 0) { + return false + } + const result = scopes.find((item: ScopeModel) => { + return item.name === nameNeedToFind + }) + return !!result +} + +export const renderCheckboxesDiff = ({ + scopes, + appScopes, + revisionScopes, +}: { + scopes: ScopeModel[] + appScopes: ScopeModel[] | undefined + revisionScopes: ScopeModel[] | undefined +}) => { + return scopes.map((scope: ScopeModel) => { + const isCheckedInAppDetail = isAppearInScope(scope.name, appScopes) + const isCheckedInRevision = isAppearInScope(scope.name, revisionScopes) + return ( +
    +

    {scope.description}

    + +
    + ) + }) +} + +export const getChangedMediaList = ({ app, revision }): DiffMediaModel[] => { + const { media: revisionMedia } = revision + const { media: appMedia } = app + if (!revisionMedia || !appMedia) { + return [ + { + order: 0, + type: 'media', + }, + ] + } + // Check the longest array to compare + const isNewMediaMoreItemThanOldOne = revisionMedia.length >= appMedia.length + if (isNewMediaMoreItemThanOldOne) { + return revisionMedia.map((revisionMedia: MediaModel, index: number) => ({ + changedMedia: revisionMedia?.uri, + currentMedia: appMedia[index]?.uri, + order: revisionMedia?.order || 0, + type: revisionMedia?.type || '', + })) + } + + return appMedia.map((currentMedia: MediaModel, index: number) => ({ + changedMedia: revisionMedia[index]?.uri, + currentMedia: currentMedia?.uri, + order: currentMedia?.order || 0, + type: currentMedia?.type || 'media', + })) +} + +export const mapIntegrationIdArrayToNameArray = ( + desktopIntegrationTypeIds?: string[], + desktopIntegrationTypesArray?: DesktopIntegrationTypeModel[], +): string[] => { + if (!desktopIntegrationTypeIds || !desktopIntegrationTypesArray) { + return [] + } + const result = desktopIntegrationTypeIds.map((id: string) => { + const matchedIntegration = desktopIntegrationTypesArray.find( + (integration: DesktopIntegrationTypeModel) => integration.id === id, + ) + return matchedIntegration?.name ?? '' + }) + const filteredResult = result.filter(r => r) + return filteredResult +} + +export type RenderDiffContentParams = { + key: string + revision: AppRevisionModel + app: AppDetailModel & { desktopIntegrationTypeIds?: string[] } + desktopIntegrationTypes: PagedResultDesktopIntegrationTypeModel_ +} + +export const renderDiffContent = ({ key, revision, app, desktopIntegrationTypes }: RenderDiffContentParams) => { + if (key === 'category') { + return ( + + ) + } + if (key === 'description') { + return + } + if (key === 'desktopIntegrationTypeIds') { + const oldIntegrationTypeArray = mapIntegrationIdArrayToNameArray( + app.desktopIntegrationTypeIds, + desktopIntegrationTypes.data, + ) + const newIntegrationTypeArray = mapIntegrationIdArrayToNameArray( + revision.desktopIntegrationTypeIds, + desktopIntegrationTypes.data, + ) + const sortedOldArray = [...oldIntegrationTypeArray].sort() + const sortedNewArray = [...newIntegrationTypeArray].sort() + return ( + + ) + } + if (['redirectUris', 'signoutUris', 'limitToClientIds', 'desktopIntegrationTypeIds'].includes(key)) { + const currentString = Array.isArray(app[key]) ? app[key].join(' ') : '' + const changedString = Array.isArray(revision[key]) ? revision[key].join(' ') : '' + return + } + return +} + +export const AppRevisionComparision: React.FC = ({ + revisionDetailState, + appDetailData, +}) => { + if (!revisionDetailState.revisionDetailData || !appDetailData) { + return null + } + const { data: revision, scopes, desktopIntegrationTypes } = revisionDetailState.revisionDetailData + + return ( +
    + {Object.keys(diffStringList).map(key => { + return ( +
    +

    {diffStringList[key]}

    + {renderDiffContent({ key, app: appDetailData, desktopIntegrationTypes, revision })} +
    + ) + })} + {renderCheckboxesDiff({ scopes, appScopes: appDetailData.scopes, revisionScopes: revision.scopes })} +
    +

    + Is listed +

    + +
    +
    +

    + Is Direct API +

    + +
    + {getChangedMediaList({ app: appDetailData, revision }).map(media => ( +
    +

    + {media.type} {media.order > 0 && {media.order}} +

    + +
    + ))} +
    + ) +} + +export default AppRevisionComparision diff --git a/packages/marketplace/src/components/ui/developer-app-detail/app-revision-modal.tsx b/packages/marketplace/src/components/ui/developer-app-detail/app-revision-modal.tsx new file mode 100644 index 0000000000..154aa1ff75 --- /dev/null +++ b/packages/marketplace/src/components/ui/developer-app-detail/app-revision-modal.tsx @@ -0,0 +1,187 @@ +import * as React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { selectAppRevisions, selectAppRevisionDetail } from '@/selector/app-revisions' +import { Modal, Button, Loader } from '@reapit/elements' +import AppRevisionComparision from './app-revision-comparision' +import CallToAction from '../call-to-action' +import { DeveloperAppDetailState } from '@/reducers/developer' +import { revisionsRequestData } from '@/actions/revisions' +import { revisionDetailRequestData, declineRevision } from '@/actions/revision-detail' +import { Dispatch } from 'redux' +import { LoginIdentity } from '@reapit/cognito-auth' +import { selectLoginIdentity } from '@/selector/auth' +import { selectClientId } from '@/selector/client' +import { developerFetchAppDetail } from '@/actions/developer' + +export type AppRevisionModalProps = { + visible: boolean + appId: string + appDetailState: DeveloperAppDetailState + afterClose: () => void +} + +export const handleUseEffectToFetchAppRevisions = (appId: string, visible: boolean, dispatch: Dispatch) => { + return () => { + if (appId && visible) { + dispatch( + revisionsRequestData({ + appId, + }), + ) + } + } +} + +export const handleUseEffectToFetchAppRevisionDetail = ( + appId: string, + visible: boolean, + dispatch: Dispatch, + appRevisionId?: string, +) => { + return () => { + if (appId && appRevisionId && visible) { + dispatch( + revisionDetailRequestData({ + appId, + appRevisionId, + }), + ) + } + } +} + +export const handleCancelPendingRevisionsSuccessCallback = ( + appId: string, + clientId: string, + dispatch: Dispatch, + setIsConfirmationModalVisible: (isVisible: boolean) => void, +) => { + return () => { + dispatch( + developerFetchAppDetail({ + id: appId, + clientId, + }), + ) + setIsConfirmationModalVisible(false) + } +} + +export const handleCancelPendingRevisionsButtonClick = ( + appId: string, + clientId: string, + dispatch: Dispatch, + setIsConfirmationModalVisible: (isVisible: boolean) => void, + appRevisionId?: string, + loginIdentity?: LoginIdentity, +) => { + return () => { + if (!appRevisionId || !loginIdentity) { + return + } + const { name, email } = loginIdentity + dispatch( + declineRevision({ + appId, + appRevisionId, + name, + email, + rejectionReason: 'Developer Cancelled', + callback: handleCancelPendingRevisionsSuccessCallback(appId, clientId, dispatch, setIsConfirmationModalVisible), + }), + ) + } +} + +const AppRevisionModal: React.FC = ({ appId, visible, appDetailState, afterClose }) => { + const [isConfirmationModalVisible, setIsConfirmationModalVisible] = React.useState(false) + const dispatch = useDispatch() + const revisions = useSelector(selectAppRevisions) + const appRevisionDetail = useSelector(selectAppRevisionDetail) + const loginIdentity = useSelector(selectLoginIdentity) + const clientId = useSelector(selectClientId) + + const revisionsData = revisions?.data + const latestAppRevisionId = revisionsData && revisionsData[0].id + const { declineFormState, revisionDetailData } = appRevisionDetail + const isDeclining = declineFormState === 'SUBMITTING' + const isDeclinedSuccessfully = declineFormState === 'SUCCESS' + let hasRevisionDetailData = false + if (revisionDetailData) { + hasRevisionDetailData = true + } + + React.useEffect(handleUseEffectToFetchAppRevisions(appId, visible, dispatch), [appId, visible, dispatch]) + + React.useEffect(handleUseEffectToFetchAppRevisionDetail(appId, visible, dispatch, latestAppRevisionId), [ + appId, + latestAppRevisionId, + visible, + dispatch, + ]) + + return ( + setIsConfirmationModalVisible(true)} + dataTest="revision-approve-button" + > + CANCEL PENDING REVISIONS + + } + > + <> + {!hasRevisionDetailData ? ( + + ) : ( + + )} + setIsConfirmationModalVisible(false)} + footerItems={ + <> + + + + } + > +

    Are you sure you wish to cancel any pending revisions for this App?

    +
    + + + + All pending revisions for this app have been cancelled. You can now use the ‘Edit Detail’ option to make any + additional changes as required. + + + +
    + ) +} + +export default AppRevisionModal diff --git a/packages/marketplace/src/components/ui/developer-app-detail/developer-app-detail-button-group.tsx b/packages/marketplace/src/components/ui/developer-app-detail/developer-app-detail-button-group.tsx new file mode 100644 index 0000000000..86e937386d --- /dev/null +++ b/packages/marketplace/src/components/ui/developer-app-detail/developer-app-detail-button-group.tsx @@ -0,0 +1,104 @@ +import * as React from 'react' +import { Dispatch } from 'redux' +import { useDispatch } from 'react-redux' +import { useHistory } from 'react-router' +import { removeAuthenticationCode } from '@/actions/app-detail' + +import { Grid, GridItem, Button } from '@reapit/elements' +import routes from '@/constants/routes' +import { DeveloperAppDetailState } from '@/reducers/developer' + +export type DeveloperAppDetailButtonGroupProps = { + appDetailState: DeveloperAppDetailState + setIsInstallationsModalOpen: (isVisible: boolean) => void + setIsAppRevisionComparisionModalOpen: (isVisible: boolean) => void + setIsDeleteModalOpen: (isVisible: boolean) => void +} + +export const handleEditDetailButtonClick = (history, dispatch: Dispatch, id?: string) => { + return () => { + if (id) { + dispatch(removeAuthenticationCode()) + history.push(`${routes.DEVELOPER_MY_APPS}/${id}/edit`) + } + } +} + +export const handlenDeleteAppButtonClick = (setIsDeleteModalOpen: (isVisible: boolean) => void) => { + return () => { + setIsDeleteModalOpen(true) + } +} + +export const handlePendingRevisionButtonClick = ( + setIsAppRevisionComparisionModalOpen: (isVisible: boolean) => void, +) => { + return () => { + setIsAppRevisionComparisionModalOpen(true) + } +} + +export const handleInstallationButtonClick = (setIsInstallationsModalOpen: (isVisible: boolean) => void) => { + return () => { + setIsInstallationsModalOpen(true) + } +} + +const DeveloperAppDetailButtonGroup: React.FC = ({ + appDetailState, + setIsAppRevisionComparisionModalOpen, + setIsDeleteModalOpen, + setIsInstallationsModalOpen, +}) => { + const { data } = appDetailState + const appId = data?.id || '' + const pendingRevisions = data?.pendingRevisions + + const history = useHistory() + const dispatch = useDispatch() + + const onInstallationButtonClick = React.useCallback(handleInstallationButtonClick(setIsInstallationsModalOpen), []) + const onPendingRevisionButtonClick = React.useCallback( + handlePendingRevisionButtonClick(setIsAppRevisionComparisionModalOpen), + [], + ) + const onEditDetailButtonClick = React.useCallback(handleEditDetailButtonClick(history, dispatch, appId), [ + history, + dispatch, + appId, + ]) + const onDeleteAppButtonClick = React.useCallback(handlenDeleteAppButtonClick(setIsDeleteModalOpen), []) + + return ( + + + + + + {pendingRevisions ? ( + + ) : ( + + )} + + + + + ) +} + +export default DeveloperAppDetailButtonGroup diff --git a/packages/marketplace/src/constants/action-types.ts b/packages/marketplace/src/constants/action-types.ts index 2ef3117862..83b5e73dc4 100644 --- a/packages/marketplace/src/constants/action-types.ts +++ b/packages/marketplace/src/constants/action-types.ts @@ -61,11 +61,15 @@ const ActionTypes = { HIDE_NOTIFICATION_MESSAGE: 'HIDE_NOTIFICATION_MESSAGE', // Client actions - CLIENT_REQUEST_DATA: 'CLIENT_REQUEST_DATA', - CLIENT_REQUEST_FAILURE: 'CLIENT_REQUEST_FAILURE', - CLIENT_LOADING: 'CLIENT_LOADING', - CLIENT_RECEIVE_DATA: 'CLIENT_RECEIVE_DATA', - CLIENT_CLEAR_DATA: 'CLIENT_CLEAR_DATA', + CLIENT_FETCH_APP_SUMMARY: 'CLIENT_FETCH_APP_SUMMARY', + CLIENT_FETCH_APP_SUMMARY_SUCCESS: 'CLIENT_FETCH_APP_SUMMARY_SUCCESS', + CLIENT_FETCH_APP_SUMMARY_FAILED: 'CLIENT_FETCH_APP_SUMMARY_FAILED', + CLIENT_CLEAR_APP_SUMMARY: 'CLIENT_CLEAR_APP_SUMMARY', + + // Client App Detail + CLIENT_FETCH_APP_DETAIL: 'CLIENT_FETCH_APP_DETAIL', + CLIENT_FETCH_APP_DETAIL_FAILED: 'CLIENT_FETCH_APP_DETAIL_FAILED', + CLIENT_FETCH_APP_DETAIL_SUCCESS: 'CLIENT_FETCH_APP_DETAIL_SUCCESS', // Installed apps actions INSTALLED_APPS_REQUEST_DATA: 'INSTALLED_APPS_REQUEST_DATA', @@ -98,6 +102,11 @@ const ActionTypes = { DEVELOPER_SET_FORM_STATE: 'DEVELOPER_SET_FORM_STATE', DEVELOPER_SHOW_MODAL: 'DEVELOPER_SHOW_MODAL', + // Client App Detail + DEVELOPER_FETCH_APP_DETAIL: 'DEVELOPER_FETCH_APP_DETAIL', + DEVELOPER_FETCH_APP_DETAIL_FAILED: 'DEVELOPER_FETCH_APP_DETAIL_FAILED', + DEVELOPER_FETCH_APP_DETAIL_SUCCESS: 'DEVELOPER_FETCH_APP_DETAIL_SUCCESS', + // App Detail actions APP_DETAIL_REQUEST_DATA: 'APP_DETAIL_REQUEST_DATA', APP_DETAIL_LOADING: 'APP_DETAIL_LOADING', diff --git a/packages/marketplace/src/constants/routes.ts b/packages/marketplace/src/constants/routes.ts index 706188fcec..1372889a84 100644 --- a/packages/marketplace/src/constants/routes.ts +++ b/packages/marketplace/src/constants/routes.ts @@ -1,5 +1,7 @@ const Routes = { CLIENT: '/client/apps', + CLIENT_APP_DETAIL: '/client/apps/:appid', + CLIENT_APP_DETAIL_MANAGE: '/client/apps/:appid/manage', CLIENT_WELCOME: '/client/welcome', INSTALLED_APPS: '/client/installed', INSTALLED_APPS_PAGINATE: '/client/installed/:page', @@ -9,9 +11,9 @@ const Routes = { DEVELOPER: '/developer', DEVELOPER_WELCOME: '/developer/welcome', DEVELOPER_MY_APPS: '/developer/apps', + DEVELOPER_APP_DETAIL: '/developer/apps/:appid', DEVELOPER_SWAGGER: '/developer/swagger', DEVELOPER_ELEMENTS: '/developer/elements', - DEVELOPER_MY_APPS_PAGINATE: '/developer/apps/:page', DEVELOPER_MY_APPS_EDIT: '/developer/apps/:appid/edit', DEVELOPER_API_DOCS: '/developer/api-docs', DEVELOPER_ANALYTICS: '/developer/analytics', diff --git a/packages/marketplace/src/core/__tests__/__snapshots__/router.tsx.snap b/packages/marketplace/src/core/__tests__/__snapshots__/router.tsx.snap index 0250dea8ad..7f37d71d60 100644 --- a/packages/marketplace/src/core/__tests__/__snapshots__/router.tsx.snap +++ b/packages/marketplace/src/core/__tests__/__snapshots__/router.tsx.snap @@ -131,6 +131,34 @@ exports[`Router should match a snapshot 1`] = ` fetcher={true} path="/client/apps" /> + + exact?: boolean fetcher?: boolean } diff --git a/packages/marketplace/src/core/router.tsx b/packages/marketplace/src/core/router.tsx index 01ef1a9e7e..3bbf2a1dc2 100644 --- a/packages/marketplace/src/core/router.tsx +++ b/packages/marketplace/src/core/router.tsx @@ -10,12 +10,15 @@ export const history = createBrowserHistory() const Login = React.lazy(() => import('../components/pages/login')) const Client = React.lazy(() => import('../components/pages/client')) +const ClientAppDetail = React.lazy(() => import('../components/pages/client-app-detail')) +const ClientAppDetailManage = React.lazy(() => import('../components/pages/client-app-detail-manage')) const ClientWelcomePage = React.lazy(() => import('../components/pages/client-welcome')) const InstalledApps = React.lazy(() => import('../components/pages/installed-apps')) const ClientSetting = React.lazy(() => import('../components/pages/settings/client-setting')) const MyApps = React.lazy(() => import('../components/pages/my-apps')) const Register = React.lazy(() => import('../components/pages/register')) const DeveloperHome = React.lazy(() => import('../components/pages/developer-home')) +const DeveloperAppDetail = React.lazy(() => import('../components/pages/developer-app-detail')) const DeveloperSubmitApp = React.lazy(() => import('../components/pages/developer-submit-app')) const AdminApprovalsPage = React.lazy(() => import('../components/pages/admin-approvals')) const AdminDevManagementPage = React.lazy(() => import('../components/pages/admin-dev-management')) @@ -53,6 +56,14 @@ const Router = () => { + + @@ -60,8 +71,8 @@ const Router = () => { diff --git a/packages/marketplace/src/core/store.ts b/packages/marketplace/src/core/store.ts index 6c8a482ed7..1cfb2437c7 100644 --- a/packages/marketplace/src/core/store.ts +++ b/packages/marketplace/src/core/store.ts @@ -29,6 +29,7 @@ import integrationTypes from '@/reducers/app-integration-types' import webhookEditReducer from '../reducers/webhook-edit-modal' import webhookSubscriptions from '@/reducers/webhook-subscriptions' import authSagas from '@/sagas/auth' +import appsSaga from '@/sagas/apps/apps' import clientSagas from '@/sagas/client' import appDetailSagas from '@/sagas/app-detail' import installedAppsSagas from '@/sagas/installed-apps' @@ -101,6 +102,7 @@ export class Store { static sagas = function*() { yield all([ fork(authSagas), + fork(appsSaga), fork(clientSagas), fork(installedAppsSagas), fork(appUsageStatsSagas), diff --git a/packages/marketplace/src/reducers/__stubs__/app-state.ts b/packages/marketplace/src/reducers/__stubs__/app-state.ts new file mode 100644 index 0000000000..959fd445b6 --- /dev/null +++ b/packages/marketplace/src/reducers/__stubs__/app-state.ts @@ -0,0 +1,219 @@ +import { ReduxState } from '@/types/core' + +const appState: ReduxState = { + client: { + appSummary: { + isAppSummaryLoading: false, + data: null, + error: null, + }, + appDetail: { + error: null, + data: null, + isAppDetailLoading: false, + }, + }, + installedApps: { + loading: false, + installedAppsData: null, + }, + myApps: { + loading: false, + myAppsData: null, + }, + developer: { + loading: false, + developerAppDetail: { + error: null, + data: null, + isAppDetailLoading: false, + }, + developerData: null, + formState: 'PENDING', + isVisible: false, + myIdentity: null, + billing: null, + isServiceChartLoading: true, + error: null, + isMonthlyBillingLoading: true, + monthlyBilling: null, + webhookPingTestStatus: null, + }, + auth: { + error: false, + loginSession: { + accessToken: 'testAccessToken', + accessTokenExpiry: 1581238260, + idToken: 'testIdToken', + idTokenExpiry: 1234428260, + refreshToken: 'testRefreshToken', + cognitoClientId: 'testCognitoClientId', + loginType: 'CLIENT', + userName: 'test@reapit.com', + mode: 'WEB', + loginIdentity: { + name: 'Test User', + email: 'test@reapit.com', + developerId: null, + clientId: 'testClientId', + adminId: null, + userCode: 'testUserCode', + }, + }, + isTermAccepted: false, + loginType: 'CLIENT', + refreshSession: { + refreshToken: 'testRefreshToken', + loginType: 'CLIENT', + userName: 'test@reapit.com', + mode: 'WEB', + cognitoClientId: 'testCognitoClientId', + authorizationCode: '', + redirectUri: '', + state: null, + }, + }, + appDetail: { + loading: false, + error: false, + appDetailData: null, + authentication: { + loading: false, + code: '', + }, + isStale: true, + }, + error: { + componentError: null, + serverError: null, + }, + submitApp: { + loading: false, + formState: 'PENDING', + submitAppData: null, + }, + submitRevision: { + formState: 'PENDING', + }, + adminApps: { + loading: false, + formState: 'PENDING', + adminAppsData: null, + }, + adminApprovals: { + loading: false, + adminApprovalsData: null, + }, + adminDevManagement: { + loading: false, + }, + developerSetStatus: { + formState: 'PENDING', + }, + revisionDetail: { + loading: false, + error: false, + revisionDetailData: null, + approveFormState: 'PENDING', + declineFormState: 'PENDING', + }, + revisions: { + loading: false, + revisions: null, + }, + appDetailModal: 'VIEW_DETAIL_BROWSE', + appDelete: { + formState: 'PENDING', + }, + appCategories: { + data: [], + pageNumber: 1, + pageSize: 12, + pageCount: 1, + totalCount: 0, + }, + settings: { + loading: true, + developerInfomation: null, + }, + installations: { + formState: 'PENDING', + loading: false, + loadingFilter: false, + installationsAppData: null, + installationsFilteredAppData: null, + }, + appUsageStats: { + loading: false, + appUsageStatsData: null, + }, + noticationMessage: { + visible: false, + variant: '', + message: '', + }, + adminStats: { + loading: false, + result: { + data: [], + totalCount: 0, + }, + }, + appHttpTraffic: { + perDayLoading: false, + trafficEvents: null, + }, + desktopIntegrationTypes: { + data: [], + pageNumber: 0, + pageSize: 0, + pageCount: 0, + totalCount: 0, + }, + webhookEdit: { + loading: false, + modalType: '', + subcriptionCustomers: { + data: [], + pageNumber: 0, + pageSize: 0, + pageCount: 0, + totalCount: 0, + }, + subcriptionTopics: { + _embedded: [], + pageNumber: 0, + pageSize: 0, + pageCount: 0, + totalCount: 0, + }, + webhookData: { + id: '', + applicationId: '', + url: '', + description: '', + topicIds: [], + customerIds: [], + active: false, + }, + }, + webhooks: { + subscriptions: { + loading: false, + error: false, + subscriptions: { + _embedded: [], + }, + }, + topics: { + applicationId: '', + loading: false, + error: false, + topics: { + _embedded: [], + }, + }, + }, +} + +export default appState diff --git a/packages/marketplace/src/reducers/__tests__/client.ts b/packages/marketplace/src/reducers/__tests__/client.ts deleted file mode 100644 index a557833e86..0000000000 --- a/packages/marketplace/src/reducers/__tests__/client.ts +++ /dev/null @@ -1,41 +0,0 @@ -import clientReducer, { defaultState } from '../client' -import { ActionType } from '../../types/core' -import ActionTypes from '../../constants/action-types' -import { appsDataStub } from '../../sagas/__stubs__/apps' - -describe('client reducer', () => { - it('should return default state if action not matched', () => { - const newState = clientReducer(undefined, { type: 'UNKNOWN' as ActionType, data: undefined }) - expect(newState).toEqual(defaultState) - }) - - it('should set loading to true when CLIENT_LOADING action is called', () => { - const newState = clientReducer(undefined, { type: ActionTypes.CLIENT_LOADING as ActionType, data: true }) - const expected = { - ...defaultState, - loading: true, - } - expect(newState).toEqual(expected) - }) - - it('should set client item data when CLIENT_RECEIVE_DATA action is called', () => { - const newState = clientReducer(undefined, { - type: ActionTypes.CLIENT_RECEIVE_DATA as ActionType, - data: appsDataStub, - }) - const expected = { - ...defaultState, - clientData: appsDataStub, - } - expect(newState).toEqual(expected) - }) - - it('should clear client item data when CLIENT_CLEAR_DATA action is called', () => { - const newState = clientReducer(undefined, { type: ActionTypes.CLIENT_CLEAR_DATA as ActionType, data: null }) - const expected = { - ...defaultState, - clientData: null, - } - expect(newState).toEqual(expected) - }) -}) diff --git a/packages/marketplace/src/reducers/client.ts b/packages/marketplace/src/reducers/client.ts deleted file mode 100644 index 2d8e418416..0000000000 --- a/packages/marketplace/src/reducers/client.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Action } from '../types/core' -import { isType } from '../utils/actions' -import { clientLoading, clientReceiveData, clientClearData, clientRequestDataFailure } from '../actions/client' -import { PagedResultAppSummaryModel_, AppSummaryModel } from '@reapit/foundations-ts-definitions' - -export interface ClientItem { - apps: PagedResultAppSummaryModel_ - featuredApps?: AppSummaryModel[] -} - -export interface ClientState { - loading: boolean - clientData: ClientItem | null -} - -export interface ClientParams { - page?: number - search?: string - searchBy?: string - category?: string -} - -export const defaultState: ClientState = { - loading: false, - clientData: null, -} - -const clientReducer = (state: ClientState = defaultState, action: Action): ClientState => { - if (isType(action, clientLoading)) { - return { - ...state, - loading: action.data, - } - } - - if (isType(action, clientReceiveData)) { - return { - ...state, - loading: false, - clientData: action.data || null, - } - } - - if (isType(action, clientClearData)) { - return { - ...state, - loading: false, - clientData: action.data, - } - } - - if (isType(action, clientRequestDataFailure)) { - return { - ...state, - loading: false, - } - } - - return state -} - -export default clientReducer diff --git a/packages/marketplace/src/reducers/client/__test__/app-detail.test.ts b/packages/marketplace/src/reducers/client/__test__/app-detail.test.ts new file mode 100644 index 0000000000..bf0b00a5af --- /dev/null +++ b/packages/marketplace/src/reducers/client/__test__/app-detail.test.ts @@ -0,0 +1,49 @@ +import appDetailReducer, { defaultState } from '../app-detail' +import { ActionType } from '../../../types/core' +import ActionTypes from '../../../constants/action-types' +import { appDetailDataStub } from '@/sagas/__stubs__/app-detail' + +describe('client app detail reducer', () => { + it('should return default state if action not matched', () => { + const newState = appDetailReducer(undefined, { type: 'UNKNOWN' as ActionType, data: undefined }) + expect(newState).toEqual(defaultState) + }) + + it('should set loading to true when CLIENT_FETCH_APP_DETAIL action is called', () => { + const newState = appDetailReducer(undefined, { + type: ActionTypes.CLIENT_FETCH_APP_DETAIL as ActionType, + data: true, + }) + const expected = { + ...defaultState, + isAppDetailLoading: true, + } + expect(newState).toEqual(expected) + }) + + it('should set client item data when CLIENT_FETCH_APP_DETAIL_SUCCESS action is called', () => { + const newState = appDetailReducer(undefined, { + type: ActionTypes.CLIENT_FETCH_APP_DETAIL_SUCCESS as ActionType, + data: appDetailDataStub.data, + }) + const expected = { + ...defaultState, + isAppDetailLoading: false, + data: appDetailDataStub.data, + } + expect(newState).toEqual(expected) + }) + + it('should set error data when CLIENT_FETCH_APP_DETAIL_FAILED is called', () => { + const newState = appDetailReducer(undefined, { + type: ActionTypes.CLIENT_FETCH_APP_DETAIL_FAILED as ActionType, + data: 'error', + }) + const expected = { + ...defaultState, + isAppDetailLoading: false, + error: 'error', + } + expect(newState).toEqual(expected) + }) +}) diff --git a/packages/marketplace/src/reducers/client/__test__/app-summary.test.ts b/packages/marketplace/src/reducers/client/__test__/app-summary.test.ts new file mode 100644 index 0000000000..18f14805ee --- /dev/null +++ b/packages/marketplace/src/reducers/client/__test__/app-summary.test.ts @@ -0,0 +1,61 @@ +import appSummaryReducer, { defaultState } from '../app-summary' +import { ActionType } from '../../../types/core' +import ActionTypes from '../../../constants/action-types' +import { appsDataStub } from '../../../sagas/__stubs__/apps' + +describe('appsumar reducer', () => { + it('should return default state if action not matched', () => { + const newState = appSummaryReducer(undefined, { type: 'UNKNOWN' as ActionType, data: undefined }) + expect(newState).toEqual(defaultState) + }) + + it('should set loading to true when CLIENT_FETCH_APP_SUMMARY action is called', () => { + const newState = appSummaryReducer(undefined, { + type: ActionTypes.CLIENT_FETCH_APP_SUMMARY as ActionType, + data: true, + }) + const expected = { + ...defaultState, + isAppSummaryLoading: true, + } + expect(newState).toEqual(expected) + }) + + it('should set client item data when CLIENT_FETCH_APP_SUMMARY_SUCCESS action is called', () => { + const newState = appSummaryReducer(undefined, { + type: ActionTypes.CLIENT_FETCH_APP_SUMMARY_SUCCESS as ActionType, + data: appsDataStub, + }) + const expected = { + ...defaultState, + isAppSummaryLoading: false, + data: appsDataStub, + } + expect(newState).toEqual(expected) + }) + + it('should set error data when CLIENT_FETCH_APP_SUMMARY_FAILED action is called', () => { + const newState = appSummaryReducer(undefined, { + type: ActionTypes.CLIENT_FETCH_APP_SUMMARY_FAILED as ActionType, + data: 'error', + }) + const expected = { + ...defaultState, + isAppSummaryLoading: false, + error: 'error', + } + expect(newState).toEqual(expected) + }) + + it('should clear client item data when CLIENT_CLEAR_APP_SUMMARY action is called', () => { + const newState = appSummaryReducer(undefined, { + type: ActionTypes.CLIENT_CLEAR_APP_SUMMARY as ActionType, + data: null, + }) + const expected = { + ...defaultState, + data: null, + } + expect(newState).toEqual(expected) + }) +}) diff --git a/packages/marketplace/src/reducers/client/app-detail.ts b/packages/marketplace/src/reducers/client/app-detail.ts new file mode 100644 index 0000000000..c3a8488bf8 --- /dev/null +++ b/packages/marketplace/src/reducers/client/app-detail.ts @@ -0,0 +1,47 @@ +import { AppDetailModel } from '@reapit/foundations-ts-definitions' +import { Action } from '@/types/core' +import { clientFetchAppDetail, clientFetchAppDetailSuccess, clientFetchAppDetailFailed } from '@/actions/client' +import { isType } from '@/utils/actions' + +export type AppDetailData = (AppDetailModel & { apiKey?: string }) | null + +export interface ClientAppDetailState { + data: AppDetailData + isAppDetailLoading: boolean + error?: string | null +} + +export const defaultState: ClientAppDetailState = { + error: null, + data: null, + isAppDetailLoading: false, +} + +const appDetailReducer = (state: ClientAppDetailState = defaultState, action: Action): ClientAppDetailState => { + if (isType(action, clientFetchAppDetail)) { + return { + ...state, + isAppDetailLoading: true, + } + } + + if (isType(action, clientFetchAppDetailSuccess)) { + return { + ...state, + isAppDetailLoading: false, + data: action.data, + } + } + + if (isType(action, clientFetchAppDetailFailed)) { + return { + ...state, + isAppDetailLoading: false, + error: action.data, + } + } + + return state +} + +export default appDetailReducer diff --git a/packages/marketplace/src/reducers/client/app-summary.ts b/packages/marketplace/src/reducers/client/app-summary.ts new file mode 100644 index 0000000000..5b85197273 --- /dev/null +++ b/packages/marketplace/src/reducers/client/app-summary.ts @@ -0,0 +1,70 @@ +import { Action } from '@/types/core' +import { isType } from '@/utils/actions' +import { + clientFetchAppSummary, + clientFetchAppSummarySuccess, + clientClearAppSummary, + clientFetchAppSummaryFailed, +} from '@/actions/client' +import { PagedResultAppSummaryModel_, AppSummaryModel } from '@reapit/foundations-ts-definitions' + +export interface ClientAppSummary { + apps: PagedResultAppSummaryModel_ + featuredApps?: AppSummaryModel[] +} + +export interface ClientAppSummaryParams { + page?: number + search?: string + searchBy?: string + category?: string +} + +export interface ClientAppSummaryState { + isAppSummaryLoading: boolean + data: ClientAppSummary | null + error?: string | null +} + +export const defaultState: ClientAppSummaryState = { + isAppSummaryLoading: false, + data: null, + error: null, +} + +const appSummaryReducer = (state: ClientAppSummaryState = defaultState, action: Action): ClientAppSummaryState => { + if (isType(action, clientFetchAppSummary)) { + return { + ...state, + isAppSummaryLoading: true, + } + } + + if (isType(action, clientFetchAppSummarySuccess)) { + return { + ...state, + isAppSummaryLoading: false, + data: action.data || null, + } + } + + if (isType(action, clientClearAppSummary)) { + return { + ...state, + isAppSummaryLoading: false, + data: null, + } + } + + if (isType(action, clientFetchAppSummaryFailed)) { + return { + ...state, + isAppSummaryLoading: false, + error: action.data, + } + } + + return state +} + +export default appSummaryReducer diff --git a/packages/marketplace/src/reducers/client/index.ts b/packages/marketplace/src/reducers/client/index.ts new file mode 100644 index 0000000000..e6181a20ce --- /dev/null +++ b/packages/marketplace/src/reducers/client/index.ts @@ -0,0 +1,13 @@ +import { combineReducers } from 'redux' +import appDetailReducer, { ClientAppDetailState } from './app-detail' +import clientReducer, { ClientAppSummaryState } from './app-summary' + +export interface ClientRootState { + appSummary: ClientAppSummaryState + appDetail: ClientAppDetailState +} + +export default combineReducers({ + appSummary: clientReducer, + appDetail: appDetailReducer, +}) diff --git a/packages/marketplace/src/reducers/developer.ts b/packages/marketplace/src/reducers/developer.ts index fbdb5214bc..3752d6990c 100644 --- a/packages/marketplace/src/reducers/developer.ts +++ b/packages/marketplace/src/reducers/developer.ts @@ -9,12 +9,20 @@ import { fetchBilling, fetchBillingSuccess, fetchBillingFailure, + developerFetchAppDetail, + developerFetchAppDetailSuccess, + developerFetchAppDetailFailed, fetchMonthlyBilling, fetchMonthlyBillingSuccess, fetchMonthlyBillingFailure, developerSetWebhookPingStatus, } from '@/actions/developer' -import { PagedResultAppSummaryModel_, ScopeModel, DeveloperModel } from '@reapit/foundations-ts-definitions' +import { + PagedResultAppSummaryModel_, + ScopeModel, + DeveloperModel, + AppDetailModel, +} from '@reapit/foundations-ts-definitions' import { developerAppShowModal } from '@/actions/developer-app-modal' export interface DeveloperRequestParams { @@ -71,6 +79,7 @@ export type WebhookPingTestStatus = 'SUCCESS' | 'FAILED' | 'LOADING' | null export interface DeveloperState { loading: boolean + developerAppDetail: DeveloperAppDetailState developerData: DeveloperItem | null formState: FormState isVisible: boolean @@ -83,8 +92,21 @@ export interface DeveloperState { webhookPingTestStatus: WebhookPingTestStatus } +export type AppDetailData = (AppDetailModel & { apiKey?: string }) | null + +export interface DeveloperAppDetailState { + data: AppDetailData + isAppDetailLoading: boolean + error?: string | null +} + export const defaultState: DeveloperState = { loading: false, + developerAppDetail: { + error: null, + data: null, + isAppDetailLoading: false, + }, developerData: null, formState: 'PENDING', isVisible: false, @@ -98,6 +120,38 @@ export const defaultState: DeveloperState = { } const developerReducer = (state: DeveloperState = defaultState, action: Action): DeveloperState => { + if (isType(action, developerFetchAppDetail)) { + return { + ...state, + developerAppDetail: { + ...state.developerAppDetail, + isAppDetailLoading: true, + }, + } + } + + if (isType(action, developerFetchAppDetailSuccess)) { + return { + ...state, + developerAppDetail: { + ...state.developerAppDetail, + data: action.data, + isAppDetailLoading: false, + }, + } + } + + if (isType(action, developerFetchAppDetailFailed)) { + return { + ...state, + developerAppDetail: { + ...state.developerAppDetail, + isAppDetailLoading: false, + error: action.data, + }, + } + } + if (isType(action, developerLoading)) { return { ...state, diff --git a/packages/marketplace/src/sagas/__stubs__/developer.ts b/packages/marketplace/src/sagas/__stubs__/developer.ts index 301bc8f91a..67c1148f6e 100644 --- a/packages/marketplace/src/sagas/__stubs__/developer.ts +++ b/packages/marketplace/src/sagas/__stubs__/developer.ts @@ -1,6 +1,7 @@ import { DeveloperState } from '@/reducers/developer' import { developerIdentity } from './developer-identity' import { billing } from './billing' +import { appDetailDataStub } from './app-detail' export const developerStub = { id: '7a96e6b2-3778-4118-9c9b-6450851e5608', @@ -17,6 +18,10 @@ export const developerStub = { export const developerState: DeveloperState = { loading: false, error: null, + developerAppDetail: { + data: appDetailDataStub.data, + isAppDetailLoading: false, + }, developerData: { data: { data: [ diff --git a/packages/marketplace/src/sagas/__tests__/client.ts b/packages/marketplace/src/sagas/__tests__/client.ts index 0148cfcf7d..66034b15b4 100644 --- a/packages/marketplace/src/sagas/__tests__/client.ts +++ b/packages/marketplace/src/sagas/__tests__/client.ts @@ -1,7 +1,7 @@ import clientSagas, { clientDataFetch, clientDataListen } from '../client' import ActionTypes from '@/constants/action-types' import { put, takeLatest, all, fork, call, select } from '@redux-saga/core/effects' -import { clientLoading, clientReceiveData, clientRequestDataFailure } from '@/actions/client' +import { clientFetchAppSummarySuccess } from '@/actions/client' import { categoriesReceiveData } from '@/actions/app-categories' import { featuredAppsDataStub, appsDataStub } from '../__stubs__/apps' import { cloneableGenerator } from '@redux-saga/testing-utils' @@ -26,7 +26,6 @@ const params = { data: { page: 1, search: '', category: '' } } describe('client fetch data', () => { const gen = cloneableGenerator(clientDataFetch as any)(params) - expect(gen.next().value).toEqual(put(clientLoading(true))) expect(gen.next().value).toEqual(select(selectClientId)) expect(gen.next('1').value).toEqual(select(selectCategories)) expect(gen.next([]).value).toEqual(select(selectFeaturedApps)) @@ -66,7 +65,7 @@ describe('client fetch data', () => { const response = [appsDataStub.data, featuredAppsDataStub.data, appCategorieStub] expect(clone.next(response).value).toEqual( put( - clientReceiveData({ + clientFetchAppSummarySuccess({ apps: response[0] as PagedResultAppSummaryModel_, featuredApps: response[1].data as AppSummaryModel[], }), @@ -76,12 +75,6 @@ describe('client fetch data', () => { expect(clone.next().done).toBe(true) }) - test('api call fail', () => { - const clone = gen.clone() - expect(clone.next([]).value).toEqual(put(clientRequestDataFailure())) - expect(clone.next().done).toBe(true) - }) - test('api call error', () => { const clone = gen.clone() // @ts-ignore @@ -99,7 +92,6 @@ describe('client fetch data', () => { describe('client fetch data error', () => { const gen = cloneableGenerator(clientDataFetch as any)(params) - expect(gen.next().value).toEqual(put(clientLoading(true))) expect(gen.next('').value).toEqual(select(selectClientId)) // @ts-ignore @@ -118,7 +110,9 @@ describe('client thunks', () => { it('should request data when called', () => { const gen = clientDataListen() - expect(gen.next().value).toEqual(takeLatest>(ActionTypes.CLIENT_REQUEST_DATA, clientDataFetch)) + expect(gen.next().value).toEqual( + takeLatest>(ActionTypes.CLIENT_FETCH_APP_SUMMARY, clientDataFetch), + ) expect(gen.next().done).toBe(true) }) }) diff --git a/packages/marketplace/src/sagas/app-installations.ts b/packages/marketplace/src/sagas/app-installations.ts index 5f1fe0752c..32b05e5732 100644 --- a/packages/marketplace/src/sagas/app-installations.ts +++ b/packages/marketplace/src/sagas/app-installations.ts @@ -87,7 +87,8 @@ export const installationsFilterSaga = function*({ data }) { } } -export const appInstallSaga = function*({ data }) { +export const appInstallSaga = function*(options) { + const data: InstallParams = options.data try { yield put(appInstallationsSetFormState('SUBMITTING')) @@ -99,6 +100,9 @@ export const appInstallSaga = function*({ data }) { } yield call(fetchInstallApp, { data, clientId, email }) + if (data.callback) { + data.callback() + } yield put(appInstallationsSetFormState('SUCCESS')) } catch (err) { logger(err) @@ -112,12 +116,16 @@ export const appInstallSaga = function*({ data }) { } } -export const appUninstallSaga = function*({ data }) { +export const appUninstallSaga = function*(options) { + const data: UninstallParams = options.data try { yield put(appInstallationsSetFormState('SUBMITTING')) const email = yield select(selectLoggedUserEmail) yield call(fetchUninstallApp, { data, email }) + if (data.callback) { + data.callback() + } yield put(appInstallationsSetFormState('SUCCESS')) } catch (err) { logger(err) diff --git a/packages/marketplace/src/sagas/apps/__test__/apps.test.ts b/packages/marketplace/src/sagas/apps/__test__/apps.test.ts new file mode 100644 index 0000000000..6dc214a148 --- /dev/null +++ b/packages/marketplace/src/sagas/apps/__test__/apps.test.ts @@ -0,0 +1,229 @@ +import appDetailSagas, { + fetchClientAppDetailSaga, + clientAppDetailDataListen, + fetchDeveloperAppDetailSaga, + developerAppDetailDataListen, +} from '../apps' +import ActionTypes from '@/constants/action-types' +import { put, takeLatest, all, fork, call } from '@redux-saga/core/effects' +import { Action } from '@/types/core' +import { cloneableGenerator } from '@redux-saga/testing-utils' +import { errorThrownServer } from '@/actions/error' +import errorMessages from '@/constants/error-messages' +import { FetchAppDetailParams, fetchAppDetail, fetchAppApiKey } from '@/services/apps' +import { clientFetchAppDetailSuccess } from '@/actions/client' +import { appDetailDataStub } from '@/sagas/__stubs__/app-detail' +import { developerFetchAppDetailSuccess } from '@/actions/developer' + +jest.mock('@reapit/elements') + +const paramsClientId: Action = { + data: { id: '9b6fd5f7-2c15-483d-b925-01b650538e52', clientId: 'DAC' }, + type: 'CLIENT_FETCH_APP_DETAIL', +} + +const params: Action = { + data: { id: '9b6fd5f7-2c15-483d-b925-01b650538e52' }, + type: 'CLIENT_FETCH_APP_DETAIL', +} + +describe('fetch client app detail with clientId', () => { + const gen = cloneableGenerator(fetchClientAppDetailSaga)(paramsClientId) + expect(gen.next().value).toEqual( + call(fetchAppDetail, { id: paramsClientId.data.id, clientId: paramsClientId.data.clientId }), + ) + + test('api call success', () => { + const clone = gen.clone() + expect(clone.next(appDetailDataStub.data).value).toEqual(put(clientFetchAppDetailSuccess(appDetailDataStub.data))) + }) + + test('api call error', () => { + const clone = gen.clone() + // @ts-ignore + expect(clone.throw('error').value).toEqual( + put( + errorThrownServer({ + type: 'SERVER', + message: errorMessages.DEFAULT_SERVER_ERROR, + }), + ), + ) + }) +}) + +describe('fetch client app detail without clientId', () => { + const gen = cloneableGenerator(fetchClientAppDetailSaga)(params) + expect(gen.next().value).toEqual(call(fetchAppDetail, { id: paramsClientId.data.id, clientId: undefined })) + + test('api call success', () => { + const clone = gen.clone() + expect(clone.next(appDetailDataStub.data).value).toEqual(put(clientFetchAppDetailSuccess(appDetailDataStub.data))) + }) + + test('api call error', () => { + const clone = gen.clone() + // @ts-ignore + expect(clone.throw('error').value).toEqual( + put( + errorThrownServer({ + type: 'SERVER', + message: errorMessages.DEFAULT_SERVER_ERROR, + }), + ), + ) + }) +}) + +describe('client app detail fetch data and fetch apiKey', () => { + const gen = cloneableGenerator(fetchClientAppDetailSaga)(params) + expect(gen.next().value).toEqual(call(fetchAppDetail, { id: params.data.id, clientId: undefined })) + + test('api call success', () => { + const clone = gen.clone() + const installationId = '09682122-0811-4f36-9bfa-05e337de3065' + const isWebComponent = true + const apiKey = 'mockApiKey' + expect( + clone.next({ + ...appDetailDataStub.data, + isWebComponent, + installationId, + }).value, + ).toEqual(call(fetchAppApiKey, { installationId })) + expect(clone.next({ apiKey }).value).toEqual( + put(clientFetchAppDetailSuccess({ ...appDetailDataStub.data, isWebComponent, installationId, apiKey })), + ) + }) + + test('api call error', () => { + const clone = gen.clone() + // @ts-ignore + expect(clone.throw('error').value).toEqual( + put( + errorThrownServer({ + type: 'SERVER', + message: errorMessages.DEFAULT_SERVER_ERROR, + }), + ), + ) + }) +}) + +describe('fetch developer app detail with clientId', () => { + const gen = cloneableGenerator(fetchDeveloperAppDetailSaga)(paramsClientId) + expect(gen.next().value).toEqual( + call(fetchAppDetail, { id: paramsClientId.data.id, clientId: paramsClientId.data.clientId }), + ) + + test('api call success', () => { + const clone = gen.clone() + expect(clone.next(appDetailDataStub.data).value).toEqual( + put(developerFetchAppDetailSuccess(appDetailDataStub.data)), + ) + }) + + test('api call error', () => { + const clone = gen.clone() + // @ts-ignore + expect(clone.throw('error').value).toEqual( + put( + errorThrownServer({ + type: 'SERVER', + message: errorMessages.DEFAULT_SERVER_ERROR, + }), + ), + ) + }) +}) + +describe('fetch developer app detail without clientId', () => { + const gen = cloneableGenerator(fetchDeveloperAppDetailSaga)(params) + expect(gen.next().value).toEqual(call(fetchAppDetail, { id: paramsClientId.data.id, clientId: undefined })) + + test('api call success', () => { + const clone = gen.clone() + expect(clone.next(appDetailDataStub.data).value).toEqual( + put(developerFetchAppDetailSuccess(appDetailDataStub.data)), + ) + }) + + test('api call error', () => { + const clone = gen.clone() + // @ts-ignore + expect(clone.throw('error').value).toEqual( + put( + errorThrownServer({ + type: 'SERVER', + message: errorMessages.DEFAULT_SERVER_ERROR, + }), + ), + ) + }) +}) + +describe('client app detail fetch data and fetch apiKey', () => { + const gen = cloneableGenerator(fetchDeveloperAppDetailSaga)(params) + expect(gen.next().value).toEqual(call(fetchAppDetail, { id: params.data.id, clientId: undefined })) + + test('api call success', () => { + const clone = gen.clone() + const installationId = '09682122-0811-4f36-9bfa-05e337de3065' + const isWebComponent = true + const apiKey = 'mockApiKey' + expect( + clone.next({ + ...appDetailDataStub.data, + isWebComponent, + installationId, + }).value, + ).toEqual(call(fetchAppApiKey, { installationId })) + expect(clone.next({ apiKey }).value).toEqual( + put(developerFetchAppDetailSuccess({ ...appDetailDataStub.data, isWebComponent, installationId, apiKey })), + ) + }) + + test('api call error', () => { + const clone = gen.clone() + // @ts-ignore + expect(clone.throw('error').value).toEqual( + put( + errorThrownServer({ + type: 'SERVER', + message: errorMessages.DEFAULT_SERVER_ERROR, + }), + ), + ) + }) +}) + +describe('client app detail thunks', () => { + describe('clientAppDetailDataListen', () => { + it('should trigger request data when called', () => { + const gen = clientAppDetailDataListen() + expect(gen.next().value).toEqual( + takeLatest>(ActionTypes.CLIENT_FETCH_APP_DETAIL, fetchClientAppDetailSaga), + ) + expect(gen.next().done).toBe(true) + }) + }) + + describe('developerAppDetailDataListen', () => { + it('should trigger request data when called', () => { + const gen = developerAppDetailDataListen() + expect(gen.next().value).toEqual( + takeLatest>(ActionTypes.DEVELOPER_FETCH_APP_DETAIL, fetchDeveloperAppDetailSaga), + ) + expect(gen.next().done).toBe(true) + }) + }) + + describe('appDetailSagas', () => { + it('should listen data request', () => { + const gen = appDetailSagas() + + expect(gen.next().value).toEqual(all([fork(clientAppDetailDataListen), fork(developerAppDetailDataListen)])) + expect(gen.next().done).toBe(true) + }) + }) +}) diff --git a/packages/marketplace/src/sagas/apps/apps.ts b/packages/marketplace/src/sagas/apps/apps.ts new file mode 100644 index 0000000000..2972377f5f --- /dev/null +++ b/packages/marketplace/src/sagas/apps/apps.ts @@ -0,0 +1,61 @@ +import { clientFetchAppDetailSuccess } from '@/actions/client' +import { put, call, fork, takeLatest, all } from '@redux-saga/core/effects' +import ActionTypes from '@/constants/action-types' +import { errorThrownServer } from '@/actions/error' +import errorMessages from '@/constants/error-messages' +import { Action } from '@/types/core' +import { logger } from 'logger' +import { fetchAppApiKey, fetchAppDetail, FetchAppDetailParams } from '@/services/apps' +import { developerFetchAppDetailSuccess } from '@/actions/developer' + +export const fetchClientAppDetailSaga = function*({ data }: Action) { + try { + const appDetailResponse = yield call(fetchAppDetail, { clientId: data.clientId, id: data.id }) + if (appDetailResponse?.isWebComponent && appDetailResponse?.installationId) { + const apiKeyResponse = yield call(fetchAppApiKey, { installationId: appDetailResponse.installationId }) + appDetailResponse.apiKey = apiKeyResponse?.apiKey || '' + } + yield put(clientFetchAppDetailSuccess(appDetailResponse)) + } catch (err) { + logger(err) + yield put( + errorThrownServer({ + type: 'SERVER', + message: errorMessages.DEFAULT_SERVER_ERROR, + }), + ) + } +} + +export const fetchDeveloperAppDetailSaga = function*({ data }: Action) { + try { + const appDetailResponse = yield call(fetchAppDetail, { clientId: data.clientId, id: data.id }) + if (appDetailResponse?.isWebComponent && appDetailResponse?.installationId) { + const apiKeyResponse = yield call(fetchAppApiKey, { installationId: appDetailResponse.installationId }) + appDetailResponse.apiKey = apiKeyResponse?.apiKey || '' + } + yield put(developerFetchAppDetailSuccess(appDetailResponse)) + } catch (err) { + logger(err) + yield put( + errorThrownServer({ + type: 'SERVER', + message: errorMessages.DEFAULT_SERVER_ERROR, + }), + ) + } +} + +export const clientAppDetailDataListen = function*() { + yield takeLatest>(ActionTypes.CLIENT_FETCH_APP_DETAIL, fetchClientAppDetailSaga) +} + +export const developerAppDetailDataListen = function*() { + yield takeLatest>(ActionTypes.DEVELOPER_FETCH_APP_DETAIL, fetchDeveloperAppDetailSaga) +} + +const appDetailSagas = function*() { + yield all([fork(clientAppDetailDataListen), fork(developerAppDetailDataListen)]) +} + +export default appDetailSagas diff --git a/packages/marketplace/src/sagas/client.ts b/packages/marketplace/src/sagas/client.ts index 539d7a9bcc..8fe573631c 100644 --- a/packages/marketplace/src/sagas/client.ts +++ b/packages/marketplace/src/sagas/client.ts @@ -1,4 +1,4 @@ -import { clientLoading, clientReceiveData, clientRequestDataFailure } from '../actions/client' +import { clientFetchAppSummarySuccess } from '../actions/client' import { categoriesReceiveData } from '@/actions/app-categories' import { put, fork, takeLatest, all, call, select } from '@redux-saga/core/effects' import ActionTypes from '../constants/action-types' @@ -10,12 +10,10 @@ import { fetcher, setQueryParams } from '@reapit/elements' import { Action } from '@/types/core' import { selectClientId, selectFeaturedApps } from '@/selector/client' import { selectCategories } from '@/selector/app-categories' -import { ClientItem, ClientParams } from '@/reducers/client' +import { ClientAppSummary, ClientAppSummaryParams } from '@/reducers/client/app-summary' import { logger } from 'logger' export const clientDataFetch = function*({ data }) { - yield put(clientLoading(true)) - try { const { page, search, category, searchBy } = data const clientId = yield select(selectClientId) @@ -62,13 +60,9 @@ export const clientDataFetch = function*({ data }) { headers: generateHeader(window.reapit.config.marketplaceApiKey), }), ]) - if (apps && categories && featuredApps) { - const clientItem: ClientItem = { apps: apps, featuredApps: featuredApps?.data } - yield put(clientReceiveData(clientItem)) - yield put(categoriesReceiveData(categories)) - } else { - yield put(clientRequestDataFailure()) - } + const clientItem: ClientAppSummary = { apps: apps, featuredApps: featuredApps?.data } + yield put(clientFetchAppSummarySuccess(clientItem)) + yield put(categoriesReceiveData(categories)) } catch (err) { logger(err) yield put( @@ -81,7 +75,7 @@ export const clientDataFetch = function*({ data }) { } export const clientDataListen = function*() { - yield takeLatest>(ActionTypes.CLIENT_REQUEST_DATA, clientDataFetch) + yield takeLatest>(ActionTypes.CLIENT_FETCH_APP_SUMMARY, clientDataFetch) } const clientSagas = function*() { diff --git a/packages/marketplace/src/sagas/revision-detail.ts b/packages/marketplace/src/sagas/revision-detail.ts index f74211e499..3f2033b5e8 100644 --- a/packages/marketplace/src/sagas/revision-detail.ts +++ b/packages/marketplace/src/sagas/revision-detail.ts @@ -108,7 +108,7 @@ export const approveRevisionListen = function*() { export const declineRevision = function*({ data: params }: Action) { const { pageNumber } = yield select(getApprovalPageNumber) yield put(declineRevisionSetFormState('SUBMITTING')) - const { appId, appRevisionId, ...body } = params + const { appId, appRevisionId, callback, ...body } = params try { const response = yield call(fetcher, { url: `${URLS.apps}/${appId}/revisions/${appRevisionId}/reject`, @@ -121,6 +121,9 @@ export const declineRevision = function*({ data: params }: Action { it('should run correctly', () => { const input = { client: { - clientData: { - featuredApps: featuredAppsDataStub.data, + appSummary: { + data: { + featuredApps: featuredAppsDataStub.data, + }, }, }, } as ReduxState @@ -62,7 +64,13 @@ describe('selectFeaturedApps', () => { }) it('should run correctly and return [', () => { - const input = {} as ReduxState + const input = { + client: { + appSummary: { + data: null, + }, + }, + } as ReduxState const result = selectFeaturedApps(input) expect(result).toEqual([]) }) diff --git a/packages/marketplace/src/selector/app-revisions.ts b/packages/marketplace/src/selector/app-revisions.ts new file mode 100644 index 0000000000..2cda7e5612 --- /dev/null +++ b/packages/marketplace/src/selector/app-revisions.ts @@ -0,0 +1,9 @@ +import { ReduxState } from '@/types/core' + +export const selectAppRevisions = (state: ReduxState) => { + return state.revisions.revisions || {} +} + +export const selectAppRevisionDetail = (state: ReduxState) => { + return state.revisionDetail || {} +} diff --git a/packages/marketplace/src/selector/auth.ts b/packages/marketplace/src/selector/auth.ts index b81b0bfb2c..1cc50c0cc2 100644 --- a/packages/marketplace/src/selector/auth.ts +++ b/packages/marketplace/src/selector/auth.ts @@ -3,3 +3,7 @@ import { ReduxState } from '@/types/core' export const selectLoginType = (state: ReduxState) => { return state.auth.loginType } + +export const selectLoginIdentity = (state: ReduxState) => { + return state.auth.loginSession?.loginIdentity +} diff --git a/packages/marketplace/src/selector/client-app-detail.ts b/packages/marketplace/src/selector/client-app-detail.ts new file mode 100644 index 0000000000..e08d660f4d --- /dev/null +++ b/packages/marketplace/src/selector/client-app-detail.ts @@ -0,0 +1,17 @@ +import { ReduxState } from '@/types/core' + +export const selectAppDetailState = (state: ReduxState) => { + return state.client || {} +} + +export const selectAppDetailData = (state: ReduxState) => { + return state.client.appDetail.data || {} +} + +export const selectAppDetailAuthentication = (state: ReduxState) => { + return state.appDetail.authentication +} + +export const selectAppDetailLoading = (state: ReduxState) => { + return state.client.appDetail.isAppDetailLoading +} diff --git a/packages/marketplace/src/selector/client.ts b/packages/marketplace/src/selector/client.ts index a22dd06864..90d70e11e7 100644 --- a/packages/marketplace/src/selector/client.ts +++ b/packages/marketplace/src/selector/client.ts @@ -8,6 +8,10 @@ export const selectLoggedUserEmail = (state: ReduxState) => { return state?.auth?.loginSession?.loginIdentity?.email } +export const selectAppSummary = (state: ReduxState) => { + return state?.client.appSummary +} + export const selectFeaturedApps = (state: ReduxState) => { - return state?.client?.clientData?.featuredApps || [] + return state?.client.appSummary.data?.featuredApps || [] } diff --git a/packages/marketplace/src/selector/developer-app-detail.ts b/packages/marketplace/src/selector/developer-app-detail.ts new file mode 100644 index 0000000000..9231997163 --- /dev/null +++ b/packages/marketplace/src/selector/developer-app-detail.ts @@ -0,0 +1,17 @@ +import { ReduxState } from '@/types/core' + +export const selectAppDetailState = (state: ReduxState) => { + return state.developer.developerAppDetail || {} +} + +export const selectAppDetailData = (state: ReduxState) => { + return state.developer.developerAppDetail.data || {} +} + +export const selectAppDetailAuthentication = (state: ReduxState) => { + return state.appDetail.authentication +} + +export const selectAppDetailLoading = (state: ReduxState) => { + return state.developer.developerAppDetail.isAppDetailLoading +} diff --git a/packages/marketplace/src/selector/installations.ts b/packages/marketplace/src/selector/installations.ts index 6dba68201a..8103b0ac97 100644 --- a/packages/marketplace/src/selector/installations.ts +++ b/packages/marketplace/src/selector/installations.ts @@ -3,3 +3,11 @@ import { ReduxState } from '@/types/core' export const getInstallations = (state: ReduxState) => { return state.installations } + +export const selectInstallAppLoading = (state: ReduxState) => { + return state.installations.loading +} + +export const selectInstallationFormState = (state: ReduxState) => { + return state.installations.formState +} diff --git a/packages/marketplace/src/services/__tests__/apps.test.ts b/packages/marketplace/src/services/__tests__/apps.test.ts new file mode 100644 index 0000000000..25637fe4e7 --- /dev/null +++ b/packages/marketplace/src/services/__tests__/apps.test.ts @@ -0,0 +1,52 @@ +import { generateHeader, URLS } from '@/constants/api' +import { fetcher } from '@reapit/elements' +import { fetchAppDetail, fetchAppApiKey } from '../apps' + +describe('fetchAppDetail', () => { + it('fetcher should run correctly in case clientId is existed', () => { + const appId = 'testAppid' + const clientId = 'testClientId' + const headers = generateHeader(window.reapit.config.marketplaceApiKey) + const url = `${URLS.apps}/${appId}?clientId=${clientId}` + + fetchAppDetail({ clientId, id: appId }).then(() => { + expect(fetcher).toBeCalledWith({ + headers, + url, + api: window.reapit.config.marketplaceApiUrl, + method: 'GET', + }) + }) + }) + it('fetcher should run correctly in case clientId is empty', () => { + const appId = 'testAppid' + const headers = generateHeader(window.reapit.config.marketplaceApiKey) + const url = `${URLS.apps}/${appId}` + + fetchAppDetail({ clientId: null, id: appId }).then(() => { + expect(fetcher).toBeCalledWith({ + headers, + url, + api: window.reapit.config.marketplaceApiUrl, + method: 'GET', + }) + }) + }) +}) + +describe('fetchAppApiKey', () => { + it('fetcher should run correctly', () => { + const installationId = 'testInstallationId' + const headers = generateHeader(window.reapit.config.marketplaceApiKey) + const url = `${URLS.installations}/${installationId}/apiKey` + + fetchAppApiKey({ installationId }).then(() => { + expect(fetcher).toBeCalledWith({ + headers, + url, + api: window.reapit.config.marketplaceApiUrl, + method: 'GET', + }) + }) + }) +}) diff --git a/packages/marketplace/src/services/apps.ts b/packages/marketplace/src/services/apps.ts new file mode 100644 index 0000000000..6d339818a9 --- /dev/null +++ b/packages/marketplace/src/services/apps.ts @@ -0,0 +1,27 @@ +import { fetcher } from '@reapit/elements' +import { URLS, generateHeader } from '../constants/api' + +export interface FetchAppDetailParams { + id: string + clientId?: string +} + +export const fetchAppDetail = async ({ clientId, id }) => { + const response = await fetcher({ + url: clientId ? `${URLS.apps}/${id}?clientId=${clientId}` : `${URLS.apps}/${id}`, + api: window.reapit.config.marketplaceApiUrl, + method: 'GET', + headers: generateHeader(window.reapit.config.marketplaceApiKey), + }) + return response +} + +export const fetchAppApiKey = async ({ installationId }) => { + const response = await fetcher({ + url: `${URLS.installations}/${installationId}/apiKey`, + api: window.reapit.config.marketplaceApiUrl, + method: 'GET', + headers: generateHeader(window.reapit.config.marketplaceApiKey), + }) + return response +} diff --git a/packages/marketplace/src/styles/blocks/app-authentication-detail.scss b/packages/marketplace/src/styles/blocks/app-authentication-detail.scss index 513a289bcd..2e4c521bbd 100644 --- a/packages/marketplace/src/styles/blocks/app-authentication-detail.scss +++ b/packages/marketplace/src/styles/blocks/app-authentication-detail.scss @@ -13,6 +13,10 @@ justify-content: space-between; } +.authenticationCode { + word-break: break-all; +} + .btnCopy { cursor: pointer; position: relative; diff --git a/packages/marketplace/src/styles/blocks/app-detail.scss b/packages/marketplace/src/styles/blocks/app-detail.scss index 36eb14c115..5e47d9601d 100644 --- a/packages/marketplace/src/styles/blocks/app-detail.scss +++ b/packages/marketplace/src/styles/blocks/app-detail.scss @@ -68,3 +68,7 @@ .appInfoSpace { margin-right: 8px; } + +.description { + word-break: break-word; +} diff --git a/packages/marketplace/src/styles/pages/developer-app-detail.scss b/packages/marketplace/src/styles/pages/developer-app-detail.scss new file mode 100644 index 0000000000..e465435eaf --- /dev/null +++ b/packages/marketplace/src/styles/pages/developer-app-detail.scss @@ -0,0 +1,20 @@ +@import '../base/colors.scss'; + +.appDetailContainer { + width: 100%; + height: 100%; + max-width: 1012px; + margin: 0 auto; + padding: 15px; + background-color: $white; +} +.appIconContainer { + width: 128px; + height: 128px; + display: flex; + align-items: center; + justify-content: center; + background-color: $grey-lighter; + border-radius: 50%; + margin: 0 auto; +} diff --git a/packages/marketplace/src/types/core.ts b/packages/marketplace/src/types/core.ts index d0237ee81a..10e4e94e61 100644 --- a/packages/marketplace/src/types/core.ts +++ b/packages/marketplace/src/types/core.ts @@ -1,7 +1,6 @@ import { AdminDevManamgenetState } from './../reducers/admin-dev-management' import Routes from '../constants/routes' import ActionTypes from '../constants/action-types' -import { ClientState } from '../reducers/client' import { InstalledAppsState } from '../reducers/installed-apps' import { MyAppsState } from '../reducers/my-apps' import { DeveloperState } from '../reducers/developer' @@ -25,6 +24,7 @@ import { AppHttpTrafficEventState } from '@/reducers/app-http-traffic-event' import { IntegrationTypeState } from '@/reducers/app-integration-types' import { WebhookEditState } from '@/reducers/webhook-edit-modal' import { WebhookState } from '@/reducers/webhook-subscriptions' +import { ClientRootState } from '@/reducers/client' export interface Action { readonly type: ActionType @@ -62,7 +62,7 @@ export interface FetcherParams { } export interface ReduxState { - client: ClientState + client: ClientRootState installedApps: InstalledAppsState myApps: MyAppsState appDetail: AppDetailState diff --git a/packages/marketplace/src/utils/__tests__/actions.ts b/packages/marketplace/src/utils/__tests__/actions.ts index 7d1ce07fbf..a8a32e7b88 100644 --- a/packages/marketplace/src/utils/__tests__/actions.ts +++ b/packages/marketplace/src/utils/__tests__/actions.ts @@ -1,27 +1,36 @@ import { actionCreator, isType } from '../actions' import ActionTypes from '../../constants/action-types' -import { clientLoading } from '../../actions/client' +import { clientFetchAppSummary, clientFetchAppSummarySuccess } from '../../actions/client' import { Action } from '../../types/core' +import { ClientAppSummaryParams } from '@/reducers/client/app-summary' describe('actions utils', () => { describe('actionCreator', () => { it('should create an action of the correct type', () => { - const loadingAction = { data: true, type: 'CLIENT_LOADING' } - expect(actionCreator(ActionTypes.CLIENT_LOADING)(true)).toEqual(loadingAction) + const actionData: ClientAppSummaryParams = { + page: 1, + } + const action = { data: actionData, type: 'CLIENT_FETCH_APP_SUMMARY' } + expect( + actionCreator(ActionTypes.CLIENT_FETCH_APP_SUMMARY)({ + page: 1, + }), + ).toEqual(action) }) }) describe('isType', () => { it('should return true if actions are equal', () => { - const loadingAction: Action = { data: true, type: 'CLIENT_LOADING' } - - expect(isType(loadingAction, clientLoading)).toBe(true) + const actionData: ClientAppSummaryParams = { + page: 1, + } + const action: Action = { data: actionData, type: 'CLIENT_FETCH_APP_SUMMARY' } + expect(isType(action, clientFetchAppSummary)).toBe(true) }) it('should return false if actions are not equal', () => { - const anotherAction: Action = { data: true, type: 'CLIENT_RECEIVE_DATA' } - - expect(isType(anotherAction, clientLoading)).toBe(false) + const anotherAction: Action = { data: true, type: 'CLIENT_FETCH_APP_SUMMARY' } + expect(isType(anotherAction, clientFetchAppSummarySuccess)).toBe(false) }) }) }) diff --git a/packages/marketplace/src/utils/__tests__/route-dispatcher.ts b/packages/marketplace/src/utils/__tests__/route-dispatcher.ts index 05c7450dd3..7033d198ef 100644 --- a/packages/marketplace/src/utils/__tests__/route-dispatcher.ts +++ b/packages/marketplace/src/utils/__tests__/route-dispatcher.ts @@ -3,7 +3,7 @@ import store from '../../core/store' import Routes from '../../constants/routes' import { GET_ALL_PAGE_SIZE } from '../../constants/paginator' import { RouteValue } from '../../types/core' -import { clientRequestData } from '@/actions/client' +import { clientFetchAppSummary } from '@/actions/client' import { developerRequestData, fetchMyIdentity } from '@/actions/developer' import { myAppsRequestData } from '@/actions/my-apps' import { installedAppsRequestData } from '@/actions/installed-apps' @@ -24,9 +24,9 @@ describe('routeDispatcher', () => { expect(getAccessToken).toHaveBeenCalledTimes(1) }) - it('should dispatch to clientRequestData for the client route', async () => { + it('should dispatch to clientFetchAppSummaryclientFetchAppSummary for the client route', async () => { await routeDispatcher(Routes.CLIENT as RouteValue) - expect(store.dispatch).toHaveBeenCalledWith(clientRequestData(getParamsFromPath(''))) + expect(store.dispatch).toHaveBeenCalledWith(clientFetchAppSummary(getParamsFromPath(''))) }) it('should dispatch to installedAppsRequestData for the installed-apps route', async () => { @@ -54,11 +54,6 @@ describe('routeDispatcher', () => { expect(store.dispatch).toHaveBeenCalledWith(developerRequestData({ page: 1 })) }) - it('should dispatch to developerRequestData for the developer paginate route', async () => { - await routeDispatcher(Routes.DEVELOPER_MY_APPS_PAGINATE as RouteValue, { page: '2' }) - expect(store.dispatch).toHaveBeenCalledWith(developerRequestData({ page: 2 })) - }) - it('should dispatch to adminApprovalsRequestData for the admin approvals data route', async () => { await routeDispatcher(Routes.ADMIN_APPROVALS as RouteValue) expect(store.dispatch).toHaveBeenCalledWith(adminApprovalsRequestData(1)) diff --git a/packages/marketplace/src/utils/route-dispatcher.ts b/packages/marketplace/src/utils/route-dispatcher.ts index e7df7649fb..71fbe08519 100644 --- a/packages/marketplace/src/utils/route-dispatcher.ts +++ b/packages/marketplace/src/utils/route-dispatcher.ts @@ -4,10 +4,10 @@ import { RouteValue, StringMap } from '../types/core' import Routes from '../constants/routes' import { GET_ALL_PAGE_SIZE } from '../constants/paginator' import store from '../core/store' -import { clientRequestData } from '../actions/client' +import { clientFetchAppSummary, clientFetchAppDetail } from '../actions/client' import { myAppsRequestData } from '../actions/my-apps' import { installedAppsRequestData } from '../actions/installed-apps' -import { developerRequestData, fetchMyIdentity } from '@/actions/developer' +import { developerRequestData, fetchMyIdentity, developerFetchAppDetail } from '@/actions/developer' import { adminApprovalsRequestData } from '../actions/admin-approvals' import { adminDevManagementRequestData } from '../actions/admin-dev-management' import { submitAppRequestData } from '../actions/submit-app' @@ -26,8 +26,22 @@ const routeDispatcher = async (route: RouteValue, params?: StringMap, search?: s switch (route) { case Routes.CLIENT: - store.dispatch(clientRequestData(getParamsFromPath(search || ''))) + store.dispatch(clientFetchAppSummary(getParamsFromPath(search || ''))) break + case Routes.CLIENT_APP_DETAIL: { + if (id) { + const clientId = selectClientId(store.state) + store.dispatch(clientFetchAppDetail({ id, clientId })) + } + break + } + case Routes.CLIENT_APP_DETAIL_MANAGE: { + if (id) { + const clientId = selectClientId(store.state) + store.dispatch(clientFetchAppDetail({ id, clientId })) + } + break + } case Routes.INSTALLED_APPS: store.dispatch(installedAppsRequestData(1)) break @@ -53,13 +67,17 @@ const routeDispatcher = async (route: RouteValue, params?: StringMap, search?: s } break } + case Routes.DEVELOPER_APP_DETAIL: { + if (id) { + const clientId = selectClientId(store.state) + store.dispatch(developerFetchAppDetail({ id, clientId })) + } + break + } case Routes.DEVELOPER_MY_APPS_EDIT: store.dispatch(submitAppRequestData()) store.dispatch(appDetailRequestData({ id })) break - case Routes.DEVELOPER_MY_APPS_PAGINATE: - store.dispatch(developerRequestData({ page: params && params.page ? Number(params.page) : 1 })) - break case Routes.ADMIN_APPROVALS: store.dispatch(adminApprovalsRequestData(1)) break