Compare commits

...

228 commits

Author SHA1 Message Date
4b12982597 add changeset 2023-09-30 22:22:32 +02:00
292a5bdb0c emails-and-messages redis apl support 2023-09-30 22:03:02 +02:00
Lukasz Ostrowski
653b98df86
Release apps (#1041)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-09-28 16:02:35 +02:00
Krzysztof Wolski
ae6dbb125b
Remove cache update on product webhooks (#1036)
* Product feed - change max function exec time to 5 minutes

* Remove cache update on product webhooks

* Add webhooks migration

---------

Co-authored-by: Lukasz Ostrowski <lukasz.ostrowski@saleor.io>
2023-09-27 12:56:12 +02:00
Lukasz Ostrowski
6948fe41ca
Update CODEOWNERS 2023-09-23 10:49:49 +02:00
Lukasz Ostrowski
2a22cf47f2
🚀 Release apps (#988)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-09-18 10:20:01 +02:00
Przemysław Łada
927d2b3bb6
Use full commit hash when referencing GitHub Action (#1002)
* Use full commit hash when referencing GitHub Action

* Use full commit hash in changeset-checker
2023-09-18 09:28:24 +02:00
dependabot[bot]
5dee65ad2c
[skip ci]: Bump the codegen group with 1 update (#990)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>
2023-09-13 11:04:10 +02:00
Przemysław Łada
c6db32b40e
Fix broken pnpm lock (#1009) 2023-09-08 20:38:48 +02:00
dependabot[bot]
5d3d81d5c2
[skip ci]: Bump @hookform/resolvers from 2.9.11 to 3.3.1 (#993)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lukasz Ostrowski <lukasz.ostrowski@saleor.io>
2023-09-08 15:35:57 +02:00
Przemysław Łada
52c46987f3
Replace assign-pr-creator-action with GitHub CLI (#1008) 2023-09-08 15:35:32 +02:00
Lukasz Ostrowski
1982d81f17
Extend pricing data in Algolia (#998)
Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>
2023-09-08 14:05:37 +02:00
Krzysztof Wolski
767d0be722
Add mandatory check for the changeset (#1000)
* Add mandatory check for the changeset

* Skip check on label

* Fx typo and add synchronize event
2023-09-08 14:04:34 +02:00
dependabot[bot]
2951fb3ab6
[skip ci]: Bump jotai from 2.0.0 to 2.4.2 (#1007)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lukasz Ostrowski <lukasz.ostrowski@saleor.io>
2023-09-08 10:40:34 +00:00
Krzysztof Wolski
7e0755ec9e
Webhook helpers (#949)
* WIP

* Added script and making implementation more roboust

* Added rollback on issues with the migration

* Cleanup the code

* Use allSettled instead of all

* Do not check spelling in schema files.
Schema is pulled from the API and is not controlled by our team

* Update the pkg json

* Fix typo on log message

* Add dedupe to the ignored words.
Its used by codegen

* Add changesets
2023-09-07 13:04:23 +02:00
Adrian Pilarczyk
a32fe7caf4
feat: taxes client logs (#944)
* feat: 🚧 add skeleton logs page

* feat:  add ClientLogsMetadataRepository

* feat:  add AvataxClientLogger and router

* feat:  implement avatax client logs in order confirmed

* feat: 🚧 add skeleton ui for logs

* feat:  add avatax client logger

* refactor: ♻️ move breadcrumbs to page lvl

* feat:  logger per config

* feat:  add logs to remaining events

* refactor: ♻️ pass clientLogger from webhook service

* feat:  add taxjar logger

* test:  add tests for logs push

* feat:  add getAll test

* feat:  add missing taxjar page

* refactor: ♻️ move unshiftItemToLimitedArray to log-utils and test

* fix: 🐛 label

* refactor: 💄 styles

* build: 👷 changeset

* fix: 🐛 typo

* refactor: ♻️ address feedback on backend side

* refactor: ♻️ adjust UI to merging of avatax & taxjar logs

* feat:  make client logs table box scrollable

* fix: 🐛 size of container

* fix: 🐛 loading on isRefetching, not isFetching

* fix: 🐛 no lines early error
2023-09-07 10:42:54 +02:00
dependabot[bot]
4a2275e999
[skip ci]: Bump the trpc group with 4 updates (#987)
Bumps the trpc group with 4 updates: [@trpc/client](https://github.com/trpc/trpc/tree/HEAD/packages/client), [@trpc/next](https://github.com/trpc/trpc/tree/HEAD/packages/next), [@trpc/react-query](https://github.com/trpc/trpc/tree/HEAD/packages/react) and [@trpc/server](https://github.com/trpc/trpc/tree/HEAD/packages/server).


Updates `@trpc/client` from 10.34.0 to 10.38.1
- [Release notes](https://github.com/trpc/trpc/releases)
- [Commits](https://github.com/trpc/trpc/commits/v10.38.1/packages/client)

Updates `@trpc/next` from 10.34.0 to 10.38.1
- [Release notes](https://github.com/trpc/trpc/releases)
- [Commits](https://github.com/trpc/trpc/commits/v10.38.1/packages/next)

Updates `@trpc/react-query` from 10.34.0 to 10.38.1
- [Release notes](https://github.com/trpc/trpc/releases)
- [Commits](https://github.com/trpc/trpc/commits/v10.38.1/packages/react)

Updates `@trpc/server` from 10.34.0 to 10.38.1
- [Release notes](https://github.com/trpc/trpc/releases)
- [Commits](https://github.com/trpc/trpc/commits/v10.38.1/packages/server)

---
updated-dependencies:
- dependency-name: "@trpc/client"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: trpc
- dependency-name: "@trpc/next"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: trpc
- dependency-name: "@trpc/react-query"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: trpc
- dependency-name: "@trpc/server"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: trpc
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-06 15:24:18 +02:00
dependabot[bot]
a5f226bdef
[skip ci]: Bump the rtl group with 1 update (#991)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-06 14:49:20 +02:00
Lukasz Ostrowski
a0a607ce99
Update app-sdk to 0.43.1 (#956) 2023-09-06 12:26:04 +00:00
Lukasz Ostrowski
6ac7799d72
Update dependabot.yaml 2023-09-06 14:23:31 +02:00
Lukasz Ostrowski
4aee4e11f8
Invoices, Klaviyo: Refactor to shared components (#989) 2023-09-06 12:22:54 +00:00
Lukasz Ostrowski
86bc946b3e
Update README.md (#962) 2023-09-06 14:22:40 +02:00
Lukasz Ostrowski
1033b93747
Update pnpm version expected by the monorepo (#985) 2023-09-06 10:50:07 +02:00
Lukasz Ostrowski
2e296996cb
Update sentry and add Sentry CLI to the workspace (#986) 2023-09-06 10:24:45 +02:00
Lukasz Ostrowski
068e529cfe
Update dependabot.yaml 2023-09-06 09:59:31 +02:00
dependabot[bot]
3bfcfa5b01
[skip ci]: Bump prettier from 3.0.1 to 3.0.3 (#983)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-06 09:47:49 +02:00
Lukasz Ostrowski
6688a55102
Update PULL_REQUEST_TEMPLATE.md 2023-09-05 19:00:46 +02:00
dependabot[bot]
4efebde36d
[skip ci]: Bump eslint-plugin-react from 7.32.2 to 7.33.2 (#979)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-05 11:47:00 +02:00
Lukasz Ostrowski
9f76a2f1f8
Update dependabot.yaml 2023-09-05 09:47:19 +02:00
Lukasz Ostrowski
627712bef3
Update dependabot.yaml 2023-09-05 09:12:35 +02:00
Lukasz Ostrowski
8e4941fd2a
🚀 Release apps (#942)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-09-05 08:58:31 +02:00
Lukasz Ostrowski
55315b89f3
Fix Search webhooks toggling (#961)
* Fix Search webhooks toggling

* fix test
2023-09-04 20:13:58 +02:00
Lukasz Ostrowski
30140eea06
Improved some spellings and add some words to allow list (#959) 2023-09-04 11:53:44 +02:00
Lukasz Ostrowski
e8660e8bb9
Extract shared packages (#948)
* [skip ci] tRPC shared package

* [skip ci] tRPC shared package - fix

* [skip ci] shared package - app sections

* [skip ci] segment - implement shared components

* [skip ci] extract theme synchronizer

* extract components and implement them in apps

* cms - extract shared packages

* Fix imports

* remove urql from peer deps
2023-09-04 11:30:57 +02:00
Adrian Pilarczyk
ceddcf96eb
refactor: ♻️ omit more in cspell config (#957) 2023-09-04 11:21:28 +02:00
Lukasz Ostrowski
1e3c08c029
Algolia fields filtering (#946)
* wip

* crud for algolia fields settings

* add ui form fields confiugraion

* adjust app to new config

* filter mapping with fields

* fix lang

* fix lang
2023-09-01 17:01:41 +02:00
Krzysztof Wolski
23e71bc7a2
Fix save button label (#950) 2023-09-01 14:08:10 +02:00
Krzysztof Wolski
261e9d1a0c
Product Feed: Add configurable image size and additional photos (#915)
* Add configurable image size and additional photos

* CR fixes

* CSpell fixes
2023-09-01 13:35:02 +02:00
Adrian Pilarczyk
0aa1d12cc4
feat: ⚗️ add check-spelling.yml gh action (#947)
* feat: ⚗️ add check-spelling.yml gh action

* chore: ⚗️ make typos to test gh action

* feat:  add cspell config

* fix: 🐛 typos

* feat: ⚗️ test verbose

* Revert "feat: ⚗️ test verbose"

This reverts commit 3bf36f5a29b6af2ca969a92f0bc61e12e89d8a44.

* feat:  add cspell locally

scripts for all files & lint-staged

* feat:  add new words to dictionary in cspell.json

* refactor: 🚚 cspell.json -> cspell.config.json

* Revert "refactor: 🚚 cspell.json -> cspell.config.json"

This reverts commit b5c96f6909cf5f0ab1173255ef7ded7e360ca83f.

* Revert "feat:  add new words to dictionary in cspell.json"

This reverts commit 69d8bdd3f9ed19ab07044e3e986c8d0b06114e43.

* feat:  add ignorePaths

* feat: add more ignore

* feat:  add words to cspell.json

* refactor: ♻️ change dictionary in cspell.json

* refactor: make words capitalized

* fix: 🐛 add md/mdx to check-spelling
2023-09-01 11:44:20 +02:00
Lukasz Ostrowski
ed30a818e3
Refactors: webhooks, config (#941) 2023-08-30 12:17:44 +02:00
Lukasz Ostrowski
056209486c
Release apps (#934)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-30 10:43:22 +02:00
Lukasz Ostrowski
0f84985c98
Add tRPC to search app (#940)
* Add tRPC to search app

* Implemented configuration endpoint in trpc

* replace settings manager to use shared factory

* replace configuration calls to trpc
2023-08-29 22:53:51 +02:00
Krzysztof Wolski
2a1385bab1
Search: Fix stale variants after product removal, add indices setup and product descriptions (#932)
* Move plaintext renderer to shared package and use it for Algolia documents

* Fix stale variants after product removal, add indices setup

* Search: webhooks migration script (#936)

* Add webhook recreation script

* Add changeset
2023-08-29 13:19:31 +02:00
Lukasz Ostrowski
a81f061fcf
update macaw (#925) 2023-08-29 10:47:50 +02:00
Lukasz Ostrowski
d9d0f64a01
Fix CMS modal bg (#935) 2023-08-28 21:51:15 +02:00
Krzysztof Wolski
fcc37e7c7e
Remove clsx package which is no longer used (#899)
* Remove clsx package which is no longer used

* Add changeset

* Remove clsx from data importer

* Remove unused clsx package
2023-08-28 19:23:11 +02:00
Adrian Pilarczyk
254cd4c9a3
Fix typo in Segment (#933)
* Fix typo

* build: 👷 add changeset
2023-08-28 13:41:54 +02:00
Lukasz Ostrowski
17925f2115
Release apps (#917)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-28 08:19:32 +02:00
Lukasz Ostrowski
e9378e7af7
fix payloadcms fields (#924)
Co-authored-by: Adrian Pilarczyk <admin@peelar.dev>
2023-08-25 15:33:18 +02:00
Adrian Pilarczyk
34efd39dcf
fix customer code calculate taxes (#922)
* fix: 🐛 calculate taxes customerCode

* build: 👷 changeset

* refactor: ♻️ address feedback

* feat:  add migration

* Empty-Commit
2023-08-25 14:50:47 +02:00
Lukasz Ostrowski
6f1c5c9436
Add Payload CMS (#905) 2023-08-25 11:34:28 +02:00
Adrian Pilarczyk
45ed9fb444
fix checkout calculate taxes (#919)
* fix: 🐛 value of customerCode in calculateTaxes

* build: 👷 add changeset

* fix: 🐛 tests
2023-08-25 11:19:53 +02:00
Adrian Pilarczyk
4a635620c4
feat: improve migration scripts visibility (#918)
* feat:  add emojis

* refactor: 🚚 migration file
2023-08-24 11:56:28 +02:00
Adrian Pilarczyk
783bd5ec55
Complete migration to OrderConfirmed flow (#916)
* refactor: 🔥 order-created & order-fulfilled code

* build: 👷 add changeset

* fix: 🐛 fix build
2023-08-24 10:32:31 +02:00
Lukasz Ostrowski
3bd2d33d97
🚀 Release apps (#897)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-24 08:28:40 +02:00
Lukasz Ostrowski
6ea3e38bd8
Changed events mapping in Segment (#914) 2023-08-23 13:42:38 +02:00
Adrian Pilarczyk
be761b251e
chore: split credentials and settings (#886)
* feat:  grey out disabled links

* chore: 🚚 move fields to new avatax settings fragment

* build: 👷 add changeset

* refactor: 🚚 move companyCode to credentials

* refactor: ♻️ make helper texts more accurate

* refactor: 🚚 Avatax -> AvaTax
2023-08-21 10:28:43 +02:00
Lukasz Ostrowski
c50797e836
add metadata manager to shared package (#901) 2023-08-17 11:53:03 +02:00
Lukasz Ostrowski
8b3d9617e6
update macaw to pre-118 (#898) 2023-08-17 09:00:47 +02:00
Lukasz Ostrowski
a2cac7849b Fix segment changeset 2023-08-16 16:31:53 +02:00
Lukasz Ostrowski
895004dd55
Release apps (#885)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-16 15:31:23 +02:00
Lukasz Ostrowski
7d574c70c9
Introduce Twilio Segment App (#881)
* bootstrap segment app from cms app

* tracking logic

* schema configuratioj

* config form

* form saving

* Connected order_created

* add more fields

* Order updated webhook

* Order cancelled event

* order refunded webhook

* order fully paid

* update deps

* error handling

* logger

* logs

* Add app to workflow

* add icon

* remove breadcrumbs

* Change 400 to 200 response if payload is invalid

* link to docs

* change semgent.io to segment
2023-08-16 15:30:46 +02:00
Krzysztof Wolski
69fe973121
Search: Add media and stock flag (#874)
* Add media and stock flag

* Add UI for updating the queries

* Add update info to changelog
2023-08-16 14:08:07 +02:00
Krzysztof Wolski
3002354c5e
Improve logging on tRPC routes (#884) 2023-08-10 19:09:06 +02:00
Lukasz Ostrowski
7f0d8d24a3
Release apps (#883)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-10 17:49:06 +02:00
Krzysztof Wolski
bb504d95aa
Product feed: Fix title template form (#882)
* Improve logging

* Fix error on parsing metadata created in the previous version

* Fix title template form

* Add changesets

* Use info level instead of debug
2023-08-10 17:37:59 +02:00
Lukasz Ostrowski
7b19ab44c3
🚀 Release apps (#865)
* Release apps

* Update CHANGELOG.md

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Adrian Pilarczyk <admin@peelar.dev>
2023-08-10 14:03:02 +02:00
Adrian Pilarczyk
416c92fb6c
feat: change the flow from OrderCreated to OrderConfirmed (#826)
* refactor: ♻️ extract order-metadata-manager

* feat: 🚧 add basic boilerplate

* feat:  add readExternalIdFromOrderMetadata

* Revert "feat:  add readExternalIdFromOrderMetadata"

This reverts commit a78d9d4597672f8605cf998a9f784aebaab27de1.

* feat:  add order-cancelled avatax adapter

* test:  add tests for AvataxOrderCancelledPayloadTransformer

* refactor: avataxId instead of externalId

* refactor: ♻️ split up webhook response

* build: ⬆️ upgrade avatax

* refactor: ♻️ extend logging in webhook response errors

* fix: 🐛 split privateMetadata and publicMetadata

* fix: 🐛 use "DEFAULT" value of companyCode for commit to work

* fix: ⚗️ fix voidTransaction type

* refactor: 🚚 order_created -> order_confirmed

* fix: 🐛 change voidReason

* build: 👷 add changeset

* refactor: 🔥 order_fulfilled webhook

* feat: Avatax metadata tax calculation date (#843)

* feat:  add metadata tax calculation date

* build: 👷 add changeset

* feat: Avatax metadata document code (#844)

* feat:  provide document code through metadata field

* build: 👷 add changeset

* refactor: ♻️ fallback to default company code for migration

* refactor: ♻️ patch order-created files and add deprecation note

* Revert "refactor: 🔥 order_fulfilled webhook"

This reverts commit fd098642735ae9d62e3a876088226bd0f108afd6.

* refactor: ♻️ patch order-fulfilled files and add deprecation note

* fix: 🐛 bring back deprecated webhooks to manifest

* feat: ⚗️ add AppWebhookMigrator (#850)

* refactor: 🚚 order_created -> order_confirmed

* refactor: 🔥 order_fulfilled webhook

* feat: ⚗️ add AppWebhookMigrator

* feat:  add mode to migrator

* feat:  add draft of run-report and migrateWebhook method

* refactor: ♻️ address feedback

* feat:  add tests and new structure

* refactor: 🔥 util

* feat:  add enable/disable webhook rollback flow

* refactor: ♻️ modify the taxes-migration flow

* refactor: ♻️ generalize document code & date resolver

* chore: 🗃️ add run-migration

* chore: 💡 update comments about migration flow

* fix: 🐛 slice document code

* refactor: ♻️ try/catch at the top level

* chore: 💡 add comments

* Update shiny-meals-wait.md

* Update soft-steaks-know.md

* Update soft-steaks-know.md

* fix:  fix test

* feat:  change createTransaction to createOrAdjustTransaction

this feature grants idempotency of the transaction flow

* feat:  add number field to OrderConfirmed payload

* chore: 💡 add deprecation comment to metadata method

* docs: 📝 add todo comment to refactor sumPayloadLines

* feat:  add resolveStringOrThrow and use it for email

* fix: 🐛 add missing number to mock
2023-08-10 13:08:20 +02:00
Lukasz Ostrowski
c7b3d35f5d
Update README.md 2023-08-09 12:23:31 +02:00
Krzysztof Wolski
b8c528397a
Use a proper capitalization of the SendGrid name (#869) 2023-08-08 14:18:11 +02:00
Lukasz Ostrowski
fe767a494b
CMS - change manifest name (#862) 2023-08-08 09:33:04 +02:00
Lukasz Ostrowski
a951af938e
Release apps (#842)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-07 17:02:13 +02:00
Krzysztof Wolski
6a84b4b7e0
Product feed: Use Handlebars for title and URL formatting (#859)
* Use handlebars templates for title and URLs

* Add changesets

* Allow chaining on set methods

* Fix failing test

* Use getValues instead of handle submit

* Narrow down type for data input
2023-08-07 16:33:01 +02:00
Krzysztof Wolski
0b0297eeb8
Product feed: Product Pricing update (#840)
* Add attribute mapping

* Improve release note

* Log the error

* Add pattern attribute

* Add group ID

* Update the item pricing
2023-08-04 13:59:40 +02:00
Lukasz Ostrowski
261957fda4
Update dev dependencies (#845)
* update prettier and syncpack

* update turbo

* Update various dev deps

* improve syncpack config

* remove c8 reporter from invoices

* fix test packages

* Fix invocies test
2023-08-04 08:39:44 +02:00
Krzysztof Wolski
fc5e6396d2
Product Feed: Add group ID (#839)
* Add attribute mapping

* Improve release note

* Log the error

* Add pattern attribute

* Add group ID
2023-08-03 11:14:56 +02:00
Krzysztof Wolski
aece07338e
Product Feed: Add attribute mapping (#838)
* Add attribute mapping

* Improve release note

* Log the error

* Add pattern attribute
2023-08-03 10:27:14 +02:00
Lukasz Ostrowski
8e33f59e0c
Release apps (#835)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-07-31 12:19:17 +02:00
Lukasz Ostrowski
2cb7e5edee
Improve Search/Algolia mappings (#827)
* parse metadata before sending to algolia

* variants mapping

* extract metadata mapping

* Changeset

* rename field
2023-07-31 12:08:05 +02:00
Lukasz Ostrowski
fa65735571
Release apps (#819)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-07-28 14:42:27 +02:00
Krzysztof Wolski
07999eadc6
Use select input for AWS region selection (#823)
* Use select input for AWS region selection

* Add changeset
2023-07-28 11:57:51 +02:00
Krzysztof Wolski
43d7e47bd4
EAM: Add AWS credentials validation (#821)
* Add AWS credentials validation

* Add changelog
2023-07-28 11:34:03 +02:00
Krzysztof Wolski
54901f8d08
Eam: Extend order payload (#816)
* Extend the order payload for translated names, attributes and price details

* Add changeset
2023-07-27 16:34:14 +02:00
Adrian Pilarczyk
a725720920
feat: taxes map entity type (#808)
* feat:  add privateMetadata to order in subscription

* feat:  add avatax-entity-type-matcher

* test:  add tests for entity-type-matcher

* refactor: ♻️ use metadata instead of privateMetadata

* refactor: ♻️ replace brs

* chore: 💡 remove todo comment

* build: 👷 add changeset

* refactor: ♻️ graphql queries with metafield instead metadata
2023-07-27 15:23:54 +02:00
Patryk Zawadzki
e9531ce79f
Add Discord to the README (#817) 2023-07-27 12:52:20 +02:00
Krzysztof Wolski
c07ddb33d6
EAM: Fulfillment updated event (#810)
* Add fulfillment update event

* Add changeset

* Improve comments
2023-07-27 10:36:43 +02:00
Lukasz Ostrowski
4c7c1c15d3
Release apps (#793)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-07-26 13:58:23 +02:00
Adrian Pilarczyk
ce608872bd
feat: disable document recording (#803)
* feat:  add disable document recording

* build: 👷 add changeset

* refactor: ♻️ address feedback

* feat: 💄 increase margin on HelperText

* refactor: ♻️ add default value for isDocumentRecordingEnabled
2023-07-25 15:01:08 +02:00
Adrian Pilarczyk
1b47ad22da
refactor: 🔥 migration code (#806) 2023-07-25 12:30:16 +02:00
Adrian Pilarczyk
5a4da7beed
feat: address validation suggestions (#802)
* feat:  add ping method to avatax-client

* refactor: ♻️ use avatax-auth-validation.service instead of address service

* refactor: ♻️ extract avatax-configuration-address-fragment

* refactor: ♻️ extract avatax-configuration-credentials-fragment

* refactor: ♻️ extract form-helper-text

* refactor: ♻️ extract form-section

* refactor: ♻️ extract avatax-configuration-taxes-fragment

* feat:  move verify to credentials fragment && add disabled form section

* refactor: 🚚 obfuscator

* feat:  add separate credentials and address validation services

* build: 👷 add changeset

* feat:  add address resolution message

* fix: 🐛 changeset

* refactor: ♻️ extract avataxAddressResolutionProcessor and add tests

* refactor:

* refactor: ♻️ remove brs from avatax-instructions

* refactor: ♻️ replace b with Text bodyStrong

* refactor: ♻️ state tuple to object

* refactor: ♻️ destructure some more constructors

* refactor: ♻️ memoize isLoadings & handlers
2023-07-25 11:15:18 +02:00
Lukasz Ostrowski
aa6fec183d
Update Macaw and bindings to pre-106 (#791)
* Bump macaw UI

* Adjust rhf-macaw binding to use new macaw API

* Adjust CRM to use new macaw api

* adjust apps to work with new macaw

* Update combo box

* fix combobox value
2023-07-24 16:45:45 +02:00
Krzysztof Wolski
2fab86b465
Update graphql codegen related packages (#801)
* Update codegen packages

* Add changeset
2023-07-24 11:50:44 +02:00
Lukasz Ostrowski
457865994e
mask api key inputs in builder io form (#792) 2023-07-20 11:02:34 +02:00
Lukasz Ostrowski
5b2976306e
remove CMS v1 app codebase (#789) 2023-07-19 14:01:17 +02:00
Lukasz Ostrowski
3ae7ac6035
Release apps (#749)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-07-19 11:38:07 +02:00
Lukasz Ostrowski
f6695817a7
cms v1 - notice about deprecation (#787) 2023-07-19 11:26:00 +02:00
Lukasz Ostrowski
5d41af93e1
CMS v2 quality improvements (#782)
* Add required validation for forms

* Add logs

* [skip-ci] handle form errors

* Improve notifications after form created

* add notification when bulk sync finishes

* Add skeletons

* Validation for channel connection form

* cr fixes
2023-07-19 10:22:00 +02:00
Lukasz Ostrowski
44333a6784
Remove monitoring app (#784)
* Remove monitoring app

* remove monitoring cicd
2023-07-19 10:21:39 +02:00
Lukasz Ostrowski
70cb741f88
CMS App v2 (#721)
* initial setup

* GRaphql setup

* [skip ci]

* Manifest and register endpoints

* Add config schemas

* contentful client

* contentful client

* [skip ci] trpc setup

* metadata manager and contentful router

* Configuration config

* contentful config - adding providers

* add provider page

* wip contentful form

* contentful form

* list providrs

* edit contentful form

* [skip ci]

* [skip ci] replace contentful sdk

* replace contentful lib

* Delete contetnful provider feature

* variant created webhook draft

* channel provider connection model

* Channel connections ui

* adding new connection

* connections ui (adding)

* [skip ci] wip edit conn

* removing connection

* rebuild modal

* refactor providers

* implement update product webhook

* add deleting product

* [skip ci] wip

* refactor contentful router

* refactor wip

* refactor config

* webhooks processor

* webhook delegations

* bulk sync section

* bulk sync page

* gql for imports

* [skip ci] bulk import contentful

* temp variant sync list with rate limiters

* wip

* wip

* wip

* new frontend for uploading

* update zod

* print config keys

* wip

* [skip ci] datocms init

* dato add provdier page

* dato form skeleton

* dato  display content type select

* full dato form

* ButtonsBox extraction

* edit dato config form

* update product in dato

* [skip ci]

* extract bulk sync processor

* dato bulk update

* [skip ci] product updated webhook

* product webhook

* crud operations router

* update cruds

* refactor webhook operations

* refactors

* refactors

* helper texts

* [skip ci] deps

* Init

* fix macaw icon

* unify app skd

* unify nextjs

* strapi setup

* fix strapi types

* strapi upload product

* strapi delete product

* strapi product updated webhook

* processor for bulk sync strapi

* shared add provider page

* refactors

* refactors

* wrap providers into folder

* refactors

* refactors

* refactors

* pnpm lock

* add logs

* name configuration mapping name

* form configurable side notes

* remove commentns

* wip providers resolver working

* central config for providers resolving

* tests wip

* remove vscode condig

* cleanup

* provider meta extract

* some tests for contentufl

* contentful client test

* more tests for contentful

* strapi helper texts

* builderio setup

* builderio form

* builderio client draft

* validate connection modal

* Add sending product to builderio

* rename builder field

* add public api key for read access

* update products

* Builder.io - bulk sync

* Fix manifest

* cr fixes

* Make strapi to work with multiple products

* Github actions
2023-07-18 10:19:21 +02:00
Krzysztof Wolski
790a47ee08
Add order refunded webhook (#751)
* Update the app sdk package

* Add order refunded webhook

* Add changeset
2023-07-13 17:50:00 +02:00
Krzysztof Wolski
3c6cd4ccec
Update the app sdk package (#754) 2023-07-13 17:38:04 +02:00
Lukasz Ostrowski
d00ceb6793
Update README.md 2023-07-13 14:45:26 +02:00
Krzysztof Wolski
62104473b4
Update tRPC and React-query (#748)
* Update tRPC and TypeScript

* Fix return types to fix types after the update

* Add changesets

* Use an exact version

* Update react query
2023-07-11 21:32:58 +02:00
Lukasz Ostrowski
e7c2d3a877
Update TS and ESLint (#734) 2023-07-11 17:02:38 +02:00
Lukasz Ostrowski
5473e466f6
Prevent dependabot to trigger Vercel deployments 2023-07-10 11:45:26 +02:00
Lukasz Ostrowski
4162b63a40
Release apps (#690)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-07-10 11:38:52 +02:00
Krzysztof Wolski
be4e7d2922
Add gift card permission to the manifest and block event (#733)
* Handle missing permissions and old Saleor Version

* Throw an error when fetching app permissions fails
2023-07-10 11:04:21 +02:00
Krzysztof Wolski
d4089ab519
Add metadata and privateMetadata to webhook payloads (#730)
* Add metadata and privateMetadata to webhook payloads

* Add changeset
2023-07-07 13:01:36 +02:00
Krzysztof Wolski
e1980aae90
📧 Prevent users from enabling events without the template (#722)
* Prevent users from enabling events without the template

* Add test for the events update validation
2023-07-06 09:02:08 +02:00
Krzysztof Wolski
78670ce712
📧 Improve log formatting (#724)
* Improve log formatting
2023-07-05 17:17:49 +02:00
Krzysztof Wolski
bda814b7b3
📧 Fix for setting up SMTP transport (#723)
* Fix SMTP transport settings based on encryption

* Stop reporting debug message as error

* Shave some time on fetching configurations

* Add changeset
2023-07-05 11:26:49 +02:00
Lukasz Ostrowski
2d77bca353
Update nextjs to 13.4.8 (#720)
* Update nextjs to 13.4.8

* Update .changeset/neat-queens-draw.md

Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>

---------

Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>
2023-07-04 12:43:57 +02:00
Lukasz Ostrowski
054ec48a66
Allow dependabot to open a daily PR for every package, but limit to 1 (#698) 2023-07-03 15:49:43 +02:00
Adrian Pilarczyk
d2b21cc1ab
feat: tax code create order (#693)
* feat:  add avatax-order-created-tax-code-matcher && extract calculate-taxes matcher

* refactor: 🚚 TaxJarTaxCodeMatcher -> TaxJarCalculateTaxesTaxCodeMatcher

* feat:  add taxjar-order-created-payload-service with tax code

* feat:  add missing tests

* build: 👷 add changeset
2023-07-03 12:56:07 +02:00
Lukasz Ostrowski
47102ba98c
Update menifests to reflect dynamic urls from env (#695) 2023-07-03 11:57:50 +02:00
Lukasz Ostrowski
a1f083c0bc
Add about field in manifest of all apps. Sorted fields (#691)
* Add about field in manifest of all apps. Sorted fields

* Add missing logo

* Update apps/klaviyo/src/pages/api/manifest.ts

Co-authored-by: Adrian Pilarczyk <admin@peelar.dev>

---------

Co-authored-by: Adrian Pilarczyk <admin@peelar.dev>
2023-06-30 14:01:46 +02:00
Lukasz Ostrowski
6299e064b6
Update app-sdk (#672)
* Update app-sdk to dev release and temporary display saleor versions in search app

* Update app-sdk

* add changeset

* Removed temp code
2023-06-29 14:35:57 +02:00
Lukasz Ostrowski
7c98e384fd
E2E tests (#668)
* playwright install

* Configrations

* assertions

* basic tests for apps installations

* tests for product feed

* Adjust PF assertions to use test-id

* Taxes smoke test

* moved files around

* Add smoke for Klaviyo

* More taxes tests

* remove workflow

* add example

* extract separate test for pf

* Improve PF test

* cr fixes
2023-06-28 17:08:00 +02:00
Lukasz Ostrowski
d34c2a1b84
Release apps (#685)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-06-28 16:45:40 +02:00
Lukasz Ostrowski
cbd763bfdd
Disabled Klaviyo ssr (#684) 2023-06-28 16:04:59 +02:00
Lukasz Ostrowski
9b65569ec3
Release apps (#556)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-06-28 15:17:54 +02:00
Adrian Pilarczyk
d42c79f366
fix: tax code matcher QA (#680)
* feat:  bring back validateAddress in TaxJar

* feat: 💄 decrease marginLeft of AppToggle label

* refactor: 🚚 cancelButton -> leftButton

* feat: 🧱 add data-testid to all buttons and forms

* refactor: ♻️ refactor app-section to accept Box props

* feat: 🧱 add rest of data-testid

* feat:  verify connections before displaying matcher pages

* feat:  always display matcher-section

* refactor: ♻️ improve fetching tax codes by adding retry and redirect on error

* refactor: 🚚 active-connection -> get-active-connection-service && improve logs

* chore: 🔊 remove objects from logs

* docs: 📝 add TESTING.md with edge-cases
2023-06-28 15:03:24 +02:00
Krzysztof Wolski
6250095a4e
📧 Introduce new event - Gift Card Sent (#661)
* Introduce new event - Gift Card Sent

* Add feature flag service and use it with gift card event

* Add saleor version check on install
2023-06-28 11:39:26 +02:00
Adrian Pilarczyk
1405deaf66
chore: 🗃️ modify migration scripts to overwrite (#681) 2023-06-28 09:58:31 +02:00
Lukasz Ostrowski
37e50db29c
update macaw in klaviyo (#670)
* update macaw in klaviyo

* cr fixes

* removed e2e artifacts
2023-06-28 09:45:35 +02:00
Lukasz Ostrowski
3bd7e3f05d
Monitoring app UI (#677)
* Replace macaw to next

* UI WIP

* ui wip

* fix graphql calls

* Fix ui

* Changsets

* Apply CR review

* fix spacing
2023-06-27 19:20:58 +02:00
Lukasz Ostrowski
f6ff907cfd Improve test-id for product feed 2023-06-27 12:58:59 +02:00
Lukasz Ostrowski
8b245c6bcf
Improve errors handling in Invoices app (#664)
* Remove migration code, add Sentry breadcrumbs

* Add Sentry breadcrumbs to invoice created webhook

* Add changeset

* remove insecure logs
2023-06-27 12:13:04 +02:00
Lukasz Ostrowski
e4497b9ba4
Add test-ids to product feed (#669)
* add test-ids to product feed

* add dynamic google form

* Update .changeset/poor-hounds-move.md

Co-authored-by: Adrian Pilarczyk <admin@peelar.dev>

---------

Co-authored-by: Adrian Pilarczyk <admin@peelar.dev>
2023-06-27 12:10:19 +02:00
Lukasz Ostrowski
57fea9166e
Redesign Slack app to use new Macaw / next (#671)
* Replace material with new Macaw ui

* Rewrtirren Slack to the new macaw

* CR fixes and imprve layour
2023-06-27 12:07:24 +02:00
Krzysztof Wolski
00e49bac11
📧 Improve descriptions (#666)
* Update description of the API key input

* Add space between description and link

* Add changeset
2023-06-22 11:43:57 +02:00
Adrian Pilarczyk
09e07995b5
feat: tax code matcher (#564)
* feat:  add tax-classes-fetcher

* refactor: ♻️ add "byId" suffix to crud-settings & remove upsert

* feat:  add updateMany method to CrudSettingsManager

* feat:  add avatax & taxjar tax-code-map-repository

* refactor: 🚚 move to tax-code directory

* feat:  add getTaxCodes methods to provider clients

* refactor: ♻️ extract taxClass and taxCode schema

* refactor: 🚚 tax-code-map -> tax-code-match

* feat:  add taxjar-tax-code.service

* feat:  add avatax-tax-code.service

* feat:  add taxClass to graphql fragment

* feat:  use tax codes in calculate taxes

* fix: 🐛 undefined tax code bug & add tests

* build: 👷 add changeset

* Update avatax-tax-code-mapper.ts

* feat:  add routers & get rid of adapters & mappers

* refactor: ♻️ logger location -> name

* refactor: ♻️ clean up & rephrase logs

* refactor: ♻️ remove __typename from query

* docs: 💡 make comments about tax codes more informative

* refactor: ♻️ use resolveOptionalOrThrow on tax code description

* refactor: ♻️ rename tax-codes -> tax-classes, move and rename tax-code-schema

* refactor: 🚚 ctx -> authData

* refactor: 🚚 createUrqlClientFromCtx -> createUrqlClientFromAuthdata

* refactor: ♻️ dont spread ctx

* docs: 💡 add comment about fallback tax code

* refactor: ♻️ remove ..ctx

* fix: 🐛 use createGraphQLClient

* feat: tax code matcher ui (#658)

* feat:  use tax codes in calculate taxes

* feat:  add getTaxCodes methods to provider clients

* feat:  add matcher tables

* feat:  add log errors middleware

* fix: 🔊 fix misleading logs

* fix: 🐛 ctx appToken bug

* feat:  add Select override with wrapping label

* feat:  pre-select match

* docs: 💡 add comments about first connection

* docs: 💡 add comment about redirect

* refactor: 🔥 duplicate file

* feat:  add AppCard to tables

* feat:  add _error to breadcrumbs exceptions

* fix: 🐛 value not set on data

* feat: 🥅 add error for no channels configured

* fix: 🐛 replace update with upsert

* refactor: 🚚 channel-configuration-settings to repository

* fix: 🐛 updating a channel configuration

* fix: 🧪 fix wrong mock

* fix: 🐛 duplicating configs

* Update cool-turtles-reflect.md
2023-06-20 17:53:27 +02:00
Krzysztof Wolski
59ff617282
Fix missing MANAGE APPS permission when using client provided in context (#663)
* Fix using wrong token for creating gql client

* Update gorgeous-hats-learn.md
2023-06-20 17:21:31 +02:00
Adrian Pilarczyk
e239fbb670
fix: 🐛 not being able to update one credential (#662)
* fix: 🐛 not being able to update one credential

* build: 👷 add changeset

* fix: 🐛 address feedback

* refactor: ♻️ remove unnecessary clone
2023-06-20 15:34:36 +02:00
Lukasz Ostrowski
3462cc343e
Update helper text for PF app - S3->region field (#660) 2023-06-20 12:37:21 +02:00
Krzysztof Wolski
82dfc3fa6f
📧 Dynamic webhook management (#646)
* Make channels section expandable based on override setting

* Revert "Make channels section expandable based on override setting"

This reverts commit e107c5e990b4110156043ed494fb0054bd936654.

* Add status  component

* Remove no longer used component

* Remove no longer used component

* Removed webhook creation during App installation

* Extend tRPC meta to contain webhook sync flag

* Add app baseUrl to the context

* Webhook management service

* Add changeset
2023-06-20 11:38:32 +02:00
Lukasz Ostrowski
ec68ed53a3
Sentry improvements (#652)
* Update Sentry in CMS

update turbo

Add test logs for cms

test sentry

Update sentry config and next config

* resolve conflict

* Add changesets

* Remove test code
2023-06-20 10:06:18 +02:00
Lukasz Ostrowski
8d3588c18f Update PR template 2023-06-19 16:47:44 +02:00
Lukasz Ostrowski
a6e7fbd77f Fix changeset 2023-06-19 16:28:09 +02:00
Krzysztof Wolski
a1ad70e879
Unify urql version, use shared util to create client (#657)
* Add createGraphQLClient util to shared package

* Update urql version and use createGraphQLClient from shared package

* Update urql version and use createGraphQLClient from shared package - data importer

* Update urql version and use createGraphQLClient from shared package - cms

* Update CRM

* Update invoices

* Update klaviyo

* Update slack

* Update products feed

* Update search

* Remove unused urql next

* Update monitoring

* Update taxes

* Remove multipart since no longer used

* Update the lockfile

* Removed urql introspection since none of our apps used it

* Add changeset
2023-06-19 15:59:27 +02:00
Adrian Pilarczyk
c4be200925
fix: QA bugs in Taxes App (#654)
* fix: 🐛 turn off validateAddress in TaxJar

* refactor: ♻️ make avatax & taxjar schemas strict

* fix: 🐛 update providers bug

* build: 👷 add changeset

* Update cold-candles-fly.md
2023-06-19 12:06:05 +02:00
Adrian Pilarczyk
c0d01db55d
Update README.md (#649) 2023-06-16 09:06:41 +02:00
Adrian Pilarczyk
3aafc9fe72
Add writing changeset guidelines (#648)
* Add writing changeset guidelines

* Update PULL_REQUEST_TEMPLATE.md
2023-06-16 08:00:46 +02:00
Lukasz Ostrowski
426552ded0
Remove dependabot PRs 2023-06-15 13:46:50 +02:00
Mikail
51827293c7
Add dependabot configuration file (#442)
* Add dependabot configuration file

This tells dependabot where to find all the packages as currently it does not know there are 17 packages.

* Update dependabot.yaml

---------

Co-authored-by: Lukasz Ostrowski <lukasz.ostrowski@saleor.io>
2023-06-15 11:12:22 +02:00
Krzysztof Wolski
8287075e29
📧 Improve SMTP event section (#548)
* Make channels section expandable based on override setting

* Revert "Make channels section expandable based on override setting"

This reverts commit e107c5e990b4110156043ed494fb0054bd936654.

* Improve copy in the descriptions

* Handle partial updates i n events

* Add status  component

* Fix typos and types

* Improve SMTP events section

* Add changeset

* Implement event sections as tables with array form

* Update the changelog

* Remove no longer used component

* Add empty option for template choice

* Remove no longer used component

* Update the test
2023-06-15 10:52:39 +02:00
Adrian Pilarczyk
f96563fc2b
feat: redesign taxes app with macaw-ui (#433)
* refactor: ♻️ replace material-ui with macaw-ui; remove all views

* add apps-ui

* feat: add configuration screen (#514)

* feat:  add basic layout

* refactor: 🔥 enabled field

* refactor: ♻️ move part of config to credentials

* refactor: ♻️ move address from channel to provider

* feat:  add all states for providers

* refactor: ♻️ merge redesign with refactor

* refactor: ♻️ replace material-ui with macaw-ui; remove all views

* fix: 🐛 text-decoration on breadcrumb link

* feat:  add stripe icon

* feat:  add AppCard

* feat:  add AppBreadcrumbs and new provider pages

* refactor: ♻️ add gridRatio to support two app grids

* refactor: 🔥 theme-synchronizer test

* refactor: ♻️ make breadcrumbs bulletproof

* fix: 🐛 failing build

* feat:  add app-layout

* build: ⬆️ upgrade macaw-ui

* feat: redesign provider forms (#533)

* build:  add @saleor/react-hook-form-macaw

* feat: add binding for Toggle in react-hook-form-macaw

* feat:  add AppToggle component

* feat:  add create-avatax-configuration and edit-avatax-configuration

* feat:  add taxjar-validation-service with address validation

* feat:  add AvataxValidationService

* feat:  add edit/create taxjar config

* feat:  add delete configuration

* refactor: ♻️ change invalid address error message

* refactor: ♻️ throw in validation services instead of authenticated: true/false

* docs: 💡 add todo comments

* refactor: ♻️ better variable naming in parse error

* fix: 🐛 disable button when loading

* refactor: ♻️ make defaultValues required

* refactor: 🔥 remove custom fontWeights

* fix: 🐛 toggle form submission bug

* feat:  extract provider-label and use in forms

* feat:  use new Section component and change copy

* refactor: ♻️ use ProviderLabel in ProvidersTable

* build: 💚 fix lockfile

* fix: 🐛 address feedback

* feat: channel provider table (#538)

* refactor: 🔥 providers-config-input-schema

* refactor: ♻️ move providers

* feat:  add basic channel-list

* refactor: 🚚 move around

* feat:  add ChannelConfigurationService

* feat:  add select provider in channel table

* refactor: 🔥 logger traces

* refactor: 🔊 refine logs

* feat:  add avatax-config-mock-generator and use in webhooks mock generators

* refactor: ♻️ split up avatax-validation.service and add tests for response resolver

* feat:  add avatax-validation-error-resolver tests

* refactor: 🚚 move taxjar-validation.service to separate folder

* feat:  extract taxjar-validation-error-resolver and add tests

* feat:  add channel-config-mock-generator

* feat:  add ChannelFetcherMockGenerator

* test:  add tests for channel-configuration-merge

* test:  add tests for crud-settings.service

* fix: 💬 channel copy

* refactor: 🔊 remove data from logs

* refactor: 🚚 move configuration & validation to configuration folder

* fix: 🐛 imports

* fix: 🐛 showing loading & add at the same time

* refactor: ♻️ public configurations & obfuscation rework

* refactor: 🚚 extract repositories from services

* refactor: 🚚 instance -> connection (#544)

* refactor: ♻️ move creating configuration service to procedure

* fix: 🐛 some paths & names while renaming

* Update apps/taxes/src/pages/api/webhooks/order-calculate-taxes.ts

* Update apps/taxes/src/pages/api/webhooks/checkout-calculate-taxes.ts

* feat: shipping tax code (#555)

* fix: 💄 minor visual issues

* feat:  add shipping tax code

* fix: 🐛 fix toggle

* refactor: ♻️ use common table in providers-table

* docs: 💡 add comment to obfuscator

* feat:  sort countries by label not code

* Taxes 💸: Add runtime migrations to schema v2 (#545)

* build:  add dotenv

* feat: 🚧 add draft of migration scripts

* refactor: ♻️ settingsManager to accept appId

* feat:  tax-providers and channels transforms

* feat:  add migration classes

* feat: 🚧 add migration to connection getAll

* feat: 🚧 add dummy data script

* feat:  add runtime channel migration

* feat:  add migration and report scripts

* fix: 🐛 name of dummy class method

* feat:  add transform tests

* build: 💚 update lockfile

* fix: creating & updating a provider (#563)

* refactor:  improve the test for update

* docs: 💡 comment crud-settings

* refactor: 🔥 unused file

* fix: 🐛 appId bug

* refactor: 🚚 rename dummy-utils

* fix: 💄 padding in table

* refactor: 💬 change avatax texts

* build: 👷 add changeset
2023-06-15 09:01:50 +02:00
Lukasz Ostrowski
cb6ee2920b
Update dependencies (#568)
* Update deps

* update deps

* update deps

* Changeset

* update lock
2023-06-15 08:18:54 +02:00
Lukasz Ostrowski
1dead1e54f
Add logo to apps (#552)
* Add logo to manifest

* dirty fix build

fix image

update image to high res

Update sdk

Add logos to manifests

Update sdk

update sdk

* fix package in cms

* fix lock

* remove sdk from macaw binding package

* update sdk
2023-06-14 16:05:36 +02:00
Lukasz Ostrowski
860bac46db update sdk 2023-06-14 13:13:04 +02:00
Lukasz Ostrowski
cce3c1ef97
Changed index prefix in algolia to be optional (#553) 2023-06-13 13:37:17 +02:00
Lukasz Ostrowski
ff9665b70f
Release apps (#542)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-06-12 14:09:15 +02:00
Krzysztof Wolski
e106ab9ff4
📧 Improve section descriptions (#547)
* Improve copy in the descriptions

* Add changeset

* Improve descriptions in the SMTP module

* Improve grammar

Co-authored-by: Adrian Pilarczyk <admin@peelar.dev>
2023-06-12 13:30:20 +02:00
Krzysztof Wolski
cce2fbc3d5
📧 Improve layout and white-space (#546)
* Bump macaw version

* Make channels section expandable based on override setting

* Add margins to dangerous section, improve copy

* Add padding between section header and its description

* Limit width of provider selection boxes

* Use proper variant of the button

* More whitespace between description and box in the sections

* Remove text

* Revert "Make channels section expandable based on override setting"

This reverts commit e107c5e990b4110156043ed494fb0054bd936654.

* Add changelog

* Improve grammar

Co-authored-by: Adrian Pilarczyk <admin@peelar.dev>

---------

Co-authored-by: Adrian Pilarczyk <admin@peelar.dev>
2023-06-12 13:18:31 +02:00
Krzysztof Wolski
928c727c35
Update macaw-ui version (#541)
* Bump macaw version

* Add changeset

* CRM update spacing mapping

* Update spacing mapping for invoices app

* Update products feed spacing mapping

* Fix accordion in webhook status, add trigger button

* Update search spacing mapping

* Improve the changelog message

* Update spacing mapping in EAM app
2023-06-09 11:42:40 +02:00
Lukasz Ostrowski
a8834a11fe
Update dev dependencies (#540)
* Update Typescript to 5.1.3

* Update Turborepo

* Update turborepo eslint config

* Update ESLint and unify version

* Changeset

* unify eslint rules and node types and moved them to root

* unify app-sdk version

* unify codegen packages across monorepo

* unify graphql package

* update lock

* enabled eslint during invoices build with next build

* Format package jsons

* push scripts to the top of package json
2023-06-07 09:25:39 +02:00
Lukasz Ostrowski
57fec74663
Release apps (#520)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-06-06 12:59:16 +02:00
Krzysztof Wolski
dca93c0f47
Downgrade EAM release to minor (#539) 2023-06-06 12:23:56 +02:00
Krzysztof Wolski
86c2b7b10b
📧 Add runtime migrations to schema v2 (#535)
* Add runtime migrations to schema v2

* V2 migration scripts (#536)
2023-06-06 11:51:59 +02:00
Michal Widera
235a7d92b7
Create workflow to build image for monitoring backend (#525) 2023-06-02 11:08:11 +02:00
Lukasz Ostrowski
b75a66497b
Protect logger from trace and debug in production (#527)
* Protec logger from trace and debug in production

* Better error message
2023-06-01 16:33:25 +02:00
Adrian Pilarczyk
6a9da7ac38
Remove filter from "check-deps" command (#523)
* Remove filter from "check-deps" command

* ci: 👷 only run check-deps on label
2023-06-01 15:12:04 +02:00
Lukasz Ostrowski
182bdd6b32
🔥 Product Feed - New macaw & production prep (#512)
* Remove utils and use shared ones

* [skip ci]

* Remove legacy Macaw and mui, replace components so app builds

* Channels existence fetch hook

* Remove shop info fetching

* Remove legacy layout and merge pages

* Index page sections

* re-Implemented s3 form

* s3 Form validation

* New config logic

* wi

* Remove old config, add router to update s3

* Channels list with form

* Update cache configurator

* Feed preview card

* Flat structure, channels router

* Connect channels urls config

* Fix fetching webhook config

* Refactor lib folder

* Category mapping

* Categories mapping preview

* Improve buttons

* Enable cache on channel config saving

* wip webhooks

* Add webhooks

* Webhook refactor

* Refactor createClient

* Tests

* Clean imports

* Add logs

* Review fixes

* Changeset

* Small xml improvement

* Add tests batch

* Add tests

* Hide amazon secret with input password

* CR fixes

* CategoriesFetcher for recursive calls

* Implement categories fetcher

* Update lockfile after rebase with main

* Change category map to override instead conifg
2023-06-01 13:33:07 +02:00
Lukasz Ostrowski
0c2fc65ac6
Update Vite and Vitest across packages (#524)
* Update Vite and Vitest across packages

* Add changeset
2023-06-01 10:36:20 +02:00
Adrian Pilarczyk
8af0f022e3
feat: align monorepo deps automatically (#518)
* build:  add syncpack

* feat:  add syncpack github action

* feat:  update syncpack

* ci: 🔧 update syncpackrc

* ci: 👷 update check-deps to ignore all

* Update check-deps.yml

* Update check-deps.yml

* Update check-deps.yml

* Update check-deps.yml

* Update package.json

* ci: 👷 add continue-on-error

* ci: 💚 move continue-on-error
2023-06-01 09:09:19 +02:00
Lukasz Ostrowski
91286da553
Update prepare-release.yml 2023-05-31 20:55:37 +02:00
Krzysztof Wolski
80030644a7
Update design to new Macaw (#426)
* Update E&M Provider UI (#459)

* Emails and messages: use App UI package (#511)

* Add changeset
2023-05-31 18:08:43 +02:00
Mikail
941f017a16
Upgrade cryptography to latest (#519)
Fixes CVE-2023-2650: Vulnerable OpenSSL included in cryptography wheels.
2023-05-31 17:30:59 +02:00
Lukasz Ostrowski
2c1495a44d
Release 2023-05-30
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-05-30 22:10:40 +02:00
Adrian Pilarczyk
ca4306162f
refactor: refine taxjar (#494)
* feat:  add first tests & use address-factory

* feat:  add distributeDiscount

* refactor: taxjar maps to adapters (#495)

* refactor: ♻️ taxjar-calculate-taxes-map with taxjar-calculate-taxes-adapter

* refactor: ♻️ taxjar-order-created-map -> taxjar-order-created-adapter

* refactor: ♻️ address 1st batch of feedback

* refactor: ♻️ split up taxjar-calculate-taxes-adapter

* refactor: 🚚 extract shipping transformer

* docs: 💡 add comment about refunds in distribute-discount

* refactor: 🚚 split up taxjar-order-created-adapter classes

* refactor: ♻️ mocks with taxjar-mock-factory

* refactor: ♻️ mocks with avatax-mock-factory

* refactor: avatax maps to adapters (#506)

* refactor: ♻️ move around & refactor avatax-order-created-map -> adapter

* refactor: 🚚 move avatax-order-created- to its own folder

* refactor: ♻️ avatax-calculate-taxes-map -> adapter

* refactor: ♻️ avatax-order-fulfilled-maps -> adapter

* feat:  add logger to adapters

* refactor: ♻️ mocks -> avatax-mock-transaction-factory & fix tests

* feat: add tests for taxjar (#509)

* fix: 🚚 tax-provider-utils.test name

* feat:  add nexus tests & other taxjar tests

* feat: 🥅 add ExpectedError and use it in webhook-response

* refactor:  unify taxjar-calculate-taxes tests with mock-generator

* feat:  add TaxJarOrderCreatedMockGenerator

* feat:  add avatax-calculate-taxes-mock-generator

* feat:  add AvataxOrderCreatedMockGenerator

* refactor: 🔥 tax-mock-factory

* fix: 🐛 housekeeping

* fix: 🐛 feedback

* feat:  add taxBase with discounts test

* fix: 🐛 address feedback

* refactor: 🔥 unused avatax-mock-factory functions

* feat:  use discount utils in all providers

* feat:  differentiate between pricesEnteredWithTax in taxjar
2023-05-29 10:35:34 +02:00
Lukasz Ostrowski
6e69f4f9f0
Add removing metadata in CRM app (#507)
* Add dev app-sdk and rewrite undefined metadata to removing it

* Implement metadata manager delete method

* Update app-sdk to 0.39.1 in all packages

* Update swift-boxes-drop.md
2023-05-24 16:08:12 +02:00
Lukasz Ostrowski
8e0b08523b
Release apps (#487)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-05-23 15:08:26 +02:00
Lukasz Ostrowski
23b5c70f51
Extract part of Semver compatibility logic to shared package and implement in Invoices and Taxes (#488)
* Extract semver compatibility logic to shared package and implement it in taxes

* Move semver checking package to packages/shared

* Update lock

* Apply suggestions from code review

Co-authored-by: Adrian Pilarczyk <admin@peelar.dev>

* Improve error message

* Fix lockfile

---------

Co-authored-by: Adrian Pilarczyk <admin@peelar.dev>
2023-05-23 11:04:52 +02:00
Krzysztof Wolski
b36502df37
Add bindings for Select component (#502) 2023-05-22 19:29:47 +02:00
Krzysztof Wolski
0c8717a1e5
Add missing changeset to the Products Feed app (#503) 2023-05-22 19:23:42 +02:00
Krzysztof Wolski
590150b8c4
Use RHF bindings in Search app (#500)
* Use RHF Macaw bindings from shared package

* Update the nextjs config
2023-05-22 18:14:05 +02:00
Krzysztof Wolski
8a339fc31b
Introduce react hook form macaw bindings (#469)
* Add components and update the configuration

* Export components to be used in apps
2023-05-22 17:47:33 +02:00
Krzysztof Wolski
ce8d9deb81
Products Feed: Convert EditorJS product descriptions to plain text (#489)
* Cache query cursors for the product feed

* Fix missing first page of products

* Add S3 upload

* Explain sze limit on multipart upload

* Change the name of function

* Update the dependencies

* Revert api response size override

* Fix multi part upload

* Remove duplicated code

* Add channel name to the file URL

* Render EditorJS formatted descriptions as plaintext.
SEO Description field will be removed

* Add changeset

* Improve tests and allow escaped signs
2023-05-22 13:00:41 +02:00
Krzysztof Wolski
238f2b5d01
Cache query cursors for the product feed and support s3 (#478)
* Cache query cursors for the product feed

* Fix missing first page of products

* Add S3 upload

* Explain sze limit on multipart upload

* Change the name of function

* Update the dependencies

* Revert api response size override

* Fix multi part upload

* Remove duplicated code

* Add channel name to the file URL
2023-05-22 12:22:28 +02:00
Lukasz Ostrowski
4801803ea0
Sentry missing config (#481)
* Add Sentry config to Emails & Messages app

* Add Sentry config to Product Feed app

* Add Sentry config to CRM app

* ADd changeset

* Clean up sentry boilderplate
2023-05-21 18:28:17 +02:00
Adrian Pilarczyk
dd799e6993
refactor: use resolveOptionalOrThrow (#480)
* refactor: 🚚 move tax-line-resolver to taxjar-calculate-taxes-map

* feat:  add resolveOptionalOrThrow util

* build: 👷 add changeset

* fix: 🐛 fix shipping line bug & add handleWebhookError

* build: 👷 update changeset

* refactor: ♻️ add optional error argument
2023-05-19 10:57:17 +02:00
Lukasz Ostrowski
a5df092828
Release apps (#483)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-05-18 15:04:11 +02:00
Lukasz Ostrowski
5e903aed00
Metadata Migrations script (#453)
* Create a package

* wip

* wip

* wip

* wip

* Move migration script to invoices

* Add migration script

* Update SDK to fix getAll method

* Add restoring migration script

* Add migrations consts

* Add changesets

* cr fixes
2023-05-18 14:55:52 +02:00
Lukasz Ostrowski
f76d8fe8da
Release apps (#473)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-05-18 14:55:30 +02:00
Lukasz Ostrowski
99c307d1cb Add debug message for contentful - ping 2023-05-18 11:07:01 +02:00
Lukasz Ostrowski
7c9a9a2890 Add debug logs for CMS 2023-05-18 10:49:44 +02:00
Adrian Pilarczyk
ba7c3de471
feat: add basic breadcrumbs component (#470)
* feat:  add empty packages/ui

* feat: ⚗️ move taxes app-grid to packages/ui

* build: ⬆️ upgrade macaw-ui in packages/ui

* add app sdk

* feat:  add basic breadcrumbs component

* refactor: ♻️ simplify breadcrumbs api

* Update packages/ui/src/breadcrumbs.tsx

Co-authored-by: Lukasz Ostrowski <lukasz.ostrowski@saleor.io>

* refactor: 🔥 next-env.d.ts

* refactor: ♻️ address breadcrumbs feedback

* chore: 🔥 remove eslint disable

* build: 👷 add changeset

---------

Co-authored-by: Lukasz Ostrowski <lukasz.ostrowski@saleor.io>
2023-05-18 09:37:51 +02:00
Lukasz Ostrowski
c4063188ca
Update app-sdk to 0.38.0 (#464) 2023-05-17 18:57:09 +02:00
Lukasz Ostrowski
f9ca488dd6 Fix TextLink component 2023-05-17 18:53:49 +02:00
Przemysław Łada
1995f507a0
Extend release workflow with tagging and releasing on GitHub (#477)
* Add github:release script

* Change release worflow
2023-05-17 15:00:51 +01:00
Krzysztof Wolski
2de2a40af1
Parallel products feed data fetching (#465)
* Parallel products feed data fetching

* Create strong-peas-begin.md
2023-05-17 13:26:36 +02:00
Adrian Pilarczyk
51134a5a8b
fix: returning 0 for line price if item is not taxable (#476)
* fix: 🐛 returning 0 for line price if item is not taxable

* build: 👷 add changeset
2023-05-17 12:49:10 +02:00
Lukasz Ostrowski
e751459b4d
Add TextLink component (#468)
* feat:  add empty packages/ui

* feat: ⚗️ move taxes app-grid to packages/ui

* build: ⬆️ upgrade macaw-ui in packages/ui

* build: ⬆️ upgrade macaw even harder

* add app sdk

* Fix app-sdk version

* Add TextLink

* Add TextLink

* Change method name

* Cleanup

---------

Co-authored-by: Adrian Pilarczyk <adrianpilarczyk314@gmail.com>
2023-05-16 21:07:08 +02:00
Lukasz Ostrowski
24615cf7c1
Add SemanticChip shared component (#471)
* feat:  add empty packages/ui

* feat: ⚗️ move taxes app-grid to packages/ui

* build: ⬆️ upgrade macaw-ui in packages/ui

* build: ⬆️ upgrade macaw even harder

* add app sdk

* Fix app-sdk version

* Add SemanticChip

* update lock

---------

Co-authored-by: Adrian Pilarczyk <adrianpilarczyk314@gmail.com>
2023-05-16 20:41:52 +02:00
Adrian Pilarczyk
403bcf87ec
Add packages/ui shared package - root branch (#467)
* feat:  add empty packages/ui

* feat: ⚗️ move taxes app-grid to packages/ui

* build: ⬆️ upgrade macaw-ui in packages/ui

* build: ⬆️ upgrade macaw even harder

* add app sdk

* Fix app-sdk version

* refactor: 🔥 remove usage in taxes

* refactor: 🔥 remove changes in turbo.json

* refactor: ♻️ address feedback

* build: ⬆️ unify next macaw-ui version in apps (#472)

* build: ⬆️ unify next macaw-ui version in apps

* Create tricky-islands-remember.md

---------

Co-authored-by: Lukasz Ostrowski <lukasz.ostrowski@saleor.io>

---------

Co-authored-by: Lukasz Ostrowski <lukasz.ostrowski@saleor.io>
2023-05-16 19:36:53 +02:00
Lukasz Ostrowski
4fe2682e45
Release apps (#463)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-05-16 09:28:24 +02:00
Adrian Pilarczyk
70bf546e28
fix: charge taxes setting (#466)
* fix: 🐛 respect pricesEnteredWithTax setting

* build: 👷 add changeset
2023-05-16 09:17:59 +02:00
Lukasz Ostrowski
1fd0960562
Release 2023-05-15
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-05-15 17:32:04 +02:00
Adrian Pilarczyk
72adeb3b13
replace failureRetry/failureNoRetry webhook response with failure (#462)
* refactor: ♻️ replace failureRetry/failureNoRetry webhook response with failure

* build: 👷 add changeset

* refactor: ♻️ address feedback
2023-05-15 11:52:14 +02:00
Lukasz Ostrowski
842b7b9cdb
Change test script to be "test" instead "test:ci" so all tests are executed properly (#437) 2023-05-15 11:03:04 +02:00
Adrian Pilarczyk
b4ddb02efb
fix: quantity not respected (#460)
* fix:  fix failing map tests

* fix: 🐛 use totalPrice instead of unitPrice to fix quantity

* build: 👷 add changeset
2023-05-12 14:49:27 +02:00
Adrian Pilarczyk
0c039f59dc
feat: make companyCode optional (#458)
* feat:  make companyCode optional

* fix: 🐛 return empty string if no companyCode in order-fulfilled

* build: 👷 add changeset
2023-05-12 10:51:00 +02:00
Adrian Pilarczyk
9ecb6291a5
fix: 🚑 channels not showing (#455)
* fix: 🚑 get first 100 of channels

* build: 👷 add changeset
2023-05-12 10:01:12 +02:00
Lukasz Ostrowski
b0eb078eea Fix broken build in invoices 2023-05-11 12:21:12 +02:00
Lukasz Ostrowski
ee1a4afd25 Fix address form not loading 2023-05-11 12:15:01 +02:00
Lukasz Ostrowski
8b22b1c1f8
Fix logs (#452)
* Add pino and pino-pretty to every package, to fix logger failing

* Fix taxes child logger

* Fix Search child logger

* Update examples .env

* changeset
2023-05-10 16:57:20 +02:00
Lukasz Ostrowski
ccd8250e2f Add changeset 2023-05-09 13:19:58 +02:00
Lukasz Ostrowski
1b3680465f
Invoices redesign to Macaw Next (#445)
* Removed old macaw and material

* Add trpc router that fetches shop address info

* Config page layout with header and address

* display default addres

* Draft channels list

* add v2 config model

* Render address overrides

* Render address overrides ui

* connect address form

* reset address form

* implement removing conifg

* connect dashboard sites

* update webhook

* Add ConfigV1 to ConfigV2 transformer

* Cleanup v1 router, abstract v2

* Implement runtime migrations

* Implement migration service in controllers

* test for configuration service

* test for app cofnig

* draft test for router

* refactor webhook

* Unify Address schema to single one

* Extractr data fetching from form
2023-05-09 12:17:54 +02:00
Lukasz Ostrowski
a3d87be3f4
Release 2023-05-05
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-05-05 08:47:22 +02:00
Lukasz Ostrowski
830cfe92ce
Extract logger (#439)
* Extract logger

* Replace logger with shared one

* Replace CRM logger with shared one

* Replace E&M logger with shared one

* Replace invoices logger with shared one

* Replace Products Feed logger with shared one

* Replace Search logger with shared one

* Replace Taxes logger with shared one

* Uninstall pino from apps direct dependency

* Update docs

* Update changeset

* Bumped Klaviyo typescript version to hopefully unblock the build

* Change packageManager field to pnpm 8.2.0

* removed package manager field from klaviyo package.json
2023-05-05 08:15:47 +02:00
Lukasz Ostrowski
b56894fa14
Fix tests (#438) 2023-05-04 09:54:27 +02:00
Lukasz Ostrowski
40bed99316
Webhooks optimization (#434)
* Disable webhooks in manifest and create graphql operations

* Add webhook enabler service

* Add webhooks to subscription events

* Disable webhooks when webhok operation fail

* display webhooks status

* Display failed deliveries

* Use react query in webhooks-status, refetch it when configu updated

* Extracted main instructons

* Better webhook instructions

* Optimize webhooks fetching from subscription

* Test for WebhooksAcivityToggler

* Changeset

* Test for webhook status handler WIP

* Test wip

* Rewrite test
2023-04-29 11:30:48 +02:00
Adrian Pilarczyk
3347a305cd
feat: add shipping tax code (#424)
* feat:  add shippingTaxCode

* build: 👷 add changeset
2023-04-26 14:40:46 +02:00
Adrian Pilarczyk
9eacc88b53
feat: align avatax with plugin (#414)
* refactor: 🚚 OrderLineFragmentFragment -> OrderLineFragment

* refactor: 🚚 getLine... to getTaxBaseLine...

* refactor: ♻️ temporarily remove usage of getTaxBaseLineTaxCode

* feat:  add shipping as line to avatax-order-created

* feat:  add description to order-created lines

* feat:  add itemCode to avatax-order-created line

* feat:  add tests for avatax maps

* feat:  add basic discounts logic

* docs: 🔥 remove comment

* build: 👷 add changeset

* fix: 🐛 shipping amount
2023-04-26 13:40:51 +02:00
Lukasz Ostrowski
aa27f9d6ef
Release apps (#429)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-04-26 13:30:05 +02:00
Lukasz Ostrowski
b33da7a70a
Improve copy in Search app (#428) 2023-04-26 12:20:08 +02:00
Lukasz Ostrowski
da3da9d8b0
Release apps (#423)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-04-26 09:17:12 +02:00
Lukasz Ostrowski
112bed4a0b
Add test for CUSTOMER_CREATED webhook in CRM app (#422)
* Add test for CUSTOMER_CREATED webhook in CRM app

* remove log
2023-04-25 18:57:17 +02:00
Lukasz Ostrowski
fc7a70f598
Search redesign to Macaw 2.0 (#420)
* Redesign

* Better form

* Remove searchKey

* Update macaw

* Test setup

* fix build

* Create curly-jars-doubt.md

* Unify vitest to (hopefully) fix build

* fix lock

* update macaw and review fixes

* fix build
2023-04-25 18:20:20 +02:00
Lukasz Ostrowski
cad39760c9
Release apps (#413)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-04-24 11:22:18 +02:00
Dawid
fc0664885e
CMS visual fixes (#417)
* CMS visual fixes

* Create sour-deers-camp.md
2023-04-24 11:18:02 +02:00
Adrian Pilarczyk
56b27b2e21
fix: tax not calculating (#412)
* fix: 🐛 disable autocomplete on country-select

* fix: 🐛 invalid response format bug

* build: 👷 add changeset

* docs: 💡 add comment with improvement idea
2023-04-23 13:18:20 +02:00
Lukasz Ostrowski
8424989dad
Release apps (#389)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-04-21 12:04:30 +02:00
Adrian Pilarczyk
84e9ca5d66
feat: add country select (#410)
* feat:  add country-select

* feat:  add taxjar address-factory

* feat:  add validateAddress methods to [provider]-client

* build: 👷 add changeset
2023-04-21 07:55:43 +02:00
Krzysztof Wolski
ede7a2e808
Notify event webhook for customer emails (#408)
* Support Notify webhook for account operations

* Fix the comment

* Do not expose internal types of the event

* Remove debug message
2023-04-19 15:56:45 +02:00
Dawid
a3636f73ef
Fix CMS app issues (#386)
* Fix CMS app visual issues

* Add provider configuration instance ping status

* Skip update webhooks processing if issuing principal is this CMS app

* Fix provider configuration form validation

* Create old-dingos-hide.md
2023-04-18 18:46:28 +02:00
Lukasz Ostrowski
2c0df91351
Apply recent ESLint rules on the codebase (#404)
* Add lint:fix script

* Reformat CRM app with eslint fix

* Apply eslint fix on data importer codebase

* Apply eslint fix on Invoices codebase

* Apply eslint fix on Klaviyo codebase

* Apply eslint fix on products-feed codebase

* Apply eslint fix on monitoring codebase

* Apply eslint fix on Search codebase

* Apply eslint fix on Slack codebase

* cleanup
2023-04-18 15:10:00 +02:00
Lukasz Ostrowski
57f6d41bc4
Update manifests to contain up to date author, homepage, support and privacy fields (#399) 2023-04-18 14:04:14 +02:00
1477 changed files with 190321 additions and 64030 deletions

View file

@ -1,8 +1,63 @@
# Changesets
# Changeset
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
## Writing guidelines
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
### Rules:
#### 1. Avoid repetition when describing bug fixes
Bad:
> Fixed a bug where the component fired confetti uncontrollably when typing into the input. Fixed a bug when...
Good:
> The input no longer fires confetti on typing.
#### 2. Use more personal tone
Bad:
> It is now possible to use the class xyz directly in…
Good:
> **You can now** use the class xyz directly in…
#### 3. Make it about the user, not the code
Bad:
> Added the X and Y fields to the schema returned by Z.
Good:
> You can now see how many users are connected to a deployment (*X*), and the users capacity of the deployment (*Y*).
#### 4. Use a minimal amount of fluff
Bad:
> After many long nights at the office, several cans of beer, and consuming the amount of pizza equal to the surface of a helipad, we finally managed to squash a bug thats been haunting you forever. Its origin reaches back to the times when Tim Berners-Lee…
Good:
> The application no longer shuts down when attempting to abort a payment.
#### 5. Use a template when lost
If you dont have an idea how to start, you may use some of those openings:
- “You can now…”
- “X no longer does Y when Z.”
- “X no longer does Y. This means you no longer need to Z.”
#### 6. Describe known issues
When the release introduces some issues or limitations, describe them:
> You may experience issues when trying to use the new view with an adblocker turned on. The issue will be fixed in the next release. For now, please…
### Resources
- https://www.youtube.com/watch?v=L3yAD319DiU
- https://keepachangelog.com/en/1.0.0/

View file

@ -1,17 +0,0 @@
---
"eslint-config-saleor": patch
"saleor-app-emails-and-messages": patch
"saleor-app-data-importer": patch
"saleor-app-products-feed": patch
"saleor-app-monitoring": patch
"@saleor/apps-shared": patch
"saleor-app-invoices": patch
"saleor-app-klaviyo": patch
"saleor-app-search": patch
"saleor-app-slack": patch
"saleor-app-taxes": patch
"saleor-app-cms": patch
"saleor-app-crm": patch
---
Update next.js to 13.3.0

View file

@ -1,17 +0,0 @@
---
"eslint-config-saleor": patch
"saleor-app-emails-and-messages": patch
"saleor-app-data-importer": patch
"saleor-app-products-feed": patch
"saleor-app-monitoring": patch
"@saleor/apps-shared": patch
"saleor-app-invoices": patch
"saleor-app-klaviyo": patch
"saleor-app-search": patch
"saleor-app-slack": patch
"saleor-app-taxes": patch
"saleor-app-cms": patch
"saleor-app-crm": patch
---
Updated @saleor/app-sdk to 0.37.3

View file

@ -1,17 +0,0 @@
---
"eslint-config-saleor": patch
"saleor-app-emails-and-messages": patch
"saleor-app-data-importer": patch
"saleor-app-products-feed": patch
"saleor-app-monitoring": patch
"@saleor/apps-shared": patch
"saleor-app-invoices": patch
"saleor-app-klaviyo": patch
"saleor-app-search": patch
"saleor-app-slack": patch
"saleor-app-taxes": patch
"saleor-app-cms": patch
"saleor-app-crm": patch
---
Update next.js to 13.3.0

View file

@ -1,5 +0,0 @@
---
"saleor-app-invoices": patch
---
Changed how Saleor version is validated during installation, to use dedicated SaleorVersionCompatibilityValidator. It also doesnt "coerce" version anymore, but uses "includePrelease" flag instead. This should match actual Saleor versioning better

View file

@ -1,5 +0,0 @@
---
"saleor-app-taxes": minor
---
Completed the order flow with new webhooks: `order_created` and `order_fulfilled`. In TaxJar, an order will be created on `order_created` with no actions on `order_fulfilled`. In Avatax, a transaction will be created on `order_created` and commited on `order_fulfilled`.

View file

@ -0,0 +1,8 @@
#changelog
---
"apps": minor
---
### Added
- `apps/emails-and-messages/.env.template`: Described the new environment variable and how it works
- `apps/emails-and-messages/src/saleor-app.ts`: Added case "redis" for switch(AplType), which takes advantage of the [RedisAPL PR](https://github.com/saleor/app-sdk/pull/287) I submitted

View file

@ -1,17 +0,0 @@
---
"eslint-config-saleor": patch
"saleor-app-emails-and-messages": patch
"saleor-app-data-importer": patch
"saleor-app-products-feed": patch
"saleor-app-monitoring": patch
"@saleor/apps-shared": patch
"saleor-app-invoices": patch
"saleor-app-klaviyo": patch
"saleor-app-search": patch
"saleor-app-slack": patch
"saleor-app-taxes": patch
"saleor-app-cms": patch
"saleor-app-crm": patch
---
Update @saleor/app-sdk to 0.37.2

View file

@ -1,5 +0,0 @@
---
"saleor-app-invoices": patch
---
Use REQUIRED_SALEOR_VERSION from manifest in app's own Saleor version validation

View file

@ -1,5 +0,0 @@
---
"@saleor/apps-shared": minor
---
Added useDashboardNotification hook, that allows quick access to AppBridge.dispatch(Notification())

View file

@ -1,15 +0,0 @@
---
"saleor-app-emails-and-messages": patch
"saleor-app-data-importer": patch
"saleor-app-products-feed": patch
"saleor-app-monitoring": patch
"saleor-app-invoices": patch
"saleor-app-klaviyo": patch
"saleor-app-search": patch
"saleor-app-slack": patch
"saleor-app-taxes": patch
"saleor-app-cms": patch
"saleor-app-crm": patch
---
Use useDashboardNotification hook from shared package, instead of direct AppBridge usage

View file

@ -1,5 +0,0 @@
---
"eslint-config-saleor": minor
---
Added new warn rules to eslint-config-saleor: `newline-after-var` and `multiline-comment-style`.

11
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,11 @@
## Scope of the PR
<!-- Describe briefly changed made in this PR -->
## Related issues
<!-- If any, mention issues that are connected with this PR -->
## Checklist
- [ ] I added changesets and [read good practices](/.changeset/README.md).

19
.github/dependabot.yaml vendored Normal file
View file

@ -0,0 +1,19 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
commit-message:
prefix: "[skip ci]"
groups:
trpc:
patterns:
- "@trpc/*"
codegen:
patterns:
- "@graphql-codegen/*"
rtl:
patterns:
- "@testing-library/*"

4
.github/labeler.yml vendored
View file

@ -1,11 +1,11 @@
"App: Data Importer": "apps/data-importer/**/*"
"App: Invoices": "apps/invoices/**/*"
"App: Klaviyo": "apps/klaviyo/**/*"
"App: Monitoring": "apps/monitoring/**/*"
"App: Product Feed": "apps/products-feed/**/*"
"App: Search": "apps/search/**/*"
"App: Slack": "apps/slack/**/*"
"App: Taxes": "apps/taxes/**/*"
"App: Emails & Messages": "apps/emails-and-messages/**/*"
"App: CMS": "apps/cms/**/*"
"App: CMS": "apps/cms-v2/**/*"
"App: CRM": "apps/crm/**/*"
"App: Segment": "apps/segment/**/*"

View file

@ -6,9 +6,12 @@ on:
jobs:
assign_creator:
if: ${{ github.event.pull_request.user.login != 'dependabot[bot]' }}
runs-on: ubuntu-latest
steps:
- name: Assign PR to creator
uses: thomaseizinger/assign-pr-creator-action@v1.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
CREATOR: ${{ github.event.pull_request.user.login }}
run: gh pr edit "$PR_URL" --add-assignee "$CREATOR"

31
.github/workflows/changeset-checker.yml vendored Normal file
View file

@ -0,0 +1,31 @@
name: Changesets
on:
pull_request:
types:
- opened
- labeled
- edited
- synchronize
branches-ignore:
- 'changeset-release/**'
jobs:
changeset_check:
name: Changeset added to the PR
# Adding 'skip changesets' label to the PR will skip this job
if: ${{ !contains( github.event.pull_request.labels.*.name, 'skip changeset') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
# check out full history
fetch-depth: 0
- uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
- uses: actions/setup-node@v3
with:
node-version: 18
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Changeset added
run: npx changeset status --since origin/main

34
.github/workflows/check-deps.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: Check dependencies versions
on:
# Run whenever a pull request is updated
pull_request:
branches:
- main
jobs:
syncpack:
if: contains(github.event.pull_request.labels.*.name, 'check dependencies')
runs-on: ubuntu-latest
name: Check dependencies
steps:
# Check out the branch so we can read/write its files
- uses: actions/checkout@v3
# Use Node.js as syncpack is written in that
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: JamieMason/syncpack-github-action@c145cec44b3731b3fe8e859679e240d6ae011f0f
continue-on-error: true
with:
package-manager: "pnpm"
bot-username: "Syncpack Bot"
commit-message: "format and sync package.json files"
# The workflow will only list mismatched dependencies, and the assignee has to manually fix them.
# version-mismatches: list mismatches in a comment
version-mismatches: "lint"
# semver-range-mismatches: don't align dependency version format
semver-range-mismatches: "ignore"
# lockfile-mismatches: don't update lockfiles when mismatches detected
lockfile-mismatches: "ignore"
# format-mismatches: don't update package.json when inconsistent formatting detected
format-mismatches: "ignore"

10
.github/workflows/check-spelling.yml vendored Normal file
View file

@ -0,0 +1,10 @@
name: "Check spelling"
on: # rebuild any PRs and main branch changes
pull_request:
jobs:
spellcheck: # run the action
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: streetsidesoftware/cspell-action@22e32eb3d70acf30e3fc09bd46edc1d30fb2d6db

View file

@ -9,7 +9,6 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
#
# TODO
# - Add tags for each app (e.g. search@3.0.1) for easier parsing
# - Print versions name to commit, for easier Vercel deployments
#
jobs:
@ -19,19 +18,24 @@ jobs:
steps:
- name: Checkout Repo
uses: actions/checkout@v3
# Tags are fetched for Changeset to distinguish from new ones while running `changeset tag`
- name: Git fetch tags
run: git fetch --tags origin
- name: Setup Node.js 16 # It was default for Changesets action, check if it can be bumped to v18
uses: actions/setup-node@v3
with:
node-version: 16
- uses: pnpm/action-setup@v2
- uses: pnpm/action-setup@d882d12c64e032187b2edb46d3a0d003b7a43598
name: Install pnpm
- run: pnpm install
- name: Create Release Pull Request
uses: changesets/action@v1
uses: changesets/action@f13b1baaa620fde937751f5d2c3572b9da32af23
id: changesets
with:
title: Release apps
title: 🚀 Release apps
commit: Release apps
publish: pnpm github:release
createGithubReleases: true
env:
# Use private access token so Github can trigger another workflow from this one
GITHUB_TOKEN: ${{ secrets.PAT }}

View file

@ -1,33 +0,0 @@
name: "App: Monitoring backend tests"
on:
pull_request:
paths:
- "apps/monitoring/backend/**"
jobs:
unit_test:
name: Unit tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/monitoring/backend
steps:
- uses: actions/checkout@v3
- name: Install poetry
run: pipx install poetry
- name: Setup python
uses: actions/setup-python@v4
with:
python-version: "3.10"
cache: poetry
- name: Install dependencies
run: poetry install
- name: Run unit tests
run: poetry run pytest
- name: Run black
run: poetry run black .
- name: Run ruff
run: poetry run ruff .
- name: Run mypy
run: poetry run mypy .

View file

@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.4
- uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
- uses: actions/setup-node@v3
with:
node-version: 18
@ -19,7 +19,7 @@ jobs:
- name: Generate schema
run: pnpm generate
- name: Test
run: pnpm test:ci
run: pnpm test
# TODO: Add coverage - crawl through every package
# - name: Generate coverage report
# uses: irongut/CodeCoverageSummary@v1.3.0

3
.gitignore vendored
View file

@ -41,3 +41,6 @@ apps/**/generated
.eslintcache
.sentryclirc
.vscode/
.cspellcache

View file

@ -1,4 +1,5 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
npm run format-package-json
npm run lint-staged

View file

@ -1 +1 @@
* @saleor/appstore
* @saleor/delivery-engineering-js

View file

@ -16,6 +16,8 @@
<a href="https://saleor.io/blog/">📰 Blog</a>
<span></span>
<a href="https://twitter.com/getsaleor">🐦 Twitter</a>
<span></span>
<a href="https://discord.gg/H52JTZAtSH">💬 Discord</a>
</div>
<div align="center">
@ -26,6 +28,13 @@
<a href="https://github.com/saleor/apps/discussions/categories/integrations-features">✍️ Propose an app</a>
</div>
<br/>
<div align="center">
[![Discord Badge](https://dcbadge.vercel.app/api/server/H52JTZAtSH)](https://discord.gg/H52JTZAtSH)
</div>
## Overview
This repository serves as a starting point in the exploration of Saleor apps.
@ -38,14 +47,22 @@ This repository serves as a starting point in the exploration of Saleor apps.
In the `apps` folder, you will find the following applications:
- [crm](https://docs.saleor.io/docs/3.x/developer/app-store/apps/crm) - exports customers from Saleor to CRM.
- [cms](https://docs.saleor.io/docs/3.x/developer/app-store/apps/cms) - exports products from Saleor to CMS.
- [data-importer](./apps/data-importer) - import data from CSV to Saleor.
- [invoices](./apps/invoices) - generate invoice PDF for each order.
- [emails-and-messages](https://docs.saleor.io/docs/3.x/developer/app-store/apps/emails-and-messages/overview) - notifications and email communication with customers.
- [invoices](https://docs.saleor.io/docs/3.x/developer/app-store/apps/invoices) - generate invoice PDF for each order.
- [klaviyo](./apps/klaviyo) - send Saleor events to Klaviyo, where you can notify the customers.
- [emails-and-messages](./apps/emails-and-messages) - notifications and email communication with customers.
- [monitoring](./apps/monitoring) - send Saleor logs to 3rd party Monitoring services
- [products-feed](./apps/products-feed) - generate products feed XML
- [search](./apps/search) - connect Saleor with search engines.
- [slack](./apps/slack) - get notifications on Slack channel from Saleor events.
- [taxes](https://docs.saleor.io/docs/3.x/developer/app-store/apps/taxes) - calculate order and checkout taxes using external services.
- [cms](./apps/cms) - exports products from Saleor to CMS.
## Another official apps
Some of Saleor apps are available in separate repositories:
- [Stripe](https://github.com/saleor/saleor-app-payment-stripe)
## Development

18
apps/cms-v2/.env.example Normal file
View file

@ -0,0 +1,18 @@
# The key used for metadata encryption. Required for production builds
SECRET_KEY=
# APL Config
# https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md
APL=file
REST_APL_ENDPOINT=
REST_APL_TOKEN=
APP_LOG_LEVEL=info
# Local development variables. When developped locally with Saleor inside docker, these can be set to:
# APP_IFRAME_BASE_URL = http://localhost:3000, so Dashboard on host can access iframe
# APP_API_BASE_URL=http://host.docker.internal:3000 - so Saleor can reach App running on host, from the container.
# If developped with tunnels, set this empty, it will fallback to default Next's localhost:3000
# https://docs.saleor.io/docs/3.x/developer/extending/apps/local-app-development
APP_IFRAME_BASE_URL=
APP_API_BASE_URL=

View file

@ -10,7 +10,6 @@ extensions:
plugins:
- typescript
- typescript-operations
- urql-introspection
- typescript-urql:
documentVariablePrefix: "Untyped"
fragmentVariablePrefix: "Untyped"

121
apps/cms-v2/CHANGELOG.md Normal file
View file

@ -0,0 +1,121 @@
# saleor-app-cms-v2
## 2.3.3
### Patch Changes
- 5d3d81d: Bumped @hookform/resolvers from 2.9.11 to 3.3.1
- 5dee65a: Updated dependencies:
- @graphql-codegen/cli@5.0.0
- 2e29699: Updated Sentry package
## 2.3.2
### Patch Changes
- e8660e8: Extracted UI components and use shared package
- 30140ee: Improved some text typos.
- Updated dependencies [e8660e8]
- Updated dependencies [e8660e8]
- Updated dependencies [e8660e8]
- Updated dependencies [e8660e8]
- Updated dependencies [e8660e8]
- Updated dependencies [e8660e8]
- @saleor/apps-shared@1.9.0
- @saleor/apps-ui@1.2.0
## 2.3.1
### Patch Changes
- a81f061: Updated Macaw to pre-127
- d9d0f64: Fixed background color of the modal. Previously white background was missing and only blur was applied. Now it is blurred and white with an opacity (in the dark mode it is fixed with dark grey)
- fcc37e7: Remove clsx package from the projects no longer using it.
- Updated dependencies [2a1385b]
- Updated dependencies [a81f061]
- Updated dependencies [fcc37e7]
- @saleor/apps-shared@1.8.1
- @saleor/react-hook-form-macaw@0.2.5
- @saleor/apps-ui@1.1.8
## 2.3.0
### Minor Changes
- 6f1c5c9: Added Payload CMS support.
### Patch Changes
- e9378e7: Fix PayloadCMS form where two inputs were stuck together without a margin. Now, they are placed in two columns
- 6f1c5c9: Fix styling of modal in the dark mode
## 2.2.2
### Patch Changes
- 8b3d961: Updated Macaw UI to pre.118
- c50797e: Extracted MetadataManager creation to factory from shared package
- Updated dependencies [8b3d961]
- Updated dependencies [c50797e]
- @saleor/react-hook-form-macaw@0.2.4
- @saleor/apps-shared@1.8.0
- @saleor/apps-ui@1.1.7
## 2.2.1
### Patch Changes
- 3002354: Added error logging for exceptions thrown at tRPC routes.
## 2.2.0
### Minor Changes
- fe767a4: Changed public (manifest) name app to be "CMS" (previously it was "CMS 2", since two apps existed at the same time)
## 2.1.2
### Patch Changes
- 261957f: Updated dependencies: vite, vitest, eslint, prettier, dotenv, turbo, syncpack, changesets, lint staged
- Updated dependencies [261957f]
- @saleor/react-hook-form-macaw@0.2.3
- @saleor/apps-shared@1.7.6
- @saleor/apps-ui@1.1.6
## 2.1.1
### Patch Changes
- 2fab86b: Updated graphql package to 16.7.1 and @graphql-codegen related dependencies to the latest version.
- aa6fec1: Updated Macaw UI to pre-106
- 4578659: Made Builder.io api keys inputs type of "password" so they are masked now
- Updated dependencies [aa6fec1]
- Updated dependencies [aa6fec1]
- @saleor/react-hook-form-macaw@0.2.2
- @saleor/apps-shared@1.7.5
- @saleor/apps-ui@1.1.5
## 2.1.0
### Minor Changes
- 5d41af9: Added validation for channel-provider connection. Now form will display error when user tries to add a connection that already exists.
- 5d41af9: Added set of improvements around app quality
- Ensured forms have fields properly set as "required", so form validation will prevent empty form submissions
- Contentful and DatoCMS forms now validate the credentials.
- Added logs (server side) in various places
- Bulk sync finished now triggers notification
- 5d41af9: Added skeletons instead raw "Loading" texts. Also removed unnecessary warning message in bulk sync section
### Patch Changes
- Updated dependencies [70cb741]
- Updated dependencies [e7c2d3a]
- Updated dependencies [3c6cd4c]
- Updated dependencies [6210447]
- @saleor/react-hook-form-macaw@0.2.1
- @saleor/apps-shared@1.7.4
- @saleor/apps-ui@1.1.4

View file

@ -0,0 +1,19 @@
fragment BulkImportProduct on Product {
id
name
slug
variants {
id
name
channelListings {
channel {
id
slug
}
price {
amount
currency
}
}
}
}

View file

@ -2,23 +2,17 @@ fragment WebhookProduct on Product {
id
name
slug
media {
url
}
channelListings {
id
channel {
id
slug
}
isPublished
}
variants {
id
name
sku
channelListings {
id
channel {
id
slug
@ -28,9 +22,5 @@ fragment WebhookProduct on Product {
currency
}
}
metadata {
key
value
}
}
}

View file

@ -1,25 +1,12 @@
fragment WebhookProductVariant on ProductVariant {
id
name
sku
product {
id
name
slug
media {
url
}
channelListings {
id
channel {
id
slug
}
isPublished
}
}
channelListings {
id
channel {
id
slug
@ -29,8 +16,4 @@ fragment WebhookProductVariant on ProductVariant {
currency
}
}
metadata {
key
value
}
}

View file

@ -0,0 +1,7 @@
query FetchChannels {
channels {
id
slug
name
}
}

View file

@ -0,0 +1,13 @@
query FetchProductsPaginated($channel: String, $after: String) {
products(first: 100, channel: $channel, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
...BulkImportProduct
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,43 @@
const { z } = require("zod");
const { withSentryConfig } = require("@sentry/nextjs");
const RequiredEnvs = z.object({
APL: z.string().min(1),
});
/** @type {import('next').NextConfig} */
const nextConfig = () => {
try {
RequiredEnvs.parse(process.env);
} catch (e) {
console.error("🚫 Missing required env variables, see message below");
console.error(e.issues);
process.exit(1);
}
return {
reactStrictMode: true,
transpilePackages: ["@saleor/apps-shared", "@saleor/apps-ui", "@saleor/react-hook-form-macaw"],
};
};
const isSentryPropertiesInEnvironment =
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_PROJECT && process.env.SENTRY_ORG;
const configWithSentry = withSentryConfig(
nextConfig,
{
silent: true,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
},
{
widenClientFileUpload: true,
transpileClientSDK: true,
tunnelRoute: "/monitoring",
hideSourceMaps: true,
disableLogger: true,
}
);
module.exports = isSentryPropertiesInEnvironment ? configWithSentry : nextConfig;

72
apps/cms-v2/package.json Normal file
View file

@ -0,0 +1,72 @@
{
"name": "saleor-app-cms-v2",
"version": "2.3.3",
"scripts": {
"build": "pnpm generate && next build",
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"generate": "graphql-codegen",
"lint": "next lint",
"lint:fix": "eslint --fix .",
"start": "next start",
"test": "vitest"
},
"dependencies": {
"@datocms/cma-client-browser": "2.0.0",
"@hookform/resolvers": "^3.3.1",
"@saleor/app-sdk": "0.43.1",
"@saleor/apps-shared": "workspace:*",
"@saleor/apps-ui": "workspace:*",
"@saleor/macaw-ui": "0.8.0-pre.127",
"@saleor/react-hook-form-macaw": "workspace:*",
"@sentry/nextjs": "7.67.0",
"@tanstack/react-query": "^4.29.19",
"@trpc/client": "10.38.1",
"@trpc/next": "10.38.1",
"@trpc/react-query": "10.38.1",
"@trpc/server": "10.38.1",
"@urql/exchange-auth": "^2.1.4",
"@vitejs/plugin-react": "4.0.4",
"contentful-management": "10.38.3",
"graphql": "16.7.1",
"graphql-tag": "^2.12.6",
"jsdom": "^20.0.3",
"next": "13.4.8",
"p-ratelimit": "1.0.1",
"pino": "^8.14.1",
"pino-pretty": "^10.0.0",
"qs": "6.11.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-error-boundary": "4.0.10",
"react-hook-form": "^7.43.9",
"strapi-sdk-js": "2.2.0",
"urql": "^4.0.4",
"usehooks-ts": "^2.9.1",
"vite": "4.4.8",
"vitest": "0.34.1",
"zod": "3.21.4"
},
"devDependencies": {
"@graphql-codegen/cli": "5.0.0",
"@graphql-codegen/introspection": "4.0.0",
"@graphql-codegen/typed-document-node": "5.0.1",
"@graphql-codegen/typescript": "4.0.1",
"@graphql-codegen/typescript-operations": "4.0.1",
"@graphql-codegen/typescript-urql": "3.7.3",
"@graphql-typed-document-node/core": "3.2.0",
"@testing-library/react": "^14.0.0",
"@testing-library/react-hooks": "^8.0.1",
"@types/qs": "^6.9.7",
"@types/react": "18.2.5",
"@types/react-dom": "18.2.5",
"eslint": "8.46.0",
"eslint-config-saleor": "workspace:*",
"node-mocks-http": "^1.12.2",
"typescript": "5.1.6"
},
"private": true,
"saleor": {
"schemaVersion": "3.10"
}
}

BIN
apps/cms-v2/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View 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}`,
});

View 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}`,
});

View 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}`,
});

View file

@ -0,0 +1,14 @@
import { BulkImportProductFragment } from "../../../generated/graphql";
export type BulkSyncProcessorHooks = {
onUploadStart?: (context: { variantId: string }) => void;
onUploadSuccess?: (context: { variantId: string }) => void;
onUploadError?: (context: { variantId: string; error: Error }) => void;
};
export interface BulkSyncProcessor {
uploadProducts(
products: BulkImportProductFragment[],
hooks: BulkSyncProcessorHooks
): Promise<void>;
}

View file

@ -0,0 +1,87 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { trpcClient } from "../trpc/trpc-client";
import { useForm } from "react-hook-form";
import { Select } from "@saleor/react-hook-form-macaw";
import { useRouter } from "next/router";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { ProvidersResolver } from "../providers/providers-resolver";
import { ButtonsBox, Layout, SkeletonLayout } from "@saleor/apps-ui";
const FormSchema = z.object({
connectionId: z.string().min(7),
});
const EmptyState = () => (
<Layout.AppSectionCard>
<Box display="flex" flexDirection={"column"} gap={4} justifyContent={"center"}>
<Text variant="heading">Bulk products synchronization</Text>
<Text>Create a channel connection above to enable bulk synchronization.</Text>
</Box>
</Layout.AppSectionCard>
);
export const BulkSyncSection = () => {
const { push } = useRouter();
const { data: connections } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
const { control, handleSubmit } = useForm({
defaultValues: {
connectionId: "",
},
resolver: zodResolver(FormSchema),
});
if (!connections || !providers) {
return <SkeletonLayout.Section />;
}
if (connections.length === 0) {
return <EmptyState />;
}
return (
<Layout.AppSectionCard>
<Text as="h2" marginBottom={6} variant="heading">
Bulk products synchronization
</Text>
<Text as="p">
Choose a connection and start synchronization. Process is running in the browser.
</Text>
<Text as="p" variant="bodyStrong">
Do not close the app until it is finished
</Text>
<Box
display="grid"
gap={4}
marginTop={4}
as="form"
onSubmit={handleSubmit((values) => {
push(`/bulk-sync/${values.connectionId}`);
})}
>
<Select
required
control={control}
name="connectionId"
label="Connection"
options={connections.map((c) => {
const provider = providers.find((p) => p.id === c.providerId)!;
const providerDisplay = ProvidersResolver.createProviderMeta(provider.type);
return {
label: `${c.channelSlug} -> ${provider?.configName} (${providerDisplay.displayName})`,
value: c.id,
};
})}
/>
<ButtonsBox>
<Button type="submit">Start sync</Button>
</ButtonsBox>
</Box>
</Layout.AppSectionCard>
);
};

View file

@ -0,0 +1,184 @@
import { Breadcrumbs, ButtonsBox, Layout } from "@saleor/apps-ui";
import { ArrowRightIcon, Box, Button, Text } from "@saleor/macaw-ui/next";
import { useEffect, useRef, useState } from "react";
import { ChannelProviderConnectionConfig, ProvidersConfig } from "../configuration";
import { AppHeader } from "../ui/app-header";
import { useBulkSyncProductsState } from "./use-bulk-sync-products-state";
import { useFetchAllProducts } from "./use-fetch-all-products";
import { VariantsSyncStatusList } from "./variants-sync-status-list";
import { ProvidersResolver } from "../providers/providers-resolver";
import { useDashboardNotification } from "@saleor/apps-shared";
const FetchProductsStep = (props: { onButtonClick(): void }) => {
return (
<Layout.AppSectionCard
footer={
<ButtonsBox>
<Button onClick={props.onButtonClick}>Prefetch products</Button>
</ButtonsBox>
}
>
<Text variant="heading" as="h2" marginBottom={4}>
Saleor products fetch
</Text>
<Text as="p">Click the button to start fetching products from Saleor API</Text>
<Text as="p">After products are fetched, you will be able to upload them to the CMS</Text>
</Layout.AppSectionCard>
);
};
const SaleorProductsFetchingStep = () => {
return (
<Box>
<Text variant="heading" as="h2" marginBottom={4}>
Saleor products fetch
</Text>
<Text as="p">Fetching...</Text>
</Box>
);
};
const SaleorProductsFetchedStep = (props: { productsNo: number; variantsNo: number }) => {
return (
<Box>
<Text variant="heading" as="h2" marginBottom={4}>
Saleor products fetch
</Text>
<Text as="p">
Fetched {props.productsNo} products and {props.variantsNo} variants
</Text>
</Box>
);
};
type Status = "initial" | "fetching" | "fetched" | "uploading";
export const BulkSyncView = ({
configuration,
connection,
}: {
configuration: ProvidersConfig.AnyFullShape;
connection: ChannelProviderConnectionConfig.FullShape;
}) => {
const processor = useRef(ProvidersResolver.createBulkSyncProcessor(configuration));
const [state, setState] = useState<Status>("initial");
const { notifySuccess } = useDashboardNotification();
const { products, finished: saleorProductsFetchFinished } = useFetchAllProducts(
state === "fetching",
connection.channelSlug,
);
const { productsStatusList, setInitialProducts, setItemStatus, finished } =
useBulkSyncProductsState();
useEffect(() => {
if (finished) {
notifySuccess("Bulk sync ended", "All products have been synced, please verify results");
}
}, [finished, notifySuccess]);
useEffect(() => {
if (!saleorProductsFetchFinished) {
return;
}
setInitialProducts(products);
setState("fetched");
}, [products, saleorProductsFetchFinished, setInitialProducts]);
useEffect(() => {
if (state !== "uploading") {
return;
}
processor.current.uploadProducts(products, {
onUploadStart({ variantId }) {
setItemStatus(variantId, "uploading");
},
onUploadSuccess({ variantId }) {
setItemStatus(variantId, "success");
},
onUploadError({ error, variantId }) {
// User will be notified about the error in the UI
setItemStatus(variantId, "error");
},
});
}, [state, products, configuration, setItemStatus]);
return (
<Box>
<AppHeader
text="Perform bulk products sync between Saleor and your CMS"
breadcrumbs={[
<Breadcrumbs.Item key="bulk-sync">Bulk Sync</Breadcrumbs.Item>,
<Breadcrumbs.Item key="connection-name">
<Box display="flex" gap={2} alignItems="center">
{connection.channelSlug}
<ArrowRightIcon /> {configuration.configName}
</Box>
</Breadcrumbs.Item>,
]}
/>
<Layout.AppSection
marginBottom={8}
heading="1. Fetch products"
sideContent={
<Text>First pre-fetch all Product Variants from Saleor. Do not close the app.</Text>
}
>
{(() => {
switch (state) {
case "initial": {
return (
<FetchProductsStep
onButtonClick={() => {
setState("fetching");
}}
/>
);
}
case "fetching": {
return <SaleorProductsFetchingStep />;
}
case "fetched":
case "uploading": {
return (
<SaleorProductsFetchedStep
productsNo={products.length}
variantsNo={productsStatusList?.length ?? 0}
/>
);
}
}
})()}
</Layout.AppSection>
{(state === "fetched" || state === "uploading") && productsStatusList && (
<Layout.AppSection
marginTop={14}
heading="2. Upload to the CMS"
sideContent={<Text>Send listed variants to the CMS</Text>}
>
<Layout.AppSectionCard>
<Text as="h2" marginBottom={4} variant="heading">
Upload products
</Text>
{state === "fetched" && (
<Box marginBottom={4}>
<Text as="p" marginBottom={2}>
Verify products below and click the button to start uploading.
</Text>
<Button onClick={() => setState("uploading")}>Start uploading</Button>
</Box>
)}
<VariantsSyncStatusList marginTop={8} variants={productsStatusList} />
</Layout.AppSectionCard>
</Layout.AppSection>
)}
</Box>
);
};

View file

@ -0,0 +1,54 @@
import { useCallback, useEffect, useState } from "react";
import { BulkImportProductFragment } from "../../../generated/graphql";
import { VariantsSyncStatusListItem } from "./variants-sync-status-list";
export const useBulkSyncProductsState = () => {
const [finished, setFinished] = useState(false);
const [productsStatusList, setProductsStatusList] = useState<VariantsSyncStatusListItem[] | null>(
null
);
useEffect(() => {
if (productsStatusList?.every((item) => item.status === "success" || item.status === "error")) {
setFinished(true);
}
}, [productsStatusList]);
return {
finished,
productsStatusList,
setInitialProducts: useCallback((products: BulkImportProductFragment[]) => {
setProductsStatusList(
products.flatMap((p) => {
const items: VariantsSyncStatusListItem[] =
p.variants?.map((v) => ({
productID: p.id,
productName: p.name,
status: "pending",
variantId: v.id,
variantName: v.name,
})) ?? [];
return items;
})
);
}, []),
setItemStatus: useCallback(
(variantID: string, status: VariantsSyncStatusListItem["status"]) => {
setProductsStatusList((items) =>
items!.map((item) => {
if (item.variantId === variantID) {
return {
...item,
status: status,
};
}
return item;
})
);
},
[]
),
};
};

View file

@ -0,0 +1,65 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useEffect, useState } from "react";
import { createGraphQLClient } from "@saleor/apps-shared";
import {
BulkImportProductFragment,
FetchProductsPaginatedDocument,
} from "../../../generated/graphql";
/**
* Original source - apps/search
*/
export const useFetchAllProducts = (started: boolean, channelSlug: string) => {
const { appBridgeState } = useAppBridge();
const saleorApiUrl = appBridgeState?.saleorApiUrl!;
const [products, setProducts] = useState<BulkImportProductFragment[]>([]);
const [finished, setFinished] = useState(false);
useEffect(() => {
if (!started) {
return;
}
if (!appBridgeState?.token) {
return;
}
const token = appBridgeState.token;
const client = createGraphQLClient({ saleorApiUrl, token });
if (!client) {
return;
}
const getProducts = async (cursor?: string): Promise<void> => {
const response = await client
.query(FetchProductsPaginatedDocument, {
after: cursor,
channel: channelSlug,
})
.toPromise();
const newProducts = response?.data?.products?.edges.map((e) => e.node) ?? [];
if (newProducts.length > 0) {
setProducts((ps) => [...ps, ...newProducts]);
}
if (
response?.data?.products?.pageInfo.hasNextPage &&
response?.data?.products?.pageInfo.endCursor
) {
return getProducts(response.data.products?.pageInfo.endCursor);
} else {
setFinished(true);
return;
}
};
getProducts(undefined);
}, [appBridgeState?.token, saleorApiUrl, started, channelSlug]);
return { products, finished };
};

View file

@ -0,0 +1,75 @@
import { SemanticChip } from "@saleor/apps-ui";
import { Box, BoxProps, Text } from "@saleor/macaw-ui/next";
export type VariantsSyncStatusListItem = {
productID: string;
productName: string;
variantName: string;
variantId: string;
status: "pending" | "uploading" | "success" | "error";
};
type Props = {
variants: Array<VariantsSyncStatusListItem>;
} & BoxProps;
const Row = (props: BoxProps) => (
<Box
padding={2}
display="grid"
__gridTemplateColumns={"2fr 2fr 1fr"}
gap={4}
alignItems="center"
{...props}
/>
);
export const VariantsSyncStatusList = ({ variants, ...props }: Props) => {
const renderStatus = (status: VariantsSyncStatusListItem["status"]) => {
switch (status) {
case "pending": {
return null;
}
case "success": {
return <SemanticChip variant="success">Uploaded</SemanticChip>;
}
case "error": {
return <SemanticChip variant="error">Error</SemanticChip>;
}
case "uploading": {
return <SemanticChip variant="default">Uploading</SemanticChip>;
}
}
};
return (
<Box {...props}>
<Row>
<Text variant="caption">Product</Text>
<Text variant="caption">Variant</Text>
<Text variant="caption">Status</Text>
</Row>
{variants.map((variant) => (
<Row key={variant.variantId}>
<Box>
<Text variant="bodyStrong" as="p">
{variant.productName}
</Text>
<Text size="small" as="p">
{variant.productID}
</Text>
</Box>
<Box>
<Text variant="bodyStrong" as="p">
{variant.variantName}
</Text>
<Text size="small" as="p">
{variant.variantId}
</Text>
</Box>
<Box>{renderStatus(variant.status)}</Box>
</Row>
))}
</Box>
);
};

View file

@ -0,0 +1,89 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightIcon, Box, Text } from "@saleor/macaw-ui/next";
import { Select } from "@saleor/react-hook-form-macaw";
import { useForm } from "react-hook-form";
import { ChannelProviderConnectionConfig } from "../configuration";
import { trpcClient } from "../trpc/trpc-client";
import { TRPCError } from "@trpc/server";
export type AddConnectionFormSchema = Omit<
ChannelProviderConnectionConfig.InputShape,
"providerType"
>;
export const AddConnectionFormID = "new-connection-form";
export const AddConnectionForm = (props: {
defaultValues: AddConnectionFormSchema;
onSubmit(values: AddConnectionFormSchema): Promise<void>;
}) => {
const { data: channels } = trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
const {
handleSubmit,
control,
setError,
formState: { errors },
} = useForm({
defaultValues: props.defaultValues,
resolver: zodResolver(
ChannelProviderConnectionConfig.Schema.Input.omit({ providerType: true })
),
});
const onSubmit = (values: AddConnectionFormSchema) => {
props.onSubmit(values).catch((err: TRPCError) => {
setError("channelSlug", {
type: "manual",
});
setError("providerId", {
type: "manual",
});
setError("root.serverError", {
type: "manual",
message: err.message,
});
});
};
return (
<Box onSubmit={handleSubmit(onSubmit)} as="form" id={AddConnectionFormID}>
<Box display="grid" __gridTemplateColumns="1fr auto 1fr" gap={4} alignItems="center">
<Select
required
size="small"
control={control}
name="channelSlug"
label="Channel"
options={
channels?.map((c) => ({
value: c.slug,
label: c.name,
})) ?? []
}
/>
<ArrowRightIcon />
<Select
required
size="small"
control={control}
name="providerId"
label="Provider"
options={
providers?.map((p) => ({
value: p.id,
label: p.configName,
})) ?? []
}
/>
</Box>
{errors.root?.serverError && (
<Text as="p" marginTop={2} color="textCriticalSubdued">
{errors.root?.serverError.message}
</Text>
)}
</Box>
);
};

View file

@ -0,0 +1,66 @@
import { Button, Text } from "@saleor/macaw-ui/next";
import { Modal } from "../ui/modal";
import {
AddConnectionForm,
AddConnectionFormID,
AddConnectionFormSchema,
} from "./add-connection-form";
import { trpcClient } from "../trpc/trpc-client";
import { ButtonsBox, SkeletonLayout } from "@saleor/apps-ui";
const defaultValues: AddConnectionFormSchema = { channelSlug: "", providerId: "" };
export const AddConnectionModal = (props: { onSuccess(): void; onClose(): void }) => {
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
if (!providers) {
return <SkeletonLayout.Section />;
}
const { mutateAsync: addProviderMutate, isLoading } =
trpcClient.channelsProvidersConnection.addConnection.useMutation({
onSuccess() {
props.onSuccess();
},
});
const handleFormSubmit = async (values: AddConnectionFormSchema) => {
const providerType = providers.find((p) => p.id === values.providerId)?.type;
if (!providerType) {
throw new Error("Provider not found");
}
return addProviderMutate({
...values,
providerType,
});
};
return (
<Modal onClose={props.onClose}>
<Text as="h2" variant="heading">
Connect channel with Provider
</Text>
<Text as="p" marginBottom={6}>
Once connected, operations on product variants on this channel will be sent to selected CMS
platform.
</Text>
<AddConnectionForm onSubmit={handleFormSubmit} defaultValues={defaultValues} />
<ButtonsBox marginTop={8}>
<Button
disabled={isLoading}
variant="tertiary"
onClick={() => {
props.onClose();
}}
>
Close
</Button>
<Button variant="primary" type="submit" form={AddConnectionFormID}>
Add connection
</Button>
</ButtonsBox>
</Modal>
);
};

View file

@ -0,0 +1,71 @@
import { AppConfigMetadataManager } from "@/modules/configuration/app-config-metadata-manager";
import { createSettingsManager } from "@/modules/configuration/metadata-manager";
import { ChannelProviderConnectionConfig } from "@/modules/configuration/schemas/channel-provider-connection.schema";
import { protectedClientProcedure } from "@/modules/trpc/protected-client-procedure";
import { router } from "@/modules/trpc/trpc-server";
import { z } from "zod";
import { FetchChannelsDocument } from "../../../generated/graphql";
import { TRPCError } from "@trpc/server";
const procedure = protectedClientProcedure.use(({ ctx, next }) => {
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
return next({
ctx: {
appConfigService: new AppConfigMetadataManager(settingsManager),
},
});
});
export const channelProviderConnectionRouter = router({
fetchAllChannels: protectedClientProcedure.query(async ({ ctx }) => {
const channels = await ctx.apiClient.query(FetchChannelsDocument, {});
return channels.data?.channels ?? [];
}),
fetchConnections: procedure.query(async ({ ctx }) => {
return (await ctx.appConfigService.get()).connections.getConnections();
}),
fetchConnection: procedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
return (await ctx.appConfigService.get()).connections.getConnectionById(input.id) ?? null;
}),
addConnection: procedure
.input(ChannelProviderConnectionConfig.Schema.Input)
.mutation(async ({ ctx, input }) => {
const config = await ctx.appConfigService.get();
try {
config.connections.addConnection(input);
} catch (e) {
switch ((e as { cause: string }).cause) {
case "PROVIDER_DOESNT_EXIST":
throw new TRPCError({
code: "BAD_REQUEST",
cause: "PROVIDER_DOESNT_EXIST",
message: "Provider doesnt exist",
});
case "CONNECTION_ALREADY_EXISTS":
throw new TRPCError({
code: "CONFLICT",
cause: "CONNECTION_EXISTS",
message: "Connection already exists",
});
}
}
ctx.appConfigService.set(config);
}),
removeConnection: procedure
.input(
z.object({
id: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const config = await ctx.appConfigService.get();
config.connections.deleteConnection(input.id);
ctx.appConfigService.set(config);
}),
});

View file

@ -0,0 +1,7 @@
import { Text } from "@saleor/macaw-ui/next";
export const ChanelProviderConnectionsSectionHeader = () => (
<Text marginBottom={4} as="h2" variant="heading">
Channels Connections
</Text>
);

View file

@ -0,0 +1,94 @@
import { useDashboardNotification } from "@saleor/apps-shared";
import { ButtonsBox, Layout, SkeletonLayout } from "@saleor/apps-ui";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { useState } from "react";
import { trpcClient } from "../trpc/trpc-client";
import { AddConnectionModal } from "./add-connection-modal";
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
import { ConnectionsList } from "./connections-list";
const NoConnections = (props: { onCreate(): void; enabled: boolean }) => (
<Box>
<ChanelProviderConnectionsSectionHeader />
<Text as="p">
No channels connected yet.{" "}
{!props.enabled &&
"Ensure you have created a provider configuration that can be connected first."}
</Text>
</Box>
);
export const ChannelProviderConnectionList = () => {
const [dialogOpen, setDialogOpen] = useState(false);
const {
data: connectionsData,
refetch: refetchConnections,
isLoading,
} = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
const { mutate: removeConnection } =
trpcClient.channelsProvidersConnection.removeConnection.useMutation({
onSuccess() {
refetchConnections();
notifySuccess("Success", "Removed connection");
},
});
const { notifySuccess } = useDashboardNotification();
// Prefetch
trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
if (!providers) {
return <SkeletonLayout.Section />;
}
const handleDelete = (connectionId: string) => {
removeConnection({ id: connectionId });
};
if (isLoading || !connectionsData) {
return <SkeletonLayout.Section />;
}
return (
<Layout.AppSectionCard
footer={
providers.length > 0 && (
<ButtonsBox>
<Button
onClick={() => {
setDialogOpen(true);
}}
>
Add connection
</Button>
</ButtonsBox>
)
}
>
{dialogOpen && (
<AddConnectionModal
onClose={() => {
setDialogOpen(false);
}}
onSuccess={() => {
refetchConnections();
notifySuccess("Success", "Connection created");
setDialogOpen(false);
}}
/>
)}
{connectionsData.length === 0 && (
<NoConnections
enabled={providers.length > 0}
onCreate={() => {
setDialogOpen(true);
}}
/>
)}
{connectionsData.length > 0 && <ConnectionsList onRemove={handleDelete} />}
</Layout.AppSectionCard>
);
};

View file

@ -0,0 +1,56 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import React from "react";
import { trpcClient } from "../trpc/trpc-client";
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
import { ProvidersResolver } from "../providers/providers-resolver";
import { SkeletonLayout } from "@saleor/apps-ui";
export const ConnectionsList = (props: { onRemove(connectionId: string): void }) => {
const { data } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
const { data: channels } = trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
if (!data || !providers) {
return <SkeletonLayout.Section />;
}
return (
<Box>
<ChanelProviderConnectionsSectionHeader />
<Box
display="grid"
justifyContent={"space-between"}
__gridTemplateColumns={"1fr 1fr auto"}
gap={4}
alignItems="center"
>
<Text variant="caption">Saleor Channel</Text>
<Text variant="caption">Target CMS</Text>
<div />
{data?.map((conn) => {
const provider = providers.find((p) => p.id === conn.providerId);
if (!provider) {
throw new Error("Provider not found");
}
const providerName = ProvidersResolver.createProviderMeta(provider.type).displayName;
return (
<React.Fragment key={conn.id}>
<Text>{channels?.find((c) => c.slug === conn.channelSlug)?.name}</Text>
<Text>
<Text>{provider.configName}</Text>
<Text color="textNeutralSubdued"> ({providerName})</Text>
</Text>
<Button onClick={() => props.onRemove(conn.id)} variant="tertiary">
Remove
</Button>
</React.Fragment>
);
})}
</Box>
</Box>
);
};

View file

@ -0,0 +1,36 @@
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { AppConfig } from "./app-config";
import { createSettingsManager } from "./metadata-manager";
import { createGraphQLClient } from "@saleor/apps-shared";
import { AuthData } from "@saleor/app-sdk/APL";
export class AppConfigMetadataManager {
public readonly metadataKey = "app-config-v1";
constructor(private mm: SettingsManager) {}
async get() {
const metadata = await this.mm.get(this.metadataKey);
return metadata ? AppConfig.parse(metadata) : new AppConfig();
}
set(config: AppConfig) {
return this.mm.set({
key: this.metadataKey,
value: config.serialize(),
});
}
static createFromAuthData(authData: AuthData): AppConfigMetadataManager {
const settingsManager = createSettingsManager(
createGraphQLClient({
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
}),
authData.appId
);
return new AppConfigMetadataManager(settingsManager);
}
}

View file

@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach } from "vitest";
import { AppConfig } from "./app-config";
import { ContentfulProviderConfig } from "./schemas/contentful-provider.schema";
import { DatocmsProviderConfig } from "./schemas/datocms-provider.schema";
const getMockContentfulInput = (): ContentfulProviderConfig.InputShape => {
return {
configName: "Test",
type: "contentful",
contentId: "test",
authToken: "test",
environment: "test",
productVariantFieldsMapping: {
channels: "channels",
productId: "productId",
productName: "productName",
productSlug: "productSlug",
variantId: "variantId",
variantName: "variantName",
},
spaceId: "test",
};
};
const getMockDatocmsInput = (): DatocmsProviderConfig.InputShape => {
return {
configName: "Test",
type: "datocms",
itemType: "test",
authToken: "test",
productVariantFieldsMapping: {
channels: "channels",
productId: "productId",
productName: "productName",
productSlug: "productSlug",
variantId: "variantId",
variantName: "variantName",
},
};
};
describe("App Config", () => {
let appConfig: AppConfig;
beforeEach(() => {
appConfig = new AppConfig();
});
it("Constructs with empty state", () => {
expect(appConfig.connections.getConnections()).toEqual([]);
expect(appConfig.providers.getProviders()).toEqual([]);
});
describe("Providers", () => {
it("Can retrieve provider by ID from the config", () => {
appConfig.providers.addProvider(getMockContentfulInput());
const provider = appConfig.providers.getProviders()[0];
expect(provider).toBeDefined();
expect(appConfig.providers.checkProviderExists(provider.id)).toBe(true);
expect(appConfig.providers.getProviderById(provider.id)).toBeDefined();
});
it("Can update the provider", () => {
appConfig.providers.addProvider(getMockContentfulInput());
const provider = appConfig.providers.getProviders()[0] as ContentfulProviderConfig.FullShape;
appConfig.providers.updateProvider({
...provider,
spaceId: "new-space-id",
});
const providerUpdated =
appConfig.providers.getProviders()[0] as ContentfulProviderConfig.FullShape;
expect(providerUpdated.spaceId).toBe("new-space-id");
});
it("Can delete provider", () => {
appConfig.providers.addProvider(getMockContentfulInput());
const provider = appConfig.providers.getProviders()[0];
expect(provider).toBeDefined();
appConfig.providers.deleteProvider(provider.id);
expect(appConfig.providers.getProviders()).toEqual([]);
});
});
describe("Connections", () => {
it("Throws if trying to add connection with provider that doesnt exist", () => {
expect(() => {
appConfig.connections.addConnection({
channelSlug: "test",
providerId: "asdasd123",
providerType: "contentful",
});
}).toThrow();
});
it("Can add connection with existing provider", () => {
appConfig.providers.addProvider(getMockContentfulInput());
appConfig.connections.addConnection({
channelSlug: "test",
providerId: appConfig.providers.getProviders()[0].id,
providerType: "contentful",
});
expect(appConfig.connections.getConnections().length).toBe(1);
});
it("Removes all connections that include passed provider", () => {
appConfig.providers.addProvider(getMockContentfulInput());
appConfig.providers.addProvider(getMockDatocmsInput());
appConfig.connections.addConnection({
channelSlug: "test",
providerId: appConfig.providers.getProviders()[0].id,
providerType: "contentful",
});
appConfig.connections.addConnection({
channelSlug: "test2",
providerId: appConfig.providers.getProviders()[0].id,
providerType: "contentful",
});
appConfig.connections.addConnection({
channelSlug: "test2",
providerId: appConfig.providers.getProviders()[1].id,
providerType: "datocms",
});
expect(appConfig.connections.getConnections().length).toBe(3);
appConfig.providers.deleteProvider(appConfig.providers.getProviders()[0].id);
const leftConnections = appConfig.connections.getConnections();
expect(leftConnections.length).toBe(1);
expect(leftConnections[0].providerType).toBe("datocms");
});
});
});

View file

@ -0,0 +1,148 @@
import { ProvidersResolver } from "../providers/providers-resolver";
import { generateId } from "../shared/generate-id";
import { ChannelProviderConnectionConfig } from "./schemas/channel-provider-connection.schema";
import { ProvidersConfig, RootConfig } from "./schemas/root-config.schema";
export const Errors = {
CONNECTION_ALREADY_EXISTS: "CONNECTION_ALREADY_EXISTS",
PROVIDER_DOESNT_EXIST: "PROVIDER_DOESNT_EXIST",
};
/**
* TODO
* - test
* - extract and delegate smaller configs?
*/
export class AppConfig {
private rootData: RootConfig.Shape = {
providers: [],
connections: [],
};
constructor(initialData?: RootConfig.Shape) {
if (initialData) {
this.rootData = RootConfig.Schema.parse(initialData);
}
}
static parse(serializedSchema: string) {
return new AppConfig(JSON.parse(serializedSchema));
}
serialize() {
return JSON.stringify(this.rootData);
}
providers = {
checkProviderExists: (id: string) => {
return !!this.rootData.providers.find((p) => p.id === id);
},
addProvider: (providerConfigInput: ProvidersConfig.AnyInputShape) => {
const inputSchema = ProvidersResolver.getProviderInputSchema(providerConfigInput.type);
const parsedConfig = inputSchema.parse(providerConfigInput);
this.rootData.providers.push({
...parsedConfig,
id: generateId(),
});
return this;
},
updateProvider: (providerConfig: ProvidersConfig.AnyFullShape) => {
const schema = ProvidersResolver.getProviderSchema(providerConfig.type);
const parsedConfig = schema.parse(providerConfig);
this.rootData.providers = this.rootData.providers.map((p) => {
if (p.id === parsedConfig.id) {
return parsedConfig;
} else {
return p;
}
});
},
deleteProvider: (id: string) => {
this.rootData.providers = this.rootData.providers.filter((p) => p.id !== id);
this.connections.deleteConnectionsWithProvider(id);
return this;
},
getProviders: () => {
return this.rootData.providers;
},
getProviderById: (id: string) => {
return this.providers.getProviders().find((p) => p.id === id);
},
};
connections = {
getConnections: () => {
return this.rootData.connections;
},
deleteConnection: (connectionID: string) => {
this.rootData.connections = this.rootData.connections.filter((c) => c.id !== connectionID);
return this;
},
addConnection: (input: ChannelProviderConnectionConfig.InputShape) => {
if (!this.providers.checkProviderExists(input.providerId)) {
const err = new Error("Provider doesnt exist");
err.cause = Errors.PROVIDER_DOESNT_EXIST;
throw err;
}
const parsed = ChannelProviderConnectionConfig.Schema.Input.parse(input);
if (this.checkConnectionExists(input)) {
const error = new Error("Connection already exists");
error.cause = Errors.CONNECTION_ALREADY_EXISTS;
throw error;
}
this.rootData.connections.push({
...parsed,
id: generateId(),
});
return this;
},
deleteConnectionsWithProvider: (providerId: string) => {
this.rootData.connections = this.rootData.connections.filter((conn) => {
return conn.providerId !== providerId;
});
},
getConnectionById: (id: string) => {
return this.connections.getConnections().find((c) => c.id === id);
},
};
/**
* Returns true if exists
*/
private checkConnectionExists(newConnection: ChannelProviderConnectionConfig.InputShape) {
/**
* Make array of strings so its easy to compare
*/
const existingConnectionsAsStrings = this.connections
.getConnections()
.map((connection) => `${connection.channelSlug}-${connection.providerId}`);
const newConnectionAsString = `${newConnection.channelSlug}-${newConnection.providerId}`;
return existingConnectionsAsStrings.includes(newConnectionAsString);
}
}

View file

@ -0,0 +1,13 @@
/**
* Re-export for convenient imports
*/
export * from "./app-config";
export * from "./app-config-metadata-manager";
export * from "./metadata-manager";
export * from "./schemas/channel-provider-connection.schema";
export * from "./schemas/contentful-provider.schema";
export * from "./schemas/builder-provider.schema";
export * from "./schemas/datocms-provider.schema";
export * from "./schemas/strapi-provider.schema";
export * from "./schemas/saleor-provider-fields-mapping.schema";
export * from "./schemas/root-config.schema";

View file

@ -0,0 +1,12 @@
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { EncryptedMetadataManagerFactory } from "@saleor/apps-shared";
import { Client } from "urql";
export const createSettingsManager = (
client: Pick<Client, "query" | "mutation">,
appId: string,
): SettingsManager => {
const metadataManagerFactory = new EncryptedMetadataManagerFactory(process.env.SECRET_KEY!);
return metadataManagerFactory.create(client, appId);
};

View file

@ -0,0 +1,24 @@
import { SaleorProviderFieldsMappingKey } from "./schemas/saleor-provider-fields-mapping.schema";
export const printSaleorProductFields = (fieldName: SaleorProviderFieldsMappingKey) => {
switch (fieldName) {
case "variantName": {
return "Variant Name";
}
case "channels": {
return "Channels";
}
case "productId": {
return "Product ID";
}
case "productName": {
return "Product Name";
}
case "productSlug": {
return "Product Slug";
}
case "variantId": {
return "Variant ID";
}
}
};

View file

@ -0,0 +1,26 @@
import { z } from "zod";
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
import { BuilderIo } from "@/modules/providers/builder.io/builder-io";
const InputSchema = z.object({
type: z.literal(BuilderIo.type),
privateApiKey: z.string().min(1),
publicApiKey: z.string().min(1),
configName: z.string().min(1),
modelName: z.string().min(1),
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
});
const FullSchema = InputSchema.extend({
id: z.string(),
});
export namespace BuilderIoProviderConfig {
export type InputShape = z.infer<typeof InputSchema>;
export type FullShape = z.infer<typeof FullSchema>;
export const Schema = {
Input: InputSchema,
Full: FullSchema,
};
}

View file

@ -0,0 +1,22 @@
import { cmsTypes } from "@/modules/providers/providers-registry";
import { z } from "zod";
const InputSchema = z.object({
channelSlug: z.string().min(1),
providerId: z.string().min(1),
providerType: z.enum(cmsTypes),
});
const FullSchema = InputSchema.extend({
id: z.string(),
});
export namespace ChannelProviderConnectionConfig {
export type InputShape = z.infer<typeof InputSchema>;
export type FullShape = z.infer<typeof FullSchema>;
export const Schema = {
Input: InputSchema,
Full: FullSchema,
};
}

View file

@ -0,0 +1,27 @@
import { z } from "zod";
import { Contentful } from "../../providers/contentful/contentful";
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
const InputSchema = z.object({
type: z.literal(Contentful.type),
authToken: z.string().min(1),
spaceId: z.string().min(1),
environment: z.string().min(1),
configName: z.string().min(1),
contentId: z.string().min(1),
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
});
const FullSchema = InputSchema.extend({
id: z.string(),
});
export namespace ContentfulProviderConfig {
export type InputShape = z.infer<typeof InputSchema>;
export type FullShape = z.infer<typeof FullSchema>;
export const Schema = {
Input: InputSchema,
Full: FullSchema,
};
}

View file

@ -0,0 +1,25 @@
import { Datocms } from "@/modules/providers/datocms/datocms";
import { z } from "zod";
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
const InputSchema = z.object({
type: z.literal(Datocms.type),
authToken: z.string().min(1),
configName: z.string().min(1),
itemType: z.string().min(1),
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
});
const FullSchema = InputSchema.extend({
id: z.string(),
});
export namespace DatocmsProviderConfig {
export type InputShape = z.infer<typeof InputSchema>;
export type FullShape = z.infer<typeof FullSchema>;
export const Schema = {
Input: InputSchema,
Full: FullSchema,
};
}

View file

@ -0,0 +1,27 @@
import { PayloadCMS } from "@/modules/providers/payloadcms/payloadcms";
import { z } from "zod";
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
const InputSchema = z.object({
type: z.literal(PayloadCMS.type),
authToken: z.string(),
configName: z.string().min(1),
collectionName: z.string().min(1),
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
payloadApiUrl: z.string().url(),
authenticatedUserSlug: z.string(),
});
const FullSchema = InputSchema.extend({
id: z.string(),
});
export namespace PayloadCmsProviderConfig {
export type InputShape = z.infer<typeof InputSchema>;
export type FullShape = z.infer<typeof FullSchema>;
export const Schema = {
Input: InputSchema,
Full: FullSchema,
};
}

View file

@ -0,0 +1,49 @@
import { z } from "zod";
import { ChannelProviderConnectionConfig } from "./channel-provider-connection.schema";
import { ContentfulProviderConfig } from "./contentful-provider.schema";
import { BuilderIoProviderConfig } from "./builder-provider.schema";
import { StrapiProviderConfig } from "./strapi-provider.schema";
import { DatocmsProviderConfig } from "./datocms-provider.schema";
import { PayloadCmsProviderConfig } from "./payloadcms-provider.schema";
export namespace ProvidersConfig {
const AnyFull = z.union([
/**
* Add more for each provider
*/
ContentfulProviderConfig.Schema.Full,
DatocmsProviderConfig.Schema.Full,
StrapiProviderConfig.Schema.Full,
BuilderIoProviderConfig.Schema.Full,
PayloadCmsProviderConfig.Schema.Full,
]);
export const Schema = {
AnyFull: AnyFull,
AnyInput: z.union([
ContentfulProviderConfig.Schema.Input,
DatocmsProviderConfig.Schema.Input,
StrapiProviderConfig.Schema.Input,
BuilderIoProviderConfig.Schema.Input,
PayloadCmsProviderConfig.Schema.Input,
]),
AnyFullList: z.array(AnyFull),
};
export type AnyFullShape = z.infer<typeof Schema.AnyFull>;
export type AnyInputShape = z.infer<typeof Schema.AnyInput>;
}
export namespace RootConfig {
/**
* Store entire app config in single file
* - Only one request
* - Always transactional
*/
export const Schema = z.object({
providers: ProvidersConfig.Schema.AnyFullList,
connections: z.array(ChannelProviderConnectionConfig.Schema.Full),
});
export type Shape = z.infer<typeof Schema>;
}

View file

@ -0,0 +1,25 @@
import { z } from "zod";
/**
* TODO Consider optional fields
*/
export const SaleorProviderFieldsMappingSchema = z.object({
variantId: z.string().min(1),
variantName: z.string().min(1),
productId: z.string().min(1),
productName: z.string().min(1),
productSlug: z.string().min(1),
channels: z.string().min(1),
});
export type SaleorProviderFieldsMappingType = z.infer<typeof SaleorProviderFieldsMappingSchema>;
export type SaleorProviderFieldsMappingKey = keyof SaleorProviderFieldsMappingType;
export const SaleorProviderFieldsMappingKeys: Array<SaleorProviderFieldsMappingKey> = [
"variantId",
"variantName",
"productId",
"productName",
"productSlug",
"channels",
];

View file

@ -0,0 +1,26 @@
import { Strapi } from "@/modules/providers/strapi/strapi";
import { z } from "zod";
import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapping.schema";
const InputSchema = z.object({
configName: z.string(),
type: z.literal(Strapi.type),
url: z.string().url().min(1),
authToken: z.string().min(1),
itemType: z.string().min(1),
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
});
const FullSchema = InputSchema.extend({
id: z.string(),
});
export namespace StrapiProviderConfig {
export type InputShape = z.infer<typeof InputSchema>;
export type FullShape = z.infer<typeof FullSchema>;
export const Schema = {
Input: InputSchema,
Full: FullSchema,
};
}

View file

@ -2,7 +2,7 @@ import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { PropsWithChildren } from "react";
import { Provider } from "urql";
import { createClient } from "../lib/graphql";
import { createGraphQLClient } from "@saleor/apps-shared";
export function GraphQLProvider(props: PropsWithChildren<{}>) {
const { appBridgeState } = useAppBridge();
@ -12,9 +12,10 @@ export function GraphQLProvider(props: PropsWithChildren<{}>) {
return <div {...props}></div>;
}
const client = createClient(saleorApiUrl, async () =>
Promise.resolve({ token: appBridgeState?.token! })
);
const client = createGraphQLClient({
saleorApiUrl,
token: appBridgeState.token,
});
return <Provider value={client} {...props} />;
}

View file

@ -0,0 +1,61 @@
import { createSettingsManager } from "../configuration/metadata-manager";
import { z } from "zod";
import { AppConfigMetadataManager } from "../configuration/app-config-metadata-manager";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { router } from "../trpc/trpc-server";
import { ProvidersConfig } from "../configuration";
const procedure = protectedClientProcedure.use(({ ctx, next }) => {
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
return next({
ctx: {
settingsManager,
appConfigService: new AppConfigMetadataManager(settingsManager),
},
});
});
export const providersListRouter = router({
getAll: procedure.query(async ({ ctx: { appConfigService } }) => {
const config = await appConfigService.get();
const providers = config.providers.getProviders();
return providers;
}),
getOne: procedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx: { appConfigService }, input }) => {
const config = await appConfigService.get();
return config.providers.getProviderById(input.id) ?? null;
}),
addOne: procedure
.input(ProvidersConfig.Schema.AnyInput)
.mutation(async ({ ctx: { appConfigService }, input }) => {
const config = await appConfigService.get();
config.providers.addProvider(input);
await appConfigService.set(config);
}),
updateOne: procedure
.input(ProvidersConfig.Schema.AnyFull)
.mutation(async ({ input, ctx: { appConfigService } }) => {
const config = await appConfigService.get();
config?.providers.updateProvider(input);
return appConfigService.set(config);
}),
deleteOne: procedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx: { appConfigService } }) => {
const config = await appConfigService.get();
config.providers.deleteProvider(input.id);
return appConfigService.set(config);
}),
});

View file

@ -0,0 +1,99 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { useRouter } from "next/router";
import React from "react";
import { ProvidersConfig } from "../configuration";
import { ProvidersResolver } from "../providers/providers-resolver";
import { trpcClient } from "../trpc/trpc-client";
import { ButtonsBox, Layout, SkeletonLayout } from "@saleor/apps-ui";
const ProvidersTable = (props: { providers: ProvidersConfig.AnyFullShape[] }) => {
const { push } = useRouter();
return (
<Box display="grid" __gridTemplateColumns="repeat(2, auto)" gap={4} alignItems="center">
<Text variant="caption">Configuration name</Text>
<div />
{props.providers.map((provider) => (
<React.Fragment key={provider.id}>
<Box>
<Text as="p" variant="bodyStrong">
{provider.configName}
</Text>
<Text as="p" variant="caption">
{ProvidersResolver.createProviderMeta(provider.type).displayName}
</Text>
</Box>
<Button
marginLeft="auto"
variant="tertiary"
onClick={() => {
push(`/edit-provider/` + provider.id);
}}
>
Edit
</Button>
</React.Fragment>
))}
</Box>
);
};
export const ProvidersList = () => {
const { data } = trpcClient.providersConfigs.getAll.useQuery();
const { push } = useRouter();
if (!data) {
return <SkeletonLayout.Section />;
}
if (data.length === 0) {
return (
<Layout.AppSectionCard
footer={
<ButtonsBox>
<Button
onClick={() => {
push("/add-provider");
}}
>
Add first CMS configuration
</Button>
</ButtonsBox>
}
>
<Text as="p" marginBottom={4}>
No configurations yet
</Text>
</Layout.AppSectionCard>
);
}
return (
<Layout.AppSectionCard
footer={
<ButtonsBox>
<Button
onClick={() => {
push("/add-provider");
}}
>
Add CMS configuration
</Button>
</ButtonsBox>
}
>
{data.length && (
<Box>
<Text variant="heading" as="h2" marginBottom={4}>
Providers configurations
</Text>
<ProvidersTable providers={data} />
</Box>
)}
</Layout.AppSectionCard>
);
};

View file

@ -0,0 +1,45 @@
import { BulkImportProductFragment } from "../../../../generated/graphql";
import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
import { BuilderIoProviderConfig } from "../../configuration";
import { BuilderIoClient } from "./builder-io.client";
export class BuilderIoBulkSyncProcessor implements BulkSyncProcessor {
constructor(private config: BuilderIoProviderConfig.FullShape) {}
async uploadProducts(
products: BulkImportProductFragment[],
hooks: BulkSyncProcessorHooks
): Promise<void> {
const client = new BuilderIoClient(this.config);
products.flatMap((product) =>
product.variants?.map((variant) => {
if (hooks.onUploadStart) {
hooks.onUploadStart({ variantId: variant.id });
}
return client
.upsertProductVariant({
id: variant.id,
name: variant.name,
channelListings: variant.channelListings,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
})
.then((r) => {
if (hooks.onUploadSuccess) {
hooks.onUploadSuccess({ variantId: variant.id });
}
})
.catch((e) => {
if (hooks.onUploadError) {
hooks.onUploadError({ variantId: variant.id, error: e });
}
});
})
);
}
}

View file

@ -0,0 +1,238 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useDashboardNotification } from "@saleor/apps-shared";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { Input } from "@saleor/react-hook-form-macaw";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { BuilderIoProviderConfig, SaleorProviderFieldsMappingKeys } from "../../configuration";
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
import { trpcClient } from "../../trpc/trpc-client";
import { ButtonsBox, SkeletonLayout, TextLink } from "@saleor/apps-ui";
type FormShape = Omit<BuilderIoProviderConfig.InputShape, "type">;
const FormSchema = BuilderIoProviderConfig.Schema.Input.omit({ type: true });
type PureFormProps = {
defaultValues: FormShape;
onSubmit(values: FormShape): void;
onDelete?(): void;
};
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
const { control, handleSubmit } = useForm({
defaultValues: defaultValues,
resolver: zodResolver(FormSchema),
});
return (
<Box
as="form"
display={"grid"}
gap={4}
onSubmit={handleSubmit((vals) => {
onSubmit(vals);
})}
>
<Input
required
control={control}
name="configName"
label="Configuration name"
helperText="Meaningful name that will help you understand it later. E.g. 'staging' or 'prod' "
/>
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Provide connection details</Text>
<Input
type="password"
required
control={control}
name="privateApiKey"
label="Private API key (write API)"
helperText={
<Text variant="caption">
You can find it in and generate in{" "}
<TextLink size="small" newTab href="https://builder.io/account/space">
account settings
</TextLink>
</Text>
}
/>
<Input
type="password"
required
control={control}
name="publicApiKey"
label="Public API key (read API)"
helperText={
<Text variant="caption">
You can find it in{" "}
<TextLink size="small" newTab href="https://builder.io/account/space">
account settings
</TextLink>
</Text>
}
/>
</Box>
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Configure fields mapping</Text>
<Input
required
control={control}
name="modelName"
label="CMS Data model name"
helperText="Structured content model name. E.g. 'products' or 'product-variants'"
placeholder="saleor-variant"
/>
<Text as="p" variant="heading" size="small">
Map fields from Saleor to your contentful schema.
</Text>
<Text as="p" marginTop={2} marginBottom={4}>
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should be
type of <Text variant="bodyStrong">JSON</Text>.
</Text>
<Box
marginBottom={4}
display="grid"
__gridTemplateColumns={"50% 50%"}
borderBottomWidth={1}
borderBottomStyle="solid"
borderColor="neutralHighlight"
padding={2}
>
<Text variant="caption">Saleor Field</Text>
<Text variant="caption">Builder.io field</Text>
</Box>
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
// todo extract this table to component
<Box
display="grid"
__gridTemplateColumns={"50% 50%"}
padding={2}
key={saleorField}
alignItems="center"
>
<Box>
<Text as="p" variant="bodyStrong">
{printSaleorProductFields(saleorField)}
</Text>
<Text variant="caption">
{saleorField === "channels" ? "JSON field" : "Text field"}
</Text>
</Box>
<Input
size="small"
control={control}
name={`productVariantFieldsMapping.${saleorField}`}
label="Builder.io Field"
/>
</Box>
))}
</Box>
<ButtonsBox>
{onDelete && (
<Button onClick={onDelete} variant="tertiary">
Delete
</Button>
)}
<Button type="submit">Save</Button>
</ButtonsBox>
</Box>
);
};
const AddFormVariant = () => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() {
notifySuccess("Success", "Saved configuration");
push("/configuration");
},
});
return (
<PureForm
onSubmit={(values) => {
addProvider({
...values,
type: "builder.io",
});
}}
defaultValues={{
configName: "",
publicApiKey: "",
privateApiKey: "",
modelName: "",
productVariantFieldsMapping: {
channels: "",
variantName: "",
productId: "",
productName: "",
productSlug: "",
variantId: "",
},
}}
/>
);
};
const EditFormVariant = (props: { configId: string }) => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { data } = trpcClient.providersConfigs.getOne.useQuery(
{
id: props.configId,
},
{
enabled: !!props.configId,
},
);
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
push("/configuration");
},
});
const { mutate: deleteProvider } = trpcClient.providersConfigs.deleteOne.useMutation({
onSuccess() {
notifySuccess("Success", "Removed configuration");
push("/configuration");
},
});
if (!data) {
return <SkeletonLayout.Section />;
}
if (data.type !== "builder.io") {
throw new Error("Trying to fill builder.io form with non builder.io data");
}
return (
<PureForm
onDelete={() => {
deleteProvider({
id: props.configId,
});
}}
onSubmit={(values) => {
mutate({
...values,
type: "builder.io",
id: props.configId,
});
}}
defaultValues={data}
/>
);
};
export const BuilderIoConfigForm = {
PureVariant: PureForm,
AddVariant: AddFormVariant,
EditVariant: EditFormVariant,
};

View file

@ -0,0 +1,67 @@
import { createLogger } from "@saleor/apps-shared";
import {
WebhookProductFragment,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import { BuilderIoProviderConfig, ContentfulProviderConfig } from "../../configuration";
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
import { BuilderIoClient } from "./builder-io.client";
export type BuilderioClientStrip = Pick<
BuilderIoClient,
"upsertProductVariant" | "deleteProductVariant" | "updateProductVariant"
>;
export type BuilderIoClientFactory = (
config: BuilderIoProviderConfig.FullShape
) => BuilderioClientStrip;
export class BuilderIoWebhooksProcessor implements ProductWebhooksProcessor {
private client: BuilderioClientStrip;
private logger = createLogger({ name: "BuilderIoWebhooksProcessor" });
constructor(
providerConfig: BuilderIoProviderConfig.FullShape,
clientFactory: BuilderIoClientFactory = () => new BuilderIoClient(providerConfig)
) {
this.client = clientFactory(providerConfig);
this.logger.trace("Created BuilderIoWebhooksProcessor");
}
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.debug({ variantId: productVariant.id }, "Called onProductVariantUpdated");
await this.client.upsertProductVariant(productVariant);
}
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.debug({ variantId: productVariant.id }, "Called onProductVariantCreated");
await this.client.upsertProductVariant(productVariant);
}
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.debug({ variantId: productVariant.id }, "Called onProductVariantDeleted");
await this.client.deleteProductVariant(productVariant.id);
}
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
this.logger.debug({ procutId: product.id }, "Called onProductUpdated");
await Promise.all(
(product.variants ?? []).map((variant) => {
return this.client.updateProductVariant({
id: variant.id,
name: variant.name,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
});
})
);
}
}

View file

@ -0,0 +1,145 @@
import { BuilderIoProviderConfig } from "@/modules/configuration";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
import { createLogger } from "@saleor/apps-shared";
import { FieldsMapper } from "../fields-mapper";
// https://www.builder.io/c/docs/write-api
export class BuilderIoClient {
private endpoint: string;
private logger = createLogger({ name: "BuilderIoClient" });
constructor(private config: BuilderIoProviderConfig.FullShape) {
this.endpoint = `https://builder.io/api/v1/write/${config.modelName}`;
}
private mapVariantToFields(variant: WebhookProductVariantFragment) {
return FieldsMapper.mapProductVariantToConfigurationFields({
variant,
configMapping: this.config.productVariantFieldsMapping,
});
}
async uploadProductVariant(variant: WebhookProductVariantFragment) {
this.logger.debug({ variantId: variant.id }, "uploadProductVariant called");
try {
const response = await fetch(this.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.privateApiKey}`,
},
body: JSON.stringify({
data: this.mapVariantToFields(variant),
published: "published",
}),
});
} catch (err) {
this.logger.error(err, "Failed to upload product variant");
throw err;
}
}
private async updateProductVariantCall(
builderIoEntryId: string,
variant: WebhookProductVariantFragment
) {
try {
const response = await fetch(this.endpoint + `/${builderIoEntryId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.privateApiKey}`,
},
body: JSON.stringify({
data: this.mapVariantToFields(variant),
published: "published",
}),
});
} catch (err) {
this.logger.error(err, "Failed to upload product variant");
throw err;
}
}
async updateProductVariant(variant: WebhookProductVariantFragment) {
const entriesToUpdate = await this.fetchBuilderIoEntryIds(variant.id);
this.logger.debug(
{
entriesToUpdate,
},
"Trying to update variants in builder.io with following IDs"
);
return Promise.all(
entriesToUpdate.map((id) => {
return this.updateProductVariantCall(id, variant);
})
);
}
async upsertProductVariant(variant: WebhookProductVariantFragment) {
const entriesToUpdate = await this.fetchBuilderIoEntryIds(variant.id);
if (entriesToUpdate.length === 0) {
this.logger.debug("Didnt find any entries to update, will upload new variant");
return this.uploadProductVariant(variant);
} else {
this.logger.debug({ entriesToUpdate }, "Found entries in builder.io, will update them");
return Promise.all(
entriesToUpdate.map((id) => {
return this.updateProductVariantCall(id, variant);
})
);
}
}
async deleteProductVariant(variantId: string) {
const idsToDelete = await this.fetchBuilderIoEntryIds(variantId);
this.logger.debug({ ids: idsToDelete }, "Will try to delete items in Builder.io");
return Promise.all(
idsToDelete.map((id) =>
fetch(this.endpoint + `/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.privateApiKey}`,
},
})
)
);
}
/**
* Can return more than 1. Builder doesnt have unique fields.
*/
private fetchBuilderIoEntryIds(variantId: string): Promise<string[]> {
this.logger.trace(
{
modelName: this.config.modelName,
variantID: variantId,
variantFieldMapping: this.config.productVariantFieldsMapping.variantId,
},
"Trying to fetch variant from Builder.io"
);
return fetch(
`https://cdn.builder.io/api/v3/content/${this.config.modelName}?apiKey=${this.config.publicApiKey}&query.data.${this.config.productVariantFieldsMapping.variantId}.$eq=${variantId}&limit=10&includeUnpublished=false&cacheSeconds=0`
)
.then((res) => res.json())
.then((data) => {
return data.results.map((result: any) => result.id) as string[];
})
.catch((err) => {
this.logger.error(err, "Failed to fetch builder.io entry id");
throw err;
});
}
}

View file

@ -0,0 +1,22 @@
import { Text } from "@saleor/macaw-ui/next";
import logo from "./builder-logo.svg";
import { CMSProviderMeta } from "../cms-provider-meta";
import { TextLink } from "@saleor/apps-ui";
export const BuilderIo = {
formSideInfo: (
<Text>
Ensure fields are properly mapped with your{" "}
<TextLink newTab href="https://www.builder.io/c/docs/models-data">
CMS Data Model
</TextLink>
.
</Text>
),
type: "builder.io" as const,
logoUrl: logo.src as string,
displayName: "Builder.io",
description:
"Builder.io is the only headless CMS that gives developers, marketers, and product managers the freedom they need to ship fast, flexible, multi-channel experiences without overwhelming your backlog.",
} satisfies CMSProviderMeta;

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256px" height="293px" viewBox="0 0 256 293" version="1.1" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid">
<title>Builder</title>
<g>
<path d="M256,82.3201829 C256.01306,94.601365 253.278083,106.725264 247.991626,117.804979 C242.705169,128.884694 235.002671,138.640609 225.452096,146.351846 L5.80199569,21.6220457 C4.96315294,21.1414587 4.18547581,20.5647543 3.48207121,19.8963015 C2.3810901,18.8171653 1.50729557,17.5283183 0.908746314,16.1084022 C0.310197059,14.6841171 0,13.1549767 0,11.6127293 C0,8.53260361 1.22331235,5.57917809 3.39906073,3.4034297 C5.57480912,1.22331235 8.52823464,7.7484371e-13 11.6083604,7.7484371e-13 L173.719091,7.7484371e-13 C195.542109,7.7484371e-13 216.469488,8.67241073 231.9007,24.1123601 C247.331911,39.5479405 256,60.4884265 256,82.3201829 Z" fill="#18B4F4"></path>
<path d="M255.99996,210.379141 C256.004322,221.192348 253.876632,231.9007 249.743584,241.888171 C245.610536,251.880012 239.550771,260.958737 231.909438,268.604439 C224.268105,276.250141 215.193748,282.314275 205.210646,286.451693 C195.227543,290.593479 184.52356,292.721168 173.719091,292.721168 L11.6083604,292.721168 C9.31028074,292.694954 7.07336674,291.99155 5.1772326,290.693965 C3.28546744,289.39638 1.81312365,287.565781 0.961173986,285.433722 C0.104855344,283.301663 -0.100486371,280.964263 0.375731649,278.714242 C0.847580696,276.46859 1.97477564,274.410804 3.61750936,272.803022 C4.27285526,272.147676 5.00684267,271.575341 5.80199569,271.099123 L92.2464888,222.013715 L225.43462,146.369322 C234.980826,154.080559 242.683324,163.832106 247.97415,174.907452 C253.264976,185.982797 256.004322,198.106696 255.99996,210.379141 Z" fill="#FD6B3C"></path>
<path d="M225.452096,146.351846 L92.2464888,222.013715 L5.80636467,271.099123 C4.97625986,271.562234 4.20732067,272.125831 3.52139197,272.785546 C36.4809217,238.965329 54.9092484,193.58481 54.8699277,146.351846 C54.9310933,99.1101448 36.5071356,53.7208878 3.53886786,19.8963015 C4.24227245,20.5647543 5.01994959,21.1414587 5.86316131,21.6220457 L225.452096,146.351846 Z" fill="#A97FF2"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,9 @@
import { ReactElement } from "react";
export interface CMSProviderMeta {
type: string;
displayName: string;
logoUrl: string;
description: string;
formSideInfo?: ReactElement;
}

View file

@ -0,0 +1,61 @@
import { createLogger } from "@saleor/apps-shared";
import { BulkImportProductFragment } from "../../../../generated/graphql";
import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
import { ContentfulProviderConfig } from "../../configuration";
import { ContentfulClient } from "./contentful-client";
import { contentfulRateLimiter } from "./contentful-rate-limiter";
export class ContentfulBulkSyncProcessor implements BulkSyncProcessor {
private logger = createLogger({ name: "ContentfulBulkSyncProcessor" });
constructor(private config: ContentfulProviderConfig.FullShape) {}
async uploadProducts(
products: BulkImportProductFragment[],
hooks: BulkSyncProcessorHooks
): Promise<void> {
const contentful = new ContentfulClient({
accessToken: this.config.authToken,
space: this.config.spaceId,
});
products.flatMap((product) => {
return product.variants?.map((variant) => {
return contentfulRateLimiter(() => {
if (hooks.onUploadStart) {
hooks.onUploadStart({ variantId: variant.id });
}
return contentful
.upsertProductVariant({
configuration: this.config,
variant: {
id: variant.id,
name: variant.name,
channelListings: variant.channelListings,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
},
})
.then((r) => {
if (r?.metadata) {
if (hooks.onUploadSuccess) {
hooks.onUploadSuccess({ variantId: variant.id });
}
}
})
.catch((e) => {
this.logger.trace(e, "Error while uploading product to Contentful");
if (hooks.onUploadError) {
hooks.onUploadError({ variantId: variant.id, error: e });
}
});
});
});
});
}
}

View file

@ -0,0 +1,295 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ContentfulClient, ContentfulApiClientChunk } from "./contentful-client";
import { ContentfulProviderConfig } from "@/modules/configuration";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
const getMockContenfulConfiguration = (): ContentfulProviderConfig.FullShape => ({
authToken: "test-token",
configName: "test-config-name",
contentId: "test-content-id",
id: "test-id",
spaceId: "test-space-id",
type: "contentful",
environment: "master",
productVariantFieldsMapping: {
channels: "channels",
productId: "product-id",
productName: "product-name",
productSlug: "product-slug",
variantId: "variant-id",
variantName: "variant-name",
},
});
const getMockWebhookProductVariant = (): WebhookProductVariantFragment => {
return {
id: "test-id",
name: "test-name",
product: {
id: "test-product-id",
name: "test-product-name",
slug: "test-product-slug",
},
channelListings: [
{
channel: {
id: "test-channel-id",
slug: "test-channel-slug",
},
price: {
amount: 100,
currency: "USD",
},
},
],
};
};
const mockGetSpace = vi.fn();
const mockGetEnvironment = vi.fn();
const mockGetEnvironments = vi.fn();
const mockGetEnvEntry = vi.fn();
const mockGetContentTypes = vi.fn();
const mockCreateEntryWithId = vi.fn();
const mockContentfulSdk: ContentfulApiClientChunk = {
getSpace: mockGetSpace.mockReturnValue(
Promise.resolve({
getEnvironment: mockGetEnvironment.mockReturnValue({
getContentTypes: mockGetContentTypes.mockReturnValue({
items: [{}],
}),
getEntry: mockGetEnvEntry.mockReturnValue({}),
createEntryWithId: mockCreateEntryWithId.mockReturnValue({}),
}),
getEnvironments: mockGetEnvironments.mockReturnValue({}),
})
),
};
describe("ContentfulClient", () => {
let contentfulClient: ContentfulClient;
beforeEach(() => {
vi.clearAllMocks();
contentfulClient = new ContentfulClient(
{
accessToken: "test-token",
space: "test-space",
},
() => mockContentfulSdk
);
});
describe("getContentTypes", () => {
it("Calls contentful SDK to fetch space->environment->contentTypes", async () => {
await contentfulClient.getContentTypes("master");
expect(mockGetContentTypes).toHaveBeenCalled();
});
});
describe("getEnvironments", () => {
it("Calls contentful SDK to fetch space->environments list", async () => {
await contentfulClient.getEnvironments();
expect(mockGetEnvironments).toHaveBeenCalled();
});
});
describe("updateProductVariant", () => {
it("Mutates the entry fields and calls update method", async () => {
const mockEntry = {
fields: {},
update: vi.fn().mockReturnValue(Promise.resolve({})),
};
mockGetEnvEntry.mockReturnValue(mockEntry);
const mockConfig = getMockContenfulConfiguration();
const mockMapping = mockConfig.productVariantFieldsMapping;
const mockVariant = getMockWebhookProductVariant();
await contentfulClient.updateProductVariant({
configuration: mockConfig,
variant: mockVariant,
});
expect(mockGetEnvEntry).toHaveBeenCalledWith(mockVariant.id);
/**
* Fields must reflect mapping config to variant real data
*
* App supports and hardcodes the locale to en-US now
*/
expect(mockEntry.fields).toEqual({
[mockMapping.productId]: {
"en-US": mockVariant.product.id,
},
[mockMapping.productName]: {
"en-US": mockVariant.product.name,
},
[mockMapping.productSlug]: {
"en-US": mockVariant.product.slug,
},
[mockMapping.variantId]: {
"en-US": mockVariant.id,
},
[mockMapping.variantName]: {
"en-US": mockVariant.name,
},
[mockMapping.channels]: {
"en-US": mockVariant.channelListings,
},
});
expect(mockEntry.update).toHaveBeenCalled();
});
});
describe("deleteProductVariant", () => {
it("Calls contentful delete method on fetched entry", async () => {
const mockEntry = {
delete: vi.fn().mockReturnValue(Promise.resolve({})),
};
mockGetEnvEntry.mockReturnValue(mockEntry);
const mockConfig = getMockContenfulConfiguration();
const mockVariant = getMockWebhookProductVariant();
await contentfulClient.deleteProductVariant({
configuration: mockConfig,
variant: { id: mockVariant.id },
});
expect(mockGetEnvEntry).toHaveBeenCalledWith(mockVariant.id);
expect(mockEntry.delete).toHaveBeenCalled();
});
});
describe("uploadProductVariant", () => {
it("Calls contentful createEntryWithId method with correct mapped fields", async () => {
const mockConfig = getMockContenfulConfiguration();
const mockMapping = mockConfig.productVariantFieldsMapping;
const mockVariant = getMockWebhookProductVariant();
await contentfulClient.uploadProductVariant({
configuration: mockConfig,
variant: mockVariant,
});
expect(mockCreateEntryWithId).toHaveBeenCalledWith(mockConfig.contentId, mockVariant.id, {
fields: {
[mockMapping.productId]: {
"en-US": mockVariant.product.id,
},
[mockMapping.productName]: {
"en-US": mockVariant.product.name,
},
[mockMapping.productSlug]: {
"en-US": mockVariant.product.slug,
},
[mockMapping.variantId]: {
"en-US": mockVariant.id,
},
[mockMapping.variantName]: {
"en-US": mockVariant.name,
},
[mockMapping.channels]: {
"en-US": mockVariant.channelListings,
},
},
});
});
});
describe("upsertProductVariant", () => {
it("Calls standard create method on SDK if entry does not exist", async () => {
const mockConfig = getMockContenfulConfiguration();
const mockMapping = mockConfig.productVariantFieldsMapping;
const mockVariant = getMockWebhookProductVariant();
await contentfulClient.upsertProductVariant({
configuration: mockConfig,
variant: mockVariant,
});
expect(mockGetEnvEntry).not.toHaveBeenCalled();
expect(mockCreateEntryWithId).toHaveBeenCalledWith(mockConfig.contentId, mockVariant.id, {
fields: {
[mockMapping.productId]: {
"en-US": mockVariant.product.id,
},
[mockMapping.productName]: {
"en-US": mockVariant.product.name,
},
[mockMapping.productSlug]: {
"en-US": mockVariant.product.slug,
},
[mockMapping.variantId]: {
"en-US": mockVariant.id,
},
[mockMapping.variantName]: {
"en-US": mockVariant.name,
},
[mockMapping.channels]: {
"en-US": mockVariant.channelListings,
},
},
});
});
it("Calls update method if SDK returned 409 error", async () => {
const mockConfig = getMockContenfulConfiguration();
const mockMapping = mockConfig.productVariantFieldsMapping;
const mockVariant = getMockWebhookProductVariant();
mockCreateEntryWithId.mockRejectedValue({
message: JSON.stringify({
status: 409,
}),
});
const mockEntry = {
fields: {},
update: vi.fn().mockReturnValue(Promise.resolve({})),
};
mockGetEnvEntry.mockReturnValue(mockEntry);
await contentfulClient.upsertProductVariant({
configuration: mockConfig,
variant: mockVariant,
});
expect(mockEntry.fields).toEqual({
[mockMapping.productId]: {
"en-US": mockVariant.product.id,
},
[mockMapping.productName]: {
"en-US": mockVariant.product.name,
},
[mockMapping.productSlug]: {
"en-US": mockVariant.product.slug,
},
[mockMapping.variantId]: {
"en-US": mockVariant.id,
},
[mockMapping.variantName]: {
"en-US": mockVariant.name,
},
[mockMapping.channels]: {
"en-US": mockVariant.channelListings,
},
});
expect(mockEntry.update).toHaveBeenCalledWith();
});
});
});

View file

@ -0,0 +1,195 @@
import { createClient, ClientAPI } from "contentful-management";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
import { ContentfulProviderConfig } from "@/modules/configuration";
import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import { createLogger } from "@saleor/apps-shared";
const ContentfulErrorMessageSchema = z.object({
status: z.number(),
});
type ConstructorOptions = {
space: string;
accessToken: string;
};
export type ContentfulApiClientChunk = Pick<ClientAPI, "getSpace">;
/**
* Allow injecting mocked client instead of mocking whole module
*/
type SdkClientFactory = (opts: ConstructorOptions) => ContentfulApiClientChunk;
const defaultSdkClientFactory: SdkClientFactory = (opts) =>
createClient({
accessToken: opts.accessToken,
});
/**
* Wrapper facade of
* https://www.npmjs.com/package/contentful
*/
export class ContentfulClient {
private client: ContentfulApiClientChunk;
private space: string;
private logger = createLogger({ name: "ContentfulClient" });
constructor(opts: ConstructorOptions, clientFactory: SdkClientFactory = defaultSdkClientFactory) {
this.space = opts.space;
this.client = clientFactory(opts);
}
/**
* Support only en-US locale now
*/
private mapVariantToConfiguredFields = (
variant: WebhookProductVariantFragment,
productVariantFieldsMapping: ContentfulProviderConfig.FullShape["productVariantFieldsMapping"]
) => {
const {
channels,
variantName: name,
productId,
productName,
productSlug,
variantId,
} = productVariantFieldsMapping;
return {
[name]: {
"en-US": variant.name,
},
[productId]: {
"en-US": variant.product.id,
},
[productName]: {
"en-US": variant.product.name,
},
[productSlug]: {
"en-US": variant.product.slug,
},
[variantId]: {
"en-US": variant.id,
},
[channels]: {
"en-US": variant.channelListings,
},
};
};
async getContentTypes(env: string) {
this.logger.trace("Attempting to get content types");
try {
const space = await this.client.getSpace(this.space);
const environment = await space.getEnvironment(env);
const contentTypes = await environment.getContentTypes();
return contentTypes;
} catch (err) {
this.logger.error(err);
throw err;
}
}
async getEnvironments() {
this.logger.trace("Attempting to get environments");
return (await this.client.getSpace(this.space)).getEnvironments();
}
async updateProductVariant({
configuration,
variant,
}: {
configuration: ContentfulProviderConfig.FullShape;
variant: WebhookProductVariantFragment;
}) {
this.logger.debug("Attempting to update product variant");
const space = await this.client.getSpace(this.space);
const env = await space.getEnvironment(configuration.environment);
const entry = await env.getEntry(variant.id);
entry.fields = this.mapVariantToConfiguredFields(
variant,
configuration.productVariantFieldsMapping
);
return entry.update();
}
async deleteProductVariant(opts: {
configuration: ContentfulProviderConfig.FullShape;
variant: Pick<WebhookProductVariantFragment, "id">;
}) {
this.logger.debug("Attempting to delete product variant");
const space = await this.client.getSpace(this.space);
const env = await space.getEnvironment(opts.configuration.environment);
const entry = await env.getEntry(opts.variant.id);
return await entry.delete();
}
async uploadProductVariant({
configuration,
variant,
}: {
configuration: ContentfulProviderConfig.FullShape;
variant: WebhookProductVariantFragment;
}) {
this.logger.debug("Attempting to upload product variant");
const space = await this.client.getSpace(this.space);
const env = await space.getEnvironment(configuration.environment);
/*
* TODO: add translations
* TODO: - should it create published? is draft
*/
return env.createEntryWithId(configuration.contentId, variant.id, {
fields: this.mapVariantToConfiguredFields(variant, configuration.productVariantFieldsMapping),
});
}
async upsertProductVariant(opts: {
configuration: ContentfulProviderConfig.FullShape;
variant: WebhookProductVariantFragment;
}) {
this.logger.debug("Attempting to upsert product variant");
try {
this.logger.trace("Attempting to upload product variant first");
return await this.uploadProductVariant(opts);
} catch (e: unknown) {
this.logger.trace("Upload failed");
if (typeof e !== "object" || e === null) {
Sentry.captureMessage("Contentful error is not expected shape");
Sentry.captureException(e);
throw e;
}
const parsedError = ContentfulErrorMessageSchema.parse(JSON.parse((e as Error).message));
if (parsedError.status === 409) {
this.logger.trace("Contentful returned 409 status, will try to update instead");
return this.updateProductVariant(opts);
} else {
Sentry.captureMessage("Contentful error failed and is not handled");
throw e;
}
}
}
}

View file

@ -0,0 +1,404 @@
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { useForm } from "react-hook-form";
import { Input, Select } from "@saleor/react-hook-form-macaw";
import { trpcClient } from "../../trpc/trpc-client";
import { useEffect, useMemo } from "react";
import { useRouter } from "next/router";
import { useDashboardNotification } from "@saleor/apps-shared";
import { ContentfulProviderConfig } from "../../configuration/schemas/contentful-provider.schema";
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
import { zodResolver } from "@hookform/resolvers/zod";
import { ButtonsBox, TextLink } from "@saleor/apps-ui";
import { SaleorProviderFieldsMappingKeys } from "@/modules/configuration";
type FormSchema = Omit<ContentfulProviderConfig.InputShape, "type">;
/**
* TODO - when space, token or env changes, refetch queries
* TODO - refactor smaller hooks
*/
const PureForm = ({
defaultValues,
onSubmit,
onDelete,
}: {
defaultValues: FormSchema;
onSubmit(values: FormSchema): void;
onDelete?(): void;
}) => {
const { notifyError } = useDashboardNotification();
const { control, getValues, setValue, watch, handleSubmit, setError, clearErrors } = useForm({
defaultValues: defaultValues,
resolver: zodResolver(ContentfulProviderConfig.Schema.Input.omit({ type: true })),
});
const { mutate: fetchContentTypes, data: contentTypesData } =
trpcClient.contentful.fetchContentTypesFromApi.useMutation({
onSuccess(data) {
setValue("contentId", data.items[0].sys.id ?? null);
clearErrors(["authToken", "spaceId"]);
},
onError() {
setError("authToken", {
type: "custom",
message: "Invalid credentials",
});
setError("spaceId", {
type: "custom",
message: "Invalid credentials",
});
notifyError(
"Error",
"Could not fetch content types from Contentful. Please check your credentials.",
);
},
});
const { mutate: fetchEnvironments, data: environmentsData } =
trpcClient.contentful.fetchEnvironmentsFromApi.useMutation({
onSuccess(data) {
setValue("environment", data.items[0].sys.id);
clearErrors(["authToken", "spaceId"]);
},
onError() {
setError("authToken", {
type: "custom",
message: "Invalid credentials",
});
setError("spaceId", {
type: "custom",
message: "Invalid credentials",
});
notifyError(
"Error",
"Could not fetch environments from Contentful. Please check your credentials.",
);
},
});
const selectedContentTypeId = watch("contentId");
const availableFields = useMemo(() => {
try {
return contentTypesData?.items?.find((i) => i.sys.id === selectedContentTypeId)?.fields;
} catch (e) {
return null;
}
}, [selectedContentTypeId, contentTypesData?.items]);
/**
* For "edit" form variant, tokens already exist, so fetch immediately
*/
useEffect(() => {
if (defaultValues.authToken && defaultValues.spaceId && defaultValues.environment) {
fetchContentTypes({
contentfulSpace: defaultValues.spaceId,
contentfulToken: defaultValues.authToken,
contentfulEnv: defaultValues.environment,
});
fetchEnvironments({
contentfulSpace: defaultValues.spaceId,
contentfulToken: defaultValues.authToken,
});
}
}, [
defaultValues.authToken,
defaultValues.spaceId,
defaultValues.environment,
fetchContentTypes,
fetchEnvironments,
]);
return (
<Box
as="form"
display={"grid"}
gap={4}
onSubmit={handleSubmit((vals) => {
onSubmit(vals);
})}
>
<Input
required
control={control}
name="configName"
label="Configuration name"
helperText="Meaningful name that will help you understand it later. E.g. 'staging' or 'prod' "
/>
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Provide connection details</Text>
<Input
required
control={control}
name="spaceId"
label="Contentful space ID"
helperText={
<Text variant="caption" color="textNeutralSubdued">
Check{" "}
<TextLink size="small" href="https://www.contentful.com/help/find-space-id/" newTab>
how to get space ID
</TextLink>
</Text>
}
/>
<Input
required
type="password"
control={control}
name="authToken"
label="Content Management Personal token"
helperText={
<Text variant="caption" color="textNeutralSubdued">
Check{" "}
<TextLink
size="small"
href="https://www.contentful.com/help/personal-access-tokens/"
newTab
>
how to generate token
</TextLink>
</Text>
}
/>
{!environmentsData && (
<ButtonsBox>
<Button
variant="secondary"
onClick={() => {
const values = getValues();
return fetchEnvironments({
contentfulSpace: values.spaceId,
contentfulToken: values.authToken,
});
}}
>
Continue
</Button>
</ButtonsBox>
)}
{environmentsData && (
<>
<Select
required
control={control}
name="environment"
label="Contentful environment"
helperText={
<Text variant="caption" color="textNeutralSubdued">
Check your environment{" "}
<TextLink
newTab
size="small"
href={`https://app.contentful.com/spaces/${getValues(
"spaceId",
)}/settings/environments`}
>
here
</TextLink>
</Text>
}
options={environmentsData.items.map((item) => ({
label: item.name,
value: item.sys.id,
}))}
/>
{!contentTypesData && (
<ButtonsBox>
<Button
variant="secondary"
onClick={() => {
const values = getValues();
return fetchContentTypes({
contentfulSpace: values.spaceId,
contentfulToken: values.authToken,
contentfulEnv: values.environment,
});
}}
>
Continue
</Button>
</ButtonsBox>
)}
</>
)}
</Box>
{contentTypesData && (
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Configure fields mapping</Text>
<Text as="p">First select content type you want to synchronize products with.</Text>
<Select
label="Content Type"
control={control}
name="contentId"
options={contentTypesData?.items.map((contentType) => ({
label: contentType.name,
value: contentType.sys.id,
}))}
/>
<Box marginTop={4}>
<Text as="p" variant="heading" size="small">
Map fields from Saleor to your Contentful schema.
</Text>
<Text as="p" marginTop={2} marginBottom={4}>
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should
be type of <Text variant="bodyStrong">JSON</Text>.
</Text>
<Box
marginBottom={4}
display="grid"
__gridTemplateColumns={"50% 50%"}
borderBottomWidth={1}
borderBottomStyle="solid"
borderColor="neutralHighlight"
padding={2}
>
<Text variant="caption">Saleor Field</Text>
<Text variant="caption">Contentful field</Text>
</Box>
{availableFields &&
SaleorProviderFieldsMappingKeys.map((saleorField) => (
<Box
display="grid"
__gridTemplateColumns={"50% 50%"}
padding={2}
key={saleorField}
alignItems="center"
>
<Box>
<Text as="p" variant="bodyStrong">
{printSaleorProductFields(saleorField)}
</Text>
<Text variant="caption">
{saleorField === "channels" ? "JSON field" : "Text field"}
</Text>
</Box>
<Select
size="small"
control={control}
name={`productVariantFieldsMapping.${saleorField}`}
label="Contentful Field"
options={availableFields.map((f) => ({
label: f.name,
value: f.id,
}))}
/>
</Box>
))}
</Box>
<ButtonsBox>
{onDelete && (
<Button onClick={onDelete} variant="tertiary">
Delete
</Button>
)}
<Button type="submit">Save</Button>
</ButtonsBox>
</Box>
)}
</Box>
);
};
const AddVariant = () => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { mutate } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() {
notifySuccess("Success", "Saved configuration");
push("/configuration");
},
});
return (
<ContentfulConfigForm.PureVariant
onSubmit={(values) => {
mutate({
...values,
type: "contentful",
});
}}
defaultValues={{
authToken: "",
configName: "",
environment: "",
contentId: "",
productVariantFieldsMapping: {
channels: "",
variantName: "",
productId: "",
productName: "",
productSlug: "",
variantId: "",
},
spaceId: "",
}}
/>
);
};
const EditVariant = ({ configId }: { configId: string }) => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { data } = trpcClient.providersConfigs.getOne.useQuery(
{
id: configId,
},
{
enabled: !!configId,
},
);
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
push("/configuration");
},
});
const { mutate: deleteProvider } = trpcClient.providersConfigs.deleteOne.useMutation({
onSuccess() {
notifySuccess("Success", "Removed configuration");
push("/configuration");
},
});
if (!data) {
return <Text>Loading</Text>;
}
if (data.type !== "contentful") {
throw new Error("Trying to fill contentful form with non contentful data");
}
return (
<ContentfulConfigForm.PureVariant
onDelete={() => {
deleteProvider({ id: configId });
}}
defaultValues={data}
onSubmit={(values) =>
mutate({
...values,
id: configId,
type: "contentful",
})
}
/>
);
};
export const ContentfulConfigForm = {
PureVariant: PureForm,
AddVariant,
EditVariant,
};

View file

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150"><defs><style>.cls-1{fill:#0033a3;}.cls-2{fill:#ffda00;}.cls-3{fill:#67b3ff;}.cls-4{fill:#eb5a68;}.cls-5{fill:#47a1ff;}.cls-6{fill:#d5465f;}</style></defs><circle class="cls-1" cx="75" cy="75" r="75"/><path class="cls-2" d="M61.2,92.29a24.59,24.59,0,0,1-7.14-17.44,24.3,24.3,0,0,1,7.26-17.37A9.68,9.68,0,0,0,47.67,43.76a44,44,0,0,0-.19,62.18A9.68,9.68,0,1,0,61.2,92.29Z"/><path class="cls-3" d="M61.32,57.48a24.57,24.57,0,0,1,17.44-7.14A24.32,24.32,0,0,1,96.13,57.6,9.67,9.67,0,1,0,109.84,44a43.82,43.82,0,0,0-62.11-.25A9.68,9.68,0,0,0,61.32,57.48Z"/><path class="cls-4" d="M96.07,92.41a24.59,24.59,0,0,1-17.44,7.14,24.34,24.34,0,0,1-17.37-7.26,9.67,9.67,0,1,0-13.71,13.65,43.88,43.88,0,0,0,62.17.24A9.69,9.69,0,0,0,96.07,92.41Z"/><path class="cls-5" d="M61.32,57.48a9.68,9.68,0,1,1,.06-13.65A9.71,9.71,0,0,1,61.32,57.48Z"/><path class="cls-6" d="M61.13,105.94a9.68,9.68,0,1,1,.07-13.65A9.7,9.7,0,0,1,61.13,105.94Z"/></svg>

After

Width:  |  Height:  |  Size: 1,017 B

View file

@ -0,0 +1,7 @@
import { pRateLimit } from "p-ratelimit";
export const contentfulRateLimiter = pRateLimit({
interval: 1000,
rate: 2,
concurrency: 2,
});

View file

@ -0,0 +1,131 @@
import { describe, expect, vi, it, beforeEach } from "vitest";
import {
ContentfulClientStrip,
ContentfulWebhooksProcessor,
} from "./contentful-webhooks-processor";
import { ContentfulProviderConfig } from "@/modules/configuration";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
const getMockContenfulConfiguration = (): ContentfulProviderConfig.FullShape => ({
authToken: "test-token",
configName: "test-config-name",
contentId: "test-content-id",
id: "test-id",
spaceId: "test-space-id",
type: "contentful",
environment: "master",
productVariantFieldsMapping: {
channels: "channels",
productId: "product-id",
productName: "product-name",
productSlug: "product-slug",
variantId: "variant-id",
variantName: "variant-name",
},
});
const getMockWebhookProductVariant = (): WebhookProductVariantFragment => {
return {
id: "test-id",
name: "test-name",
product: {
id: "test-product-id",
name: "test-product-name",
slug: "test-product-slug",
},
channelListings: [
{
channel: {
id: "test-channel-id",
slug: "test-channel-slug",
},
price: {
amount: 100,
currency: "USD",
},
},
],
};
};
const mockContentfulClient: ContentfulClientStrip = {
deleteProductVariant: vi.fn(),
upsertProductVariant: vi.fn(),
};
describe("ContentfulWebhooksProcessor", () => {
let processor: ContentfulWebhooksProcessor;
beforeEach(() => {
vi.clearAllMocks();
processor = new ContentfulWebhooksProcessor(
getMockContenfulConfiguration(),
() => mockContentfulClient
);
});
it("onProductVariantUpdated calls client upsert method", () => {
const mockProductVariant = getMockWebhookProductVariant();
processor.onProductVariantUpdated(mockProductVariant);
expect(mockContentfulClient.upsertProductVariant).toHaveBeenCalledWith(
expect.objectContaining({
configuration: getMockContenfulConfiguration(),
variant: mockProductVariant,
})
);
});
it("onProductVariantCreated calls client upsert method", () => {
const mockProductVariant = getMockWebhookProductVariant();
processor.onProductVariantCreated(mockProductVariant);
expect(mockContentfulClient.upsertProductVariant).toHaveBeenCalledWith(
expect.objectContaining({
configuration: getMockContenfulConfiguration(),
variant: mockProductVariant,
})
);
});
it("onProductVariantDeleted calls client delete method", () => {
const mockProductVariant = getMockWebhookProductVariant();
processor.onProductVariantDeleted(mockProductVariant);
expect(mockContentfulClient.deleteProductVariant).toHaveBeenCalledWith(
expect.objectContaining({
configuration: getMockContenfulConfiguration(),
variant: mockProductVariant,
})
);
});
it("onProductUpdated calls client upsert method for every product variant", () => {
const mockProductVariant1 = getMockWebhookProductVariant();
const mockProductVariant2 = getMockWebhookProductVariant();
const mockProductVariant3 = getMockWebhookProductVariant();
mockProductVariant1.id = "test-product-variant-id-1";
mockProductVariant2.id = "test-product-variant-id-2";
mockProductVariant3.id = "test-product-variant-id-3";
processor.onProductUpdated({
id: "test-product-id",
name: "test-product-name",
slug: "test-product-slug",
variants: [mockProductVariant1, mockProductVariant2, mockProductVariant3],
channelListings: [
{
channel: { id: "test-channel-id", slug: "test-channel-slug" },
id: "test-id",
},
],
});
expect(mockContentfulClient.upsertProductVariant).toHaveBeenCalledTimes(3);
});
});

View file

@ -0,0 +1,79 @@
import { createLogger } from "@saleor/apps-shared";
import {
WebhookProductFragment,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import { ContentfulProviderConfig } from "../../configuration";
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
import { ContentfulClient } from "./contentful-client";
export type ContentfulClientStrip = Pick<
ContentfulClient,
"upsertProductVariant" | "deleteProductVariant"
>;
export type ContentfulClientFactory = (
config: ContentfulProviderConfig.FullShape
) => ContentfulClientStrip;
export class ContentfulWebhooksProcessor implements ProductWebhooksProcessor {
private client: ContentfulClientStrip;
private logger = createLogger({ name: "ContentfulWebhooksProcessor" });
constructor(
private providerConfig: ContentfulProviderConfig.FullShape,
clientFactory: ContentfulClientFactory = () =>
new ContentfulClient({
accessToken: providerConfig.authToken,
space: providerConfig.spaceId,
})
) {
this.client = clientFactory(providerConfig);
}
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantUpdated called");
await this.client.upsertProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantCreated called");
await this.client.upsertProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantDeleted called");
await this.client.deleteProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
this.logger.trace("onProductUpdated called");
await Promise.all(
(product.variants ?? []).map((variant) => {
return this.client.upsertProductVariant({
configuration: this.providerConfig,
variant: {
id: variant.id,
name: variant.name,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
},
});
})
);
}
}

View file

@ -0,0 +1,70 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { AppConfigMetadataManager } from "../../configuration/app-config-metadata-manager";
import { createSettingsManager } from "../../configuration/metadata-manager";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { router } from "../../trpc/trpc-server";
import { ContentfulClient } from "./contentful-client";
import { createFlatProxy } from "@trpc/server/shared";
import { createLogger } from "@saleor/apps-shared";
const procedure = protectedClientProcedure.use(({ ctx, next }) => {
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
return next({
ctx: {
settingsManager,
appConfigService: new AppConfigMetadataManager(settingsManager),
logger: createLogger({ name: "contentfulRouter" }),
},
});
});
/**
* Operations specific for Contentful service.
*
* For configruration see providers-list.router.ts
*/
export const contentfulRouter = router({
fetchEnvironmentsFromApi: procedure
.input(
z.object({
contentfulToken: z.string(),
contentfulSpace: z.string(),
})
)
.mutation(({ ctx, input }) => {
const client = new ContentfulClient({
accessToken: input.contentfulToken,
space: input.contentfulSpace,
});
return client.getEnvironments().catch((e) => {
ctx.logger.error("Failed to fetch environments");
throw new TRPCError({ code: "BAD_REQUEST" });
});
}),
fetchContentTypesFromApi: procedure
.input(
z.object({
contentfulToken: z.string(),
contentfulSpace: z.string(),
contentfulEnv: z.string(),
})
)
.mutation(({ ctx, input }) => {
const client = new ContentfulClient({
accessToken: input.contentfulToken,
space: input.contentfulSpace,
});
return client.getContentTypes(input.contentfulEnv).catch((e) => {
ctx.logger.error("Failed to fetch content types");
throw new TRPCError({ code: "BAD_REQUEST" });
});
}),
});

View file

@ -0,0 +1,13 @@
import { Text } from "@saleor/macaw-ui/next";
import logo from "./contentful-logo.svg";
import { CMSProviderMeta } from "../cms-provider-meta";
export const Contentful = {
formSideInfo: <Text>App will save each variant with the same ID as variant ID.</Text>,
type: "contentful" as const,
logoUrl: logo.src as string,
displayName: "Contentful",
description:
"More than a headless CMS, Contentful is the API-first composable content platform to create, manage and publish content on any digital channel.",
} satisfies CMSProviderMeta;

View file

@ -0,0 +1,52 @@
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
import { BulkImportProductFragment } from "../../../../generated/graphql";
import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
import { DatoCMSClient } from "./datocms-client";
import { createLogger } from "@saleor/apps-shared";
export class DatocmsBulkSyncProcessor implements BulkSyncProcessor {
constructor(private config: DatocmsProviderConfig.FullShape) {}
async uploadProducts(
products: BulkImportProductFragment[],
hooks: BulkSyncProcessorHooks
): Promise<void> {
const client = new DatoCMSClient({
apiToken: this.config.authToken,
});
products.flatMap((product) =>
product.variants?.map((variant) => {
if (hooks.onUploadStart) {
hooks.onUploadStart({ variantId: variant.id });
}
return client
.upsertProduct({
configuration: this.config,
variant: {
id: variant.id,
name: variant.name,
channelListings: variant.channelListings,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
},
})
.then((r) => {
if (hooks.onUploadSuccess) {
hooks.onUploadSuccess({ variantId: variant.id });
}
})
.catch((e) => {
if (hooks.onUploadError) {
hooks.onUploadError({ variantId: variant.id, error: e });
}
});
})
);
}
}

View file

@ -0,0 +1,187 @@
import { buildClient, Client, SimpleSchemaTypes, ApiError } from "@datocms/cma-client-browser";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
import { createLogger } from "@saleor/apps-shared";
import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
import { FieldsMapper } from "../fields-mapper";
type Context = {
configuration: DatocmsProviderConfig.FullShape;
variant: WebhookProductVariantFragment;
};
/*
* todo error handling
*/
export class DatoCMSClient {
private client: Client;
private logger = createLogger({ name: "DatoCMSClient" });
constructor(opts: { apiToken: string }) {
this.client = buildClient({ apiToken: opts.apiToken });
}
getContentTypes() {
this.logger.trace("Trying to get content types");
return this.client.itemTypes.list();
}
getFieldsForContentType({ itemTypeID }: { itemTypeID: string }) {
this.logger.trace("Trying to get fields for a content type");
return this.client.fields.list({ type: "item_type", id: itemTypeID });
}
private getItemBySaleorVariantId({
variantIdFieldName: variantFieldName,
variantID,
contentType,
}: {
variantIdFieldName: string;
variantID: string;
contentType: string;
}) {
this.logger.trace("Trying to fetch item by Saleor variant ID", { variantID: variantID });
return this.client.items.list({
filter: {
type: contentType,
fields: {
[variantFieldName]: {
eq: variantID,
},
},
},
});
}
private mapVariantToDatoCMSFields({
configuration,
variant,
}: Context): SimpleSchemaTypes.ItemCreateSchema {
const fields = FieldsMapper.mapProductVariantToConfigurationFields({
variant,
configMapping: configuration.productVariantFieldsMapping,
});
/**
* Dato requires JSON to be stringified first so overwrite this single fields
*/
fields[configuration.productVariantFieldsMapping.channels] = JSON.stringify(
variant.channelListings
);
return {
item_type: { type: "item_type", id: configuration.itemType },
...fields,
};
}
async deleteProductVariant({ configuration, variant }: Context) {
this.logger.debug("Trying to delete product variant");
const remoteProducts = await this.getItemBySaleorVariantId({
variantIdFieldName: configuration.productVariantFieldsMapping.variantId,
variantID: variant.id,
contentType: configuration.itemType,
});
if (remoteProducts.length > 1) {
this.logger.warn(
"More than 1 variant with the same ID found in the CMS. Will remove all of them, but this should not happen if unique field was set"
);
}
if (remoteProducts.length === 0) {
this.logger.trace("No product found in Datocms, skipping deletion");
return;
}
return Promise.all(
remoteProducts.map((p) => {
return this.client.items.rawDestroy(p.id);
})
);
}
uploadProductVariant(context: Context) {
this.logger.debug("Trying to upload product variant");
return this.client.items.create(this.mapVariantToDatoCMSFields(context));
}
async updateProductVariant({ configuration, variant }: Context) {
const products = await this.getItemBySaleorVariantId({
variantIdFieldName: configuration.productVariantFieldsMapping.variantId,
variantID: variant.id,
contentType: configuration.itemType,
});
if (products.length > 1) {
this.logger.warn(
"Found more than one product variant with the same ID. Will update all of them, but this should not happen if unique field was set",
{
variantID: variant.id,
}
);
}
return Promise.all(
products.map((product) => {
this.logger.trace("Trying to update variant", { datoID: product.id });
return this.client.items.update(
product.id,
this.mapVariantToDatoCMSFields({
configuration,
variant,
})
);
})
);
}
upsertProduct({ configuration, variant }: Context) {
this.logger.debug("Trying to upsert product variant");
const DatoErrorBody = z.object({
data: z.array(
z.object({
validation: z.object({
attributes: z.object({
details: z.object({
code: z.string(),
}),
}),
}),
})
),
});
return this.uploadProductVariant({ configuration, variant }).catch((err: ApiError) => {
try {
const errorBody = DatoErrorBody.parse(err.response.body);
const isUniqueIdError = errorBody.data.find(
(d) => d.validation.attributes.details.code === "VALIDATION_UNIQUE"
);
if (isUniqueIdError) {
return this.updateProductVariant({ configuration, variant });
} else {
throw new Error(JSON.stringify(err.cause));
}
} catch (e) {
Sentry.captureException("Invalid error shape from DatoCMS", (c) => {
return c.setExtra("error", err);
});
throw new Error(err.humanMessage ?? "DatoCMS error - can upload product variant");
}
});
}
}

View file

@ -0,0 +1,321 @@
import { SaleorProviderFieldsMappingKeys } from "@/modules/configuration";
import { zodResolver } from "@hookform/resolvers/zod";
import { useDashboardNotification } from "@saleor/apps-shared";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { Input, Select } from "@saleor/react-hook-form-macaw";
import { useRouter } from "next/router";
import React, { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
import { trpcClient } from "../../trpc/trpc-client";
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
import { ButtonsBox } from "@saleor/apps-ui";
type FormShape = Omit<DatocmsProviderConfig.InputShape, "type">;
type PureFormProps = {
defaultValues: FormShape;
onSubmit(values: FormShape): void;
onDelete?(): void;
};
/*
* todo react on token change, refresh mutation
*/
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
const { notifyError } = useDashboardNotification();
const { control, getValues, setValue, watch, handleSubmit, clearErrors, setError } = useForm({
defaultValues: defaultValues,
resolver: zodResolver(DatocmsProviderConfig.Schema.Input.omit({ type: true })),
});
const { mutate: fetchContentTypes, data: contentTypesData } =
trpcClient.datocms.fetchContentTypes.useMutation({
onSuccess(data) {
clearErrors("authToken");
},
onError() {
setError("authToken", {
type: "custom",
message: "Invalid credentials",
});
notifyError(
"Error",
"Could not fetch content types from DatoCMS. Please check your credentials.",
);
},
});
const { mutate: fetchContentTypeFields, data: fieldsData } =
trpcClient.datocms.fetchContentTypeFields.useMutation({
onSuccess(data) {
clearErrors("authToken");
},
onError() {
setError("authToken", {
type: "custom",
message: "Invalid credentials",
});
notifyError(
"Error",
"Could not fetch content types from DatoCMS. Please check your credentials.",
);
},
});
const contentTypesSelectOptions = useMemo(() => {
if (!contentTypesData) {
return null;
}
return contentTypesData.map((item) => ({
label: item.name,
value: item.id,
}));
}, [contentTypesData]);
const selectedContentType = watch("itemType");
useEffect(() => {
if (selectedContentType) {
fetchContentTypeFields({
contentTypeID: selectedContentType,
apiToken: getValues("authToken"),
});
}
}, [selectedContentType, getValues, fetchContentTypeFields]);
useEffect(() => {
if (defaultValues.authToken && defaultValues.itemType) {
fetchContentTypes({
apiToken: defaultValues.authToken,
});
fetchContentTypeFields({
apiToken: defaultValues.authToken,
contentTypeID: defaultValues.itemType,
});
}
}, [defaultValues, fetchContentTypes, fetchContentTypeFields]);
const fetchContentTypesButton = (
<ButtonsBox>
<Button
variant="secondary"
onClick={() => {
const values = getValues();
return fetchContentTypes({
apiToken: values.authToken,
});
}}
>
Continue
</Button>
</ButtonsBox>
);
return (
<Box
as="form"
display={"grid"}
gap={4}
onSubmit={handleSubmit((vals) => {
onSubmit(vals);
})}
>
<Input
required
control={control}
name="configName"
label="Configuration name"
helperText="Meaningful name that will help you understand it later. E.g. 'staging' or 'prod' "
/>
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Provide connection details</Text>
<Input
required
control={control}
name="authToken"
type="password"
label="API Token"
helperText="Project -> Settings -> API Tokens -> Full-access API token."
/>
{!contentTypesData && fetchContentTypesButton}
</Box>
{contentTypesSelectOptions && (
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Configure fields mapping</Text>
<Select
label="Item type"
options={contentTypesSelectOptions}
name="itemType"
control={control}
helperText="Model that will keep Saleor data. You should create one just for Saleor data."
/>
{fieldsData && (
<React.Fragment>
<Text as="p" variant="heading" size="small">
Map fields from Saleor to your DatoCMS schema.
</Text>
<Text as="p" marginTop={2} marginBottom={4}>
All fields should be type of <Text variant="bodyStrong">Text</Text>. Channels should
be type of <Text variant="bodyStrong">JSON</Text>.
</Text>
<Box
marginBottom={4}
display="grid"
__gridTemplateColumns={"50% 50%"}
borderBottomWidth={1}
borderBottomStyle="solid"
borderColor="neutralHighlight"
padding={2}
>
<Text variant="caption">Saleor Field</Text>
<Text variant="caption">DatoCMS field</Text>
</Box>
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
// todo extract this table to component
<Box
display="grid"
__gridTemplateColumns={"50% 50%"}
padding={2}
key={saleorField}
alignItems="center"
>
<Box>
<Text as="p" variant="bodyStrong">
{printSaleorProductFields(saleorField)}
</Text>
<Text variant="caption">
{saleorField === "channels" ? "JSON field" : "Text field"}
</Text>
</Box>
<Select
size="small"
control={control}
name={`productVariantFieldsMapping.${saleorField}`}
label="DatoCMS Field"
options={fieldsData.map((f) => ({
label: f.label,
value: f.api_key,
}))}
/>
</Box>
))}
</React.Fragment>
)}
</Box>
)}
{contentTypesSelectOptions && (
<ButtonsBox>
{onDelete && (
<Button onClick={onDelete} variant="tertiary">
Delete
</Button>
)}
<Button type="submit">Save</Button>
</ButtonsBox>
)}
</Box>
);
};
const AddFormVariant = () => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() {
notifySuccess("Success", "Saved configuration");
push("/configuration");
},
});
return (
<PureForm
onSubmit={(values) => {
addProvider({
...values,
type: "datocms",
});
}}
defaultValues={{
authToken: "",
configName: "",
itemType: "",
productVariantFieldsMapping: {
channels: "",
variantName: "",
productId: "",
productName: "",
productSlug: "",
variantId: "",
},
}}
/>
);
};
const EditFormVariant = (props: { configId: string }) => {
const { push } = useRouter();
const { notifySuccess } = useDashboardNotification();
const { data } = trpcClient.providersConfigs.getOne.useQuery(
{
id: props.configId,
},
{
enabled: !!props.configId,
},
);
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
push("/configuration");
},
});
const { mutate: deleteProvider } = trpcClient.providersConfigs.deleteOne.useMutation({
onSuccess() {
notifySuccess("Success", "Removed configuration");
push("/configuration");
},
});
if (!data) {
return null;
}
if (data.type !== "datocms") {
throw new Error("Trying to fill datocms form with non datocms data");
}
return (
<PureForm
onDelete={() => {
deleteProvider({
id: props.configId,
});
}}
onSubmit={(values) => {
mutate({
...values,
type: "datocms",
id: props.configId,
});
}}
defaultValues={data}
/>
);
};
export const DatoCMSConfigForm = {
PureVariant: PureForm,
AddVariant: AddFormVariant,
EditVariant: EditFormVariant,
};

View file

@ -0,0 +1,70 @@
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
import {
WebhookProductFragment,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
import { DatoCMSClient } from "./datocms-client";
import { createLogger } from "@saleor/apps-shared";
/*
* todo error handling
*/
export class DatocmsWebhooksProcessor implements ProductWebhooksProcessor {
private client: DatoCMSClient;
private logger = createLogger({ name: "DatocmsWebhooksProcessor" });
constructor(private providerConfig: DatocmsProviderConfig.FullShape) {
this.client = new DatoCMSClient({
apiToken: providerConfig.authToken,
});
}
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantUpdated called");
await this.client.updateProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantCreated called");
await this.client.uploadProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantDeleted called");
await this.client.deleteProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
this.logger.trace("onProductUpdated called");
await Promise.all(
(product.variants ?? []).map((variant) => {
return this.client.upsertProduct({
configuration: this.providerConfig,
variant: {
id: variant.id,
name: variant.name,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
},
});
})
);
}
}

View file

@ -0,0 +1,55 @@
import { z } from "zod";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { router } from "../../trpc/trpc-server";
import { DatoCMSClient } from "./datocms-client";
import { TRPCError } from "@trpc/server";
/**
* Operations specific for Datocms service.
*
* For configruration see providers-list.router.ts
*/
export const datocmsRouter = router({
fetchContentTypes: protectedClientProcedure
.input(
z.object({
apiToken: z.string(),
})
)
.mutation(({ ctx, input }) => {
const client = new DatoCMSClient({
apiToken: input.apiToken,
});
return client.getContentTypes().catch((err) => {
throw new TRPCError({
code: "BAD_REQUEST",
});
});
}),
fetchContentTypeFields: protectedClientProcedure
.input(
z.object({
contentTypeID: z.string(),
apiToken: z.string(),
})
)
.mutation(({ ctx, input }) => {
const client = new DatoCMSClient({
apiToken: input.apiToken,
});
return client
.getFieldsForContentType({
itemTypeID: input.contentTypeID,
})
.catch((err) => {
throw new TRPCError({
code: "BAD_REQUEST",
});
});
}),
});

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 94.652 95.261"><defs><linearGradient id="a" y1="47.631" x2="94.652" y2="47.631" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff593d"/><stop offset="1" stop-color="#ff7751"/></linearGradient></defs><title>d_only</title><path d="M47.326,0H0V95.261H47.326c23.67,0,47.326-21.326,47.326-47.624S71,0,47.326,0Zm0,69.274a21.644,21.644,0,1,1,21.65-21.637A21.635,21.635,0,0,1,47.326,69.274Z" fill="url(#a)"/></svg>

After

Width:  |  Height:  |  Size: 516 B

View file

@ -0,0 +1,21 @@
import { Box, Text } from "@saleor/macaw-ui/next";
import { CMSProviderMeta } from "../cms-provider-meta";
import logo from "./datocms.svg";
export const Datocms = {
formSideInfo: (
<Box>
<Text as="p" marginBottom={2}>
App will use Saleor Product Variant as a unique identifier. It will be saved as one of the
fields. Please ensure you map Variant ID to field that is UNIQUE in DatoCMS.
</Text>
<Text>Otherwise, products may be duplicated</Text>
</Box>
),
type: "datocms" as const,
logoUrl: logo.src,
displayName: "DatoCMS",
description:
"It's the headless CMS for the modern web. More than 25,000 businesses use DatoCMS to create online content at scale from a central hub and distribute it via API.",
} satisfies CMSProviderMeta;

View file

@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { FieldsMapper } from "./fields-mapper";
describe("FieldsMapper", () => {
it("Maps variant fields to configuration fields", () => {
const mappedFields = FieldsMapper.mapProductVariantToConfigurationFields({
configMapping: {
channels: "channels",
productId: "product-id",
productName: "product-name",
productSlug: "product-slug",
variantId: "variant-id",
variantName: "variant-name",
},
variant: {
id: "aaa-bbb-ccc",
name: "43",
channelListings: [
{
channel: {
id: "12345",
slug: "default-channel",
},
},
],
product: {
id: "aaa-bbb-123",
name: "Shoes",
slug: "shoes",
},
},
});
expect(mappedFields).toEqual({
"variant-name": "43",
"product-id": "aaa-bbb-123",
"product-name": "Shoes",
"product-slug": "shoes",
"variant-id": "aaa-bbb-ccc",
channels: [
{
channel: {
id: "12345",
slug: "default-channel",
},
},
],
});
});
});

View file

@ -0,0 +1,23 @@
import { WebhookProductVariantFragment } from "../../../generated/graphql";
import { ProvidersConfig } from "../configuration";
export class FieldsMapper {
static mapProductVariantToConfigurationFields({
configMapping,
variant,
}: {
variant: WebhookProductVariantFragment;
configMapping: ProvidersConfig.AnyFullShape["productVariantFieldsMapping"];
}) {
const { channels, variantName, productId, productName, productSlug, variantId } = configMapping;
return {
[variantName]: variant.name,
[productId]: variant.product.id,
[productName]: variant.product.name,
[productSlug]: variant.product.slug,
[variantId]: variant.id,
[channels]: variant.channelListings,
};
}
}

Some files were not shown because too many files have changed in this diff Show more