Add CMS hub app (#262)

* Add CMS hub app

* Cleanup

* Refactor CMS product variants webhooks

* Test utils 1

* Remove legacy code

* Add .env example

* Update types

* Remove unused code

* Fix cms client opertions settings test

* Fix pnpm-lock file

* Bump typescript to 4.9, support satisfies expressions
This commit is contained in:
Dawid 2023-03-16 11:17:00 +01:00 committed by GitHub
parent e93a4dc1c8
commit a07f337992
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
102 changed files with 35438 additions and 19 deletions

9
apps/cms/.env.example Normal file
View file

@ -0,0 +1,9 @@
# 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=
REST_APL_ENDPOINT=
REST_APL_TOKEN=

4
apps/cms/.eslintrc Normal file
View file

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

20
apps/cms/.graphqlrc.yml Normal file
View file

@ -0,0 +1,20 @@
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
- urql-introspection
- typescript-urql:
documentVariablePrefix: "Untyped"
fragmentVariablePrefix: "Untyped"
- typed-document-node
generated/schema.graphql:
plugins:
- schema-ast

142
apps/cms/CONTRIBUTING.md Normal file
View file

@ -0,0 +1,142 @@
# Contributing
## Overview
CMS Hub connects Saleor to a variety of CMSes. Each integration requires **a provider** that implements an interface for supported operations.
Currently, CMS Hub allows to perform operations on **products** (specifically, exporting them from Saleor to CMS). That means you need to implement creating, updating, and deleting a product through the API of the CMS you are integrating with.
CMS Hub will:
- execute the actions on the right webhook
- extract the product data and pass it to an provider
- provide some integration logic (e.g. add the product id from the CMS to the product metadata)
- create a UI and manage your integration's tokens based on supplied config
## Adding a provider
If you want to add a provider for a new CMS, here is what you have to do:
1. Go to `/src/lib/cms/config.ts`.
2. Update the `providersConfig` variable with basic information about your provider: `name`, `label` and `tokens`:
```ts
// src/lib/cms/config.ts
export const providersConfig = {
contentful: {
...
},
payload: {
name: "payload",
label: "Payload",
tokens: [
{ name: "baseUrl", label: "Base Url" },
{ name: "token", label: "Token", required: true },
],
},
} satisfies ProvidersConfig;
```
> `tokens` is an array that contains names of all the tokens your provider requires. The names will be used to:
>
> - generate config type (see: `CreateProviderConfig` in step 4)
> - generate an integration configuration view (see: `src/views/configuration.tsx`)
> - store & fetch the tokens from the settings API (see: `src/pages/api/settings.ts`)
3. Add configuration fields to schema:
```ts
// src/lib/cms/config/providers.ts
...
export type PayloadConfig = CreateProviderConfig<"payload">; // Generates the type for a config based on the configuration in `src/lib/cms/providers/index.ts`.
...
export const payloadConfigSchema: z.ZodType<PayloadConfig> = z.object({
enabled: z.boolean(),
...
}); // Creates a schema for validating the config using [zod](https://github.com/colinhacks/zod).
export const providersSchemaSet = {
...
payload: payloadConfigSchema,
};
```
3. Create a file following the naming convention `[cmsName].ts`, e.g.: `src/lib/cms/providers/payload.ts`. This file will contain all the provider logic. You can implement it as you like, as long as it follows the expected format.
4. Start with importing all the helper functions and types:
```ts
// src/lib/cms/providers/payload.ts
import { createProvider } from "./create";
import {
CreateOperations,
CreateProviderConfig,
} from "../types";
import { PayloadConfig, payloadConfigSchema } from "../config";
const payloadOperations: CreateOperations<PayloadConfig> = (config) => {
...
} // This is where you write logic for all the supported operations (e.g. creating a product). This function runs only if the config was successfully validated.
export default createProvider(payloadOperations, payloadConfigSchema); // `createProvider` combines everything together.
```
5. Implement the operations:
```ts
// src/lib/cms/providers/payload.ts
...
const payloadOperations: CreateOperations<PayloadConfig> = (config) => {
return {
createProduct: async (payload) => ...
}
}
```
Each operation accepts a payload (sent from the webhook) and should return a promise. CMS Hub does not verify the value returned from the CMS.
> **Important!**
>
> The return type of the `createProduct` method is different than the rest. It must return **a promise** of:
>
> ```ts
> { ok: true; data: { id: string } } // the success state
> | { ok: false; error: string } // the failure state
> ```
>
> We need it to synchronise the Saleor product with the CMS product. The product id returned from the CMS is used to update the product metadata in Saleor.
6. Import your provider at the top of the `src/lib/cms/providers/index.ts` file:
```ts
import contentful from "./contentful";
...
import payload from "./payload";
```
7. Add it to the `cmsProviders` variable.
8. Go to `src/lib/cms/client.ts`. Add a `case` for your provider inside the `switch` statement in `createCmsClient` function:
```ts
switch (provider) {
case "strapi": {
return cmsProviders.strapi.create(config.strapi);
}
...
case "payload": {
return cmsProviders.payload.create(config.payload);
}
...
}
```
---
And that's it, you are golden! 🎖️

44
apps/cms/README.md Normal file
View file

@ -0,0 +1,44 @@
![CMS](https://user-images.githubusercontent.com/249912/71523206-4e45f800-28c8-11ea-84ba-345a9bfc998a.png)
<div align="center">
<h1>CMS</h1>
</div>
# Overview
## What is it?
CMS Hub is a Saleor app that exports products from Saleor to several popular CMSes.
Here is a list of currently supported CMSes and their configuration guides:
- [Strapi](docs/strapi.md)
- [Contentful](docs/contentful.md)
- [DatoCMS](docs/datocms.md)
## How does it work?
1. `cms` listens to Saleor product variant events through [webhooks](https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks).
2. When an event is triggered, we extract the product data and pass it to the CMS Client.
3. CMS Client checks what CMS you picked, transforms the data to the format the CMS expects, and sends it over.
### Assumptions
Currently, the CMS Hub does not support mapping Saleor fields to your CMS fields. We assume that products in your CMS have the following fields:
- strings fields: `saleor_id`, `name`, `product_id`, `product_name`, `product_slug`,
- JSON fileds: `channels`.
## How to use it?
1. Install the application in your Dashboard and open it.
2. Go to Providers.
3. Add CMS provider instance with proper configuration.
4. Go to Channels.
5. Turn on provider instances for desired channels.
6. Go to Products in Dashboard.
7. Add, edit or remove product variant to see it is added, updated or removed in configured CMS provider accordingly.
## How can I contribute?
See [CONTRIBUTING.md](./CONTRIBUTING.md).

View file

@ -0,0 +1,14 @@
# Contentful
## Configuration
Contentful integration requires several configuration tokens. You should enter them in the _Apps -> CMS_ configuration view.
Here is the list of the tokens and instructions on how to obtain them:
- `baseUrl`: the API URL. For Contentful, it's `https://api.contentful.com`.
- `token`: the authorization token. For instructions on how to create one for CMS Hub, please go to _API keys in the Contentful web app_ section in the [Contentful "Authentication" documentation](https://www.contentful.com/developers/docs/references/authentication/).
- `environment`: id of the Contentful environment you use. To find it, go to _Settings -> Environments_ in the Contentful dashboard.
- `spaceId`: id of the Contentful space. To find it, go to _Settings -> General settings_ in the Contentful dashboard.
- `contentId`: the id of the content model. To obtain it, go to _Content model_ and to the view of a single product in your Contentful dashboard. Your URL may look something like: "https://app.contentful.com/spaces/xxxx/content_types/product/fields". Then, look to the right side of the screen. You will find a copyable "CONTENT TYPE ID" box there.
- `locale`: the localization code for your content. E.g.: `en-US`.

12
apps/cms/docs/datocms.md Normal file
View file

@ -0,0 +1,12 @@
# DatoCMS
## Configuration
DatoCMS integration requires several configuration tokens. You should enter them in the _Apps -> CMS_ configuration view.
Here is the list of the tokens and instructions on how to obtain them
- `baseUrl`: the optional URL to your DatoCMS project. If you leave this blank, this URL will be inferred from your API Token.
- `token`: the API token with access to Content Management API. You can find this in your DatoCMS project settings.
- `itemTypeId`: item type ID (number). You can find this as Model ID in your DatoCMS product variant model settings, by clicking "Edit model".
- `environment`: optional environment name. If you leave this blank, default environment will be used. You can find this in your DatoCMS project settings.

10
apps/cms/docs/strapi.md Normal file
View file

@ -0,0 +1,10 @@
# Strapi
## Configuration
Strapi integration requires several configuration tokens. You should enter them in the _Apps -> CMS_ configuration view.
Here is the list of the tokens and instructions on how to obtain them
- `baseUrl`: the API URL. It's the address of your Strapi API. For local Strapi development it will be: `http://localhost:XXXX`.
- `token`: the authorization token. For instructions on how to create one for CMS Hub, please go to the [Strapi "Managing API tokens" documentation](https://docs.strapi.io/user-docs/latest/settings/managing-global-settings.html#managing-api-tokens).

View file

View file

@ -0,0 +1,36 @@
fragment WebhookProductVariant on ProductVariant {
id
name
sku
product {
id
name
slug
media {
url
}
channelListings {
id
channel {
id
slug
}
isPublished
}
}
channelListings {
id
channel {
id
slug
}
price {
amount
currency
}
}
metadata {
key
value
}
}

View file

View file

@ -0,0 +1,9 @@
mutation DeleteMetadata($id: ID!, $keys: [String!]!) {
deleteMetadata(id: $id, keys: $keys) {
errors {
field
message
code
}
}
}

View file

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

View file

@ -0,0 +1,9 @@
mutation UpdateMetadata($id: ID!, $input: [MetadataInput!]!) {
updateMetadata(id: $id, input: $input) {
errors {
field
message
code
}
}
}

View file

View file

@ -0,0 +1,9 @@
query FetchAppDetails {
app {
id
privateMetadata {
key
value
}
}
}

View file

@ -0,0 +1,8 @@
query GetProduct($id: ID!) {
product(id: $id) {
slug
media {
url
}
}
}

File diff suppressed because it is too large Load diff

View file

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

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

4
apps/cms/next.config.js Normal file
View file

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
};

64
apps/cms/package.json Normal file
View file

@ -0,0 +1,64 @@
{
"name": "saleor-app-cms",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
"build": "pnpm generate && next build",
"start": "next start",
"lint": "next lint",
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"generate": "graphql-codegen",
"test": "vitest"
},
"saleor": {
"schemaVersion": "3.7"
},
"dependencies": {
"@datocms/cma-client-node": "^1.2.4",
"@hookform/resolvers": "^2.9.10",
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
"@material-ui/lab": "4.0.0-alpha.61",
"@saleor/app-sdk": "0.34.0",
"@saleor/macaw-ui": "^0.6.7",
"@urql/exchange-auth": "^1.0.0",
"clsx": "^1.2.1",
"graphql": "^16.6.0",
"graphql-tag": "^2.12.6",
"next": "13.2",
"pino": "^8.8.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.39.1",
"urql": "^3.0.3",
"usehooks-ts": "^2.9.1",
"uuid": "^9.0.0",
"zod": "^3.19.1"
},
"devDependencies": {
"@graphql-codegen/cli": "2.13.3",
"@graphql-codegen/introspection": "2.2.1",
"@graphql-codegen/typed-document-node": "^2.3.3",
"@graphql-codegen/typescript": "2.7.3",
"@graphql-codegen/typescript-operations": "2.5.3",
"@graphql-codegen/typescript-urql": "^3.7.0",
"@graphql-codegen/urql-introspection": "2.2.1",
"@graphql-typed-document-node/core": "^3.1.1",
"@testing-library/react": "^13.4.0",
"@types/node": "^18.8.1",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@types/uuid": "^8.3.4",
"eslint": "8.25.0",
"eslint-config-next": "12.3.1",
"eslint-config-prettier": "^8.5.0",
"prettier": "^2.7.1",
"typescript": "4.9",
"vitest": "^0.29.2"
},
"lint-staged": {
"*.{js,ts,tsx}": "eslint --cache --fix",
"*.{js,ts,tsx,css,md,json}": "prettier --write"
}
}

37
apps/cms/saleor-app.ts Normal file
View file

@ -0,0 +1,37 @@
import { APL, FileAPL, SaleorCloudAPL, UpstashAPL } from "@saleor/app-sdk/APL";
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
/**
* By default, auth data are stored in the `.auth-data.json` (FileAPL).
* For multi-tenant applications and deployments please use UpstashAPL.
*
* To read more about storing auth data, read the
* [APL documentation](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)
*/
let apl: APL;
const aplType = process.env.APL ?? "file";
switch (aplType) {
case "upstash":
// Require `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables
apl = new UpstashAPL();
break;
case "saleor-cloud": {
if (!process.env.REST_APL_ENDPOINT || !process.env.REST_APL_TOKEN) {
throw new Error("Rest APL is not configured - missing env variables. Check saleor-app.ts");
}
apl = new SaleorCloudAPL({
resourceUrl: process.env.REST_APL_ENDPOINT as string,
token: process.env.REST_APL_TOKEN,
});
break;
}
default:
apl = new FileAPL();
}
export const saleorApp = new SaleorApp({
apl,
});

View file

@ -0,0 +1,8 @@
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.3333 0.5C22.53 0.5 23.5 1.47005 23.5 2.66667V8C23.5 8.46024 23.1269 8.83333 22.6667 8.83333C22.2064 8.83333 21.8333 8.46024 21.8333 8V2.66667C21.8333 2.39052 21.6095 2.16667 21.3333 2.16667H2.66667C2.39053 2.16667 2.16667 2.39052 2.16667 2.66667V17.3333C2.16667 17.6095 2.39053 17.8333 2.66667 17.8333H7.33333C7.79357 17.8333 8.16667 18.2064 8.16667 18.6667C8.16667 19.1269 7.79357 19.5 7.33333 19.5H2.66667C1.47005 19.5 0.5 18.5299 0.5 17.3333V2.66667C0.5 1.47005 1.47005 0.5 2.66667 0.5H21.3333Z" fill="white"/>
<path d="M5.33333 6.66667C6.06971 6.66667 6.66667 6.06971 6.66667 5.33333C6.66667 4.59695 6.06971 4 5.33333 4C4.59695 4 4 4.59695 4 5.33333C4 6.06971 4.59695 6.66667 5.33333 6.66667Z" fill="white"/>
<path d="M10.6667 5.33333C10.6667 6.06971 10.0697 6.66667 9.33333 6.66667C8.59695 6.66667 8 6.06971 8 5.33333C8 4.59695 8.59695 4 9.33333 4C10.0697 4 10.6667 4.59695 10.6667 5.33333Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 10.5378C18.8034 10.5378 17.8333 11.5078 17.8333 12.7044V15.3711C17.8333 16.5677 18.8034 17.5378 20 17.5378H22.6667C23.8633 17.5378 24.8333 16.5677 24.8333 15.3711V12.7044C24.8333 11.5078 23.8633 10.5378 22.6667 10.5378H20ZM19.5 12.7044C19.5 12.4283 19.7239 12.2044 20 12.2044H22.6667C22.9428 12.2044 23.1667 12.4283 23.1667 12.7044V15.3711C23.1667 15.6472 22.9428 15.8711 22.6667 15.8711H20C19.7239 15.8711 19.5 15.6472 19.5 15.3711V12.7044Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.8333 20.7044C17.8333 19.5078 18.8034 18.5378 20 18.5378H22.6667C23.8633 18.5378 24.8333 19.5078 24.8333 20.7044V23.3711C24.8333 24.5677 23.8633 25.5378 22.6667 25.5378H20C18.8034 25.5378 17.8333 24.5677 17.8333 23.3711V20.7044ZM20 20.2044C19.7239 20.2044 19.5 20.4283 19.5 20.7044V23.3711C19.5 23.6472 19.7239 23.8711 20 23.8711H22.6667C22.9428 23.8711 23.1667 23.6472 23.1667 23.3711V20.7044C23.1667 20.4283 22.9428 20.2044 22.6667 20.2044H20Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 10.5378C10.8034 10.5378 9.83333 11.5078 9.83333 12.7044V23.3711C9.83333 24.5677 10.8034 25.5378 12 25.5378H14.6667C15.8633 25.5378 16.8333 24.5677 16.8333 23.3711V12.7044C16.8333 11.5078 15.8633 10.5378 14.6667 10.5378H12ZM11.5 12.7044C11.5 12.4283 11.7239 12.2044 12 12.2044H14.6667C14.9428 12.2044 15.1667 12.4283 15.1667 12.7044V23.3711C15.1667 23.6472 14.9428 23.8711 14.6667 23.8711H12C11.7239 23.8711 11.5 23.6472 11.5 23.3711V12.7044Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 40 KiB

View file

@ -0,0 +1,9 @@
<svg width="24" height="24" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 0H0V18H9C13.5013 18 18 13.9704 18 9.00123C18 4.0321 13.5021 0 9 0ZM9 13.0896C8.18591 13.0894 7.39018 12.8493 6.71342 12.3998C6.03666 11.9502 5.50928 11.3113 5.19796 10.5639C4.88664 9.81649 4.80537 8.99415 4.96442 8.20086C5.12348 7.40757 5.51571 6.67895 6.09153 6.10716C6.66735 5.53537 7.40088 5.14607 8.19938 4.98851C8.99787 4.83095 9.82545 4.9122 10.5775 5.22197C11.3295 5.53175 11.9722 6.05615 12.4242 6.72885C12.8763 7.40155 13.1174 8.19235 13.1172 9.00123C13.1172 9.53835 13.0108 10.0702 12.8038 10.5664C12.5969 11.0626 12.2936 11.5135 11.9113 11.8931C11.5289 12.2728 11.075 12.5739 10.5755 12.7792C10.0759 12.9845 9.54057 13.09 9 13.0896Z" fill="url(#paint0_linear_15412_85374)"/>
<defs>
<linearGradient id="paint0_linear_15412_85374" x1="0" y1="9.00009" x2="18" y2="9.00009" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF593D"/>
<stop offset="1" stop-color="#FF7751"/>
</linearGradient>
</defs>
</svg>

After

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

View file

@ -0,0 +1,3 @@
export { default as ContentfulIcon } from "./contentful-icon.svg";
export { default as StrapiIcon } from "./strapi-icon.svg";
export { default as DatocmsIcon } from "./datocms-icon.svg";

View file

@ -0,0 +1,11 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_15398_13939)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9398 16.3135V8.5562C15.9398 8.42355 15.8871 8.29633 15.7933 8.20254C15.6995 8.10875 15.5723 8.05606 15.4396 8.05606H7.69141V0.0488281H23.438C23.5037 0.0488152 23.5687 0.0617446 23.6294 0.0868781C23.6901 0.112012 23.7453 0.148857 23.7918 0.195309C23.8382 0.241762 23.8751 0.296912 23.9002 0.35761C23.9254 0.418308 23.9383 0.483364 23.9383 0.549065V16.3135H15.9398Z" fill="#8E75FF"/>
<path opacity="0.405" fill-rule="evenodd" clip-rule="evenodd" d="M7.69204 0.0488281V8.05606H0.296975C0.247518 8.05605 0.199175 8.04138 0.158054 8.0139C0.116932 7.98642 0.0848781 7.94737 0.0659409 7.90168C0.0470037 7.856 0.0420334 7.80572 0.051658 7.75721C0.0612825 7.7087 0.0850701 7.66413 0.120015 7.62913L7.69204 0.0488281ZM15.9405 23.7163V16.3135H23.9389L16.3675 23.8931C16.3325 23.928 16.288 23.9519 16.2395 23.9615C16.1909 23.9712 16.1407 23.9662 16.095 23.9473C16.0493 23.9284 16.0102 23.8963 15.9827 23.8552C15.9552 23.8141 15.9405 23.7658 15.9405 23.7163ZM7.69204 8.05606H15.6903C15.7566 8.05606 15.8203 8.08241 15.8672 8.1293C15.9141 8.17619 15.9405 8.2398 15.9405 8.30613V16.3135H8.19228C8.12659 16.3135 8.06154 16.3005 8.00085 16.2754C7.94016 16.2502 7.88501 16.2134 7.83856 16.1669C7.79211 16.1205 7.75526 16.0654 7.73012 16.0047C7.70498 15.944 7.69204 15.8789 7.69204 15.8132V8.05606Z" fill="#8E75FF"/>
</g>
<defs>
<clipPath id="clip0_15398_13939">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,19 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { AppLinkProps } from "../../modules/ui/app-link";
export const useAppRedirect = () => {
const { appBridge } = useAppBridge();
const redirectToPath = (props: AppLinkProps) => {
return appBridge?.dispatch({
type: "redirect",
payload: {
newContext: false,
actionId: "redirect_from_cms_app",
to: props.path ? props.path : props.href,
},
});
};
return { redirect: redirectToPath };
};

View file

@ -0,0 +1,11 @@
type ChannelListing = Record<string, any> & {
channel: {
slug: string;
};
};
export const getChannelsSlugsFromSaleorItem = (
item?: { channelListings?: ChannelListing[] | null } | null
) => {
return item?.channelListings?.map((cl) => cl.channel.slug) || [];
};

View file

@ -0,0 +1,148 @@
import {
ProductVariantUpdatedWebhookPayloadFragment,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import { CmsClientOperations } from "../types";
import { getCmsIdFromSaleorItem } from "./metadata";
import { logger as pinoLogger } from "../../logger";
interface CmsClientOperationResult {
createdCmsId?: string;
deletedCmsId?: string;
error?: string;
}
const executeCmsClientOperation = async ({
cmsClient,
productVariant,
}: {
cmsClient: CmsClientOperations;
productVariant: WebhookProductVariantFragment;
}): Promise<CmsClientOperationResult | undefined> => {
const logger = pinoLogger.child({ cmsClient });
logger.debug("Execute CMS client operation called");
const cmsId = getCmsIdFromSaleorItem(productVariant, cmsClient.cmsProviderInstanceId);
if (cmsId && cmsClient.operationType === "deleteProduct") {
logger.debug("CMS deleting item called", { cmsId });
try {
await cmsClient.operations.deleteProduct({
id: cmsId,
});
return {
deletedCmsId: cmsId,
};
} catch (error) {
logger.error("Error deleting item", { error });
return {
error: "Error deleting item.",
};
}
} else if (cmsId && cmsClient.operationType === "updateProduct") {
logger.debug("CMS updating item called", { cmsId });
try {
await cmsClient.operations.updateProduct({
// todo: change params of product methods because of below:
// * In some CMSes, cmsId may be productId. Perhaps it's better to just pass everything as one big object
// * and decide on the id on the provider level.
id: cmsId,
input: {
saleorId: productVariant.id,
sku: productVariant.sku,
name: productVariant.name,
image: productVariant.product.media?.[0]?.url ?? "",
productId: productVariant.product.id,
productName: productVariant.product.name,
productSlug: productVariant.product.slug,
channels: productVariant.channelListings?.map((cl) => cl.channel.slug) || [],
},
});
} catch (error) {
logger.error("Error updating item", { error });
return {
error: "Error updating item.",
};
}
} else if (!cmsId && cmsClient.operationType === "createProduct") {
logger.debug("CMS creating item called");
try {
const createProductResponse = await cmsClient.operations.createProduct({
input: {
saleorId: productVariant.id,
sku: productVariant.sku,
name: productVariant.name,
image: productVariant.product.media?.[0]?.url ?? "",
productId: productVariant.product.id,
productName: productVariant.product.name,
productSlug: productVariant.product.slug,
channels: productVariant.channelListings?.map((cl) => cl.channel.slug) || [],
},
});
if (createProductResponse?.ok) {
return {
createdCmsId: createProductResponse.data.id,
};
} else {
return {
error: createProductResponse?.error,
};
}
} catch (error) {
logger.error("Error creating item", { error });
return {
error: "Error creating item.",
};
}
}
};
export const executeCmsOperations = async ({
cmsOperations,
productVariant,
}: {
cmsOperations: CmsClientOperations[];
productVariant: Exclude<
ProductVariantUpdatedWebhookPayloadFragment["productVariant"],
undefined | null
>;
}) => {
const cmsProviderInstanceProductVariantIdsToCreate: Record<string, string> = {};
const cmsProviderInstanceProductVariantIdsToDelete: Record<string, string> = {};
const cmsErrors: string[] = [];
await Promise.all(
cmsOperations.map(async (cmsClient) => {
const { createdCmsId, deletedCmsId, error } =
(await executeCmsClientOperation({
cmsClient,
productVariant,
})) || {};
if (createdCmsId) {
cmsProviderInstanceProductVariantIdsToCreate[cmsClient.cmsProviderInstanceId] =
createdCmsId;
}
if (deletedCmsId) {
cmsProviderInstanceProductVariantIdsToDelete[cmsClient.cmsProviderInstanceId] =
deletedCmsId;
}
if (error) {
cmsErrors.push(error);
}
})
);
return {
cmsProviderInstanceProductVariantIdsToCreate,
cmsProviderInstanceProductVariantIdsToDelete,
cmsErrors,
};
};

View file

@ -0,0 +1,253 @@
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { describe, expect, it, vi } from "vitest";
import { CMSSchemaChannels, CMSSchemaProviderInstances } from "../config";
import { CmsClientOperations } from "../types";
import { createCmsOperations } from "./clients-operations";
import { createCmsKeyForSaleorItem } from "./metadata";
import * as Settings from "./settings";
type WebhookContext = Parameters<NextWebhookApiHandler>["2"];
const mockedContext: Pick<WebhookContext, "authData"> = {
authData: {
saleorApiUrl: "url",
token: "token",
appId: "appId",
domain: "domain",
jwks: "jwks",
},
};
vi.mock("../../metadata", () => ({
createSettingsManager: () => ({}),
}));
describe("CMS Clients Operations", () => {
it("should return no creation operations when no variant channels passed and no variant cms passed", async () => {
vi.spyOn(Settings, "getChannelsSettings").mockImplementationOnce(async () => ({}));
vi.spyOn(Settings, "getProviderInstancesSettings").mockImplementationOnce(async () => ({}));
vi.spyOn(Settings, "getProductVariantProviderInstancesToAlter").mockImplementationOnce(
async () =>
({
toCreate: [],
toUpdate: [],
toRemove: [],
} as Settings.ProductVariantProviderInstancesToAlter)
);
const cmsOperations = await createCmsOperations({
context: mockedContext,
productVariantChannels: [],
productVariantCmsKeys: [],
});
expect(cmsOperations).toEqual<CmsClientOperations[]>([]);
});
it("should return create operation when variant with channel listing that does not exist in provider instance passed", async () => {
vi.spyOn(Settings, "getChannelsSettings").mockImplementationOnce(
async () =>
({
"default-channel": {
channelSlug: "default-channel",
enabledProviderInstances: ["first-provider"],
},
"other-channel": {
channelSlug: "other-channel",
enabledProviderInstances: ["first-provider", "second-provider"],
},
} as CMSSchemaChannels)
);
vi.spyOn(Settings, "getProviderInstancesSettings").mockImplementationOnce(
async () =>
({
"first-provider": {
name: "First provider",
token: "token",
baseUrl: "baseUrl",
id: "first-provider",
providerName: "strapi",
},
"second-provider": {
name: "Second provider",
token: "token",
baseUrl: "baseUrl",
id: "second-provider",
providerName: "strapi",
},
"third-provider": {
name: "Third provider",
token: "token",
baseUrl: "baseUrl",
id: "third-provider",
providerName: "strapi",
},
} as CMSSchemaProviderInstances)
);
// Following mock assumes function calculations went correct and returns correct values
vi.spyOn(Settings, "getProductVariantProviderInstancesToAlter").mockImplementationOnce(
async () =>
({
toCreate: ["first-provider"],
toUpdate: [],
toRemove: [],
} as Settings.ProductVariantProviderInstancesToAlter)
);
const cmsOperations = await createCmsOperations({
context: mockedContext,
productVariantChannels: ["default-channel"],
productVariantCmsKeys: [],
});
expect(cmsOperations).toEqual<CmsClientOperations[]>([
{
cmsProviderInstanceId: "first-provider",
operationType: "createProduct",
operations: {
createProduct: expect.any(Function),
deleteProduct: expect.any(Function),
updateProduct: expect.any(Function),
},
},
]);
});
it("should return update operation when variant with channel listing that exists in provider instance passed", async () => {
vi.spyOn(Settings, "getChannelsSettings").mockImplementationOnce(
async () =>
({
"default-channel": {
channelSlug: "default-channel",
enabledProviderInstances: ["first-provider"],
},
"other-channel": {
channelSlug: "other-channel",
enabledProviderInstances: ["first-provider", "second-provider"],
},
} as CMSSchemaChannels)
);
vi.spyOn(Settings, "getProviderInstancesSettings").mockImplementationOnce(
async () =>
({
"first-provider": {
name: "First provider",
token: "token",
baseUrl: "baseUrl",
id: "first-provider",
providerName: "strapi",
},
"second-provider": {
name: "Second provider",
token: "token",
baseUrl: "baseUrl",
id: "second-provider",
providerName: "strapi",
},
"third-provider": {
name: "Third provider",
token: "token",
baseUrl: "baseUrl",
id: "third-provider",
providerName: "strapi",
},
} as CMSSchemaProviderInstances)
);
// Following mock assumes function calculations went correct and returns correct values
vi.spyOn(Settings, "getProductVariantProviderInstancesToAlter").mockImplementationOnce(
async () =>
({
toCreate: [],
toUpdate: ["first-provider"],
toRemove: [],
} as Settings.ProductVariantProviderInstancesToAlter)
);
const cmsOperations = await createCmsOperations({
context: mockedContext,
productVariantChannels: ["default-channel"],
productVariantCmsKeys: [createCmsKeyForSaleorItem("first-provider")],
});
expect(cmsOperations).toEqual<CmsClientOperations[]>([
{
cmsProviderInstanceId: "first-provider",
operationType: "updateProduct",
operations: {
createProduct: expect.any(Function),
deleteProduct: expect.any(Function),
updateProduct: expect.any(Function),
},
},
]);
});
it("should return delete operation when variant without channel listing that exists in provider instance passed", async () => {
vi.spyOn(Settings, "getChannelsSettings").mockImplementationOnce(
async () =>
({
"default-channel": {
channelSlug: "default-channel",
enabledProviderInstances: ["first-provider"],
},
"other-channel": {
channelSlug: "other-channel",
enabledProviderInstances: ["first-provider", "second-provider"],
},
} as CMSSchemaChannels)
);
vi.spyOn(Settings, "getProviderInstancesSettings").mockImplementationOnce(
async () =>
({
"first-provider": {
name: "First provider",
token: "token",
baseUrl: "baseUrl",
id: "first-provider",
providerName: "strapi",
},
"second-provider": {
name: "Second provider",
token: "token",
baseUrl: "baseUrl",
id: "second-provider",
providerName: "strapi",
},
"third-provider": {
name: "Third provider",
token: "token",
baseUrl: "baseUrl",
id: "third-provider",
providerName: "strapi",
},
} as CMSSchemaProviderInstances)
);
// Following mock assumes function calculations went correct and returns correct values
vi.spyOn(Settings, "getProductVariantProviderInstancesToAlter").mockImplementationOnce(
async () =>
({
toCreate: [],
toUpdate: [],
toRemove: ["first-provider"],
} as Settings.ProductVariantProviderInstancesToAlter)
);
const cmsOperations = await createCmsOperations({
context: mockedContext,
productVariantChannels: [],
productVariantCmsKeys: [createCmsKeyForSaleorItem("first-provider")],
});
expect(cmsOperations).toEqual<CmsClientOperations[]>([
{
cmsProviderInstanceId: "first-provider",
operationType: "deleteProduct",
operations: {
createProduct: expect.any(Function),
deleteProduct: expect.any(Function),
updateProduct: expect.any(Function),
},
},
]);
});
});

View file

@ -0,0 +1,113 @@
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { createClient } from "../../graphql";
import { createSettingsManager } from "../../metadata";
import { getOperationType } from "./operations";
import {
getChannelsSettings,
getProviderInstancesSettings,
getProductVariantProviderInstancesToAlter,
} from "./settings";
import { providersSchemaSet } from "../config";
import cmsProviders, { CMSProvider } from "../providers";
import { CmsClientOperations } from "../types";
import { logger as pinoLogger } from "../../logger";
import { getCmsIdFromSaleorItemKey } from "./metadata";
type WebhookContext = Parameters<NextWebhookApiHandler>["2"];
// todo: add support for multiple providers at once
export const createCmsOperations = async ({
context,
productVariantChannels,
productVariantCmsKeys,
}: {
context: Pick<WebhookContext, "authData">;
productVariantChannels: string[];
productVariantCmsKeys: string[];
}) => {
const logger = pinoLogger.child({
productVariantChannels,
productVariantCmsKeys,
});
const saleorApiUrl = context.authData.saleorApiUrl;
const token = context.authData.token;
const client = createClient(saleorApiUrl, async () => ({
token: token,
}));
const settingsManager = createSettingsManager(client);
const channelsSettingsParsed = await getChannelsSettings(settingsManager);
const providerInstancesSettingsParsed = await getProviderInstancesSettings(settingsManager);
const productVariantCmsProviderInstances = productVariantCmsKeys.map((cmsKey) =>
getCmsIdFromSaleorItemKey(cmsKey)
);
const productVariantProviderInstancesToAlter = await getProductVariantProviderInstancesToAlter({
channelsSettingsParsed,
productVariantChannels,
productVariantCmsProviderInstances,
});
const allProductVariantProviderInstancesToAlter = [
...productVariantProviderInstancesToAlter.toCreate,
...productVariantProviderInstancesToAlter.toUpdate,
...productVariantProviderInstancesToAlter.toRemove,
];
if (!allProductVariantProviderInstancesToAlter.length) {
// todo: use instead: throw new Error("The channel settings were not found.");
// continue with other provider instances
return [];
}
const enabledProviderInstancesSettings = Object.values(providerInstancesSettingsParsed).filter(
(providerInstance) => allProductVariantProviderInstancesToAlter.includes(providerInstance.id)
);
const clientsOperations = enabledProviderInstancesSettings.reduce<CmsClientOperations[]>(
(acc, providerInstanceSettings) => {
const provider = cmsProviders[
providerInstanceSettings.providerName as CMSProvider
] as (typeof cmsProviders)[keyof typeof cmsProviders];
const validation =
providersSchemaSet[providerInstanceSettings.providerName as CMSProvider].safeParse(
providerInstanceSettings
);
if (!validation.success) {
// todo: use instead: throw new Error(validation.error.message);
// continue with other provider instances
logger.error("The provider instance settings validation failed.", {
error: validation.error.message,
});
return acc;
}
const config = validation.data;
if (provider) {
return [
...acc,
{
cmsProviderInstanceId: providerInstanceSettings.id,
// todo: fix validation to not pass config as any
operations: provider.create(config as any), // config without validation = providerInstanceSettings as any
operationType: getOperationType({
providerInstancesWithRequestedOperation: productVariantProviderInstancesToAlter,
providerInstanceId: providerInstanceSettings.id,
}),
},
];
}
return acc;
},
[] as CmsClientOperations[]
);
return clientsOperations;
};

View file

@ -0,0 +1,3 @@
export { createCmsOperations } from "./clients-operations";
export { executeCmsOperations } from "./clients-execution";
export { executeMetadataUpdate } from "./metadata-execution";

View file

@ -0,0 +1,51 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import {
DeleteMetadataDocument,
ProductVariantUpdatedWebhookPayloadFragment,
UpdateMetadataDocument,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import { createClient } from "../../graphql";
import { createCmsKeyForSaleorItem } from "./metadata";
type WebhookContext = Parameters<NextWebhookApiHandler>["2"];
export const executeMetadataUpdate = async ({
context,
productVariant,
cmsProviderInstanceIdsToCreate,
cmsProviderInstanceIdsToDelete,
}: {
context: WebhookContext;
productVariant: WebhookProductVariantFragment;
cmsProviderInstanceIdsToCreate: Record<string, string>;
cmsProviderInstanceIdsToDelete: Record<string, string>;
}) => {
const { token, saleorApiUrl } = context.authData;
const apiClient = createClient(saleorApiUrl, async () => ({ token }));
if (Object.keys(cmsProviderInstanceIdsToCreate).length) {
await apiClient
.mutation(UpdateMetadataDocument, {
id: productVariant.id,
input: Object.entries(cmsProviderInstanceIdsToCreate).map(
([cmsProviderInstanceId, cmsProductVariantId]) => ({
key: createCmsKeyForSaleorItem(cmsProviderInstanceId),
value: cmsProductVariantId,
})
),
})
.toPromise();
}
if (Object.keys(cmsProviderInstanceIdsToDelete).length) {
await apiClient
.mutation(DeleteMetadataDocument, {
id: productVariant.id,
keys: Object.entries(cmsProviderInstanceIdsToDelete).map(([cmsProviderInstanceId]) =>
createCmsKeyForSaleorItem(cmsProviderInstanceId)
),
})
.toPromise();
}
};

View file

@ -0,0 +1,24 @@
import { CMS_ID_KEY } from "../config";
type MetadataItem = Record<string, any> & { key: string; value: string };
export const getCmsKeysFromSaleorItem = (item?: { metadata: MetadataItem[] } | null) => {
return (
item?.metadata.filter((item) => item.key.startsWith(CMS_ID_KEY)).map((item) => item.key) || []
);
};
export const createCmsKeyForSaleorItem = (cmsProviderInstanceId: string) => {
return `${CMS_ID_KEY}_${cmsProviderInstanceId}`;
};
export const getCmsIdFromSaleorItemKey = (key: string) => {
return key.split("_")[2];
};
export const getCmsIdFromSaleorItem = (
item: Record<string, any> & { metadata: MetadataItem[] },
cmsProviderInstanceId: string
) =>
item.metadata.find((item) => item.key === createCmsKeyForSaleorItem(cmsProviderInstanceId))
?.value;

View file

@ -0,0 +1,22 @@
import { CmsOperations } from "../types";
import { ProductVariantProviderInstancesToAlter } from "./settings";
export const getOperationType = ({
providerInstancesWithRequestedOperation: { toCreate, toUpdate, toRemove },
providerInstanceId,
}: {
providerInstancesWithRequestedOperation: ProductVariantProviderInstancesToAlter;
providerInstanceId: string;
}): keyof CmsOperations => {
if (toCreate.includes(providerInstanceId)) {
return "createProduct";
}
if (toUpdate.includes(providerInstanceId)) {
return "updateProduct";
}
if (toRemove.includes(providerInstanceId)) {
return "deleteProduct";
}
return "updateProduct";
};

View file

@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import { CMSSchemaChannels } from "../config";
import { getProductVariantProviderInstancesToAlter } from "./settings";
vi.mock("../../metadata", () => ({
createSettingsManager: () => ({}),
}));
describe("CMS Clients Operations", () => {
it("should return provider instance in create group when variant channel listing passed and channel provider instance not passed", async () => {
const channelsSettings = {
"default-channel": {
channelSlug: "default-channel",
enabledProviderInstances: ["first-provider"],
},
"other-channel": {
channelSlug: "other-channel",
enabledProviderInstances: ["first-provider", "second-provider"],
},
} as CMSSchemaChannels;
const providerInstances = await getProductVariantProviderInstancesToAlter({
channelsSettingsParsed: channelsSettings,
productVariantChannels: ["default-channel"], // function should infer that variant has ["first-provider"]
productVariantCmsProviderInstances: [],
});
expect(providerInstances).toEqual({
toCreate: ["first-provider"],
toUpdate: [],
toRemove: [],
});
});
it("should return provider instance in update group when variant channel listing passed and channel provider instance passed", async () => {
const channelsSettings = {
"default-channel": {
channelSlug: "default-channel",
enabledProviderInstances: ["first-provider"],
},
"other-channel": {
channelSlug: "other-channel",
enabledProviderInstances: ["first-provider", "second-provider"],
},
} as CMSSchemaChannels;
const providerInstances = await getProductVariantProviderInstancesToAlter({
channelsSettingsParsed: channelsSettings,
productVariantChannels: ["default-channel"], // function should infer that variant has ["first-provider"]
productVariantCmsProviderInstances: ["first-provider"],
});
expect(providerInstances).toEqual({
toCreate: [],
toUpdate: ["first-provider"],
toRemove: [],
});
});
it("should return provider instance in remove group when variant channel listing not passed and channel provider instance passed", async () => {
const channelsSettings = {
"default-channel": {
channelSlug: "default-channel",
enabledProviderInstances: ["first-provider"],
},
"other-channel": {
channelSlug: "other-channel",
enabledProviderInstances: ["first-provider", "second-provider"],
},
} as CMSSchemaChannels;
const providerInstances = await getProductVariantProviderInstancesToAlter({
channelsSettingsParsed: channelsSettings,
productVariantChannels: [], // function should infer that variant has []
productVariantCmsProviderInstances: ["first-provider"],
});
expect(providerInstances).toEqual({
toCreate: [],
toUpdate: [],
toRemove: ["first-provider"],
});
});
});

View file

@ -0,0 +1,101 @@
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
import { CMSSchemaChannels, CMSSchemaProviderInstances } from "../config";
import { createCmsKeyForSaleorItem, getCmsIdFromSaleorItemKey } from "./metadata";
export const getChannelsSettings = async (settingsManager: EncryptedMetadataManager) => {
const channelsSettings = await settingsManager.get("channels");
try {
const channelsSettingsParsed =
(channelsSettings && (JSON.parse(channelsSettings) as CMSSchemaChannels)) || {};
return channelsSettingsParsed;
} catch (e) {
return {};
}
};
export const getProviderInstancesSettings = async (settingsManager: EncryptedMetadataManager) => {
const providerInstancesSettings = await settingsManager.get("providerInstances");
try {
const providerInstancesSettingsParsed =
(providerInstancesSettings &&
(JSON.parse(providerInstancesSettings) as CMSSchemaProviderInstances)) ||
{};
return providerInstancesSettingsParsed;
} catch (e) {
return {};
}
};
const filterNotExistingProviderInstances = (
providerInstances: string[],
newProviderInstances: string[]
) => {
return newProviderInstances.filter(
(newProviderInstance) => !providerInstances.includes(newProviderInstance)
);
};
const mergeProviderInstances = (providerInstances: string[], newProviderInstances: string[]) => {
return [
...providerInstances,
...filterNotExistingProviderInstances(providerInstances, newProviderInstances),
];
};
export interface ProductVariantSingleChannelSettings {
enabledProviderInstances: string[];
channelSlug: string;
}
export interface ProductVariantProviderInstancesToAlter {
toCreate: string[];
toUpdate: string[];
toRemove: string[];
}
export interface ProductVariantProviderInstancesToAlterOpts {
channelsSettingsParsed: Record<string, ProductVariantSingleChannelSettings>;
productVariantChannels: string[];
productVariantCmsProviderInstances: string[];
}
/**
* Returns list of provider instances that have to have performed create, update or remove operations against them.
* The list is based on the channels that the product variant is assigned to and the cms provider instances indicating instances
* that the product variant has been already created in.
*/
export const getProductVariantProviderInstancesToAlter = async ({
channelsSettingsParsed,
productVariantChannels,
productVariantCmsProviderInstances,
}: ProductVariantProviderInstancesToAlterOpts) => {
const enabledChannelsForProductVariant = Object.values(channelsSettingsParsed).filter(
(channelSettings) =>
!!productVariantChannels.length &&
!!productVariantChannels.includes(channelSettings.channelSlug)
);
const channelsProvidersForProductVariant = enabledChannelsForProductVariant.reduce(
(acc, channelSettings) => mergeProviderInstances(acc, channelSettings.enabledProviderInstances),
[] as string[]
);
const productVariantCmsKeysNotYetInChannelsSettings = channelsProvidersForProductVariant.filter(
(cms) => !productVariantCmsProviderInstances.includes(cms)
);
const productVariantCmsKeysInChannelsSettings = productVariantCmsProviderInstances.filter((cms) =>
channelsProvidersForProductVariant.includes(cms)
);
const productVariantCmsKeysNoLongerInChannelsSettings = productVariantCmsProviderInstances.filter(
(cms) => !channelsProvidersForProductVariant.includes(cms)
);
return {
toCreate: productVariantCmsKeysNotYetInChannelsSettings,
toUpdate: productVariantCmsKeysInChannelsSettings,
toRemove: productVariantCmsKeysNoLongerInChannelsSettings,
};
};

View file

@ -0,0 +1,22 @@
import { z } from "zod";
import { ChannelFragment } from "../../../../generated/graphql";
export const channelCommonSchema = z.object({
channelSlug: z.string(),
});
export type ChannelCommonSchema = z.infer<typeof channelCommonSchema>;
export const channelSchema = z
.object({
enabledProviderInstances: z.array(z.string()),
})
.merge(channelCommonSchema);
export type ChannelSchema = z.infer<typeof channelSchema>;
export type SingleChannelSchema = ChannelSchema & ChannelCommonSchema;
export type MergedChannelSchema = SingleChannelSchema & {
channel: ChannelFragment;
};

View file

@ -0,0 +1,18 @@
import { z } from "zod";
import { channelSchema } from "./channels";
import { providerInstanceSchema } from "./providers";
export * from "./channels";
export * from "./providers";
export const CMS_ID_KEY = "cmsId";
export const cmsSchemaProviderInstances = z.record(z.string(), providerInstanceSchema);
export const cmsSchemaChannels = z.record(z.string(), channelSchema);
export const cmsSchema = z.object({
providerInstances: cmsSchemaProviderInstances,
channels: cmsSchemaChannels,
});
export type CMSSchemaProviderInstances = z.infer<typeof cmsSchemaProviderInstances>;
export type CMSSchemaChannels = z.infer<typeof cmsSchemaChannels>;

View file

@ -0,0 +1,174 @@
import { z } from "zod";
import { CreateProviderConfig } from "../types";
import { ContentfulIcon, DatocmsIcon, StrapiIcon } from "../../../assets";
type ProviderToken = {
name: string;
label: string;
helpText: string;
required?: boolean;
};
type ProviderConfig = {
name: string;
label: string;
icon: React.ReactNode;
tokens: ProviderToken[];
};
type ProvidersConfig = Record<string, ProviderConfig>;
export const providersConfig = {
contentful: {
name: "contentful",
label: "Contentful",
icon: ContentfulIcon,
tokens: [
{
name: "baseUrl",
label: "Base URL",
helpText: "CDN API URL of your Contentful project, e.g. https://cdn.contentful.com.",
},
{
name: "token",
label: "Token",
helpText:
"You can find this in your Contentful project, go to Settings > API keys > Content management tokens > Generate personal token.",
},
{
name: "environment",
label: "Environment",
helpText:
"Environment of your content, e.g. master. You can find this in your Contentful project, go to Settings > Environments.",
},
{
name: "spaceId",
label: "Space ID",
helpText:
"You can find this in your Contentful project, go to settings > general settings.",
},
{
name: "contentId",
label: "Content ID",
helpText:
"You can find this in your Contentful project, go to Content model > select model > Content type id.",
},
{
name: "locale",
label: "Locale",
helpText:
"Locale of your content, e.g. en-US. You can find this in your Contentful project, go to Settings > Locales.",
},
],
},
strapi: {
name: "strapi",
label: "Strapi",
icon: StrapiIcon,
tokens: [
{
required: true,
name: "baseUrl",
label: "Base URL",
helpText: "API URL of your Strapi project.",
},
{
required: true,
name: "token",
label: "API Token (with full access)",
helpText:
"You can find this in your Strapi project settings, go to Settings > API Tokens and use full access token or create new one.",
},
],
},
datocms: {
name: "datocms",
label: "DatoCMS",
icon: DatocmsIcon,
tokens: [
{
required: true,
name: "token",
label: "API Token (with access to Content Management API)",
helpText: "You can find this in your DatoCMS project settings.",
},
{
required: true,
name: "itemTypeId",
label: "Item Type ID (number)",
helpText:
'You can find this as Model ID in your DatoCMS product variant model settings, by clicking "Edit model".',
},
{
name: "baseUrl",
label: "Base URL",
helpText:
"Optional URL to your DatoCMS project. If you leave this blank, this URL will be inferred from your API Token.",
},
{
name: "environment",
label: "Environment",
helpText:
"Optional environment name. If you leave this blank, default environment will be used. You can find this in your DatoCMS project settings.",
},
],
},
} satisfies ProvidersConfig;
export type StrapiConfig = CreateProviderConfig<"strapi">;
export type ContentfulConfig = CreateProviderConfig<"contentful">;
export type DatocmsConfig = CreateProviderConfig<"datocms">;
export const strapiConfigSchema = z.object({
name: z.string().min(1),
token: z.string().min(1),
baseUrl: z.string().min(1),
});
export const contentfulConfigSchema = z.object({
name: z.string().min(1),
token: z.string(),
baseUrl: z.string(),
environment: z.string(),
spaceId: z.string(),
locale: z.string(),
contentId: z.string(),
});
export const datocmsConfigSchema = z.object({
name: z.string().min(1),
token: z.string().min(1),
baseUrl: z.string(),
environment: z.string(),
itemTypeId: z.string().min(1),
});
export const providerCommonSchema = z.object({
id: z.string(),
providerName: z.string(),
});
export type ProviderCommonSchema = z.infer<typeof providerCommonSchema>;
// todo: helper function so you dont have to merge manually
export const providersSchemaSet = {
strapi: strapiConfigSchema.merge(providerCommonSchema),
contentful: contentfulConfigSchema.merge(providerCommonSchema),
datocms: datocmsConfigSchema.merge(providerCommonSchema),
};
export type CMSProviderSchema = keyof typeof providersSchemaSet;
export const providersSchema = z.object(providersSchemaSet);
export type ProvidersSchema = z.infer<typeof providersSchema>;
export type SingleProviderSchema = ProvidersSchema[keyof ProvidersSchema] & ProviderCommonSchema;
export const providerInstanceSchema = z.union([
strapiConfigSchema.merge(providerCommonSchema),
contentfulConfigSchema.merge(providerCommonSchema),
datocmsConfigSchema.merge(providerCommonSchema),
]);
export type ProviderInstanceSchema = z.infer<typeof providerInstanceSchema>;

View file

@ -0,0 +1,4 @@
export * from "./config";
export * from "./client";
export * from "./providers";
export { default as cmsProviders } from "./providers";

View file

@ -0,0 +1,143 @@
import { v4 as uuidv4 } from "uuid";
import { ContentfulConfig, contentfulConfigSchema } from "../config";
import { CreateOperations, CreateProductResponse, ProductInput } from "../types";
import { createProvider } from "./create";
const contentfulFetch = (endpoint: string, config: ContentfulConfig, options?: RequestInit) => {
const { baseUrl, token } = config;
return fetch(`${baseUrl}${endpoint}`, {
...options,
headers: {
...options?.headers,
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
};
type ContentfulBody = {
fields: Record<string, any>;
};
type ContentfulResponse = {
message?: string;
fields?: Record<string, any>;
sys: {
id: string;
version?: number;
};
};
const transformInputToBody = ({
input,
locale,
}: {
input: ProductInput;
locale: string;
}): ContentfulBody => {
const body = {
fields: {
saleor_id: {
[locale]: input.saleorId,
},
name: {
[locale]: input.name,
},
product_id: {
[locale]: input.productId,
},
product_slug: {
[locale]: input.productSlug,
},
product_name: {
[locale]: input.productName,
},
channels: {
[locale]: JSON.stringify(input.channels),
},
// image: {
// [locale]: input.image,
// },
},
};
return body;
};
const transformCreateProductResponse = (response: ContentfulResponse): CreateProductResponse => {
if (response.message) {
return {
ok: false,
error: "Something went wrong!",
};
}
return {
ok: true,
data: {
id: response.sys.id,
},
};
};
const getEntryEndpoint = ({
resourceId,
spaceId,
environment,
}: {
resourceId: string;
spaceId: string;
environment: string;
}): string => `/spaces/${spaceId}/environments/${environment}/entries/${resourceId}`;
const contentfulOperations: CreateOperations<ContentfulConfig> = (config) => {
const { baseUrl, token, environment, spaceId, contentId, locale } = config;
return {
createProduct: async (params) => {
// Contentful API does not auto generate resource ID during creation, it has to be provided.
const resourceId = uuidv4();
const body = transformInputToBody({ input: params.input, locale });
const endpoint = getEntryEndpoint({
resourceId,
environment,
spaceId,
});
const response = await contentfulFetch(endpoint, config, {
method: "PUT",
body: JSON.stringify(body),
headers: {
"X-Contentful-Content-Type": contentId,
},
});
const result = await response.json();
return transformCreateProductResponse(result);
},
updateProduct: async ({ id, input }) => {
const body = transformInputToBody({ input, locale });
const endpoint = getEntryEndpoint({
resourceId: id,
environment,
spaceId,
});
const getEntryResponse = await contentfulFetch(endpoint, config, { method: "GET" });
const entry = await getEntryResponse.json();
const response = await contentfulFetch(endpoint, config, {
method: "PUT",
body: JSON.stringify(body),
headers: {
"X-Contentful-Version": entry.sys.version,
},
});
const result = await response.json();
return result;
},
deleteProduct: ({ id }) => {
const endpoint = getEntryEndpoint({ resourceId: id, environment, spaceId });
return contentfulFetch(endpoint, config, { method: "DELETE" });
},
};
};
export default createProvider(contentfulOperations, contentfulConfigSchema);

View file

@ -0,0 +1,12 @@
import { z } from "zod";
import { BaseConfig, CreateOperations, Provider } from "../types";
export const createProvider = <TConfig extends BaseConfig>(
operations: CreateOperations<TConfig>,
schema: z.ZodType<TConfig>
): Provider<TConfig> => {
return {
create: operations,
schema,
};
};

View file

@ -0,0 +1,86 @@
import { createProvider } from "./create";
import { CreateOperations, CreateProductResponse } from "../types";
import { ApiError, buildClient, SimpleSchemaTypes } from "@datocms/cma-client-node";
import { DatocmsConfig, datocmsConfigSchema } from "../config";
const datocmsClient = (config: DatocmsConfig, options?: RequestInit) => {
const { baseUrl, token, environment } = config;
const clientEnvironment = environment ? { environment } : {};
const clientBaseUrl = baseUrl ? { baseUrl } : {};
return buildClient({
apiToken: token,
...clientEnvironment,
...clientBaseUrl,
});
};
const transformResponseError = (error: unknown): CreateProductResponse => {
if (error instanceof ApiError) {
return {
ok: false,
error: error.message,
};
} else {
return {
ok: false,
error: "Something went wrong!",
};
}
};
const transformResponseItem = (item: SimpleSchemaTypes.Item): CreateProductResponse => {
return {
ok: true,
data: {
id: item.id,
},
};
};
const datocmsOperations: CreateOperations<DatocmsConfig> = (config) => {
return {
createProduct: async ({ input }) => {
const client = datocmsClient(config);
try {
const item = await client.items.create({
item_type: {
id: config.itemTypeId,
type: "item_type",
},
saleor_id: input.saleorId,
name: input.name,
channels: JSON.stringify(input.channels),
product_id: input.productId,
product_name: input.productName,
product_slug: input.productSlug,
});
return transformResponseItem(item);
} catch (error) {
return transformResponseError(error);
}
},
updateProduct: async ({ id, input }) => {
const client = datocmsClient(config);
await client.items.update(id, {
saleor_id: input.saleorId,
name: input.name,
channels: JSON.stringify(input.channels),
product_id: input.productId,
product_name: input.productName,
product_slug: input.productSlug,
});
},
deleteProduct: async ({ id }) => {
const client = datocmsClient(config);
await client.items.destroy(id);
},
};
};
export default createProvider(datocmsOperations, datocmsConfigSchema);

View file

@ -0,0 +1,13 @@
import contentful from "./contentful";
import strapi from "./strapi";
import datocms from "./datocms";
const cmsProviders = {
contentful,
strapi,
datocms,
};
export type CMSProvider = keyof typeof cmsProviders;
export default cmsProviders;

View file

@ -0,0 +1,97 @@
import { StrapiConfig, strapiConfigSchema } from "../config";
import { CmsOperations, CreateOperations, CreateProductResponse, ProductInput } from "../types";
import { createProvider } from "./create";
const strapiFetch = (endpoint: string, config: StrapiConfig, options?: RequestInit) => {
const { baseUrl, token } = config;
return fetch(`${baseUrl}${endpoint}`, {
...options,
headers: {
...options?.headers,
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
};
type StrapiBody = {
data: Record<string, any> & { saleor_id: string };
};
const transformInputToBody = ({ input }: { input: ProductInput }): StrapiBody => {
const body = {
data: {
saleor_id: input.saleorId,
name: input.name,
channels: JSON.stringify(input.channels),
product_id: input.productId,
product_name: input.productName,
product_slug: input.productSlug,
},
};
return body;
};
type StrapiResponse =
| {
data: null;
error: {
status: number;
name: string;
message: string;
details?: {
errors: unknown[];
};
};
}
| {
data: {
id: string;
attributes: Record<string, any>;
meta: Record<string, any>;
};
error: null;
};
const transformCreateProductResponse = (response: StrapiResponse): CreateProductResponse => {
if (response.error) {
return {
ok: false,
error: "Something went wrong!",
};
}
return {
ok: true,
data: {
id: response.data.id,
},
};
};
type CreateStrapiOperations = CreateOperations<StrapiConfig>;
export const strapiOperations: CreateStrapiOperations = (config): CmsOperations => {
return {
createProduct: async (params) => {
const body = transformInputToBody(params);
const response = await strapiFetch("/products", config, {
method: "POST",
body: JSON.stringify(body),
});
const result = await response.json();
return transformCreateProductResponse(result);
},
updateProduct: ({ id, input }) => {
const body = transformInputToBody({ input });
return strapiFetch(`/products/${id}`, config, { method: "PUT", body: JSON.stringify(body) });
},
deleteProduct: ({ id }) => {
return strapiFetch(`/products/${id}`, config, { method: "DELETE" });
},
};
};
export default createProvider(strapiOperations, strapiConfigSchema);

View file

@ -0,0 +1,51 @@
import { z } from "zod";
import { providersConfig } from "./config";
export type ProductInput = Record<string, any> & {
saleorId: string;
name: string;
productId: string;
productName: string;
productSlug: string;
channels: string[];
image?: string;
};
export type CreateProductResponse =
| { ok: true; data: { id: string } }
| { ok: false; error: string };
export type CmsOperations = {
getAllProducts?: () => Promise<Response>;
getProduct?: ({ id }: { id: string }) => Promise<Response>;
createProduct: ({ input }: { input: ProductInput }) => Promise<CreateProductResponse>;
updateProduct: ({ id, input }: { id: string; input: ProductInput }) => Promise<Response | void>;
deleteProduct: ({ id }: { id: string }) => Promise<Response | void>;
};
export type CmsClientOperations = {
cmsProviderInstanceId: string;
operations: CmsOperations;
operationType: keyof CmsOperations;
};
export type GetProviderTokens<TProviderName extends keyof typeof providersConfig> =
(typeof providersConfig)[TProviderName]["tokens"][number];
export type BaseConfig = {
name: string;
};
// * Generates the config based on the data supplied in the `providersConfig` variable.
export type CreateProviderConfig<TProviderName extends keyof typeof providersConfig> = Record<
GetProviderTokens<TProviderName>["name"],
string
> &
BaseConfig;
export type CreateOperations<TConfig extends BaseConfig> = (config: TConfig) => CmsOperations;
export type Provider<TConfig extends BaseConfig> = {
create: CreateOperations<TConfig>;
schema: z.ZodType<TConfig>;
};

View file

@ -0,0 +1,48 @@
import { AuthConfig, authExchange } from "@urql/exchange-auth";
import {
cacheExchange,
createClient as urqlCreateClient,
dedupExchange,
fetchExchange,
} from "urql";
interface IAuthState {
token: string;
}
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
urqlCreateClient({
url,
exchanges: [
dedupExchange,
cacheExchange,
authExchange<IAuthState>({
addAuthToOperation: ({ authState, operation }) => {
if (!authState || !authState?.token) {
return operation;
}
const fetchOptions =
typeof operation.context.fetchOptions === "function"
? operation.context.fetchOptions()
: operation.context.fetchOptions || {};
return {
...operation,
context: {
...operation.context,
fetchOptions: {
...fetchOptions,
headers: {
...fetchOptions.headers,
"Authorization-Bearer": authState.token,
},
},
},
};
},
getAuth,
}),
fetchExchange,
],
});

View file

@ -0,0 +1,19 @@
import pino from "pino";
/**
* TODO Set up log drain etc
*/
export const logger = pino({
level: "debug",
transport:
process.env.NODE_ENV === "development"
? {
target: "pino-pretty",
options: {
colorize: true,
},
}
: undefined,
});
export const createLogger = logger.child.bind(logger);

View file

@ -0,0 +1,80 @@
import { MetadataEntry, EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
import { Client } from "urql";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateAppMetadataDocument,
} from "../../generated/graphql";
// Function is using urql graphql client to fetch all available metadata.
// Before returning query result, we are transforming response to list of objects with key and value fields
// which can be used by the manager.
// Result of this query is cached by the manager.
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
if (error) {
console.debug("Error during fetching the metadata: ", error);
return [];
}
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
}
// Mutate function takes urql client and metadata entries, and construct mutation to the API.
// Before data are send, additional query for required App ID is made.
// The manager will use updated entries returned by this mutation to update it's cache.
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
// to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
.toPromise();
if (idQueryError) {
console.debug("Could not fetch the app id: ", idQueryError);
throw new Error(
"Could not fetch the app id. Please check if auth data for the client are valid."
);
}
const appId = idQueryData?.app?.id;
if (!appId) {
console.debug("Missing app id");
throw new Error("Could not fetch the app ID");
}
const { error: mutationError, data: mutationData } = await client
.mutation(UpdateAppMetadataDocument, {
id: appId,
input: metadata,
})
.toPromise();
if (mutationError) {
console.debug("Mutation error: ", 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: Client) => {
// 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),
});
};

View file

@ -0,0 +1,19 @@
import React, { PropsWithChildren } from "react";
import dynamic from "next/dynamic";
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
/**
* Saleor App can be rendered only as a Saleor Dashboard iframe.
* All content is rendered after Dashboard exchanges auth with the app.
* Hence, there is no reason to render app server side.
*
* This component forces app to work in SPA-mode. It simplifies browser-only code and reduces need
* of using dynamic() calls
*
* You can use this wrapper selectively for some pages or remove it completely.
* It doesn't affect Saleor communication, but may cause problems with some client-only code.
*/
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
ssr: false,
});

View file

@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { AppBridgeState } from "@saleor/app-sdk/app-bridge";
import { render, waitFor } from "@testing-library/react";
import { ThemeSynchronizer } from "./theme-synchronizer";
const appBridgeState: AppBridgeState = {
ready: true,
token: "token",
domain: "some-domain.saleor.cloud",
theme: "dark",
path: "/",
locale: "en",
id: "app-id",
saleorApiUrl: "https://some-domain.saleor.cloud/graphql/",
};
const mockThemeChange = vi.fn();
vi.mock("@saleor/app-sdk/app-bridge", () => {
return {
useAppBridge() {
return {
appBridgeState: appBridgeState,
};
},
};
});
vi.mock("@saleor/macaw-ui", () => {
return {
useTheme() {
return {
setTheme: mockThemeChange,
themeType: "light",
};
},
};
});
describe("ThemeSynchronizer", () => {
it("Updates MacawUI theme when AppBridgeState theme changes", () => {
render(<ThemeSynchronizer />);
return waitFor(() => {
expect(mockThemeChange).toHaveBeenCalledWith("dark");
});
});
});

View file

@ -0,0 +1,33 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui";
import { memo, useEffect } from "react";
/**
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
* Macaw must be informed about this change from AppBridge.
*
* If you are not using Macaw, you can remove this.
*/
function _ThemeSynchronizer() {
const { appBridgeState } = useAppBridge();
const { setTheme, themeType } = useTheme();
useEffect(() => {
if (!setTheme || !appBridgeState?.theme) {
return;
}
if (themeType !== appBridgeState?.theme) {
setTheme(appBridgeState.theme);
/**
* Hack to fix macaw, which is going into infinite loop on light mode (probably de-sync local storage with react state)
* TODO Fix me when Macaw 2.0 is shipped
*/
window.localStorage.setItem("macaw-ui-theme", appBridgeState.theme);
}
}, [appBridgeState?.theme, setTheme, themeType]);
return null;
}
export const ThemeSynchronizer = memo(_ThemeSynchronizer);

View file

@ -0,0 +1,40 @@
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
export interface AppConfigurator<TConfig extends Record<string, any>> {
setConfig(config: TConfig): Promise<void>;
getConfig(): Promise<TConfig | undefined>;
}
export class PrivateMetadataAppConfigurator<TConfig extends Record<string, any>>
implements AppConfigurator<TConfig>
{
constructor(
private metadataManager: SettingsManager,
private saleorApiUrl: string,
private metadataKey: string
) {
this.metadataKey = metadataKey;
}
getConfig(): Promise<TConfig | undefined> {
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
if (!data) {
return data;
}
try {
return JSON.parse(data);
} catch (e) {
throw new Error("Invalid metadata value, cant be parsed");
}
});
}
setConfig(config: TConfig): Promise<void> {
return this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(config),
domain: this.saleorApiUrl,
});
}
}

View file

@ -0,0 +1,70 @@
import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
import { Client } from "urql";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateAppMetadataDocument,
} from "../../../generated/graphql";
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
console.log("fetchAllMetadata", error, data);
if (error) {
return [];
}
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
}
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
// to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
.toPromise();
if (idQueryError) {
throw new Error(
"Could not fetch the app id. Please check if auth data for the client are valid."
);
}
const appId = idQueryData?.app?.id;
if (!appId) {
throw new Error("Could not fetch the app ID");
}
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: Client) => {
// 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),
});
};

