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:
parent
cad39760c9
commit
fc7a70f598
45 changed files with 5342 additions and 4852 deletions
5
.changeset/curly-jars-doubt.md
Normal file
5
.changeset/curly-jars-doubt.md
Normal 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
|
|
@ -39,7 +39,7 @@
|
|||
"urql": "^3.0.3",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"uuid": "^9.0.0",
|
||||
"vite": "^4.2.1",
|
||||
"vite": "^4.3.1",
|
||||
"zod": "^3.19.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
"react-is": "^18.2.0",
|
||||
"urql": "^3.0.3",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"vite": "^4.2.1",
|
||||
"vite": "^4.3.1",
|
||||
"vitest": "^0.30.1",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|||
import { useTheme } from "@saleor/macaw-ui/next";
|
||||
import { memo, useEffect } from "react";
|
||||
|
||||
// todo move to shared
|
||||
export function ThemeSynchronizer() {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const { setTheme } = useTheme();
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
"react-dom": "18.2.0",
|
||||
"urql": "^3.0.3",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"vite": "^4.2.1",
|
||||
"vite": "^4.3.1",
|
||||
"vitest": "^0.30.1",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
"react-query": "^3.39.3",
|
||||
"urql": "^3.0.3",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"vite": "^4.2.1",
|
||||
"vite": "^4.3.1",
|
||||
"vitest": "^0.30.1",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
|
|
|
@ -62,6 +62,7 @@
|
|||
"@types/react": "^18.0.27",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/rimraf": "^3.0.2",
|
||||
"@types/semver": "^7.3.13",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"@vitest/coverage-c8": "^0.28.4",
|
||||
"dotenv": "^16.0.3",
|
||||
|
@ -69,8 +70,7 @@
|
|||
"jsdom": "^20.0.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"typescript": "4.9.5",
|
||||
"vite": "^4.2.1",
|
||||
"vitest": "^0.30.1",
|
||||
"@types/semver": "^7.3.13"
|
||||
"vite": "^4.3.1",
|
||||
"vitest": "^0.30.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,7 +32,9 @@
|
|||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"urql": "^3.0.3"
|
||||
"urql": "^3.0.3",
|
||||
"vite": "4.3.1",
|
||||
"vitest": "0.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "3.2.2",
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.42.1",
|
||||
"urql": "^3.0.3",
|
||||
"vite": "^4.2.1",
|
||||
"vite": "^4.3.1",
|
||||
"vitest": "^0.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
"react-is": "^18.2.0",
|
||||
"urql": "^3.0.3",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"vite": "^4.2.1",
|
||||
"vite": "^4.3.1",
|
||||
"vitest": "^0.30.1",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
|
|
|
@ -15,12 +15,10 @@
|
|||
"schemaVersion": "3.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@material-ui/core": "^4.12.4",
|
||||
"@material-ui/icons": "^4.11.3",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"@hookform/resolvers": "^3.1.0",
|
||||
"@saleor/app-sdk": "0.37.3",
|
||||
"@saleor/apps-shared": "workspace:*",
|
||||
"@saleor/macaw-ui": "0.7.2",
|
||||
"@saleor/macaw-ui": "^0.8.0-pre.76",
|
||||
"@sentry/nextjs": "^7.46.0",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@urql/exchange-auth": "^1.0.0",
|
||||
|
@ -29,16 +27,17 @@
|
|||
"debug": "^4.3.4",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"instantsearch.css": "^7.4.5",
|
||||
"next": "13.3.0",
|
||||
"next-urql": "4.0.0",
|
||||
"pino": "^8.8.0",
|
||||
"pino-pretty": "^9.1.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-hook-form": "^7.39.1",
|
||||
"react-instantsearch-hooks-web": "^6.38.0",
|
||||
"react-query": "^3.39.2",
|
||||
"urql": "^3.0.3"
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-query": "^3.39.3",
|
||||
"urql": "^3.0.3",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "3.2.2",
|
||||
|
@ -51,13 +50,16 @@
|
|||
"@graphql-codegen/urql-introspection": "2.2.1",
|
||||
"@graphql-typed-document-node/core": "^3.1.2",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/react": "^18.0.25",
|
||||
"@types/react": "~18.0.38",
|
||||
"@types/react-dom": "^18.0.8",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"eslint": "8.27.0",
|
||||
"eslint-config-next": "13.0.2",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-config-saleor": "workspace:*",
|
||||
"prettier": "^2.7.1",
|
||||
"typescript": "4.8.4"
|
||||
"typescript": "4.8.4",
|
||||
"vite": "^4.3.1",
|
||||
"vitest": "^0.30.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
167
apps/search/src/components/AlgoliaConfigurationForm.tsx
Normal file
167
apps/search/src/components/AlgoliaConfigurationForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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");
|
||||
}
|
||||
};
|
36
apps/search/src/components/AppSection.tsx
Normal file
36
apps/search/src/components/AppSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,11 +1,8 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { Button } from "@saleor/macaw-ui";
|
||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider";
|
||||
import { useConfiguration } from "../lib/configuration";
|
||||
import { Products, useQueryAllProducts } from "./useQueryAllProducts";
|
||||
import { WarningOutlined, WarningRounded } from "@material-ui/icons";
|
||||
import { Typography } from "@material-ui/core";
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
|
@ -16,11 +13,7 @@ export const ImportProductsToAlgolia = () => {
|
|||
|
||||
const products = useQueryAllProducts(!started);
|
||||
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const algoliaConfiguration = useConfiguration(
|
||||
appBridgeState?.saleorApiUrl,
|
||||
appBridgeState?.token
|
||||
);
|
||||
const algoliaConfiguration = useConfiguration();
|
||||
|
||||
const searchProvider = useMemo(() => {
|
||||
if (!algoliaConfiguration.data?.appId || !algoliaConfiguration.data.secretKey) {
|
||||
|
@ -59,26 +52,31 @@ export const ImportProductsToAlgolia = () => {
|
|||
}, [searchProvider, currentProductIndex, isAlgoliaImporting, products]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
paddingBottom: 32,
|
||||
cursor: started ? "wait" : "auto",
|
||||
}}
|
||||
>
|
||||
<Box __cursor={started ? "wait" : "auto"}>
|
||||
{searchProvider ? (
|
||||
<Button disabled={started || !searchProvider} onClick={importProducts} fullWidth>
|
||||
Start importing products and variants to Algolia
|
||||
</Button>
|
||||
<Box>
|
||||
<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>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<div>
|
||||
<Typography align="center">
|
||||
<WarningRounded />
|
||||
</Typography>
|
||||
<Typography>Ensure Algolia is configured</Typography>
|
||||
</div>
|
||||
<Box>
|
||||
<Text variant={"heading"} as={"p"} color={"textCriticalDefault"} marginBottom={4}>
|
||||
App not configured
|
||||
</Text>
|
||||
<Text>Configure Algolia first</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{started && (
|
||||
|
@ -103,7 +101,7 @@ export const ImportProductsToAlgolia = () => {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
9
apps/search/src/domain/configuration.ts
Normal file
9
apps/search/src/domain/configuration.ts
Normal 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>;
|
|
@ -136,6 +136,10 @@ export class AlgoliaSearchProvider implements SearchProvider {
|
|||
await this.deleteGroupedByIndex(groupedByIndexToDelete);
|
||||
}
|
||||
}
|
||||
|
||||
async ping() {
|
||||
return this.#algolia.listIndices().then(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
type GroupedByIndex = Record<string, AlgoliaObject[]>;
|
||||
|
@ -190,9 +194,11 @@ const groupProductsByIndexName = (
|
|||
.map((p) => groupVariantByIndexName(p, { visibleInListings, indexNamePrefix }))
|
||||
.filter(isNotNil)
|
||||
.flatMap((x) => Object.entries(x));
|
||||
|
||||
const groupedByIndex = batchesAndIndices.reduce((acc, [indexName, objects]) => {
|
||||
acc[indexName] = acc[indexName] ?? [];
|
||||
acc[indexName].push(...objects);
|
||||
|
||||
return acc;
|
||||
}, {} as GroupedByIndex);
|
||||
|
||||
|
|
39
apps/search/src/lib/algolia/algoliaUtils.test.ts
Normal file
39
apps/search/src/lib/algolia/algoliaUtils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
|
@ -15,8 +15,13 @@ export function channelListingToAlgoliaIndexId(
|
|||
channelListing: PartialChannelListing,
|
||||
indexNamePrefix: string | undefined
|
||||
) {
|
||||
/**
|
||||
* Index name should not start with . (dot)
|
||||
*/
|
||||
const normalizedPrefix = indexNamePrefix === "" ? undefined : indexNamePrefix;
|
||||
|
||||
const nameSegments = [
|
||||
indexNamePrefix,
|
||||
normalizedPrefix,
|
||||
channelListing.channel.slug,
|
||||
channelListing.channel.currencyCode,
|
||||
"products",
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
export type AlgoliaConfigurationFields = {
|
||||
appId?: string;
|
||||
secretKey?: string;
|
||||
searchKey?: string;
|
||||
indexNamePrefix?: string;
|
||||
};
|
|
@ -1,21 +1,23 @@
|
|||
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) => {
|
||||
const res = await fetch("/api/configuration", {
|
||||
headers: {
|
||||
"authorization-bearer": token,
|
||||
"saleor-api-url": saleorApiUrl,
|
||||
},
|
||||
});
|
||||
const data = (await res.json()) as { data?: AlgoliaConfigurationFields };
|
||||
export const fetchConfiguration = async (fetch: typeof window.fetch) => {
|
||||
try {
|
||||
const res = await fetch("/api/configuration");
|
||||
const data = (await res.json()) as { data?: AppConfigurationFields };
|
||||
|
||||
return data.data;
|
||||
return data.data;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const useConfiguration = (saleorApiUrl?: string | undefined, token?: string | undefined) =>
|
||||
useQuery({
|
||||
export const useConfiguration = () => {
|
||||
const fetch = useAuthenticatedFetch();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["configuration"],
|
||||
queryFn: () => fetchConfiguration(saleorApiUrl!, token!),
|
||||
enabled: !!token && !!saleorApiUrl,
|
||||
queryFn: () => fetchConfiguration(fetch),
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import debugPkg from "debug";
|
||||
|
||||
/**
|
||||
* todo rewrite to pino logger
|
||||
*/
|
||||
export const createDebug = (namespace: string) => debugPkg.debug(`app-search:${namespace}`);
|
||||
|
|
|
@ -5,7 +5,6 @@ import {
|
|||
createClient as urqlCreateClient,
|
||||
dedupExchange,
|
||||
fetchExchange,
|
||||
ssrExchange,
|
||||
} from "urql";
|
||||
|
||||
interface IAuthState {
|
||||
|
|
17
apps/search/src/lib/logger.ts
Normal file
17
apps/search/src/lib/logger.ts
Normal 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);
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
});
|
|
@ -1,8 +1,6 @@
|
|||
import {
|
||||
ProductWebhookPayloadFragment,
|
||||
Product,
|
||||
ProductVariantWebhookPayloadFragment,
|
||||
ProductVariant,
|
||||
ProductWebhookPayloadFragment,
|
||||
} from "../../generated/graphql";
|
||||
|
||||
export interface SearchProvider {
|
||||
|
|
|
@ -1,28 +1,25 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { useTheme } from "@saleor/macaw-ui";
|
||||
import { memo, useEffect } from "react";
|
||||
import { useTheme } from "@saleor/macaw-ui/next";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
|
||||
* Macaw must be informed about this change from AppBridge.
|
||||
*
|
||||
* If you are not using Macaw, you can remove this.
|
||||
*/
|
||||
function _ThemeSynchronizer() {
|
||||
// todo move to shared
|
||||
export function ThemeSynchronizer() {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const { setTheme, themeType } = useTheme();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (!setTheme || !appBridgeState?.theme) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (themeType !== appBridgeState?.theme) {
|
||||
setTheme(appBridgeState.theme);
|
||||
if (appBridgeState.theme === "light") {
|
||||
setTheme("defaultLight");
|
||||
}
|
||||
}, [appBridgeState?.theme, setTheme, themeType]);
|
||||
|
||||
if (appBridgeState.theme === "dark") {
|
||||
setTheme("defaultDark");
|
||||
}
|
||||
}, [appBridgeState?.theme, setTheme]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ThemeSynchronizer = memo(_ThemeSynchronizer);
|
||||
|
|
|
@ -1,26 +1,14 @@
|
|||
import "../styles/globals.css";
|
||||
|
||||
import { Theme } from "@material-ui/core/styles";
|
||||
import "@saleor/macaw-ui/next/style";
|
||||
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
|
||||
import {
|
||||
dark,
|
||||
light,
|
||||
SaleorThemeColors,
|
||||
ThemeProvider as MacawUIThemeProvider,
|
||||
} from "@saleor/macaw-ui";
|
||||
import React, { PropsWithChildren, useEffect } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { AppProps } from "next/app";
|
||||
import { GraphQLProvider } from "../providers/GraphQLProvider";
|
||||
import { QueryClient, QueryClientProvider } from "react-query";
|
||||
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
|
||||
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
|
||||
import { NoSSRWrapper } from "../lib/no-ssr-wrapper";
|
||||
|
||||
const themeOverrides: Partial<Theme> = {
|
||||
/**
|
||||
* You can override MacawUI theme here
|
||||
*/
|
||||
};
|
||||
import { Box, ThemeProvider } from "@saleor/macaw-ui/next";
|
||||
import { NoSSRWrapper } from "@saleor/apps-shared";
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
/**
|
||||
* 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>
|
||||
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
|
||||
<GraphQLProvider>
|
||||
<ThemeProvider palettes={palettes} overrides={themeOverrides} ssr>
|
||||
<ThemeProvider>
|
||||
<ThemeSynchronizer />
|
||||
<RoutePropagator />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Component {...pageProps} />
|
||||
<Box padding={13}>
|
||||
<Component {...pageProps} />
|
||||
</Box>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</GraphQLProvider>
|
||||
|
|
|
@ -16,10 +16,10 @@
|
|||
* - https://reactjs.org/docs/error-boundaries.html
|
||||
*/
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import NextErrorComponent from 'next/error';
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
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
|
||||
* compensate for https://github.com/vercel/next.js/issues/8592
|
||||
|
@ -29,7 +29,7 @@ const CustomErrorComponent = props => {
|
|||
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
|
||||
* time to send the error before the lambda exits
|
||||
|
|
|
@ -4,16 +4,18 @@ import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
|||
import { createClient } from "../../lib/graphql";
|
||||
import { createSettingsManager } from "../../lib/metadata";
|
||||
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 { createLogger } from "../../lib/logger";
|
||||
import { AppConfigurationFields } from "../../domain/configuration";
|
||||
|
||||
const debug = createDebug("/api/configuration");
|
||||
const logger = createLogger({
|
||||
handler: "api/configuration",
|
||||
});
|
||||
|
||||
export interface SettingsApiResponse {
|
||||
success: boolean;
|
||||
data?: AlgoliaConfigurationFields;
|
||||
data?: AppConfigurationFields;
|
||||
}
|
||||
|
||||
const sendResponse = async (
|
||||
|
@ -22,14 +24,17 @@ const sendResponse = async (
|
|||
settings: SettingsManager,
|
||||
domain: string
|
||||
) => {
|
||||
const data = {
|
||||
secretKey: (await settings.get("secretKey", domain)) || "",
|
||||
appId: (await settings.get("appId", domain)) || "",
|
||||
indexNamePrefix: (await settings.get("indexNamePrefix", domain)) || "",
|
||||
};
|
||||
|
||||
logger.debug(data, "Will return following settings");
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: statusCode === 200,
|
||||
data: {
|
||||
secretKey: (await settings.get("secretKey", domain)) || "",
|
||||
searchKey: (await settings.get("searchKey", domain)) || "",
|
||||
appId: (await settings.get("appId", domain)) || "",
|
||||
indexNamePrefix: (await settings.get("indexNamePrefix", domain)) || "",
|
||||
},
|
||||
data,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -38,11 +43,12 @@ export const handler = async (
|
|||
res: NextApiResponse,
|
||||
ctx: ProtectedHandlerContext
|
||||
) => {
|
||||
debug("Configuration handler received request");
|
||||
|
||||
const {
|
||||
authData: { token, saleorApiUrl },
|
||||
} = ctx;
|
||||
|
||||
logger.debug({ saleorApiUrl }, "handler called");
|
||||
|
||||
const client = createClient(saleorApiUrl, async () => Promise.resolve({ token: token }));
|
||||
|
||||
const settings = createSettingsManager(client);
|
||||
|
@ -50,25 +56,25 @@ export const handler = async (
|
|||
const domain = new URL(saleorApiUrl).host;
|
||||
|
||||
if (req.method === "GET") {
|
||||
debug("Returning configuration");
|
||||
logger.debug("Returning configuration");
|
||||
|
||||
await sendResponse(res, 200, settings, domain);
|
||||
return;
|
||||
} else if (req.method === "POST") {
|
||||
debug("Updating the configuration");
|
||||
const { appId, searchKey, secretKey, indexNamePrefix } = JSON.parse(
|
||||
req.body
|
||||
) as AlgoliaConfigurationFields;
|
||||
logger.debug("Updating the configuration");
|
||||
|
||||
const { appId, secretKey, indexNamePrefix } = JSON.parse(req.body) as AppConfigurationFields;
|
||||
|
||||
await settings.set([
|
||||
{ key: "secretKey", value: secretKey || "", domain },
|
||||
{ key: "searchKey", value: searchKey || "", domain },
|
||||
{ key: "appId", value: appId || "", domain },
|
||||
{ key: "indexNamePrefix", value: indexNamePrefix || "", domain },
|
||||
]);
|
||||
await sendResponse(res, 200, settings, domain);
|
||||
return;
|
||||
}
|
||||
debug("Method not supported");
|
||||
logger.error("Method not supported");
|
||||
|
||||
res.status(405).end();
|
||||
};
|
||||
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
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 { LinearProgress } from "@material-ui/core";
|
||||
|
||||
const IndexPage = () => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
|
||||
if (isInIframe() && !appBridgeState?.token) {
|
||||
return <LinearProgress />;
|
||||
return <div>Loading</div>;
|
||||
}
|
||||
|
||||
return <ConfigurationView />;
|
||||
|
|
6
apps/search/src/setup-tests.ts
Normal file
6
apps/search/src/setup-tests.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* Add test setup logic here
|
||||
*
|
||||
* https://vitest.dev/config/#setupfiles
|
||||
*/
|
||||
export {};
|
|
@ -1,17 +1,16 @@
|
|||
body {
|
||||
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
color: #111;
|
||||
padding-top: 32px;
|
||||
color: var(--mu-colors-foreground-text-neutral-plain);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: var(--mu-colors-background-plain);
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
padding: 0.1em 0.2em;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--mu-colors-foreground-text-neutral-subdued);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
33
apps/search/src/views/configuration/configuration.view.tsx
Normal file
33
apps/search/src/views/configuration/configuration.view.tsx
Normal 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>
|
||||
);
|
||||
};
|
13
apps/search/vitest.config.ts
Normal file
13
apps/search/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
|
@ -32,7 +32,9 @@
|
|||
"react-dom": "18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"urql": "^3.0.3",
|
||||
"usehooks-ts": "^2.9.1"
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"vite": "4.3.1",
|
||||
"vitest": "0.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "3.2.2",
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
"taxjar": "^4.0.1",
|
||||
"urql": "^3.0.3",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"vite": "^4.2.1",
|
||||
"vite": "^4.3.1",
|
||||
"vitest": "^0.30.1",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
|
|
9092
pnpm-lock.yaml
9092
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue