Refactor apps UI (#3363)

* Bump macaw-ui

* Add grouping util

* Refactor App cards UI using CSS Grid

* Rename AppListCard to AppListRow

* Fix unit tests

* Bump macaw-ui

* Remove inline styling

* Fix app installtion for second pair

* Add keys to grid rows

* Change grid template rows to repeat(4, auto)

* Replace groupIntoPairs with chunk method from lodash

* Fix borders on safari

* Add paddings to section names
This commit is contained in:
Michał Droń 2023-03-17 10:38:51 +01:00 committed by GitHub
parent 4eebf16b55
commit 51326e52c4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 254 additions and 154 deletions

14
package-lock.json generated
View file

@ -26,7 +26,7 @@
"@material-ui/lab": "^4.0.0-alpha.61",
"@material-ui/styles": "^4.11.4",
"@reach/auto-id": "^0.16.0",
"@saleor/macaw-ui": "^0.8.0-pre.43",
"@saleor/macaw-ui": "^0.8.0-pre.49",
"@saleor/sdk": "^0.4.4",
"@sentry/react": "^6.0.0",
"@types/faker": "^5.1.6",
@ -7271,9 +7271,9 @@
}
},
"node_modules/@saleor/macaw-ui": {
"version": "0.8.0-pre.44",
"resolved": "https://registry.npmjs.org/@saleor/macaw-ui/-/macaw-ui-0.8.0-pre.44.tgz",
"integrity": "sha512-iSygNhSfMxJrmnbXKKmE/iKc7dMq/m996Hqfl5ZFcZ6KpurmI0940AjJcYDMnDXUl+Md6OJyRxv7PKKy8nU+Mg==",
"version": "0.8.0-pre.49",
"resolved": "https://registry.npmjs.org/@saleor/macaw-ui/-/macaw-ui-0.8.0-pre.49.tgz",
"integrity": "sha512-SHUfBE1AaDsam/K3ZcotDtUH6/jcyRR9Gi/Zpvw/cTcwoosq09WcewMWZNdHgY5Fkbgve7HTKb+wCyah+sBO8w==",
"dependencies": {
"@floating-ui/react-dom-interactions": "^0.5.0",
"@radix-ui/react-radio-group": "^1.1.1",
@ -41431,9 +41431,9 @@
}
},
"@saleor/macaw-ui": {
"version": "0.8.0-pre.44",
"resolved": "https://registry.npmjs.org/@saleor/macaw-ui/-/macaw-ui-0.8.0-pre.44.tgz",
"integrity": "sha512-iSygNhSfMxJrmnbXKKmE/iKc7dMq/m996Hqfl5ZFcZ6KpurmI0940AjJcYDMnDXUl+Md6OJyRxv7PKKy8nU+Mg==",
"version": "0.8.0-pre.49",
"resolved": "https://registry.npmjs.org/@saleor/macaw-ui/-/macaw-ui-0.8.0-pre.49.tgz",
"integrity": "sha512-SHUfBE1AaDsam/K3ZcotDtUH6/jcyRR9Gi/Zpvw/cTcwoosq09WcewMWZNdHgY5Fkbgve7HTKb+wCyah+sBO8w==",
"requires": {
"@floating-ui/react-dom-interactions": "^0.5.0",
"@radix-ui/react-radio-group": "^1.1.1",

View file

@ -33,7 +33,7 @@
"@material-ui/lab": "^4.0.0-alpha.61",
"@material-ui/styles": "^4.11.4",
"@reach/auto-id": "^0.16.0",
"@saleor/macaw-ui": "^0.8.0-pre.43",
"@saleor/macaw-ui": "^0.8.0-pre.49",
"@saleor/sdk": "^0.4.4",
"@sentry/react": "^6.0.0",
"@types/faker": "^5.1.6",

View file

@ -1,11 +1,11 @@
import { GetV2SaleorAppsResponse } from "@dashboard/apps/marketplace.types";
import { resolveInstallationOfMarketplaceApp } from "@dashboard/apps/utils";
import { AppInstallationFragment } from "@dashboard/graphql";
import { Skeleton } from "@material-ui/lab";
import { Box } from "@saleor/macaw-ui/next";
import chunk from "lodash/chunk";
import React from "react";
import AppListCard from "../AppListCard";
import AppListRow from "../AppListRow";
interface AllAppListProps {
appList?: GetV2SaleorAppsResponse.SaleorApp[];
@ -20,28 +20,19 @@ const AllAppList: React.FC<AllAppListProps> = ({
navigateToAppInstallPage,
navigateToGithubForkPage,
}) => {
const appsPairs = React.useMemo(() => chunk(appList, 2), [appList]);
if (!appList) {
return <Skeleton />;
}
return (
<Box
display="grid"
gridTemplateColumns={{
mobile: 1,
desktop: 2,
}}
gap={8}
marginTop={8}
>
{appList.map(app => (
<AppListCard
key={app.name.en}
app={app}
appInstallation={resolveInstallationOfMarketplaceApp(
app,
appInstallationList,
)}
<Box display="flex" flexDirection="column" gap={8} marginTop={8}>
{appsPairs.map(appPair => (
<AppListRow
key={appPair[0].name.en}
appPair={appPair}
appInstallationList={appInstallationList}
navigateToAppInstallPage={navigateToAppInstallPage}
navigateToGithubForkPage={navigateToGithubForkPage}
/>

View file

@ -1,70 +0,0 @@
import { useAppListContext } from "@dashboard/apps/context";
import { GetV2SaleorAppsResponse } from "@dashboard/apps/marketplace.types";
import { getAppDetails } from "@dashboard/apps/utils";
import { AppInstallationFragment } from "@dashboard/graphql";
import { Box } from "@saleor/macaw-ui/next";
import React from "react";
import { useIntl } from "react-intl";
import AppListCardActions from "./AppListCardActions";
import AppListCardDescription from "./AppListCardDescription";
import AppListCardIntegrations from "./AppListCardIntegrations";
import AppListCardLinks from "./AppListCardLinks";
interface AppListCardProps {
app: GetV2SaleorAppsResponse.SaleorApp;
appInstallation?: AppInstallationFragment;
navigateToAppInstallPage?: (manifestUrl: string) => void;
navigateToGithubForkPage?: (githubForkUrl: string) => void;
}
const AppListCard: React.FC<AppListCardProps> = ({
app,
appInstallation,
navigateToAppInstallPage,
navigateToGithubForkPage,
}) => {
const intl = useIntl();
const { retryAppInstallation, removeAppInstallation } = useAppListContext();
const details = getAppDetails({
intl,
app,
appInstallation,
navigateToAppInstallPage,
navigateToGithubForkPage,
retryAppInstallation,
removeAppInstallation,
});
return (
<Box
display="flex"
flexDirection="column"
justifyContent="space-between"
borderStyle="solid"
borderWidth={1}
padding={8}
borderRadius={3}
borderColor="neutralPlain"
>
<Box>
<AppListCardDescription app={app} />
<AppListCardLinks links={details.links} />
<AppListCardIntegrations app={app} />
</Box>
<Box>
<AppListCardActions
releaseDate={details.releaseDate}
installationPending={details.installationPending}
installHandler={details.installHandler}
githubForkHandler={details.githubForkHandler}
retryInstallHandler={details.retryInstallHandler}
removeInstallHandler={details.removeInstallHandler}
/>
</Box>
</Box>
);
};
AppListCard.displayName = "AppListCard";
export default AppListCard;

View file

@ -1,3 +0,0 @@
export * from "./AppListCard";
export { default } from "./AppListCard";
export * from "./AppLogo";

View file

@ -3,7 +3,7 @@ import { TopNav } from "@dashboard/components/AppLayout/TopNav";
import useNavigator from "@dashboard/hooks/useNavigator";
import { sectionNames } from "@dashboard/intl";
import { ListProps } from "@dashboard/types";
import { Box, Text } from "@saleor/macaw-ui/next";
import { Box, sprinkles, Text } from "@saleor/macaw-ui/next";
import React, { useCallback } from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -114,7 +114,12 @@ export const AppListPage: React.FC<AppListPageProps> = props => {
<MarketplaceAlert error={marketplaceError} />
{sectionsAvailability.all && !marketplaceError && (
<Box marginTop={10}>
<Text as="h3" variant="heading" color="textNeutralSubdued">
<Text
as="h3"
variant="heading"
color="textNeutralSubdued"
className={sprinkles({ paddingX: 8 })}
>
<FormattedMessage {...messages.allApps} />
</Text>
<AllAppList
@ -127,7 +132,12 @@ export const AppListPage: React.FC<AppListPageProps> = props => {
)}
{sectionsAvailability.comingSoon && !marketplaceError && (
<Box marginTop={10}>
<Text as="h3" variant="heading" color="textNeutralSubdued">
<Text
as="h3"
variant="heading"
color="textNeutralSubdued"
className={sprinkles({ paddingX: 8 })}
>
{intl.formatMessage(messages.comingSoonApps)}
</Text>
<AllAppList

View file

@ -43,7 +43,18 @@ const AppListCardActions: React.FC<AppListCardActionsProps> = ({
}
return (
<Box display="flex" justifyContent="flex-end" gap={6}>
<Box
display="flex"
justifyContent="flex-end"
gap={6}
borderStyle="solid"
borderWidth={1}
borderBottomLeftRadius={3}
borderBottomRightRadius={3}
borderColor="neutralPlain"
borderTopStyle="none"
padding={8}
>
{githubForkHandler && (
<Button
variant="secondary"

View file

@ -11,7 +11,15 @@ interface AppListCardDescriptionProps {
const AppListCardDescription: React.FC<AppListCardDescriptionProps> = ({
app,
}) => (
<Box marginBottom={6}>
<Box
borderStyle="solid"
borderWidth={1}
borderTopLeftRadius={3}
borderTopRightRadius={3}
borderColor="neutralPlain"
borderBottomStyle="none"
padding={8}
>
<Box
display="flex"
flexDirection="row"

View file

@ -4,21 +4,35 @@ import { Box, Text } from "@saleor/macaw-ui/next";
import React from "react";
interface AppListCardIntegrationsProps {
app: GetV2SaleorAppsResponse.SaleorApp;
integrations: GetV2SaleorAppsResponse.SaleorApp["integrations"];
}
const AppListCardIntegrations: React.FC<AppListCardIntegrationsProps> = ({
app,
integrations,
}) => {
const { themeType } = useTheme();
if (!app.integrations.length) {
if (!integrations) {
return null;
}
return (
<Box as="ul" display="flex" flexDirection="row" flexWrap="wrap" gap={8}>
{app.integrations.map(integration => (
<Box
as="ul"
display="flex"
flexDirection="row"
flexWrap="wrap"
gap={8}
margin={0}
borderColor="neutralPlain"
borderLeftStyle="solid"
borderRightStyle="solid"
borderWidth={1}
paddingY={5}
paddingX={8}
alignItems="start"
>
{integrations.map(integration => (
<Box
as="li"
display="flex"
@ -32,6 +46,7 @@ const AppListCardIntegrations: React.FC<AppListCardIntegrationsProps> = ({
borderRadius={3}
borderStyle="solid"
borderColor="neutralPlain"
borderWidth={1}
padding={3}
display="flex"
placeItems="center"

View file

@ -19,9 +19,14 @@ const AppListCardLinks: React.FC<AppListCardLinksProps> = ({ links }) => {
flexDirection="row"
flexWrap="wrap"
gap={7}
marginBottom={6}
borderLeftStyle="solid"
borderRightStyle="solid"
borderWidth={1}
borderColor="neutralPlain"
paddingY={6}
paddingX={8}
>
{links.map(link => (
{links?.map(link => (
<Box as="span" key={link.name}>
<Text variant="body" size="small" color="textBrandDefault">
<Link href={link.url} target="_blank">
@ -33,5 +38,6 @@ const AppListCardLinks: React.FC<AppListCardLinksProps> = ({ links }) => {
</Box>
);
};
AppListCardLinks.displayName = "AppListCardLinks";
export default AppListCardLinks;

View file

@ -12,7 +12,7 @@ import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import AppListCard from "./AppListCard";
import AppListRow from "./AppListRow";
jest.mock("@dashboard/apps/context", () => ({
useAppListContext: jest.fn(() => ({
@ -32,7 +32,10 @@ jest.mock("@dashboard/config", () => {
};
});
describe("Apps AppListCard", () => {
const releasedAppPair = [releasedApp, releasedApp];
const comingSoonAppPair = [comingSoonApp, comingSoonApp];
describe("Apps AppListRow", () => {
it("displays released app details when released app data passed", () => {
// Arrange
const integrationImages = releasedApp.integrations.map(
@ -40,17 +43,17 @@ describe("Apps AppListCard", () => {
);
render(
<Wrapper>
<AppListCard app={releasedApp} />
<AppListRow appPair={releasedAppPair} />
</Wrapper>,
);
const name = screen.queryByText(releasedApp.name.en);
const description = screen.queryByText(releasedApp.description.en);
const name = screen.queryAllByText(releasedApp.name.en);
const description = screen.queryAllByText(releasedApp.description.en);
const images = screen.getAllByRole("img");
const links = screen.getAllByRole("link");
// Assert
expect(name).toBeTruthy();
expect(description).toBeTruthy();
expect(name[0]).toBeTruthy();
expect(description[0]).toBeTruthy();
const expectedImages = [releasedApp.logo.source, ...integrationImages];
images.forEach(image =>
expect(expectedImages).toContain(image.getAttribute("src")),
@ -71,22 +74,22 @@ describe("Apps AppListCard", () => {
const navigateToVercelDeploymentPage = jest.fn();
render(
<Wrapper>
<AppListCard
app={releasedApp}
<AppListRow
appPair={releasedAppPair}
navigateToAppInstallPage={navigateToAppInstallPage}
navigateToGithubForkPage={navigateToVercelDeploymentPage}
/>
</Wrapper>,
);
const user = userEvent.setup();
const installButton = screen.getByTestId("app-install-button");
const deployToVercelButton = screen.getByTestId(
const installButton = screen.getAllByTestId("app-install-button");
const deployToVercelButton = screen.getAllByTestId(
"app-fork-on-github-button",
);
// Act
await user.click(installButton);
await user.click(deployToVercelButton);
await user.click(installButton[0]);
await user.click(deployToVercelButton[0]);
// Assert
expect(navigateToAppInstallPage).toBeCalledTimes(1);
@ -100,26 +103,26 @@ describe("Apps AppListCard", () => {
);
render(
<Wrapper>
<AppListCard app={comingSoonApp} />
<AppListRow appPair={comingSoonAppPair} />
</Wrapper>,
);
const name = screen.queryByText(comingSoonApp.name.en);
const description = screen.queryByText(comingSoonApp.description.en);
const name = screen.queryAllByText(comingSoonApp.name.en);
const description = screen.queryAllByText(comingSoonApp.description.en);
const images = screen.getAllByRole("img");
const links = screen.queryAllByRole("link");
const releaseDate = screen.queryByText(comingSoonApp.releaseDate, {
const releaseDate = screen.queryAllByText(comingSoonApp.releaseDate, {
exact: false,
});
// Assert
expect(name).toBeTruthy();
expect(description).toBeTruthy();
expect(name[0]).toBeTruthy();
expect(description[0]).toBeTruthy();
const expectedImages = [comingSoonApp.logo.source, ...integrationImages];
images.forEach(image =>
expect(expectedImages).toContain(image.getAttribute("src")),
);
expect(links).toHaveLength(0);
expect(releaseDate).toBeTruthy();
expect(releaseDate[0]).toBeTruthy();
});
it("displays placeholder initial when no released app logo passed", () => {
@ -133,12 +136,14 @@ describe("Apps AppListCard", () => {
};
render(
<Wrapper>
<AppListCard app={app} />
<AppListRow appPair={[app, app]} />
</Wrapper>,
);
const logo = screen.getByTestId("app-logo");
const logoPlaceholder = within(logo).queryByTestId("app-logo-placeholder");
const logoImage = within(logo).queryByRole("img");
const logo = screen.getAllByTestId("app-logo");
const logoPlaceholder = within(logo[0]).queryByTestId(
"app-logo-placeholder",
);
const logoImage = within(logo[0]).queryByRole("img");
// Assert
expect(logoPlaceholder).toBeTruthy();
@ -157,12 +162,14 @@ describe("Apps AppListCard", () => {
};
render(
<Wrapper>
<AppListCard app={app} />
<AppListRow appPair={[app, app]} />
</Wrapper>,
);
const logo = screen.getByTestId("app-logo");
const logoPlaceholder = within(logo).queryByTestId("app-logo-placeholder");
const logoImage = within(logo).queryByRole("img");
const logo = screen.getAllByTestId("app-logo");
const logoPlaceholder = within(logo[0]).queryByTestId(
"app-logo-placeholder",
);
const logoImage = within(logo[0]).queryByRole("img");
// Assert
expect(logoPlaceholder).toBeTruthy();
@ -174,11 +181,14 @@ describe("Apps AppListCard", () => {
// Arrange
render(
<Wrapper>
<AppListCard app={releasedApp} appInstallation={failedAppInProgress} />
<AppListRow
appPair={releasedAppPair}
appInstallationList={[failedAppInProgress]}
/>
</Wrapper>,
);
const status = screen.getByTestId("app-installation-failed");
const statusDetails = within(status).queryByText(
const status = screen.getAllByTestId("app-installation-failed");
const statusDetails = within(status[0]).queryByText(
appInstallationStatusMessages.failed.defaultMessage,
);
@ -190,11 +200,14 @@ describe("Apps AppListCard", () => {
// Arrange
render(
<Wrapper>
<AppListCard app={releasedApp} appInstallation={pendingAppInProgress} />
<AppListRow
appPair={releasedAppPair}
appInstallationList={[pendingAppInProgress]}
/>
</Wrapper>,
);
const status = screen.getByTestId("app-installation-pending");
const statusText = within(status).queryByText(
const status = screen.getAllByTestId("app-installation-pending");
const statusText = within(status[0]).queryByText(
appInstallationStatusMessages.pending.defaultMessage,
);
@ -214,16 +227,19 @@ describe("Apps AppListCard", () => {
}));
render(
<Wrapper>
<AppListCard app={releasedApp} appInstallation={failedAppInProgress} />
<AppListRow
appPair={releasedAppPair}
appInstallationList={[failedAppInProgress]}
/>
</Wrapper>,
);
const user = userEvent.setup();
const retryButton = screen.getByTestId("app-retry-install-button");
const removeButton = screen.getByTestId("app-remove-install-button");
const retryButton = screen.getAllByTestId("app-retry-install-button");
const removeButton = screen.getAllByTestId("app-remove-install-button");
// Act
await user.click(retryButton);
await user.click(removeButton);
await user.click(retryButton[0]);
await user.click(removeButton[0]);
// Assert
expect(retryAppInstallation).toHaveBeenCalledWith(failedAppInProgress.id);

View file

@ -0,0 +1,113 @@
import { useAppListContext } from "@dashboard/apps/context";
import { GetV2SaleorAppsResponse } from "@dashboard/apps/marketplace.types";
import {
getAppDetails,
resolveInstallationOfMarketplaceApp,
} from "@dashboard/apps/utils";
import { AppInstallationFragment } from "@dashboard/graphql";
import { Box } from "@saleor/macaw-ui/next";
import React from "react";
import { useIntl } from "react-intl";
import AppListCardActions from "./AppListCardActions";
import AppListCardDescription from "./AppListCardDescription";
import AppListCardIntegrations from "./AppListCardIntegrations";
import AppListCardLinks from "./AppListCardLinks";
interface AppListRowProps {
appPair: GetV2SaleorAppsResponse.SaleorApp[];
appInstallationList?: AppInstallationFragment[];
navigateToAppInstallPage?: (manifestUrl: string) => void;
navigateToGithubForkPage?: (githubForkUrl: string) => void;
}
const AppListRow: React.FC<AppListRowProps> = ({
appPair,
appInstallationList,
navigateToAppInstallPage,
navigateToGithubForkPage,
}) => {
const intl = useIntl();
const { retryAppInstallation, removeAppInstallation } = useAppListContext();
const isSingleApp = appPair.length === 1;
const appDetails = React.useCallback(
(app: GetV2SaleorAppsResponse.SaleorApp) =>
getAppDetails({
intl,
app,
appInstallation: resolveInstallationOfMarketplaceApp(
app,
appInstallationList,
),
navigateToAppInstallPage,
navigateToGithubForkPage,
retryAppInstallation,
removeAppInstallation,
}),
[
appInstallationList,
intl,
navigateToAppInstallPage,
navigateToGithubForkPage,
removeAppInstallation,
retryAppInstallation,
],
);
return (
<Box
display="grid"
gridTemplateColumns={2}
__gridTemplateRows="repeat(4, auto)"
gridAutoFlow={isSingleApp ? "column" : "row"}
columnGap={8}
padding={8}
>
{appPair.map(app => (
<AppListCardDescription key={app.name.en + "description"} app={app} />
))}
{appPair.map(app => (
<AppListCardLinks
key={app.name.en + "links"}
links={appDetails(app).links}
/>
))}
{appPair.map(app => {
if (appPair.every(app => !app.integrations?.length)) {
return null;
}
return (
<AppListCardIntegrations
key={app.name.en + "integrations"}
integrations={app.integrations}
/>
);
})}
{appPair.map(app => {
const {
releaseDate,
installationPending,
installHandler,
githubForkHandler,
retryInstallHandler,
removeInstallHandler,
} = appDetails(app);
return (
<AppListCardActions
key={app.name.en + "actions"}
releaseDate={releaseDate}
installationPending={installationPending}
installHandler={installHandler}
githubForkHandler={githubForkHandler}
retryInstallHandler={retryInstallHandler}
removeInstallHandler={removeInstallHandler}
/>
);
})}
</Box>
);
};
AppListRow.displayName = "AppListRow";
export default AppListRow;

View file

@ -0,0 +1,3 @@
export * from "./AppListRow";
export { default } from "./AppListRow";
export * from "./AppLogo";

View file

@ -61,7 +61,7 @@ export const failedAppInProgress: AppInstallationFragment = {
__typename: "AppInstallation",
appName: "app",
id: "QXBwSW5zdGFsbGF0aW9uOjk2",
manifestUrl: "http://localhost:3000/manifest",
manifestUrl: "https://www.released-example.com/manifest",
message: "Failed to connect to app. Try later or contact with app support.",
status: JobStatusEnum.FAILED,
};
@ -70,7 +70,7 @@ export const pendingAppInProgress: AppInstallationFragment = {
__typename: "AppInstallation",
appName: "app pending",
id: "QXBwSW5zdGFsbGF0aW9uOjk2",
manifestUrl: "http://localhost:3000/manifest",
manifestUrl: "https://www.released-example.com/manifest",
message: "Pending.",
status: JobStatusEnum.PENDING,
};
@ -79,7 +79,7 @@ export const successAppInProgress: AppInstallationFragment = {
__typename: "AppInstallation",
appName: "app success",
id: "QXBwSW5zdGFsbGF0aW9uOjk2",
manifestUrl: "http://localhost:3000/manifest",
manifestUrl: "https://www.released-example.com/manifest",
message: "Success.",
status: JobStatusEnum.SUCCESS,
};