Refactor AppDetailsPage to Macaw Next (#3818)

* Refactor AppDetailsPage to Macaw Next

* Update locales

* CR fixes

* Add error boundary
This commit is contained in:
Lukasz Ostrowski 2023-06-29 15:50:13 +02:00 committed by GitHub
parent c12a42932d
commit a1482cc453
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 203 additions and 128 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-dashboard": patch
---
Refactored Manage App screen to use Macaw/next. Added missing empty-state messages, like missing permissions or app description.

View file

@ -1927,6 +1927,10 @@
"context": "dialog content", "context": "dialog content",
"string": "Which address would you like to use as shipping address for selected customer:" "string": "Which address would you like to use as shipping address for selected customer:"
}, },
"CHoZ8S": {
"context": "app privacy policy link",
"string": "View this apps privacy policy."
},
"CJEIRC": { "CJEIRC": {
"string": "Product export has finished and was sent to your email address." "string": "Product export has finished and was sent to your email address."
}, },
@ -2597,10 +2601,6 @@
"context": "button", "context": "button",
"string": "Unassign" "string": "Unassign"
}, },
"Go50v2": {
"context": "app privacy policy link",
"string": "View this apps privacy policy"
},
"GpqEl5": { "GpqEl5": {
"context": "shipping method description", "context": "shipping method description",
"string": "Description" "string": "Description"
@ -5444,6 +5444,9 @@
"b+jcaN": { "b+jcaN": {
"string": "There are still fulfillments created for this order. Cancel the fulfillments first before you cancel the order." "string": "There are still fulfillments created for this order. Cancel the fulfillments first before you cancel the order."
}, },
"b088Xv": {
"string": "App doesn't provide a description."
},
"b1t9bM": { "b1t9bM": {
"context": "empty headers text", "context": "empty headers text",
"string": "No custom request headers created for this webhook. Use the button below to add new custom request header." "string": "No custom request headers created for this webhook. Use the button below to add new custom request header."
@ -5946,6 +5949,9 @@
"context": "attributes, section header", "context": "attributes, section header",
"string": "Variant Attributes" "string": "Variant Attributes"
}, },
"f3hf+w": {
"string": "App doesn't provide a privacy policy."
},
"f91E8b": { "f91E8b": {
"context": "app repository", "context": "app repository",
"string": "Repository" "string": "Repository"
@ -8231,6 +8237,9 @@
"context": "order refund amount", "context": "order refund amount",
"string": "Proposed refund amount" "string": "Proposed refund amount"
}, },
"wDYozn": {
"string": "App doesn't have any permissions granted."
},
"wHdMAX": { "wHdMAX": {
"context": "sale value, header", "context": "sale value, header",
"string": "Value" "string": "Value"

View file

@ -1,27 +1,45 @@
import CardTitle from "@dashboard/components/CardTitle";
import Skeleton from "@dashboard/components/Skeleton"; import Skeleton from "@dashboard/components/Skeleton";
import { Card, CardContent } from "@material-ui/core"; import { Box, BoxProps, Text } from "@saleor/macaw-ui/next";
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import messages from "./messages"; import messages from "./messages";
interface AboutCardProps { type AboutCardProps = {
aboutApp?: string | null; aboutApp?: string | null;
loading: boolean; loading: boolean;
} } & BoxProps;
const AboutCard: React.FC<AboutCardProps> = ({ aboutApp, loading }) => { export const AboutCard: React.FC<AboutCardProps> = ({
aboutApp,
loading,
...boxProps
}) => {
const intl = useIntl(); const intl = useIntl();
const renderContent = () => {
if (loading) {
return <Skeleton />;
}
if (aboutApp) {
return <ReactMarkdown source={aboutApp} />;
}
if (!aboutApp) {
return <Text>{intl.formatMessage(messages.noAboutApp)}</Text>;
}
throw new Error('Leaking "if" statement, should never happen');
};
return ( return (
<Card> <Box {...boxProps}>
<CardTitle title={intl.formatMessage(messages.aboutAppTitle)} /> <Text variant={"heading"} as={"h2"} marginBottom={4}>
<CardContent> {intl.formatMessage(messages.aboutAppTitle)}
{!loading ? <ReactMarkdown source={aboutApp ?? ""} /> : <Skeleton />} </Text>
</CardContent> <Box>{renderContent()}</Box>
</Card> </Box>
); );
}; };
export default AboutCard;

View file

@ -1,7 +1,7 @@
import { Meta, StoryObj } from "@storybook/react"; import { Meta, StoryObj } from "@storybook/react";
import { appDetails } from "../../fixtures"; import { appDetails } from "../../fixtures";
import AppDetailsPage, { AppDetailsPageProps } from "./AppDetailsPage"; import { AppDetailsPage, AppDetailsPageProps } from "./AppDetailsPage";
const props: AppDetailsPageProps = { const props: AppDetailsPageProps = {
data: appDetails, data: appDetails,

View file

@ -3,7 +3,7 @@ import { render } from "@testing-library/react";
import React from "react"; import React from "react";
import { appDetails } from "../../fixtures"; import { appDetails } from "../../fixtures";
import AppDetailsPage from "./AppDetailsPage"; import { AppDetailsPage } from "./AppDetailsPage";
const mockHeader = jest.fn(); const mockHeader = jest.fn();
jest.mock("./Header", () => props => { jest.mock("./Header", () => props => {
@ -12,22 +12,29 @@ jest.mock("./Header", () => props => {
}); });
const mockAboutCard = jest.fn(); const mockAboutCard = jest.fn();
jest.mock("./AboutCard", () => props => { jest.mock("./AboutCard", () => ({
mockAboutCard(props); AboutCard: props => {
return <></>; mockAboutCard(props);
}); return <></>;
},
}));
const mockPermissionsCard = jest.fn(); const mockPermissionsCard = jest.fn();
jest.mock("./PermissionsCard", () => props => {
mockPermissionsCard(props); jest.mock("./PermissionsCard", () => ({
return <></>; PermissionsCard: props => {
}); mockPermissionsCard(props);
return <></>;
},
}));
const mockDataPrivacyCard = jest.fn(); const mockDataPrivacyCard = jest.fn();
jest.mock("./DataPrivacyCard", () => props => { jest.mock("./DataPrivacyCard", () => ({
mockDataPrivacyCard(props); DataPrivacyCard: props => {
return <></>; mockDataPrivacyCard(props);
}); return <></>;
},
}));
beforeEach(() => { beforeEach(() => {
mockHeader.mockClear(); mockHeader.mockClear();
@ -36,6 +43,9 @@ beforeEach(() => {
mockDataPrivacyCard.mockClear(); mockDataPrivacyCard.mockClear();
}); });
/**
* TODO Rewrite tests to actually render the tree
*/
describe("Apps AppDetailsPage", () => { describe("Apps AppDetailsPage", () => {
it("displays app details when app data passed", () => { it("displays app details when app data passed", () => {
// Arrange // Arrange
@ -61,17 +71,23 @@ describe("Apps AppDetailsPage", () => {
onAppDeactivateOpen, onAppDeactivateOpen,
onAppDeleteOpen, onAppDeleteOpen,
}); });
expect(mockAboutCard).toHaveBeenCalledWith({ expect(mockAboutCard).toHaveBeenCalledWith(
aboutApp: appDetails.aboutApp, expect.objectContaining({
loading: false, aboutApp: appDetails.aboutApp,
}); loading: false,
expect(mockPermissionsCard).toHaveBeenCalledWith({ }),
permissions: appDetails.permissions, );
loading: false, expect(mockPermissionsCard).toHaveBeenCalledWith(
}); expect.objectContaining({
expect(mockDataPrivacyCard).toHaveBeenCalledWith({ permissions: appDetails.permissions,
dataPrivacyUrl: appDetails.dataPrivacyUrl, loading: false,
loading: false, }),
}); );
expect(mockDataPrivacyCard).toHaveBeenCalledWith(
expect.objectContaining({
dataPrivacyUrl: appDetails.dataPrivacyUrl,
loading: false,
}),
);
}); });
}); });

View file

@ -1,11 +1,13 @@
import CardSpacer from "@dashboard/components/CardSpacer";
import { AppQuery } from "@dashboard/graphql"; import { AppQuery } from "@dashboard/graphql";
import errorTracker from "@dashboard/services/errorTracking";
import { Box, Text } from "@saleor/macaw-ui/next";
import React from "react"; import React from "react";
import { ErrorBoundary } from "react-error-boundary";
import AboutCard from "./AboutCard"; import { AboutCard } from "./AboutCard";
import DataPrivacyCard from "./DataPrivacyCard"; import { DataPrivacyCard } from "./DataPrivacyCard";
import Header from "./Header"; import Header from "./Header";
import PermissionsCard from "./PermissionsCard"; import { PermissionsCard } from "./PermissionsCard";
export interface AppDetailsPageProps { export interface AppDetailsPageProps {
loading: boolean; loading: boolean;
@ -27,25 +29,33 @@ export const AppDetailsPage: React.FC<AppDetailsPageProps> = ({
} }
return ( return (
<> <ErrorBoundary
onError={errorTracker.captureException}
fallbackRender={() => (
<Box padding={4}>
<Text>Error, please refresh the page</Text>
</Box>
)}
>
<Header <Header
data={data} data={data}
onAppActivateOpen={onAppActivateOpen} onAppActivateOpen={onAppActivateOpen}
onAppDeactivateOpen={onAppDeactivateOpen} onAppDeactivateOpen={onAppDeactivateOpen}
onAppDeleteOpen={onAppDeleteOpen} onAppDeleteOpen={onAppDeleteOpen}
/> />
<AboutCard aboutApp={data?.aboutApp} loading={loading} /> <AboutCard margin={6} aboutApp={data?.aboutApp} loading={loading} />
<CardSpacer /> <PermissionsCard
<PermissionsCard permissions={data?.permissions} loading={loading} /> margin={6}
<CardSpacer /> permissions={data?.permissions}
loading={loading}
/>
<DataPrivacyCard <DataPrivacyCard
margin={6}
dataPrivacyUrl={data?.dataPrivacyUrl} dataPrivacyUrl={data?.dataPrivacyUrl}
loading={loading} loading={loading}
/> />
<CardSpacer /> </ErrorBoundary>
</>
); );
}; };
AppDetailsPage.displayName = "AppDetailsPage"; AppDetailsPage.displayName = "AppDetailsPage";
export default AppDetailsPage;

View file

@ -1,47 +1,53 @@
// @ts-strict-ignore
import CardTitle from "@dashboard/components/CardTitle";
import ExternalLink from "@dashboard/components/ExternalLink"; import ExternalLink from "@dashboard/components/ExternalLink";
import Skeleton from "@dashboard/components/Skeleton"; import Skeleton from "@dashboard/components/Skeleton";
import { Card, CardContent } from "@material-ui/core"; import { Box, BoxProps, Text } from "@saleor/macaw-ui/next";
import React from "react"; import React from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import messages from "./messages"; import messages from "./messages";
import { useStyles } from "./styles";
interface DataPrivacyCardProps { type DataPrivacyCardProps = {
dataPrivacyUrl?: string | null; dataPrivacyUrl?: string | null;
loading: boolean; loading: boolean;
} } & BoxProps;
const DataPrivacyCard: React.FC<DataPrivacyCardProps> = ({ export const DataPrivacyCard: React.FC<DataPrivacyCardProps> = ({
dataPrivacyUrl, dataPrivacyUrl,
loading, loading,
...boxProps
}) => { }) => {
const classes = useStyles();
const intl = useIntl(); const intl = useIntl();
if (!dataPrivacyUrl && !loading) { if (!dataPrivacyUrl && !loading) {
return null; return null;
} }
const renderContent = () => {
if (loading) {
return <Skeleton />;
}
if (dataPrivacyUrl) {
return (
<ExternalLink href={dataPrivacyUrl} target="_blank">
<FormattedMessage {...messages.dataPrivacyDescription} />
</ExternalLink>
);
}
if (!dataPrivacyUrl) {
return <Text>{intl.formatMessage(messages.noDataPrivacyPage)}</Text>;
}
throw new Error('Leaking "if" statement, should never happen');
};
return ( return (
<Card> <Box {...boxProps}>
<CardTitle title={intl.formatMessage(messages.dataPrivacyTitle)} /> <Text variant={"heading"} marginBottom={4} as={"h2"}>
<CardContent> {intl.formatMessage(messages.dataPrivacyTitle)}
{!loading ? ( </Text>
<ExternalLink <Box>{renderContent()}</Box>
className={classes.linkContainer} </Box>
href={dataPrivacyUrl}
target="_blank"
>
<FormattedMessage {...messages.dataPrivacyDescription} />
</ExternalLink>
) : (
<Skeleton />
)}
</CardContent>
</Card>
); );
}; };
export default DataPrivacyCard;

View file

@ -1,48 +1,61 @@
// @ts-strict-ignore
import CardTitle from "@dashboard/components/CardTitle";
import Skeleton from "@dashboard/components/Skeleton"; import Skeleton from "@dashboard/components/Skeleton";
import { AppQuery } from "@dashboard/graphql"; import { PermissionEnum } from "@dashboard/graphql";
import { Card, CardContent, Typography } from "@material-ui/core"; import { Box, BoxProps, Text } from "@saleor/macaw-ui/next";
import React from "react"; import React from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import messages from "./messages"; import messages from "./messages";
import { useStyles } from "./styles";
interface PermissionsCardProps { type PermissionsCardProps = {
permissions?: AppQuery["app"]["permissions"]; permissions: Array<{
name: string;
code: PermissionEnum;
}> | null;
loading: boolean; loading: boolean;
} } & BoxProps;
const PermissionsCard: React.FC<PermissionsCardProps> = ({ export const PermissionsCard: React.FC<PermissionsCardProps> = ({
permissions, permissions,
loading, loading,
...boxProps
}) => { }) => {
const classes = useStyles();
const intl = useIntl(); const intl = useIntl();
const renderContent = () => {
if (loading) {
return <Skeleton />;
}
if (permissions && permissions.length === 0) {
return <Text>{intl.formatMessage(messages.appNoPermissions)}</Text>;
}
if (permissions && permissions.length > 0) {
return (
<>
<Text as={"p"} marginBottom={4}>
<FormattedMessage {...messages.appPermissionsDescription} />
</Text>
<Box as={"ul"}>
{permissions?.map(perm => (
<Box as={"li"} paddingX={4} paddingY={2} key={perm.code}>
<Text>{perm.name}</Text>
</Box>
))}
</Box>
</>
);
}
throw new Error('Leaking "if" statement, should never happen');
};
return ( return (
<Card> <Box {...boxProps}>
<CardTitle title={intl.formatMessage(messages.appPermissionsTitle)} /> <Text variant={"heading"} marginBottom={4} as={"h2"}>
<CardContent> {intl.formatMessage(messages.appPermissionsTitle)}
{!loading ? ( </Text>
<> <Box>{renderContent()}</Box>
<Typography> </Box>
<FormattedMessage {...messages.appPermissionsDescription} />
</Typography>
{!!permissions?.length && (
<ul className={classes.permissionsContainer}>
{permissions?.map(perm => (
<li key={perm.code}>{perm.name}</li>
))}
</ul>
)}
</>
) : (
<Skeleton />
)}
</CardContent>
</Card>
); );
}; };
export default PermissionsCard;

View file

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

View file

@ -16,6 +16,10 @@ export default defineMessages({
defaultMessage: "App permissions", defaultMessage: "App permissions",
description: "section header", description: "section header",
}, },
appNoPermissions: {
defaultMessage: "App doesn't have any permissions granted.",
id: "wDYozn",
},
appPermissionsDescription: { appPermissionsDescription: {
id: "7oQUMG", id: "7oQUMG",
defaultMessage: "This app has permissions to:", defaultMessage: "This app has permissions to:",
@ -27,8 +31,16 @@ export default defineMessages({
description: "section header", description: "section header",
}, },
dataPrivacyDescription: { dataPrivacyDescription: {
id: "Go50v2", id: "CHoZ8S",
defaultMessage: "View this apps privacy policy", defaultMessage: "View this apps privacy policy.",
description: "app privacy policy link", description: "app privacy policy link",
}, },
noDataPrivacyPage: {
id: "f3hf+w",
defaultMessage: "App doesn't provide a privacy policy.",
},
noAboutApp: {
id: "b088Xv",
defaultMessage: "App doesn't provide a description.",
},
}); });

View file

@ -38,10 +38,6 @@ export const useStyles = makeStyles(
marginTop: 0, marginTop: 0,
width: "100%", width: "100%",
}, },
linkContainer: {
fontWeight: 500,
marginTop: theme.spacing(1.5),
},
marketplaceContent: { marketplaceContent: {
"& button": { "& button": {
marginTop: theme.spacing(1), marginTop: theme.spacing(1),
@ -51,15 +47,6 @@ export const useStyles = makeStyles(
}, },
padding: theme.spacing(1), padding: theme.spacing(1),
}, },
permissionsContainer: {
"& li": {
"&:last-child": {
marginBottom: 0,
},
marginBottom: theme.spacing(1),
},
paddingLeft: theme.spacing(2),
},
}), }),
{ name: "AppDetailsPage" }, { name: "AppDetailsPage" },
); );

View file

@ -18,7 +18,7 @@ import { useIntl } from "react-intl";
import AppActivateDialog from "../../components/AppActivateDialog"; import AppActivateDialog from "../../components/AppActivateDialog";
import AppDeactivateDialog from "../../components/AppDeactivateDialog"; import AppDeactivateDialog from "../../components/AppDeactivateDialog";
import AppDetailsPage from "../../components/AppDetailsPage"; import { AppDetailsPage } from "../../components/AppDetailsPage";
import { import {
AppDetailsUrlDialog, AppDetailsUrlDialog,
AppDetailsUrlQueryParams, AppDetailsUrlQueryParams,