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:
parent
e93a4dc1c8
commit
a07f337992
102 changed files with 35438 additions and 19 deletions
9
apps/cms/.env.example
Normal file
9
apps/cms/.env.example
Normal 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
4
apps/cms/.eslintrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"root": true,
|
||||
"extends": ["saleor"]
|
||||
}
|
20
apps/cms/.graphqlrc.yml
Normal file
20
apps/cms/.graphqlrc.yml
Normal 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
142
apps/cms/CONTRIBUTING.md
Normal 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
44
apps/cms/README.md
Normal file
|
@ -0,0 +1,44 @@
|
|||

|
||||
|
||||
<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).
|
14
apps/cms/docs/contentful.md
Normal file
14
apps/cms/docs/contentful.md
Normal 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
12
apps/cms/docs/datocms.md
Normal 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
10
apps/cms/docs/strapi.md
Normal 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).
|
0
apps/cms/graphql/fragments/.gitkeep
Normal file
0
apps/cms/graphql/fragments/.gitkeep
Normal file
36
apps/cms/graphql/fragments/WebhookProductVariant.graphql
Normal file
36
apps/cms/graphql/fragments/WebhookProductVariant.graphql
Normal 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
|
||||
}
|
||||
}
|
0
apps/cms/graphql/mutations/.gitkeep
Normal file
0
apps/cms/graphql/mutations/.gitkeep
Normal file
9
apps/cms/graphql/mutations/DeleteProductMetadata.graphql
Normal file
9
apps/cms/graphql/mutations/DeleteProductMetadata.graphql
Normal file
|
@ -0,0 +1,9 @@
|
|||
mutation DeleteMetadata($id: ID!, $keys: [String!]!) {
|
||||
deleteMetadata(id: $id, keys: $keys) {
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
10
apps/cms/graphql/mutations/UpdateAppMetadata.graphql
Normal file
10
apps/cms/graphql/mutations/UpdateAppMetadata.graphql
Normal file
|
@ -0,0 +1,10 @@
|
|||
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||
updatePrivateMetadata(id: $id, input: $input) {
|
||||
item {
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
9
apps/cms/graphql/mutations/UpdateProductMetadata.graphql
Normal file
9
apps/cms/graphql/mutations/UpdateProductMetadata.graphql
Normal file
|
@ -0,0 +1,9 @@
|
|||
mutation UpdateMetadata($id: ID!, $input: [MetadataInput!]!) {
|
||||
updateMetadata(id: $id, input: $input) {
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
0
apps/cms/graphql/queries/.gitkeep
Normal file
0
apps/cms/graphql/queries/.gitkeep
Normal file
9
apps/cms/graphql/queries/FetchAppDetails.graphql
Normal file
9
apps/cms/graphql/queries/FetchAppDetails.graphql
Normal file
|
@ -0,0 +1,9 @@
|
|||
query FetchAppDetails {
|
||||
app {
|
||||
id
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
8
apps/cms/graphql/queries/GetProduct.graphql
Normal file
8
apps/cms/graphql/queries/GetProduct.graphql
Normal file
|
@ -0,0 +1,8 @@
|
|||
query GetProduct($id: ID!) {
|
||||
product(id: $id) {
|
||||
slug
|
||||
media {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
29625
apps/cms/graphql/schema.graphql
Normal file
29625
apps/cms/graphql/schema.graphql
Normal file
File diff suppressed because it is too large
Load diff
0
apps/cms/graphql/subscriptions/.gitkeep
Normal file
0
apps/cms/graphql/subscriptions/.gitkeep
Normal file
5
apps/cms/next-env.d.ts
vendored
Normal file
5
apps/cms/next-env.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
4
apps/cms/next.config.js
Normal file
4
apps/cms/next.config.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
};
|
64
apps/cms/package.json
Normal file
64
apps/cms/package.json
Normal 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
37
apps/cms/saleor-app.ts
Normal 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,
|
||||
});
|
8
apps/cms/src/assets/app-icon.svg
Normal file
8
apps/cms/src/assets/app-icon.svg
Normal 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 |
9
apps/cms/src/assets/contentful-icon.svg
Normal file
9
apps/cms/src/assets/contentful-icon.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 40 KiB |
9
apps/cms/src/assets/datocms-icon.svg
Normal file
9
apps/cms/src/assets/datocms-icon.svg
Normal 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 |
3
apps/cms/src/assets/index.ts
Normal file
3
apps/cms/src/assets/index.ts
Normal 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";
|
11
apps/cms/src/assets/strapi-icon.svg
Normal file
11
apps/cms/src/assets/strapi-icon.svg
Normal 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 |
19
apps/cms/src/lib/app/redirect.ts
Normal file
19
apps/cms/src/lib/app/redirect.ts
Normal 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 };
|
||||
};
|
11
apps/cms/src/lib/cms/client/channels.ts
Normal file
11
apps/cms/src/lib/cms/client/channels.ts
Normal 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) || [];
|
||||
};
|
148
apps/cms/src/lib/cms/client/clients-execution.ts
Normal file
148
apps/cms/src/lib/cms/client/clients-execution.ts
Normal 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,
|
||||
};
|
||||
};
|
253
apps/cms/src/lib/cms/client/clients-operations.test.ts
Normal file
253
apps/cms/src/lib/cms/client/clients-operations.test.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
113
apps/cms/src/lib/cms/client/clients-operations.ts
Normal file
113
apps/cms/src/lib/cms/client/clients-operations.ts
Normal 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;
|
||||
};
|
3
apps/cms/src/lib/cms/client/index.ts
Normal file
3
apps/cms/src/lib/cms/client/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { createCmsOperations } from "./clients-operations";
|
||||
export { executeCmsOperations } from "./clients-execution";
|
||||
export { executeMetadataUpdate } from "./metadata-execution";
|
51
apps/cms/src/lib/cms/client/metadata-execution.ts
Normal file
51
apps/cms/src/lib/cms/client/metadata-execution.ts
Normal 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();
|
||||
}
|
||||
};
|
24
apps/cms/src/lib/cms/client/metadata.ts
Normal file
24
apps/cms/src/lib/cms/client/metadata.ts
Normal 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;
|
22
apps/cms/src/lib/cms/client/operations.ts
Normal file
22
apps/cms/src/lib/cms/client/operations.ts
Normal 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";
|
||||
};
|
84
apps/cms/src/lib/cms/client/settings.test.ts
Normal file
84
apps/cms/src/lib/cms/client/settings.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
101
apps/cms/src/lib/cms/client/settings.ts
Normal file
101
apps/cms/src/lib/cms/client/settings.ts
Normal 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,
|
||||
};
|
||||
};
|
22
apps/cms/src/lib/cms/config/channels.ts
Normal file
22
apps/cms/src/lib/cms/config/channels.ts
Normal 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;
|
||||
};
|
18
apps/cms/src/lib/cms/config/index.ts
Normal file
18
apps/cms/src/lib/cms/config/index.ts
Normal 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>;
|
174
apps/cms/src/lib/cms/config/providers.ts
Normal file
174
apps/cms/src/lib/cms/config/providers.ts
Normal 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>;
|
4
apps/cms/src/lib/cms/index.ts
Normal file
4
apps/cms/src/lib/cms/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from "./config";
|
||||
export * from "./client";
|
||||
export * from "./providers";
|
||||
export { default as cmsProviders } from "./providers";
|
143
apps/cms/src/lib/cms/providers/contentful.ts
Normal file
143
apps/cms/src/lib/cms/providers/contentful.ts
Normal 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);
|
12
apps/cms/src/lib/cms/providers/create.ts
Normal file
12
apps/cms/src/lib/cms/providers/create.ts
Normal 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,
|
||||
};
|
||||
};
|
86
apps/cms/src/lib/cms/providers/datocms.ts
Normal file
86
apps/cms/src/lib/cms/providers/datocms.ts
Normal 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);
|
13
apps/cms/src/lib/cms/providers/index.ts
Normal file
13
apps/cms/src/lib/cms/providers/index.ts
Normal 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;
|
97
apps/cms/src/lib/cms/providers/strapi.ts
Normal file
97
apps/cms/src/lib/cms/providers/strapi.ts
Normal 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);
|
51
apps/cms/src/lib/cms/types.ts
Normal file
51
apps/cms/src/lib/cms/types.ts
Normal 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>;
|
||||
};
|
48
apps/cms/src/lib/graphql.ts
Normal file
48
apps/cms/src/lib/graphql.ts
Normal 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,
|
||||
],
|
||||
});
|
19
apps/cms/src/lib/logger.ts
Normal file
19
apps/cms/src/lib/logger.ts
Normal 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);
|
80
apps/cms/src/lib/metadata.ts
Normal file
80
apps/cms/src/lib/metadata.ts
Normal 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),
|
||||
});
|
||||
};
|
19
apps/cms/src/lib/no-ssr-wrapper.tsx
Normal file
19
apps/cms/src/lib/no-ssr-wrapper.tsx
Normal 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,
|
||||
});
|
48
apps/cms/src/lib/theme-synchronizer.test.tsx
Normal file
48
apps/cms/src/lib/theme-synchronizer.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
33
apps/cms/src/lib/theme-synchronizer.tsx
Normal file
33
apps/cms/src/lib/theme-synchronizer.tsx
Normal 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);
|
40
apps/cms/src/modules/app-configuration/app-configurator.ts
Normal file
40
apps/cms/src/modules/app-configuration/app-configurator.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
70
apps/cms/src/modules/app-configuration/metadata-manager.ts
Normal file
70
apps/cms/src/modules/app-configuration/metadata-manager.ts
Normal 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),
|
||||
});
|
||||
};
|
154
apps/cms/src/modules/channels/ui/channel-configuration-form.tsx
Normal file
154
apps/cms/src/modules/channels/ui/channel-configuration-form.tsx
Normal 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;
|
122
apps/cms/src/modules/channels/ui/channel-configuration.tsx
Normal file
122
apps/cms/src/modules/channels/ui/channel-configuration.tsx
Normal 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;
|
76
apps/cms/src/modules/channels/ui/channels-list-items.tsx
Normal file
76
apps/cms/src/modules/channels/ui/channels-list-items.tsx
Normal 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;
|
59
apps/cms/src/modules/channels/ui/channels-list.tsx
Normal file
59
apps/cms/src/modules/channels/ui/channels-list.tsx
Normal 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;
|
44
apps/cms/src/modules/channels/ui/channels.tsx
Normal file
44
apps/cms/src/modules/channels/ui/channels.tsx
Normal 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;
|
18
apps/cms/src/modules/channels/ui/graphql/channels.ts
Normal file
18
apps/cms/src/modules/channels/ui/graphql/channels.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`;
|
51
apps/cms/src/modules/channels/ui/hooks/useChannels.ts
Normal file
51
apps/cms/src/modules/channels/ui/hooks/useChannels.ts
Normal 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;
|
84
apps/cms/src/modules/channels/ui/hooks/useChannelsFetch.ts
Normal file
84
apps/cms/src/modules/channels/ui/hooks/useChannelsFetch.ts
Normal 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;
|
9
apps/cms/src/modules/channels/ui/types.ts
Normal file
9
apps/cms/src/modules/channels/ui/types.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export interface ChannelsLoading {
|
||||
fetching: boolean;
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelsErrors {
|
||||
fetching?: Error | null;
|
||||
saving?: Error | null;
|
||||
}
|
9
apps/cms/src/modules/cms/utils.ts
Normal file
9
apps/cms/src/modules/cms/utils.ts
Normal 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}`;
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
14
apps/cms/src/modules/provider-instances/ui/provider-icon.tsx
Normal file
14
apps/cms/src/modules/provider-instances/ui/provider-icon.tsx
Normal 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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
9
apps/cms/src/modules/provider-instances/ui/types.ts
Normal file
9
apps/cms/src/modules/provider-instances/ui/types.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export interface ProvidersLoading {
|
||||
fetching: boolean;
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
export interface ProvidersErrors {
|
||||
fetching?: Error | null;
|
||||
saving?: Error | null;
|
||||
}
|
72
apps/cms/src/modules/providers/config.ts
Normal file
72
apps/cms/src/modules/providers/config.ts
Normal 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;
|
14
apps/cms/src/modules/ui/app-container.tsx
Normal file
14
apps/cms/src/modules/ui/app-container.tsx
Normal 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>;
|
||||
};
|
19
apps/cms/src/modules/ui/app-grid.tsx
Normal file
19
apps/cms/src/modules/ui/app-grid.tsx
Normal 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>;
|
||||
};
|
11
apps/cms/src/modules/ui/app-layout.tsx
Normal file
11
apps/cms/src/modules/ui/app-layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
18
apps/cms/src/modules/ui/app-link.tsx
Normal file
18
apps/cms/src/modules/ui/app-link.tsx
Normal 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>
|
||||
);
|
||||
};
|
18
apps/cms/src/modules/ui/app-paper.tsx
Normal file
18
apps/cms/src/modules/ui/app-paper.tsx
Normal 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>
|
||||
);
|
||||
};
|
23
apps/cms/src/modules/ui/app-tab-nav-button.tsx
Normal file
23
apps/cms/src/modules/ui/app-tab-nav-button.tsx
Normal 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>
|
||||
);
|
||||
};
|
43
apps/cms/src/modules/ui/app-tabs.tsx
Normal file
43
apps/cms/src/modules/ui/app-tabs.tsx
Normal 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;
|
53
apps/cms/src/modules/ui/instructions.tsx
Normal file
53
apps/cms/src/modules/ui/instructions.tsx
Normal 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'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>
|
||||
);
|
||||
};
|
98
apps/cms/src/pages/_app.tsx
Normal file
98
apps/cms/src/pages/_app.tsx
Normal 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;
|
80
apps/cms/src/pages/api/channels.ts
Normal file
80
apps/cms/src/pages/api/channels.ts
Normal 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"]);
|
28
apps/cms/src/pages/api/manifest.ts
Normal file
28
apps/cms/src/pages/api/manifest.ts
Normal 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;
|
||||
},
|
||||
});
|
116
apps/cms/src/pages/api/provider-instances.ts
Normal file
116
apps/cms/src/pages/api/provider-instances.ts
Normal 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"]);
|
9
apps/cms/src/pages/api/register.ts
Normal file
9
apps/cms/src/pages/api/register.ts
Normal 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);
|
99
apps/cms/src/pages/api/webhooks/product-variant-created.ts
Normal file
99
apps/cms/src/pages/api/webhooks/product-variant-created.ts
Normal 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);
|
99
apps/cms/src/pages/api/webhooks/product-variant-deleted.ts
Normal file
99
apps/cms/src/pages/api/webhooks/product-variant-deleted.ts
Normal 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);
|
102
apps/cms/src/pages/api/webhooks/product-variant-updated.ts
Normal file
102
apps/cms/src/pages/api/webhooks/product-variant-updated.ts
Normal 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);
|
21
apps/cms/src/pages/channels.tsx
Normal file
21
apps/cms/src/pages/channels.tsx
Normal 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;
|
32
apps/cms/src/pages/index.tsx
Normal file
32
apps/cms/src/pages/index.tsx
Normal 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;
|
21
apps/cms/src/pages/providers.tsx
Normal file
21
apps/cms/src/pages/providers.tsx
Normal 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;
|
18
apps/cms/src/providers/GraphQLProvider.tsx
Normal file
18
apps/cms/src/providers/GraphQLProvider.tsx
Normal 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;
|
BIN
apps/cms/src/public/favicon.ico
Normal file
BIN
apps/cms/src/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
4
apps/cms/src/public/vercel.svg
Normal file
4
apps/cms/src/public/vercel.svg
Normal 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 |
22
apps/cms/src/styles/globals.css
Normal file
22
apps/cms/src/styles/globals.css
Normal 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
Loading…
Reference in a new issue