Search redesign to Macaw 2.0 (#420)

* Redesign

* Better form

* Remove searchKey

* Update macaw

* Test setup

* fix build

* Create curly-jars-doubt.md

* Unify vitest to (hopefully) fix build

* fix lock

* update macaw and review fixes

* fix build
This commit is contained in:
Lukasz Ostrowski 2023-04-25 18:20:20 +02:00 committed by GitHub
parent cad39760c9
commit fc7a70f598
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 5342 additions and 4852 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-search": minor
---
Redesigned app to Macaw 2.0. Removed legacy code and unused libraries. Introduced Pino logger and Vitest. Bumped Macaw to 0.8.0 pre-release

View file

@ -39,7 +39,7 @@
"urql": "^3.0.3", "urql": "^3.0.3",
"usehooks-ts": "^2.9.1", "usehooks-ts": "^2.9.1",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"vite": "^4.2.1", "vite": "^4.3.1",
"zod": "^3.19.1" "zod": "^3.19.1"
}, },
"devDependencies": { "devDependencies": {

View file

@ -41,7 +41,7 @@
"react-is": "^18.2.0", "react-is": "^18.2.0",
"urql": "^3.0.3", "urql": "^3.0.3",
"usehooks-ts": "^2.9.1", "usehooks-ts": "^2.9.1",
"vite": "^4.2.1", "vite": "^4.3.1",
"vitest": "^0.30.1", "vitest": "^0.30.1",
"zod": "^3.20.2" "zod": "^3.20.2"
}, },

View file

@ -2,6 +2,7 @@ import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui/next"; import { useTheme } from "@saleor/macaw-ui/next";
import { memo, useEffect } from "react"; import { memo, useEffect } from "react";
// todo move to shared
export function ThemeSynchronizer() { export function ThemeSynchronizer() {
const { appBridgeState } = useAppBridge(); const { appBridgeState } = useAppBridge();
const { setTheme } = useTheme(); const { setTheme } = useTheme();

View file

@ -38,7 +38,7 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"urql": "^3.0.3", "urql": "^3.0.3",
"usehooks-ts": "^2.9.1", "usehooks-ts": "^2.9.1",
"vite": "^4.2.1", "vite": "^4.3.1",
"vitest": "^0.30.1", "vitest": "^0.30.1",
"zod": "^3.20.2" "zod": "^3.20.2"
}, },

View file

@ -51,7 +51,7 @@
"react-query": "^3.39.3", "react-query": "^3.39.3",
"urql": "^3.0.3", "urql": "^3.0.3",
"usehooks-ts": "^2.9.1", "usehooks-ts": "^2.9.1",
"vite": "^4.2.1", "vite": "^4.3.1",
"vitest": "^0.30.1", "vitest": "^0.30.1",
"zod": "^3.20.2" "zod": "^3.20.2"
}, },

View file

@ -62,6 +62,7 @@
"@types/react": "^18.0.27", "@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/rimraf": "^3.0.2", "@types/rimraf": "^3.0.2",
"@types/semver": "^7.3.13",
"@vitejs/plugin-react": "^3.0.0", "@vitejs/plugin-react": "^3.0.0",
"@vitest/coverage-c8": "^0.28.4", "@vitest/coverage-c8": "^0.28.4",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
@ -69,8 +70,7 @@
"jsdom": "^20.0.3", "jsdom": "^20.0.3",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"typescript": "4.9.5", "typescript": "4.9.5",
"vite": "^4.2.1", "vite": "^4.3.1",
"vitest": "^0.30.1", "vitest": "^0.30.1"
"@types/semver": "^7.3.13"
} }
} }

View file

@ -32,7 +32,9 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"urql": "^3.0.3" "urql": "^3.0.3",
"vite": "4.3.1",
"vitest": "0.30.1"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "3.2.2", "@graphql-codegen/cli": "3.2.2",

View file

@ -32,7 +32,7 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.42.1", "react-hook-form": "^7.42.1",
"urql": "^3.0.3", "urql": "^3.0.3",
"vite": "^4.2.1", "vite": "^4.3.1",
"vitest": "^0.30.1" "vitest": "^0.30.1"
}, },
"devDependencies": { "devDependencies": {

View file

@ -45,7 +45,7 @@
"react-is": "^18.2.0", "react-is": "^18.2.0",
"urql": "^3.0.3", "urql": "^3.0.3",
"usehooks-ts": "^2.9.1", "usehooks-ts": "^2.9.1",
"vite": "^4.2.1", "vite": "^4.3.1",
"vitest": "^0.30.1", "vitest": "^0.30.1",
"zod": "^3.20.2" "zod": "^3.20.2"
}, },

View file

