Compare commits
7 commits
poc-data-e
...
main
Author | SHA1 | Date | |
---|---|---|---|
4b12982597 | |||
292a5bdb0c | |||
![]() |
653b98df86 | ||
![]() |
ae6dbb125b | ||
![]() |
6948fe41ca | ||
![]() |
2a22cf47f2 | ||
![]() |
927d2b3bb6 |
202 changed files with 651 additions and 51400 deletions
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-taxes": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
Added logs for AvaTax and TaxJar. Logs are stored in the app metadata. Only the last 100 events are stored. Each provider configuration has its own logs. You can get to them by a new button "Logs" in the provider table.
|
|
|
@ -1,11 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-cms-v2": patch
|
|
||||||
"saleor-app-emails-and-messages": patch
|
|
||||||
"saleor-app-invoices": patch
|
|
||||||
"saleor-app-products-feed": patch
|
|
||||||
"saleor-app-search": patch
|
|
||||||
"saleor-app-segment": patch
|
|
||||||
"saleor-app-taxes": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Bumped @hookform/resolvers from 2.9.11 to 3.3.1
|
|
|
@ -1,17 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-emails-and-messages": patch
|
|
||||||
"@saleor/webhook-utils": patch
|
|
||||||
"saleor-app-data-importer": patch
|
|
||||||
"saleor-app-products-feed": patch
|
|
||||||
"saleor-app-invoices": patch
|
|
||||||
"saleor-app-klaviyo": patch
|
|
||||||
"saleor-app-segment": patch
|
|
||||||
"saleor-app-cms-v2": patch
|
|
||||||
"saleor-app-search": patch
|
|
||||||
"saleor-app-slack": patch
|
|
||||||
"saleor-app-taxes": patch
|
|
||||||
"saleor-app-crm": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Updated dependencies:
|
|
||||||
- @graphql-codegen/cli@5.0.0
|
|
|
@ -1,15 +0,0 @@
|
||||||
---
|
|
||||||
"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-segment": patch
|
|
||||||
"saleor-app-cms-v2": patch
|
|
||||||
"saleor-app-search": patch
|
|
||||||
"saleor-app-slack": patch
|
|
||||||
"saleor-app-taxes": patch
|
|
||||||
"saleor-app-crm": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Updated Sentry package
|
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-klaviyo": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Fixed error where config couldn't be saved
|
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-search": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Webhook migration scripts has been moved to the shared package.
|
|
8
.changeset/pretty-pink-panda.md
Normal file
8
.changeset/pretty-pink-panda.md
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
#changelog
|
||||||
|
---
|
||||||
|
"apps": minor
|
||||||
|
---
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `apps/emails-and-messages/.env.template`: Described the new environment variable and how it works
|
||||||
|
- `apps/emails-and-messages/src/saleor-app.ts`: Added case "redis" for switch(AplType), which takes advantage of the [RedisAPL PR](https://github.com/saleor/app-sdk/pull/287) I submitted
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-invoices": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
Replace text "loading" messages with skeletons
|
|
|
@ -1,33 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-search": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
Added a new `pricing` field to the Algolia object representation. It now passes variant pricing representation from GraphQL:
|
|
||||||
|
|
||||||
```graphQL
|
|
||||||
price {
|
|
||||||
gross {
|
|
||||||
amount
|
|
||||||
}
|
|
||||||
net {
|
|
||||||
amount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
discount {
|
|
||||||
gross {
|
|
||||||
amount
|
|
||||||
}
|
|
||||||
net {
|
|
||||||
amount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onSale
|
|
||||||
priceUndiscounted {
|
|
||||||
gross {
|
|
||||||
amount
|
|
||||||
}
|
|
||||||
net {
|
|
||||||
amount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
"@saleor/webhook-utils": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
Introduced a new shared package with helpers used for webhook management.
|
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-taxes": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
[skip ci]: Bump jotai from 2.0.0 to 2.4.2
|
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-invoices": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
Redesigned app layout. Now app uses shared sections as other apps.
|
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
"saleor-app-klaviyo": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
Improved app layout to match modern style.
|
|
2
.github/workflows/changeset-checker.yml
vendored
2
.github/workflows/changeset-checker.yml
vendored
|
@ -20,7 +20,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
# check out full history
|
# check out full history
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
|
|
2
.github/workflows/check-deps.yml
vendored
2
.github/workflows/check-deps.yml
vendored
|
@ -17,7 +17,7 @@ jobs:
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
- uses: JamieMason/syncpack-github-action@0.2.2
|
- uses: JamieMason/syncpack-github-action@c145cec44b3731b3fe8e859679e240d6ae011f0f
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
package-manager: "pnpm"
|
package-manager: "pnpm"
|
||||||
|
|
2
.github/workflows/check-spelling.yml
vendored
2
.github/workflows/check-spelling.yml
vendored
|
@ -7,4 +7,4 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: streetsidesoftware/cspell-action@v3
|
- uses: streetsidesoftware/cspell-action@22e32eb3d70acf30e3fc09bd46edc1d30fb2d6db
|
||||||
|
|
4
.github/workflows/prepare-release.yml
vendored
4
.github/workflows/prepare-release.yml
vendored
|
@ -25,11 +25,11 @@ jobs:
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 16
|
||||||
- uses: pnpm/action-setup@v2
|
- uses: pnpm/action-setup@d882d12c64e032187b2edb46d3a0d003b7a43598
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
- run: pnpm install
|
- run: pnpm install
|
||||||
- name: Create Release Pull Request
|
- name: Create Release Pull Request
|
||||||
uses: changesets/action@v1
|
uses: changesets/action@f13b1baaa620fde937751f5d2c3572b9da32af23
|
||||||
id: changesets
|
id: changesets
|
||||||
with:
|
with:
|
||||||
title: 🚀 Release apps
|
title: 🚀 Release apps
|
||||||
|
|
2
.github/workflows/unit-tests.yml
vendored
2
.github/workflows/unit-tests.yml
vendored
|
@ -9,7 +9,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: pnpm/action-setup@v2.2.4
|
- uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
* @saleor/appstore
|
* @saleor/delivery-engineering-js
|
||||||
|
|
|
@ -1,5 +1,14 @@
|
||||||
# saleor-app-cms-v2
|
# saleor-app-cms-v2
|
||||||
|
|
||||||
|
## 2.3.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 5d3d81d: Bumped @hookform/resolvers from 2.9.11 to 3.3.1
|
||||||
|
- 5dee65a: Updated dependencies:
|
||||||
|
- @graphql-codegen/cli@5.0.0
|
||||||
|
- 2e29699: Updated Sentry package
|
||||||
|
|
||||||
## 2.3.2
|
## 2.3.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "saleor-app-cms-v2",
|
"name": "saleor-app-cms-v2",
|
||||||
"version": "2.3.2",
|
"version": "2.3.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm generate && next build",
|
"build": "pnpm generate && next build",
|
||||||
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
# saleor-app-crm
|
# saleor-app-crm
|
||||||
|
|
||||||
|
## 1.7.8
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 5dee65a: Updated dependencies:
|
||||||
|
- @graphql-codegen/cli@5.0.0
|
||||||
|
- 2e29699: Updated Sentry package
|
||||||
|
|
||||||
## 1.7.7
|
## 1.7.7
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "saleor-app-crm",
|
"name": "saleor-app-crm",
|
||||||
"version": "1.7.7",
|
"version": "1.7.8",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm generate && next build",
|
"build": "pnpm generate && next build",
|
||||||
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
APP_DEBUG=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"]
|
|
||||||
}
|
|
3
apps/data-exporter/.gitignore
vendored
3
apps/data-exporter/.gitignore
vendored
|
@ -1,3 +0,0 @@
|
||||||
|
|
||||||
# Sentry Auth Token
|
|
||||||
.sentryclirc
|
|
|
@ -1,19 +0,0 @@
|
||||||
schema: graphql/schema.graphql
|
|
||||||
documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx]
|
|
||||||
extensions:
|
|
||||||
codegen:
|
|
||||||
overwrite: true
|
|
||||||
generates:
|
|
||||||
generated/graphql.ts:
|
|
||||||
config:
|
|
||||||
dedupeFragments: true
|
|
||||||
plugins:
|
|
||||||
- typescript
|
|
||||||
- typescript-operations
|
|
||||||
- typescript-urql:
|
|
||||||
documentVariablePrefix: "Untyped"
|
|
||||||
fragmentVariablePrefix: "Untyped"
|
|
||||||
- typed-document-node
|
|
||||||
generated/schema.graphql:
|
|
||||||
plugins:
|
|
||||||
- schema-ast
|
|
|
@ -1,364 +0,0 @@
|
||||||
# saleor-app-products-feed
|
|
||||||
|
|
||||||
## 1.12.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- 261e9d1: Added additional images attribute to the feed for media uploaded to the product.
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 23e71bc: Fix typo on button label
|
|
||||||
- 261e9d1: Improved default resolution of the submitted images. Was: 500px, now it's 1024px.
|
|
||||||
Users can now configure the size in the app configuration.
|
|
||||||
- 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
|
|
||||||
|
|
||||||
## 1.11.4
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 2a1385b: Plaintext EditorJS renderer has been moved to the shared package.
|
|
||||||
- a81f061: Updated Macaw to pre-127
|
|
||||||
- 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
|
|
||||||
|
|
||||||
## 1.11.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 8b3d961: Updated Macaw UI to pre.118
|
|
||||||
- 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
|
|
||||||
|
|
||||||
## 1.11.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 3002354: Added error logging for exceptions thrown at tRPC routes.
|
|
||||||
|
|
||||||
## 1.11.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- bb504d9: Fixed error on loading configuration created in the previous version of the app.
|
|
||||||
- bb504d9: Fixed issue with saving changes in the title template form.
|
|
||||||
- bb504d9: Improved error logging in the tRPC API.
|
|
||||||
|
|
||||||
## 1.11.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- 6a84b4b: Changed format of product URLs to Handlebars. Previous format can still be parsed, so this change does not affect existing configurations.
|
|
||||||
|
|
||||||
Old format: `http://example.com/{variantId}`
|
|
||||||
New format: `http://example.com/{{variant.id}}`
|
|
||||||
|
|
||||||
- 6a84b4b: Added item title customization using Handlebars.
|
|
||||||
- fc5e639: Feed format has been changed to leverage Product Group ID field:
|
|
||||||
- Product ID: feed items use SKU if available, product variant ID is used otherwise
|
|
||||||
- Product Group ID: product ID is used for all the items
|
|
||||||
- 0b0297e: Updated pricing attributes according to the Google guidelines.
|
|
||||||
|
|
||||||
Was:
|
|
||||||
|
|
||||||
- Price: base or discounted price
|
|
||||||
|
|
||||||
Now:
|
|
||||||
|
|
||||||
- Price: always the base price. Attribute skipped if amount is equal to 0.
|
|
||||||
- Sale price: discounted price. Attribute skipped if value is the same as base price
|
|
||||||
|
|
||||||
- aece073: Added configuration for choosing which product attributes should be used for generating Google Product Feed. Supported feed attributes: Brand, Color, Size, Material, Pattern.
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
## 1.10.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 07999ea: Changed input type for region in the AWS credentials section. Region can be now selected from the list, instead of text input.
|
|
||||||
- 43d7e47: Added validation for AWS credentials. If provided configuration for S3 Bucket is invalid, it won't be saved.
|
|
||||||
|
|
||||||
## 1.10.2
|
|
||||||
|
|
||||||
### 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
|
|
||||||
- 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
|
|
||||||
|
|
||||||
## 1.10.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 70cb741: Update Zod to 3.21.4
|
|
||||||
- e7c2d3a: Updated and ESLint dependencies
|
|
||||||
- 3c6cd4c: Updated the @saleor/app-sdk package to version 0.41.1.
|
|
||||||
- 6210447: Updated tRPC packages to 10.34.0
|
|
||||||
- 6210447: Updated @tanstack/react-query 4.29.19
|
|
||||||
- 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.10.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- a1f083c: Filled "about" field in App Manifest. Dashboard will display it in app details page now.
|
|
||||||
- 47102ba: Added additional ENV variables (see each app's .env.example), that can overwrite app base URL. This change allows easy apps development using Docker
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 2d77bca: Updated Next.js to 13.4.8
|
|
||||||
- 6299e06: Update @saleor/app-sdk to 0.41.0
|
|
||||||
- Updated dependencies [2d77bca]
|
|
||||||
- Updated dependencies [6299e06]
|
|
||||||
- @saleor/apps-shared@1.7.3
|
|
||||||
- @saleor/apps-ui@1.1.3
|
|
||||||
- @saleor/react-hook-form-macaw@0.2.0
|
|
||||||
|
|
||||||
## 1.9.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- 1dead1e: Included dedicated logo and attached it to App's manifest. From Saleor 3.15 the logo will be visible in the Dashboard during and after installation.
|
|
||||||
- 3462cc3: 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.
|
|
||||||
- e4497b9: Added test-id attributes to several meaningful elements. Now quering in automated tests are more stable
|
|
||||||
- e4497b9: Make Google Categories mapping lazy loaded, so the page loads immediately and forms add with a delay. Previously the page was stuck due to large chunk size
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 860bac4: Updated @saleor/app-sdk to 0.40.1
|
|
||||||
- a1ad70e: Updated configuration and dependencies of GraphQL client - urql.
|
|
||||||
All applications use now unified config for creating the client. Also unused related packages has been removed.
|
|
||||||
- ec68ed5: Updated Sentry package and config. Set Sentry release to match package.json version. Now Sentry will use semver version instead a commit
|
|
||||||
- cb6ee29: Updated dependencies
|
|
||||||
- Updated dependencies [f96563f]
|
|
||||||
- Updated dependencies [f96563f]
|
|
||||||
- Updated dependencies [860bac4]
|
|
||||||
- Updated dependencies [a1ad70e]
|
|
||||||
- Updated dependencies [cb6ee29]
|
|
||||||
- Updated dependencies [a1ad70e]
|
|
||||||
- @saleor/react-hook-form-macaw@0.2.0
|
|
||||||
- @saleor/apps-ui@1.1.2
|
|
||||||
- @saleor/apps-shared@1.7.2
|
|
||||||
|
|
||||||
## 1.8.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- a8834a1: Unified graphql version to 16.6
|
|
||||||
- a8834a1: Unified graphql codegen packages
|
|
||||||
- a8834a1: Removed unnecessary duplicated dependencies from apps and moved them to shared and root (types, eslint rules)
|
|
||||||
- a8834a1: Updated dev dependencies - Typescript, Eslint and Turborepo
|
|
||||||
- 928c727: Updated @saleor/macaw-ui to 0.8.0-pre.95. This version introduces change in spacing scale, so there may be slight changes in spacing
|
|
||||||
- Updated dependencies [a8834a1]
|
|
||||||
- Updated dependencies [a8834a1]
|
|
||||||
- Updated dependencies [a8834a1]
|
|
||||||
- Updated dependencies [a8834a1]
|
|
||||||
- Updated dependencies [a8834a1]
|
|
||||||
- Updated dependencies [928c727]
|
|
||||||
- @saleor/react-hook-form-macaw@0.1.2
|
|
||||||
- @saleor/apps-shared@1.7.1
|
|
||||||
- @saleor/apps-ui@1.1.1
|
|
||||||
|
|
||||||
## 1.8.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- 182bdd6: This version introduces major update of the App UI:
|
|
||||||
|
|
||||||
- Added Macaw UI / next
|
|
||||||
- Rebuilt app's UI to match modern Saleor guidelines
|
|
||||||
- Moved S3 form to be configured once, globally
|
|
||||||
- Added tests and refactored App configuration/metadata (incompatible with previous version)
|
|
||||||
- Product-related webhooks are now refreshing cache responsible on indexing large databases
|
|
||||||
|
|
||||||
App was not officially released yet, hence only minor update is introduced
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 0c2fc65: Update dev dependencies - Vite and Vitest. These changes will not affect runtime Apps, but can affect tests and builds
|
|
||||||
- Updated dependencies [0c2fc65]
|
|
||||||
- Updated dependencies [b75a664]
|
|
||||||
- @saleor/react-hook-form-macaw@0.1.1
|
|
||||||
- @saleor/apps-shared@1.7.0
|
|
||||||
|
|
||||||
## 1.7.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 6e69f4f: Update app-sdk to 0.39.1
|
|
||||||
- Updated dependencies [6e69f4f]
|
|
||||||
- @saleor/apps-shared@1.6.1
|
|
||||||
|
|
||||||
## 1.7.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- 0c8717a: Generated feed XML can now be uploaded to the s3 bucket.
|
|
||||||
|
|
||||||
Feed generation is now much faster thanks to implemented caching.
|
|
||||||
|
|
||||||
- 4801803: Added Sentry config. If Sentry is configured in ENV, it will use default Sentry configuration for Next.js to send errors to the Sentry
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- ce8d9de: Product description in the feed is now a plaintext instead of JSON.
|
|
||||||
- Updated dependencies [23b5c70]
|
|
||||||
- @saleor/apps-shared@1.6.0
|
|
||||||
|
|
||||||
## 1.6.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- c406318: Updated dep @saleor/app-sdk to 0.38.0
|
|
||||||
- 2de2a40: Query for the product details run now in paralell to speed up overall feed generation
|
|
||||||
- Updated dependencies [c406318]
|
|
||||||
- @saleor/apps-shared@1.5.1
|
|
||||||
|
|
||||||
## 1.6.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 8b22b1c: Restored Pino logger packages to each app, to fix failing logs in development. Also updated .env.example to contain up to date APP_LOG_LEVEL variable
|
|
||||||
|
|
||||||
## 1.6.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- 830cfe9: Changed APP_DEBUG env to APP_LOG_LEVEL
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 830cfe9: Replaced internal logger implementation with shared logger
|
|
||||||
- Updated dependencies [830cfe9]
|
|
||||||
- @saleor/apps-shared@1.5.0
|
|
||||||
|
|
||||||
## 1.5.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- 57f6d41: Updated Manifest to contain up to date support, privacy, homepage and author fields
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 2c0df91: Added lint:fix script, so `eslint --fix` can be run deliberately
|
|
||||||
- e167e72: Update next.js to 13.3.0
|
|
||||||
- 74174c4: Updated @saleor/app-sdk to 0.37.3
|
|
||||||
- 2e51890: Update next.js to 13.3.0
|
|
||||||
- 2e51890: Update @saleor/app-sdk to 0.37.2
|
|
||||||
- 2e51890: Use useDashboardNotification hook from shared package, instead of direct AppBridge usage
|
|
||||||
- Updated dependencies [2c0df91]
|
|
||||||
- Updated dependencies [e167e72]
|
|
||||||
- Updated dependencies [74174c4]
|
|
||||||
- Updated dependencies [2e51890]
|
|
||||||
- Updated dependencies [2e51890]
|
|
||||||
- Updated dependencies [2e51890]
|
|
||||||
- @saleor/apps-shared@1.4.0
|
|
||||||
|
|
||||||
## 1.4.3
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- eca52ad: Replace "export default" with named exports
|
|
||||||
- @saleor/apps-shared@1.3.0
|
|
||||||
|
|
||||||
## 1.4.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- a811d30: Added default cache of feed file to 5 minutes. It can be overwritten by env variable.
|
|
||||||
|
|
||||||
## 1.4.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 5fad97c: Update the UI to the common theme
|
|
||||||
- f58043f: Add Google Products Category mapping
|
|
||||||
|
|
||||||
## 1.4.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- 7cb3b89: Added "author" field to the Manifest, set it to Saleor Commerce, so Dashboard can display it too
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 7cb3b89: Replace apps to avoid AppPermission (use Permission for client permissions) and authData.domain (use saleorApiUrl)
|
|
||||||
- 7cb3b89: Updated @saleor/app-sdk to 0.37.1
|
|
||||||
|
|
||||||
## 1.3.2
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- e93a4dc: Updated GraphQL Code Generator package
|
|
||||||
|
|
||||||
## 1.3.1
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- dca82bb: Update app-sdk to pre-0.34.0. Update Async Webhooks to use new API
|
|
||||||
|
|
||||||
## 1.3.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- 2d23480: Remove TitleBar component from apps, because it is moved to Dashboard, outside of iframe context
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- Updated dependencies [2d23480]
|
|
||||||
- @saleor/apps-shared@1.3.0
|
|
||||||
|
|
||||||
## 1.2.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- 289b42f: Breaking change for app maintainers: VercelAPL can no longer be set for the app since it's deprecated and will be removed in app-sdk 0.30.0. As a replacement, we recommend using Upstash APL or implementing your own.
|
|
||||||
Read more about APLs: https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- 4e4257d: Ensure initially selected channel will not crash if no channels exist
|
|
||||||
|
|
||||||
## 1.1.0
|
|
||||||
|
|
||||||
### Minor Changes
|
|
||||||
|
|
||||||
- 1c9b2c4: Change public app names to be more readable
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- ca3a030: Add products feed app
|
|
||||||
- Updated dependencies [5fc88ed]
|
|
||||||
- @saleor/apps-shared@1.2.0
|
|
|
@ -1,114 +0,0 @@
|
||||||
<div align="center">
|
|
||||||
<h1>Products feed</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div align="center">
|
|
||||||
<p>Share products data with the feed aggregators</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## About Saleor Invoices app
|
|
||||||
|
|
||||||
- Create Google Merchant Feed XML
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
Before you start, make sure you have installed:
|
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/)
|
|
||||||
- [pnpm](https://pnpm.io/)
|
|
||||||
- [Saleor CLI](https://docs.saleor.io/docs/3.x/cli) - optional, but recommended
|
|
||||||
|
|
||||||
### With CLI
|
|
||||||
|
|
||||||
The easiest way to set up a Saleor app is by using the Saleor CLI.
|
|
||||||
|
|
||||||
[Saleor CLI](https://github.com/saleor/saleor-cli) is designed to save you from the repetitive chores around Saleor development, including creating Apps. It will take the burden of spawning new apps locally, connecting them with Saleor environments, and establishing a tunnel for local development in seconds.
|
|
||||||
|
|
||||||
[Full Saleor CLI reference](https://docs.saleor.io/docs/3.x/developer/cli)
|
|
||||||
|
|
||||||
If you don't have a (free developer) Saleor Cloud account, create one with the following command:
|
|
||||||
|
|
||||||
```
|
|
||||||
saleor register
|
|
||||||
```
|
|
||||||
|
|
||||||
Now you're ready to create your first App:
|
|
||||||
|
|
||||||
```
|
|
||||||
saleor app create [your-app-name]
|
|
||||||
```
|
|
||||||
|
|
||||||
In this step, Saleor CLI will:
|
|
||||||
|
|
||||||
- clone this repository to the specified folder
|
|
||||||
- install dependencies
|
|
||||||
- ask you whether you'd like to install the app in the selected Saleor environment
|
|
||||||
- create `.env` file
|
|
||||||
- start the app in development mode
|
|
||||||
|
|
||||||
Having your app ready, the final thing you want to establish is a tunnel with your Saleor environment. Go to your app's directory first and run:
|
|
||||||
|
|
||||||
```
|
|
||||||
saleor app tunnel
|
|
||||||
```
|
|
||||||
|
|
||||||
Your local application should be available now to the outside world (Saleor instance) for accepting all the events via webhooks.
|
|
||||||
|
|
||||||
A quick note: the next time you come back to your project, it is enough to launch your app in a standard way (and then launch your tunnel as described earlier):
|
|
||||||
|
|
||||||
```
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Without CLI
|
|
||||||
|
|
||||||
1. Install the dependencies by running:
|
|
||||||
|
|
||||||
```
|
|
||||||
pnpm install
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Start the local server with:
|
|
||||||
|
|
||||||
```
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Expose local environment using tunnel:
|
|
||||||
Use tunneling tools like [localtunnel](https://github.com/localtunnel/localtunnel) or [ngrok](https://ngrok.com/).
|
|
||||||
|
|
||||||
4. Install application at your dashboard:
|
|
||||||
|
|
||||||
If you use Saleor Cloud or your local server is exposed, you can install your app by following this link:
|
|
||||||
|
|
||||||
```
|
|
||||||
[YOUR_SALEOR_DASHBOARD_URL]/apps/install?manifestUrl=[YOUR_APP_TUNNEL_MANIFEST_URL]
|
|
||||||
```
|
|
||||||
|
|
||||||
This template host manifest at `/api/manifest`
|
|
||||||
|
|
||||||
You can also install application using GQL or command line. Follow the guide [how to install your app](https://docs.saleor.io/docs/3.x/developer/extending/apps/installing-apps#installation-using-graphql-api) to learn more.
|
|
||||||
|
|
||||||
### Generated schema and typings
|
|
||||||
|
|
||||||
Commands `build` and `dev` would generate schema and typed functions using Saleor's GraphQL endpoint. Commit the `generated` folder to your repo as they are necessary for queries and keeping track of the schema changes.
|
|
||||||
|
|
||||||
[Learn more](https://www.graphql-code-generator.com/) about GraphQL code generation.
|
|
||||||
|
|
||||||
### Storing registration data - APL
|
|
||||||
|
|
||||||
During registration process Saleor API pass the auth token to the app. With this token App can query Saleor API with privileged access (depending on requested permissions during the installation).
|
|
||||||
To store this data, app-template use a different [APL interfaces](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md).
|
|
||||||
|
|
||||||
The choice of the APL is done using `APL` environment variable. If value is not set, FileAPL is used. Available choices:
|
|
||||||
|
|
||||||
- `file`: no additional setup is required. Good choice for local development. Can't be used for multi tenant-apps or be deployed (not intended for production)
|
|
||||||
- `upstash`: use [Upstash](https://upstash.com/) Redis as storage method. Free account required. Can be used for development and production and supports multi-tenancy. Requires `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables to be set
|
|
||||||
|
|
||||||
If you want to use your own database, you can implement your own APL. [Check the documentation to read more.](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)
|
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
- `FEED_CACHE_MAX_AGE`: Amount of seconds the the response will be cached for. Default time is 5 minutes.
|
|
|
@ -1,5 +0,0 @@
|
||||||
fragment AttributeWithMappingFragment on Attribute {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
slug
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
fragment CategoryWithMappingFragment on Category {
|
|
||||||
id
|
|
||||||
parent{
|
|
||||||
name
|
|
||||||
parent{
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
name
|
|
||||||
googleCategoryId: metafield(key: "google_category_id")
|
|
||||||
}
|
|
|
@ -1,68 +0,0 @@
|
||||||
fragment GoogleFeedProductVariant on ProductVariant {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
sku
|
|
||||||
pricing {
|
|
||||||
priceUndiscounted{
|
|
||||||
gross {
|
|
||||||
currency
|
|
||||||
amount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
price {
|
|
||||||
gross {
|
|
||||||
currency
|
|
||||||
amount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
quantityAvailable
|
|
||||||
attributes {
|
|
||||||
attribute {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
values {
|
|
||||||
value
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
product {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
slug
|
|
||||||
description
|
|
||||||
seoDescription
|
|
||||||
media{
|
|
||||||
id
|
|
||||||
alt
|
|
||||||
url(size: $imageSize)
|
|
||||||
type
|
|
||||||
}
|
|
||||||
variants{
|
|
||||||
id
|
|
||||||
media{
|
|
||||||
id
|
|
||||||
alt
|
|
||||||
url(size: $imageSize)
|
|
||||||
type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
attributes{
|
|
||||||
attribute{
|
|
||||||
id
|
|
||||||
}
|
|
||||||
values{
|
|
||||||
value
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
thumbnail(size: $imageSize) {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
category {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
googleCategoryId: metafield(key: "google_category_id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
fragment ProductVariantWebhookPayload on ProductVariant {
|
|
||||||
channel
|
|
||||||
channelListings {
|
|
||||||
channel {
|
|
||||||
slug
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
fragment ProductWebhookPayload on Product {
|
|
||||||
channel
|
|
||||||
channelListings {
|
|
||||||
channel {
|
|
||||||
slug
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
mutation UpdateCategoryMapping($id: ID!, $googleCategoryId: String!) {
|
|
||||||
updateMetadata(id: $id, input: {key: "google_category_id", value: $googleCategoryId}) {
|
|
||||||
errors {
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
query FetchAppDetails {
|
|
||||||
app {
|
|
||||||
id
|
|
||||||
privateMetadata {
|
|
||||||
key
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
query FetchAttributesWithMapping($cursor: String){
|
|
||||||
attributes(first: 100, after: $cursor){
|
|
||||||
pageInfo{
|
|
||||||
hasNextPage
|
|
||||||
endCursor
|
|
||||||
}
|
|
||||||
edges{
|
|
||||||
node{
|
|
||||||
...AttributeWithMappingFragment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
query FetchCategoriesWithMapping($cursor: String){
|
|
||||||
categories(first: 100, after: $cursor){
|
|
||||||
pageInfo{
|
|
||||||
hasNextPage
|
|
||||||
endCursor
|
|
||||||
}
|
|
||||||
edges{
|
|
||||||
node{
|
|
||||||
...CategoryWithMappingFragment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
query FetchProductCursors($first:Int!, $after: String, $channel: String!){
|
|
||||||
productVariants(first:$first, after: $after, channel: $channel){
|
|
||||||
pageInfo{
|
|
||||||
hasNextPage
|
|
||||||
startCursor
|
|
||||||
endCursor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
query FetchProductDataForFeed($first:Int!, $after: String, $channel: String!, $imageSize: Int = 1024){
|
|
||||||
productVariants(first:$first, after: $after, channel: $channel){
|
|
||||||
pageInfo{
|
|
||||||
hasNextPage
|
|
||||||
endCursor
|
|
||||||
}
|
|
||||||
edges{
|
|
||||||
node{
|
|
||||||
...GoogleFeedProductVariant
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
query ShopDetails {
|
|
||||||
shop{
|
|
||||||
name
|
|
||||||
description
|
|
||||||
}
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,9 +0,0 @@
|
||||||
subscription ProductCreated {
|
|
||||||
event {
|
|
||||||
... on ProductCreated {
|
|
||||||
product {
|
|
||||||
...ProductWebhookPayload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
subscription ProductDeleted {
|
|
||||||
event {
|
|
||||||
... on ProductDeleted {
|
|
||||||
product {
|
|
||||||
...ProductWebhookPayload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
subscription ProductUpdated {
|
|
||||||
event {
|
|
||||||
... on ProductUpdated {
|
|
||||||
product {
|
|
||||||
...ProductWebhookPayload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
subscription ProductVariantCreated {
|
|
||||||
event {
|
|
||||||
... on ProductVariantCreated {
|
|
||||||
productVariant {
|
|
||||||
...ProductVariantWebhookPayload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
subscription ProductVariantDeleted {
|
|
||||||
event {
|
|
||||||
... on ProductVariantDeleted {
|
|
||||||
productVariant {
|
|
||||||
...ProductVariantWebhookPayload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
subscription ProductVariantUpdated {
|
|
||||||
event {
|
|
||||||
... on ProductVariantUpdated {
|
|
||||||
productVariant {
|
|
||||||
...ProductVariantWebhookPayload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
5
apps/data-exporter/next-env.d.ts
vendored
5
apps/data-exporter/next-env.d.ts
vendored
|
@ -1,5 +0,0 @@
|
||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|
|
@ -1,28 +0,0 @@
|
||||||
const { withSentryConfig } = require("@sentry/nextjs");
|
|
||||||
|
|
||||||
const isSentryPropertiesInEnvironment =
|
|
||||||
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG;
|
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
reactStrictMode: true,
|
|
||||||
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"],
|
|
||||||
};
|
|
||||||
|
|
||||||
const configWithSentry = withSentryConfig(
|
|
||||||
nextConfig,
|
|
||||||
{
|
|
||||||
silent: true,
|
|
||||||
org: process.env.SENTRY_ORG,
|
|
||||||
project: process.env.SENTRY_PROJECT,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
widenClientFileUpload: true,
|
|
||||||
transpileClientSDK: true,
|
|
||||||
tunnelRoute: "/monitoring",
|
|
||||||
hideSourceMaps: true,
|
|
||||||
disableLogger: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = isSentryPropertiesInEnvironment ? configWithSentry : nextConfig;
|
|
|
@ -1,69 +0,0 @@
|
||||||
{
|
|
||||||
"name": "saleor-app-data-exporter",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"scripts": {
|
|
||||||
"build": "pnpm generate && next build",
|
|
||||||
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
|
|
||||||
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
|
|
||||||
"generate": "graphql-codegen",
|
|
||||||
"lint": "next lint",
|
|
||||||
"lint:fix": "eslint --fix .",
|
|
||||||
"start": "next start",
|
|
||||||
"test": "vitest"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@aws-sdk/client-s3": "^3.332.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",
|
|
||||||
"fast-xml-parser": "^4.0.15",
|
|
||||||
"graphql": "16.7.1",
|
|
||||||
"graphql-tag": "^2.12.6",
|
|
||||||
"handlebars": "^4.7.7",
|
|
||||||
"jsdom": "^20.0.3",
|
|
||||||
"next": "13.4.8",
|
|
||||||
"pino": "^8.14.1",
|
|
||||||
"pino-pretty": "^10.0.0",
|
|
||||||
"react": "18.2.0",
|
|
||||||
"react-dom": "18.2.0",
|
|
||||||
"react-hook-form": "^7.43.9",
|
|
||||||
"react-is": "^18.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/schema-ast": "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/react": "18.2.5",
|
|
||||||
"@types/react-dom": "18.2.5",
|
|
||||||
"eslint": "8.46.0",
|
|
||||||
"eslint-config-saleor": "workspace:*",
|
|
||||||
"typescript": "5.1.6"
|
|
||||||
},
|
|
||||||
"private": true,
|
|
||||||
"saleor": {
|
|
||||||
"schemaVersion": "3.10"
|
|
||||||
}
|
|
||||||
}
|
|
Binary file not shown.
Before Width: | Height: | Size: 9.9 KiB |
|
@ -1,37 +0,0 @@
|
||||||
/*
|
|
||||||
* This file configures the initialization of Sentry on the client.
|
|
||||||
* The config you add here will be used whenever a users loads a page in their browser.
|
|
||||||
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import pkg from "./package.json";
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
||||||
|
|
||||||
// Adjust this value in production, or use tracesSampler for greater control
|
|
||||||
tracesSampleRate: 0.5,
|
|
||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
|
||||||
debug: false,
|
|
||||||
|
|
||||||
replaysOnErrorSampleRate: 1.0,
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This sets the sample rate to be 10%. You may want this to be 100% while
|
|
||||||
* in development and sample at a lower rate in production
|
|
||||||
*/
|
|
||||||
replaysSessionSampleRate: 0.1,
|
|
||||||
|
|
||||||
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
|
|
||||||
integrations: [
|
|
||||||
new Sentry.Replay({
|
|
||||||
// Additional Replay configuration goes in here, for example:
|
|
||||||
maskAllText: true,
|
|
||||||
blockAllMedia: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
environment: process.env.SENTRY_ENVIRONMENT,
|
|
||||||
release: `${pkg.name}@${pkg.version}`,
|
|
||||||
});
|
|
|
@ -1,21 +0,0 @@
|
||||||
/*
|
|
||||||
* This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
|
||||||
* The config you add here will be used whenever one of the edge features is loaded.
|
|
||||||
* Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
|
||||||
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import pkg from "./package.json";
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
||||||
|
|
||||||
// Adjust this value in production, or use tracesSampler for greater control
|
|
||||||
tracesSampleRate: 0.5,
|
|
||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
|
||||||
debug: false,
|
|
||||||
environment: process.env.SENTRY_ENVIRONMENT,
|
|
||||||
release: `${pkg.name}@${pkg.version}`,
|
|
||||||
});
|
|
|
@ -1,20 +0,0 @@
|
||||||
/*
|
|
||||||
* This file configures the initialization of Sentry on the server.
|
|
||||||
* The config you add here will be used whenever the server handles a request.
|
|
||||||
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import pkg from "./package.json";
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
|
||||||
|
|
||||||
// Adjust this value in production, or use tracesSampler for greater control
|
|
||||||
tracesSampleRate: 0.5,
|
|
||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
|
||||||
debug: false,
|
|
||||||
environment: process.env.SENTRY_ENVIRONMENT,
|
|
||||||
release: `${pkg.name}@${pkg.version}`,
|
|
||||||
});
|
|
|
@ -1,11 +0,0 @@
|
||||||
import { AuthData } from "@saleor/app-sdk/APL";
|
|
||||||
import { createGraphQLClient } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
export const GraphqlClientFactory = {
|
|
||||||
fromAuthData(authData: Pick<AuthData, "token" | "saleorApiUrl">) {
|
|
||||||
return createGraphQLClient({
|
|
||||||
saleorApiUrl: authData.saleorApiUrl,
|
|
||||||
token: authData.token,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -1,95 +0,0 @@
|
||||||
import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
|
|
||||||
import { Client, gql } from "urql";
|
|
||||||
import {
|
|
||||||
FetchAppDetailsDocument,
|
|
||||||
FetchAppDetailsQuery,
|
|
||||||
UpdateAppMetadataDocument,
|
|
||||||
} from "../../generated/graphql";
|
|
||||||
|
|
||||||
gql`
|
|
||||||
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
|
|
||||||
updatePrivateMetadata(id: $id, input: $input) {
|
|
||||||
item {
|
|
||||||
privateMetadata {
|
|
||||||
key
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
gql`
|
|
||||||
query FetchAppDetails {
|
|
||||||
app {
|
|
||||||
id
|
|
||||||
privateMetadata {
|
|
||||||
key
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
|
|
||||||
const { error, data } = await client
|
|
||||||
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
|
|
||||||
.toPromise();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
|
|
||||||
// to update the metadata, ID is required
|
|
||||||
const { error: idQueryError, data: idQueryData } = await client
|
|
||||||
.query(FetchAppDetailsDocument, {})
|
|
||||||
.toPromise();
|
|
||||||
|
|
||||||
if (idQueryError) {
|
|
||||||
throw new Error(
|
|
||||||
"Could not fetch the app id. Please check if auth data for the client are valid."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const appId = idQueryData?.app?.id;
|
|
||||||
|
|
||||||
if (!appId) {
|
|
||||||
throw new Error("Could not fetch the app ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error: mutationError, data: mutationData } = await client
|
|
||||||
.mutation(UpdateAppMetadataDocument, {
|
|
||||||
id: appId,
|
|
||||||
input: metadata,
|
|
||||||
})
|
|
||||||
.toPromise();
|
|
||||||
|
|
||||||
if (mutationError) {
|
|
||||||
throw new Error(`Mutation error: ${mutationError.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
|
|
||||||
key: md.key,
|
|
||||||
value: md.value,
|
|
||||||
})) || []
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createSettingsManager = (client: Client) => {
|
|
||||||
/*
|
|
||||||
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
|
|
||||||
* We recommend it for production, because all values are encrypted.
|
|
||||||
* If your use case require plain text values, you can use MetadataManager.
|
|
||||||
*/
|
|
||||||
return new EncryptedMetadataManager({
|
|
||||||
// Secret key should be randomly created for production and set as environment variable
|
|
||||||
encryptionKey: process.env.SECRET_KEY!,
|
|
||||||
fetchMetadata: () => fetchAllMetadata(client),
|
|
||||||
mutateMetadata: (metadata) => mutateMetadata(client, metadata),
|
|
||||||
});
|
|
||||||
};
|
|
|
@ -1,25 +0,0 @@
|
||||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { useTheme } from "@saleor/macaw-ui/next";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
// todo move to shared
|
|
||||||
export function ThemeSynchronizer() {
|
|
||||||
const { appBridgeState } = useAppBridge();
|
|
||||||
const { setTheme } = useTheme();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!setTheme || !appBridgeState?.theme) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appBridgeState.theme === "light") {
|
|
||||||
setTheme("defaultLight");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appBridgeState.theme === "dark") {
|
|
||||||
setTheme("defaultDark");
|
|
||||||
}
|
|
||||||
}, [appBridgeState?.theme, setTheme]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
|
||||||
|
|
||||||
export class AppConfigMetadataManager {
|
|
||||||
private readonly metadataKey = "app-config-v1";
|
|
||||||
|
|
||||||
constructor(private settingsManager: SettingsManager) {}
|
|
||||||
|
|
||||||
get() {
|
|
||||||
return this.settingsManager.get(this.metadataKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
set(stringMetadata: string) {
|
|
||||||
return this.settingsManager.set({
|
|
||||||
key: this.metadataKey,
|
|
||||||
value: stringMetadata,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,295 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { AppConfig, RootConfig } from "./app-config";
|
|
||||||
|
|
||||||
const exampleChannelConfig: RootConfig["channelConfig"] = {
|
|
||||||
test: {
|
|
||||||
storefrontUrls: {
|
|
||||||
productStorefrontUrl: "https://example.com",
|
|
||||||
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const exampleS3Config: RootConfig["s3"] = {
|
|
||||||
accessKeyId: "example-access-key",
|
|
||||||
bucketName: "example-bucket-name",
|
|
||||||
region: "eu-west-1",
|
|
||||||
secretAccessKey: "example-secret-key",
|
|
||||||
};
|
|
||||||
|
|
||||||
const exampleAttributeMappingConfig: RootConfig["attributeMapping"] = {
|
|
||||||
brandAttributeIds: ["brand-attribute-1"],
|
|
||||||
colorAttributeIds: [],
|
|
||||||
patternAttributeIds: [],
|
|
||||||
materialAttributeIds: [],
|
|
||||||
sizeAttributeIds: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const exampleTitleTemplate: RootConfig["titleTemplate"] =
|
|
||||||
"Example {{ variant.product.name }} - {{ variant.name }}";
|
|
||||||
|
|
||||||
const exampleImageSize: RootConfig["imageSize"] = 1024;
|
|
||||||
|
|
||||||
const exampleConfiguration: RootConfig = {
|
|
||||||
channelConfig: exampleChannelConfig,
|
|
||||||
s3: exampleS3Config,
|
|
||||||
attributeMapping: exampleAttributeMappingConfig,
|
|
||||||
titleTemplate: exampleTitleTemplate,
|
|
||||||
imageSize: exampleImageSize,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("AppConfig", function () {
|
|
||||||
describe("Construction", () => {
|
|
||||||
it("Constructs configuration with default values, when empty object is passed as initial data", () => {
|
|
||||||
const instance = new AppConfig();
|
|
||||||
|
|
||||||
expect(instance.getRootConfig()).toEqual({
|
|
||||||
channelConfig: {},
|
|
||||||
s3: null,
|
|
||||||
attributeMapping: {
|
|
||||||
brandAttributeIds: [],
|
|
||||||
colorAttributeIds: [],
|
|
||||||
patternAttributeIds: [],
|
|
||||||
materialAttributeIds: [],
|
|
||||||
sizeAttributeIds: [],
|
|
||||||
},
|
|
||||||
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
|
|
||||||
imageSize: 1024,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Constructs configuration, when valid initial state is passed", () => {
|
|
||||||
const instance = new AppConfig(exampleConfiguration);
|
|
||||||
|
|
||||||
expect(instance.getRootConfig()).toEqual(exampleConfiguration);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Fill attribute mapping, image size and title template with default values, when initial data are lacking those fields", () => {
|
|
||||||
const configurationWithoutMapping = structuredClone(exampleConfiguration);
|
|
||||||
|
|
||||||
// @ts-expect-error: Simulating data before the migration
|
|
||||||
delete configurationWithoutMapping.attributeMapping;
|
|
||||||
// @ts-expect-error
|
|
||||||
delete configurationWithoutMapping.titleTemplate;
|
|
||||||
// @ts-expect-error
|
|
||||||
delete configurationWithoutMapping.imageSize;
|
|
||||||
|
|
||||||
const instance = new AppConfig(configurationWithoutMapping as any); // Casting used to prevent TS from reporting an error
|
|
||||||
|
|
||||||
expect(instance.getRootConfig()).toEqual({
|
|
||||||
...exampleConfiguration,
|
|
||||||
attributeMapping: {
|
|
||||||
brandAttributeIds: [],
|
|
||||||
colorAttributeIds: [],
|
|
||||||
patternAttributeIds: [],
|
|
||||||
materialAttributeIds: [],
|
|
||||||
sizeAttributeIds: [],
|
|
||||||
},
|
|
||||||
titleTemplate: "{{variant.product.name}} - {{variant.name}}",
|
|
||||||
imageSize: 1024,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Fails construction if invalid state provided", () => {
|
|
||||||
expect(
|
|
||||||
() =>
|
|
||||||
new AppConfig({
|
|
||||||
// @ts-expect-error
|
|
||||||
foo: "bar",
|
|
||||||
}),
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Parses from string", () => {
|
|
||||||
const instance1 = new AppConfig({
|
|
||||||
s3: {
|
|
||||||
region: "region",
|
|
||||||
bucketName: "bucket",
|
|
||||||
accessKeyId: "access",
|
|
||||||
secretAccessKey: "secret",
|
|
||||||
},
|
|
||||||
channelConfig: {},
|
|
||||||
attributeMapping: {
|
|
||||||
brandAttributeIds: [],
|
|
||||||
colorAttributeIds: [],
|
|
||||||
patternAttributeIds: [],
|
|
||||||
materialAttributeIds: [],
|
|
||||||
sizeAttributeIds: [],
|
|
||||||
},
|
|
||||||
titleTemplate: "{{ variant.name }}",
|
|
||||||
imageSize: 1024,
|
|
||||||
});
|
|
||||||
|
|
||||||
const serialized = instance1.serialize();
|
|
||||||
|
|
||||||
const instance2 = AppConfig.parse(serialized);
|
|
||||||
|
|
||||||
expect(instance2.getRootConfig()).toEqual({
|
|
||||||
s3: {
|
|
||||||
region: "region",
|
|
||||||
bucketName: "bucket",
|
|
||||||
accessKeyId: "access",
|
|
||||||
secretAccessKey: "secret",
|
|
||||||
},
|
|
||||||
channelConfig: {},
|
|
||||||
attributeMapping: {
|
|
||||||
brandAttributeIds: [],
|
|
||||||
colorAttributeIds: [],
|
|
||||||
patternAttributeIds: [],
|
|
||||||
materialAttributeIds: [],
|
|
||||||
sizeAttributeIds: [],
|
|
||||||
},
|
|
||||||
titleTemplate: "{{ variant.name }}",
|
|
||||||
imageSize: 1024,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getters", () => {
|
|
||||||
const instance = new AppConfig({
|
|
||||||
s3: {
|
|
||||||
region: "region",
|
|
||||||
bucketName: "bucket",
|
|
||||||
accessKeyId: "access",
|
|
||||||
secretAccessKey: "secret",
|
|
||||||
},
|
|
||||||
channelConfig: {
|
|
||||||
test: {
|
|
||||||
storefrontUrls: {
|
|
||||||
productStorefrontUrl: "https://example.com",
|
|
||||||
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attributeMapping: {
|
|
||||||
brandAttributeIds: [],
|
|
||||||
colorAttributeIds: [],
|
|
||||||
patternAttributeIds: [],
|
|
||||||
materialAttributeIds: [],
|
|
||||||
sizeAttributeIds: ["size-id"],
|
|
||||||
},
|
|
||||||
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
|
|
||||||
imageSize: 1024,
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getRootConfig returns root config data", () => {
|
|
||||||
expect(instance.getRootConfig()).toEqual({
|
|
||||||
s3: {
|
|
||||||
region: "region",
|
|
||||||
bucketName: "bucket",
|
|
||||||
accessKeyId: "access",
|
|
||||||
secretAccessKey: "secret",
|
|
||||||
},
|
|
||||||
channelConfig: {
|
|
||||||
test: {
|
|
||||||
storefrontUrls: {
|
|
||||||
productStorefrontUrl: "https://example.com",
|
|
||||||
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
attributeMapping: {
|
|
||||||
brandAttributeIds: [],
|
|
||||||
colorAttributeIds: [],
|
|
||||||
patternAttributeIds: [],
|
|
||||||
materialAttributeIds: [],
|
|
||||||
sizeAttributeIds: ["size-id"],
|
|
||||||
},
|
|
||||||
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
|
|
||||||
imageSize: 1024,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getUrlsForChannel gets data for given channel or undefined if doesn't exist", () => {
|
|
||||||
expect(instance.getUrlsForChannel("test")).toEqual({
|
|
||||||
productStorefrontUrl: "https://example.com",
|
|
||||||
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(instance.getUrlsForChannel("not-existing")).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getS3Config gets s3 data", () => {
|
|
||||||
expect(instance.getS3Config()).toEqual({
|
|
||||||
region: "region",
|
|
||||||
bucketName: "bucket",
|
|
||||||
accessKeyId: "access",
|
|
||||||
secretAccessKey: "secret",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getAttributeMapping gets attribute data", () => {
|
|
||||||
expect(instance.getAttributeMapping()).toEqual({
|
|
||||||
brandAttributeIds: [],
|
|
||||||
colorAttributeIds: [],
|
|
||||||
patternAttributeIds: [],
|
|
||||||
materialAttributeIds: [],
|
|
||||||
sizeAttributeIds: ["size-id"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setters", () => {
|
|
||||||
it("setS3 sets valid config to s3 key and rejects invalid config", () => {
|
|
||||||
const instance = new AppConfig();
|
|
||||||
|
|
||||||
instance.setS3({
|
|
||||||
region: "region",
|
|
||||||
bucketName: "bucket",
|
|
||||||
accessKeyId: "access",
|
|
||||||
secretAccessKey: "secret",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(instance.getS3Config()).toEqual({
|
|
||||||
region: "region",
|
|
||||||
bucketName: "bucket",
|
|
||||||
accessKeyId: "access",
|
|
||||||
secretAccessKey: "secret",
|
|
||||||
});
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
expect(() => instance.setS3({ foo: "bar" })).toThrowError();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setChannelUrls sets valid config to channelConfig[channelSlug] and rejects invalid config", () => {
|
|
||||||
const instance = new AppConfig();
|
|
||||||
|
|
||||||
instance.setChannelUrls("test", {
|
|
||||||
productStorefrontUrl: "https://example.com",
|
|
||||||
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(instance.getUrlsForChannel("test")).toEqual({
|
|
||||||
productStorefrontUrl: "https://example.com",
|
|
||||||
storefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
|
|
||||||
});
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
expect(() => instance.setChannelUrls("channel", "foo")).toThrowError();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Serializes to string", () => {
|
|
||||||
const instance = new AppConfig();
|
|
||||||
|
|
||||||
instance.setS3({
|
|
||||||
region: "region",
|
|
||||||
bucketName: "bucket",
|
|
||||||
accessKeyId: "access",
|
|
||||||
secretAccessKey: "secret",
|
|
||||||
});
|
|
||||||
|
|
||||||
const serialized = instance.serialize();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only way to check if serialization works is to deserialize. Order of serialized fields is not given so string cant be asserted.
|
|
||||||
* JSON.parse can be used but its testing implementation details
|
|
||||||
*/
|
|
||||||
expect(AppConfig.parse(serialized).getS3Config()).toEqual({
|
|
||||||
region: "region",
|
|
||||||
bucketName: "bucket",
|
|
||||||
accessKeyId: "access",
|
|
||||||
secretAccessKey: "secret",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,170 +0,0 @@
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const imageSizeFieldSchema = z.coerce.number().gte(256).default(1024);
|
|
||||||
|
|
||||||
export const imageSizeInputSchema = z.object({
|
|
||||||
imageSize: imageSizeFieldSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ImageSizeInput = z.infer<typeof imageSizeInputSchema>;
|
|
||||||
|
|
||||||
const titleTemplateFieldSchema = z.string().default("{{variant.product.name}} - {{variant.name}}");
|
|
||||||
|
|
||||||
export const titleTemplateInputSchema = z.object({
|
|
||||||
titleTemplate: titleTemplateFieldSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TitleTemplateInput = z.infer<typeof titleTemplateInputSchema>;
|
|
||||||
|
|
||||||
const attributeMappingSchema = z.object({
|
|
||||||
brandAttributeIds: z.array(z.string()).default([]),
|
|
||||||
colorAttributeIds: z.array(z.string()).default([]),
|
|
||||||
sizeAttributeIds: z.array(z.string()).default([]),
|
|
||||||
materialAttributeIds: z.array(z.string()).default([]),
|
|
||||||
patternAttributeIds: z.array(z.string()).default([]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const s3ConfigSchema = z.object({
|
|
||||||
bucketName: z.string().min(1),
|
|
||||||
secretAccessKey: z.string().min(1),
|
|
||||||
accessKeyId: z.string().min(1),
|
|
||||||
region: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
const urlConfigurationSchema = z.object({
|
|
||||||
storefrontUrl: z.string().min(1).url(),
|
|
||||||
productStorefrontUrl: z.string().min(1).url(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const rootAppConfigSchema = z.object({
|
|
||||||
s3: s3ConfigSchema.nullable(),
|
|
||||||
titleTemplate: titleTemplateFieldSchema
|
|
||||||
.optional()
|
|
||||||
.default(titleTemplateFieldSchema.parse(undefined)),
|
|
||||||
imageSize: imageSizeFieldSchema.optional().default(imageSizeFieldSchema.parse(undefined)),
|
|
||||||
attributeMapping: attributeMappingSchema
|
|
||||||
.nullable()
|
|
||||||
.optional()
|
|
||||||
.default(attributeMappingSchema.parse({})),
|
|
||||||
channelConfig: z.record(z.object({ storefrontUrls: urlConfigurationSchema })),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const AppConfigSchema = {
|
|
||||||
root: rootAppConfigSchema,
|
|
||||||
s3Bucket: s3ConfigSchema,
|
|
||||||
channelUrls: urlConfigurationSchema,
|
|
||||||
attributeMapping: attributeMappingSchema,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RootConfig = z.infer<typeof rootAppConfigSchema>;
|
|
||||||
|
|
||||||
export type ChannelUrlsConfig = z.infer<typeof AppConfigSchema.channelUrls>;
|
|
||||||
|
|
||||||
const logger = createLogger({ name: "AppConfig" });
|
|
||||||
|
|
||||||
export class AppConfig {
|
|
||||||
private rootData: RootConfig = {
|
|
||||||
channelConfig: {},
|
|
||||||
s3: null,
|
|
||||||
attributeMapping: attributeMappingSchema.parse({}),
|
|
||||||
titleTemplate: titleTemplateFieldSchema.parse(undefined),
|
|
||||||
imageSize: imageSizeFieldSchema.parse(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(initialData?: RootConfig) {
|
|
||||||
if (initialData) {
|
|
||||||
try {
|
|
||||||
this.rootData = rootAppConfigSchema.parse(initialData);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(e, "Could not parse initial data");
|
|
||||||
throw new Error("Can't load the configuration");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static parse(serializedSchema: string) {
|
|
||||||
return new AppConfig(JSON.parse(serializedSchema));
|
|
||||||
}
|
|
||||||
|
|
||||||
getRootConfig() {
|
|
||||||
return this.rootData;
|
|
||||||
}
|
|
||||||
|
|
||||||
serialize() {
|
|
||||||
return JSON.stringify(this.rootData);
|
|
||||||
}
|
|
||||||
|
|
||||||
setS3(s3Config: z.infer<typeof s3ConfigSchema>) {
|
|
||||||
try {
|
|
||||||
this.rootData.s3 = s3ConfigSchema.parse(s3Config);
|
|
||||||
|
|
||||||
return this;
|
|
||||||
} catch (e) {
|
|
||||||
logger.info(e, "Invalid S3 config provided");
|
|
||||||
throw new Error("Invalid S3 config provided");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setAttributeMapping(attributeMapping: z.infer<typeof attributeMappingSchema>) {
|
|
||||||
try {
|
|
||||||
this.rootData.attributeMapping = attributeMappingSchema.parse(attributeMapping);
|
|
||||||
|
|
||||||
return this;
|
|
||||||
} catch (e) {
|
|
||||||
logger.info(e, "Invalid mapping config provided");
|
|
||||||
throw new Error("Invalid mapping config provided");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setChannelUrls(channelSlug: string, urlsConfig: z.infer<typeof urlConfigurationSchema>) {
|
|
||||||
try {
|
|
||||||
const parsedConfig = urlConfigurationSchema.parse(urlsConfig);
|
|
||||||
|
|
||||||
this.rootData.channelConfig[channelSlug] = {
|
|
||||||
storefrontUrls: parsedConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
return this;
|
|
||||||
} catch (e) {
|
|
||||||
logger.info(e, "Invalid channels config provided");
|
|
||||||
throw new Error("Invalid channels config provided");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getUrlsForChannel(channelSlug: string) {
|
|
||||||
try {
|
|
||||||
return this.rootData.channelConfig[channelSlug].storefrontUrls;
|
|
||||||
} catch (e) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getS3Config() {
|
|
||||||
return this.rootData.s3;
|
|
||||||
}
|
|
||||||
|
|
||||||
getAttributeMapping() {
|
|
||||||
return this.rootData.attributeMapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTitleTemplate(titleTemplate: z.infer<typeof titleTemplateFieldSchema>) {
|
|
||||||
this.rootData.titleTemplate = titleTemplate;
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTitleTemplate() {
|
|
||||||
return this.rootData.titleTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
setImageSize(imageSize: z.infer<typeof imageSizeFieldSchema>) {
|
|
||||||
this.rootData.imageSize = imageSize;
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
getImageSize() {
|
|
||||||
return this.rootData.imageSize;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,231 +0,0 @@
|
||||||
import { router } from "../trpc/trpc-server";
|
|
||||||
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
import { updateCacheForConfigurations } from "../metadata-cache/update-cache-for-configurations";
|
|
||||||
import { AppConfigSchema, imageSizeInputSchema, titleTemplateInputSchema } from "./app-config";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { createS3ClientFromConfiguration } from "../file-storage/s3/create-s3-client-from-configuration";
|
|
||||||
import { checkBucketAccess } from "../file-storage/s3/check-bucket-access";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
|
||||||
import { AttributeFetcher } from "./attribute-fetcher";
|
|
||||||
import { renderHandlebarsTemplate } from "../handlebarsTemplates/render-handlebars-template";
|
|
||||||
import { prepareExampleVariantData } from "./prepare-example-variant-data";
|
|
||||||
|
|
||||||
export const appConfigurationRouter = router({
|
|
||||||
/**
|
|
||||||
* Prefer fetching all to avoid unnecessary calls. Routes are cached by react-query
|
|
||||||
*/
|
|
||||||
fetch: protectedClientProcedure.query(async ({ ctx: { logger, getConfig } }) => {
|
|
||||||
logger.debug("Fetching configuration");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const configuration = await getConfig();
|
|
||||||
|
|
||||||
logger.debug("Configuration fetched");
|
|
||||||
return configuration.getRootConfig();
|
|
||||||
} catch (e) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: "Can't fetch the configuration",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
testS3BucketConfiguration: protectedClientProcedure
|
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
|
||||||
.input(AppConfigSchema.s3Bucket)
|
|
||||||
.mutation(async ({ ctx: { saleorApiUrl }, input }) => {
|
|
||||||
const logger = createLogger({ saleorApiUrl: saleorApiUrl });
|
|
||||||
|
|
||||||
logger.debug("Validate the credentials");
|
|
||||||
|
|
||||||
const s3Client = createS3ClientFromConfiguration(input);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await checkBucketAccess({
|
|
||||||
bucketName: input.bucketName,
|
|
||||||
s3Client,
|
|
||||||
});
|
|
||||||
logger.debug("Verification succeeded");
|
|
||||||
} catch {
|
|
||||||
logger.debug("Validation failed");
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Could not access the S3 bucket using the provided credentials",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
setS3BucketConfiguration: protectedClientProcedure
|
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
|
||||||
.input(AppConfigSchema.s3Bucket)
|
|
||||||
.mutation(async ({ ctx: { saleorApiUrl, getConfig, appConfigMetadataManager }, input }) => {
|
|
||||||
const logger = createLogger({ saleorApiUrl: saleorApiUrl });
|
|
||||||
|
|
||||||
logger.debug("Validate credentials");
|
|
||||||
|
|
||||||
const s3Client = createS3ClientFromConfiguration(input);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await checkBucketAccess({
|
|
||||||
bucketName: input.bucketName,
|
|
||||||
s3Client,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
logger.debug("Validation failed");
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Could not access the S3 bucket using the provided credentials",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Credentials validated, saving");
|
|
||||||
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
config.setS3(input);
|
|
||||||
|
|
||||||
await appConfigMetadataManager.set(config.serialize());
|
|
||||||
|
|
||||||
logger.debug("Config saved");
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
setChannelsUrls: protectedClientProcedure
|
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
channelSlug: z.string(),
|
|
||||||
urls: AppConfigSchema.channelUrls,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mutation(
|
|
||||||
async ({
|
|
||||||
ctx: { getConfig, apiClient, saleorApiUrl, appConfigMetadataManager, logger },
|
|
||||||
input,
|
|
||||||
}) => {
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO Check if this has to run, once its cached, it should be invalidated by webhooks only.
|
|
||||||
*
|
|
||||||
* But this operation isn't expensive and users will not continuously save this form
|
|
||||||
*/
|
|
||||||
await updateCacheForConfigurations({
|
|
||||||
client: apiClient,
|
|
||||||
channelsSlugs: [input.channelSlug],
|
|
||||||
saleorApiUrl: saleorApiUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug({ channel: input.channelSlug }, "Updated cache for channel");
|
|
||||||
|
|
||||||
config.setChannelUrls(input.channelSlug, input.urls);
|
|
||||||
|
|
||||||
await appConfigMetadataManager.set(config.serialize());
|
|
||||||
|
|
||||||
logger.debug("Saved config");
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
setAttributeMapping: protectedClientProcedure
|
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
|
||||||
.input(AppConfigSchema.attributeMapping)
|
|
||||||
.mutation(async ({ ctx: { getConfig, appConfigMetadataManager, logger }, input }) => {
|
|
||||||
logger.debug("Setting attribute mapping");
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
config.setAttributeMapping(input);
|
|
||||||
|
|
||||||
await appConfigMetadataManager.set(config.serialize());
|
|
||||||
logger.debug("Attribute map set");
|
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
getAttributes: protectedClientProcedure
|
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
|
||||||
.query(async ({ ctx: { logger, apiClient } }) => {
|
|
||||||
const fetcher = new AttributeFetcher(apiClient);
|
|
||||||
|
|
||||||
const result = await fetcher.fetchAllAttributes().catch((e) => {
|
|
||||||
logger.error(e, "Can't fetch the attributes");
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: "Can't fetch the attributes",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug("Returning attributes");
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}),
|
|
||||||
setImageSize: protectedClientProcedure
|
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
|
||||||
.input(imageSizeInputSchema)
|
|
||||||
.mutation(async ({ ctx: { getConfig, appConfigMetadataManager, logger }, input }) => {
|
|
||||||
logger.debug("Setting image size");
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
config.setImageSize(input.imageSize);
|
|
||||||
|
|
||||||
await appConfigMetadataManager.set(config.serialize());
|
|
||||||
|
|
||||||
logger.debug("image size set");
|
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
|
|
||||||
setTitleTemplate: protectedClientProcedure
|
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
|
||||||
.input(titleTemplateInputSchema)
|
|
||||||
.mutation(async ({ ctx: { getConfig, appConfigMetadataManager, logger }, input }) => {
|
|
||||||
logger.debug("Setting title template");
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
// Test render to prevent saving invalid template
|
|
||||||
try {
|
|
||||||
renderHandlebarsTemplate({
|
|
||||||
data: {},
|
|
||||||
template: input.titleTemplate,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug({ error: err }, "Template render failed");
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Submitted template is invalid",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
config.setTitleTemplate(input.titleTemplate);
|
|
||||||
|
|
||||||
await appConfigMetadataManager.set(config.serialize());
|
|
||||||
|
|
||||||
logger.debug("Template title set");
|
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
|
|
||||||
renderTemplate: protectedClientProcedure
|
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
|
||||||
.input(titleTemplateInputSchema)
|
|
||||||
.mutation(async ({ ctx: { getConfig, logger }, input }) => {
|
|
||||||
logger.debug(input, "renderTemplate called");
|
|
||||||
const config = await getConfig();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const title = renderHandlebarsTemplate({
|
|
||||||
data: prepareExampleVariantData({
|
|
||||||
attributeMapping: config.getAttributeMapping(),
|
|
||||||
}),
|
|
||||||
template: input.titleTemplate,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug("Title rendered succeeded");
|
|
||||||
|
|
||||||
return { title };
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug({ error: err }, "Template render failed");
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Submitted template is invalid",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
|
|
@ -1,49 +0,0 @@
|
||||||
import { Client } from "urql";
|
|
||||||
import {
|
|
||||||
AttributeWithMappingFragmentFragment,
|
|
||||||
FetchAttributesWithMappingDocument,
|
|
||||||
} from "../../../generated/graphql";
|
|
||||||
|
|
||||||
export class AttributeFetcher {
|
|
||||||
constructor(private apiClient: Pick<Client, "query">) {}
|
|
||||||
|
|
||||||
private async fetchRecursivePage(
|
|
||||||
accumulator: AttributeWithMappingFragmentFragment[],
|
|
||||||
cursor?: string
|
|
||||||
): Promise<AttributeWithMappingFragmentFragment[]> {
|
|
||||||
const result = await this.apiClient
|
|
||||||
.query(FetchAttributesWithMappingDocument, {
|
|
||||||
cursor,
|
|
||||||
})
|
|
||||||
.toPromise();
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.data) {
|
|
||||||
// todo sentry
|
|
||||||
throw new Error("Empty attributes data");
|
|
||||||
}
|
|
||||||
|
|
||||||
accumulator = [...accumulator, ...(result.data.attributes?.edges.map((c) => c.node) ?? [])];
|
|
||||||
|
|
||||||
const hasNextPage = result.data.attributes?.pageInfo.hasNextPage;
|
|
||||||
const endCursor = result.data.attributes?.pageInfo.endCursor;
|
|
||||||
|
|
||||||
if (hasNextPage && endCursor) {
|
|
||||||
return this.fetchRecursivePage(accumulator, endCursor);
|
|
||||||
} else {
|
|
||||||
return accumulator;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches all attribute pages - standard page is max 100 items
|
|
||||||
*/
|
|
||||||
async fetchAllAttributes(): Promise<AttributeWithMappingFragmentFragment[]> {
|
|
||||||
let attributes: AttributeWithMappingFragmentFragment[] = [];
|
|
||||||
|
|
||||||
return this.fetchRecursivePage(attributes, undefined);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,137 +0,0 @@
|
||||||
import { AppConfigSchema, RootConfig } from "./app-config";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
|
||||||
|
|
||||||
import React, { useCallback, useMemo } from "react";
|
|
||||||
import { Multiselect } from "@saleor/react-hook-form-macaw";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { trpcClient } from "../trpc/trpc-client";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
import { AttributeWithMappingFragmentFragment } from "../../../generated/graphql";
|
|
||||||
|
|
||||||
type AttributeMappingConfiguration = Exclude<RootConfig["attributeMapping"], null>;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
initialData: AttributeMappingConfiguration;
|
|
||||||
attributes: AttributeWithMappingFragmentFragment[];
|
|
||||||
onSubmit(data: AttributeMappingConfiguration): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AttributeMappingConfigurationForm = (props: Props) => {
|
|
||||||
const { handleSubmit, control } = useForm<AttributeMappingConfiguration>({
|
|
||||||
defaultValues: props.initialData,
|
|
||||||
resolver: zodResolver(AppConfigSchema.attributeMapping),
|
|
||||||
});
|
|
||||||
|
|
||||||
const options = props.attributes.map((a) => ({ value: a.id, label: a.name || a.id })) || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
as={"form"}
|
|
||||||
display={"flex"}
|
|
||||||
gap={5}
|
|
||||||
flexDirection={"column"}
|
|
||||||
onSubmit={handleSubmit((data) => {
|
|
||||||
props.onSubmit(data);
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Multiselect
|
|
||||||
control={control}
|
|
||||||
name="brandAttributeIds"
|
|
||||||
label="Brand attributes"
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
<Multiselect
|
|
||||||
control={control}
|
|
||||||
name="colorAttributeIds"
|
|
||||||
label="Color attributes"
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
<Multiselect
|
|
||||||
control={control}
|
|
||||||
name="materialAttributeIds"
|
|
||||||
label="Material attributes"
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
<Multiselect
|
|
||||||
control={control}
|
|
||||||
name="patternAttributeIds"
|
|
||||||
label="Pattern attributes"
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
<Multiselect
|
|
||||||
control={control}
|
|
||||||
name="sizeAttributeIds"
|
|
||||||
label="Size attributes"
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
<Box display={"flex"} flexDirection={"row"} gap={4} justifyContent={"flex-end"}>
|
|
||||||
<Button type="submit" variant="primary">
|
|
||||||
Save mapping
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ConnectedAttributeMappingForm = () => {
|
|
||||||
const { notifyError, notifySuccess } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { data: attributes, isLoading: isAttributesLoading } =
|
|
||||||
trpcClient.appConfiguration.getAttributes.useQuery();
|
|
||||||
|
|
||||||
const { data, isLoading: isConfigurationLoading } = trpcClient.appConfiguration.fetch.useQuery();
|
|
||||||
|
|
||||||
const isLoading = isAttributesLoading || isConfigurationLoading;
|
|
||||||
|
|
||||||
const { mutate } = trpcClient.appConfiguration.setAttributeMapping.useMutation({
|
|
||||||
onSuccess() {
|
|
||||||
notifySuccess("Success", "Updated attribute mapping");
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
notifyError("Error", "Failed to update, please refresh and try again");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
async (data: AttributeMappingConfiguration) => {
|
|
||||||
mutate(data);
|
|
||||||
},
|
|
||||||
[mutate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const formData: AttributeMappingConfiguration = useMemo(() => {
|
|
||||||
if (data?.attributeMapping) {
|
|
||||||
return data.attributeMapping;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
colorAttributeIds: [],
|
|
||||||
sizeAttributeIds: [],
|
|
||||||
brandAttributeIds: [],
|
|
||||||
patternAttributeIds: [],
|
|
||||||
materialAttributeIds: [],
|
|
||||||
};
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Text>Loading...</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const showForm = !isLoading && attributes?.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{showForm ? (
|
|
||||||
<AttributeMappingConfigurationForm
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
initialData={formData}
|
|
||||||
attributes={attributes}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box>Loading</Box>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,106 +0,0 @@
|
||||||
import { Accordion, Box, Button, Divider, PropsWithBox, Text } from "@saleor/macaw-ui/next";
|
|
||||||
import { Input } from "@saleor/react-hook-form-macaw";
|
|
||||||
import { trpcClient } from "../trpc/trpc-client";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { AppConfigSchema, ChannelUrlsConfig } from "./app-config";
|
|
||||||
import { FeedPreviewCard } from "./feed-preview-card";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
const ChannelConfigForm = ({ channelSlug, ...props }: PropsWithBox<{ channelSlug: string }>) => {
|
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { data: appConfig } = trpcClient.appConfiguration.fetch.useQuery();
|
|
||||||
|
|
||||||
const channelConfig = appConfig?.channelConfig[channelSlug];
|
|
||||||
|
|
||||||
const { mutate } = trpcClient.appConfiguration.setChannelsUrls.useMutation({
|
|
||||||
onSuccess() {
|
|
||||||
notifySuccess("Success");
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
notifyError("Failed saving configuration.", "Refresh the page and try again");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { control, handleSubmit } = useForm<ChannelUrlsConfig>({
|
|
||||||
resolver: zodResolver(AppConfigSchema.channelUrls),
|
|
||||||
defaultValues: {
|
|
||||||
productStorefrontUrl: channelConfig?.storefrontUrls.productStorefrontUrl ?? "",
|
|
||||||
storefrontUrl: channelConfig?.storefrontUrls.storefrontUrl ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
onSubmit={handleSubmit((data) => {
|
|
||||||
mutate({
|
|
||||||
urls: data,
|
|
||||||
channelSlug,
|
|
||||||
});
|
|
||||||
})}
|
|
||||||
as={"form"}
|
|
||||||
display={"grid"}
|
|
||||||
gap={3}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Text variant={"heading"} as={"h2"} marginBottom={1.5}>
|
|
||||||
Configure channel URLs
|
|
||||||
</Text>
|
|
||||||
<Input
|
|
||||||
label={"Storefront URL"}
|
|
||||||
placeholder={"https://myshop.com"}
|
|
||||||
helperText={"Public address of your storefront"}
|
|
||||||
name={"storefrontUrl"}
|
|
||||||
control={control}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label={"Storefront product URL"}
|
|
||||||
placeholder={"https://myshop.com/product/{productSlug}"}
|
|
||||||
name={"productStorefrontUrl"}
|
|
||||||
control={control}
|
|
||||||
helperText={
|
|
||||||
"Public address of your storefront product page. Use placeholder tags to inject dynamic product data"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Box display={"flex"} justifyContent={"flex-end"}>
|
|
||||||
<Button type={"submit"} __width={"fit-content"}>
|
|
||||||
Save channel settings
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ChannelsConfigAccordion = () => {
|
|
||||||
const { data, isLoading } = trpcClient.channels.fetch.useQuery();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Text>Loading...</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Accordion display={"grid"} gap={5}>
|
|
||||||
{data?.map((channel) => (
|
|
||||||
<Accordion.Item
|
|
||||||
key={channel.id}
|
|
||||||
value={channel.id}
|
|
||||||
borderColor={"neutralHighlight"}
|
|
||||||
borderWidth={1}
|
|
||||||
borderBottomStyle={"solid"}
|
|
||||||
paddingBottom={5}
|
|
||||||
>
|
|
||||||
<Accordion.Trigger>
|
|
||||||
<Text>{channel.name}</Text>
|
|
||||||
<Accordion.TriggerButton />
|
|
||||||
</Accordion.Trigger>
|
|
||||||
<Accordion.Content>
|
|
||||||
<ChannelConfigForm margin={5} channelSlug={channel.slug} />
|
|
||||||
<Divider />
|
|
||||||
<FeedPreviewCard channelSlug={channel.slug} margin={5} marginTop={9} />
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { Client, gql } from "urql";
|
|
||||||
import { FetchChannelsDocument } from "../../../../generated/graphql";
|
|
||||||
|
|
||||||
gql`
|
|
||||||
fragment Channel on Channel {
|
|
||||||
name
|
|
||||||
id
|
|
||||||
slug
|
|
||||||
}
|
|
||||||
|
|
||||||
query FetchChannels {
|
|
||||||
channels {
|
|
||||||
...Channel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export class ChannelsFetcher {
|
|
||||||
constructor(private client: Client) {}
|
|
||||||
|
|
||||||
fetchChannels() {
|
|
||||||
return this.client
|
|
||||||
.query(FetchChannelsDocument, {})
|
|
||||||
.toPromise()
|
|
||||||
.then((r) => r.data?.channels ?? null);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
import { router } from "../../trpc/trpc-server";
|
|
||||||
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
|
|
||||||
import { ChannelsFetcher } from "./channels-fetcher";
|
|
||||||
import { ChannelFragment } from "../../../../generated/graphql";
|
|
||||||
|
|
||||||
export const channelsRouter = router({
|
|
||||||
fetch: protectedClientProcedure.query(
|
|
||||||
async ({ ctx: { logger, apiClient } }): Promise<ChannelFragment[]> => {
|
|
||||||
const fetcher = new ChannelsFetcher(apiClient);
|
|
||||||
|
|
||||||
logger.debug("Fetching channels");
|
|
||||||
const channels = fetcher.fetchChannels().then((channels) => channels ?? []);
|
|
||||||
|
|
||||||
logger.debug("Channels fetched successfully");
|
|
||||||
return channels;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
});
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { trpcClient } from "../../trpc/trpc-client";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This app requires channels to exist, so redirect to error page if channels don't exist
|
|
||||||
*/
|
|
||||||
export const useChannelsExistenceChecking = () => {
|
|
||||||
const channels = trpcClient.channels.fetch.useQuery();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (channels.isSuccess && channels.data.length === 0) {
|
|
||||||
router.push("/not-ready");
|
|
||||||
}
|
|
||||||
}, [channels.data, channels.isSuccess, router]);
|
|
||||||
};
|
|
|
@ -1,58 +0,0 @@
|
||||||
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
|
|
||||||
|
|
||||||
export const exampleVariantData: GoogleFeedProductVariantFragment = {
|
|
||||||
id: "UHJvZHVjdFZhcmlhbnQ6MzYx",
|
|
||||||
name: "M",
|
|
||||||
sku: "218223580",
|
|
||||||
pricing: {
|
|
||||||
price: {
|
|
||||||
gross: {
|
|
||||||
currency: "USD",
|
|
||||||
amount: 45,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
quantityAvailable: 50,
|
|
||||||
attributes: [
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "QXR0cmlidXRlOjM4",
|
|
||||||
},
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
value: "",
|
|
||||||
name: "M",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
product: {
|
|
||||||
id: "UHJvZHVjdDoxMzc=",
|
|
||||||
name: "Blue Polygon Shirt",
|
|
||||||
slug: "blue-polygon-shirt",
|
|
||||||
description:
|
|
||||||
'{"time": 1653425319677, "blocks": [{"id": "sMEIn2NR8s", "data": {"text": "<b>Ever have those days where you feel a bit geometric?</b> Can\'t quite shape yourself up right? Show your different sides with a Saleor styles."}, "type": "paragraph"}], "version": "2.22.2"}',
|
|
||||||
seoDescription: "",
|
|
||||||
attributes: [
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "QXR0cmlidXRlOjM2",
|
|
||||||
},
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
value: "",
|
|
||||||
name: "Cotton",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
thumbnail: {
|
|
||||||
url: "https://example.eu.saleor.cloud/media/thumbnails/products/saleor-blue-polygon-tee-front_thumbnail_256.png",
|
|
||||||
},
|
|
||||||
category: {
|
|
||||||
id: "Q2F0ZWdvcnk6Mzk=",
|
|
||||||
name: "T-shirts",
|
|
||||||
googleCategoryId: "42",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -1,44 +0,0 @@
|
||||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|
||||||
import { Box, Button, Input, PropsWithBox, Text } from "@saleor/macaw-ui/next";
|
|
||||||
import { useGetFeedApiUrl } from "../feed-url/use-get-feed-api-url";
|
|
||||||
|
|
||||||
interface FeedPreviewCardProps {
|
|
||||||
channelSlug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FeedPreviewCard = ({ channelSlug, ...props }: PropsWithBox<FeedPreviewCardProps>) => {
|
|
||||||
const { appBridge } = useAppBridge();
|
|
||||||
|
|
||||||
const googleFeedUrl = useGetFeedApiUrl(channelSlug);
|
|
||||||
|
|
||||||
if (!googleFeedUrl) {
|
|
||||||
// Should never happen
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const openUrlInNewTab = async (url: string) => {
|
|
||||||
await appBridge?.dispatch(actions.Redirect({ to: url, newContext: true }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box {...props}>
|
|
||||||
<Text variant={"heading"} as={"h2"} marginBottom={1.5}>
|
|
||||||
Test your feed
|
|
||||||
</Text>
|
|
||||||
<Input
|
|
||||||
label="Google feed URL"
|
|
||||||
value={googleFeedUrl}
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.target.select();
|
|
||||||
}}
|
|
||||||
helperText="Dedicated URL for your Google Merchant Feed. Click to select and copy."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box display={"flex"} justifyContent={"flex-end"}>
|
|
||||||
<Button variant="secondary" onClick={() => openUrlInNewTab(googleFeedUrl)} marginTop={3}>
|
|
||||||
Open feed in a new tab
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,98 +0,0 @@
|
||||||
import { ImageSizeInput, imageSizeInputSchema } from "./app-config";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
|
||||||
|
|
||||||
import React, { useCallback, useMemo } from "react";
|
|
||||||
import { Select } from "@saleor/react-hook-form-macaw";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { trpcClient } from "../trpc/trpc-client";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
initialData: ImageSizeInput;
|
|
||||||
onSubmit(data: ImageSizeInput): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const imageSizeOptions = [
|
|
||||||
{ value: "256", label: "256px" },
|
|
||||||
{ value: "512", label: "512px" },
|
|
||||||
{ value: "1024", label: "1024px" },
|
|
||||||
{ value: "2048", label: "2048px" },
|
|
||||||
{ value: "4096", label: "4096px" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ImageConfigurationForm = (props: Props) => {
|
|
||||||
const { handleSubmit, control, formState } = useForm<ImageSizeInput>({
|
|
||||||
defaultValues: props.initialData,
|
|
||||||
resolver: zodResolver(imageSizeInputSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
as={"form"}
|
|
||||||
display={"flex"}
|
|
||||||
gap={5}
|
|
||||||
flexDirection={"column"}
|
|
||||||
onSubmit={handleSubmit(props.onSubmit)}
|
|
||||||
>
|
|
||||||
<Select control={control} name="imageSize" label="Image size" options={imageSizeOptions} />
|
|
||||||
{!!formState.errors.imageSize?.message && (
|
|
||||||
<Text variant="caption" color={"textCriticalSubdued"}>
|
|
||||||
{formState.errors.imageSize?.message}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Box display={"flex"} flexDirection={"row"} gap={4} justifyContent={"flex-end"}>
|
|
||||||
<Button type="submit" variant="primary">
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ConnectedImageConfigurationForm = () => {
|
|
||||||
const { notifyError, notifySuccess } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { data, isLoading } = trpcClient.appConfiguration.fetch.useQuery();
|
|
||||||
|
|
||||||
const { mutate } = trpcClient.appConfiguration.setImageSize.useMutation({
|
|
||||||
onSuccess() {
|
|
||||||
notifySuccess("Success", "Updated image size");
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
notifyError("Error", "Failed to update, please refresh and try again");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
async (data: ImageSizeInput) => {
|
|
||||||
mutate(data);
|
|
||||||
},
|
|
||||||
[mutate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const formData: ImageSizeInput = useMemo(() => {
|
|
||||||
if (data?.imageSize) {
|
|
||||||
return {
|
|
||||||
imageSize: data.imageSize,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return imageSizeInputSchema.parse({});
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Text>Loading...</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!isLoading ? (
|
|
||||||
<ImageConfigurationForm onSubmit={handleSubmit} initialData={formData} />
|
|
||||||
) : (
|
|
||||||
<Box>Loading</Box>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,19 +0,0 @@
|
||||||
import { getMappedAttributes } from "../google-feed/attribute-mapping";
|
|
||||||
import { RootConfig } from "./app-config";
|
|
||||||
import { exampleVariantData } from "./example-variant-data";
|
|
||||||
|
|
||||||
interface PrepareExampleVariantData {
|
|
||||||
attributeMapping?: RootConfig["attributeMapping"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const prepareExampleVariantData = ({ attributeMapping }: PrepareExampleVariantData) => {
|
|
||||||
const attributes = getMappedAttributes({
|
|
||||||
attributeMapping: attributeMapping,
|
|
||||||
variant: exampleVariantData,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
variant: exampleVariantData,
|
|
||||||
googleAttributes: attributes,
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,134 +0,0 @@
|
||||||
import { AppConfigSchema, RootConfig } from "./app-config";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
|
||||||
|
|
||||||
import React, { useCallback, useMemo } from "react";
|
|
||||||
import { Input, Select } from "@saleor/react-hook-form-macaw";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { trpcClient } from "../trpc/trpc-client";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
import { awsRegionList } from "../file-storage/s3/aws-region-list";
|
|
||||||
|
|
||||||
type S3BucketConfiguration = Exclude<RootConfig["s3"], null>;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
initialData: S3BucketConfiguration;
|
|
||||||
onSubmit(data: S3BucketConfiguration): Promise<void>;
|
|
||||||
onValidate(data: S3BucketConfiguration): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const S3ConfigurationForm = (props: Props) => {
|
|
||||||
const { handleSubmit, control, getValues } = useForm<S3BucketConfiguration>({
|
|
||||||
defaultValues: props.initialData,
|
|
||||||
resolver: zodResolver(AppConfigSchema.s3Bucket),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
as={"form"}
|
|
||||||
display={"flex"}
|
|
||||||
gap={5}
|
|
||||||
flexDirection={"column"}
|
|
||||||
onSubmit={handleSubmit((data) => {
|
|
||||||
props.onSubmit(data);
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Input size={"small"} name={"accessKeyId"} control={control} label="Amazon access key ID" />
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type={"password"}
|
|
||||||
size={"small"}
|
|
||||||
name={"secretAccessKey"}
|
|
||||||
control={control}
|
|
||||||
label="Amazon secret access key"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input size={"small"} name={"bucketName"} control={control} label="Bucket name" />
|
|
||||||
|
|
||||||
<Select
|
|
||||||
control={control}
|
|
||||||
label="Region"
|
|
||||||
name={"region"}
|
|
||||||
options={awsRegionList.map((region) => ({ label: region, value: region }))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box display={"flex"} flexDirection={"row"} gap={4} justifyContent={"flex-end"}>
|
|
||||||
<Button variant="secondary" onClick={() => props.onValidate(getValues())}>
|
|
||||||
Test credentials
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" variant="primary">
|
|
||||||
Save bucket configuration
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ConnectedS3ConfigurationForm = () => {
|
|
||||||
const { notifyError, notifySuccess } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { mutate } = trpcClient.appConfiguration.setS3BucketConfiguration.useMutation({
|
|
||||||
onSuccess() {
|
|
||||||
notifySuccess("Success", "Updated S3 configuration");
|
|
||||||
},
|
|
||||||
onError({ message }) {
|
|
||||||
if (message) {
|
|
||||||
notifyError("Error", message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notifyError("Error", "Failed to update, please refresh and try again");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: testConfigurationMutate } =
|
|
||||||
trpcClient.appConfiguration.testS3BucketConfiguration.useMutation({
|
|
||||||
onSuccess() {
|
|
||||||
notifySuccess("Configuration is valid");
|
|
||||||
},
|
|
||||||
onError({ message }) {
|
|
||||||
notifyError("Error", message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, isLoading } = trpcClient.appConfiguration.fetch.useQuery();
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
async (data: S3BucketConfiguration) => {
|
|
||||||
mutate(data);
|
|
||||||
},
|
|
||||||
[mutate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleValidate = useCallback(
|
|
||||||
async (data: S3BucketConfiguration) => {
|
|
||||||
testConfigurationMutate(data);
|
|
||||||
},
|
|
||||||
[testConfigurationMutate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const formData: S3BucketConfiguration = useMemo(() => {
|
|
||||||
if (data?.s3) {
|
|
||||||
return data.s3;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessKeyId: "",
|
|
||||||
bucketName: "",
|
|
||||||
region: "",
|
|
||||||
secretAccessKey: "",
|
|
||||||
};
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Text>Loading...</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<S3ConfigurationForm
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
initialData={formData}
|
|
||||||
onValidate={handleValidate}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,120 +0,0 @@
|
||||||
import { TitleTemplateInput, titleTemplateInputSchema } from "./app-config";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import { Box, Button, Text } from "@saleor/macaw-ui/next";
|
|
||||||
|
|
||||||
import React, { useCallback, useMemo, useState } from "react";
|
|
||||||
import { Input } from "@saleor/react-hook-form-macaw";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { trpcClient } from "../trpc/trpc-client";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
initialData: TitleTemplateInput;
|
|
||||||
preview: string | undefined;
|
|
||||||
onSubmit(data: TitleTemplateInput): Promise<void>;
|
|
||||||
onPreview(data: TitleTemplateInput): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TitleFormattingConfigurationForm = (props: Props) => {
|
|
||||||
const { handleSubmit, control, getValues } = useForm<TitleTemplateInput>({
|
|
||||||
defaultValues: props.initialData,
|
|
||||||
resolver: zodResolver(titleTemplateInputSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
as={"form"}
|
|
||||||
display={"flex"}
|
|
||||||
gap={5}
|
|
||||||
flexDirection={"column"}
|
|
||||||
onSubmit={handleSubmit((data) => {
|
|
||||||
props.onSubmit(data);
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Input control={control} name="titleTemplate" label="Title template" />
|
|
||||||
{props.preview?.length && <Text variant="caption">{props.preview}</Text>}
|
|
||||||
<Box display={"flex"} flexDirection={"row"} gap={4} justifyContent={"flex-end"}>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => {
|
|
||||||
props.onPreview(getValues());
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" variant="primary">
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ConnectedTitleFormattingForm = () => {
|
|
||||||
const { notifyError, notifySuccess } = useDashboardNotification();
|
|
||||||
const [preview, setPreview] = useState<string | undefined>();
|
|
||||||
|
|
||||||
const { data, isLoading } = trpcClient.appConfiguration.fetch.useQuery();
|
|
||||||
|
|
||||||
const { mutate } = trpcClient.appConfiguration.setTitleTemplate.useMutation({
|
|
||||||
onSuccess() {
|
|
||||||
notifySuccess("Success", "Updated title template");
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
notifyError("Error", "Failed to update, please refresh and try again");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutate: previewMutate } = trpcClient.appConfiguration.renderTemplate.useMutation({
|
|
||||||
onSuccess(data) {
|
|
||||||
setPreview(data.title);
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
notifyError("Error", "Template invalid");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
async (data: TitleTemplateInput) => {
|
|
||||||
mutate(data);
|
|
||||||
},
|
|
||||||
[mutate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePreview = useCallback(
|
|
||||||
async (data: TitleTemplateInput) => {
|
|
||||||
previewMutate(data);
|
|
||||||
},
|
|
||||||
[previewMutate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const formData: TitleTemplateInput = useMemo(() => {
|
|
||||||
if (data?.titleTemplate) {
|
|
||||||
return {
|
|
||||||
titleTemplate: data.titleTemplate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return titleTemplateInputSchema.parse({});
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Text>Loading...</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!isLoading ? (
|
|
||||||
<TitleFormattingConfigurationForm
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
initialData={formData}
|
|
||||||
onPreview={handlePreview}
|
|
||||||
preview={preview}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box>Loading</Box>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,168 +0,0 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { CategoriesFetcher } from "./categories-fetcher";
|
|
||||||
import { Client, OperationResult } from "urql";
|
|
||||||
import { FetchCategoriesWithMappingQuery } from "../../../generated/graphql";
|
|
||||||
|
|
||||||
type FetchResult = OperationResult<FetchCategoriesWithMappingQuery, { cursor: string | undefined }>;
|
|
||||||
|
|
||||||
const generateCategoryEdgeMock = (uniqueIncrement: number) => {
|
|
||||||
return {
|
|
||||||
node: {
|
|
||||||
name: `Category ${uniqueIncrement}`,
|
|
||||||
id: `cat-${uniqueIncrement}`,
|
|
||||||
googleCategoryId: `${uniqueIncrement * 2}`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateArr = (length: number) => new Array(length).fill(null).map((_, index) => index);
|
|
||||||
|
|
||||||
const wait = () => new Promise((res) => setTimeout(res, 100));
|
|
||||||
|
|
||||||
describe("CategoriesFetcher", () => {
|
|
||||||
const mockQueryPromise = vi.fn<any, FetchResult>();
|
|
||||||
|
|
||||||
const mockClient: Pick<Client, "query"> = {
|
|
||||||
// @ts-ignore - It's hard to mock urql mocks - but it can be improved
|
|
||||||
query() {
|
|
||||||
return {
|
|
||||||
toPromise: mockQueryPromise,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const instance = new CategoriesFetcher(mockClient);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Fetches single page of categories correctly", async () => {
|
|
||||||
mockQueryPromise.mockImplementationOnce(async () => {
|
|
||||||
const data: FetchCategoriesWithMappingQuery = {
|
|
||||||
categories: {
|
|
||||||
pageInfo: {
|
|
||||||
endCursor: undefined,
|
|
||||||
hasNextPage: false,
|
|
||||||
},
|
|
||||||
edges: [generateCategoryEdgeMock(1), generateCategoryEdgeMock(2)],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
error: undefined,
|
|
||||||
data: data,
|
|
||||||
} as FetchResult;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await instance.fetchAllCategories();
|
|
||||||
|
|
||||||
expect(result).toEqual([
|
|
||||||
{
|
|
||||||
googleCategoryId: "2",
|
|
||||||
id: "cat-1",
|
|
||||||
name: "Category 1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
googleCategoryId: "4",
|
|
||||||
id: "cat-2",
|
|
||||||
name: "Category 2",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Fetches 3 pages correctly and merges them", async () => {
|
|
||||||
mockQueryPromise.mockImplementationOnce(async () => {
|
|
||||||
await wait();
|
|
||||||
|
|
||||||
return {
|
|
||||||
error: undefined,
|
|
||||||
data: {
|
|
||||||
categories: {
|
|
||||||
edges: generateArr(100).map((index) => generateCategoryEdgeMock(index)),
|
|
||||||
pageInfo: {
|
|
||||||
hasNextPage: true,
|
|
||||||
endCursor: "cat-99",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as FetchResult;
|
|
||||||
});
|
|
||||||
|
|
||||||
mockQueryPromise.mockImplementationOnce(async () => {
|
|
||||||
await wait();
|
|
||||||
|
|
||||||
return {
|
|
||||||
error: undefined,
|
|
||||||
data: {
|
|
||||||
categories: {
|
|
||||||
edges: generateArr(100)
|
|
||||||
.map((index) => index + 100)
|
|
||||||
.map((index) => generateCategoryEdgeMock(index)),
|
|
||||||
pageInfo: {
|
|
||||||
hasNextPage: true,
|
|
||||||
endCursor: "cat-199",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as FetchResult;
|
|
||||||
});
|
|
||||||
|
|
||||||
mockQueryPromise.mockImplementationOnce(async () => {
|
|
||||||
await wait();
|
|
||||||
|
|
||||||
return {
|
|
||||||
error: undefined,
|
|
||||||
data: {
|
|
||||||
categories: {
|
|
||||||
edges: generateArr(100)
|
|
||||||
.map((index) => index + 200)
|
|
||||||
.map((index) => generateCategoryEdgeMock(index)),
|
|
||||||
pageInfo: {
|
|
||||||
hasNextPage: false,
|
|
||||||
endCursor: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as FetchResult;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await instance.fetchAllCategories();
|
|
||||||
|
|
||||||
expect(result).toHaveLength(300);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Some indexes assertions
|
|
||||||
*/
|
|
||||||
[0, 5, 99, 299].forEach((index) => {
|
|
||||||
expect(result[index]).toEqual({
|
|
||||||
googleCategoryId: `${index * 2}`,
|
|
||||||
id: `cat-${index}`,
|
|
||||||
name: `Category ${index}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Returns empty array if not categories returned from the API", async () => {
|
|
||||||
mockQueryPromise.mockImplementationOnce(async () => {
|
|
||||||
const data: FetchCategoriesWithMappingQuery = {
|
|
||||||
categories: {
|
|
||||||
pageInfo: {
|
|
||||||
endCursor: undefined,
|
|
||||||
hasNextPage: false,
|
|
||||||
},
|
|
||||||
edges: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
error: undefined,
|
|
||||||
data: data,
|
|
||||||
} as FetchResult;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await instance.fetchAllCategories();
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,49 +0,0 @@
|
||||||
import { Client } from "urql";
|
|
||||||
import {
|
|
||||||
CategoryWithMappingFragmentFragment,
|
|
||||||
FetchCategoriesWithMappingDocument,
|
|
||||||
} from "../../../generated/graphql";
|
|
||||||
|
|
||||||
export class CategoriesFetcher {
|
|
||||||
constructor(private apiClient: Pick<Client, "query">) {}
|
|
||||||
|
|
||||||
private async fetchRecursivePage(
|
|
||||||
accumulator: CategoryWithMappingFragmentFragment[],
|
|
||||||
cursor?: string
|
|
||||||
): Promise<CategoryWithMappingFragmentFragment[]> {
|
|
||||||
const result = await this.apiClient
|
|
||||||
.query(FetchCategoriesWithMappingDocument, {
|
|
||||||
cursor,
|
|
||||||
})
|
|
||||||
.toPromise();
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.data) {
|
|
||||||
// todo sentry
|
|
||||||
throw new Error("Empty categories data");
|
|
||||||
}
|
|
||||||
|
|
||||||
accumulator = [...accumulator, ...(result.data.categories?.edges.map((c) => c.node) ?? [])];
|
|
||||||
|
|
||||||
const hasNextPage = result.data.categories?.pageInfo.hasNextPage;
|
|
||||||
const endCursor = result.data.categories?.pageInfo.endCursor;
|
|
||||||
|
|
||||||
if (hasNextPage && endCursor) {
|
|
||||||
return this.fetchRecursivePage(accumulator, endCursor);
|
|
||||||
} else {
|
|
||||||
return accumulator;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches all categories pages - standard page is max 100 items
|
|
||||||
*/
|
|
||||||
async fetchAllCategories(): Promise<CategoryWithMappingFragmentFragment[]> {
|
|
||||||
let categories: CategoryWithMappingFragmentFragment[] = [];
|
|
||||||
|
|
||||||
return this.fetchRecursivePage(categories, undefined);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const SetCategoryMappingInputSchema = z.object({
|
|
||||||
categoryId: z.string().min(0),
|
|
||||||
googleCategoryId: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type SetCategoryMappingInputType = z.infer<typeof SetCategoryMappingInputSchema>;
|
|
|
@ -1,60 +0,0 @@
|
||||||
import { router } from "../trpc/trpc-server";
|
|
||||||
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
|
|
||||||
import { SetCategoryMappingInputSchema } from "./category-mapping-input-schema";
|
|
||||||
import { UpdateCategoryMappingDocument } from "../../../generated/graphql";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
|
||||||
import { CategoriesFetcher } from "./categories-fetcher";
|
|
||||||
|
|
||||||
export const categoryMappingRouter = router({
|
|
||||||
/**
|
|
||||||
* Get all the category mappings to Google categories from its public metadata
|
|
||||||
*/
|
|
||||||
getCategoryMappings: protectedClientProcedure.query(
|
|
||||||
async ({ ctx: { logger, apiClient }, input }) => {
|
|
||||||
const categoriesFetcher = new CategoriesFetcher(apiClient);
|
|
||||||
|
|
||||||
const result = await categoriesFetcher.fetchAllCategories().catch((e) => {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: "Can't fetch the categories",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug("Returning categories");
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
),
|
|
||||||
/**
|
|
||||||
* Sets the Google category mapping for a given category in its public metadata
|
|
||||||
*/
|
|
||||||
setCategoryMapping: protectedClientProcedure
|
|
||||||
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
|
|
||||||
.input(SetCategoryMappingInputSchema)
|
|
||||||
.mutation(async ({ ctx: { logger, apiClient }, input }) => {
|
|
||||||
const { error } = await apiClient
|
|
||||||
.mutation(UpdateCategoryMappingDocument, {
|
|
||||||
id: input.categoryId,
|
|
||||||
googleCategoryId: input.googleCategoryId || "",
|
|
||||||
})
|
|
||||||
.toPromise();
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
{
|
|
||||||
input,
|
|
||||||
},
|
|
||||||
"Updated category mapping"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
logger.error(`Error during the GraphqlAPI call: ${error.message}`);
|
|
||||||
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
|
||||||
message: "Can't save the category",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}),
|
|
||||||
});
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { CategoryWithMappingFragmentFragment } from "../../../generated/graphql";
|
|
||||||
import React from "react";
|
|
||||||
import { Box, ChevronRightIcon, Text } from "@saleor/macaw-ui/next";
|
|
||||||
|
|
||||||
export const constructCategoryBreadcrumbs = (category: CategoryWithMappingFragmentFragment) =>
|
|
||||||
[category.parent?.parent?.name, category.parent?.name, category.name].filter((segment) =>
|
|
||||||
Boolean(segment)
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CategoryBreadcrumbs = (props: { category: CategoryWithMappingFragmentFragment }) => {
|
|
||||||
const breadcrumbs = constructCategoryBreadcrumbs(props.category);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box display={"flex"} marginBottom={1} data-testid={"category-breadcrumb"}>
|
|
||||||
{breadcrumbs.map((category, index) => {
|
|
||||||
const isLast = index === breadcrumbs.length - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box display={"flex"} key={category}>
|
|
||||||
<Text variant={isLast ? "bodyStrong" : "body"}>{category}</Text>
|
|
||||||
{!isLast && <ChevronRightIcon />}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,90 +0,0 @@
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import {
|
|
||||||
SetCategoryMappingInputSchema,
|
|
||||||
SetCategoryMappingInputType,
|
|
||||||
} from "../category-mapping-input-schema";
|
|
||||||
import { CategoryWithMappingFragmentFragment } from "../../../../generated/graphql";
|
|
||||||
import { trpcClient } from "../../trpc/trpc-client";
|
|
||||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
|
||||||
import { Box, Button, PropsWithBox } from "@saleor/macaw-ui/next";
|
|
||||||
import { GoogleProductCategories } from "../google-product-categories";
|
|
||||||
import { CategoryBreadcrumbs } from "../construct-category-breadcrumbs";
|
|
||||||
|
|
||||||
type CategoryMappingFormProps = {
|
|
||||||
category: CategoryWithMappingFragmentFragment;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use Combobox, when virtualized. For such amount of data it almost crashes app due to slow rendering
|
|
||||||
* https://github.com/saleor/macaw-ui/issues/452
|
|
||||||
*/
|
|
||||||
export const CategoryMappingForm = ({
|
|
||||||
category,
|
|
||||||
...props
|
|
||||||
}: PropsWithBox<CategoryMappingFormProps>) => {
|
|
||||||
const { notifySuccess, notifyError } = useDashboardNotification();
|
|
||||||
|
|
||||||
const { control, handleSubmit, formState } = useForm<SetCategoryMappingInputType>({
|
|
||||||
defaultValues: {
|
|
||||||
categoryId: category.id,
|
|
||||||
googleCategoryId: category.googleCategoryId || undefined,
|
|
||||||
},
|
|
||||||
resolver: zodResolver(SetCategoryMappingInputSchema),
|
|
||||||
});
|
|
||||||
const { mutate, isLoading } = trpcClient.categoryMapping.setCategoryMapping.useMutation({
|
|
||||||
onError() {
|
|
||||||
notifyError("Could not save the category mapping");
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
notifySuccess("Success");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
as={"form"}
|
|
||||||
{...props}
|
|
||||||
onSubmit={handleSubmit((data, event) => {
|
|
||||||
mutate(data);
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<CategoryBreadcrumbs category={category} />
|
|
||||||
<Box display={"flex"} gap={5} __width={"100%"}>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
name="googleCategoryId"
|
|
||||||
render={({ field: { value, onChange } }) => {
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
data-testid={"google-category-select"}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
value={value}
|
|
||||||
onChange={(event) => {
|
|
||||||
onChange(event.target.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value={undefined}>No override</option>
|
|
||||||
{GoogleProductCategories.map((choice) => (
|
|
||||||
<option key={choice.id} value={choice.id.toString()}>
|
|
||||||
{choice.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
data-testid={"google-category-submit"}
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
disabled={isLoading || !formState.isDirty}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,50 +0,0 @@
|
||||||
import { Box, Text } from "@saleor/macaw-ui/next";
|
|
||||||
import { trpcClient } from "../../trpc/trpc-client";
|
|
||||||
import { CategoryBreadcrumbs } from "../construct-category-breadcrumbs";
|
|
||||||
import { GoogleProductCategories } from "../google-product-categories";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
const GoogleCategory = (props: { categoryId: string }) => {
|
|
||||||
const googleCategory = useMemo(() => {
|
|
||||||
return GoogleProductCategories.find((cat) => cat.id === props.categoryId);
|
|
||||||
}, [props.categoryId]);
|
|
||||||
|
|
||||||
return <Text size={"small"}>{googleCategory?.name}</Text>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CategoryMappingPreview = () => {
|
|
||||||
const { data: categories, isLoading } = trpcClient.categoryMapping.getCategoryMappings.useQuery();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Text>Loading</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (categories?.length === 0) {
|
|
||||||
return <Text>No categories</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Text marginBottom={5} as={"h2"} variant={"heading"}>
|
|
||||||
Mapped categories
|
|
||||||
</Text>
|
|
||||||
{categories!
|
|
||||||
.filter((c) => c.googleCategoryId)
|
|
||||||
.map((category) => {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={category.id}
|
|
||||||
marginBottom={1.5}
|
|
||||||
borderBottomStyle={"solid"}
|
|
||||||
borderColor={"neutralHighlight"}
|
|
||||||
borderWidth={1}
|
|
||||||
paddingBottom={1.5}
|
|
||||||
>
|
|
||||||
<CategoryBreadcrumbs category={category} />
|
|
||||||
<GoogleCategory categoryId={category.googleCategoryId!} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,25 +0,0 @@
|
||||||
import { trpcClient } from "../../trpc/trpc-client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { CategoryMappingForm } from "./category-mapping-form";
|
|
||||||
import { Box, Text } from "@saleor/macaw-ui/next";
|
|
||||||
|
|
||||||
export const CategoryMapping = () => {
|
|
||||||
const { data: categories, isLoading } = trpcClient.categoryMapping.getCategoryMappings.useQuery();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Text>Loading</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (categories && categories.length === 0) {
|
|
||||||
return <Text>No categories to map</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
{categories!.map((category) => (
|
|
||||||
<CategoryMappingForm category={category} key={category.id} marginBottom={5} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,23 +0,0 @@
|
||||||
import React, { PropsWithChildren } from "react";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { renderHook } from "@testing-library/react";
|
|
||||||
import { useGetFeedApiUrl } from "./use-get-feed-api-url";
|
|
||||||
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
|
|
||||||
|
|
||||||
describe("useGetFeedApiUrl", function () {
|
|
||||||
const appBridge = new AppBridge({ saleorApiUrl: "https://example.com/graphql/" });
|
|
||||||
|
|
||||||
const HookWrapper = ({ children }: PropsWithChildren<{}>) => {
|
|
||||||
return <AppBridgeProvider appBridgeInstance={appBridge}>{children}</AppBridgeProvider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
it("Constructs valid URL from window origin and channel slug", () => {
|
|
||||||
const { result } = renderHook(() => useGetFeedApiUrl("test-slug"), {
|
|
||||||
wrapper: HookWrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current).toEqual(
|
|
||||||
"http://localhost:3000/api/feed/https%3A%2F%2Fexample.com%2Fgraphql%2F/test-slug/google.xml"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,13 +0,0 @@
|
||||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
|
||||||
|
|
||||||
export const useGetFeedApiUrl = (channelSlug: string) => {
|
|
||||||
const { appBridgeState } = useAppBridge();
|
|
||||||
|
|
||||||
if (!appBridgeState || !window.location.origin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${window.location.origin}/api/feed/${encodeURIComponent(
|
|
||||||
appBridgeState.saleorApiUrl as string
|
|
||||||
)}/${channelSlug}/google.xml`;
|
|
||||||
};
|
|
|
@ -1,31 +0,0 @@
|
||||||
export const awsRegionList = [
|
|
||||||
"af-south-1",
|
|
||||||
"ap-east-1",
|
|
||||||
"ap-northeast-1",
|
|
||||||
"ap-northeast-2",
|
|
||||||
"ap-northeast-3",
|
|
||||||
"ap-south-1",
|
|
||||||
"ap-south-2",
|
|
||||||
"ap-southeast-1",
|
|
||||||
"ap-southeast-2",
|
|
||||||
"ap-southeast-3",
|
|
||||||
"ap-southeast-4",
|
|
||||||
"ca-central-1",
|
|
||||||
"eu-central-1",
|
|
||||||
"eu-central-2",
|
|
||||||
"eu-north-1",
|
|
||||||
"eu-south-1",
|
|
||||||
"eu-south-2",
|
|
||||||
"eu-west-1",
|
|
||||||
"eu-west-2",
|
|
||||||
"eu-west-3",
|
|
||||||
"me-central-1",
|
|
||||||
"me-south-1",
|
|
||||||
"sa-east-1",
|
|
||||||
"us-east-1",
|
|
||||||
"us-east-2",
|
|
||||||
"us-gov-east-1",
|
|
||||||
"us-gov-west-1",
|
|
||||||
"us-west-1",
|
|
||||||
"us-west-2",
|
|
||||||
] as const;
|
|
|
@ -1,15 +0,0 @@
|
||||||
import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3";
|
|
||||||
|
|
||||||
interface checkBucketAccessArgs {
|
|
||||||
s3Client: S3Client;
|
|
||||||
bucketName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if client can access the bucket. Throws an error otherwise
|
|
||||||
export const checkBucketAccess = async ({ s3Client, bucketName }: checkBucketAccessArgs) => {
|
|
||||||
await s3Client.send(
|
|
||||||
new HeadBucketCommand({
|
|
||||||
Bucket: bucketName,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,6 +0,0 @@
|
||||||
/*
|
|
||||||
* AWS multipart uploads require a minimum file size of 5 MB.
|
|
||||||
* https://docs.aws.amazon.com/AmazonS3/latest/userguide/qfacts.html
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const MULTI_PART_SIZE_THRESHOLD = 5 * 1024 * 1024;
|
|
|
@ -1,16 +0,0 @@
|
||||||
import { S3Client } from "@aws-sdk/client-s3";
|
|
||||||
import { RootConfig } from "../../app-configuration/app-config";
|
|
||||||
|
|
||||||
export const createS3ClientFromConfiguration = ({
|
|
||||||
accessKeyId,
|
|
||||||
secretAccessKey,
|
|
||||||
region,
|
|
||||||
}: Exclude<RootConfig["s3"], null>) => {
|
|
||||||
return new S3Client({
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: accessKeyId,
|
|
||||||
secretAccessKey: secretAccessKey,
|
|
||||||
},
|
|
||||||
region: region,
|
|
||||||
});
|
|
||||||
};
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { GetObjectAttributesCommand, S3Client } from "@aws-sdk/client-s3";
|
|
||||||
|
|
||||||
export interface GetFileDetailsArgs {
|
|
||||||
s3Client: S3Client;
|
|
||||||
fileName: string;
|
|
||||||
bucketName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getFileDetails = async ({ s3Client, bucketName, fileName }: GetFileDetailsArgs) => {
|
|
||||||
return await s3Client.send(
|
|
||||||
new GetObjectAttributesCommand({
|
|
||||||
Bucket: bucketName,
|
|
||||||
Key: fileName,
|
|
||||||
ObjectAttributes: ["ObjectParts"],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,18 +0,0 @@
|
||||||
import { S3Client } from "@aws-sdk/client-s3";
|
|
||||||
import { UploadMultiPart } from "./upload-multi-part";
|
|
||||||
import { UploadSinglePart } from "./upload-single-part";
|
|
||||||
import { MULTI_PART_SIZE_THRESHOLD } from "./const";
|
|
||||||
|
|
||||||
export interface UploadFileArgs {
|
|
||||||
s3Client: S3Client;
|
|
||||||
fileName: string;
|
|
||||||
buffer: Buffer;
|
|
||||||
bucketName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const uploadFile = async (args: UploadFileArgs) => {
|
|
||||||
if (args.buffer.length > MULTI_PART_SIZE_THRESHOLD) {
|
|
||||||
return await UploadMultiPart(args);
|
|
||||||
}
|
|
||||||
return await UploadSinglePart(args);
|
|
||||||
};
|
|
|
@ -1,94 +0,0 @@
|
||||||
import {
|
|
||||||
AbortMultipartUploadCommand,
|
|
||||||
CompleteMultipartUploadCommand,
|
|
||||||
CreateMultipartUploadCommand,
|
|
||||||
UploadPartCommand,
|
|
||||||
} from "@aws-sdk/client-s3";
|
|
||||||
import { UploadFileArgs } from "./upload-file";
|
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
|
||||||
import { MULTI_PART_SIZE_THRESHOLD } from "./const";
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Code based on S3 docs:
|
|
||||||
* https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/javascript_s3_code_examples.html
|
|
||||||
*/
|
|
||||||
|
|
||||||
const logger = createLogger({
|
|
||||||
fn: "UploadMultiPart",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const UploadMultiPart = async ({
|
|
||||||
s3Client,
|
|
||||||
fileName,
|
|
||||||
buffer,
|
|
||||||
bucketName,
|
|
||||||
}: UploadFileArgs) => {
|
|
||||||
let uploadId;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const multipartUpload = await s3Client.send(
|
|
||||||
new CreateMultipartUploadCommand({
|
|
||||||
Bucket: bucketName,
|
|
||||||
Key: fileName,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
uploadId = multipartUpload.UploadId;
|
|
||||||
|
|
||||||
const uploadPromises = [];
|
|
||||||
// Multipart uploads require a minimum size of 5 MB per part.
|
|
||||||
const partSize = MULTI_PART_SIZE_THRESHOLD;
|
|
||||||
const numberOfParts = Math.ceil(buffer.length / partSize);
|
|
||||||
|
|
||||||
// Upload each part.
|
|
||||||
for (let i = 0; i < numberOfParts; i++) {
|
|
||||||
const start = i * partSize;
|
|
||||||
const end = start + partSize;
|
|
||||||
|
|
||||||
uploadPromises.push(
|
|
||||||
s3Client
|
|
||||||
.send(
|
|
||||||
new UploadPartCommand({
|
|
||||||
Bucket: bucketName,
|
|
||||||
Key: fileName,
|
|
||||||
UploadId: uploadId,
|
|
||||||
Body: buffer.subarray(start, end),
|
|
||||||
PartNumber: i + 1,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.then((d) => {
|
|
||||||
logger.debug(`Part ${i + 1}/${numberOfParts} uploaded`);
|
|
||||||
return d;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadResults = await Promise.all(uploadPromises);
|
|
||||||
|
|
||||||
return await s3Client.send(
|
|
||||||
new CompleteMultipartUploadCommand({
|
|
||||||
Bucket: bucketName,
|
|
||||||
Key: fileName,
|
|
||||||
UploadId: uploadId,
|
|
||||||
MultipartUpload: {
|
|
||||||
Parts: uploadResults.map(({ ETag }, i) => ({
|
|
||||||
ETag,
|
|
||||||
PartNumber: i + 1,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(err);
|
|
||||||
|
|
||||||
if (uploadId) {
|
|
||||||
const abortCommand = new AbortMultipartUploadCommand({
|
|
||||||
Bucket: bucketName,
|
|
||||||
Key: fileName,
|
|
||||||
UploadId: uploadId,
|
|
||||||
});
|
|
||||||
|
|
||||||
await s3Client.send(abortCommand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
|
||||||
import { UploadFileArgs } from "./upload-file";
|
|
||||||
|
|
||||||
export const UploadSinglePart = async ({
|
|
||||||
s3Client,
|
|
||||||
fileName,
|
|
||||||
buffer,
|
|
||||||
bucketName,
|
|
||||||
}: UploadFileArgs) => {
|
|
||||||
return await s3Client.send(
|
|
||||||
new PutObjectCommand({
|
|
||||||
Bucket: bucketName,
|
|
||||||
Body: buffer,
|
|
||||||
Key: fileName,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,28 +0,0 @@
|
||||||
import { RootConfig } from "../../app-configuration/app-config";
|
|
||||||
|
|
||||||
interface GetDownloadUrlArgs {
|
|
||||||
s3BucketConfiguration: Exclude<RootConfig["s3"], null>;
|
|
||||||
saleorApiUrl: string;
|
|
||||||
channel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getDownloadUrl = ({
|
|
||||||
s3BucketConfiguration,
|
|
||||||
saleorApiUrl,
|
|
||||||
channel,
|
|
||||||
}: GetDownloadUrlArgs) => {
|
|
||||||
return `https://${s3BucketConfiguration.bucketName}.s3.${
|
|
||||||
s3BucketConfiguration.region
|
|
||||||
}.amazonaws.com/${getFileName({ saleorApiUrl, channel })}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface GetFileNameArgs {
|
|
||||||
saleorApiUrl: string;
|
|
||||||
channel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getFileName = ({ saleorApiUrl, channel }: GetFileNameArgs) => {
|
|
||||||
const apiUrl = new URL(saleorApiUrl);
|
|
||||||
|
|
||||||
return `${apiUrl.hostname}/${channel}/google.xml`;
|
|
||||||
};
|
|
|
@ -1,268 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
|
|
||||||
import { attributeArrayToValueString, getMappedAttributes } from "./attribute-mapping";
|
|
||||||
|
|
||||||
const productBase: GoogleFeedProductVariantFragment["product"] = {
|
|
||||||
name: "Product",
|
|
||||||
__typename: "Product",
|
|
||||||
id: "product-id",
|
|
||||||
category: {
|
|
||||||
id: "cat-id",
|
|
||||||
__typename: "Category",
|
|
||||||
name: "Category Name",
|
|
||||||
googleCategoryId: "1",
|
|
||||||
},
|
|
||||||
description: "Product description",
|
|
||||||
seoDescription: "Seo description",
|
|
||||||
slug: "product-slug",
|
|
||||||
thumbnail: { __typename: "Image", url: "" },
|
|
||||||
attributes: [
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "main-color",
|
|
||||||
},
|
|
||||||
values: [{ name: "Black" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "accent-color",
|
|
||||||
},
|
|
||||||
values: [{ name: "Red" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "size",
|
|
||||||
},
|
|
||||||
values: [{ name: "XL" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "pattern",
|
|
||||||
},
|
|
||||||
values: [{ name: "plain" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const priceBase: GoogleFeedProductVariantFragment["pricing"] = {
|
|
||||||
__typename: "VariantPricingInfo",
|
|
||||||
price: {
|
|
||||||
__typename: "TaxedMoney",
|
|
||||||
gross: {
|
|
||||||
__typename: "Money",
|
|
||||||
amount: 1,
|
|
||||||
currency: "USD",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("attribute-mapping", () => {
|
|
||||||
describe("attributeArrayToValueString", () => {
|
|
||||||
it("Return undefined, when no attributes", () => {
|
|
||||||
expect(attributeArrayToValueString([])).toStrictEqual(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Return value, when attribute have value assigned", () => {
|
|
||||||
expect(
|
|
||||||
attributeArrayToValueString([
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "1",
|
|
||||||
},
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
name: "Red",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "2",
|
|
||||||
},
|
|
||||||
values: [],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
).toStrictEqual("Red");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Return all values, when attribute have multiple value assigned", () => {
|
|
||||||
expect(
|
|
||||||
attributeArrayToValueString([
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "1",
|
|
||||||
},
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
name: "Red",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Blue",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "2",
|
|
||||||
},
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
name: "Yellow",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
).toStrictEqual("Red/Blue/Yellow");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getMappedAttributes", () => {
|
|
||||||
it("Return undefined, when no mapping is passed", () => {
|
|
||||||
expect(
|
|
||||||
getMappedAttributes({
|
|
||||||
variant: {
|
|
||||||
id: "id1",
|
|
||||||
__typename: "ProductVariant",
|
|
||||||
sku: "sku1",
|
|
||||||
quantityAvailable: 1,
|
|
||||||
pricing: priceBase,
|
|
||||||
name: "Product variant",
|
|
||||||
product: productBase,
|
|
||||||
attributes: [],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
).toStrictEqual(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Return empty values, when variant has no related attributes", () => {
|
|
||||||
expect(
|
|
||||||
getMappedAttributes({
|
|
||||||
variant: {
|
|
||||||
id: "id1",
|
|
||||||
__typename: "ProductVariant",
|
|
||||||
sku: "sku1",
|
|
||||||
quantityAvailable: 1,
|
|
||||||
pricing: priceBase,
|
|
||||||
name: "Product variant",
|
|
||||||
product: productBase,
|
|
||||||
attributes: [],
|
|
||||||
},
|
|
||||||
attributeMapping: {
|
|
||||||
brandAttributeIds: ["brand-id"],
|
|
||||||
colorAttributeIds: ["color-id"],
|
|
||||||
patternAttributeIds: ["pattern-id"],
|
|
||||||
materialAttributeIds: ["material-id"],
|
|
||||||
sizeAttributeIds: ["size-id"],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
).toStrictEqual({
|
|
||||||
material: undefined,
|
|
||||||
color: undefined,
|
|
||||||
size: undefined,
|
|
||||||
brand: undefined,
|
|
||||||
pattern: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Return attribute values, when variant has attributes used by mapping", () => {
|
|
||||||
expect(
|
|
||||||
getMappedAttributes({
|
|
||||||
variant: {
|
|
||||||
id: "id1",
|
|
||||||
__typename: "ProductVariant",
|
|
||||||
sku: "sku1",
|
|
||||||
quantityAvailable: 1,
|
|
||||||
pricing: priceBase,
|
|
||||||
name: "Product variant",
|
|
||||||
product: productBase,
|
|
||||||
attributes: [
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "should be ignored",
|
|
||||||
},
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
name: "ignored",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "brand-id",
|
|
||||||
},
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
name: "Saleor",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "size-id",
|
|
||||||
},
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
name: "XL",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "color-base-id",
|
|
||||||
},
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
name: "Red",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "color-secondary-id",
|
|
||||||
},
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
name: "Black",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "material-id",
|
|
||||||
},
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
name: "Cotton",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute: {
|
|
||||||
id: "pattern-id",
|
|
||||||
},
|
|
||||||
values: [
|
|
||||||
{
|
|
||||||
name: "Plain",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
attributeMapping: {
|
|
||||||
brandAttributeIds: ["brand-id"],
|
|
||||||
colorAttributeIds: ["color-base-id", "color-secondary-id"],
|
|
||||||
materialAttributeIds: ["material-id"],
|
|
||||||
sizeAttributeIds: ["size-id"],
|
|
||||||
patternAttributeIds: ["pattern-id"],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
).toStrictEqual({
|
|
||||||
material: "Cotton",
|
|
||||||
color: "Red/Black",
|
|
||||||
size: "XL",
|
|
||||||
brand: "Saleor",
|
|
||||||
pattern: "Plain",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,69 +0,0 @@
|
||||||
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
|
|
||||||
import { RootConfig } from "../app-configuration/app-config";
|
|
||||||
|
|
||||||
interface GetMappedAttributesArgs {
|
|
||||||
variant: GoogleFeedProductVariantFragment;
|
|
||||||
attributeMapping?: RootConfig["attributeMapping"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const attributeArrayToValueString = (
|
|
||||||
attributes?: GoogleFeedProductVariantFragment["attributes"]
|
|
||||||
) => {
|
|
||||||
if (!attributes?.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return attributes
|
|
||||||
.map((a) => a.values)
|
|
||||||
.flat() // Multiple values can be assigned to the attribute
|
|
||||||
.map((v) => v.name) // get value to display
|
|
||||||
.filter((v) => !!v) // filter out empty values
|
|
||||||
.join("/"); // Format of multi value attribute recommended by Google
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMappedAttributes = ({
|
|
||||||
variant,
|
|
||||||
attributeMapping: mapping,
|
|
||||||
}: GetMappedAttributesArgs) => {
|
|
||||||
/*
|
|
||||||
* We have to take in account both product and variant attributes since we use flat
|
|
||||||
* model in the feed
|
|
||||||
*/
|
|
||||||
if (!mapping) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const attributes = variant.attributes.concat(variant.product.attributes);
|
|
||||||
|
|
||||||
const materialAttributes = attributes.filter((a) =>
|
|
||||||
mapping.materialAttributeIds.includes(a.attribute.id)
|
|
||||||
);
|
|
||||||
const materialValue = attributeArrayToValueString(materialAttributes);
|
|
||||||
|
|
||||||
const brandAttributes = attributes.filter((a) =>
|
|
||||||
mapping.brandAttributeIds.includes(a.attribute.id)
|
|
||||||
);
|
|
||||||
const brandValue = attributeArrayToValueString(brandAttributes);
|
|
||||||
|
|
||||||
const colorAttributes = attributes.filter((a) =>
|
|
||||||
mapping.colorAttributeIds.includes(a.attribute.id)
|
|
||||||
);
|
|
||||||
const colorValue = attributeArrayToValueString(colorAttributes);
|
|
||||||
|
|
||||||
const patternAttributes = attributes.filter((a) =>
|
|
||||||
mapping.patternAttributeIds.includes(a.attribute.id)
|
|
||||||
);
|
|
||||||
const patternValue = attributeArrayToValueString(patternAttributes);
|
|
||||||
|
|
||||||
const sizeAttributes = attributes.filter((a) =>
|
|
||||||
mapping.sizeAttributeIds.includes(a.attribute.id)
|
|
||||||
);
|
|
||||||
const sizeValue = attributeArrayToValueString(sizeAttributes);
|
|
||||||
|
|
||||||
return {
|
|
||||||
material: materialValue,
|
|
||||||
brand: brandValue,
|
|
||||||
color: colorValue,
|
|
||||||
size: sizeValue,
|
|
||||||
pattern: patternValue,
|
|
||||||
};
|
|
||||||
};
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue