Improve contextual actions in new apps marketplace (#2975)

This commit is contained in:
Dawid 2023-01-19 12:54:57 +01:00 committed by GitHub
parent ae5af8ff3d
commit 513655f3a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 1598 additions and 301 deletions

View file

@ -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"
},

View file

@ -36,11 +36,7 @@ export const appsList = gql`
export const appsInProgressList = gql`
query AppsInstallations {
appsInstallations {
status
message
appName
manifestUrl
id
...AppInstallation
}
}
`;

View file

@ -108,9 +108,9 @@ export const AppDetails: React.FC<AppDetailsProps> = ({ id, params }) => {
onCompleted: data => {
if (!data?.appDelete?.errors?.length) {
refetch();
closeModal();
refetchExtensionList();
removeAppNotify();
navigate(appsListPath);
}
},
});

View file

@ -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

View file

@ -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<Types.AppsListQuery, Types.
export const AppsInstallationsDocument = gql`
query AppsInstallations {
appsInstallations {
status
message
appName
manifestUrl
id
...AppInstallation
}
}
`;
${AppInstallationFragmentDoc}`;
/**
* __useAppsInstallationsQuery__

View file

@ -7057,6 +7057,8 @@ export type AppManifestFragment = { __typename: 'Manifest', identifier: string,
export type AppFragment = { __typename: 'App', id: string, name: string | null, created: any | null, isActive: boolean | null, type: AppTypeEnum | null, homepageUrl: string | null, appUrl: string | null, manifestUrl: string | null, configurationUrl: string | null, supportUrl: string | null, version: string | null, accessToken: string | null, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, 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 };

View file

@ -352,6 +352,11 @@ export const buttonMessages = defineMessages({
defaultMessage: "Install",
description: "button",
},
retry: {
id: "KMxiiF",
defaultMessage: "Retry",
description: "button",
},
});
export const sectionNames = defineMessages({

View file

@ -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<AllAppListProps> = ({
appList,
appInstallationList,
navigateToAppInstallPage,
navigateToVercelDeploymentPage,
}) => {
@ -28,6 +32,10 @@ const AllAppList: React.FC<AllAppListProps> = ({
<AppListCard
key={app.name.en}
app={app}
appInstallation={resolveInstallationOfMarketplaceApp(
app,
appInstallationList,
)}
navigateToAppInstallPage={navigateToAppInstallPage}
navigateToVercelDeploymentPage={navigateToVercelDeploymentPage}
/>

View file

@ -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)`,
},
},
}),
{

View file

@ -25,10 +25,10 @@ const AppActivateDialog: React.FC<AppActivateDialogProps> = ({
}) => {
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, {

View file

@ -27,10 +27,10 @@ const AppDeactivateDialog: React.FC<AppDeactivateDialogProps> = ({
}) => {
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, {

View file

@ -26,14 +26,14 @@ const AppDeleteDialog: React.FC<AppDeleteDialogProps> = ({
}) => {
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) {

View file

@ -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", () => <AppInProgressDeleteDialog {...props} />)
.add("unnamed app", () => <AppInProgressDeleteDialog {...props} name="" />);

View file

@ -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(
<Wrapper>
<AppInProgressDeleteDialog
confirmButtonState="default"
open={true}
name={name}
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppInProgressDeleteDialog
confirmButtonState="default"
open={true}
name={""}
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppInProgressDeleteDialog
confirmButtonState="default"
open={true}
name={null}
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
const dialogContent = screen.getByTestId("dialog-content");
// Assert
const expectedText = msgs.deleteApp.defaultMessage;
expect(dialogContent).toHaveTextContent(expectedText);
});
});

View file

@ -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: <strong>{getStringOrPlaceholder(name)}</strong>,
});
};
return (
<ActionDialog
confirmButtonState={confirmButtonState}
open={open}
onClose={onClose}
onConfirm={onConfirm}
title={intl.formatMessage(msgs.header)}
variant="delete"
>
<DialogContentText data-test-id="dialog-content">
{getMainText()}
</DialogContentText>
</ActionDialog>
);
};
AppInProgressDeleteDialog.displayName = "AppInProgressDeleteDialog";
export default AppInProgressDeleteDialog;

View file

@ -0,0 +1,2 @@
export * from "./AppInProgressDeleteDialog";
export { default } from "./AppInProgressDeleteDialog";

View file

@ -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",
},
});

View file

@ -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(
<Wrapper>
<AppListCard app={releasedApp} appInstallation={failedAppInProgress} />
</Wrapper>,
);
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(
<Wrapper>
<AppListCard app={releasedApp} appInstallation={pendingAppInProgress} />
</Wrapper>,
);
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(
<Wrapper>
<AppListCard app={releasedApp} appInstallation={failedAppInProgress} />
</Wrapper>,
);
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);
});
});

View file

@ -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<AppListCardProps> = ({
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<AppListCardProps> = ({
</CardContent>
<AppListCardActions
releaseDate={details.releaseDate}
installationPending={details.installationPending}
installHandler={details.installHandler}
vercelDeployHandler={details.vercelDeployHandler}
retryInstallHandler={details.retryInstallHandler}
removeInstallHandler={details.removeInstallHandler}
/>
</Card>
</>

View file

@ -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<AppListCardActionsProps> = ({
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<AppListCardActionsProps> = ({
<FormattedMessage {...buttonMessages.install} />
</Button>
)}
{installationPending && (
<Typography
className={classes.cardActionsText}
data-test-id="app-installation-pending"
>
<FormattedMessage {...appInstallationStatusMessages.pending} />
</Typography>
)}
<InstallErrorAction
appInstallation={appInstallation}
retryInstall={retryInstallHandler}
removeInstall={removeInstallHandler}
/>
{releaseDate && (
<Typography className={classes.releaseDate}>
<FormattedMessage

View file

@ -0,0 +1,68 @@
import { AppInstallationFragment } from "@dashboard/graphql";
import { buttonMessages } from "@dashboard/intl";
import { appInstallationStatusMessages } from "@dashboard/new-apps/messages";
import { Typography } from "@material-ui/core";
import {
Button,
Indicator,
Tooltip,
TooltipMountWrapper,
} from "@saleor/macaw-ui";
import React from "react";
import { FormattedMessage } from "react-intl";
import { useActionsStyles } from "./styles";
interface InstallErrorActionProps {
appInstallation?: AppInstallationFragment;
retryInstall?: () => void;
removeInstall?: () => void;
}
const InstallErrorAction = ({
appInstallation,
retryInstall,
removeInstall,
}: InstallErrorActionProps) => {
const classes = useActionsStyles();
if (!retryInstall && !removeInstall) {
return null;
}
return (
<>
<Typography
className={classes.cardActionsIssueText}
data-test-id="app-installation-failed"
>
<FormattedMessage {...appInstallationStatusMessages.failed} />
<Tooltip title={appInstallation?.message} variant="error">
<TooltipMountWrapper>
<Indicator icon="error" />
</TooltipMountWrapper>
</Tooltip>
</Typography>
{retryInstall && (
<Button
variant="secondary"
onClick={retryInstall}
data-test-id="app-retry-install-button"
>
<FormattedMessage {...buttonMessages.retry} />
</Button>
)}
{removeInstall && (
<Button
variant="secondary"
onClick={removeInstall}
data-test-id="app-remove-install-button"
>
<FormattedMessage {...buttonMessages.cancel} />
</Button>
)}
</>
);
};
InstallErrorAction.displayName = "InstallErrorAction";
export default InstallErrorAction;

View file

@ -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],
},

View file

@ -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<AppListPageProps> = props => {
const {
appsInstallations,
installedApps,
installableMarketplaceApps,
comingSoonMarketplaceApps,
@ -42,6 +45,10 @@ export const AppListPage: React.FC<AppListPageProps> = props => {
installedApps,
installableMarketplaceApps,
);
const verifiedAppsIntallations = getVerifiedAppsInstallations(
appsInstallations,
installableMarketplaceApps,
);
const verifiedInstallableMarketplaceApps = getVerifiedInstallableMarketplaceApps(
installedApps,
installableMarketplaceApps,
@ -80,6 +87,7 @@ export const AppListPage: React.FC<AppListPageProps> = props => {
<SectionHeader title={intl.formatMessage(messages.installedApps)} />
<InstalledAppList
appList={verifiedInstalledApps}
appInstallationList={verifiedAppsIntallations}
disabled={disabled}
settings={settings}
onUpdateListSettings={onUpdateListSettings}
@ -89,9 +97,17 @@ export const AppListPage: React.FC<AppListPageProps> = props => {
<MarketplaceAlert error={marketplaceError} />
{sectionsAvailability.all && !marketplaceError && (
<>
<SectionHeader title={intl.formatMessage(messages.allApps)} />
<SectionHeader
title={
<>
<FormattedMessage {...messages.allApps} />
<PreviewPill className={classes.previewLabel} />
</>
}
/>
<AllAppList
appList={verifiedInstallableMarketplaceApps}
appInstallationList={appsInstallations}
navigateToAppInstallPage={navigateToAppInstallPage}
navigateToVercelDeploymentPage={navigateToVercelDeploymentPage}
/>
@ -102,7 +118,10 @@ export const AppListPage: React.FC<AppListPageProps> = props => {
<SectionHeader
title={intl.formatMessage(messages.comingSoonApps)}
/>
<AllAppList appList={comingSoonMarketplaceApps} />
<AllAppList
appList={comingSoonMarketplaceApps}
appInstallationList={appsInstallations}
/>
</>
)}
</div>

View file

@ -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",

View file

@ -12,6 +12,9 @@ export const useStyles = makeStyles(
margin: theme.spacing(8, 0, 3, 0),
textTransform: "uppercase",
},
previewLabel: {
marginLeft: theme.spacing(2),
},
}),
{ name: "AppListPage" },
);

View file

@ -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[];

View file

@ -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);

View file

@ -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.

View file

@ -8,6 +8,7 @@ export const useStyles = makeStyles(
},
manifestText: {
color: theme.palette.text.secondary,
display: "inline",
"&:hover svg": {
visibility: "visible",
},

View file

@ -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<InstalledAppListProps> = ({
appList,
appInstallationList,
disabled,
settings,
onUpdateListSettings,
}) => {
const classes = useStyles();
if (!appList) {
if (!appList || !appInstallationList) {
return <Skeleton />;
}
return (
<Table className={classes.table}>
<TableBody>
{appList.map(({ app, isExternal }) => (
<InstalledAppListRow key={app.id} app={app} isExternal={isExternal} />
{appInstallationList?.map(({ appInstallation, logo, isExternal }) => (
<NotInstalledAppListRow
key={appInstallation.id}
appInstallation={appInstallation}
isExternal={isExternal}
logo={logo}
/>
))}
{appList.map(({ app, isExternal, logo }) => (
<InstalledAppListRow
key={app.id}
app={app}
isExternal={isExternal}
logo={logo}
/>
))}
</TableBody>
<TableFooter>

View file

@ -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(
<Component
data={{
@ -42,9 +42,9 @@ describe("Apps InstalledAppListRow", () => {
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(
<Component
data={{
@ -78,9 +78,9 @@ describe("Apps InstalledAppListRow", () => {
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(
<Component
@ -107,9 +107,9 @@ describe("Apps InstalledAppListRow", () => {
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(
<Component
data={{
@ -131,69 +131,20 @@ describe("Apps InstalledAppListRow", () => {
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(
<Component
data={{
app: inactiveApp,
isExternal: false,
}}
context={{
activateApp,
deactivateApp,
removeApp,
}}
/>,
);
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);
});
});

View file

@ -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<InstalledApp> = ({
app,
isExternal,
}) => {
export const InstalledAppListRow: React.FC<InstalledApp> = 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 (
<TableRowLink className={classes.row} href={AppUrls.resolveAppUrl(app.id)}>
<TableCellAvatar
initials={app.name?.[0]?.toUpperCase()}
thumbnail={undefined}
thumbnail={logo?.source || undefined}
avatarProps={classes.logo}
className={clsx(classes.col, classes.colLogo)}
>
<div className={classes.mainContent}>
@ -50,7 +42,7 @@ export const InstalledAppListRow: React.FC<InstalledApp> = ({
<Pill
color="warning"
className={classes.externalAppLabel}
label={intl.formatMessage(messages.externalApp)}
label={intl.formatMessage(appsMessages.externalApp)}
data-test-id="app-external-label"
/>
)}
@ -70,22 +62,15 @@ export const InstalledAppListRow: React.FC<InstalledApp> = ({
{`(${intl.formatMessage(messages.tunnelDevelopment)})`}
</Typography>
) : null}
<TableButtonWrapper>
<Switch
checked={!!app.isActive}
onChange={handleToggleActive}
data-test-id="app-active-switch"
/>
</TableButtonWrapper>
<AppPermissions permissions={app.permissions} />
<TableButtonWrapper>
<IconButton
variant="secondary"
color="primary"
onClick={() => removeApp(app.id)}
data-test-id="app-remove-button"
onClick={() => openAppSettings(app.id)}
data-test-id="app-settings-button"
>
<DeleteIcon />
<SettingsIcon />
</IconButton>
</TableButtonWrapper>
</div>

View file

@ -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",

View file

@ -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<InstalledApp>(
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,
},

View file

@ -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(
<Wrapper>
<NotInstalledAppListRow
appInstallation={failedAppInProgress}
isExternal={false}
/>
</Wrapper>,
);
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(
<Wrapper>
<NotInstalledAppListRow
appInstallation={pendingAppInProgress}
isExternal={false}
/>
</Wrapper>,
);
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(
<Wrapper>
<NotInstalledAppListRow
appInstallation={failedAppInProgress}
isExternal={false}
/>
</Wrapper>,
);
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);
});
});

View file

@ -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<AppInstallation> = props => {
const { appInstallation, isExternal, logo } = props;
const intl = useIntl();
const classes = useStyles(props);
const { retryAppInstallation, removeAppInstallation } = useAppListContext();
return (
<TableRowLink>
<TableCellAvatar
initials={appInstallation.appName?.[0]?.toUpperCase()}
thumbnail={logo?.source || undefined}
avatarProps={classes.logo}
className={clsx(classes.col, classes.colLogo)}
>
<div className={classes.mainContent}>
<Typography variant="body1" className={classes.name}>
{appInstallation.appName}
</Typography>
{isExternal && (
<Pill
color="warning"
className={classes.externalAppLabel}
label={intl.formatMessage(appsMessages.externalApp)}
data-test-id="app-external-label"
/>
)}
</div>
{appInstallation.manifestUrl && (
<AppManifestTableDisplay manifestUrl={appInstallation.manifestUrl} />
)}
</TableCellAvatar>
<TableCell className={clsx(classes.col, classes.colActions)}>
<div className={classes.actions}>
{appInstallation?.status === JobStatusEnum.PENDING && (
<>
<Typography
variant="caption"
className={classes.pending}
data-test-id="app-pending-label"
>
{intl.formatMessage(appInstallationStatusMessages.pending)}
</Typography>
<div className={classes.colSpinner}>
<CircularProgress size={20} />
</div>
</>
)}
{appInstallation?.status === JobStatusEnum.FAILED && (
<>
<Typography
variant="body2"
className={classes.failed}
data-test-id="app-failed-label"
>
<FormattedMessage {...appInstallationStatusMessages.failed} />
<Tooltip title={appInstallation.message} variant="error">
<TooltipMountWrapper>
<Indicator icon="error" />
</TooltipMountWrapper>
</Tooltip>
</Typography>
<TableButtonWrapper>
<Button
variant="secondary"
color="primary"
onClick={() => retryAppInstallation(appInstallation.id)}
data-test-id="app-installation-retry-button"
>
<FormattedMessage {...buttonMessages.retry} />
</Button>
</TableButtonWrapper>
<TableButtonWrapper>
<IconButton
variant="secondary"
color="primary"
onClick={() => removeAppInstallation(appInstallation.id)}
data-test-id="app-installation-remove-button"
>
<DeleteIcon />
</IconButton>
</TableButtonWrapper>
</>
)}
</div>
</TableCell>
</TableRowLink>
);
};
export default NotInstalledAppListRow;

View file

@ -0,0 +1,2 @@
export * from "./NotInstalledAppListRow";
export { default } from "./NotInstalledAppListRow";

View file

@ -0,0 +1,9 @@
import { defineMessages } from "react-intl";
export const messages = defineMessages({
tunnelDevelopment: {
id: "RjT+Vo",
defaultMessage: "Tunnel - development",
description: "label",
},
});

View file

@ -0,0 +1,78 @@
import { AppInstallation } from "@dashboard/new-apps/types";
import { makeStyles } from "@saleor/macaw-ui";
export const useStyles = makeStyles<AppInstallation>(
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" },
);

View file

@ -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<SectionHeaderProps> = ({ title }) => {

View file

@ -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<

View file

@ -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 = {

View file

@ -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<Record<"id" | "name", string>>
>("activeInstallations", []);
const intervalId = useRef<null | number>(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;

View file

@ -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",
},
});

View file

@ -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 {

View file

@ -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<AppListUrlDialog> & 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}`,
};

View file

@ -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<typeof getAppDetails>;
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);
});
});
});

View file

@ -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;

View file

@ -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<AppsListProps> = ({ 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<AppsListProps> = ({ 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<AppsListProps> = ({ 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<AppsListProps> = ({ params }) => {
!!AppsConfig.marketplaceApiUri,
marketplaceAppList,
);
const appsInstallations = appsInProgressData?.appsInstallations;
const installedApps = mapEdgesToItems(installedAppsData?.apps);
const currentAppName = params.id && findById(params.id, installedApps)?.name;
return (
<AppListContext.Provider value={context}>
<PaginatorContext.Provider value={{ ...pageInfo, ...paginationValues }}>
<AppDeleteDialog
confirmButtonState={deleteAppOpts.status}
name={currentAppName}
<AppInProgressDeleteDialog
confirmButtonState={deleteInProgressAppOpts.status}
name={getAppInProgressName(
params.id || "",
appsInProgressData?.appsInstallations,
)}
onClose={closeModal}
onConfirm={handleRemoveConfirm}
type="EXTERNAL"
open={params.action === "remove-app" && !!params.id}
/>
<AppActivateDialog
confirmButtonState={activateAppResult.status}
name={currentAppName}
onClose={closeModal}
onConfirm={handleActivateAppConfirm}
open={params.action === "app-activate" && !!params.id}
/>
<AppDeactivateDialog
confirmButtonState={deactivateAppResult.status}
name={currentAppName}
onClose={closeModal}
onConfirm={handleDeactivateAppConfirm}
open={params.action === "app-deactivate" && !!params.id}
onConfirm={() => handleRemoveInProgress(params?.id || "")}
open={params.action === "app-installation-remove"}
/>
<AppListPage
appsInstallations={appsInstallations}
installedApps={installedApps}
installableMarketplaceApps={installableMarketplaceApps}
comingSoonMarketplaceApps={comingSoonMarketplaceApps}

View file

@ -6,14 +6,19 @@ export const messages = defineMessages({
defaultMessage: "App successfully removed",
description: "app has been removed",
},
appActivated: {
id: "D/+84n",
defaultMessage: "App activated",
description: "snackbar text",
appInstalled: {
id: "0fM/pV",
defaultMessage: "App installed",
description: "message title",
},
appDeactivated: {
id: "USO8PB",
defaultMessage: "App deactivated",
description: "snackbar text",
appReadyToUse: {
id: "ZprV2g",
defaultMessage: "{name} is ready to be used",
description: "app has been installed",
},
appCouldntInstall: {
id: "5t/4um",
defaultMessage: "Couldnt Install {name}",
description: "message title",
},
});