POC WIP
This commit is contained in:
parent
5dee65ad2c
commit
de12370c46
130 changed files with 50796 additions and 305 deletions
9
apps/data-exporter/.env.example
Normal file
9
apps/data-exporter/.env.example
Normal file
|
@ -0,0 +1,9 @@
|
|||
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=
|
4
apps/data-exporter/.eslintrc
Normal file
4
apps/data-exporter/.eslintrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"root": true,
|
||||
"extends": ["saleor"]
|
||||
}
|
3
apps/data-exporter/.gitignore
vendored
Normal file
3
apps/data-exporter/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
|
||||
# Sentry Auth Token
|
||||
.sentryclirc
|
19
apps/data-exporter/.graphqlrc.yml
Normal file
19
apps/data-exporter/.graphqlrc.yml
Normal file
|
@ -0,0 +1,19 @@
|
|||
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
|
364
apps/data-exporter/CHANGELOG.md
Normal file
364
apps/data-exporter/CHANGELOG.md
Normal file
|
@ -0,0 +1,364 @@
|
|||
# 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
|
114
apps/data-exporter/README.md
Normal file
114
apps/data-exporter/README.md
Normal file
|
@ -0,0 +1,114 @@
|
|||
<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.
|
0
apps/data-exporter/graphql/fragments/.gitkeep
Normal file
0
apps/data-exporter/graphql/fragments/.gitkeep
Normal file
|
@ -0,0 +1,5 @@
|
|||
fragment AttributeWithMappingFragment on Attribute {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
fragment CategoryWithMappingFragment on Category {
|
||||
id
|
||||
parent{
|
||||
name
|
||||
parent{
|
||||
name
|
||||
}
|
||||
}
|
||||
name
|
||||
googleCategoryId: metafield(key: "google_category_id")
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
fragment ProductVariantWebhookPayload on ProductVariant {
|
||||
channel
|
||||
channelListings {
|
||||
channel {
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
fragment ProductWebhookPayload on Product {
|
||||
channel
|
||||
channelListings {
|
||||
channel {
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
0
apps/data-exporter/graphql/mutations/.gitkeep
Normal file
0
apps/data-exporter/graphql/mutations/.gitkeep
Normal file
|
@ -0,0 +1,7 @@
|
|||
mutation UpdateCategoryMapping($id: ID!, $googleCategoryId: String!) {
|
||||
updateMetadata(id: $id, input: {key: "google_category_id", value: $googleCategoryId}) {
|
||||
errors {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
0
apps/data-exporter/graphql/queries/.gitkeep
Normal file
0
apps/data-exporter/graphql/queries/.gitkeep
Normal file
|
@ -0,0 +1,9 @@
|
|||
query FetchAppDetails {
|
||||
app {
|
||||
id
|
||||
privateMetadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
query FetchAttributesWithMapping($cursor: String){
|
||||
attributes(first: 100, after: $cursor){
|
||||
pageInfo{
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges{
|
||||
node{
|
||||
...AttributeWithMappingFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
query FetchCategoriesWithMapping($cursor: String){
|
||||
categories(first: 100, after: $cursor){
|
||||
pageInfo{
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges{
|
||||
node{
|
||||
...CategoryWithMappingFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
query FetchProductCursors($first:Int!, $after: String, $channel: String!){
|
||||
productVariants(first:$first, after: $after, channel: $channel){
|
||||
pageInfo{
|
||||
hasNextPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
query FetchProductDataForFeed($first:Int!, $after: String, $channel: String!, $imageSize: Int = 1024){
|
||||
productVariants(first:$first, after: $after, channel: $channel){
|
||||
pageInfo{
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
edges{
|
||||
node{
|
||||
...GoogleFeedProductVariant
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
6
apps/data-exporter/graphql/queries/ShopDetails.graphql
Normal file
6
apps/data-exporter/graphql/queries/ShopDetails.graphql
Normal file
|
@ -0,0 +1,6 @@
|
|||
query ShopDetails {
|
||||
shop{
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
29625
apps/data-exporter/graphql/schema.graphql
Normal file
29625
apps/data-exporter/graphql/schema.graphql
Normal file
File diff suppressed because it is too large
Load diff
0
apps/data-exporter/graphql/subscriptions/.gitkeep
Normal file
0
apps/data-exporter/graphql/subscriptions/.gitkeep
Normal file
|
@ -0,0 +1,9 @@
|
|||
subscription ProductCreated {
|
||||
event {
|
||||
... on ProductCreated {
|
||||
product {
|
||||
...ProductWebhookPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
subscription ProductDeleted {
|
||||
event {
|
||||
... on ProductDeleted {
|
||||
product {
|
||||
...ProductWebhookPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
subscription ProductUpdated {
|
||||
event {
|
||||
... on ProductUpdated {
|
||||
product {
|
||||
...ProductWebhookPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
subscription ProductVariantCreated {
|
||||
event {
|
||||
... on ProductVariantCreated {
|
||||
productVariant {
|
||||
...ProductVariantWebhookPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
subscription ProductVariantDeleted {
|
||||
event {
|
||||
... on ProductVariantDeleted {
|
||||
productVariant {
|
||||
...ProductVariantWebhookPayload
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
subscription ProductVariantUpdated {
|
||||
event {
|
||||
... on ProductVariantUpdated {
|
||||
productVariant {
|
||||
...ProductVariantWebhookPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
5
apps/data-exporter/next-env.d.ts
vendored
Normal file
5
apps/data-exporter/next-env.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
/// <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.
|
28
apps/data-exporter/next.config.js
Normal file
28
apps/data-exporter/next.config.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
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;
|
69
apps/data-exporter/package.json
Normal file
69
apps/data-exporter/package.json
Normal file
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
BIN
apps/data-exporter/public/logo.png
Normal file
BIN
apps/data-exporter/public/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.9 KiB |
37
apps/data-exporter/sentry.client.config.ts
Normal file
37
apps/data-exporter/sentry.client.config.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* This file configures the initialization of Sentry on the client.
|
||||
* The config you add here will be used whenever a users loads a page in their browser.
|
||||
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
*/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import pkg from "./package.json";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 0.5,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
|
||||
/*
|
||||
* This sets the sample rate to be 10%. You may want this to be 100% while
|
||||
* in development and sample at a lower rate in production
|
||||
*/
|
||||
replaysSessionSampleRate: 0.1,
|
||||
|
||||
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
|
||||
integrations: [
|
||||
new Sentry.Replay({
|
||||
// Additional Replay configuration goes in here, for example:
|
||||
maskAllText: true,
|
||||
blockAllMedia: true,
|
||||
}),
|
||||
],
|
||||
environment: process.env.SENTRY_ENVIRONMENT,
|
||||
release: `${pkg.name}@${pkg.version}`,
|
||||
});
|
21
apps/data-exporter/sentry.edge.config.ts
Normal file
21
apps/data-exporter/sentry.edge.config.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
||||
* The config you add here will be used whenever one of the edge features is loaded.
|
||||
* Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||
* https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
*/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import pkg from "./package.json";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 0.5,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
environment: process.env.SENTRY_ENVIRONMENT,
|
||||
release: `${pkg.name}@${pkg.version}`,
|
||||
});
|
20
apps/data-exporter/sentry.server.config.ts
Normal file
20
apps/data-exporter/sentry.server.config.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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}`,
|
||||
});
|
11
apps/data-exporter/src/lib/create-graphql-client.ts
Normal file
11
apps/data-exporter/src/lib/create-graphql-client.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
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,
|
||||
});
|
||||
},
|
||||
};
|
95
apps/data-exporter/src/lib/metadata-manager.ts
Normal file
95
apps/data-exporter/src/lib/metadata-manager.ts
Normal file
|
@ -0,0 +1,95 @@
|
|||
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),
|
||||
});
|
||||
};
|
25
apps/data-exporter/src/lib/theme-synchronizer.tsx
Normal file
25
apps/data-exporter/src/lib/theme-synchronizer.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
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;
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,295 @@
|
|||
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",
|
||||
});
|
||||
});
|
||||
});
|
170
apps/data-exporter/src/modules/app-configuration/app-config.ts
Normal file
170
apps/data-exporter/src/modules/app-configuration/app-config.ts
Normal file
|
@ -0,0 +1,170 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,231 @@
|
|||
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",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
|
@ -0,0 +1,49 @@
|
|||
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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,106 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,27 @@
|
|||
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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
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;
|
||||
},
|
||||
),
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
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]);
|
||||
};
|
|
@ -0,0 +1,58 @@
|
|||
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",
|
||||
},
|
||||
},
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,98 @@
|
|||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
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,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,134 @@
|
|||
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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,120 @@
|
|||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,168 @@
|
|||
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([]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,49 @@
|
|||
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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
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>;
|
|
@ -0,0 +1,60 @@
|
|||
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;
|
||||
}),
|
||||
});
|
|
@ -0,0 +1,27 @@
|
|||
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
|
@ -0,0 +1,90 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,50 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,25 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
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"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,13 @@
|
|||
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`;
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
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;
|
|
@ -0,0 +1,15 @@
|
|||
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,
|
||||
})
|
||||
);
|
||||
};
|
6
apps/data-exporter/src/modules/file-storage/s3/const.ts
Normal file
6
apps/data-exporter/src/modules/file-storage/s3/const.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
/*
|
||||
* 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;
|
|
@ -0,0 +1,16 @@
|
|||
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,
|
||||
});
|
||||
};
|
|
@ -0,0 +1,17 @@
|
|||
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"],
|
||||
})
|
||||
);
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
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);
|
||||
};
|
|
@ -0,0 +1,94 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,17 @@
|
|||
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,
|
||||
})
|
||||
);
|
||||
};
|
|
@ -0,0 +1,28 @@
|
|||
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`;
|
||||
};
|
|
@ -0,0 +1,268 @@
|
|||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,69 @@
|
|||
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,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,99 @@
|
|||
import { url } from "inspector";
|
||||
import { Client } from "urql";
|
||||
import { createLogger } from "@saleor/apps-shared";
|
||||
import {
|
||||
FetchProductCursorsDocument,
|
||||
FetchProductDataForFeedDocument,
|
||||
GoogleFeedProductVariantFragment,
|
||||
} from "../../../generated/graphql";
|
||||
|
||||
export const getCursors = async ({ client, channel }: { client: Client; channel: string }) => {
|
||||
const logger = createLogger({ saleorApiUrl: url, channel, fn: "getCursors" });
|
||||
|
||||
logger.debug(`Fetching cursors for channel ${channel}`);
|
||||
|
||||
let result = await client
|
||||
.query(FetchProductCursorsDocument, { channel: channel, first: 100 })
|
||||
.toPromise();
|
||||
|
||||
const cursors: Array<string> = [];
|
||||
|
||||
while (result.data?.productVariants?.pageInfo.hasNextPage) {
|
||||
result = await client
|
||||
.query(FetchProductCursorsDocument, {
|
||||
channel: channel,
|
||||
first: 100,
|
||||
after: result.data.productVariants.pageInfo.endCursor,
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
const endCursor = result.data?.productVariants?.pageInfo.endCursor;
|
||||
|
||||
if (endCursor) {
|
||||
cursors.push(endCursor);
|
||||
}
|
||||
}
|
||||
return cursors;
|
||||
};
|
||||
|
||||
const fetchVariants = async ({
|
||||
client,
|
||||
after,
|
||||
channel,
|
||||
imageSize,
|
||||
}: {
|
||||
client: Client;
|
||||
after?: string;
|
||||
channel: string;
|
||||
imageSize?: number;
|
||||
}): Promise<GoogleFeedProductVariantFragment[]> => {
|
||||
const logger = createLogger({ saleorApiUrl: url, channel, fn: "fetchVariants" });
|
||||
|
||||
logger.debug(`Fetching variants for channel ${channel} with cursor ${after}`);
|
||||
|
||||
const result = await client
|
||||
.query(FetchProductDataForFeedDocument, {
|
||||
channel: channel,
|
||||
first: 100,
|
||||
after,
|
||||
imageSize,
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
logger.error(`Error during the GraphqlAPI call: ${result.error.message}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.data?.productVariants?.edges.map((e) => e.node) || [];
|
||||
};
|
||||
|
||||
interface FetchProductDataArgs {
|
||||
client: Client;
|
||||
channel: string;
|
||||
cursors?: Array<string>;
|
||||
imageSize?: number;
|
||||
}
|
||||
|
||||
export const fetchProductData = async ({
|
||||
client,
|
||||
channel,
|
||||
cursors,
|
||||
imageSize,
|
||||
}: FetchProductDataArgs) => {
|
||||
const logger = createLogger({ saleorApiUrl: url, channel, route: "Google Product Feed" });
|
||||
|
||||
const cachedCursors = cursors || (await getCursors({ client, channel }));
|
||||
|
||||
const pageCursors = [undefined, ...cachedCursors];
|
||||
|
||||
logger.debug(`Query generated ${pageCursors.length} cursors`);
|
||||
|
||||
const promises = pageCursors.map((cursor) =>
|
||||
fetchVariants({ client, after: cursor, channel, imageSize }),
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
return results.flat();
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
import { url } from "inspector";
|
||||
import { Client } from "urql";
|
||||
import { createLogger } from "@saleor/apps-shared";
|
||||
import { ShopDetailsDocument } from "../../../generated/graphql";
|
||||
|
||||
interface FetchShopDataArgs {
|
||||
client: Client;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
export const fetchShopData = async ({ client, channel }: FetchShopDataArgs) => {
|
||||
const logger = createLogger({ saleorApiUrl: url, channel, route: "Google Product Feed" });
|
||||
|
||||
const result = await client.query(ShopDetailsDocument, {}).toPromise();
|
||||
const shopDetails = result.data?.shop;
|
||||
|
||||
if (result.error) {
|
||||
logger.error(`Error during the GraphqlAPI call: ${result.error.message}`);
|
||||
throw new Error("Error during the GraphQL API call");
|
||||
}
|
||||
|
||||
if (!shopDetails) {
|
||||
logger.error("Shop details query returned no data");
|
||||
throw new Error("Shop details query returned no data");
|
||||
}
|
||||
|
||||
return {
|
||||
shopName: shopDetails?.name,
|
||||
shopDescription: shopDetails?.description || undefined,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,176 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
|
||||
import { generateGoogleXmlFeed } from "./generate-google-xml-feed";
|
||||
|
||||
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: [],
|
||||
};
|
||||
|
||||
const priceBase: GoogleFeedProductVariantFragment["pricing"] = {
|
||||
__typename: "VariantPricingInfo",
|
||||
price: {
|
||||
__typename: "TaxedMoney",
|
||||
gross: {
|
||||
__typename: "Money",
|
||||
amount: 1,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
priceUndiscounted: {
|
||||
__typename: "TaxedMoney",
|
||||
gross: {
|
||||
__typename: "Money",
|
||||
amount: 2,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("generateGoogleXmlFeed", () => {
|
||||
it("Generates feed", () => {
|
||||
const result = generateGoogleXmlFeed({
|
||||
productStorefrontUrl: "https://example.com/p/{{ variant.product.slug }}",
|
||||
shopDescription: "Description",
|
||||
shopName: "Example",
|
||||
storefrontUrl: "https://example.com",
|
||||
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
|
||||
productVariants: [
|
||||
{
|
||||
id: "id1",
|
||||
__typename: "ProductVariant",
|
||||
sku: "sku1",
|
||||
quantityAvailable: 1,
|
||||
pricing: priceBase,
|
||||
name: "Product variant",
|
||||
product: productBase,
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
id: "id2",
|
||||
__typename: "ProductVariant",
|
||||
sku: "sku2",
|
||||
quantityAvailable: 0,
|
||||
pricing: priceBase,
|
||||
name: "Product variant 2",
|
||||
product: productBase,
|
||||
attributes: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
|
||||
<rss xmlns:g=\\"http://base.google.com/ns/1.0\\" version=\\"2.0\\">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>https://example.com</link>
|
||||
<description>Description</description>
|
||||
<item>
|
||||
<g:id>sku1</g:id>
|
||||
<g:item_group_id>product-id</g:item_group_id>
|
||||
<title>Product - Product variant</title>
|
||||
<g:condition>new</g:condition>
|
||||
<g:availability>in_stock</g:availability>
|
||||
<g:product_type>Category Name</g:product_type>
|
||||
<g:google_product_category>1</g:google_product_category>
|
||||
<link>https://example.com/p/product-slug</link>
|
||||
<g:price>2.00 USD</g:price>
|
||||
<g:sale_price>1.00 USD</g:sale_price>
|
||||
</item>
|
||||
<item>
|
||||
<g:id>sku2</g:id>
|
||||
<g:item_group_id>product-id</g:item_group_id>
|
||||
<title>Product - Product variant 2</title>
|
||||
<g:condition>new</g:condition>
|
||||
<g:availability>out_of_stock</g:availability>
|
||||
<g:product_type>Category Name</g:product_type>
|
||||
<g:google_product_category>1</g:google_product_category>
|
||||
<link>https://example.com/p/product-slug</link>
|
||||
<g:price>2.00 USD</g:price>
|
||||
<g:sale_price>1.00 USD</g:sale_price>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"
|
||||
`);
|
||||
});
|
||||
|
||||
it("Generates feed with rendered urls, when provided old style URL template", () => {
|
||||
const result = generateGoogleXmlFeed({
|
||||
productStorefrontUrl: "https://example.com/p/{productSlug}",
|
||||
shopDescription: "Description",
|
||||
shopName: "Example",
|
||||
storefrontUrl: "https://example.com",
|
||||
titleTemplate: "{{ variant.product.name }} - {{ variant.name }}",
|
||||
productVariants: [
|
||||
{
|
||||
id: "id1",
|
||||
__typename: "ProductVariant",
|
||||
sku: "sku1",
|
||||
quantityAvailable: 1,
|
||||
pricing: priceBase,
|
||||
name: "Product variant",
|
||||
product: productBase,
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
id: "id2",
|
||||
__typename: "ProductVariant",
|
||||
sku: "sku2",
|
||||
quantityAvailable: 0,
|
||||
pricing: priceBase,
|
||||
name: "Product variant 2",
|
||||
product: productBase,
|
||||
attributes: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
"<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
|
||||
<rss xmlns:g=\\"http://base.google.com/ns/1.0\\" version=\\"2.0\\">
|
||||
<channel>
|
||||
<title>Example</title>
|
||||
<link>https://example.com</link>
|
||||
<description>Description</description>
|
||||
<item>
|
||||
<g:id>sku1</g:id>
|
||||
<g:item_group_id>product-id</g:item_group_id>
|
||||
<title>Product - Product variant</title>
|
||||
<g:condition>new</g:condition>
|
||||
<g:availability>in_stock</g:availability>
|
||||
<g:product_type>Category Name</g:product_type>
|
||||
<g:google_product_category>1</g:google_product_category>
|
||||
<link>https://example.com/p/product-slug</link>
|
||||
<g:price>2.00 USD</g:price>
|
||||
<g:sale_price>1.00 USD</g:sale_price>
|
||||
</item>
|
||||
<item>
|
||||
<g:id>sku2</g:id>
|
||||
<g:item_group_id>product-id</g:item_group_id>
|
||||
<title>Product - Product variant 2</title>
|
||||
<g:condition>new</g:condition>
|
||||
<g:availability>out_of_stock</g:availability>
|
||||
<g:product_type>Category Name</g:product_type>
|
||||
<g:google_product_category>1</g:google_product_category>
|
||||
<link>https://example.com/p/product-slug</link>
|
||||
<g:price>2.00 USD</g:price>
|
||||
<g:sale_price>1.00 USD</g:sale_price>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,138 @@
|
|||
import { XMLBuilder } from "fast-xml-parser";
|
||||
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
|
||||
import { productToProxy } from "./product-to-proxy";
|
||||
import { shopDetailsToProxy } from "./shop-details-to-proxy";
|
||||
import { RootConfig } from "../app-configuration/app-config";
|
||||
import { getMappedAttributes } from "./attribute-mapping";
|
||||
import { priceMapping } from "./price-mapping";
|
||||
import { renderHandlebarsTemplate } from "../handlebarsTemplates/render-handlebars-template";
|
||||
import { transformTemplateFormat } from "../handlebarsTemplates/transform-template-format";
|
||||
import { EditorJsPlaintextRenderer } from "@saleor/apps-shared";
|
||||
import { getRelatedMedia, getVariantMediaMap } from "./get-related-media";
|
||||
|
||||
interface GenerateGoogleXmlFeedArgs {
|
||||
productVariants: GoogleFeedProductVariantFragment[];
|
||||
storefrontUrl: string;
|
||||
productStorefrontUrl: string;
|
||||
titleTemplate: string;
|
||||
attributeMapping?: RootConfig["attributeMapping"];
|
||||
shopName: string;
|
||||
shopDescription?: string;
|
||||
}
|
||||
|
||||
export const generateGoogleXmlFeed = ({
|
||||
attributeMapping,
|
||||
productVariants,
|
||||
storefrontUrl,
|
||||
titleTemplate,
|
||||
productStorefrontUrl,
|
||||
shopName,
|
||||
shopDescription,
|
||||
}: GenerateGoogleXmlFeedArgs) => {
|
||||
const items = productVariants.map((variant) => {
|
||||
const attributes = getMappedAttributes({
|
||||
attributeMapping: attributeMapping,
|
||||
variant,
|
||||
});
|
||||
|
||||
const pricing = priceMapping({ pricing: variant.pricing });
|
||||
|
||||
let title = "";
|
||||
|
||||
try {
|
||||
title = renderHandlebarsTemplate({
|
||||
data: {
|
||||
variant,
|
||||
googleAttributes: attributes,
|
||||
},
|
||||
template: titleTemplate,
|
||||
});
|
||||
} catch {}
|
||||
|
||||
let link = undefined;
|
||||
|
||||
const { additionalImages, thumbnailUrl } = getRelatedMedia({
|
||||
productMedia: variant.product.media || [],
|
||||
productVariantId: variant.id,
|
||||
variantMediaMap: getVariantMediaMap({ variant }) || [],
|
||||
});
|
||||
|
||||
try {
|
||||
link = renderHandlebarsTemplate({
|
||||
data: {
|
||||
variant,
|
||||
googleAttributes: attributes,
|
||||
},
|
||||
template: transformTemplateFormat({ template: productStorefrontUrl }),
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return productToProxy({
|
||||
link,
|
||||
title: title || "",
|
||||
id: variant.product.id,
|
||||
slug: variant.product.slug,
|
||||
variantId: variant.id,
|
||||
sku: variant.sku || undefined,
|
||||
description: EditorJsPlaintextRenderer({ stringData: variant.product.description }),
|
||||
availability:
|
||||
variant.quantityAvailable && variant.quantityAvailable > 0 ? "in_stock" : "out_of_stock",
|
||||
category: variant.product.category?.name || "unknown",
|
||||
googleProductCategory: variant.product.category?.googleCategoryId || "",
|
||||
imageUrl: thumbnailUrl,
|
||||
additionalImageLinks: additionalImages,
|
||||
material: attributes?.material,
|
||||
color: attributes?.color,
|
||||
brand: attributes?.brand,
|
||||
pattern: attributes?.pattern,
|
||||
size: attributes?.size,
|
||||
...pricing,
|
||||
});
|
||||
});
|
||||
|
||||
const builder = new XMLBuilder({
|
||||
attributeNamePrefix: "@_",
|
||||
attributesGroupName: "@",
|
||||
textNodeName: "#text",
|
||||
ignoreAttributes: false,
|
||||
format: true,
|
||||
indentBy: " ",
|
||||
suppressEmptyNode: false,
|
||||
preserveOrder: true,
|
||||
});
|
||||
|
||||
const channelData = shopDetailsToProxy({
|
||||
title: shopName,
|
||||
description: shopDescription,
|
||||
storefrontUrl,
|
||||
});
|
||||
|
||||
const data = [
|
||||
{
|
||||
"?xml": [
|
||||
{
|
||||
"#text": "",
|
||||
},
|
||||
],
|
||||
":@": {
|
||||
"@_version": "1.0",
|
||||
"@_encoding": "utf-8",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
rss: [
|
||||
{
|
||||
// @ts-ignore - This is "just an object" that is transformed to XML. I don't see good way to type it, other than "any"
|
||||
channel: channelData.concat(items),
|
||||
},
|
||||
],
|
||||
":@": {
|
||||
"@_xmlns:g": "http://base.google.com/ns/1.0",
|
||||
"@_version": "2.0",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return builder.build(data);
|
||||
};
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GoogleFeedSettingsFetcher } from "./get-google-feed-settings";
|
||||
import { AppConfig } from "../app-configuration/app-config";
|
||||
|
||||
describe("GoogleFeedSettingsFetcher", () => {
|
||||
it("Fetches channel-specific config with S3 config from Metadata", async () => {
|
||||
const getMetadataMock = vi.fn().mockImplementation(async () => {
|
||||
const appConfig = new AppConfig({
|
||||
channelConfig: {
|
||||
testChannel: {
|
||||
storefrontUrls: {
|
||||
storefrontUrl: "https://example.com",
|
||||
productStorefrontUrl:
|
||||
"https://example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
|
||||
},
|
||||
},
|
||||
anotherChannel: {
|
||||
storefrontUrls: {
|
||||
storefrontUrl: "https://another.example.com",
|
||||
productStorefrontUrl:
|
||||
"https://another.example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
|
||||
},
|
||||
},
|
||||
},
|
||||
s3: {
|
||||
accessKeyId: "accessKeyId",
|
||||
bucketName: "bucketName",
|
||||
region: "region",
|
||||
secretAccessKey: "secretAccessKey",
|
||||
},
|
||||
attributeMapping: null,
|
||||
titleTemplate: "{{ variant.name }}",
|
||||
imageSize: 1024,
|
||||
});
|
||||
|
||||
return appConfig.serialize();
|
||||
});
|
||||
|
||||
const instance = new GoogleFeedSettingsFetcher({
|
||||
settingsManager: {
|
||||
get: getMetadataMock,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await instance.fetch("testChannel");
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
storefrontUrl: "https://example.com",
|
||||
productStorefrontUrl: "https://example.com/p/{{ variant.product.slug }}/v/{{ variant.slug }}",
|
||||
s3BucketConfiguration: {
|
||||
bucketName: "bucketName",
|
||||
secretAccessKey: "secretAccessKey",
|
||||
accessKeyId: "accessKeyId",
|
||||
region: "region",
|
||||
},
|
||||
attributeMapping: null,
|
||||
titleTemplate: "{{ variant.name }}",
|
||||
imageSize: 1024,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,52 @@
|
|||
import { AuthData } from "@saleor/app-sdk/APL";
|
||||
import { AppConfigMetadataManager } from "../app-configuration/app-config-metadata-manager";
|
||||
import { GraphqlClientFactory } from "../../lib/create-graphql-client";
|
||||
import { createSettingsManager } from "../../lib/metadata-manager";
|
||||
import { AppConfig } from "../app-configuration/app-config";
|
||||
|
||||
export class GoogleFeedSettingsFetcher {
|
||||
static createFromAuthData(authData: AuthData) {
|
||||
return new GoogleFeedSettingsFetcher({
|
||||
settingsManager: new AppConfigMetadataManager(
|
||||
createSettingsManager(GraphqlClientFactory.fromAuthData(authData)),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
settingsManager: Pick<AppConfigMetadataManager, "get">;
|
||||
|
||||
constructor(params: { settingsManager: Pick<AppConfigMetadataManager, "get"> }) {
|
||||
this.settingsManager = params.settingsManager;
|
||||
}
|
||||
|
||||
async fetch(channelSlug: string) {
|
||||
const configString = await this.settingsManager.get();
|
||||
|
||||
if (!configString) {
|
||||
throw new Error("App is not configured");
|
||||
}
|
||||
|
||||
const appConfig = AppConfig.parse(configString);
|
||||
const channelConfig = appConfig.getUrlsForChannel(channelSlug);
|
||||
|
||||
if (!channelConfig) {
|
||||
throw new Error("App is not configured");
|
||||
}
|
||||
|
||||
const storefrontUrl = channelConfig.storefrontUrl;
|
||||
const productStorefrontUrl = channelConfig.productStorefrontUrl;
|
||||
|
||||
if (!storefrontUrl.length || !productStorefrontUrl.length) {
|
||||
throw new Error("The application has not been configured");
|
||||
}
|
||||
|
||||
return {
|
||||
storefrontUrl,
|
||||
productStorefrontUrl,
|
||||
s3BucketConfiguration: appConfig.getS3Config(),
|
||||
attributeMapping: appConfig.getAttributeMapping(),
|
||||
titleTemplate: appConfig.getTitleTemplate(),
|
||||
imageSize: appConfig.getImageSize(),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { getRelatedMedia } from "./get-related-media";
|
||||
import { ProductMediaType } from "../../../generated/graphql";
|
||||
|
||||
describe("getRelatedMedia", () => {
|
||||
it("Returns thumbnail, when set to the product", () => {
|
||||
expect(
|
||||
getRelatedMedia({
|
||||
productMedia: [
|
||||
{
|
||||
id: "id-1",
|
||||
type: ProductMediaType.Image,
|
||||
url: "http://example.com/1.jpg",
|
||||
},
|
||||
],
|
||||
productVariantId: "variant-1",
|
||||
variantMediaMap: {},
|
||||
}),
|
||||
).toStrictEqual({
|
||||
thumbnailUrl: "http://example.com/1.jpg",
|
||||
additionalImages: [],
|
||||
});
|
||||
});
|
||||
it("Returns undefined as thumbnail URL, when product has no media", () => {
|
||||
expect(
|
||||
getRelatedMedia({
|
||||
productMedia: [],
|
||||
productVariantId: "variant-1",
|
||||
variantMediaMap: {},
|
||||
}),
|
||||
).toStrictEqual({
|
||||
thumbnailUrl: undefined,
|
||||
additionalImages: [],
|
||||
});
|
||||
});
|
||||
it("Returns thumbnail and additional images, when all images are set to the product", () => {
|
||||
expect(
|
||||
getRelatedMedia({
|
||||
productMedia: [
|
||||
{
|
||||
id: "id-1",
|
||||
type: ProductMediaType.Image,
|
||||
url: "http://example.com/1.jpg",
|
||||
},
|
||||
{
|
||||
id: "id-2",
|
||||
type: ProductMediaType.Image,
|
||||
url: "http://example.com/2.jpg",
|
||||
},
|
||||
{
|
||||
id: "id-3",
|
||||
type: ProductMediaType.Image,
|
||||
url: "http://example.com/3.jpg",
|
||||
},
|
||||
],
|
||||
productVariantId: "variant-1",
|
||||
variantMediaMap: {},
|
||||
}),
|
||||
).toStrictEqual({
|
||||
thumbnailUrl: "http://example.com/1.jpg",
|
||||
additionalImages: ["http://example.com/2.jpg", "http://example.com/3.jpg"],
|
||||
});
|
||||
});
|
||||
it("Returns filtered list of images, when some of the images are assigned to the other variants", () => {
|
||||
expect(
|
||||
getRelatedMedia({
|
||||
productMedia: [
|
||||
{
|
||||
id: "id-1",
|
||||
type: ProductMediaType.Image,
|
||||
url: "http://example.com/1.jpg",
|
||||
},
|
||||
{
|
||||
id: "id-other-variant",
|
||||
type: ProductMediaType.Image,
|
||||
url: "http://example.com/2-other-variant.jpg",
|
||||
},
|
||||
{
|
||||
id: "id-3",
|
||||
type: ProductMediaType.Image,
|
||||
url: "http://example.com/3.jpg",
|
||||
},
|
||||
],
|
||||
productVariantId: "variant-1",
|
||||
variantMediaMap: {
|
||||
"variant-2": [
|
||||
{
|
||||
id: "id-other-variant",
|
||||
type: ProductMediaType.Image,
|
||||
url: "http://example.com/2-other-variant.jpg",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toStrictEqual({
|
||||
thumbnailUrl: "http://example.com/1.jpg",
|
||||
additionalImages: ["http://example.com/3.jpg"],
|
||||
});
|
||||
});
|
||||
it("Returns only URLs to photos, when product has both Image and Video media types", () => {
|
||||
expect(
|
||||
getRelatedMedia({
|
||||
productMedia: [
|
||||
{
|
||||
id: "id-1",
|
||||
type: ProductMediaType.Image,
|
||||
url: "http://example.com/image-1.jpg",
|
||||
},
|
||||
{
|
||||
id: "id-2",
|
||||
type: ProductMediaType.Video,
|
||||
url: "http://example.com/video-2.mp4",
|
||||
},
|
||||
{
|
||||
id: "id-3",
|
||||
type: ProductMediaType.Image,
|
||||
url: "http://example.com/image-3.jpg",
|
||||
},
|
||||
],
|
||||
productVariantId: "variant-1",
|
||||
variantMediaMap: {},
|
||||
}),
|
||||
).toStrictEqual({
|
||||
thumbnailUrl: "http://example.com/image-1.jpg",
|
||||
additionalImages: ["http://example.com/image-3.jpg"],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,63 @@
|
|||
import { GoogleFeedProductVariantFragment, ProductMediaType } from "../../../generated/graphql";
|
||||
|
||||
type Media = {
|
||||
id: string;
|
||||
url: string;
|
||||
type: ProductMediaType;
|
||||
};
|
||||
|
||||
interface getRelatedMediaArgs {
|
||||
productVariantId: string;
|
||||
productMedia: Media[];
|
||||
variantMediaMap: Record<string, Media[]>;
|
||||
}
|
||||
|
||||
/*
|
||||
* Filters media related to the product variant and returns:
|
||||
* - thumbnailUrl: Product thumbnail (since Saleor has no dedicated field to the variant thumbnail)
|
||||
* - additionalImages: Url list of photos assigned to particular variant and product photos not associated with any other variant
|
||||
*/
|
||||
export const getRelatedMedia = ({
|
||||
productVariantId,
|
||||
variantMediaMap,
|
||||
productMedia,
|
||||
}: getRelatedMediaArgs) => {
|
||||
// Saleor always uses the first photo as thumbnail - even if it's assigned to the variant
|
||||
const productThumbnailUrl = productMedia[0]?.url;
|
||||
|
||||
const mediaAssignedToAnyVariant = Object.values(variantMediaMap).flat() || [];
|
||||
|
||||
const mediaAssignedToNoVariant =
|
||||
productMedia?.filter((m) => !mediaAssignedToAnyVariant.find((vm) => vm.id === m.id)) || [];
|
||||
|
||||
const mediaAssignedToVariant = variantMediaMap[productVariantId] || [];
|
||||
|
||||
const additionalImages =
|
||||
[...mediaAssignedToVariant, ...mediaAssignedToNoVariant]
|
||||
?.filter((media) => media.type === ProductMediaType.Image) // Videos are not supported by the field
|
||||
.map((media) => media.url)
|
||||
.filter((url) => url !== productThumbnailUrl) || []; // Exclude image used as thumbnail
|
||||
|
||||
return {
|
||||
thumbnailUrl: productThumbnailUrl,
|
||||
additionalImages,
|
||||
};
|
||||
};
|
||||
|
||||
interface GetVariantMediaMapArgs {
|
||||
variant: GoogleFeedProductVariantFragment;
|
||||
}
|
||||
|
||||
export const getVariantMediaMap = ({ variant }: GetVariantMediaMapArgs) => {
|
||||
return (
|
||||
variant.product.variants?.reduce((accumulator: Record<string, Array<Media>>, currentValue) => {
|
||||
const id = currentValue?.id;
|
||||
|
||||
if (!id) {
|
||||
return accumulator;
|
||||
}
|
||||
accumulator[id] = currentValue.media?.filter((m) => !!m) || [];
|
||||
return accumulator;
|
||||
}, {}) || {}
|
||||
);
|
||||
};
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { priceMapping } from "./price-mapping";
|
||||
|
||||
describe("priceMapping", () => {
|
||||
it("Return undefined, when no pricing available", () => {
|
||||
expect(
|
||||
priceMapping({
|
||||
pricing: undefined,
|
||||
})
|
||||
).toStrictEqual(undefined);
|
||||
});
|
||||
it("Return undefined, when amount is equal to 0", () => {
|
||||
expect(
|
||||
priceMapping({
|
||||
pricing: {
|
||||
priceUndiscounted: {
|
||||
gross: {
|
||||
amount: 0,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).toStrictEqual(undefined);
|
||||
});
|
||||
it("Return formatted base price, when there is no sale", () => {
|
||||
expect(
|
||||
priceMapping({
|
||||
pricing: {
|
||||
priceUndiscounted: {
|
||||
gross: {
|
||||
amount: 10.5,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).toStrictEqual({ price: "10.50 USD" });
|
||||
});
|
||||
it("Return formatted base and sale prices, when there is a sale", () => {
|
||||
expect(
|
||||
priceMapping({
|
||||
pricing: {
|
||||
priceUndiscounted: {
|
||||
gross: {
|
||||
amount: 10.5,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
price: {
|
||||
gross: {
|
||||
amount: 5.25,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).toStrictEqual({ price: "10.50 USD", salePrice: "5.25 USD" });
|
||||
});
|
||||
});
|
56
apps/data-exporter/src/modules/google-feed/price-mapping.ts
Normal file
56
apps/data-exporter/src/modules/google-feed/price-mapping.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
|
||||
|
||||
/**
|
||||
* Price format has to be altered from the en format to the one expected by Google
|
||||
* eg. 1.00 USD, 5.00 PLN
|
||||
*/
|
||||
const formatCurrency = (currency: string, amount: number) => {
|
||||
return (
|
||||
new Intl.NumberFormat("en-EN", {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: 2,
|
||||
style: "decimal",
|
||||
currencyDisplay: "code",
|
||||
currency: currency,
|
||||
}).format(amount) + ` ${currency}`
|
||||
);
|
||||
};
|
||||
|
||||
interface priceMappingArgs {
|
||||
pricing: GoogleFeedProductVariantFragment["pricing"];
|
||||
}
|
||||
|
||||
/*
|
||||
* Maps variant pricing to Google Feed format.
|
||||
* https://support.google.com/merchants/answer/6324371
|
||||
*/
|
||||
export const priceMapping = ({ pricing }: priceMappingArgs) => {
|
||||
const priceUndiscounted = pricing?.priceUndiscounted?.gross;
|
||||
|
||||
// Pricing should not be submitted empty or with 0 value
|
||||
if (!priceUndiscounted?.amount) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Price attribute is expected to be a base price
|
||||
const formattedUndiscountedPrice = formatCurrency(
|
||||
priceUndiscounted.currency,
|
||||
priceUndiscounted.amount
|
||||
);
|
||||
|
||||
const discountedPrice = pricing?.price?.gross;
|
||||
|
||||
// Return early if there is no sale
|
||||
if (!discountedPrice || discountedPrice?.amount === priceUndiscounted.amount) {
|
||||
return {
|
||||
price: formattedUndiscountedPrice,
|
||||
};
|
||||
}
|
||||
|
||||
const formattedDiscountedPrice = formatCurrency(discountedPrice.currency, discountedPrice.amount);
|
||||
|
||||
return {
|
||||
price: formattedUndiscountedPrice,
|
||||
salePrice: formattedDiscountedPrice,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,167 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { productToProxy } from "./product-to-proxy";
|
||||
|
||||
describe("productToProxy", () => {
|
||||
it("Falls back product ID, if product SKU doesn't exist", () => {
|
||||
const result = productToProxy({
|
||||
slug: "slug",
|
||||
availability: "in_stock",
|
||||
category: "1",
|
||||
condition: "new",
|
||||
id: "product-id",
|
||||
title: "title",
|
||||
variantId: "variant-id",
|
||||
additionalImageLinks: [],
|
||||
});
|
||||
|
||||
expect(result.item).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
"g:id": expect.arrayContaining([{ "#text": "variant-id" }]),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('Falls back g:condition to "new" if product condition doesn\'t exist', () => {
|
||||
const result = productToProxy({
|
||||
slug: "slug",
|
||||
availability: "in_stock",
|
||||
category: "1",
|
||||
/*
|
||||
* Missing condition field:
|
||||
* condition: "new",
|
||||
*/
|
||||
id: "product-id",
|
||||
title: "title",
|
||||
variantId: "variant-id",
|
||||
additionalImageLinks: [],
|
||||
});
|
||||
|
||||
expect(result.item).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
"g:condition": expect.arrayContaining([{ "#text": "new" }]),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Fills product description if exist", () => {
|
||||
const result = productToProxy({
|
||||
slug: "slug",
|
||||
availability: "in_stock",
|
||||
category: "1",
|
||||
condition: "new",
|
||||
id: "product-id",
|
||||
title: "title",
|
||||
variantId: "variant-id",
|
||||
description: "Product description",
|
||||
additionalImageLinks: [],
|
||||
});
|
||||
|
||||
expect(result.item).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
"g:description": expect.arrayContaining([{ "#text": "Product description" }]),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Fills google product category if exist", () => {
|
||||
const result = productToProxy({
|
||||
slug: "slug",
|
||||
availability: "in_stock",
|
||||
category: "1",
|
||||
condition: "new",
|
||||
googleProductCategory: "1",
|
||||
id: "product-id",
|
||||
title: "title",
|
||||
variantId: "variant-id",
|
||||
additionalImageLinks: [],
|
||||
});
|
||||
|
||||
expect(result.item).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
"g:google_product_category": expect.arrayContaining([{ "#text": "1" }]),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Adds link section, when url is provided", () => {
|
||||
const result = productToProxy({
|
||||
slug: "slug",
|
||||
availability: "in_stock",
|
||||
category: "1",
|
||||
condition: "new",
|
||||
googleProductCategory: "1",
|
||||
id: "product-id",
|
||||
title: "title",
|
||||
variantId: "variant-id",
|
||||
link: "https://example.com/p/product-id",
|
||||
additionalImageLinks: [],
|
||||
});
|
||||
|
||||
expect(result.item).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
link: expect.arrayContaining([
|
||||
{
|
||||
"#text": "https://example.com/p/product-id",
|
||||
},
|
||||
]),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Adds g:image_link if imageUrl exist in product", () => {
|
||||
const result = productToProxy({
|
||||
slug: "slug",
|
||||
availability: "in_stock",
|
||||
category: "1",
|
||||
condition: "new",
|
||||
googleProductCategory: "1",
|
||||
id: "product-id",
|
||||
title: "title",
|
||||
variantId: "variant-id",
|
||||
imageUrl: "https://image.example.com",
|
||||
additionalImageLinks: [],
|
||||
});
|
||||
|
||||
expect(result.item).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
"g:image_link": expect.arrayContaining([{ "#text": "https://image.example.com" }]),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("Adds g:price if product price exists", () => {
|
||||
const result = productToProxy({
|
||||
slug: "slug",
|
||||
availability: "in_stock",
|
||||
category: "1",
|
||||
condition: "new",
|
||||
googleProductCategory: "1",
|
||||
id: "product-id",
|
||||
title: "title",
|
||||
variantId: "variant-id",
|
||||
imageUrl: "https://image.example.com",
|
||||
price: "50.00 USD",
|
||||
additionalImageLinks: [],
|
||||
});
|
||||
|
||||
expect(result.item).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
"g:price": expect.arrayContaining([{ "#text": "50.00 USD" }]),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
179
apps/data-exporter/src/modules/google-feed/product-to-proxy.ts
Normal file
179
apps/data-exporter/src/modules/google-feed/product-to-proxy.ts
Normal file
|
@ -0,0 +1,179 @@
|
|||
import { GoogleProxyItem, ProductEntry } from "./types";
|
||||
|
||||
export const productToProxy = (p: ProductEntry) => {
|
||||
const item: GoogleProxyItem[] = [
|
||||
{
|
||||
"g:id": [
|
||||
{
|
||||
"#text": p.sku || p.variantId,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"g:item_group_id": [
|
||||
{
|
||||
"#text": p.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: [
|
||||
{
|
||||
"#text": p.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"g:condition": [
|
||||
{
|
||||
"#text": p.condition || "new",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
"g:availability": [
|
||||
{
|
||||
"#text": p.availability,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"g:product_type": [
|
||||
{
|
||||
"#text": p.category,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (p.description?.length) {
|
||||
item.push({
|
||||
"g:description": [
|
||||
{
|
||||
"#text": p.description,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This field is optional and Google automatically match category if not has been provided
|
||||
*
|
||||
* https://support.google.com/merchants/answer/6324436?hl=en
|
||||
*/
|
||||
|
||||
if (p.googleProductCategory?.length) {
|
||||
item.push({
|
||||
"g:google_product_category": [
|
||||
{
|
||||
"#text": p.googleProductCategory,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.link?.length) {
|
||||
item.push({
|
||||
link: [
|
||||
{
|
||||
"#text": p.link,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.imageUrl?.length) {
|
||||
item.push({
|
||||
"g:image_link": [
|
||||
{
|
||||
"#text": p.imageUrl,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
for (const additional_image of p.additionalImageLinks) {
|
||||
item.push({
|
||||
"g:additional_image_link": [
|
||||
{
|
||||
"#text": additional_image,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.price?.length) {
|
||||
item.push({
|
||||
"g:price": [
|
||||
{
|
||||
"#text": p.price,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.salePrice?.length) {
|
||||
item.push({
|
||||
"g:sale_price": [
|
||||
{
|
||||
"#text": p.salePrice,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.material) {
|
||||
item.push({
|
||||
"g:material": [
|
||||
{
|
||||
"#text": p.material,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.brand) {
|
||||
item.push({
|
||||
"g:brand": [
|
||||
{
|
||||
"#text": p.brand,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.color) {
|
||||
item.push({
|
||||
"g:color": [
|
||||
{
|
||||
"#text": p.color,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.size) {
|
||||
item.push({
|
||||
"g:size": [
|
||||
{
|
||||
"#text": p.size,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.pattern) {
|
||||
item.push({
|
||||
"g:pattern": [
|
||||
{
|
||||
"#text": p.pattern,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
item,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { shopDetailsToProxy } from "./shop-details-to-proxy";
|
||||
|
||||
describe("shopDetailsToProxy", () => {
|
||||
it("Renders title and storefront url", () => {
|
||||
expect(
|
||||
shopDetailsToProxy({
|
||||
description: "Shop description",
|
||||
storefrontUrl: "https://example.com",
|
||||
title: "Shop title",
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"title": [
|
||||
{
|
||||
"#text": "Shop title",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"link": [
|
||||
{
|
||||
"#text": "https://example.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"description": [
|
||||
{
|
||||
"#text": "Shop description",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("Renders without description if not provided", () => {
|
||||
expect(
|
||||
shopDetailsToProxy({
|
||||
storefrontUrl: "https://example.com",
|
||||
title: "Shop title",
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"title": [
|
||||
{
|
||||
"#text": "Shop title",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"link": [
|
||||
{
|
||||
"#text": "https://example.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,32 @@
|
|||
import { GoogleProxyItem, ShopDetailsEntry } from "./types";
|
||||
|
||||
export const shopDetailsToProxy = ({ title, storefrontUrl, description }: ShopDetailsEntry) => {
|
||||
const data: GoogleProxyItem[] = [
|
||||
{
|
||||
title: [
|
||||
{
|
||||
"#text": title,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
link: [
|
||||
{
|
||||
"#text": storefrontUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (description) {
|
||||
data.push({
|
||||
description: [
|
||||
{
|
||||
"#text": description,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
30
apps/data-exporter/src/modules/google-feed/types.ts
Normal file
30
apps/data-exporter/src/modules/google-feed/types.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
export type ProductEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
sku?: string;
|
||||
slug: string;
|
||||
variantId: string;
|
||||
description?: string;
|
||||
link?: string;
|
||||
imageUrl?: string;
|
||||
additionalImageLinks: string[];
|
||||
condition?: "new" | "refurbished" | "used";
|
||||
price?: string;
|
||||
salePrice?: string;
|
||||
googleProductCategory?: string;
|
||||
availability: "in_stock" | "out_of_stock" | "preorder" | "backorder";
|
||||
category: string;
|
||||
material?: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
brand?: string;
|
||||
pattern?: string;
|
||||
};
|
||||
|
||||
export type ShopDetailsEntry = {
|
||||
title: string;
|
||||
storefrontUrl: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type GoogleProxyItem = Record<string, Array<Record<string, string>>>;
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { renderHandlebarsTemplate } from "./render-handlebars-template";
|
||||
|
||||
describe("renderHandlebarsTemplate", () => {
|
||||
it("Returns formatted string, when valid template and data are provided", () => {
|
||||
expect(
|
||||
renderHandlebarsTemplate({
|
||||
data: { name: "John", hobby: "fishing" },
|
||||
template: "Hello, my name is {{ name }}. My hobby is {{ hobby }}.",
|
||||
})
|
||||
).toStrictEqual("Hello, my name is John. My hobby is fishing.");
|
||||
});
|
||||
it("Throws an error, when provided template is not valid", () => {
|
||||
expect(() =>
|
||||
renderHandlebarsTemplate({
|
||||
data: { name: "John", hobby: "fishing" },
|
||||
template: "Hello, my name is {{ name }}. My hobby is {{ hobby", // no closing brackets to trigger an error
|
||||
})
|
||||
).toThrowError("Could not render the template");
|
||||
});
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
// TODO: PF and EAM apps use handlebars. Extract this module to shared package.
|
||||
|
||||
import Handlebars from "handlebars";
|
||||
import { createLogger } from "@saleor/apps-shared";
|
||||
|
||||
const logger = createLogger({
|
||||
name: "renderHandlebarsTemplate",
|
||||
});
|
||||
|
||||
interface RenderHandlebarsTemplateArgs {
|
||||
template: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const renderHandlebarsTemplate = ({ template, data }: RenderHandlebarsTemplateArgs) => {
|
||||
try {
|
||||
const compiledTemplate = Handlebars.compile(template);
|
||||
|
||||
return compiledTemplate(data);
|
||||
} catch (error) {
|
||||
logger.error(error, "Template compilation failed");
|
||||
throw new Error("Could not render the template");
|
||||
}
|
||||
};
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { transformTemplateFormat } from "./transform-template-format";
|
||||
|
||||
describe("templateTransformer", () => {
|
||||
it("Returns unchanged string, when no v1 tags are found", () => {
|
||||
const template = "No changes, {unknownTag}";
|
||||
|
||||
expect(transformTemplateFormat({ template })).toBe(template);
|
||||
});
|
||||
it("Transforms tags to handlebars format, when template contain any", () => {
|
||||
const oldTemplate = "Test: {productId} {productName} {productSlug} {variantId} {variantName}";
|
||||
const handlebarsTemplate =
|
||||
"Test: {{ variant.product.id }} {{ variant.product.name }} {{ variant.product.slug }} {{ variant.id }} {{ variant.name }}";
|
||||
|
||||
expect(transformTemplateFormat({ template: oldTemplate })).toBe(handlebarsTemplate);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,15 @@
|
|||
interface transformTemplateFormatArgs {
|
||||
template: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* Transform simple templates to handlebars format.
|
||||
* Example: `{productID}` will be transformed to `{{ variant. product.id}}`.
|
||||
*/
|
||||
export const transformTemplateFormat = ({ template }: transformTemplateFormatArgs) =>
|
||||
template
|
||||
.replace("{productId}", "{{ variant.product.id }}")
|
||||
.replace("{productSlug}", "{{ variant.product.slug }}")
|
||||
.replace("{productName}", "{{ variant.product.name }}")
|
||||
.replace("{variantId}", "{{ variant.id }}")
|
||||
.replace("{variantName}", "{{ variant.name }}");
|
|
@ -0,0 +1,33 @@
|
|||
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
|
||||
|
||||
export class CacheConfigurator {
|
||||
private metadataKeyPrefix = "cursor-cache-";
|
||||
|
||||
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
|
||||
|
||||
private constructKey(channel: string) {
|
||||
return this.metadataKeyPrefix + channel;
|
||||
}
|
||||
|
||||
get({ channel }: { channel: string }): Promise<string[] | undefined> {
|
||||
return this.metadataManager.get(this.constructKey(channel), this.saleorApiUrl).then((data) => {
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
throw new Error("Invalid metadata value, can't be parsed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
set({ channel, value }: { channel: string; value: string[] }): Promise<void> {
|
||||
return this.metadataManager.set({
|
||||
key: this.constructKey(channel),
|
||||
value: JSON.stringify(value),
|
||||
domain: this.saleorApiUrl,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import { createLogger } from "@saleor/apps-shared";
|
||||
import { CacheConfigurator } from "./cache-configurator";
|
||||
import { createSettingsManager } from "../../lib/metadata-manager";
|
||||
import { getCursors } from "../google-feed/fetch-product-data";
|
||||
import { Client } from "urql";
|
||||
|
||||
interface UpdateCacheForConfigurationsArgs {
|
||||
client: Client;
|
||||
saleorApiUrl: string;
|
||||
channelsSlugs: string[];
|
||||
}
|
||||
|
||||
export const updateCacheForConfigurations = async ({
|
||||
client,
|
||||
channelsSlugs,
|
||||
saleorApiUrl,
|
||||
}: UpdateCacheForConfigurationsArgs) => {
|
||||
const logger = createLogger({ saleorApiUrl: saleorApiUrl });
|
||||
|
||||
logger.debug("Updating the cursor cache");
|
||||
|
||||
const cache = new CacheConfigurator(createSettingsManager(client), saleorApiUrl);
|
||||
|
||||
const cacheUpdatePromises = channelsSlugs.map(async (channel) => {
|
||||
const cursors = await getCursors({ client, channel });
|
||||
|
||||
await cache.set({ channel, value: cursors });
|
||||
});
|
||||
|
||||
await Promise.all(cacheUpdatePromises);
|
||||
|
||||
logger.debug("Cursor cache updated");
|
||||
};
|
|
@ -0,0 +1,41 @@
|
|||
import { GraphqlClientFactory } from "../../lib/create-graphql-client";
|
||||
import { updateCacheForConfigurations } from "./update-cache-for-configurations";
|
||||
import { AuthData } from "@saleor/app-sdk/APL";
|
||||
import {
|
||||
ProductVariantWebhookPayloadFragment,
|
||||
ProductWebhookPayloadFragment,
|
||||
} from "../../../generated/graphql";
|
||||
import { NextApiResponse } from "next";
|
||||
|
||||
type ChannelFragment =
|
||||
| Pick<ProductWebhookPayloadFragment, "channel" | "channelListings">
|
||||
| Pick<ProductVariantWebhookPayloadFragment, "channel" | "channelListings">;
|
||||
|
||||
export const updateCacheOnWebhook = async ({
|
||||
channels,
|
||||
authData,
|
||||
res,
|
||||
}: {
|
||||
authData: AuthData;
|
||||
channels: ChannelFragment;
|
||||
res: NextApiResponse;
|
||||
}) => {
|
||||
const client = GraphqlClientFactory.fromAuthData(authData);
|
||||
|
||||
const channelsSlugs = [
|
||||
channels.channel,
|
||||
...(channels.channelListings?.map((cl) => cl.channel.slug) ?? []),
|
||||
].filter((c) => c) as string[];
|
||||
|
||||
if (channelsSlugs.length === 0) {
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
await updateCacheForConfigurations({
|
||||
channelsSlugs,
|
||||
client,
|
||||
saleorApiUrl: authData.saleorApiUrl,
|
||||
});
|
||||
|
||||
return res.status(200).end();
|
||||
};
|
32
apps/data-exporter/src/modules/trpc/middlewares.ts
Normal file
32
apps/data-exporter/src/modules/trpc/middlewares.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { createLogger } from "@saleor/apps-shared";
|
||||
import { middleware } from "./trpc-server";
|
||||
|
||||
export const attachLogger = middleware(async ({ ctx, next, type, path }) => {
|
||||
const loggerName = `tRPC ${type} ${path.replace(/\./g, "/")}`;
|
||||
|
||||
const logger = createLogger({
|
||||
name: loggerName,
|
||||
requestType: type,
|
||||
path,
|
||||
saleorApiUrl: ctx.saleorApiUrl,
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
logger.debug(`Requested received`);
|
||||
|
||||
const result = await next({
|
||||
ctx: {
|
||||
logger,
|
||||
},
|
||||
});
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
if (result.ok) {
|
||||
logger.debug({ durationMs }, `Response successful`);
|
||||
} else {
|
||||
logger.debug({ durationMs }, `Response with error`);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue