remove CMS v1 app codebase (#789)

This commit is contained in:
Lukasz Ostrowski 2023-07-19 14:01:17 +02:00 committed by GitHub
parent 3ae7ac6035
commit 5b2976306e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
127 changed files with 6 additions and 37376 deletions

View file

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

2
.github/labeler.yml vendored
View file

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

View file

@ -1,18 +0,0 @@
# 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=
APP_LOG_LEVEL=info
# Local development variables. When developped locally with Saleor inside docker, these can be set to:
# APP_IFRAME_BASE_URL = http://localhost:3000, so Dashboard on host can access iframe
# APP_API_BASE_URL=http://host.docker.internal:3000 - so Saleor can reach App running on host, from the container.
# If developped with tunnels, set this empty, it will fallback to default Next's localhost:3000
# https://docs.saleor.io/docs/3.x/developer/extending/apps/local-app-development
APP_IFRAME_BASE_URL=
APP_API_BASE_URL=

View file

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

3
apps/cms/.gitignore vendored
View file

@ -1,3 +0,0 @@
# Sentry Auth Token
.sentryclirc

View file

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

View file

@ -1,173 +0,0 @@
# saleor-app-cms
## 1.8.0
### Minor Changes
- f669581: Added warning bar that informs that app is no longer maintained
### Patch Changes
- 70cb741: Update Zod to 3.21.4
- e7c2d3a: Updated and ESLint dependencies
- 3c6cd4c: Updated the @saleor/app-sdk package to version 0.41.1.
- Updated dependencies [70cb741]
- Updated dependencies [e7c2d3a]
- Updated dependencies [3c6cd4c]
- @saleor/apps-shared@1.7.4
## 1.7.0
### Minor Changes
- a1f083c: Filled "about" field in App Manifest. Dashboard will display it in app details page now.
- 47102ba: Added additional ENV variables (see each app's .env.example), that can overwrite app base URL. This change allows easy apps development using Docker
### Patch Changes
- 2d77bca: Updated Next.js to 13.4.8
- 6299e06: Update @saleor/app-sdk to 0.41.0
- Updated dependencies [2d77bca]
- Updated dependencies [6299e06]
- @saleor/apps-shared@1.7.3
## 1.6.0
### Minor Changes
- 1dead1e: Included dedicated logo and attached it to App's manifest. From Saleor 3.15 the logo will be visible in the Dashboard during and after installation.
### Patch Changes
- 860bac4: Updated @saleor/app-sdk to 0.40.1
- a1ad70e: Updated configuration and dependencies of GraphQL client - urql.
All applications use now unified config for creating the client. Also unused related packages has been removed.
- ec68ed5: Updated Sentry package and config. Set Sentry release to match package.json version. Now Sentry will use semver version instead a commit
- cb6ee29: Updated dependencies
- Updated dependencies [860bac4]
- Updated dependencies [a1ad70e]
- Updated dependencies [cb6ee29]
- Updated dependencies [a1ad70e]
- @saleor/apps-shared@1.7.2
## 1.5.4
### Patch Changes
- a8834a1: Unified graphql version to 16.6
- a8834a1: Unified graphql codegen packages
- a8834a1: Removed unnecessary duplicated dependencies from apps and moved them to shared and root (types, eslint rules)
- a8834a1: Updated dev dependencies - Typescript, Eslint and Turborepo
- Updated dependencies [a8834a1]
- Updated dependencies [a8834a1]
- Updated dependencies [a8834a1]
- Updated dependencies [a8834a1]
- @saleor/apps-shared@1.7.1
## 1.5.3
### Patch Changes
- 0c2fc65: Update dev dependencies - Vite and Vitest. These changes will not affect runtime Apps, but can affect tests and builds
- Updated dependencies [0c2fc65]
- Updated dependencies [b75a664]
- @saleor/apps-shared@1.7.0
## 1.5.2
### Patch Changes
- 6e69f4f: Update app-sdk to 0.39.1
- Updated dependencies [6e69f4f]
- @saleor/apps-shared@1.6.1
## 1.5.1
### Patch Changes
- Updated dependencies [23b5c70]
- @saleor/apps-shared@1.6.0
## 1.5.0
### Minor Changes
- 7c9a9a2: Added additional debug logs for "ping" endpoint. In case of failed connection, logs can be checked for details. Endpoint also will return error to the frontend
### Patch Changes
- c406318: Updated dep @saleor/app-sdk to 0.38.0
- Updated dependencies [c406318]
- @saleor/apps-shared@1.5.1
## 1.4.1
### Patch Changes
- 8b22b1c: Restored Pino logger packages to each app, to fix failing logs in development. Also updated .env.example to contain up to date APP_LOG_LEVEL variable
## 1.4.0
### Minor Changes
- 830cfe9: Changed APP_DEBUG env to APP_LOG_LEVEL
### Patch Changes
- 830cfe9: Replaced internal logger implementation with shared logger
- Updated dependencies [830cfe9]
- @saleor/apps-shared@1.5.0
## 1.3.1
### Patch Changes
- fc06648: CMS visual fixes
## 1.3.0
### Minor Changes
- 57f6d41: Updated Manifest to contain up to date support, privacy, homepage and author fields
### Patch Changes
- 2c0df91: Added lint:fix script, so `eslint --fix` can be run deliberately
- e167e72: Update next.js to 13.3.0
- 74174c4: Updated @saleor/app-sdk to 0.37.3
- 2e51890: Update next.js to 13.3.0
- a3636f7: Fix CMS app issues
Check if CMS provider instance configuration is working
- 2e51890: Update @saleor/app-sdk to 0.37.2
- 2e51890: Use useDashboardNotification hook from shared package, instead of direct AppBridge usage
- Updated dependencies [2c0df91]
- Updated dependencies [e167e72]
- Updated dependencies [74174c4]
- Updated dependencies [2e51890]
- Updated dependencies [2e51890]
- Updated dependencies [2e51890]
- @saleor/apps-shared@1.4.0
## 1.2.0
### Minor Changes
- 1da5163: Redesigned the app to better match new Dashboard
### Patch Changes
- 246b943: Provider configurations UI fixes
- eca52ad: Replace "export default" with named exports
- @saleor/apps-shared@1.3.0
## 1.1.0
### Minor Changes
- b80df17: Added Sentry integration
- 7cb3b89: Added "author" field to the Manifest, set it to Saleor Commerce, so Dashboard can display it too
### Patch Changes
- 7cb3b89: Replace apps to avoid AppPermission (use Permission for client permissions) and authData.domain (use saleorApiUrl)
- 7cb3b89: Updated @saleor/app-sdk to 0.37.1

View file

@ -1,142 +0,0 @@
# 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/providers.ts`.
2. Update the `providersConfig` variable with basic information about your provider: `name`, `label` and `tokens`:
```ts
// src/lib/cms/config/providers.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 const payloadProvider = 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! 🎖️

View file

@ -1,54 +0,0 @@
# Deprecated
This is v1 version of the CMS app. It's no longer maintained and will be removed.
Please check [v2](../cms-v2/)
![CMS](https://user-images.githubusercontent.com/249912/71523206-4e45f800-28c8-11ea-84ba-345a9bfc998a.png)
<div align="center">
<h1>CMS v1</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.
### Mapping fields
Currently, the CMS Hub does not support mapping Saleor fields to your CMS fields. Your product model should have the following fields:
- strings fields: `saleor_id`, `name`, `product_id`, `product_name`, `product_slug`,
- JSON fileds: `channels`.
### Known issues
CMS Hub updates product variants in CMS providers on create, update or delete product variant webook events. They are triggered on product variant channel listing added or updated, but they currently don't trigger on deleting channel listing due to the [core issue #12247](https://github.com/saleor/saleor/issues/12247). To make it working on deleting channel listing, you can additionally modify other product variant field e.g. `name`, so webhook handlers receive the updated channel listings as well.
## How to use it?
1. Install the application in your Dashboard and open it.
2. Go to Providers.
3. Add CMS provider instance with proper configuration.
4. Go to Channels.
5. Turn on provider instances for desired channels.
6. Go to Products in Dashboard.
7. Add, edit or remove product variant to see it is added, updated or removed in configured CMS provider accordingly.
## How can I contribute?
See [CONTRIBUTING.md](./CONTRIBUTING.md).

View file

@ -1,15 +0,0 @@
# 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`.
- `apiRequestsPerSecond`: API rate limits (API requests per second). The default is 7. Used in bulk products variants sync. Higher rate limits may speed up a little products variants bulk sync. Higher rate limit may apply depending on different Contentful plan, learn more at https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/api-rate-limits.

View file

@ -1,12 +0,0 @@
# 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. More instructions of how to create it available at [DatoCMS "Authentication" documentation](https://www.datocms.com/docs/content-management-api/authentication).
- `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.

View file

@ -1,11 +0,0 @@
# 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).
- `contentTypeId`: the content type id. You can find this in your Strapi project, go to Content-Type Builder > select content type > click Edit > use API ID (Plural). For more unstruction of how to get content type id, please go to [Strapi "Editing content types" documentation](https://docs.strapi.io/user-docs/content-type-builder/managing-content-types#editing-content-types).

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +0,0 @@
query FetchProductVariantMetadata($id: ID!) {
productVariant(id: $id) {
id
metadata {
key
value
}
}
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,28 +0,0 @@
const { withSentryConfig } = require("@sentry/nextjs");
const isSentryPropertiesInEnvironment =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG;
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: ["@saleor/apps-shared"],
};
const configWithSentry = withSentryConfig(
nextConfig,
{
silent: true,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
},
{
widenClientFileUpload: true,
transpileClientSDK: true,
tunnelRoute: "/monitoring",
hideSourceMaps: true,
disableLogger: true,
}
);
module.exports = isSentryPropertiesInEnvironment ? configWithSentry : nextConfig;

View file

@ -1,63 +0,0 @@
{
"name": "saleor-app-cms",
"version": "1.8.0",
"scripts": {
"build": "pnpm generate && next build",
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"generate": "graphql-codegen",
"lint": "next lint",
"lint:fix": "eslint --fix .",
"start": "next start",
"test": "vitest"
},
"dependencies": {
"@datocms/cma-client-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.41.1",
"@saleor/apps-shared": "workspace:*",
"@saleor/macaw-ui": "^0.7.2",
"@sentry/nextjs": "7.55.2",
"@urql/exchange-auth": "^2.1.4",
"clsx": "^1.2.1",
"graphql": "16.6.0",
"graphql-tag": "^2.12.6",
"next": "13.4.8",
"pino": "^8.14.1",
"pino-pretty": "^10.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.39.1",
"react-markdown": "^8.0.5",
"urql": "^4.0.4",
"usehooks-ts": "^2.9.1",
"uuid": "^9.0.0",
"vite": "4.3.9",
"zod": "3.21.4"
},
"devDependencies": {
"@graphql-codegen/cli": "3.2.2",
"@graphql-codegen/introspection": "3.0.1",
"@graphql-codegen/typed-document-node": "3.0.2",
"@graphql-codegen/typescript": "3.0.2",
"@graphql-codegen/typescript-operations": "3.0.2",
"@graphql-codegen/typescript-urql": "3.7.3",
"@graphql-typed-document-node/core": "3.2.0",
"@testing-library/react": "^13.4.0",
"@types/react": "18.2.5",
"@types/react-dom": "18.2.5",
"@types/uuid": "^8.3.4",
"@vitejs/plugin-react": "4.0.0",
"eslint": "8.44.0",
"jsdom": "^20.0.3",
"typescript": "5.1.6",
"vitest": "0.31.3"
},
"private": true,
"saleor": {
"schemaVersion": "3.7"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 40 KiB

View file

@ -1,9 +0,0 @@
<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>

Before

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

View file

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

View file

@ -1,11 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View file

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

View file

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

View file

@ -1,306 +0,0 @@
import {
ProductVariantUpdatedWebhookPayloadFragment,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import {
BaseResponse,
CmsClientBatchOperations,
CmsClientOperations,
ProductResponseSuccess,
} from "../types";
import { getCmsIdFromSaleorItem } from "./metadata";
import { createLogger } from "@saleor/apps-shared";
import { CMSProvider, cmsProviders } from "../providers";
import { ProviderInstanceSchema, providersSchemaSet } from "../config";
export const pingProviderInstance = async (
providerInstanceSettings: ProviderInstanceSchema
): Promise<BaseResponse> => {
const logger = createLogger({ providerInstanceSettings });
logger.debug("Ping provider instance called");
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) {
logger.error("The provider instance settings validation failed.", {
error: validation.error.message,
});
return { ok: false };
}
const config = validation.data;
const client = provider.create(config as any); // config without validation = providerInstanceSettings as any
const pingResult = await client.ping();
return pingResult;
};
interface CmsClientOperationResult {
createdCmsId?: string;
deletedCmsId?: string;
error?: string;
}
const executeCmsClientOperation = async ({
cmsClient,
productVariant,
}: {
cmsClient: CmsClientOperations;
productVariant: WebhookProductVariantFragment;
}): Promise<CmsClientOperationResult | undefined> => {
const logger = createLogger({ 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.",
};
}
}
};
interface CmsClientBatchOperationResult {
createdCmsIds?: ProductResponseSuccess["data"][];
deletedCmsIds?: ProductResponseSuccess["data"][];
error?: string;
}
export const executeCmsClientBatchOperation = async ({
cmsClient,
productsVariants,
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance,
}: {
cmsClient: CmsClientBatchOperations;
productsVariants: WebhookProductVariantFragment[];
/**
* Lookup function with purposely long name like in Java Spring ORM to verify condition against unintended deletion of product variant from CMS.
* On purpose passed as an argument, for inversion of control.
*/
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance: (
productVariant: WebhookProductVariantFragment
) => boolean;
}): Promise<CmsClientBatchOperationResult | undefined> => {
const logger = createLogger({ cmsClient });
logger.debug({ operations: cmsClient.operations }, "Execute CMS client operation called");
if (cmsClient.operationType === "createBatchProducts") {
const productsVariansToCreate = productsVariants.reduce<WebhookProductVariantFragment[]>(
(productsVariansToCreate, productVariant) => {
const cmsId = getCmsIdFromSaleorItem(productVariant, cmsClient.cmsProviderInstanceId);
if (!cmsId) {
return [...productsVariansToCreate, productVariant];
}
return productsVariansToCreate;
},
[] as WebhookProductVariantFragment[]
);
if (productsVariansToCreate.length) {
logger.debug("CMS creating batch items called");
try {
const createBatchProductsResponse = await cmsClient.operations.createBatchProducts({
input: productsVariansToCreate.map((productVariant) => ({
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) || [],
})),
});
return {
createdCmsIds:
createBatchProductsResponse
?.filter((item) => item.ok && "data" in item)
.map((item) => (item as ProductResponseSuccess).data) || [],
};
} catch (error) {
logger.error({ error }, "Error creating batch items");
return {
error: "Error creating batch items.",
};
}
}
}
if (cmsClient.operationType === "deleteBatchProducts") {
const CMSIdsToRemove = productsVariants.reduce((CMSIdsToRemove, productVariant) => {
const cmsId = getCmsIdFromSaleorItem(productVariant, cmsClient.cmsProviderInstanceId);
const productVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance =
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance(
productVariant
);
if (cmsId && !productVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance) {
return [
...CMSIdsToRemove,
{
id: cmsId,
saleorId: productVariant.id,
},
];
}
return CMSIdsToRemove;
}, [] as ProductResponseSuccess["data"][]);
if (CMSIdsToRemove.length) {
logger.debug("CMS removing batch items called");
try {
await cmsClient.operations.deleteBatchProducts({
ids: CMSIdsToRemove.map((item) => item.id),
});
return {
deletedCmsIds: CMSIdsToRemove,
};
} catch (error) {
logger.error({ error }, "Error removing batch items");
return {
error: "Error removing batch items.",
};
}
}
}
};
export const executeCmsOperations = async ({
cmsOperations,
productVariant,
}: {
cmsOperations: CmsClientOperations[];
productVariant: Exclude<
ProductVariantUpdatedWebhookPayloadFragment["productVariant"],
undefined | null
>;
}) => {
const cmsProviderInstanceProductVariantIdsToCreate: Record<string, string> = {};
const cmsProviderInstanceProductVariantIdsToDelete: Record<string, string> = {};
const cmsErrors: string[] = [];
await Promise.all(
cmsOperations.map(async (cmsClient) => {
const { createdCmsId, deletedCmsId, error } =
(await executeCmsClientOperation({
cmsClient,
productVariant,
})) || {};
if (createdCmsId) {
cmsProviderInstanceProductVariantIdsToCreate[cmsClient.cmsProviderInstanceId] =
createdCmsId;
}
if (deletedCmsId) {
cmsProviderInstanceProductVariantIdsToDelete[cmsClient.cmsProviderInstanceId] =
deletedCmsId;
}
if (error) {
cmsErrors.push(error);
}
})
);
return {
cmsProviderInstanceProductVariantIdsToCreate,
cmsProviderInstanceProductVariantIdsToDelete,
cmsErrors,
};
};

View file

@ -1,271 +0,0 @@
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { type Client } from "urql";
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: "https://domain.saleor.io/graphql/",
token: "token",
appId: "appId",
domain: "domain.saleor.io",
},
};
const createMockedClient = () => ({} as Client);
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,
client: createMockedClient(),
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: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "first-provider",
providerName: "strapi",
},
"second-provider": {
name: "Second provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "second-provider",
providerName: "strapi",
},
"third-provider": {
name: "Third provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
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,
client: createMockedClient(),
productVariantChannels: ["default-channel"],
productVariantCmsKeys: [],
});
const operationsItem = cmsOperations[0];
/**
* Replace deep equal with single ones due to some strange errors in vite/jest.
* Functions were not matched properly in deep object
*/
expect(operationsItem.cmsProviderInstanceId).toBe("first-provider");
expect(operationsItem.operationType).toBe("createProduct");
expect(operationsItem.operations.createProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.deleteProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.updateProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.ping).toEqual(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: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "first-provider",
providerName: "strapi",
},
"second-provider": {
name: "Second provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "second-provider",
providerName: "strapi",
},
"third-provider": {
name: "Third provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
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,
client: createMockedClient(),
productVariantChannels: ["default-channel"],
productVariantCmsKeys: [createCmsKeyForSaleorItem("first-provider")],
});
const operationsItem = cmsOperations[0];
/**
* Replace deep equal with single ones due to some strange errors in vite/jest.
* Functions were not matched properly in deep object
*/
expect(operationsItem.cmsProviderInstanceId).toBe("first-provider");
expect(operationsItem.operationType).toBe("updateProduct");
expect(operationsItem.operations.createProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.deleteProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.updateProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.ping).toEqual(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: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "first-provider",
providerName: "strapi",
},
"second-provider": {
name: "Second provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "second-provider",
providerName: "strapi",
},
"third-provider": {
name: "Third provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
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,
client: createMockedClient(),
productVariantChannels: [],
productVariantCmsKeys: [createCmsKeyForSaleorItem("first-provider")],
});
const operationsItem = cmsOperations[0];
/**
* Replace deep equal with single ones due to some strange errors in vite/jest.
* Functions were not matched properly in deep object
*/
expect(operationsItem.cmsProviderInstanceId).toBe("first-provider");
expect(operationsItem.operationType).toBe("deleteProduct");
expect(operationsItem.operations.createProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.deleteProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.updateProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.ping).toEqual(expect.any(Function));
});
});

View file

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

View file

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

View file

@ -1,131 +0,0 @@
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { Client } from "urql";
import {
DeleteMetadataDocument,
UpdateMetadataDocument,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import { createCmsKeyForSaleorItem } from "./metadata";
import { createGraphQLClient } from "@saleor/apps-shared";
type WebhookContext = Parameters<NextWebhookApiHandler>["2"];
export type MetadataRecord = Record<string, string>;
const executeMetadataUpdateMutation = async ({
apiClient,
itemId,
cmsProviderInstanceIdsToCreate = {},
cmsProviderInstanceIdsToDelete = {},
}: {
apiClient: Client;
itemId: string;
cmsProviderInstanceIdsToCreate?: Record<string, string>;
cmsProviderInstanceIdsToDelete?: Record<string, string>;
}) => {
if (Object.keys(cmsProviderInstanceIdsToCreate).length) {
await apiClient
.mutation(UpdateMetadataDocument, {
id: itemId,
input: Object.entries(cmsProviderInstanceIdsToCreate).map(
([cmsProviderInstanceId, cmsProductVariantId]) => ({
key: createCmsKeyForSaleorItem(cmsProviderInstanceId),
value: cmsProductVariantId,
})
),
})
.toPromise();
}
if (Object.keys(cmsProviderInstanceIdsToDelete).length) {
await apiClient
.mutation(DeleteMetadataDocument, {
id: itemId,
keys: Object.entries(cmsProviderInstanceIdsToDelete).map(([cmsProviderInstanceId]) =>
createCmsKeyForSaleorItem(cmsProviderInstanceId)
),
})
.toPromise();
}
};
export const updateMetadata = async ({
context,
productVariant,
cmsProviderInstanceIdsToCreate,
cmsProviderInstanceIdsToDelete,
}: {
context: WebhookContext;
productVariant: WebhookProductVariantFragment;
cmsProviderInstanceIdsToCreate: Record<string, string>;
cmsProviderInstanceIdsToDelete: Record<string, string>;
}) => {
const { token, saleorApiUrl } = context.authData;
const apiClient = createGraphQLClient({ saleorApiUrl, token });
await executeMetadataUpdateMutation({
apiClient,
itemId: productVariant.id,
cmsProviderInstanceIdsToCreate,
cmsProviderInstanceIdsToDelete,
});
};
type ItemMetadataRecord = {
id: string;
cmsProviderInstanceIds: MetadataRecord;
};
export const batchUpdateMetadata = async ({
context,
variantCMSProviderInstanceIdsToCreate,
variantCMSProviderInstanceIdsToDelete,
}: {
context: Pick<WebhookContext, "authData">;
variantCMSProviderInstanceIdsToCreate: ItemMetadataRecord[];
variantCMSProviderInstanceIdsToDelete: ItemMetadataRecord[];
}) => {
const { token, saleorApiUrl } = context.authData;
const apiClient = createGraphQLClient({ saleorApiUrl, token });
const variantCMSProviderInstanceIdsToCreateMap = variantCMSProviderInstanceIdsToCreate.reduce(
(acc, { id, cmsProviderInstanceIds }) => ({
...acc,
[id]: {
...(acc[id] || {}),
...cmsProviderInstanceIds,
},
}),
{} as Record<string, MetadataRecord>
);
const variantCMSProviderInstanceIdsToDeleteMap = variantCMSProviderInstanceIdsToDelete.reduce(
(acc, { id, cmsProviderInstanceIds }) => ({
...acc,
[id]: {
...(acc[id] || {}),
...cmsProviderInstanceIds,
},
}),
{} as Record<string, MetadataRecord>
);
const mutationsToExecute = [
Object.entries(variantCMSProviderInstanceIdsToCreateMap).map(
([itemId, cmsProviderInstanceIdsToCreate]) =>
executeMetadataUpdateMutation({
apiClient,
itemId,
cmsProviderInstanceIdsToCreate,
})
),
Object.entries(variantCMSProviderInstanceIdsToDeleteMap).map(
([itemId, cmsProviderInstanceIdsToDelete]) =>
executeMetadataUpdateMutation({
apiClient,
itemId,
cmsProviderInstanceIdsToDelete,
})
),
];
await Promise.all(mutationsToExecute);
};

View file

@ -1,24 +0,0 @@
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("_")[1];
};
export const getCmsIdFromSaleorItem = (
item: Record<string, any> & { metadata: MetadataItem[] },
cmsProviderInstanceId: string
) =>
item.metadata.find((item) => item.key === createCmsKeyForSaleorItem(cmsProviderInstanceId))
?.value;

View file

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

View file

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

View file

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

View file

@ -1,23 +0,0 @@
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()),
requireSyncProviderInstances: z.array(z.string()).optional(),
})
.merge(channelCommonSchema);
export type ChannelSchema = z.infer<typeof channelSchema>;
export type SingleChannelSchema = ChannelSchema & ChannelCommonSchema;
export type MergedChannelSchema = SingleChannelSchema & {
channel: ChannelFragment;
};

View file

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

View file

@ -1,200 +0,0 @@
import { z } from "zod";
import { CreateProviderConfig } from "../types";
import { ContentfulIcon, DatocmsIcon, StrapiIcon } from "../../../assets";
type ProviderToken = {
name: string;
label: string;
helpText: string;
required?: boolean;
secret?: 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: [
{
required: true,
secret: true,
name: "token",
label: "Token",
helpText:
'You can find this in your Contentful project, go to Settings > API Keys > Content Management Tokens > Generate Personal Token. More instructions at [Contentful "Authentication" documentation](https://www.contentful.com/developers/docs/references/authentication/).',
},
{
required: true,
name: "environment",
label: "Environment",
helpText:
"Environment of your content, e.g. master. You can find this in your Contentful project, go to Settings > Environments.",
},
{
required: true,
name: "spaceId",
label: "Space ID",
helpText:
"You can find this in your Contentful project, go to Settings > General Settings.",
},
{
required: true,
name: "contentId",
label: "Content ID",
helpText:
"You can find this in your Contentful project, go to Content Model > select Model > Content Type ID.",
},
{
required: true,
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.",
},
{
name: "baseUrl",
label: "Base URL",
helpText:
"Content management API URL of your Contentful project. If you leave this blank, default https://api.contentful.com will be used.",
},
{
name: "apiRequestsPerSecond",
label: "API requests per second",
helpText:
"API rate limits. The default is 7. Used in bulk products variants sync. Higher rate limits may speed up a little products variants bulk sync. Higher rate limit may apply depending on different Contentful plan, learn more at https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/api-rate-limits.",
},
],
},
strapi: {
name: "strapi",
label: "Strapi",
icon: StrapiIcon,
tokens: [
{
required: true,
name: "baseUrl",
label: "Base URL",
helpText: "API URL of your Strapi project. E.g. https://your-strapi-project/api.",
},
{
required: true,
secret: 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. More instructions at [Strapi "Managing API tokens" documentation](https://docs.strapi.io/user-docs/latest/settings/managing-global-settings.html#managing-api-tokens).',
},
{
required: true,
name: "contentTypeId",
label: "Content Type ID (plural)",
helpText:
'You can find this in your Strapi project, go to Content-Type Builder > select Content Type > click Edit > Use API ID (Plural). More instructions at [Strapi "Editing content types" documentation](https://docs.strapi.io/user-docs/content-type-builder/managing-content-types#editing-content-types).',
},
],
},
datocms: {
name: "datocms",
label: "DatoCMS",
icon: DatocmsIcon,
tokens: [
{
required: true,
secret: true,
name: "token",
label: "API Token (with access to Content Management API)",
helpText:
'You can find this in your DatoCMS project settings. More instructions at [DatoCMS "Authentication" documentation](https://www.datocms.com/docs/content-management-api/authentication).',
},
{
required: true,
name: "itemTypeId",
label: "Item Type ID (number)",
helpText:
"To find your Model ID go into Models -> Your product variant Model -> Edit Model button. The Model ID is in the right corner.",
},
{
name: "baseUrl",
label: "Base URL",
helpText:
"URL to your DatoCMS project. If you leave this blank, this URL will be inferred from your API Token.",
},
{
name: "environment",
label: "Environment",
helpText:
"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().url().min(1),
contentTypeId: z.string().min(1),
});
export const contentfulConfigSchema = z.object({
name: z.string().min(1),
token: z.string().min(1),
environment: z.string().min(1),
spaceId: z.string().min(1),
locale: z.string().min(1),
contentId: z.string().min(1),
baseUrl: z.string().url().optional().or(z.literal("")),
apiRequestsPerSecond: z.number().optional().or(z.literal("")),
});
export const datocmsConfigSchema = z.object({
name: z.string().min(1),
token: z.string().min(1),
itemTypeId: z.number().min(1),
baseUrl: z.string().url().optional().or(z.literal("")),
environment: z.string(),
});
export const providerCommonSchema = z.object({
id: z.string(),
providerName: z.string(),
});
export type ProviderCommonSchema = z.infer<typeof providerCommonSchema>;
// todo: helper function so you dont have to merge manually
export const providersSchemaSet = {
strapi: strapiConfigSchema.merge(providerCommonSchema),
contentful: contentfulConfigSchema.merge(providerCommonSchema),
datocms: datocmsConfigSchema.merge(providerCommonSchema),
};
export type CMSProviderSchema = keyof typeof providersSchemaSet;
export const providersSchema = z.object(providersSchemaSet);
export type ProvidersSchema = z.infer<typeof providersSchema>;
export type SingleProviderSchema = ProvidersSchema[keyof ProvidersSchema] & ProviderCommonSchema;
export const providerInstanceSchema = z.union([
strapiConfigSchema.merge(providerCommonSchema),
contentfulConfigSchema.merge(providerCommonSchema),
datocmsConfigSchema.merge(providerCommonSchema),
]);
export type ProviderInstanceSchema = z.infer<typeof providerInstanceSchema>;

View file

@ -1,16 +0,0 @@
export const fetchWithRateLimit = async <A, R>(
args: A[],
fun: (arg: A) => Promise<R>,
requestPerSecondLimit: number
) => {
const delay = 1000 / requestPerSecondLimit;
const results: Promise<R>[] = [];
for (const arg of args) {
const result = fun(arg);
results.push(result);
await new Promise((resolve) => setTimeout(resolve, delay));
}
return await Promise.all(results);
};

View file

@ -1,3 +0,0 @@
export * from "./config";
export * from "./client";
export * from "./providers";

View file

@ -1,269 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import { ContentfulConfig, contentfulConfigSchema } from "../config";
import { CreateOperations, ProductResponse, ProductInput } from "../types";
import { createProvider } from "./create";
import { fetchWithRateLimit } from "../data-sync";
import { createLogger } from "@saleor/apps-shared";
const contentfulFetch = (endpoint: string, config: ContentfulConfig, options?: RequestInit) => {
const baseUrl = config.baseUrl || "https://api.contentful.com";
const token = config.token;
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;
};
statusCode: number;
input: ProductInput;
};
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]: input.channels,
},
},
};
return body;
};
const transformCreateProductResponse = (response: ContentfulResponse): ProductResponse => {
if (response.message) {
return {
ok: false,
error: "Something went wrong!",
};
}
return {
ok: true,
data: {
id: response.sys.id,
saleorId: response.input.saleorId,
},
};
};
const getEntryEndpoint = ({
resourceId,
spaceId,
environment,
}: {
resourceId: string;
spaceId: string;
environment: string;
}): string => `/spaces/${spaceId}/environments/${environment}/entries/${resourceId}`;
const contentfulOperations: CreateOperations<ContentfulConfig> = (config) => {
const logger = createLogger({ cms: "contentful" });
const { environment, spaceId, contentId, locale, apiRequestsPerSecond } = config;
const requestPerSecondLimit = Number(apiRequestsPerSecond || 7);
const pingCMS = async () => {
const endpoint = `/spaces/${spaceId}`;
const response = await contentfulFetch(endpoint, config, { method: "GET" });
const respBody = await response.json();
logger.debug({ response, body: respBody }, "pingCMS response");
return {
ok: response.ok,
};
};
const createProductInCMS = async (input: ProductInput): Promise<ContentfulResponse> => {
// Contentful API does not auto generate resource ID during creation, it has to be provided.
const resourceId = uuidv4();
const body = transformInputToBody({ 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,
},
});
logger.debug({ response }, "createProduct response");
const json = await response.json();
return {
...json,
statusCode: response.status,
input,
};
};
const updateProductInCMS = async (id: string, input: ProductInput) => {
const body = transformInputToBody({ input, locale });
const endpoint = getEntryEndpoint({
resourceId: id,
environment,
spaceId,
});
const getEntryResponse = await contentfulFetch(endpoint, config, { method: "GET" });
logger.debug({ getEntryResponse }, "updateProduct getEntryResponse");
const entry = await getEntryResponse.json();
logger.debug({ entry }, "updateProduct entry");
const response = await contentfulFetch(endpoint, config, {
method: "PUT",
body: JSON.stringify(body),
headers: {
"X-Contentful-Version": entry.sys.version,
},
});
logger.debug({ response }, "updateProduct response");
const json = await response.json();
return {
...json,
statusCode: response.status,
};
};
const deleteProductInCMS = async (id: string) => {
const endpoint = getEntryEndpoint({ resourceId: id, environment, spaceId });
return await contentfulFetch(endpoint, config, { method: "DELETE" });
};
const createBatchProductsInCMS = async (input: ProductInput[]) => {
// Contentful doesn't support batch creation of items, so we need to create them one by one
// Take into account rate limit
const firstResults = await fetchWithRateLimit(input, createProductInCMS, requestPerSecondLimit);
const failedWithLimitResults = firstResults.filter((result) => result.statusCode === 429);
// Retry with delay x2 if by any chance hit rate limit with HTTP 429
let secondResults: ContentfulResponse[] = [];
if (failedWithLimitResults.length > 0) {
logger.debug("createBatchProductsInCMS retrying failed by rate limit with delay x2");
secondResults = await fetchWithRateLimit(
failedWithLimitResults,
(result) => createProductInCMS(result.input),
requestPerSecondLimit / 2
);
}
return [...firstResults.filter((result) => result.statusCode !== 429), ...secondResults];
};
const deleteBatchProductsInCMS = async (ids: string[]) => {
// Contentful doesn't support batch deletion of items, so we need to delete them one by one
// Take into account rate limit
const firstResults = await fetchWithRateLimit(ids, deleteProductInCMS, requestPerSecondLimit);
const failedWithLimitResults = firstResults.filter((result) => result.status === 429);
// Retry with delay x2 if by any chance hit rate limit with HTTP 429
let secondResults: Response[] = [];
if (failedWithLimitResults.length > 0) {
logger.debug("deleteBatchProductsInCMS retrying failed by rate limit with delay x2");
secondResults = await fetchWithRateLimit(
failedWithLimitResults,
(result) => deleteProductInCMS(result.url),
requestPerSecondLimit / 2
);
}
return [...firstResults.filter((result) => result.status !== 429), ...secondResults];
};
return {
ping: async () => {
const response = await pingCMS();
logger.debug({ response }, "ping response");
return response;
},
createProduct: async ({ input }) => {
const result = await createProductInCMS(input);
logger.debug({ result }, "createProduct result");
return transformCreateProductResponse(result);
},
updateProduct: async ({ id, input }) => {
const result = await updateProductInCMS(id, input);
logger.debug({ result }, "updateProduct result");
return result;
},
deleteProduct: async ({ id }) => {
const response = await deleteProductInCMS(id);
logger.debug({ response }, "deleteProduct response");
return response;
},
createBatchProducts: async ({ input }) => {
const results = await createBatchProductsInCMS(input);
logger.debug({ results }, "createBatchProducts results");
return results.map((result) => transformCreateProductResponse(result));
},
deleteBatchProducts: async ({ ids }) => {
const results = await deleteBatchProductsInCMS(ids);
logger.debug({ results }, "deleteBatchProducts results");
},
};
};
export const contentfulProvider = createProvider(contentfulOperations, contentfulConfigSchema);

View file

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

View file

@ -1,147 +0,0 @@
import { createProvider } from "./create";
import { CreateOperations, ProductInput, ProductResponse } from "../types";
import { ApiError, buildClient, SimpleSchemaTypes } from "@datocms/cma-client-node";
import { DatocmsConfig, datocmsConfigSchema } from "../config";
import { createLogger } from "@saleor/apps-shared";
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): ProductResponse => {
if (error instanceof ApiError) {
return {
ok: false,
error: error.message,
};
} else {
return {
ok: false,
error: "Something went wrong!",
};
}
};
const transformResponseItem = (
item: SimpleSchemaTypes.Item,
input: ProductInput
): ProductResponse => {
return {
ok: true,
data: {
id: item.id,
saleorId: input.saleorId,
},
};
};
const datocmsOperations: CreateOperations<DatocmsConfig> = (config) => {
const logger = createLogger({ cms: "strapi" });
const client = datocmsClient(config);
const pingCMS = async () => client.users.findMe();
const createProductInCMS = async (input: ProductInput) =>
client.items.create({
item_type: {
id: String(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,
});
const updateProductInCMS = async (id: string, input: ProductInput) =>
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,
});
const deleteProductInCMS = async (id: string) => client.items.destroy(id);
const createBatchProductsInCMS = async (input: ProductInput[]) =>
// DatoCMS doesn't support batch creation of items, so we need to create them one by one
Promise.all(
input.map(async (item) => ({
id: await createProductInCMS(item),
input: item,
}))
);
const deleteBatchProductsInCMS = async (ids: string[]) =>
client.items.bulkDestroy({
items: ids.map((id) => ({ id, type: "item" })),
});
return {
ping: async () => {
try {
const response = await pingCMS();
logger.debug({ response }, "ping response");
if (!response.id) {
throw new Error();
}
return { ok: true };
} catch (error) {
return { ok: false };
}
},
createProduct: async ({ input }) => {
try {
const item = await createProductInCMS(input);
logger.debug({ item }, "createProduct response");
return transformResponseItem(item, input);
} catch (error) {
return transformResponseError(error);
}
},
updateProduct: async ({ id, input }) => {
const item = await updateProductInCMS(id, input);
logger.debug({ item }, "updateProduct response");
},
deleteProduct: async ({ id }) => {
const item = await deleteProductInCMS(id);
logger.debug({ item }, "deleteProduct response");
},
createBatchProducts: async ({ input }) => {
const items = await createBatchProductsInCMS(input);
logger.debug({ items }, "createBatchProducts response");
return items.map((item) => transformResponseItem(item.id, item.input));
},
deleteBatchProducts: async ({ ids }) => {
const items = await deleteBatchProductsInCMS(ids);
logger.debug({ items }, "deleteBatchProducts response");
},
};
};
export const datoCmsProvider = createProvider(datocmsOperations, datocmsConfigSchema);

View file

@ -1,11 +0,0 @@
import { contentfulProvider } from "./contentful";
import { strapiProvider } from "./strapi";
import { datoCmsProvider } from "./datocms";
export const cmsProviders = {
contentful: contentfulProvider,
strapi: strapiProvider,
datocms: datoCmsProvider,
};
export type CMSProvider = keyof typeof cmsProviders;

View file

@ -1,180 +0,0 @@
import { StrapiConfig, strapiConfigSchema } from "../config";
import { CreateOperations, ProductResponse, ProductInput } from "../types";
import { createProvider } from "./create";
import { createLogger } from "@saleor/apps-shared";
const strapiFetch = async (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: ProductInput): StrapiBody => {
const body = {
data: {
saleor_id: input.saleorId,
name: input.name,
channels: 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,
input: ProductInput
): ProductResponse => {
if (response.error) {
return {
ok: false,
error: "Something went wrong!",
};
}
return {
ok: true,
data: {
id: response.data.id,
saleorId: input.saleorId,
},
};
};
type CreateStrapiOperations = CreateOperations<StrapiConfig>;
export const strapiOperations: CreateStrapiOperations = (config) => {
const logger = createLogger({ cms: "strapi" });
const { contentTypeId } = config;
const pingCMS = async () => {
const response = await strapiFetch(`/${contentTypeId}`, config, {
method: "GET",
});
logger.debug({ response }, "pingCMS response");
return { ok: response.ok };
};
const createProductInCMS = async (input: ProductInput): Promise<StrapiResponse> => {
const body = transformInputToBody(input);
const response = await strapiFetch(`/${contentTypeId}`, config, {
method: "POST",
body: JSON.stringify(body),
});
logger.debug({ response }, "createProduct response");
return await response.json();
};
const updateProductInCMS = async (id: string, input: ProductInput) => {
const body = transformInputToBody(input);
return await strapiFetch(`/${contentTypeId}/${id}`, config, {
method: "PUT",
body: JSON.stringify(body),
});
};
const deleteProductInCMS = async (id: string) => {
return await strapiFetch(`/${contentTypeId}/${id}`, config, { method: "DELETE" });
};
const createBatchProductsInCMS = async (input: ProductInput[]) => {
// Strapi doesn't support batch creation of items, so we need to create them one by one
return await Promise.all(
input.map(async (product) => ({
response: await createProductInCMS(product),
input: product,
}))
);
};
const deleteBatchProductsInCMS = async (ids: string[]) => {
// Strapi doesn't support batch deletion of items, so we need to delete them one by one
return await Promise.all(ids.map((id) => deleteProductInCMS(id)));
};
return {
ping: async () => {
const response = await pingCMS();
logger.debug({ response }, "ping response");
return response;
},
createProduct: async ({ input }) => {
const result = await createProductInCMS(input);
logger.debug({ result }, "createProduct result");
return transformCreateProductResponse(result, input);
},
updateProduct: async ({ id, input }) => {
const response = await updateProductInCMS(id, input);
logger.debug({ response }, "updateProduct response");
return response;
},
deleteProduct: async ({ id }) => {
const response = await deleteProductInCMS(id);
logger.debug({ response }, "deleteProduct response");
return response;
},
createBatchProducts: async ({ input }) => {
const results = await createBatchProductsInCMS(input);
logger.debug({ results }, "createBatchProducts results");
return results.map((result) => transformCreateProductResponse(result.response, result.input));
},
deleteBatchProducts: async ({ ids }) => {
const responses = await deleteBatchProductsInCMS(ids);
logger.debug({ responses }, "deleteBatchProducts responses");
return responses;
},
};
};
export const strapiProvider = createProvider(strapiOperations, strapiConfigSchema);

View file

@ -1,63 +0,0 @@
import { z } from "zod";
import { ProvidersSchema, 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 BaseResponse = { ok: boolean };
export type ProductResponseSuccess = { ok: true; data: { id: string; saleorId: string } };
export type ProductResponseError = { ok: false; error: string };
export type ProductResponse = ProductResponseSuccess | ProductResponseError;
export type CmsOperations = {
ping: () => Promise<BaseResponse>;
getProduct?: ({ id }: { id: string }) => Promise<Response>;
createProduct: ({ input }: { input: ProductInput }) => Promise<ProductResponse>;
updateProduct: ({ id, input }: { id: string; input: ProductInput }) => Promise<Response | void>;
deleteProduct: ({ id }: { id: string }) => Promise<Response | void>;
};
export type CmsBatchOperations = {
getAllProducts?: () => Promise<Response>;
createBatchProducts: ({ input }: { input: ProductInput[] }) => Promise<ProductResponse[]>;
deleteBatchProducts: ({ ids }: { ids: string[] }) => Promise<Response[] | void>;
};
export type CmsClientOperations = {
cmsProviderInstanceId: string;
operations: CmsOperations;
operationType: keyof CmsOperations;
};
export type CmsClientBatchOperations = {
cmsProviderInstanceId: string;
operations: CmsBatchOperations;
operationType: keyof CmsBatchOperations;
};
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> = Omit<
ProvidersSchema[TProviderName],
"id" | "providerName"
> &
BaseConfig;
export type CreateOperations<TConfig extends BaseConfig> = (
config: TConfig
) => CmsOperations & CmsBatchOperations;
export type Provider<TConfig extends BaseConfig> = {
create: CreateOperations<TConfig>;
schema: z.ZodType<TConfig>;
};

View file

@ -1,120 +0,0 @@
import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
import { Client } from "urql";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
FetchProductVariantMetadataDocument,
FetchProductVariantMetadataQuery,
UpdateAppMetadataDocument,
} from "../../generated/graphql";
import { createLogger } from "@saleor/apps-shared";
/*
* 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 logger = createLogger({
function: "fetchAllMetadata",
});
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
if (error) {
logger.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[]) {
const logger = createLogger({
function: "mutateMetadata",
});
// to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
.toPromise();
if (idQueryError) {
logger.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) {
logger.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) {
logger.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),
});
};
export async function fetchProductVariantMetadata(
client: Client,
productId: string
): Promise<MetadataEntry[]> {
const logger = createLogger({
function: "fetchProductVariantMetadata",
productId,
});
const { error, data } = await client
.query<FetchProductVariantMetadataQuery>(FetchProductVariantMetadataDocument, {
id: productId,
})
.toPromise();
if (error) {
logger.debug("Error during fetching product metadata", error);
return [];
}
return data?.productVariant?.metadata.map((md) => ({ key: md.key, value: md.value })) || [];
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,70 +0,0 @@
import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
import { Client } from "urql";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateAppMetadataDocument,
} from "../../../generated/graphql";
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
console.log("fetchAllMetadata", error, data);
if (error) {
return [];
}
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
}
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
// to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
.toPromise();
if (idQueryError) {
throw new Error(
"Could not fetch the app id. Please check if auth data for the client are valid."
);
}
const appId = idQueryData?.app?.id;
if (!appId) {
throw new Error("Could not fetch the app ID");
}
const { error: mutationError, data: mutationData } = await client
.mutation(UpdateAppMetadataDocument, {
id: appId,
input: metadata,
})
.toPromise();
if (mutationError) {
throw new Error(`Mutation error: ${mutationError.message}`);
}
return (
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
key: md.key,
value: md.value,
})) || []
);
}
export const createSettingsManager = (client: Client) => {
// EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
// We recommend it for production, because all values are encrypted.
// If your use case require plain text values, you can use MetadataManager.
return new EncryptedMetadataManager({
// Secret key should be randomly created for production and set as environment variable
encryptionKey: process.env.SECRET_KEY!,
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutateMetadata(client, metadata),
});
};

View file

@ -1,210 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Checkbox, FormControl, Typography } from "@material-ui/core";
import {
Button,
List,
ListBody,
ListFooter,
ListHeader,
ListItem,
ListItemCell,
makeStyles,
Notification,
Alert,
IconButton,
} from "@saleor/macaw-ui";
import React from "react";
import { useForm } from "react-hook-form";
import {
channelSchema,
ChannelSchema,
MergedChannelSchema,
SingleChannelSchema,
SingleProviderSchema,
} from "../../../lib/cms/config";
import { ProviderIcon } from "../../provider-instances/ui/provider-icon";
import { ChannelsLoading } from "./types";
const useStyles = makeStyles((theme) => {
return {
item: {
height: "auto !important",
display: "grid",
gridTemplateColumns: "1fr 80px 80px",
},
itemCell: {
display: "flex",
alignItems: "center",
gap: theme.spacing(2),
},
itemCellCenter: {
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing(2),
},
itemCellProgress: {
padding: theme.spacing(0, 4),
gridColumn: "1/5",
},
footer: {
display: "flex",
justifyContent: "flex-end",
padding: theme.spacing(2, 4),
},
form: {
border: `1px solid hsla(212, 44%, 13%, 0.08)`,
borderRadius: 8,
},
};
});
interface ChannelConfigurationFormProps {
channel?: MergedChannelSchema | null;
providerInstances: SingleProviderSchema[];
loading: ChannelsLoading;
onSubmit: (channel: SingleChannelSchema) => any;
onSync: (providerInstanceId: string) => any;
}
export const ChannelConfigurationForm = ({
channel,
providerInstances,
loading,
onSubmit,
onSync,
}: 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 || [],
});
resetField("requireSyncProviderInstances", {
defaultValue: channel?.requireSyncProviderInstances || [],
});
}, [channel, providerInstances]);
const errors = formState.errors;
return (
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
{!!Object.entries(errors).length && (
<Typography variant="body1" color="error">
Error validating form
</Typography>
)}
<input type="hidden" {...register("channelSlug")} value={channel?.channelSlug} />
<List gridTemplate={["1fr", "80px", "checkbox"]}>
<ListHeader>
<ListItem className={styles.item}>
<ListItemCell>CMS provider configuration</ListItemCell>
<ListItemCell className={styles.itemCellCenter}>Active</ListItemCell>
<ListItemCell className={styles.itemCellCenter}>Sync</ListItemCell>
</ListItem>
</ListHeader>
<ListBody>
{providerInstances.map((providerInstance) => {
const enabledProviderInstances = watch("enabledProviderInstances");
const requireSyncProviderInstances = watch("requireSyncProviderInstances");
const isEnabled = enabledProviderInstances?.some(
(formOption) => formOption === providerInstance.id
);
const requireSync = requireSyncProviderInstances?.some(
(formOption) => formOption === providerInstance.id
);
return (
<ListItem key={providerInstance.id} className={styles.item}>
<ListItemCell className={styles.itemCell}>
<ProviderIcon providerName={providerInstance.providerName} />
{providerInstance.name}
</ListItemCell>
<ListItemCell padding="checkbox" className={styles.itemCellCenter}>
<FormControl
{...register("enabledProviderInstances")}
name="enabledProviderInstances"
checked={isEnabled}
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>
<ListItemCell className={styles.itemCellCenter}>
<Button
variant="primary"
disabled={
!requireSync || !!loading.productsVariantsSync.syncingProviderInstanceId
}
onClick={() => onSync(providerInstance.id)}
>
Sync
</Button>
</ListItemCell>
{loading.productsVariantsSync.syncingProviderInstanceId === providerInstance.id && (
<ListItemCell className={styles.itemCellProgress}>
Syncing products...
<progress
value={loading.productsVariantsSync.currentProductIndex}
max={loading.productsVariantsSync.totalProductsCount}
style={{
height: "30px",
width: "500px",
maxWidth: "100%",
}}
/>
</ListItemCell>
)}
</ListItem>
);
})}
{/* </>
)}
/> */}
</ListBody>
<ListFooter className={styles.footer}>
<Button variant="primary" disabled={loading.channels.saving} type="submit">
{loading.channels.saving ? "..." : "Save"}
</Button>
</ListFooter>
</List>
</form>
);
};

View file

@ -1,122 +0,0 @@
import { AppPaper } from "../../ui/app-paper";
import { Grid, Paper, Typography } from "@material-ui/core";
import { Skeleton } from "@material-ui/lab";
import { ChannelConfigurationForm } from "./channel-configuration-form";
import {
MergedChannelSchema,
SingleChannelSchema,
SingleProviderSchema,
} from "../../../lib/cms/config";
import { ChannelsLoading } from "./types";
import { makeStyles } from "@saleor/macaw-ui";
import { AppTabNavButton } from "../../ui/app-tab-nav-button";
import { ChannelsDataErrors } from "./hooks/useChannels";
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;
syncChannelProviderInstance: (providerInstanceId: string) => any;
loading: ChannelsLoading;
errors: ChannelsDataErrors;
}
export const ChannelConfiguration = ({
activeChannel,
providerInstances,
saveChannel,
syncChannelProviderInstance,
loading,
errors,
}: ChannelConfigurationProps) => {
const styles = useStyles();
if (loading.channels.fetching || loading.channels.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 configuration before you manage its setup 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}
onSubmit={saveChannel}
onSync={syncChannelProviderInstance}
/>
</Paper>
);
};

View file

@ -1,72 +0,0 @@
import {
makeStyles,
OffsettedList,
OffsettedListBody,
OffsettedListHeader,
OffsettedListItem,
OffsettedListItemCell,
} from "@saleor/macaw-ui";
import clsx from "clsx";
import { MergedChannelSchema } from "../../../lib/cms/config";
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;
}
export 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>
);
};

View file

@ -1,47 +0,0 @@
import { Skeleton } from "@material-ui/lab";
import { MergedChannelSchema } from "../../../lib/cms";
import { AppPaper } from "../../ui/app-paper";
import { ChannelsLoading } from "./types";
import { ChannelsSelect } from "./channels-select";
import { ChannelsDataErrors } from "./hooks/useChannels";
const ChannelsListSkeleton = () => {
return (
<AppPaper>
<Skeleton variant="rect" width={"100%"} height={30} />
</AppPaper>
);
};
interface ChannelsListProps {
channels: MergedChannelSchema[];
activeChannel?: MergedChannelSchema | null;
setActiveChannel: (channel: MergedChannelSchema | null) => void;
loading: ChannelsLoading;
errors: ChannelsDataErrors;
}
export const ChannelsList = ({
channels,
activeChannel,
setActiveChannel,
loading,
errors,
}: ChannelsListProps) => {
if (loading.channels.fetching) {
return <ChannelsListSkeleton />;
}
if (errors.fetching) {
return <div>Error loading channels</div>;
}
return (
<ChannelsSelect
channels={channels}
activeChannel={activeChannel}
setActiveChannel={setActiveChannel}
/>
);
};

View file

@ -1,41 +0,0 @@
import { MergedChannelSchema } from "../../../lib/cms/config";
import { FormControl, InputLabel, MenuItem, Select, SelectProps } from "@material-ui/core";
interface ChannelsListItemsProps extends SelectProps {
channels: MergedChannelSchema[];
activeChannel?: MergedChannelSchema | null;
setActiveChannel: (channel: MergedChannelSchema | null) => void;
}
export const ChannelsSelect = ({
channels,
activeChannel,
setActiveChannel,
...props
}: ChannelsListItemsProps) => {
console.log(activeChannel);
return (
<FormControl>
<InputLabel id="channel-select">Select channel to configure</InputLabel>
<Select
labelId="channel-select"
{...props}
variant="outlined"
fullWidth
value={activeChannel?.channel.id}
onChange={(e, value) => {
console.log(e.target.value);
setActiveChannel(channels.find((c) => c.channel.id === e.target.value)!);
}}
>
{channels.map((c) => (
<MenuItem key={c.channel.id} value={c.channel.id}>
{c.channel.name}
</MenuItem>
))}
</Select>
</FormControl>
);
};

View file

@ -1,105 +0,0 @@
import { makeStyles } from "@saleor/macaw-ui";
import { useEffect, useState } from "react";
import { MergedChannelSchema, SingleChannelSchema } from "../../../lib/cms/config";
import {
useProductsVariantsSync,
ProductsVariantsSyncOperation,
} from "../../cms/hooks/useProductsVariantsSync";
import { useProviderInstances } from "../../provider-instances/ui/hooks/useProviderInstances";
import { AppTabs } from "../../ui/app-tabs";
import { ChannelConfiguration } from "./channel-configuration";
import { ChannelsList } from "./channels-list";
import { useChannels } from "./hooks/useChannels";
import { ChannelsLoading } from "./types";
const useStyles = makeStyles({
wrapper: {
display: "flex",
flexDirection: "column",
gap: 16,
},
});
export const Channels = () => {
const styles = useStyles();
const { channels, saveChannel, loading: loadingChannels, 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);
useEffect(() => {
if (!activeChannelSlug && channels.length > 0) {
setActiveChannelSlug(channels[0].channelSlug);
}
}, [channels]);
const handleOnSyncCompleted = (providerInstanceId: string, error?: string) => {
if (!activeChannel) {
return;
}
if (error) {
return;
}
saveChannel({
...activeChannel,
requireSyncProviderInstances: activeChannel.requireSyncProviderInstances?.filter(
(id) => id !== providerInstanceId
),
});
};
const { sync, loading: loadingProductsVariantsSync } = useProductsVariantsSync(
activeChannelSlug,
handleOnSyncCompleted
);
const handleSync = async (providerInstanceId: string) => {
if (!activeChannel) {
return;
}
const operation: ProductsVariantsSyncOperation =
activeChannel.enabledProviderInstances.includes(providerInstanceId) ? "ADD" : "DELETE";
return sync(providerInstanceId, operation);
};
const loading: ChannelsLoading = {
channels: loadingChannels,
productsVariantsSync: loadingProductsVariantsSync,
};
return (
<>
<AppTabs activeTab="channels" />
<div className={styles.wrapper}>
<ChannelsList
channels={channels}
activeChannel={activeChannel}
setActiveChannel={handleSetActiveChannel}
loading={loading}
errors={errors}
/>
<ChannelConfiguration
activeChannel={activeChannel}
providerInstances={providerInstances}
saveChannel={saveChannel}
syncChannelProviderInstance={handleSync}
loading={loading}
errors={errors}
/>
</div>
</>
);
};

View file

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

View file

@ -1,90 +0,0 @@
import { useChannelsFetch } from "./useChannelsFetch";
import { MergedChannelSchema, SingleChannelSchema } from "../../../../lib/cms/config";
import { useChannelsQuery } from "../../../../../generated/graphql";
import { useIsMounted } from "usehooks-ts";
import { useDashboardNotification } from "@saleor/apps-shared";
export interface ChannelsDataLoading {
fetching: boolean;
saving: boolean;
}
export interface ChannelsDataErrors {
fetching?: Error | null;
saving?: Error | null;
}
export const useChannels = () => {
const isMounted = useIsMounted();
const [channelsQueryData, channelsQueryOpts] = useChannelsQuery({
pause: !isMounted,
});
const {
saveChannel: saveChannelFetch,
isSaving,
data: settings,
error: fetchingError,
isFetching,
} = useChannelsFetch();
const { notifySuccess, notifyError } = useDashboardNotification();
const saveChannel = async (channelToSave: SingleChannelSchema) => {
console.log("saveChannel", channelToSave);
const currentlyEnabledProviderInstances =
settings?.[`${channelToSave.channelSlug}`]?.enabledProviderInstances || [];
const toEnableProviderInstances = channelToSave.enabledProviderInstances || [];
const changedSyncProviderInstances = [
...currentlyEnabledProviderInstances.filter(
(instance) => !toEnableProviderInstances.includes(instance)
),
...toEnableProviderInstances.filter(
(instance) => !currentlyEnabledProviderInstances.includes(instance)
),
];
const fetchResult = await saveChannelFetch({
...channelToSave,
requireSyncProviderInstances: [
...(channelToSave.requireSyncProviderInstances || []),
...changedSyncProviderInstances.filter(
(instance) => !(channelToSave.requireSyncProviderInstances || []).includes(instance)
),
],
});
if (fetchResult.success) {
notifySuccess("Success", "Configuration saved");
} else {
notifyError("Error", "Error while saving configuration");
}
};
const loading: ChannelsDataLoading = {
fetching: isFetching || channelsQueryData.fetching,
saving: isSaving,
};
const errors: ChannelsDataErrors = {
fetching: fetchingError ? Error(fetchingError) : null,
saving: null,
};
const channels =
channelsQueryData.data?.channels?.map(
(channel) =>
({
channelSlug: channel.slug,
enabledProviderInstances: settings
? settings[`${channel.slug}`]?.enabledProviderInstances
: [],
requireSyncProviderInstances: settings
? settings[`${channel.slug}`]?.requireSyncProviderInstances
: [],
channel: channel,
} as MergedChannelSchema)
) || [];
return { channels, saveChannel, loading, errors };
};

View file

@ -1,88 +0,0 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import React from "react";
import { 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";
export 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);
return {
success: true,
};
} else {
throw new Error();
}
} catch (error) {
console.log(error);
return {
success: false,
};
}
};
React.useEffect(() => {
getChannels();
}, []);
return { saveChannel, isSaving, data: config, isFetching, error: validationError };
};

View file

@ -1,7 +0,0 @@
import { ProductsVariantsSyncLoading } from "../../cms/hooks/useProductsVariantsSync";
import { ChannelsDataLoading } from "./hooks/useChannels";
export interface ChannelsLoading {
channels: ChannelsDataLoading;
productsVariantsSync: ProductsVariantsSyncLoading;
}

View file

@ -1,168 +0,0 @@
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { useCallback, useEffect, useState } from "react";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
import { Products, useQueryAllProducts } from "./useQueryAllProducts";
export interface ProductsVariantsSyncLoading {
syncingProviderInstanceId?: string;
currentProductIndex?: number;
totalProductsCount?: number;
}
export type ProductsVariantsSyncOperation = "ADD" | "DELETE";
const BATCH_SIZE = 100;
interface UseProductsVariantsSyncHandlers {
sync: (providerInstanceId: string, operation: ProductsVariantsSyncOperation) => void;
loading: ProductsVariantsSyncLoading;
}
export const useProductsVariantsSync = (
channelSlug: string | null,
onSyncCompleted: (providerInstanceId: string, error?: string) => void
): UseProductsVariantsSyncHandlers => {
const { appBridge, appBridgeState } = useAppBridge();
const [startedProviderInstanceId, setStartedProviderInstanceId] = useState<string>();
const [startedOperation, setStartedOperation] = useState<ProductsVariantsSyncOperation>();
const [currentProductIndex, setCurrentProductIndex] = useState(0);
const [isImporting, setIsImporting] = useState(false);
const { products, fetchCompleted } = useQueryAllProducts(!startedProviderInstanceId, channelSlug);
const sync = (providerInstanceId: string, operation: ProductsVariantsSyncOperation) => {
setStartedProviderInstanceId(providerInstanceId);
setStartedOperation(operation);
setCurrentProductIndex(0);
};
const syncFetch = async (
providerInstanceId: string,
operation: ProductsVariantsSyncOperation,
productsBatch: Products
) => {
const productsVariants = productsBatch.reduce((acc, product) => {
const variants = product.variants?.map((variant) => {
const { variants: _, ...productFields } = product;
return {
product: productFields,
...variant,
};
});
return variants ? [...acc, ...variants] : acc;
}, [] as WebhookProductVariantFragment[]);
try {
const syncResponse = await fetch("/api/sync-products-variants", {
method: "POST",
headers: [
["content-type", "application/json"],
[SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!],
[SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!],
],
body: JSON.stringify({
providerInstanceId,
productsVariants,
operation,
}),
});
const syncResult = await syncResponse.json();
return syncResult;
} catch (error) {
console.error("useProductsVariantsSync syncFetch error", error);
}
};
const completeSync = (completedProviderInstanceIdSync: string, error?: string) => {
setStartedProviderInstanceId(undefined);
setStartedOperation(undefined);
setCurrentProductIndex(0);
onSyncCompleted(completedProviderInstanceIdSync, error);
if (error) {
appBridge?.dispatch(
actions.Notification({
title: "Error",
status: "error",
text: "Error syncing products variants",
apiMessage: error,
})
);
} else {
appBridge?.dispatch(
actions.Notification({
title: "Success",
status: "success",
text: "Products variants sync completed successfully",
})
);
}
};
useEffect(() => {
if (
products.length <= currentProductIndex &&
fetchCompleted &&
startedProviderInstanceId &&
startedOperation
) {
completeSync(startedProviderInstanceId);
}
}, [products.length, currentProductIndex, fetchCompleted]);
useEffect(() => {
if (!startedProviderInstanceId || !startedOperation) {
return;
}
if (products.length <= currentProductIndex) {
return;
}
if (isImporting) {
return;
}
(async () => {
setIsImporting(true);
const productsBatchStartIndex = currentProductIndex;
const productsBatchEndIndex = Math.min(currentProductIndex + BATCH_SIZE, products.length);
const productsBatch = products.slice(productsBatchStartIndex, productsBatchEndIndex);
// temporary solution, cannot use directly backend methods without fetch, due to non-browser Node dependency, like await cmsProvider.updatedBatchProducts(productsBatch);
const syncResult = await syncFetch(
startedProviderInstanceId,
startedOperation,
productsBatch
);
if (syncResult.error) {
completeSync(startedProviderInstanceId, syncResult.error);
} else {
setCurrentProductIndex(productsBatchEndIndex);
}
setIsImporting(false);
})();
}, [
startedProviderInstanceId,
startedOperation,
currentProductIndex,
isImporting,
products.length,
]);
const loading: ProductsVariantsSyncLoading = {
syncingProviderInstanceId: startedProviderInstanceId,
currentProductIndex,
totalProductsCount: products.length,
};
return {
sync,
loading,
};
};

View file

@ -1,81 +0,0 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useEffect, useState } from "react";
import {
ProductsDataForImportDocument,
ProductsDataForImportQuery,
} from "../../../../generated/graphql";
import { createGraphQLClient } from "@saleor/apps-shared";
const PER_PAGE = 100;
export type Products = NonNullable<
ProductsDataForImportQuery["products"]
>["edges"][number]["node"][];
export const useQueryAllProducts = (paused: boolean, channelSlug: string | null) => {
const { appBridgeState } = useAppBridge();
const saleorApiUrl = appBridgeState?.saleorApiUrl!;
const [products, setProducts] = useState<Products>([]);
const [fetchCompleted, setFetchCompleted] = useState(false);
useEffect(() => {
if (paused) {
setProducts([]);
setFetchCompleted(false);
}
}, [paused]);
useEffect(() => {
if (paused || !channelSlug || !appBridgeState?.token) {
return;
}
const token = appBridgeState.token;
const client = createGraphQLClient({ saleorApiUrl, token });
if (!client) {
return;
}
const getProducts = async (channelSlug: string, cursor: string): Promise<void> => {
const response = await client
.query(
ProductsDataForImportDocument,
{
after: cursor,
first: PER_PAGE,
channel: channelSlug!,
},
{
requestPolicy: "network-only", // Invalidate products data, because it could contain legacy products variants metadata that indicates these products variants existance in CMS providers
}
)
.toPromise();
const newProducts = response?.data?.products?.edges.map((e) => e.node) ?? [];
if (newProducts.length > 0) {
setProducts((ps) => [...ps, ...newProducts]);
}
if (
response?.data?.products?.pageInfo.hasNextPage &&
response?.data?.products?.pageInfo.endCursor
) {
// get next page of products
return getProducts(channelSlug, response.data.products?.pageInfo.endCursor);
} else {
setFetchCompleted(true);
}
};
(async () => {
await getProducts(channelSlug, "");
})();
}, [appBridgeState?.token, saleorApiUrl, paused, channelSlug]);
return {
products,
fetchCompleted,
};
};

View file

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

View file

@ -1,70 +0,0 @@
import { useEffect, useState } from "react";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
const getCurrentTime = () => new Date().toLocaleTimeString();
export interface ProviderInstancePingStatus {
providerInstanceId: string;
success: boolean;
time: string;
}
export interface PingProviderInstanceOpts {
result: ProviderInstancePingStatus | null;
refresh: () => void;
}
export const usePingProviderInstance = (providerInstanceId: string | null) => {
const { appBridgeState } = useAppBridge();
const [result, setResult] = useState<null | ProviderInstancePingStatus>(null);
const ping = async (providerInstanceId: string): Promise<ProviderInstancePingStatus> => {
try {
const pingResponse = await fetch("/api/ping-provider-instance", {
method: "POST",
headers: [
["content-type", "application/json"],
[SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!],
[SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!],
],
body: JSON.stringify({
providerInstanceId,
}),
});
const pingResult = await pingResponse.json();
return {
providerInstanceId,
success: pingResult.success,
time: getCurrentTime(),
};
} catch (error) {
console.error("useProductsVariantsSync syncFetch error", error);
return {
providerInstanceId,
success: false,
time: getCurrentTime(),
};
}
};
const refresh = () => {
setResult(null);
if (providerInstanceId) {
ping(providerInstanceId).then((result) => setResult(result));
}
};
useEffect(() => {
refresh();
}, [providerInstanceId]);
return {
result,
refresh,
};
};

View file

@ -1,43 +0,0 @@
import { useProviderInstancesFetch } from "./useProviderInstancesFetch";
import { SingleProviderSchema } from "../../../../lib/cms/config";
import { ProvidersErrors, ProvidersLoading } from "../types";
export const useProviderInstances = () => {
const {
saveProviderInstance: saveProviderInstanceFetch,
deleteProviderInstance: deleteProviderInstanceFetch,
isSaving,
data: settings,
error: fetchingError,
isFetching,
} = useProviderInstancesFetch();
const saveProviderInstance = async (providerInstanceToSave: SingleProviderSchema) => {
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 };
};

View file

@ -1,134 +0,0 @@
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";
export 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) => {
console.log(instance);
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,
};
};

View file

@ -1,31 +0,0 @@
import { makeStyles } from "@saleor/macaw-ui";
import { getProviderByName } from "../../providers/config";
import Image from "next/image";
import clsx from "clsx";
interface ProviderIconProps {
providerName: string;
small?: boolean;
}
const useStyles = makeStyles((theme) => ({
small: {
width: theme.spacing(2.5),
},
}));
export const ProviderIcon = ({ providerName, small = false }: ProviderIconProps) => {
const styles = useStyles();
const provider = getProviderByName(providerName);
return provider ? (
<Image
src={provider.iconSrc}
alt={`${provider.label} icon`}
className={clsx({
[styles.small]: small,
})}
/>
) : null;
};

View file

@ -1,176 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Grid, TextField, Typography } from "@material-ui/core";
import { Button, makeStyles } from "@saleor/macaw-ui";
import React from "react";
import { Path, useForm } from "react-hook-form";
import {
CMSProviderSchema,
providersConfig,
ProvidersSchema,
providersSchemaSet,
SingleProviderSchema,
} from "../../../lib/cms/config";
import { Provider } from "../../providers/config";
import { AppMarkdownText } from "../../ui/app-markdown-text";
import { ZodNumber } from "zod";
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;
}
export 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),
});
React.useEffect(() => {
resetField("providerName" as Path<ProvidersSchema[TProvider]>, {
// @ts-ignore todo
defaultValue: provider.name,
});
if (providerInstance && providerInstance.providerName === provider.name) {
reset(providerInstance as ProvidersSchema[TProvider]);
}
}, [provider, providerInstance]);
const submitHandler = (values: SingleProviderSchema) => {
onSubmit(values);
};
const fields = providersConfig[provider.name as TProvider].tokens;
const errors = formState.errors;
const getOptionalText = (token: Record<string, unknown>) =>
"required" in token && token.required ? "" : "*Optional. ";
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("providerName" as Path<ProvidersSchema[TProvider]>)}
value={provider.name}
/>
<Grid item xs={12}>
<TextField
{...register("name" as Path<ProvidersSchema[TProvider]>)}
label="Configuration name"
type="text"
name="name"
InputLabelProps={{
shrink: !!watch("name" as Path<ProvidersSchema[TProvider]>),
}}
fullWidth
error={!!errors.name}
helperText={
<>
{errors.name?.message ||
"Used to differentiate configuration instance. You may create multiple instances of provider configuration, e.g. Contentful Prod, Contentful Test, etc."}
</>
}
/>
</Grid>
{fields.map((token) => {
const isSecret = token.secret ? { type: "password" } : {};
return (
<Grid xs={12} item key={token.name}>
<TextField
{...register(token.name as Path<ProvidersSchema[TProvider]>, {
required: "required" in token && token.required,
valueAsNumber:
schema.shape[token.name as keyof typeof schema.shape] instanceof ZodNumber,
})}
{...isSecret}
label={token.label}
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 && (
<AppMarkdownText>{`${getOptionalText(token)}${
token.helpText
}`}</AppMarkdownText>
))}
</>
}
/>
</Grid>
);
})}
{providerInstance ? (
<Grid item xs={12}>
<TextField
{...register("id" as Path<ProvidersSchema[TProvider]>)}
label="Configuration id"
type="text"
name="id"
fullWidth
helperText="Automatically generated unique identifier for the configuration instance."
disabled={true}
/>
</Grid>
) : (
<input type="hidden" {...register("id" as Path<ProvidersSchema[TProvider]>)} />
)}
<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>
);
};

View file

@ -1,241 +0,0 @@
import { FormControlLabel, Grid, Radio, RadioGroup, Typography } from "@material-ui/core";
import { Button, 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";
import { ProviderInstancePingStatus } from "./hooks/usePingProviderInstance";
import { ProviderInstancePing } from "./provider-instance-ping";
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),
},
newProviderButton: {
margin: "30px auto",
},
newProviderContainer: {
display: "flex",
justifyContent: "center",
},
box: {
border: `1px solid hsla(212, 44%, 13%, 0.08)`,
borderRadius: 8,
padding: 20,
},
successStatus: {
color: theme.palette.type === "dark" ? theme.palette.success.light : theme.palette.success.dark,
},
errorStatus: {
color: theme.palette.error.main,
},
}));
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;
providerInstancePingStatus: ProviderInstancePingStatus | null;
onNewProviderRequest(): void;
}
export const ProviderInstanceConfiguration = ({
activeProviderInstance,
newProviderInstance,
saveProviderInstance,
deleteProviderInstance,
loading,
providerInstancePingStatus,
onNewProviderRequest,
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);
};
const isPingStatusLoading =
providerInstancePingStatus?.providerInstanceId !== activeProviderInstance?.id;
if (loading.fetching || loading.saving) {
return <ProviderInstanceConfigurationSkeleton />;
}
if (!newProviderInstance && !activeProviderInstance) {
return (
<AppPaper>
<Typography variant="body1" className={styles.textCenter}>
Please select a provider configuration or add new one.
</Typography>
<div className={styles.newProviderContainer}>
<Button
onClick={onNewProviderRequest}
variant="primary"
className={styles.newProviderButton}
>
Create a Provider config
</Button>
</div>
</AppPaper>
);
}
return (
<AppPaper className={styles.box}>
<div>
{errors.fetching && (
<Typography variant="body1" color="error">
Error fetching available providers
</Typography>
)}
{errors.saving && (
<Typography variant="body1" color="error">
Error saving provider configuration
</Typography>
)}
{!!newProviderInstance && (
<Typography variant="h3" className={styles.textHeader}>
Add new configuration
</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 ? (
<>
{!newProviderInstance && (
<>
<br />
<ProviderInstancePing
loading={isPingStatusLoading}
status={providerInstancePingStatus}
/>
</>
)}
<br />
<ProviderInstanceConfigurationForm
provider={selectedProvider}
providerInstance={!newProviderInstance ? activeProviderInstance : null}
loading={loading.saving}
onSubmit={saveProviderInstance}
onDelete={deleteProviderInstance}
/>
</>
) : (
<>
<br />
<Typography variant="body1" className={styles.textCenter}>
Please select a provider.
</Typography>
</>
)}
</div>
</AppPaper>
);
};

View file

@ -1,46 +0,0 @@
import { Typography } from "@material-ui/core";
import { Skeleton } from "@material-ui/lab";
import { makeStyles } from "@saleor/macaw-ui";
import { ProviderInstancePingStatus } from "./hooks/usePingProviderInstance";
import clsx from "clsx";
const useStyles = makeStyles((theme) => ({
successStatus: {
color: theme.palette.type === "dark" ? theme.palette.success.light : theme.palette.success.dark,
},
errorStatus: {
color: theme.palette.error.main,
},
}));
interface ProviderInstancePingStatusProps {
loading: boolean;
status: ProviderInstancePingStatus | null;
}
export const ProviderInstancePing = ({ loading, status }: ProviderInstancePingStatusProps) => {
const styles = useStyles();
const parseProviderInstanceStatus = () => {
const statusText = status?.success ? "Ok" : "Error";
const checkTime = `(check time ${status?.time})`;
return `Configuration connection: ${statusText} ${checkTime}`;
};
if (loading) {
return <Skeleton />;
}
return (
<Typography
variant="body1"
className={clsx({
[styles.successStatus]: status?.success,
[styles.errorStatus]: !status?.success,
})}
>
{parseProviderInstanceStatus()}
</Typography>
);
};

View file

@ -1,92 +0,0 @@
import {
makeStyles,
OffsettedList,
OffsettedListBody,
OffsettedListHeader,
OffsettedListItem,
OffsettedListItemCell,
} from "@saleor/macaw-ui";
import clsx from "clsx";
import React from "react";
import { SingleProviderSchema } from "../../../lib/cms/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;
}
export const ProviderInstancesListItems = ({
providerInstances,
activeProviderInstance,
setActiveProviderInstance,
}: ProviderInstancesListItemsProps) => {
const styles = useStyles();
return (
<OffsettedList gridTemplate={["1fr", "1fr"]}>
<OffsettedListHeader>
<OffsettedListItem className={styles.headerItem}>
<OffsettedListItemCell>CMS provider configuration</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>
);
};

View file

@ -1,97 +0,0 @@
import { FormControl, Grid, InputLabel, MenuItem, Select } from "@material-ui/core";
import { Skeleton } from "@material-ui/lab";
import { SingleProviderSchema } from "../../../lib/cms/config";
import { AppPaper } from "../../ui/app-paper";
import { ProvidersErrors, ProvidersLoading } from "./types";
import { ProviderIcon } from "./provider-icon";
import { makeStyles } from "@saleor/macaw-ui";
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>
);
};
const useStyles = makeStyles((theme) => ({
menuItem: {
display: "flex",
alignItems: "center",
gap: theme.spacing(2),
},
}));
interface ProviderInstancesListProps {
providerInstances: SingleProviderSchema[];
activeProviderInstance?: SingleProviderSchema | null;
newProviderInstance?: SingleProviderSchema | null;
setActiveProviderInstance: (providerInstance: SingleProviderSchema | null) => void;
requestAddProviderInstance: () => void;
loading: ProvidersLoading;
errors: ProvidersErrors;
}
export const ProviderInstancesSelect = ({
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>;
}
if (providerInstances.length === 0 || !activeProviderInstance) {
return null;
}
return (
<FormControl fullWidth>
<InputLabel id="provider-select">Select Provider to configure</InputLabel>
<Select
labelId="channel-select"
variant="outlined"
fullWidth
value={activeProviderInstance?.id}
onChange={(e, value) => {
handleSetActiveProviderInstance(providerInstances.find((p) => p.id === e.target.value)!);
}}
>
{providerInstances.map((p) => (
<MenuItem key={p.id} value={p.id}>
<div className={styles.menuItem}>
<ProviderIcon providerName={p.providerName} small />
{p.name}
</div>
</MenuItem>
))}
</Select>
</FormControl>
);
};

View file

@ -1,120 +0,0 @@
import { ProviderInstanceConfiguration } from "./provider-instance-configuration";
import { SingleProviderSchema } from "../../../lib/cms/config";
import { useEffect, useState } from "react";
import { useProviderInstances } from "./hooks/useProviderInstances";
import { AppTabs } from "../../ui/app-tabs";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { Button, makeStyles } from "@saleor/macaw-ui";
import { ProviderInstancesSelect } from "./provider-instances-list";
import { Add } from "@material-ui/icons";
import { useDashboardNotification } from "@saleor/apps-shared";
import { usePingProviderInstance } from "./hooks/usePingProviderInstance";
const useStyles = makeStyles({
wrapper: {
display: "flex",
flexDirection: "column",
gap: 16,
},
});
export const ProviderInstances = () => {
const styles = useStyles();
const { providerInstances, saveProviderInstance, deleteProviderInstance, loading, errors } =
useProviderInstances();
const [activeProviderInstanceId, setActiveProviderInstanceId] = useState<string | null>(null);
const [newProviderInstance, setNewProviderInstance] = useState<SingleProviderSchema | null>(null);
const { notifySuccess } = useDashboardNotification();
const pingProviderInstanceOpts = usePingProviderInstance(activeProviderInstanceId);
useEffect(() => {
if (providerInstances.length && !activeProviderInstanceId) {
setActiveProviderInstanceId(providerInstances[0].id);
}
}, [providerInstances]);
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);
notifySuccess("Success", "Configuration saved");
if (newProviderInstance) {
setNewProviderInstance(null);
}
if (newProviderInstance && savedProviderInstance) {
setActiveProviderInstanceId(savedProviderInstance.id);
}
if (!newProviderInstance) {
pingProviderInstanceOpts.refresh();
}
};
const handleDeleteProviderInstance = async (providerInstance: SingleProviderSchema) => {
await deleteProviderInstance(providerInstance);
if (activeProviderInstanceId === providerInstance.id) {
setActiveProviderInstanceId(null);
}
};
const activeProviderInstance = providerInstances.find(
(providerInstance) => providerInstance.id === activeProviderInstanceId
);
return (
<>
<AppTabs activeTab="providers" />
<div className={styles.wrapper}>
{!newProviderInstance && (
<ProviderInstancesSelect
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}
providerInstancePingStatus={pingProviderInstanceOpts.result}
onNewProviderRequest={handleAddNewProviderInstance}
/>
</div>
<div>
{providerInstances.length > 0 && (
<Button
size="medium"
startIcon={<Add />}
fullWidth
onClick={handleAddNewProviderInstance}
>
Add configuration
</Button>
)}
</div>
</>
);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,35 +0,0 @@
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import ReactMarkdown from "react-markdown";
import { ReactMarkdownOptions } from "react-markdown/lib/react-markdown";
export const AppMarkdownText = ({ children, components, ...rest }: ReactMarkdownOptions) => {
const { appBridge } = useAppBridge();
const onClickHelpTextLink = (
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
href?: string
) => {
event.preventDefault();
if (href) {
appBridge?.dispatch(
actions.Redirect({
to: href,
newContext: true,
})
);
}
};
return (
<ReactMarkdown
{...rest}
components={{
...components,
a: (props) => <a {...props} onClick={(event) => onClickHelpTextLink(event, props.href)} />,
}}
>
{children}
</ReactMarkdown>
);
};

View file

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

View file

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

View file

@ -1,72 +0,0 @@
import { makeStyles } from "@saleor/macaw-ui";
import { useRouter } from "next/router";
import clsx from "clsx";
import { ButtonBase, Typography } from "@material-ui/core";
const useStyles = makeStyles({
tabs: {
display: "flex",
flexDirection: "column",
},
button: {
background: "#fff",
border: "none",
fontSize: 14,
height: 50,
textAlign: "left",
cursor: "pointer",
borderRadius: 8,
padding: "0 20px",
justifyContent: "flex-start",
},
active: {
border: `1px solid hsla(212, 14%, 77%, 1)`,
},
});
const tabs = {
home: {
label: "Home",
},
channels: {
label: "Channels",
},
providers: {
label: "Providers",
},
};
export type AppTab = keyof typeof tabs;
interface AppTabsProps {
activeTab: keyof typeof tabs;
}
export const AppTabs = ({ activeTab }: AppTabsProps) => {
const styles = useStyles();
const router = useRouter();
const handleTabChange = (value: string) => {
router.push(value);
};
return (
<div className={styles.tabs}>
{Object.entries(tabs).map(([key, config]) => (
<ButtonBase
disableRipple
className={clsx(styles.button, {
[styles.active]: activeTab === key,
})}
key={key}
onClick={() => {
handleTabChange(key);
}}
>
<Typography>{config.label}</Typography>
</ButtonBase>
))}
</div>
);
};

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