Compare commits
94 commits
remove-cms
...
main
Author | SHA1 | Date | |
---|---|---|---|
4b12982597 | |||
292a5bdb0c | |||
![]() |
653b98df86 | ||
![]() |
ae6dbb125b | ||
![]() |
6948fe41ca | ||
![]() |
2a22cf47f2 | ||
![]() |
927d2b3bb6 | ||
![]() |
5dee65ad2c | ||
![]() |
c6db32b40e | ||
![]() |
5d3d81d5c2 | ||
![]() |
52c46987f3 | ||
![]() |
1982d81f17 | ||
![]() |
767d0be722 | ||
![]() |
2951fb3ab6 | ||
![]() |
7e0755ec9e | ||
![]() |
a32fe7caf4 | ||
![]() |
4a2275e999 | ||
![]() |
a5f226bdef | ||
![]() |
a0a607ce99 | ||
![]() |
6ac7799d72 | ||
![]() |
4aee4e11f8 | ||
![]() |
86bc946b3e | ||
![]() |
1033b93747 | ||
![]() |
2e296996cb | ||
![]() |
068e529cfe | ||
![]() |
3bfcfa5b01 | ||
![]() |
6688a55102 | ||
![]() |
4efebde36d | ||
![]() |
9f76a2f1f8 | ||
![]() |
627712bef3 | ||
![]() |
8e4941fd2a | ||
![]() |
55315b89f3 | ||
![]() |
30140eea06 | ||
![]() |
e8660e8bb9 | ||
![]() |
ceddcf96eb | ||
![]() |
1e3c08c029 | ||
![]() |
23e71bc7a2 | ||
![]() |
261e9d1a0c | ||
![]() |
0aa1d12cc4 | ||
![]() |
ed30a818e3 | ||
![]() |
056209486c | ||
![]() |
0f84985c98 | ||
![]() |
2a1385bab1 | ||
![]() |
a81f061fcf | ||
![]() |
d9d0f64a01 | ||
![]() |
fcc37e7c7e | ||
![]() |
254cd4c9a3 | ||
![]() |
17925f2115 | ||
![]() |
e9378e7af7 | ||
![]() |
34efd39dcf | ||
![]() |
6f1c5c9436 | ||
![]() |
45ed9fb444 | ||
![]() |
4a635620c4 | ||
![]() |
783bd5ec55 | ||
![]() |
3bd2d33d97 | ||
![]() |
6ea3e38bd8 | ||
![]() |
be761b251e | ||
![]() |
c50797e836 | ||
![]() |
8b3d9617e6 | ||
![]() |
a2cac7849b | ||
![]() |
895004dd55 | ||
![]() |
7d574c70c9 | ||
![]() |
69fe973121 | ||
![]() |
3002354c5e | ||
![]() |
7f0d8d24a3 | ||
![]() |
bb504d95aa | ||
![]() |
7b19ab44c3 | ||
![]() |
416c92fb6c | ||
![]() |
c7b3d35f5d | ||
![]() |
b8c528397a | ||
![]() |
fe767a494b | ||
![]() |
a951af938e | ||
![]() |
6a84b4b7e0 | ||
![]() |
0b0297eeb8 | ||
![]() |
261957fda4 | ||
![]() |
fc5e6396d2 | ||
![]() |
aece07338e | ||
![]() |
8e33f59e0c | ||
![]() |
2cb7e5edee | ||
![]() |
fa65735571 | ||
![]() |
07999eadc6 | ||
![]() |
43d7e47bd4 | ||
![]() |
54901f8d08 | ||
![]() |
a725720920 | ||
![]() |
e9531ce79f | ||
![]() |
c07ddb33d6 | ||
![]() |
4c7c1c15d3 | ||
![]() |
ce608872bd | ||
![]() |
1b47ad22da | ||
![]() |
5a4da7beed | ||
![]() |
aa6fec183d | ||
![]() |
2fab86b465 | ||
![]() |
457865994e | ||
![]() |
5b2976306e |
692 changed files with 105029 additions and 28665 deletions
8
.changeset/pretty-pink-panda.md
Normal file
8
.changeset/pretty-pink-panda.md
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
#changelog
|
||||||
|
---
|
||||||
|
"apps": minor
|
||||||
|
---
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `apps/emails-and-messages/.env.template`: Described the new environment variable and how it works
|
||||||
|
- `apps/emails-and-messages/src/saleor-app.ts`: Added case "redis" for switch(AplType), which takes advantage of the [RedisAPL PR](https://github.com/saleor/app-sdk/pull/287) I submitted
|
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
@ -8,5 +8,4 @@
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] `.github/dependabot.yaml` is up-to date.
|
|
||||||
- [ ] I added changesets and [read good practices](/.changeset/README.md).
|
- [ ] I added changesets and [read good practices](/.changeset/README.md).
|
||||||
|
|
119
.github/dependabot.yaml
vendored
119
.github/dependabot.yaml
vendored
|
@ -2,117 +2,18 @@ version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
open-pull-requests-limit: 1
|
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
commit-message:
|
commit-message:
|
||||||
prefix: "[skip ci]"
|
prefix: "[skip ci]"
|
||||||
|
groups:
|
||||||
|
trpc:
|
||||||
|
patterns:
|
||||||
|
- "@trpc/*"
|
||||||
|
codegen:
|
||||||
|
patterns:
|
||||||
|
- "@graphql-codegen/*"
|
||||||
|
rtl:
|
||||||
|
patterns:
|
||||||
|
- "@testing-library/*"
|
||||||
|
|
||||||
# Packages
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/packages/eslint-config-saleor"
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/packages/shared"
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/packages/ui"
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/packages/react-hook-form-macaw"
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
|
|
||||||
# Apps
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/apps/cms"
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/apps/cms-v2"
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/crm
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/data-importer
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/emails-and-messages
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/invoices
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/klaviyo
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/products-feed
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/search
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/slack
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
open-pull-requests-limit: 1
|
|
||||||
directory: apps/taxes
|
|
||||||
commit-message:
|
|
||||||
prefix: "[skip ci]"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
|
|
3
.github/labeler.yml
vendored
3
.github/labeler.yml
vendored
|
@ -6,5 +6,6 @@
|
||||||
"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/**/*"
|
||||||
|
"App: Segment": "apps/segment/**/*"
|
||||||
|
|
9
.github/workflows/assign-pr.yml
vendored
9
.github/workflows/assign-pr.yml
vendored
|
@ -6,9 +6,12 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
assign_creator:
|
assign_creator:
|
||||||
|
if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Assign PR to creator
|
- name: Assign PR to creator
|
||||||
uses: thomaseizinger/assign-pr-creator-action@v1.0.0
|
env:
|
||||||
with:
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||||
|
CREATOR: ${{ github.event.pull_request.user.login }}
|
||||||
|
run: gh pr edit "$PR_URL" --add-assignee "$CREATOR"
|
||||||
|
|
31
.github/workflows/changeset-checker.yml
vendored
Normal file
31
.github/workflows/changeset-checker.yml
vendored
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
name: Changesets
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- labeled
|
||||||
|
- edited
|
||||||
|
- synchronize
|
||||||
|
branches-ignore:
|
||||||
|
- 'changeset-release/**'
|
||||||
|
jobs:
|
||||||
|
changeset_check:
|
||||||
|
name: Changeset added to the PR
|
||||||
|
# Adding 'skip changesets' label to the PR will skip this job
|
||||||
|
if: ${{ !contains( github.event.pull_request.labels.*.name, 'skip changeset') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
# check out full history
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
cache: "pnpm"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
- name: Changeset added
|
||||||
|
run: npx changeset status --since origin/main
|
2
.github/workflows/check-deps.yml
vendored
2
.github/workflows/check-deps.yml
vendored
|
@ -17,7 +17,7 @@ jobs:
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
- uses: JamieMason/syncpack-github-action@0.2.2
|
- uses: JamieMason/syncpack-github-action@c145cec44b3731b3fe8e859679e240d6ae011f0f
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
package-manager: "pnpm"
|
package-manager: "pnpm"
|
||||||
|
|
10
.github/workflows/check-spelling.yml
vendored
Normal file
10
.github/workflows/check-spelling.yml
vendored
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
name: "Check spelling"
|
||||||
|
on: # rebuild any PRs and main branch changes
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
spellcheck: # run the action
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: streetsidesoftware/cspell-action@22e32eb3d70acf30e3fc09bd46edc1d30fb2d6db
|
4
.github/workflows/prepare-release.yml
vendored
4
.github/workflows/prepare-release.yml
vendored
|
@ -25,11 +25,11 @@ jobs:
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 16
|
||||||
- uses: pnpm/action-setup@v2
|
- uses: pnpm/action-setup@d882d12c64e032187b2edb46d3a0d003b7a43598
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
- name: Create Release Pull Request
|
- name: Create Release Pull Request
|
||||||
uses: changesets/action@v1
|
uses: changesets/action@f13b1baaa620fde937751f5d2c3572b9da32af23
|
||||||
id: changesets
|
id: changesets
|
||||||
with:
|
with:
|
||||||
title: 🚀 Release apps
|
title: 🚀 Release apps
|
||||||
|
|
2
.github/workflows/unit-tests.yml
vendored
2
.github/workflows/unit-tests.yml
vendored
|
@ -9,7 +9,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -43,3 +43,4 @@ apps/**/generated
|
||||||
.sentryclirc
|
.sentryclirc
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.cspellcache
|
|
@ -1 +1 @@
|
||||||
* @saleor/appstore
|
* @saleor/delivery-engineering-js
|
||||||
|
|
13
README.md
13
README.md
|
@ -16,6 +16,8 @@
|
||||||
<a href="https://saleor.io/blog/">📰 Blog</a>
|
<a href="https://saleor.io/blog/">📰 Blog</a>
|
||||||
<span> • </span>
|
<span> • </span>
|
||||||
<a href="https://twitter.com/getsaleor">🐦 Twitter</a>
|
<a href="https://twitter.com/getsaleor">🐦 Twitter</a>
|
||||||
|
<span> • </span>
|
||||||
|
<a href="https://discord.gg/H52JTZAtSH">💬 Discord</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
@ -46,10 +48,10 @@ This repository serves as a starting point in the exploration of Saleor apps.
|
||||||
In the `apps` folder, you will find the following applications:
|
In the `apps` folder, you will find the following applications:
|
||||||
|
|
||||||
- [crm](https://docs.saleor.io/docs/3.x/developer/app-store/apps/crm) - exports customers from Saleor to CRM.
|
- [crm](https://docs.saleor.io/docs/3.x/developer/app-store/apps/crm) - exports customers from Saleor to CRM.
|
||||||
- [cms](./apps/cms) - exports products from Saleor to CMS.
|
- [cms](https://docs.saleor.io/docs/3.x/developer/app-store/apps/cms) - exports products from Saleor to CMS.
|
||||||
- [data-importer](./apps/data-importer) - import data from CSV to Saleor.
|
- [data-importer](./apps/data-importer) - import data from CSV to Saleor.
|
||||||
- [emails-and-messages](./apps/emails-and-messages) - notifications and email communication with customers.
|
- [emails-and-messages](https://docs.saleor.io/docs/3.x/developer/app-store/apps/emails-and-messages/overview) - notifications and email communication with customers.
|
||||||
- [invoices](./apps/invoices) - generate invoice PDF for each order.
|
- [invoices](https://docs.saleor.io/docs/3.x/developer/app-store/apps/invoices) - generate invoice PDF for each order.
|
||||||
- [klaviyo](./apps/klaviyo) - send Saleor events to Klaviyo, where you can notify the customers.
|
- [klaviyo](./apps/klaviyo) - send Saleor events to Klaviyo, where you can notify the customers.
|
||||||
- [monitoring](./apps/monitoring) - send Saleor logs to 3rd party Monitoring services
|
- [monitoring](./apps/monitoring) - send Saleor logs to 3rd party Monitoring services
|
||||||
- [products-feed](./apps/products-feed) - generate products feed XML
|
- [products-feed](./apps/products-feed) - generate products feed XML
|
||||||
|
@ -57,6 +59,11 @@ In the `apps` folder, you will find the following applications:
|
||||||
- [slack](./apps/slack) - get notifications on Slack channel from Saleor events.
|
- [slack](./apps/slack) - get notifications on Slack channel from Saleor events.
|
||||||
- [taxes](https://docs.saleor.io/docs/3.x/developer/app-store/apps/taxes) - calculate order and checkout taxes using external services.
|
- [taxes](https://docs.saleor.io/docs/3.x/developer/app-store/apps/taxes) - calculate order and checkout taxes using external services.
|
||||||
|
|
||||||
|
## Another official apps
|
||||||
|
|
||||||
|
Some of Saleor apps are available in separate repositories:
|
||||||
|
- [Stripe](https://github.com/saleor/saleor-app-payment-stripe)
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
You can find the documentation for saleor/apps on [docs.saleor.io](https://docs.saleor.io/docs/3.x/developer/app-store/development).
|
You can find the documentation for saleor/apps on [docs.saleor.io](https://docs.saleor.io/docs/3.x/developer/app-store/development).
|
||||||
|
|
|
@ -1,5 +1,101 @@
|
||||||
# saleor-app-cms-v2
|
# saleor-app-cms-v2
|
||||||
|
|
||||||
|
## 2.3.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 5d3d81d: Bumped @hookform/resolvers from 2.9.11 to 3.3.1
|
||||||
|
- 5dee65a: Updated dependencies:
|
||||||
|
- @graphql-codegen/cli@5.0.0
|
||||||
|
- 2e29699: Updated Sentry package
|
||||||
|
|
||||||
|
## 2.3.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- e8660e8: Extracted UI components and use shared package
|
||||||
|
- 30140ee: Improved some text typos.
|
||||||
|
- Updated dependencies [e8660e8]
|
||||||
|
- Updated dependencies [e8660e8]
|
||||||
|
- Updated dependencies [e8660e8]
|
||||||
|
- Updated dependencies [e8660e8]
|
||||||
|
- Updated dependencies [e8660e8]
|
||||||
|
- Updated dependencies [e8660e8]
|
||||||
|
- @saleor/apps-shared@1.9.0
|
||||||
|
- @saleor/apps-ui@1.2.0
|
||||||
|
|
||||||
|
## 2.3.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- a81f061: Updated Macaw to pre-127
|
||||||
|
- d9d0f64: Fixed background color of the modal. Previously white background was missing and only blur was applied. Now it is blurred and white with an opacity (in the dark mode it is fixed with dark grey)
|
||||||
|
- fcc37e7: Remove clsx package from the projects no longer using it.
|
||||||
|
- Updated dependencies [2a1385b]
|
||||||
|
- Updated dependencies [a81f061]
|
||||||
|
- Updated dependencies [fcc37e7]
|
||||||
|
- @saleor/apps-shared@1.8.1
|
||||||
|
- @saleor/react-hook-form-macaw@0.2.5
|
||||||
|
- @saleor/apps-ui@1.1.8
|
||||||
|
|
||||||
|
## 2.3.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 6f1c5c9: Added Payload CMS support.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- e9378e7: Fix PayloadCMS form where two inputs were stuck together without a margin. Now, they are placed in two columns
|
||||||
|
- 6f1c5c9: Fix styling of modal in the dark mode
|
||||||
|
|
||||||
|
## 2.2.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 8b3d961: Updated Macaw UI to pre.118
|
||||||
|
- c50797e: Extracted MetadataManager creation to factory from shared package
|
||||||
|
- Updated dependencies [8b3d961]
|
||||||
|
- Updated dependencies [c50797e]
|
||||||
|
- @saleor/react-hook-form-macaw@0.2.4
|
||||||
|
- @saleor/apps-shared@1.8.0
|
||||||
|
- @saleor/apps-ui@1.1.7
|
||||||
|
|
||||||
|
## 2.2.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 3002354: Added error logging for exceptions thrown at tRPC routes.
|
||||||
|
|
||||||
|
## 2.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- fe767a4: Changed public (manifest) name app to be "CMS" (previously it was "CMS 2", since two apps existed at the same time)
|
||||||
|
|
||||||
|
## 2.1.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 261957f: Updated dependencies: vite, vitest, eslint, prettier, dotenv, turbo, syncpack, changesets, lint staged
|
||||||
|
- Updated dependencies [261957f]
|
||||||
|
- @saleor/react-hook-form-macaw@0.2.3
|
||||||
|
- @saleor/apps-shared@1.7.6
|
||||||
|
- @saleor/apps-ui@1.1.6
|
||||||
|
|
||||||
|
## 2.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 2fab86b: Updated graphql package to 16.7.1 and @graphql-codegen related dependencies to the latest version.
|
||||||
|
- aa6fec1: Updated Macaw UI to pre-106
|
||||||
|
- 4578659: Made Builder.io api keys inputs type of "password" so they are masked now
|
||||||
|
- Updated dependencies [aa6fec1]
|
||||||
|
- Updated dependencies [aa6fec1]
|
||||||
|
- @saleor/react-hook-form-macaw@0.2.2
|
||||||
|
- @saleor/apps-shared@1.7.5
|
||||||
|
- @saleor/apps-ui@1.1.5
|
||||||
|
|
||||||
## 2.1.0
|
## 2.1.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "saleor-app-cms-v2",
|
"name": "saleor-app-cms-v2",
|
||||||
"version": "2.1.0",
|
"version": "2.3.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm generate && next build",
|
"build": "pnpm generate && next build",
|
||||||
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
||||||
|
@ -13,28 +13,29 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@datocms/cma-client-browser": "2.0.0",
|
"@datocms/cma-client-browser": "2.0.0",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.3.1",
|
||||||
"@saleor/app-sdk": "0.41.1",
|
"@saleor/app-sdk": "0.43.1",
|
||||||
"@saleor/apps-shared": "workspace:*",
|
"@saleor/apps-shared": "workspace:*",
|
||||||
"@saleor/apps-ui": "workspace:*",
|
"@saleor/apps-ui": "workspace:*",
|
||||||
"@saleor/macaw-ui": "0.8.0-pre.95",
|
"@saleor/macaw-ui": "0.8.0-pre.127",
|
||||||
"@saleor/react-hook-form-macaw": "workspace:*",
|
"@saleor/react-hook-form-macaw": "workspace:*",
|
||||||
"@sentry/nextjs": "7.55.2",
|
"@sentry/nextjs": "7.67.0",
|
||||||
"@tanstack/react-query": "^4.29.19",
|
"@tanstack/react-query": "^4.29.19",
|
||||||
"@trpc/client": "10.34.0",
|
"@trpc/client": "10.38.1",
|
||||||
"@trpc/next": "10.34.0",
|
"@trpc/next": "10.38.1",
|
||||||
"@trpc/react-query": "10.34.0",
|
"@trpc/react-query": "10.38.1",
|
||||||
"@trpc/server": "10.34.0",
|
"@trpc/server": "10.38.1",
|
||||||
"@urql/exchange-auth": "^2.1.4",
|
"@urql/exchange-auth": "^2.1.4",
|
||||||
"@vitejs/plugin-react": "4.0.0",
|
"@vitejs/plugin-react": "4.0.4",
|
||||||
"contentful-management": "10.38.3",
|
"contentful-management": "10.38.3",
|
||||||
"graphql": "16.6.0",
|
"graphql": "16.7.1",
|
||||||
"graphql-tag": "^2.12.6",
|
"graphql-tag": "^2.12.6",
|
||||||
"jsdom": "^20.0.3",
|
"jsdom": "^20.0.3",
|
||||||
"next": "13.4.8",
|
"next": "13.4.8",
|
||||||
"p-ratelimit": "1.0.1",
|
"p-ratelimit": "1.0.1",
|
||||||
"pino": "^8.14.1",
|
"pino": "^8.14.1",
|
||||||
"pino-pretty": "^10.0.0",
|
"pino-pretty": "^10.0.0",
|
||||||
|
"qs": "6.11.2",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-error-boundary": "4.0.10",
|
"react-error-boundary": "4.0.10",
|
||||||
|
@ -42,23 +43,24 @@
|
||||||
"strapi-sdk-js": "2.2.0",
|
"strapi-sdk-js": "2.2.0",
|
||||||
"urql": "^4.0.4",
|
"urql": "^4.0.4",
|
||||||
"usehooks-ts": "^2.9.1",
|
"usehooks-ts": "^2.9.1",
|
||||||
"vite": "4.3.9",
|
"vite": "4.4.8",
|
||||||
"vitest": "0.31.3",
|
"vitest": "0.34.1",
|
||||||
"zod": "3.21.4"
|
"zod": "3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/cli": "3.2.2",
|
"@graphql-codegen/cli": "5.0.0",
|
||||||
"@graphql-codegen/introspection": "3.0.1",
|
"@graphql-codegen/introspection": "4.0.0",
|
||||||
"@graphql-codegen/typed-document-node": "3.0.2",
|
"@graphql-codegen/typed-document-node": "5.0.1",
|
||||||
"@graphql-codegen/typescript": "3.0.2",
|
"@graphql-codegen/typescript": "4.0.1",
|
||||||
"@graphql-codegen/typescript-operations": "3.0.2",
|
"@graphql-codegen/typescript-operations": "4.0.1",
|
||||||
"@graphql-codegen/typescript-urql": "3.7.3",
|
"@graphql-codegen/typescript-urql": "3.7.3",
|
||||||
"@graphql-typed-document-node/core": "3.2.0",
|
"@graphql-typed-document-node/core": "3.2.0",
|
||||||
"@testing-library/react": "^13.4.0",
|
"@testing-library/react": "^14.0.0",
|
||||||
"@testing-library/react-hooks": "^8.0.1",
|
"@testing-library/react-hooks": "^8.0.1",
|
||||||
|
"@types/qs": "^6.9.7",
|
||||||
"@types/react": "18.2.5",
|
"@types/react": "18.2.5",
|
||||||
"@types/react-dom": "18.2.5",
|
"@types/react-dom": "18.2.5",
|
||||||
"eslint": "8.44.0",
|
"eslint": "8.46.0",
|
||||||
"eslint-config-saleor": "workspace:*",
|
"eslint-config-saleor": "workspace:*",
|
||||||
"node-mocks-http": "^1.12.2",
|
"node-mocks-http": "^1.12.2",
|
||||||
"typescript": "5.1.6"
|
"typescript": "5.1.6"
|
||||||
|
|
|
@ -6,26 +6,20 @@ import { Select } from "@saleor/react-hook-form-macaw";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ButtonsBox } from "../ui/buttons-box";
|
|
||||||
import { ProvidersResolver } from "../providers/providers-resolver";
|
import { ProvidersResolver } from "../providers/providers-resolver";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { ButtonsBox, Layout, SkeletonLayout } from "@saleor/apps-ui";
|
||||||
|
|
||||||
const FormSchema = z.object({
|
const FormSchema = z.object({
|
||||||
connectionId: z.string().min(7),
|
connectionId: z.string().min(7),
|
||||||
});
|
});
|
||||||
|
|
||||||
const EmptyState = () => (
|
const EmptyState = () => (
|
||||||
<Box
|
<Layout.AppSectionCard>
|
||||||
display="flex"
|
<Box display="flex" flexDirection={"column"} gap={4} justifyContent={"center"}>
|
||||||
paddingY={4}
|
<Text variant="heading">Bulk products synchronization</Text>
|
||||||
flexDirection={"column"}
|
<Text>Create a channel connection above to enable bulk synchronization.</Text>
|
||||||
gap={4}
|
</Box>
|
||||||
alignItems={"center"}
|
</Layout.AppSectionCard>
|
||||||
justifyContent={"center"}
|
|
||||||
>
|
|
||||||
<Text variant="heading">No connections configured</Text>
|
|
||||||
<Text>Create a channel connection above to enable bulk synchronization.</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const BulkSyncSection = () => {
|
export const BulkSyncSection = () => {
|
||||||
|
@ -42,7 +36,7 @@ export const BulkSyncSection = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!connections || !providers) {
|
if (!connections || !providers) {
|
||||||
return <Skeleton.Section />;
|
return <SkeletonLayout.Section />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connections.length === 0) {
|
if (connections.length === 0) {
|
||||||
|
@ -50,7 +44,7 @@ export const BulkSyncSection = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Layout.AppSectionCard>
|
||||||
<Text as="h2" marginBottom={6} variant="heading">
|
<Text as="h2" marginBottom={6} variant="heading">
|
||||||
Bulk products synchronization
|
Bulk products synchronization
|
||||||
</Text>
|
</Text>
|
||||||
|
@ -88,6 +82,6 @@ export const BulkSyncSection = () => {
|
||||||
<Button type="submit">Start sync</Button>
|
<Button type="submit">Start sync</Button>
|
||||||
</ButtonsBox>
|
</ButtonsBox>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Layout.AppSectionCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,10 +1,8 @@
|
||||||
import { Breadcrumbs } from "@saleor/apps-ui";
|
import { Breadcrumbs, ButtonsBox, Layout } from "@saleor/apps-ui";
|
||||||
import { ArrowRightIcon, Box, Button, Text } from "@saleor/macaw-ui/next";
|
import { ArrowRightIcon, Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ChannelProviderConnectionConfig, ProvidersConfig } from "../configuration";
|
import { ChannelProviderConnectionConfig, ProvidersConfig } from "../configuration";
|
||||||
import { AppHeader } from "../ui/app-header";
|
import { AppHeader } from "../ui/app-header";
|
||||||
import { AppSection } from "../ui/app-section";
|
|
||||||
import { ButtonsBox } from "../ui/buttons-box";
|
|
||||||
|
|
||||||
import { useBulkSyncProductsState } from "./use-bulk-sync-products-state";
|
import { useBulkSyncProductsState } from "./use-bulk-sync-products-state";
|
||||||
import { useFetchAllProducts } from "./use-fetch-all-products";
|
import { useFetchAllProducts } from "./use-fetch-all-products";
|
||||||
|
@ -14,16 +12,19 @@ import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
|
||||||
const FetchProductsStep = (props: { onButtonClick(): void }) => {
|
const FetchProductsStep = (props: { onButtonClick(): void }) => {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Layout.AppSectionCard
|
||||||
|
footer={
|
||||||
|
<ButtonsBox>
|
||||||
|
<Button onClick={props.onButtonClick}>Prefetch products</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Text variant="heading" as="h2" marginBottom={4}>
|
<Text variant="heading" as="h2" marginBottom={4}>
|
||||||
Saleor products fetch
|
Saleor products fetch
|
||||||
</Text>
|
</Text>
|
||||||
<Text as="p">Click the button to start fetching products from Saleor API</Text>
|
<Text as="p">Click the button to start fetching products from Saleor API</Text>
|
||||||
<Text as="p">After products are fetched, you will be able to upload them to the CMS</Text>
|
<Text as="p">After products are fetched, you will be able to upload them to the CMS</Text>
|
||||||
<ButtonsBox>
|
</Layout.AppSectionCard>
|
||||||
<Button onClick={props.onButtonClick}>Prefetch products</Button>
|
|
||||||
</ButtonsBox>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -66,7 +67,7 @@ export const BulkSyncView = ({
|
||||||
|
|
||||||
const { products, finished: saleorProductsFetchFinished } = useFetchAllProducts(
|
const { products, finished: saleorProductsFetchFinished } = useFetchAllProducts(
|
||||||
state === "fetching",
|
state === "fetching",
|
||||||
connection.channelSlug
|
connection.channelSlug,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { productsStatusList, setInitialProducts, setItemStatus, finished } =
|
const { productsStatusList, setInitialProducts, setItemStatus, finished } =
|
||||||
|
@ -121,9 +122,14 @@ export const BulkSyncView = ({
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AppSection
|
<Layout.AppSection
|
||||||
marginBottom={8}
|
marginBottom={8}
|
||||||
mainContent={(() => {
|
heading="1. Fetch products"
|
||||||
|
sideContent={
|
||||||
|
<Text>First pre-fetch all Product Variants from Saleor. Do not close the app.</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "initial": {
|
case "initial": {
|
||||||
return (
|
return (
|
||||||
|
@ -149,33 +155,29 @@ export const BulkSyncView = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
heading="1. Fetch products"
|
</Layout.AppSection>
|
||||||
sideContent={
|
|
||||||
<Text>First pre-fetch all Product Variants from Saleor. Do not close the app.</Text>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(state === "fetched" || state === "uploading") && productsStatusList && (
|
{(state === "fetched" || state === "uploading") && productsStatusList && (
|
||||||
<AppSection
|
<Layout.AppSection
|
||||||
|
marginTop={14}
|
||||||
heading="2. Upload to the CMS"
|
heading="2. Upload to the CMS"
|
||||||
sideContent={<Text>Send listed variants to the CMS</Text>}
|
sideContent={<Text>Send listed variants to the CMS</Text>}
|
||||||
mainContent={
|
>
|
||||||
<Box>
|
<Layout.AppSectionCard>
|
||||||
<Text as="h2" marginBottom={4} variant="heading">
|
<Text as="h2" marginBottom={4} variant="heading">
|
||||||
Upload products
|
Upload products
|
||||||
</Text>
|
</Text>
|
||||||
{state === "fetched" && (
|
{state === "fetched" && (
|
||||||
<Box marginBottom={4}>
|
<Box marginBottom={4}>
|
||||||
<Text as="p" marginBottom={2}>
|
<Text as="p" marginBottom={2}>
|
||||||
Verify products below and click the button to start uploading.
|
Verify products below and click the button to start uploading.
|
||||||
</Text>
|
</Text>
|
||||||
<Button onClick={() => setState("uploading")}>Start uploading</Button>
|
<Button onClick={() => setState("uploading")}>Start uploading</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<VariantsSyncStatusList marginTop={8} variants={productsStatusList} />
|
<VariantsSyncStatusList marginTop={8} variants={productsStatusList} />
|
||||||
</Box>
|
</Layout.AppSectionCard>
|
||||||
}
|
</Layout.AppSection>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
@ -57,10 +57,12 @@ export const AddConnectionForm = (props: {
|
||||||
control={control}
|
control={control}
|
||||||
name="channelSlug"
|
name="channelSlug"
|
||||||
label="Channel"
|
label="Channel"
|
||||||
options={channels?.map((c) => ({
|
options={
|
||||||
value: c.slug,
|
channels?.map((c) => ({
|
||||||
label: c.name,
|
value: c.slug,
|
||||||
}))}
|
label: c.name,
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<ArrowRightIcon />
|
<ArrowRightIcon />
|
||||||
<Select
|
<Select
|
||||||
|
@ -69,10 +71,12 @@ export const AddConnectionForm = (props: {
|
||||||
control={control}
|
control={control}
|
||||||
name="providerId"
|
name="providerId"
|
||||||
label="Provider"
|
label="Provider"
|
||||||
options={providers?.map((p) => ({
|
options={
|
||||||
value: p.id,
|
providers?.map((p) => ({
|
||||||
label: p.configName,
|
value: p.id,
|
||||||
}))}
|
label: p.configName,
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{errors.root?.serverError && (
|
{errors.root?.serverError && (
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { Button, Text } from "@saleor/macaw-ui/next";
|
import { Button, Text } from "@saleor/macaw-ui/next";
|
||||||
import { ButtonsBox } from "../ui/buttons-box";
|
|
||||||
import { Modal } from "../ui/modal";
|
import { Modal } from "../ui/modal";
|
||||||
import {
|
import {
|
||||||
AddConnectionForm,
|
AddConnectionForm,
|
||||||
|
@ -7,7 +6,7 @@ import {
|
||||||
AddConnectionFormSchema,
|
AddConnectionFormSchema,
|
||||||
} from "./add-connection-form";
|
} from "./add-connection-form";
|
||||||
import { trpcClient } from "../trpc/trpc-client";
|
import { trpcClient } from "../trpc/trpc-client";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { ButtonsBox, SkeletonLayout } from "@saleor/apps-ui";
|
||||||
|
|
||||||
const defaultValues: AddConnectionFormSchema = { channelSlug: "", providerId: "" };
|
const defaultValues: AddConnectionFormSchema = { channelSlug: "", providerId: "" };
|
||||||
|
|
||||||
|
@ -15,7 +14,7 @@ export const AddConnectionModal = (props: { onSuccess(): void; onClose(): void }
|
||||||
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||||
|
|
||||||
if (!providers) {
|
if (!providers) {
|
||||||
return <Skeleton.Section />;
|
return <SkeletonLayout.Section />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { mutateAsync: addProviderMutate, isLoading } =
|
const { mutateAsync: addProviderMutate, isLoading } =
|
||||||
|
|
|
@ -1,35 +1,31 @@
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { ButtonsBox, Layout, SkeletonLayout } from "@saleor/apps-ui";
|
||||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { trpcClient } from "../trpc/trpc-client";
|
import { trpcClient } from "../trpc/trpc-client";
|
||||||
import { ButtonsBox } from "../ui/buttons-box";
|
|
||||||
import { AddConnectionFormSchema } from "./add-connection-form";
|
|
||||||
import { AddConnectionModal } from "./add-connection-modal";
|
import { AddConnectionModal } from "./add-connection-modal";
|
||||||
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
|
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
|
||||||
import { ConnectionsList } from "./connections-list";
|
import { ConnectionsList } from "./connections-list";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
|
||||||
|
|
||||||
const NoConnections = (props: { onCreate(): void; enabled: boolean }) => (
|
const NoConnections = (props: { onCreate(): void; enabled: boolean }) => (
|
||||||
<Box>
|
<Box>
|
||||||
<ChanelProviderConnectionsSectionHeader />
|
<ChanelProviderConnectionsSectionHeader />
|
||||||
<Text marginBottom={4} as="p">
|
<Text as="p">
|
||||||
No channels connected yet.{" "}
|
No channels connected yet.{" "}
|
||||||
{!props.enabled &&
|
{!props.enabled &&
|
||||||
"Ensure you have created a provider configuration that can be connected first."}
|
"Ensure you have created a provider configuration that can be connected first."}
|
||||||
</Text>
|
</Text>
|
||||||
{props.enabled && (
|
|
||||||
<ButtonsBox>
|
|
||||||
<Button onClick={props.onCreate}>Create first connection</Button>
|
|
||||||
</ButtonsBox>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ChannelProviderConnectionList = () => {
|
export const ChannelProviderConnectionList = () => {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
const { data: connectionsData, refetch: refetchConnections } =
|
const {
|
||||||
trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
|
data: connectionsData,
|
||||||
|
refetch: refetchConnections,
|
||||||
|
isLoading,
|
||||||
|
} = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
|
||||||
|
|
||||||
const { mutate: removeConnection } =
|
const { mutate: removeConnection } =
|
||||||
trpcClient.channelsProvidersConnection.removeConnection.useMutation({
|
trpcClient.channelsProvidersConnection.removeConnection.useMutation({
|
||||||
|
@ -45,19 +41,33 @@ export const ChannelProviderConnectionList = () => {
|
||||||
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||||
|
|
||||||
if (!providers) {
|
if (!providers) {
|
||||||
return <Skeleton.Section />;
|
return <SkeletonLayout.Section />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (connectionId: string) => {
|
const handleDelete = (connectionId: string) => {
|
||||||
removeConnection({ id: connectionId });
|
removeConnection({ id: connectionId });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!connectionsData) {
|
if (isLoading || !connectionsData) {
|
||||||
return <Text>Loading</Text>;
|
return <SkeletonLayout.Section />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Layout.AppSectionCard
|
||||||
|
footer={
|
||||||
|
providers.length > 0 && (
|
||||||
|
<ButtonsBox>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add connection
|
||||||
|
</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
{dialogOpen && (
|
{dialogOpen && (
|
||||||
<AddConnectionModal
|
<AddConnectionModal
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
@ -79,17 +89,6 @@ export const ChannelProviderConnectionList = () => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{connectionsData.length > 0 && <ConnectionsList onRemove={handleDelete} />}
|
{connectionsData.length > 0 && <ConnectionsList onRemove={handleDelete} />}
|
||||||
{connectionsData.length > 0 && (
|
</Layout.AppSectionCard>
|
||||||
<ButtonsBox marginTop={6}>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add connection
|
|
||||||
</Button>
|
|
||||||
</ButtonsBox>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,7 +4,7 @@ import React from "react";
|
||||||
import { trpcClient } from "../trpc/trpc-client";
|
import { trpcClient } from "../trpc/trpc-client";
|
||||||
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
|
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
|
||||||
import { ProvidersResolver } from "../providers/providers-resolver";
|
import { ProvidersResolver } from "../providers/providers-resolver";
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { SkeletonLayout } from "@saleor/apps-ui";
|
||||||
|
|
||||||
export const ConnectionsList = (props: { onRemove(connectionId: string): void }) => {
|
export const ConnectionsList = (props: { onRemove(connectionId: string): void }) => {
|
||||||
const { data } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
|
const { data } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
|
||||||
|
@ -12,7 +12,7 @@ export const ConnectionsList = (props: { onRemove(connectionId: string): void })
|
||||||
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||||
|
|
||||||
if (!data || !providers) {
|
if (!data || !providers) {
|
||||||
return <Skeleton.Section />;
|
return <SkeletonLayout.Section />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,101 +1,12 @@
|
||||||
import {
|
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||||
MetadataEntry,
|
import { EncryptedMetadataManagerFactory } from "@saleor/apps-shared";
|
||||||
EncryptedMetadataManager,
|
import { Client } from "urql";
|
||||||
SettingsManager,
|
|
||||||
} from "@saleor/app-sdk/settings-manager";
|
|
||||||
import { Client, gql } from "urql";
|
|
||||||
import {
|
|
||||||
FetchAppDetailsDocument,
|
|
||||||
FetchAppDetailsQuery,
|
|
||||||
UpdateAppMetadataDocument,
|
|
||||||
} from "../../../generated/graphql";
|
|
||||||
|
|
||||||
gql`
|
|
||||||
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
|
|
||||||
updatePrivateMetadata(id: $id, input: $input) {
|
|
||||||
item {
|
|
||||||
privateMetadata {
|
|
||||||
key
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
gql`
|
|
||||||
query FetchAppDetails {
|
|
||||||
app {
|
|
||||||
id
|
|
||||||
privateMetadata {
|
|
||||||
key
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
gql`
|
|
||||||
mutation RemoveMetadata($id: ID!, $keys: [String!]!) {
|
|
||||||
deletePrivateMetadata(id: $id, keys: $keys) {
|
|
||||||
errors {
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export type SimpleGraphqlClient = Pick<Client, "mutation" | "query">;
|
|
||||||
|
|
||||||
async function fetchAllMetadata(client: SimpleGraphqlClient): Promise<MetadataEntry[]> {
|
|
||||||
const { error, data } = await client
|
|
||||||
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
|
|
||||||
.toPromise();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mutateMetadata(
|
|
||||||
client: SimpleGraphqlClient,
|
|
||||||
metadata: MetadataEntry[],
|
|
||||||
appId: string
|
|
||||||
) {
|
|
||||||
const { error: mutationError, data: mutationData } = await client
|
|
||||||
.mutation(UpdateAppMetadataDocument, {
|
|
||||||
id: appId,
|
|
||||||
input: metadata,
|
|
||||||
})
|
|
||||||
.toPromise();
|
|
||||||
|
|
||||||
if (mutationError) {
|
|
||||||
throw new Error(`Mutation error: ${mutationError.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
|
|
||||||
key: md.key,
|
|
||||||
value: md.value,
|
|
||||||
})) || []
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createSettingsManager = (
|
export const createSettingsManager = (
|
||||||
client: SimpleGraphqlClient,
|
client: Pick<Client, "query" | "mutation">,
|
||||||
appId: string
|
appId: string,
|
||||||
): SettingsManager => {
|
): SettingsManager => {
|
||||||
/*
|
const metadataManagerFactory = new EncryptedMetadataManagerFactory(process.env.SECRET_KEY!);
|
||||||
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
|
|
||||||
* We recommend it for production, because all values are encrypted.
|
return metadataManagerFactory.create(client, appId);
|
||||||
* If your use case require plain text values, you can use MetadataManager.
|
|
||||||
*/
|
|
||||||
return new EncryptedMetadataManager({
|
|
||||||
// Secret key should be randomly created for production and set as environment variable
|
|
||||||
encryptionKey: process.env.SECRET_KEY!,
|
|
||||||
fetchMetadata: () => fetchAllMetadata(client),
|
|
||||||
mutateMetadata: (metadata) => mutateMetadata(client, metadata, appId),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { PayloadCMS } from "@/modules/providers/payloadcms/payloadcms";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
|
||||||
|
|
||||||
|
const InputSchema = z.object({
|
||||||
|
type: z.literal(PayloadCMS.type),
|
||||||
|
authToken: z.string(),
|
||||||
|
configName: z.string().min(1),
|
||||||
|
collectionName: z.string().min(1),
|
||||||
|
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
|
||||||
|
payloadApiUrl: z.string().url(),
|
||||||
|
authenticatedUserSlug: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const FullSchema = InputSchema.extend({
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export namespace PayloadCmsProviderConfig {
|
||||||
|
export type InputShape = z.infer<typeof InputSchema>;
|
||||||
|
export type FullShape = z.infer<typeof FullSchema>;
|
||||||
|
|
||||||
|
export const Schema = {
|
||||||
|
Input: InputSchema,
|
||||||
|
Full: FullSchema,
|
||||||
|
};
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import { ContentfulProviderConfig } from "./contentful-provider.schema";
|
||||||
import { BuilderIoProviderConfig } from "./builder-provider.schema";
|
import { BuilderIoProviderConfig } from "./builder-provider.schema";
|
||||||
import { StrapiProviderConfig } from "./strapi-provider.schema";
|
import { StrapiProviderConfig } from "./strapi-provider.schema";
|
||||||
import { DatocmsProviderConfig } from "./datocms-provider.schema";
|
import { DatocmsProviderConfig } from "./datocms-provider.schema";
|
||||||
|
import { PayloadCmsProviderConfig } from "./payloadcms-provider.schema";
|
||||||
|
|
||||||
export namespace ProvidersConfig {
|
export namespace ProvidersConfig {
|
||||||
const AnyFull = z.union([
|
const AnyFull = z.union([
|
||||||
|
@ -14,6 +15,7 @@ export namespace ProvidersConfig {
|
||||||
DatocmsProviderConfig.Schema.Full,
|
DatocmsProviderConfig.Schema.Full,
|
||||||
StrapiProviderConfig.Schema.Full,
|
StrapiProviderConfig.Schema.Full,
|
||||||
BuilderIoProviderConfig.Schema.Full,
|
BuilderIoProviderConfig.Schema.Full,
|
||||||
|
PayloadCmsProviderConfig.Schema.Full,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const Schema = {
|
export const Schema = {
|
||||||
|
@ -23,6 +25,7 @@ export namespace ProvidersConfig {
|
||||||
DatocmsProviderConfig.Schema.Input,
|
DatocmsProviderConfig.Schema.Input,
|
||||||
StrapiProviderConfig.Schema.Input,
|
StrapiProviderConfig.Schema.Input,
|
||||||
BuilderIoProviderConfig.Schema.Input,
|
BuilderIoProviderConfig.Schema.Input,
|
||||||
|
PayloadCmsProviderConfig.Schema.Input,
|
||||||
]),
|
]),
|
||||||
AnyFullList: z.array(AnyFull),
|
AnyFullList: z.array(AnyFull),
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,8 +5,8 @@ import { ProvidersConfig } from "../configuration";
|
||||||
|
|
||||||
import { ProvidersResolver } from "../providers/providers-resolver";
|
import { ProvidersResolver } from "../providers/providers-resolver";
|
||||||
import { trpcClient } from "../trpc/trpc-client";
|
import { trpcClient } from "../trpc/trpc-client";
|
||||||
import { ButtonsBox } from "../ui/buttons-box";
|
|
||||||
import { Skeleton } from "../ui/skeleton";
|
import { ButtonsBox, Layout, SkeletonLayout } from "@saleor/apps-ui";
|
||||||
|
|
||||||
const ProvidersTable = (props: { providers: ProvidersConfig.AnyFullShape[] }) => {
|
const ProvidersTable = (props: { providers: ProvidersConfig.AnyFullShape[] }) => {
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
|
@ -47,30 +47,45 @@ export const ProvidersList = () => {
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <Skeleton.Section />;
|
return <SkeletonLayout.Section />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Layout.AppSectionCard
|
||||||
|
footer={
|
||||||
|
<ButtonsBox>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
push("/add-provider");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add first CMS configuration
|
||||||
|
</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Text as="p" marginBottom={4}>
|
<Text as="p" marginBottom={4}>
|
||||||
No configurations yet
|
No configurations yet
|
||||||
</Text>
|
</Text>
|
||||||
|
</Layout.AppSectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.AppSectionCard
|
||||||
|
footer={
|
||||||
<ButtonsBox>
|
<ButtonsBox>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
push("/add-provider");
|
push("/add-provider");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add first CMS configuration
|
Add CMS configuration
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonsBox>
|
</ButtonsBox>
|
||||||
</Box>
|
}
|
||||||
);
|
>
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{data.length && (
|
{data.length && (
|
||||||
<Box>
|
<Box>
|
||||||
<Text variant="heading" as="h2" marginBottom={4}>
|
<Text variant="heading" as="h2" marginBottom={4}>
|
||||||
|
@ -79,15 +94,6 @@ export const ProvidersList = () => {
|
||||||
<ProvidersTable providers={data} />
|
<ProvidersTable providers={data} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<ButtonsBox marginTop={8}>
|
</Layout.AppSectionCard>
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
push("/add-provider");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add CMS configuration
|
|
||||||
</Button>
|
|
||||||
</ButtonsBox>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,9 +7,7 @@ import { useForm } from "react-hook-form";
|
||||||
import { BuilderIoProviderConfig, SaleorProviderFieldsMappingKeys } from "../../configuration";
|
import { BuilderIoProviderConfig, SaleorProviderFieldsMappingKeys } from "../../configuration";
|
||||||
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
|
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
|
||||||
import { trpcClient } from "../../trpc/trpc-client";
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
import { ButtonsBox } from "../../ui/buttons-box";
|
import { ButtonsBox, SkeletonLayout, TextLink } from "@saleor/apps-ui";
|
||||||
import { TextLink } from "@saleor/apps-ui";
|
|
||||||
import { Skeleton } from "@/modules/ui/skeleton";
|
|
||||||
|
|
||||||
type FormShape = Omit<BuilderIoProviderConfig.InputShape, "type">;
|
type FormShape = Omit<BuilderIoProviderConfig.InputShape, "type">;
|
||||||
const FormSchema = BuilderIoProviderConfig.Schema.Input.omit({ type: true });
|
const FormSchema = BuilderIoProviderConfig.Schema.Input.omit({ type: true });
|
||||||
|
@ -46,6 +44,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||||
<Box display={"grid"} gap={4} marginY={4}>
|
<Box display={"grid"} gap={4} marginY={4}>
|
||||||
<Text variant="heading">Provide connection details</Text>
|
<Text variant="heading">Provide connection details</Text>
|
||||||
<Input
|
<Input
|
||||||
|
type="password"
|
||||||
required
|
required
|
||||||
control={control}
|
control={control}
|
||||||
name="privateApiKey"
|
name="privateApiKey"
|
||||||
|
@ -60,6 +59,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
type="password"
|
||||||
required
|
required
|
||||||
control={control}
|
control={control}
|
||||||
name="publicApiKey"
|
name="publicApiKey"
|
||||||
|
@ -102,7 +102,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||||
padding={2}
|
padding={2}
|
||||||
>
|
>
|
||||||
<Text variant="caption">Saleor Field</Text>
|
<Text variant="caption">Saleor Field</Text>
|
||||||
<Text variant="caption">Contentful field</Text>
|
<Text variant="caption">Builder.io field</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
|
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
|
||||||
// todo extract this table to component
|
// todo extract this table to component
|
||||||
|
@ -125,7 +125,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||||
size="small"
|
size="small"
|
||||||
control={control}
|
control={control}
|
||||||
name={`productVariantFieldsMapping.${saleorField}`}
|
name={`productVariantFieldsMapping.${saleorField}`}
|
||||||
label="Builer.io Field"
|
label="Builder.io Field"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
@ -188,7 +188,7 @@ const EditFormVariant = (props: { configId: string }) => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!props.configId,
|
enabled: !!props.configId,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
|
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
|
@ -205,11 +205,11 @@ const EditFormVariant = (props: { configId: string }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <Skeleton.Section />;
|
return <SkeletonLayout.Section />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type !== "builder.io") {
|
if (data.type !== "builder.io") {
|
||||||
throw new Error("Trying to fill builer.io form with non builder.io data");
|
throw new Error("Trying to fill builder.io form with non builder.io data");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -9,8 +9,7 @@ import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
import { ContentfulProviderConfig } from "../../configuration/schemas/contentful-provider.schema";
|
import { ContentfulProviderConfig } from "../../configuration/schemas/contentful-provider.schema";
|
||||||
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
|
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ButtonsBox } from "../../ui/buttons-box";
|
import { ButtonsBox, TextLink } from "@saleor/apps-ui";
|
||||||
import { TextLink } from "@saleor/apps-ui";
|
|
||||||
import { SaleorProviderFieldsMappingKeys } from "@/modules/configuration";
|
import { SaleorProviderFieldsMappingKeys } from "@/modules/configuration";
|
||||||
|
|
||||||
type FormSchema = Omit<ContentfulProviderConfig.InputShape, "type">;
|
type FormSchema = Omit<ContentfulProviderConfig.InputShape, "type">;
|
||||||
|
@ -53,7 +52,7 @@ const PureForm = ({
|
||||||
});
|
});
|
||||||
notifyError(
|
notifyError(
|
||||||
"Error",
|
"Error",
|
||||||
"Could not fetch content types from Contentful. Please check your credentials."
|
"Could not fetch content types from Contentful. Please check your credentials.",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -76,7 +75,7 @@ const PureForm = ({
|
||||||
});
|
});
|
||||||
notifyError(
|
notifyError(
|
||||||
"Error",
|
"Error",
|
||||||
"Could not fetch environments from Contentful. Please check your credentials."
|
"Could not fetch environments from Contentful. Please check your credentials.",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -197,7 +196,7 @@ const PureForm = ({
|
||||||
newTab
|
newTab
|
||||||
size="small"
|
size="small"
|
||||||
href={`https://app.contentful.com/spaces/${getValues(
|
href={`https://app.contentful.com/spaces/${getValues(
|
||||||
"spaceId"
|
"spaceId",
|
||||||
)}/settings/environments`}
|
)}/settings/environments`}
|
||||||
>
|
>
|
||||||
here
|
here
|
||||||
|
@ -247,7 +246,7 @@ const PureForm = ({
|
||||||
|
|
||||||
<Box marginTop={4}>
|
<Box marginTop={4}>
|
||||||
<Text as="p" variant="heading" size="small">
|
<Text as="p" variant="heading" size="small">
|
||||||
Map fields from Saleor to your contentful schema.
|
Map fields from Saleor to your Contentful schema.
|
||||||
</Text>
|
</Text>
|
||||||
<Text as="p" marginTop={2} marginBottom={4}>
|
<Text as="p" marginTop={2} marginBottom={4}>
|
||||||
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should
|
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should
|
||||||
|
@ -357,7 +356,7 @@ const EditVariant = ({ configId }: { configId: string }) => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!configId,
|
enabled: !!configId,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
|
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
|
|
|
@ -8,8 +8,8 @@ import React, { useEffect, useMemo } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
|
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
|
||||||
import { trpcClient } from "../../trpc/trpc-client";
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
import { ButtonsBox } from "../../ui/buttons-box";
|
|
||||||
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
|
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
|
||||||
|
import { ButtonsBox } from "@saleor/apps-ui";
|
||||||
|
|
||||||
type FormShape = Omit<DatocmsProviderConfig.InputShape, "type">;
|
type FormShape = Omit<DatocmsProviderConfig.InputShape, "type">;
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||||
|
|
||||||
notifyError(
|
notifyError(
|
||||||
"Error",
|
"Error",
|
||||||
"Could not fetch content types from DatoCMS. Please check your credentials."
|
"Could not fetch content types from DatoCMS. Please check your credentials.",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -61,7 +61,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||||
|
|
||||||
notifyError(
|
notifyError(
|
||||||
"Error",
|
"Error",
|
||||||
"Could not fetch content types from DatoCMS. Please check your credentials."
|
"Could not fetch content types from DatoCMS. Please check your credentials.",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -161,7 +161,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||||
{fieldsData && (
|
{fieldsData && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Text as="p" variant="heading" size="small">
|
<Text as="p" variant="heading" size="small">
|
||||||
Map fields from Saleor to your contentful schema.
|
Map fields from Saleor to your DatoCMS schema.
|
||||||
</Text>
|
</Text>
|
||||||
<Text as="p" marginTop={2} marginBottom={4}>
|
<Text as="p" marginTop={2} marginBottom={4}>
|
||||||
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should
|
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should
|
||||||
|
@ -177,7 +177,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||||
padding={2}
|
padding={2}
|
||||||
>
|
>
|
||||||
<Text variant="caption">Saleor Field</Text>
|
<Text variant="caption">Saleor Field</Text>
|
||||||
<Text variant="caption">Contentful field</Text>
|
<Text variant="caption">DatoCMS field</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
|
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
|
||||||
// todo extract this table to component
|
// todo extract this table to component
|
||||||
|
@ -271,7 +271,7 @@ const EditFormVariant = (props: { configId: string }) => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!props.configId,
|
enabled: !!props.configId,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
|
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
|
|
BIN
apps/cms-v2/src/modules/providers/payloadcms/logo.png
Normal file
BIN
apps/cms-v2/src/modules/providers/payloadcms/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.4 KiB |
|
@ -0,0 +1,51 @@
|
||||||
|
import { BulkImportProductFragment } from "../../../../generated/graphql";
|
||||||
|
import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
|
||||||
|
|
||||||
|
import { PayloadCmsProviderConfig } from "@/modules/configuration/schemas/payloadcms-provider.schema";
|
||||||
|
import { PayloadCMSClient } from "./payloadcms-client";
|
||||||
|
|
||||||
|
// todo CORS or proxy
|
||||||
|
export class PayloadCmsBulkSyncProcessor implements BulkSyncProcessor {
|
||||||
|
constructor(private config: PayloadCmsProviderConfig.FullShape) {}
|
||||||
|
|
||||||
|
async uploadProducts(
|
||||||
|
products: BulkImportProductFragment[],
|
||||||
|
hooks: BulkSyncProcessorHooks,
|
||||||
|
): Promise<void> {
|
||||||
|
const client = new PayloadCMSClient();
|
||||||
|
|
||||||
|
products.flatMap(
|
||||||
|
(product) =>
|
||||||
|
product.variants?.map((variant) => {
|
||||||
|
if (hooks.onUploadStart) {
|
||||||
|
hooks.onUploadStart({ variantId: variant.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return client
|
||||||
|
.upsertProductVariant({
|
||||||
|
configuration: this.config,
|
||||||
|
variant: {
|
||||||
|
id: variant.id,
|
||||||
|
name: variant.name,
|
||||||
|
channelListings: variant.channelListings,
|
||||||
|
product: {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
slug: product.slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (hooks.onUploadSuccess) {
|
||||||
|
hooks.onUploadSuccess({ variantId: variant.id });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (hooks.onUploadError) {
|
||||||
|
hooks.onUploadError({ variantId: variant.id, error: e });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
|
||||||
|
|
||||||
|
import { PayloadCmsProviderConfig } from "@/modules/configuration/schemas/payloadcms-provider.schema";
|
||||||
|
import { FieldsMapper } from "../fields-mapper";
|
||||||
|
|
||||||
|
import qs from "qs";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
type Context = {
|
||||||
|
configuration: PayloadCmsProviderConfig.FullShape;
|
||||||
|
variant: WebhookProductVariantFragment;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client uses REST API with built-in query language
|
||||||
|
* https://payloadcms.com/docs/queries/overview#rest-queries
|
||||||
|
*/
|
||||||
|
export class PayloadCMSClient {
|
||||||
|
private logger = createLogger({ name: "PayloadCMSClient" });
|
||||||
|
|
||||||
|
private mapVariantToPayloadFields({ configuration, variant }: Context) {
|
||||||
|
const fields = FieldsMapper.mapProductVariantToConfigurationFields({
|
||||||
|
variant,
|
||||||
|
configMapping: configuration.productVariantFieldsMapping,
|
||||||
|
});
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructCollectionUrl(config: PayloadCmsProviderConfig.FullShape) {
|
||||||
|
return `${config.payloadApiUrl}/${config.collectionName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getItemsBySaleorVariantId(context: Context) {
|
||||||
|
const queryString = qs.stringify(
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
[context.configuration.productVariantFieldsMapping.variantId]: {
|
||||||
|
equals: context.variant.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
addQueryPrefix: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return fetch(`${this.constructCollectionUrl(context.configuration)}${queryString}`, {
|
||||||
|
headers: this.getHeaders(context),
|
||||||
|
}).then((r) => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProductVariant(context: Context) {
|
||||||
|
const queryString = qs.stringify(
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
[context.configuration.productVariantFieldsMapping.variantId]: {
|
||||||
|
equals: context.variant.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
addQueryPrefix: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
this.constructCollectionUrl(context.configuration) + queryString,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: this.getHeaders(context),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status >= 400) {
|
||||||
|
throw new Error("Error while deleting product variant");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(e);
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHeaders(context: Context) {
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://payloadcms.com/docs/authentication/config#api-keys
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
context.configuration.authToken.length > 0 &&
|
||||||
|
context.configuration.authenticatedUserSlug.length > 0
|
||||||
|
) {
|
||||||
|
headers.append(
|
||||||
|
"Authorization",
|
||||||
|
`${context.configuration.authenticatedUserSlug} API-Key ${context.configuration.authToken}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadProductVariant(context: Context) {
|
||||||
|
this.logger.debug("Trying to upload product variant");
|
||||||
|
|
||||||
|
return fetch(this.constructCollectionUrl(context.configuration), {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(this.mapVariantToPayloadFields(context)),
|
||||||
|
headers: this.getHeaders(context),
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (r.status >= 400) {
|
||||||
|
throw new Error(`Error while uploading product variant: ${r.statusText}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
this.logger.error(e);
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProductVariant({ configuration, variant }: Context) {
|
||||||
|
this.logger.debug("Trying to update product variant");
|
||||||
|
|
||||||
|
const queryString = qs.stringify(
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
[configuration.productVariantFieldsMapping.variantId]: {
|
||||||
|
equals: variant.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
addQueryPrefix: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.constructCollectionUrl(configuration) + queryString, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(this.mapVariantToPayloadFields({ configuration, variant })),
|
||||||
|
headers: this.getHeaders({ configuration, variant }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 400) {
|
||||||
|
throw new Error("Error while updating product variant");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(e);
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertProductVariant(context: Context) {
|
||||||
|
this.logger.debug("Trying to upsert product variant");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.uploadProductVariant(context);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug("Failed to upload, will try to update");
|
||||||
|
|
||||||
|
await this.updateProductVariant(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,264 @@
|
||||||
|
import { SaleorProviderFieldsMappingKeys } from "@/modules/configuration";
|
||||||
|
import { PayloadCmsProviderConfig } from "@/modules/configuration/schemas/payloadcms-provider.schema";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { Input, Select } from "@saleor/react-hook-form-macaw";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import React from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { ButtonsBox, TextLink } from "@saleor/apps-ui";
|
||||||
|
|
||||||
|
type FormShape = Omit<PayloadCmsProviderConfig.InputShape, "type">;
|
||||||
|
|
||||||
|
type PureFormProps = {
|
||||||
|
defaultValues: FormShape;
|
||||||
|
onSubmit(values: FormShape): void;
|
||||||
|
onDelete?(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* todo react on token change, refresh mutation
|
||||||
|
*/
|
||||||
|
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||||
|
const { notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { control, getValues, setValue, watch, handleSubmit, clearErrors, setError } = useForm({
|
||||||
|
defaultValues: defaultValues,
|
||||||
|
resolver: zodResolver(PayloadCmsProviderConfig.Schema.Input.omit({ type: true })),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
display={"grid"}
|
||||||
|
gap={4}
|
||||||
|
onSubmit={handleSubmit((vals) => {
|
||||||
|
onSubmit(vals);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
control={control}
|
||||||
|
name="configName"
|
||||||
|
label="Configuration name"
|
||||||
|
helperText="Meaningful name that will help you understand it later. E.g. 'staging' or 'prod' "
|
||||||
|
/>
|
||||||
|
<Box display={"grid"} gap={4} marginY={4}>
|
||||||
|
<Text variant="heading">Provide connection details</Text>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
control={control}
|
||||||
|
name="payloadApiUrl"
|
||||||
|
type="url"
|
||||||
|
label="API url"
|
||||||
|
helperText="URL where Payload API is available. By default ends with /api"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
backgroundColor="surfaceNeutralHighlight"
|
||||||
|
borderColor="neutralHighlight"
|
||||||
|
borderWidth={1}
|
||||||
|
borderStyle={"solid"}
|
||||||
|
padding={4}
|
||||||
|
borderRadius={4}
|
||||||
|
>
|
||||||
|
<Text variant="heading" as="h1" marginBottom={4}>
|
||||||
|
Authorization
|
||||||
|
</Text>
|
||||||
|
<Text marginBottom={2} as="p">
|
||||||
|
Payload can be configured to have open operations (not recommended) or to require an API
|
||||||
|
key. Key can be generated per user. To authenticate, you need to provide both user slug
|
||||||
|
and the key itself.{" "}
|
||||||
|
</Text>
|
||||||
|
<TextLink
|
||||||
|
marginBottom={2}
|
||||||
|
display="block"
|
||||||
|
newTab
|
||||||
|
href="https://payloadcms.com/docs/authentication/config"
|
||||||
|
>
|
||||||
|
Read more in Payload docs
|
||||||
|
</TextLink>
|
||||||
|
<Text as="p" marginBottom={6}>
|
||||||
|
If your API is open (e.g. for development purposes) leave both fields empty.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box display="grid" gap={4} gridTemplateColumns={2}>
|
||||||
|
<Input
|
||||||
|
control={control}
|
||||||
|
name="authenticatedUserSlug"
|
||||||
|
label="Authenticated user slug"
|
||||||
|
placeholder="e.g. apps"
|
||||||
|
/>
|
||||||
|
<Input control={control} name="authToken" type="password" label="User API Key" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box display={"grid"} gap={4} marginY={4}>
|
||||||
|
<Text variant="heading">Configure fields mapping</Text>
|
||||||
|
<Input
|
||||||
|
label="Collection Slug"
|
||||||
|
name="collectionName"
|
||||||
|
control={control}
|
||||||
|
helperText="Slug of your collection in Payload, e.g. 'saleorVariants'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<React.Fragment>
|
||||||
|
<Text as="p" variant="heading" size="small">
|
||||||
|
Map fields from Saleor to your Payload schema.
|
||||||
|
</Text>
|
||||||
|
<Text as="p" marginTop={2} marginBottom={4}>
|
||||||
|
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should be
|
||||||
|
type of <Text variant="bodyStrong">JSON</Text>.
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
marginBottom={4}
|
||||||
|
display="grid"
|
||||||
|
__gridTemplateColumns={"50% 50%"}
|
||||||
|
borderBottomWidth={1}
|
||||||
|
borderBottomStyle="solid"
|
||||||
|
borderColor="neutralHighlight"
|
||||||
|
padding={2}
|
||||||
|
>
|
||||||
|
<Text variant="caption">Saleor Field</Text>
|
||||||
|
<Text variant="caption">Payload field</Text>
|
||||||
|
</Box>
|
||||||
|
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
|
||||||
|
// todo extract this table to component
|
||||||
|
<Box
|
||||||
|
display="grid"
|
||||||
|
__gridTemplateColumns={"50% 50%"}
|
||||||
|
padding={2}
|
||||||
|
key={saleorField}
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Text as="p" variant="bodyStrong">
|
||||||
|
{printSaleorProductFields(saleorField)}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption">
|
||||||
|
{saleorField === "channels" ? "JSON field" : "Text field"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
control={control}
|
||||||
|
name={`productVariantFieldsMapping.${saleorField}`}
|
||||||
|
label="CMS Field"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<ButtonsBox>
|
||||||
|
{onDelete && (
|
||||||
|
<Button onClick={onDelete} variant="tertiary">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddFormVariant = () => {
|
||||||
|
const { push } = useRouter();
|
||||||
|
const { notifySuccess } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { mutate } = trpcClient.providersConfigs.addOne.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Added new configuration");
|
||||||
|
push("/configuration");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PureForm
|
||||||
|
onSubmit={(values) => {
|
||||||
|
mutate({
|
||||||
|
...values,
|
||||||
|
type: "payloadcms",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
defaultValues={{
|
||||||
|
payloadApiUrl: "",
|
||||||
|
authToken: "",
|
||||||
|
configName: "",
|
||||||
|
collectionName: "",
|
||||||
|
authenticatedUserSlug: "",
|
||||||
|
productVariantFieldsMapping: {
|
||||||
|
channels: "",
|
||||||
|
variantName: "",
|
||||||
|
productId: "",
|
||||||
|
productName: "",
|
||||||
|
productSlug: "",
|
||||||
|
variantId: "",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditFormVariant = (props: { configId: string }) => {
|
||||||
|
const { push } = useRouter();
|
||||||
|
const { notifySuccess } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { data } = trpcClient.providersConfigs.getOne.useQuery(
|
||||||
|
{
|
||||||
|
id: props.configId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!props.configId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Updated configuration");
|
||||||
|
push("/configuration");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteProvider } = trpcClient.providersConfigs.deleteOne.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Removed configuration");
|
||||||
|
push("/configuration");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type !== "payloadcms") {
|
||||||
|
throw new Error("Trying to fill Payload CMS form with non Payload CMS data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PureForm
|
||||||
|
onDelete={() => {
|
||||||
|
deleteProvider({
|
||||||
|
id: props.configId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
mutate({
|
||||||
|
...values,
|
||||||
|
type: "payloadcms",
|
||||||
|
id: props.configId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
defaultValues={data}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PayloadCMSConfigForm = {
|
||||||
|
PureVariant: PureForm,
|
||||||
|
AddVariant: AddFormVariant,
|
||||||
|
EditVariant: EditFormVariant,
|
||||||
|
};
|
|
@ -0,0 +1,69 @@
|
||||||
|
import {
|
||||||
|
WebhookProductFragment,
|
||||||
|
WebhookProductVariantFragment,
|
||||||
|
} from "../../../../generated/graphql";
|
||||||
|
|
||||||
|
import { PayloadCmsProviderConfig } from "@/modules/configuration/schemas/payloadcms-provider.schema";
|
||||||
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
|
||||||
|
import { PayloadCMSClient } from "./payloadcms-client";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* todo error handling
|
||||||
|
*/
|
||||||
|
export class PayloadCmsWebhooksProcessor implements ProductWebhooksProcessor {
|
||||||
|
private client = new PayloadCMSClient();
|
||||||
|
|
||||||
|
private logger = createLogger({ name: "PayloadCmsWebhooksProcessor" });
|
||||||
|
|
||||||
|
constructor(private providerConfig: PayloadCmsProviderConfig.FullShape) {}
|
||||||
|
|
||||||
|
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||||
|
this.logger.trace("onProductVariantUpdated called");
|
||||||
|
|
||||||
|
await this.client.upsertProductVariant({
|
||||||
|
configuration: this.providerConfig,
|
||||||
|
variant: productVariant,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||||
|
this.logger.trace("onProductVariantCreated called");
|
||||||
|
|
||||||
|
await this.client.uploadProductVariant({
|
||||||
|
configuration: this.providerConfig,
|
||||||
|
variant: productVariant,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||||
|
this.logger.trace("onProductVariantDeleted called");
|
||||||
|
|
||||||
|
await this.client.deleteProductVariant({
|
||||||
|
configuration: this.providerConfig,
|
||||||
|
variant: productVariant,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
|
||||||
|
this.logger.trace("onProductUpdated called");
|
||||||
|
|
||||||
|
const client = new PayloadCMSClient();
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
(product.variants ?? []).map((variant) => {
|
||||||
|
return client.upsertProductVariant({
|
||||||
|
configuration: this.providerConfig,
|
||||||
|
variant: {
|
||||||
|
id: variant.id,
|
||||||
|
name: variant.name,
|
||||||
|
product: {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
slug: product.slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
29
apps/cms-v2/src/modules/providers/payloadcms/payloadcms.tsx
Normal file
29
apps/cms-v2/src/modules/providers/payloadcms/payloadcms.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
import { CMSProviderMeta } from "../cms-provider-meta";
|
||||||
|
import logo from "./logo.png";
|
||||||
|
import { TextLink } from "@saleor/apps-ui";
|
||||||
|
|
||||||
|
export const PayloadCMS = {
|
||||||
|
formSideInfo: (
|
||||||
|
<Box>
|
||||||
|
<Text as="p" marginBottom={2}>
|
||||||
|
Configure the Payload CMS integration by providing required information.
|
||||||
|
</Text>
|
||||||
|
<Text as="p" marginBottom={2}>
|
||||||
|
Fields are not validated - ensure you enter correct values.
|
||||||
|
</Text>
|
||||||
|
<Text as="p" marginBottom={2}>
|
||||||
|
Consult{" "}
|
||||||
|
<TextLink newTab href="https://docs.saleor.io/docs/3.x/developer/app-store/apps/cms">
|
||||||
|
docs
|
||||||
|
</TextLink>{" "}
|
||||||
|
for more information how to set up Payload CMS.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
type: "payloadcms" as const,
|
||||||
|
logoUrl: logo.src,
|
||||||
|
displayName: "Payload",
|
||||||
|
description: "Open source, typescript first headless CMS. GraphQL included.",
|
||||||
|
} satisfies CMSProviderMeta;
|
|
@ -1,12 +1,24 @@
|
||||||
import { BuilderIo } from "./builder.io/builder-io";
|
import { BuilderIo } from "./builder.io/builder-io";
|
||||||
import { Contentful } from "./contentful/contentful";
|
import { Contentful } from "./contentful/contentful";
|
||||||
import { Datocms } from "./datocms/datocms";
|
import { Datocms } from "./datocms/datocms";
|
||||||
|
import { PayloadCMS } from "./payloadcms/payloadcms";
|
||||||
import { Strapi } from "./strapi/strapi";
|
import { Strapi } from "./strapi/strapi";
|
||||||
|
|
||||||
export type CMS = typeof Contentful | typeof Datocms | typeof Strapi | typeof BuilderIo;
|
export type CMS =
|
||||||
|
| typeof Contentful
|
||||||
|
| typeof Datocms
|
||||||
|
| typeof Strapi
|
||||||
|
| typeof BuilderIo
|
||||||
|
| typeof PayloadCMS;
|
||||||
|
|
||||||
export type CMSType = CMS["type"];
|
export type CMSType = CMS["type"];
|
||||||
|
|
||||||
export const cmsTypes = [Contentful.type, Datocms.type, Strapi.type, BuilderIo.type] as const;
|
export const cmsTypes = [
|
||||||
|
Contentful.type,
|
||||||
|
Datocms.type,
|
||||||
|
Strapi.type,
|
||||||
|
BuilderIo.type,
|
||||||
|
PayloadCMS.type,
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const CMSProviders = [Contentful, Datocms, Strapi, BuilderIo] as const;
|
export const CMSProviders = [Contentful, Datocms, Strapi, BuilderIo, PayloadCMS] as const;
|
||||||
|
|
|
@ -22,6 +22,10 @@ import { DatocmsProviderConfig } from "../configuration/schemas/datocms-provider
|
||||||
import { BuilderIo } from "./builder.io/builder-io";
|
import { BuilderIo } from "./builder.io/builder-io";
|
||||||
import { BuilderIoWebhooksProcessor } from "./builder.io/builder-io-webhooks-processor";
|
import { BuilderIoWebhooksProcessor } from "./builder.io/builder-io-webhooks-processor";
|
||||||
import { BuilderIoBulkSyncProcessor } from "./builder.io/builder-io-bulk-sync-processor";
|
import { BuilderIoBulkSyncProcessor } from "./builder.io/builder-io-bulk-sync-processor";
|
||||||
|
import { PayloadCmsBulkSyncProcessor } from "./payloadcms/payloadcms-bulk-sync-processor";
|
||||||
|
import { PayloadCmsProviderConfig } from "../configuration/schemas/payloadcms-provider.schema";
|
||||||
|
import { PayloadCMS } from "./payloadcms/payloadcms";
|
||||||
|
import { PayloadCmsWebhooksProcessor } from "./payloadcms/payloadcms-webhooks-processor";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Almost-single source of new providers. Every time app will need to resolve a provider, it will use on of these factories.
|
* Almost-single source of new providers. Every time app will need to resolve a provider, it will use on of these factories.
|
||||||
|
@ -39,9 +43,9 @@ export const ProvidersResolver = {
|
||||||
case "builder.io": {
|
case "builder.io": {
|
||||||
return new BuilderIoBulkSyncProcessor(config);
|
return new BuilderIoBulkSyncProcessor(config);
|
||||||
}
|
}
|
||||||
|
case "payloadcms": {
|
||||||
default:
|
return new PayloadCmsBulkSyncProcessor(config);
|
||||||
throw new Error(`Unknown provider`);
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getProviderInputSchema(type: CMSType) {
|
getProviderInputSchema(type: CMSType) {
|
||||||
|
@ -54,9 +58,8 @@ export const ProvidersResolver = {
|
||||||
return StrapiProviderConfig.Schema.Input;
|
return StrapiProviderConfig.Schema.Input;
|
||||||
case "builder.io":
|
case "builder.io":
|
||||||
return BuilderIoProviderConfig.Schema.Input;
|
return BuilderIoProviderConfig.Schema.Input;
|
||||||
default: {
|
case "payloadcms":
|
||||||
throw new Error("Failed to build input schema");
|
return PayloadCmsProviderConfig.Schema.Input;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getProviderSchema(type: CMSType) {
|
getProviderSchema(type: CMSType) {
|
||||||
|
@ -69,12 +72,11 @@ export const ProvidersResolver = {
|
||||||
return StrapiProviderConfig.Schema.Full;
|
return StrapiProviderConfig.Schema.Full;
|
||||||
case "builder.io":
|
case "builder.io":
|
||||||
return BuilderIoProviderConfig.Schema.Full;
|
return BuilderIoProviderConfig.Schema.Full;
|
||||||
default: {
|
case "payloadcms":
|
||||||
throw new Error("Failed to build provdier schema");
|
return PayloadCmsProviderConfig.Schema.Full;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
createProviderMeta(type: CMSType | string): CMS {
|
createProviderMeta(type: CMSType): CMS {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "contentful": {
|
case "contentful": {
|
||||||
return Contentful;
|
return Contentful;
|
||||||
|
@ -88,8 +90,8 @@ export const ProvidersResolver = {
|
||||||
case "builder.io": {
|
case "builder.io": {
|
||||||
return BuilderIo;
|
return BuilderIo;
|
||||||
}
|
}
|
||||||
default: {
|
case "payloadcms": {
|
||||||
throw new Error("Unknown provider");
|
return PayloadCMS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -107,13 +109,13 @@ export const ProvidersResolver = {
|
||||||
case "builder.io": {
|
case "builder.io": {
|
||||||
return new BuilderIoWebhooksProcessor(config);
|
return new BuilderIoWebhooksProcessor(config);
|
||||||
}
|
}
|
||||||
default: {
|
case "payloadcms": {
|
||||||
throw new Error("Failed to build webhook processor.");
|
return new PayloadCmsWebhooksProcessor(config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getEditProviderFormComponent: (
|
getEditProviderFormComponent: (
|
||||||
type: CMSType
|
type: CMSType,
|
||||||
): ComponentType<{
|
): ComponentType<{
|
||||||
configId: string;
|
configId: string;
|
||||||
}> => {
|
}> => {
|
||||||
|
@ -121,33 +123,37 @@ export const ProvidersResolver = {
|
||||||
case "contentful": {
|
case "contentful": {
|
||||||
return dynamic(() =>
|
return dynamic(() =>
|
||||||
import("./contentful/contentful-config-form").then(
|
import("./contentful/contentful-config-form").then(
|
||||||
(module) => module.ContentfulConfigForm.EditVariant
|
(module) => module.ContentfulConfigForm.EditVariant,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "datocms": {
|
case "datocms": {
|
||||||
return dynamic(() =>
|
return dynamic(() =>
|
||||||
import("./datocms/datocms-config-form").then(
|
import("./datocms/datocms-config-form").then(
|
||||||
(module) => module.DatoCMSConfigForm.EditVariant
|
(module) => module.DatoCMSConfigForm.EditVariant,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "strapi": {
|
case "strapi": {
|
||||||
return dynamic(() =>
|
return dynamic(() =>
|
||||||
import("./strapi/strapi-config-form").then(
|
import("./strapi/strapi-config-form").then(
|
||||||
(module) => module.StrapiConfigForm.EditVariant
|
(module) => module.StrapiConfigForm.EditVariant,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "builder.io": {
|
case "builder.io": {
|
||||||
return dynamic(() =>
|
return dynamic(() =>
|
||||||
import("./builder.io/builder-io-config-form").then(
|
import("./builder.io/builder-io-config-form").then(
|
||||||
(module) => module.BuilderIoConfigForm.EditVariant
|
(module) => module.BuilderIoConfigForm.EditVariant,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
default: {
|
case "payloadcms": {
|
||||||
throw new Error("Provider form not registered");
|
return dynamic(() =>
|
||||||
|
import("./payloadcms/payloadcms-config-form").then(
|
||||||
|
(module) => module.PayloadCMSConfigForm.EditVariant,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -156,31 +162,37 @@ export const ProvidersResolver = {
|
||||||
case "contentful": {
|
case "contentful": {
|
||||||
return dynamic(() =>
|
return dynamic(() =>
|
||||||
import("./contentful/contentful-config-form").then(
|
import("./contentful/contentful-config-form").then(
|
||||||
(module) => module.ContentfulConfigForm.AddVariant
|
(module) => module.ContentfulConfigForm.AddVariant,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "datocms": {
|
case "datocms": {
|
||||||
return dynamic(() =>
|
return dynamic(() =>
|
||||||
import("./datocms/datocms-config-form").then(
|
import("./datocms/datocms-config-form").then(
|
||||||
(module) => module.DatoCMSConfigForm.AddVariant
|
(module) => module.DatoCMSConfigForm.AddVariant,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "strapi": {
|
case "strapi": {
|
||||||
return dynamic(() =>
|
return dynamic(() =>
|
||||||
import("./strapi/strapi-config-form").then((module) => module.StrapiConfigForm.AddVariant)
|
import("./strapi/strapi-config-form").then(
|
||||||
|
(module) => module.StrapiConfigForm.AddVariant,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "builder.io": {
|
case "builder.io": {
|
||||||
return dynamic(() =>
|
return dynamic(() =>
|
||||||
import("./builder.io/builder-io-config-form").then(
|
import("./builder.io/builder-io-config-form").then(
|
||||||
(module) => module.BuilderIoConfigForm.AddVariant
|
(module) => module.BuilderIoConfigForm.AddVariant,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
default: {
|
case "payloadcms": {
|
||||||
throw new Error("Provider form not registered");
|
return dynamic(() =>
|
||||||
|
import("./payloadcms/payloadcms-config-form").then(
|
||||||
|
(module) => module.PayloadCMSConfigForm.AddVariant,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -7,8 +7,7 @@ import { useForm } from "react-hook-form";
|
||||||
import { SaleorProviderFieldsMappingKeys, StrapiProviderConfig } from "../../configuration";
|
import { SaleorProviderFieldsMappingKeys, StrapiProviderConfig } from "../../configuration";
|
||||||
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
|
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
|
||||||
import { trpcClient } from "../../trpc/trpc-client";
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
import { ButtonsBox } from "../../ui/buttons-box";
|
import { ButtonsBox, SkeletonLayout } from "@saleor/apps-ui";
|
||||||
import { Skeleton } from "@/modules/ui/skeleton";
|
|
||||||
|
|
||||||
type FormShape = Omit<StrapiProviderConfig.InputShape, "type">;
|
type FormShape = Omit<StrapiProviderConfig.InputShape, "type">;
|
||||||
|
|
||||||
|
@ -86,7 +85,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||||
padding={2}
|
padding={2}
|
||||||
>
|
>
|
||||||
<Text variant="caption">Saleor Field</Text>
|
<Text variant="caption">Saleor Field</Text>
|
||||||
<Text variant="caption">Contentful field</Text>
|
<Text variant="caption">Strapi field</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
|
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
|
||||||
// todo extract this table to component
|
// todo extract this table to component
|
||||||
|
@ -172,7 +171,7 @@ const EditFormVariant = (props: { configId: string }) => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!props.configId,
|
enabled: !!props.configId,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
|
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
|
@ -189,7 +188,7 @@ const EditFormVariant = (props: { configId: string }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <Skeleton.Section />;
|
return <SkeletonLayout.Section />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type !== "strapi") {
|
if (data.type !== "strapi") {
|
||||||
|
|
|
@ -9,7 +9,7 @@ dialog {
|
||||||
|
|
||||||
.dialog-overlay {
|
.dialog-overlay {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: rgba(255, 255, 255, 0.8);
|
background: color-mix(in srgb, var(--mu-colors-background-plain) 80%, transparent);
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: blur(5px);
|
||||||
content: "";
|
content: "";
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import * as trpcNext from "@trpc/server/adapters/next";
|
import * as trpcNext from "@trpc/server/adapters/next";
|
||||||
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const";
|
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const";
|
||||||
import { inferAsyncReturnType } from "@trpc/server";
|
import { inferAsyncReturnType } from "@trpc/server";
|
||||||
import { getBaseUrl } from "@/modules/shared/get-base-url";
|
import { getAppBaseUrl } from "@saleor/apps-shared";
|
||||||
|
|
||||||
export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => {
|
export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => {
|
||||||
const baseUrl = getBaseUrl(req.headers);
|
const baseUrl = getAppBaseUrl(req.headers);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER] as string | undefined,
|
token: req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER] as string | undefined,
|
||||||
|
|
|
@ -1,43 +0,0 @@
|
||||||
import { Box, PropsWithBox, Text } from "@saleor/macaw-ui/next";
|
|
||||||
import { ReactNode } from "react";
|
|
||||||
|
|
||||||
// todo move to shared
|
|
||||||
export const AppSection = ({
|
|
||||||
heading,
|
|
||||||
sideContent,
|
|
||||||
mainContent,
|
|
||||||
includePadding = true,
|
|
||||||
...props
|
|
||||||
}: PropsWithBox<{
|
|
||||||
heading: string;
|
|
||||||
sideContent?: ReactNode;
|
|
||||||
mainContent: ReactNode;
|
|
||||||
includePadding?: boolean;
|
|
||||||
}>) => {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
as="section"
|
|
||||||
__gridTemplateColumns={"400px auto"}
|
|
||||||
display={"grid"}
|
|
||||||
gap={10}
|
|
||||||
__maxWidth={"1200px"}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Text as="h2" variant={"heading"} size={"large"} marginBottom={1.5}>
|
|
||||||
{heading}
|
|
||||||
</Text>
|
|
||||||
{sideContent}
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
borderStyle={"solid"}
|
|
||||||
borderColor={"neutralPlain"}
|
|
||||||
borderWidth={1}
|
|
||||||
padding={includePadding ? 5 : 0}
|
|
||||||
borderRadius={4}
|
|
||||||
>
|
|
||||||
{mainContent}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { Box, BoxProps } from "@saleor/macaw-ui/next";
|
import { Box, BoxProps } from "@saleor/macaw-ui/next";
|
||||||
import { forwardRef } from "react";
|
|
||||||
|
|
||||||
export const Modal = ({ onClose, ...rest }: { onClose(): void } & BoxProps) => {
|
export const Modal = ({ onClose, ...rest }: { onClose(): void } & BoxProps) => {
|
||||||
return (
|
return (
|
||||||
|
@ -12,6 +11,8 @@ export const Modal = ({ onClose, ...rest }: { onClose(): void } & BoxProps) => {
|
||||||
as="dialog"
|
as="dialog"
|
||||||
__maxWidth="400px"
|
__maxWidth="400px"
|
||||||
boxShadow={"modal"}
|
boxShadow={"modal"}
|
||||||
|
backgroundColor="surfaceNeutralPlain"
|
||||||
|
color="textNeutralDefault"
|
||||||
open
|
open
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,29 +0,0 @@
|
||||||
import { Box, BoxProps } from "@saleor/macaw-ui/next";
|
|
||||||
|
|
||||||
// TODO: Make it more generic, move to shared or contribute to macaw
|
|
||||||
const Section = (props: BoxProps) => {
|
|
||||||
return (
|
|
||||||
<Box display="grid" gap={2} {...props}>
|
|
||||||
<Box
|
|
||||||
__height="10px"
|
|
||||||
backgroundColor="surfaceNeutralHighlight"
|
|
||||||
borderRadius={2}
|
|
||||||
__width="50%"
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
__height="10px"
|
|
||||||
backgroundColor="surfaceNeutralHighlight"
|
|
||||||
borderRadius={2}
|
|
||||||
__width="70%"
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
__height="10px"
|
|
||||||
backgroundColor="surfaceNeutralHighlight"
|
|
||||||
borderRadius={2}
|
|
||||||
__width="60%"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Skeleton = { Section };
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { CMSProviders } from "@/modules/providers/providers-registry";
|
import { CMSProviders } from "@/modules/providers/providers-registry";
|
||||||
import { AppHeader } from "@/modules/ui/app-header";
|
import { AppHeader } from "@/modules/ui/app-header";
|
||||||
import { AppSection } from "@/modules/ui/app-section";
|
import { Breadcrumbs, Layout } from "@saleor/apps-ui";
|
||||||
import { Breadcrumbs } from "@saleor/apps-ui";
|
|
||||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
import { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
@ -16,14 +15,15 @@ const AddProviderPage: NextPage = () => {
|
||||||
text="Connect CMS platforms to the App."
|
text="Connect CMS platforms to the App."
|
||||||
breadcrumbs={[<Breadcrumbs.Item key="provider">Add Provider</Breadcrumbs.Item>]}
|
breadcrumbs={[<Breadcrumbs.Item key="provider">Add Provider</Breadcrumbs.Item>]}
|
||||||
/>
|
/>
|
||||||
<AppSection
|
<Layout.AppSection
|
||||||
heading="Select CMS provider"
|
heading="Select CMS provider"
|
||||||
sideContent={
|
sideContent={
|
||||||
<Box>
|
<Box>
|
||||||
<Text>App allows to connect one or more CMS platforms. You can add more later.</Text>
|
<Text>App allows to connect one or more CMS platforms. You can add more later.</Text>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
mainContent={
|
>
|
||||||
|
<Layout.AppSectionCard>
|
||||||
<Box
|
<Box
|
||||||
display="grid"
|
display="grid"
|
||||||
__gridTemplateColumns="auto auto auto"
|
__gridTemplateColumns="auto auto auto"
|
||||||
|
@ -54,8 +54,8 @@ const AddProviderPage: NextPage = () => {
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
}
|
</Layout.AppSectionCard>
|
||||||
/>
|
</Layout.AppSection>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,12 +1,8 @@
|
||||||
import { ContentfulConfigForm } from "@/modules/providers/contentful/contentful-config-form";
|
|
||||||
import { DatoCMSConfigForm } from "@/modules/providers/datocms/datocms-config-form";
|
|
||||||
import { CMSType } from "@/modules/providers/providers-registry";
|
import { CMSType } from "@/modules/providers/providers-registry";
|
||||||
import { ProvidersResolver } from "@/modules/providers/providers-resolver";
|
import { ProvidersResolver } from "@/modules/providers/providers-resolver";
|
||||||
|
|
||||||
import { StrapiConfigForm } from "@/modules/providers/strapi/strapi-config-form";
|
|
||||||
import { AppHeader } from "@/modules/ui/app-header";
|
import { AppHeader } from "@/modules/ui/app-header";
|
||||||
import { AppSection } from "@/modules/ui/app-section";
|
import { Breadcrumbs, Layout } from "@saleor/apps-ui";
|
||||||
import { Breadcrumbs } from "@saleor/apps-ui";
|
|
||||||
import { Box, Text } from "@saleor/macaw-ui/next";
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
import { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
@ -16,7 +12,7 @@ const AddProviderPage: NextPage = () => {
|
||||||
const { query } = useRouter();
|
const { query } = useRouter();
|
||||||
|
|
||||||
const provider = useMemo(() => {
|
const provider = useMemo(() => {
|
||||||
return query.type ? ProvidersResolver.createProviderMeta(query.type as string) : null;
|
return query.type ? ProvidersResolver.createProviderMeta(query.type as CMSType) : null;
|
||||||
}, [query.type]);
|
}, [query.type]);
|
||||||
|
|
||||||
if (!provider) return null;
|
if (!provider) return null;
|
||||||
|
@ -35,7 +31,7 @@ const AddProviderPage: NextPage = () => {
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AppSection
|
<Layout.AppSection
|
||||||
heading={`Set up ${provider.displayName}`}
|
heading={`Set up ${provider.displayName}`}
|
||||||
sideContent={
|
sideContent={
|
||||||
<Box>
|
<Box>
|
||||||
|
@ -43,8 +39,11 @@ const AddProviderPage: NextPage = () => {
|
||||||
{provider.formSideInfo && <Box marginTop={6}>{provider.formSideInfo}</Box>}
|
{provider.formSideInfo && <Box marginTop={6}>{provider.formSideInfo}</Box>}
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
mainContent={<FormComponent />}
|
>
|
||||||
/>
|
<Layout.AppSectionCard>
|
||||||
|
<FormComponent />
|
||||||
|
</Layout.AppSectionCard>
|
||||||
|
</Layout.AppSection>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -31,7 +31,7 @@ export default createManifestHandler({
|
||||||
],
|
],
|
||||||
homepageUrl: "https://github.com/saleor/apps",
|
homepageUrl: "https://github.com/saleor/apps",
|
||||||
id: "saleor.app.cms2",
|
id: "saleor.app.cms2",
|
||||||
name: "CMS 2",
|
name: "CMS",
|
||||||
permissions: ["MANAGE_PRODUCTS"],
|
permissions: ["MANAGE_PRODUCTS"],
|
||||||
requiredSaleorVersion: ">=3.10 <4",
|
requiredSaleorVersion: ">=3.10 <4",
|
||||||
supportUrl: "https://github.com/saleor/apps/discussions",
|
supportUrl: "https://github.com/saleor/apps/discussions",
|
||||||
|
|
|
@ -1,8 +1,18 @@
|
||||||
import * as trpcNext from "@trpc/server/adapters/next";
|
import * as trpcNext from "@trpc/server/adapters/next";
|
||||||
import { createTrpcContext } from "../../../modules/trpc/trpc-context";
|
import { createTrpcContext } from "../../../modules/trpc/trpc-context";
|
||||||
import { appRouter } from "../../../modules/trpc/trpc-app-router";
|
import { appRouter } from "../../../modules/trpc/trpc-app-router";
|
||||||
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
|
||||||
|
const logger = createLogger({ name: "tRPC error" });
|
||||||
|
|
||||||
export default trpcNext.createNextApiHandler({
|
export default trpcNext.createNextApiHandler({
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: createTrpcContext,
|
createContext: createTrpcContext,
|
||||||
|
onError: ({ path, error }) => {
|
||||||
|
if (error.code === "INTERNAL_SERVER_ERROR") {
|
||||||
|
logger.error(error, `${path} returned error:`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.debug(error, `${path} returned error:`);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import { BulkSyncView } from "@/modules/bulk-sync/bulk-sync-view";
|
import { BulkSyncView } from "@/modules/bulk-sync/bulk-sync-view";
|
||||||
import { trpcClient } from "@/modules/trpc/trpc-client";
|
import { trpcClient } from "@/modules/trpc/trpc-client";
|
||||||
|
import { SkeletonLayout } from "@saleor/apps-ui";
|
||||||
import { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Text } from "@saleor/macaw-ui/next";
|
|
||||||
import { Skeleton } from "@/modules/ui/skeleton";
|
|
||||||
|
|
||||||
const BulkSyncPage: NextPage = () => {
|
const BulkSyncPage: NextPage = () => {
|
||||||
const { query } = useRouter();
|
const { query } = useRouter();
|
||||||
|
@ -23,7 +22,7 @@ const BulkSyncPage: NextPage = () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!parsedID,
|
enabled: !!parsedID,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -36,7 +35,7 @@ const BulkSyncPage: NextPage = () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!connection,
|
enabled: !!connection,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if ((providerFetched && !provider) || (connectionFetched && !connection)) {
|
if ((providerFetched && !provider) || (connectionFetched && !connection)) {
|
||||||
|
@ -45,7 +44,7 @@ const BulkSyncPage: NextPage = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connectionLoading || providerLoading) {
|
if (connectionLoading || providerLoading) {
|
||||||
return <Skeleton.Section />;
|
return <SkeletonLayout.Section />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(provider && connection)) {
|
if (!(provider && connection)) {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { BulkSyncSection } from "@/modules/bulk-sync/bulk-sync-section";
|
||||||
import { ChannelProviderConnectionList } from "@/modules/channel-provider-connection/channels-provider-connection-list";
|
import { ChannelProviderConnectionList } from "@/modules/channel-provider-connection/channels-provider-connection-list";
|
||||||
import { ProvidersList } from "@/modules/providers-listing/providers-list";
|
import { ProvidersList } from "@/modules/providers-listing/providers-list";
|
||||||
import { AppHeader } from "@/modules/ui/app-header";
|
import { AppHeader } from "@/modules/ui/app-header";
|
||||||
import { AppSection } from "@/modules/ui/app-section";
|
import { Layout } from "@saleor/apps-ui";
|
||||||
import { Box, Text } from "@saleor/macaw-ui/next";
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
import { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ const ConfigurationPage: NextPage = () => {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<AppSection
|
<Layout.AppSection
|
||||||
marginBottom={14}
|
marginBottom={14}
|
||||||
heading="Providers configuration"
|
heading="Providers configuration"
|
||||||
sideContent={
|
sideContent={
|
||||||
|
@ -18,9 +18,10 @@ const ConfigurationPage: NextPage = () => {
|
||||||
<Text>Configure one or more CMS providers to synchronize Saleor products.</Text>
|
<Text>Configure one or more CMS providers to synchronize Saleor products.</Text>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
mainContent={<ProvidersList />}
|
>
|
||||||
/>
|
<ProvidersList />
|
||||||
<AppSection
|
</Layout.AppSection>
|
||||||
|
<Layout.AppSection
|
||||||
marginBottom={14}
|
marginBottom={14}
|
||||||
heading="Automatic synchronization"
|
heading="Automatic synchronization"
|
||||||
sideContent={
|
sideContent={
|
||||||
|
@ -31,9 +32,10 @@ const ConfigurationPage: NextPage = () => {
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
mainContent={<ChannelProviderConnectionList />}
|
>
|
||||||
/>
|
<ChannelProviderConnectionList />
|
||||||
<AppSection
|
</Layout.AppSection>
|
||||||
|
<Layout.AppSection
|
||||||
heading="Initial sync"
|
heading="Initial sync"
|
||||||
sideContent={
|
sideContent={
|
||||||
<Box>
|
<Box>
|
||||||
|
@ -44,8 +46,9 @@ const ConfigurationPage: NextPage = () => {
|
||||||
<Text as="p">Its recommended to run this flow initially, once app is configured.</Text>
|
<Text as="p">Its recommended to run this flow initially, once app is configured.</Text>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
mainContent={<BulkSyncSection />}
|
>
|
||||||
/>
|
<BulkSyncSection />
|
||||||
|
</Layout.AppSection>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,9 +2,7 @@ import { ProvidersResolver } from "@/modules/providers/providers-resolver";
|
||||||
|
|
||||||
import { trpcClient } from "@/modules/trpc/trpc-client";
|
import { trpcClient } from "@/modules/trpc/trpc-client";
|
||||||
import { AppHeader } from "@/modules/ui/app-header";
|
import { AppHeader } from "@/modules/ui/app-header";
|
||||||
import { AppSection } from "@/modules/ui/app-section";
|
import { Breadcrumbs, Layout, SkeletonLayout } from "@saleor/apps-ui";
|
||||||
import { Skeleton } from "@/modules/ui/skeleton";
|
|
||||||
import { Breadcrumbs } from "@saleor/apps-ui";
|
|
||||||
import { Box, Text } from "@saleor/macaw-ui/next";
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
import { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
@ -20,7 +18,7 @@ const EditProviderPage: NextPage = () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!configId,
|
enabled: !!configId,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const provider = useMemo(() => {
|
const provider = useMemo(() => {
|
||||||
|
@ -28,7 +26,7 @@ const EditProviderPage: NextPage = () => {
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Skeleton.Section />;
|
return <SkeletonLayout.Section />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFetched && !data) {
|
if (isFetched && !data) {
|
||||||
|
@ -38,7 +36,7 @@ const EditProviderPage: NextPage = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
return <Skeleton.Section />;
|
return <SkeletonLayout.Section />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditForm = ProvidersResolver.getEditProviderFormComponent(provider.type);
|
const EditForm = ProvidersResolver.getEditProviderFormComponent(provider.type);
|
||||||
|
@ -53,13 +51,16 @@ const EditProviderPage: NextPage = () => {
|
||||||
<Breadcrumbs.Item key="configname">{data?.configName}</Breadcrumbs.Item>,
|
<Breadcrumbs.Item key="configname">{data?.configName}</Breadcrumbs.Item>,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<AppSection
|
<Layout.AppSection
|
||||||
heading="Edit CMS configuration"
|
heading="Edit CMS configuration"
|
||||||
mainContent={<EditForm configId={configId} />}
|
|
||||||
sideContent={
|
sideContent={
|
||||||
<Box>{provider.formSideInfo && <Box marginTop={6}>{provider.formSideInfo}</Box>}</Box>
|
<Box>{provider.formSideInfo && <Box marginTop={6}>{provider.formSideInfo}</Box>}</Box>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
<Layout.AppSectionCard>
|
||||||
|
<EditForm configId={configId} />
|
||||||
|
</Layout.AppSectionCard>
|
||||||
|
</Layout.AppSection>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1 +1,3 @@
|
||||||
export {};
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
vi.stubEnv("SECRET_KEY", "TEST");
|
||||||
|
|
3
apps/cms/.gitignore
vendored
3
apps/cms/.gitignore
vendored
|
@ -1,3 +0,0 @@
|
||||||
|
|
||||||
# Sentry Auth Token
|
|
||||||
.sentryclirc
|
|
|
@ -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
|
|
|
@ -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! 🎖️
|
|
|
@ -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/)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
<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).
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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).
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
mutation DeleteMetadata($id: ID!, $keys: [String!]!) {
|
|
||||||
deleteMetadata(id: $id, keys: $keys) {
|
|
||||||
errors {
|
|
||||||
field
|
|
||||||
message
|
|
||||||
code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
mutation UpdateMetadata($id: ID!, $input: [MetadataInput!]!) {
|
|
||||||
updateMetadata(id: $id, input: $input) {
|
|
||||||
errors {
|
|
||||||
field
|
|
||||||
message
|
|
||||||
code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
query FetchAppDetails {
|
|
||||||
app {
|
|
||||||
id
|
|
||||||
privateMetadata {
|
|
||||||
key
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
query FetchProductVariantMetadata($id: ID!) {
|
|
||||||
productVariant(id: $id) {
|
|
||||||
id
|
|
||||||
metadata {
|
|
||||||
key
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
query GetProduct($id: ID!) {
|
|
||||||
product(id: $id) {
|
|
||||||
slug
|
|
||||||
media {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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";
|
|
|
@ -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 |
|
@ -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 };
|
|
||||||
};
|
|
|
@ -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) || [];
|
|
||||||
};
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -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));
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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;
|
|
||||||
};
|
|
|
@ -1,3 +0,0 @@
|
||||||
export { createCmsOperations } from "./clients-operations";
|
|
||||||
export { executeCmsOperations } from "./clients-execution";
|
|
||||||
export { updateMetadata, batchUpdateMetadata } from "./metadata-execution";
|
|
|
@ -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);
|
|
||||||
};
|
|
|
@ -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;
|
|
|
@ -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";
|
|
||||||
};
|
|
|
@ -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"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -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;
|
|
||||||
};
|
|
|
@ -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>;
|
|
|
@ -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>;
|
|
|
@ -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);
|
|
||||||
};
|
|
|
@ -1,3 +0,0 @@
|
||||||
export * from "./config";
|
|
||||||
export * from "./client";
|
|
||||||
export * from "./providers";
|
|
|
@ -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);
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -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);
|
|
|
@ -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;
|
|
|
@ -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);
|
|
|
@ -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>;
|
|
||||||
};
|
|
|
@ -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 })) || [];
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
});
|
|
|
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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);
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue