Compare commits
6 commits
main
...
introduce-
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a260414894 | ||
![]() |
246789cb0c | ||
![]() |
4cb15a8033 | ||
![]() |
6b9f8ac32b | ||
![]() |
bd757776f2 | ||
![]() |
7efa970079 |
1024 changed files with 57626 additions and 155412 deletions
5
.changeset/blue-lies-enjoy.md
Normal file
5
.changeset/blue-lies-enjoy.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-taxes": minor
|
||||
---
|
||||
|
||||
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.
|
15
.changeset/blue-wasps-cover.md
Normal file
15
.changeset/blue-wasps-cover.md
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
"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.
|
5
.changeset/cold-candles-fly.md
Normal file
5
.changeset/cold-candles-fly.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-taxes": patch
|
||||
---
|
||||
|
||||
The update provider configuration views no longer return "invalid credentials" and "invalid address" errors in inappropriate cases. The latter required temporarily disabling the TaxJar address validation, as it currently doesn't work.
|
5
.changeset/cool-turtles-reflect.md
Normal file
5
.changeset/cool-turtles-reflect.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-taxes": minor
|
||||
---
|
||||
|
||||
Added fetching tax codes from tax providers and storing the matched combinations of the provider tax codes/Saleor tax classes. The mapped tax codes are then used in the tax calculation process.
|
5
.changeset/eight-toes-kneel.md
Normal file
5
.changeset/eight-toes-kneel.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@saleor/react-hook-form-macaw": minor
|
||||
---
|
||||
|
||||
Added a binding for the macaw-ui's Toggle component.
|
5
.changeset/eighty-boxes-exist.md
Normal file
5
.changeset/eighty-boxes-exist.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-products-feed": minor
|
||||
---
|
||||
|
||||
Improved helper text in S3 form - region field. Now it should be more explicit that only region code (like "eu-west-1") should be provided.
|
5
.changeset/few-boxes-doubt.md
Normal file
5
.changeset/few-boxes-doubt.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@saleor/apps-ui": patch
|
||||
---
|
||||
|
||||
Fixed a missing text-decoration on a breadcrumb link.
|
6
.changeset/funny-fireants-turn.md
Normal file
6
.changeset/funny-fireants-turn.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@saleor/apps-cli": patch
|
||||
---
|
||||
|
||||
Introduced Apps CLI.
|
||||
The app is intended to help with common tasks in app development, like installing apps from manifest or debugging webhooks.
|
8
.changeset/gorgeous-hats-learn.md
Normal file
8
.changeset/gorgeous-hats-learn.md
Normal file
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
"saleor-app-emails-and-messages": patch
|
||||
"saleor-app-invoices": patch
|
||||
"saleor-app-crm": patch
|
||||
---
|
||||
|
||||
Fixed "Not enough permissions" error during configuration management.
|
||||
|
17
.changeset/lemon-maps-grow.md
Normal file
17
.changeset/lemon-maps-grow.md
Normal file
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
"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
|
17
.changeset/poor-chairs-reflect.md
Normal file
17
.changeset/poor-chairs-reflect.md
Normal file
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
"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-app-slack": patch
|
||||
"saleor-app-taxes": patch
|
||||
"saleor-app-cms": patch
|
||||
"saleor-app-crm": patch
|
||||
---
|
||||
|
||||
Updated configuration and dependencies of GraphQL client - urql.
|
||||
All applications use now unified config for creating the client. Also unused related packages has been removed.
|
5
.changeset/poor-needles-burn.md
Normal file
5
.changeset/poor-needles-burn.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-emails-and-messages": patch
|
||||
---
|
||||
|
||||
Improved descriptions in the events section and API key inputs.
|
|
@ -1,8 +0,0 @@
|
|||
#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
|
14
.changeset/short-ligers-dance.md
Normal file
14
.changeset/short-ligers-dance.md
Normal file
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
"saleor-app-emails-and-messages": patch
|
||||
"saleor-app-data-importer": patch
|
||||
"saleor-app-products-feed": patch
|
||||
"saleor-app-invoices": patch
|
||||
"saleor-app-klaviyo": patch
|
||||
"saleor-app-search": patch
|
||||
"saleor-app-slack": patch
|
||||
"saleor-app-taxes": patch
|
||||
"saleor-app-cms": patch
|
||||
"saleor-app-crm": patch
|
||||
---
|
||||
|
||||
Updated Sentry package and config. Set Sentry release to match package.json version. Now Sentry will use semver version instead a commit
|
19
.changeset/spicy-seahorses-shake.md
Normal file
19
.changeset/spicy-seahorses-shake.md
Normal file
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
"@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
|
5
.changeset/stupid-insects-smash.md
Normal file
5
.changeset/stupid-insects-smash.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@saleor/apps-shared": patch
|
||||
---
|
||||
|
||||
Added `createGraphQLClient` function to shared package. Can be used to create urql client instance with optional authorization.
|
5
.changeset/tricky-rings-beam.md
Normal file
5
.changeset/tricky-rings-beam.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-search": patch
|
||||
---
|
||||
|
||||
Regression - Fixed Algolia "index prefix" field to be optional, just like it was before the refcator.
|
7
.changeset/two-dingos-notice.md
Normal file
7
.changeset/two-dingos-notice.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"saleor-app-emails-and-messages": minor
|
||||
---
|
||||
|
||||
Webhooks are no longer created during the app registration. Instead, the app will subscribe events based on it's configuration, after change has been detected.
|
||||
|
||||
This change does not have negative impact on existing app installations - webhooks will be removed during next change of the provider configuration.
|
5
.changeset/wild-pears-swim.md
Normal file
5
.changeset/wild-pears-swim.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-taxes": patch
|
||||
---
|
||||
|
||||
Fixed the issue with updating a provider. When updating one of the credentials, the user no longer sees "invalid credentials" error.
|
5
.changeset/young-tigers-provide.md
Normal file
5
.changeset/young-tigers-provide.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-emails-and-messages": patch
|
||||
---
|
||||
|
||||
Events section UI has been updated. All events are displayed now as single table.
|
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
@ -8,4 +8,5 @@
|
|||
|
||||
## Checklist
|
||||
|
||||
- [ ] `.github/dependabot.yaml` is up-to date.
|
||||
- [ ] I added changesets and [read good practices](/.changeset/README.md).
|
||||
|
|
108
.github/dependabot.yaml
vendored
108
.github/dependabot.yaml
vendored
|
@ -2,18 +2,100 @@ version: 2
|
|||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
open-pull-requests-limit: 0
|
||||
schedule:
|
||||
interval: "daily"
|
||||
commit-message:
|
||||
prefix: "[skip ci]"
|
||||
groups:
|
||||
trpc:
|
||||
patterns:
|
||||
- "@trpc/*"
|
||||
codegen:
|
||||
patterns:
|
||||
- "@graphql-codegen/*"
|
||||
rtl:
|
||||
patterns:
|
||||
- "@testing-library/*"
|
||||
interval: "weekly"
|
||||
|
||||
# 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/apps-cli"
|
||||
open-pull-requests-limit: 0
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- 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: Invoices": "apps/invoices/**/*"
|
||||
"App: Klaviyo": "apps/klaviyo/**/*"
|
||||
"App: Monitoring": "apps/monitoring/**/*"
|
||||
"App: Product Feed": "apps/products-feed/**/*"
|
||||
"App: Search": "apps/search/**/*"
|
||||
"App: Slack": "apps/slack/**/*"
|
||||
"App: Taxes": "apps/taxes/**/*"
|
||||
"App: Emails & Messages": "apps/emails-and-messages/**/*"
|
||||
"App: CMS": "apps/cms-v2/**/*"
|
||||
"App: CMS": "apps/cms/**/*"
|
||||
"App: CRM": "apps/crm/**/*"
|
||||
"App: Segment": "apps/segment/**/*"
|
||||
|
|
9
.github/workflows/assign-pr.yml
vendored
9
.github/workflows/assign-pr.yml
vendored
|
@ -6,12 +6,9 @@ on:
|
|||
|
||||
jobs:
|
||||
assign_creator:
|
||||
if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Assign PR to creator
|
||||
env:
|
||||
GITHUB_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"
|
||||
uses: thomaseizinger/assign-pr-creator-action@v1.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
81
.github/workflows/build-and-publish-monitoring-backend.yml
vendored
Normal file
81
.github/workflows/build-and-publish-monitoring-backend.yml
vendored
Normal file
|
@ -0,0 +1,81 @@
|
|||
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
31
.github/workflows/changeset-checker.yml
vendored
|
@ -1,31 +0,0 @@
|
|||
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
|
||||
with:
|
||||
node-version: 18
|
||||
- uses: JamieMason/syncpack-github-action@c145cec44b3731b3fe8e859679e240d6ae011f0f
|
||||
- uses: JamieMason/syncpack-github-action@0.2.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
package-manager: "pnpm"
|
||||
|
|
10
.github/workflows/check-spelling.yml
vendored
10
.github/workflows/check-spelling.yml
vendored
|
@ -1,10 +0,0 @@
|
|||
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
|
||||
with:
|
||||
node-version: 16
|
||||
- uses: pnpm/action-setup@d882d12c64e032187b2edb46d3a0d003b7a43598
|
||||
- uses: pnpm/action-setup@v2
|
||||
name: Install pnpm
|
||||
- run: pnpm install
|
||||
- name: Create Release Pull Request
|
||||
uses: changesets/action@f13b1baaa620fde937751f5d2c3572b9da32af23
|
||||
uses: changesets/action@v1
|
||||
id: changesets
|
||||
with:
|
||||
title: 🚀 Release apps
|
||||
|
|
33
.github/workflows/unit-tests-monitoring.yml
vendored
Normal file
33
.github/workflows/unit-tests-monitoring.yml
vendored
Normal file
|
@ -0,0 +1,33 @@
|
|||
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
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
|
|
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -40,7 +40,4 @@ coverage/
|
|||
apps/**/generated
|
||||
.eslintcache
|
||||
|
||||
.sentryclirc
|
||||
|
||||
.vscode/
|
||||
.cspellcache
|
||||
.sentryclirc
|
|
@ -1 +1 @@
|
|||
* @saleor/delivery-engineering-js
|
||||
* @saleor/appstore
|
20
README.md
20
README.md
|
@ -16,8 +16,6 @@
|
|||
<a href="https://saleor.io/blog/">📰 Blog</a>
|
||||
<span> • </span>
|
||||
<a href="https://twitter.com/getsaleor">🐦 Twitter</a>
|
||||
<span> • </span>
|
||||
<a href="https://discord.gg/H52JTZAtSH">💬 Discord</a>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
@ -28,13 +26,6 @@
|
|||
<a href="https://github.com/saleor/apps/discussions/categories/integrations-features">✍️ Propose an app</a>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<div align="center">
|
||||
|
||||
[](https://discord.gg/H52JTZAtSH)
|
||||
|
||||
</div>
|
||||
|
||||
## Overview
|
||||
|
||||
This repository serves as a starting point in the exploration of Saleor apps.
|
||||
|
@ -48,10 +39,10 @@ This repository serves as a starting point in the exploration of Saleor apps.
|
|||
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.
|
||||
- [cms](https://docs.saleor.io/docs/3.x/developer/app-store/apps/cms) - exports products from Saleor to CMS.
|
||||
- [cms](./apps/cms) - exports products from Saleor to CMS.
|
||||
- [data-importer](./apps/data-importer) - import data from CSV to Saleor.
|
||||
- [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](https://docs.saleor.io/docs/3.x/developer/app-store/apps/invoices) - generate invoice PDF for each order.
|
||||
- [emails-and-messages](./apps/emails-and-messages) - notifications and email communication with customers.
|
||||
- [invoices](./apps/invoices) - generate invoice PDF for each order.
|
||||
- [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
|
||||
- [products-feed](./apps/products-feed) - generate products feed XML
|
||||
|
@ -59,11 +50,6 @@ In the `apps` folder, you will find the following applications:
|
|||
- [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.
|
||||
|
||||
## Another official apps
|
||||
|
||||
Some of Saleor apps are available in separate repositories:
|
||||
- [Stripe](https://github.com/saleor/saleor-app-payment-stripe)
|
||||
|
||||
## Development
|
||||
|
||||
You can find the documentation for saleor/apps on [docs.saleor.io](https://docs.saleor.io/docs/3.x/developer/app-store/development).
|
||||
|
|
3
apps/apps-cli/.env.example
Normal file
3
apps/apps-cli/.env.example
Normal file
|
@ -0,0 +1,3 @@
|
|||
INSTANCE_URL=https://demo.saleor.io/graphql/
|
||||
USER_EMAIL=admin@example.com
|
||||
USER_PASSWORD=password123
|
148
apps/apps-cli/.gitignore
vendored
Normal file
148
apps/apps-cli/.gitignore
vendored
Normal file
|
@ -0,0 +1,148 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
package-lock.json
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# other
|
||||
build/
|
||||
.vscode/
|
||||
binaries/
|
||||
.saleor
|
||||
token.txt
|
||||
vendor/
|
||||
test.js
|
||||
dummy/
|
||||
..bfg-report
|
||||
.idea/
|
||||
.DS_Store
|
||||
package/
|
||||
|
||||
.type-coverage/
|
||||
coverage-ts/
|
37
apps/apps-cli/README.md
Normal file
37
apps/apps-cli/README.md
Normal file
|
@ -0,0 +1,37 @@
|
|||
### Install dependencies
|
||||
|
||||
This project uses [pnpm](https://pnpm.io) for managing dependencies
|
||||
|
||||
```
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Run Watch Mode
|
||||
|
||||
```
|
||||
pnpm watch
|
||||
```
|
||||
|
||||
### Run CLI
|
||||
|
||||
```
|
||||
node build/cli.js ...
|
||||
```
|
||||
|
||||
### Available commands
|
||||
|
||||
List of available commands:
|
||||
|
||||
```
|
||||
node dist/app-cli.js -h
|
||||
```
|
||||
|
||||
Description of available arguments:
|
||||
|
||||
```
|
||||
node dist/app-cli.js [command name] -h
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
If options are not passed as arguments, cli will try to read environment variables. Example configuration is available in `.env.example` file.
|
18
apps/apps-cli/codegen.ts
Normal file
18
apps/apps-cli/codegen.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
/* eslint-disable import/no-default-export */
|
||||
import { CodegenConfig } from "@graphql-codegen/cli";
|
||||
|
||||
const config: CodegenConfig = {
|
||||
schema: "https://demo.saleor.io/graphql/",
|
||||
documents: ["src/saleor-api/operations/*.ts"],
|
||||
ignoreNoDocuments: true, // for better experience with the watcher
|
||||
generates: {
|
||||
"./src/saleor-api/generated/": {
|
||||
preset: "client",
|
||||
presetConfig: {
|
||||
fragmentMasking: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
67
apps/apps-cli/package.json
Normal file
67
apps/apps-cli/package.json
Normal file
|
@ -0,0 +1,67 @@
|
|||
{
|
||||
"name": "@saleor/apps-cli",
|
||||
"description": "",
|
||||
"version": "0.0.0",
|
||||
"author": "Saleor",
|
||||
"scripts": {
|
||||
"build": "pnpm bundle",
|
||||
"generate": "graphql-codegen",
|
||||
"lint": "prettier --write . && eslint src/**/*.ts --cache --fix",
|
||||
"test": "pnpm vitest",
|
||||
"typecov": "type-coverage --cache",
|
||||
"typecov-report": "typescript-coverage-report",
|
||||
"watch": "concurrently \"npm:watch-*\"",
|
||||
"watch-esbuild": "esbuild --watch src/cli.ts --bundle --minify --outfile=dist/apps-cli.js --platform=node --format=esm --target=node18 --banner:js=\"import { createRequire } from 'module';const require = createRequire(import.meta.url);import { dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url));\" --out-extension:.js=.js",
|
||||
"watch-generate": "graphql-codegen -w",
|
||||
"watch-ts": "tsc --noEmit --watch --preserveWatchOutput"
|
||||
},
|
||||
"bin": {
|
||||
"saleor": "./dist/apps-cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@inquirer/prompts": "^2.1.1",
|
||||
"@oclif/core": "^1.26.2",
|
||||
"@saleor/app-sdk": "0.40.1",
|
||||
"chalk": "^5.2.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"enquirer": "^2.3.6",
|
||||
"fs-extra": "^11.1.1",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "^6.1.0",
|
||||
"open": "^9.1.0",
|
||||
"ora": "^6.3.1",
|
||||
"semver": "^7.5.1",
|
||||
"slugify": "^1.6.6",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^4.0.1",
|
||||
"@graphql-codegen/client-preset": "^4.0.1",
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@types/yargs": "^17.0.24",
|
||||
"concurrently": "^8.2.0",
|
||||
"esbuild": "^0.18.2",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-saleor": "workspace:*",
|
||||
"pkg": "^5.8.1",
|
||||
"prettier": "2.8.8",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsm": "^2.3.0",
|
||||
"type-coverage": "^2.26.0",
|
||||
"typescript": "^5.1.3",
|
||||
"typescript-coverage-report": "^0.7.0",
|
||||
"vitest": "^0.32.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || ^20"
|
||||
},
|
||||
"files": [
|
||||
"dist/apps-cli.js"
|
||||
],
|
||||
"license": "BSD 3-Clause",
|
||||
"type": "module"
|
||||
}
|
158
apps/apps-cli/src/cli.ts
Normal file
158
apps/apps-cli/src/cli.ts
Normal file
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import chalk from "chalk";
|
||||
import { createRequire } from "module";
|
||||
import semver from "semver";
|
||||
import yargs from "yargs";
|
||||
import { hideBin } from "yargs/helpers";
|
||||
import { installAppCommand } from "./commands/install-app-command";
|
||||
import { uninstallAppCommand } from "./commands/uninstall-app-command";
|
||||
import "dotenv/config";
|
||||
import { webhooksCommand } from "./commands/webhooks-command";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require("../package.json");
|
||||
|
||||
if (!semver.satisfies(process.versions.node, ">= 18")) {
|
||||
console.error(`${chalk.red("ERROR")}: CLI requires Node.js 18.x or later`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const parser = yargs(hideBin(process.argv))
|
||||
.scriptName("apps-cli")
|
||||
.version(pkg.version)
|
||||
.alias("V", "version")
|
||||
.usage("Usage: $0 <command> [options]")
|
||||
.config({
|
||||
instanceUrl: process.env.INSTANCE_URL,
|
||||
userEmail: process.env.USER_EMAIL,
|
||||
userPassword: process.env.USER_PASSWORD,
|
||||
})
|
||||
.command(
|
||||
"installApp",
|
||||
"Install an app on a Saleor instance based on provided manifest.",
|
||||
(yargs) => {
|
||||
return yargs
|
||||
.option("instanceUrl", {
|
||||
type: "string",
|
||||
desc: "URL to the Saleor GraphQL API. Example: https://example.com/graphql/",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("userEmail", {
|
||||
type: "string",
|
||||
desc: "Dashboard user email",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("userPassword", {
|
||||
type: "string",
|
||||
desc: "Dashboard user password",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("manifestUrl", {
|
||||
type: "string",
|
||||
desc: "URL to the app manifest. Example: https://example.com/api/manifest",
|
||||
demandOption: true,
|
||||
});
|
||||
},
|
||||
(argv) => {
|
||||
installAppCommand({
|
||||
instanceUrl: argv.instanceUrl,
|
||||
userEmail: argv.userEmail,
|
||||
userPassword: argv.userPassword,
|
||||
manifestUrl: argv.manifestUrl,
|
||||
});
|
||||
}
|
||||
)
|
||||
.command(
|
||||
"uninstallApp",
|
||||
"If no filter is passed, CLI will display a list of installed apps and ask which one to remove. Otherwise all apps matching the filter will be removed.",
|
||||
(yargs) => {
|
||||
return yargs
|
||||
.option("instanceUrl", {
|
||||
type: "string",
|
||||
desc: "URL to the Saleor GraphQL API",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("userEmail", {
|
||||
type: "string",
|
||||
desc: "Dashboard user email",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("userPassword", {
|
||||
type: "string",
|
||||
desc: "Dashboard user password",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("manifestUrl", {
|
||||
type: "string",
|
||||
desc: "Url to the app manifest which you want to remove",
|
||||
})
|
||||
.option("appName", {
|
||||
type: "string",
|
||||
desc: "Name of the app to remove",
|
||||
})
|
||||
.option("appId", {
|
||||
type: "string",
|
||||
desc: "If of the app to remove",
|
||||
})
|
||||
.option("all", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
desc: "Will remove all apps",
|
||||
})
|
||||
.option("force", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
desc: "No confirmation",
|
||||
});
|
||||
},
|
||||
(argv) => {
|
||||
uninstallAppCommand({
|
||||
instanceUrl: argv.instanceUrl,
|
||||
userEmail: argv.userEmail,
|
||||
userPassword: argv.userPassword,
|
||||
manifestUrl: argv.manifestUrl,
|
||||
appId: argv.appId,
|
||||
all: argv.all,
|
||||
force: argv.force,
|
||||
});
|
||||
}
|
||||
)
|
||||
.command(
|
||||
"webhooks",
|
||||
"Print webhook details of installed app.",
|
||||
(yargs) => {
|
||||
return yargs
|
||||
.option("instanceUrl", {
|
||||
type: "string",
|
||||
desc: "URL to the Saleor GraphQL API. Example: https://example.com/graphql/",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("userEmail", {
|
||||
type: "string",
|
||||
desc: "Dashboard user email",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("userPassword", {
|
||||
type: "string",
|
||||
desc: "Dashboard user password",
|
||||
demandOption: true,
|
||||
});
|
||||
},
|
||||
(argv) => {
|
||||
webhooksCommand({
|
||||
instanceUrl: argv.instanceUrl,
|
||||
userEmail: argv.userEmail,
|
||||
userPassword: argv.userPassword,
|
||||
});
|
||||
}
|
||||
)
|
||||
.demandCommand(1, "You need at least one command before moving on")
|
||||
.alias("h", "help")
|
||||
.wrap(null);
|
||||
|
||||
try {
|
||||
await parser.parse();
|
||||
} catch (error) {
|
||||
console.log("parser error");
|
||||
}
|
35
apps/apps-cli/src/commands/install-app-command.ts
Normal file
35
apps/apps-cli/src/commands/install-app-command.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { installAndWaitForResult } from "../lib/install-and-wait-for-result";
|
||||
import { getAccessTokenMutation } from "../saleor-api/operations/get-access-token-mutation";
|
||||
import ora from "ora";
|
||||
|
||||
interface InstallAppCommandArgs {
|
||||
instanceUrl: string;
|
||||
userEmail: string;
|
||||
userPassword: string;
|
||||
manifestUrl: string;
|
||||
}
|
||||
|
||||
export const installAppCommand = async ({
|
||||
instanceUrl,
|
||||
manifestUrl,
|
||||
userEmail,
|
||||
userPassword,
|
||||
}: InstallAppCommandArgs) => {
|
||||
const loginSpinner = ora("Logging into Saleor instance").start();
|
||||
|
||||
const token = await getAccessTokenMutation({
|
||||
email: userEmail,
|
||||
password: userPassword,
|
||||
saleorApiUrl: instanceUrl,
|
||||
});
|
||||
|
||||
loginSpinner.succeed();
|
||||
|
||||
const installedAppData = await installAndWaitForResult({
|
||||
saleorApiUrl: instanceUrl,
|
||||
token,
|
||||
appManifestUrl: manifestUrl,
|
||||
});
|
||||
|
||||
console.log(`App ${installedAppData.name} (${installedAppData.id}) installed!`);
|
||||
};
|
110
apps/apps-cli/src/commands/uninstall-app-command.ts
Normal file
110
apps/apps-cli/src/commands/uninstall-app-command.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { checkbox, confirm } from "@inquirer/prompts";
|
||||
import { getAccessTokenMutation } from "../saleor-api/operations/get-access-token-mutation";
|
||||
import { getAppsListQuery } from "../saleor-api/operations/get-apps-list-query";
|
||||
import { uninstallAppMutation } from "../saleor-api/operations/uninstall-app-mutation";
|
||||
import { filterApps } from "../lib/filter-apps";
|
||||
import ora from "ora";
|
||||
|
||||
interface UninstallAppCommandArgs {
|
||||
instanceUrl: string;
|
||||
userEmail: string;
|
||||
userPassword: string;
|
||||
manifestUrl?: string;
|
||||
appName?: string;
|
||||
appId?: string;
|
||||
all?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export const uninstallAppCommand = async ({
|
||||
instanceUrl,
|
||||
manifestUrl,
|
||||
userEmail,
|
||||
userPassword,
|
||||
all,
|
||||
force,
|
||||
appId,
|
||||
appName,
|
||||
}: UninstallAppCommandArgs) => {
|
||||
const loginSpinner = ora("Logging into Saleor instance").start();
|
||||
|
||||
const token = await getAccessTokenMutation({
|
||||
email: userEmail,
|
||||
password: userPassword,
|
||||
saleorApiUrl: instanceUrl,
|
||||
});
|
||||
|
||||
loginSpinner.succeed();
|
||||
|
||||
const appIdsToRemove: string[] = [];
|
||||
|
||||
if (appId) {
|
||||
appIdsToRemove.push(appId);
|
||||
} else {
|
||||
const appListSpinner = ora("Fetching installed apps").start();
|
||||
|
||||
const installedApps = await getAppsListQuery({
|
||||
saleorApiUrl: instanceUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
appListSpinner.succeed();
|
||||
|
||||
if (!installedApps.length) {
|
||||
console.log("No apps installed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Display CLI interface with multiselect if none of the filters were provided
|
||||
if (appId || appName || manifestUrl) {
|
||||
const filteredApps = filterApps({
|
||||
apps: installedApps,
|
||||
filter: {
|
||||
id: appId,
|
||||
name: appName,
|
||||
manifestUrl: manifestUrl,
|
||||
},
|
||||
});
|
||||
|
||||
appIdsToRemove.push(...filteredApps.map((app) => app.id));
|
||||
} else if (all) {
|
||||
appIdsToRemove.push(...installedApps.map((app) => app.id));
|
||||
} else {
|
||||
const selectedIds = await checkbox({
|
||||
message: "Select apps to uninstall",
|
||||
choices: installedApps.map((app) => ({
|
||||
name: app.name ? `${app.name} (${app.id}) ${app.type}` : app.id,
|
||||
value: app.id,
|
||||
})),
|
||||
});
|
||||
|
||||
appIdsToRemove.push(...selectedIds);
|
||||
}
|
||||
}
|
||||
const confirmed = force
|
||||
? true
|
||||
: await confirm({
|
||||
message: `${appIdsToRemove.length} apps will be removed. Continue?`,
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
console.log("Operation aborted - no confirmation");
|
||||
return;
|
||||
}
|
||||
|
||||
const uninstallSpinner = ora("Uninstalling apps").start();
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
appIdsToRemove.map((appId) =>
|
||||
uninstallAppMutation({ saleorApiUrl: instanceUrl, token, id: appId })
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
uninstallSpinner.fail();
|
||||
console.error(e);
|
||||
return;
|
||||
}
|
||||
uninstallSpinner.succeed();
|
||||
};
|
120
apps/apps-cli/src/commands/webhooks-command.ts
Normal file
120
apps/apps-cli/src/commands/webhooks-command.ts
Normal file
|
@ -0,0 +1,120 @@
|
|||
import ora from "ora";
|
||||
import { getAccessTokenMutation } from "../saleor-api/operations/get-access-token-mutation";
|
||||
import { getAppsListQuery } from "../saleor-api/operations/get-apps-list-query";
|
||||
import { select } from "@inquirer/prompts";
|
||||
import { getAppWebhooksQuery } from "../saleor-api/operations/get-app-webhooks-query";
|
||||
import { removeWebhookMutation } from "../saleor-api/operations/remove-webhook-mutation";
|
||||
|
||||
interface DumpMetadataCommandArgs {
|
||||
instanceUrl: string;
|
||||
userEmail: string;
|
||||
userPassword: string;
|
||||
}
|
||||
|
||||
export const webhooksCommand = async ({
|
||||
instanceUrl,
|
||||
userEmail,
|
||||
userPassword,
|
||||
}: DumpMetadataCommandArgs) => {
|
||||
const loginSpinner = ora("Logging into Saleor instance").start();
|
||||
|
||||
const token = await getAccessTokenMutation({
|
||||
email: userEmail,
|
||||
password: userPassword,
|
||||
saleorApiUrl: instanceUrl,
|
||||
});
|
||||
|
||||
loginSpinner.succeed();
|
||||
|
||||
const appListSpinner = ora("Fetching installed apps").start();
|
||||
|
||||
const installedApps = await getAppsListQuery({
|
||||
saleorApiUrl: instanceUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
appListSpinner.succeed();
|
||||
|
||||
if (!installedApps.length) {
|
||||
console.log("No apps installed");
|
||||
return;
|
||||
}
|
||||
|
||||
const appId = await select({
|
||||
message: "Select app",
|
||||
choices: installedApps.map((app) => ({
|
||||
name: app.name ? `${app.name} (${app.id})` : app.id,
|
||||
value: app.id,
|
||||
})),
|
||||
});
|
||||
|
||||
const webhooksData = await getAppWebhooksQuery({
|
||||
appId,
|
||||
saleorApiUrl: instanceUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
if (!webhooksData.length) {
|
||||
console.log("Application has no webhooks configured");
|
||||
return;
|
||||
}
|
||||
|
||||
const webhook = await select({
|
||||
message: "Select webhook to investigate",
|
||||
choices: webhooksData.map((webhook) => ({
|
||||
name: `${webhook.name} (${[...webhook.syncEvents, ...webhook.asyncEvents]
|
||||
.map((e) => e.name)
|
||||
.join(", ")})`,
|
||||
value: webhook,
|
||||
description: `
|
||||
Target url: ${webhook.targetUrl}
|
||||
Active: ${webhook.isActive}
|
||||
Captured event deliveries count: ${webhook.eventDeliveries?.edges.length}
|
||||
`,
|
||||
})),
|
||||
});
|
||||
|
||||
const operation = await select({
|
||||
message: "Operation",
|
||||
choices: [
|
||||
{
|
||||
name: "List event deliveries",
|
||||
value: "list",
|
||||
},
|
||||
{
|
||||
name: "Remove webhook",
|
||||
value: "remove",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (operation === "list") {
|
||||
console.log("Number of entries: ", webhook.eventDeliveries?.edges.length);
|
||||
for (const deliveryEdge of webhook.eventDeliveries?.edges ?? []) {
|
||||
const delivery = deliveryEdge.node;
|
||||
|
||||
console.log(`
|
||||
Event type: ${delivery.eventType}
|
||||
Created at: ${delivery.createdAt}
|
||||
Status: ${delivery.status}`);
|
||||
const attempts = delivery.attempts?.edges ?? [];
|
||||
const lastAttempt = attempts[attempts.length - 1]?.node;
|
||||
|
||||
if (lastAttempt) {
|
||||
console.log(`
|
||||
Date of the last attempt: ${lastAttempt.createdAt}
|
||||
Status: ${lastAttempt.status}`);
|
||||
}
|
||||
}
|
||||
} else if (operation === "remove") {
|
||||
const removeSpinner = ora("Removing webhook...").start();
|
||||
|
||||
await removeWebhookMutation({
|
||||
saleorApiUrl: instanceUrl,
|
||||
token,
|
||||
webhookId: webhook.id,
|
||||
});
|
||||
|
||||
removeSpinner.succeed();
|
||||
}
|
||||
};
|
21
apps/apps-cli/src/lib/fetch-app-manifest.ts
Normal file
21
apps/apps-cli/src/lib/fetch-app-manifest.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { AppManifest } from "@saleor/app-sdk/types";
|
||||
|
||||
export const fetchAppManifest = async (manifestUrl: string) => {
|
||||
const manifestDataResponse = await fetch(manifestUrl);
|
||||
|
||||
let manifestData: AppManifest;
|
||||
|
||||
if (!manifestDataResponse.ok) {
|
||||
console.log("Error fetching manifest");
|
||||
throw new Error("Error fetching manifest");
|
||||
}
|
||||
|
||||
try {
|
||||
manifestData = (await manifestDataResponse.json()) as AppManifest;
|
||||
} catch (e) {
|
||||
console.log("Error parsing manifest");
|
||||
throw new Error("Error parsing manifest");
|
||||
}
|
||||
|
||||
return manifestData;
|
||||
};
|
65
apps/apps-cli/src/lib/filter-apps.test.ts
Normal file
65
apps/apps-cli/src/lib/filter-apps.test.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { AppDetailsFragment } from "../saleor-api/generated/graphql";
|
||||
import { filterApps } from "./filter-apps";
|
||||
|
||||
const mockedApp1: AppDetailsFragment = {
|
||||
id: "1",
|
||||
name: "app1",
|
||||
manifestUrl: "https://app1.com",
|
||||
};
|
||||
|
||||
const mockedApp1Duplicate: AppDetailsFragment = {
|
||||
id: "2",
|
||||
name: "app1",
|
||||
manifestUrl: "https://app1.com",
|
||||
};
|
||||
|
||||
const mockedApp2: AppDetailsFragment = {
|
||||
id: "3",
|
||||
name: "app2",
|
||||
manifestUrl: "https://app2.com",
|
||||
};
|
||||
|
||||
const mockedAppList = [mockedApp1, mockedApp1Duplicate, mockedApp2];
|
||||
|
||||
describe("filterApps", function () {
|
||||
it("Return the same apps, when no filters applied", async () => {
|
||||
expect(
|
||||
filterApps({
|
||||
apps: mockedAppList,
|
||||
filter: {},
|
||||
})
|
||||
).toStrictEqual(mockedAppList);
|
||||
});
|
||||
it("Return all apps with the same name, when filter name is applied", async () => {
|
||||
expect(
|
||||
filterApps({
|
||||
apps: mockedAppList,
|
||||
filter: {
|
||||
name: mockedApp1.name!,
|
||||
},
|
||||
})
|
||||
).toStrictEqual([mockedApp1, mockedApp1Duplicate]);
|
||||
});
|
||||
it("Return all apps with the same manifest, when filter manifest is applied", async () => {
|
||||
expect(
|
||||
filterApps({
|
||||
apps: mockedAppList,
|
||||
filter: {
|
||||
manifestUrl: mockedApp1.manifestUrl!,
|
||||
},
|
||||
})
|
||||
).toStrictEqual([mockedApp1, mockedApp1Duplicate]);
|
||||
});
|
||||
|
||||
it("Return app with given id, when filter id is applied", async () => {
|
||||
expect(
|
||||
filterApps({
|
||||
apps: mockedAppList,
|
||||
filter: {
|
||||
id: mockedApp1.id,
|
||||
},
|
||||
})
|
||||
).toStrictEqual([mockedApp1]);
|
||||
});
|
||||
});
|
28
apps/apps-cli/src/lib/filter-apps.ts
Normal file
28
apps/apps-cli/src/lib/filter-apps.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { AppDetailsFragment } from "../saleor-api/generated/graphql";
|
||||
|
||||
interface FilterAppsArgs {
|
||||
apps: AppDetailsFragment[];
|
||||
filter: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
manifestUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const filterApps = ({ apps, filter: { id, manifestUrl, name } }: FilterAppsArgs) => {
|
||||
return apps.filter((app) => {
|
||||
if (id && app.id !== id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name && app.name !== name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manifestUrl && app.manifestUrl !== manifestUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
94
apps/apps-cli/src/lib/install-and-wait-for-result.ts
Normal file
94
apps/apps-cli/src/lib/install-and-wait-for-result.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import ora from "ora";
|
||||
import { fetchAppManifest } from "./fetch-app-manifest";
|
||||
import { filterApps } from "./filter-apps";
|
||||
import { getAppInstallationsListQuery } from "../saleor-api/operations/get-app-installations-list-query";
|
||||
import { getAppsListQuery } from "../saleor-api/operations/get-apps-list-query";
|
||||
import { installAppMutation } from "../saleor-api/operations/install-app-mutation";
|
||||
|
||||
interface InstallAndWaitForResultArgs {
|
||||
saleorApiUrl: string;
|
||||
token: string;
|
||||
appManifestUrl: string;
|
||||
}
|
||||
|
||||
function delay(timeMs: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, timeMs));
|
||||
}
|
||||
|
||||
/*
|
||||
* Attempt to install app from the manifest, wait for the operation to complete
|
||||
* and return app installation result.
|
||||
* If will throw error if any of the steps fails.
|
||||
*/
|
||||
export const installAndWaitForResult = async ({
|
||||
saleorApiUrl,
|
||||
token,
|
||||
appManifestUrl,
|
||||
}: InstallAndWaitForResultArgs) => {
|
||||
const manifestSpinner = ora("Fetching app manifest").start();
|
||||
|
||||
const manifestData = await fetchAppManifest(appManifestUrl);
|
||||
|
||||
manifestSpinner.succeed();
|
||||
|
||||
const installSpinner = ora("Installing the app").start();
|
||||
|
||||
const appInstallationJob = await installAppMutation({
|
||||
manifestUrl: appManifestUrl,
|
||||
saleorApiUrl: saleorApiUrl,
|
||||
token,
|
||||
appName: manifestData.name,
|
||||
});
|
||||
|
||||
installSpinner.text = `Installing the app (job id: ${appInstallationJob.id})`;
|
||||
|
||||
// Lets give the API a bit of time to process installation
|
||||
await delay(1000);
|
||||
|
||||
// App installation is on progress, now we have to monitor if it resolved. Wait max 20s for the result
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const currentAppInstallations = await getAppInstallationsListQuery({
|
||||
saleorApiUrl: saleorApiUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const appInstallation = currentAppInstallations.find((x) => x.id === appInstallationJob.id);
|
||||
|
||||
if (!appInstallation) {
|
||||
// Job has been processed! If not on the list, it means it was successful
|
||||
break;
|
||||
}
|
||||
|
||||
if (appInstallation.status === "FAILED") {
|
||||
installSpinner.fail("Installation failed");
|
||||
throw new Error("App installation failed: " + appInstallation.message);
|
||||
}
|
||||
|
||||
// Wait a bit and check again
|
||||
await delay(2000);
|
||||
}
|
||||
|
||||
installSpinner.text = "Confirming the app installed";
|
||||
|
||||
// App should be installed by now, fetch its details
|
||||
const currentAppInstallations = await getAppsListQuery({
|
||||
saleorApiUrl,
|
||||
token,
|
||||
});
|
||||
|
||||
const installedApp = filterApps({
|
||||
apps: currentAppInstallations,
|
||||
filter: {
|
||||
manifestUrl: appManifestUrl,
|
||||
},
|
||||
});
|
||||
|
||||
if (!installedApp.length) {
|
||||
// Investigate if this can happen - app not in the list of installed apps nor in the list of installations
|
||||
throw new Error("App not found on the list of installed apps");
|
||||
}
|
||||
|
||||
installSpinner.succeed("App installed!");
|
||||
|
||||
return installedApp[0];
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
import request from "graphql-request";
|
||||
import { graphql } from "../generated/gql";
|
||||
|
||||
const getAccessTokenMutationDocument = graphql(/* GraphQL */ `
|
||||
mutation GetAccessToken($email: String!, $password: String!) {
|
||||
tokenCreate(email: $email, password: $password) {
|
||||
token
|
||||
refreshToken
|
||||
errors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const getAccessTokenMutation = async ({
|
||||
saleorApiUrl,
|
||||
email,
|
||||
password,
|
||||
}: {
|
||||
saleorApiUrl: string;
|
||||
password: string;
|
||||
email: string;
|
||||
}) => {
|
||||
const { tokenCreate } = await request(saleorApiUrl, getAccessTokenMutationDocument, {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (tokenCreate?.errors.length) {
|
||||
console.log("mutation failed", tokenCreate?.errors);
|
||||
throw new Error(`Get access token mutation failed - API returned errors`);
|
||||
}
|
||||
const token = tokenCreate?.token;
|
||||
|
||||
if (!token) {
|
||||
throw new Error(`Get access token mutation failed - no token in the response`);
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
import request from "graphql-request";
|
||||
|
||||
import { graphql } from "../generated/gql";
|
||||
|
||||
const getAppInstallationsQueryDocument = graphql(/* GraphQL */ `
|
||||
query GetAppInstallations {
|
||||
appsInstallations {
|
||||
id
|
||||
status
|
||||
message
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const getAppInstallationsListQuery = async ({
|
||||
saleorApiUrl,
|
||||
token,
|
||||
}: {
|
||||
saleorApiUrl: string;
|
||||
token: string;
|
||||
}) => {
|
||||
const { appsInstallations } = await request(
|
||||
saleorApiUrl,
|
||||
getAppInstallationsQueryDocument,
|
||||
{},
|
||||
{ "Authorization-Bearer": token }
|
||||
);
|
||||
|
||||
return appsInstallations;
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
import request from "graphql-request";
|
||||
|
||||
import { graphql } from "../generated/gql";
|
||||
|
||||
const getAppMetadataQueryDocument = graphql(/* GraphQL */ `
|
||||
query GetAppMetadata {
|
||||
app(id: "QXBwOjE=") {
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const getAppMetadataQuery = async ({
|
||||
saleorApiUrl,
|
||||
token,
|
||||
}: {
|
||||
saleorApiUrl: string;
|
||||
token: string;
|
||||
}) => {
|
||||
const { app } = await request(
|
||||
saleorApiUrl,
|
||||
getAppMetadataQueryDocument,
|
||||
{},
|
||||
{ "Authorization-Bearer": token }
|
||||
);
|
||||
|
||||
return app?.metadata ?? [];
|
||||
};
|
|
@ -0,0 +1,67 @@
|
|||
import request from "graphql-request";
|
||||
|
||||
import { graphql } from "../generated/gql";
|
||||
|
||||
const getAppWebhooksQueryDocument = graphql(/* GraphQL */ `
|
||||
query GetAppWebhooks($id: ID!) {
|
||||
app(id: $id) {
|
||||
webhooks {
|
||||
id
|
||||
name
|
||||
isActive
|
||||
syncEvents {
|
||||
name
|
||||
eventType
|
||||
}
|
||||
asyncEvents {
|
||||
name
|
||||
eventType
|
||||
}
|
||||
targetUrl
|
||||
eventDeliveries(first: 10) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
createdAt
|
||||
status
|
||||
eventType
|
||||
attempts(first: 10) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
createdAt
|
||||
taskId
|
||||
duration
|
||||
response
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const getAppWebhooksQuery = async ({
|
||||
saleorApiUrl,
|
||||
token,
|
||||
appId,
|
||||
}: {
|
||||
saleorApiUrl: string;
|
||||
token: string;
|
||||
appId: string;
|
||||
}) => {
|
||||
const { app } = await request(
|
||||
saleorApiUrl,
|
||||
getAppWebhooksQueryDocument,
|
||||
{
|
||||
id: appId,
|
||||
},
|
||||
{ "Authorization-Bearer": token }
|
||||
);
|
||||
|
||||
return app?.webhooks ?? [];
|
||||
};
|
|
@ -0,0 +1,48 @@
|
|||
import request from "graphql-request";
|
||||
|
||||
import { graphql } from "../generated/gql";
|
||||
|
||||
export const AppDetailsFragment = graphql(/* GraphQL */ `
|
||||
fragment AppDetails on App {
|
||||
id
|
||||
name
|
||||
isActive
|
||||
type
|
||||
created
|
||||
manifestUrl
|
||||
}
|
||||
`);
|
||||
|
||||
const getAppsQueryDocument = graphql(/* GraphQL */ `
|
||||
query GetApps {
|
||||
apps(
|
||||
first: 100
|
||||
filter: { type: THIRDPARTY }
|
||||
sortBy: { field: CREATION_DATE, direction: DESC }
|
||||
) {
|
||||
totalCount
|
||||
edges {
|
||||
node {
|
||||
...AppDetails
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const getAppsListQuery = async ({
|
||||
saleorApiUrl,
|
||||
token,
|
||||
}: {
|
||||
saleorApiUrl: string;
|
||||
token: string;
|
||||
}) => {
|
||||
const { apps } = await request(
|
||||
saleorApiUrl,
|
||||
getAppsQueryDocument,
|
||||
{},
|
||||
{ "Authorization-Bearer": token }
|
||||
);
|
||||
|
||||
return apps?.edges.map(({ node }) => node) ?? [];
|
||||
};
|
|
@ -0,0 +1,58 @@
|
|||
import request from "graphql-request";
|
||||
|
||||
import { graphql } from "../generated/gql";
|
||||
|
||||
const installAppMutationDocument = graphql(/* GraphQL */ `
|
||||
mutation InstallApp($input: AppInstallInput!) {
|
||||
appInstall(input: $input) {
|
||||
appInstallation {
|
||||
id
|
||||
status
|
||||
appName
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const installAppMutation = async ({
|
||||
saleorApiUrl,
|
||||
token,
|
||||
appName,
|
||||
manifestUrl,
|
||||
}: {
|
||||
saleorApiUrl: string;
|
||||
token: string;
|
||||
manifestUrl: string;
|
||||
appName: string;
|
||||
}) => {
|
||||
const { appInstall } = await request(
|
||||
saleorApiUrl,
|
||||
installAppMutationDocument,
|
||||
{
|
||||
input: {
|
||||
manifestUrl,
|
||||
activateAfterInstallation: true,
|
||||
appName,
|
||||
},
|
||||
},
|
||||
{ "Authorization-Bearer": token }
|
||||
);
|
||||
|
||||
if (appInstall?.errors.length) {
|
||||
console.log("Sth went wrong", appInstall.errors);
|
||||
throw new Error(`Install app ${appName} mutation failed`);
|
||||
}
|
||||
|
||||
if (!appInstall?.appInstallation) {
|
||||
console.log("App installation not returned");
|
||||
throw new Error(
|
||||
`Install app ${appName} mutation failed - no app installation data in the response`
|
||||
);
|
||||
}
|
||||
|
||||
return appInstall?.appInstallation;
|
||||
};
|
|
@ -0,0 +1,40 @@
|
|||
import request from "graphql-request";
|
||||
|
||||
import { graphql } from "../generated/gql";
|
||||
|
||||
const removeWebhookMutationDocument = graphql(/* GraphQL */ `
|
||||
mutation RemoveWebhook($webhookId: ID!) {
|
||||
webhookDelete(id: $webhookId) {
|
||||
errors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const removeWebhookMutation = async ({
|
||||
saleorApiUrl,
|
||||
token,
|
||||
webhookId,
|
||||
}: {
|
||||
saleorApiUrl: string;
|
||||
token: string;
|
||||
webhookId: string;
|
||||
}) => {
|
||||
const { webhookDelete } = await request(
|
||||
saleorApiUrl,
|
||||
removeWebhookMutationDocument,
|
||||
{
|
||||
webhookId,
|
||||
},
|
||||
{ "Authorization-Bearer": token }
|
||||
);
|
||||
|
||||
if (webhookDelete?.errors.length) {
|
||||
console.log("Sth went wrong", webhookDelete.errors);
|
||||
throw new Error(`Remove webhook mutation failed`);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
|
@ -0,0 +1,48 @@
|
|||
import request from "graphql-request";
|
||||
|
||||
import { graphql } from "../generated/gql";
|
||||
import { AppErrorCode } from "../generated/graphql";
|
||||
|
||||
const uninstallAppMutationDocument = graphql(/* GraphQL */ `
|
||||
mutation UninstallApp($id: ID!) {
|
||||
appDelete(id: $id) {
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const uninstallAppMutation = async ({
|
||||
saleorApiUrl,
|
||||
token,
|
||||
id,
|
||||
}: {
|
||||
saleorApiUrl: string;
|
||||
token: string;
|
||||
id: string;
|
||||
}) => {
|
||||
const { appDelete } = await request(
|
||||
saleorApiUrl,
|
||||
uninstallAppMutationDocument,
|
||||
{
|
||||
id,
|
||||
},
|
||||
{ "Authorization-Bearer": token }
|
||||
);
|
||||
|
||||
if (appDelete?.errors.length) {
|
||||
const error = appDelete.errors[0];
|
||||
|
||||
if (error.code === AppErrorCode.NotFound) {
|
||||
throw new Error(`Uninstall app ${id} mutation failed - no installed app with this ID`);
|
||||
}
|
||||
throw new Error(
|
||||
`Uninstall app ${id} mutation failed. API responded with error: ${error.code} - ${error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
97
apps/apps-cli/tsconfig.json
Normal file
97
apps/apps-cli/tsconfig.json
Normal file
|
@ -0,0 +1,97 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
/* Projects */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
/* Language and Environment */
|
||||
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
"lib": [
|
||||
"es2021"
|
||||
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
|
||||
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
/* Modules */
|
||||
"module": "ES2022" /* Specify what module code is generated. */,
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
"resolveJsonModule": true /* Enable importing .json files */,
|
||||
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./build" /* Specify an output folder for all emitted files. */,
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||
/* Type Checking */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
|
||||
"useUnknownInCatchVariables": true /* Type catch clause variables as 'unknown' instead of 'any'. */,
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
17
apps/apps-cli/turbo.json
Normal file
17
apps/apps-cli/turbo.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": [
|
||||
"//"
|
||||
],
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"pipeline": {
|
||||
"build": {
|
||||
"env": [
|
||||
"APP_DEBUG",
|
||||
"NODE_ENV",
|
||||
"INSTANCE_URL",
|
||||
"USER_EMAIL",
|
||||
"USER_PASSWORD"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
# The key used for metadata encryption. Required for production builds
|
||||
SECRET_KEY=
|
||||
|
||||
# APL Config
|
||||
# https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md
|
||||
APL=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=
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"root": true,
|
||||
"extends": ["saleor"]
|
||||
}
|
|
@ -1,121 +0,0 @@
|
|||
# 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
|
|
@ -1,19 +0,0 @@
|
|||
fragment BulkImportProduct on Product {
|
||||
id
|
||||
name
|
||||
slug
|
||||
variants {
|
||||
id
|
||||
name
|
||||
channelListings {
|
||||
channel {
|
||||
id
|
||||
slug
|
||||
}
|
||||
price {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
query FetchChannels {
|
||||
channels {
|
||||
id
|
||||
slug
|
||||
name
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
query FetchProductsPaginated($channel: String, $after: String) {
|
||||
products(first: 100, channel: $channel, after: $after) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
node {
|
||||
...BulkImportProduct
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -1,72 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
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>;
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -1,184 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -1,54 +0,0 @@
|
|||
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;
|
||||
})
|
||||
);
|
||||
},
|
||||
[]
|
||||
),
|
||||
};
|
||||
};
|
|
@ -1,65 +0,0 @@
|
|||
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 };
|
||||
};
|
|
@ -1,75 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -1,89 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -1,66 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -1,71 +0,0 @@
|
|||
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);
|
||||
}),
|
||||
});
|
|
@ -1,7 +0,0 @@
|
|||
import { Text } from "@saleor/macaw-ui/next";
|
||||
|
||||
export const ChanelProviderConnectionsSectionHeader = () => (
|
||||
<Text marginBottom={4} as="h2" variant="heading">
|
||||
Channels Connections
|
||||
</Text>
|
||||
);
|
|
@ -1,94 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -1,56 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -1,36 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
|
@ -1,148 +0,0 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,148 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
/**
|
||||
* 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";
|
|
@ -1,12 +0,0 @@
|
|||
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);
|
||||
};
|
|
@ -1,24 +0,0 @@
|
|||
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";
|
||||
}
|
||||
}
|
||||
};
|
|
@ -1,26 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
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>;
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
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",
|
||||
];
|
|
@ -1,26 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
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);
|
||||
}),
|
||||
});
|
|
@ -1,99 +0,0 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -1,45 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,238 +0,0 @@
|
|||
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,
|
||||
};
|
|
@ -1,67 +0,0 @@
|
|||
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,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,145 +0,0 @@
|
|||
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;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
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;
|
|
@ -1,9 +0,0 @@
|
|||
<?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>
|
Before Width: | Height: | Size: 2.1 KiB |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue