CMS App v2 (#721)

* initial setup

* GRaphql setup

* [skip ci]

* Manifest and register endpoints

* Add config schemas

* contentful client

* contentful client

* [skip ci] trpc setup

* metadata manager and contentful router

* Configuration config

* contentful config - adding providers

* add provider page

* wip contentful form

* contentful form

* list providrs

* edit contentful form

* [skip ci]

* [skip ci] replace contentful sdk

* replace contentful lib

* Delete contetnful provider feature

* variant created webhook draft

* channel provider connection model

* Channel connections ui

* adding new connection

* connections ui (adding)

* [skip ci] wip edit conn

* removing connection

* rebuild modal

* refactor providers

* implement update product webhook

* add deleting product

* [skip ci] wip

* refactor contentful router

* refactor wip

* refactor config

* webhooks processor

* webhook delegations

* bulk sync section

* bulk sync page

* gql for imports

* [skip ci] bulk import contentful

* temp variant sync list with rate limiters

* wip

* wip

* wip

* new frontend for uploading

* update zod

* print config keys

* wip

* [skip ci] datocms init

* dato add provdier page

* dato form skeleton

* dato  display content type select

* full dato form

* ButtonsBox extraction

* edit dato config form

* update product in dato

* [skip ci]

* extract bulk sync processor

* dato bulk update

* [skip ci] product updated webhook

* product webhook

* crud operations router

* update cruds

* refactor webhook operations

* refactors

* refactors

* helper texts

* [skip ci] deps

* Init

* fix macaw icon

* unify app skd

* unify nextjs

* strapi setup

* fix strapi types

* strapi upload product

* strapi delete product

* strapi product updated webhook

* processor for bulk sync strapi

* shared add provider page

* refactors

* refactors

* wrap providers into folder

* refactors

* refactors

* refactors

* pnpm lock

* add logs

* name configuration mapping name

* form configurable side notes

* remove commentns

* wip providers resolver working

* central config for providers resolving

* tests wip

* remove vscode condig

* cleanup

* provider meta extract

* some tests for contentufl

* contentful client test

* more tests for contentful

* strapi helper texts

* builderio setup

* builderio form

* builderio client draft

* validate connection modal

* Add sending product to builderio

* rename builder field

* add public api key for read access

* update products

* Builder.io - bulk sync

* Fix manifest

* cr fixes

* Make strapi to work with multiple products

* Github actions
This commit is contained in:
Lukasz Ostrowski 2023-07-18 10:19:21 +02:00 committed by GitHub
parent 790a47ee08
commit 70cb741f88
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
134 changed files with 33785 additions and 148 deletions

View file

@ -0,0 +1,16 @@
---
"@saleor/react-hook-form-macaw": patch
"saleor-app-emails-and-messages": patch
"saleor-app-data-importer": patch
"saleor-app-products-feed": patch
"@saleor/apps-shared": patch
"saleor-app-invoices": patch
"@saleor/e2e": patch
"saleor-app-search": patch
"@saleor/apps-ui": patch
"saleor-app-taxes": patch
"saleor-app-cms": patch
"saleor-app-crm": patch
---
Update Zod to 3.21.4

View file

@ -46,6 +46,13 @@ updates:
interval: "weekly" interval: "weekly"
commit-message: commit-message:
prefix: "[skip ci]" prefix: "[skip ci]"
- package-ecosystem: "npm"
directory: "/apps/cms-v2"
open-pull-requests-limit: 1
schedule:
interval: "weekly"
commit-message:
prefix: "[skip ci]"
- package-ecosystem: "npm" - package-ecosystem: "npm"
directory: apps/crm directory: apps/crm
open-pull-requests-limit: 1 open-pull-requests-limit: 1

2
.github/labeler.yml vendored
View file

@ -7,5 +7,5 @@
"App: Slack": "apps/slack/**/*" "App: Slack": "apps/slack/**/*"
"App: Taxes": "apps/taxes/**/*" "App: Taxes": "apps/taxes/**/*"
"App: Emails & Messages": "apps/emails-and-messages/**/*" "App: Emails & Messages": "apps/emails-and-messages/**/*"
"App: CMS": "apps/cms/**/*" "App: CMS": ["apps/cms/**/*", "apps/cms-v2/**/*"]
"App: CRM": "apps/crm/**/*" "App: CRM": "apps/crm/**/*"

4
.gitignore vendored
View file

@ -40,4 +40,6 @@ coverage/
apps/**/generated apps/**/generated
.eslintcache .eslintcache
.sentryclirc .sentryclirc
.vscode/

18
apps/cms-v2/.env.example Normal file
View file

@ -0,0 +1,18 @@
# The key used for metadata encryption. Required for production builds
SECRET_KEY=
# APL Config
# https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md
APL=file
REST_APL_ENDPOINT=
REST_APL_TOKEN=
APP_LOG_LEVEL=info
# Local development variables. When developped locally with Saleor inside docker, these can be set to:
# APP_IFRAME_BASE_URL = http://localhost:3000, so Dashboard on host can access iframe
# APP_API_BASE_URL=http://host.docker.internal:3000 - so Saleor can reach App running on host, from the container.
# If developped with tunnels, set this empty, it will fallback to default Next's localhost:3000
# https://docs.saleor.io/docs/3.x/developer/extending/apps/local-app-development
APP_IFRAME_BASE_URL=
APP_API_BASE_URL=

View file

@ -0,0 +1,4 @@
{
"root": true,
"extends": ["saleor"]
}

View file

@ -0,0 +1,19 @@
schema: graphql/schema.graphql
documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx]
extensions:
codegen:
overwrite: true
generates:
generated/graphql.ts:
config:
dedupeFragments: true
plugins:
- typescript
- typescript-operations
- typescript-urql:
documentVariablePrefix: "Untyped"
fragmentVariablePrefix: "Untyped"
- typed-document-node
generated/schema.graphql:
plugins:
- schema-ast

View file

@ -0,0 +1,19 @@
fragment BulkImportProduct on Product {
id
name
slug
variants {
id
name
channelListings {
channel {
id
slug
}
price {
amount
currency
}
}
}
}

View file

@ -0,0 +1,26 @@
fragment WebhookProduct on Product {
id
name
slug
channelListings {
id
channel {
id
slug
}
}
variants {
id
name
channelListings {
channel {
id
slug
}
price {
amount
currency
}
}
}
}

View file

@ -0,0 +1,19 @@
fragment WebhookProductVariant on ProductVariant {
id
name
product {
id
name
slug
}
channelListings {
channel {
id
slug
}
price {
amount
currency
}
}
}

View file

@ -0,0 +1,10 @@
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
updatePrivateMetadata(id: $id, input: $input) {
item {
privateMetadata {
key
value
}
}
}
}

View file

@ -0,0 +1,7 @@
query FetchChannels {
channels {
id
slug
name
}
}

View file

@ -0,0 +1,13 @@
query FetchProductsPaginated($channel: String, $after: String) {
products(first: 100, channel: $channel, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
...BulkImportProduct
}
}
}
}

File diff suppressed because it is too large Load diff

5
apps/cms-v2/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View file

@ -0,0 +1,43 @@
const { z } = require("zod");
const { withSentryConfig } = require("@sentry/nextjs");
const RequiredEnvs = z.object({
APL: z.string().min(1),
});
/** @type {import('next').NextConfig} */
const nextConfig = () => {
try {
RequiredEnvs.parse(process.env);
} catch (e) {
console.error("🚫 Missing required env variables, see message below");
console.error(e.issues);
process.exit(1);
}
return {
reactStrictMode: true,
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"],
};
};
const isSentryPropertiesInEnvironment =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG;
const configWithSentry = withSentryConfig(
nextConfig,
{
silent: true,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
},
{
widenClientFileUpload: true,
transpileClientSDK: true,
tunnelRoute: "/monitoring",
hideSourceMaps: true,
disableLogger: true,
}
);
module.exports = isSentryPropertiesInEnvironment ? configWithSentry : nextConfig;

70
apps/cms-v2/package.json Normal file
View file

@ -0,0 +1,70 @@
{
"name": "saleor-app-cms-v2",
"version": "2.0.0",
"scripts": {
"build": "pnpm generate && next build",
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"generate": "graphql-codegen",
"lint": "next lint",
"lint:fix": "eslint --fix .",
"start": "next start",
"test": "vitest"
},
"dependencies": {
"@datocms/cma-client-browser": "2.0.0",
"@hookform/resolvers": "^3.1.0",
"@saleor/app-sdk": "0.41.1",
"@saleor/apps-shared": "workspace:*",
"@saleor/apps-ui": "workspace:*",
"@saleor/macaw-ui": "0.8.0-pre.95",
"@saleor/react-hook-form-macaw": "workspace:*",
"@sentry/nextjs": "7.55.2",
"@tanstack/react-query": "^4.29.19",
"@trpc/client": "10.34.0",
"@trpc/next": "10.34.0",
"@trpc/react-query": "10.34.0",
"@trpc/server": "10.34.0",
"@urql/exchange-auth": "^2.1.4",
"@vitejs/plugin-react": "4.0.0",
"contentful-management": "10.38.3",
"graphql": "16.6.0",
"graphql-tag": "^2.12.6",
"jsdom": "^20.0.3",
"next": "13.4.8",
"p-ratelimit": "1.0.1",
"pino": "^8.14.1",
"pino-pretty": "^10.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-error-boundary": "4.0.10",
"react-hook-form": "^7.43.9",
"strapi-sdk-js": "2.2.0",
"urql": "^4.0.4",
"usehooks-ts": "^2.9.1",
"vite": "4.3.9",
"vitest": "0.31.3",
"zod": "3.21.4"
},
"devDependencies": {
"@graphql-codegen/cli": "3.2.2",
"@graphql-codegen/introspection": "3.0.1",
"@graphql-codegen/typed-document-node": "3.0.2",
"@graphql-codegen/typescript": "3.0.2",
"@graphql-codegen/typescript-operations": "3.0.2",
"@graphql-codegen/typescript-urql": "3.7.3",
"@graphql-typed-document-node/core": "3.2.0",
"@testing-library/react": "^13.4.0",
"@testing-library/react-hooks": "^8.0.1",
"@types/react": "18.2.5",
"@types/react-dom": "18.2.5",
"eslint": "8.44.0",
"eslint-config-saleor": "workspace:*",
"node-mocks-http": "^1.12.2",
"typescript": "5.1.6"
},
"private": true,
"saleor": {
"schemaVersion": "3.10"
}
}

BIN
apps/cms-v2/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View file

@ -0,0 +1,37 @@
/*
* This file configures the initialization of Sentry on the client.
* The config you add here will be used whenever a users loads a page in their browser.
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
*/
import * as Sentry from "@sentry/nextjs";
import pkg from "./package.json";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
replaysOnErrorSampleRate: 1.0,
/*
* This sets the sample rate to be 10%. You may want this to be 100% while
* in development and sample at a lower rate in production
*/
replaysSessionSampleRate: 0.1,
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
integrations: [
new Sentry.Replay({
// Additional Replay configuration goes in here, for example:
maskAllText: true,
blockAllMedia: true,
}),
],
environment: process.env.SENTRY_ENVIRONMENT,
release: `${pkg.name}@${pkg.version}`,
});

View file

@ -0,0 +1,21 @@
/*
* This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
* The config you add here will be used whenever one of the edge features is loaded.
* Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
*/
import * as Sentry from "@sentry/nextjs";
import pkg from "./package.json";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
environment: process.env.SENTRY_ENVIRONMENT,
release: `${pkg.name}@${pkg.version}`,
});

View file

@ -0,0 +1,20 @@
/*
* This file configures the initialization of Sentry on the server.
* The config you add here will be used whenever the server handles a request.
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
*/
import * as Sentry from "@sentry/nextjs";
import pkg from "./package.json";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 0.5,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
environment: process.env.SENTRY_ENVIRONMENT,
release: `${pkg.name}@${pkg.version}`,
});

View file

@ -0,0 +1,14 @@
import { BulkImportProductFragment } from "../../../generated/graphql";
export type BulkSyncProcessorHooks = {
onUploadStart?: (context: { variantId: string }) => void;
onUploadSuccess?: (context: { variantId: string }) => void;
onUploadError?: (context: { variantId: string; error: Error }) => void;
};
export interface BulkSyncProcessor {
uploadProducts(
products: BulkImportProductFragment[],
hooks: BulkSyncProcessorHooks
): Promise<void>;
}

View file

@ -0,0 +1,92 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { trpcClient } from "../trpc/trpc-client";
import { useForm } from "react-hook-form";
import { Select } from "@saleor/react-hook-form-macaw";
import { useRouter } from "next/router";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { ButtonsBox } from "../ui/buttons-box";
import { ProvidersResolver } from "../providers/providers-resolver";
const FormSchema = z.object({
connectionId: z.string().min(7),
});
const EmptyState = () => (
<Box
display="flex"
paddingY={4}
flexDirection={"column"}
gap={4}
alignItems={"center"}
justifyContent={"center"}
>
<Text variant="heading">No connections configured</Text>
<Text>Create a channel connection above to enable bulk synchronization.</Text>
</Box>
);
export const BulkSyncSection = () => {
const { push } = useRouter();
const { data: connections } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
const { control, handleSubmit } = useForm<z.infer<typeof FormSchema>>({
defaultValues: {
connectionId: "",
},
resolver: zodResolver(FormSchema),
});
if (!connections || !providers) {
return <Text>Loading...</Text>;
}
if (connections.length === 0) {
return <EmptyState />;
}
return (
<Box>
<Text as="h2" marginBottom={6} variant="heading">
Bulk products synchronization
</Text>
<Text as="p">
Choose a connection and start synchronization. Process is running in the browser.
</Text>
<Text as="p" variant="bodyStrong">
Do not close the app until it is finished
</Text>
<Box
display="grid"
gap={4}
marginTop={4}
as="form"
onSubmit={handleSubmit((values) => {
push(`/bulk-sync/${values.connectionId}`);
})}
>
<Select
required
control={control}
name="connectionId"
label="Connection"
options={connections.map((c) => {
const provider = providers.find((p) => p.id === c.providerId)!;
const providerDisplay = ProvidersResolver.createProviderMeta(provider.type);
return {
label: `${c.channelSlug} -> ${provider?.configName} (${providerDisplay.displayName})`,
value: c.id,
};
})}
/>
<ButtonsBox>
<Button type="submit">Start sync</Button>
</ButtonsBox>
</Box>
</Box>
);
};

View file

@ -0,0 +1,173 @@
import { Breadcrumbs } from "@saleor/apps-ui";
import { ArrowRightIcon, Box, Button, Text } from "@saleor/macaw-ui/next";
import { useEffect, useRef, useState } from "react";
import { ChannelProviderConnectionConfig, ProvidersConfig } from "../configuration";
import { AppHeader } from "../ui/app-header";
import { AppSection } from "../ui/app-section";
import { ButtonsBox } from "../ui/buttons-box";
import { useBulkSyncProductsState } from "./use-bulk-sync-products-state";
import { useFetchAllProducts } from "./use-fetch-all-products";
import { VariantsSyncStatusList } from "./variants-sync-status-list";
import { ProvidersResolver } from "../providers/providers-resolver";
const FetchProductsStep = (props: { onButtonClick(): void }) => {
return (
<Box>
<Text variant="heading" as="h2" marginBottom={4}>
Saleor products fetch
</Text>
<Text as="p">Click the button to start fetching products from Saleor API</Text>
<Text as="p">After products are fetched, you will be able to upload them to the CMS</Text>
<ButtonsBox>
<Button onClick={props.onButtonClick}>Prefetch products</Button>
</ButtonsBox>
</Box>
);
};
const SaleorProductsFetchingStep = () => {
return (
<Box>
<Text variant="heading" as="h2" marginBottom={4}>
Saleor products fetch
</Text>
<Text as="p">Fetching...</Text>
</Box>
);
};
const SaleorProductsFetchedStep = (props: { productsNo: number; variantsNo: number }) => {
return (
<Box>
<Text variant="heading" as="h2" marginBottom={4}>
Saleor products fetch
</Text>
<Text as="p">
Fetched {props.productsNo} products and {props.variantsNo} variants
</Text>
</Box>
);
};
type Status = "initial" | "fetching" | "fetched" | "uploading";
export const BulkSyncView = ({
configuration,
connection,
}: {
configuration: ProvidersConfig.AnyFullShape;
connection: ChannelProviderConnectionConfig.FullShape;
}) => {
const processor = useRef(ProvidersResolver.createBulkSyncProcessor(configuration));
const [state, setState] = useState<Status>("initial");
const { products, finished: saleorProductsFetchFinished } = useFetchAllProducts(
state === "fetching",
connection.channelSlug
);
const { productsStatusList, setInitialProducts, setItemStatus } = useBulkSyncProductsState();
useEffect(() => {
if (!saleorProductsFetchFinished) {
return;
}
setInitialProducts(products);
setState("fetched");
}, [products, saleorProductsFetchFinished, setInitialProducts]);
useEffect(() => {
if (state !== "uploading") {
return;
}
processor.current.uploadProducts(products, {
onUploadStart({ variantId }) {
setItemStatus(variantId, "uploading");
},
onUploadSuccess({ variantId }) {
setItemStatus(variantId, "success");
},
onUploadError({ error, variantId }) {
// todo handle error
setItemStatus(variantId, "error");
},
});
}, [state, products, configuration, setItemStatus]);
return (
<Box>
<AppHeader
text="Perform bulk products sync between Saleor and your CMS"
breadcrumbs={[
<Breadcrumbs.Item key="bulk-sync">Bulk Sync</Breadcrumbs.Item>,
<Breadcrumbs.Item key="connection-name">
<Box display="flex" gap={2} alignItems="center">
{connection.channelSlug}
<ArrowRightIcon /> {configuration.configName}
</Box>
</Breadcrumbs.Item>,
]}
/>
<AppSection
marginBottom={8}
mainContent={(() => {
switch (state) {
case "initial": {
return (
<FetchProductsStep
onButtonClick={() => {
setState("fetching");
}}
/>
);
}
case "fetching": {
return <SaleorProductsFetchingStep />;
}
case "fetched":
case "uploading": {
return (
<SaleorProductsFetchedStep
productsNo={products.length}
variantsNo={productsStatusList?.length ?? 0}
/>
);
}
}
})()}
heading="1. Fetch products"
sideContent={
<Text>First pre-fetch all Product Variants from Saleor. Do not close the app.</Text>
}
/>
{(state === "fetched" || state === "uploading") && productsStatusList && (
<AppSection
heading="2. Upload to the CMS"
sideContent={<Text>Send listed variants to the CMS</Text>}
mainContent={
<Box>
<Text as="h2" marginBottom={4} variant="heading">
Upload products
</Text>
{state === "fetched" && (
<Box marginBottom={4}>
<Text as="p" marginBottom={2}>
Verify products below and click the button to start uploading.
</Text>
<Button onClick={() => setState("uploading")}>Start uploading</Button>
</Box>
)}
<VariantsSyncStatusList marginTop={8} variants={productsStatusList} />
</Box>
}
/>
)}
</Box>
);
};