View file

@ -0,0 +1,154 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Checkbox, FormControl, FormControlLabel, Switch, Typography } from "@material-ui/core";
import {
makeStyles,
List,
ListBody,
ListHeader,
ListItem,
ListItemCell,
ListFooter,
Button,
} from "@saleor/macaw-ui";
import React from "react";
import { Controller, useController, useForm } from "react-hook-form";
import { z } from "zod";
import {
channelSchema,
ChannelSchema,
MergedChannelSchema,
SingleChannelSchema,
SingleProviderSchema,
} from "../../../lib/cms/config";
import ProviderIcon from "../../provider-instances/ui/provider-icon";
const useStyles = makeStyles((theme) => {
return {
item: {
height: "auto !important",
display: "grid",
gridTemplateColumns: "1fr 80px",
},
itemCell: {
display: "flex",
alignItems: "center",
gap: theme.spacing(2),
},
footer: {
display: "flex",
justifyContent: "flex-end",
padding: theme.spacing(2, 4),
},
};
});
interface ChannelConfigurationFormProps {
channel?: MergedChannelSchema | null;
providerInstances: SingleProviderSchema[];
loading: boolean;
onSubmit: (channel: SingleChannelSchema) => any;
}
const ChannelConfigurationForm = ({
channel,
providerInstances,
loading,
onSubmit,
}: ChannelConfigurationFormProps) => {
const styles = useStyles();
const {
control,
setValue,
reset,
resetField,
handleSubmit,
getValues,
formState,
register,
watch,
} = useForm<ChannelSchema>({
resolver: zodResolver(channelSchema),
});
React.useEffect(() => {
if (channel) {
reset(channel);
}
resetField("channelSlug", {
defaultValue: channel?.channelSlug,
});
resetField("enabledProviderInstances", {
defaultValue: channel?.enabledProviderInstances || [],
});
}, [channel, providerInstances]);
const errors = formState.errors;
return (
<form onSubmit={handleSubmit(onSubmit)}>
{!!Object.entries(errors).length && (
<Typography variant="body1" color="error">
Error validating form
</Typography>
)}
<input type="hidden" {...register("channelSlug")} value={channel?.channelSlug} />
<List gridTemplate={["1fr", "checkbox"]}>
<ListHeader>
<ListItem className={styles.item}>
<ListItemCell>CMS provider instance</ListItemCell>
<ListItemCell>Active</ListItemCell>
</ListItem>
</ListHeader>
<ListBody>
{providerInstances.map((providerInstance) => (
<ListItem key={providerInstance.name} className={styles.item}>
<ListItemCell className={styles.itemCell}>
<ProviderIcon providerName={providerInstance.providerName} />
{providerInstance.name}
</ListItemCell>
<ListItemCell padding="checkbox">
<FormControl
{...register("enabledProviderInstances")}
name="enabledProviderInstances"
checked={watch("enabledProviderInstances")?.some(
(formOption) => formOption === providerInstance.id
)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const valueCopy = getValues("enabledProviderInstances")
? [...getValues("enabledProviderInstances")]
: [];
if (event.target.checked) {
valueCopy.push(providerInstance.id);
} else {
const idx = valueCopy.findIndex(
(formOption) => formOption === providerInstance.id
);
valueCopy.splice(idx, 1);
}
resetField("enabledProviderInstances", {
defaultValue: valueCopy,
});
}}
value={providerInstance.name}
component={(props) => <Checkbox {...props} />}
/>
</ListItemCell>
</ListItem>
))}
{/* </>
)}
/> */}
</ListBody>
<ListFooter className={styles.footer}>
<Button variant="primary" disabled={loading} type="submit">
{loading ? "..." : "Save"}
</Button>
</ListFooter>
</List>
</form>
);
};
export default ChannelConfigurationForm;

