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:
parent
790a47ee08
commit
70cb741f88
134 changed files with 33785 additions and 148 deletions
16
.changeset/fair-cats-lick.md
Normal file
16
.changeset/fair-cats-lick.md
Normal 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
|
7
.github/dependabot.yaml
vendored
7
.github/dependabot.yaml
vendored
|
@ -46,6 +46,13 @@ updates:
|
|||
interval: "weekly"
|
||||
commit-message:
|
||||
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"
|
||||
directory: apps/crm
|
||||
open-pull-requests-limit: 1
|
||||
|
|
2
.github/labeler.yml
vendored
2
.github/labeler.yml
vendored
|
@ -7,5 +7,5 @@
|
|||
"App: Slack": "apps/slack/**/*"
|
||||
"App: Taxes": "apps/taxes/**/*"
|
||||
"App: Emails & Messages": "apps/emails-and-messages/**/*"
|
||||
"App: CMS": "apps/cms/**/*"
|
||||
"App: CMS": ["apps/cms/**/*", "apps/cms-v2/**/*"]
|
||||
"App: CRM": "apps/crm/**/*"
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -41,3 +41,5 @@ apps/**/generated
|
|||
.eslintcache
|
||||
|
||||
.sentryclirc
|
||||
|
||||
.vscode/
|
18
apps/cms-v2/.env.example
Normal file
18
apps/cms-v2/.env.example
Normal 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=
|
4
apps/cms-v2/.eslintrc.json
Normal file
4
apps/cms-v2/.eslintrc.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"root": true,
|
||||
"extends": ["saleor"]
|
||||
}
|
19
apps/cms-v2/.graphqlrc.yml
Normal file
19
apps/cms-v2/.graphqlrc.yml
Normal 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
|
19
apps/cms-v2/graphql/fragments/BulkImportProduct.graphql
Normal file
19
apps/cms-v2/graphql/fragments/BulkImportProduct.graphql
Normal file
|
@ -0,0 +1,19 @@
|
|||
fragment BulkImportProduct on Product {
|
||||
id
|
||||
name
|
||||
slug
|
||||
variants {
|
||||
id
|
||||
name
|
||||
channelListings {
|
||||
channel {
|
||||
id
|
||||
slug
|
||||
}
|
||||
price {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
26
apps/cms-v2/graphql/fragments/WebhookProduct.graphql
Normal file
26
apps/cms-v2/graphql/fragments/WebhookProduct.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
19
apps/cms-v2/graphql/fragments/WebhookProductVariant.graphql
Normal file
19
apps/cms-v2/graphql/fragments/WebhookProductVariant.graphql
Normal file
|
@ -0,0 +1,19 @@
|
|||
fragment WebhookProductVariant on ProductVariant {
|
||||
id
|
||||
name
|
||||
product {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
||||
channelListings {
|
||||
channel {
|
||||
id
|
||||
slug
|
||||
}
|
||||
price {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
10
apps/cms-v2/graphql/mutations/UpdateAppMetadata.graphql
Normal file
10
apps/cms-v2/graphql/mutations/UpdateAppMetadata.graphql
Normal file
|
@ -0,0 +1,10 @@
|
|||
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||
updatePrivateMetadata(id: $id, input: $input) {
|
||||
item {
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
7
apps/cms-v2/graphql/queries/FetchChannels.graphql
Normal file
7
apps/cms-v2/graphql/queries/FetchChannels.graphql
Normal file
|
@ -0,0 +1,7 @@
|
|||
query FetchChannels {
|
||||
channels {
|
||||
id
|
||||
slug
|
||||
name
|
||||
}
|
||||
}
|
13
apps/cms-v2/graphql/queries/FetchProductsPaginated.graphql
Normal file
13
apps/cms-v2/graphql/queries/FetchProductsPaginated.graphql
Normal file
|
@ -0,0 +1,13 @@
|
|||
query FetchProductsPaginated($channel: String, $after: String) {
|
||||
products(first: 100, channel: $channel, after: $after) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
node {
|
||||
...BulkImportProduct
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
26597
apps/cms-v2/graphql/schema.graphql
Normal file
26597
apps/cms-v2/graphql/schema.graphql
Normal file
File diff suppressed because it is too large
Load diff
0
apps/cms-v2/graphql/subscriptions/.gitkeep
Normal file
0
apps/cms-v2/graphql/subscriptions/.gitkeep
Normal file
5
apps/cms-v2/next-env.d.ts
vendored
Normal file
5
apps/cms-v2/next-env.d.ts
vendored
Normal 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.
|
43
apps/cms-v2/next.config.js
Normal file
43
apps/cms-v2/next.config.js
Normal 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
70
apps/cms-v2/package.json
Normal 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
BIN
apps/cms-v2/public/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.1 KiB |
37
apps/cms-v2/sentry.client.config.ts
Normal file
37
apps/cms-v2/sentry.client.config.ts
Normal 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}`,
|
||||
});
|
21
apps/cms-v2/sentry.edge.config.ts
Normal file
21
apps/cms-v2/sentry.edge.config.ts
Normal 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}`,
|
||||
});
|
20
apps/cms-v2/sentry.server.config.ts
Normal file
20
apps/cms-v2/sentry.server.config.ts
Normal 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}`,
|
||||
});
|
14
apps/cms-v2/src/modules/bulk-sync/bulk-sync-processor.ts
Normal file
14
apps/cms-v2/src/modules/bulk-sync/bulk-sync-processor.ts
Normal 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>;
|
||||
}
|
92
apps/cms-v2/src/modules/bulk-sync/bulk-sync-section.tsx
Normal file
92
apps/cms-v2/src/modules/bulk-sync/bulk-sync-section.tsx
Normal 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>
|
||||
);
|
||||
};
|
173
apps/cms-v2/src/modules/bulk-sync/bulk-sync-view.tsx
Normal file
173
apps/cms-v2/src/modules/bulk-sync/bulk-sync-view.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
})
|
||||
);
|
||||
},
|
||||
[]
|
||||
),
|
||||
};
|
||||
};
|
65
apps/cms-v2/src/modules/bulk-sync/use-fetch-all-products.tsx
Normal file
65
apps/cms-v2/src/modules/bulk-sync/use-fetch-all-products.tsx
Normal 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 };
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -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);
|
||||
}),
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
import { Text } from "@saleor/macaw-ui/next";
|
||||
|
||||
export const ChanelProviderConnectionsSectionHeader = () => (
|
||||
<Text marginBottom={4} as="h2" variant="heading">
|
||||
Channels Connections
|
||||
</Text>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
148
apps/cms-v2/src/modules/configuration/app-config.test.ts
Normal file
148
apps/cms-v2/src/modules/configuration/app-config.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
115
apps/cms-v2/src/modules/configuration/app-config.ts
Normal file
115
apps/cms-v2/src/modules/configuration/app-config.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
13
apps/cms-v2/src/modules/configuration/index.ts
Normal file
13
apps/cms-v2/src/modules/configuration/index.ts
Normal 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";
|
101
apps/cms-v2/src/modules/configuration/metadata-manager.ts
Normal file
101
apps/cms-v2/src/modules/configuration/metadata-manager.ts
Normal 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),
|
||||
});
|
||||
};
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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",
|
||||
];
|
|
@ -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,
|
||||
};
|
||||
}
|
21
apps/cms-v2/src/modules/graphql/GraphQLProvider.tsx
Normal file
21
apps/cms-v2/src/modules/graphql/GraphQLProvider.tsx
Normal 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} />;
|
||||
}
|
|
@ -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);
|
||||
}),
|
||||
});
|
92
apps/cms-v2/src/modules/providers-listing/providers-list.tsx
Normal file
92
apps/cms-v2/src/modules/providers-listing/providers-list.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
22
apps/cms-v2/src/modules/providers/builder.io/builder-io.tsx
Normal file
22
apps/cms-v2/src/modules/providers/builder.io/builder-io.tsx
Normal 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;
|
|
@ -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 |
9
apps/cms-v2/src/modules/providers/cms-provider-meta.ts
Normal file
9
apps/cms-v2/src/modules/providers/cms-provider-meta.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { ReactElement } from "react";
|
||||
|
||||
export interface CMSProviderMeta {
|
||||
type: string;
|
||||
displayName: string;
|
||||
logoUrl: string;
|
||||
description: string;
|
||||
formSideInfo?: ReactElement;
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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 |
|
@ -0,0 +1,7 @@
|
|||
import { pRateLimit } from "p-ratelimit";
|
||||
|
||||
export const contentfulRateLimiter = pRateLimit({
|
||||
interval: 1000,
|
||||
rate: 2,
|
||||
concurrency: 2,
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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" });
|
||||
});
|
||||
}),
|
||||
});
|
13
apps/cms-v2/src/modules/providers/contentful/contentful.tsx
Normal file
13
apps/cms-v2/src/modules/providers/contentful/contentful.tsx
Normal 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;
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
181
apps/cms-v2/src/modules/providers/datocms/datocms-client.ts
Normal file
181
apps/cms-v2/src/modules/providers/datocms/datocms-client.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
44
apps/cms-v2/src/modules/providers/datocms/datocms.router.ts
Normal file
44
apps/cms-v2/src/modules/providers/datocms/datocms.router.ts
Normal 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,
|
||||
});
|
||||
}),
|
||||
});
|
1
apps/cms-v2/src/modules/providers/datocms/datocms.svg
Normal file
1
apps/cms-v2/src/modules/providers/datocms/datocms.svg
Normal 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 |
21
apps/cms-v2/src/modules/providers/datocms/datocms.tsx
Normal file
21
apps/cms-v2/src/modules/providers/datocms/datocms.tsx
Normal 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;
|
12
apps/cms-v2/src/modules/providers/providers-registry.ts
Normal file
12
apps/cms-v2/src/modules/providers/providers-registry.ts
Normal 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;
|
187
apps/cms-v2/src/modules/providers/providers-resolver.tsx
Normal file
187
apps/cms-v2/src/modules/providers/providers-resolver.tsx
Normal 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");
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
162
apps/cms-v2/src/modules/providers/strapi/strapi-client.ts
Normal file
162
apps/cms-v2/src/modules/providers/strapi/strapi-client.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
232
apps/cms-v2/src/modules/providers/strapi/strapi-config-form.tsx
Normal file
232
apps/cms-v2/src/modules/providers/strapi/strapi-config-form.tsx
Normal 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,
|
||||
};
|
8
apps/cms-v2/src/modules/providers/strapi/strapi-logo.svg
Normal file
8
apps/cms-v2/src/modules/providers/strapi/strapi-logo.svg
Normal 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 |
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import { router } from "../../trpc/trpc-server";
|
||||
|
||||
export const strapiRouter = router({});
|
21
apps/cms-v2/src/modules/providers/strapi/strapi.tsx
Normal file
21
apps/cms-v2/src/modules/providers/strapi/strapi.tsx
Normal 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;
|
8
apps/cms-v2/src/modules/shared/generate-id.test.ts
Normal file
8
apps/cms-v2/src/modules/shared/generate-id.test.ts
Normal 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);
|
||||
});
|
||||
});
|
5
apps/cms-v2/src/modules/shared/generate-id.ts
Normal file
5
apps/cms-v2/src/modules/shared/generate-id.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { randomBytes } from "crypto";
|
||||
|
||||
export const generateId = (length = 8) => {
|
||||
return randomBytes(length).toString("hex");
|
||||
};
|
13
apps/cms-v2/src/modules/shared/get-base-url.ts
Normal file
13
apps/cms-v2/src/modules/shared/get-base-url.ts
Normal 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}`;
|
||||
};
|
20
apps/cms-v2/src/modules/theme/styles.css
Normal file
20
apps/cms-v2/src/modules/theme/styles.css
Normal 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;
|
||||
}
|
24
apps/cms-v2/src/modules/theme/theme-synchronizer.tsx
Normal file
24
apps/cms-v2/src/modules/theme/theme-synchronizer.tsx
Normal 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;
|
||||
}
|
116
apps/cms-v2/src/modules/trpc/protected-client-procedure.ts
Normal file
116
apps/cms-v2/src/modules/trpc/protected-client-procedure.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
16
apps/cms-v2/src/modules/trpc/trpc-app-router.ts
Normal file
16
apps/cms-v2/src/modules/trpc/trpc-app-router.ts
Normal 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;
|
44
apps/cms-v2/src/modules/trpc/trpc-client.ts
Normal file
44
apps/cms-v2/src/modules/trpc/trpc-client.ts
Normal 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,
|
||||
});
|
18
apps/cms-v2/src/modules/trpc/trpc-context.ts
Normal file
18
apps/cms-v2/src/modules/trpc/trpc-context.ts
Normal 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>;
|
31
apps/cms-v2/src/modules/trpc/trpc-server.ts
Normal file
31
apps/cms-v2/src/modules/trpc/trpc-server.ts
Normal 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;
|
43
apps/cms-v2/src/modules/ui/app-header.tsx
Normal file
43
apps/cms-v2/src/modules/ui/app-header.tsx
Normal 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>
|
||||
);
|
||||
};
|
43
apps/cms-v2/src/modules/ui/app-section.tsx
Normal file
43
apps/cms-v2/src/modules/ui/app-section.tsx
Normal 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>
|
||||
);
|
||||
};
|
5
apps/cms-v2/src/modules/ui/buttons-box.tsx
Normal file
5
apps/cms-v2/src/modules/ui/buttons-box.tsx
Normal 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} />;
|
||||
};
|
21
apps/cms-v2/src/modules/ui/modal.tsx
Normal file
21
apps/cms-v2/src/modules/ui/modal.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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>;
|
||||
}
|
|
@ -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
Loading…
Reference in a new issue