View file

@ -0,0 +1,46 @@
import { useCallback, useState } from "react";
import { BulkImportProductFragment } from "../../../generated/graphql";
import { VariantsSyncStatusListItem } from "./variants-sync-status-list";
export const useBulkSyncProductsState = () => {
const [productsStatusList, setProductsStatusList] = useState<VariantsSyncStatusListItem[] | null>(
null
);
return {
productsStatusList,
setInitialProducts: useCallback((products: BulkImportProductFragment[]) => {
setProductsStatusList(
products.flatMap((p) => {
const items: VariantsSyncStatusListItem[] =
p.variants?.map((v) => ({
productID: p.id,
productName: p.name,
status: "pending",
variantId: v.id,
variantName: v.name,
})) ?? [];
return items;
})
);
}, []),
setItemStatus: useCallback(
(variantID: string, status: VariantsSyncStatusListItem["status"]) => {
setProductsStatusList((items) =>
items!.map((item) => {
if (item.variantId === variantID) {
return {
...item,
status: status,
};
}
return item;
})
);
},
[]
),
};
};

View file

@ -0,0 +1,65 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useEffect, useState } from "react";
import { createGraphQLClient } from "@saleor/apps-shared";
import {
BulkImportProductFragment,
FetchProductsPaginatedDocument,
} from "../../../generated/graphql";
/**
* Original source - apps/search
*/
export const useFetchAllProducts = (started: boolean, channelSlug: string) => {
const { appBridgeState } = useAppBridge();
const saleorApiUrl = appBridgeState?.saleorApiUrl!;
const [products, setProducts] = useState<BulkImportProductFragment[]>([]);
const [finished, setFinished] = useState(false);
useEffect(() => {
if (!started) {
return;
}
if (!appBridgeState?.token) {
return;
}
const token = appBridgeState.token;
const client = createGraphQLClient({ saleorApiUrl, token });
if (!client) {
return;
}
const getProducts = async (cursor?: string): Promise<void> => {
const response = await client
.query(FetchProductsPaginatedDocument, {
after: cursor,
channel: channelSlug,
})
.toPromise();
const newProducts = response?.data?.products?.edges.map((e) => e.node) ?? [];
if (newProducts.length > 0) {
setProducts((ps) => [...ps, ...newProducts]);
}
if (
response?.data?.products?.pageInfo.hasNextPage &&
response?.data?.products?.pageInfo.endCursor
) {
return getProducts(response.data.products?.pageInfo.endCursor);
} else {
setFinished(true);
return;
}
};
getProducts(undefined);
}, [appBridgeState?.token, saleorApiUrl, started, channelSlug]);
return { products, finished };
};

View file

@ -0,0 +1,75 @@
import { SemanticChip } from "@saleor/apps-ui";
import { Box, BoxProps, Text } from "@saleor/macaw-ui/next";
export type VariantsSyncStatusListItem = {
productID: string;
productName: string;
variantName: string;
variantId: string;
status: "pending" | "uploading" | "success" | "error";
};
type Props = {
variants: Array<VariantsSyncStatusListItem>;
} & BoxProps;
const Row = (props: BoxProps) => (
<Box
padding={2}
display="grid"
__gridTemplateColumns={"2fr 2fr 1fr"}
gap={4}
alignItems="center"
{...props}
/>
);
export const VariantsSyncStatusList = ({ variants, ...props }: Props) => {
const renderStatus = (status: VariantsSyncStatusListItem["status"]) => {
switch (status) {
case "pending": {
return null;
}
case "success": {
return <SemanticChip variant="success">Uploaded</SemanticChip>;
}
case "error": {
return <SemanticChip variant="error">Error</SemanticChip>;
}
case "uploading": {
return <SemanticChip variant="default">Uploading</SemanticChip>;
}
}
};
return (
<Box {...props}>
<Row>
<Text variant="caption">Product</Text>
<Text variant="caption">Variant</Text>
<Text variant="caption">Status</Text>
</Row>
{variants.map((variant) => (
<Row key={variant.variantId}>
<Box>
<Text variant="bodyStrong" as="p">
{variant.productName}
</Text>
<Text size="small" as="p">
{variant.productID}
</Text>
</Box>
<Box>
<Text variant="bodyStrong" as="p">
{variant.variantName}
</Text>
<Text size="small" as="p">
{variant.variantId}
</Text>
</Box>
<Box>{renderStatus(variant.status)}</Box>
</Row>
))}
</Box>
);
};

View file

@ -0,0 +1,64 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightIcon, Box } from "@saleor/macaw-ui/next";
import { Select } from "@saleor/react-hook-form-macaw";
import { useForm } from "react-hook-form";
import { ChannelProviderConnectionConfig } from "../configuration";
import { trpcClient } from "../trpc/trpc-client";
export type AddConnectionFormSchema = Omit<
ChannelProviderConnectionConfig.InputShape,
"providerType"
>;
export const AddConnectionFormID = "new-connection-form";
export const AddConnectionForm = (props: {
defaultValues: AddConnectionFormSchema;
onSubmit(values: AddConnectionFormSchema): void;
}) => {
const { data: channels } = trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
const { handleSubmit, control } = useForm<AddConnectionFormSchema>({
defaultValues: props.defaultValues,
resolver: zodResolver(
ChannelProviderConnectionConfig.Schema.Input.omit({ providerType: true })
),
});
return (
<Box
onSubmit={handleSubmit(props.onSubmit)}
as="form"
id={AddConnectionFormID}
display="grid"
__gridTemplateColumns="1fr auto 1fr"
gap={4}
alignItems="center"
>
<Select
required
size="small"
control={control}
name="channelSlug"
label="Channel"
options={channels?.map((c) => ({
value: c.slug,
label: c.name,
}))}
/>
<ArrowRightIcon />
<Select
required
size="small"
control={control}
name="providerId"
label="Provider"
options={providers?.map((p) => ({
value: p.id,
label: p.configName,
}))}
/>
</Box>
);
};

View file

@ -0,0 +1,39 @@
import { Button, Text } from "@saleor/macaw-ui/next";
import { ButtonsBox } from "../ui/buttons-box";
import { Modal } from "../ui/modal";
import {
AddConnectionForm,
AddConnectionFormID,
AddConnectionFormSchema,
} from "./add-connection-form";
const defaultValues: AddConnectionFormSchema = { channelSlug: "", providerId: "" };
export const AddConnectionModal = (props: {
onSubmit(values: AddConnectionFormSchema): void;
onClose(): void;
}) => (
<Modal onClose={props.onClose}>
<Text as="h2" variant="heading">
Connect channel with Provider
</Text>
<Text as="p" marginBottom={6}>
Once connected, operations on product variants on this channel will be sent to selected CMS
platform.
</Text>
<AddConnectionForm onSubmit={props.onSubmit} defaultValues={defaultValues} />
<ButtonsBox marginTop={8}>
<Button
variant="tertiary"
onClick={() => {
props.onClose();
}}
>
Close
</Button>
<Button variant="primary" type="submit" form={AddConnectionFormID}>
Add connection
</Button>
</ButtonsBox>
</Modal>
);

View file

@ -0,0 +1,53 @@
import { AppConfigMetadataManager } from "@/modules/configuration/app-config-metadata-manager";
import { createSettingsManager } from "@/modules/configuration/metadata-manager";
import { ChannelProviderConnectionConfig } from "@/modules/configuration/schemas/channel-provider-connection.schema";
import { protectedClientProcedure } from "@/modules/trpc/protected-client-procedure";
import { router } from "@/modules/trpc/trpc-server";
import { z } from "zod";
import { FetchChannelsDocument } from "../../../generated/graphql";
const procedure = protectedClientProcedure.use(({ ctx, next }) => {
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
return next({
ctx: {
appConfigService: new AppConfigMetadataManager(settingsManager),
},
});
});
export const channelProviderConnectionRouter = router({
fetchAllChannels: protectedClientProcedure.query(async ({ ctx }) => {
const channels = await ctx.apiClient.query(FetchChannelsDocument, {});
return channels.data?.channels ?? [];
}),
fetchConnections: procedure.query(async ({ ctx }) => {
return (await ctx.appConfigService.get()).connections.getConnections();
}),
fetchConnection: procedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
return (await ctx.appConfigService.get()).connections.getConnectionById(input.id) ?? null;
}),
addConnection: procedure
.input(ChannelProviderConnectionConfig.Schema.Input)
.mutation(async ({ ctx, input }) => {
const config = await ctx.appConfigService.get();
config.connections.addConnection(input);
ctx.appConfigService.set(config);
}),
removeConnection: procedure
.input(
z.object({
id: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const config = await ctx.appConfigService.get();
config.connections.deleteConnection(input.id);
ctx.appConfigService.set(config);
}),
});

View file

@ -0,0 +1,7 @@
import { Text } from "@saleor/macaw-ui/next";
export const ChanelProviderConnectionsSectionHeader = () => (
<Text marginBottom={4} as="h2" variant="heading">
Channels Connections
</Text>
);

View file

@ -0,0 +1,111 @@
import { useDashboardNotification } from "@saleor/apps-shared";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { useState } from "react";
import { trpcClient } from "../trpc/trpc-client";
import { ButtonsBox } from "../ui/buttons-box";
import { AddConnectionFormSchema } from "./add-connection-form";
import { AddConnectionModal } from "./add-connection-modal";
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
import { ConnectionsList } from "./connections-list";
const NoConnections = (props: { onCreate(): void; enabled: boolean }) => (
<Box>
<ChanelProviderConnectionsSectionHeader />
<Text marginBottom={4} as="p">
No channels connected yet.{" "}
{!props.enabled &&
"Ensure you have created a provider configuration that can be connected first."}
</Text>
{props.enabled && (
<ButtonsBox>
<Button onClick={props.onCreate}>Create first connection</Button>
</ButtonsBox>
)}
</Box>
);
export const ChannelProviderConnectionList = () => {
const [dialogOpen, setDialogOpen] = useState(false);
const { data: connectionsData, refetch } =
trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
const { mutate: removeConnection } =
trpcClient.channelsProvidersConnection.removeConnection.useMutation({
onSuccess() {
refetch();
notifySuccess("Success", "Removed connection");
},
});
const { notifySuccess } = useDashboardNotification();
// Prefetch
trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
const { mutate: addProviderMutate } =
trpcClient.channelsProvidersConnection.addConnection.useMutation({
onSuccess() {
notifySuccess("Success", "Added connection");
refetch();
setDialogOpen(false);
},
});
if (!providers) {
return null;
}
const handleFormSubmit = (values: AddConnectionFormSchema) => {
const providerType = providers.find((p) => p.id === values.providerId)?.type;
if (!providerType) {
throw new Error("Provider not found");
}
addProviderMutate({
...values,
providerType,
});
};
const handleDelete = (connectionId: string) => {
removeConnection({ id: connectionId });
};
if (!connectionsData) {
return <Text>Loading</Text>;
}
return (
<Box>
{dialogOpen && (
<AddConnectionModal
onClose={() => {
setDialogOpen(false);
}}
onSubmit={handleFormSubmit}
/>
)}
{connectionsData.length === 0 && (
<NoConnections
enabled={providers.length > 0}
onCreate={() => {
setDialogOpen(true);
}}
/>
)}
{connectionsData.length > 0 && <ConnectionsList onRemove={handleDelete} />}
{connectionsData.length > 0 && (
<ButtonsBox marginTop={6}>
<Button
onClick={() => {
setDialogOpen(true);
}}
>
Add connection
</Button>
</ButtonsBox>
)}
</Box>
);
};

View file

@ -0,0 +1,55 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React from "react";
import { trpcClient } from "../trpc/trpc-client";
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
import { ProvidersResolver } from "../providers/providers-resolver";
export const ConnectionsList = (props: { onRemove(connectionId: string): void }) => {
const { data } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
const { data: channels } = trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
if (!data || !providers) {
return null;
}
return (
<Box>
<ChanelProviderConnectionsSectionHeader />
<Box
display="grid"
justifyContent={"space-between"}
__gridTemplateColumns={"1fr 1fr auto"}
gap={4}
alignItems="center"
>
<Text variant="caption">Saleor Channel</Text>
<Text variant="caption">Target CMS</Text>
<div />
{data?.map((conn) => {
const provider = providers.find((p) => p.id === conn.providerId);
if (!provider) {
throw new Error("Provider not found");
}
const providerName = ProvidersResolver.createProviderMeta(provider.type).displayName;
return (
<React.Fragment key={conn.id}>
<Text>{channels?.find((c) => c.slug === conn.channelSlug)?.name}</Text>
<Text>
<Text>{provider.configName}</Text>
<Text color="textNeutralSubdued"> ({providerName})</Text>
</Text>
<Button onClick={() => props.onRemove(conn.id)} variant="tertiary">
Remove
</Button>
</React.Fragment>
);
})}
</Box>
</Box>
);
};

View file

@ -0,0 +1,36 @@
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { AppConfig } from "./app-config";
import { createSettingsManager } from "./metadata-manager";
import { createGraphQLClient } from "@saleor/apps-shared";
import { AuthData } from "@saleor/app-sdk/APL";
export class AppConfigMetadataManager {
public readonly metadataKey = "app-config-v1";
constructor(private mm: SettingsManager) {}
async get() {
const metadata = await this.mm.get(this.metadataKey);
return metadata ? AppConfig.parse(metadata) : new AppConfig();
}
set(config: AppConfig) {
return this.mm.set({
key: this.metadataKey,
value: config.serialize(),
});
}
static createFromAuthData(authData: AuthData): AppConfigMetadataManager {
const settingsManager = createSettingsManager(
createGraphQLClient({
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
}),
authData.appId
);
return new AppConfigMetadataManager(settingsManager);
}
}

View file

@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach } from "vitest";
import { AppConfig } from "./app-config";
import { ContentfulProviderConfig } from "./schemas/contentful-provider.schema";
import { DatocmsProviderConfig } from "./schemas/datocms-provider.schema";
const getMockContentfulInput = (): ContentfulProviderConfig.InputShape => {
return {
configName: "Test",
type: "contentful",
contentId: "test",
authToken: "test",
environment: "test",
productVariantFieldsMapping: {
channels: "channels",
productId: "productId",
productName: "productName",
productSlug: "productSlug",
variantId: "variantId",
variantName: "variantName",
},
spaceId: "test",
};
};
const getMockDatocmsInput = (): DatocmsProviderConfig.InputShape => {
return {
configName: "Test",
type: "datocms",
itemType: "test",
authToken: "test",
productVariantFieldsMapping: {
channels: "channels",
productId: "productId",
productName: "productName",
productSlug: "productSlug",
variantId: "variantId",
variantName: "variantName",
},
};
};
describe("App Config", () => {
let appConfig: AppConfig;
beforeEach(() => {
appConfig = new AppConfig();
});
it("Constructs with empty state", () => {
expect(appConfig.connections.getConnections()).toEqual([]);
expect(appConfig.providers.getProviders()).toEqual([]);
});
describe("Providers", () => {
it("Can retrieve provider by ID from the config", () => {
appConfig.providers.addProvider(getMockContentfulInput());
const provider = appConfig.providers.getProviders()[0];
expect(provider).toBeDefined();
expect(appConfig.providers.checkProviderExists(provider.id)).toBe(true);
expect(appConfig.providers.getProviderById(provider.id)).toBeDefined();
});
it("Can update the provider", () => {
appConfig.providers.addProvider(getMockContentfulInput());
const provider = appConfig.providers.getProviders()[0] as ContentfulProviderConfig.FullShape;
appConfig.providers.updateProvider({
...provider,
spaceId: "new-space-id",
});
const providerUpdated =
appConfig.providers.getProviders()[0] as ContentfulProviderConfig.FullShape;
expect(providerUpdated.spaceId).toBe("new-space-id");
});
it("Can delete provider", () => {
appConfig.providers.addProvider(getMockContentfulInput());
const provider = appConfig.providers.getProviders()[0];
expect(provider).toBeDefined();
appConfig.providers.deleteProvider(provider.id);
expect(appConfig.providers.getProviders()).toEqual([]);
});
});
describe("Connections", () => {
it("Throws if trying to add connection with provider that doesnt exist", () => {
expect(() => {
appConfig.connections.addConnection({
channelSlug: "test",
providerId: "asdasd123",
providerType: "contentful",
});
}).toThrow();
});
it("Can add connection with existing provider", () => {
appConfig.providers.addProvider(getMockContentfulInput());
appConfig.connections.addConnection({
channelSlug: "test",
providerId: appConfig.providers.getProviders()[0].id,
providerType: "contentful",
});
expect(appConfig.connections.getConnections().length).toBe(1);
});
it("Removes all connections that include passed provider", () => {
appConfig.providers.addProvider(getMockContentfulInput());
appConfig.providers.addProvider(getMockDatocmsInput());
appConfig.connections.addConnection({
channelSlug: "test",
providerId: appConfig.providers.getProviders()[0].id,
providerType: "contentful",
});
appConfig.connections.addConnection({
channelSlug: "test2",
providerId: appConfig.providers.getProviders()[0].id,
providerType: "contentful",
});
appConfig.connections.addConnection({
channelSlug: "test2",
providerId: appConfig.providers.getProviders()[1].id,
providerType: "datocms",
});
expect(appConfig.connections.getConnections().length).toBe(3);
appConfig.providers.deleteProvider(appConfig.providers.getProviders()[0].id);
const leftConnections = appConfig.connections.getConnections();
expect(leftConnections.length).toBe(1);
expect(leftConnections[0].providerType).toBe("datocms");
});
});
});

View file

@ -0,0 +1,115 @@
import { ProvidersResolver } from "../providers/providers-resolver";
import { generateId } from "../shared/generate-id";
import { ChannelProviderConnectionConfig } from "./schemas/channel-provider-connection.schema";
import { ProvidersConfig, RootConfig } from "./schemas/root-config.schema";
/**
* TODO
* - test
* - extract and delegate smaller configs?
*/
export class AppConfig {
private rootData: RootConfig.Shape = {
providers: [],
connections: [],
};
constructor(initialData?: RootConfig.Shape) {
if (initialData) {
this.rootData = RootConfig.Schema.parse(initialData);
}
}
static parse(serializedSchema: string) {
return new AppConfig(JSON.parse(serializedSchema));
}
serialize() {
return JSON.stringify(this.rootData);
}
providers = {
checkProviderExists: (id: string) => {
return !!this.rootData.providers.find((p) => p.id === id);
},
addProvider: (providerConfigInput: ProvidersConfig.AnyInputShape) => {
const inputSchema = ProvidersResolver.getProviderInputSchema(providerConfigInput.type);
const parsedConfig = inputSchema.parse(providerConfigInput);
this.rootData.providers.push({
...parsedConfig,
id: generateId(),
});
return this;
},
updateProvider: (providerConfig: ProvidersConfig.AnyFullShape) => {
const schema = ProvidersResolver.getProviderSchema(providerConfig.type);
const parsedConfig = schema.parse(providerConfig);
this.rootData.providers = this.rootData.providers.map((p) => {
if (p.id === parsedConfig.id) {
return parsedConfig;
} else {
return p;
}
});
},
deleteProvider: (id: string) => {
this.rootData.providers = this.rootData.providers.filter((p) => p.id !== id);
this.connections.deleteConnectionsWithProvider(id);
return this;
},
getProviders: () => {
return this.rootData.providers;
},
getProviderById: (id: string) => {
return this.providers.getProviders().find((p) => p.id === id);
},
};
connections = {
getConnections: () => {
return this.rootData.connections;
},
deleteConnection: (connectionID: string) => {
this.rootData.connections = this.rootData.connections.filter((c) => c.id !== connectionID);
return this;
},
addConnection: (input: ChannelProviderConnectionConfig.InputShape) => {
if (!this.providers.checkProviderExists(input.providerId)) {
throw new Error("Provider doesnt exist");
}
const parsed = ChannelProviderConnectionConfig.Schema.Input.parse(input);
this.rootData.connections.push({
...parsed,
id: generateId(),
});
return this;
},
deleteConnectionsWithProvider: (providerId: string) => {
this.rootData.connections = this.rootData.connections.filter((conn) => {
return conn.providerId !== providerId;
});
},
getConnectionById: (id: string) => {
return this.connections.getConnections().find((c) => c.id === id);
},
};
}

View file

@ -0,0 +1,13 @@
/**
* Re-export for convenient imports
*/
export * from "./app-config";
export * from "./app-config-metadata-manager";
export * from "./metadata-manager";
export * from "./schemas/channel-provider-connection.schema";
export * from "./schemas/contentful-provider.schema";
export * from "./schemas/builder-provider.schema";
export * from "./schemas/datocms-provider.schema";
export * from "./schemas/strapi-provider.schema";
export * from "./schemas/saleor-provider-fields-mapping.schema";
export * from "./schemas/root-config.schema";

View file

@ -0,0 +1,101 @@
import {
MetadataEntry,
EncryptedMetadataManager,
SettingsManager,
} from "@saleor/app-sdk/settings-manager";
import { Client, gql } from "urql";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateAppMetadataDocument,
} from "../../../generated/graphql";
gql`
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
updatePrivateMetadata(id: $id, input: $input) {
item {
privateMetadata {
key
value
}
}
}
}
`;
gql`
query FetchAppDetails {
app {
id
privateMetadata {
key
value
}
}
}
`;
gql`
mutation RemoveMetadata($id: ID!, $keys: [String!]!) {
deletePrivateMetadata(id: $id, keys: $keys) {
errors {
message
}
}
}
`;
export type SimpleGraphqlClient = Pick<Client, "mutation" | "query">;
async function fetchAllMetadata(client: SimpleGraphqlClient): Promise<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
if (error) {
return [];
}
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
}
async function mutateMetadata(
client: SimpleGraphqlClient,
metadata: MetadataEntry[],
appId: string
) {
const { error: mutationError, data: mutationData } = await client
.mutation(UpdateAppMetadataDocument, {
id: appId,
input: metadata,
})
.toPromise();
if (mutationError) {
throw new Error(`Mutation error: ${mutationError.message}`);
}
return (
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
key: md.key,
value: md.value,
})) || []
);
}
export const createSettingsManager = (
client: SimpleGraphqlClient,
appId: string
): SettingsManager => {
/*
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
* We recommend it for production, because all values are encrypted.
* If your use case require plain text values, you can use MetadataManager.
*/
return new EncryptedMetadataManager({
// Secret key should be randomly created for production and set as environment variable
encryptionKey: process.env.SECRET_KEY!,
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutateMetadata(client, metadata, appId),
});
};

View file

@ -0,0 +1,24 @@
import { SaleorProviderFieldsMappingKey } from "./schemas/saleor-provider-fields-mapping.schema";
export const printSaleorProductFields = (fieldName: SaleorProviderFieldsMappingKey) => {
switch (fieldName) {
case "variantName": {
return "Variant Name";
}
case "channels": {
return "Channels";
}
case "productId": {
return "Product ID";
}
case "productName": {
return "Product Name";
}
case "productSlug": {
return "Product Slug";
}
case "variantId": {
return "Variant ID";
}
}
};

View file

@ -0,0 +1,26 @@
import { z } from "zod";
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
import { BuilderIo } from "@/modules/providers/builder.io/builder-io";
const InputSchema = z.object({
type: z.literal(BuilderIo.type),
privateApiKey: z.string().min(1),
publicApiKey: z.string().min(1),
configName: z.string().min(1),
modelName: z.string().min(1),
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
});
const FullSchema = InputSchema.extend({
id: z.string(),
});
export namespace BuilderIoProviderConfig {
export type InputShape = z.infer<typeof InputSchema>;
export type FullShape = z.infer<typeof FullSchema>;
export const Schema = {
Input: InputSchema,
Full: FullSchema,
};
}

View file

@ -0,0 +1,22 @@
import { cmsTypes } from "@/modules/providers/providers-registry";
import { z } from "zod";
const InputSchema = z.object({
channelSlug: z.string().min(1),
providerId: z.string().min(1),
providerType: z.enum(cmsTypes),
});
const FullSchema = InputSchema.extend({
id: z.string(),
});
export namespace ChannelProviderConnectionConfig {
export type InputShape = z.infer<typeof InputSchema>;
export type FullShape = z.infer<typeof FullSchema>;
export const Schema = {
Input: InputSchema,
Full: FullSchema,
};
}

View file

@ -0,0 +1,27 @@
import { z } from "zod";
import { Contentful } from "../../providers/contentful/contentful";
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
const InputSchema = z.object({
type: z.literal(Contentful.type),
authToken: z.string(),
spaceId: z.string(),
environment: z.string(),
configName: z.string(),
contentId: z.string(),
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
});
const FullSchema = InputSchema.extend({
id: z.string(),
});
export namespace ContentfulProviderConfig {
export type InputShape = z.infer<typeof InputSchema>;
export type FullShape = z.infer<typeof FullSchema>;
export const Schema = {
Input: InputSchema,
Full: FullSchema,
};
}

View file

@ -0,0 +1,25 @@
import { Datocms } from "@/modules/providers/datocms/datocms";
import { z } from "zod";
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
const InputSchema = z.object({
type: z.literal(Datocms.type),
authToken: z.string(),
configName: z.string(),
itemType: z.string(),
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
});
const FullSchema = InputSchema.extend({
id: z.string(),
});
export namespace DatocmsProviderConfig {
export type InputShape = z.infer<typeof InputSchema>;
export type FullShape = z.infer<typeof FullSchema>;
export const Schema = {
Input: InputSchema,
Full: FullSchema,
};
}

View file

@ -0,0 +1,47 @@
import { z } from "zod";
import { ChannelProviderConnectionConfig } from "./channel-provider-connection.schema";
import { ContentfulProviderConfig } from "./contentful-provider.schema";
import { BuilderIoProviderConfig } from "./builder-provider.schema";
import { StrapiProviderConfig } from "./strapi-provider.schema";
import { DatocmsProviderConfig } from "./datocms-provider.schema";
// todo move to shared bootstrap?
export namespace ProvidersConfig {
const AnyFull = z.union([
/**
* Add more for each provider
*/
ContentfulProviderConfig.Schema.Full,
DatocmsProviderConfig.Schema.Full,
StrapiProviderConfig.Schema.Full,
BuilderIoProviderConfig.Schema.Full,
]);
export const Schema = {
AnyFull: AnyFull,
AnyInput: z.union([
ContentfulProviderConfig.Schema.Input,
DatocmsProviderConfig.Schema.Input,
StrapiProviderConfig.Schema.Input,
BuilderIoProviderConfig.Schema.Input,
]),
AnyFullList: z.array(AnyFull),
};
export type AnyFullShape = z.infer<typeof Schema.AnyFull>;
export type AnyInputShape = z.infer<typeof Schema.AnyInput>;
}
export namespace RootConfig {
/**
* Store entire app config in single file
* - Only one request
* - Always transactional
*/
export const Schema = z.object({
providers: ProvidersConfig.Schema.AnyFullList,
connections: z.array(ChannelProviderConnectionConfig.Schema.Full),
});
export type Shape = z.infer<typeof Schema>;
}

View file

@ -0,0 +1,25 @@
import { z } from "zod";
/**
* TODO Consider optional fields
*/
export const SaleorProviderFieldsMappingSchema = z.object({
variantId: z.string().min(1),
variantName: z.string().min(1),
productId: z.string().min(1),
productName: z.string().min(1),
productSlug: z.string().min(1),
channels: z.string().min(1),
});
export type SaleorProviderFieldsMappingType = z.infer<typeof SaleorProviderFieldsMappingSchema>;
export type SaleorProviderFieldsMappingKey = keyof SaleorProviderFieldsMappingType;
export const SaleorProviderFieldsMappingKeys: Array<SaleorProviderFieldsMappingKey> = [
"variantId",
"variantName",
"productId",
"productName",
"productSlug",
"channels",
];

View file

@ -0,0 +1,26 @@
import { Strapi } from "@/modules/providers/strapi/strapi";
import { z } from "zod";
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
const InputSchema = z.object({
configName: z.string(),
type: z.literal(Strapi.type),
url: z.string().url(),
authToken: z.string(),
itemType: z.string(),
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
});
const FullSchema = InputSchema.extend({
id: z.string(),
});
export namespace StrapiProviderConfig {
export type InputShape = z.infer<typeof InputSchema>;
export type FullShape = z.infer<typeof FullSchema>;
export const Schema = {
Input: InputSchema,
Full: FullSchema,
};
}

View file

@ -0,0 +1,21 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { PropsWithChildren } from "react";
import { Provider } from "urql";
import { createGraphQLClient } from "@saleor/apps-shared";
export function GraphQLProvider(props: PropsWithChildren<{}>) {
const { appBridgeState } = useAppBridge();
const saleorApiUrl = appBridgeState?.saleorApiUrl!;
if (!appBridgeState?.saleorApiUrl) {
return <div {...props}></div>;
}
const client = createGraphQLClient({
saleorApiUrl,
token: appBridgeState.token,
});
return <Provider value={client} {...props} />;
}

View file

@ -0,0 +1,61 @@
import { createSettingsManager } from "../configuration/metadata-manager";
import { z } from "zod";
import { AppConfigMetadataManager } from "../configuration/app-config-metadata-manager";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server";
import { ProvidersConfig } from "../configuration";
const procedure = protectedClientProcedure.use(({ ctx, next }) => {
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
return next({
ctx: {
settingsManager,
appConfigService: new AppConfigMetadataManager(settingsManager),
},
});
});
export const providersListRouter = router({
getAll: procedure.query(async ({ ctx: { appConfigService } }) => {
const config = await appConfigService.get();
const providers = config.providers.getProviders();
return providers;
}),
getOne: procedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx: { appConfigService }, input }) => {
const config = await appConfigService.get();
return config.providers.getProviderById(input.id) ?? null;
}),
addOne: procedure
.input(ProvidersConfig.Schema.AnyInput)
.mutation(async ({ ctx: { appConfigService }, input }) => {
const config = await appConfigService.get();
config.providers.addProvider(input);
await appConfigService.set(config);
}),
updateOne: procedure
.input(ProvidersConfig.Schema.AnyFull)
.mutation(async ({ input, ctx: { appConfigService } }) => {
const config = await appConfigService.get();
config?.providers.updateProvider(input);
return appConfigService.set(config);
}),
deleteOne: procedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx: { appConfigService } }) => {
const config = await appConfigService.get();
config.providers.deleteProvider(input.id);
return appConfigService.set(config);
}),
});

View file

@ -0,0 +1,92 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { useRouter } from "next/router";
import React from "react";
import { ProvidersConfig } from "../configuration";
import { ProvidersResolver } from "../providers/providers-resolver";
import { trpcClient } from "../trpc/trpc-client";
import { ButtonsBox } from "../ui/buttons-box";
const ProvidersTable = (props: { providers: ProvidersConfig.AnyFullShape[] }) => {
const { push } = useRouter();
return (
<Box display="grid" __gridTemplateColumns="repeat(2, auto)" gap={4} alignItems="center">
<Text variant="caption">Configuration name</Text>
<div />
{props.providers.map((provider) => (
<React.Fragment key={provider.id}>
<Box>
<Text as="p" variant="bodyStrong">
{provider.configName}
</Text>
<Text as="p" variant="caption">
{ProvidersResolver.createProviderMeta(provider.type).displayName}
</Text>
</Box>
<Button
marginLeft="auto"
variant="tertiary"
onClick={() => {
push(`/edit-provider/` + provider.id);
}}
>
Edit
</Button>
</React.Fragment>
))}
</Box>
);
};
export const ProvidersList = () => {
const { data } = trpcClient.providersConfigs.getAll.useQuery();
const { push } = useRouter();
if (!data) {
return null;
}
if (data.length === 0) {
return (
<Box>
<Text as="p" marginBottom={4}>
No configurations yet
</Text>
<ButtonsBox>
<Button
onClick={() => {
push("/add-provider");
}}
>
Add first CMS configuration
</Button>
</ButtonsBox>
</Box>
);
}
return (
<Box>
{data.length && (
<Box>
<Text variant="heading" as="h2" marginBottom={4}>
Providers configurations
</Text>
<ProvidersTable providers={data} />
</Box>
)}
<ButtonsBox marginTop={8}>
<Button
onClick={() => {
push("/add-provider");
}}
>
Add CMS configuration
</Button>
</ButtonsBox>
</Box>
);
};

View file

@ -0,0 +1,45 @@
import { BulkImportProductFragment } from "../../../../generated/graphql";
import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
import { BuilderIoProviderConfig } from "../../configuration";
import { BuilderIoClient } from "./builder-io.client";
export class BuilderIoBulkSyncProcessor implements BulkSyncProcessor {
constructor(private config: BuilderIoProviderConfig.FullShape) {}
async uploadProducts(
products: BulkImportProductFragment[],
hooks: BulkSyncProcessorHooks
): Promise<void> {
const client = new BuilderIoClient(this.config);
products.flatMap((product) =>
product.variants?.map((variant) => {
if (hooks.onUploadStart) {
hooks.onUploadStart({ variantId: variant.id });
}
return client
.upsertProductVariant({
id: variant.id,
name: variant.name,
channelListings: variant.channelListings,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
})
.then((r) => {
if (hooks.onUploadSuccess) {
hooks.onUploadSuccess({ variantId: variant.id });
}
})
.catch((e) => {
if (hooks.onUploadError) {
hooks.onUploadError({ variantId: variant.id, error: e });
}
});
})
);
}
}

View file

