diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 9c2e92cb6..733b90735 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -531,6 +531,10 @@ "context": "order status", "string": "Partially returned" }, + "26VlBZ": { + "context": "information", + "string": "Problem occured during installation." + }, "28GZnc": { "string": "Start typing to begin search..." }, @@ -2123,6 +2127,10 @@ "context": "dialog header", "string": "Select a page type" }, + "F1VtFa": { + "context": "information", + "string": "Installation is pending..." + }, "F2cR19": { "context": "error message", "string": "Error details: {message}" @@ -2938,6 +2946,10 @@ "context": "edit menu item, header", "string": "Edit Item" }, + "KMxiiF": { + "context": "button", + "string": "Retry" + }, "KN7zKn": { "string": "Error" }, diff --git a/src/apps/queries.ts b/src/apps/queries.ts index a50c7a219..5889385fc 100644 --- a/src/apps/queries.ts +++ b/src/apps/queries.ts @@ -36,11 +36,7 @@ export const appsList = gql` export const appsInProgressList = gql` query AppsInstallations { appsInstallations { - status - message - appName - manifestUrl - id + ...AppInstallation } } `; diff --git a/src/apps/views/AppDetails/AppDetails.tsx b/src/apps/views/AppDetails/AppDetails.tsx index 4bf6a4261..c7e3abdbd 100644 --- a/src/apps/views/AppDetails/AppDetails.tsx +++ b/src/apps/views/AppDetails/AppDetails.tsx @@ -108,9 +108,9 @@ export const AppDetails: React.FC = ({ id, params }) => { onCompleted: data => { if (!data?.appDelete?.errors?.length) { refetch(); - closeModal(); refetchExtensionList(); removeAppNotify(); + navigate(appsListPath); } }, }); diff --git a/src/fragments/apps.ts b/src/fragments/apps.ts index 6bd7abaab..91d0c3b58 100644 --- a/src/fragments/apps.ts +++ b/src/fragments/apps.ts @@ -53,6 +53,16 @@ export const appFragment = gql` } `; +export const appInstallationFragment = gql` + fragment AppInstallation on AppInstallation { + status + message + appName + manifestUrl + id + } +`; + export const appListItemFragment = gql` fragment AppListItem on App { id diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index 6775278a9..d8107a25b 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -67,6 +67,15 @@ export const AppFragmentDoc = gql` } } ${WebhookFragmentDoc}`; +export const AppInstallationFragmentDoc = gql` + fragment AppInstallation on AppInstallation { + status + message + appName + manifestUrl + id +} + `; export const AppPermissionFragmentDoc = gql` fragment AppPermission on Permission { name @@ -3320,14 +3329,10 @@ export type AppsListQueryResult = Apollo.QueryResult, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, tokens: Array<{ __typename: 'AppToken', authToken: string | null, id: string, name: string | null }> | null, webhooks: Array<{ __typename: 'Webhook', id: string, name: string, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } }> | null }; +export type AppInstallationFragment = { __typename: 'AppInstallation', status: JobStatusEnum, message: string | null, appName: string, manifestUrl: string, id: string }; + export type AppListItemFragment = { __typename: 'App', id: string, name: string | null, isActive: boolean | null, type: AppTypeEnum | null, appUrl: string | null, manifestUrl: string | null, version: string | null, permissions: Array<{ __typename: 'Permission', name: string, code: PermissionEnum }> | null }; export type AppPermissionFragment = { __typename: 'Permission', name: string, code: PermissionEnum }; diff --git a/src/intl.ts b/src/intl.ts index 04cccec92..2511a594a 100644 --- a/src/intl.ts +++ b/src/intl.ts @@ -352,6 +352,11 @@ export const buttonMessages = defineMessages({ defaultMessage: "Install", description: "button", }, + retry: { + id: "KMxiiF", + defaultMessage: "Retry", + description: "button", + }, }); export const sectionNames = defineMessages({ diff --git a/src/new-apps/components/AllAppList/AllAppList.tsx b/src/new-apps/components/AllAppList/AllAppList.tsx index 6f312cfef..8b8746b00 100644 --- a/src/new-apps/components/AllAppList/AllAppList.tsx +++ b/src/new-apps/components/AllAppList/AllAppList.tsx @@ -1,4 +1,6 @@ +import { AppInstallationFragment } from "@dashboard/graphql"; import { GetV2SaleorAppsResponse } from "@dashboard/new-apps/marketplace.types"; +import { resolveInstallationOfMarketplaceApp } from "@dashboard/new-apps/utils"; import { Skeleton } from "@material-ui/lab"; import React from "react"; @@ -7,12 +9,14 @@ import { useStyles } from "./styles"; interface AllAppListProps { appList?: GetV2SaleorAppsResponse.SaleorApp[]; + appInstallationList?: AppInstallationFragment[]; navigateToAppInstallPage?: (manifestUrl: string) => void; navigateToVercelDeploymentPage?: (vercelDeploymentUrl: string) => void; } const AllAppList: React.FC = ({ appList, + appInstallationList, navigateToAppInstallPage, navigateToVercelDeploymentPage, }) => { @@ -28,6 +32,10 @@ const AllAppList: React.FC = ({ diff --git a/src/new-apps/components/AllAppList/styles.ts b/src/new-apps/components/AllAppList/styles.ts index 5020adfc1..ed0ac934e 100644 --- a/src/new-apps/components/AllAppList/styles.ts +++ b/src/new-apps/components/AllAppList/styles.ts @@ -4,9 +4,15 @@ export const useStyles = makeStyles( theme => ({ appListWrapper: { display: "grid", - gridTemplateColumns: `repeat(auto-fit, minmax(400px, 1fr))`, + gridTemplateColumns: `repeat(1, 1fr)`, gap: theme.spacing(2), marginTop: theme.spacing(1), + [theme.breakpoints.up(900)]: { + gridTemplateColumns: `repeat(2, 1fr)`, + }, + [theme.breakpoints.up("lg")]: { + gridTemplateColumns: `repeat(3, 1fr)`, + }, }, }), { diff --git a/src/new-apps/components/AppActivateDialog/AppActivateDialog.tsx b/src/new-apps/components/AppActivateDialog/AppActivateDialog.tsx index 59448a71d..e9ccef525 100644 --- a/src/new-apps/components/AppActivateDialog/AppActivateDialog.tsx +++ b/src/new-apps/components/AppActivateDialog/AppActivateDialog.tsx @@ -25,10 +25,10 @@ const AppActivateDialog: React.FC = ({ }) => { const intl = useIntl(); - const missingName = name === null || name === ""; + const isNameMissing = name === null || name === ""; const getMainText = () => { - if (missingName) { + if (isNameMissing) { return intl.formatMessage(msgs.activateApp); } return intl.formatMessage(msgs.activateNamedApp, { diff --git a/src/new-apps/components/AppDeactivateDialog/AppDeactivateDialog.tsx b/src/new-apps/components/AppDeactivateDialog/AppDeactivateDialog.tsx index 06692f996..2082cfa41 100644 --- a/src/new-apps/components/AppDeactivateDialog/AppDeactivateDialog.tsx +++ b/src/new-apps/components/AppDeactivateDialog/AppDeactivateDialog.tsx @@ -27,10 +27,10 @@ const AppDeactivateDialog: React.FC = ({ }) => { const intl = useIntl(); - const missingName = name === null || name === ""; + const isNameMissing = name === null || name === ""; const getMainText = () => { - if (missingName) { + if (isNameMissing) { return intl.formatMessage(msgs.deactivateApp); } return intl.formatMessage(msgs.deactivateNamedApp, { diff --git a/src/new-apps/components/AppDeleteDialog/AppDeleteDialog.tsx b/src/new-apps/components/AppDeleteDialog/AppDeleteDialog.tsx index 84c82febb..0887f3453 100644 --- a/src/new-apps/components/AppDeleteDialog/AppDeleteDialog.tsx +++ b/src/new-apps/components/AppDeleteDialog/AppDeleteDialog.tsx @@ -26,14 +26,14 @@ const AppDeleteDialog: React.FC = ({ }) => { const intl = useIntl(); - const missingName = name === null || name === ""; + const isNameMissing = name === null || name === ""; const isExternal = type === "EXTERNAL"; const getMainText = () => { - if (missingName && isExternal) { + if (isNameMissing && isExternal) { return intl.formatMessage(msgs.deleteApp); } - if (missingName) { + if (isNameMissing) { return intl.formatMessage(msgs.deleteLocalApp); } if (isExternal) { diff --git a/src/new-apps/components/AppInProgressDeleteDialog/AppInProgressDeleteDialog.stories.tsx b/src/new-apps/components/AppInProgressDeleteDialog/AppInProgressDeleteDialog.stories.tsx new file mode 100644 index 000000000..2d0fe3733 --- /dev/null +++ b/src/new-apps/components/AppInProgressDeleteDialog/AppInProgressDeleteDialog.stories.tsx @@ -0,0 +1,20 @@ +import Decorator from "@dashboard/storybook/Decorator"; +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import AppInProgressDeleteDialog, { + AppInProgressDeleteDialogProps, +} from "./AppInProgressDeleteDialog"; + +const props: AppInProgressDeleteDialogProps = { + confirmButtonState: "default", + name: "App", + onClose: () => undefined, + onConfirm: () => undefined, + open: true, +}; + +storiesOf("Views / Apps / Delete app failed installation", module) + .addDecorator(Decorator) + .add("default", () => ) + .add("unnamed app", () => ); diff --git a/src/new-apps/components/AppInProgressDeleteDialog/AppInProgressDeleteDialog.test.tsx b/src/new-apps/components/AppInProgressDeleteDialog/AppInProgressDeleteDialog.test.tsx new file mode 100644 index 000000000..1c3ec7c88 --- /dev/null +++ b/src/new-apps/components/AppInProgressDeleteDialog/AppInProgressDeleteDialog.test.tsx @@ -0,0 +1,72 @@ +import Wrapper from "@test/wrapper"; +import { render, screen } from "@testing-library/react"; +import React from "react"; + +import AppInProgressDeleteDialog from "./AppInProgressDeleteDialog"; +import msgs from "./messages"; + +describe("Apps AppInProgressDeleteDialog", () => { + it("displays action text with app name when app name passed", () => { + // Arrange + const name = "Test App"; + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.deleteNamedApp.defaultMessage.replace( + "{name}", + name, + ); + expect(dialogContent).toHaveTextContent(expectedText); + }); + + it("displays action text without app name when app name is empty", () => { + // Arrange + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.deleteApp.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedText); + }); + + it("displays action text without app name when app name is null", () => { + // Arrange + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.deleteApp.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedText); + }); +}); diff --git a/src/new-apps/components/AppInProgressDeleteDialog/AppInProgressDeleteDialog.tsx b/src/new-apps/components/AppInProgressDeleteDialog/AppInProgressDeleteDialog.tsx new file mode 100644 index 000000000..e434747dd --- /dev/null +++ b/src/new-apps/components/AppInProgressDeleteDialog/AppInProgressDeleteDialog.tsx @@ -0,0 +1,54 @@ +import ActionDialog from "@dashboard/components/ActionDialog"; +import { getStringOrPlaceholder } from "@dashboard/misc"; +import { DialogContentText } from "@material-ui/core"; +import { ConfirmButtonTransitionState } from "@saleor/macaw-ui"; +import React from "react"; +import { useIntl } from "react-intl"; + +import msgs from "./messages"; + +export interface AppInProgressDeleteDialogProps { + confirmButtonState: ConfirmButtonTransitionState; + open: boolean; + name?: string | null; + onClose: () => void; + onConfirm: () => void; +} + +const AppInProgressDeleteDialog = ({ + confirmButtonState, + open, + name, + onClose, + onConfirm, +}: AppInProgressDeleteDialogProps) => { + const intl = useIntl(); + + const isNameMissing = name === null || name === ""; + + const getMainText = () => { + if (isNameMissing) { + return intl.formatMessage(msgs.deleteApp); + } + return intl.formatMessage(msgs.deleteNamedApp, { + name: {getStringOrPlaceholder(name)}, + }); + }; + + return ( + + + {getMainText()} + + + ); +}; +AppInProgressDeleteDialog.displayName = "AppInProgressDeleteDialog"; +export default AppInProgressDeleteDialog; diff --git a/src/new-apps/components/AppInProgressDeleteDialog/index.ts b/src/new-apps/components/AppInProgressDeleteDialog/index.ts new file mode 100644 index 000000000..64c241dd2 --- /dev/null +++ b/src/new-apps/components/AppInProgressDeleteDialog/index.ts @@ -0,0 +1,2 @@ +export * from "./AppInProgressDeleteDialog"; +export { default } from "./AppInProgressDeleteDialog"; diff --git a/src/new-apps/components/AppInProgressDeleteDialog/messages.ts b/src/new-apps/components/AppInProgressDeleteDialog/messages.ts new file mode 100644 index 000000000..32930cff6 --- /dev/null +++ b/src/new-apps/components/AppInProgressDeleteDialog/messages.ts @@ -0,0 +1,20 @@ +import { defineMessages } from "react-intl"; + +export default defineMessages({ + header: { + id: "zQX6xO", + defaultMessage: "Delete App", + description: "dialog header", + }, + deleteApp: { + id: "6hLZNA", + defaultMessage: "Are you sure you want to delete this app?", + description: "delete app", + }, + deleteNamedApp: { + id: "EWD/wU", + defaultMessage: + "Deleting {name}, you will remove installation of the app. If you are paying for app subscription, remember to unsubscribe from the app in Saleor Marketplace. Are you sure you want to delete the app?", + description: "delete app", + }, +}); diff --git a/src/new-apps/components/AppListCard/AppListCard.test.tsx b/src/new-apps/components/AppListCard/AppListCard.test.tsx index cdae29ff3..84fdc64a7 100644 --- a/src/new-apps/components/AppListCard/AppListCard.test.tsx +++ b/src/new-apps/components/AppListCard/AppListCard.test.tsx @@ -1,5 +1,12 @@ -import { comingSoonApp, releasedApp } from "@dashboard/new-apps/fixtures"; +import * as context from "@dashboard/new-apps/context"; +import { + comingSoonApp, + failedAppInProgress, + pendingAppInProgress, + releasedApp, +} from "@dashboard/new-apps/fixtures"; import { GetV2SaleorAppsResponse } from "@dashboard/new-apps/marketplace.types"; +import { appInstallationStatusMessages } from "@dashboard/new-apps/messages"; import Wrapper from "@test/wrapper"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; @@ -7,6 +14,14 @@ import React from "react"; import AppListCard from "./AppListCard"; +jest.mock("@dashboard/new-apps/context", () => ({ + useAppListContext: jest.fn(() => ({ + openAppSettings: jest.fn(), + removeAppInstallation: jest.fn(), + retryAppInstallation: jest.fn(), + })), +})); + describe("Apps AppListCard", () => { it("displays released app details when released app data passed", () => { // Arrange @@ -144,4 +159,66 @@ describe("Apps AppListCard", () => { expect(logoPlaceholder?.textContent).toBe(app.name.en[0]); expect(logoImage).toBeFalsy(); }); + + it("displays app installation details when failed installation data passed", () => { + // Arrange + render( + + + , + ); + const status = screen.getByTestId("app-installation-failed"); + const statusDetails = within(status).queryByText( + appInstallationStatusMessages.failed.defaultMessage, + ); + + // Assert + expect(statusDetails).toBeTruthy(); + }); + + it("displays app installation details when pending installation data passed", () => { + // Arrange + render( + + + , + ); + const status = screen.getByTestId("app-installation-pending"); + const statusText = within(status).queryByText( + appInstallationStatusMessages.pending.defaultMessage, + ); + + // Assert + expect(statusText).toBeTruthy(); + }); + + it("calls handlers when failed installation data passed and buttons clicked", async () => { + // Arrange + const openAppSettings = jest.fn(); + const removeAppInstallation = jest.fn(); + const retryAppInstallation = jest.fn(); + jest.spyOn(context, "useAppListContext").mockImplementation(() => ({ + openAppSettings, + removeAppInstallation, + retryAppInstallation, + })); + render( + + + , + ); + const user = userEvent.setup(); + const retryButton = screen.getByTestId("app-retry-install-button"); + const removeButton = screen.getByTestId("app-remove-install-button"); + + // Act + await user.click(retryButton); + await user.click(removeButton); + + // Assert + expect(retryAppInstallation).toHaveBeenCalledWith(failedAppInProgress.id); + expect(retryAppInstallation).toHaveBeenCalledTimes(1); + expect(removeAppInstallation).toHaveBeenCalledWith(failedAppInProgress.id); + expect(removeAppInstallation).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/new-apps/components/AppListCard/AppListCard.tsx b/src/new-apps/components/AppListCard/AppListCard.tsx index 644a08963..6c9038aff 100644 --- a/src/new-apps/components/AppListCard/AppListCard.tsx +++ b/src/new-apps/components/AppListCard/AppListCard.tsx @@ -1,3 +1,5 @@ +import { AppInstallationFragment } from "@dashboard/graphql"; +import { useAppListContext } from "@dashboard/new-apps/context"; import { GetV2SaleorAppsResponse } from "@dashboard/new-apps/marketplace.types"; import { getAppDetails } from "@dashboard/new-apps/utils"; import { Card, CardContent } from "@material-ui/core"; @@ -12,24 +14,30 @@ import { useStyles } from "./styles"; interface AppListCardProps { app: GetV2SaleorAppsResponse.SaleorApp; + appInstallation?: AppInstallationFragment; navigateToAppInstallPage?: (manifestUrl: string) => void; navigateToVercelDeploymentPage?: (vercelDeploymentUrl: string) => void; } const AppListCard: React.FC = ({ app, + appInstallation, navigateToAppInstallPage, navigateToVercelDeploymentPage, }) => { const classes = useStyles(); const intl = useIntl(); + const { retryAppInstallation, removeAppInstallation } = useAppListContext(); - const details = getAppDetails( + const details = getAppDetails({ intl, app, + appInstallation, navigateToAppInstallPage, navigateToVercelDeploymentPage, - ); + retryAppInstallation, + removeAppInstallation, + }); return ( <> @@ -41,8 +49,11 @@ const AppListCard: React.FC = ({ diff --git a/src/new-apps/components/AppListCard/AppListCardActions.tsx b/src/new-apps/components/AppListCard/AppListCardActions.tsx index 1add67768..f7a4a585d 100644 --- a/src/new-apps/components/AppListCard/AppListCardActions.tsx +++ b/src/new-apps/components/AppListCard/AppListCardActions.tsx @@ -1,27 +1,45 @@ import { Button } from "@dashboard/components/Button"; import Hr from "@dashboard/components/Hr"; +import { AppInstallationFragment } from "@dashboard/graphql"; import { buttonMessages } from "@dashboard/intl"; +import { appInstallationStatusMessages } from "@dashboard/new-apps/messages"; import { CardActions, Typography } from "@material-ui/core"; import React from "react"; import { FormattedMessage } from "react-intl"; +import InstallErrorAction from "./ErrorInstallAction"; import { messages } from "./messages"; import { useActionsStyles } from "./styles"; interface AppListCardActionsProps { releaseDate: string | undefined; + installationPending?: boolean; + appInstallation?: AppInstallationFragment; installHandler?: () => void; vercelDeployHandler?: () => void; + retryInstallHandler?: () => void; + removeInstallHandler?: () => void; } const AppListCardActions: React.FC = ({ releaseDate, + installationPending = false, + appInstallation, installHandler, vercelDeployHandler, + retryInstallHandler, + removeInstallHandler, }) => { const classes = useActionsStyles(); - if (!installHandler && !vercelDeployHandler && !releaseDate) { + if ( + !installHandler && + !vercelDeployHandler && + !releaseDate && + !retryInstallHandler && + !removeInstallHandler && + !installationPending + ) { return null; } @@ -47,6 +65,19 @@ const AppListCardActions: React.FC = ({ )} + {installationPending && ( + + + + )} + {releaseDate && ( void; + removeInstall?: () => void; +} + +const InstallErrorAction = ({ + appInstallation, + retryInstall, + removeInstall, +}: InstallErrorActionProps) => { + const classes = useActionsStyles(); + + if (!retryInstall && !removeInstall) { + return null; + } + + return ( + <> + + + + + + + + + {retryInstall && ( + + )} + {removeInstall && ( + + )} + + ); +}; +InstallErrorAction.displayName = "InstallErrorAction"; +export default InstallErrorAction; diff --git a/src/new-apps/components/AppListCard/styles.ts b/src/new-apps/components/AppListCard/styles.ts index 06e1cc7e5..302f7f65e 100644 --- a/src/new-apps/components/AppListCard/styles.ts +++ b/src/new-apps/components/AppListCard/styles.ts @@ -103,6 +103,18 @@ export const useActionsStyles = makeStyles( padding: theme.spacing(2, 4), minHeight: theme.spacing(9), }, + cardActionsText: { + width: "100%", + }, + cardActionsIssueText: { + width: "100%", + color: theme.palette.error.main, + whiteSpace: "nowrap", + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: theme.spacing(1), + }, releaseDate: { color: theme.palette.saleor.main[3], }, diff --git a/src/new-apps/components/AppListPage/AppListPage.tsx b/src/new-apps/components/AppListPage/AppListPage.tsx index 85fd32f2e..046d30723 100644 --- a/src/new-apps/components/AppListPage/AppListPage.tsx +++ b/src/new-apps/components/AppListPage/AppListPage.tsx @@ -1,5 +1,6 @@ import Container from "@dashboard/components/Container"; import PageHeader from "@dashboard/components/PageHeader"; +import PreviewPill from "@dashboard/components/PreviewPill"; import useNavigator from "@dashboard/hooks/useNavigator"; import { sectionNames } from "@dashboard/intl"; import { AppUrls } from "@dashboard/new-apps/urls"; @@ -17,6 +18,7 @@ import { messages } from "./messages"; import { useStyles } from "./styles"; import { AppListPageSections } from "./types"; import { + getVerifiedAppsInstallations, getVerifiedInstallableMarketplaceApps, getVerifiedInstalledApps, resolveSectionsAvailability, @@ -28,6 +30,7 @@ export interface AppListPageProps extends AppListPageSections, ListProps { export const AppListPage: React.FC = props => { const { + appsInstallations, installedApps, installableMarketplaceApps, comingSoonMarketplaceApps, @@ -42,6 +45,10 @@ export const AppListPage: React.FC = props => { installedApps, installableMarketplaceApps, ); + const verifiedAppsIntallations = getVerifiedAppsInstallations( + appsInstallations, + installableMarketplaceApps, + ); const verifiedInstallableMarketplaceApps = getVerifiedInstallableMarketplaceApps( installedApps, installableMarketplaceApps, @@ -80,6 +87,7 @@ export const AppListPage: React.FC = props => { = props => { {sectionsAvailability.all && !marketplaceError && ( <> - + + + + + } + /> @@ -102,7 +118,10 @@ export const AppListPage: React.FC = props => { - + )} diff --git a/src/new-apps/components/AppListPage/messages.ts b/src/new-apps/components/AppListPage/messages.ts index 3871349c0..d44d38123 100644 --- a/src/new-apps/components/AppListPage/messages.ts +++ b/src/new-apps/components/AppListPage/messages.ts @@ -12,6 +12,11 @@ export const messages = defineMessages({ defaultMessage: "Installed Apps", description: "section header", }, + ongoingInstallations: { + id: "nIrjSR", + defaultMessage: "Ongoing Installations", + description: "section header", + }, allApps: { id: "yZB41s", defaultMessage: "All Apps", diff --git a/src/new-apps/components/AppListPage/styles.ts b/src/new-apps/components/AppListPage/styles.ts index 24414836a..d6a3b0b07 100644 --- a/src/new-apps/components/AppListPage/styles.ts +++ b/src/new-apps/components/AppListPage/styles.ts @@ -12,6 +12,9 @@ export const useStyles = makeStyles( margin: theme.spacing(8, 0, 3, 0), textTransform: "uppercase", }, + previewLabel: { + marginLeft: theme.spacing(2), + }, }), { name: "AppListPage" }, ); diff --git a/src/new-apps/components/AppListPage/types.ts b/src/new-apps/components/AppListPage/types.ts index 32a2525ba..0cc5079c6 100644 --- a/src/new-apps/components/AppListPage/types.ts +++ b/src/new-apps/components/AppListPage/types.ts @@ -1,7 +1,11 @@ -import { AppListItemFragment } from "@dashboard/graphql"; +import { + AppInstallationFragment, + AppListItemFragment, +} from "@dashboard/graphql"; import { GetV2SaleorAppsResponse } from "@dashboard/new-apps/marketplace.types"; export interface AppListPageSections { + appsInstallations?: AppInstallationFragment[]; installedApps?: AppListItemFragment[]; installableMarketplaceApps?: GetV2SaleorAppsResponse.ReleasedSaleorApp[]; comingSoonMarketplaceApps?: GetV2SaleorAppsResponse.ComingSoonSaleorApp[]; diff --git a/src/new-apps/components/AppListPage/utils.test.ts b/src/new-apps/components/AppListPage/utils.test.ts index d509ac0e4..b6629b644 100644 --- a/src/new-apps/components/AppListPage/utils.test.ts +++ b/src/new-apps/components/AppListPage/utils.test.ts @@ -4,6 +4,7 @@ import { PermissionEnum, } from "@dashboard/graphql"; import { + appsInProgress, comingSoonApp, installedAppsList, releasedApp, @@ -20,6 +21,7 @@ describe("App List available sections util", () => { it("should return available app list sections when all data are passed", () => { // Arrange const appSectionsData: AppListPageSections = { + appsInstallations: appsInProgress, installedApps: installedAppsList, installableMarketplaceApps: [releasedApp], comingSoonMarketplaceApps: [comingSoonApp], @@ -40,6 +42,7 @@ describe("App List available sections util", () => { it("should return no available app list sections when no data are passed", () => { // Arrange const appSectionsData: AppListPageSections = { + appsInstallations: [], installedApps: [], installableMarketplaceApps: [], comingSoonMarketplaceApps: [], @@ -56,6 +59,111 @@ describe("App List available sections util", () => { }; expect(sectionsAvailability).toEqual(expectedSectionsAvailability); }); + + it("should return only installed apps section as available when only installed apps are passed", () => { + // Arrange + const appSectionsData: AppListPageSections = { + appsInstallations: [], + installedApps: installedAppsList, + installableMarketplaceApps: [], + comingSoonMarketplaceApps: [], + }; + + // Act + const sectionsAvailability = resolveSectionsAvailability(appSectionsData); + + // Assert + const expectedSectionsAvailability = { + installed: true, + all: false, + comingSoon: false, + }; + expect(sectionsAvailability).toEqual(expectedSectionsAvailability); + }); + + it("should return only installed apps section as available when only installations are passed", () => { + // Arrange + const appSectionsData: AppListPageSections = { + appsInstallations: appsInProgress, + installedApps: [], + installableMarketplaceApps: [], + comingSoonMarketplaceApps: [], + }; + + // Act + const sectionsAvailability = resolveSectionsAvailability(appSectionsData); + + // Assert + const expectedSectionsAvailability = { + installed: true, + all: false, + comingSoon: false, + }; + expect(sectionsAvailability).toEqual(expectedSectionsAvailability); + }); + + it("should return available app list sections when all data are undefined", () => { + // Arrange + const appSectionsData: AppListPageSections = { + appsInstallations: undefined, + installedApps: undefined, + installableMarketplaceApps: undefined, + comingSoonMarketplaceApps: undefined, + }; + + // Act + const sectionsAvailability = resolveSectionsAvailability(appSectionsData); + + // Assert + const expectedSectionsAvailability = { + installed: true, + all: true, + comingSoon: true, + }; + expect(sectionsAvailability).toEqual(expectedSectionsAvailability); + }); + + it("should return installed apps section as available when installed apps passed and installations is undefined", () => { + // Arrange + const appSectionsData: AppListPageSections = { + appsInstallations: undefined, + installedApps: installedAppsList, + installableMarketplaceApps: [], + comingSoonMarketplaceApps: [], + }; + + // Act + const sectionsAvailability = resolveSectionsAvailability(appSectionsData); + + // Assert + const expectedSectionsAvailability = { + installed: true, + all: false, + comingSoon: false, + }; + expect(sectionsAvailability).toEqual(expectedSectionsAvailability); + }); + + it("should return installed apps section as available when installed apps is undefined and installations are passed", () => { + // Arrange + const appSectionsData: AppListPageSections = { + appsInstallations: appsInProgress, + installedApps: undefined, + installableMarketplaceApps: [], + comingSoonMarketplaceApps: [], + }; + + // Act + const sectionsAvailability = resolveSectionsAvailability(appSectionsData); + + // Assert + const expectedSectionsAvailability = { + installed: true, + all: false, + comingSoon: false, + }; + expect(sectionsAvailability).toEqual(expectedSectionsAvailability); + }); }); describe("App List verified installed apps util", () => { @@ -134,10 +242,15 @@ describe("App List verified installed apps util", () => { { app: installedApps[0], isExternal: false, + logo: { + source: "https://www.example.com/logo", + color: "#000000", + }, }, { app: installedApps[1], isExternal: true, + logo: undefined, }, ]; expect(verifiedInstalledApps).toEqual(expectedVerifiedInstalledApps); diff --git a/src/new-apps/components/AppListPage/utils.ts b/src/new-apps/components/AppListPage/utils.ts index 3f3e8892c..9e7c4c86f 100644 --- a/src/new-apps/components/AppListPage/utils.ts +++ b/src/new-apps/components/AppListPage/utils.ts @@ -1,34 +1,73 @@ -import { AppListItemFragment } from "@dashboard/graphql"; +import { + AppInstallationFragment, + AppListItemFragment, +} from "@dashboard/graphql"; import { GetV2SaleorAppsResponse } from "@dashboard/new-apps/marketplace.types"; -import { InstalledApp } from "@dashboard/new-apps/types"; +import { AppInstallation, InstalledApp } from "@dashboard/new-apps/types"; import { AppListPageSections } from "./types"; export const resolveSectionsAvailability = ({ + appsInstallations, installedApps, installableMarketplaceApps, comingSoonMarketplaceApps, }: AppListPageSections) => ({ - installed: !installedApps || !!installedApps.length, + installed: + !installedApps || + !!installedApps.length || + !appsInstallations || + !!appsInstallations.length, all: !installableMarketplaceApps || !!installableMarketplaceApps.length, comingSoon: !comingSoonMarketplaceApps || !!comingSoonMarketplaceApps.length, }); -const isAppExternal = ( +const findAppInMarketplace = ( manifestUrl: string | null, installableMarketplaceApps?: GetV2SaleorAppsResponse.ReleasedSaleorApp[], -) => - !!manifestUrl && - !!installableMarketplaceApps?.every(app => app.manifestUrl !== manifestUrl); +) => { + if (!manifestUrl) { + return undefined; + } + + return installableMarketplaceApps?.find( + app => app.manifestUrl === manifestUrl, + ); +}; export const getVerifiedInstalledApps = ( installedApps?: AppListItemFragment[], installableMarketplaceApps?: GetV2SaleorAppsResponse.ReleasedSaleorApp[], ): InstalledApp[] | undefined => - installedApps?.map(app => ({ - app, - isExternal: isAppExternal(app.manifestUrl, installableMarketplaceApps), - })); + installedApps?.map(app => { + const marketplaceApp = findAppInMarketplace( + app.manifestUrl, + installableMarketplaceApps, + ); + + return { + app, + isExternal: !marketplaceApp, + logo: marketplaceApp?.logo, + }; + }); + +export const getVerifiedAppsInstallations = ( + appsInstallations?: AppInstallationFragment[], + installableMarketplaceApps?: GetV2SaleorAppsResponse.ReleasedSaleorApp[], +): AppInstallation[] | undefined => + appsInstallations?.map(appInstallation => { + const marketplaceApp = findAppInMarketplace( + appInstallation.manifestUrl, + installableMarketplaceApps, + ); + + return { + appInstallation, + isExternal: !marketplaceApp, + logo: marketplaceApp?.logo, + }; + }); /* * Temporary solution for checking if app is not installed. diff --git a/src/new-apps/components/AppManifestTableDisplay/styles.ts b/src/new-apps/components/AppManifestTableDisplay/styles.ts index b0d1a745b..df58ae4c1 100644 --- a/src/new-apps/components/AppManifestTableDisplay/styles.ts +++ b/src/new-apps/components/AppManifestTableDisplay/styles.ts @@ -8,6 +8,7 @@ export const useStyles = makeStyles( }, manifestText: { color: theme.palette.text.secondary, + display: "inline", "&:hover svg": { visibility: "visible", }, diff --git a/src/new-apps/components/InstalledAppList/InstalledAppList.tsx b/src/new-apps/components/InstalledAppList/InstalledAppList.tsx index 463489786..1e578779c 100644 --- a/src/new-apps/components/InstalledAppList/InstalledAppList.tsx +++ b/src/new-apps/components/InstalledAppList/InstalledAppList.tsx @@ -1,34 +1,50 @@ import { TablePaginationWithContext } from "@dashboard/components/TablePagination"; -import { InstalledApp } from "@dashboard/new-apps/types"; +import { AppInstallation, InstalledApp } from "@dashboard/new-apps/types"; import { ListProps } from "@dashboard/types"; import { Table, TableBody, TableFooter, TableRow } from "@material-ui/core"; import { Skeleton } from "@material-ui/lab"; import React from "react"; import InstalledAppListRow from "../InstalledAppListRow"; +import NotInstalledAppListRow from "../NotInstalledAppListRow"; import { useStyles } from "./styles"; interface InstalledAppListProps extends ListProps { appList?: InstalledApp[]; + appInstallationList?: AppInstallation[]; } const InstalledAppList: React.FC = ({ appList, + appInstallationList, disabled, settings, onUpdateListSettings, }) => { const classes = useStyles(); - if (!appList) { + if (!appList || !appInstallationList) { return ; } return ( - {appList.map(({ app, isExternal }) => ( - + {appInstallationList?.map(({ appInstallation, logo, isExternal }) => ( + + ))} + {appList.map(({ app, isExternal, logo }) => ( + ))} diff --git a/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.test.tsx b/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.test.tsx index b2fc2ab75..d41a01dc1 100644 --- a/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.test.tsx +++ b/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.test.tsx @@ -3,10 +3,10 @@ import { AppListContext, AppListContextValues, } from "@dashboard/new-apps/context"; -import { activeApp, inactiveApp } from "@dashboard/new-apps/fixtures"; +import { activeApp } from "@dashboard/new-apps/fixtures"; import { InstalledApp } from "@dashboard/new-apps/types"; import Wrapper from "@test/wrapper"; -import { render, screen, within } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; import { MemoryRouter as Router } from "react-router-dom"; @@ -32,9 +32,9 @@ const Component = ({ describe("Apps InstalledAppListRow", () => { it("displays app details when basic app data passed", () => { // Arrange - const activateApp = jest.fn(); - const deactivateApp = jest.fn(); - const removeApp = jest.fn(); + const openAppSettings = jest.fn(); + const removeAppInstallation = jest.fn(); + const retryAppInstallation = jest.fn(); render( { isExternal: false, }} context={{ - activateApp, - deactivateApp, - removeApp, + openAppSettings, + removeAppInstallation, + retryAppInstallation, }} />, ); @@ -68,9 +68,9 @@ describe("Apps InstalledAppListRow", () => { it("displays external label when app is external", () => { // Arrange - const activateApp = jest.fn(); - const deactivateApp = jest.fn(); - const removeApp = jest.fn(); + const openAppSettings = jest.fn(); + const removeAppInstallation = jest.fn(); + const retryAppInstallation = jest.fn(); render( { isExternal: true, }} context={{ - activateApp, - deactivateApp, - removeApp, + openAppSettings, + removeAppInstallation, + retryAppInstallation, }} />, ); @@ -92,9 +92,9 @@ describe("Apps InstalledAppListRow", () => { it("displays tunnnel label when app is served via tunnnel", () => { // Arrange - const activateApp = jest.fn(); - const deactivateApp = jest.fn(); - const removeApp = jest.fn(); + const openAppSettings = jest.fn(); + const removeAppInstallation = jest.fn(); + const retryAppInstallation = jest.fn(); const AppsConfig = getAppsConfig(); render( { isExternal: false, }} context={{ - activateApp, - deactivateApp, - removeApp, + openAppSettings, + removeAppInstallation, + retryAppInstallation, }} />, ); @@ -119,11 +119,11 @@ describe("Apps InstalledAppListRow", () => { expect(tunnelLabel).toBeTruthy(); }); - it("calls handlers when active app data passed and buttons clicked", async () => { + it("calls handlers when app data passed and buttons clicked", async () => { // Arrange - const activateApp = jest.fn(); - const deactivateApp = jest.fn(); - const removeApp = jest.fn(); + const openAppSettings = jest.fn(); + const removeAppInstallation = jest.fn(); + const retryAppInstallation = jest.fn(); render( { isExternal: false, }} context={{ - activateApp, - deactivateApp, - removeApp, + openAppSettings, + removeAppInstallation, + retryAppInstallation, }} />, ); const user = userEvent.setup(); - const activeSwitch = within( - screen.getByTestId("app-active-switch"), - ).getByRole("checkbox"); - const removeButton = screen.getByTestId("app-remove-button"); - - // Assert - expect(activeSwitch).toBeChecked(); + const settingsButton = screen.getByTestId("app-settings-button"); // Act - await user.click(activeSwitch); - await user.click(removeButton); + await user.click(settingsButton); // Assert - expect(deactivateApp).toHaveBeenCalledWith(activeApp.id); - expect(deactivateApp).toHaveBeenCalledTimes(1); - expect(activateApp).not.toHaveBeenCalled(); - expect(removeApp).toHaveBeenCalledWith(activeApp.id); - expect(removeApp).toHaveBeenCalledTimes(1); - }); - - it("calls handlers when inactive app data passed and buttons clicked", async () => { - // Arrange - const activateApp = jest.fn(); - const deactivateApp = jest.fn(); - const removeApp = jest.fn(); - render( - , - ); - const user = userEvent.setup(); - const activeSwitch = within( - screen.getByTestId("app-active-switch"), - ).getByRole("checkbox"); - const removeButton = screen.getByTestId("app-remove-button"); - - // Assert - expect(activeSwitch).not.toBeChecked(); - - // Act - await user.click(activeSwitch); - await user.click(removeButton); - - // Assert - expect(activateApp).toHaveBeenCalledWith(inactiveApp.id); - expect(activateApp).toHaveBeenCalledTimes(1); - expect(deactivateApp).not.toHaveBeenCalled(); - expect(removeApp).toHaveBeenCalledWith(inactiveApp.id); - expect(removeApp).toHaveBeenCalledTimes(1); + expect(openAppSettings).toHaveBeenCalledWith(activeApp.id); + expect(openAppSettings).toHaveBeenCalledTimes(1); }); }); diff --git a/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.tsx b/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.tsx index 1c992f95a..7079c76b8 100644 --- a/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.tsx +++ b/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.tsx @@ -2,11 +2,12 @@ import { TableButtonWrapper } from "@dashboard/components/TableButtonWrapper/Tab import TableCellAvatar from "@dashboard/components/TableCellAvatar"; import TableRowLink from "@dashboard/components/TableRowLink"; import { useAppListContext } from "@dashboard/new-apps/context"; +import { appsMessages } from "@dashboard/new-apps/messages"; import { InstalledApp } from "@dashboard/new-apps/types"; import { AppUrls } from "@dashboard/new-apps/urls"; import { isAppInTunnel } from "@dashboard/new-apps/utils"; -import { Switch, TableCell, Typography } from "@material-ui/core"; -import { DeleteIcon, IconButton, Pill } from "@saleor/macaw-ui"; +import { TableCell, Typography } from "@material-ui/core"; +import { IconButton, Pill, SettingsIcon } from "@saleor/macaw-ui"; import clsx from "clsx"; import React from "react"; import { useIntl } from "react-intl"; @@ -16,27 +17,18 @@ import { AppPermissions } from "../AppPermissions"; import { messages } from "./messages"; import { useStyles } from "./styles"; -export const InstalledAppListRow: React.FC = ({ - app, - isExternal, -}) => { +export const InstalledAppListRow: React.FC = props => { + const { app, isExternal, logo } = props; const intl = useIntl(); - const classes = useStyles(); - const { activateApp, deactivateApp, removeApp } = useAppListContext(); - - const handleToggleActive = () => { - if (app.isActive) { - deactivateApp(app.id); - } else { - activateApp(app.id); - } - }; + const classes = useStyles(props); + const { openAppSettings } = useAppListContext(); return (
@@ -50,7 +42,7 @@ export const InstalledAppListRow: React.FC = ({ )} @@ -70,22 +62,15 @@ export const InstalledAppListRow: React.FC = ({ {`(${intl.formatMessage(messages.tunnelDevelopment)})`} ) : null} - - - removeApp(app.id)} - data-test-id="app-remove-button" + onClick={() => openAppSettings(app.id)} + data-test-id="app-settings-button" > - +
diff --git a/src/new-apps/components/InstalledAppListRow/messages.ts b/src/new-apps/components/InstalledAppListRow/messages.ts index c3ef5fc9e..914beda14 100644 --- a/src/new-apps/components/InstalledAppListRow/messages.ts +++ b/src/new-apps/components/InstalledAppListRow/messages.ts @@ -1,11 +1,6 @@ import { defineMessages } from "react-intl"; export const messages = defineMessages({ - externalApp: { - id: "nA2g2e", - defaultMessage: "External app", - description: "label", - }, tunnelDevelopment: { id: "RjT+Vo", defaultMessage: "Tunnel - development", diff --git a/src/new-apps/components/InstalledAppListRow/styles.ts b/src/new-apps/components/InstalledAppListRow/styles.ts index d79ababe7..bccd5f005 100644 --- a/src/new-apps/components/InstalledAppListRow/styles.ts +++ b/src/new-apps/components/InstalledAppListRow/styles.ts @@ -1,12 +1,20 @@ +import { InstalledApp } from "@dashboard/new-apps/types"; import { makeStyles } from "@saleor/macaw-ui"; -export const useStyles = makeStyles( +export const useStyles = makeStyles( theme => ({ mainContent: { display: "flex", alignItems: "center", gap: theme.spacing(2), }, + logo: props => ({ + backgroundColor: props.logo?.color || theme.palette.grey[300], + color: props.logo?.color ? "#fff" : theme.palette.grey[600], + padding: theme.spacing(1.2), + borderRadius: theme.spacing(1), + borderWidth: 0, + }), name: { fontWeight: 600, }, diff --git a/src/new-apps/components/NotInstalledAppListRow/NotInstalledAppListRow.test.tsx b/src/new-apps/components/NotInstalledAppListRow/NotInstalledAppListRow.test.tsx new file mode 100644 index 000000000..9cb5a6df9 --- /dev/null +++ b/src/new-apps/components/NotInstalledAppListRow/NotInstalledAppListRow.test.tsx @@ -0,0 +1,102 @@ +import * as context from "@dashboard/new-apps/context"; +import { + failedAppInProgress, + pendingAppInProgress, +} from "@dashboard/new-apps/fixtures"; +import Wrapper from "@test/wrapper"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; + +import NotInstalledAppListRow from "./NotInstalledAppListRow"; + +jest.mock("@dashboard/new-apps/context", () => ({ + useAppListContext: jest.fn(() => ({ + openAppSettings: jest.fn(), + removeAppInstallation: jest.fn(), + retryAppInstallation: jest.fn(), + })), +})); + +describe("Apps NotInstalledAppListRow", () => { + it("displays app installation details when failed installation data passed", () => { + // Arrange + render( + + + , + ); + const name = screen.queryByText(failedAppInProgress.appName); + const manifestDomain = screen.queryByText( + new URL(failedAppInProgress.manifestUrl as string).host, + ); + const pendingLabel = screen.queryByTestId("app-pending-label"); + const failedLabel = screen.queryByTestId("app-failed-label"); + + // Assert + expect(name).toBeTruthy(); + expect(manifestDomain).toBeTruthy(); + expect(pendingLabel).toBeFalsy(); + expect(failedLabel).toBeTruthy(); + }); + + it("displays app installation details when pending installation data passed", () => { + // Arrange + render( + + + , + ); + const name = screen.queryByText(pendingAppInProgress.appName); + const manifestDomain = screen.queryByText( + new URL(pendingAppInProgress.manifestUrl as string).host, + ); + const pendingLabel = screen.queryByTestId("app-pending-label"); + const failedLabel = screen.queryByTestId("app-failed-label"); + + // Assert + expect(name).toBeTruthy(); + expect(manifestDomain).toBeTruthy(); + expect(pendingLabel).toBeTruthy(); + expect(failedLabel).toBeFalsy(); + }); + + it("calls handlers when app installation data passed and buttons clicked", async () => { + // Arrange + const openAppSettings = jest.fn(); + const removeAppInstallation = jest.fn(); + const retryAppInstallation = jest.fn(); + jest.spyOn(context, "useAppListContext").mockImplementation(() => ({ + openAppSettings, + removeAppInstallation, + retryAppInstallation, + })); + render( + + + , + ); + const user = userEvent.setup(); + const retryButton = screen.getByTestId("app-installation-retry-button"); + const removeButton = screen.getByTestId("app-installation-remove-button"); + + // Act + await user.click(retryButton); + await user.click(removeButton); + + // Assert + expect(retryAppInstallation).toHaveBeenCalledWith(failedAppInProgress.id); + expect(retryAppInstallation).toHaveBeenCalledTimes(1); + expect(removeAppInstallation).toHaveBeenCalledWith(failedAppInProgress.id); + expect(removeAppInstallation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/new-apps/components/NotInstalledAppListRow/NotInstalledAppListRow.tsx b/src/new-apps/components/NotInstalledAppListRow/NotInstalledAppListRow.tsx new file mode 100644 index 000000000..95be9b631 --- /dev/null +++ b/src/new-apps/components/NotInstalledAppListRow/NotInstalledAppListRow.tsx @@ -0,0 +1,118 @@ +import TableButtonWrapper from "@dashboard/components/TableButtonWrapper/TableButtonWrapper"; +import TableCellAvatar from "@dashboard/components/TableCellAvatar"; +import TableRowLink from "@dashboard/components/TableRowLink"; +import { JobStatusEnum } from "@dashboard/graphql"; +import { buttonMessages } from "@dashboard/intl"; +import { useAppListContext } from "@dashboard/new-apps/context"; +import { + appInstallationStatusMessages, + appsMessages, +} from "@dashboard/new-apps/messages"; +import { AppInstallation } from "@dashboard/new-apps/types"; +import { CircularProgress, TableCell, Typography } from "@material-ui/core"; +import { + Button, + DeleteIcon, + IconButton, + Indicator, + Pill, + Tooltip, + TooltipMountWrapper, +} from "@saleor/macaw-ui"; +import clsx from "clsx"; +import React from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import AppManifestTableDisplay from "../AppManifestTableDisplay"; +import { useStyles } from "./styles"; + +export const NotInstalledAppListRow: React.FC = props => { + const { appInstallation, isExternal, logo } = props; + const intl = useIntl(); + const classes = useStyles(props); + const { retryAppInstallation, removeAppInstallation } = useAppListContext(); + + return ( + + +
+ + {appInstallation.appName} + + {isExternal && ( + + )} +
+ {appInstallation.manifestUrl && ( + + )} +
+ +
+ {appInstallation?.status === JobStatusEnum.PENDING && ( + <> + + {intl.formatMessage(appInstallationStatusMessages.pending)} + +
+ +
+ + )} + {appInstallation?.status === JobStatusEnum.FAILED && ( + <> + + + + + + + + + + + + + removeAppInstallation(appInstallation.id)} + data-test-id="app-installation-remove-button" + > + + + + + )} +
+
+
+ ); +}; + +export default NotInstalledAppListRow; diff --git a/src/new-apps/components/NotInstalledAppListRow/index.ts b/src/new-apps/components/NotInstalledAppListRow/index.ts new file mode 100644 index 000000000..bfc88e870 --- /dev/null +++ b/src/new-apps/components/NotInstalledAppListRow/index.ts @@ -0,0 +1,2 @@ +export * from "./NotInstalledAppListRow"; +export { default } from "./NotInstalledAppListRow"; diff --git a/src/new-apps/components/NotInstalledAppListRow/messages.ts b/src/new-apps/components/NotInstalledAppListRow/messages.ts new file mode 100644 index 000000000..914beda14 --- /dev/null +++ b/src/new-apps/components/NotInstalledAppListRow/messages.ts @@ -0,0 +1,9 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + tunnelDevelopment: { + id: "RjT+Vo", + defaultMessage: "Tunnel - development", + description: "label", + }, +}); diff --git a/src/new-apps/components/NotInstalledAppListRow/styles.ts b/src/new-apps/components/NotInstalledAppListRow/styles.ts new file mode 100644 index 000000000..a0555e119 --- /dev/null +++ b/src/new-apps/components/NotInstalledAppListRow/styles.ts @@ -0,0 +1,78 @@ +import { AppInstallation } from "@dashboard/new-apps/types"; +import { makeStyles } from "@saleor/macaw-ui"; + +export const useStyles = makeStyles( + theme => ({ + mainContent: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + }, + logo: props => ({ + backgroundColor: props.logo?.color || theme.palette.grey[300], + color: props.logo?.color ? "#fff" : theme.palette.grey[600], + padding: theme.spacing(1.2), + borderRadius: theme.spacing(1), + borderWidth: 0, + }), + name: { + fontWeight: 600, + }, + tunnel: { + color: theme.palette.saleor.main[3], + whiteSpace: "nowrap", + }, + failed: { + color: theme.palette.error.main, + whiteSpace: "nowrap", + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: theme.spacing(1), + }, + pending: { + color: theme.palette.saleor.main[3], + whiteSpace: "nowrap", + }, + colSpinner: { + "& svg": { + textAlign: "right", + }, + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(2), + display: "flex", + alignItems: "center", + }, + actions: { + alignItems: "center", + display: "flex", + flexDirection: "row", + justifyContent: "flex-end", + textAlign: "right", + gap: theme.spacing(1), + }, + externalAppLabel: { + cursor: "pointer", + }, + col: { + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + }, + colLogo: { + width: "100%", + "&.MuiTableCell-root:first-child:not(.MuiTableCell-paddingCheckbox)": { + paddingLeft: theme.spacing(3), + width: "100%", + }, + height: "auto", + }, + colActions: { + "&&": { + paddingRight: theme.spacing(3), + textAlign: "right", + }, + height: "auto", + }, + }), + { name: "InstalledAppListRow" }, +); diff --git a/src/new-apps/components/SectionHeader/SectionHeader.tsx b/src/new-apps/components/SectionHeader/SectionHeader.tsx index 76025da08..df6acaa99 100644 --- a/src/new-apps/components/SectionHeader/SectionHeader.tsx +++ b/src/new-apps/components/SectionHeader/SectionHeader.tsx @@ -1,10 +1,10 @@ import { Typography } from "@material-ui/core"; -import React from "react"; +import React, { ReactNode } from "react"; import { useStyles } from "./styles"; interface SectionHeaderProps { - title: string; + title: ReactNode; } export const SectionHeader: React.FC = ({ title }) => { diff --git a/src/new-apps/context.ts b/src/new-apps/context.ts index 136974f99..e82095d8b 100644 --- a/src/new-apps/context.ts +++ b/src/new-apps/context.ts @@ -2,9 +2,9 @@ import React from "react"; export interface AppListContextValues { - activateApp: (appId: string) => void; - deactivateApp: (appId: string) => void; - removeApp: (appId: string) => void; + removeAppInstallation: (installationId: string) => void; + retryAppInstallation: (installationId: string) => void; + openAppSettings: (appId: string) => void; } export const AppListContext = React.createContext< diff --git a/src/new-apps/fixtures.ts b/src/new-apps/fixtures.ts index 5d389826a..e2ab889a1 100644 --- a/src/new-apps/fixtures.ts +++ b/src/new-apps/fixtures.ts @@ -1,7 +1,7 @@ import { + AppInstallationFragment, AppListItemFragment, AppManifestFragment, - AppsInstallationsQuery, AppTypeEnum, JobStatusEnum, PermissionEnum, @@ -55,31 +55,37 @@ export const installedAppsList: AppListItemFragment[] = [ inactiveApp, ]; -export const appsInProgress: AppsInstallationsQuery["appsInstallations"] = [ - { - __typename: "AppInstallation", - appName: "app", - id: "QXBwSW5zdGFsbGF0aW9uOjk2", - manifestUrl: "http://localhost:3000/manifest", - message: "Failed to connect to app. Try later or contact with app support.", - status: JobStatusEnum.FAILED, - }, - { - __typename: "AppInstallation", - appName: "app pending", - id: "QXBwSW5zdGFsbGF0aW9uOjk2", - manifestUrl: "http://localhost:3000/manifest", - message: "Pending.", - status: JobStatusEnum.PENDING, - }, - { - __typename: "AppInstallation", - appName: "app success", - id: "QXBwSW5zdGFsbGF0aW9uOjk2", - manifestUrl: "http://localhost:3000/manifest", - message: "Success.", - status: JobStatusEnum.SUCCESS, - }, +export const failedAppInProgress: AppInstallationFragment = { + __typename: "AppInstallation", + appName: "app", + id: "QXBwSW5zdGFsbGF0aW9uOjk2", + manifestUrl: "http://localhost:3000/manifest", + message: "Failed to connect to app. Try later or contact with app support.", + status: JobStatusEnum.FAILED, +}; + +export const pendingAppInProgress: AppInstallationFragment = { + __typename: "AppInstallation", + appName: "app pending", + id: "QXBwSW5zdGFsbGF0aW9uOjk2", + manifestUrl: "http://localhost:3000/manifest", + message: "Pending.", + status: JobStatusEnum.PENDING, +}; + +export const successAppInProgress: AppInstallationFragment = { + __typename: "AppInstallation", + appName: "app success", + id: "QXBwSW5zdGFsbGF0aW9uOjk2", + manifestUrl: "http://localhost:3000/manifest", + message: "Success.", + status: JobStatusEnum.SUCCESS, +}; + +export const appsInProgress: AppInstallationFragment[] = [ + failedAppInProgress, + pendingAppInProgress, + successAppInProgress, ]; export const installApp: AppManifestFragment = { diff --git a/src/new-apps/hooks/useActiveAppsInstallations.ts b/src/new-apps/hooks/useActiveAppsInstallations.ts new file mode 100644 index 000000000..818e54598 --- /dev/null +++ b/src/new-apps/hooks/useActiveAppsInstallations.ts @@ -0,0 +1,178 @@ +import { useApolloClient } from "@apollo/client"; +import { EXTENSION_LIST_QUERY } from "@dashboard/apps/queries"; +import { + AppInstallationFragment, + AppsInstallationsQuery, + JobStatusEnum, + useAppDeleteFailedInstallationMutation, + useAppRetryInstallMutation, +} from "@dashboard/graphql"; +import useLocalStorage from "@dashboard/hooks/useLocalStorage"; +import { useEffect, useRef } from "react"; + +interface UseActiveAppsInstallations { + appsInProgressData: AppsInstallationsQuery | undefined; + appsInProgressRefetch: () => void; + appsRefetch: () => void; + removeInProgressAppNotify: () => void; + installedAppNotify: (name: string) => void; + onInstallSuccess: () => void; + onInstallError: (installation: AppInstallationFragment) => void; + onRemoveInProgressAppSuccess: () => void; +} + +function useActiveAppsInstallations({ + appsInProgressData, + appsInProgressRefetch, + appsRefetch, + installedAppNotify, + removeInProgressAppNotify, + onInstallSuccess, + onInstallError, + onRemoveInProgressAppSuccess, +}: UseActiveAppsInstallations) { + const client = useApolloClient(); + const [activeInstallations, setActiveInstallations] = useLocalStorage< + Array> + >("activeInstallations", []); + const intervalId = useRef(null); + + const refetchExtensionList = () => { + client.refetchQueries({ + include: [EXTENSION_LIST_QUERY], + }); + }; + + const removeInstallation = (id: string) => + setActiveInstallations(installations => + installations.filter(item => item.id !== id), + ); + + const [retryInstallApp, retryInstallAppOpts] = useAppRetryInstallMutation({ + onCompleted: data => { + if (!data?.appRetryInstall?.errors?.length) { + const appInstallation = data.appRetryInstall?.appInstallation; + if (appInstallation) { + setActiveInstallations(installations => [ + ...installations, + { + id: appInstallation.id, + name: appInstallation.appName, + }, + ]); + } + } + }, + }); + + const handleAppInstallRetry = (id: string) => + retryInstallApp({ variables: { id } }); + + const [ + deleteInProgressApp, + deleteInProgressAppOpts, + ] = useAppDeleteFailedInstallationMutation({ + onCompleted: data => { + if (!data?.appDeleteFailedInstallation?.errors?.length) { + removeInProgressAppNotify(); + appsInProgressRefetch(); + onRemoveInProgressAppSuccess(); + } + }, + }); + + const handleRemoveInProgress = (id: string) => + deleteInProgressApp({ + variables: { + id, + }, + }); + + /** + * Check if there has occured by any reason untracked installation with status PENDING and add it to activeInstallations. + */ + useEffect( + () => + appsInProgressData?.appsInstallations?.forEach(app => { + if (app.status === JobStatusEnum.PENDING) { + const item = activeInstallations.find( + installation => installation.id === app.id, + ); + if (!item) { + setActiveInstallations(installations => [ + ...installations, + { + id: app.id, + name: app.appName, + }, + ]); + } + } + }), + [appsInProgressData], + ); + + /** + * Fetch active installations to make its status in localStorage up to date. + */ + useEffect(() => { + if (activeInstallations.length && !!appsInProgressData) { + if (!intervalId.current) { + intervalId.current = window.setInterval(() => { + appsInProgressRefetch(); + appsRefetch(); + }, 2000); + } + } + if (!activeInstallations.length && intervalId.current) { + clearInterval(intervalId.current); + intervalId.current = null; + } + + return () => { + if (intervalId.current) { + clearInterval(intervalId.current); + intervalId.current = null; + } + }; + }, [activeInstallations.length, appsInProgressData]); + + /** + * Do what is needed and call chaange handlers when installation status changes. + */ + useEffect(() => { + const appsInProgress = appsInProgressData?.appsInstallations || []; + if (activeInstallations.length && !!appsInProgressData) { + let newAppInstalled = false; + activeInstallations.forEach(installation => { + const item = appsInProgress?.find(app => app.id === installation.id); + if (!item) { + removeInstallation(installation.id); + installedAppNotify(installation.name); + appsInProgressRefetch(); + appsRefetch(); + newAppInstalled = true; + } else if (item.status === JobStatusEnum.SUCCESS) { + removeInstallation(installation.id); + installedAppNotify(item.appName); + onInstallSuccess(); + newAppInstalled = true; + } else if (item.status === JobStatusEnum.FAILED) { + removeInstallation(installation.id); + onInstallError(item); + } + }); + if (newAppInstalled) { + refetchExtensionList(); + } + } + }, [activeInstallations.length, appsInProgressData]); + + return { + handleAppInstallRetry, + handleRemoveInProgress, + retryInstallAppOpts, + deleteInProgressAppOpts, + }; +} +export default useActiveAppsInstallations; diff --git a/src/new-apps/messages.ts b/src/new-apps/messages.ts index 79d7c46b4..01df76c51 100644 --- a/src/new-apps/messages.ts +++ b/src/new-apps/messages.ts @@ -21,4 +21,22 @@ export const appsMessages = defineMessages({ defaultMessage: "App Manifest URL", description: "label", }, + externalApp: { + id: "nA2g2e", + defaultMessage: "External app", + description: "label", + }, +}); + +export const appInstallationStatusMessages = defineMessages({ + pending: { + id: "F1VtFa", + defaultMessage: "Installation is pending...", + description: "information", + }, + failed: { + id: "26VlBZ", + defaultMessage: "Problem occured during installation.", + description: "information", + }, }); diff --git a/src/new-apps/types.ts b/src/new-apps/types.ts index 506e470e8..ab28e8c9a 100644 --- a/src/new-apps/types.ts +++ b/src/new-apps/types.ts @@ -1,8 +1,23 @@ -import { AppListItemFragment } from "@dashboard/graphql"; +import { + AppInstallationFragment, + AppListItemFragment, +} from "@dashboard/graphql"; + +interface AppLogo { + source: string | null; + color: string; +} export interface InstalledApp { app: AppListItemFragment; isExternal: boolean; + logo?: AppLogo; +} + +export interface AppInstallation { + appInstallation: AppInstallationFragment; + isExternal: boolean; + logo?: AppLogo; } export interface AppLink { diff --git a/src/new-apps/urls.ts b/src/new-apps/urls.ts index 820b36f43..83e653760 100644 --- a/src/new-apps/urls.ts +++ b/src/new-apps/urls.ts @@ -4,7 +4,7 @@ import urlJoin from "url-join"; import { Dialog, SingleAction } from "../types"; -export type AppListUrlDialog = "remove-app" | "app-activate" | "app-deactivate"; +export type AppListUrlDialog = "app-installation-remove"; export type AppListUrlQueryParams = Dialog & SingleAction; export const AppSections = { @@ -14,6 +14,7 @@ export const AppSections = { export const AppPaths = { appListPath: AppSections.appsSection, resolveAppPath: (id: string) => urlJoin(AppSections.appsSection, id, "app"), + resolveAppDetailsPath: (id: string) => urlJoin(AppSections.appsSection, id), appInstallPath: urlJoin(AppSections.appsSection, "install"), }; @@ -22,6 +23,10 @@ export const AppUrls = { AppPaths.appListPath + "?" + stringifyQs(params), resolveAppUrl: (id: string, params?: AppDetailsUrlQueryParams) => AppPaths.resolveAppPath(encodeURIComponent(id)) + "?" + stringifyQs(params), + resolveAppDetailsUrl: (id: string, params?: AppDetailsUrlQueryParams) => + AppPaths.resolveAppDetailsPath(encodeURIComponent(id)) + + "?" + + stringifyQs(params), resolveAppInstallUrl: (manifestUrl: string) => `${AppPaths.appInstallPath}?manifestUrl=${manifestUrl}`, }; diff --git a/src/new-apps/utils.test.ts b/src/new-apps/utils.test.ts index 12dbb444a..e3718e7e0 100644 --- a/src/new-apps/utils.test.ts +++ b/src/new-apps/utils.test.ts @@ -1,11 +1,13 @@ +import { AppInstallationFragment, JobStatusEnum } from "@dashboard/graphql"; import { intlMock } from "@test/intl"; +import { appsInProgress, releasedApp } from "./fixtures"; import { GetV2SaleorAppsResponse } from "./marketplace.types"; -import { getAppDetails } from "./utils"; +import { getAppDetails, resolveInstallationOfMarketplaceApp } from "./utils"; type AppDetails = ReturnType; -describe("App utils", () => { +describe("App utils app details", () => { it("should return app details when required released app data passed", () => { // Arrange const app: GetV2SaleorAppsResponse.ReleasedSaleorApp = { @@ -28,18 +30,24 @@ describe("App utils", () => { }; // Act - const details = getAppDetails( - intlMock, + const details = getAppDetails({ + intl: intlMock, app, - () => undefined, - () => undefined, - ); + appInstallation: undefined, + navigateToAppInstallPage: () => undefined, + navigateToVercelDeploymentPage: () => undefined, + removeAppInstallation: () => undefined, + retryAppInstallation: () => undefined, + }); // Assert const expectedDetails: AppDetails = { releaseDate: undefined, installHandler: expect.any(Function), vercelDeployHandler: expect.any(Function), + installationPending: undefined, + removeInstallHandler: undefined, + retryInstallHandler: undefined, links: [ { name: expect.any(String), @@ -76,7 +84,15 @@ describe("App utils", () => { }; // Act - const details = getAppDetails(intlMock, app); + const details = getAppDetails({ + intl: intlMock, + app, + appInstallation: undefined, + navigateToAppInstallPage: () => undefined, + navigateToVercelDeploymentPage: () => undefined, + removeAppInstallation: () => undefined, + retryAppInstallation: () => undefined, + }); // Assert const expectedDetails: AppDetails = { @@ -84,7 +100,169 @@ describe("App utils", () => { installHandler: undefined, vercelDeployHandler: undefined, links: [], + installationPending: undefined, + removeInstallHandler: undefined, + retryInstallHandler: undefined, }; expect(details).toEqual(expectedDetails); }); + + it("should return app details when required app pending installation data passed", () => { + // Arrange + const app: GetV2SaleorAppsResponse.ReleasedSaleorApp = { + name: { + en: "Test app", + }, + description: { + en: "Test app description", + }, + logo: { + source: "https://www.example.com/logo", + color: "#000000", + }, + integrations: [], + manifestUrl: "https://www.example.com/manifest", + privacyUrl: "https://www.example.com/privacy", + supportUrl: "https://www.example.com/support", + repositoryUrl: "https://www.example.com/repository", + vercelDeploymentUrl: "https://www.example.com/deployment", + }; + const appInstallation: AppInstallationFragment = { + __typename: "AppInstallation", + id: "test-installation-id", + appName: "Test app", + status: JobStatusEnum.PENDING, + message: "Test message", + manifestUrl: "https://www.example.com/manifest", + }; + + // Act + const details = getAppDetails({ + intl: intlMock, + app, + appInstallation, + navigateToAppInstallPage: () => undefined, + navigateToVercelDeploymentPage: () => undefined, + removeAppInstallation: () => undefined, + retryAppInstallation: () => undefined, + }); + + // Assert + const expectedDetails: AppDetails = { + releaseDate: undefined, + installHandler: undefined, + vercelDeployHandler: undefined, + links: [ + { + name: expect.any(String), + url: "https://www.example.com/repository", + }, + { + name: expect.any(String), + url: "https://www.example.com/support", + }, + { + name: expect.any(String), + url: "https://www.example.com/privacy", + }, + ], + installationPending: true, + removeInstallHandler: undefined, + retryInstallHandler: undefined, + }; + expect(details).toEqual(expectedDetails); + }); + + it("should return app details when required app failed installation data passed", () => { + // Arrange + const app: GetV2SaleorAppsResponse.ReleasedSaleorApp = { + name: { + en: "Test app", + }, + description: { + en: "Test app description", + }, + logo: { + source: "https://www.example.com/logo", + color: "#000000", + }, + integrations: [], + manifestUrl: "https://www.example.com/manifest", + privacyUrl: "https://www.example.com/privacy", + supportUrl: "https://www.example.com/support", + repositoryUrl: "https://www.example.com/repository", + vercelDeploymentUrl: "https://www.example.com/deployment", + }; + const appInstallation: AppInstallationFragment = { + __typename: "AppInstallation", + id: "test-installation-id", + appName: "Test app", + status: JobStatusEnum.FAILED, + message: "Test message", + manifestUrl: "https://www.example.com/manifest", + }; + + // Act + const details = getAppDetails({ + intl: intlMock, + app, + appInstallation, + navigateToAppInstallPage: () => undefined, + navigateToVercelDeploymentPage: () => undefined, + removeAppInstallation: () => undefined, + retryAppInstallation: () => undefined, + }); + + // Assert + const expectedDetails: AppDetails = { + releaseDate: undefined, + installHandler: undefined, + vercelDeployHandler: undefined, + links: [ + { + name: expect.any(String), + url: "https://www.example.com/repository", + }, + { + name: expect.any(String), + url: "https://www.example.com/support", + }, + { + name: expect.any(String), + url: "https://www.example.com/privacy", + }, + ], + installationPending: false, + removeInstallHandler: expect.any(Function), + retryInstallHandler: expect.any(Function), + }; + expect(details).toEqual(expectedDetails); + }); + + describe("App utils resolve apps installations", () => { + it("should return app installation that has manifest related to passed app details when app installation list and app details are passed", () => { + // Arrange + const releasedAppInstallation: AppInstallationFragment = { + __typename: "AppInstallation", + id: "test-installation-id", + appName: releasedApp.name.en, + status: JobStatusEnum.PENDING, + message: "Test message", + manifestUrl: releasedApp.manifestUrl, + }; + const appInstallationList: AppInstallationFragment[] = [ + releasedAppInstallation, + ...appsInProgress, + ]; + + // Act + const installation = resolveInstallationOfMarketplaceApp( + releasedApp, + appInstallationList, + ); + + // Assert + expect(installation).toEqual(releasedAppInstallation); + }); + }); }); diff --git a/src/new-apps/utils.ts b/src/new-apps/utils.ts index aaf081450..cdeecd967 100644 --- a/src/new-apps/utils.ts +++ b/src/new-apps/utils.ts @@ -1,4 +1,5 @@ import { getAppsConfig } from "@dashboard/config"; +import { AppInstallationFragment, JobStatusEnum } from "@dashboard/graphql"; import { IntlShape } from "react-intl"; import { GetV2SaleorAppsResponse } from "./marketplace.types"; @@ -22,6 +23,27 @@ const getComingSoonMarketplaceApps = ( "releaseDate" in app, ) as GetV2SaleorAppsResponse.ComingSoonSaleorApp[] | undefined; +const getAppManifestUrl = ( + marketplaceApp: GetV2SaleorAppsResponse.SaleorApp, +) => { + if ("manifestUrl" in marketplaceApp) { + return marketplaceApp.manifestUrl; + } +}; + +export const resolveInstallationOfMarketplaceApp = ( + marketplaceApp: GetV2SaleorAppsResponse.SaleorApp, + appInstallations?: AppInstallationFragment[], +) => { + const manifestUrl = getAppManifestUrl(marketplaceApp); + + if (manifestUrl) { + return appInstallations?.find( + appInstallation => appInstallation.manifestUrl === manifestUrl, + ); + } +}; + export const getMarketplaceAppsLists = ( isMarketplaceAvailable: boolean, marketplaceAppList?: GetV2SaleorAppsResponse.SaleorApp[], @@ -66,12 +88,25 @@ const prepareAppLinks = ( }, ]; -export const getAppDetails = ( - intl: IntlShape, - app: GetV2SaleorAppsResponse.SaleorApp, - navigateToAppInstallPage?: (url: string) => void, - navigateToVercelDeploymentPage?: (url?: string) => void, -) => { +interface GetAppDetailsOpts { + intl: IntlShape; + app: GetV2SaleorAppsResponse.SaleorApp; + appInstallation?: AppInstallationFragment; + navigateToAppInstallPage?: (url: string) => void; + navigateToVercelDeploymentPage?: (url?: string) => void; + retryAppInstallation: (installationId: string) => void; + removeAppInstallation: (installationId: string) => void; +} + +export const getAppDetails = ({ + intl, + app, + appInstallation, + navigateToAppInstallPage, + navigateToVercelDeploymentPage, + retryAppInstallation, + removeAppInstallation, +}: GetAppDetailsOpts) => { const isAppComingSoon = !("manifestUrl" in app) && !("vercelDeploymentUrl" in app) && @@ -79,16 +114,34 @@ export const getAppDetails = ( const isAppInstallable = "manifestUrl" in app && !!navigateToAppInstallPage; const isAppVercelDeployable = "vercelDeploymentUrl" in app && !!navigateToVercelDeploymentPage; + const installationPending = + appInstallation && appInstallation.status === JobStatusEnum.PENDING; return { - releaseDate: isAppComingSoon ? app.releaseDate : undefined, - installHandler: isAppInstallable - ? () => navigateToAppInstallPage(app.manifestUrl) - : undefined, + releaseDate: + !appInstallation && isAppComingSoon ? app.releaseDate : undefined, + installHandler: + !appInstallation && isAppInstallable + ? () => navigateToAppInstallPage(app.manifestUrl) + : undefined, vercelDeployHandler: - isAppVercelDeployable && !!app.vercelDeploymentUrl + !appInstallation && isAppVercelDeployable && !!app.vercelDeploymentUrl ? () => navigateToVercelDeploymentPage(app.vercelDeploymentUrl) : undefined, + installationPending, + retryInstallHandler: + appInstallation && !installationPending + ? () => retryAppInstallation(appInstallation.id) + : undefined, + removeInstallHandler: + appInstallation && !installationPending + ? () => removeAppInstallation(appInstallation.id) + : undefined, links: isAppComingSoon ? [] : prepareAppLinks(intl, app), }; }; + +export const getAppInProgressName = ( + id: string, + collection?: AppInstallationFragment[], +) => collection?.find(app => app.id === id)?.appName || id; diff --git a/src/new-apps/views/AppList/AppList.tsx b/src/new-apps/views/AppList/AppList.tsx index 18a7a8a53..e92cd8437 100644 --- a/src/new-apps/views/AppList/AppList.tsx +++ b/src/new-apps/views/AppList/AppList.tsx @@ -1,13 +1,10 @@ -import { useApolloClient } from "@apollo/client"; -import { EXTENSION_LIST_QUERY } from "@dashboard/apps/queries"; import { getAppsConfig } from "@dashboard/config"; import { + AppInstallationFragment, AppSortField, AppTypeEnum, OrderDirection, - useAppActivateMutation, - useAppDeactivateMutation, - useAppDeleteMutation, + useAppsInstallationsQuery, useAppsListQuery, } from "@dashboard/graphql"; import useListSettings from "@dashboard/hooks/useListSettings"; @@ -17,22 +14,23 @@ import useLocalPaginator, { import useNavigator from "@dashboard/hooks/useNavigator"; import useNotifier from "@dashboard/hooks/useNotifier"; import { PaginatorContext } from "@dashboard/hooks/usePaginator"; -import { findById } from "@dashboard/misc"; -import AppActivateDialog from "@dashboard/new-apps/components/AppActivateDialog"; -import AppDeactivateDialog from "@dashboard/new-apps/components/AppDeactivateDialog"; -import AppDeleteDialog from "@dashboard/new-apps/components/AppDeleteDialog"; +import AppInProgressDeleteDialog from "@dashboard/new-apps/components/AppInProgressDeleteDialog"; import AppListPage from "@dashboard/new-apps/components/AppListPage/AppListPage"; import { AppListContext, AppListContextValues, } from "@dashboard/new-apps/context"; +import useActiveAppsInstallations from "@dashboard/new-apps/hooks/useActiveAppsInstallations"; import useMarketplaceApps from "@dashboard/new-apps/hooks/useMarketplaceApps"; import { AppListUrlDialog, AppListUrlQueryParams, AppUrls, } from "@dashboard/new-apps/urls"; -import { getMarketplaceAppsLists } from "@dashboard/new-apps/utils"; +import { + getAppInProgressName, + getMarketplaceAppsLists, +} from "@dashboard/new-apps/utils"; import { ListViews } from "@dashboard/types"; import createDialogActionHandlers from "@dashboard/utils/handlers/dialogActionHandlers"; import { mapEdgesToItems } from "@dashboard/utils/maps"; @@ -49,7 +47,7 @@ export const AppsList: React.FC = ({ params }) => { const navigate = useNavigator(); const notify = useNotifier(); const intl = useIntl(); - const client = useApolloClient(); + const [openModal, closeModal] = createDialogActionHandlers< AppListUrlDialog, AppListUrlQueryParams @@ -69,7 +67,11 @@ export const AppsList: React.FC = ({ params }) => { ); const paginate = useLocalPaginator(setPaginationState); - const { data: installedAppsData, loading, refetch } = useAppsListQuery({ + const { + data: installedAppsData, + loading, + refetch: appsRefetch, + } = useAppsListQuery({ displayLoader: true, variables: { ...paginationState, @@ -84,74 +86,63 @@ export const AppsList: React.FC = ({ params }) => { paginationState, ); - const refetchExtensionList = () => { - client.refetchQueries({ - include: [EXTENSION_LIST_QUERY], + const { + data: appsInProgressData, + refetch: appsInProgressRefetch, + } = useAppsInstallationsQuery({ + displayLoader: false, + }); + + const installedAppNotify = (name: string) => { + notify({ + status: "success", + text: intl.formatMessage(messages.appReadyToUse, { name }), + title: intl.formatMessage(messages.appInstalled), }); }; - const handleRemoveConfirm = () => - deleteApp({ - variables: { - id: params.id || "", - }, - }); - - const removeAppNotify = () => { + const removeInProgressAppNotify = () => { notify({ status: "success", text: intl.formatMessage(messages.appRemoved), }); }; - const handleActivateAppConfirm = () => - activateApp({ variables: { id: params.id || "" } }); + const onAppInstallError = (item: AppInstallationFragment) => { + notify({ + status: "error", + text: item.message, + title: intl.formatMessage(messages.appCouldntInstall, { + name: item.appName, + }), + }); + }; - const handleDeactivateAppConfirm = () => - deactivateApp({ variables: { id: params.id || "" } }); - - const [deleteApp, deleteAppOpts] = useAppDeleteMutation({ - onCompleted: data => { - if (!data?.appDelete?.errors?.length) { - refetch(); - closeModal(); - refetchExtensionList(); - removeAppNotify(); - } - }, - }); - const [activateApp, activateAppResult] = useAppActivateMutation({ - onCompleted: data => { - if (!data?.appActivate?.errors?.length) { - notify({ - status: "success", - text: intl.formatMessage(messages.appActivated), - }); - refetch(); - closeModal(); - } - }, - }); - const [deactivateApp, deactivateAppResult] = useAppDeactivateMutation({ - onCompleted: data => { - if (!data?.appDeactivate?.errors?.length) { - notify({ - status: "success", - text: intl.formatMessage(messages.appDeactivated), - }); - refetch(); - closeModal(); - } + const { + handleAppInstallRetry, + handleRemoveInProgress, + deleteInProgressAppOpts, + } = useActiveAppsInstallations({ + appsInProgressData, + appsInProgressRefetch, + appsRefetch, + installedAppNotify, + removeInProgressAppNotify, + onInstallSuccess: () => { + appsRefetch(); + appsInProgressRefetch(); }, + onInstallError: onAppInstallError, + onRemoveInProgressAppSuccess: closeModal, }); const context: AppListContextValues = React.useMemo( () => ({ - activateApp: id => openModal("app-activate", { id }), - deactivateApp: id => openModal("app-deactivate", { id }), - removeApp: id => openModal("remove-app", { id }), + retryAppInstallation: handleAppInstallRetry, + removeAppInstallation: id => openModal("app-installation-remove", { id }), + openAppSettings: id => navigate(AppUrls.resolveAppDetailsUrl(id)), }), - [activateApp, deactivateApp, deleteApp], + [navigate, openModal], ); const { data: marketplaceAppList, error } = useMarketplaceApps( @@ -165,35 +156,24 @@ export const AppsList: React.FC = ({ params }) => { !!AppsConfig.marketplaceApiUri, marketplaceAppList, ); + const appsInstallations = appsInProgressData?.appsInstallations; const installedApps = mapEdgesToItems(installedAppsData?.apps); - const currentAppName = params.id && findById(params.id, installedApps)?.name; return ( - - - handleRemoveInProgress(params?.id || "")} + open={params.action === "app-installation-remove"} />