View file

@ -0,0 +1,122 @@
import { AppPaper } from "../../ui/app-paper";
import { FormControlLabel, Grid, Paper, Radio, RadioGroup, Typography } from "@material-ui/core";
import { Skeleton } from "@material-ui/lab";
import ChannelConfigurationForm from "./channel-configuration-form";
import {
MergedChannelSchema,
ProvidersSchema,
SingleChannelSchema,
SingleProviderSchema,
} from "../../../lib/cms/config";
import { ChannelsErrors, ChannelsLoading } from "./types";
import { makeStyles } from "@saleor/macaw-ui";
import { AppTabNavButton } from "../../ui/app-tab-nav-button";
const useStyles = makeStyles((theme) => ({
textCenter: {
textAlign: "center",
},
}));
const ChannelConfigurationSkeleton = () => {
return (
<AppPaper>
<Grid container spacing={2}>
<Grid item xs={12}>
<Skeleton variant="rect" width={"35%"} height={10} />
</Grid>
<Grid item xs={12}>
<Skeleton variant="rect" width={"100%"} height={30} />
</Grid>
<br />
<Grid item xs={12}>
<Skeleton variant="rect" width={"35%"} height={10} />
</Grid>
<Grid item xs={8}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
<Grid item xs={4}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
<Grid item xs={6}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
<Grid item xs={6}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
<Grid item xs={12}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
</Grid>
</AppPaper>
);
};
interface ChannelConfigurationProps {
activeChannel?: MergedChannelSchema | null;
providerInstances: SingleProviderSchema[];
saveChannel: (channel: SingleChannelSchema) => any;
loading: ChannelsLoading;
errors: ChannelsErrors;
}
const ChannelConfiguration = ({
activeChannel,
providerInstances,
saveChannel,
loading,
errors,
}: ChannelConfigurationProps) => {
const styles = useStyles();
if (loading.fetching || loading.saving) {
return <ChannelConfigurationSkeleton />;
}
if (!activeChannel) {
return (
<AppPaper>
<Typography variant="body1" className={styles.textCenter}>
Please select a channel.
</Typography>
</AppPaper>
);
}
if (!providerInstances.length) {
return (
<AppPaper>
<Typography variant="body1" className={styles.textCenter}>
Please create at least one provider instance before you manage its configuration in
channels.
<br />
<br />
Go to the <AppTabNavButton to="providers">Providers</AppTabNavButton> tab.
</Typography>
</AppPaper>
);
}
return (
<Paper elevation={0}>
{errors.fetching && (
<Typography variant="body1" color="error">
Error fetching available channels
</Typography>
)}
{errors.saving && (
<Typography variant="body1" color="error">
Error saving channel configuration
</Typography>
)}
<ChannelConfigurationForm
channel={activeChannel}
providerInstances={providerInstances}
loading={loading.saving}
onSubmit={saveChannel}
/>
</Paper>
);
};
export default ChannelConfiguration;

View file

@ -0,0 +1,76 @@
import { makeStyles } from "@saleor/macaw-ui";
import {
OffsettedList,
OffsettedListBody,
OffsettedListHeader,
OffsettedListItem,
OffsettedListItemCell,
} from "@saleor/macaw-ui";
import clsx from "clsx";
import { ChannelFragment } from "../../../../generated/graphql";
import { MergedChannelSchema, SingleChannelSchema } from "../../../lib/cms/config";
import ProviderIcon from "../../provider-instances/ui/provider-icon";
const useStyles = makeStyles((theme) => {
return {
headerItem: {
height: "auto !important",
display: "grid",
gridTemplateColumns: "1fr",
},
listItem: {
cursor: "pointer",
height: "auto !important",
display: "grid",
gridTemplateColumns: "1fr",
},
listItemActive: {
border: `2px solid ${theme.palette.primary.main}`,
},
cellSlug: {
fontFamily: "monospace",
opacity: 0.8,
},
};
});
interface ChannelsListItemsProps {
channels: MergedChannelSchema[];
activeChannel?: MergedChannelSchema | null;
setActiveChannel: (channel: MergedChannelSchema | null) => void;
}
const ChannelsListItems = ({
channels,
activeChannel,
setActiveChannel,
}: ChannelsListItemsProps) => {
const styles = useStyles();
return (
<OffsettedList gridTemplate={["1fr", "1fr"]}>
<OffsettedListHeader>
<OffsettedListItem className={styles.headerItem}>
<OffsettedListItemCell>Channel</OffsettedListItemCell>
</OffsettedListItem>
</OffsettedListHeader>
<OffsettedListBody>
{channels.map((channel) => {
return (
<OffsettedListItem
onClick={() => setActiveChannel(channel)}
className={clsx(styles.listItem, {
[styles.listItemActive]: activeChannel?.channelSlug === channel.channelSlug,
})}
key={channel.channelSlug}
>
<OffsettedListItemCell>{channel.channel.name}</OffsettedListItemCell>
</OffsettedListItem>
);
})}
</OffsettedListBody>
</OffsettedList>
);
};
export default ChannelsListItems;

View file

@ -0,0 +1,59 @@
import { Grid } from "@material-ui/core";
import { Skeleton } from "@material-ui/lab";
import { ChannelFragment } from "../../../../generated/graphql";
import { MergedChannelSchema, SingleChannelSchema } from "../../../lib/cms";
import { AppPaper } from "../../ui/app-paper";
import ChannelsListItems from "./channels-list-items";
import { ChannelsErrors, ChannelsLoading } from "./types";
const ChannelsListSkeleton = () => {
return (
<AppPaper>
<Grid container spacing={2}>
<Grid item xs={12}>
<Skeleton variant="rect" width={"45%"} height={10} />
</Grid>
<Grid item xs={12}>
<Skeleton variant="rect" width={"100%"} height={30} />
</Grid>
<Grid item xs={12}>
<Skeleton variant="rect" width={"100%"} height={30} />
</Grid>
</Grid>
</AppPaper>
);
};
interface ChannelsListProps {
channels: MergedChannelSchema[];
activeChannel?: MergedChannelSchema | null;
setActiveChannel: (channel: MergedChannelSchema | null) => void;
loading: ChannelsLoading;
errors: ChannelsErrors;
}
const ChannelsList = ({
channels,
activeChannel,
setActiveChannel,
loading,
errors,
}: ChannelsListProps) => {
if (loading.fetching) {
return <ChannelsListSkeleton />;
}
if (errors.fetching) {
return <div>Error loading channels</div>;
}
return (
<ChannelsListItems
channels={channels}
activeChannel={activeChannel}
setActiveChannel={setActiveChannel}
/>
);
};
export default ChannelsList;

View file

@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
import { MergedChannelSchema, SingleChannelSchema } from "../../../lib/cms/config";
import useProviderInstances from "../../provider-instances/ui/hooks/useProviderInstances";
import { Instructions } from "../../ui/instructions";
import ChannelConfiguration from "./channel-configuration";
import ChannelsList from "./channels-list";
import useChannels from "./hooks/useChannels";
import useSaveChannels from "./hooks/useChannelsFetch";
const Channels = () => {
const { channels, saveChannel, loading, errors } = useChannels();
const { providerInstances } = useProviderInstances();
const [activeChannelSlug, setActiveChannelSlug] = useState<string | null>(
channels.length ? channels[0].channelSlug : null
);
const handleSetActiveChannel = (channel: MergedChannelSchema | null) => {
setActiveChannelSlug(channel?.channelSlug || null);
};
const activeChannel = channels.find((channel) => channel.channelSlug === activeChannelSlug);
return (
<>
<ChannelsList
channels={channels}
activeChannel={activeChannel}
setActiveChannel={handleSetActiveChannel}
loading={loading}
errors={errors}
/>
<ChannelConfiguration
activeChannel={activeChannel}
providerInstances={providerInstances}
saveChannel={saveChannel}
loading={loading}
errors={errors}
/>
<Instructions />
</>
);
};
export default Channels;

View file

@ -0,0 +1,18 @@
import { gql } from "urql";
export const channelFragment = gql`
fragment Channel on Channel {
id
slug
name
}
`;
export const channels = gql`
${channelFragment}
query channels {
channels {
...Channel
}
}
`;

View file

@ -0,0 +1,51 @@
import useChannelsFetch from "./useChannelsFetch";
import { MergedChannelSchema, SingleChannelSchema } from "../../../../lib/cms/config";
import { ChannelsErrors, ChannelsLoading } from "../types";
import { useChannelsQuery } from "../../../../../generated/graphql";
import { useIsMounted } from "usehooks-ts";
const useChannels = () => {
const isMounted = useIsMounted();
const [channelsQueryData, channelsQueryOpts] = useChannelsQuery({
pause: !isMounted,
});
const {
saveChannel: saveChannelFetch,
isSaving,
data: settings,
error: fetchingError,
isFetching,
} = useChannelsFetch();
const saveChannel = (channelToSave: SingleChannelSchema) => {
console.log("saveChannel", channelToSave);
saveChannelFetch(channelToSave);
};
const loading: ChannelsLoading = {
fetching: isFetching || channelsQueryData.fetching,
saving: isSaving,
};
const errors: ChannelsErrors = {
fetching: fetchingError ? Error(fetchingError) : null,
saving: null,
};
const channels =
channelsQueryData.data?.channels?.map(
(channel) =>
({
channelSlug: channel.slug,
enabledProviderInstances: settings
? settings[`${channel.slug}`]?.enabledProviderInstances
: [],
channel: channel,
} as MergedChannelSchema)
) || [];
return { channels, saveChannel, loading, errors };
};
export default useChannels;

View file

@ -0,0 +1,84 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import React from "react";
import {
CMSSchemaChannels,
cmsSchemaChannels,
SingleChannelSchema,
} from "../../../../lib/cms/config";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { ChannelsApiResponse } from "../../../../pages/api/channels";
const useChannelsFetch = () => {
const { appBridgeState } = useAppBridge();
const [isSaving, setIsSaving] = React.useState(false);
const [isFetching, setIsFetching] = React.useState(false);
const [config, setConfig] = React.useState<CMSSchemaChannels | null>(null);
const [validationError, setValidationError] = React.useState<string | null>(null);
const getChannels = async () => {
setIsFetching(true);
const response = await fetch("/api/channels", {
headers: [
["content-type", "application/json"],
[SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!],
[SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!],
],
}).catch((r) => {
setIsFetching(false);
console.error(r);
return r;
});
try {
const result = (await response.json()) as ChannelsApiResponse;
setIsFetching(false);
if (result.success && result.data) {
const config = result.data;
setConfig(config);
}
} catch (error) {
console.log(error);
}
};
const saveChannel = async (config: SingleChannelSchema) => {
try {
setIsSaving(true);
const response = await fetch("/api/channels", {
method: "POST",
headers: [
["content-type", "application/json"],
[SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!],
[SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!],
],
body: JSON.stringify(config),
});
const result = await response.json();
setIsSaving(false);
console.log("saveSettings result", result);
if (result.success && result.data) {
const config = result.data;
console.log("saveSettings config", config);
setConfig(config);
}
} catch (error) {
console.log(error);
}
};
React.useEffect(() => {
getChannels();
}, []);
return { saveChannel, isSaving, data: config, isFetching, error: validationError };
};
export default useChannelsFetch;

