Compare commits
143 commits
eam-improv
...
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 | ||
![]() |
3ae7ac6035 | ||
![]() |
f6695817a7 | ||
![]() |
5d41af93e1 | ||
![]() |
44333a6784 | ||
![]() |
70cb741f88 | ||
![]() |
790a47ee08 | ||
![]() |
3c6cd4ccec | ||
![]() |
d00ceb6793 | ||
![]() |
62104473b4 | ||
![]() |
e7c2d3a877 | ||
![]() |
5473e466f6 | ||
![]() |
4162b63a40 | ||
![]() |
be4e7d2922 | ||
![]() |
d4089ab519 | ||
![]() |
e1980aae90 | ||
![]() |
78670ce712 | ||
![]() |
bda814b7b3 | ||
![]() |
2d77bca353 | ||
![]() |
054ec48a66 | ||
![]() |
d2b21cc1ab | ||
![]() |
47102ba98c | ||
![]() |
a1f083c0bc | ||
![]() |
6299e064b6 | ||
![]() |
7c98e384fd | ||
![]() |
d34c2a1b84 | ||
![]() |
cbd763bfdd | ||
![]() |
9b65569ec3 | ||
![]() |
d42c79f366 | ||
![]() |
6250095a4e | ||
![]() |
1405deaf66 | ||
![]() |
37e50db29c | ||
![]() |
3bd7e3f05d | ||
![]() |
f6ff907cfd | ||
![]() |
8b245c6bcf | ||
![]() |
e4497b9ba4 | ||
![]() |
57fea9166e | ||
![]() |
00e49bac11 | ||
![]() |
09e07995b5 | ||
![]() |
59ff617282 | ||
![]() |
e239fbb670 | ||
![]() |
3462cc343e | ||
![]() |
82dfc3fa6f | ||
![]() |
ec68ed53a3 | ||
![]() |
8d3588c18f | ||
![]() |
a6e7fbd77f | ||
![]() |
a1ad70e879 | ||
![]() |
c4be200925 | ||
![]() |
c0d01db55d | ||
![]() |
3aafc9fe72 |
1111 changed files with 159838 additions and 56702 deletions
|
@ -1,8 +1,63 @@
|
||||||
# Changesets
|
# Changeset
|
||||||
|
|
||||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
## Writing guidelines
|
||||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
|
||||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
|
||||||
|
|
||||||
We have a quick list of common questions to get you started engaging with this project in
|
### Rules:
|
||||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
|
||||||
|
#### 1. Avoid repetition when describing bug fixes
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
> Fixed a bug where the component fired confetti uncontrollably when typing into the input. Fixed a bug when...
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
> The input no longer fires confetti on typing.
|
||||||
|
|
||||||
|
#### 2. Use more personal tone
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
> It is now possible to use the class xyz directly in…
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
> **You can now** use the class xyz directly in…
|
||||||
|
|
||||||
|
#### 3. Make it about the user, not the code
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
> Added the X and Y fields to the schema returned by Z.
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
> You can now see how many users are connected to a deployment (*X*), and the users capacity of the deployment (*Y*).
|
||||||
|
|
||||||
|
#### 4. Use a minimal amount of fluff
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
> After many long nights at the office, several cans of beer, and consuming the amount of pizza equal to the surface of a helipad, we finally managed to squash a bug that’s been haunting you forever. Its origin reaches back to the times when Tim Berners-Lee…
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
> The application no longer shuts down when attempting to abort a payment.
|
||||||
|
|
||||||
|
#### 5. Use a template when lost
|
||||||
|
|
||||||
|
If you don’t have an idea how to start, you may use some of those openings:
|
||||||
|
|
||||||
|
- “You can now…”
|
||||||
|
- “X no longer does Y when Z.”
|
||||||
|
- “X no longer does Y. This means you no longer need to Z.”
|
||||||
|
|
||||||
|
#### 6. Describe known issues
|
||||||
|
|
||||||
|
When the release introduces some issues or limitations, describe them:
|
||||||
|
|
||||||
|
> You may experience issues when trying to use the new view with an adblocker turned on. The issue will be fixed in the next release. For now, please…
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
- https://www.youtube.com/watch?v=L3yAD319DiU
|
||||||
|
- https://keepachangelog.com/en/1.0.0/
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-taxes": major
|
|
||||||
---
|
|
||||||
|
|
||||||
Redesigned the app's UI with the new version of macaw-ui. Introduced breaking changes in the structure of providers configuration and channels configuration. Added migrations that convert the obsolete configurations to the new format. Added address validation for tax providers.
|
|
|
@ -1,15 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-emails-and-messages": minor
|
|
||||||
"saleor-app-data-importer": minor
|
|
||||||
"saleor-app-products-feed": minor
|
|
||||||
"saleor-app-monitoring": minor
|
|
||||||
"saleor-app-invoices": minor
|
|
||||||
"saleor-app-klaviyo": minor
|
|
||||||
"saleor-app-search": minor
|
|
||||||
"saleor-app-slack": minor
|
|
||||||
"saleor-app-taxes": minor
|
|
||||||
"saleor-app-cms": minor
|
|
||||||
"saleor-app-crm": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
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.
|
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
"@saleor/react-hook-form-macaw": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
Added a binding for the macaw-ui's Toggle component.
|
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
"@saleor/apps-ui": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Fixed a missing text-decoration on a breadcrumb link.
|
|
|
@ -1,17 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-emails-and-messages": patch
|
|
||||||
"saleor-app-data-importer": patch
|
|
||||||
"saleor-app-products-feed": patch
|
|
||||||
"saleor-app-monitoring": patch
|
|
||||||
"@saleor/apps-shared": patch
|
|
||||||
"saleor-app-invoices": patch
|
|
||||||
"saleor-app-klaviyo": patch
|
|
||||||
"saleor-app-search": patch
|
|
||||||
"@saleor/apps-ui": patch
|
|
||||||
"saleor-app-slack": patch
|
|
||||||
"saleor-app-taxes": patch
|
|
||||||
"saleor-app-cms": patch
|
|
||||||
"saleor-app-crm": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Updated @saleor/app-sdk to 0.40.1
|
|
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,19 +0,0 @@
|
||||||
---
|
|
||||||
"@saleor/react-hook-form-macaw": patch
|
|
||||||
"eslint-config-saleor": patch
|
|
||||||
"saleor-app-emails-and-messages": patch
|
|
||||||
"saleor-app-data-importer": patch
|
|
||||||
"saleor-app-products-feed": patch
|
|
||||||
"saleor-app-monitoring": patch
|
|
||||||
"@saleor/apps-shared": patch
|
|
||||||
"saleor-app-invoices": patch
|
|
||||||
"saleor-app-klaviyo": patch
|
|
||||||
"saleor-app-search": patch
|
|
||||||
"@saleor/apps-ui": patch
|
|
||||||
"saleor-app-slack": patch
|
|
||||||
"saleor-app-taxes": patch
|
|
||||||
"saleor-app-cms": patch
|
|
||||||
"saleor-app-crm": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Updated dependencies
|
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-search": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Regression - Fixed Algolia "index prefix" field to be optional, just like it was before the refcator.
|
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-emails-and-messages": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Events section UI has been updated. All events are displayed now as single table.
|
|
13
.github/PULL_REQUEST_TEMPLATE.md
vendored
13
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
@ -1,2 +1,11 @@
|
||||||
# Impact
|
## Scope of the PR
|
||||||
* [ ] .github/dependabot.yaml is up-to date.
|
|
||||||
|
<!-- Describe briefly changed made in this PR -->
|
||||||
|
|
||||||
|
## Related issues
|
||||||
|
|
||||||
|
<!-- If any, mention issues that are connected with this PR -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] I added changesets and [read good practices](/.changeset/README.md).
|
||||||
|
|
103
.github/dependabot.yaml
vendored
103
.github/dependabot.yaml
vendored
|
@ -2,95 +2,18 @@ version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "daily"
|
||||||
|
commit-message:
|
||||||
|
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: 0
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/packages/shared"
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/packages/ui"
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/packages/react-hook-form-macaw"
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
|
|
||||||
# Apps
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: "/apps/cms"
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/crm
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/data-importer
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/emails-and-messages
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/invoices
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/klaviyo
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/monitoring
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "pip"
|
|
||||||
directory: apps/monitoring/backend
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "docker"
|
|
||||||
directory: apps/monitoring/backend
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/products-feed
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/search
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
directory: apps/slack
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
- package-ecosystem: "npm"
|
|
||||||
open-pull-requests-limit: 0
|
|
||||||
directory: apps/taxes
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
|
|
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
|
@ -1,11 +1,11 @@
|
||||||
"App: Data Importer": "apps/data-importer/**/*"
|
"App: Data Importer": "apps/data-importer/**/*"
|
||||||
"App: Invoices": "apps/invoices/**/*"
|
"App: Invoices": "apps/invoices/**/*"
|
||||||
"App: Klaviyo": "apps/klaviyo/**/*"
|
"App: Klaviyo": "apps/klaviyo/**/*"
|
||||||
"App: Monitoring": "apps/monitoring/**/*"
|
|
||||||
"App: Product Feed": "apps/products-feed/**/*"
|
"App: Product Feed": "apps/products-feed/**/*"
|
||||||
"App: Search": "apps/search/**/*"
|
"App: Search": "apps/search/**/*"
|
||||||
"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/**/*"
|
"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"
|
||||||
|
|
|
@ -1,81 +0,0 @@
|
||||||
name: Publish image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- apps/monitoring/backend/**
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
env:
|
|
||||||
AWS_REGION: eu-west-1
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v2
|
|
||||||
with:
|
|
||||||
platforms: all
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
with:
|
|
||||||
install: true
|
|
||||||
|
|
||||||
- name: Configure AWS credentials
|
|
||||||
uses: aws-actions/configure-aws-credentials@v2
|
|
||||||
with:
|
|
||||||
aws-access-key-id: ${{ secrets.AWS_APPS_STAGING_ACCESS_KEY_ID }}
|
|
||||||
aws-secret-access-key: ${{ secrets.AWS_APPS_STAGING_SECRET_ACCESS_KEY }}
|
|
||||||
aws-region: ${{ env.AWS_REGION }}
|
|
||||||
|
|
||||||
- id: ecr-login
|
|
||||||
name: Login to Amazon ECR
|
|
||||||
uses: aws-actions/amazon-ecr-login@v1
|
|
||||||
with:
|
|
||||||
registries: ${{ secrets.AWS_ECR_ACCOUNT }}
|
|
||||||
|
|
||||||
- name: Evaluate image tags
|
|
||||||
run: |
|
|
||||||
IMAGE_REPOSITORY=${{ steps.ecr-login.outputs.registry }}/${{ secrets.ECR_REPOSITORY }}
|
|
||||||
BRANCH_IMAGE_TAG=${{ github.ref_name }}
|
|
||||||
UNIQUE_IMAGE_TAG=${BRANCH_IMAGE_TAG}-$(git rev-parse --short HEAD)
|
|
||||||
|
|
||||||
IMAGE_TAGS=${IMAGE_REPOSITORY}:${BRANCH_IMAGE_TAG},${IMAGE_REPOSITORY}:${UNIQUE_IMAGE_TAG}
|
|
||||||
|
|
||||||
echo "UNIQUE_IMAGE_TAG=${UNIQUE_IMAGE_TAG}" >> $GITHUB_ENV
|
|
||||||
echo "IMAGE_TAGS=${IMAGE_TAGS}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
timeout-minutes: 20
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
context: ./apps/monitoring/backend
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: ${{ env.IMAGE_TAGS }}
|
|
||||||
target: prod
|
|
||||||
cache-from: type=gha,scope=buildkit-master
|
|
||||||
cache-to: type=gha,scope=buildkit-master
|
|
||||||
|
|
||||||
# - name: Configure GitHub credentials
|
|
||||||
# run: |
|
|
||||||
# GITHUB_TOKEN=$( \
|
|
||||||
# curl --request GET --url ${{ secrets.VAULT_URL}} --header "Authorization: JWT ${{ secrets.VAULT_JWT }}" | jq -r .token \
|
|
||||||
# )
|
|
||||||
# echo "GITHUB_TOKEN=${GITHUB_TOKEN}" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
# - name: Trigger Helm deployment
|
|
||||||
# run: |
|
|
||||||
# gh api /repos/saleor/saleor-cloud-deployments/dispatches \
|
|
||||||
# --input - <<< '{
|
|
||||||
# "event_type": "deploy-app-monitoring-staging",
|
|
||||||
# "client_payload": {
|
|
||||||
# "image_tag": "${{ env.UNIQUE_IMAGE_TAG }}"
|
|
||||||
# }
|
|
||||||
# }'
|
|
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
|
||||||
|
|
33
.github/workflows/unit-tests-monitoring.yml
vendored
33
.github/workflows/unit-tests-monitoring.yml
vendored
|
@ -1,33 +0,0 @@
|
||||||
name: "App: Monitoring backend tests"
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "apps/monitoring/backend/**"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
unit_test:
|
|
||||||
name: Unit tests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: apps/monitoring/backend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Install poetry
|
|
||||||
run: pipx install poetry
|
|
||||||
- name: Setup python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: "3.10"
|
|
||||||
cache: poetry
|
|
||||||
- name: Install dependencies
|
|
||||||
run: poetry install
|
|
||||||
- name: Run unit tests
|
|
||||||
run: poetry run pytest
|
|
||||||
- name: Run black
|
|
||||||
run: poetry run black .
|
|
||||||
- name: Run ruff
|
|
||||||
run: poetry run ruff .
|
|
||||||
- name: Run mypy
|
|
||||||
run: poetry run mypy .
|
|
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
|
||||||
|
|
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -40,4 +40,7 @@ coverage/
|
||||||
apps/**/generated
|
apps/**/generated
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
.sentryclirc
|
.sentryclirc
|
||||||
|
|
||||||
|
.vscode/
|
||||||
|
.cspellcache
|
|
@ -1 +1 @@
|
||||||
* @saleor/appstore
|
* @saleor/delivery-engineering-js
|
||||||
|
|
20
README.md
20
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">
|
||||||
|
@ -26,6 +28,13 @@
|
||||||
<a href="https://github.com/saleor/apps/discussions/categories/integrations-features">✍️ Propose an app</a>
|
<a href="https://github.com/saleor/apps/discussions/categories/integrations-features">✍️ Propose an app</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://discord.gg/H52JTZAtSH)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This repository serves as a starting point in the exploration of Saleor apps.
|
This repository serves as a starting point in the exploration of Saleor apps.
|
||||||
|
@ -39,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
|
||||||
|
@ -50,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).
|
||||||
|
|
18
apps/cms-v2/.env.example
Normal file
18
apps/cms-v2/.env.example
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# The key used for metadata encryption. Required for production builds
|
||||||
|
SECRET_KEY=
|
||||||
|
|
||||||
|
# APL Config
|
||||||
|
# https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md
|
||||||
|
APL=file
|
||||||
|
REST_APL_ENDPOINT=
|
||||||
|
REST_APL_TOKEN=
|
||||||
|
|
||||||
|
APP_LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Local development variables. When developped locally with Saleor inside docker, these can be set to:
|
||||||
|
# APP_IFRAME_BASE_URL = http://localhost:3000, so Dashboard on host can access iframe
|
||||||
|
# APP_API_BASE_URL=http://host.docker.internal:3000 - so Saleor can reach App running on host, from the container.
|
||||||
|
# If developped with tunnels, set this empty, it will fallback to default Next's localhost:3000
|
||||||
|
# https://docs.saleor.io/docs/3.x/developer/extending/apps/local-app-development
|
||||||
|
APP_IFRAME_BASE_URL=
|
||||||
|
APP_API_BASE_URL=
|
|
@ -10,7 +10,6 @@ extensions:
|
||||||
plugins:
|
plugins:
|
||||||
- typescript
|
- typescript
|
||||||
- typescript-operations
|
- typescript-operations
|
||||||
- urql-introspection
|
|
||||||
- typescript-urql:
|
- typescript-urql:
|
||||||
documentVariablePrefix: "Untyped"
|
documentVariablePrefix: "Untyped"
|
||||||
fragmentVariablePrefix: "Untyped"
|
fragmentVariablePrefix: "Untyped"
|
121
apps/cms-v2/CHANGELOG.md
Normal file
121
apps/cms-v2/CHANGELOG.md
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 5d41af9: Added validation for channel-provider connection. Now form will display error when user tries to add a connection that already exists.
|
||||||
|
- 5d41af9: Added set of improvements around app quality
|
||||||
|
|
||||||
|
- Ensured forms have fields properly set as "required", so form validation will prevent empty form submissions
|
||||||
|
- Contentful and DatoCMS forms now validate the credentials.
|
||||||
|
- Added logs (server side) in various places
|
||||||
|
- Bulk sync finished now triggers notification
|
||||||
|
|
||||||
|
- 5d41af9: Added skeletons instead raw "Loading" texts. Also removed unnecessary warning message in bulk sync section
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [70cb741]
|
||||||
|
- Updated dependencies [e7c2d3a]
|
||||||
|
- Updated dependencies [3c6cd4c]
|
||||||
|
- Updated dependencies [6210447]
|
||||||
|
- @saleor/react-hook-form-macaw@0.2.1
|
||||||
|
- @saleor/apps-shared@1.7.4
|
||||||
|
- @saleor/apps-ui@1.1.4
|
19
apps/cms-v2/graphql/fragments/BulkImportProduct.graphql
Normal file
19
apps/cms-v2/graphql/fragments/BulkImportProduct.graphql
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
fragment BulkImportProduct on Product {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
variants {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
channelListings {
|
||||||
|
channel {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
price {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,23 +2,17 @@ fragment WebhookProduct on Product {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
slug
|
slug
|
||||||
media {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
channelListings {
|
channelListings {
|
||||||
id
|
id
|
||||||
channel {
|
channel {
|
||||||
id
|
id
|
||||||
slug
|
slug
|
||||||
}
|
}
|
||||||
isPublished
|
|
||||||
}
|
}
|
||||||
variants {
|
variants {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
sku
|
|
||||||
channelListings {
|
channelListings {
|
||||||
id
|
|
||||||
channel {
|
channel {
|
||||||
id
|
id
|
||||||
slug
|
slug
|
||||||
|
@ -28,9 +22,5 @@ fragment WebhookProduct on Product {
|
||||||
currency
|
currency
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
metadata {
|
|
||||||
key
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,25 +1,12 @@
|
||||||
fragment WebhookProductVariant on ProductVariant {
|
fragment WebhookProductVariant on ProductVariant {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
sku
|
|
||||||
product {
|
product {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
slug
|
slug
|
||||||
media {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
channelListings {
|
|
||||||
id
|
|
||||||
channel {
|
|
||||||
id
|
|
||||||
slug
|
|
||||||
}
|
|
||||||
isPublished
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
channelListings {
|
channelListings {
|
||||||
id
|
|
||||||
channel {
|
channel {
|
||||||
id
|
id
|
||||||
slug
|
slug
|
||||||
|
@ -29,8 +16,4 @@ fragment WebhookProductVariant on ProductVariant {
|
||||||
currency
|
currency
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
metadata {
|
|
||||||
key
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
}
|
7
apps/cms-v2/graphql/queries/FetchChannels.graphql
Normal file
7
apps/cms-v2/graphql/queries/FetchChannels.graphql
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
query FetchChannels {
|
||||||
|
channels {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
13
apps/cms-v2/graphql/queries/FetchProductsPaginated.graphql
Normal file
13
apps/cms-v2/graphql/queries/FetchProductsPaginated.graphql
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
query FetchProductsPaginated($channel: String, $after: String) {
|
||||||
|
products(first: 100, channel: $channel, after: $after) {
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
...BulkImportProduct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
26597
apps/cms-v2/graphql/schema.graphql
Normal file
26597
apps/cms-v2/graphql/schema.graphql
Normal file
File diff suppressed because it is too large
Load diff
43
apps/cms-v2/next.config.js
Normal file
43
apps/cms-v2/next.config.js
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
const { z } = require("zod");
|
||||||
|
const { withSentryConfig } = require("@sentry/nextjs");
|
||||||
|
|
||||||
|
const RequiredEnvs = z.object({
|
||||||
|
APL: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = () => {
|
||||||
|
try {
|
||||||
|
RequiredEnvs.parse(process.env);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("🚫 Missing required env variables, see message below");
|
||||||
|
console.error(e.issues);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reactStrictMode: true,
|
||||||
|
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSentryPropertiesInEnvironment =
|
||||||
|
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG;
|
||||||
|
|
||||||
|
const configWithSentry = withSentryConfig(
|
||||||
|
nextConfig,
|
||||||
|
{
|
||||||
|
silent: true,
|
||||||
|
org: process.env.SENTRY_ORG,
|
||||||
|
project: process.env.SENTRY_PROJECT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
widenClientFileUpload: true,
|
||||||
|
transpileClientSDK: true,
|
||||||
|
tunnelRoute: "/monitoring",
|
||||||
|
hideSourceMaps: true,
|
||||||
|
disableLogger: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = isSentryPropertiesInEnvironment ? configWithSentry : nextConfig;
|
72
apps/cms-v2/package.json
Normal file
72
apps/cms-v2/package.json
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
{
|
||||||
|
"name": "saleor-app-cms-v2",
|
||||||
|
"version": "2.3.3",
|
||||||
|
"scripts": {
|
||||||
|
"build": "pnpm generate && next build",
|
||||||
|
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
||||||
|
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
|
||||||
|
"generate": "graphql-codegen",
|
||||||
|
"lint": "next lint",
|
||||||
|
"lint:fix": "eslint --fix .",
|
||||||
|
"start": "next start",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@datocms/cma-client-browser": "2.0.0",
|
||||||
|
"@hookform/resolvers": "^3.3.1",
|
||||||
|
"@saleor/app-sdk": "0.43.1",
|
||||||
|
"@saleor/apps-shared": "workspace:*",
|
||||||
|
"@saleor/apps-ui": "workspace:*",
|
||||||
|
"@saleor/macaw-ui": "0.8.0-pre.127",
|
||||||
|
"@saleor/react-hook-form-macaw": "workspace:*",
|
||||||
|
"@sentry/nextjs": "7.67.0",
|
||||||
|
"@tanstack/react-query": "^4.29.19",
|
||||||
|
"@trpc/client": "10.38.1",
|
||||||
|
"@trpc/next": "10.38.1",
|
||||||
|
"@trpc/react-query": "10.38.1",
|
||||||
|
"@trpc/server": "10.38.1",
|
||||||
|
"@urql/exchange-auth": "^2.1.4",
|
||||||
|
"@vitejs/plugin-react": "4.0.4",
|
||||||
|
"contentful-management": "10.38.3",
|
||||||
|
"graphql": "16.7.1",
|
||||||
|
"graphql-tag": "^2.12.6",
|
||||||
|
"jsdom": "^20.0.3",
|
||||||
|
"next": "13.4.8",
|
||||||
|
"p-ratelimit": "1.0.1",
|
||||||
|
"pino": "^8.14.1",
|
||||||
|
"pino-pretty": "^10.0.0",
|
||||||
|
"qs": "6.11.2",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"react-error-boundary": "4.0.10",
|
||||||
|
"react-hook-form": "^7.43.9",
|
||||||
|
"strapi-sdk-js": "2.2.0",
|
||||||
|
"urql": "^4.0.4",
|
||||||
|
"usehooks-ts": "^2.9.1",
|
||||||
|
"vite": "4.4.8",
|
||||||
|
"vitest": "0.34.1",
|
||||||
|
"zod": "3.21.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@graphql-codegen/cli": "5.0.0",
|
||||||
|
"@graphql-codegen/introspection": "4.0.0",
|
||||||
|
"@graphql-codegen/typed-document-node": "5.0.1",
|
||||||
|
"@graphql-codegen/typescript": "4.0.1",
|
||||||
|
"@graphql-codegen/typescript-operations": "4.0.1",
|
||||||
|
"@graphql-codegen/typescript-urql": "3.7.3",
|
||||||
|
"@graphql-typed-document-node/core": "3.2.0",
|
||||||
|
"@testing-library/react": "^14.0.0",
|
||||||
|
"@testing-library/react-hooks": "^8.0.1",
|
||||||
|
"@types/qs": "^6.9.7",
|
||||||
|
"@types/react": "18.2.5",
|
||||||
|
"@types/react-dom": "18.2.5",
|
||||||
|
"eslint": "8.46.0",
|
||||||
|
"eslint-config-saleor": "workspace:*",
|
||||||
|
"node-mocks-http": "^1.12.2",
|
||||||
|
"typescript": "5.1.6"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"saleor": {
|
||||||
|
"schemaVersion": "3.10"
|
||||||
|
}
|
||||||
|
}
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
37
apps/cms-v2/sentry.client.config.ts
Normal file
37
apps/cms-v2/sentry.client.config.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* This file configures the initialization of Sentry on the client.
|
||||||
|
* The config you add here will be used whenever a users loads a page in their browser.
|
||||||
|
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import pkg from "./package.json";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 0.5,
|
||||||
|
|
||||||
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
|
debug: false,
|
||||||
|
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This sets the sample rate to be 10%. You may want this to be 100% while
|
||||||
|
* in development and sample at a lower rate in production
|
||||||
|
*/
|
||||||
|
replaysSessionSampleRate: 0.1,
|
||||||
|
|
||||||
|
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
|
||||||
|
integrations: [
|
||||||
|
new Sentry.Replay({
|
||||||
|
// Additional Replay configuration goes in here, for example:
|
||||||
|
maskAllText: true,
|
||||||
|
blockAllMedia: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
environment: process.env.SENTRY_ENVIRONMENT,
|
||||||
|
release: `${pkg.name}@${pkg.version}`,
|
||||||
|
});
|
21
apps/cms-v2/sentry.edge.config.ts
Normal file
21
apps/cms-v2/sentry.edge.config.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
||||||
|
* The config you add here will be used whenever one of the edge features is loaded.
|
||||||
|
* Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||||
|
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import pkg from "./package.json";
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
|
||||||
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
|
tracesSampleRate: 0.5,
|
||||||
|
|
||||||
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
|
debug: false,
|
||||||
|
environment: process.env.SENTRY_ENVIRONMENT,
|
||||||
|
release: `${pkg.name}@${pkg.version}`,
|
||||||
|
});
|
|
@ -5,17 +5,16 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import pkg from "./package.json";
|
||||||
const SENTRY_DSN = process.env.SENTRY_DSN
|
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: SENTRY_DSN,
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
|
|
||||||
// Adjust this value in production, or use tracesSampler for greater control
|
// Adjust this value in production, or use tracesSampler for greater control
|
||||||
tracesSampleRate: 1.0,
|
tracesSampleRate: 0.5,
|
||||||
/*
|
|
||||||
* ...
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
* Note: if you want to override the automatic release value, do not set a
|
debug: false,
|
||||||
* `release` value here - use the environment variable `SENTRY_RELEASE`, so
|
environment: process.env.SENTRY_ENVIRONMENT,
|
||||||
* that it will also get attached to your source maps
|
release: `${pkg.name}@${pkg.version}`,
|
||||||
*/
|
|
||||||
});
|
});
|
14
apps/cms-v2/src/modules/bulk-sync/bulk-sync-processor.ts
Normal file
14
apps/cms-v2/src/modules/bulk-sync/bulk-sync-processor.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { BulkImportProductFragment } from "../../../generated/graphql";
|
||||||
|
|
||||||
|
export type BulkSyncProcessorHooks = {
|
||||||
|
onUploadStart?: (context: { variantId: string }) => void;
|
||||||
|
onUploadSuccess?: (context: { variantId: string }) => void;
|
||||||
|
onUploadError?: (context: { variantId: string; error: Error }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BulkSyncProcessor {
|
||||||
|
uploadProducts(
|
||||||
|
products: BulkImportProductFragment[],
|
||||||
|
hooks: BulkSyncProcessorHooks
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
87
apps/cms-v2/src/modules/bulk-sync/bulk-sync-section.tsx
Normal file
87
apps/cms-v2/src/modules/bulk-sync/bulk-sync-section.tsx
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { trpcClient } from "../trpc/trpc-client";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Select } from "@saleor/react-hook-form-macaw";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ProvidersResolver } from "../providers/providers-resolver";
|
||||||
|
import { ButtonsBox, Layout, SkeletonLayout } from "@saleor/apps-ui";
|
||||||
|
|
||||||
|
const FormSchema = z.object({
|
||||||
|
connectionId: z.string().min(7),
|
||||||
|
});
|
||||||
|
|
||||||
|
const EmptyState = () => (
|
||||||
|
<Layout.AppSectionCard>
|
||||||
|
<Box display="flex" flexDirection={"column"} gap={4} justifyContent={"center"}>
|
||||||
|
<Text variant="heading">Bulk products synchronization</Text>
|
||||||
|
<Text>Create a channel connection above to enable bulk synchronization.</Text>
|
||||||
|
</Box>
|
||||||
|
</Layout.AppSectionCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const BulkSyncSection = () => {
|
||||||
|
const { push } = useRouter();
|
||||||
|
|
||||||
|
const { data: connections } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
|
||||||
|
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||||
|
|
||||||
|
const { control, handleSubmit } = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
connectionId: "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(FormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!connections || !providers) {
|
||||||
|
return <SkeletonLayout.Section />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connections.length === 0) {
|
||||||
|
return <EmptyState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.AppSectionCard>
|
||||||
|
<Text as="h2" marginBottom={6} variant="heading">
|
||||||
|
Bulk products synchronization
|
||||||
|
</Text>
|
||||||
|
<Text as="p">
|
||||||
|
Choose a connection and start synchronization. Process is running in the browser.
|
||||||
|
</Text>
|
||||||
|
<Text as="p" variant="bodyStrong">
|
||||||
|
Do not close the app until it is finished
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
display="grid"
|
||||||
|
gap={4}
|
||||||
|
marginTop={4}
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit((values) => {
|
||||||
|
push(`/bulk-sync/${values.connectionId}`);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
required
|
||||||
|
control={control}
|
||||||
|
name="connectionId"
|
||||||
|
label="Connection"
|
||||||
|
options={connections.map((c) => {
|
||||||
|
const provider = providers.find((p) => p.id === c.providerId)!;
|
||||||
|
const providerDisplay = ProvidersResolver.createProviderMeta(provider.type);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `${c.channelSlug} -> ${provider?.configName} (${providerDisplay.displayName})`,
|
||||||
|
value: c.id,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<ButtonsBox>
|
||||||
|
<Button type="submit">Start sync</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
</Box>
|
||||||
|
</Layout.AppSectionCard>
|
||||||
|
);
|
||||||
|
};
|
184
apps/cms-v2/src/modules/bulk-sync/bulk-sync-view.tsx
Normal file
184
apps/cms-v2/src/modules/bulk-sync/bulk-sync-view.tsx
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
import { Breadcrumbs, ButtonsBox, Layout } from "@saleor/apps-ui";
|
||||||
|
import { ArrowRightIcon, Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { ChannelProviderConnectionConfig, ProvidersConfig } from "../configuration";
|
||||||
|
import { AppHeader } from "../ui/app-header";
|
||||||
|
|
||||||
|
import { useBulkSyncProductsState } from "./use-bulk-sync-products-state";
|
||||||
|
import { useFetchAllProducts } from "./use-fetch-all-products";
|
||||||
|
import { VariantsSyncStatusList } from "./variants-sync-status-list";
|
||||||
|
import { ProvidersResolver } from "../providers/providers-resolver";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
|
||||||
|
const FetchProductsStep = (props: { onButtonClick(): void }) => {
|
||||||
|
return (
|
||||||
|
<Layout.AppSectionCard
|
||||||
|
footer={
|
||||||
|
<ButtonsBox>
|
||||||
|
<Button onClick={props.onButtonClick}>Prefetch products</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text variant="heading" as="h2" marginBottom={4}>
|
||||||
|
Saleor products fetch
|
||||||
|
</Text>
|
||||||
|
<Text as="p">Click the button to start fetching products from Saleor API</Text>
|
||||||
|
<Text as="p">After products are fetched, you will be able to upload them to the CMS</Text>
|
||||||
|
</Layout.AppSectionCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SaleorProductsFetchingStep = () => {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text variant="heading" as="h2" marginBottom={4}>
|
||||||
|
Saleor products fetch
|
||||||
|
</Text>
|
||||||
|
<Text as="p">Fetching...</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SaleorProductsFetchedStep = (props: { productsNo: number; variantsNo: number }) => {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text variant="heading" as="h2" marginBottom={4}>
|
||||||
|
Saleor products fetch
|
||||||
|
</Text>
|
||||||
|
<Text as="p">
|
||||||
|
Fetched {props.productsNo} products and {props.variantsNo} variants
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type Status = "initial" | "fetching" | "fetched" | "uploading";
|
||||||
|
|
||||||
|
export const BulkSyncView = ({
|
||||||
|
configuration,
|
||||||
|
connection,
|
||||||
|
}: {
|
||||||
|
configuration: ProvidersConfig.AnyFullShape;
|
||||||
|
connection: ChannelProviderConnectionConfig.FullShape;
|
||||||
|
}) => {
|
||||||
|
const processor = useRef(ProvidersResolver.createBulkSyncProcessor(configuration));
|
||||||
|
const [state, setState] = useState<Status>("initial");
|
||||||
|
const { notifySuccess } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { products, finished: saleorProductsFetchFinished } = useFetchAllProducts(
|
||||||
|
state === "fetching",
|
||||||
|
connection.channelSlug,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { productsStatusList, setInitialProducts, setItemStatus, finished } =
|
||||||
|
useBulkSyncProductsState();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (finished) {
|
||||||
|
notifySuccess("Bulk sync ended", "All products have been synced, please verify results");
|
||||||
|
}
|
||||||
|
}, [finished, notifySuccess]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!saleorProductsFetchFinished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitialProducts(products);
|
||||||
|
setState("fetched");
|
||||||
|
}, [products, saleorProductsFetchFinished, setInitialProducts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== "uploading") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
processor.current.uploadProducts(products, {
|
||||||
|
onUploadStart({ variantId }) {
|
||||||
|
setItemStatus(variantId, "uploading");
|
||||||
|
},
|
||||||
|
onUploadSuccess({ variantId }) {
|
||||||
|
setItemStatus(variantId, "success");
|
||||||
|
},
|
||||||
|
onUploadError({ error, variantId }) {
|
||||||
|
// User will be notified about the error in the UI
|
||||||
|
setItemStatus(variantId, "error");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [state, products, configuration, setItemStatus]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<AppHeader
|
||||||
|
text="Perform bulk products sync between Saleor and your CMS"
|
||||||
|
breadcrumbs={[
|
||||||
|
<Breadcrumbs.Item key="bulk-sync">Bulk Sync</Breadcrumbs.Item>,
|
||||||
|
<Breadcrumbs.Item key="connection-name">
|
||||||
|
<Box display="flex" gap={2} alignItems="center">
|
||||||
|
{connection.channelSlug}
|
||||||
|
<ArrowRightIcon /> {configuration.configName}
|
||||||
|
</Box>
|
||||||
|
</Breadcrumbs.Item>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Layout.AppSection
|
||||||
|
marginBottom={8}
|
||||||
|
heading="1. Fetch products"
|
||||||
|
sideContent={
|
||||||
|
<Text>First pre-fetch all Product Variants from Saleor. Do not close the app.</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
switch (state) {
|
||||||
|
case "initial": {
|
||||||
|
return (
|
||||||
|
<FetchProductsStep
|
||||||
|
onButtonClick={() => {
|
||||||
|
setState("fetching");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "fetching": {
|
||||||
|
return <SaleorProductsFetchingStep />;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "fetched":
|
||||||
|
case "uploading": {
|
||||||
|
return (
|
||||||
|
<SaleorProductsFetchedStep
|
||||||
|
productsNo={products.length}
|
||||||
|
variantsNo={productsStatusList?.length ?? 0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</Layout.AppSection>
|
||||||
|
|
||||||
|
{(state === "fetched" || state === "uploading") && productsStatusList && (
|
||||||
|
<Layout.AppSection
|
||||||
|
marginTop={14}
|
||||||
|
heading="2. Upload to the CMS"
|
||||||
|
sideContent={<Text>Send listed variants to the CMS</Text>}
|
||||||
|
>
|
||||||
|
<Layout.AppSectionCard>
|
||||||
|
<Text as="h2" marginBottom={4} variant="heading">
|
||||||
|
Upload products
|
||||||
|
</Text>
|
||||||
|
{state === "fetched" && (
|
||||||
|
<Box marginBottom={4}>
|
||||||
|
<Text as="p" marginBottom={2}>
|
||||||
|
Verify products below and click the button to start uploading.
|
||||||
|
</Text>
|
||||||
|
<Button onClick={() => setState("uploading")}>Start uploading</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<VariantsSyncStatusList marginTop={8} variants={productsStatusList} />
|
||||||
|
</Layout.AppSectionCard>
|
||||||
|
</Layout.AppSection>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { BulkImportProductFragment } from "../../../generated/graphql";
|
||||||
|
import { VariantsSyncStatusListItem } from "./variants-sync-status-list";
|
||||||
|
|
||||||
|
export const useBulkSyncProductsState = () => {
|
||||||
|
const [finished, setFinished] = useState(false);
|
||||||
|
const [productsStatusList, setProductsStatusList] = useState<VariantsSyncStatusListItem[] | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (productsStatusList?.every((item) => item.status === "success" || item.status === "error")) {
|
||||||
|
setFinished(true);
|
||||||
|
}
|
||||||
|
}, [productsStatusList]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
finished,
|
||||||
|
productsStatusList,
|
||||||
|
setInitialProducts: useCallback((products: BulkImportProductFragment[]) => {
|
||||||
|
setProductsStatusList(
|
||||||
|
products.flatMap((p) => {
|
||||||
|
const items: VariantsSyncStatusListItem[] =
|
||||||
|
p.variants?.map((v) => ({
|
||||||
|
productID: p.id,
|
||||||
|
productName: p.name,
|
||||||
|
status: "pending",
|
||||||
|
variantId: v.id,
|
||||||
|
variantName: v.name,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
return items;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, []),
|
||||||
|
setItemStatus: useCallback(
|
||||||
|
(variantID: string, status: VariantsSyncStatusListItem["status"]) => {
|
||||||
|
setProductsStatusList((items) =>
|
||||||
|
items!.map((item) => {
|
||||||
|
if (item.variantId === variantID) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
status: status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
65
apps/cms-v2/src/modules/bulk-sync/use-fetch-all-products.tsx
Normal file
65
apps/cms-v2/src/modules/bulk-sync/use-fetch-all-products.tsx
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { createGraphQLClient } from "@saleor/apps-shared";
|
||||||
|
import {
|
||||||
|
BulkImportProductFragment,
|
||||||
|
FetchProductsPaginatedDocument,
|
||||||
|
} from "../../../generated/graphql";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Original source - apps/search
|
||||||
|
*/
|
||||||
|
export const useFetchAllProducts = (started: boolean, channelSlug: string) => {
|
||||||
|
const { appBridgeState } = useAppBridge();
|
||||||
|
const saleorApiUrl = appBridgeState?.saleorApiUrl!;
|
||||||
|
|
||||||
|
const [products, setProducts] = useState<BulkImportProductFragment[]>([]);
|
||||||
|
const [finished, setFinished] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appBridgeState?.token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = appBridgeState.token;
|
||||||
|
const client = createGraphQLClient({ saleorApiUrl, token });
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProducts = async (cursor?: string): Promise<void> => {
|
||||||
|
const response = await client
|
||||||
|
.query(FetchProductsPaginatedDocument, {
|
||||||
|
after: cursor,
|
||||||
|
channel: channelSlug,
|
||||||
|
})
|
||||||
|
.toPromise();
|
||||||
|
|
||||||
|
const newProducts = response?.data?.products?.edges.map((e) => e.node) ?? [];
|
||||||
|
|
||||||
|
if (newProducts.length > 0) {
|
||||||
|
setProducts((ps) => [...ps, ...newProducts]);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
response?.data?.products?.pageInfo.hasNextPage &&
|
||||||
|
response?.data?.products?.pageInfo.endCursor
|
||||||
|
) {
|
||||||
|
return getProducts(response.data.products?.pageInfo.endCursor);
|
||||||
|
} else {
|
||||||
|
setFinished(true);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getProducts(undefined);
|
||||||
|
}, [appBridgeState?.token, saleorApiUrl, started, channelSlug]);
|
||||||
|
|
||||||
|
return { products, finished };
|
||||||
|
};
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { SemanticChip } from "@saleor/apps-ui";
|
||||||
|
import { Box, BoxProps, Text } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
export type VariantsSyncStatusListItem = {
|
||||||
|
productID: string;
|
||||||
|
productName: string;
|
||||||
|
variantName: string;
|
||||||
|
variantId: string;
|
||||||
|
status: "pending" | "uploading" | "success" | "error";
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
variants: Array<VariantsSyncStatusListItem>;
|
||||||
|
} & BoxProps;
|
||||||
|
|
||||||
|
const Row = (props: BoxProps) => (
|
||||||
|
<Box
|
||||||
|
padding={2}
|
||||||
|
display="grid"
|
||||||
|
__gridTemplateColumns={"2fr 2fr 1fr"}
|
||||||
|
gap={4}
|
||||||
|
alignItems="center"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const VariantsSyncStatusList = ({ variants, ...props }: Props) => {
|
||||||
|
const renderStatus = (status: VariantsSyncStatusListItem["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "pending": {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
case "success": {
|
||||||
|
return <SemanticChip variant="success">Uploaded</SemanticChip>;
|
||||||
|
}
|
||||||
|
case "error": {
|
||||||
|
return <SemanticChip variant="error">Error</SemanticChip>;
|
||||||
|
}
|
||||||
|
case "uploading": {
|
||||||
|
return <SemanticChip variant="default">Uploading</SemanticChip>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box {...props}>
|
||||||
|
<Row>
|
||||||
|
<Text variant="caption">Product</Text>
|
||||||
|
<Text variant="caption">Variant</Text>
|
||||||
|
<Text variant="caption">Status</Text>
|
||||||
|
</Row>
|
||||||
|
{variants.map((variant) => (
|
||||||
|
<Row key={variant.variantId}>
|
||||||
|
<Box>
|
||||||
|
<Text variant="bodyStrong" as="p">
|
||||||
|
{variant.productName}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" as="p">
|
||||||
|
{variant.productID}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text variant="bodyStrong" as="p">
|
||||||
|
{variant.variantName}
|
||||||
|
</Text>
|
||||||
|
<Text size="small" as="p">
|
||||||
|
{variant.variantId}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>{renderStatus(variant.status)}</Box>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ArrowRightIcon, Box, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { Select } from "@saleor/react-hook-form-macaw";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { ChannelProviderConnectionConfig } from "../configuration";
|
||||||
|
import { trpcClient } from "../trpc/trpc-client";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
export type AddConnectionFormSchema = Omit<
|
||||||
|
ChannelProviderConnectionConfig.InputShape,
|
||||||
|
"providerType"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const AddConnectionFormID = "new-connection-form";
|
||||||
|
|
||||||
|
export const AddConnectionForm = (props: {
|
||||||
|
defaultValues: AddConnectionFormSchema;
|
||||||
|
onSubmit(values: AddConnectionFormSchema): Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const { data: channels } = trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
|
||||||
|
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
setError,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm({
|
||||||
|
defaultValues: props.defaultValues,
|
||||||
|
resolver: zodResolver(
|
||||||
|
ChannelProviderConnectionConfig.Schema.Input.omit({ providerType: true })
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (values: AddConnectionFormSchema) => {
|
||||||
|
props.onSubmit(values).catch((err: TRPCError) => {
|
||||||
|
setError("channelSlug", {
|
||||||
|
type: "manual",
|
||||||
|
});
|
||||||
|
setError("providerId", {
|
||||||
|
type: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
setError("root.serverError", {
|
||||||
|
type: "manual",
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box onSubmit={handleSubmit(onSubmit)} as="form" id={AddConnectionFormID}>
|
||||||
|
<Box display="grid" __gridTemplateColumns="1fr auto 1fr" gap={4} alignItems="center">
|
||||||
|
<Select
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
control={control}
|
||||||
|
name="channelSlug"
|
||||||
|
label="Channel"
|
||||||
|
options={
|
||||||
|
channels?.map((c) => ({
|
||||||
|
value: c.slug,
|
||||||
|
label: c.name,
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
<Select
|
||||||
|
required
|
||||||
|
size="small"
|
||||||
|
control={control}
|
||||||
|
name="providerId"
|
||||||
|
label="Provider"
|
||||||
|
options={
|
||||||
|
providers?.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: p.configName,
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{errors.root?.serverError && (
|
||||||
|
<Text as="p" marginTop={2} color="textCriticalSubdued">
|
||||||
|
{errors.root?.serverError.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { Modal } from "../ui/modal";
|
||||||
|
import {
|
||||||
|
AddConnectionForm,
|
||||||
|
AddConnectionFormID,
|
||||||
|
AddConnectionFormSchema,
|
||||||
|
} from "./add-connection-form";
|
||||||
|
import { trpcClient } from "../trpc/trpc-client";
|
||||||
|
import { ButtonsBox, SkeletonLayout } from "@saleor/apps-ui";
|
||||||
|
|
||||||
|
const defaultValues: AddConnectionFormSchema = { channelSlug: "", providerId: "" };
|
||||||
|
|
||||||
|
export const AddConnectionModal = (props: { onSuccess(): void; onClose(): void }) => {
|
||||||
|
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||||
|
|
||||||
|
if (!providers) {
|
||||||
|
return <SkeletonLayout.Section />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mutateAsync: addProviderMutate, isLoading } =
|
||||||
|
trpcClient.channelsProvidersConnection.addConnection.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
props.onSuccess();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFormSubmit = async (values: AddConnectionFormSchema) => {
|
||||||
|
const providerType = providers.find((p) => p.id === values.providerId)?.type;
|
||||||
|
|
||||||
|
if (!providerType) {
|
||||||
|
throw new Error("Provider not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return addProviderMutate({
|
||||||
|
...values,
|
||||||
|
providerType,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={props.onClose}>
|
||||||
|
<Text as="h2" variant="heading">
|
||||||
|
Connect channel with Provider
|
||||||
|
</Text>
|
||||||
|
<Text as="p" marginBottom={6}>
|
||||||
|
Once connected, operations on product variants on this channel will be sent to selected CMS
|
||||||
|
platform.
|
||||||
|
</Text>
|
||||||
|
<AddConnectionForm onSubmit={handleFormSubmit} defaultValues={defaultValues} />
|
||||||
|
<ButtonsBox marginTop={8}>
|
||||||
|
<Button
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="tertiary"
|
||||||
|
onClick={() => {
|
||||||
|
props.onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" type="submit" form={AddConnectionFormID}>
|
||||||
|
Add connection
|
||||||
|
</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { AppConfigMetadataManager } from "@/modules/configuration/app-config-metadata-manager";
|
||||||
|
import { createSettingsManager } from "@/modules/configuration/metadata-manager";
|
||||||
|
import { ChannelProviderConnectionConfig } from "@/modules/configuration/schemas/channel-provider-connection.schema";
|
||||||
|
import { protectedClientProcedure } from "@/modules/trpc/protected-client-procedure";
|
||||||
|
import { router } from "@/modules/trpc/trpc-server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { FetchChannelsDocument } from "../../../generated/graphql";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
const procedure = protectedClientProcedure.use(({ ctx, next }) => {
|
||||||
|
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
appConfigService: new AppConfigMetadataManager(settingsManager),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const channelProviderConnectionRouter = router({
|
||||||
|
fetchAllChannels: protectedClientProcedure.query(async ({ ctx }) => {
|
||||||
|
const channels = await ctx.apiClient.query(FetchChannelsDocument, {});
|
||||||
|
|
||||||
|
return channels.data?.channels ?? [];
|
||||||
|
}),
|
||||||
|
fetchConnections: procedure.query(async ({ ctx }) => {
|
||||||
|
return (await ctx.appConfigService.get()).connections.getConnections();
|
||||||
|
}),
|
||||||
|
fetchConnection: procedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||||
|
return (await ctx.appConfigService.get()).connections.getConnectionById(input.id) ?? null;
|
||||||
|
}),
|
||||||
|
addConnection: procedure
|
||||||
|
.input(ChannelProviderConnectionConfig.Schema.Input)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const config = await ctx.appConfigService.get();
|
||||||
|
|
||||||
|
try {
|
||||||
|
config.connections.addConnection(input);
|
||||||
|
} catch (e) {
|
||||||
|
switch ((e as { cause: string }).cause) {
|
||||||
|
case "PROVIDER_DOESNT_EXIST":
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
cause: "PROVIDER_DOESNT_EXIST",
|
||||||
|
message: "Provider doesnt exist",
|
||||||
|
});
|
||||||
|
case "CONNECTION_ALREADY_EXISTS":
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
cause: "CONNECTION_EXISTS",
|
||||||
|
message: "Connection already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.appConfigService.set(config);
|
||||||
|
}),
|
||||||
|
removeConnection: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const config = await ctx.appConfigService.get();
|
||||||
|
|
||||||
|
config.connections.deleteConnection(input.id);
|
||||||
|
|
||||||
|
ctx.appConfigService.set(config);
|
||||||
|
}),
|
||||||
|
});
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Text } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
export const ChanelProviderConnectionsSectionHeader = () => (
|
||||||
|
<Text marginBottom={4} as="h2" variant="heading">
|
||||||
|
Channels Connections
|
||||||
|
</Text>
|
||||||
|
);
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { ButtonsBox, Layout, SkeletonLayout } from "@saleor/apps-ui";
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { trpcClient } from "../trpc/trpc-client";
|
||||||
|
import { AddConnectionModal } from "./add-connection-modal";
|
||||||
|
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
|
||||||
|
import { ConnectionsList } from "./connections-list";
|
||||||
|
|
||||||
|
const NoConnections = (props: { onCreate(): void; enabled: boolean }) => (
|
||||||
|
<Box>
|
||||||
|
<ChanelProviderConnectionsSectionHeader />
|
||||||
|
<Text as="p">
|
||||||
|
No channels connected yet.{" "}
|
||||||
|
{!props.enabled &&
|
||||||
|
"Ensure you have created a provider configuration that can be connected first."}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChannelProviderConnectionList = () => {
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: connectionsData,
|
||||||
|
refetch: refetchConnections,
|
||||||
|
isLoading,
|
||||||
|
} = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
|
||||||
|
|
||||||
|
const { mutate: removeConnection } =
|
||||||
|
trpcClient.channelsProvidersConnection.removeConnection.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
refetchConnections();
|
||||||
|
notifySuccess("Success", "Removed connection");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { notifySuccess } = useDashboardNotification();
|
||||||
|
|
||||||
|
// Prefetch
|
||||||
|
trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
|
||||||
|
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||||
|
|
||||||
|
if (!providers) {
|
||||||
|
return <SkeletonLayout.Section />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (connectionId: string) => {
|
||||||
|
removeConnection({ id: connectionId });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading || !connectionsData) {
|
||||||
|
return <SkeletonLayout.Section />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.AppSectionCard
|
||||||
|
footer={
|
||||||
|
providers.length > 0 && (
|
||||||
|
<ButtonsBox>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add connection
|
||||||
|
</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{dialogOpen && (
|
||||||
|
<AddConnectionModal
|
||||||
|
onClose={() => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
refetchConnections();
|
||||||
|
notifySuccess("Success", "Connection created");
|
||||||
|
setDialogOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{connectionsData.length === 0 && (
|
||||||
|
<NoConnections
|
||||||
|
enabled={providers.length > 0}
|
||||||
|
onCreate={() => {
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{connectionsData.length > 0 && <ConnectionsList onRemove={handleDelete} />}
|
||||||
|
</Layout.AppSectionCard>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { trpcClient } from "../trpc/trpc-client";
|
||||||
|
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
|
||||||
|
import { ProvidersResolver } from "../providers/providers-resolver";
|
||||||
|
import { SkeletonLayout } from "@saleor/apps-ui";
|
||||||
|
|
||||||
|
export const ConnectionsList = (props: { onRemove(connectionId: string): void }) => {
|
||||||
|
const { data } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
|
||||||
|
const { data: channels } = trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
|
||||||
|
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||||
|
|
||||||
|
if (!data || !providers) {
|
||||||
|
return <SkeletonLayout.Section />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<ChanelProviderConnectionsSectionHeader />
|
||||||
|
<Box
|
||||||
|
display="grid"
|
||||||
|
justifyContent={"space-between"}
|
||||||
|
__gridTemplateColumns={"1fr 1fr auto"}
|
||||||
|
gap={4}
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Text variant="caption">Saleor Channel</Text>
|
||||||
|
<Text variant="caption">Target CMS</Text>
|
||||||
|
<div />
|
||||||
|
{data?.map((conn) => {
|
||||||
|
const provider = providers.find((p) => p.id === conn.providerId);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error("Provider not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerName = ProvidersResolver.createProviderMeta(provider.type).displayName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={conn.id}>
|
||||||
|
<Text>{channels?.find((c) => c.slug === conn.channelSlug)?.name}</Text>
|
||||||
|
<Text>
|
||||||
|
<Text>{provider.configName}</Text>
|
||||||
|
<Text color="textNeutralSubdued"> ({providerName})</Text>
|
||||||
|
</Text>
|
||||||
|
<Button onClick={() => props.onRemove(conn.id)} variant="tertiary">
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||||
|
import { AppConfig } from "./app-config";
|
||||||
|
import { createSettingsManager } from "./metadata-manager";
|
||||||
|
import { createGraphQLClient } from "@saleor/apps-shared";
|
||||||
|
import { AuthData } from "@saleor/app-sdk/APL";
|
||||||
|
|
||||||
|
export class AppConfigMetadataManager {
|
||||||
|
public readonly metadataKey = "app-config-v1";
|
||||||
|
|
||||||
|
constructor(private mm: SettingsManager) {}
|
||||||
|
|
||||||
|
async get() {
|
||||||
|
const metadata = await this.mm.get(this.metadataKey);
|
||||||
|
|
||||||
|
return metadata ? AppConfig.parse(metadata) : new AppConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
set(config: AppConfig) {
|
||||||
|
return this.mm.set({
|
||||||
|
key: this.metadataKey,
|
||||||
|
value: config.serialize(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static createFromAuthData(authData: AuthData): AppConfigMetadataManager {
|
||||||
|
const settingsManager = createSettingsManager(
|
||||||
|
createGraphQLClient({
|
||||||
|
saleorApiUrl: authData.saleorApiUrl,
|
||||||
|
token: authData.token,
|
||||||
|
}),
|
||||||
|
authData.appId
|
||||||
|
);
|
||||||
|
|
||||||
|
return new AppConfigMetadataManager(settingsManager);
|
||||||
|
}
|
||||||
|
}
|
148
apps/cms-v2/src/modules/configuration/app-config.test.ts
Normal file
148
apps/cms-v2/src/modules/configuration/app-config.test.ts
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { AppConfig } from "./app-config";
|
||||||
|
import { ContentfulProviderConfig } from "./schemas/contentful-provider.schema";
|
||||||
|
import { DatocmsProviderConfig } from "./schemas/datocms-provider.schema";
|
||||||
|
|
||||||
|
const getMockContentfulInput = (): ContentfulProviderConfig.InputShape => {
|
||||||
|
return {
|
||||||
|
configName: "Test",
|
||||||
|
type: "contentful",
|
||||||
|
contentId: "test",
|
||||||
|
authToken: "test",
|
||||||
|
environment: "test",
|
||||||
|
productVariantFieldsMapping: {
|
||||||
|
channels: "channels",
|
||||||
|
productId: "productId",
|
||||||
|
productName: "productName",
|
||||||
|
productSlug: "productSlug",
|
||||||
|
variantId: "variantId",
|
||||||
|
variantName: "variantName",
|
||||||
|
},
|
||||||
|
spaceId: "test",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const getMockDatocmsInput = (): DatocmsProviderConfig.InputShape => {
|
||||||
|
return {
|
||||||
|
configName: "Test",
|
||||||
|
type: "datocms",
|
||||||
|
itemType: "test",
|
||||||
|
authToken: "test",
|
||||||
|
productVariantFieldsMapping: {
|
||||||
|
channels: "channels",
|
||||||
|
productId: "productId",
|
||||||
|
productName: "productName",
|
||||||
|
productSlug: "productSlug",
|
||||||
|
variantId: "variantId",
|
||||||
|
variantName: "variantName",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("App Config", () => {
|
||||||
|
let appConfig: AppConfig;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
appConfig = new AppConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Constructs with empty state", () => {
|
||||||
|
expect(appConfig.connections.getConnections()).toEqual([]);
|
||||||
|
expect(appConfig.providers.getProviders()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Providers", () => {
|
||||||
|
it("Can retrieve provider by ID from the config", () => {
|
||||||
|
appConfig.providers.addProvider(getMockContentfulInput());
|
||||||
|
|
||||||
|
const provider = appConfig.providers.getProviders()[0];
|
||||||
|
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
expect(appConfig.providers.checkProviderExists(provider.id)).toBe(true);
|
||||||
|
expect(appConfig.providers.getProviderById(provider.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Can update the provider", () => {
|
||||||
|
appConfig.providers.addProvider(getMockContentfulInput());
|
||||||
|
|
||||||
|
const provider = appConfig.providers.getProviders()[0] as ContentfulProviderConfig.FullShape;
|
||||||
|
|
||||||
|
appConfig.providers.updateProvider({
|
||||||
|
...provider,
|
||||||
|
spaceId: "new-space-id",
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerUpdated =
|
||||||
|
appConfig.providers.getProviders()[0] as ContentfulProviderConfig.FullShape;
|
||||||
|
|
||||||
|
expect(providerUpdated.spaceId).toBe("new-space-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Can delete provider", () => {
|
||||||
|
appConfig.providers.addProvider(getMockContentfulInput());
|
||||||
|
|
||||||
|
const provider = appConfig.providers.getProviders()[0];
|
||||||
|
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
|
||||||
|
appConfig.providers.deleteProvider(provider.id);
|
||||||
|
|
||||||
|
expect(appConfig.providers.getProviders()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Connections", () => {
|
||||||
|
it("Throws if trying to add connection with provider that doesnt exist", () => {
|
||||||
|
expect(() => {
|
||||||
|
appConfig.connections.addConnection({
|
||||||
|
channelSlug: "test",
|
||||||
|
providerId: "asdasd123",
|
||||||
|
providerType: "contentful",
|
||||||
|
});
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Can add connection with existing provider", () => {
|
||||||
|
appConfig.providers.addProvider(getMockContentfulInput());
|
||||||
|
|
||||||
|
appConfig.connections.addConnection({
|
||||||
|
channelSlug: "test",
|
||||||
|
providerId: appConfig.providers.getProviders()[0].id,
|
||||||
|
providerType: "contentful",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(appConfig.connections.getConnections().length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Removes all connections that include passed provider", () => {
|
||||||
|
appConfig.providers.addProvider(getMockContentfulInput());
|
||||||
|
appConfig.providers.addProvider(getMockDatocmsInput());
|
||||||
|
|
||||||
|
appConfig.connections.addConnection({
|
||||||
|
channelSlug: "test",
|
||||||
|
providerId: appConfig.providers.getProviders()[0].id,
|
||||||
|
providerType: "contentful",
|
||||||
|
});
|
||||||
|
|
||||||
|
appConfig.connections.addConnection({
|
||||||
|
channelSlug: "test2",
|
||||||
|
providerId: appConfig.providers.getProviders()[0].id,
|
||||||
|
providerType: "contentful",
|
||||||
|
});
|
||||||
|
|
||||||
|
appConfig.connections.addConnection({
|
||||||
|
channelSlug: "test2",
|
||||||
|
providerId: appConfig.providers.getProviders()[1].id,
|
||||||
|
providerType: "datocms",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(appConfig.connections.getConnections().length).toBe(3);
|
||||||
|
|
||||||
|
appConfig.providers.deleteProvider(appConfig.providers.getProviders()[0].id);
|
||||||
|
|
||||||
|
const leftConnections = appConfig.connections.getConnections();
|
||||||
|
|
||||||
|
expect(leftConnections.length).toBe(1);
|
||||||
|
expect(leftConnections[0].providerType).toBe("datocms");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
148
apps/cms-v2/src/modules/configuration/app-config.ts
Normal file
148
apps/cms-v2/src/modules/configuration/app-config.ts
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
import { ProvidersResolver } from "../providers/providers-resolver";
|
||||||
|
import { generateId } from "../shared/generate-id";
|
||||||
|
import { ChannelProviderConnectionConfig } from "./schemas/channel-provider-connection.schema";
|
||||||
|
import { ProvidersConfig, RootConfig } from "./schemas/root-config.schema";
|
||||||
|
|
||||||
|
export const Errors = {
|
||||||
|
CONNECTION_ALREADY_EXISTS: "CONNECTION_ALREADY_EXISTS",
|
||||||
|
PROVIDER_DOESNT_EXIST: "PROVIDER_DOESNT_EXIST",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO
|
||||||
|
* - test
|
||||||
|
* - extract and delegate smaller configs?
|
||||||
|
*/
|
||||||
|
export class AppConfig {
|
||||||
|
private rootData: RootConfig.Shape = {
|
||||||
|
providers: [],
|
||||||
|
connections: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(initialData?: RootConfig.Shape) {
|
||||||
|
if (initialData) {
|
||||||
|
this.rootData = RootConfig.Schema.parse(initialData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static parse(serializedSchema: string) {
|
||||||
|
return new AppConfig(JSON.parse(serializedSchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize() {
|
||||||
|
return JSON.stringify(this.rootData);
|
||||||
|
}
|
||||||
|
|
||||||
|
providers = {
|
||||||
|
checkProviderExists: (id: string) => {
|
||||||
|
return !!this.rootData.providers.find((p) => p.id === id);
|
||||||
|
},
|
||||||
|
|
||||||
|
addProvider: (providerConfigInput: ProvidersConfig.AnyInputShape) => {
|
||||||
|
const inputSchema = ProvidersResolver.getProviderInputSchema(providerConfigInput.type);
|
||||||
|
|
||||||
|
const parsedConfig = inputSchema.parse(providerConfigInput);
|
||||||
|
|
||||||
|
this.rootData.providers.push({
|
||||||
|
...parsedConfig,
|
||||||
|
id: generateId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProvider: (providerConfig: ProvidersConfig.AnyFullShape) => {
|
||||||
|
const schema = ProvidersResolver.getProviderSchema(providerConfig.type);
|
||||||
|
|
||||||
|
const parsedConfig = schema.parse(providerConfig);
|
||||||
|
|
||||||
|
this.rootData.providers = this.rootData.providers.map((p) => {
|
||||||
|
if (p.id === parsedConfig.id) {
|
||||||
|
return parsedConfig;
|
||||||
|
} else {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteProvider: (id: string) => {
|
||||||
|
this.rootData.providers = this.rootData.providers.filter((p) => p.id !== id);
|
||||||
|
this.connections.deleteConnectionsWithProvider(id);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
getProviders: () => {
|
||||||
|
return this.rootData.providers;
|
||||||
|
},
|
||||||
|
|
||||||
|
getProviderById: (id: string) => {
|
||||||
|
return this.providers.getProviders().find((p) => p.id === id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
connections = {
|
||||||
|
getConnections: () => {
|
||||||
|
return this.rootData.connections;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteConnection: (connectionID: string) => {
|
||||||
|
this.rootData.connections = this.rootData.connections.filter((c) => c.id !== connectionID);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
addConnection: (input: ChannelProviderConnectionConfig.InputShape) => {
|
||||||
|
if (!this.providers.checkProviderExists(input.providerId)) {
|
||||||
|
const err = new Error("Provider doesnt exist");
|
||||||
|
|
||||||
|
err.cause = Errors.PROVIDER_DOESNT_EXIST;
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = ChannelProviderConnectionConfig.Schema.Input.parse(input);
|
||||||
|
|
||||||
|
if (this.checkConnectionExists(input)) {
|
||||||
|
const error = new Error("Connection already exists");
|
||||||
|
|
||||||
|
error.cause = Errors.CONNECTION_ALREADY_EXISTS;
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rootData.connections.push({
|
||||||
|
...parsed,
|
||||||
|
id: generateId(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteConnectionsWithProvider: (providerId: string) => {
|
||||||
|
this.rootData.connections = this.rootData.connections.filter((conn) => {
|
||||||
|
return conn.providerId !== providerId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getConnectionById: (id: string) => {
|
||||||
|
return this.connections.getConnections().find((c) => c.id === id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if exists
|
||||||
|
*/
|
||||||
|
private checkConnectionExists(newConnection: ChannelProviderConnectionConfig.InputShape) {
|
||||||
|
/**
|
||||||
|
* Make array of strings so its easy to compare
|
||||||
|
*/
|
||||||
|
const existingConnectionsAsStrings = this.connections
|
||||||
|
.getConnections()
|
||||||
|
.map((connection) => `${connection.channelSlug}-${connection.providerId}`);
|
||||||
|
|
||||||
|
const newConnectionAsString = `${newConnection.channelSlug}-${newConnection.providerId}`;
|
||||||
|
|
||||||
|
return existingConnectionsAsStrings.includes(newConnectionAsString);
|
||||||
|
}
|
||||||
|
}
|
13
apps/cms-v2/src/modules/configuration/index.ts
Normal file
13
apps/cms-v2/src/modules/configuration/index.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Re-export for convenient imports
|
||||||
|
*/
|
||||||
|
export * from "./app-config";
|
||||||
|
export * from "./app-config-metadata-manager";
|
||||||
|
export * from "./metadata-manager";
|
||||||
|
export * from "./schemas/channel-provider-connection.schema";
|
||||||
|
export * from "./schemas/contentful-provider.schema";
|
||||||
|
export * from "./schemas/builder-provider.schema";
|
||||||
|
export * from "./schemas/datocms-provider.schema";
|
||||||
|
export * from "./schemas/strapi-provider.schema";
|
||||||
|
export * from "./schemas/saleor-provider-fields-mapping.schema";
|
||||||
|
export * from "./schemas/root-config.schema";
|
12
apps/cms-v2/src/modules/configuration/metadata-manager.ts
Normal file
12
apps/cms-v2/src/modules/configuration/metadata-manager.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||||
|
import { EncryptedMetadataManagerFactory } from "@saleor/apps-shared";
|
||||||
|
import { Client } from "urql";
|
||||||
|
|
||||||
|
export const createSettingsManager = (
|
||||||
|
client: Pick<Client, "query" | "mutation">,
|
||||||
|
appId: string,
|
||||||
|
): SettingsManager => {
|
||||||
|
const metadataManagerFactory = new EncryptedMetadataManagerFactory(process.env.SECRET_KEY!);
|
||||||
|
|
||||||
|
return metadataManagerFactory.create(client, appId);
|
||||||
|
};
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { SaleorProviderFieldsMappingKey } from "./schemas/saleor-provider-fields-mapping.schema";
|
||||||
|
|
||||||
|
export const printSaleorProductFields = (fieldName: SaleorProviderFieldsMappingKey) => {
|
||||||
|
switch (fieldName) {
|
||||||
|
case "variantName": {
|
||||||
|
return "Variant Name";
|
||||||
|
}
|
||||||
|
case "channels": {
|
||||||
|
return "Channels";
|
||||||
|
}
|
||||||
|
case "productId": {
|
||||||
|
return "Product ID";
|
||||||
|
}
|
||||||
|
case "productName": {
|
||||||
|
return "Product Name";
|
||||||
|
}
|
||||||
|
case "productSlug": {
|
||||||
|
return "Product Slug";
|
||||||
|
}
|
||||||
|
case "variantId": {
|
||||||
|
return "Variant ID";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
|
||||||
|
import { BuilderIo } from "@/modules/providers/builder.io/builder-io";
|
||||||
|
|
||||||
|
const InputSchema = z.object({
|
||||||
|
type: z.literal(BuilderIo.type),
|
||||||
|
privateApiKey: z.string().min(1),
|
||||||
|
publicApiKey: z.string().min(1),
|
||||||
|
configName: z.string().min(1),
|
||||||
|
modelName: z.string().min(1),
|
||||||
|
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const FullSchema = InputSchema.extend({
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export namespace BuilderIoProviderConfig {
|
||||||
|
export type InputShape = z.infer<typeof InputSchema>;
|
||||||
|
export type FullShape = z.infer<typeof FullSchema>;
|
||||||
|
|
||||||
|
export const Schema = {
|
||||||
|
Input: InputSchema,
|
||||||
|
Full: FullSchema,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { cmsTypes } from "@/modules/providers/providers-registry";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const InputSchema = z.object({
|
||||||
|
channelSlug: z.string().min(1),
|
||||||
|
providerId: z.string().min(1),
|
||||||
|
providerType: z.enum(cmsTypes),
|
||||||
|
});
|
||||||
|
|
||||||
|
const FullSchema = InputSchema.extend({
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export namespace ChannelProviderConnectionConfig {
|
||||||
|
export type InputShape = z.infer<typeof InputSchema>;
|
||||||
|
export type FullShape = z.infer<typeof FullSchema>;
|
||||||
|
|
||||||
|
export const Schema = {
|
||||||
|
Input: InputSchema,
|
||||||
|
Full: FullSchema,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Contentful } from "../../providers/contentful/contentful";
|
||||||
|
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
|
||||||
|
|
||||||
|
const InputSchema = z.object({
|
||||||
|
type: z.literal(Contentful.type),
|
||||||
|
authToken: z.string().min(1),
|
||||||
|
spaceId: z.string().min(1),
|
||||||
|
environment: z.string().min(1),
|
||||||
|
configName: z.string().min(1),
|
||||||
|
contentId: z.string().min(1),
|
||||||
|
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const FullSchema = InputSchema.extend({
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export namespace ContentfulProviderConfig {
|
||||||
|
export type InputShape = z.infer<typeof InputSchema>;
|
||||||
|
export type FullShape = z.infer<typeof FullSchema>;
|
||||||
|
|
||||||
|
export const Schema = {
|
||||||
|
Input: InputSchema,
|
||||||
|
Full: FullSchema,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Datocms } from "@/modules/providers/datocms/datocms";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
|
||||||
|
|
||||||
|
const InputSchema = z.object({
|
||||||
|
type: z.literal(Datocms.type),
|
||||||
|
authToken: z.string().min(1),
|
||||||
|
configName: z.string().min(1),
|
||||||
|
itemType: z.string().min(1),
|
||||||
|
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const FullSchema = InputSchema.extend({
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export namespace DatocmsProviderConfig {
|
||||||
|
export type InputShape = z.infer<typeof InputSchema>;
|
||||||
|
export type FullShape = z.infer<typeof FullSchema>;
|
||||||
|
|
||||||
|
export const Schema = {
|
||||||
|
Input: InputSchema,
|
||||||
|
Full: FullSchema,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ChannelProviderConnectionConfig } from "./channel-provider-connection.schema";
|
||||||
|
import { ContentfulProviderConfig } from "./contentful-provider.schema";
|
||||||
|
import { BuilderIoProviderConfig } from "./builder-provider.schema";
|
||||||
|
import { StrapiProviderConfig } from "./strapi-provider.schema";
|
||||||
|
import { DatocmsProviderConfig } from "./datocms-provider.schema";
|
||||||
|
import { PayloadCmsProviderConfig } from "./payloadcms-provider.schema";
|
||||||
|
|
||||||
|
export namespace ProvidersConfig {
|
||||||
|
const AnyFull = z.union([
|
||||||
|
/**
|
||||||
|
* Add more for each provider
|
||||||
|
*/
|
||||||
|
ContentfulProviderConfig.Schema.Full,
|
||||||
|
DatocmsProviderConfig.Schema.Full,
|
||||||
|
StrapiProviderConfig.Schema.Full,
|
||||||
|
BuilderIoProviderConfig.Schema.Full,
|
||||||
|
PayloadCmsProviderConfig.Schema.Full,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const Schema = {
|
||||||
|
AnyFull: AnyFull,
|
||||||
|
AnyInput: z.union([
|
||||||
|
ContentfulProviderConfig.Schema.Input,
|
||||||
|
DatocmsProviderConfig.Schema.Input,
|
||||||
|
StrapiProviderConfig.Schema.Input,
|
||||||
|
BuilderIoProviderConfig.Schema.Input,
|
||||||
|
PayloadCmsProviderConfig.Schema.Input,
|
||||||
|
]),
|
||||||
|
AnyFullList: z.array(AnyFull),
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnyFullShape = z.infer<typeof Schema.AnyFull>;
|
||||||
|
export type AnyInputShape = z.infer<typeof Schema.AnyInput>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace RootConfig {
|
||||||
|
/**
|
||||||
|
* Store entire app config in single file
|
||||||
|
* - Only one request
|
||||||
|
* - Always transactional
|
||||||
|
*/
|
||||||
|
export const Schema = z.object({
|
||||||
|
providers: ProvidersConfig.Schema.AnyFullList,
|
||||||
|
connections: z.array(ChannelProviderConnectionConfig.Schema.Full),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Shape = z.infer<typeof Schema>;
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO Consider optional fields
|
||||||
|
*/
|
||||||
|
export const SaleorProviderFieldsMappingSchema = z.object({
|
||||||
|
variantId: z.string().min(1),
|
||||||
|
variantName: z.string().min(1),
|
||||||
|
productId: z.string().min(1),
|
||||||
|
productName: z.string().min(1),
|
||||||
|
productSlug: z.string().min(1),
|
||||||
|
channels: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SaleorProviderFieldsMappingType = z.infer<typeof SaleorProviderFieldsMappingSchema>;
|
||||||
|
export type SaleorProviderFieldsMappingKey = keyof SaleorProviderFieldsMappingType;
|
||||||
|
|
||||||
|
export const SaleorProviderFieldsMappingKeys: Array<SaleorProviderFieldsMappingKey> = [
|
||||||
|
"variantId",
|
||||||
|
"variantName",
|
||||||
|
"productId",
|
||||||
|
"productName",
|
||||||
|
"productSlug",
|
||||||
|
"channels",
|
||||||
|
];
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Strapi } from "@/modules/providers/strapi/strapi";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
|
||||||
|
|
||||||
|
const InputSchema = z.object({
|
||||||
|
configName: z.string(),
|
||||||
|
type: z.literal(Strapi.type),
|
||||||
|
url: z.string().url().min(1),
|
||||||
|
authToken: z.string().min(1),
|
||||||
|
itemType: z.string().min(1),
|
||||||
|
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const FullSchema = InputSchema.extend({
|
||||||
|
id: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export namespace StrapiProviderConfig {
|
||||||
|
export type InputShape = z.infer<typeof InputSchema>;
|
||||||
|
export type FullShape = z.infer<typeof FullSchema>;
|
||||||
|
|
||||||
|
export const Schema = {
|
||||||
|
Input: InputSchema,
|
||||||
|
Full: FullSchema,
|
||||||
|
};
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||||
import { PropsWithChildren } from "react";
|
import { PropsWithChildren } from "react";
|
||||||
import { Provider } from "urql";
|
import { Provider } from "urql";
|
||||||
|
|
||||||
import { createClient } from "../lib/graphql";
|
import { createGraphQLClient } from "@saleor/apps-shared";
|
||||||
|
|
||||||
export function GraphQLProvider(props: PropsWithChildren<{}>) {
|
export function GraphQLProvider(props: PropsWithChildren<{}>) {
|
||||||
const { appBridgeState } = useAppBridge();
|
const { appBridgeState } = useAppBridge();
|
||||||
|
@ -12,9 +12,10 @@ export function GraphQLProvider(props: PropsWithChildren<{}>) {
|
||||||
return <div {...props}></div>;
|
return <div {...props}></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = createClient(saleorApiUrl, async () =>
|
const client = createGraphQLClient({
|
||||||
Promise.resolve({ token: appBridgeState?.token! })
|
saleorApiUrl,
|
||||||
);
|
token: appBridgeState.token,
|
||||||
|
});
|
||||||
|
|
||||||
return <Provider value={client} {...props} />;
|
return <Provider value={client} {...props} />;
|
||||||
}
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { createSettingsManager } from "../configuration/metadata-manager";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AppConfigMetadataManager } from "../configuration/app-config-metadata-manager";
|
||||||
|
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
||||||
|
import { router } from "../trpc/trpc-server";
|
||||||
|
import { ProvidersConfig } from "../configuration";
|
||||||
|
|
||||||
|
const procedure = protectedClientProcedure.use(({ ctx, next }) => {
|
||||||
|
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
settingsManager,
|
||||||
|
appConfigService: new AppConfigMetadataManager(settingsManager),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const providersListRouter = router({
|
||||||
|
getAll: procedure.query(async ({ ctx: { appConfigService } }) => {
|
||||||
|
const config = await appConfigService.get();
|
||||||
|
const providers = config.providers.getProviders();
|
||||||
|
|
||||||
|
return providers;
|
||||||
|
}),
|
||||||
|
getOne: procedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.query(async ({ ctx: { appConfigService }, input }) => {
|
||||||
|
const config = await appConfigService.get();
|
||||||
|
|
||||||
|
return config.providers.getProviderById(input.id) ?? null;
|
||||||
|
}),
|
||||||
|
addOne: procedure
|
||||||
|
.input(ProvidersConfig.Schema.AnyInput)
|
||||||
|
.mutation(async ({ ctx: { appConfigService }, input }) => {
|
||||||
|
const config = await appConfigService.get();
|
||||||
|
|
||||||
|
config.providers.addProvider(input);
|
||||||
|
|
||||||
|
await appConfigService.set(config);
|
||||||
|
}),
|
||||||
|
updateOne: procedure
|
||||||
|
.input(ProvidersConfig.Schema.AnyFull)
|
||||||
|
.mutation(async ({ input, ctx: { appConfigService } }) => {
|
||||||
|
const config = await appConfigService.get();
|
||||||
|
|
||||||
|
config?.providers.updateProvider(input);
|
||||||
|
|
||||||
|
return appConfigService.set(config);
|
||||||
|
}),
|
||||||
|
deleteOne: procedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ input, ctx: { appConfigService } }) => {
|
||||||
|
const config = await appConfigService.get();
|
||||||
|
|
||||||
|
config.providers.deleteProvider(input.id);
|
||||||
|
|
||||||
|
return appConfigService.set(config);
|
||||||
|
}),
|
||||||
|
});
|
99
apps/cms-v2/src/modules/providers-listing/providers-list.tsx
Normal file
99
apps/cms-v2/src/modules/providers-listing/providers-list.tsx
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import React from "react";
|
||||||
|
import { ProvidersConfig } from "../configuration";
|
||||||
|
|
||||||
|
import { ProvidersResolver } from "../providers/providers-resolver";
|
||||||
|
import { trpcClient } from "../trpc/trpc-client";
|
||||||
|
|
||||||
|
import { ButtonsBox, Layout, SkeletonLayout } from "@saleor/apps-ui";
|
||||||
|
|
||||||
|
const ProvidersTable = (props: { providers: ProvidersConfig.AnyFullShape[] }) => {
|
||||||
|
const { push } = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box display="grid" __gridTemplateColumns="repeat(2, auto)" gap={4} alignItems="center">
|
||||||
|
<Text variant="caption">Configuration name</Text>
|
||||||
|
|
||||||
|
<div />
|
||||||
|
|
||||||
|
{props.providers.map((provider) => (
|
||||||
|
<React.Fragment key={provider.id}>
|
||||||
|
<Box>
|
||||||
|
<Text as="p" variant="bodyStrong">
|
||||||
|
{provider.configName}
|
||||||
|
</Text>
|
||||||
|
<Text as="p" variant="caption">
|
||||||
|
{ProvidersResolver.createProviderMeta(provider.type).displayName}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
marginLeft="auto"
|
||||||
|
variant="tertiary"
|
||||||
|
onClick={() => {
|
||||||
|
push(`/edit-provider/` + provider.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProvidersList = () => {
|
||||||
|
const { data } = trpcClient.providersConfigs.getAll.useQuery();
|
||||||
|
const { push } = useRouter();
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <SkeletonLayout.Section />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Layout.AppSectionCard
|
||||||
|
footer={
|
||||||
|
<ButtonsBox>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
push("/add-provider");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add first CMS configuration
|
||||||
|
</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text as="p" marginBottom={4}>
|
||||||
|
No configurations yet
|
||||||
|
</Text>
|
||||||
|
</Layout.AppSectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout.AppSectionCard
|
||||||
|
footer={
|
||||||
|
<ButtonsBox>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
push("/add-provider");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add CMS configuration
|
||||||
|
</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data.length && (
|
||||||
|
<Box>
|
||||||
|
<Text variant="heading" as="h2" marginBottom={4}>
|
||||||
|
Providers configurations
|
||||||
|
</Text>
|
||||||
|
<ProvidersTable providers={data} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Layout.AppSectionCard>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { BulkImportProductFragment } from "../../../../generated/graphql";
|
||||||
|
import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
|
||||||
|
import { BuilderIoProviderConfig } from "../../configuration";
|
||||||
|
import { BuilderIoClient } from "./builder-io.client";
|
||||||
|
|
||||||
|
export class BuilderIoBulkSyncProcessor implements BulkSyncProcessor {
|
||||||
|
constructor(private config: BuilderIoProviderConfig.FullShape) {}
|
||||||
|
|
||||||
|
async uploadProducts(
|
||||||
|
products: BulkImportProductFragment[],
|
||||||
|
hooks: BulkSyncProcessorHooks
|
||||||
|
): Promise<void> {
|
||||||
|
const client = new BuilderIoClient(this.config);
|
||||||
|
|
||||||
|
products.flatMap((product) =>
|
||||||
|
product.variants?.map((variant) => {
|
||||||
|
if (hooks.onUploadStart) {
|
||||||
|
hooks.onUploadStart({ variantId: variant.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return client
|
||||||
|
.upsertProductVariant({
|
||||||
|
id: variant.id,
|
||||||
|
name: variant.name,
|
||||||
|
channelListings: variant.channelListings,
|
||||||
|
product: {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
slug: product.slug,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (hooks.onUploadSuccess) {
|
||||||
|
hooks.onUploadSuccess({ variantId: variant.id });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (hooks.onUploadError) {
|
||||||
|
hooks.onUploadError({ variantId: variant.id, error: e });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,238 @@
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { Input } from "@saleor/react-hook-form-macaw";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { BuilderIoProviderConfig, SaleorProviderFieldsMappingKeys } from "../../configuration";
|
||||||
|
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { ButtonsBox, SkeletonLayout, TextLink } from "@saleor/apps-ui";
|
||||||
|
|
||||||
|
type FormShape = Omit<BuilderIoProviderConfig.InputShape, "type">;
|
||||||
|
const FormSchema = BuilderIoProviderConfig.Schema.Input.omit({ type: true });
|
||||||
|
|
||||||
|
type PureFormProps = {
|
||||||
|
defaultValues: FormShape;
|
||||||
|
onSubmit(values: FormShape): void;
|
||||||
|
onDelete?(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||||
|
const { control, handleSubmit } = useForm({
|
||||||
|
defaultValues: defaultValues,
|
||||||
|
resolver: zodResolver(FormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
display={"grid"}
|
||||||
|
gap={4}
|
||||||
|
onSubmit={handleSubmit((vals) => {
|
||||||
|
onSubmit(vals);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
control={control}
|
||||||
|
name="configName"
|
||||||
|
label="Configuration name"
|
||||||
|
helperText="Meaningful name that will help you understand it later. E.g. 'staging' or 'prod' "
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box display={"grid"} gap={4} marginY={4}>
|
||||||
|
<Text variant="heading">Provide connection details</Text>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
control={control}
|
||||||
|
name="privateApiKey"
|
||||||
|
label="Private API key (write API)"
|
||||||
|
helperText={
|
||||||
|
<Text variant="caption">
|
||||||
|
You can find it in and generate in{" "}
|
||||||
|
<TextLink size="small" newTab href="https://builder.io/account/space">
|
||||||
|
account settings
|
||||||
|
</TextLink>
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
control={control}
|
||||||
|
name="publicApiKey"
|
||||||
|
label="Public API key (read API)"
|
||||||
|
helperText={
|
||||||
|
<Text variant="caption">
|
||||||
|
You can find it in{" "}
|
||||||
|
<TextLink size="small" newTab href="https://builder.io/account/space">
|
||||||
|
account settings
|
||||||
|
</TextLink>
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box display={"grid"} gap={4} marginY={4}>
|
||||||
|
<Text variant="heading">Configure fields mapping</Text>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
control={control}
|
||||||
|
name="modelName"
|
||||||
|
label="CMS Data model name"
|
||||||
|
helperText="Structured content model name. E.g. 'products' or 'product-variants'"
|
||||||
|
placeholder="saleor-variant"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text as="p" variant="heading" size="small">
|
||||||
|
Map fields from Saleor to your contentful schema.
|
||||||
|
</Text>
|
||||||
|
<Text as="p" marginTop={2} marginBottom={4}>
|
||||||
|
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should be
|
||||||
|
type of <Text variant="bodyStrong">JSON</Text>.
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
marginBottom={4}
|
||||||
|
display="grid"
|
||||||
|
__gridTemplateColumns={"50% 50%"}
|
||||||
|
borderBottomWidth={1}
|
||||||
|
borderBottomStyle="solid"
|
||||||
|
borderColor="neutralHighlight"
|
||||||
|
padding={2}
|
||||||
|
>
|
||||||
|
<Text variant="caption">Saleor Field</Text>
|
||||||
|
<Text variant="caption">Builder.io 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="Builder.io Field"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<ButtonsBox>
|
||||||
|
{onDelete && (
|
||||||
|
<Button onClick={onDelete} variant="tertiary">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddFormVariant = () => {
|
||||||
|
const { push } = useRouter();
|
||||||
|
const { notifySuccess } = useDashboardNotification();
|
||||||
|
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Saved configuration");
|
||||||
|
push("/configuration");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PureForm
|
||||||
|
onSubmit={(values) => {
|
||||||
|
addProvider({
|
||||||
|
...values,
|
||||||
|
type: "builder.io",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
defaultValues={{
|
||||||
|
configName: "",
|
||||||
|
publicApiKey: "",
|
||||||
|
privateApiKey: "",
|
||||||
|
modelName: "",
|
||||||
|
productVariantFieldsMapping: {
|
||||||
|
channels: "",
|
||||||
|
variantName: "",
|
||||||
|
productId: "",
|
||||||
|
productName: "",
|
||||||
|
productSlug: "",
|
||||||
|
variantId: "",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditFormVariant = (props: { configId: string }) => {
|
||||||
|
const { push } = useRouter();
|
||||||
|
const { notifySuccess } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { data } = trpcClient.providersConfigs.getOne.useQuery(
|
||||||
|
{
|
||||||
|
id: props.configId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!props.configId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Updated configuration");
|
||||||
|
push("/configuration");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteProvider } = trpcClient.providersConfigs.deleteOne.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Removed configuration");
|
||||||
|
push("/configuration");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <SkeletonLayout.Section />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type !== "builder.io") {
|
||||||
|
throw new Error("Trying to fill builder.io form with non builder.io data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PureForm
|
||||||
|
onDelete={() => {
|
||||||
|
deleteProvider({
|
||||||
|
id: props.configId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
mutate({
|
||||||
|
...values,
|
||||||
|
type: "builder.io",
|
||||||
|
id: props.configId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
defaultValues={data}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BuilderIoConfigForm = {
|
||||||
|
PureVariant: PureForm,
|
||||||
|
AddVariant: AddFormVariant,
|
||||||
|
EditVariant: EditFormVariant,
|
||||||
|
};
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
import {
|
||||||
|
WebhookProductFragment,
|
||||||
|
WebhookProductVariantFragment,
|
||||||
|
} from "../../../../generated/graphql";
|
||||||
|
import { BuilderIoProviderConfig, ContentfulProviderConfig } from "../../configuration";
|
||||||
|
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
|
||||||
|
import { BuilderIoClient } from "./builder-io.client";
|
||||||
|
|
||||||
|
export type BuilderioClientStrip = Pick<
|
||||||
|
BuilderIoClient,
|
||||||
|
"upsertProductVariant" | "deleteProductVariant" | "updateProductVariant"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type BuilderIoClientFactory = (
|
||||||
|
config: BuilderIoProviderConfig.FullShape
|
||||||
|
) => BuilderioClientStrip;
|
||||||
|
|
||||||
|
export class BuilderIoWebhooksProcessor implements ProductWebhooksProcessor {
|
||||||
|
private client: BuilderioClientStrip;
|
||||||
|
private logger = createLogger({ name: "BuilderIoWebhooksProcessor" });
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
providerConfig: BuilderIoProviderConfig.FullShape,
|
||||||
|
clientFactory: BuilderIoClientFactory = () => new BuilderIoClient(providerConfig)
|
||||||
|
) {
|
||||||
|
this.client = clientFactory(providerConfig);
|
||||||
|
|
||||||
|
this.logger.trace("Created BuilderIoWebhooksProcessor");
|
||||||
|
}
|
||||||
|
|
||||||
|
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||||
|
this.logger.debug({ variantId: productVariant.id }, "Called onProductVariantUpdated");
|
||||||
|
|
||||||
|
await this.client.upsertProductVariant(productVariant);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||||
|
this.logger.debug({ variantId: productVariant.id }, "Called onProductVariantCreated");
|
||||||
|
|
||||||
|
await this.client.upsertProductVariant(productVariant);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||||
|
this.logger.debug({ variantId: productVariant.id }, "Called onProductVariantDeleted");
|
||||||
|
|
||||||
|
await this.client.deleteProductVariant(productVariant.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
|
||||||
|
this.logger.debug({ procutId: product.id }, "Called onProductUpdated");
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
(product.variants ?? []).map((variant) => {
|
||||||
|
return this.client.updateProductVariant({
|
||||||
|
id: variant.id,
|
||||||
|
name: variant.name,
|
||||||
|
product: {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
slug: product.slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { BuilderIoProviderConfig } from "@/modules/configuration";
|
||||||
|
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
|
||||||
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
import { FieldsMapper } from "../fields-mapper";
|
||||||
|
|
||||||
|
// https://www.builder.io/c/docs/write-api
|
||||||
|
export class BuilderIoClient {
|
||||||
|
private endpoint: string;
|
||||||
|
private logger = createLogger({ name: "BuilderIoClient" });
|
||||||
|
|
||||||
|
constructor(private config: BuilderIoProviderConfig.FullShape) {
|
||||||
|
this.endpoint = `https://builder.io/api/v1/write/${config.modelName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapVariantToFields(variant: WebhookProductVariantFragment) {
|
||||||
|
return FieldsMapper.mapProductVariantToConfigurationFields({
|
||||||
|
variant,
|
||||||
|
configMapping: this.config.productVariantFieldsMapping,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadProductVariant(variant: WebhookProductVariantFragment) {
|
||||||
|
this.logger.debug({ variantId: variant.id }, "uploadProductVariant called");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${this.config.privateApiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: this.mapVariantToFields(variant),
|
||||||
|
published: "published",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(err, "Failed to upload product variant");
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateProductVariantCall(
|
||||||
|
builderIoEntryId: string,
|
||||||
|
variant: WebhookProductVariantFragment
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.endpoint + `/${builderIoEntryId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${this.config.privateApiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: this.mapVariantToFields(variant),
|
||||||
|
published: "published",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(err, "Failed to upload product variant");
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProductVariant(variant: WebhookProductVariantFragment) {
|
||||||
|
const entriesToUpdate = await this.fetchBuilderIoEntryIds(variant.id);
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
{
|
||||||
|
entriesToUpdate,
|
||||||
|
},
|
||||||
|
"Trying to update variants in builder.io with following IDs"
|
||||||
|
);
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
entriesToUpdate.map((id) => {
|
||||||
|
return this.updateProductVariantCall(id, variant);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertProductVariant(variant: WebhookProductVariantFragment) {
|
||||||
|
const entriesToUpdate = await this.fetchBuilderIoEntryIds(variant.id);
|
||||||
|
|
||||||
|
if (entriesToUpdate.length === 0) {
|
||||||
|
this.logger.debug("Didnt find any entries to update, will upload new variant");
|
||||||
|
|
||||||
|
return this.uploadProductVariant(variant);
|
||||||
|
} else {
|
||||||
|
this.logger.debug({ entriesToUpdate }, "Found entries in builder.io, will update them");
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
entriesToUpdate.map((id) => {
|
||||||
|
return this.updateProductVariantCall(id, variant);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProductVariant(variantId: string) {
|
||||||
|
const idsToDelete = await this.fetchBuilderIoEntryIds(variantId);
|
||||||
|
|
||||||
|
this.logger.debug({ ids: idsToDelete }, "Will try to delete items in Builder.io");
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
idsToDelete.map((id) =>
|
||||||
|
fetch(this.endpoint + `/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${this.config.privateApiKey}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can return more than 1. Builder doesnt have unique fields.
|
||||||
|
*/
|
||||||
|
private fetchBuilderIoEntryIds(variantId: string): Promise<string[]> {
|
||||||
|
this.logger.trace(
|
||||||
|
{
|
||||||
|
modelName: this.config.modelName,
|
||||||
|
variantID: variantId,
|
||||||
|
variantFieldMapping: this.config.productVariantFieldsMapping.variantId,
|
||||||
|
},
|
||||||
|
"Trying to fetch variant from Builder.io"
|
||||||
|
);
|
||||||
|
|
||||||
|
return fetch(
|
||||||
|
`https://cdn.builder.io/api/v3/content/${this.config.modelName}?apiKey=${this.config.publicApiKey}&query.data.${this.config.productVariantFieldsMapping.variantId}.$eq=${variantId}&limit=10&includeUnpublished=false&cacheSeconds=0`
|
||||||
|
)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
return data.results.map((result: any) => result.id) as string[];
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.logger.error(err, "Failed to fetch builder.io entry id");
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
22
apps/cms-v2/src/modules/providers/builder.io/builder-io.tsx
Normal file
22
apps/cms-v2/src/modules/providers/builder.io/builder-io.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { Text } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
import logo from "./builder-logo.svg";
|
||||||
|
import { CMSProviderMeta } from "../cms-provider-meta";
|
||||||
|
import { TextLink } from "@saleor/apps-ui";
|
||||||
|
|
||||||
|
export const BuilderIo = {
|
||||||
|
formSideInfo: (
|
||||||
|
<Text>
|
||||||
|
Ensure fields are properly mapped with your{" "}
|
||||||
|
<TextLink newTab href="https://www.builder.io/c/docs/models-data">
|
||||||
|
CMS Data Model
|
||||||
|
</TextLink>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
type: "builder.io" as const,
|
||||||
|
logoUrl: logo.src as string,
|
||||||
|
displayName: "Builder.io",
|
||||||
|
description:
|
||||||
|
"Builder.io is the only headless CMS that gives developers, marketers, and product managers the freedom they need to ship fast, flexible, multi-channel experiences without overwhelming your backlog.",
|
||||||
|
} satisfies CMSProviderMeta;
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="256px" height="293px" viewBox="0 0 256 293" version="1.1" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid">
|
||||||
|
<title>Builder</title>
|
||||||
|
<g>
|
||||||
|
<path d="M256,82.3201829 C256.01306,94.601365 253.278083,106.725264 247.991626,117.804979 C242.705169,128.884694 235.002671,138.640609 225.452096,146.351846 L5.80199569,21.6220457 C4.96315294,21.1414587 4.18547581,20.5647543 3.48207121,19.8963015 C2.3810901,18.8171653 1.50729557,17.5283183 0.908746314,16.1084022 C0.310197059,14.6841171 0,13.1549767 0,11.6127293 C0,8.53260361 1.22331235,5.57917809 3.39906073,3.4034297 C5.57480912,1.22331235 8.52823464,7.7484371e-13 11.6083604,7.7484371e-13 L173.719091,7.7484371e-13 C195.542109,7.7484371e-13 216.469488,8.67241073 231.9007,24.1123601 C247.331911,39.5479405 256,60.4884265 256,82.3201829 Z" fill="#18B4F4"></path>
|
||||||
|
<path d="M255.99996,210.379141 C256.004322,221.192348 253.876632,231.9007 249.743584,241.888171 C245.610536,251.880012 239.550771,260.958737 231.909438,268.604439 C224.268105,276.250141 215.193748,282.314275 205.210646,286.451693 C195.227543,290.593479 184.52356,292.721168 173.719091,292.721168 L11.6083604,292.721168 C9.31028074,292.694954 7.07336674,291.99155 5.1772326,290.693965 C3.28546744,289.39638 1.81312365,287.565781 0.961173986,285.433722 C0.104855344,283.301663 -0.100486371,280.964263 0.375731649,278.714242 C0.847580696,276.46859 1.97477564,274.410804 3.61750936,272.803022 C4.27285526,272.147676 5.00684267,271.575341 5.80199569,271.099123 L92.2464888,222.013715 L225.43462,146.369322 C234.980826,154.080559 242.683324,163.832106 247.97415,174.907452 C253.264976,185.982797 256.004322,198.106696 255.99996,210.379141 Z" fill="#FD6B3C"></path>
|
||||||
|
<path d="M225.452096,146.351846 L92.2464888,222.013715 L5.80636467,271.099123 C4.97625986,271.562234 4.20732067,272.125831 3.52139197,272.785546 C36.4809217,238.965329 54.9092484,193.58481 54.8699277,146.351846 C54.9310933,99.1101448 36.5071356,53.7208878 3.53886786,19.8963015 C4.24227245,20.5647543 5.01994959,21.1414587 5.86316131,21.6220457 L225.452096,146.351846 Z" fill="#A97FF2"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.1 KiB |
9
apps/cms-v2/src/modules/providers/cms-provider-meta.ts
Normal file
9
apps/cms-v2/src/modules/providers/cms-provider-meta.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { ReactElement } from "react";
|
||||||
|
|
||||||
|
export interface CMSProviderMeta {
|
||||||
|
type: string;
|
||||||
|
displayName: string;
|
||||||
|
logoUrl: string;
|
||||||
|
description: string;
|
||||||
|
formSideInfo?: ReactElement;
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
import { BulkImportProductFragment } from "../../../../generated/graphql";
|
||||||
|
import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
|
||||||
|
import { ContentfulProviderConfig } from "../../configuration";
|
||||||
|
import { ContentfulClient } from "./contentful-client";
|
||||||
|
import { contentfulRateLimiter } from "./contentful-rate-limiter";
|
||||||
|
|
||||||
|
export class ContentfulBulkSyncProcessor implements BulkSyncProcessor {
|
||||||
|
private logger = createLogger({ name: "ContentfulBulkSyncProcessor" });
|
||||||
|
|
||||||
|
constructor(private config: ContentfulProviderConfig.FullShape) {}
|
||||||
|
|
||||||
|
async uploadProducts(
|
||||||
|
products: BulkImportProductFragment[],
|
||||||
|
hooks: BulkSyncProcessorHooks
|
||||||
|
): Promise<void> {
|
||||||
|
const contentful = new ContentfulClient({
|
||||||
|
accessToken: this.config.authToken,
|
||||||
|
space: this.config.spaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
products.flatMap((product) => {
|
||||||
|
return product.variants?.map((variant) => {
|
||||||
|
return contentfulRateLimiter(() => {
|
||||||
|
if (hooks.onUploadStart) {
|
||||||
|
hooks.onUploadStart({ variantId: variant.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentful
|
||||||
|
.upsertProductVariant({
|
||||||
|
configuration: this.config,
|
||||||
|
variant: {
|
||||||
|
id: variant.id,
|
||||||
|
name: variant.name,
|
||||||
|
channelListings: variant.channelListings,
|
||||||
|
product: {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
slug: product.slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (r?.metadata) {
|
||||||
|
if (hooks.onUploadSuccess) {
|
||||||
|
hooks.onUploadSuccess({ variantId: variant.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
this.logger.trace(e, "Error while uploading product to Contentful");
|
||||||
|
|
||||||
|
if (hooks.onUploadError) {
|
||||||
|
hooks.onUploadError({ variantId: variant.id, error: e });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,295 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { ContentfulClient, ContentfulApiClientChunk } from "./contentful-client";
|
||||||
|
import { ContentfulProviderConfig } from "@/modules/configuration";
|
||||||
|
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
|
||||||
|
|
||||||
|
const getMockContenfulConfiguration = (): ContentfulProviderConfig.FullShape => ({
|
||||||
|
authToken: "test-token",
|
||||||
|
configName: "test-config-name",
|
||||||
|
contentId: "test-content-id",
|
||||||
|
id: "test-id",
|
||||||
|
spaceId: "test-space-id",
|
||||||
|
type: "contentful",
|
||||||
|
environment: "master",
|
||||||
|
productVariantFieldsMapping: {
|
||||||
|
channels: "channels",
|
||||||
|
productId: "product-id",
|
||||||
|
productName: "product-name",
|
||||||
|
productSlug: "product-slug",
|
||||||
|
variantId: "variant-id",
|
||||||
|
variantName: "variant-name",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getMockWebhookProductVariant = (): WebhookProductVariantFragment => {
|
||||||
|
return {
|
||||||
|
id: "test-id",
|
||||||
|
name: "test-name",
|
||||||
|
product: {
|
||||||
|
id: "test-product-id",
|
||||||
|
name: "test-product-name",
|
||||||
|
slug: "test-product-slug",
|
||||||
|
},
|
||||||
|
channelListings: [
|
||||||
|
{
|
||||||
|
channel: {
|
||||||
|
id: "test-channel-id",
|
||||||
|
slug: "test-channel-slug",
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
amount: 100,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockGetSpace = vi.fn();
|
||||||
|
const mockGetEnvironment = vi.fn();
|
||||||
|
const mockGetEnvironments = vi.fn();
|
||||||
|
const mockGetEnvEntry = vi.fn();
|
||||||
|
const mockGetContentTypes = vi.fn();
|
||||||
|
const mockCreateEntryWithId = vi.fn();
|
||||||
|
|
||||||
|
const mockContentfulSdk: ContentfulApiClientChunk = {
|
||||||
|
getSpace: mockGetSpace.mockReturnValue(
|
||||||
|
Promise.resolve({
|
||||||
|
getEnvironment: mockGetEnvironment.mockReturnValue({
|
||||||
|
getContentTypes: mockGetContentTypes.mockReturnValue({
|
||||||
|
items: [{}],
|
||||||
|
}),
|
||||||
|
getEntry: mockGetEnvEntry.mockReturnValue({}),
|
||||||
|
createEntryWithId: mockCreateEntryWithId.mockReturnValue({}),
|
||||||
|
}),
|
||||||
|
getEnvironments: mockGetEnvironments.mockReturnValue({}),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ContentfulClient", () => {
|
||||||
|
let contentfulClient: ContentfulClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
contentfulClient = new ContentfulClient(
|
||||||
|
{
|
||||||
|
accessToken: "test-token",
|
||||||
|
space: "test-space",
|
||||||
|
},
|
||||||
|
() => mockContentfulSdk
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getContentTypes", () => {
|
||||||
|
it("Calls contentful SDK to fetch space->environment->contentTypes", async () => {
|
||||||
|
await contentfulClient.getContentTypes("master");
|
||||||
|
|
||||||
|
expect(mockGetContentTypes).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getEnvironments", () => {
|
||||||
|
it("Calls contentful SDK to fetch space->environments list", async () => {
|
||||||
|
await contentfulClient.getEnvironments();
|
||||||
|
|
||||||
|
expect(mockGetEnvironments).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateProductVariant", () => {
|
||||||
|
it("Mutates the entry fields and calls update method", async () => {
|
||||||
|
const mockEntry = {
|
||||||
|
fields: {},
|
||||||
|
update: vi.fn().mockReturnValue(Promise.resolve({})),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetEnvEntry.mockReturnValue(mockEntry);
|
||||||
|
|
||||||
|
const mockConfig = getMockContenfulConfiguration();
|
||||||
|
const mockMapping = mockConfig.productVariantFieldsMapping;
|
||||||
|
|
||||||
|
const mockVariant = getMockWebhookProductVariant();
|
||||||
|
|
||||||
|
await contentfulClient.updateProductVariant({
|
||||||
|
configuration: mockConfig,
|
||||||
|
variant: mockVariant,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetEnvEntry).toHaveBeenCalledWith(mockVariant.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields must reflect mapping config to variant real data
|
||||||
|
*
|
||||||
|
* App supports and hardcodes the locale to en-US now
|
||||||
|
*/
|
||||||
|
expect(mockEntry.fields).toEqual({
|
||||||
|
[mockMapping.productId]: {
|
||||||
|
"en-US": mockVariant.product.id,
|
||||||
|
},
|
||||||
|
[mockMapping.productName]: {
|
||||||
|
"en-US": mockVariant.product.name,
|
||||||
|
},
|
||||||
|
[mockMapping.productSlug]: {
|
||||||
|
"en-US": mockVariant.product.slug,
|
||||||
|
},
|
||||||
|
[mockMapping.variantId]: {
|
||||||
|
"en-US": mockVariant.id,
|
||||||
|
},
|
||||||
|
[mockMapping.variantName]: {
|
||||||
|
"en-US": mockVariant.name,
|
||||||
|
},
|
||||||
|
[mockMapping.channels]: {
|
||||||
|
"en-US": mockVariant.channelListings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockEntry.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteProductVariant", () => {
|
||||||
|
it("Calls contentful delete method on fetched entry", async () => {
|
||||||
|
const mockEntry = {
|
||||||
|
delete: vi.fn().mockReturnValue(Promise.resolve({})),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetEnvEntry.mockReturnValue(mockEntry);
|
||||||
|
|
||||||
|
const mockConfig = getMockContenfulConfiguration();
|
||||||
|
const mockVariant = getMockWebhookProductVariant();
|
||||||
|
|
||||||
|
await contentfulClient.deleteProductVariant({
|
||||||
|
configuration: mockConfig,
|
||||||
|
variant: { id: mockVariant.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetEnvEntry).toHaveBeenCalledWith(mockVariant.id);
|
||||||
|
expect(mockEntry.delete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("uploadProductVariant", () => {
|
||||||
|
it("Calls contentful createEntryWithId method with correct mapped fields", async () => {
|
||||||
|
const mockConfig = getMockContenfulConfiguration();
|
||||||
|
const mockMapping = mockConfig.productVariantFieldsMapping;
|
||||||
|
|
||||||
|
const mockVariant = getMockWebhookProductVariant();
|
||||||
|
|
||||||
|
await contentfulClient.uploadProductVariant({
|
||||||
|
configuration: mockConfig,
|
||||||
|
variant: mockVariant,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCreateEntryWithId).toHaveBeenCalledWith(mockConfig.contentId, mockVariant.id, {
|
||||||
|
fields: {
|
||||||
|
[mockMapping.productId]: {
|
||||||
|
"en-US": mockVariant.product.id,
|
||||||
|
},
|
||||||
|
[mockMapping.productName]: {
|
||||||
|
"en-US": mockVariant.product.name,
|
||||||
|
},
|
||||||
|
[mockMapping.productSlug]: {
|
||||||
|
"en-US": mockVariant.product.slug,
|
||||||
|
},
|
||||||
|
[mockMapping.variantId]: {
|
||||||
|
"en-US": mockVariant.id,
|
||||||
|
},
|
||||||
|
[mockMapping.variantName]: {
|
||||||
|
"en-US": mockVariant.name,
|
||||||
|
},
|
||||||
|
[mockMapping.channels]: {
|
||||||
|
"en-US": mockVariant.channelListings,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upsertProductVariant", () => {
|
||||||
|
it("Calls standard create method on SDK if entry does not exist", async () => {
|
||||||
|
const mockConfig = getMockContenfulConfiguration();
|
||||||
|
const mockMapping = mockConfig.productVariantFieldsMapping;
|
||||||
|
|
||||||
|
const mockVariant = getMockWebhookProductVariant();
|
||||||
|
|
||||||
|
await contentfulClient.upsertProductVariant({
|
||||||
|
configuration: mockConfig,
|
||||||
|
variant: mockVariant,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGetEnvEntry).not.toHaveBeenCalled();
|
||||||
|
expect(mockCreateEntryWithId).toHaveBeenCalledWith(mockConfig.contentId, mockVariant.id, {
|
||||||
|
fields: {
|
||||||
|
[mockMapping.productId]: {
|
||||||
|
"en-US": mockVariant.product.id,
|
||||||
|
},
|
||||||
|
[mockMapping.productName]: {
|
||||||
|
"en-US": mockVariant.product.name,
|
||||||
|
},
|
||||||
|
[mockMapping.productSlug]: {
|
||||||
|
"en-US": mockVariant.product.slug,
|
||||||
|
},
|
||||||
|
[mockMapping.variantId]: {
|
||||||
|
"en-US": mockVariant.id,
|
||||||
|
},
|
||||||
|
[mockMapping.variantName]: {
|
||||||
|
"en-US": mockVariant.name,
|
||||||
|
},
|
||||||
|
[mockMapping.channels]: {
|
||||||
|
"en-US": mockVariant.channelListings,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Calls update method if SDK returned 409 error", async () => {
|
||||||
|
const mockConfig = getMockContenfulConfiguration();
|
||||||
|
const mockMapping = mockConfig.productVariantFieldsMapping;
|
||||||
|
|
||||||
|
const mockVariant = getMockWebhookProductVariant();
|
||||||
|
|
||||||
|
mockCreateEntryWithId.mockRejectedValue({
|
||||||
|
message: JSON.stringify({
|
||||||
|
status: 409,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockEntry = {
|
||||||
|
fields: {},
|
||||||
|
update: vi.fn().mockReturnValue(Promise.resolve({})),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetEnvEntry.mockReturnValue(mockEntry);
|
||||||
|
|
||||||
|
await contentfulClient.upsertProductVariant({
|
||||||
|
configuration: mockConfig,
|
||||||
|
variant: mockVariant,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockEntry.fields).toEqual({
|
||||||
|
[mockMapping.productId]: {
|
||||||
|
"en-US": mockVariant.product.id,
|
||||||
|
},
|
||||||
|
[mockMapping.productName]: {
|
||||||
|
"en-US": mockVariant.product.name,
|
||||||
|
},
|
||||||
|
[mockMapping.productSlug]: {
|
||||||
|
"en-US": mockVariant.product.slug,
|
||||||
|
},
|
||||||
|
[mockMapping.variantId]: {
|
||||||
|
"en-US": mockVariant.id,
|
||||||
|
},
|
||||||
|
[mockMapping.variantName]: {
|
||||||
|
"en-US": mockVariant.name,
|
||||||
|
},
|
||||||
|
[mockMapping.channels]: {
|
||||||
|
"en-US": mockVariant.channelListings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockEntry.update).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { createClient, ClientAPI } from "contentful-management";
|
||||||
|
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
|
||||||
|
import { ContentfulProviderConfig } from "@/modules/configuration";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
|
||||||
|
const ContentfulErrorMessageSchema = z.object({
|
||||||
|
status: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ConstructorOptions = {
|
||||||
|
space: string;
|
||||||
|
accessToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContentfulApiClientChunk = Pick<ClientAPI, "getSpace">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow injecting mocked client instead of mocking whole module
|
||||||
|
*/
|
||||||
|
type SdkClientFactory = (opts: ConstructorOptions) => ContentfulApiClientChunk;
|
||||||
|
|
||||||
|
const defaultSdkClientFactory: SdkClientFactory = (opts) =>
|
||||||
|
createClient({
|
||||||
|
accessToken: opts.accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper facade of
|
||||||
|
* https://www.npmjs.com/package/contentful
|
||||||
|
*/
|
||||||
|
export class ContentfulClient {
|
||||||
|
private client: ContentfulApiClientChunk;
|
||||||
|
private space: string;
|
||||||
|
|
||||||
|
private logger = createLogger({ name: "ContentfulClient" });
|
||||||
|
|
||||||
|
constructor(opts: ConstructorOptions, clientFactory: SdkClientFactory = defaultSdkClientFactory) {
|
||||||
|
this.space = opts.space;
|
||||||
|
|
||||||
|
this.client = clientFactory(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Support only en-US locale now
|
||||||
|
*/
|
||||||
|
private mapVariantToConfiguredFields = (
|
||||||
|
variant: WebhookProductVariantFragment,
|
||||||
|
productVariantFieldsMapping: ContentfulProviderConfig.FullShape["productVariantFieldsMapping"]
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
channels,
|
||||||
|
variantName: name,
|
||||||
|
productId,
|
||||||
|
productName,
|
||||||
|
productSlug,
|
||||||
|
variantId,
|
||||||
|
} = productVariantFieldsMapping;
|
||||||
|
|
||||||
|
return {
|
||||||
|
[name]: {
|
||||||
|
"en-US": variant.name,
|
||||||
|
},
|
||||||
|
[productId]: {
|
||||||
|
"en-US": variant.product.id,
|
||||||
|
},
|
||||||
|
[productName]: {
|
||||||
|
"en-US": variant.product.name,
|
||||||
|
},
|
||||||
|
[productSlug]: {
|
||||||
|
"en-US": variant.product.slug,
|
||||||
|
},
|
||||||
|
[variantId]: {
|
||||||
|
"en-US": variant.id,
|
||||||
|
},
|
||||||
|
[channels]: {
|
||||||
|
"en-US": variant.channelListings,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async getContentTypes(env: string) {
|
||||||
|
this.logger.trace("Attempting to get content types");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const space = await this.client.getSpace(this.space);
|
||||||
|
const environment = await space.getEnvironment(env);
|
||||||
|
const contentTypes = await environment.getContentTypes();
|
||||||
|
|
||||||
|
return contentTypes;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(err);
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEnvironments() {
|
||||||
|
this.logger.trace("Attempting to get environments");
|
||||||
|
|
||||||
|
return (await this.client.getSpace(this.space)).getEnvironments();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProductVariant({
|
||||||
|
configuration,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
configuration: ContentfulProviderConfig.FullShape;
|
||||||
|
variant: WebhookProductVariantFragment;
|
||||||
|
}) {
|
||||||
|
this.logger.debug("Attempting to update product variant");
|
||||||
|
|
||||||
|
const space = await this.client.getSpace(this.space);
|
||||||
|
const env = await space.getEnvironment(configuration.environment);
|
||||||
|
|
||||||
|
const entry = await env.getEntry(variant.id);
|
||||||
|
|
||||||
|
entry.fields = this.mapVariantToConfiguredFields(
|
||||||
|
variant,
|
||||||
|
configuration.productVariantFieldsMapping
|
||||||
|
);
|
||||||
|
|
||||||
|
return entry.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProductVariant(opts: {
|
||||||
|
configuration: ContentfulProviderConfig.FullShape;
|
||||||
|
variant: Pick<WebhookProductVariantFragment, "id">;
|
||||||
|
}) {
|
||||||
|
this.logger.debug("Attempting to delete product variant");
|
||||||
|
|
||||||
|
const space = await this.client.getSpace(this.space);
|
||||||
|
const env = await space.getEnvironment(opts.configuration.environment);
|
||||||
|
|
||||||
|
const entry = await env.getEntry(opts.variant.id);
|
||||||
|
|
||||||
|
return await entry.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadProductVariant({
|
||||||
|
configuration,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
configuration: ContentfulProviderConfig.FullShape;
|
||||||
|
variant: WebhookProductVariantFragment;
|
||||||
|
}) {
|
||||||
|
this.logger.debug("Attempting to upload product variant");
|
||||||
|
|
||||||
|
const space = await this.client.getSpace(this.space);
|
||||||
|
const env = await space.getEnvironment(configuration.environment);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* TODO: add translations
|
||||||
|
* TODO: - should it create published? is draft
|
||||||
|
*/
|
||||||
|
return env.createEntryWithId(configuration.contentId, variant.id, {
|
||||||
|
fields: this.mapVariantToConfiguredFields(variant, configuration.productVariantFieldsMapping),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertProductVariant(opts: {
|
||||||
|
configuration: ContentfulProviderConfig.FullShape;
|
||||||
|
variant: WebhookProductVariantFragment;
|
||||||
|
}) {
|
||||||
|
this.logger.debug("Attempting to upsert product variant");
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.trace("Attempting to upload product variant first");
|
||||||
|
|
||||||
|
return await this.uploadProductVariant(opts);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
this.logger.trace("Upload failed");
|
||||||
|
|
||||||
|
if (typeof e !== "object" || e === null) {
|
||||||
|
Sentry.captureMessage("Contentful error is not expected shape");
|
||||||
|
Sentry.captureException(e);
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedError = ContentfulErrorMessageSchema.parse(JSON.parse((e as Error).message));
|
||||||
|
|
||||||
|
if (parsedError.status === 409) {
|
||||||
|
this.logger.trace("Contentful returned 409 status, will try to update instead");
|
||||||
|
|
||||||
|
return this.updateProductVariant(opts);
|
||||||
|
} else {
|
||||||
|
Sentry.captureMessage("Contentful error failed and is not handled");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,404 @@
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Input, Select } from "@saleor/react-hook-form-macaw";
|
||||||
|
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { ContentfulProviderConfig } from "../../configuration/schemas/contentful-provider.schema";
|
||||||
|
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ButtonsBox, TextLink } from "@saleor/apps-ui";
|
||||||
|
import { SaleorProviderFieldsMappingKeys } from "@/modules/configuration";
|
||||||
|
|
||||||
|
type FormSchema = Omit<ContentfulProviderConfig.InputShape, "type">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO - when space, token or env changes, refetch queries
|
||||||
|
* TODO - refactor smaller hooks
|
||||||
|
*/
|
||||||
|
const PureForm = ({
|
||||||
|
defaultValues,
|
||||||
|
onSubmit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
defaultValues: FormSchema;
|
||||||
|
onSubmit(values: FormSchema): void;
|
||||||
|
onDelete?(): void;
|
||||||
|
}) => {
|
||||||
|
const { notifyError } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { control, getValues, setValue, watch, handleSubmit, setError, clearErrors } = useForm({
|
||||||
|
defaultValues: defaultValues,
|
||||||
|
resolver: zodResolver(ContentfulProviderConfig.Schema.Input.omit({ type: true })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: fetchContentTypes, data: contentTypesData } =
|
||||||
|
trpcClient.contentful.fetchContentTypesFromApi.useMutation({
|
||||||
|
onSuccess(data) {
|
||||||
|
setValue("contentId", data.items[0].sys.id ?? null);
|
||||||
|
|
||||||
|
clearErrors(["authToken", "spaceId"]);
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
setError("authToken", {
|
||||||
|
type: "custom",
|
||||||
|
message: "Invalid credentials",
|
||||||
|
});
|
||||||
|
setError("spaceId", {
|
||||||
|
type: "custom",
|
||||||
|
message: "Invalid credentials",
|
||||||
|
});
|
||||||
|
notifyError(
|
||||||
|
"Error",
|
||||||
|
"Could not fetch content types from Contentful. Please check your credentials.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: fetchEnvironments, data: environmentsData } =
|
||||||
|
trpcClient.contentful.fetchEnvironmentsFromApi.useMutation({
|
||||||
|
onSuccess(data) {
|
||||||
|
setValue("environment", data.items[0].sys.id);
|
||||||
|
|
||||||
|
clearErrors(["authToken", "spaceId"]);
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
setError("authToken", {
|
||||||
|
type: "custom",
|
||||||
|
message: "Invalid credentials",
|
||||||
|
});
|
||||||
|
setError("spaceId", {
|
||||||
|
type: "custom",
|
||||||
|
message: "Invalid credentials",
|
||||||
|
});
|
||||||
|
notifyError(
|
||||||
|
"Error",
|
||||||
|
"Could not fetch environments from Contentful. Please check your credentials.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedContentTypeId = watch("contentId");
|
||||||
|
|
||||||
|
const availableFields = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return contentTypesData?.items?.find((i) => i.sys.id === selectedContentTypeId)?.fields;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [selectedContentTypeId, contentTypesData?.items]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For "edit" form variant, tokens already exist, so fetch immediately
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultValues.authToken && defaultValues.spaceId && defaultValues.environment) {
|
||||||
|
fetchContentTypes({
|
||||||
|
contentfulSpace: defaultValues.spaceId,
|
||||||
|
contentfulToken: defaultValues.authToken,
|
||||||
|
contentfulEnv: defaultValues.environment,
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchEnvironments({
|
||||||
|
contentfulSpace: defaultValues.spaceId,
|
||||||
|
contentfulToken: defaultValues.authToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
defaultValues.authToken,
|
||||||
|
defaultValues.spaceId,
|
||||||
|
defaultValues.environment,
|
||||||
|
fetchContentTypes,
|
||||||
|
fetchEnvironments,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
display={"grid"}
|
||||||
|
gap={4}
|
||||||
|
onSubmit={handleSubmit((vals) => {
|
||||||
|
onSubmit(vals);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
control={control}
|
||||||
|
name="configName"
|
||||||
|
label="Configuration name"
|
||||||
|
helperText="Meaningful name that will help you understand it later. E.g. 'staging' or 'prod' "
|
||||||
|
/>
|
||||||
|
<Box display={"grid"} gap={4} marginY={4}>
|
||||||
|
<Text variant="heading">Provide connection details</Text>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
control={control}
|
||||||
|
name="spaceId"
|
||||||
|
label="Contentful space ID"
|
||||||
|
helperText={
|
||||||
|
<Text variant="caption" color="textNeutralSubdued">
|
||||||
|
Check{" "}
|
||||||
|
<TextLink size="small" href="https://www.contentful.com/help/find-space-id/" newTab>
|
||||||
|
how to get space ID
|
||||||
|
</TextLink>
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
control={control}
|
||||||
|
name="authToken"
|
||||||
|
label="Content Management Personal token"
|
||||||
|
helperText={
|
||||||
|
<Text variant="caption" color="textNeutralSubdued">
|
||||||
|
Check{" "}
|
||||||
|
<TextLink
|
||||||
|
size="small"
|
||||||
|
href="https://www.contentful.com/help/personal-access-tokens/"
|
||||||
|
newTab
|
||||||
|
>
|
||||||
|
how to generate token
|
||||||
|
</TextLink>
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!environmentsData && (
|
||||||
|
<ButtonsBox>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
const values = getValues();
|
||||||
|
|
||||||
|
return fetchEnvironments({
|
||||||
|
contentfulSpace: values.spaceId,
|
||||||
|
contentfulToken: values.authToken,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
)}
|
||||||
|
{environmentsData && (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
required
|
||||||
|
control={control}
|
||||||
|
name="environment"
|
||||||
|
label="Contentful environment"
|
||||||
|
helperText={
|
||||||
|
<Text variant="caption" color="textNeutralSubdued">
|
||||||
|
Check your environment{" "}
|
||||||
|
<TextLink
|
||||||
|
newTab
|
||||||
|
size="small"
|
||||||
|
href={`https://app.contentful.com/spaces/${getValues(
|
||||||
|
"spaceId",
|
||||||
|
)}/settings/environments`}
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</TextLink>
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
options={environmentsData.items.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.sys.id,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
{!contentTypesData && (
|
||||||
|
<ButtonsBox>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
const values = getValues();
|
||||||
|
|
||||||
|
return fetchContentTypes({
|
||||||
|
contentfulSpace: values.spaceId,
|
||||||
|
contentfulToken: values.authToken,
|
||||||
|
contentfulEnv: values.environment,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{contentTypesData && (
|
||||||
|
<Box display={"grid"} gap={4} marginY={4}>
|
||||||
|
<Text variant="heading">Configure fields mapping</Text>
|
||||||
|
<Text as="p">First select content type you want to synchronize products with.</Text>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Content Type"
|
||||||
|
control={control}
|
||||||
|
name="contentId"
|
||||||
|
options={contentTypesData?.items.map((contentType) => ({
|
||||||
|
label: contentType.name,
|
||||||
|
value: contentType.sys.id,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box marginTop={4}>
|
||||||
|
<Text as="p" variant="heading" size="small">
|
||||||
|
Map fields from Saleor to your Contentful schema.
|
||||||
|
</Text>
|
||||||
|
<Text as="p" marginTop={2} marginBottom={4}>
|
||||||
|
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should
|
||||||
|
be type of <Text variant="bodyStrong">JSON</Text>.
|
||||||
|
</Text>
|
||||||
|
<Box
|
||||||
|
marginBottom={4}
|
||||||
|
display="grid"
|
||||||
|
__gridTemplateColumns={"50% 50%"}
|
||||||
|
borderBottomWidth={1}
|
||||||
|
borderBottomStyle="solid"
|
||||||
|
borderColor="neutralHighlight"
|
||||||
|
padding={2}
|
||||||
|
>
|
||||||
|
<Text variant="caption">Saleor Field</Text>
|
||||||
|
<Text variant="caption">Contentful field</Text>
|
||||||
|
</Box>
|
||||||
|
{availableFields &&
|
||||||
|
SaleorProviderFieldsMappingKeys.map((saleorField) => (
|
||||||
|
<Box
|
||||||
|
display="grid"
|
||||||
|
__gridTemplateColumns={"50% 50%"}
|
||||||
|
padding={2}
|
||||||
|
key={saleorField}
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Text as="p" variant="bodyStrong">
|
||||||
|
{printSaleorProductFields(saleorField)}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption">
|
||||||
|
{saleorField === "channels" ? "JSON field" : "Text field"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
control={control}
|
||||||
|
name={`productVariantFieldsMapping.${saleorField}`}
|
||||||
|
label="Contentful Field"
|
||||||
|
options={availableFields.map((f) => ({
|
||||||
|
label: f.name,
|
||||||
|
value: f.id,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<ButtonsBox>
|
||||||
|
{onDelete && (
|
||||||
|
<Button onClick={onDelete} variant="tertiary">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddVariant = () => {
|
||||||
|
const { push } = useRouter();
|
||||||
|
const { notifySuccess } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { mutate } = trpcClient.providersConfigs.addOne.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Saved configuration");
|
||||||
|
push("/configuration");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentfulConfigForm.PureVariant
|
||||||
|
onSubmit={(values) => {
|
||||||
|
mutate({
|
||||||
|
...values,
|
||||||
|
type: "contentful",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
defaultValues={{
|
||||||
|
authToken: "",
|
||||||
|
configName: "",
|
||||||
|
environment: "",
|
||||||
|
contentId: "",
|
||||||
|
productVariantFieldsMapping: {
|
||||||
|
channels: "",
|
||||||
|
variantName: "",
|
||||||
|
productId: "",
|
||||||
|
productName: "",
|
||||||
|
productSlug: "",
|
||||||
|
variantId: "",
|
||||||
|
},
|
||||||
|
spaceId: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditVariant = ({ configId }: { configId: string }) => {
|
||||||
|
const { push } = useRouter();
|
||||||
|
const { notifySuccess } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { data } = trpcClient.providersConfigs.getOne.useQuery(
|
||||||
|
{
|
||||||
|
id: configId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!configId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Updated configuration");
|
||||||
|
push("/configuration");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteProvider } = trpcClient.providersConfigs.deleteOne.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Removed configuration");
|
||||||
|
push("/configuration");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <Text>Loading</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type !== "contentful") {
|
||||||
|
throw new Error("Trying to fill contentful form with non contentful data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentfulConfigForm.PureVariant
|
||||||
|
onDelete={() => {
|
||||||
|
deleteProvider({ id: configId });
|
||||||
|
}}
|
||||||
|
defaultValues={data}
|
||||||
|
onSubmit={(values) =>
|
||||||
|
mutate({
|
||||||
|
...values,
|
||||||
|
id: configId,
|
||||||
|
type: "contentful",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ContentfulConfigForm = {
|
||||||
|
PureVariant: PureForm,
|
||||||
|
AddVariant,
|
||||||
|
EditVariant,
|
||||||
|
};
|
|
@ -0,0 +1 @@
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150"><defs><style>.cls-1{fill:#0033a3;}.cls-2{fill:#ffda00;}.cls-3{fill:#67b3ff;}.cls-4{fill:#eb5a68;}.cls-5{fill:#47a1ff;}.cls-6{fill:#d5465f;}</style></defs><circle class="cls-1" cx="75" cy="75" r="75"/><path class="cls-2" d="M61.2,92.29a24.59,24.59,0,0,1-7.14-17.44,24.3,24.3,0,0,1,7.26-17.37A9.68,9.68,0,0,0,47.67,43.76a44,44,0,0,0-.19,62.18A9.68,9.68,0,1,0,61.2,92.29Z"/><path class="cls-3" d="M61.32,57.48a24.57,24.57,0,0,1,17.44-7.14A24.32,24.32,0,0,1,96.13,57.6,9.67,9.67,0,1,0,109.84,44a43.82,43.82,0,0,0-62.11-.25A9.68,9.68,0,0,0,61.32,57.48Z"/><path class="cls-4" d="M96.07,92.41a24.59,24.59,0,0,1-17.44,7.14,24.34,24.34,0,0,1-17.37-7.26,9.67,9.67,0,1,0-13.71,13.65,43.88,43.88,0,0,0,62.17.24A9.69,9.69,0,0,0,96.07,92.41Z"/><path class="cls-5" d="M61.32,57.48a9.68,9.68,0,1,1,.06-13.65A9.71,9.71,0,0,1,61.32,57.48Z"/><path class="cls-6" d="M61.13,105.94a9.68,9.68,0,1,1,.07-13.65A9.7,9.7,0,0,1,61.13,105.94Z"/></svg>
|
After Width: | Height: | Size: 1,017 B |
|
@ -0,0 +1,7 @@
|
||||||
|
import { pRateLimit } from "p-ratelimit";
|
||||||
|
|
||||||
|
export const contentfulRateLimiter = pRateLimit({
|
||||||
|
interval: 1000,
|
||||||
|
rate: 2,
|
||||||
|
concurrency: 2,
|
||||||
|
});
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { describe, expect, vi, it, beforeEach } from "vitest";
|
||||||
|
import {
|
||||||
|
ContentfulClientStrip,
|
||||||
|
ContentfulWebhooksProcessor,
|
||||||
|
} from "./contentful-webhooks-processor";
|
||||||
|
import { ContentfulProviderConfig } from "@/modules/configuration";
|
||||||
|
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
|
||||||
|
|
||||||
|
const getMockContenfulConfiguration = (): ContentfulProviderConfig.FullShape => ({
|
||||||
|
authToken: "test-token",
|
||||||
|
configName: "test-config-name",
|
||||||
|
contentId: "test-content-id",
|
||||||
|
id: "test-id",
|
||||||
|
spaceId: "test-space-id",
|
||||||
|
type: "contentful",
|
||||||
|
environment: "master",
|
||||||
|
productVariantFieldsMapping: {
|
||||||
|
channels: "channels",
|
||||||
|
productId: "product-id",
|
||||||
|
productName: "product-name",
|
||||||
|
productSlug: "product-slug",
|
||||||
|
variantId: "variant-id",
|
||||||
|
variantName: "variant-name",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getMockWebhookProductVariant = (): WebhookProductVariantFragment => {
|
||||||
|
return {
|
||||||
|
id: "test-id",
|
||||||
|
name: "test-name",
|
||||||
|
product: {
|
||||||
|
id: "test-product-id",
|
||||||
|
name: "test-product-name",
|
||||||
|
slug: "test-product-slug",
|
||||||
|
},
|
||||||
|
channelListings: [
|
||||||
|
{
|
||||||
|
channel: {
|
||||||
|
id: "test-channel-id",
|
||||||
|
slug: "test-channel-slug",
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
amount: 100,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockContentfulClient: ContentfulClientStrip = {
|
||||||
|
deleteProductVariant: vi.fn(),
|
||||||
|
upsertProductVariant: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ContentfulWebhooksProcessor", () => {
|
||||||
|
let processor: ContentfulWebhooksProcessor;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
processor = new ContentfulWebhooksProcessor(
|
||||||
|
getMockContenfulConfiguration(),
|
||||||
|
() => mockContentfulClient
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onProductVariantUpdated calls client upsert method", () => {
|
||||||
|
const mockProductVariant = getMockWebhookProductVariant();
|
||||||
|
|
||||||
|
processor.onProductVariantUpdated(mockProductVariant);
|
||||||
|
|
||||||
|
expect(mockContentfulClient.upsertProductVariant).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
configuration: getMockContenfulConfiguration(),
|
||||||
|
variant: mockProductVariant,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onProductVariantCreated calls client upsert method", () => {
|
||||||
|
const mockProductVariant = getMockWebhookProductVariant();
|
||||||
|
|
||||||
|
processor.onProductVariantCreated(mockProductVariant);
|
||||||
|
|
||||||
|
expect(mockContentfulClient.upsertProductVariant).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
configuration: getMockContenfulConfiguration(),
|
||||||
|
variant: mockProductVariant,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onProductVariantDeleted calls client delete method", () => {
|
||||||
|
const mockProductVariant = getMockWebhookProductVariant();
|
||||||
|
|
||||||
|
processor.onProductVariantDeleted(mockProductVariant);
|
||||||
|
|
||||||
|
expect(mockContentfulClient.deleteProductVariant).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
configuration: getMockContenfulConfiguration(),
|
||||||
|
variant: mockProductVariant,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onProductUpdated calls client upsert method for every product variant", () => {
|
||||||
|
const mockProductVariant1 = getMockWebhookProductVariant();
|
||||||
|
const mockProductVariant2 = getMockWebhookProductVariant();
|
||||||
|
const mockProductVariant3 = getMockWebhookProductVariant();
|
||||||
|
|
||||||
|
mockProductVariant1.id = "test-product-variant-id-1";
|
||||||
|
mockProductVariant2.id = "test-product-variant-id-2";
|
||||||
|
mockProductVariant3.id = "test-product-variant-id-3";
|
||||||
|
|
||||||
|
processor.onProductUpdated({
|
||||||
|
id: "test-product-id",
|
||||||
|
name: "test-product-name",
|
||||||
|
slug: "test-product-slug",
|
||||||
|
variants: [mockProductVariant1, mockProductVariant2, mockProductVariant3],
|
||||||
|
channelListings: [
|
||||||
|
{
|
||||||
|
channel: { id: "test-channel-id", slug: "test-channel-slug" },
|
||||||
|
id: "test-id",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockContentfulClient.upsertProductVariant).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
import {
|
||||||
|
WebhookProductFragment,
|
||||||
|
WebhookProductVariantFragment,
|
||||||
|
} from "../../../../generated/graphql";
|
||||||
|
import { ContentfulProviderConfig } from "../../configuration";
|
||||||
|
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
|
||||||
|
import { ContentfulClient } from "./contentful-client";
|
||||||
|
|
||||||
|
export type ContentfulClientStrip = Pick<
|
||||||
|
ContentfulClient,
|
||||||
|
"upsertProductVariant" | "deleteProductVariant"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type ContentfulClientFactory = (
|
||||||
|
config: ContentfulProviderConfig.FullShape
|
||||||
|
) => ContentfulClientStrip;
|
||||||
|
|
||||||
|
export class ContentfulWebhooksProcessor implements ProductWebhooksProcessor {
|
||||||
|
private client: ContentfulClientStrip;
|
||||||
|
private logger = createLogger({ name: "ContentfulWebhooksProcessor" });
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private providerConfig: ContentfulProviderConfig.FullShape,
|
||||||
|
clientFactory: ContentfulClientFactory = () =>
|
||||||
|
new ContentfulClient({
|
||||||
|
accessToken: providerConfig.authToken,
|
||||||
|
space: providerConfig.spaceId,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
this.client = clientFactory(providerConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||||
|
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.upsertProductVariant({
|
||||||
|
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");
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
(product.variants ?? []).map((variant) => {
|
||||||
|
return this.client.upsertProductVariant({
|
||||||
|
configuration: this.providerConfig,
|
||||||
|
variant: {
|
||||||
|
id: variant.id,
|
||||||
|
name: variant.name,
|
||||||
|
product: {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
slug: product.slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AppConfigMetadataManager } from "../../configuration/app-config-metadata-manager";
|
||||||
|
|
||||||
|
import { createSettingsManager } from "../../configuration/metadata-manager";
|
||||||
|
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
|
||||||
|
import { router } from "../../trpc/trpc-server";
|
||||||
|
|
||||||
|
import { ContentfulClient } from "./contentful-client";
|
||||||
|
import { createFlatProxy } from "@trpc/server/shared";
|
||||||
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
|
||||||
|
const procedure = protectedClientProcedure.use(({ ctx, next }) => {
|
||||||
|
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
settingsManager,
|
||||||
|
appConfigService: new AppConfigMetadataManager(settingsManager),
|
||||||
|
logger: createLogger({ name: "contentfulRouter" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations specific for Contentful service.
|
||||||
|
*
|
||||||
|
* For configruration see providers-list.router.ts
|
||||||
|
*/
|
||||||
|
export const contentfulRouter = router({
|
||||||
|
fetchEnvironmentsFromApi: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
contentfulToken: z.string(),
|
||||||
|
contentfulSpace: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(({ ctx, input }) => {
|
||||||
|
const client = new ContentfulClient({
|
||||||
|
accessToken: input.contentfulToken,
|
||||||
|
space: input.contentfulSpace,
|
||||||
|
});
|
||||||
|
|
||||||
|
return client.getEnvironments().catch((e) => {
|
||||||
|
ctx.logger.error("Failed to fetch environments");
|
||||||
|
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST" });
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
fetchContentTypesFromApi: procedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
contentfulToken: z.string(),
|
||||||
|
contentfulSpace: z.string(),
|
||||||
|
contentfulEnv: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(({ ctx, input }) => {
|
||||||
|
const client = new ContentfulClient({
|
||||||
|
accessToken: input.contentfulToken,
|
||||||
|
space: input.contentfulSpace,
|
||||||
|
});
|
||||||
|
|
||||||
|
return client.getContentTypes(input.contentfulEnv).catch((e) => {
|
||||||
|
ctx.logger.error("Failed to fetch content types");
|
||||||
|
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST" });
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
13
apps/cms-v2/src/modules/providers/contentful/contentful.tsx
Normal file
13
apps/cms-v2/src/modules/providers/contentful/contentful.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { Text } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
import logo from "./contentful-logo.svg";
|
||||||
|
import { CMSProviderMeta } from "../cms-provider-meta";
|
||||||
|
|
||||||
|
export const Contentful = {
|
||||||
|
formSideInfo: <Text>App will save each variant with the same ID as variant ID.</Text>,
|
||||||
|
type: "contentful" as const,
|
||||||
|
logoUrl: logo.src as string,
|
||||||
|
displayName: "Contentful",
|
||||||
|
description:
|
||||||
|
"More than a headless CMS, Contentful is the API-first composable content platform to create, manage and publish content on any digital channel.",
|
||||||
|
} satisfies CMSProviderMeta;
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
|
||||||
|
import { BulkImportProductFragment } from "../../../../generated/graphql";
|
||||||
|
import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
|
||||||
|
|
||||||
|
import { DatoCMSClient } from "./datocms-client";
|
||||||
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
|
||||||
|
export class DatocmsBulkSyncProcessor implements BulkSyncProcessor {
|
||||||
|
constructor(private config: DatocmsProviderConfig.FullShape) {}
|
||||||
|
|
||||||
|
async uploadProducts(
|
||||||
|
products: BulkImportProductFragment[],
|
||||||
|
hooks: BulkSyncProcessorHooks
|
||||||
|
): Promise<void> {
|
||||||
|
const client = new DatoCMSClient({
|
||||||
|
apiToken: this.config.authToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
products.flatMap((product) =>
|
||||||
|
product.variants?.map((variant) => {
|
||||||
|
if (hooks.onUploadStart) {
|
||||||
|
hooks.onUploadStart({ variantId: variant.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return client
|
||||||
|
.upsertProduct({
|
||||||
|
configuration: this.config,
|
||||||
|
variant: {
|
||||||
|
id: variant.id,
|
||||||
|
name: variant.name,
|
||||||
|
channelListings: variant.channelListings,
|
||||||
|
product: {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
slug: product.slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (hooks.onUploadSuccess) {
|
||||||
|
hooks.onUploadSuccess({ variantId: variant.id });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (hooks.onUploadError) {
|
||||||
|
hooks.onUploadError({ variantId: variant.id, error: e });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
187
apps/cms-v2/src/modules/providers/datocms/datocms-client.ts
Normal file
187
apps/cms-v2/src/modules/providers/datocms/datocms-client.ts
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
import { buildClient, Client, SimpleSchemaTypes, ApiError } from "@datocms/cma-client-browser";
|
||||||
|
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
|
||||||
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
|
||||||
|
import { FieldsMapper } from "../fields-mapper";
|
||||||
|
|
||||||
|
type Context = {
|
||||||
|
configuration: DatocmsProviderConfig.FullShape;
|
||||||
|
variant: WebhookProductVariantFragment;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* todo error handling
|
||||||
|
*/
|
||||||
|
export class DatoCMSClient {
|
||||||
|
private client: Client;
|
||||||
|
private logger = createLogger({ name: "DatoCMSClient" });
|
||||||
|
|
||||||
|
constructor(opts: { apiToken: string }) {
|
||||||
|
this.client = buildClient({ apiToken: opts.apiToken });
|
||||||
|
}
|
||||||
|
|
||||||
|
getContentTypes() {
|
||||||
|
this.logger.trace("Trying to get content types");
|
||||||
|
|
||||||
|
return this.client.itemTypes.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
getFieldsForContentType({ itemTypeID }: { itemTypeID: string }) {
|
||||||
|
this.logger.trace("Trying to get fields for a content type");
|
||||||
|
|
||||||
|
return this.client.fields.list({ type: "item_type", id: itemTypeID });
|
||||||
|
}
|
||||||
|
|
||||||
|
private getItemBySaleorVariantId({
|
||||||
|
variantIdFieldName: variantFieldName,
|
||||||
|
variantID,
|
||||||
|
contentType,
|
||||||
|
}: {
|
||||||
|
variantIdFieldName: string;
|
||||||
|
variantID: string;
|
||||||
|
contentType: string;
|
||||||
|
}) {
|
||||||
|
this.logger.trace("Trying to fetch item by Saleor variant ID", { variantID: variantID });
|
||||||
|
|
||||||
|
return this.client.items.list({
|
||||||
|
filter: {
|
||||||
|
type: contentType,
|
||||||
|
fields: {
|
||||||
|
[variantFieldName]: {
|
||||||
|
eq: variantID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapVariantToDatoCMSFields({
|
||||||
|
configuration,
|
||||||
|
variant,
|
||||||
|
}: Context): SimpleSchemaTypes.ItemCreateSchema {
|
||||||
|
const fields = FieldsMapper.mapProductVariantToConfigurationFields({
|
||||||
|
variant,
|
||||||
|
configMapping: configuration.productVariantFieldsMapping,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dato requires JSON to be stringified first so overwrite this single fields
|
||||||
|
*/
|
||||||
|
fields[configuration.productVariantFieldsMapping.channels] = JSON.stringify(
|
||||||
|
variant.channelListings
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
item_type: { type: "item_type", id: configuration.itemType },
|
||||||
|
...fields,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProductVariant({ configuration, variant }: Context) {
|
||||||
|
this.logger.debug("Trying to delete product variant");
|
||||||
|
|
||||||
|
const remoteProducts = await this.getItemBySaleorVariantId({
|
||||||
|
variantIdFieldName: configuration.productVariantFieldsMapping.variantId,
|
||||||
|
variantID: variant.id,
|
||||||
|
contentType: configuration.itemType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (remoteProducts.length > 1) {
|
||||||
|
this.logger.warn(
|
||||||
|
"More than 1 variant with the same ID found in the CMS. Will remove all of them, but this should not happen if unique field was set"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remoteProducts.length === 0) {
|
||||||
|
this.logger.trace("No product found in Datocms, skipping deletion");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
remoteProducts.map((p) => {
|
||||||
|
return this.client.items.rawDestroy(p.id);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadProductVariant(context: Context) {
|
||||||
|
this.logger.debug("Trying to upload product variant");
|
||||||
|
|
||||||
|
return this.client.items.create(this.mapVariantToDatoCMSFields(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProductVariant({ configuration, variant }: Context) {
|
||||||
|
const products = await this.getItemBySaleorVariantId({
|
||||||
|
variantIdFieldName: configuration.productVariantFieldsMapping.variantId,
|
||||||
|
variantID: variant.id,
|
||||||
|
contentType: configuration.itemType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (products.length > 1) {
|
||||||
|
this.logger.warn(
|
||||||
|
"Found more than one product variant with the same ID. Will update all of them, but this should not happen if unique field was set",
|
||||||
|
{
|
||||||
|
variantID: variant.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
products.map((product) => {
|
||||||
|
this.logger.trace("Trying to update variant", { datoID: product.id });
|
||||||
|
|
||||||
|
return this.client.items.update(
|
||||||
|
product.id,
|
||||||
|
this.mapVariantToDatoCMSFields({
|
||||||
|
configuration,
|
||||||
|
variant,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertProduct({ configuration, variant }: Context) {
|
||||||
|
this.logger.debug("Trying to upsert product variant");
|
||||||
|
|
||||||
|
const DatoErrorBody = z.object({
|
||||||
|
data: z.array(
|
||||||
|
z.object({
|
||||||
|
validation: z.object({
|
||||||
|
attributes: z.object({
|
||||||
|
details: z.object({
|
||||||
|
code: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.uploadProductVariant({ configuration, variant }).catch((err: ApiError) => {
|
||||||
|
try {
|
||||||
|
const errorBody = DatoErrorBody.parse(err.response.body);
|
||||||
|
|
||||||
|
const isUniqueIdError = errorBody.data.find(
|
||||||
|
(d) => d.validation.attributes.details.code === "VALIDATION_UNIQUE"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isUniqueIdError) {
|
||||||
|
return this.updateProductVariant({ configuration, variant });
|
||||||
|
} else {
|
||||||
|
throw new Error(JSON.stringify(err.cause));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Sentry.captureException("Invalid error shape from DatoCMS", (c) => {
|
||||||
|
return c.setExtra("error", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new Error(err.humanMessage ?? "DatoCMS error - can upload product variant");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,321 @@
|
||||||
|
import { SaleorProviderFieldsMappingKeys } from "@/modules/configuration";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||||
|
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
||||||
|
import { Input, Select } from "@saleor/react-hook-form-macaw";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import React, { useEffect, useMemo } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
|
||||||
|
import { trpcClient } from "../../trpc/trpc-client";
|
||||||
|
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
|
||||||
|
import { ButtonsBox } from "@saleor/apps-ui";
|
||||||
|
|
||||||
|
type FormShape = Omit<DatocmsProviderConfig.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(DatocmsProviderConfig.Schema.Input.omit({ type: true })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: fetchContentTypes, data: contentTypesData } =
|
||||||
|
trpcClient.datocms.fetchContentTypes.useMutation({
|
||||||
|
onSuccess(data) {
|
||||||
|
clearErrors("authToken");
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
setError("authToken", {
|
||||||
|
type: "custom",
|
||||||
|
message: "Invalid credentials",
|
||||||
|
});
|
||||||
|
|
||||||
|
notifyError(
|
||||||
|
"Error",
|
||||||
|
"Could not fetch content types from DatoCMS. Please check your credentials.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: fetchContentTypeFields, data: fieldsData } =
|
||||||
|
trpcClient.datocms.fetchContentTypeFields.useMutation({
|
||||||
|
onSuccess(data) {
|
||||||
|
clearErrors("authToken");
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
setError("authToken", {
|
||||||
|
type: "custom",
|
||||||
|
message: "Invalid credentials",
|
||||||
|
});
|
||||||
|
|
||||||
|
notifyError(
|
||||||
|
"Error",
|
||||||
|
"Could not fetch content types from DatoCMS. Please check your credentials.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentTypesSelectOptions = useMemo(() => {
|
||||||
|
if (!contentTypesData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentTypesData.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.id,
|
||||||
|
}));
|
||||||
|
}, [contentTypesData]);
|
||||||
|
|
||||||
|
const selectedContentType = watch("itemType");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedContentType) {
|
||||||
|
fetchContentTypeFields({
|
||||||
|
contentTypeID: selectedContentType,
|
||||||
|
apiToken: getValues("authToken"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedContentType, getValues, fetchContentTypeFields]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultValues.authToken && defaultValues.itemType) {
|
||||||
|
fetchContentTypes({
|
||||||
|
apiToken: defaultValues.authToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchContentTypeFields({
|
||||||
|
apiToken: defaultValues.authToken,
|
||||||
|
contentTypeID: defaultValues.itemType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [defaultValues, fetchContentTypes, fetchContentTypeFields]);
|
||||||
|
|
||||||
|
const fetchContentTypesButton = (
|
||||||
|
<ButtonsBox>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
const values = getValues();
|
||||||
|
|
||||||
|
return fetchContentTypes({
|
||||||
|
apiToken: values.authToken,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
display={"grid"}
|
||||||
|
gap={4}
|
||||||
|
onSubmit={handleSubmit((vals) => {
|
||||||
|
onSubmit(vals);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
control={control}
|
||||||
|
name="configName"
|
||||||
|
label="Configuration name"
|
||||||
|
helperText="Meaningful name that will help you understand it later. E.g. 'staging' or 'prod' "
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box display={"grid"} gap={4} marginY={4}>
|
||||||
|
<Text variant="heading">Provide connection details</Text>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
control={control}
|
||||||
|
name="authToken"
|
||||||
|
type="password"
|
||||||
|
label="API Token"
|
||||||
|
helperText="Project -> Settings -> API Tokens -> Full-access API token."
|
||||||
|
/>
|
||||||
|
{!contentTypesData && fetchContentTypesButton}
|
||||||
|
</Box>
|
||||||
|
{contentTypesSelectOptions && (
|
||||||
|
<Box display={"grid"} gap={4} marginY={4}>
|
||||||
|
<Text variant="heading">Configure fields mapping</Text>
|
||||||
|
<Select
|
||||||
|
label="Item type"
|
||||||
|
options={contentTypesSelectOptions}
|
||||||
|
name="itemType"
|
||||||
|
control={control}
|
||||||
|
helperText="Model that will keep Saleor data. You should create one just for Saleor data."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{fieldsData && (
|
||||||
|
<React.Fragment>
|
||||||
|
<Text as="p" variant="heading" size="small">
|
||||||
|
Map fields from Saleor to your DatoCMS 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">DatoCMS field</Text>
|
||||||
|
</Box>
|
||||||
|
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
|
||||||
|
// todo extract this table to component
|
||||||
|
<Box
|
||||||
|
display="grid"
|
||||||
|
__gridTemplateColumns={"50% 50%"}
|
||||||
|
padding={2}
|
||||||
|
key={saleorField}
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Text as="p" variant="bodyStrong">
|
||||||
|
{printSaleorProductFields(saleorField)}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption">
|
||||||
|
{saleorField === "channels" ? "JSON field" : "Text field"}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
control={control}
|
||||||
|
name={`productVariantFieldsMapping.${saleorField}`}
|
||||||
|
label="DatoCMS Field"
|
||||||
|
options={fieldsData.map((f) => ({
|
||||||
|
label: f.label,
|
||||||
|
value: f.api_key,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{contentTypesSelectOptions && (
|
||||||
|
<ButtonsBox>
|
||||||
|
{onDelete && (
|
||||||
|
<Button onClick={onDelete} variant="tertiary">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddFormVariant = () => {
|
||||||
|
const { push } = useRouter();
|
||||||
|
const { notifySuccess } = useDashboardNotification();
|
||||||
|
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Saved configuration");
|
||||||
|
push("/configuration");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PureForm
|
||||||
|
onSubmit={(values) => {
|
||||||
|
addProvider({
|
||||||
|
...values,
|
||||||
|
type: "datocms",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
defaultValues={{
|
||||||
|
authToken: "",
|
||||||
|
configName: "",
|
||||||
|
itemType: "",
|
||||||
|
productVariantFieldsMapping: {
|
||||||
|
channels: "",
|
||||||
|
variantName: "",
|
||||||
|
productId: "",
|
||||||
|
productName: "",
|
||||||
|
productSlug: "",
|
||||||
|
variantId: "",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditFormVariant = (props: { configId: string }) => {
|
||||||
|
const { push } = useRouter();
|
||||||
|
const { notifySuccess } = useDashboardNotification();
|
||||||
|
|
||||||
|
const { data } = trpcClient.providersConfigs.getOne.useQuery(
|
||||||
|
{
|
||||||
|
id: props.configId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!props.configId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Updated configuration");
|
||||||
|
push("/configuration");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate: deleteProvider } = trpcClient.providersConfigs.deleteOne.useMutation({
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess("Success", "Removed configuration");
|
||||||
|
push("/configuration");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type !== "datocms") {
|
||||||
|
throw new Error("Trying to fill datocms form with non datocms data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PureForm
|
||||||
|
onDelete={() => {
|
||||||
|
deleteProvider({
|
||||||
|
id: props.configId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
mutate({
|
||||||
|
...values,
|
||||||
|
type: "datocms",
|
||||||
|
id: props.configId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
defaultValues={data}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DatoCMSConfigForm = {
|
||||||
|
PureVariant: PureForm,
|
||||||
|
AddVariant: AddFormVariant,
|
||||||
|
EditVariant: EditFormVariant,
|
||||||
|
};
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
|
||||||
|
import {
|
||||||
|
WebhookProductFragment,
|
||||||
|
WebhookProductVariantFragment,
|
||||||
|
} from "../../../../generated/graphql";
|
||||||
|
|
||||||
|
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
|
||||||
|
import { DatoCMSClient } from "./datocms-client";
|
||||||
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* todo error handling
|
||||||
|
*/
|
||||||
|
export class DatocmsWebhooksProcessor implements ProductWebhooksProcessor {
|
||||||
|
private client: DatoCMSClient;
|
||||||
|
private logger = createLogger({ name: "DatocmsWebhooksProcessor" });
|
||||||
|
|
||||||
|
constructor(private providerConfig: DatocmsProviderConfig.FullShape) {
|
||||||
|
this.client = new DatoCMSClient({
|
||||||
|
apiToken: providerConfig.authToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||||
|
this.logger.trace("onProductVariantUpdated called");
|
||||||
|
|
||||||
|
await this.client.updateProductVariant({
|
||||||
|
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");
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
(product.variants ?? []).map((variant) => {
|
||||||
|
return this.client.upsertProduct({
|
||||||
|
configuration: this.providerConfig,
|
||||||
|
variant: {
|
||||||
|
id: variant.id,
|
||||||
|
name: variant.name,
|
||||||
|
product: {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
slug: product.slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
55
apps/cms-v2/src/modules/providers/datocms/datocms.router.ts
Normal file
55
apps/cms-v2/src/modules/providers/datocms/datocms.router.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
|
||||||
|
import { router } from "../../trpc/trpc-server";
|
||||||
|
|
||||||
|
import { DatoCMSClient } from "./datocms-client";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operations specific for Datocms service.
|
||||||
|
*
|
||||||
|
* For configruration see providers-list.router.ts
|
||||||
|
*/
|
||||||
|
export const datocmsRouter = router({
|
||||||
|
fetchContentTypes: protectedClientProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
apiToken: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(({ ctx, input }) => {
|
||||||
|
const client = new DatoCMSClient({
|
||||||
|
apiToken: input.apiToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
return client.getContentTypes().catch((err) => {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
fetchContentTypeFields: protectedClientProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
contentTypeID: z.string(),
|
||||||
|
apiToken: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(({ ctx, input }) => {
|
||||||
|
const client = new DatoCMSClient({
|
||||||
|
apiToken: input.apiToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
return client
|
||||||
|
.getFieldsForContentType({
|
||||||
|
itemTypeID: input.contentTypeID,
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
1
apps/cms-v2/src/modules/providers/datocms/datocms.svg
Normal file
1
apps/cms-v2/src/modules/providers/datocms/datocms.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 94.652 95.261"><defs><linearGradient id="a" y1="47.631" x2="94.652" y2="47.631" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff593d"/><stop offset="1" stop-color="#ff7751"/></linearGradient></defs><title>d_only</title><path d="M47.326,0H0V95.261H47.326c23.67,0,47.326-21.326,47.326-47.624S71,0,47.326,0Zm0,69.274a21.644,21.644,0,1,1,21.65-21.637A21.635,21.635,0,0,1,47.326,69.274Z" fill="url(#a)"/></svg>
|
After Width: | Height: | Size: 516 B |
21
apps/cms-v2/src/modules/providers/datocms/datocms.tsx
Normal file
21
apps/cms-v2/src/modules/providers/datocms/datocms.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||||
|
|
||||||
|
import { CMSProviderMeta } from "../cms-provider-meta";
|
||||||
|
import logo from "./datocms.svg";
|
||||||
|
|
||||||
|
export const Datocms = {
|
||||||
|
formSideInfo: (
|
||||||
|
<Box>
|
||||||
|
<Text as="p" marginBottom={2}>
|
||||||
|
App will use Saleor Product Variant as a unique identifier. It will be saved as one of the
|
||||||
|
fields. Please ensure you map Variant ID to field that is UNIQUE in DatoCMS.
|
||||||
|
</Text>
|
||||||
|
<Text>Otherwise, products may be duplicated</Text>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
type: "datocms" as const,
|
||||||
|
logoUrl: logo.src,
|
||||||
|
displayName: "DatoCMS",
|
||||||
|
description:
|
||||||
|
"It's the headless CMS for the modern web. More than 25,000 businesses use DatoCMS to create online content at scale from a central hub and distribute it via API.",
|
||||||
|
} satisfies CMSProviderMeta;
|
50
apps/cms-v2/src/modules/providers/fields-mapper.test.ts
Normal file
50
apps/cms-v2/src/modules/providers/fields-mapper.test.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { FieldsMapper } from "./fields-mapper";
|
||||||
|
|
||||||
|
describe("FieldsMapper", () => {
|
||||||
|
it("Maps variant fields to configuration fields", () => {
|
||||||
|
const mappedFields = FieldsMapper.mapProductVariantToConfigurationFields({
|
||||||
|
configMapping: {
|
||||||
|
channels: "channels",
|
||||||
|
productId: "product-id",
|
||||||
|
productName: "product-name",
|
||||||
|
productSlug: "product-slug",
|
||||||
|
variantId: "variant-id",
|
||||||
|
variantName: "variant-name",
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
id: "aaa-bbb-ccc",
|
||||||
|
name: "43",
|
||||||
|
channelListings: [
|
||||||
|
{
|
||||||
|
channel: {
|
||||||
|
id: "12345",
|
||||||
|
slug: "default-channel",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
product: {
|
||||||
|
id: "aaa-bbb-123",
|
||||||
|
name: "Shoes",
|
||||||
|
slug: "shoes",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mappedFields).toEqual({
|
||||||
|
"variant-name": "43",
|
||||||
|
"product-id": "aaa-bbb-123",
|
||||||
|
"product-name": "Shoes",
|
||||||
|
"product-slug": "shoes",
|
||||||
|
"variant-id": "aaa-bbb-ccc",
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
channel: {
|
||||||
|
id: "12345",
|
||||||
|
slug: "default-channel",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
23
apps/cms-v2/src/modules/providers/fields-mapper.ts
Normal file
23
apps/cms-v2/src/modules/providers/fields-mapper.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { WebhookProductVariantFragment } from "../../../generated/graphql";
|
||||||
|
import { ProvidersConfig } from "../configuration";
|
||||||
|
|
||||||
|
export class FieldsMapper {
|
||||||
|
static mapProductVariantToConfigurationFields({
|
||||||
|
configMapping,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
variant: WebhookProductVariantFragment;
|
||||||
|
configMapping: ProvidersConfig.AnyFullShape["productVariantFieldsMapping"];
|
||||||
|
}) {
|
||||||
|
const { channels, variantName, productId, productName, productSlug, variantId } = configMapping;
|
||||||
|
|
||||||
|
return {
|
||||||
|
[variantName]: variant.name,
|
||||||
|
[productId]: variant.product.id,
|
||||||
|
[productName]: variant.product.name,
|
||||||
|
[productSlug]: variant.product.slug,
|
||||||
|
[variantId]: variant.id,
|
||||||
|
[channels]: variant.channelListings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue