Skip to content

Commit

Permalink
Add attribute mapping
Browse files Browse the repository at this point in the history
  • Loading branch information
krzysztofwolski committed Aug 2, 2023
1 parent 07999ea commit d868f2d
Show file tree
Hide file tree
Showing 17 changed files with 697 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/nine-dryers-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"saleor-app-products-feed": minor
---

Added new feature: Attributes required by the Google Merchant can now be mapped to product attributes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
fragment AttributeWithMappingFragment on Attribute {
id
name
slug
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,30 @@ fragment GoogleFeedProductVariant on ProductVariant {
}
}
quantityAvailable
attributes {
attribute {
id
}
values {
value
name
}
}
product {
id
name
slug
description
seoDescription
attributes{
attribute{
id
}
values{
value
name
}
}
thumbnail {
url
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
query FetchAttributesWithMapping($cursor: String){
attributes(first: 100, after: $cursor){
pageInfo{
hasNextPage
endCursor
}
edges{
node{
...AttributeWithMappingFragment
}
}
}
}
26 changes: 26 additions & 0 deletions apps/products-feed/src/modules/app-configuration/app-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { z } from "zod";

const attributeMappingSchema = z.object({
brandAttributeIds: z.array(z.string()),
colorAttributeIds: z.array(z.string()),
sizeAttributeIds: z.array(z.string()),
materialAttributeIds: z.array(z.string()),
});

const s3ConfigSchema = z.object({
bucketName: z.string().min(1),
secretAccessKey: z.string().min(1),
Expand All @@ -14,13 +21,15 @@ const urlConfigurationSchema = z.object({

const rootAppConfigSchema = z.object({
s3: s3ConfigSchema.nullable(),
attributeMapping: attributeMappingSchema.nullable(),
channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })),
});

export const AppConfigSchema = {
root: rootAppConfigSchema,
s3Bucket: s3ConfigSchema,
channelUrls: urlConfigurationSchema,
attributeMapping: attributeMappingSchema,
};

export type RootConfig = z.infer<typeof rootAppConfigSchema>;
Expand All @@ -31,6 +40,7 @@ export class AppConfig {
private rootData: RootConfig = {
channelConfig: {},
s3: null,
attributeMapping: null,
};

constructor(initialData?: RootConfig) {
Expand Down Expand Up @@ -63,6 +73,18 @@ export class AppConfig {
}
}

setAttributeMapping(attributeMapping: z.infer<typeof attributeMappingSchema>) {
try {
this.rootData.attributeMapping = attributeMappingSchema.parse(attributeMapping);

return this;
} catch (e) {
console.error(e);

throw new Error("Invalid mapping config provided");
}
}

setChannelUrls(channelSlug: string, urlsConfig: z.infer<typeof urlConfigurationSchema>) {
try {
const parsedConfig = urlConfigurationSchema.parse(urlsConfig);
Expand All @@ -88,4 +110,8 @@ export class AppConfig {
getS3Config() {
return this.rootData.s3;
}

getAttributeMapping() {
return this.rootData.attributeMapping;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { z } from "zod";
import { createS3ClientFromConfiguration } from "../file-storage/s3/create-s3-client-from-configuration";
import { checkBucketAccess } from "../file-storage/s3/check-bucket-access";
import { TRPCError } from "@trpc/server";
import { AttributeFetcher } from "./attribute-fetcher";

export const appConfigurationRouter = router({
/**
Expand Down Expand Up @@ -116,4 +117,38 @@ export const appConfigurationRouter = router({
return null;
}
),
setAttributeMapping: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(AppConfigSchema.attributeMapping)
.mutation(
async ({
ctx: { getConfig, apiClient, saleorApiUrl, appConfigMetadataManager, logger },
input,
}) => {
const config = await getConfig();

config.setAttributeMapping(input);

await appConfigMetadataManager.set(config.serialize());

return null;
}
),

getAttributes: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.query(async ({ ctx: { logger, apiClient } }) => {
const fetcher = new AttributeFetcher(apiClient);

const result = await fetcher.fetchAllAttributes().catch((e) => {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Can't fetch the attributes",
});
});

logger.debug("Returning attributes");

return result;
}),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Client } from "urql";
import {
AttributeWithMappingFragmentFragment,
FetchAttributesWithMappingDocument,
} from "../../../generated/graphql";

export class AttributeFetcher {
constructor(private apiClient: Pick<Client, "query">) {}

private async fetchRecursivePage(
accumulator: AttributeWithMappingFragmentFragment[],
cursor?: string
): Promise<AttributeWithMappingFragmentFragment[]> {
const result = await this.apiClient
.query(FetchAttributesWithMappingDocument, {
cursor,
})
.toPromise();

if (result.error) {
throw new Error(result.error.message);
}

if (!result.data) {
// todo sentry
throw new Error("Empty attributes data");
}

accumulator = [...accumulator, ...(result.data.attributes?.edges.map((c) => c.node) ?? [])];

const hasNextPage = result.data.attributes?.pageInfo.hasNextPage;
const endCursor = result.data.attributes?.pageInfo.endCursor;

if (hasNextPage && endCursor) {
return this.fetchRecursivePage(accumulator, endCursor);
} else {
return accumulator;
}
}

/**
* Fetches all attribute pages - standard page is max 100 items
*/
async fetchAllAttributes(): Promise<AttributeWithMappingFragmentFragment[]> {
let attributes: AttributeWithMappingFragmentFragment[] = [];

return this.fetchRecursivePage(attributes, undefined);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { AppConfigSchema, RootConfig } from "./app-config";
import { useForm } from "react-hook-form";

import { Box, Button, Text } from "@saleor/macaw-ui/next";

import React, { useCallback, useMemo } from "react";
import { Multiselect } from "@saleor/react-hook-form-macaw";
import { zodResolver } from "@hookform/resolvers/zod";
import { trpcClient } from "../trpc/trpc-client";
import { useDashboardNotification } from "@saleor/apps-shared";
import { AttributeWithMappingFragmentFragment } from "../../../generated/graphql";

type AttributeMappingConfiguration = Exclude<RootConfig["attributeMapping"], null>;

type Props = {
initialData: AttributeMappingConfiguration;
attributes: AttributeWithMappingFragmentFragment[];
onSubmit(data: AttributeMappingConfiguration): Promise<void>;
};

export const AttributeMappingConfigurationForm = (props: Props) => {
const { handleSubmit, control } = useForm<AttributeMappingConfiguration>({
defaultValues: props.initialData,
resolver: zodResolver(AppConfigSchema.attributeMapping),
});

const options = props.attributes.map((a) => ({ value: a.id, label: a.name || a.id })) || [];

return (
<Box
as={"form"}
display={"flex"}
gap={5}
flexDirection={"column"}
onSubmit={handleSubmit((data) => {
props.onSubmit(data);
})}
>
<Multiselect
control={control}
name="brandAttributeIds"
label="Brand attributes"
options={options}
/>
<Multiselect
control={control}
name="sizeAttributeIds"
label="Size attributes"
options={options}
/>
<Multiselect
control={control}
name="materialAttributeIds"
label="Material attributes"
options={options}
/>
<Multiselect
control={control}
name="colorAttributeIds"
label="Color attributes"
options={options}
/>

<Box display={"flex"} flexDirection={"row"} gap={4} justifyContent={"flex-end"}>
<Button type="submit" variant="primary">
Save mapping
</Button>
</Box>
</Box>
);
};

export const ConnectedAttributeMappingForm = () => {
const { notifyError, notifySuccess } = useDashboardNotification();

const { data: attributes, isLoading: isAttributesLoading } =
trpcClient.appConfiguration.getAttributes.useQuery();

const { data, isLoading: isConfigurationLoading } = trpcClient.appConfiguration.fetch.useQuery();

const isLoading = isAttributesLoading || isConfigurationLoading;

const { mutate } = trpcClient.appConfiguration.setAttributeMapping.useMutation({
onSuccess() {
notifySuccess("Success", "Updated attribute mapping");
},
onError() {
notifyError("Error", "Failed to update, please refresh and try again");
},
});

const handleSubmit = useCallback(
async (data: AttributeMappingConfiguration) => {
mutate(data);
},
[mutate]
);

const formData: AttributeMappingConfiguration = useMemo(() => {
if (data?.attributeMapping) {
return data.attributeMapping;
}

return {
colorAttributeIds: [],
sizeAttributeIds: [],
brandAttributeIds: [],
materialAttributeIds: [],
};
}, [data]);

if (isLoading) {
return <Text>Loading...</Text>;
}

const showForm = !isLoading && attributes?.length;

return (
<>
{showForm ? (
<AttributeMappingConfigurationForm
onSubmit={handleSubmit}
initialData={formData}
attributes={attributes}
/>
) : (
<Box>Loading</Box>
)}
</>
);
};
Loading

0 comments on commit d868f2d

Please sign in to comment.