View file

@ -0,0 +1,9 @@
export interface ChannelsLoading {
fetching: boolean;
saving: boolean;
}
export interface ChannelsErrors {
fetching?: Error | null;
saving?: Error | null;
}

View file

@ -0,0 +1,9 @@
export const generateUniqueId = () => {
const date = new Date();
const offsetInMinutes = date.getTimezoneOffset();
const randomDate = date.setMinutes(date.getMinutes() + offsetInMinutes).valueOf();
const randomString = (Math.random() + 1).toString(36).substring(7);
return `${randomString}${randomDate}`;
};

View file

@ -0,0 +1,47 @@
import useProviderInstancesFetch from "./useProviderInstancesFetch";
import { SingleProviderSchema } from "../../../../lib/cms/config";
import { ProvidersErrors, ProvidersLoading } from "../types";
const useProviderInstances = () => {
const {
saveProviderInstance: saveProviderInstanceFetch,
deleteProviderInstance: deleteProviderInstanceFetch,
isSaving,
data: settings,
error: fetchingError,
isFetching,
} = useProviderInstancesFetch();
const saveProviderInstance = async (providerInstanceToSave: SingleProviderSchema) => {
console.log("saveProviderInstance", providerInstanceToSave);
return await saveProviderInstanceFetch(providerInstanceToSave);
};
const deleteProviderInstance = async (providerInstanceToDelete: SingleProviderSchema) => {
console.log("deleteProviderInstance", providerInstanceToDelete);
await deleteProviderInstanceFetch(providerInstanceToDelete);
};
const loading: ProvidersLoading = {
fetching: isFetching,
saving: isSaving,
};
const errors: ProvidersErrors = {
fetching: fetchingError ? Error(fetchingError) : null,
saving: null,
};
const providerInstances =
(settings &&
Object.entries(settings).map(([key, values]) => ({
...values,
}))) ||
[];
return { providerInstances, saveProviderInstance, deleteProviderInstance, loading, errors };
};
export default useProviderInstances;

View file

@ -0,0 +1,134 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import React from "react";
import { CMSSchemaProviderInstances, SingleProviderSchema } from "../../../../lib/cms/config";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { ProviderInstancesApiResponse } from "../../../../pages/api/provider-instances";
const useProviderInstancesFetch = () => {
const { appBridgeState } = useAppBridge();
const [isSaving, setIsSaving] = React.useState(false);
const [isFetching, setIsFetching] = React.useState(false);
const [config, setConfig] = React.useState<CMSSchemaProviderInstances | null>(null);
const [validationError, setValidationError] = React.useState<string | null>(null);
const getProviderInstances = async () => {
setIsFetching(true);
const response = await fetch("/api/provider-instances", {
headers: [
["content-type", "application/json"],
[SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!],
[SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!],
],
}).catch((r) => {
setIsFetching(false);
console.error(r);
return r;
});
try {
const result = (await response.json()) as ProviderInstancesApiResponse;
setIsFetching(false);
console.log("getSettings result", result);
if (result.success && result.data) {
// const config = transformSettingsIntoConfig(result.data);
const config = result.data as CMSSchemaProviderInstances;
console.log("getSettings config", config);
// const validation = cmsSchemaProviderInstances.safeParse(config);
// console.log("getSettings validation", validation);
// if (validation.success) {
// setConfig({ ...config, ...(validation.data as CMSSchemaProviderInstances) });
// } else {
// // todo: show toast instead
// setValidationError(validation.error.message);
// }
setConfig(config);
}
} catch (error) {
console.log(error);
}
};
const saveProviderInstance = async (instance: SingleProviderSchema) => {
try {
setIsSaving(true);
const response = await fetch("/api/provider-instances", {
method: "POST",
headers: [
["content-type", "application/json"],
[SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!],
[SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!],
],
body: JSON.stringify(instance),
});
const result = (await response.json()) as ProviderInstancesApiResponse;
setIsSaving(false);
console.log("saveSettings result", result);
if (result.success && result.data) {
const instanceInConfig = result.data as SingleProviderSchema;
console.log("saveSettings config", instanceInConfig);
setConfig({
...config,
[instanceInConfig.id]: instanceInConfig,
});
return instanceInConfig;
}
} catch (error) {
console.log(error);
}
};
const deleteProviderInstance = async (instance: SingleProviderSchema) => {
try {
setIsSaving(true);
const response = await fetch("/api/provider-instances", {
method: "DELETE",
headers: [
["content-type", "application/json"],
[SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!],
[SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!],
],
body: JSON.stringify(instance),
});
const result = await response.json();
setIsSaving(false);
console.log("deleteSettings result", result);
if (result.success && result.data) {
const config = result.data;
console.log("deleteSettings config", config);
setConfig(config);
}
} catch (error) {
console.log(error);
}
};
React.useEffect(() => {
getProviderInstances();
}, []);
return {
saveProviderInstance,
deleteProviderInstance,
isSaving,
data: config,
isFetching,
error: validationError,
};
};
export default useProviderInstancesFetch;

View file

@ -0,0 +1,14 @@
import { getProviderByName } from "../../providers/config";
import Image from "next/image";
interface ProviderIconProps {
providerName: string;
}
const ProviderIcon = ({ providerName }: ProviderIconProps) => {
const provider = getProviderByName(providerName);
return provider ? <Image src={provider.iconSrc} alt={`${provider.label} icon`} /> : null;
};
export default ProviderIcon;

View file

@ -0,0 +1,156 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Checkbox, FormControlLabel, Grid, TextField, Typography } from "@material-ui/core";
import { Button, makeStyles } from "@saleor/macaw-ui";
import React from "react";
import { Controller, DeepRequired, FieldErrorsImpl, Path, useForm } from "react-hook-form";
import { z } from "zod";
import {
providersConfig,
CMSProviderSchema,
providersSchemaSet,
ProvidersSchema,
SingleProviderSchema,
ProviderInstanceSchema,
} from "../../../lib/cms/config";
import { Provider } from "../../providers/config";
const useStyles = makeStyles((theme) => ({
footer: {
display: "flex",
justifyContent: "flex-end",
},
footerComplex: {
display: "flex",
justifyContent: "space-between",
},
}));
interface ProviderInstanceConfigurationFormProps<TProvider extends CMSProviderSchema> {
provider: Provider;
providerInstance?: SingleProviderSchema | null;
onSubmit: (provider: SingleProviderSchema) => any;
onDelete: (provider: SingleProviderSchema) => any;
loading: boolean;
}
const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderSchema>({
provider,
providerInstance,
onSubmit,
onDelete,
loading,
}: ProviderInstanceConfigurationFormProps<TProvider>) => {
const styles = useStyles();
const schema = providersSchemaSet[provider.name as TProvider];
const {
register,
handleSubmit,
getValues,
reset,
resetField,
control,
formState,
trigger,
unregister,
watch,
} = useForm<ProvidersSchema[TProvider]>({
resolver: zodResolver(schema),
});
console.log("form", providerInstance);
React.useEffect(() => {
resetField("providerName" as Path<ProvidersSchema[TProvider]>, {
defaultValue: provider.name,
});
if (providerInstance && providerInstance.providerName === provider.name) {
console.log(providerInstance);
reset(providerInstance as ProvidersSchema[TProvider]);
}
}, [provider, providerInstance]);
const submitHandler = (values: SingleProviderSchema) => {
console.log(values);
onSubmit(values);
};
const fields = providersConfig[provider.name as TProvider].tokens;
const errors = formState.errors;
return (
<form onSubmit={handleSubmit(submitHandler)}>
<Grid container spacing={1}>
{!!Object.entries(errors).length && (
<Typography variant="body1" color="error">
Error validating form
</Typography>
)}
<input
type="hidden"
{...register("id" as Path<ProvidersSchema[TProvider]>)}
value={providerInstance?.id}
/>
<input
type="hidden"
{...register("providerName" as Path<ProvidersSchema[TProvider]>)}
value={provider.name}
/>
<Grid item xs={12}>
<TextField
{...register("name" as Path<ProvidersSchema[TProvider]>)}
label="Custom instance name *"
type="text"
name="name"
InputLabelProps={{
shrink: !!watch("name" as Path<ProvidersSchema[TProvider]>),
}}
fullWidth
error={!!errors.name}
helperText={<>{errors.name?.message}</>}
/>
</Grid>
{fields.map((token) => (
<Grid xs={12} item key={token.name}>
<TextField
{...register(token.name as Path<ProvidersSchema[TProvider]>, {
required: "required" in token && token.required,
})}
// required={"required" in token && token.required}
label={token.label + ("required" in token && token.required ? " *" : "")}
type="password"
name={token.name}
InputLabelProps={{
shrink: !!watch(token.name as Path<ProvidersSchema[TProvider]>),
}}
fullWidth
// @ts-ignore TODO: fix errors typing
error={!!errors[token.name as Path<ProvidersSchema[TProvider]>]}
helperText={
<>
{errors[token.name as Path<ProvidersSchema[TProvider]>]?.message ||
("helpText" in token && token.helpText)}
</>
}
/>
</Grid>
))}
<Grid item xs={12} className={providerInstance ? styles.footerComplex : styles.footer}>
{providerInstance && (
<Button variant="secondary" disabled={loading} onClick={() => onDelete(getValues())}>
Delete
</Button>
)}
<Button variant="primary" disabled={loading} type="submit">
{loading ? "..." : providerInstance ? "Save" : "Add"}
</Button>
</Grid>
</Grid>
</form>
);
};
export default ProviderInstanceConfigurationForm;

View file

@ -0,0 +1,191 @@
import { FormControlLabel, Grid, Radio, RadioGroup, Typography } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
import Image from "next/image";
import React from "react";
import { CMSProviderSchema, providersConfig, SingleProviderSchema } from "../../../lib/cms/config";
import { AppPaper } from "../../ui/app-paper";
import ProviderInstanceConfigurationForm from "./provider-instance-configuration-form";
import { Skeleton } from "@material-ui/lab";
import { ProvidersErrors, ProvidersLoading } from "./types";
import { getProviderByName, Provider } from "../../providers/config";
const useStyles = makeStyles((theme) => ({
radioLabel: {
width: "100%",
padding: theme.spacing(1),
"&:hover": {
backgroundColor:
theme.palette.type === "dark" ? theme.palette.primary.dark : theme.palette.grey[50],
},
},
gridItem: {
display: "flex",
justifyContent: "center",
},
radioLabelActive: {
backgroundColor:
theme.palette.type === "dark" ? theme.palette.primary.dark : theme.palette.grey[50],
},
iconWithLabel: {
display: "flex",
alignItems: "center",
flexDirection: "column",
gap: theme.spacing(1),
},
textCenter: {
textAlign: "center",
},
textHeader: {
textAlign: "center",
margin: theme.spacing(1, 0, 3, 0),
},
}));
const ProviderInstanceConfigurationSkeleton = () => {
return (
<AppPaper>
<Grid container spacing={2}>
<Grid item xs={12}>
<Skeleton variant="rect" width={"35%"} height={10} />
</Grid>
<Grid item xs={12}>
<Skeleton variant="rect" width={"100%"} height={30} />
</Grid>
<br />
<Grid item xs={12}>
<Skeleton variant="rect" width={"35%"} height={10} />
</Grid>
<Grid item xs={8}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
<Grid item xs={4}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
<Grid item xs={6}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
<Grid item xs={6}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
<Grid item xs={12}>
<Skeleton variant="rect" width={"100%"} height={50} />
</Grid>
</Grid>
</AppPaper>
);
};
interface ProviderInstanceConfigurationProps {
activeProviderInstance?: SingleProviderSchema | null;
newProviderInstance?: SingleProviderSchema | null;
saveProviderInstance: (providerInstance: SingleProviderSchema) => any;
deleteProviderInstance: (providerInstance: SingleProviderSchema) => any;
loading: ProvidersLoading;
errors: ProvidersErrors;
}
const ProviderInstanceConfiguration = ({
activeProviderInstance,
newProviderInstance,
saveProviderInstance,
deleteProviderInstance,
loading,
errors,
}: ProviderInstanceConfigurationProps) => {
const [selectedProvider, setSelectedProvider] = React.useState<Provider | undefined>(
getProviderByName(activeProviderInstance?.providerName)
);
const styles = useStyles();
React.useEffect(() => {
const provider = getProviderByName(activeProviderInstance?.providerName);
setSelectedProvider(provider);
}, [activeProviderInstance]);
const handleProviderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const providerName = e.target.value as CMSProviderSchema;
const provider = getProviderByName(providerName);
setSelectedProvider(provider);
};
if (loading.fetching || loading.saving) {
return <ProviderInstanceConfigurationSkeleton />;
}
if (!newProviderInstance && !activeProviderInstance) {
return (
<AppPaper>
<Typography variant="body1" className={styles.textCenter}>
Please select a provider instance or add new one.
</Typography>
</AppPaper>
);
}
return (
<AppPaper>
{errors.fetching && (
<Typography variant="body1" color="error">
Error fetching available providers
</Typography>
)}
{errors.saving && (
<Typography variant="body1" color="error">
Error saving provider instance configuration
</Typography>
)}
{!!newProviderInstance && (
<Typography variant="h3" className={styles.textHeader}>
Add new instance
</Typography>
)}
<RadioGroup value={selectedProvider?.name ?? ""} onChange={handleProviderChange}>
<Grid container justifyContent="center">
{Object.entries(providersConfig).map(([name, config]) => (
<Grid className={styles.gridItem} item xs={4} key={name}>
<FormControlLabel
className={
selectedProvider?.name === name
? `${styles.radioLabelActive} ${styles.radioLabel}`
: styles.radioLabel
}
control={<Radio style={{ display: "none" }} name="provider" value={name} />}
label={
<div className={styles.iconWithLabel}>
<Image src={config.icon} alt={`${config.label} icon`} height={32} width={32} />
<Typography variant="body1">{config.label}</Typography>
</div>
}
labelPlacement="top"
aria-label={config.label}
/>
</Grid>
))}
</Grid>
</RadioGroup>
{selectedProvider ? (
<>
<br />
<ProviderInstanceConfigurationForm
provider={selectedProvider}
providerInstance={activeProviderInstance}
loading={loading.saving}
onSubmit={saveProviderInstance}
onDelete={deleteProviderInstance}
/>
</>
) : (
<>
<br />
<Typography variant="body1" className={styles.textCenter}>
Please select a provider.
</Typography>
</>
)}
</AppPaper>
);
};
export default ProviderInstanceConfiguration;

View file

@ -0,0 +1,96 @@
import {
makeStyles,
OffsettedList,
OffsettedListBody,
OffsettedListHeader,
OffsettedListItem,
OffsettedListItemCell,
} from "@saleor/macaw-ui";
import Image from "next/image";
import clsx from "clsx";
import React from "react";
import { SingleProviderSchema } from "../../../lib/cms/config";
import { getProviderByName } from "../../providers/config";
import ProviderIcon from "./provider-icon";
const useStyles = makeStyles((theme) => {
return {
headerItem: {
height: "auto !important",
display: "grid",
gridTemplateColumns: "1fr",
},
listItem: {
cursor: "pointer",
height: "auto !important",
display: "grid",
gridTemplateColumns: "1fr",
},
listItemActive: {
border: `2px solid ${theme.palette.primary.main}`,
},
cell: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing(1),
},
};
});
export interface ProviderItemToken {
id: string;
name: string;
label: string;
value: string;
}
export interface ProviderItem {
id: string;
label: string;
name: string;
iconSrc: string;
tokens: ProviderItemToken[];
}
interface ProviderInstancesListItemsProps {
providerInstances: SingleProviderSchema[];
activeProviderInstance?: SingleProviderSchema | null;
setActiveProviderInstance: (provider: SingleProviderSchema) => void;
}
const ProviderInstancesListItems = ({
providerInstances,
activeProviderInstance,
setActiveProviderInstance,
}: ProviderInstancesListItemsProps) => {
const styles = useStyles();
return (
<OffsettedList gridTemplate={["1fr", "1fr"]}>
<OffsettedListHeader>
<OffsettedListItem className={styles.headerItem}>
<OffsettedListItemCell>CMS provider instance</OffsettedListItemCell>
</OffsettedListItem>
</OffsettedListHeader>
<OffsettedListBody>
{providerInstances.map((providerInstance) => (
<OffsettedListItem
onClick={() => setActiveProviderInstance(providerInstance)}
className={clsx(styles.listItem, {
[styles.listItemActive]: activeProviderInstance?.id === providerInstance.id,
})}
key={providerInstance.id}
>
<OffsettedListItemCell className={styles.cell}>
{providerInstance.name}
<ProviderIcon providerName={providerInstance.providerName} />
</OffsettedListItemCell>
</OffsettedListItem>
))}
</OffsettedListBody>
</OffsettedList>
);
};
export default ProviderInstancesListItems;