@ -15,12 +15,10 @@
"schemaVersion": "3.7" "schemaVersion": "3.7"
}, },
"dependencies": { "dependencies": {
"@material-ui/core": "^4.12.4", "@hookform/resolvers": "^3.1.0",
"@material-ui/icons": "^4.11.3",
"@material-ui/lab": "4.0.0-alpha.61",
"@saleor/app-sdk": "0.37.3", "@saleor/app-sdk": "0.37.3",
"@saleor/apps-shared": "workspace:*", "@saleor/apps-shared": "workspace:*",
"@saleor/macaw-ui": "0.7.2", "@saleor/macaw-ui": "^0.8.0-pre.76",
"@sentry/nextjs": "^7.46.0", "@sentry/nextjs": "^7.46.0",
"@types/debug": "^4.1.7", "@types/debug": "^4.1.7",
"@urql/exchange-auth": "^1.0.0", "@urql/exchange-auth": "^1.0.0",
@ -29,16 +27,17 @@
"debug": "^4.3.4", "debug": "^4.3.4",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"instantsearch.css": "^7.4.5",
"next": "13.3.0", "next": "13.3.0",
"next-urql": "4.0.0", "next-urql": "4.0.0",
"pino": "^8.8.0",
"pino-pretty": "^9.1.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-hook-form": "^7.39.1", "react-hook-form": "^7.43.9",
"react-instantsearch-hooks-web": "^6.38.0", "react-query": "^3.39.3",
"react-query": "^3.39.2", "urql": "^3.0.3",
"urql": "^3.0.3" "zod": "^3.21.4"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "3.2.2", "@graphql-codegen/cli": "3.2.2",
@ -51,13 +50,16 @@
"@graphql-codegen/urql-introspection": "2.2.1", "@graphql-codegen/urql-introspection": "2.2.1",
"@graphql-typed-document-node/core": "^3.1.2", "@graphql-typed-document-node/core": "^3.1.2",
"@types/node": "^18.11.9", "@types/node": "^18.11.9",
"@types/react": "^18.0.25", "@types/react": "~18.0.38",
"@types/react-dom": "^18.0.8", "@types/react-dom": "^18.0.8",
"@vitejs/plugin-react": "^4.0.0",
"eslint": "8.27.0", "eslint": "8.27.0",
"eslint-config-next": "13.0.2", "eslint-config-next": "13.0.2",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-config-saleor": "workspace:*", "eslint-config-saleor": "workspace:*",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"typescript": "4.8.4" "typescript": "4.8.4",
"vite": "^4.3.1",
"vitest": "^0.30.1"
} }
} }

View file

@ -1,168 +0,0 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Button, makeStyles } from "@saleor/macaw-ui";
import { Card, CardActions, CardHeader, TextField } from "@material-ui/core";
import { Controller, useForm } from "react-hook-form";
import { useQuery, useQueryClient, useMutation } from "react-query";
import { AlgoliaConfigurationFields } from "../lib/algolia/types";
import { fetchConfiguration } from "../lib/configuration";
const useStyles = makeStyles((theme) => ({
form: {
margin: theme.spacing(2),
},
confirmButton: {
marginLeft: "auto",
},
fieldContainer: {
marginBottom: theme.spacing(2),
},
}));
export const AlgoliaConfigurationCard = () => {
const { appBridge, appBridgeState } = useAppBridge();
const { handleSubmit, setValue, control } = useForm<AlgoliaConfigurationFields>({
defaultValues: { appId: "", indexNamePrefix: "", searchKey: "", secretKey: "" },
});
const classes = useStyles();
const { token, saleorApiUrl } = appBridgeState || {};
const reactQueryClient = useQueryClient();
const { isLoading: isQueryLoading } = useQuery({
queryKey: ["configuration"],
onSuccess(data) {
setValue("secretKey", data?.secretKey || "");
setValue("searchKey", data?.searchKey || "");
setValue("appId", data?.appId || "");
setValue("indexNamePrefix", data?.indexNamePrefix || "");
},
queryFn: async () => fetchConfiguration(saleorApiUrl!, token!),
enabled: !!token && !!saleorApiUrl,
});
const { mutate, isLoading: isMutationLoading } = useMutation(
async (conf: AlgoliaConfigurationFields) => {
const resp = await fetch("/api/configuration", {
method: "POST",
headers: {
"saleor-api-url": saleorApiUrl!,
"authorization-bearer": token!,
},
body: JSON.stringify(conf),
});
if (resp.status >= 200 && resp.status < 300) {
const data = (await resp.json()) as { data?: AlgoliaConfigurationFields };
return data.data;
}
throw new Error(`Server responded with status code ${resp.status}`);
},
{
onSuccess: async () => {
reactQueryClient.refetchQueries({
queryKey: ["configuration"],
});
appBridge?.dispatch({
type: "notification",
payload: {
status: "success",
title: "Configuration saved!",
actionId: "message-from-app",
},
});
},
onError: async (data: Error) => {
appBridge?.dispatch({
type: "notification",
payload: {
status: "error",
title: "Could not save the configuration",
text: data.message,
actionId: "message-from-app",
},
});
},
}
);
const onFormSubmit = handleSubmit(async (conf) => mutate(conf));
const isFormDisabled = isMutationLoading || isQueryLoading || !token || !saleorApiUrl;
return (
<Card>
<form onSubmit={onFormSubmit}>
<CardHeader title="Configure Algolia settings"></CardHeader>
<div className={classes.form}>
<Controller
name="appId"
control={control}
render={({ field }) => (
<TextField
className={classes.fieldContainer}
disabled={isFormDisabled}
label="Application ID"
helperText="Usually 10 characters, e.g. XYZAAABB00"
{...field}
fullWidth
/>
)}
/>
<Controller
name="searchKey"
control={control}
render={({ field }) => (
<TextField
className={classes.fieldContainer}
disabled={isFormDisabled}
label="Search-Only API Key"
{...field}
fullWidth
/>
)}
/>
<div key="secret" className={classes.fieldContainer}>
<Controller
name="secretKey"
control={control}
render={({ field }) => (
<TextField
helperText="In Algolia dashboard it's a masked field"
disabled={isFormDisabled}
label="Admin API Key"
{...field}
fullWidth
/>
)}
/>
</div>
<Controller
name="indexNamePrefix"
control={control}
render={({ field }) => (
<TextField
className={classes.fieldContainer}
disabled={isFormDisabled}
label="Index name prefix"
helperText='Optional prefix, you can add "test" or "staging" to test the app'
{...field}
fullWidth
/>
)}
/>
<CardActions style={{ padding: "30px 0 0 0" }}>
<Button disabled={isFormDisabled} type="submit" variant="primary" fullWidth>
{isFormDisabled ? "Loading..." : "Save"}
</Button>
</CardActions>
</div>
</form>
</Card>
);
};
/**
* Export default for Next.dynamic
*/

View file

@ -0,0 +1,167 @@
import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge";
import { Controller, useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { fetchConfiguration } from "../lib/configuration";
import { Box, Button, Divider, Input, Text } from "@saleor/macaw-ui/next";
import { useDashboardNotification } from "@saleor/apps-shared";
import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { AppConfigurationFields, AppConfigurationSchema } from "../domain/configuration";
export const AlgoliaConfigurationForm = () => {
const { notifyError, notifySuccess } = useDashboardNotification();
const fetch = useAuthenticatedFetch();
const [credentialsValidationError, setCredentialsValidationError] = useState(false);
const { handleSubmit, trigger, setValue, control } = useForm<AppConfigurationFields>({
defaultValues: { appId: "", indexNamePrefix: "", secretKey: "" },
// @ts-ignore - todo - some strange TS error happens here
resolver: zodResolver(AppConfigurationSchema),
});
const reactQueryClient = useQueryClient();
/**
* TODO Extract to hook
*/
const { isLoading: isQueryLoading } = useQuery({
queryKey: ["configuration"],
onSuccess(data) {
setValue("secretKey", data?.secretKey || "");
setValue("appId", data?.appId || "");
setValue("indexNamePrefix", data?.indexNamePrefix || "");
},
queryFn: async () => fetchConfiguration(fetch),
});
const { mutate, isLoading: isMutationLoading } = useMutation(
async (conf: AppConfigurationFields) => {
const resp = await fetch("/api/configuration", {
method: "POST",
body: JSON.stringify(conf),
});
if (resp.status >= 200 && resp.status < 300) {
const data = (await resp.json()) as { data?: AppConfigurationFields };
return data.data;
}
throw new Error(`Server responded with status code ${resp.status}`);
},
{
onSuccess: async () => {
reactQueryClient.refetchQueries({
queryKey: ["configuration"],
});
notifySuccess("Configuration saved!");
},
onError: async (data: Error) => {
notifyError("Could not save the configuration", data.message);
},
}
);
const onFormSubmit = handleSubmit(async (conf) => {
const client = new AlgoliaSearchProvider({
appId: conf.appId ?? "",
apiKey: conf.secretKey ?? "",
indexNamePrefix: conf.indexNamePrefix,
});
try {
await client.ping();
setCredentialsValidationError(false);
mutate(conf);
} catch (e) {
trigger();
setCredentialsValidationError(true);
}
});
const isFormDisabled = isMutationLoading || isQueryLoading;
return (
<Box>
<form onSubmit={onFormSubmit}>
<Box padding={8}>
<Box marginBottom={8}>
<Controller
name="appId"
control={control}
render={({ field, fieldState }) => {
return (
<Input
disabled={isFormDisabled}
required
label="Application ID"
error={fieldState.invalid}
helperText={
fieldState.error?.message ?? "Usually 10 characters, e.g. XYZAAABB00"
}
{...field}
/>
);
}}
/>
</Box>
<Box marginBottom={8} key={"secret"} /* todo why is this "key" here? */>
<Controller
name="secretKey"
control={control}
render={({ field, fieldState }) => (
<Input
helperText={
fieldState.error?.message ?? "In Algolia dashboard it's a masked field"
}
disabled={isFormDisabled}
required
label="Admin API Key"
error={fieldState.invalid}
{...field}
/>
)}
/>
</Box>
<Controller
name="indexNamePrefix"
control={control}
render={({ field, fieldState }) => {
return (
<Input
disabled={isFormDisabled}
error={fieldState.invalid}
label="Index name prefix"
helperText={
fieldState.error?.message ??
'Optional prefix, you can add "test" or "staging" to test the app'
}
{...field}
/>
);
}}
/>
{credentialsValidationError && (
<Box marginTop={8}>
<Text color={"textCriticalDefault"}>
Could not connect to Algolia. Please verify your credentials
</Text>
</Box>
)}
</Box>
<Divider margin={0} marginTop={8} />
<Box paddingX={8} paddingY={6} display={"flex"} justifyContent={"flex-end"}>
<Button disabled={isFormDisabled} type="submit" variant="primary">
{isFormDisabled ? "Loading..." : "Save"}
</Button>
</Box>
</form>
</Box>
);
};

View file

@ -1,41 +0,0 @@
import { makeStyles } from "@saleor/macaw-ui";
import { PropsWithChildren } from "react";
const useStyles = makeStyles({
root3equal: {
display: "grid",
gridTemplateColumns: "280px auto 280px",
alignItems: "start",
gap: 32,
maxWidth: 1180,
margin: "0 auto",
},
oneTo2: {
display: "grid",
gridTemplateColumns: "460px 1fr",
alignItems: "start",
gap: 32,
maxWidth: 1180,
margin: "0 auto",
},
});
type Props = PropsWithChildren<{
variant?: "3-equal" | "1:2";
}>;
/**
* TODO Refactor, make generic across the apps
*/
export const AppColumnsLayout = ({ children, variant = "3-equal" }: Props) => {
const styles = useStyles();
switch (variant) {
case "1:2":
return <div className={styles.oneTo2}>{children}</div>;
case "3-equal":
return <div className={styles.root3equal}>{children}</div>;
default:
throw new Error("Invalid variant");
}
};

View file

@ -0,0 +1,36 @@
import { Box, PropsWithBox, Text } from "@saleor/macaw-ui/next";
import { ReactNode } from "react";
// todo move to shared
export const AppSection = ({
heading,
sideContent,
mainContent,
includePadding = false,
...props
}: PropsWithBox<{
heading: string;
sideContent?: ReactNode;
mainContent: ReactNode;
includePadding?: boolean;
}>) => {
return (
<Box as="section" __gridTemplateColumns={"400px auto"} display={"grid"} gap={13} {...props}>
<Box>
<Text as="h2" variant={"heading"} size={"large"} marginBottom={4}>
{heading}
</Text>
{sideContent}
</Box>
<Box
borderStyle={"solid"}
borderColor={"neutralPlain"}
borderWidth={1}
padding={includePadding ? 8 : 0}
borderRadius={4}
>
{mainContent}
</Box>
</Box>
);
};

View file

@ -1,45 +0,0 @@
import { Card, CardContent, CardHeader } from "@material-ui/core";
import { ImportProductsToAlgolia } from "./ImportProductsToAlgolia";
import { AlgoliaConfigurationCard } from "./AlgoliaConfigurationCard";
import { makeStyles } from "@saleor/macaw-ui";
import { useRouter } from "next/router";
import { Instructions } from "./Instructions";
import { AppColumnsLayout } from "./AppColumnsLayout";
const useStyles = makeStyles((theme) => ({
buttonsGrid: { display: "flex", gap: 10 },
topBar: {
marginBottom: 32,
},
indexActions: {
marginTop: 10,
},
wrapper: {
minHeight: "100%",
paddingBottom: 50,
},
tabs: { marginLeft: 32, marginBottom: 32 },
}));
export const ConfigurationView = () => {
const styles = useStyles();
return (
<div className={styles.wrapper}>
<AppColumnsLayout>
<div />
<div>
<AlgoliaConfigurationCard />
<Card className={styles.indexActions}>
<CardHeader title="Indexing" />
<CardContent>
<ImportProductsToAlgolia />
</CardContent>
</Card>
</div>
<Instructions />
</AppColumnsLayout>
</div>
);
};

View file

@ -1,31 +0,0 @@
import { ImageIcon } from "@saleor/macaw-ui";
import { Highlight, useHits } from "react-instantsearch-hooks-web";
import styles from "../styles/search.module.css";
import Image from "next/image";
function Hit(props: { hit: any }) {
return (
<div className={styles.wrapper}>
{props.hit?.thumbnail ? (
<Image height={60} width={60} src={props.hit.thumbnail} alt={props.hit.slug} />
) : (
<div className={styles.fallbackThumbnail}>
<ImageIcon />
</div>
)}
<Highlight attribute="name" hit={props.hit} />
</div>
);
}
export function Hits() {
const { hits } = useHits();
return (
<div className={styles.hitsWrapper}>
{hits.map((hit) => (
<Hit key={hit.objectID} hit={hit} />
))}
</div>
);
}

View file

@ -1,11 +1,8 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge"; import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { Button } from "@saleor/macaw-ui";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider"; import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider";
import { useConfiguration } from "../lib/configuration"; import { useConfiguration } from "../lib/configuration";
import { Products, useQueryAllProducts } from "./useQueryAllProducts"; import { Products, useQueryAllProducts } from "./useQueryAllProducts";
import { WarningOutlined, WarningRounded } from "@material-ui/icons";
import { Typography } from "@material-ui/core";
const BATCH_SIZE = 100; const BATCH_SIZE = 100;
@ -16,11 +13,7 @@ export const ImportProductsToAlgolia = () => {
const products = useQueryAllProducts(!started); const products = useQueryAllProducts(!started);
const { appBridgeState } = useAppBridge(); const algoliaConfiguration = useConfiguration();
const algoliaConfiguration = useConfiguration(
appBridgeState?.saleorApiUrl,
appBridgeState?.token
);
const searchProvider = useMemo(() => { const searchProvider = useMemo(() => {
if (!algoliaConfiguration.data?.appId || !algoliaConfiguration.data.secretKey) { if (!algoliaConfiguration.data?.appId || !algoliaConfiguration.data.secretKey) {
@ -59,26 +52,31 @@ export const ImportProductsToAlgolia = () => {
}, [searchProvider, currentProductIndex, isAlgoliaImporting, products]); }, [searchProvider, currentProductIndex, isAlgoliaImporting, products]);
return ( return (
<div <Box __cursor={started ? "wait" : "auto"}>
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
paddingBottom: 32,
cursor: started ? "wait" : "auto",
}}
>
{searchProvider ? ( {searchProvider ? (
<Button disabled={started || !searchProvider} onClick={importProducts} fullWidth> <Box>
Start importing products and variants to Algolia <Text variant={"heading"} as={"p"} marginBottom={4}>
Importing products & variants
</Text>
<Text as={"p"}>
Trigger initial indexing for products catalogue. It can take few minutes.{" "}
</Text>
<Text marginBottom={8} variant={"bodyStrong"}>
Do not close the app - its running client-side
</Text>
<Box display={"flex"} justifyContent={"flex-end"}>
<Button disabled={started || !searchProvider} onClick={importProducts}>
Start importing
</Button> </Button>
</Box>
</Box>
) : ( ) : (
<div> <Box>
<Typography align="center"> <Text variant={"heading"} as={"p"} color={"textCriticalDefault"} marginBottom={4}>
<WarningRounded /> App not configured
</Typography> </Text>
<Typography>Ensure Algolia is configured</Typography> <Text>Configure Algolia first</Text>
</div> </Box>
)} )}
{started && ( {started && (
@ -103,7 +101,7 @@ export const ImportProductsToAlgolia = () => {
/> />
</div> </div>
)} )}
</div> </Box>
); );
}; };

View file

@ -1,72 +0,0 @@
import { Card, CardContent, CardHeader, List, ListItem, Typography, Link } from "@material-ui/core";
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
export function Instructions() {
const { appBridge } = useAppBridge();
const algoliaDashboardUrl = "https://www.algolia.com/apps/dashboard";
const algoliaApiKeysUrl = "https://www.algolia.com/account/api-keys/all";
const openExternalUrl = (to: string) => {
appBridge?.dispatch({
type: "redirect",
payload: {
newContext: true,
actionId: "redirect_from_search_app",
to,
},
});
};
return (
<Card>
<CardHeader title="Instructions" />
<CardContent>
<Typography paragraph>
How to configure
<List style={{ marginBottom: 20 }}>
<ListItem>
<Link
onClick={(e) => {
e.preventDefault();
openExternalUrl(algoliaDashboardUrl);
}}
href={algoliaDashboardUrl}
>
1. Create a new Algolia application
</Link>
</ListItem>
<ListItem>
<Typography>
2. Navigate to{" "}
<Link
href={algoliaApiKeysUrl}
onClick={() => {
openExternalUrl(algoliaApiKeysUrl);
}}
>
application keys
</Link>{" "}
section and copy values to the form below
</Typography>
</ListItem>
<ListItem>3. Save configuration</ListItem>
</List>
Useful links
<List>
<ListItem>
<Link
onClick={(e) => {
e.preventDefault();
openExternalUrl("https://github.com/saleor/saleor-app-search");
}}
>
Visit repository & detailed configuration guide
</Link>
</ListItem>
</List>
</Typography>
</CardContent>
</Card>
);
}

View file

@ -1,33 +0,0 @@
import { InputAdornment, TextField } from "@material-ui/core";
import { SearchIcon } from "@saleor/macaw-ui";
import { ChangeEvent } from "react";
import { useSearchBox } from "react-instantsearch-hooks-web";
import styles from "../styles/search.module.css";
export function SearchBox() {
const { query, refine } = useSearchBox();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
refine(e.target.value);
};
return (
<div className={styles.textFieldContainer}>
<TextField
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
value={query}
onChange={handleChange}
placeholder={"Search products..."}
inputProps={{ style: { padding: "16px" } }}
className={styles.textField}
/>
</div>
);
}

View file

@ -0,0 +1,9 @@
import { z } from "zod";
export const AppConfigurationSchema = z.object({
appId: z.string().min(3),
indexNamePrefix: z.string().optional(),
secretKey: z.string().min(3),
});
export type AppConfigurationFields = z.infer<typeof AppConfigurationSchema>;

View file

@ -136,6 +136,10 @@ export class AlgoliaSearchProvider implements SearchProvider {
await this.deleteGroupedByIndex(groupedByIndexToDelete); await this.deleteGroupedByIndex(groupedByIndexToDelete);
} }
} }
async ping() {
return this.#algolia.listIndices().then(() => undefined);
}
} }
type GroupedByIndex = Record<string, AlgoliaObject[]>; type GroupedByIndex = Record<string, AlgoliaObject[]>;
@ -190,9 +194,11 @@ const groupProductsByIndexName = (
.map((p) => groupVariantByIndexName(p, { visibleInListings, indexNamePrefix })) .map((p) => groupVariantByIndexName(p, { visibleInListings, indexNamePrefix }))
.filter(isNotNil) .filter(isNotNil)
.flatMap((x) => Object.entries(x)); .flatMap((x) => Object.entries(x));
const groupedByIndex = batchesAndIndices.reduce((acc, [indexName, objects]) => { const groupedByIndex = batchesAndIndices.reduce((acc, [indexName, objects]) => {
acc[indexName] = acc[indexName] ?? []; acc[indexName] = acc[indexName] ?? [];
acc[indexName].push(...objects); acc[indexName].push(...objects);
return acc; return acc;
}, {} as GroupedByIndex); }, {} as GroupedByIndex);

View file

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { channelListingToAlgoliaIndexId } from "./algoliaUtils";
describe("algoliaUtils", function () {
describe("channelListingToAlgoliaIndexId", function () {
it("Creates proper index from channel and defined prefix", () => {
const result = channelListingToAlgoliaIndexId(
{
channel: { slug: "usd", currencyCode: "USD" },
},
"staging"
);
expect(result).toEqual("staging.usd.USD.products");
});
it("Creates proper index from channel and empty string prefix", () => {
const result = channelListingToAlgoliaIndexId(
{
channel: { slug: "usd", currencyCode: "USD" },
},
""
);
expect(result).toEqual("usd.USD.products");
});
it("Creates proper index from channel and undefined prefix", () => {
const result = channelListingToAlgoliaIndexId(
{
channel: { slug: "usd", currencyCode: "USD" },
},
undefined
);
expect(result).toEqual("usd.USD.products");
});
});
});

View file

@ -15,8 +15,13 @@ export function channelListingToAlgoliaIndexId(
channelListing: PartialChannelListing, channelListing: PartialChannelListing,
indexNamePrefix: string | undefined indexNamePrefix: string | undefined
) { ) {
/**
* Index name should not start with . (dot)
*/
const normalizedPrefix = indexNamePrefix === "" ? undefined : indexNamePrefix;
const nameSegments = [ const nameSegments = [
indexNamePrefix, normalizedPrefix,
channelListing.channel.slug, channelListing.channel.slug,
channelListing.channel.currencyCode, channelListing.channel.currencyCode,
"products", "products",

View file

@ -1,6 +0,0 @@
export type AlgoliaConfigurationFields = {
appId?: string;
secretKey?: string;
searchKey?: string;
indexNamePrefix?: string;
};

View file

@ -1,21 +1,23 @@
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { AlgoliaConfigurationFields } from "./algolia/types"; import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge";
import { AppConfigurationFields } from "../domain/configuration";
export const fetchConfiguration = async (saleorApiUrl: string, token: string) => { export const fetchConfiguration = async (fetch: typeof window.fetch) => {
const res = await fetch("/api/configuration", { try {
headers: { const res = await fetch("/api/configuration");
"authorization-bearer": token, const data = (await res.json()) as { data?: AppConfigurationFields };
"saleor-api-url": saleorApiUrl,
},
});
const data = (await res.json()) as { data?: AlgoliaConfigurationFields };
return data.data; return data.data;
} catch (e) {
throw e;
}
}; };
export const useConfiguration = (saleorApiUrl?: string | undefined, token?: string | undefined) => export const useConfiguration = () => {
useQuery({ const fetch = useAuthenticatedFetch();
return useQuery({
queryKey: ["configuration"], queryKey: ["configuration"],
queryFn: () => fetchConfiguration(saleorApiUrl!, token!), queryFn: () => fetchConfiguration(fetch),
enabled: !!token && !!saleorApiUrl,
}); });
};

View file

@ -1,3 +1,6 @@
import debugPkg from "debug"; import debugPkg from "debug";
/**
* todo rewrite to pino logger
*/
export const createDebug = (namespace: string) => debugPkg.debug(`app-search:${namespace}`); export const createDebug = (namespace: string) => debugPkg.debug(`app-search:${namespace}`);

View file

@ -5,7 +5,6 @@ import {
createClient as urqlCreateClient, createClient as urqlCreateClient,
dedupExchange, dedupExchange,
fetchExchange, fetchExchange,
ssrExchange,
} from "urql"; } from "urql";
interface IAuthState { interface IAuthState {

View file

@ -0,0 +1,17 @@
import pino from "pino";
export const logger = pino({
level: "debug",
redact: ["token", "secretKey"],
transport:
process.env.NODE_ENV === "development"
? {
target: "pino-pretty",
options: {
colorize: true,
},
}
: undefined,
});
export const createLogger = logger.child.bind(logger);

View file

@ -1,4 +1,4 @@
import { MetadataEntry, EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager"; import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
import { Client } from "urql"; import { Client } from "urql";
import { import {

View file

@ -1,8 +0,0 @@
import React, { PropsWithChildren } from "react";
import dynamic from "next/dynamic";
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
ssr: false,
});

View file

@ -1,8 +1,6 @@
import { import {
ProductWebhookPayloadFragment,
Product,
ProductVariantWebhookPayloadFragment, ProductVariantWebhookPayloadFragment,
ProductVariant, ProductWebhookPayloadFragment,
} from "../../generated/graphql"; } from "../../generated/graphql";
export interface SearchProvider { export interface SearchProvider {

View file

@ -1,28 +1,25 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge"; import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui"; import { useTheme } from "@saleor/macaw-ui/next";
import { memo, useEffect } from "react"; import { useEffect } from "react";
/** // todo move to shared
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard, export function ThemeSynchronizer() {
* Macaw must be informed about this change from AppBridge.
*
* If you are not using Macaw, you can remove this.
*/
function _ThemeSynchronizer() {
const { appBridgeState } = useAppBridge(); const { appBridgeState } = useAppBridge();
const { setTheme, themeType } = useTheme(); const { setTheme } = useTheme();
useEffect(() => { useEffect(() => {
if (!setTheme || !appBridgeState?.theme) { if (!setTheme || !appBridgeState?.theme) {
return; return;
} }
if (themeType !== appBridgeState?.theme) { if (appBridgeState.theme === "light") {
setTheme(appBridgeState.theme); setTheme("defaultLight");
} }
}, [appBridgeState?.theme, setTheme, themeType]);
if (appBridgeState.theme === "dark") {
setTheme("defaultDark");
}
}, [appBridgeState?.theme, setTheme]);
return null; return null;
} }
export const ThemeSynchronizer = memo(_ThemeSynchronizer);

View file

@ -1,26 +1,14 @@
import "../styles/globals.css"; import "../styles/globals.css";
import "@saleor/macaw-ui/next/style";
import { Theme } from "@material-ui/core/styles";
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge"; import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
import { import React, { useEffect } from "react";
dark,
light,
SaleorThemeColors,
ThemeProvider as MacawUIThemeProvider,
} from "@saleor/macaw-ui";
import React, { PropsWithChildren, useEffect } from "react";
import { AppProps } from "next/app"; import { AppProps } from "next/app";
import { GraphQLProvider } from "../providers/GraphQLProvider"; import { GraphQLProvider } from "../providers/GraphQLProvider";
import { QueryClient, QueryClientProvider } from "react-query"; import { QueryClient, QueryClientProvider } from "react-query";
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next"; import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import { ThemeSynchronizer } from "../lib/theme-synchronizer"; import { ThemeSynchronizer } from "../lib/theme-synchronizer";
import { NoSSRWrapper } from "../lib/no-ssr-wrapper"; import { Box, ThemeProvider } from "@saleor/macaw-ui/next";
import { NoSSRWrapper } from "@saleor/apps-shared";
const themeOverrides: Partial<Theme> = {
/**
* You can override MacawUI theme here
*/
};
/** /**
* Ensure instance is a singleton. * Ensure instance is a singleton.
@ -36,38 +24,6 @@ const queryClient = new QueryClient({
}, },
}); });
type PalettesOverride = Record<"light" | "dark", SaleorThemeColors>;
/**
* Temporary override of colors, to match new dashboard palette.
* Long term this will be replaced with Macaw UI 2.x with up to date design tokens
*/
const palettes: PalettesOverride = {
light: {
...light,
background: {
default: "#fff",
paper: "#fff",
},
},
dark: {
...dark,
background: {
default: "hsla(211, 42%, 14%, 1)",
paper: "hsla(211, 42%, 14%, 1)",
},
},
};
/**
* That's a hack required by Macaw-UI incompatibility with React@18
*
* TODO: use from packages/shared
*/
const ThemeProvider = MacawUIThemeProvider as React.FC<
PropsWithChildren<{ overrides?: Partial<Theme>; ssr: boolean; palettes: PalettesOverride }>
>;
function NextApp({ Component, pageProps }: AppProps) { function NextApp({ Component, pageProps }: AppProps) {
/** /**
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed. * Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
@ -84,11 +40,13 @@ function NextApp({ Component, pageProps }: AppProps) {
<NoSSRWrapper> <NoSSRWrapper>
<AppBridgeProvider appBridgeInstance={appBridgeInstance}> <AppBridgeProvider appBridgeInstance={appBridgeInstance}>
<GraphQLProvider> <GraphQLProvider>
<ThemeProvider palettes={palettes} overrides={themeOverrides} ssr> <ThemeProvider>
<ThemeSynchronizer /> <ThemeSynchronizer />
<RoutePropagator /> <RoutePropagator />
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Box padding={13}>
<Component {...pageProps} /> <Component {...pageProps} />
</Box>
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider> </ThemeProvider>
</GraphQLProvider> </GraphQLProvider>

View file

@ -16,10 +16,10 @@
* - https://reactjs.org/docs/error-boundaries.html * - https://reactjs.org/docs/error-boundaries.html
*/ */
import * as Sentry from '@sentry/nextjs'; import * as Sentry from "@sentry/nextjs";
import NextErrorComponent from 'next/error'; import NextErrorComponent from "next/error";
const CustomErrorComponent = props => { const CustomErrorComponent = (props) => {
/* /*
* If you're using a Nextjs version prior to 12.2.1, uncomment this to * If you're using a Nextjs version prior to 12.2.1, uncomment this to
* compensate for https://github.com/vercel/next.js/issues/8592 * compensate for https://github.com/vercel/next.js/issues/8592
@ -29,7 +29,7 @@ const CustomErrorComponent = props => {
return <NextErrorComponent statusCode={props.statusCode} />; return <NextErrorComponent statusCode={props.statusCode} />;
}; };
CustomErrorComponent.getInitialProps = async contextData => { CustomErrorComponent.getInitialProps = async (contextData) => {
/* /*
* In case this is running in a serverless function, await this in order to give Sentry * In case this is running in a serverless function, await this in order to give Sentry
* time to send the error before the lambda exits * time to send the error before the lambda exits

View file

@ -4,16 +4,18 @@ import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { createClient } from "../../lib/graphql"; import { createClient } from "../../lib/graphql";
import { createSettingsManager } from "../../lib/metadata"; import { createSettingsManager } from "../../lib/metadata";
import { saleorApp } from "../../../saleor-app"; import { saleorApp } from "../../../saleor-app";
import { AlgoliaConfigurationFields } from "../../lib/algolia/types";
import { createDebug } from "../../lib/debug";
import { createProtectedHandler, ProtectedHandlerContext } from "@saleor/app-sdk/handlers/next"; import { createProtectedHandler, ProtectedHandlerContext } from "@saleor/app-sdk/handlers/next";
import { createLogger } from "../../lib/logger";
import { AppConfigurationFields } from "../../domain/configuration";
const debug = createDebug("/api/configuration"); const logger = createLogger({
handler: "api/configuration",
});
export interface SettingsApiResponse { export interface SettingsApiResponse {
success: boolean; success: boolean;
data?: AlgoliaConfigurationFields; data?: AppConfigurationFields;
} }
const sendResponse = async ( const sendResponse = async (
@ -22,14 +24,17 @@ const sendResponse = async (
settings: SettingsManager, settings: SettingsManager,
domain: string domain: string
) => { ) => {
res.status(statusCode).json({ const data = {
success: statusCode === 200,
data: {
secretKey: (await settings.get("secretKey", domain)) || "", secretKey: (await settings.get("secretKey", domain)) || "",
searchKey: (await settings.get("searchKey", domain)) || "",
appId: (await settings.get("appId", domain)) || "", appId: (await settings.get("appId", domain)) || "",
indexNamePrefix: (await settings.get("indexNamePrefix", domain)) || "", indexNamePrefix: (await settings.get("indexNamePrefix", domain)) || "",
}, };
logger.debug(data, "Will return following settings");
res.status(statusCode).json({
success: statusCode === 200,
data,
}); });
}; };
@ -38,11 +43,12 @@ export const handler = async (
res: NextApiResponse, res: NextApiResponse,
ctx: ProtectedHandlerContext ctx: ProtectedHandlerContext
) => { ) => {
debug("Configuration handler received request");
const { const {
authData: { token, saleorApiUrl }, authData: { token, saleorApiUrl },
} = ctx; } = ctx;
logger.debug({ saleorApiUrl }, "handler called");
const client = createClient(saleorApiUrl, async () => Promise.resolve({ token: token })); const client = createClient(saleorApiUrl, async () => Promise.resolve({ token: token }));
const settings = createSettingsManager(client); const settings = createSettingsManager(client);
@ -50,25 +56,25 @@ export const handler = async (
const domain = new URL(saleorApiUrl).host; const domain = new URL(saleorApiUrl).host;
if (req.method === "GET") { if (req.method === "GET") {
debug("Returning configuration"); logger.debug("Returning configuration");
await sendResponse(res, 200, settings, domain); await sendResponse(res, 200, settings, domain);
return; return;
} else if (req.method === "POST") { } else if (req.method === "POST") {
debug("Updating the configuration"); logger.debug("Updating the configuration");
const { appId, searchKey, secretKey, indexNamePrefix } = JSON.parse(
req.body const { appId, secretKey, indexNamePrefix } = JSON.parse(req.body) as AppConfigurationFields;
) as AlgoliaConfigurationFields;
await settings.set([ await settings.set([
{ key: "secretKey", value: secretKey || "", domain }, { key: "secretKey", value: secretKey || "", domain },
{ key: "searchKey", value: searchKey || "", domain },
{ key: "appId", value: appId || "", domain }, { key: "appId", value: appId || "", domain },
{ key: "indexNamePrefix", value: indexNamePrefix || "", domain }, { key: "indexNamePrefix", value: indexNamePrefix || "", domain },
]); ]);
await sendResponse(res, 200, settings, domain); await sendResponse(res, 200, settings, domain);
return; return;
} }
debug("Method not supported"); logger.error("Method not supported");
res.status(405).end(); res.status(405).end();
}; };

View file

@ -1,13 +1,12 @@
import { useAppBridge, withAuthorization } from "@saleor/app-sdk/app-bridge"; import { useAppBridge, withAuthorization } from "@saleor/app-sdk/app-bridge";
import { ConfigurationView } from "../components/ConfigurationView"; import { ConfigurationView } from "../views/configuration/configuration.view";
import { isInIframe } from "@saleor/apps-shared"; import { isInIframe } from "@saleor/apps-shared";
import { LinearProgress } from "@material-ui/core";
const IndexPage = () => { const IndexPage = () => {
const { appBridgeState } = useAppBridge(); const { appBridgeState } = useAppBridge();
if (isInIframe() && !appBridgeState?.token) { if (isInIframe() && !appBridgeState?.token) {
return <LinearProgress />; return <div>Loading</div>;
} }
return <ConfigurationView />; return <ConfigurationView />;

View file

@ -0,0 +1,6 @@
/**
* Add test setup logic here
*
* https://vitest.dev/config/#setupfiles
*/
export {};

View file

@ -1,17 +1,16 @@
body { body {
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, color: var(--mu-colors-foreground-text-neutral-plain);
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; padding: 0;
color: #111; margin: 0;
padding-top: 32px; background: var(--mu-colors-background-plain);
} }
code { code {
background: #f6f8fa;
border: 1px solid #eaeaea;
border-radius: 5px;
display: inline-block; display: inline-block;
margin-top: 10px; letter-spacing: 0.1em;
padding: 0.1em 0.2em; color: var(--mu-colors-foreground-text-neutral-subdued);
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, }
Bitstream Vera Sans Mono, Courier New, monospace;
a {
text-decoration: none;
} }

View file

@ -1,50 +0,0 @@
.wrapper {
display: grid;
height: 60px;
width: 100%;
grid-auto-flow: column;
grid-template-columns: 60px auto;
gap: 30px;
}
.hitsWrapper {
display: grid;
gap: 15px;
}
.textField {
border: none;
}
.textFieldContainer {
display: flex;
place-content: left;
}
.contentWrapper {
display: flex;
flex-direction: column;
gap: 2rem;
}
.filterGrid {
display: grid;
grid-template-columns: 4fr;
grid-gap: 2rem;
max-width: 100%;
}
.fallbackThumbnail {
display: flex;
justify-content: center;
align-items: center;
width: 60px;
height: 60px;
}
.filters {
margin-left: 16px;
}
.tabs {
margin-bottom: 32px;
margin-left: 32px;
}

View file

@ -0,0 +1,33 @@
import { Box, Text } from "@saleor/macaw-ui/next";
import { AppSection } from "../../components/AppSection";
import { AlgoliaConfigurationForm } from "../../components/AlgoliaConfigurationForm";
import { ImportProductsToAlgolia } from "../../components/ImportProductsToAlgolia";
export const ConfigurationView = () => {
return (
<Box>
<Text variant={"hero"} size={"medium"} as={"h1"}>
Configuration
</Text>
<Text as="p" marginTop={4} __marginBottom={"100px"}>
Configure the App - fill your Algolia settings to allow products indexing
</Text>
<AppSection
heading="Algolia settings"
mainContent={<AlgoliaConfigurationForm />}
sideContent={<Text>Provide Algolia settings. </Text>}
/>
<AppSection
includePadding
marginTop={13}
heading="Index products"
mainContent={<ImportProductsToAlgolia />}
sideContent={
<Box>
<Text>Perform initial index of all products in your Saleor database</Text>
</Box>
}
/>
</Box>
);
};

View file

@ -0,0 +1,13 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
passWithNoTests: true,
environment: "jsdom",
setupFiles: "./src/setup-tests.ts",
css: false,
},
});

View file

@ -32,7 +32,9 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"urql": "^3.0.3", "urql": "^3.0.3",
"usehooks-ts": "^2.9.1" "usehooks-ts": "^2.9.1",
"vite": "4.3.1",
"vitest": "0.30.1"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "3.2.2", "@graphql-codegen/cli": "3.2.2",

View file

@ -45,7 +45,7 @@
"taxjar": "^4.0.1", "taxjar": "^4.0.1",
"urql": "^3.0.3", "urql": "^3.0.3",
"usehooks-ts": "^2.9.1", "usehooks-ts": "^2.9.1",
"vite": "^4.2.1", "vite": "^4.3.1",
"vitest": "^0.30.1", "vitest": "^0.30.1",
"zod": "^3.20.2" "zod": "^3.20.2"
}, },

File diff suppressed because it is too large Load diff