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/developer-app-detail/developer-app-detail.tsx b/packages/marketplace/src/components/pages/developer-app-detail/developer-app-detail.tsx index 115333b4f0..dfd3380355 100644 --- 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 @@ -2,32 +2,79 @@ import * as React from 'react' import { useSelector } from 'react-redux' 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 { Loader } from '@reapit/elements' +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' export type DeveloperAppDetailProps = {} +export const handleOnDeleteAppSuccess = history => { + return () => { + history.replace(routes.DEVELOPER_MY_APPS) + } +} + 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) - - if (!appDetailData.id || isLoadingAppDetail) { - return - } + const { id, name } = appDetailData return (
} + buttonGroup={ + id && ( + + ) + } /> + setIsDeleteModalOpen(false)} + visible={isDeleteModalOpen} + onDeleteSuccess={handleOnDeleteAppSuccess(history)} + /> + + setIsInstallationsModalOpen(false)} + onUninstallSuccess={() => { + setIsInstallationsModalOpen(false) + }} + /> + + setIsAppRevisionComparisionModalOpen(false)} + /> + {isLoadingAppDetail && }
) } 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 index 9454a10b7e..b1df7033c8 100644 --- 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 @@ -3,7 +3,7 @@ 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 } from '@reapit/elements' +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' @@ -86,7 +86,7 @@ const AppContent: React.FC = ({ appDetailData, loginType }) => apiKey, media = [], scopes = [], - description, + description = '', } = appDetailData const [isShowApiKey, setIsShowApikey] = React.useState(false) @@ -140,7 +140,7 @@ const AppContent: React.FC = ({ appDetailData, loginType }) => })} -

{description}

+
{carouselImages.length > 0 && (
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..78514f9fee --- /dev/null +++ b/packages/marketplace/src/components/ui/developer-app-detail/app-revision-comparision.tsx @@ -0,0 +1,213 @@ +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 + // const app = appDetailData + + 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..115b887985 --- /dev/null +++ b/packages/marketplace/src/components/ui/developer-app-detail/app-revision-modal.tsx @@ -0,0 +1,178 @@ +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 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: () => { + dispatch( + developerFetchAppDetail({ + id: appId, + clientId, + }), + ) + setIsConfirmationModalVisible(false) + }, + }), + ) + } +} + +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 index d0d7b7f25b..0e713317f8 100644 --- 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 @@ -3,9 +3,6 @@ import { Dispatch } from 'redux' import { useDispatch } from 'react-redux' import { useHistory } from 'react-router' import { removeAuthenticationCode } from '@/actions/app-detail' -import AppDelete from '@/components/ui/app-delete' -import AppInstallations from '@/components/ui/app-installations/app-installations-modal' -import DeveloperAppRevisionModal from '@/components/ui/developer-app-revision-modal' import { Grid, GridItem, Button } from '@reapit/elements' import routes from '@/constants/routes' @@ -13,6 +10,9 @@ 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) => { @@ -44,17 +44,18 @@ export const handleInstallationButtonClick = (setIsInstallationsModalOpen: (isMo } } -const DeveloperAppDetailButtonGroup: React.FC = ({ appDetailState }) => { +const DeveloperAppDetailButtonGroup: React.FC = ({ + appDetailState, + setIsAppRevisionComparisionModalOpen, + setIsDeleteModalOpen, + setIsInstallationsModalOpen, +}) => { const { data } = appDetailState const appId = data?.id || '' - const appName = data?.name || '' const pendingRevisions = data?.pendingRevisions const history = useHistory() const dispatch = useDispatch() - const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false) - const [isInstallationsModalOpen, setIsInstallationsModalOpen] = React.useState(false) - const [isAppRevisionComparisionModalOpen, setIsAppRevisionComparisionModalOpen] = React.useState(false) const onInstallationButtonClick = React.useCallback(handleInstallationButtonClick(setIsInstallationsModalOpen), []) const onPendingRevisionButtonClick = React.useCallback( @@ -69,63 +70,34 @@ const DeveloperAppDetailButtonGroup: React.FC - - - + + + {pendingRevisions ? ( + - - - {pendingRevisions ? ( - - ) : ( - - )} - - - - - - setIsDeleteModalOpen(false)} - visible={isDeleteModalOpen} - onDeleteSuccess={() => { - setIsDeleteModalOpen(false) - }} - /> - - setIsInstallationsModalOpen(false)} - onUninstallSuccess={() => { - setIsInstallationsModalOpen(false) - }} - /> + )} - {/* setIsAppRevisionComparisionModalOpen(false)} - /> */} - + + + ) } 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 { + 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/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; +}