View file

@ -0,0 +1,95 @@
import { Grid } from "@material-ui/core";
import { Add } from "@material-ui/icons";
import { Skeleton } from "@material-ui/lab";
import { Button, makeStyles } from "@saleor/macaw-ui";
import { SingleProviderSchema } from "../../../lib/cms/config";
import { AppPaper } from "../../ui/app-paper";
import ProviderInstancesListItems, { ProviderItem } from "./provider-instances-list-items";
import { ProvidersErrors, ProvidersLoading } from "./types";
const useStyles = makeStyles((theme) => {
return {
button: {
padding: theme.spacing(1, 2),
justifyContent: "flex-start",
},
};
});
const ProviderInstancesListSkeleton = () => {
return (
<AppPaper>
<Grid container spacing={2}>
<Grid item xs={12}>
<Skeleton variant="rect" width={"45%"} height={10} />
</Grid>
<Grid item xs={12}>
<Skeleton variant="rect" width={"100%"} height={30} />
</Grid>
<Grid item xs={12}>
<Skeleton variant="rect" width={"100%"} height={30} />
</Grid>
</Grid>
</AppPaper>
);
};
interface ProviderInstancesListProps {
providerInstances: SingleProviderSchema[];
activeProviderInstance?: SingleProviderSchema | null;
newProviderInstance?: SingleProviderSchema | null;
setActiveProviderInstance: (providerInstance: SingleProviderSchema | null) => void;
requestAddProviderInstance: () => void;
loading: ProvidersLoading;
errors: ProvidersErrors;
}
const ProviderInstancesList = ({
providerInstances,
activeProviderInstance,
newProviderInstance,
setActiveProviderInstance,
requestAddProviderInstance,
loading,
errors,
}: ProviderInstancesListProps) => {
const styles = useStyles();
const handleSetActiveProviderInstance = (providerInstance: SingleProviderSchema) => {
setActiveProviderInstance(providerInstance);
};
if (loading.fetching) {
return <ProviderInstancesListSkeleton />;
}
if (errors.fetching) {
return <div>Error loading providers</div>;
}
return (
<Grid container spacing={1}>
{!!providerInstances.length && (
<Grid item xs={12}>
<ProviderInstancesListItems
providerInstances={providerInstances}
activeProviderInstance={activeProviderInstance}
setActiveProviderInstance={handleSetActiveProviderInstance}
/>
</Grid>
)}
<Grid item xs={12}>
<Button
startIcon={<Add />}
className={styles.button}
fullWidth
onClick={requestAddProviderInstance}
>
Add provider
</Button>
</Grid>
</Grid>
);
};
export default ProviderInstancesList;

View file

@ -0,0 +1,77 @@
import ProviderInstancesList from "./provider-instances-list";
import { Instructions } from "../../ui/instructions";
import ProviderInstanceConfiguration from "./provider-instance-configuration";
import { providersConfig, ProvidersSchema, SingleProviderSchema } from "../../../lib/cms/config";
import { useEffect, useState } from "react";
import useProviderInstances from "./hooks/useProviderInstances";
const ProviderInstances = () => {
const { providerInstances, saveProviderInstance, deleteProviderInstance, loading, errors } =
useProviderInstances();
const [activeProviderInstanceId, setActiveProviderInstanceId] = useState<string | null>(
providerInstances.length ? providerInstances[0].id : null
);
const [newProviderInstance, setNewProviderInstance] = useState<SingleProviderSchema | null>(null);
const handleSetActiveProviderInstance = (providerInstance: SingleProviderSchema | null) => {
setActiveProviderInstanceId(providerInstance?.id || null);
if (newProviderInstance) {
setNewProviderInstance(null);
}
};
const handleAddNewProviderInstance = () => {
setNewProviderInstance({} as SingleProviderSchema);
if (activeProviderInstanceId) {
setActiveProviderInstanceId(null);
}
};
const handleSaveProviderInstance = async (providerInstance: SingleProviderSchema) => {
const savedProviderInstance = await saveProviderInstance(providerInstance);
if (newProviderInstance) {
setNewProviderInstance(null);
}
if (newProviderInstance && savedProviderInstance) {
setActiveProviderInstanceId(savedProviderInstance.id);
}
};
const handleDeleteProviderInstance = async (providerInstance: SingleProviderSchema) => {
await deleteProviderInstance(providerInstance);
if (activeProviderInstanceId === providerInstance.id) {
setActiveProviderInstanceId(null);
}
};
const activeProviderInstance = providerInstances.find(
(providerInstance) => providerInstance.id === activeProviderInstanceId
);
return (
<>
<ProviderInstancesList
providerInstances={providerInstances}
activeProviderInstance={activeProviderInstance}
newProviderInstance={newProviderInstance}
setActiveProviderInstance={handleSetActiveProviderInstance}
requestAddProviderInstance={handleAddNewProviderInstance}
loading={loading}
errors={errors}
/>
<ProviderInstanceConfiguration
activeProviderInstance={activeProviderInstance}
newProviderInstance={newProviderInstance}
saveProviderInstance={handleSaveProviderInstance}
deleteProviderInstance={handleDeleteProviderInstance}
loading={loading}
errors={errors}
/>
<Instructions />
</>
);
};
export default ProviderInstances;

View file

@ -0,0 +1,9 @@
export interface ProvidersLoading {
fetching: boolean;
saving: boolean;
}
export interface ProvidersErrors {
fetching?: Error | null;
saving?: Error | null;
}

View file

@ -0,0 +1,72 @@
import { z } from "zod";
import { ContentfulIcon, DatocmsIcon, StrapiIcon } from "../../assets";
export const CMS_ID_KEY = "cmsId";
export interface ProviderToken {
name: string;
label: string;
required?: boolean;
}
export interface Provider {
name: string;
label: string;
iconSrc: string;
tokens: ProviderToken[];
}
export type ProviderMap = Record<string, Provider>;
export const providersMap: ProviderMap = {
contentful: {
name: "contentful",
label: "Contentful",
iconSrc: ContentfulIcon,
tokens: [
{ name: "baseUrl", label: "Base URL" },
{ name: "token", label: "Token" },
{ name: "environment", label: "Environment" },
{ name: "spaceId", label: "Space ID" },
{ name: "contentId", label: "Content ID" },
{ name: "locale", label: "Locale" },
],
},
strapi: {
name: "strapi",
label: "Strapi",
iconSrc: StrapiIcon,
tokens: [
{ name: "baseUrl", label: "Base Url" },
{ name: "token", label: "Token" },
],
},
datocms: {
name: "datocms",
label: "DatoCMS",
iconSrc: DatocmsIcon,
tokens: [
{
name: "token",
label: "API Token (with access to Content Management API)",
required: true,
},
{
name: "itemTypeId",
label: "Item Type ID (either number or text)",
required: true,
},
{
name: "baseUrl",
label: "Base URL",
},
{
name: "environment",
label: "Environment",
},
],
},
};
export const getProviderByName = (name?: keyof ProviderMap): Provider | undefined =>
name ? providersMap[name] : undefined;

View file

@ -0,0 +1,14 @@
import { makeStyles } from "@saleor/macaw-ui";
export const useStyles = makeStyles({
root: {
maxWidth: 1180,
margin: "0 auto",
},
});
export const AppContainer = ({ children }: { children: React.ReactNode }) => {
const styles = useStyles();
return <div className={styles.root}>{children}</div>;
};

View file

@ -0,0 +1,19 @@
import { makeStyles } from "@saleor/macaw-ui";
import { PropsWithChildren } from "react";
export const useStyles = makeStyles({
root: {
display: "grid",
gridTemplateColumns: "280px auto 280px",
alignItems: "start",
gap: 32,
},
});
export type Props = PropsWithChildren<{}>;
export const AppGrid = ({ children }: Props) => {
const styles = useStyles();
return <div className={styles.root}>{children}</div>;
};

View file

@ -0,0 +1,11 @@
import React from "react";
import { AppContainer } from "./app-container";
import { AppGrid } from "./app-grid";
export const AppLayout = ({ children }: { children: React.ReactNode }) => {
return (
<AppContainer>
<AppGrid>{children}</AppGrid>
</AppContainer>
);
};

View file

@ -0,0 +1,18 @@
import { Link } from "@material-ui/core";
import { PropsWithChildren } from "react";
import { useAppRedirect } from "../../lib/app/redirect";
export type AppLinkProps = {
path?: string;
href: string;
};
export const AppLink = ({ children, ...props }: PropsWithChildren<AppLinkProps>) => {
const { redirect } = useAppRedirect();
return (
<Link target={"_blank"} href={props.href} rel="noreferrer" onClick={() => redirect(props)}>
{children}
</Link>
);
};

View file

@ -0,0 +1,18 @@
import { Paper } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
import React from "react";
const useStyles = makeStyles({
root: {
padding: "16px",
},
});
export const AppPaper = ({ children }: { children: React.ReactNode }) => {
const styles = useStyles();
return (
<Paper elevation={0} className={styles.root}>
{children}
</Paper>
);
};

View file

@ -0,0 +1,23 @@
import { Link, makeStyles } from "@material-ui/core";
import { useRouter } from "next/router";
import { PropsWithChildren } from "react";
import { AppTab } from "./app-tabs";
const useStyles = makeStyles((theme) => ({
button: {
fontSize: "inherit",
fontFamily: "inherit",
verticalAlign: "unset",
},
}));
export const AppTabNavButton = ({ children, to }: PropsWithChildren<{ to: AppTab }>) => {
const styles = useStyles();
const router = useRouter();
return (
<Link className={styles.button} component="button" onClick={() => router.push(to)}>
{children}
</Link>
);
};

View file

@ -0,0 +1,43 @@
import { makeStyles, PageTab, PageTabs } from "@saleor/macaw-ui";
import { useRouter } from "next/router";
const useStyles = makeStyles({
tabs: {
margin: "16px 0",
},
});
const tabs = {
channels: {
label: "Channels",
},
providers: {
label: "Providers",
},
};
export type AppTab = keyof typeof tabs;
interface AppTabsProps {
activeTab: keyof typeof tabs;
}
const AppTabs = ({ activeTab }: AppTabsProps) => {
const styles = useStyles();
const router = useRouter();
const handleTabChange = (value: string) => {
router.push(value);
};
return (
<div className={styles.tabs}>
<PageTabs value={activeTab} onChange={handleTabChange}>
{Object.entries(tabs).map(([key, config]) => (
<PageTab key={key} value={key} label={config.label} />
))}
</PageTabs>
</div>
);
};
export default AppTabs;

View file

@ -0,0 +1,53 @@
import { Typography } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
import { AppLink } from "./app-link";
import { AppPaper } from "./app-paper";
import { AppTabNavButton } from "./app-tab-nav-button";
const useStyles = makeStyles(() => ({
root: {
display: "flex",
flexDirection: "column",
gap: "16px",
},
list: {
paddingLeft: "16px",
margin: 0,
color: "inherit",
},
}));
export const Instructions = () => {
const styles = useStyles();
return (
<AppPaper>
<div className={styles.root}>
<Typography variant="h4">
Use external service for cms product data syncronization
</Typography>
<Typography variant="body1">
<ol className={styles.list}>
<li>
In the CMS App, go to the <AppTabNavButton to="providers">Providers</AppTabNavButton>{" "}
tab to add an instance of your provider. Click <q>Add provider</q>, and select the cms
provider you want to use. Fill in the configuration form and hit <q>Save</q>.
</li>
<li>
Go to the <AppTabNavButton to="channels">Channels</AppTabNavButton> tab. Select a
channel. In the <q>Channel cms provider</q> field, select the created instance. Fill
in the rest of the form, and hit <q>Save</q>.
</li>
<li>
Saleor will now use the channel&#39;s configured cms provider for product
syncronisation once it is created, updated or deleted.
</li>
<li>
To see the effect, go to <AppLink href="/products">Products</AppLink>. Add, update or
delete channel listing for any product variant.
</li>
</ol>
</Typography>
</div>
</AppPaper>
);
};

View file

@ -0,0 +1,98 @@
import "../styles/globals.css";
import { Theme } from "@material-ui/core/styles";
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
import {
dark,
light,
SaleorThemeColors,
ThemeProvider as MacawUIThemeProvider,
} from "@saleor/macaw-ui";
import React, { PropsWithChildren, ReactElement, ReactNode, useEffect } from "react";
import { AppProps } from "next/app";
import GraphQLProvider from "../providers/GraphQLProvider";
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import { NoSSRWrapper } from "../lib/no-ssr-wrapper";
import { NextPage } from "next";
const themeOverrides: Partial<Theme> = {
/**
* You can override MacawUI theme here
*/
};
/**
* Ensure instance is a singleton.
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
*/
const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
type PalettesOverride = Record<"light" | "dark", SaleorThemeColors>;
/**
* Temporary override of colors, to match new dashboard palette.
* Long term this will be replaced with Macaw UI 2.x with up to date design tokens
*/
const palettes: PalettesOverride = {
light: {
...light,
background: {
default: "#fff",
paper: "#fff",
},
},
dark: {
...dark,
background: {
default: "hsla(211, 42%, 14%, 1)",
paper: "hsla(211, 42%, 14%, 1)",
},
},
};
/**
* That's a hack required by Macaw-UI incompatibility with React@18
*/
const ThemeProvider = MacawUIThemeProvider as React.FC<
PropsWithChildren<{ overrides?: Partial<Theme>; ssr: boolean; palettes: PalettesOverride }>
>;
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
};
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
};
function NextApp({ Component, pageProps }: AppPropsWithLayout) {
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout ?? ((page) => page);
/**
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
*/
useEffect(() => {
const jssStyles = document.querySelector("#jss-server-side");
if (jssStyles) {
jssStyles?.parentElement?.removeChild(jssStyles);
}
}, []);
return (
<NoSSRWrapper>
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
<GraphQLProvider>
<ThemeProvider palettes={palettes} overrides={themeOverrides} ssr>
<ThemeSynchronizer />
<RoutePropagator />
{getLayout(<Component {...pageProps} />)}
</ThemeProvider>
</GraphQLProvider>
</AppBridgeProvider>
</NoSSRWrapper>
);
}
export default NextApp;

View file