@ -0,0 +1,241 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useDashboardNotification } from "@saleor/apps-shared";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { Input } from "@saleor/react-hook-form-macaw";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { BuilderIoProviderConfig, SaleorProviderFieldsMappingKeys } from "../../configuration";
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
import { trpcClient } from "../../trpc/trpc-client";
import { ButtonsBox } from "../../ui/buttons-box";
import { TextLink } from "@saleor/apps-ui";
type FormShape = Omit<BuilderIoProviderConfig.InputShape, "type">;
const FormSchema = BuilderIoProviderConfig.Schema.Input.omit({ type: true });
type PureFormProps = {
defaultValues: FormShape;
onSubmit(values: FormShape): void;
onDelete?(): void;
};
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
const { control, handleSubmit } = useForm<FormShape>({
defaultValues: defaultValues,
resolver: zodResolver(FormSchema),
});
return (
<Box
as="form"
display={"grid"}
gap={4}
onSubmit={handleSubmit((vals) => {
onSubmit(vals);
})}
>
<Input
required
control={control}
name="configName"
label="Configuration name"
helperText="Meaningful name that will help you understand it later. E.g. 'staging' or 'prod' "
/>
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Provide connection details</Text>
<Input
required
control={control}
name="privateApiKey"
label="Private API key (write API)"
helperText={
<Text variant="caption">
You can find it in and generate in{" "}
<TextLink size="small" newTab href="https://builder.io/account/space">
account settings
</TextLink>
</Text>
}
/>
<Input
required
control={control}
name="publicApiKey"
label="Public API key (read API)"
helperText={
<Text variant="caption">
You can find it in{" "}
<TextLink size="small" newTab href="https://builder.io/account/space">
account settings
</TextLink>
</Text>
}
/>
</Box>
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Configure fields mapping</Text>
<Input
required
control={control}
name="modelName"
label="CMS Data model name"
helperText="Structured content model name. E.g. 'products' or 'product-variants'"
placeholder="saleor-variant"
/>
<Text as="p" variant="heading" size="small">
Map fields from Saleor to your contentful schema.
</Text>
<Text as="p" marginTop={2} marginBottom={4}>
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should be
type of <Text variant="bodyStrong">JSON</Text>.
</Text>
<Box
marginBottom={4}
display="grid"
__gridTemplateColumns={"50% 50%"}
borderBottomWidth={1}
borderBottomStyle="solid"
borderColor="neutralHighlight"
padding={2}
>
<Text variant="caption">Saleor Field</Text>
<Text variant="caption">Contentful field</Text>
</Box>
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
// todo extract this table to component
<Box
display="grid"
__gridTemplateColumns={"50% 50%"}
padding={2}
key={saleorField}
alignItems="center"
>
<Box>
<Text as="p" variant="bodyStrong">
{printSaleorProductFields(saleorField)}
</Text>
<Text variant="caption">
{saleorField === "channels" ? "JSON field" : "Text field"}
</Text>
</Box>
<Input
size="small"
control={control}
name={`productVariantFieldsMapping.${saleorField}`}
label="Builer.io Field"
/>
</Box>
))}
</Box>
<ButtonsBox>
{onDelete && (
<Button onClick={onDelete} variant="tertiary">
Delete
</Button>
)}
<Button type="submit">Save</Button>
</ButtonsBox>
</Box>
);
};
const AddFormVariant = () => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
push("/configuration");
},
});
return (
<PureForm
onSubmit={(values) => {
addProvider({
...values,
type: "builder.io",
});
}}
defaultValues={{
configName: "",
publicApiKey: "",
privateApiKey: "",
modelName: "",
productVariantFieldsMapping: {
channels: "",
variantName: "",
productId: "",
productName: "",
productSlug: "",
variantId: "",
},
}}
/>
);
};
const EditFormVariant = (props: { configId: string }) => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { data } = trpcClient.providersConfigs.getOne.useQuery(
{
id: props.configId,
},
{
enabled: !!props.configId,
}
);
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
push("/configuration");
},
});
const { mutate: deleteProvider } = trpcClient.providersConfigs.deleteOne.useMutation({
onSuccess() {
notifySuccess("Success", "Removed configuration");
push("/configuration");
},
});
if (!data) {
return null;
}
if (data.type !== "builder.io") {
throw new Error("Trying to fill builer.io form with non builder.io data");
}
return (
<PureForm
onDelete={() => {
deleteProvider({
id: props.configId,
});
}}
onSubmit={(values) => {
mutate({
...values,
type: "builder.io",
id: props.configId,
});
}}
defaultValues={data}
/>
);
};
/*
* todo make the same with contentful
* todo improve copy
*/
export const BuilderIoConfigForm = {
PureVariant: PureForm,
AddVariant: AddFormVariant,
EditVariant: EditFormVariant,
};

View file

@ -0,0 +1,67 @@
import { createLogger } from "@saleor/apps-shared";
import {
WebhookProductFragment,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import { BuilderIoProviderConfig, ContentfulProviderConfig } from "../../configuration";
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
import { BuilderIoClient } from "./builder-io.client";
export type BuilderioClientStrip = Pick<
BuilderIoClient,
"upsertProductVariant" | "deleteProductVariant" | "updateProductVariant"
>;
export type BuilderIoClientFactory = (
config: BuilderIoProviderConfig.FullShape
) => BuilderioClientStrip;
export class BuilderIoWebhooksProcessor implements ProductWebhooksProcessor {
private client: BuilderioClientStrip;
private logger = createLogger({ name: "BuilderIoWebhooksProcessor" });
constructor(
providerConfig: BuilderIoProviderConfig.FullShape,
clientFactory: BuilderIoClientFactory = () => new BuilderIoClient(providerConfig)
) {
this.client = clientFactory(providerConfig);
this.logger.trace("Created BuilderIoWebhooksProcessor");
}
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.debug({ variantId: productVariant.id }, "Called onProductVariantUpdated");
await this.client.upsertProductVariant(productVariant);
}
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.debug({ variantId: productVariant.id }, "Called onProductVariantCreated");
await this.client.upsertProductVariant(productVariant);
}
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.debug({ variantId: productVariant.id }, "Called onProductVariantDeleted");
await this.client.deleteProductVariant(productVariant.id);
}
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
this.logger.debug({ procutId: product.id }, "Called onProductUpdated");
await Promise.all(
(product.variants ?? []).map((variant) => {
return this.client.updateProductVariant({
id: variant.id,
name: variant.name,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
});
})
);
}
}

View file

@ -0,0 +1,151 @@
import { BuilderIoProviderConfig } from "@/modules/configuration";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
import { createLogger } from "@saleor/apps-shared";
// https://www.builder.io/c/docs/write-api
export class BuilderIoClient {
private endpoint: string;
private logger = createLogger({ name: "BuilderIoClient" });
constructor(private config: BuilderIoProviderConfig.FullShape) {
this.endpoint = `https://builder.io/api/v1/write/${config.modelName}`;
}
private mapVariantToFields(variant: WebhookProductVariantFragment) {
const { channels, productId, productName, productSlug, variantId, variantName } =
this.config.productVariantFieldsMapping;
return {
[channels]: variant.channelListings,
[productId]: variant.product.id,
[productName]: variant.product.name,
[productSlug]: variant.product.slug,
[variantId]: variant.id,
[variantName]: variant.name,
};
}
async uploadProductVariant(variant: WebhookProductVariantFragment) {
this.logger.debug({ variantId: variant.id }, "uploadProductVariant called");
try {
const response = await fetch(this.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.privateApiKey}`,
},
body: JSON.stringify({
data: this.mapVariantToFields(variant),
published: "published",
}),
});
} catch (err) {
this.logger.error(err, "Failed to upload product variant");
throw err;
}
}
private async updateProductVariantCall(
builderIoEntryId: string,
variant: WebhookProductVariantFragment
) {
try {
const response = await fetch(this.endpoint + `/${builderIoEntryId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.privateApiKey}`,
},
body: JSON.stringify({
data: this.mapVariantToFields(variant),
published: "published",
}),
});
} catch (err) {
this.logger.error(err, "Failed to upload product variant");
throw err;
}
}
async updateProductVariant(variant: WebhookProductVariantFragment) {
const entriesToUpdate = await this.fetchBuilderIoEntryIds(variant.id);
this.logger.debug(
{
entriesToUpdate,
},
"Trying to update variants in builder.io with following IDs"
);
return Promise.all(
entriesToUpdate.map((id) => {
return this.updateProductVariantCall(id, variant);
})
);
}
async upsertProductVariant(variant: WebhookProductVariantFragment) {
const entriesToUpdate = await this.fetchBuilderIoEntryIds(variant.id);
if (entriesToUpdate.length === 0) {
this.logger.debug("Didnt find any entries to update, will upload new variant");
return this.uploadProductVariant(variant);
} else {
this.logger.debug({ entriesToUpdate }, "Found entries in builder.io, will update them");
return Promise.all(
entriesToUpdate.map((id) => {
return this.updateProductVariantCall(id, variant);
})
);
}
}
async deleteProductVariant(variantId: string) {
const idsToDelete = await this.fetchBuilderIoEntryIds(variantId);
this.logger.debug({ ids: idsToDelete }, "Will try to delete items in Builder.io");
return Promise.all(
idsToDelete.map((id) =>
fetch(this.endpoint + `/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.privateApiKey}`,
},
})
)
);
}
/**
* Can return more than 1. Builder doesnt have unique fields.
*/
private fetchBuilderIoEntryIds(variantId: string): Promise<string[]> {
this.logger.trace(
{
modelName: this.config.modelName,
variantID: variantId,
variantFieldMapping: this.config.productVariantFieldsMapping.variantId,
},
"Trying to fetch variant from Builder.io"
);
return fetch(
`https://cdn.builder.io/api/v3/content/${this.config.modelName}?apiKey=${this.config.publicApiKey}&query.data.${this.config.productVariantFieldsMapping.variantId}.$eq=${variantId}&limit=10&includeUnpublished=false&cacheSeconds=0`
)
.then((res) => res.json())
.then((data) => {
return data.results.map((result: any) => result.id) as string[];
})
.catch((err) => {
this.logger.error(err, "Failed to fetch builder.io entry id");
throw err;
});
}
}

View file

@ -0,0 +1,22 @@
import { Text } from "@saleor/macaw-ui/next";
import logo from "./builder-logo.svg";
import { CMSProviderMeta } from "../cms-provider-meta";
import { TextLink } from "@saleor/apps-ui";
export const BuilderIo = {
formSideInfo: (
<Text>
Ensure fields are properly mapped with your{" "}
<TextLink newTab href="https://www.builder.io/c/docs/models-data">
CMS Data Model
</TextLink>
.
</Text>
),
type: "builder.io" as const,
logoUrl: logo.src as string,
displayName: "Builder.io",
description:
"Builder.io is the only headless CMS that gives developers, marketers, and product managers the freedom they need to ship fast, flexible, multi-channel experiences without overwhelming your backlog.",
} satisfies CMSProviderMeta;

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256px" height="293px" viewBox="0 0 256 293" version="1.1" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid">
<title>Builder</title>
<g>
<path d="M256,82.3201829 C256.01306,94.601365 253.278083,106.725264 247.991626,117.804979 C242.705169,128.884694 235.002671,138.640609 225.452096,146.351846 L5.80199569,21.6220457 C4.96315294,21.1414587 4.18547581,20.5647543 3.48207121,19.8963015 C2.3810901,18.8171653 1.50729557,17.5283183 0.908746314,16.1084022 C0.310197059,14.6841171 0,13.1549767 0,11.6127293 C0,8.53260361 1.22331235,5.57917809 3.39906073,3.4034297 C5.57480912,1.22331235 8.52823464,7.7484371e-13 11.6083604,7.7484371e-13 L173.719091,7.7484371e-13 C195.542109,7.7484371e-13 216.469488,8.67241073 231.9007,24.1123601 C247.331911,39.5479405 256,60.4884265 256,82.3201829 Z" fill="#18B4F4"></path>
<path d="M255.99996,210.379141 C256.004322,221.192348 253.876632,231.9007 249.743584,241.888171 C245.610536,251.880012 239.550771,260.958737 231.909438,268.604439 C224.268105,276.250141 215.193748,282.314275 205.210646,286.451693 C195.227543,290.593479 184.52356,292.721168 173.719091,292.721168 L11.6083604,292.721168 C9.31028074,292.694954 7.07336674,291.99155 5.1772326,290.693965 C3.28546744,289.39638 1.81312365,287.565781 0.961173986,285.433722 C0.104855344,283.301663 -0.100486371,280.964263 0.375731649,278.714242 C0.847580696,276.46859 1.97477564,274.410804 3.61750936,272.803022 C4.27285526,272.147676 5.00684267,271.575341 5.80199569,271.099123 L92.2464888,222.013715 L225.43462,146.369322 C234.980826,154.080559 242.683324,163.832106 247.97415,174.907452 C253.264976,185.982797 256.004322,198.106696 255.99996,210.379141 Z" fill="#FD6B3C"></path>
<path d="M225.452096,146.351846 L92.2464888,222.013715 L5.80636467,271.099123 C4.97625986,271.562234 4.20732067,272.125831 3.52139197,272.785546 C36.4809217,238.965329 54.9092484,193.58481 54.8699277,146.351846 C54.9310933,99.1101448 36.5071356,53.7208878 3.53886786,19.8963015 C4.24227245,20.5647543 5.01994959,21.1414587 5.86316131,21.6220457 L225.452096,146.351846 Z" fill="#A97FF2"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,9 @@
import { ReactElement } from "react";
export interface CMSProviderMeta {
type: string;
displayName: string;
logoUrl: string;
description: string;
formSideInfo?: ReactElement;
}

View file

@ -0,0 +1,61 @@
import { createLogger } from "@saleor/apps-shared";
import { BulkImportProductFragment } from "../../../../generated/graphql";
import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
import { ContentfulProviderConfig } from "../../configuration";
import { ContentfulClient } from "./contentful-client";
import { contentfulRateLimiter } from "./contentful-rate-limiter";
export class ContentfulBulkSyncProcessor implements BulkSyncProcessor {
private logger = createLogger({ name: "ContentfulBulkSyncProcessor" });
constructor(private config: ContentfulProviderConfig.FullShape) {}
async uploadProducts(
products: BulkImportProductFragment[],
hooks: BulkSyncProcessorHooks
): Promise<void> {
const contentful = new ContentfulClient({
accessToken: this.config.authToken,
space: this.config.spaceId,
});
products.flatMap((product) => {
return product.variants?.map((variant) => {
return contentfulRateLimiter(() => {
if (hooks.onUploadStart) {
hooks.onUploadStart({ variantId: variant.id });
}
return contentful
.upsertProductVariant({
configuration: this.config,
variant: {
id: variant.id,
name: variant.name,
channelListings: variant.channelListings,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
},
})
.then((r) => {
if (r?.metadata) {
if (hooks.onUploadSuccess) {
hooks.onUploadSuccess({ variantId: variant.id });
}
}
})
.catch((e) => {
this.logger.trace(e, "Error while uploading product to Contentful");
if (hooks.onUploadError) {
hooks.onUploadError({ variantId: variant.id, error: e });
}
});
});
});
});
}
}

View file

@ -0,0 +1,295 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ContentfulClient, ContentfulApiClientChunk } from "./contentful-client";
import { ContentfulProviderConfig } from "@/modules/configuration";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
const getMockContenfulConfiguration = (): ContentfulProviderConfig.FullShape => ({
authToken: "test-token",
configName: "test-config-name",
contentId: "test-content-id",
id: "test-id",
spaceId: "test-space-id",
type: "contentful",
environment: "master",
productVariantFieldsMapping: {
channels: "channels",
productId: "product-id",
productName: "product-name",
productSlug: "product-slug",
variantId: "variant-id",
variantName: "variant-name",
},
});
const getMockWebhookProductVariant = (): WebhookProductVariantFragment => {
return {
id: "test-id",
name: "test-name",
product: {
id: "test-product-id",
name: "test-product-name",
slug: "test-product-slug",
},
channelListings: [
{
channel: {
id: "test-channel-id",
slug: "test-channel-slug",
},
price: {
amount: 100,
currency: "USD",
},
},
],
};
};
const mockGetSpace = vi.fn();
const mockGetEnvironment = vi.fn();
const mockGetEnvironments = vi.fn();
const mockGetEnvEntry = vi.fn();
const mockGetContentTypes = vi.fn();
const mockCreateEntryWithId = vi.fn();
const mockContentfulSdk: ContentfulApiClientChunk = {
getSpace: mockGetSpace.mockReturnValue(
Promise.resolve({
getEnvironment: mockGetEnvironment.mockReturnValue({
getContentTypes: mockGetContentTypes.mockReturnValue({
items: [{}],
}),
getEntry: mockGetEnvEntry.mockReturnValue({}),
createEntryWithId: mockCreateEntryWithId.mockReturnValue({}),
}),
getEnvironments: mockGetEnvironments.mockReturnValue({}),
})
),
};
describe("ContentfulClient", () => {
let contentfulClient: ContentfulClient;
beforeEach(() => {
vi.clearAllMocks();
contentfulClient = new ContentfulClient(
{
accessToken: "test-token",
space: "test-space",
},
() => mockContentfulSdk
);
});
describe("getContentTypes", () => {
it("Calls contentful SDK to fetch space->environment->contentTypes", async () => {
await contentfulClient.getContentTypes("master");
expect(mockGetContentTypes).toHaveBeenCalled();
});
});
describe("getEnvironments", () => {
it("Calls contentful SDK to fetch space->environments list", async () => {
await contentfulClient.getEnvironments();
expect(mockGetEnvironments).toHaveBeenCalled();
});
});
describe("updateProductVariant", () => {
it("Mutates the entry fields and calls update method", async () => {
const mockEntry = {
fields: {},
update: vi.fn().mockReturnValue(Promise.resolve({})),
};
mockGetEnvEntry.mockReturnValue(mockEntry);
const mockConfig = getMockContenfulConfiguration();
const mockMapping = mockConfig.productVariantFieldsMapping;
const mockVariant = getMockWebhookProductVariant();
await contentfulClient.updateProductVariant({
configuration: mockConfig,
variant: mockVariant,
});
expect(mockGetEnvEntry).toHaveBeenCalledWith(mockVariant.id);
/**
* Fields must reflect mapping config to variant real data
*
* App supports and hardcodes the locale to en-US now
*/
expect(mockEntry.fields).toEqual({
[mockMapping.productId]: {
"en-US": mockVariant.product.id,
},
[mockMapping.productName]: {
"en-US": mockVariant.product.name,
},
[mockMapping.productSlug]: {
"en-US": mockVariant.product.slug,
},
[mockMapping.variantId]: {
"en-US": mockVariant.id,
},
[mockMapping.variantName]: {
"en-US": mockVariant.name,
},
[mockMapping.channels]: {
"en-US": mockVariant.channelListings,
},
});
expect(mockEntry.update).toHaveBeenCalled();
});
});
describe("deleteProductVariant", () => {
it("Calls contentful delete method on fetched entry", async () => {
const mockEntry = {
delete: vi.fn().mockReturnValue(Promise.resolve({})),
};
mockGetEnvEntry.mockReturnValue(mockEntry);
const mockConfig = getMockContenfulConfiguration();
const mockVariant = getMockWebhookProductVariant();
await contentfulClient.deleteProductVariant({
configuration: mockConfig,
variant: { id: mockVariant.id },
});
expect(mockGetEnvEntry).toHaveBeenCalledWith(mockVariant.id);
expect(mockEntry.delete).toHaveBeenCalled();
});
});
describe("uploadProductVariant", () => {
it("Calls contentful createEntryWithId method with correct mapped fields", async () => {
const mockConfig = getMockContenfulConfiguration();
const mockMapping = mockConfig.productVariantFieldsMapping;
const mockVariant = getMockWebhookProductVariant();
await contentfulClient.uploadProductVariant({
configuration: mockConfig,
variant: mockVariant,
});
expect(mockCreateEntryWithId).toHaveBeenCalledWith(mockConfig.contentId, mockVariant.id, {
fields: {
[mockMapping.productId]: {
"en-US": mockVariant.product.id,
},
[mockMapping.productName]: {
"en-US": mockVariant.product.name,
},
[mockMapping.productSlug]: {
"en-US": mockVariant.product.slug,
},
[mockMapping.variantId]: {
"en-US": mockVariant.id,
},
[mockMapping.variantName]: {
"en-US": mockVariant.name,
},
[mockMapping.channels]: {
"en-US": mockVariant.channelListings,
},
},
});
});
});
describe("upsertProductVariant", () => {
it("Calls standard create method on SDK if entry does not exist", async () => {
const mockConfig = getMockContenfulConfiguration();
const mockMapping = mockConfig.productVariantFieldsMapping;
const mockVariant = getMockWebhookProductVariant();
await contentfulClient.upsertProductVariant({
configuration: mockConfig,
variant: mockVariant,
});
expect(mockGetEnvEntry).not.toHaveBeenCalled();
expect(mockCreateEntryWithId).toHaveBeenCalledWith(mockConfig.contentId, mockVariant.id, {
fields: {
[mockMapping.productId]: {
"en-US": mockVariant.product.id,
},
[mockMapping.productName]: {
"en-US": mockVariant.product.name,
},
[mockMapping.productSlug]: {
"en-US": mockVariant.product.slug,
},
[mockMapping.variantId]: {
"en-US": mockVariant.id,
},
[mockMapping.variantName]: {
"en-US": mockVariant.name,
},
[mockMapping.channels]: {
"en-US": mockVariant.channelListings,
},
},
});
});
it("Calls update method if SDK returned 409 error", async () => {
const mockConfig = getMockContenfulConfiguration();
const mockMapping = mockConfig.productVariantFieldsMapping;
const mockVariant = getMockWebhookProductVariant();
mockCreateEntryWithId.mockRejectedValue({
message: JSON.stringify({
status: 409,
}),
});
const mockEntry = {
fields: {},
update: vi.fn().mockReturnValue(Promise.resolve({})),
};
mockGetEnvEntry.mockReturnValue(mockEntry);
await contentfulClient.upsertProductVariant({
configuration: mockConfig,
variant: mockVariant,
});
expect(mockEntry.fields).toEqual({
[mockMapping.productId]: {
"en-US": mockVariant.product.id,
},
[mockMapping.productName]: {
"en-US": mockVariant.product.name,
},
[mockMapping.productSlug]: {
"en-US": mockVariant.product.slug,
},
[mockMapping.variantId]: {
"en-US": mockVariant.id,
},
[mockMapping.variantName]: {
"en-US": mockVariant.name,
},
[mockMapping.channels]: {
"en-US": mockVariant.channelListings,
},
});
expect(mockEntry.update).toHaveBeenCalledWith();
});
});
});

View file

@ -0,0 +1,197 @@
import { createClient, ClientAPI } from "contentful-management";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
import { ContentfulProviderConfig } from "@/modules/configuration";
import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import { createLogger } from "@saleor/apps-shared";
const ContentfulErrorMessageSchema = z.object({
status: z.number(),
});
type ConstructorOptions = {
space: string;
accessToken: string;
};
export type ContentfulApiClientChunk = Pick<ClientAPI, "getSpace">;
/**
* Allow injecting mocked client instead of mocking whole module
*/
type SdkClientFactory = (opts: ConstructorOptions) => ContentfulApiClientChunk;
const defaultSdkClientFactory: SdkClientFactory = (opts) =>
createClient({
accessToken: opts.accessToken,
});
/**
* Wrapper facade of
* https://www.npmjs.com/package/contentful
*
* TODO: tests
*/
export class ContentfulClient {
private client: ContentfulApiClientChunk;
private space: string;
private logger = createLogger({ name: "ContentfulClient" });
constructor(opts: ConstructorOptions, clientFactory: SdkClientFactory = defaultSdkClientFactory) {
this.space = opts.space;
this.client = clientFactory(opts);
}
/**
* Support on en-US locale now
*/
private mapVariantToConfiguredFields = (
variant: WebhookProductVariantFragment,
productVariantFieldsMapping: ContentfulProviderConfig.FullShape["productVariantFieldsMapping"]
) => {
const {
channels,
variantName: name,
productId,
productName,
productSlug,
variantId,
} = productVariantFieldsMapping;
return {
[name]: {
"en-US": variant.name,
},
[productId]: {
"en-US": variant.product.id,
},
[productName]: {
"en-US": variant.product.name,
},
[productSlug]: {
"en-US": variant.product.slug,
},
[variantId]: {
"en-US": variant.id,
},
[channels]: {
"en-US": variant.channelListings,
},
};
};
async getContentTypes(env: string) {
this.logger.trace("Attempting to get content types");
try {
const space = await this.client.getSpace(this.space);
const environment = await space.getEnvironment(env);
const contentTypes = await environment.getContentTypes();
return contentTypes;
} catch (err) {
this.logger.error(err);
throw err;
}
}
async getEnvironments() {
this.logger.trace("Attempting to get environments");
return (await this.client.getSpace(this.space)).getEnvironments();
}
async updateProductVariant({
configuration,
variant,
}: {
configuration: ContentfulProviderConfig.FullShape;
variant: WebhookProductVariantFragment;
}) {
this.logger.debug("Attempting to update product variant");
const space = await this.client.getSpace(this.space);
const env = await space.getEnvironment(configuration.environment);
const entry = await env.getEntry(variant.id);
entry.fields = this.mapVariantToConfiguredFields(
variant,
configuration.productVariantFieldsMapping
);
return entry.update();
}
async deleteProductVariant(opts: {
configuration: ContentfulProviderConfig.FullShape;
variant: Pick<WebhookProductVariantFragment, "id">;
}) {
this.logger.debug("Attempting to delete product variant");
const space = await this.client.getSpace(this.space);
const env = await space.getEnvironment(opts.configuration.environment);
const entry = await env.getEntry(opts.variant.id);
return await entry.delete();
}
async uploadProductVariant({
configuration,
variant,
}: {
configuration: ContentfulProviderConfig.FullShape;
variant: WebhookProductVariantFragment;
}) {
this.logger.debug("Attempting to upload product variant");
const space = await this.client.getSpace(this.space);
const env = await space.getEnvironment(configuration.environment);
/*
* TODO: add translations
* TODO: - should it create published? is draft
*/
return env.createEntryWithId(configuration.contentId, variant.id, {
fields: this.mapVariantToConfiguredFields(variant, configuration.productVariantFieldsMapping),
});
}
async upsertProductVariant(opts: {
configuration: ContentfulProviderConfig.FullShape;
variant: WebhookProductVariantFragment;
}) {
this.logger.debug("Attempting to upsert product variant");
try {
this.logger.trace("Attempting to upload product variant first");
return await this.uploadProductVariant(opts);
} catch (e: unknown) {
this.logger.trace("Upload failed");
if (typeof e !== "object" || e === null) {
Sentry.captureMessage("Contentful error is not expected shape");
Sentry.captureException(e);
throw e;
}
const parsedError = ContentfulErrorMessageSchema.parse(JSON.parse((e as Error).message));
if (parsedError.status === 409) {
this.logger.trace("Contentful returned 409 status, will try to update instead");
return this.updateProductVariant(opts);
} else {
Sentry.captureMessage("Contentful error failed and is not handled");
throw e;
}
}
}
}

View file

@ -0,0 +1,379 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { useForm } from "react-hook-form";
import { Input, Select } from "@saleor/react-hook-form-macaw";
import { trpcClient } from "../../trpc/trpc-client";
import { useEffect, useMemo } from "react";
import { useRouter } from "next/router";
import { useDashboardNotification } from "@saleor/apps-shared";
import { ContentfulProviderConfig } from "../../configuration/schemas/contentful-provider.schema";
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
import { zodResolver } from "@hookform/resolvers/zod";
import { ButtonsBox } from "../../ui/buttons-box";
import { TextLink } from "@saleor/apps-ui";
import { SaleorProviderFieldsMappingKeys } from "@/modules/configuration";
type FormSchema = Omit<ContentfulProviderConfig.InputShape, "type">;
/**
* TODO - when space, token or env changes, refetch queries
* TODO - error handling
* TODO - refactor smaller hooks
*/
const PureForm = ({
defaultValues,
onSubmit,
onDelete,
}: {
defaultValues: FormSchema;
onSubmit(values: FormSchema): void;
onDelete?(): void;
}) => {
const {
control,
getValues,
setValue,
watch,
handleSubmit,
formState: { errors },
} = useForm<FormSchema>({
defaultValues: defaultValues,
resolver: zodResolver(ContentfulProviderConfig.Schema.Input.omit({ type: true })),
});
const { mutate: fetchContentTypes, data: contentTypesData } =
trpcClient.contentful.fetchContentTypesFromApi.useMutation({
onSuccess(data) {
setValue("contentId", data.items[0].sys.id ?? null);
},
});
const { mutate: fetchEnvironments, data: environmentsData } =
trpcClient.contentful.fetchEnvironmentsFromApi.useMutation({
onSuccess(data) {
setValue("environment", data.items[0].sys.id);
},
});
const selectedContentTypeId = watch("contentId");
const availableFields = useMemo(() => {
try {
return contentTypesData?.items?.find((i) => i.sys.id === selectedContentTypeId)?.fields;
} catch (e) {
return null;
}
}, [selectedContentTypeId, contentTypesData?.items]);
/**
* For "edit" form variant, tokens already exist, so fetch immediately
*/
useEffect(() => {
if (defaultValues.authToken && defaultValues.spaceId && defaultValues.environment) {
fetchContentTypes({
contentfulSpace: defaultValues.spaceId,
contentfulToken: defaultValues.authToken,
contentfulEnv: defaultValues.environment,
});
fetchEnvironments({
contentfulSpace: defaultValues.spaceId,
contentfulToken: defaultValues.authToken,
});
}
}, [
defaultValues.authToken,
defaultValues.spaceId,
defaultValues.environment,
fetchContentTypes,
fetchEnvironments,
]);
return (
<Box
as="form"
display={"grid"}
gap={4}
onSubmit={handleSubmit((vals) => {
onSubmit(vals);
})}
>
<Input
required
control={control}
name="configName"
label="Configuration name"
helperText="Meaningful name that will help you understand it later. E.g. 'staging' or 'prod' "
/>
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Provide connection details</Text>
<Input
required
control={control}
name="spaceId"
label="Contentful space ID"
helperText={
<Text variant="caption" color="textNeutralSubdued">
Check{" "}
<TextLink size="small" href="https://www.contentful.com/help/find-space-id/" newTab>
how to get space ID
</TextLink>
</Text>
}
/>
<Input
required
type="password"
control={control}
name="authToken"
label="Content Management Personal token"
helperText={
<Text variant="caption" color="textNeutralSubdued">
Check{" "}
<TextLink
size="small"
href="https://www.contentful.com/help/personal-access-tokens/"
newTab
>
how to generate token
</TextLink>
</Text>
}
/>
{!environmentsData && (
<ButtonsBox>
<Button
variant="secondary"
onClick={() => {
const values = getValues();
return fetchEnvironments({
contentfulSpace: values.spaceId,
contentfulToken: values.authToken,
});
}}
>
Continue
</Button>
</ButtonsBox>
)}
{environmentsData && (
<>
<Select
required
control={control}
name="environment"
label="Contentful environment"
helperText={
<Text variant="caption" color="textNeutralSubdued">
Check your environment{" "}
<TextLink
newTab
size="small"
href={`https://app.contentful.com/spaces/${getValues(
"spaceId"
)}/settings/environments`}
>
here
</TextLink>
</Text>
}
options={environmentsData.items.map((item) => ({
label: item.name,
value: item.sys.id,
}))}
/>
{!contentTypesData && (
<ButtonsBox>
<Button
variant="secondary"
onClick={() => {
const values = getValues();
return fetchContentTypes({
contentfulSpace: values.spaceId,
contentfulToken: values.authToken,
contentfulEnv: values.environment,
});
}}
>
Continue
</Button>
</ButtonsBox>
)}
</>
)}
</Box>
{contentTypesData && (
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Configure fields mapping</Text>
<Text as="p">First select content type you want to synchronize products with.</Text>
<Select
label="Content Type"
control={control}
name="contentId"
options={contentTypesData?.items.map((contentType) => ({
label: contentType.name,
value: contentType.sys.id,
}))}
/>
<Box marginTop={4}>
<Text as="p" variant="heading" size="small">
Map fields from Saleor to your contentful schema.
</Text>
<Text as="p" marginTop={2} marginBottom={4}>
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should
be type of <Text variant="bodyStrong">JSON</Text>.
</Text>
<Box
marginBottom={4}
display="grid"
__gridTemplateColumns={"50% 50%"}
borderBottomWidth={1}
borderBottomStyle="solid"
borderColor="neutralHighlight"
padding={2}
>
<Text variant="caption">Saleor Field</Text>
<Text variant="caption">Contentful field</Text>
</Box>
{availableFields &&
SaleorProviderFieldsMappingKeys.map((saleorField) => (
<Box
display="grid"
__gridTemplateColumns={"50% 50%"}
padding={2}
key={saleorField}
alignItems="center"
>
<Box>
<Text as="p" variant="bodyStrong">
{printSaleorProductFields(saleorField)}
</Text>
<Text variant="caption">
{saleorField === "channels" ? "JSON field" : "Text field"}
</Text>
</Box>
<Select
size="small"
control={control}
name={`productVariantFieldsMapping.${saleorField}`}
label="Contentful Field"
options={availableFields.map((f) => ({
label: f.name,
value: f.id,
}))}
/>
</Box>
))}
</Box>
<ButtonsBox>
{onDelete && (
<Button onClick={onDelete} variant="tertiary">
Delete
</Button>
)}
<Button type="submit">Save</Button>
</ButtonsBox>
</Box>
)}
</Box>
);
};
const AddVariant = () => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { mutate } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
push("/configuration");
},
});
return (
<ContentfulConfigForm.PureVariant
onSubmit={(values) => {
mutate({
...values,
type: "contentful",
});
}}
defaultValues={{
authToken: "",
configName: "",
environment: "",
contentId: "",
productVariantFieldsMapping: {
channels: "",
variantName: "",
productId: "",
productName: "",
productSlug: "",
variantId: "",
},
spaceId: "",
}}
/>
);
};
const EditVariant = ({ configId }: { configId: string }) => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { data } = trpcClient.providersConfigs.getOne.useQuery(
{
id: configId,
},
{
enabled: !!configId,
}
);
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
push("/configuration");
},
});
const { mutate: deleteProvider } = trpcClient.providersConfigs.deleteOne.useMutation({
onSuccess() {
notifySuccess("Success", "Removed configuration");
push("/configuration");
},
});
if (!data) {
return <Text>Loading</Text>;
}
if (data.type !== "contentful") {
throw new Error("Trying to fill contentful form with non contentful data");
}
return (
<ContentfulConfigForm.PureVariant
onDelete={() => {
deleteProvider({ id: configId });
}}
defaultValues={data}
onSubmit={(values) =>
mutate({
...values,
id: configId,
type: "contentful",
})
}
/>
);
};
export const ContentfulConfigForm = {
PureVariant: PureForm,
AddVariant,
EditVariant,
};

View file

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150"><defs><style>.cls-1{fill:#0033a3;}.cls-2{fill:#ffda00;}.cls-3{fill:#67b3ff;}.cls-4{fill:#eb5a68;}.cls-5{fill:#47a1ff;}.cls-6{fill:#d5465f;}</style></defs><circle class="cls-1" cx="75" cy="75" r="75"/><path class="cls-2" d="M61.2,92.29a24.59,24.59,0,0,1-7.14-17.44,24.3,24.3,0,0,1,7.26-17.37A9.68,9.68,0,0,0,47.67,43.76a44,44,0,0,0-.19,62.18A9.68,9.68,0,1,0,61.2,92.29Z"/><path class="cls-3" d="M61.32,57.48a24.57,24.57,0,0,1,17.44-7.14A24.32,24.32,0,0,1,96.13,57.6,9.67,9.67,0,1,0,109.84,44a43.82,43.82,0,0,0-62.11-.25A9.68,9.68,0,0,0,61.32,57.48Z"/><path class="cls-4" d="M96.07,92.41a24.59,24.59,0,0,1-17.44,7.14,24.34,24.34,0,0,1-17.37-7.26,9.67,9.67,0,1,0-13.71,13.65,43.88,43.88,0,0,0,62.17.24A9.69,9.69,0,0,0,96.07,92.41Z"/><path class="cls-5" d="M61.32,57.48a9.68,9.68,0,1,1,.06-13.65A9.71,9.71,0,0,1,61.32,57.48Z"/><path class="cls-6" d="M61.13,105.94a9.68,9.68,0,1,1,.07-13.65A9.7,9.7,0,0,1,61.13,105.94Z"/></svg>

After

Width:  |  Height:  |  Size: 1,017 B

View file

@ -0,0 +1,7 @@
import { pRateLimit } from "p-ratelimit";
export const contentfulRateLimiter = pRateLimit({
interval: 1000,
rate: 2,
concurrency: 2,
});

View file