@ -0,0 +1,80 @@
import { createProtectedHandler, NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next";
import type { NextApiRequest, NextApiResponse } from "next";
import { saleorApp } from "../../../saleor-app";
import { CMSSchemaChannels, SingleChannelSchema } from "../../lib/cms/config";
import { createClient } from "../../lib/graphql";
import { createSettingsManager } from "../../lib/metadata";
export type SettingsUpdateApiRequest = SingleChannelSchema;
export interface ChannelsApiResponse {
success: boolean;
data?: CMSSchemaChannels;
}
// todo: implement
// const obfuscateSecret = (secret: string) => {
// return "*".repeat(secret.length - 4) + secret.substring(secret.length - 4);
// };
const handler: NextProtectedApiHandler = async (
req: NextApiRequest,
res: NextApiResponse<ChannelsApiResponse>,
context
) => {
const { authData } = context;
const client = createClient(authData.saleorApiUrl, async () => ({
token: authData.token,
}));
const settingsManager = createSettingsManager(client);
if (req.method === "GET") {
const settingsManagerValue = await settingsManager.get("channels");
return res.status(200).json({
success: true,
data: settingsManagerValue && JSON.parse(settingsManagerValue),
});
} else if (req.method === "POST") {
const channelSettings = req.body as SingleChannelSchema;
if (channelSettings) {
const currentSettings = await settingsManager.get("channels");
const currentSettingsParsed = currentSettings && JSON.parse(currentSettings);
const settings = [
{
key: "channels",
value: JSON.stringify({
...currentSettingsParsed,
[channelSettings.channelSlug]: channelSettings,
}),
},
];
try {
await settingsManager.set(settings);
return res.status(200).json({
success: true,
data: {
...currentSettingsParsed,
[channelSettings.channelSlug]: channelSettings,
},
});
} catch (error) {
return res.status(500).json({ success: false });
}
} else {
console.log("Missing Settings Values");
return res.status(400).json({ success: false });
}
}
return res.status(405).end();
};
export default createProtectedHandler(handler, saleorApp.apl, ["MANAGE_APPS"]);

View file

@ -0,0 +1,28 @@
import { AppManifest } from "@saleor/app-sdk/types";
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
import packageJson from "../../../package.json";
import { productVariantUpdatedWebhook } from "./webhooks/product-variant-updated";
import { productVariantCreatedWebhook } from "./webhooks/product-variant-created";
import { productVariantDeletedWebhook } from "./webhooks/product-variant-deleted";
export default createManifestHandler({
async manifestFactory(context) {
const manifest: AppManifest = {
name: "CMS",
tokenTargetUrl: `${context.appBaseUrl}/api/register`,
appUrl: context.appBaseUrl,
permissions: ["MANAGE_PRODUCTS"],
id: "saleor.app.cms",
version: packageJson.version,
webhooks: [
productVariantCreatedWebhook.getWebhookManifest(context.appBaseUrl),
productVariantUpdatedWebhook.getWebhookManifest(context.appBaseUrl),
productVariantDeletedWebhook.getWebhookManifest(context.appBaseUrl),
],
extensions: [],
};
return manifest;
},
});

View file

@ -0,0 +1,116 @@
import { createProtectedHandler, NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next";
import type { NextApiRequest, NextApiResponse } from "next";
import { saleorApp } from "../../../saleor-app";
import { CMSSchemaProviderInstances, SingleProviderSchema } from "../../lib/cms/config";
import { createClient } from "../../lib/graphql";
import { createSettingsManager } from "../../lib/metadata";
import { generateUniqueId } from "../../modules/cms/utils";
export type SettingsUpdateApiRequest = SingleProviderSchema;
export interface ProviderInstancesApiResponse {
success: boolean;
data?: CMSSchemaProviderInstances | SingleProviderSchema;
}
// todo: implement
// const obfuscateSecret = (secret: string) => {
// return "*".repeat(secret.length - 4) + secret.substring(secret.length - 4);
// };
const handler: NextProtectedApiHandler = async (
req: NextApiRequest,
res: NextApiResponse<ProviderInstancesApiResponse>,
context
) => {
const { authData } = context;
const client = createClient(authData.saleorApiUrl, async () => ({
token: authData.token,
}));
const settingsManager = createSettingsManager(client);
if (req.method === "GET") {
const settingsManagerValue = await settingsManager.get("providerInstances");
return res.status(200).json({
success: true,
data: settingsManagerValue && JSON.parse(settingsManagerValue),
});
} else if (req.method === "POST") {
const providerInstanceSettings = req.body as SingleProviderSchema;
if (providerInstanceSettings) {
const currentSettings = await settingsManager.get("providerInstances");
const currentSettingsParsed = currentSettings && JSON.parse(currentSettings);
const providerInstanceSettingsWithId: SingleProviderSchema = {
...providerInstanceSettings,
id: providerInstanceSettings.id || generateUniqueId(),
};
const settings = [
{
key: "providerInstances",
value: JSON.stringify({
...currentSettingsParsed,
[providerInstanceSettingsWithId.id]: providerInstanceSettingsWithId,
}),
},
];
try {
await settingsManager.set(settings);
return res.status(200).json({
success: true,
data: providerInstanceSettingsWithId,
});
} catch (error) {
return res.status(500).json({ success: false });
}
} else {
console.log("Missing Settings Values");
return res.status(400).json({ success: false });
}
} else if (req.method === "DELETE") {
const providerInstanceSettings = req.body as SingleProviderSchema;
if (providerInstanceSettings) {
const currentSettings = await settingsManager.get("providerInstances");
const currentSettingsParsed = currentSettings && JSON.parse(currentSettings);
const { [providerInstanceSettings.id]: _, ...rest } = currentSettingsParsed;
const settings = [
{
key: "providerInstances",
value: JSON.stringify({
...rest,
}),
},
];
try {
await settingsManager.set(settings);
return res.status(200).json({
success: true,
data: {
...rest,
},
});
} catch (error) {
return res.status(500).json({ success: false });
}
} else {
console.log("Missing Settings Values");
return res.status(400).json({ success: false });
}
}
return res.status(405).end();
};
export default createProtectedHandler(handler, saleorApp.apl, ["MANAGE_APPS"]);

View file

@ -0,0 +1,9 @@
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../../saleor-app";
/**
* Required endpoint, called by Saleor to install app.
* It will exchange tokens with app, so saleorApp.apl will contain token
*/
export default createAppRegisterHandler(saleorApp);

View file

@ -0,0 +1,99 @@
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import { gql } from "urql";
import {
ProductVariantCreatedWebhookPayloadFragment,
UntypedWebhookProductVariantFragmentDoc,
} from "../../../../generated/graphql";
import { saleorApp } from "../../../../saleor-app";
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
import {
createCmsOperations,
executeCmsOperations,
executeMetadataUpdate,
} from "../../../lib/cms/client";
import { logger as pinoLogger } from "../../../lib/logger";
export const config = {
api: {
bodyParser: false,
},
};
export const ProductVariantCreatedWebhookPayload = gql`
${UntypedWebhookProductVariantFragmentDoc}
fragment ProductVariantCreatedWebhookPayload on ProductVariantCreated {
productVariant {
...WebhookProductVariant
}
}
`;
export const ProductVariantCreatedSubscription = gql`
${ProductVariantCreatedWebhookPayload}
subscription ProductVariantCreated {
event {
...ProductVariantCreatedWebhookPayload
}
}
`;
export const productVariantCreatedWebhook =
new SaleorAsyncWebhook<ProductVariantCreatedWebhookPayloadFragment>({
name: "Cms-hub product variant created webhook",
webhookPath: "api/webhooks/product-variant-created",
event: "PRODUCT_VARIANT_CREATED",
apl: saleorApp.apl,
query: ProductVariantCreatedSubscription,
});
export const handler: NextWebhookApiHandler<ProductVariantCreatedWebhookPayloadFragment> = async (
req,
res,
context
) => {
const { productVariant } = context.payload;
const logger = pinoLogger.child({
productVariant,
});
logger.debug("Called webhook PRODUCT_VARIANT_CREATED");
if (!productVariant) {
return res.status(500).json({
errors: [
"No product variant data payload provided. Cannot process product variant syncronisation in CMS providers.",
],
});
}
const productVariantChannels = getChannelsSlugsFromSaleorItem(productVariant);
const cmsOperations = await createCmsOperations({
context,
productVariantChannels: productVariantChannels,
productVariantCmsKeys: [],
});
const {
cmsProviderInstanceProductVariantIdsToCreate,
cmsProviderInstanceProductVariantIdsToDelete,
cmsErrors,
} = await executeCmsOperations({
cmsOperations,
productVariant,
});
await executeMetadataUpdate({
context,
productVariant,
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
cmsProviderInstanceIdsToDelete: cmsProviderInstanceProductVariantIdsToDelete,
});
if (!cmsErrors.length) {
return res.status(200).end();
} else {
return res.status(500).json({ errors: cmsErrors });
}
};
export default productVariantCreatedWebhook.createHandler(handler);

View file

@ -0,0 +1,99 @@
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import { gql } from "urql";
import {
ProductVariantDeletedWebhookPayloadFragment,
UntypedWebhookProductVariantFragmentDoc,
} from "../../../../generated/graphql";
import { saleorApp } from "../../../../saleor-app";
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
import {
createCmsOperations,
executeCmsOperations,
executeMetadataUpdate,
} from "../../../lib/cms/client";
import { logger as pinoLogger } from "../../../lib/logger";
export const config = {
api: {
bodyParser: false,
},
};
export const ProductVariantDeletedWebhookPayload = gql`
${UntypedWebhookProductVariantFragmentDoc}
fragment ProductVariantDeletedWebhookPayload on ProductVariantDeleted {
productVariant {
...WebhookProductVariant
}
}
`;
export const ProductVariantDeletedSubscription = gql`
${ProductVariantDeletedWebhookPayload}
subscription ProductVariantDeleted {
event {
...ProductVariantDeletedWebhookPayload
}
}
`;
export const productVariantDeletedWebhook =
new SaleorAsyncWebhook<ProductVariantDeletedWebhookPayloadFragment>({
name: "Cms-hub product variant deleted webhook",
webhookPath: "api/webhooks/product-variant-deleted",
event: "PRODUCT_VARIANT_DELETED",
apl: saleorApp.apl,
query: ProductVariantDeletedSubscription,
});
export const handler: NextWebhookApiHandler<ProductVariantDeletedWebhookPayloadFragment> = async (
req,
res,
context
) => {
const { productVariant } = context.payload;
const logger = pinoLogger.child({
productVariant,
});
logger.debug("Called webhook PRODUCT_VARIANT_DELETED");
if (!productVariant) {
return res.status(500).json({
errors: [
"No product variant data payload provided. Cannot process product variant syncronisation in CMS providers.",
],
});
}
const productVariantCmsKeys = getCmsKeysFromSaleorItem(productVariant);
const cmsOperations = await createCmsOperations({
context,
productVariantChannels: [],
productVariantCmsKeys: productVariantCmsKeys,
});
const {
cmsProviderInstanceProductVariantIdsToCreate,
cmsProviderInstanceProductVariantIdsToDelete,
cmsErrors,
} = await executeCmsOperations({
cmsOperations,
productVariant,
});
await executeMetadataUpdate({
context,
productVariant,
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
cmsProviderInstanceIdsToDelete: cmsProviderInstanceProductVariantIdsToDelete,
});
if (!cmsErrors.length) {
return res.status(200).end();
} else {
return res.status(500).json({ errors: cmsErrors });
}
};
export default productVariantDeletedWebhook.createHandler(handler);

View file

@ -0,0 +1,102 @@
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import { gql } from "urql";
import {
ProductVariantUpdatedWebhookPayloadFragment,
UntypedWebhookProductVariantFragmentDoc,
} from "../../../../generated/graphql";
import { saleorApp } from "../../../../saleor-app";
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels";
import {
createCmsOperations,
executeCmsOperations,
executeMetadataUpdate,
} from "../../../lib/cms/client";
import { logger as pinoLogger } from "../../../lib/logger";
export const config = {
api: {
bodyParser: false,
},
};
export const ProductVariantUpdatedWebhookPayload = gql`
${UntypedWebhookProductVariantFragmentDoc}
fragment ProductVariantUpdatedWebhookPayload on ProductVariantUpdated {
productVariant {
...WebhookProductVariant
}
}
`;
export const ProductVariantUpdatedSubscription = gql`
${ProductVariantUpdatedWebhookPayload}
subscription ProductVariantUpdated {
event {
...ProductVariantUpdatedWebhookPayload
}
}
`;
export const productVariantUpdatedWebhook =
new SaleorAsyncWebhook<ProductVariantUpdatedWebhookPayloadFragment>({
name: "Cms-hub product variant updated webhook",
webhookPath: "api/webhooks/product-variant-updated",
event: "PRODUCT_VARIANT_UPDATED",
apl: saleorApp.apl,
query: ProductVariantUpdatedSubscription,
});
export const handler: NextWebhookApiHandler<ProductVariantUpdatedWebhookPayloadFragment> = async (
req,
res,
context
) => {
// * product_updated event triggers on product_created as well 🤷
const { productVariant } = context.payload;
const logger = pinoLogger.child({
productVariant,
});
logger.debug("Called webhook PRODUCT_VARIANT_UPDATED");
if (!productVariant) {
return res.status(500).json({
errors: [
"No product variant data payload provided. Cannot process product variant syncronisation in CMS providers.",
],
});
}
const productVariantChannels = getChannelsSlugsFromSaleorItem(productVariant);
const productVariantCmsKeys = getCmsKeysFromSaleorItem(productVariant);
const cmsOperations = await createCmsOperations({
context,
productVariantChannels: productVariantChannels,
productVariantCmsKeys: productVariantCmsKeys,
});
const {
cmsProviderInstanceProductVariantIdsToCreate,
cmsProviderInstanceProductVariantIdsToDelete,
cmsErrors,
} = await executeCmsOperations({
cmsOperations,
productVariant,
});
await executeMetadataUpdate({
context,
productVariant,
cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate,
cmsProviderInstanceIdsToDelete: cmsProviderInstanceProductVariantIdsToDelete,
});
if (!cmsErrors.length) {
return res.status(200).end();
} else {
return res.status(500).json({ errors: cmsErrors });
}
};
export default productVariantUpdatedWebhook.createHandler(handler);

View file

@ -0,0 +1,21 @@
import AppTabs from "../modules/ui/app-tabs";
import Channels from "../modules/channels/ui/channels";
import { AppContainer } from "../modules/ui/app-container";
import { AppLayout } from "../modules/ui/app-layout";
import { NextPageWithLayout } from "./_app";
import { ReactElement } from "react";
const Page: NextPageWithLayout = () => <Channels />;
Page.getLayout = function getLayout(page: ReactElement) {
return (
<main>
<AppContainer>
<AppTabs activeTab="channels" />
</AppContainer>
<AppLayout>{page}</AppLayout>
</main>
);
};
export default Page;

View file

@ -0,0 +1,32 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { NextPage } from "next";
import dynamic from "next/dynamic";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useIsMounted } from "usehooks-ts";
/**
* This is page publicly accessible from your app.
* You should probably remove it.
*/
const IndexPage: NextPage = () => {
const { appBridgeState } = useAppBridge();
const isMounted = useIsMounted();
const { replace } = useRouter();
useEffect(() => {
if (isMounted() && appBridgeState?.ready) {
replace("/providers");
}
}, [isMounted, appBridgeState?.ready]);
return (
<div>
<h1>Saleor CMS Hub</h1>
<p>This is Saleor App that allows to use external cms providers to sync products data.</p>
<p>Install the app in your Saleor instance and open it in Dashboard.</p>
</div>
);
};
export default IndexPage;

View file

@ -0,0 +1,21 @@
import { AppContainer } from "../modules/ui/app-container";
import { AppLayout } from "../modules/ui/app-layout";
import AppTabs from "../modules/ui/app-tabs";
import ProviderInstances from "../modules/provider-instances/ui/provider-instances";
import { NextPageWithLayout } from "./_app";
import { ReactElement } from "react";
const Page: NextPageWithLayout = () => <ProviderInstances />;
Page.getLayout = function getLayout(page: ReactElement) {
return (
<main>
<AppContainer>
<AppTabs activeTab="providers" />
</AppContainer>
<AppLayout>{page}</AppLayout>
</main>
);
};
export default Page;

View file

@ -0,0 +1,18 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { PropsWithChildren } from "react";
import { Provider } from "urql";
import { createClient } from "../lib/graphql";
function GraphQLProvider(props: PropsWithChildren<{}>) {
const { appBridgeState } = useAppBridge();
const saleorApiUrl = appBridgeState?.saleorApiUrl!;
const client = createClient(saleorApiUrl, async () => ({
token: appBridgeState?.token!,
}));
return <Provider value={client} {...props} />;
}
export default GraphQLProvider;

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,4 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,22 @@
body {
font-family: Inter, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
"Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
color: #111;
padding: 1rem 2rem;
}
code {
background: #f6f8fa;
border: 1px solid #eaeaea;
border-radius: 5px;
display: inline-block;
margin-top: 10px;
padding: 0.75rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
}
code::before {
content: "$ ";
opacity: 0.6;
}

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