@ -0,0 +1,131 @@
import { describe, expect, vi, it, beforeEach } from "vitest";
import {
ContentfulClientStrip,
ContentfulWebhooksProcessor,
} from "./contentful-webhooks-processor";
import { ContentfulProviderConfig } from "@/modules/configuration";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
const getMockContenfulConfiguration = (): ContentfulProviderConfig.FullShape => ({
authToken: "test-token",
configName: "test-config-name",
contentId: "test-content-id",
id: "test-id",
spaceId: "test-space-id",
type: "contentful",
environment: "master",
productVariantFieldsMapping: {
channels: "channels",
productId: "product-id",
productName: "product-name",
productSlug: "product-slug",
variantId: "variant-id",
variantName: "variant-name",
},
});
const getMockWebhookProductVariant = (): WebhookProductVariantFragment => {
return {
id: "test-id",
name: "test-name",
product: {
id: "test-product-id",
name: "test-product-name",
slug: "test-product-slug",
},
channelListings: [
{
channel: {
id: "test-channel-id",
slug: "test-channel-slug",
},
price: {
amount: 100,
currency: "USD",
},
},
],
};
};
const mockContentfulClient: ContentfulClientStrip = {
deleteProductVariant: vi.fn(),
upsertProductVariant: vi.fn(),
};
describe("ContentfulWebhooksProcessor", () => {
let processor: ContentfulWebhooksProcessor;
beforeEach(() => {
vi.clearAllMocks();
processor = new ContentfulWebhooksProcessor(
getMockContenfulConfiguration(),
() => mockContentfulClient
);
});
it("onProductVariantUpdated calls client upsert method", () => {
const mockProductVariant = getMockWebhookProductVariant();
processor.onProductVariantUpdated(mockProductVariant);
expect(mockContentfulClient.upsertProductVariant).toHaveBeenCalledWith(
expect.objectContaining({
configuration: getMockContenfulConfiguration(),
variant: mockProductVariant,
})
);
});
it("onProductVariantCreated calls client upsert method", () => {
const mockProductVariant = getMockWebhookProductVariant();
processor.onProductVariantCreated(mockProductVariant);
expect(mockContentfulClient.upsertProductVariant).toHaveBeenCalledWith(
expect.objectContaining({
configuration: getMockContenfulConfiguration(),
variant: mockProductVariant,
})
);
});
it("onProductVariantDeleted calls client delete method", () => {
const mockProductVariant = getMockWebhookProductVariant();
processor.onProductVariantDeleted(mockProductVariant);
expect(mockContentfulClient.deleteProductVariant).toHaveBeenCalledWith(
expect.objectContaining({
configuration: getMockContenfulConfiguration(),
variant: mockProductVariant,
})
);
});
it("onProductUpdated calls client upsert method for every product variant", () => {
const mockProductVariant1 = getMockWebhookProductVariant();
const mockProductVariant2 = getMockWebhookProductVariant();
const mockProductVariant3 = getMockWebhookProductVariant();
mockProductVariant1.id = "test-product-variant-id-1";
mockProductVariant2.id = "test-product-variant-id-2";
mockProductVariant3.id = "test-product-variant-id-3";
processor.onProductUpdated({
id: "test-product-id",
name: "test-product-name",
slug: "test-product-slug",
variants: [mockProductVariant1, mockProductVariant2, mockProductVariant3],
channelListings: [
{
channel: { id: "test-channel-id", slug: "test-channel-slug" },
id: "test-id",
},
],
});
expect(mockContentfulClient.upsertProductVariant).toHaveBeenCalledTimes(3);
});
});

View file

@ -0,0 +1,69 @@
import {
WebhookProductFragment,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import { ContentfulProviderConfig } from "../../configuration";
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
import { ContentfulClient } from "./contentful-client";
export type ContentfulClientStrip = Pick<
ContentfulClient,
"upsertProductVariant" | "deleteProductVariant"
>;
export type ContentfulClientFactory = (
config: ContentfulProviderConfig.FullShape
) => ContentfulClientStrip;
export class ContentfulWebhooksProcessor implements ProductWebhooksProcessor {
private client: ContentfulClientStrip;
constructor(
private providerConfig: ContentfulProviderConfig.FullShape,
clientFactory: ContentfulClientFactory = () =>
new ContentfulClient({
accessToken: providerConfig.authToken,
space: providerConfig.spaceId,
})
) {
this.client = clientFactory(providerConfig);
}
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
await this.client.upsertProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
await this.client.upsertProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
await this.client.deleteProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
await Promise.all(
(product.variants ?? []).map((variant) => {
return this.client.upsertProductVariant({
configuration: this.providerConfig,
variant: {
id: variant.id,
name: variant.name,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
},
});
})
);
}
}

View file

@ -0,0 +1,63 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { AppConfigMetadataManager } from "../../configuration/app-config-metadata-manager";
import { createSettingsManager } from "../../configuration/metadata-manager";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { router } from "../../trpc/trpc-server";
import { ContentfulClient } from "./contentful-client";
const procedure = protectedClientProcedure.use(({ ctx, next }) => {
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
return next({
ctx: {
settingsManager,
appConfigService: new AppConfigMetadataManager(settingsManager),
},
});
});
/**
* Operations specific for Contentful service.
*
* For configruration see providers-list.router.ts
*/
export const contentfulRouter = router({
fetchEnvironmentsFromApi: procedure
.input(
z.object({
contentfulToken: z.string(),
contentfulSpace: z.string(),
})
)
.mutation(({ ctx, input }) => {
const client = new ContentfulClient({
accessToken: input.contentfulToken,
space: input.contentfulSpace,
});
return client.getEnvironments();
}),
fetchContentTypesFromApi: procedure
.input(
z.object({
contentfulToken: z.string(),
contentfulSpace: z.string(),
contentfulEnv: z.string(),
})
)
.mutation(({ ctx, input }) => {
const client = new ContentfulClient({
accessToken: input.contentfulToken,
space: input.contentfulSpace,
});
return client.getContentTypes(input.contentfulEnv).catch((e) => {
console.error(e);
throw new TRPCError({ code: "BAD_REQUEST" });
});
}),
});

View file

@ -0,0 +1,13 @@
import { Text } from "@saleor/macaw-ui/next";
import logo from "./contentful-logo.svg";
import { CMSProviderMeta } from "../cms-provider-meta";
export const Contentful = {
formSideInfo: <Text>App will save each variant with the same ID as variant ID.</Text>,
type: "contentful" as const,
logoUrl: logo.src as string,
displayName: "Contentful",
description:
"More than a headless CMS, Contentful is the API-first composable content platform to create, manage and publish content on any digital channel.",
} satisfies CMSProviderMeta;

View file

@ -0,0 +1,51 @@
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
import { BulkImportProductFragment } from "../../../../generated/graphql";
import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
import { DatoCMSClient } from "./datocms-client";
export class DatocmsBulkSyncProcessor implements BulkSyncProcessor {
constructor(private config: DatocmsProviderConfig.FullShape) {}
async uploadProducts(
products: BulkImportProductFragment[],
hooks: BulkSyncProcessorHooks
): Promise<void> {
const client = new DatoCMSClient({
apiToken: this.config.authToken,
});
products.flatMap((product) =>
product.variants?.map((variant) => {
if (hooks.onUploadStart) {
hooks.onUploadStart({ variantId: variant.id });
}
return client
.upsertProduct({
configuration: this.config,
variant: {
id: variant.id,
name: variant.name,
channelListings: variant.channelListings,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
},
})
.then((r) => {
if (hooks.onUploadSuccess) {
hooks.onUploadSuccess({ variantId: variant.id });
}
})
.catch((e) => {
if (hooks.onUploadError) {
hooks.onUploadError({ variantId: variant.id, error: e });
}
});
})
);
}
}

View file

@ -0,0 +1,181 @@
import { buildClient, Client, SimpleSchemaTypes, ApiError } from "@datocms/cma-client-browser";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
import { createLogger } from "@saleor/apps-shared";
import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
type Context = {
configuration: DatocmsProviderConfig.FullShape;
variant: WebhookProductVariantFragment;
};
/*
* todo error handling
*/
export class DatoCMSClient {
private client: Client;
private logger = createLogger({ name: "DatoCMSClient" });
constructor(opts: { apiToken: string }) {
this.client = buildClient({ apiToken: opts.apiToken });
}
getContentTypes() {
this.logger.trace("Trying to get content types");
return this.client.itemTypes.list();
}
getFieldsForContentType({ itemTypeID }: { itemTypeID: string }) {
this.logger.trace("Trying to get fields for a content type");
return this.client.fields.list({ type: "item_type", id: itemTypeID });
}
private getItemBySaleorVariantId({
variantIdFieldName: variantFieldName,
variantID,
contentType,
}: {
variantIdFieldName: string;
variantID: string;
contentType: string;
}) {
this.logger.trace("Trying to fetch item by Saleor variant ID", { variantID: variantID });
return this.client.items.list({
filter: {
type: contentType,
fields: {
[variantFieldName]: {
eq: variantID,
},
},
},
});
}
private mapVariantToDatoCMSFields({
configuration,
variant,
}: Context): SimpleSchemaTypes.ItemCreateSchema {
const fieldsMap = configuration.productVariantFieldsMapping;
return {
item_type: { type: "item_type", id: configuration.itemType },
[fieldsMap.variantName]: variant.name,
[fieldsMap.productId]: variant.product.id,
[fieldsMap.productName]: variant.product.name,
[fieldsMap.productSlug]: variant.product.slug,
[fieldsMap.variantId]: variant.id,
[fieldsMap.channels]: JSON.stringify(variant.channelListings),
};
}
async deleteProductVariant({ configuration, variant }: Context) {
this.logger.debug("Trying to delete product variant");
const remoteProducts = await this.getItemBySaleorVariantId({
variantIdFieldName: configuration.productVariantFieldsMapping.variantId,
variantID: variant.id,
contentType: configuration.itemType,
});
if (remoteProducts.length > 1) {
this.logger.warn(
"More than 1 variant with the same ID found in the CMS. Will remove all of them, but this should not happen if unique field was set"
);
}
if (remoteProducts.length === 0) {
this.logger.trace("No product found in Datocms, skipping deletion");
return;
}
return Promise.all(
remoteProducts.map((p) => {
return this.client.items.rawDestroy(p.id);
})
);
}
uploadProductVariant(context: Context) {
this.logger.debug("Trying to upload product variant");
return this.client.items.create(this.mapVariantToDatoCMSFields(context));
}
async updateProductVariant({ configuration, variant }: Context) {
const products = await this.getItemBySaleorVariantId({
variantIdFieldName: configuration.productVariantFieldsMapping.variantId,
variantID: variant.id,
contentType: configuration.itemType,
});
if (products.length > 1) {
this.logger.warn(
"Found more than one product variant with the same ID. Will update all of them, but this should not happen if unique field was set",
{
variantID: variant.id,
}
);
}
return Promise.all(
products.map((product) => {
this.logger.trace("Trying to update variant", { datoID: product.id });
return this.client.items.update(
product.id,
this.mapVariantToDatoCMSFields({
configuration,
variant,
})
);
})
);
}
upsertProduct({ configuration, variant }: Context) {
this.logger.debug("Trying to upsert product variant");
const DatoErrorBody = z.object({
data: z.array(
z.object({
validation: z.object({
attributes: z.object({
details: z.object({
code: z.string(),
}),
}),
}),
})
),
});
return this.uploadProductVariant({ configuration, variant }).catch((err: ApiError) => {
try {
const errorBody = DatoErrorBody.parse(err.response.body);
const isUniqueIdError = errorBody.data.find(
(d) => d.validation.attributes.details.code === "VALIDATION_UNIQUE"
);
if (isUniqueIdError) {
return this.updateProductVariant({ configuration, variant });
} else {
throw new Error(JSON.stringify(err.cause));
}
} catch (e) {
Sentry.captureException("Invalid error shape from DatoCMS", (c) => {
return c.setExtra("error", err);
});
throw new Error(err.humanMessage ?? "DatoCMS error - can upload product variant");
}
});
}
}

View file

@ -0,0 +1,315 @@
import { SaleorProviderFieldsMappingKeys } from "@/modules/configuration";
import { zodResolver } from "@hookform/resolvers/zod";
import { useDashboardNotification } from "@saleor/apps-shared";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { Input, Select } from "@saleor/react-hook-form-macaw";
import { useRouter } from "next/router";
import React, { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
import { trpcClient } from "../../trpc/trpc-client";
import { ButtonsBox } from "../../ui/buttons-box";
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
type FormShape = Omit<DatocmsProviderConfig.InputShape, "type">;
type PureFormProps = {
defaultValues: FormShape;
onSubmit(values: FormShape): void;
onDelete?(): void;
};
const useDatoCmsRemoteFields = () => {
const { mutate: fetchContentTypes, data: contentTypesData } =
trpcClient.datocms.fetchContentTypes.useMutation();
const { mutate: fetchContentTypeFields, data: fieldsData } =
trpcClient.datocms.fetchContentTypeFields.useMutation();
const contentTypesSelectOptions = useMemo(() => {
if (!contentTypesData) {
return null;
}
return contentTypesData.map((item) => ({
label: item.name,
value: item.id,
}));
}, [contentTypesData]);
return {
fetchContentTypes,
contentTypesData,
contentTypesSelectOptions,
fetchContentTypeFields,
fieldsData,
};
};
/*
* todo react on token error
* todo react on token change, refresh mutation
*/
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
const {
control,
getValues,
setValue,
watch,
handleSubmit,
formState: { errors },
} = useForm<FormShape>({
defaultValues: defaultValues,
resolver: zodResolver(DatocmsProviderConfig.Schema.Input.omit({ type: true })),
});
const {
contentTypesData,
fetchContentTypes,
contentTypesSelectOptions,
fetchContentTypeFields,
fieldsData,
} = useDatoCmsRemoteFields();
const selectedContentType = watch("itemType");
useEffect(() => {
if (selectedContentType) {
fetchContentTypeFields({
contentTypeID: selectedContentType,
apiToken: getValues("authToken"),
});
}
}, [selectedContentType, getValues, fetchContentTypeFields]);
useEffect(() => {
if (defaultValues.authToken && defaultValues.itemType) {
fetchContentTypes({
apiToken: defaultValues.authToken,
});
fetchContentTypeFields({
apiToken: defaultValues.authToken,
contentTypeID: defaultValues.itemType,
});
}
}, [defaultValues, fetchContentTypes, fetchContentTypeFields]);
const fetchContentTypesButton = (
<ButtonsBox>
<Button
variant="secondary"
onClick={() => {
const values = getValues();
return fetchContentTypes({
apiToken: values.authToken,
});
}}
>
Continue
</Button>
</ButtonsBox>
);
return (
<Box
as="form"
display={"grid"}
gap={4}
onSubmit={handleSubmit((vals) => {
onSubmit(vals);
})}
>
<Input
required
control={control}
name="configName"
label="Configuration name"
helperText="Meaningful name that will help you understand it later. E.g. 'staging' or 'prod' "
/>
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Provide connection details</Text>
<Input
required
control={control}
name="authToken"
type="password"
label="API Token"
helperText="Project -> Settings -> API Tokens -> Full-access API token."
/>
{!contentTypesData && fetchContentTypesButton}
</Box>
{contentTypesSelectOptions && (
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Configure fields mapping</Text>
<Select
label="Item type"
options={contentTypesSelectOptions}
name="itemType"
control={control}
helperText="Model that will keep Saleor data. You should create one just for Saleor data."
/>
{fieldsData && (
<React.Fragment>
<Text as="p" variant="heading" size="small">
Map fields from Saleor to your contentful schema.
</Text>
<Text as="p" marginTop={2} marginBottom={4}>
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should
be type of <Text variant="bodyStrong">JSON</Text>.
</Text>
<Box
marginBottom={4}
display="grid"
__gridTemplateColumns={"50% 50%"}
borderBottomWidth={1}
borderBottomStyle="solid"
borderColor="neutralHighlight"
padding={2}
>
<Text variant="caption">Saleor Field</Text>
<Text variant="caption">Contentful field</Text>
</Box>
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
// todo extract this table to component
<Box
display="grid"
__gridTemplateColumns={"50% 50%"}
padding={2}
key={saleorField}
alignItems="center"
>
<Box>
<Text as="p" variant="bodyStrong">
{printSaleorProductFields(saleorField)}
</Text>
<Text variant="caption">
{saleorField === "channels" ? "JSON field" : "Text field"}
</Text>
</Box>
<Select
size="small"
control={control}
name={`productVariantFieldsMapping.${saleorField}`}
label="DatoCMS Field"
options={fieldsData.map((f) => ({
label: f.label,
value: f.api_key,
}))}
/>
</Box>
))}
</React.Fragment>
)}
</Box>
)}
{contentTypesSelectOptions && (
<ButtonsBox>
{onDelete && (
<Button onClick={onDelete} variant="tertiary">
Delete
</Button>
)}
<Button type="submit">Save</Button>
</ButtonsBox>
)}
</Box>
);
};
const AddFormVariant = () => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
push("/configuration");
},
});
return (
<PureForm
onSubmit={(values) => {
addProvider({
...values,
type: "datocms",
});
}}
defaultValues={{
authToken: "",
configName: "",
itemType: "",
productVariantFieldsMapping: {
channels: "",
variantName: "",
productId: "",
productName: "",
productSlug: "",
variantId: "",
},
}}
/>
);
};
const EditFormVariant = (props: { configId: string }) => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { data } = trpcClient.providersConfigs.getOne.useQuery(
{
id: props.configId,
},
{
enabled: !!props.configId,
}
);
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
push("/configuration");
},
});
const { mutate: deleteProvider } = trpcClient.providersConfigs.deleteOne.useMutation({
onSuccess() {
notifySuccess("Success", "Removed configuration");
push("/configuration");
},
});
if (!data) {
return null;
}
if (data.type !== "datocms") {
throw new Error("Trying to fill datocms form with non datocms data");
}
return (
<PureForm
onDelete={() => {
deleteProvider({
id: props.configId,
});
}}
onSubmit={(values) => {
mutate({
...values,
type: "datocms",
id: props.configId,
});
}}
defaultValues={data}
/>
);
};
export const DatoCMSConfigForm = {
PureVariant: PureForm,
AddVariant: AddFormVariant,
EditVariant: EditFormVariant,
};

View file

@ -0,0 +1,59 @@
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
import {
WebhookProductFragment,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
import { DatoCMSClient } from "./datocms-client";
/*
* todo error handling
*/
export class DatocmsWebhooksProcessor implements ProductWebhooksProcessor {
private client: DatoCMSClient;
constructor(private providerConfig: DatocmsProviderConfig.FullShape) {
this.client = new DatoCMSClient({
apiToken: providerConfig.authToken,
});
}
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
await this.client.updateProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
await this.client.uploadProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
await this.client.deleteProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
await Promise.all(
(product.variants ?? []).map((variant) => {
return this.client.upsertProduct({
configuration: this.providerConfig,
variant: {
id: variant.id,
name: variant.name,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
},
});
})
);
}
}

View file

@ -0,0 +1,44 @@
import { z } from "zod";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { router } from "../../trpc/trpc-server";
import { DatoCMSClient } from "./datocms-client";
/**
* Operations specific for Datocms service.
*
* For configruration see providers-list.router.ts
*/
export const datocmsRouter = router({
fetchContentTypes: protectedClientProcedure
.input(
z.object({
apiToken: z.string(),
})
)
.mutation(({ ctx, input }) => {
const client = new DatoCMSClient({
apiToken: input.apiToken,
});
return client.getContentTypes();
}),
fetchContentTypeFields: protectedClientProcedure
.input(
z.object({
contentTypeID: z.string(),
apiToken: z.string(),
})
)
.mutation(({ ctx, input }) => {
const client = new DatoCMSClient({
apiToken: input.apiToken,
});
return client.getFieldsForContentType({
itemTypeID: input.contentTypeID,
});
}),
});

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 94.652 95.261"><defs><linearGradient id="a" y1="47.631" x2="94.652" y2="47.631" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff593d"/><stop offset="1" stop-color="#ff7751"/></linearGradient></defs><title>d_only</title><path d="M47.326,0H0V95.261H47.326c23.67,0,47.326-21.326,47.326-47.624S71,0,47.326,0Zm0,69.274a21.644,21.644,0,1,1,21.65-21.637A21.635,21.635,0,0,1,47.326,69.274Z" fill="url(#a)"/></svg>

After

Width:  |  Height:  |  Size: 516 B

View file

@ -0,0 +1,21 @@
import { Box, Text } from "@saleor/macaw-ui/next";
import { CMSProviderMeta } from "../cms-provider-meta";
import logo from "./datocms.svg";
export const Datocms = {
formSideInfo: (
<Box>
<Text as="p" marginBottom={2}>
App will use Saleor Product Variant as a unique identifier. It will be saved as one of the
fields. Please ensure you map Variant ID to field that is UNIQUE in DatoCMS.
</Text>
<Text>Otherwise, products may be duplicated</Text>
</Box>
),
type: "datocms" as const,
logoUrl: logo.src,
displayName: "DatoCMS",
description:
"It's the headless CMS for the modern web. More than 25,000 businesses use DatoCMS to create online content at scale from a central hub and distribute it via API.",
} satisfies CMSProviderMeta;

View file

@ -0,0 +1,12 @@
import { BuilderIo } from "./builder.io/builder-io";
import { Contentful } from "./contentful/contentful";
import { Datocms } from "./datocms/datocms";
import { Strapi } from "./strapi/strapi";
export type CMS = typeof Contentful | typeof Datocms | typeof Strapi | typeof BuilderIo;
export type CMSType = CMS["type"];
export const cmsTypes = [Contentful.type, Datocms.type, Strapi.type, BuilderIo.type] as const;
export const CMSProviders = [Contentful, Datocms, Strapi, BuilderIo] as const;

View file

@ -0,0 +1,187 @@
import { BulkSyncProcessor } from "../bulk-sync/bulk-sync-processor";
import {
BuilderIoProviderConfig,
ContentfulProviderConfig,
ProvidersConfig,
StrapiProviderConfig,
} from "../configuration";
import { ContentfulBulkSyncProcessor } from "./contentful/contentful-bulk-sync-processor";
import { DatocmsBulkSyncProcessor } from "./datocms/datocms-bulk-sync-processor";
import { StrapiBulkSyncProcessor } from "./strapi/strapi-bulk-sync-processor";
import dynamic from "next/dynamic";
import { ComponentType, ReactElement } from "react";
import { Contentful } from "./contentful/contentful";
import { ContentfulWebhooksProcessor } from "./contentful/contentful-webhooks-processor";
import { Datocms } from "./datocms/datocms";
import { DatocmsWebhooksProcessor } from "./datocms/datocms-webhooks-processor";
import { CMS, CMSType } from "./providers-registry";
import { Strapi } from "./strapi/strapi";
import { StrapiWebhooksProcessor } from "./strapi/strapi-webhooks-processor";
import { DatocmsProviderConfig } from "../configuration/schemas/datocms-provider.schema";
import { BuilderIo } from "./builder.io/builder-io";
import { BuilderIoWebhooksProcessor } from "./builder.io/builder-io-webhooks-processor";
import { BuilderIoBulkSyncProcessor } from "./builder.io/builder-io-bulk-sync-processor";
/**
* Almost-single source of new providers. Every time app will need to resolve a provider, it will use on of these factories.
* Frontend/UI must be dynamic, to render async chunk. Otherwise there is a circular dependency.
*/
export const ProvidersResolver = {
createBulkSyncProcessor(config: ProvidersConfig.AnyFullShape): BulkSyncProcessor {
switch (config.type) {
case "contentful":
return new ContentfulBulkSyncProcessor(config);
case "datocms":
return new DatocmsBulkSyncProcessor(config);
case "strapi":
return new StrapiBulkSyncProcessor(config);
case "builder.io": {
return new BuilderIoBulkSyncProcessor(config);
}
default:
throw new Error(`Unknown provider`);
}
},
getProviderInputSchema(type: CMSType) {
switch (type) {
case "contentful":
return ContentfulProviderConfig.Schema.Input;
case "datocms":
return DatocmsProviderConfig.Schema.Input;
case "strapi":
return StrapiProviderConfig.Schema.Input;
case "builder.io":
return BuilderIoProviderConfig.Schema.Input;
default: {
throw new Error("Failed to build input schema");
}
}
},
getProviderSchema(type: CMSType) {
switch (type) {
case "contentful":
return ContentfulProviderConfig.Schema.Full;
case "datocms":
return DatocmsProviderConfig.Schema.Full;
case "strapi":
return StrapiProviderConfig.Schema.Full;
case "builder.io":
return BuilderIoProviderConfig.Schema.Full;
default: {
throw new Error("Failed to build provdier schema");
}
}
},
createProviderMeta(type: CMSType | string): CMS {
switch (type) {
case "contentful": {
return Contentful;
}
case "datocms": {
return Datocms;
}
case "strapi": {
return Strapi;
}
case "builder.io": {
return BuilderIo;
}
default: {
throw new Error("Unknown provider");
}
}
},
createWebhooksProcessor(config: ProvidersConfig.AnyFullShape) {
switch (config.type) {
case "contentful": {
return new ContentfulWebhooksProcessor(config);
}
case "datocms": {
return new DatocmsWebhooksProcessor(config);
}
case "strapi": {
return new StrapiWebhooksProcessor(config);
}
case "builder.io": {
return new BuilderIoWebhooksProcessor(config);
}
default: {
throw new Error("Failed to build webhook processor.");
}
}
},
getEditProviderFormComponent: (
type: CMSType
): ComponentType<{
configId: string;
}> => {
switch (type) {
case "contentful": {
return dynamic(() =>
import("./contentful/contentful-config-form").then(
(module) => module.ContentfulConfigForm.EditVariant
)
);
}
case "datocms": {
return dynamic(() =>
import("./datocms/datocms-config-form").then(
(module) => module.DatoCMSConfigForm.EditVariant
)
);
}
case "strapi": {
return dynamic(() =>
import("./strapi/strapi-config-form").then(
(module) => module.StrapiConfigForm.EditVariant
)
);
}
case "builder.io": {
return dynamic(() =>
import("./builder.io/builder-io-config-form").then(
(module) => module.BuilderIoConfigForm.EditVariant
)
);
}
default: {
throw new Error("Provider form not registered");
}
}
},
getAddNewProviderFormComponent: (type: CMSType): ComponentType<{}> => {
switch (type) {
case "contentful": {
return dynamic(() =>
import("./contentful/contentful-config-form").then(
(module) => module.ContentfulConfigForm.AddVariant
)
);
}
case "datocms": {
return dynamic(() =>
import("./datocms/datocms-config-form").then(
(module) => module.DatoCMSConfigForm.AddVariant
)
);
}
case "strapi": {
return dynamic(() =>
import("./strapi/strapi-config-form").then((module) => module.StrapiConfigForm.AddVariant)
);
}
case "builder.io": {
return dynamic(() =>
import("./builder.io/builder-io-config-form").then(
(module) => module.BuilderIoConfigForm.AddVariant
)
);
}
default: {
throw new Error("Provider form not registered");
}
}
},
};

View file

@ -0,0 +1,51 @@
import { BulkImportProductFragment } from "../../../../generated/graphql";
import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
import { StrapiProviderConfig } from "../../configuration";
import { StrapiClient } from "./strapi-client";
export class StrapiBulkSyncProcessor implements BulkSyncProcessor {
constructor(private config: StrapiProviderConfig.FullShape) {}
async uploadProducts(
products: BulkImportProductFragment[],
hooks: BulkSyncProcessorHooks
): Promise<void> {
const client = new StrapiClient({
token: this.config.authToken,
url: this.config.url,
});
products.flatMap((product) =>
product.variants?.map((variant) => {
if (hooks.onUploadStart) {
hooks.onUploadStart({ variantId: variant.id });
}
return client
.upsertProduct({
configuration: this.config,
variant: {
id: variant.id,
name: variant.name,
channelListings: variant.channelListings,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
},
})
.then((r) => {
if (hooks.onUploadSuccess) {
hooks.onUploadSuccess({ variantId: variant.id });
}
})
.catch((e) => {
if (hooks.onUploadError) {
hooks.onUploadError({ variantId: variant.id, error: e });
}
});
})
);
}
}

View file

@ -0,0 +1,162 @@
import Strapi from "strapi-sdk-js";
import { StrapiProviderConfig } from "@/modules/configuration";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
import { z } from "zod";
import { createLogger } from "@saleor/apps-shared";
// partial response
const strapiFindOperationResult = z.object({
data: z.array(
z.object({
id: z.number(),
})
),
});
// todo error handling, tests
export class StrapiClient {
private client: Strapi;
private logger = createLogger({ name: "StrapiClient" });
constructor(options: { url: string; token: string }) {
this.client = new Strapi({
url: options.url,
axiosOptions: {
headers: {
Authorization: `Bearer ${options.token}`,
},
},
});
}
private getProducts(configuration: StrapiProviderConfig.FullShape, saleorVariantId: string) {
return this.client
.find(configuration.itemType, {
filters: {
[configuration.productVariantFieldsMapping.variantId]: {
$eq: saleorVariantId,
},
},
})
.then((response) => {
const parsedResponse = strapiFindOperationResult.parse(response);
if (!parsedResponse.data.length) {
return null; // product was not found, maybe it was not indexed first
}
return parsedResponse.data;
});
}
async deleteProduct({
configuration,
variant,
}: {
configuration: StrapiProviderConfig.FullShape;
variant: WebhookProductVariantFragment;
}) {
const strapiProducts = await this.getProducts(configuration, variant.id);
this.logger.trace({ strapiProducts }, "Fetched products from strapi that will be deleted");
if (!strapiProducts) {
return;
}
return Promise.all(
strapiProducts.map((strapiProduct) =>
this.client.delete(configuration.itemType, strapiProduct.id)
)
);
}
async uploadProduct({
configuration,
variant,
}: {
configuration: StrapiProviderConfig.FullShape;
variant: WebhookProductVariantFragment;
}) {
this.logger.trace({ variantId: variant.id }, "Will upload product variant");
try {
const result = await this.client.create(configuration.itemType, {
// todo extract to common mapping function
[configuration.productVariantFieldsMapping.variantName]: variant.name,
[configuration.productVariantFieldsMapping.variantId]: variant.id,
[configuration.productVariantFieldsMapping.productName]: variant.product.name,
[configuration.productVariantFieldsMapping.productId]: variant.product.id,
[configuration.productVariantFieldsMapping.channels]: variant.channelListings,
[configuration.productVariantFieldsMapping.productSlug]: variant.product.slug,
});
return result;
} catch (e) {
console.error(e);
}
}
async updateProduct({
configuration,
variant,
strapiProductId,
}: {
strapiProductId?: number;
configuration: StrapiProviderConfig.FullShape;
variant: WebhookProductVariantFragment;
}) {
let strapiProductIdsToUpdate = strapiProductId ? [strapiProductId] : null;
if (!strapiProductIdsToUpdate) {
const strapiProducts = await this.getProducts(configuration, variant.id);
if (!strapiProducts) {
return;
}
strapiProductIdsToUpdate = strapiProducts.map((strapiProduct) => strapiProduct.id);
}
this.logger.trace({ strapiProductIdsToUpdate }, "Will try to update strapi products");
try {
return Promise.all(
strapiProductIdsToUpdate.map((strapiProductId) => {
return this.client.update(configuration.itemType, strapiProductId, {
[configuration.productVariantFieldsMapping.variantName]: variant.name,
[configuration.productVariantFieldsMapping.variantId]: variant.id,
[configuration.productVariantFieldsMapping.productName]: variant.product.name,
[configuration.productVariantFieldsMapping.productId]: variant.product.id,
[configuration.productVariantFieldsMapping.channels]: variant.channelListings,
[configuration.productVariantFieldsMapping.productSlug]: variant.product.slug,
});
})
);
} catch (e) {
console.error(e);
}
}
async upsertProduct({
configuration,
variant,
}: {
configuration: StrapiProviderConfig.FullShape;
variant: WebhookProductVariantFragment;
}) {
const strapiProducts = await this.getProducts(configuration, variant.id);
this.logger.trace({ strapiProducts }, "Will try to upsert strapi products");
if (strapiProducts) {
return Promise.all(
strapiProducts.map((strapiProduct) => {
return this.updateProduct({ configuration, variant, strapiProductId: strapiProduct.id });
})
);
} else {
return this.uploadProduct({ configuration, variant });
}
}
}

View file

@ -0,0 +1,232 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useDashboardNotification } from "@saleor/apps-shared";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { Input } from "@saleor/react-hook-form-macaw";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { SaleorProviderFieldsMappingKeys, StrapiProviderConfig } from "../../configuration";
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
import { trpcClient } from "../../trpc/trpc-client";
import { ButtonsBox } from "../../ui/buttons-box";
type FormShape = Omit<StrapiProviderConfig.InputShape, "type">;
type PureFormProps = {
defaultValues: FormShape;
onSubmit(values: FormShape): void;
onDelete?(): void;
};
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
const {
control,
getValues,
setValue,
watch,
handleSubmit,
formState: { errors },
} = useForm<FormShape>({
defaultValues: defaultValues,
resolver: zodResolver(StrapiProviderConfig.Schema.Input.omit({ type: true })),
});
return (
<Box
as="form"
display={"grid"}
gap={4}
onSubmit={handleSubmit((vals) => {
onSubmit(vals);
})}
>
<Input
required
control={control}
name="configName"
label="Configuration name"
helperText="Meaningful name that will help you understand it later. E.g. 'staging' or 'prod' "
/>
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Provide connection details</Text>
<Input
required
control={control}
name="authToken"
label="Auth token"
helperText="Token with full permissions for the content type you want Saleor to store product."
/>
<Input
required
control={control}
name="url"
label="API Url"
helperText="Base api URL, usually without /api suffix"
placeholder="https://your-strapi-url.com"
/>
</Box>
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Configure fields mapping</Text>
<Input
label="Item type"
name="itemType"
control={control}
placeholder="saleor-products"
helperText="Plural name of the content type you want Saleor to send product to. E.g. 'products' or 'product-variants'"
/>
<Text as="p" variant="heading" size="small">
Map fields from Saleor to your Strapi schema.
</Text>
<Text as="p" marginTop={2} marginBottom={4}>
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should be
type of <Text variant="bodyStrong">JSON</Text>.
</Text>
<Box
marginBottom={4}
display="grid"
__gridTemplateColumns={"50% 50%"}
borderBottomWidth={1}
borderBottomStyle="solid"
borderColor="neutralHighlight"
padding={2}
>
<Text variant="caption">Saleor Field</Text>
<Text variant="caption">Contentful field</Text>
</Box>
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
// todo extract this table to component
<Box
display="grid"
__gridTemplateColumns={"50% 50%"}
padding={2}
key={saleorField}
alignItems="center"
>
<Box>
<Text as="p" variant="bodyStrong">
{printSaleorProductFields(saleorField)}
</Text>
<Text variant="caption">
{saleorField === "channels" ? "JSON field" : "Text field"}
</Text>
</Box>
<Input
size="small"
control={control}
name={`productVariantFieldsMapping.${saleorField}`}
label="Strapi Field"
/>
</Box>
))}
</Box>
<ButtonsBox>
{onDelete && (
<Button onClick={onDelete} variant="tertiary">
Delete
</Button>
)}
<Button type="submit">Save</Button>
</ButtonsBox>
</Box>
);
};
const AddFormVariant = () => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
push("/configuration");
},
});
return (
<PureForm
onSubmit={(values) => {
addProvider({
...values,
type: "strapi",
});
}}
defaultValues={{
configName: "",
authToken: "",
url: "",
itemType: "",
productVariantFieldsMapping: {
channels: "",
variantName: "",
productId: "",
productName: "",
productSlug: "",
variantId: "",
},
}}
/>
);
};
const EditFormVariant = (props: { configId: string }) => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { data } = trpcClient.providersConfigs.getOne.useQuery(
{
id: props.configId,
},
{
enabled: !!props.configId,
}
);
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
push("/configuration");
},
});
const { mutate: deleteProvider } = trpcClient.providersConfigs.deleteOne.useMutation({
onSuccess() {
notifySuccess("Success", "Removed configuration");
push("/configuration");
},
});
if (!data) {
return null;
}
if (data.type !== "strapi") {
throw new Error("Trying to fill strapi form with non strapi data");
}
return (
<PureForm
onDelete={() => {
deleteProvider({
id: props.configId,
});
}}
onSubmit={(values) => {
mutate({
...values,
type: "strapi",
id: props.configId,
});
}}
defaultValues={data}
/>
);
};
/*
* todo make the same with contentful
* todo improve copy
*/
export const StrapiConfigForm = {
PureVariant: PureForm,
AddVariant: AddFormVariant,
EditVariant: EditFormVariant,
};

View file

@ -0,0 +1,8 @@
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 208C0 109.948 0 60.9218 30.4609 30.4609C60.9218 0 109.948 0 208 0H392C490.052 0 539.078 0 569.539 30.4609C600 60.9218 600 109.948 600 208V392C600 490.052 600 539.078 569.539 569.539C539.078 600 490.052 600 392 600H208C109.948 600 60.9218 600 30.4609 569.539C0 539.078 0 490.052 0 392V208Z" fill="#4945FF"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M414 182H212V285H315V388H418V186C418 183.791 416.209 182 414 182Z" fill="white"/>
<rect x="311" y="285" width="4" height="4" fill="white"/>
<path d="M212 285H311C313.209 285 315 286.791 315 289V388H216C213.791 388 212 386.209 212 384V285Z" fill="#9593FF"/>
<path d="M315 388H418L318.414 487.586C317.154 488.846 315 487.953 315 486.172V388Z" fill="#9593FF"/>
<path d="M212 285H113.828C112.046 285 111.154 282.846 112.414 281.586L212 182V285Z" fill="#9593FF"/>
</svg>

After

Width:  |  Height:  |  Size: 934 B

View file

@ -0,0 +1,44 @@
import {
WebhookProductFragment,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import { StrapiProviderConfig } from "../../configuration/schemas/strapi-provider.schema";
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
import { StrapiClient } from "./strapi-client";
export class StrapiWebhooksProcessor implements ProductWebhooksProcessor {
private client: StrapiClient;
constructor(private config: StrapiProviderConfig.FullShape) {
this.client = new StrapiClient({ url: config.url, token: config.authToken });
}
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.client.updateProduct({ configuration: this.config, variant: productVariant });
}
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.client.uploadProduct({ configuration: this.config, variant: productVariant });
}
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
this.client.deleteProduct({ configuration: this.config, variant: productVariant });
}
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
await Promise.all(
(product.variants ?? []).map((variant) => {
return this.client.upsertProduct({
configuration: this.config,
variant: {
id: variant.id,
name: variant.name,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
},
});
})
);
}
}

View file

@ -0,0 +1,3 @@
import { router } from "../../trpc/trpc-server";
export const strapiRouter = router({});

View file

@ -0,0 +1,21 @@
import { Box, Text } from "@saleor/macaw-ui/next";
import logo from "./strapi-logo.svg";
import { CMSProviderMeta } from "../cms-provider-meta";
export const Strapi = {
formSideInfo: (
<Box>
<Text as="p" marginBottom={2}>
App will use Saleor Product Variant as a unique identifier. It will be saved as one of the
fields. Please ensure you map Variant ID to field that is UNIQUE in Strapi.
</Text>
<Text>Otherwise, products may be duplicated</Text>
</Box>
),
type: "strapi" as const,
logoUrl: logo.src as string,
displayName: "Strapi",
description:
"Strapi is the leading open-source headless CMS. 100% JavaScript and fully customizable.",
} satisfies CMSProviderMeta;

View file

@ -0,0 +1,8 @@
import { describe, it, expect } from "vitest";
import { generateId } from "./generate-id";
describe("generateId", () => {
it("generates a string", () => {
expect(typeof generateId() === "string").toBe(true);
});
});

View file

@ -0,0 +1,5 @@
import { randomBytes } from "crypto";
export const generateId = (length = 8) => {
return randomBytes(length).toString("hex");
};

View file

@ -0,0 +1,13 @@
// todo move to app sdk
export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => {
const { host, "x-forwarded-proto": xForwardedProto = "http" } = headers;
const xForwardedProtos = Array.isArray(xForwardedProto)
? xForwardedProto.join(",")
: xForwardedProto;
const protocols = xForwardedProtos.split(",");
// prefer https over other protocols
const protocol = protocols.find((el) => el === "https") || protocols[0];
return `${protocol}://${host}`;
};

View file

@ -0,0 +1,20 @@
dialog::backdrop {
display:none;
}
dialog {
z-index: 999;
}
.dialog-overlay {
z-index: 1;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(5px);
content: "";
position: fixed;
top:0;
bottom:0;
left:0;
right: 0;
}

View file

@ -0,0 +1,24 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui/next";
import { useEffect } from "react";
export function ThemeSynchronizer() {
const { appBridgeState } = useAppBridge();
const { setTheme } = useTheme();
useEffect(() => {
if (!setTheme || !appBridgeState?.theme) {
return;
}
if (appBridgeState.theme === "light") {
setTheme("defaultLight");
}
if (appBridgeState.theme === "dark") {
setTheme("defaultDark");
}
}, [appBridgeState?.theme, setTheme]);
return null;
}

View file

@ -0,0 +1,116 @@
import { verifyJWT } from "@saleor/app-sdk/verify-jwt";
import { middleware, procedure } from "./trpc-server";
import { TRPCError } from "@trpc/server";
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../saleor-app";
import { createGraphQLClient, logger } from "@saleor/apps-shared";
const attachAppToken = middleware(async ({ ctx, next }) => {
logger.debug("attachAppToken middleware");
if (!ctx.saleorApiUrl) {
logger.debug("ctx.saleorApiUrl not found, throwing");
throw new TRPCError({
code: "BAD_REQUEST",
message: "Missing saleorApiUrl in request",
});
}
const authData = await saleorApp.apl.get(ctx.saleorApiUrl);
if (!authData) {
logger.debug("authData not found, throwing 401");
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Missing auth data",
});
}
return next({
ctx: {
appToken: authData.token,
saleorApiUrl: authData.saleorApiUrl,
appId: authData.appId,
},
});
});
const validateClientToken = middleware(async ({ ctx, next, meta }) => {
logger.debug(
{
permissions: meta?.requiredClientPermissions,
},
"Calling validateClientToken middleware with permissions required"
);
if (!ctx.token) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Missing token in request. This middleware can be used only in frontend",
});
}
if (!ctx.appId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Missing appId in request. This middleware can be used after auth is attached",
});
}
if (!ctx.saleorApiUrl) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"Missing saleorApiUrl in request. This middleware can be used after auth is attached",
});
}
if (!ctx.ssr) {
try {
logger.debug("trying to verify JWT token from frontend");
logger.debug({ token: ctx.token ? `${ctx.token[0]}...` : undefined });
await verifyJWT({
appId: ctx.appId,
token: ctx.token,
saleorApiUrl: ctx.saleorApiUrl,
requiredPermissions: meta?.requiredClientPermissions ?? [],
});
} catch (e) {
logger.debug("JWT verification failed, throwing");
throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED");
}
}
return next({
ctx: {
...ctx,
saleorApiUrl: ctx.saleorApiUrl,
},
});
});
/**
* Construct common graphQL client and attach it to the context
*
* Can be used only if called from the frontend (react-query),
* otherwise jwks validation will fail (if createCaller used)
*
* TODO Rethink middleware composition to enable safe server-side router calls
*/
export const protectedClientProcedure = procedure
.use(attachAppToken)
.use(validateClientToken)
.use(async ({ ctx, next }) => {
const client = createGraphQLClient({ saleorApiUrl: ctx.saleorApiUrl, token: ctx.appToken });
return next({
ctx: {
apiClient: client,
appToken: ctx.appToken,
saleorApiUrl: ctx.saleorApiUrl,
},
});
});

View file

@ -0,0 +1,16 @@
import { channelProviderConnectionRouter } from "../channel-provider-connection/channel-provider-connection.router";
import { contentfulRouter } from "../providers/contentful/contentful.router";
import { datocmsRouter } from "../providers/datocms/datocms.router";
import { providersListRouter } from "../providers-listing/providers-list.router";
import { strapiRouter } from "../providers/strapi/strapi.router";
import { router } from "./trpc-server";
export const appRouter = router({
contentful: contentfulRouter,
datocms: datocmsRouter,
providersConfigs: providersListRouter,
channelsProvidersConnection: channelProviderConnectionRouter,
strapi: strapiRouter,
});
export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,44 @@
import { httpBatchLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { appBridgeInstance } from "../../pages/_app";
import { AppRouter } from "./trpc-app-router";
function getBaseUrl() {
if (typeof window !== "undefined") return "";
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export const trpcClient = createTRPCNext<AppRouter>({
config({ ctx }) {
return {
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers() {
const { token, saleorApiUrl } = appBridgeInstance?.getState() || {};
if (!token || !saleorApiUrl) {
console.error(
"Can't initialize tRPC client before establishing the App Bridge connection"
);
throw new Error("Token and Saleor API URL unknown");
}
return {
/**
* Attach headers from app to client requests, so tRPC can add them to context
*/
[SALEOR_AUTHORIZATION_BEARER_HEADER]: appBridgeInstance?.getState().token,
[SALEOR_API_URL_HEADER]: appBridgeInstance?.getState().saleorApiUrl,
};
},
}),
],
queryClientConfig: { defaultOptions: { queries: { refetchOnWindowFocus: false } } },
};
},
ssr: false,
});

View file

@ -0,0 +1,18 @@
import * as trpcNext from "@trpc/server/adapters/next";
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const";
import { inferAsyncReturnType } from "@trpc/server";
import { getBaseUrl } from "@/modules/shared/get-base-url";
export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => {
const baseUrl = getBaseUrl(req.headers);
return {
token: req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER] as string | undefined,
saleorApiUrl: req.headers[SALEOR_API_URL_HEADER] as string | undefined,
appId: undefined as undefined | string,
ssr: undefined as undefined | boolean,
baseUrl,
};
};
export type TrpcContext = inferAsyncReturnType<typeof createTrpcContext>;

View file

@ -0,0 +1,31 @@
import { initTRPC } from "@trpc/server";
import { TrpcContext } from "./trpc-context";
import { Permission } from "@saleor/app-sdk/types";
import { ZodError } from "zod";
interface Meta {
requiredClientPermissions?: Permission[];
updateWebhooks?: boolean;
}
const t = initTRPC
.context<TrpcContext>()
.meta<Meta>()
.create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.code === "BAD_REQUEST" && error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
export const router = t.router;
export const procedure = t.procedure;
export const middleware = t.middleware;

View file

@ -0,0 +1,43 @@
import { Breadcrumbs } from "@saleor/apps-ui/src/breadcrumbs";
import { Box, PropsWithBox, Text } from "@saleor/macaw-ui/next";
import { ComponentProps, ReactElement } from "react";
type Props = PropsWithBox<{
breadcrumbs?: ReactElement<ComponentProps<typeof Breadcrumbs.Item>>[];
text?: ReactElement | string;
}>;
export const AppHeader = ({
breadcrumbs,
text = "Connect Saleor Products to your favorite CMS platforms",
...props
}: Props) => {
const breadcrumbsRender = breadcrumbs ? (
<Breadcrumbs>
<Breadcrumbs.Item>Saleor App CMS</Breadcrumbs.Item>
<Breadcrumbs.Item href="/configuration">Configuration</Breadcrumbs.Item>
{breadcrumbs}
</Breadcrumbs>
) : (
<Breadcrumbs>
<Breadcrumbs.Item>Saleor App CMS</Breadcrumbs.Item>
<Breadcrumbs.Item>Configuration</Breadcrumbs.Item>
</Breadcrumbs>
);
return (
<Box
marginBottom={14}
borderBottomWidth={1}
borderBottomStyle="solid"
borderColor={"neutralHighlight"}
paddingBottom={6}
{...props}
>
{breadcrumbsRender}
<Text as="p" marginTop={4}>
{text}
</Text>
</Box>
);
};

View file

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

View file

@ -0,0 +1,5 @@
import { BoxProps, Box } from "@saleor/macaw-ui/next";
export const ButtonsBox = (props: BoxProps) => {
return <Box display={"flex"} justifyContent="flex-end" gap={4} {...props} />;
};

View file

@ -0,0 +1,21 @@
import { Box, BoxProps } from "@saleor/macaw-ui/next";
import { forwardRef } from "react";
export const Modal = ({ onClose, ...rest }: { onClose(): void } & BoxProps) => {
return (
<>
<Box
padding={6}
borderWidth={1}
borderRadius={4}
borderColor="neutralHighlight"
as="dialog"
__maxWidth="400px"
boxShadow={"modal"}
open
{...rest}
/>
<Box className="dialog-overlay" onClick={onClose} />
</>
);
};

View file

@ -0,0 +1,21 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { AppConfigMetadataManager, RootConfig } from "../configuration";
export type WebhookContext = Pick<RootConfig.Shape, "connections" | "providers">;
export const createWebhookConfigContext = async ({
authData,
}: {
authData: AuthData;
}): Promise<WebhookContext> => {
const configManager = AppConfigMetadataManager.createFromAuthData(authData);
const appConfig = await configManager.get();
const providers = appConfig.providers.getProviders();
const connections = appConfig.connections.getConnections();
return {
providers,
connections,
};
};

View file

@ -0,0 +1,8 @@
import { WebhookProductFragment, WebhookProductVariantFragment } from "../../../generated/graphql";
export interface ProductWebhooksProcessor {
onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void>;
onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void>;
onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void>;
onProductUpdated(product: WebhookProductFragment): Promise<void>;
}

View file

@ -0,0 +1,201 @@
import { describe, vi, it, expect, beforeEach } from "vitest";
import { ProductWebhooksProcessor } from "./product-webhooks-processor";
import { WebhooksProcessorsDelegator } from "./webhooks-processors-delegator";
import { AppConfig, ContentfulProviderConfig, DatocmsProviderConfig } from "../configuration";
import { WebhookProductVariantFragment } from "../../../generated/graphql";
const getMockContentfulInput = (): ContentfulProviderConfig.InputShape => {
return {
configName: "Test",
type: "contentful",
contentId: "test",
authToken: "test",
environment: "test",
productVariantFieldsMapping: {
channels: "channels",
productId: "productId",
productName: "productName",
productSlug: "productSlug",
variantId: "variantId",
variantName: "variantName",
},
spaceId: "test",
};
};
const getMockDatocmsInput = (): DatocmsProviderConfig.InputShape => {
return {
configName: "Test",
type: "datocms",
itemType: "test",
authToken: "test",
productVariantFieldsMapping: {
channels: "channels",
productId: "productId",
productName: "productName",
productSlug: "productSlug",
variantId: "variantId",
variantName: "variantName",
},
};
};
const mockWebookProcessor: ProductWebhooksProcessor = {
onProductUpdated: vi.fn(),
onProductVariantCreated: vi.fn(),
onProductVariantDeleted: vi.fn(),
onProductVariantUpdated: vi.fn(),
};
const prepareFilledAppConfig = () => {
const appConfig = new AppConfig();
appConfig.providers.addProvider(getMockContentfulInput());
appConfig.providers.addProvider(getMockDatocmsInput());
appConfig.connections.addConnection({
providerId: appConfig.providers.getProviders()[0].id,
channelSlug: "test",
providerType: "contentful",
});
appConfig.connections.addConnection({
providerId: appConfig.providers.getProviders()[1].id,
channelSlug: "test",
providerType: "contentful",
});
/**
* This one will be ignored by delegator for update and created, because mocks are set for slug "test"
*/
appConfig.connections.addConnection({
providerId: appConfig.providers.getProviders()[1].id,
channelSlug: "test-2",
providerType: "contentful",
});
return appConfig;
};
const getMockProductVariantWebhookFragment = (): WebhookProductVariantFragment => {
return {
id: "variant-id",
name: "variant-name",
product: {
id: "product-id",
name: "product-name",
slug: "product-slug",
},
channelListings: [
{
channel: {
id: "channel-id",
slug: "test",
},
},
],
};
};
describe("WebhooksProcessorsDelegator", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("Variant Created Operations", () => {
it("Calls processor CREATE method every for every connection that matches channel slug", async () => {
const appConfig = prepareFilledAppConfig();
const delegator = new WebhooksProcessorsDelegator({
injectProcessorFactory: () => mockWebookProcessor,
context: {
connections: appConfig.connections.getConnections(),
providers: appConfig.providers.getProviders(),
},
});
await delegator.delegateVariantCreatedOperations(getMockProductVariantWebhookFragment());
expect(mockWebookProcessor.onProductVariantCreated).toHaveBeenCalledTimes(2);
});
});
describe("Variant Updated Operations", () => {
it("Calls processor UPDATE method every for every connection that matches channel slug", async () => {
const appConfig = prepareFilledAppConfig();
const delegator = new WebhooksProcessorsDelegator({
injectProcessorFactory: () => mockWebookProcessor,
context: {
connections: appConfig.connections.getConnections(),
providers: appConfig.providers.getProviders(),
},
});
await delegator.delegateVariantUpdatedOperations(getMockProductVariantWebhookFragment());
expect(mockWebookProcessor.onProductVariantUpdated).toHaveBeenCalledTimes(2);
});
});
describe("Variant Deleted Operations", () => {
it("Calls processor DELETE method for every connection, even if channel slug does not match, so CMS is not left with orphans", async () => {
const appConfig = prepareFilledAppConfig();
const delegator = new WebhooksProcessorsDelegator({
injectProcessorFactory: () => mockWebookProcessor,
context: {
connections: appConfig.connections.getConnections(),
providers: appConfig.providers.getProviders(),
},
});
await delegator.delegateVariantDeletedOperations(getMockProductVariantWebhookFragment());
/**
* Will be called 3 times
*/
expect(mockWebookProcessor.onProductVariantDeleted).toHaveBeenCalledTimes(3);
});
});
describe("Product Updated Operations", () => {
it("Calls processor UPDATE PRODUCT method for every connection, even if channel slug does not match, so CMS is not left with orphans", async () => {
const appConfig = prepareFilledAppConfig();
const delegator = new WebhooksProcessorsDelegator({
injectProcessorFactory: () => mockWebookProcessor,
context: {
connections: appConfig.connections.getConnections(),
providers: appConfig.providers.getProviders(),
},
});
await delegator.delegateProductUpdatedOperations({
id: "product-id",
name: "product-name",
slug: "product-slug",
channelListings: [
{
channel: {
id: "channel-id",
slug: "test",
},
id: "channel-listing-id",
},
],
variants: [
{
id: "variant-id",
name: "variant-name",
},
],
});
/**
* Will be called 3 times
*/
expect(mockWebookProcessor.onProductUpdated).toHaveBeenCalledTimes(3);
});
});
});

Some files were not shown because too many files have changed in this diff Show more