Compare commits

...

94 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
692 changed files with 105029 additions and 28665 deletions

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

@ -8,5 +8,4 @@
## Checklist
- [ ] `.github/dependabot.yaml` is up-to date.
- [ ] I added changesets and [read good practices](/.changeset/README.md).

View file

@ -2,117 +2,18 @@ version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
open-pull-requests-limit: 1
schedule:
interval: "daily"
commit-message:
prefix: "[skip ci]"
groups:
trpc:
patterns:
- "@trpc/*"
codegen:
patterns:
- "@graphql-codegen/*"
rtl:
patterns:
- "@testing-library/*"
# Packages
- package-ecosystem: "npm"
directory: "/packages/eslint-config-saleor"
open-pull-requests-limit: 1
schedule:
interval: "weekly"
commit-message:
prefix: "[skip ci]"
- package-ecosystem: "npm"
directory: "/packages/shared"
open-pull-requests-limit: 1
schedule:
interval: "weekly"
commit-message:
prefix: "[skip ci]"
- package-ecosystem: "npm"
directory: "/packages/ui"
open-pull-requests-limit: 1
schedule:
interval: "weekly"
commit-message:
prefix: "[skip ci]"
- package-ecosystem: "npm"
directory: "/packages/react-hook-form-macaw"
open-pull-requests-limit: 1
schedule:
interval: "weekly"
commit-message:
prefix: "[skip ci]"
# Apps
- package-ecosystem: "npm"
directory: "/apps/cms"
open-pull-requests-limit: 1
schedule:
interval: "weekly"
commit-message:
prefix: "[skip ci]"
- package-ecosystem: "npm"
directory: "/apps/cms-v2"
open-pull-requests-limit: 1
schedule:
interval: "weekly"
commit-message:
prefix: "[skip ci]"
- package-ecosystem: "npm"
directory: apps/crm
open-pull-requests-limit: 1
schedule:
interval: weekly
commit-message:
prefix: "[skip ci]"
- package-ecosystem: "npm"
directory: apps/data-importer
open-pull-requests-limit: 1
schedule:
interval: weekly
commit-message:
prefix: "[skip ci]"
- package-ecosystem: "npm"
directory: apps/emails-and-messages
open-pull-requests-limit: 1
schedule:
interval: weekly
commit-message:
prefix: "[skip ci]"
- package-ecosystem: "npm"
directory: apps/invoices
open-pull-requests-limit: 1
schedule:
interval: weekly
commit-message:
prefix: "[skip ci]"
- package-ecosystem: "npm"
directory: apps/klaviyo
open-pull-requests-limit: 1
schedule:
interval: weekly
commit-message:
prefix: "[skip ci]"
- package-ecosystem: "npm"
directory: apps/products-feed
open-pull-requests-limit: 1
commit-message:
prefix: "[skip ci]"
schedule:
interval: weekly
- package-ecosystem: "npm"
directory: apps/search
open-pull-requests-limit: 1
commit-message:
prefix: "[skip ci]"
schedule:
interval: weekly
- package-ecosystem: "npm"
directory: apps/slack
open-pull-requests-limit: 1
commit-message:
prefix: "[skip ci]"
schedule:
interval: weekly
- package-ecosystem: "npm"
open-pull-requests-limit: 1
directory: apps/taxes
commit-message:
prefix: "[skip ci]"
schedule:
interval: weekly

3
.github/labeler.yml vendored
View file

@ -6,5 +6,6 @@
"App: Slack": "apps/slack/**/*"
"App: Taxes": "apps/taxes/**/*"
"App: Emails & Messages": "apps/emails-and-messages/**/*"
"App: CMS": ["apps/cms/**/*", "apps/cms-v2/**/*"]
"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

View file

@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: JamieMason/syncpack-github-action@0.2.2
- uses: JamieMason/syncpack-github-action@c145cec44b3731b3fe8e859679e240d6ae011f0f
continue-on-error: true
with:
package-manager: "pnpm"

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

@ -25,11 +25,11 @@ jobs:
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

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

3
.gitignore vendored
View file

@ -42,4 +42,5 @@ apps/**/generated
.sentryclirc
.vscode/
.vscode/
.cspellcache

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">
@ -46,10 +48,10 @@ This repository serves as a starting point in the exploration of Saleor apps.
In the `apps` folder, you will find the following applications:
- [crm](https://docs.saleor.io/docs/3.x/developer/app-store/apps/crm) - exports customers from Saleor to CRM.
- [cms](./apps/cms) - exports products from Saleor to CMS.
- [cms](https://docs.saleor.io/docs/3.x/developer/app-store/apps/cms) - exports products from Saleor to CMS.
- [data-importer](./apps/data-importer) - import data from CSV to Saleor.
- [emails-and-messages](./apps/emails-and-messages) - notifications and email communication with customers.
- [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.
- [monitoring](./apps/monitoring) - send Saleor logs to 3rd party Monitoring services
- [products-feed](./apps/products-feed) - generate products feed XML
@ -57,6 +59,11 @@ In the `apps` folder, you will find the following applications:
- [slack](./apps/slack) - get notifications on Slack channel from Saleor events.
- [taxes](https://docs.saleor.io/docs/3.x/developer/app-store/apps/taxes) - calculate order and checkout taxes using external services.
## Another official apps
Some of Saleor apps are available in separate repositories:
- [Stripe](https://github.com/saleor/saleor-app-payment-stripe)
## Development
You can find the documentation for saleor/apps on [docs.saleor.io](https://docs.saleor.io/docs/3.x/developer/app-store/development).

View file

@ -1,5 +1,101 @@
# 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

View file

@ -1,6 +1,6 @@
{
"name": "saleor-app-cms-v2",
"version": "2.1.0",
"version": "2.3.3",
"scripts": {
"build": "pnpm generate && next build",
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
@ -13,28 +13,29 @@
},
"dependencies": {
"@datocms/cma-client-browser": "2.0.0",
"@hookform/resolvers": "^3.1.0",
"@saleor/app-sdk": "0.41.1",
"@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.95",
"@saleor/macaw-ui": "0.8.0-pre.127",
"@saleor/react-hook-form-macaw": "workspace:*",
"@sentry/nextjs": "7.55.2",
"@sentry/nextjs": "7.67.0",
"@tanstack/react-query": "^4.29.19",
"@trpc/client": "10.34.0",
"@trpc/next": "10.34.0",
"@trpc/react-query": "10.34.0",
"@trpc/server": "10.34.0",
"@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.0",
"@vitejs/plugin-react": "4.0.4",
"contentful-management": "10.38.3",
"graphql": "16.6.0",
"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",
@ -42,23 +43,24 @@
"strapi-sdk-js": "2.2.0",
"urql": "^4.0.4",
"usehooks-ts": "^2.9.1",
"vite": "4.3.9",
"vitest": "0.31.3",
"vite": "4.4.8",
"vitest": "0.34.1",
"zod": "3.21.4"
},
"devDependencies": {
"@graphql-codegen/cli": "3.2.2",
"@graphql-codegen/introspection": "3.0.1",
"@graphql-codegen/typed-document-node": "3.0.2",
"@graphql-codegen/typescript": "3.0.2",
"@graphql-codegen/typescript-operations": "3.0.2",
"@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": "^13.4.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.44.0",
"eslint": "8.46.0",
"eslint-config-saleor": "workspace:*",
"node-mocks-http": "^1.12.2",
"typescript": "5.1.6"

View file

@ -6,26 +6,20 @@ import { Select } from "@saleor/react-hook-form-macaw";
import { useRouter } from "next/router";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { ButtonsBox } from "../ui/buttons-box";
import { ProvidersResolver } from "../providers/providers-resolver";
import { Skeleton } from "../ui/skeleton";
import { ButtonsBox, Layout, SkeletonLayout } from "@saleor/apps-ui";
const FormSchema = z.object({
connectionId: z.string().min(7),
});
const EmptyState = () => (
<Box
display="flex"
paddingY={4}
flexDirection={"column"}
gap={4}
alignItems={"center"}
justifyContent={"center"}
>
<Text variant="heading">No connections configured</Text>
<Text>Create a channel connection above to enable bulk synchronization.</Text>
</Box>
<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 = () => {
@ -42,7 +36,7 @@ export const BulkSyncSection = () => {
});
if (!connections || !providers) {
return <Skeleton.Section />;
return <SkeletonLayout.Section />;
}
if (connections.length === 0) {
@ -50,7 +44,7 @@ export const BulkSyncSection = () => {
}
return (
<Box>
<Layout.AppSectionCard>
<Text as="h2" marginBottom={6} variant="heading">
Bulk products synchronization
</Text>
@ -88,6 +82,6 @@ export const BulkSyncSection = () => {
<Button type="submit">Start sync</Button>
</ButtonsBox>
</Box>
</Box>
</Layout.AppSectionCard>
);
};

View file

@ -1,10 +1,8 @@
import { Breadcrumbs } from "@saleor/apps-ui";
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 { AppSection } from "../ui/app-section";
import { ButtonsBox } from "../ui/buttons-box";
import { useBulkSyncProductsState } from "./use-bulk-sync-products-state";
import { useFetchAllProducts } from "./use-fetch-all-products";
@ -14,16 +12,19 @@ import { useDashboardNotification } from "@saleor/apps-shared";
const FetchProductsStep = (props: { onButtonClick(): void }) => {
return (
<Box>
<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>
<ButtonsBox>
<Button onClick={props.onButtonClick}>Prefetch products</Button>
</ButtonsBox>
</Box>
</Layout.AppSectionCard>
);
};
@ -66,7 +67,7 @@ export const BulkSyncView = ({
const { products, finished: saleorProductsFetchFinished } = useFetchAllProducts(
state === "fetching",
connection.channelSlug
connection.channelSlug,
);
const { productsStatusList, setInitialProducts, setItemStatus, finished } =
@ -121,9 +122,14 @@ export const BulkSyncView = ({
]}
/>
<AppSection
<Layout.AppSection
marginBottom={8}
mainContent={(() => {
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 (
@ -149,33 +155,29 @@ export const BulkSyncView = ({
}
}
})()}
heading="1. Fetch products"
sideContent={
<Text>First pre-fetch all Product Variants from Saleor. Do not close the app.</Text>
}
/>
</Layout.AppSection>
{(state === "fetched" || state === "uploading") && productsStatusList && (
<AppSection
<Layout.AppSection
marginTop={14}
heading="2. Upload to the CMS"
sideContent={<Text>Send listed variants to the CMS</Text>}
mainContent={
<Box>
<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} />
</Box>
}
/>
>
<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

@ -57,10 +57,12 @@ export const AddConnectionForm = (props: {
control={control}
name="channelSlug"
label="Channel"
options={channels?.map((c) => ({
value: c.slug,
label: c.name,
}))}
options={
channels?.map((c) => ({
value: c.slug,
label: c.name,
})) ?? []
}
/>
<ArrowRightIcon />
<Select
@ -69,10 +71,12 @@ export const AddConnectionForm = (props: {
control={control}
name="providerId"
label="Provider"
options={providers?.map((p) => ({
value: p.id,
label: p.configName,
}))}
options={
providers?.map((p) => ({
value: p.id,
label: p.configName,
})) ?? []
}
/>
</Box>
{errors.root?.serverError && (

View file

@ -1,5 +1,4 @@
import { Button, Text } from "@saleor/macaw-ui/next";
import { ButtonsBox } from "../ui/buttons-box";
import { Modal } from "../ui/modal";
import {
AddConnectionForm,
@ -7,7 +6,7 @@ import {
AddConnectionFormSchema,
} from "./add-connection-form";
import { trpcClient } from "../trpc/trpc-client";
import { Skeleton } from "../ui/skeleton";
import { ButtonsBox, SkeletonLayout } from "@saleor/apps-ui";
const defaultValues: AddConnectionFormSchema = { channelSlug: "", providerId: "" };
@ -15,7 +14,7 @@ export const AddConnectionModal = (props: { onSuccess(): void; onClose(): void }
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
if (!providers) {
return <Skeleton.Section />;
return <SkeletonLayout.Section />;
}
const { mutateAsync: addProviderMutate, isLoading } =

View file

@ -1,35 +1,31 @@
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 { ButtonsBox } from "../ui/buttons-box";
import { AddConnectionFormSchema } from "./add-connection-form";
import { AddConnectionModal } from "./add-connection-modal";
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
import { ConnectionsList } from "./connections-list";
import { Skeleton } from "../ui/skeleton";
const NoConnections = (props: { onCreate(): void; enabled: boolean }) => (
<Box>
<ChanelProviderConnectionsSectionHeader />
<Text marginBottom={4} as="p">
<Text as="p">
No channels connected yet.{" "}
{!props.enabled &&
"Ensure you have created a provider configuration that can be connected first."}
</Text>
{props.enabled && (
<ButtonsBox>
<Button onClick={props.onCreate}>Create first connection</Button>
</ButtonsBox>
)}
</Box>
);
export const ChannelProviderConnectionList = () => {
const [dialogOpen, setDialogOpen] = useState(false);
const { data: connectionsData, refetch: refetchConnections } =
trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
const {
data: connectionsData,
refetch: refetchConnections,
isLoading,
} = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
const { mutate: removeConnection } =
trpcClient.channelsProvidersConnection.removeConnection.useMutation({
@ -45,19 +41,33 @@ export const ChannelProviderConnectionList = () => {
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
if (!providers) {
return <Skeleton.Section />;
return <SkeletonLayout.Section />;
}
const handleDelete = (connectionId: string) => {
removeConnection({ id: connectionId });
};
if (!connectionsData) {
return <Text>Loading</Text>;
if (isLoading || !connectionsData) {
return <SkeletonLayout.Section />;
}
return (
<Box>
<Layout.AppSectionCard
footer={
providers.length > 0 && (
<ButtonsBox>
<Button
onClick={() => {
setDialogOpen(true);
}}
>
Add connection
</Button>
</ButtonsBox>
)
}
>
{dialogOpen && (
<AddConnectionModal
onClose={() => {
@ -79,17 +89,6 @@ export const ChannelProviderConnectionList = () => {
/>
)}
{connectionsData.length > 0 && <ConnectionsList onRemove={handleDelete} />}
{connectionsData.length > 0 && (
<ButtonsBox marginTop={6}>
<Button
onClick={() => {
setDialogOpen(true);
}}
>
Add connection
</Button>
</ButtonsBox>
)}
</Box>
</Layout.AppSectionCard>
);
};

View file

@ -4,7 +4,7 @@ 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 { Skeleton } from "../ui/skeleton";
import { SkeletonLayout } from "@saleor/apps-ui";
export const ConnectionsList = (props: { onRemove(connectionId: string): void }) => {
const { data } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
@ -12,7 +12,7 @@ export const ConnectionsList = (props: { onRemove(connectionId: string): void })
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
if (!data || !providers) {
return <Skeleton.Section />;
return <SkeletonLayout.Section />;
}
return (

View file

@ -1,101 +1,12 @@
import {
MetadataEntry,
EncryptedMetadataManager,
SettingsManager,
} from "@saleor/app-sdk/settings-manager";
import { Client, gql } from "urql";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateAppMetadataDocument,
} from "../../../generated/graphql";
gql`
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
updatePrivateMetadata(id: $id, input: $input) {
item {
privateMetadata {
key
value
}
}
}
}
`;
gql`
query FetchAppDetails {
app {
id
privateMetadata {
key
value
}
}
}
`;
gql`
mutation RemoveMetadata($id: ID!, $keys: [String!]!) {
deletePrivateMetadata(id: $id, keys: $keys) {
errors {
message
}
}
}
`;
export type SimpleGraphqlClient = Pick<Client, "mutation" | "query">;
async function fetchAllMetadata(client: SimpleGraphqlClient): Promise<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
if (error) {
return [];
}
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
}
async function mutateMetadata(
client: SimpleGraphqlClient,
metadata: MetadataEntry[],
appId: string
) {
const { error: mutationError, data: mutationData } = await client
.mutation(UpdateAppMetadataDocument, {
id: appId,
input: metadata,
})
.toPromise();
if (mutationError) {
throw new Error(`Mutation error: ${mutationError.message}`);
}
return (
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
key: md.key,
value: md.value,
})) || []
);
}
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { EncryptedMetadataManagerFactory } from "@saleor/apps-shared";
import { Client } from "urql";
export const createSettingsManager = (
client: SimpleGraphqlClient,
appId: string
client: Pick<Client, "query" | "mutation">,
appId: string,
): SettingsManager => {
/*
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
* We recommend it for production, because all values are encrypted.
* If your use case require plain text values, you can use MetadataManager.
*/
return new EncryptedMetadataManager({
// Secret key should be randomly created for production and set as environment variable
encryptionKey: process.env.SECRET_KEY!,
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutateMetadata(client, metadata, appId),
});
const metadataManagerFactory = new EncryptedMetadataManagerFactory(process.env.SECRET_KEY!);
return metadataManagerFactory.create(client, appId);
};

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

@ -4,6 +4,7 @@ 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([
@ -14,6 +15,7 @@ export namespace ProvidersConfig {
DatocmsProviderConfig.Schema.Full,
StrapiProviderConfig.Schema.Full,
BuilderIoProviderConfig.Schema.Full,
PayloadCmsProviderConfig.Schema.Full,
]);
export const Schema = {
@ -23,6 +25,7 @@ export namespace ProvidersConfig {
DatocmsProviderConfig.Schema.Input,
StrapiProviderConfig.Schema.Input,
BuilderIoProviderConfig.Schema.Input,
PayloadCmsProviderConfig.Schema.Input,
]),
AnyFullList: z.array(AnyFull),
};

View file

@ -5,8 +5,8 @@ import { ProvidersConfig } from "../configuration";
import { ProvidersResolver } from "../providers/providers-resolver";
import { trpcClient } from "../trpc/trpc-client";
import { ButtonsBox } from "../ui/buttons-box";
import { Skeleton } from "../ui/skeleton";
import { ButtonsBox, Layout, SkeletonLayout } from "@saleor/apps-ui";
const ProvidersTable = (props: { providers: ProvidersConfig.AnyFullShape[] }) => {
const { push } = useRouter();
@ -47,30 +47,45 @@ export const ProvidersList = () => {
const { push } = useRouter();
if (!data) {
return <Skeleton.Section />;
return <SkeletonLayout.Section />;
}
if (data.length === 0) {
return (
<Box>
<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 first CMS configuration
Add CMS configuration
</Button>
</ButtonsBox>
</Box>
);
}
return (
<Box>
}
>
{data.length && (
<Box>
<Text variant="heading" as="h2" marginBottom={4}>
@ -79,15 +94,6 @@ export const ProvidersList = () => {
<ProvidersTable providers={data} />
</Box>
)}
<ButtonsBox marginTop={8}>
<Button
onClick={() => {
push("/add-provider");
}}
>
Add CMS configuration
</Button>
</ButtonsBox>
</Box>
</Layout.AppSectionCard>
);
};

View file

@ -7,9 +7,7 @@ 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 } from "../../ui/buttons-box";
import { TextLink } from "@saleor/apps-ui";
import { Skeleton } from "@/modules/ui/skeleton";
import { ButtonsBox, SkeletonLayout, TextLink } from "@saleor/apps-ui";
type FormShape = Omit<BuilderIoProviderConfig.InputShape, "type">;
const FormSchema = BuilderIoProviderConfig.Schema.Input.omit({ type: true });
@ -46,6 +44,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Provide connection details</Text>
<Input
type="password"
required
control={control}
name="privateApiKey"
@ -60,6 +59,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
}
/>
<Input
type="password"
required
control={control}
name="publicApiKey"
@ -102,7 +102,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
padding={2}
>
<Text variant="caption">Saleor Field</Text>
<Text variant="caption">Contentful field</Text>
<Text variant="caption">Builder.io field</Text>
</Box>
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
// todo extract this table to component
@ -125,7 +125,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
size="small"
control={control}
name={`productVariantFieldsMapping.${saleorField}`}
label="Builer.io Field"
label="Builder.io Field"
/>
</Box>
))}
@ -188,7 +188,7 @@ const EditFormVariant = (props: { configId: string }) => {
},
{
enabled: !!props.configId,
}
},
);
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
onSuccess() {
@ -205,11 +205,11 @@ const EditFormVariant = (props: { configId: string }) => {
});
if (!data) {
return <Skeleton.Section />;
return <SkeletonLayout.Section />;
}
if (data.type !== "builder.io") {
throw new Error("Trying to fill builer.io form with non builder.io data");
throw new Error("Trying to fill builder.io form with non builder.io data");
}
return (

View file

@ -9,8 +9,7 @@ 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 } from "../../ui/buttons-box";
import { TextLink } from "@saleor/apps-ui";
import { ButtonsBox, TextLink } from "@saleor/apps-ui";
import { SaleorProviderFieldsMappingKeys } from "@/modules/configuration";
type FormSchema = Omit<ContentfulProviderConfig.InputShape, "type">;
@ -53,7 +52,7 @@ const PureForm = ({
});
notifyError(
"Error",
"Could not fetch content types from Contentful. Please check your credentials."
"Could not fetch content types from Contentful. Please check your credentials.",
);
},
});
@ -76,7 +75,7 @@ const PureForm = ({
});
notifyError(
"Error",
"Could not fetch environments from Contentful. Please check your credentials."
"Could not fetch environments from Contentful. Please check your credentials.",
);
},
});
@ -197,7 +196,7 @@ const PureForm = ({
newTab
size="small"
href={`https://app.contentful.com/spaces/${getValues(
"spaceId"
"spaceId",
)}/settings/environments`}
>
here
@ -247,7 +246,7 @@ const PureForm = ({
<Box marginTop={4}>
<Text as="p" variant="heading" size="small">
Map fields from Saleor to your contentful schema.
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
@ -357,7 +356,7 @@ const EditVariant = ({ configId }: { configId: string }) => {
},
{
enabled: !!configId,
}
},
);
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
onSuccess() {

View file

@ -8,8 +8,8 @@ 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 { ButtonsBox } from "../../ui/buttons-box";
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
import { ButtonsBox } from "@saleor/apps-ui";
type FormShape = Omit<DatocmsProviderConfig.InputShape, "type">;
@ -43,7 +43,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
notifyError(
"Error",
"Could not fetch content types from DatoCMS. Please check your credentials."
"Could not fetch content types from DatoCMS. Please check your credentials.",
);
},
});
@ -61,7 +61,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
notifyError(
"Error",
"Could not fetch content types from DatoCMS. Please check your credentials."
"Could not fetch content types from DatoCMS. Please check your credentials.",
);
},
});
@ -161,7 +161,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
{fieldsData && (
<React.Fragment>
<Text as="p" variant="heading" size="small">
Map fields from Saleor to your contentful schema.
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
@ -177,7 +177,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
padding={2}
>
<Text variant="caption">Saleor Field</Text>
<Text variant="caption">Contentful field</Text>
<Text variant="caption">DatoCMS field</Text>
</Box>
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
// todo extract this table to component
@ -271,7 +271,7 @@ const EditFormVariant = (props: { configId: string }) => {
},
{
enabled: !!props.configId,
}
},
);
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
onSuccess() {

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View file

@ -0,0 +1,51 @@
import { BulkImportProductFragment } from "../../../../generated/graphql";
import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
import { PayloadCmsProviderConfig } from "@/modules/configuration/schemas/payloadcms-provider.schema";
import { PayloadCMSClient } from "./payloadcms-client";
// todo CORS or proxy
export class PayloadCmsBulkSyncProcessor implements BulkSyncProcessor {
constructor(private config: PayloadCmsProviderConfig.FullShape) {}
async uploadProducts(
products: BulkImportProductFragment[],
hooks: BulkSyncProcessorHooks,
): Promise<void> {
const client = new PayloadCMSClient();
products.flatMap(
(product) =>
product.variants?.map((variant) => {
if (hooks.onUploadStart) {
hooks.onUploadStart({ variantId: variant.id });
}
return client
.upsertProductVariant({
configuration: this.config,
variant: {
id: variant.id,
name: variant.name,
channelListings: variant.channelListings,
product: {
id: product.id,
name: product.name,
slug: product.slug,
},
},
})
.then((r) => {
if (hooks.onUploadSuccess) {
hooks.onUploadSuccess({ variantId: variant.id });
}
})
.catch((e) => {
if (hooks.onUploadError) {
hooks.onUploadError({ variantId: variant.id, error: e });
}
});
}),
);
}
}

View file

@ -0,0 +1,172 @@
import { createLogger } from "@saleor/apps-shared";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
import { PayloadCmsProviderConfig } from "@/modules/configuration/schemas/payloadcms-provider.schema";
import { FieldsMapper } from "../fields-mapper";
import qs from "qs";
import { z } from "zod";
type Context = {
configuration: PayloadCmsProviderConfig.FullShape;
variant: WebhookProductVariantFragment;
};
/**
* Client uses REST API with built-in query language
* https://payloadcms.com/docs/queries/overview#rest-queries
*/
export class PayloadCMSClient {
private logger = createLogger({ name: "PayloadCMSClient" });
private mapVariantToPayloadFields({ configuration, variant }: Context) {
const fields = FieldsMapper.mapProductVariantToConfigurationFields({
variant,
configMapping: configuration.productVariantFieldsMapping,
});
return fields;
}
private constructCollectionUrl(config: PayloadCmsProviderConfig.FullShape) {
return `${config.payloadApiUrl}/${config.collectionName}`;
}
getItemsBySaleorVariantId(context: Context) {
const queryString = qs.stringify(
{
where: {
[context.configuration.productVariantFieldsMapping.variantId]: {
equals: context.variant.id,
},
},
},
{
addQueryPrefix: true,
},
);
return fetch(`${this.constructCollectionUrl(context.configuration)}${queryString}`, {
headers: this.getHeaders(context),
}).then((r) => r.json());
}
async deleteProductVariant(context: Context) {
const queryString = qs.stringify(
{
where: {
[context.configuration.productVariantFieldsMapping.variantId]: {
equals: context.variant.id,
},
},
},
{
addQueryPrefix: true,
},
);
try {
const response = await fetch(
this.constructCollectionUrl(context.configuration) + queryString,
{
method: "DELETE",
headers: this.getHeaders(context),
},
);
if (response.status >= 400) {
throw new Error("Error while deleting product variant");
}
} catch (e) {
this.logger.error(e);
throw e;
}
}
private getHeaders(context: Context) {
const headers = new Headers({
"Content-Type": "application/json",
});
/**
* https://payloadcms.com/docs/authentication/config#api-keys
*/
if (
context.configuration.authToken.length > 0 &&
context.configuration.authenticatedUserSlug.length > 0
) {
headers.append(
"Authorization",
`${context.configuration.authenticatedUserSlug} API-Key ${context.configuration.authToken}`,
);
}
return headers;
}
uploadProductVariant(context: Context) {
this.logger.debug("Trying to upload product variant");
return fetch(this.constructCollectionUrl(context.configuration), {
method: "POST",
body: JSON.stringify(this.mapVariantToPayloadFields(context)),
headers: this.getHeaders(context),
})
.then((r) => {
if (r.status >= 400) {
throw new Error(`Error while uploading product variant: ${r.statusText}`);
}
})
.catch((e) => {
this.logger.error(e);
throw e;
});
}
async updateProductVariant({ configuration, variant }: Context) {
this.logger.debug("Trying to update product variant");
const queryString = qs.stringify(
{
where: {
[configuration.productVariantFieldsMapping.variantId]: {
equals: variant.id,
},
},
},
{
addQueryPrefix: true,
},
);
try {
const response = await fetch(this.constructCollectionUrl(configuration) + queryString, {
method: "PATCH",
body: JSON.stringify(this.mapVariantToPayloadFields({ configuration, variant })),
headers: this.getHeaders({ configuration, variant }),
});
if (response.status >= 400) {
throw new Error("Error while updating product variant");
}
} catch (e) {
this.logger.error(e);
throw e;
}
}
async upsertProductVariant(context: Context) {
this.logger.debug("Trying to upsert product variant");
try {
await this.uploadProductVariant(context);
} catch (e) {
this.logger.debug("Failed to upload, will try to update");
await this.updateProductVariant(context);
}
}
}

View file

@ -0,0 +1,264 @@
import { SaleorProviderFieldsMappingKeys } from "@/modules/configuration";
import { PayloadCmsProviderConfig } from "@/modules/configuration/schemas/payloadcms-provider.schema";
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 from "react";
import { useForm } from "react-hook-form";
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
import { trpcClient } from "../../trpc/trpc-client";
import { ButtonsBox, TextLink } from "@saleor/apps-ui";
type FormShape = Omit<PayloadCmsProviderConfig.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(PayloadCmsProviderConfig.Schema.Input.omit({ type: true })),
});
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="payloadApiUrl"
type="url"
label="API url"
helperText="URL where Payload API is available. By default ends with /api"
/>
<Box
backgroundColor="surfaceNeutralHighlight"
borderColor="neutralHighlight"
borderWidth={1}
borderStyle={"solid"}
padding={4}
borderRadius={4}
>
<Text variant="heading" as="h1" marginBottom={4}>
Authorization
</Text>
<Text marginBottom={2} as="p">
Payload can be configured to have open operations (not recommended) or to require an API
key. Key can be generated per user. To authenticate, you need to provide both user slug
and the key itself.{" "}
</Text>
<TextLink
marginBottom={2}
display="block"
newTab
href="https://payloadcms.com/docs/authentication/config"
>
Read more in Payload docs
</TextLink>
<Text as="p" marginBottom={6}>
If your API is open (e.g. for development purposes) leave both fields empty.
</Text>
<Box display="grid" gap={4} gridTemplateColumns={2}>
<Input
control={control}
name="authenticatedUserSlug"
label="Authenticated user slug"
placeholder="e.g. apps"
/>
<Input control={control} name="authToken" type="password" label="User API Key" />
</Box>
</Box>
</Box>
<Box display={"grid"} gap={4} marginY={4}>
<Text variant="heading">Configure fields mapping</Text>
<Input
label="Collection Slug"
name="collectionName"
control={control}
helperText="Slug of your collection in Payload, e.g. 'saleorVariants'"
/>
<React.Fragment>
<Text as="p" variant="heading" size="small">
Map fields from Saleor to your Payload 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">Payload 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="CMS Field"
/>
</Box>
))}
</React.Fragment>
</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 } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() {
notifySuccess("Success", "Added new configuration");
push("/configuration");
},
});
return (
<PureForm
onSubmit={(values) => {
mutate({
...values,
type: "payloadcms",
});
}}
defaultValues={{
payloadApiUrl: "",
authToken: "",
configName: "",
collectionName: "",
authenticatedUserSlug: "",
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 !== "payloadcms") {
throw new Error("Trying to fill Payload CMS form with non Payload CMS data");
}
return (
<PureForm
onDelete={() => {
deleteProvider({
id: props.configId,
});
}}
onSubmit={(values) => {
mutate({
...values,
type: "payloadcms",
id: props.configId,
});
}}
defaultValues={data}
/>
);
};
export const PayloadCMSConfigForm = {
PureVariant: PureForm,
AddVariant: AddFormVariant,
EditVariant: EditFormVariant,
};

View file

@ -0,0 +1,69 @@
import {
WebhookProductFragment,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import { PayloadCmsProviderConfig } from "@/modules/configuration/schemas/payloadcms-provider.schema";
import { createLogger } from "@saleor/apps-shared";
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
import { PayloadCMSClient } from "./payloadcms-client";
/*
* todo error handling
*/
export class PayloadCmsWebhooksProcessor implements ProductWebhooksProcessor {
private client = new PayloadCMSClient();
private logger = createLogger({ name: "PayloadCmsWebhooksProcessor" });
constructor(private providerConfig: PayloadCmsProviderConfig.FullShape) {}
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.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");
const client = new PayloadCMSClient();
await Promise.all(
(product.variants ?? []).map((variant) => {
return 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,29 @@
import { Box, Text } from "@saleor/macaw-ui/next";
import { CMSProviderMeta } from "../cms-provider-meta";
import logo from "./logo.png";
import { TextLink } from "@saleor/apps-ui";
export const PayloadCMS = {
formSideInfo: (
<Box>
<Text as="p" marginBottom={2}>
Configure the Payload CMS integration by providing required information.
</Text>
<Text as="p" marginBottom={2}>
Fields are not validated - ensure you enter correct values.
</Text>
<Text as="p" marginBottom={2}>
Consult{" "}
<TextLink newTab href="https://docs.saleor.io/docs/3.x/developer/app-store/apps/cms">
docs
</TextLink>{" "}
for more information how to set up Payload CMS.
</Text>
</Box>
),
type: "payloadcms" as const,
logoUrl: logo.src,
displayName: "Payload",
description: "Open source, typescript first headless CMS. GraphQL included.",
} satisfies CMSProviderMeta;

View file

@ -1,12 +1,24 @@
import { BuilderIo } from "./builder.io/builder-io";
import { Contentful } from "./contentful/contentful";
import { Datocms } from "./datocms/datocms";
import { PayloadCMS } from "./payloadcms/payloadcms";
import { Strapi } from "./strapi/strapi";
export type CMS = typeof Contentful | typeof Datocms | typeof Strapi | typeof BuilderIo;
export type CMS =
| typeof Contentful
| typeof Datocms
| typeof Strapi
| typeof BuilderIo
| typeof PayloadCMS;
export type CMSType = CMS["type"];
export const cmsTypes = [Contentful.type, Datocms.type, Strapi.type, BuilderIo.type] as const;
export const cmsTypes = [
Contentful.type,
Datocms.type,
Strapi.type,
BuilderIo.type,
PayloadCMS.type,
] as const;
export const CMSProviders = [Contentful, Datocms, Strapi, BuilderIo] as const;
export const CMSProviders = [Contentful, Datocms, Strapi, BuilderIo, PayloadCMS] as const;

View file

@ -22,6 +22,10 @@ import { DatocmsProviderConfig } from "../configuration/schemas/datocms-provider
import { BuilderIo } from "./builder.io/builder-io";
import { BuilderIoWebhooksProcessor } from "./builder.io/builder-io-webhooks-processor";
import { BuilderIoBulkSyncProcessor } from "./builder.io/builder-io-bulk-sync-processor";
import { PayloadCmsBulkSyncProcessor } from "./payloadcms/payloadcms-bulk-sync-processor";
import { PayloadCmsProviderConfig } from "../configuration/schemas/payloadcms-provider.schema";
import { PayloadCMS } from "./payloadcms/payloadcms";
import { PayloadCmsWebhooksProcessor } from "./payloadcms/payloadcms-webhooks-processor";
/**
* Almost-single source of new providers. Every time app will need to resolve a provider, it will use on of these factories.
@ -39,9 +43,9 @@ export const ProvidersResolver = {
case "builder.io": {
return new BuilderIoBulkSyncProcessor(config);
}
default:
throw new Error(`Unknown provider`);
case "payloadcms": {
return new PayloadCmsBulkSyncProcessor(config);
}
}
},
getProviderInputSchema(type: CMSType) {
@ -54,9 +58,8 @@ export const ProvidersResolver = {
return StrapiProviderConfig.Schema.Input;
case "builder.io":
return BuilderIoProviderConfig.Schema.Input;
default: {
throw new Error("Failed to build input schema");
}
case "payloadcms":
return PayloadCmsProviderConfig.Schema.Input;
}
},
getProviderSchema(type: CMSType) {
@ -69,12 +72,11 @@ export const ProvidersResolver = {
return StrapiProviderConfig.Schema.Full;
case "builder.io":
return BuilderIoProviderConfig.Schema.Full;
default: {
throw new Error("Failed to build provdier schema");
}
case "payloadcms":
return PayloadCmsProviderConfig.Schema.Full;
}
},
createProviderMeta(type: CMSType | string): CMS {
createProviderMeta(type: CMSType): CMS {
switch (type) {
case "contentful": {
return Contentful;
@ -88,8 +90,8 @@ export const ProvidersResolver = {
case "builder.io": {
return BuilderIo;
}
default: {
throw new Error("Unknown provider");
case "payloadcms": {
return PayloadCMS;
}
}
},
@ -107,13 +109,13 @@ export const ProvidersResolver = {
case "builder.io": {
return new BuilderIoWebhooksProcessor(config);
}
default: {
throw new Error("Failed to build webhook processor.");
case "payloadcms": {
return new PayloadCmsWebhooksProcessor(config);
}
}
},
getEditProviderFormComponent: (
type: CMSType
type: CMSType,
): ComponentType<{
configId: string;
}> => {
@ -121,33 +123,37 @@ export const ProvidersResolver = {
case "contentful": {
return dynamic(() =>
import("./contentful/contentful-config-form").then(
(module) => module.ContentfulConfigForm.EditVariant
)
(module) => module.ContentfulConfigForm.EditVariant,
),
);
}
case "datocms": {
return dynamic(() =>
import("./datocms/datocms-config-form").then(
(module) => module.DatoCMSConfigForm.EditVariant
)
(module) => module.DatoCMSConfigForm.EditVariant,
),
);
}
case "strapi": {
return dynamic(() =>
import("./strapi/strapi-config-form").then(
(module) => module.StrapiConfigForm.EditVariant
)
(module) => module.StrapiConfigForm.EditVariant,
),
);
}
case "builder.io": {
return dynamic(() =>
import("./builder.io/builder-io-config-form").then(
(module) => module.BuilderIoConfigForm.EditVariant
)
(module) => module.BuilderIoConfigForm.EditVariant,
),
);
}
default: {
throw new Error("Provider form not registered");
case "payloadcms": {
return dynamic(() =>
import("./payloadcms/payloadcms-config-form").then(
(module) => module.PayloadCMSConfigForm.EditVariant,
),
);
}
}
},
@ -156,31 +162,37 @@ export const ProvidersResolver = {
case "contentful": {
return dynamic(() =>
import("./contentful/contentful-config-form").then(
(module) => module.ContentfulConfigForm.AddVariant
)
(module) => module.ContentfulConfigForm.AddVariant,
),
);
}
case "datocms": {
return dynamic(() =>
import("./datocms/datocms-config-form").then(
(module) => module.DatoCMSConfigForm.AddVariant
)
(module) => module.DatoCMSConfigForm.AddVariant,
),
);
}
case "strapi": {
return dynamic(() =>
import("./strapi/strapi-config-form").then((module) => module.StrapiConfigForm.AddVariant)
import("./strapi/strapi-config-form").then(
(module) => module.StrapiConfigForm.AddVariant,
),
);
}
case "builder.io": {
return dynamic(() =>
import("./builder.io/builder-io-config-form").then(
(module) => module.BuilderIoConfigForm.AddVariant
)
(module) => module.BuilderIoConfigForm.AddVariant,
),
);
}
default: {
throw new Error("Provider form not registered");
case "payloadcms": {
return dynamic(() =>
import("./payloadcms/payloadcms-config-form").then(
(module) => module.PayloadCMSConfigForm.AddVariant,
),
);
}
}
},

View file

@ -7,8 +7,7 @@ import { useForm } from "react-hook-form";
import { SaleorProviderFieldsMappingKeys, StrapiProviderConfig } from "../../configuration";
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
import { trpcClient } from "../../trpc/trpc-client";
import { ButtonsBox } from "../../ui/buttons-box";
import { Skeleton } from "@/modules/ui/skeleton";
import { ButtonsBox, SkeletonLayout } from "@saleor/apps-ui";
type FormShape = Omit<StrapiProviderConfig.InputShape, "type">;
@ -86,7 +85,7 @@ const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
padding={2}
>
<Text variant="caption">Saleor Field</Text>
<Text variant="caption">Contentful field</Text>
<Text variant="caption">Strapi field</Text>
</Box>
{SaleorProviderFieldsMappingKeys.map((saleorField) => (
// todo extract this table to component
@ -172,7 +171,7 @@ const EditFormVariant = (props: { configId: string }) => {
},
{
enabled: !!props.configId,
}
},
);
const { mutate } = trpcClient.providersConfigs.updateOne.useMutation({
onSuccess() {
@ -189,7 +188,7 @@ const EditFormVariant = (props: { configId: string }) => {
});
if (!data) {
return <Skeleton.Section />;
return <SkeletonLayout.Section />;
}
if (data.type !== "strapi") {

View file

@ -9,7 +9,7 @@ dialog {
.dialog-overlay {
z-index: 1;
background: rgba(255, 255, 255, 0.8);
background: color-mix(in srgb, var(--mu-colors-background-plain) 80%, transparent);
backdrop-filter: blur(5px);
content: "";
position: fixed;

View file

@ -1,10 +1,10 @@
import * as trpcNext from "@trpc/server/adapters/next";
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const";
import { inferAsyncReturnType } from "@trpc/server";
import { getBaseUrl } from "@/modules/shared/get-base-url";
import { getAppBaseUrl } from "@saleor/apps-shared";
export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => {
const baseUrl = getBaseUrl(req.headers);
const baseUrl = getAppBaseUrl(req.headers);
return {
token: req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER] as string | undefined,

View file

@ -1,43 +0,0 @@
import { Box, PropsWithBox, Text } from "@saleor/macaw-ui/next";
import { ReactNode } from "react";
// todo move to shared
export const AppSection = ({
heading,
sideContent,
mainContent,
includePadding = true,
...props
}: PropsWithBox<{
heading: string;
sideContent?: ReactNode;
mainContent: ReactNode;
includePadding?: boolean;
}>) => {
return (
<Box
as="section"
__gridTemplateColumns={"400px auto"}
display={"grid"}
gap={10}
__maxWidth={"1200px"}
{...props}
>
<Box>
<Text as="h2" variant={"heading"} size={"large"} marginBottom={1.5}>
{heading}
</Text>
{sideContent}
</Box>
<Box
borderStyle={"solid"}
borderColor={"neutralPlain"}
borderWidth={1}
padding={includePadding ? 5 : 0}
borderRadius={4}
>
{mainContent}
</Box>
</Box>
);
};

View file

@ -1,5 +1,4 @@
import { Box, BoxProps } from "@saleor/macaw-ui/next";
import { forwardRef } from "react";
export const Modal = ({ onClose, ...rest }: { onClose(): void } & BoxProps) => {
return (
@ -12,6 +11,8 @@ export const Modal = ({ onClose, ...rest }: { onClose(): void } & BoxProps) => {
as="dialog"
__maxWidth="400px"
boxShadow={"modal"}
backgroundColor="surfaceNeutralPlain"
color="textNeutralDefault"
open
{...rest}
/>

View file

@ -1,29 +0,0 @@
import { Box, BoxProps } from "@saleor/macaw-ui/next";
// TODO: Make it more generic, move to shared or contribute to macaw
const Section = (props: BoxProps) => {
return (
<Box display="grid" gap={2} {...props}>
<Box
__height="10px"
backgroundColor="surfaceNeutralHighlight"
borderRadius={2}
__width="50%"
/>
<Box
__height="10px"
backgroundColor="surfaceNeutralHighlight"
borderRadius={2}
__width="70%"
/>
<Box
__height="10px"
backgroundColor="surfaceNeutralHighlight"
borderRadius={2}
__width="60%"
/>
</Box>
);
};
export const Skeleton = { Section };

View file

@ -1,7 +1,6 @@
import { CMSProviders } from "@/modules/providers/providers-registry";
import { AppHeader } from "@/modules/ui/app-header";
import { AppSection } from "@/modules/ui/app-section";
import { Breadcrumbs } from "@saleor/apps-ui";
import { Breadcrumbs, Layout } from "@saleor/apps-ui";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { NextPage } from "next";
import { useRouter } from "next/router";
@ -16,14 +15,15 @@ const AddProviderPage: NextPage = () => {
text="Connect CMS platforms to the App."
breadcrumbs={[<Breadcrumbs.Item key="provider">Add Provider</Breadcrumbs.Item>]}
/>
<AppSection
<Layout.AppSection
heading="Select CMS provider"
sideContent={
<Box>
<Text>App allows to connect one or more CMS platforms. You can add more later.</Text>
</Box>
}
mainContent={
>
<Layout.AppSectionCard>
<Box
display="grid"
__gridTemplateColumns="auto auto auto"
@ -54,8 +54,8 @@ const AddProviderPage: NextPage = () => {
</React.Fragment>
))}
</Box>
}
/>
</Layout.AppSectionCard>
</Layout.AppSection>
</Box>
);
};

View file

@ -1,12 +1,8 @@
import { ContentfulConfigForm } from "@/modules/providers/contentful/contentful-config-form";
import { DatoCMSConfigForm } from "@/modules/providers/datocms/datocms-config-form";
import { CMSType } from "@/modules/providers/providers-registry";
import { ProvidersResolver } from "@/modules/providers/providers-resolver";
import { StrapiConfigForm } from "@/modules/providers/strapi/strapi-config-form";
import { AppHeader } from "@/modules/ui/app-header";
import { AppSection } from "@/modules/ui/app-section";
import { Breadcrumbs } from "@saleor/apps-ui";
import { Breadcrumbs, Layout } from "@saleor/apps-ui";
import { Box, Text } from "@saleor/macaw-ui/next";
import { NextPage } from "next";
import { useRouter } from "next/router";
@ -16,7 +12,7 @@ const AddProviderPage: NextPage = () => {
const { query } = useRouter();
const provider = useMemo(() => {
return query.type ? ProvidersResolver.createProviderMeta(query.type as string) : null;
return query.type ? ProvidersResolver.createProviderMeta(query.type as CMSType) : null;
}, [query.type]);
if (!provider) return null;
@ -35,7 +31,7 @@ const AddProviderPage: NextPage = () => {
]}
/>
<AppSection
<Layout.AppSection
heading={`Set up ${provider.displayName}`}
sideContent={
<Box>
@ -43,8 +39,11 @@ const AddProviderPage: NextPage = () => {
{provider.formSideInfo && <Box marginTop={6}>{provider.formSideInfo}</Box>}
</Box>
}
mainContent={<FormComponent />}
/>
>
<Layout.AppSectionCard>
<FormComponent />
</Layout.AppSectionCard>
</Layout.AppSection>
</Box>
);
};

View file

@ -31,7 +31,7 @@ export default createManifestHandler({
],
homepageUrl: "https://github.com/saleor/apps",
id: "saleor.app.cms2",
name: "CMS 2",
name: "CMS",
permissions: ["MANAGE_PRODUCTS"],
requiredSaleorVersion: ">=3.10 <4",
supportUrl: "https://github.com/saleor/apps/discussions",

View file

@ -1,8 +1,18 @@
import * as trpcNext from "@trpc/server/adapters/next";
import { createTrpcContext } from "../../../modules/trpc/trpc-context";
import { appRouter } from "../../../modules/trpc/trpc-app-router";
import { createLogger } from "@saleor/apps-shared";
const logger = createLogger({ name: "tRPC error" });
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: createTrpcContext,
onError: ({ path, error }) => {
if (error.code === "INTERNAL_SERVER_ERROR") {
logger.error(error, `${path} returned error:`);
return;
}
logger.debug(error, `${path} returned error:`);
},
});

View file

@ -1,10 +1,9 @@
import { BulkSyncView } from "@/modules/bulk-sync/bulk-sync-view";
import { trpcClient } from "@/modules/trpc/trpc-client";
import { SkeletonLayout } from "@saleor/apps-ui";
import { NextPage } from "next";
import { useRouter } from "next/router";
import { z } from "zod";
import { Text } from "@saleor/macaw-ui/next";
import { Skeleton } from "@/modules/ui/skeleton";
const BulkSyncPage: NextPage = () => {
const { query } = useRouter();
@ -23,7 +22,7 @@ const BulkSyncPage: NextPage = () => {
},
{
enabled: !!parsedID,
}
},
);
const {
@ -36,7 +35,7 @@ const BulkSyncPage: NextPage = () => {
},
{
enabled: !!connection,
}
},
);
if ((providerFetched && !provider) || (connectionFetched && !connection)) {
@ -45,7 +44,7 @@ const BulkSyncPage: NextPage = () => {
}
if (connectionLoading || providerLoading) {
return <Skeleton.Section />;
return <SkeletonLayout.Section />;
}
if (!(provider && connection)) {

View file

@ -2,7 +2,7 @@ import { BulkSyncSection } from "@/modules/bulk-sync/bulk-sync-section";
import { ChannelProviderConnectionList } from "@/modules/channel-provider-connection/channels-provider-connection-list";
import { ProvidersList } from "@/modules/providers-listing/providers-list";
import { AppHeader } from "@/modules/ui/app-header";
import { AppSection } from "@/modules/ui/app-section";
import { Layout } from "@saleor/apps-ui";
import { Box, Text } from "@saleor/macaw-ui/next";
import { NextPage } from "next";
@ -10,7 +10,7 @@ const ConfigurationPage: NextPage = () => {
return (
<Box>
<AppHeader />
<AppSection
<Layout.AppSection
marginBottom={14}
heading="Providers configuration"
sideContent={
@ -18,9 +18,10 @@ const ConfigurationPage: NextPage = () => {
<Text>Configure one or more CMS providers to synchronize Saleor products.</Text>
</Box>
}
mainContent={<ProvidersList />}
/>
<AppSection
>
<ProvidersList />
</Layout.AppSection>
<Layout.AppSection
marginBottom={14}
heading="Automatic synchronization"
sideContent={
@ -31,9 +32,10 @@ const ConfigurationPage: NextPage = () => {
</Text>
</Box>
}
mainContent={<ChannelProviderConnectionList />}
/>
<AppSection
>
<ChannelProviderConnectionList />
</Layout.AppSection>
<Layout.AppSection
heading="Initial sync"
sideContent={
<Box>
@ -44,8 +46,9 @@ const ConfigurationPage: NextPage = () => {
<Text as="p">Its recommended to run this flow initially, once app is configured.</Text>
</Box>
}
mainContent={<BulkSyncSection />}
/>
>
<BulkSyncSection />
</Layout.AppSection>
</Box>
);
};

View file

@ -2,9 +2,7 @@ import { ProvidersResolver } from "@/modules/providers/providers-resolver";
import { trpcClient } from "@/modules/trpc/trpc-client";
import { AppHeader } from "@/modules/ui/app-header";
import { AppSection } from "@/modules/ui/app-section";
import { Skeleton } from "@/modules/ui/skeleton";
import { Breadcrumbs } from "@saleor/apps-ui";
import { Breadcrumbs, Layout, SkeletonLayout } from "@saleor/apps-ui";
import { Box, Text } from "@saleor/macaw-ui/next";
import { NextPage } from "next";
import { useRouter } from "next/router";
@ -20,7 +18,7 @@ const EditProviderPage: NextPage = () => {
},
{
enabled: !!configId,
}
},
);
const provider = useMemo(() => {
@ -28,7 +26,7 @@ const EditProviderPage: NextPage = () => {
}, [data]);
if (isLoading) {
return <Skeleton.Section />;
return <SkeletonLayout.Section />;
}
if (isFetched && !data) {
@ -38,7 +36,7 @@ const EditProviderPage: NextPage = () => {
}
if (!provider) {
return <Skeleton.Section />;
return <SkeletonLayout.Section />;
}
const EditForm = ProvidersResolver.getEditProviderFormComponent(provider.type);
@ -53,13 +51,16 @@ const EditProviderPage: NextPage = () => {
<Breadcrumbs.Item key="configname">{data?.configName}</Breadcrumbs.Item>,
]}
/>
<AppSection
<Layout.AppSection
heading="Edit CMS configuration"
mainContent={<EditForm configId={configId} />}
sideContent={
<Box>{provider.formSideInfo && <Box marginTop={6}>{provider.formSideInfo}</Box>}</Box>
}
/>
>
<Layout.AppSectionCard>
<EditForm configId={configId} />
</Layout.AppSectionCard>
</Layout.AppSection>
</Box>
);
};

View file

@ -1 +1,3 @@
export {};
import { vi } from "vitest";
vi.stubEnv("SECRET_KEY", "TEST");

3
apps/cms/.gitignore vendored
View file

@ -1,3 +0,0 @@
# Sentry Auth Token
.sentryclirc

View file

@ -1,173 +0,0 @@
# saleor-app-cms
## 1.8.0
### Minor Changes
- f669581: Added warning bar that informs that app is no longer maintained
### Patch Changes
- 70cb741: Update Zod to 3.21.4
- e7c2d3a: Updated and ESLint dependencies
- 3c6cd4c: Updated the @saleor/app-sdk package to version 0.41.1.
- Updated dependencies [70cb741]
- Updated dependencies [e7c2d3a]
- Updated dependencies [3c6cd4c]
- @saleor/apps-shared@1.7.4
## 1.7.0
### Minor Changes
- a1f083c: Filled "about" field in App Manifest. Dashboard will display it in app details page now.
- 47102ba: Added additional ENV variables (see each app's .env.example), that can overwrite app base URL. This change allows easy apps development using Docker
### Patch Changes
- 2d77bca: Updated Next.js to 13.4.8
- 6299e06: Update @saleor/app-sdk to 0.41.0
- Updated dependencies [2d77bca]
- Updated dependencies [6299e06]
- @saleor/apps-shared@1.7.3
## 1.6.0
### Minor Changes
- 1dead1e: Included dedicated logo and attached it to App's manifest. From Saleor 3.15 the logo will be visible in the Dashboard during and after installation.
### Patch Changes
- 860bac4: Updated @saleor/app-sdk to 0.40.1
- a1ad70e: Updated configuration and dependencies of GraphQL client - urql.
All applications use now unified config for creating the client. Also unused related packages has been removed.
- ec68ed5: Updated Sentry package and config. Set Sentry release to match package.json version. Now Sentry will use semver version instead a commit
- cb6ee29: Updated dependencies
- Updated dependencies [860bac4]
- Updated dependencies [a1ad70e]
- Updated dependencies [cb6ee29]
- Updated dependencies [a1ad70e]
- @saleor/apps-shared@1.7.2
## 1.5.4
### Patch Changes
- a8834a1: Unified graphql version to 16.6
- a8834a1: Unified graphql codegen packages
- a8834a1: Removed unnecessary duplicated dependencies from apps and moved them to shared and root (types, eslint rules)
- a8834a1: Updated dev dependencies - Typescript, Eslint and Turborepo
- Updated dependencies [a8834a1]
- Updated dependencies [a8834a1]
- Updated dependencies [a8834a1]
- Updated dependencies [a8834a1]
- @saleor/apps-shared@1.7.1
## 1.5.3
### Patch Changes
- 0c2fc65: Update dev dependencies - Vite and Vitest. These changes will not affect runtime Apps, but can affect tests and builds
- Updated dependencies [0c2fc65]
- Updated dependencies [b75a664]
- @saleor/apps-shared@1.7.0
## 1.5.2
### Patch Changes
- 6e69f4f: Update app-sdk to 0.39.1
- Updated dependencies [6e69f4f]
- @saleor/apps-shared@1.6.1
## 1.5.1
### Patch Changes
- Updated dependencies [23b5c70]
- @saleor/apps-shared@1.6.0
## 1.5.0
### Minor Changes
- 7c9a9a2: Added additional debug logs for "ping" endpoint. In case of failed connection, logs can be checked for details. Endpoint also will return error to the frontend
### Patch Changes
- c406318: Updated dep @saleor/app-sdk to 0.38.0
- Updated dependencies [c406318]
- @saleor/apps-shared@1.5.1
## 1.4.1
### Patch Changes
- 8b22b1c: Restored Pino logger packages to each app, to fix failing logs in development. Also updated .env.example to contain up to date APP_LOG_LEVEL variable
## 1.4.0
### Minor Changes
- 830cfe9: Changed APP_DEBUG env to APP_LOG_LEVEL
### Patch Changes
- 830cfe9: Replaced internal logger implementation with shared logger
- Updated dependencies [830cfe9]
- @saleor/apps-shared@1.5.0
## 1.3.1
### Patch Changes
- fc06648: CMS visual fixes
## 1.3.0
### Minor Changes
- 57f6d41: Updated Manifest to contain up to date support, privacy, homepage and author fields
### Patch Changes
- 2c0df91: Added lint:fix script, so `eslint --fix` can be run deliberately
- e167e72: Update next.js to 13.3.0
- 74174c4: Updated @saleor/app-sdk to 0.37.3
- 2e51890: Update next.js to 13.3.0
- a3636f7: Fix CMS app issues
Check if CMS provider instance configuration is working
- 2e51890: Update @saleor/app-sdk to 0.37.2
- 2e51890: Use useDashboardNotification hook from shared package, instead of direct AppBridge usage
- Updated dependencies [2c0df91]
- Updated dependencies [e167e72]
- Updated dependencies [74174c4]
- Updated dependencies [2e51890]
- Updated dependencies [2e51890]
- Updated dependencies [2e51890]
- @saleor/apps-shared@1.4.0
## 1.2.0
### Minor Changes
- 1da5163: Redesigned the app to better match new Dashboard
### Patch Changes
- 246b943: Provider configurations UI fixes
- eca52ad: Replace "export default" with named exports
- @saleor/apps-shared@1.3.0
## 1.1.0
### Minor Changes
- b80df17: Added Sentry integration
- 7cb3b89: Added "author" field to the Manifest, set it to Saleor Commerce, so Dashboard can display it too
### Patch Changes
- 7cb3b89: Replace apps to avoid AppPermission (use Permission for client permissions) and authData.domain (use saleorApiUrl)
- 7cb3b89: Updated @saleor/app-sdk to 0.37.1

View file

@ -1,142 +0,0 @@
# Contributing
## Overview
CMS Hub connects Saleor to a variety of CMSes. Each integration requires **a provider** that implements an interface for supported operations.
Currently, CMS Hub allows to perform operations on **products** (specifically, exporting them from Saleor to CMS). That means you need to implement creating, updating, and deleting a product through the API of the CMS you are integrating with.
CMS Hub will:
- execute the actions on the right webhook
- extract the product data and pass it to an provider
- provide some integration logic (e.g. add the product id from the CMS to the product metadata)
- create a UI and manage your integration's tokens based on supplied config
## Adding a provider
If you want to add a provider for a new CMS, here is what you have to do:
1. Go to `/src/lib/cms/config/providers.ts`.
2. Update the `providersConfig` variable with basic information about your provider: `name`, `label` and `tokens`:
```ts
// src/lib/cms/config/providers.ts
export const providersConfig = {
contentful: {
...
},
payload: {
name: "payload",
label: "Payload",
tokens: [
{ name: "baseUrl", label: "Base Url" },
{ name: "token", label: "Token", required: true },
],
},
} satisfies ProvidersConfig;
```
> `tokens` is an array that contains names of all the tokens your provider requires. The names will be used to:
>
> - generate config type (see: `CreateProviderConfig` in step 4)
> - generate an integration configuration view (see: `src/views/configuration.tsx`)
> - store & fetch the tokens from the settings API (see: `src/pages/api/settings.ts`)
3. Add configuration fields to schema:
```ts
// src/lib/cms/config/providers.ts
...
export type PayloadConfig = CreateProviderConfig<"payload">; // Generates the type for a config based on the configuration in `src/lib/cms/providers/index.ts`.
...
export const payloadConfigSchema: z.ZodType<PayloadConfig> = z.object({
enabled: z.boolean(),
...
}); // Creates a schema for validating the config using [zod](https://github.com/colinhacks/zod).
export const providersSchemaSet = {
...
payload: payloadConfigSchema,
};
```
3. Create a file following the naming convention `[cmsName].ts`, e.g.: `src/lib/cms/providers/payload.ts`. This file will contain all the provider logic. You can implement it as you like, as long as it follows the expected format.
4. Start with importing all the helper functions and types:
```ts
// src/lib/cms/providers/payload.ts
import { createProvider } from "./create";
import {
CreateOperations,
CreateProviderConfig,
} from "../types";
import { PayloadConfig, payloadConfigSchema } from "../config";
const payloadOperations: CreateOperations<PayloadConfig> = (config) => {
...
} // This is where you write logic for all the supported operations (e.g. creating a product). This function runs only if the config was successfully validated.
export const payloadProvider = createProvider(payloadOperations, payloadConfigSchema); // `createProvider` combines everything together.
```
5. Implement the operations:
```ts
// src/lib/cms/providers/payload.ts
...
const payloadOperations: CreateOperations<PayloadConfig> = (config) => {
return {
createProduct: async (payload) => ...
}
}
```
Each operation accepts a payload (sent from the webhook) and should return a promise. CMS Hub does not verify the value returned from the CMS.
> **Important!**
>
> The return type of the `createProduct` method is different than the rest. It must return **a promise** of:
>
> ```ts
> { ok: true; data: { id: string } } // the success state
> | { ok: false; error: string } // the failure state
> ```
>
> We need it to synchronise the Saleor product with the CMS product. The product id returned from the CMS is used to update the product metadata in Saleor.
6. Import your provider at the top of the `src/lib/cms/providers/index.ts` file:
```ts
import contentful from "./contentful";
...
import payload from "./payload";
```
7. Add it to the `cmsProviders` variable.
8. Go to `src/lib/cms/client.ts`. Add a `case` for your provider inside the `switch` statement in `createCmsClient` function:
```ts
switch (provider) {
case "strapi": {
return cmsProviders.strapi.create(config.strapi);
}
...
case "payload": {
return cmsProviders.payload.create(config.payload);
}
...
}
```
---
And that's it, you are golden! 🎖️

View file

@ -1,54 +0,0 @@
# Deprecated
This is v1 version of the CMS app. It's no longer maintained and will be removed.
Please check [v2](../cms-v2/)
![CMS](https://user-images.githubusercontent.com/249912/71523206-4e45f800-28c8-11ea-84ba-345a9bfc998a.png)
<div align="center">
<h1>CMS v1</h1>
</div>
# Overview
## What is it?
CMS Hub is a Saleor app that exports products from Saleor to several popular CMSes.
Here is a list of currently supported CMSes and their configuration guides:
- [Strapi](docs/strapi.md)
- [Contentful](docs/contentful.md)
- [DatoCMS](docs/datocms.md)
## How does it work?
1. `cms` listens to Saleor product variant events through [webhooks](https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks).
2. When an event is triggered, we extract the product data and pass it to the CMS Client.
3. CMS Client checks what CMS you picked, transforms the data to the format the CMS expects, and sends it over.
### Mapping fields
Currently, the CMS Hub does not support mapping Saleor fields to your CMS fields. Your product model should have the following fields:
- strings fields: `saleor_id`, `name`, `product_id`, `product_name`, `product_slug`,
- JSON fileds: `channels`.
### Known issues
CMS Hub updates product variants in CMS providers on create, update or delete product variant webook events. They are triggered on product variant channel listing added or updated, but they currently don't trigger on deleting channel listing due to the [core issue #12247](https://github.com/saleor/saleor/issues/12247). To make it working on deleting channel listing, you can additionally modify other product variant field e.g. `name`, so webhook handlers receive the updated channel listings as well.
## How to use it?
1. Install the application in your Dashboard and open it.
2. Go to Providers.
3. Add CMS provider instance with proper configuration.
4. Go to Channels.
5. Turn on provider instances for desired channels.
6. Go to Products in Dashboard.
7. Add, edit or remove product variant to see it is added, updated or removed in configured CMS provider accordingly.
## How can I contribute?
See [CONTRIBUTING.md](./CONTRIBUTING.md).

View file

@ -1,15 +0,0 @@
# Contentful
## Configuration
Contentful integration requires several configuration tokens. You should enter them in the _Apps -> CMS_ configuration view.
Here is the list of the tokens and instructions on how to obtain them:
- `baseUrl`: the API URL. For Contentful, it's `https://api.contentful.com`.
- `token`: the authorization token. For instructions on how to create one for CMS Hub, please go to _API keys in the Contentful web app_ section in the [Contentful "Authentication" documentation](https://www.contentful.com/developers/docs/references/authentication/).
- `environment`: id of the Contentful environment you use. To find it, go to _Settings -> Environments_ in the Contentful dashboard.
- `spaceId`: id of the Contentful space. To find it, go to _Settings -> General settings_ in the Contentful dashboard.
- `contentId`: the id of the content model. To obtain it, go to _Content model_ and to the view of a single product in your Contentful dashboard. Your URL may look something like: "https://app.contentful.com/spaces/xxxx/content_types/product/fields". Then, look to the right side of the screen. You will find a copyable "CONTENT TYPE ID" box there.
- `locale`: the localization code for your content. E.g.: `en-US`.
- `apiRequestsPerSecond`: API rate limits (API requests per second). The default is 7. Used in bulk products variants sync. Higher rate limits may speed up a little products variants bulk sync. Higher rate limit may apply depending on different Contentful plan, learn more at https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/api-rate-limits.

View file

@ -1,12 +0,0 @@
# DatoCMS
## Configuration
DatoCMS integration requires several configuration tokens. You should enter them in the _Apps -> CMS_ configuration view.
Here is the list of the tokens and instructions on how to obtain them
- `baseUrl`: the optional URL to your DatoCMS project. If you leave this blank, this URL will be inferred from your API Token.
- `token`: the API token with access to Content Management API. You can find this in your DatoCMS project settings. More instructions of how to create it available at [DatoCMS "Authentication" documentation](https://www.datocms.com/docs/content-management-api/authentication).
- `itemTypeId`: item type ID (number). You can find this as Model ID in your DatoCMS product variant model settings, by clicking "Edit model".
- `environment`: optional environment name. If you leave this blank, default environment will be used. You can find this in your DatoCMS project settings.

View file

@ -1,11 +0,0 @@
# Strapi
## Configuration
Strapi integration requires several configuration tokens. You should enter them in the _Apps -> CMS_ configuration view.
Here is the list of the tokens and instructions on how to obtain them
- `baseUrl`: the API URL. It's the address of your Strapi API. For local Strapi development it will be: `http://localhost:XXXX`.
- `token`: the authorization token. For instructions on how to create one for CMS Hub, please go to the [Strapi "Managing API tokens" documentation](https://docs.strapi.io/user-docs/latest/settings/managing-global-settings.html#managing-api-tokens).
- `contentTypeId`: the content type id. You can find this in your Strapi project, go to Content-Type Builder > select content type > click Edit > use API ID (Plural). For more unstruction of how to get content type id, please go to [Strapi "Editing content types" documentation](https://docs.strapi.io/user-docs/content-type-builder/managing-content-types#editing-content-types).

View file

@ -1,36 +0,0 @@
fragment WebhookProduct on Product {
id
name
slug
media {
url
}
channelListings {
id
channel {
id
slug
}
isPublished
}
variants {
id
name
sku
channelListings {
id
channel {
id
slug
}
price {
amount
currency
}
}
metadata {
key
value
}
}
}

View file

@ -1,36 +0,0 @@
fragment WebhookProductVariant on ProductVariant {
id
name
sku
product {
id
name
slug
media {
url
}
channelListings {
id
channel {
id
slug
}
isPublished
}
}
channelListings {
id
channel {
id
slug
}
price {
amount
currency
}
}
metadata {
key
value
}
}

View file

@ -1,9 +0,0 @@
mutation DeleteMetadata($id: ID!, $keys: [String!]!) {
deleteMetadata(id: $id, keys: $keys) {
errors {
field
message
code
}
}
}

View file

@ -1,9 +0,0 @@
mutation UpdateMetadata($id: ID!, $input: [MetadataInput!]!) {
updateMetadata(id: $id, input: $input) {
errors {
field
message
code
}
}
}

View file

@ -1,9 +0,0 @@
query FetchAppDetails {
app {
id
privateMetadata {
key
value
}
}
}

View file

@ -1,9 +0,0 @@
query FetchProductVariantMetadata($id: ID!) {
productVariant(id: $id) {
id
metadata {
key
value
}
}
}

View file

@ -1,8 +0,0 @@
query GetProduct($id: ID!) {
product(id: $id) {
slug
media {
url
}
}
}

View file

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

View file

@ -1,63 +0,0 @@
{
"name": "saleor-app-cms",
"version": "1.8.0",
"scripts": {
"build": "pnpm generate && next build",
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"generate": "graphql-codegen",
"lint": "next lint",
"lint:fix": "eslint --fix .",
"start": "next start",
"test": "vitest"
},
"dependencies": {
"@datocms/cma-client-node": "^1.2.4",
"@hookform/resolvers": "^2.9.10",
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
"@material-ui/lab": "4.0.0-alpha.61",
"@saleor/app-sdk": "0.41.1",
"@saleor/apps-shared": "workspace:*",
"@saleor/macaw-ui": "^0.7.2",
"@sentry/nextjs": "7.55.2",
"@urql/exchange-auth": "^2.1.4",
"clsx": "^1.2.1",
"graphql": "16.6.0",
"graphql-tag": "^2.12.6",
"next": "13.4.8",
"pino": "^8.14.1",
"pino-pretty": "^10.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.39.1",
"react-markdown": "^8.0.5",
"urql": "^4.0.4",
"usehooks-ts": "^2.9.1",
"uuid": "^9.0.0",
"vite": "4.3.9",
"zod": "3.21.4"
},
"devDependencies": {
"@graphql-codegen/cli": "3.2.2",
"@graphql-codegen/introspection": "3.0.1",
"@graphql-codegen/typed-document-node": "3.0.2",
"@graphql-codegen/typescript": "3.0.2",
"@graphql-codegen/typescript-operations": "3.0.2",
"@graphql-codegen/typescript-urql": "3.7.3",
"@graphql-typed-document-node/core": "3.2.0",
"@testing-library/react": "^13.4.0",
"@types/react": "18.2.5",
"@types/react-dom": "18.2.5",
"@types/uuid": "^8.3.4",
"@vitejs/plugin-react": "4.0.0",
"eslint": "8.44.0",
"jsdom": "^20.0.3",
"typescript": "5.1.6",
"vitest": "0.31.3"
},
"private": true,
"saleor": {
"schemaVersion": "3.7"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

View file

@ -1,8 +0,0 @@
<svg width="25" height="26" viewBox="0 0 25 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.3333 0.5C22.53 0.5 23.5 1.47005 23.5 2.66667V8C23.5 8.46024 23.1269 8.83333 22.6667 8.83333C22.2064 8.83333 21.8333 8.46024 21.8333 8V2.66667C21.8333 2.39052 21.6095 2.16667 21.3333 2.16667H2.66667C2.39053 2.16667 2.16667 2.39052 2.16667 2.66667V17.3333C2.16667 17.6095 2.39053 17.8333 2.66667 17.8333H7.33333C7.79357 17.8333 8.16667 18.2064 8.16667 18.6667C8.16667 19.1269 7.79357 19.5 7.33333 19.5H2.66667C1.47005 19.5 0.5 18.5299 0.5 17.3333V2.66667C0.5 1.47005 1.47005 0.5 2.66667 0.5H21.3333Z" fill="white"/>
<path d="M5.33333 6.66667C6.06971 6.66667 6.66667 6.06971 6.66667 5.33333C6.66667 4.59695 6.06971 4 5.33333 4C4.59695 4 4 4.59695 4 5.33333C4 6.06971 4.59695 6.66667 5.33333 6.66667Z" fill="white"/>
<path d="M10.6667 5.33333C10.6667 6.06971 10.0697 6.66667 9.33333 6.66667C8.59695 6.66667 8 6.06971 8 5.33333C8 4.59695 8.59695 4 9.33333 4C10.0697 4 10.6667 4.59695 10.6667 5.33333Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 10.5378C18.8034 10.5378 17.8333 11.5078 17.8333 12.7044V15.3711C17.8333 16.5677 18.8034 17.5378 20 17.5378H22.6667C23.8633 17.5378 24.8333 16.5677 24.8333 15.3711V12.7044C24.8333 11.5078 23.8633 10.5378 22.6667 10.5378H20ZM19.5 12.7044C19.5 12.4283 19.7239 12.2044 20 12.2044H22.6667C22.9428 12.2044 23.1667 12.4283 23.1667 12.7044V15.3711C23.1667 15.6472 22.9428 15.8711 22.6667 15.8711H20C19.7239 15.8711 19.5 15.6472 19.5 15.3711V12.7044Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.8333 20.7044C17.8333 19.5078 18.8034 18.5378 20 18.5378H22.6667C23.8633 18.5378 24.8333 19.5078 24.8333 20.7044V23.3711C24.8333 24.5677 23.8633 25.5378 22.6667 25.5378H20C18.8034 25.5378 17.8333 24.5677 17.8333 23.3711V20.7044ZM20 20.2044C19.7239 20.2044 19.5 20.4283 19.5 20.7044V23.3711C19.5 23.6472 19.7239 23.8711 20 23.8711H22.6667C22.9428 23.8711 23.1667 23.6472 23.1667 23.3711V20.7044C23.1667 20.4283 22.9428 20.2044 22.6667 20.2044H20Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 10.5378C10.8034 10.5378 9.83333 11.5078 9.83333 12.7044V23.3711C9.83333 24.5677 10.8034 25.5378 12 25.5378H14.6667C15.8633 25.5378 16.8333 24.5677 16.8333 23.3711V12.7044C16.8333 11.5078 15.8633 10.5378 14.6667 10.5378H12ZM11.5 12.7044C11.5 12.4283 11.7239 12.2044 12 12.2044H14.6667C14.9428 12.2044 15.1667 12.4283 15.1667 12.7044V23.3711C15.1667 23.6472 14.9428 23.8711 14.6667 23.8711H12C11.7239 23.8711 11.5 23.6472 11.5 23.3711V12.7044Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 40 KiB

View file

@ -1,9 +0,0 @@
<svg width="24" height="24" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 0H0V18H9C13.5013 18 18 13.9704 18 9.00123C18 4.0321 13.5021 0 9 0ZM9 13.0896C8.18591 13.0894 7.39018 12.8493 6.71342 12.3998C6.03666 11.9502 5.50928 11.3113 5.19796 10.5639C4.88664 9.81649 4.80537 8.99415 4.96442 8.20086C5.12348 7.40757 5.51571 6.67895 6.09153 6.10716C6.66735 5.53537 7.40088 5.14607 8.19938 4.98851C8.99787 4.83095 9.82545 4.9122 10.5775 5.22197C11.3295 5.53175 11.9722 6.05615 12.4242 6.72885C12.8763 7.40155 13.1174 8.19235 13.1172 9.00123C13.1172 9.53835 13.0108 10.0702 12.8038 10.5664C12.5969 11.0626 12.2936 11.5135 11.9113 11.8931C11.5289 12.2728 11.075 12.5739 10.5755 12.7792C10.0759 12.9845 9.54057 13.09 9 13.0896Z" fill="url(#paint0_linear_15412_85374)"/>
<defs>
<linearGradient id="paint0_linear_15412_85374" x1="0" y1="9.00009" x2="18" y2="9.00009" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF593D"/>
<stop offset="1" stop-color="#FF7751"/>
</linearGradient>
</defs>
</svg>

Before

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

View file

@ -1,3 +0,0 @@
export { default as ContentfulIcon } from "./contentful-icon.svg";
export { default as StrapiIcon } from "./strapi-icon.svg";
export { default as DatocmsIcon } from "./datocms-icon.svg";

View file

@ -1,11 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_15398_13939)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9398 16.3135V8.5562C15.9398 8.42355 15.8871 8.29633 15.7933 8.20254C15.6995 8.10875 15.5723 8.05606 15.4396 8.05606H7.69141V0.0488281H23.438C23.5037 0.0488152 23.5687 0.0617446 23.6294 0.0868781C23.6901 0.112012 23.7453 0.148857 23.7918 0.195309C23.8382 0.241762 23.8751 0.296912 23.9002 0.35761C23.9254 0.418308 23.9383 0.483364 23.9383 0.549065V16.3135H15.9398Z" fill="#8E75FF"/>
<path opacity="0.405" fill-rule="evenodd" clip-rule="evenodd" d="M7.69204 0.0488281V8.05606H0.296975C0.247518 8.05605 0.199175 8.04138 0.158054 8.0139C0.116932 7.98642 0.0848781 7.94737 0.0659409 7.90168C0.0470037 7.856 0.0420334 7.80572 0.051658 7.75721C0.0612825 7.7087 0.0850701 7.66413 0.120015 7.62913L7.69204 0.0488281ZM15.9405 23.7163V16.3135H23.9389L16.3675 23.8931C16.3325 23.928 16.288 23.9519 16.2395 23.9615C16.1909 23.9712 16.1407 23.9662 16.095 23.9473C16.0493 23.9284 16.0102 23.8963 15.9827 23.8552C15.9552 23.8141 15.9405 23.7658 15.9405 23.7163ZM7.69204 8.05606H15.6903C15.7566 8.05606 15.8203 8.08241 15.8672 8.1293C15.9141 8.17619 15.9405 8.2398 15.9405 8.30613V16.3135H8.19228C8.12659 16.3135 8.06154 16.3005 8.00085 16.2754C7.94016 16.2502 7.88501 16.2134 7.83856 16.1669C7.79211 16.1205 7.75526 16.0654 7.73012 16.0047C7.70498 15.944 7.69204 15.8789 7.69204 15.8132V8.05606Z" fill="#8E75FF"/>
</g>
<defs>
<clipPath id="clip0_15398_13939">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1,19 +0,0 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { AppLinkProps } from "../../modules/ui/app-link";
export const useAppRedirect = () => {
const { appBridge } = useAppBridge();
const redirectToPath = (props: AppLinkProps) => {
return appBridge?.dispatch({
type: "redirect",
payload: {
newContext: false,
actionId: "redirect_from_cms_app",
to: props.path ? props.path : props.href,
},
});
};
return { redirect: redirectToPath };
};

View file

@ -1,11 +0,0 @@
type ChannelListing = Record<string, any> & {
channel: {
slug: string;
};
};
export const getChannelsSlugsFromSaleorItem = (
item?: { channelListings?: ChannelListing[] | null } | null
) => {
return item?.channelListings?.map((cl) => cl.channel.slug) || [];
};

View file

@ -1,306 +0,0 @@
import {
ProductVariantUpdatedWebhookPayloadFragment,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import {
BaseResponse,
CmsClientBatchOperations,
CmsClientOperations,
ProductResponseSuccess,
} from "../types";
import { getCmsIdFromSaleorItem } from "./metadata";
import { createLogger } from "@saleor/apps-shared";
import { CMSProvider, cmsProviders } from "../providers";
import { ProviderInstanceSchema, providersSchemaSet } from "../config";
export const pingProviderInstance = async (
providerInstanceSettings: ProviderInstanceSchema
): Promise<BaseResponse> => {
const logger = createLogger({ providerInstanceSettings });
logger.debug("Ping provider instance called");
const provider = cmsProviders[
providerInstanceSettings.providerName as CMSProvider
] as (typeof cmsProviders)[keyof typeof cmsProviders];
const validation =
providersSchemaSet[providerInstanceSettings.providerName as CMSProvider].safeParse(
providerInstanceSettings
);
if (!validation.success) {
logger.error("The provider instance settings validation failed.", {
error: validation.error.message,
});
return { ok: false };
}
const config = validation.data;
const client = provider.create(config as any); // config without validation = providerInstanceSettings as any
const pingResult = await client.ping();
return pingResult;
};
interface CmsClientOperationResult {
createdCmsId?: string;
deletedCmsId?: string;
error?: string;
}
const executeCmsClientOperation = async ({
cmsClient,
productVariant,
}: {
cmsClient: CmsClientOperations;
productVariant: WebhookProductVariantFragment;
}): Promise<CmsClientOperationResult | undefined> => {
const logger = createLogger({ cmsClient });
logger.debug("Execute CMS client operation called");
const cmsId = getCmsIdFromSaleorItem(productVariant, cmsClient.cmsProviderInstanceId);
if (cmsId && cmsClient.operationType === "deleteProduct") {
logger.debug("CMS deleting item called", { cmsId });
try {
await cmsClient.operations.deleteProduct({
id: cmsId,
});
return {
deletedCmsId: cmsId,
};
} catch (error) {
logger.error("Error deleting item", { error });
return {
error: "Error deleting item.",
};
}
} else if (cmsId && cmsClient.operationType === "updateProduct") {
logger.debug("CMS updating item called", { cmsId });
try {
await cmsClient.operations.updateProduct({
/*
* todo: change params of product methods because of below:
* * In some CMSes, cmsId may be productId. Perhaps it's better to just pass everything as one big object
* * and decide on the id on the provider level.
*/
id: cmsId,
input: {
saleorId: productVariant.id,
sku: productVariant.sku,
name: productVariant.name,
image: productVariant.product.media?.[0]?.url ?? "",
productId: productVariant.product.id,
productName: productVariant.product.name,
productSlug: productVariant.product.slug,
channels: productVariant.channelListings?.map((cl) => cl.channel.slug) || [],
},
});
} catch (error) {
logger.error("Error updating item", { error });
return {
error: "Error updating item.",
};
}
} else if (!cmsId && cmsClient.operationType === "createProduct") {
logger.debug("CMS creating item called");
try {
const createProductResponse = await cmsClient.operations.createProduct({
input: {
saleorId: productVariant.id,
sku: productVariant.sku,
name: productVariant.name,
image: productVariant.product.media?.[0]?.url ?? "",
productId: productVariant.product.id,
productName: productVariant.product.name,
productSlug: productVariant.product.slug,
channels: productVariant.channelListings?.map((cl) => cl.channel.slug) || [],
},
});
if (createProductResponse?.ok) {
return {
createdCmsId: createProductResponse.data.id,
};
} else {
return {
error: createProductResponse?.error,
};
}
} catch (error) {
logger.error("Error creating item", { error });
return {
error: "Error creating item.",
};
}
}
};
interface CmsClientBatchOperationResult {
createdCmsIds?: ProductResponseSuccess["data"][];
deletedCmsIds?: ProductResponseSuccess["data"][];
error?: string;
}
export const executeCmsClientBatchOperation = async ({
cmsClient,
productsVariants,
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance,
}: {
cmsClient: CmsClientBatchOperations;
productsVariants: WebhookProductVariantFragment[];
/**
* Lookup function with purposely long name like in Java Spring ORM to verify condition against unintended deletion of product variant from CMS.
* On purpose passed as an argument, for inversion of control.
*/
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance: (
productVariant: WebhookProductVariantFragment
) => boolean;
}): Promise<CmsClientBatchOperationResult | undefined> => {
const logger = createLogger({ cmsClient });
logger.debug({ operations: cmsClient.operations }, "Execute CMS client operation called");
if (cmsClient.operationType === "createBatchProducts") {
const productsVariansToCreate = productsVariants.reduce<WebhookProductVariantFragment[]>(
(productsVariansToCreate, productVariant) => {
const cmsId = getCmsIdFromSaleorItem(productVariant, cmsClient.cmsProviderInstanceId);
if (!cmsId) {
return [...productsVariansToCreate, productVariant];
}
return productsVariansToCreate;
},
[] as WebhookProductVariantFragment[]
);
if (productsVariansToCreate.length) {
logger.debug("CMS creating batch items called");
try {
const createBatchProductsResponse = await cmsClient.operations.createBatchProducts({
input: productsVariansToCreate.map((productVariant) => ({
saleorId: productVariant.id,
sku: productVariant.sku,
name: productVariant.name,
image: productVariant.product.media?.[0]?.url ?? "",
productId: productVariant.product.id,
productName: productVariant.product.name,
productSlug: productVariant.product.slug,
channels: productVariant.channelListings?.map((cl) => cl.channel.slug) || [],
})),
});
return {
createdCmsIds:
createBatchProductsResponse
?.filter((item) => item.ok && "data" in item)
.map((item) => (item as ProductResponseSuccess).data) || [],
};
} catch (error) {
logger.error({ error }, "Error creating batch items");
return {
error: "Error creating batch items.",
};
}
}
}
if (cmsClient.operationType === "deleteBatchProducts") {
const CMSIdsToRemove = productsVariants.reduce((CMSIdsToRemove, productVariant) => {
const cmsId = getCmsIdFromSaleorItem(productVariant, cmsClient.cmsProviderInstanceId);
const productVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance =
verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance(
productVariant
);
if (cmsId && !productVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance) {
return [
...CMSIdsToRemove,
{
id: cmsId,
saleorId: productVariant.id,
},
];
}
return CMSIdsToRemove;
}, [] as ProductResponseSuccess["data"][]);
if (CMSIdsToRemove.length) {
logger.debug("CMS removing batch items called");
try {
await cmsClient.operations.deleteBatchProducts({
ids: CMSIdsToRemove.map((item) => item.id),
});
return {
deletedCmsIds: CMSIdsToRemove,
};
} catch (error) {
logger.error({ error }, "Error removing batch items");
return {
error: "Error removing batch items.",
};
}
}
}
};
export const executeCmsOperations = async ({
cmsOperations,
productVariant,
}: {
cmsOperations: CmsClientOperations[];
productVariant: Exclude<
ProductVariantUpdatedWebhookPayloadFragment["productVariant"],
undefined | null
>;
}) => {
const cmsProviderInstanceProductVariantIdsToCreate: Record<string, string> = {};
const cmsProviderInstanceProductVariantIdsToDelete: Record<string, string> = {};
const cmsErrors: string[] = [];
await Promise.all(
cmsOperations.map(async (cmsClient) => {
const { createdCmsId, deletedCmsId, error } =
(await executeCmsClientOperation({
cmsClient,
productVariant,
})) || {};
if (createdCmsId) {
cmsProviderInstanceProductVariantIdsToCreate[cmsClient.cmsProviderInstanceId] =
createdCmsId;
}
if (deletedCmsId) {
cmsProviderInstanceProductVariantIdsToDelete[cmsClient.cmsProviderInstanceId] =
deletedCmsId;
}
if (error) {
cmsErrors.push(error);
}
})
);
return {
cmsProviderInstanceProductVariantIdsToCreate,
cmsProviderInstanceProductVariantIdsToDelete,
cmsErrors,
};
};

View file

@ -1,271 +0,0 @@
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { type Client } from "urql";
import { describe, expect, it, vi } from "vitest";
import { CMSSchemaChannels, CMSSchemaProviderInstances } from "../config";
import { CmsClientOperations } from "../types";
import { createCmsOperations } from "./clients-operations";
import { createCmsKeyForSaleorItem } from "./metadata";
import * as Settings from "./settings";
type WebhookContext = Parameters<NextWebhookApiHandler>["2"];
const mockedContext: Pick<WebhookContext, "authData"> = {
authData: {
saleorApiUrl: "https://domain.saleor.io/graphql/",
token: "token",
appId: "appId",
domain: "domain.saleor.io",
},
};
const createMockedClient = () => ({} as Client);
vi.mock("../../metadata", () => ({
createSettingsManager: () => ({}),
}));
describe("CMS Clients Operations", () => {
it("should return no creation operations when no variant channels passed and no variant cms passed", async () => {
vi.spyOn(Settings, "getChannelsSettings").mockImplementationOnce(async () => ({}));
vi.spyOn(Settings, "getProviderInstancesSettings").mockImplementationOnce(async () => ({}));
vi.spyOn(Settings, "getProductVariantProviderInstancesToAlter").mockImplementationOnce(
async () =>
({
toCreate: [],
toUpdate: [],
toRemove: [],
} as Settings.ProductVariantProviderInstancesToAlter)
);
const cmsOperations = await createCmsOperations({
context: mockedContext,
client: createMockedClient(),
productVariantChannels: [],
productVariantCmsKeys: [],
});
expect(cmsOperations).toEqual<CmsClientOperations[]>([]);
});
it("should return create operation when variant with channel listing that does not exist in provider instance passed", async () => {
vi.spyOn(Settings, "getChannelsSettings").mockImplementationOnce(
async () =>
({
"default-channel": {
channelSlug: "default-channel",
enabledProviderInstances: ["first-provider"],
},
"other-channel": {
channelSlug: "other-channel",
enabledProviderInstances: ["first-provider", "second-provider"],
},
} as CMSSchemaChannels)
);
vi.spyOn(Settings, "getProviderInstancesSettings").mockImplementationOnce(
async () =>
({
"first-provider": {
name: "First provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "first-provider",
providerName: "strapi",
},
"second-provider": {
name: "Second provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "second-provider",
providerName: "strapi",
},
"third-provider": {
name: "Third provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "third-provider",
providerName: "strapi",
},
} as CMSSchemaProviderInstances)
);
// Following mock assumes function calculations went correct and returns correct values
vi.spyOn(Settings, "getProductVariantProviderInstancesToAlter").mockImplementationOnce(
async () =>
({
toCreate: ["first-provider"],
toUpdate: [],
toRemove: [],
} as Settings.ProductVariantProviderInstancesToAlter)
);
const cmsOperations = await createCmsOperations({
context: mockedContext,
client: createMockedClient(),
productVariantChannels: ["default-channel"],
productVariantCmsKeys: [],
});
const operationsItem = cmsOperations[0];
/**
* Replace deep equal with single ones due to some strange errors in vite/jest.
* Functions were not matched properly in deep object
*/
expect(operationsItem.cmsProviderInstanceId).toBe("first-provider");
expect(operationsItem.operationType).toBe("createProduct");
expect(operationsItem.operations.createProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.deleteProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.updateProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.ping).toEqual(expect.any(Function));
});
it("should return update operation when variant with channel listing that exists in provider instance passed", async () => {
vi.spyOn(Settings, "getChannelsSettings").mockImplementationOnce(
async () =>
({
"default-channel": {
channelSlug: "default-channel",
enabledProviderInstances: ["first-provider"],
},
"other-channel": {
channelSlug: "other-channel",
enabledProviderInstances: ["first-provider", "second-provider"],
},
} as CMSSchemaChannels)
);
vi.spyOn(Settings, "getProviderInstancesSettings").mockImplementationOnce(
async () =>
({
"first-provider": {
name: "First provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "first-provider",
providerName: "strapi",
},
"second-provider": {
name: "Second provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "second-provider",
providerName: "strapi",
},
"third-provider": {
name: "Third provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "third-provider",
providerName: "strapi",
},
} as CMSSchemaProviderInstances)
);
// Following mock assumes function calculations went correct and returns correct values
vi.spyOn(Settings, "getProductVariantProviderInstancesToAlter").mockImplementationOnce(
async () =>
({
toCreate: [],
toUpdate: ["first-provider"],
toRemove: [],
} as Settings.ProductVariantProviderInstancesToAlter)
);
const cmsOperations = await createCmsOperations({
context: mockedContext,
client: createMockedClient(),
productVariantChannels: ["default-channel"],
productVariantCmsKeys: [createCmsKeyForSaleorItem("first-provider")],
});
const operationsItem = cmsOperations[0];
/**
* Replace deep equal with single ones due to some strange errors in vite/jest.
* Functions were not matched properly in deep object
*/
expect(operationsItem.cmsProviderInstanceId).toBe("first-provider");
expect(operationsItem.operationType).toBe("updateProduct");
expect(operationsItem.operations.createProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.deleteProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.updateProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.ping).toEqual(expect.any(Function));
});
it("should return delete operation when variant without channel listing that exists in provider instance passed", async () => {
vi.spyOn(Settings, "getChannelsSettings").mockImplementationOnce(
async () =>
({
"default-channel": {
channelSlug: "default-channel",
enabledProviderInstances: ["first-provider"],
},
"other-channel": {
channelSlug: "other-channel",
enabledProviderInstances: ["first-provider", "second-provider"],
},
} as CMSSchemaChannels)
);
vi.spyOn(Settings, "getProviderInstancesSettings").mockImplementationOnce(
async () =>
({
"first-provider": {
name: "First provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "first-provider",
providerName: "strapi",
},
"second-provider": {
name: "Second provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "second-provider",
providerName: "strapi",
},
"third-provider": {
name: "Third provider",
token: "token",
baseUrl: "http://localhost:3000",
contentTypeId: "contentTypeId",
id: "third-provider",
providerName: "strapi",
},
} as CMSSchemaProviderInstances)
);
// Following mock assumes function calculations went correct and returns correct values
vi.spyOn(Settings, "getProductVariantProviderInstancesToAlter").mockImplementationOnce(
async () =>
({
toCreate: [],
toUpdate: [],
toRemove: ["first-provider"],
} as Settings.ProductVariantProviderInstancesToAlter)
);
const cmsOperations = await createCmsOperations({
context: mockedContext,
client: createMockedClient(),
productVariantChannels: [],
productVariantCmsKeys: [createCmsKeyForSaleorItem("first-provider")],
});
const operationsItem = cmsOperations[0];
/**
* Replace deep equal with single ones due to some strange errors in vite/jest.
* Functions were not matched properly in deep object
*/
expect(operationsItem.cmsProviderInstanceId).toBe("first-provider");
expect(operationsItem.operationType).toBe("deleteProduct");
expect(operationsItem.operations.createProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.deleteProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.updateProduct).toEqual(expect.any(Function));
expect(operationsItem.operations.ping).toEqual(expect.any(Function));
});
});

View file

@ -1,116 +0,0 @@
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { createSettingsManager } from "../../metadata";
import { getOperationType } from "./operations";
import {
getChannelsSettings,
getProductVariantProviderInstancesToAlter,
getProviderInstancesSettings,
} from "./settings";
import { providersSchemaSet } from "../config";
import { CMSProvider, cmsProviders } from "../providers";
import { CmsClientOperations } from "../types";
import { getCmsIdFromSaleorItemKey } from "./metadata";
import { type Client } from "urql";
import { createLogger } from "@saleor/apps-shared";
type WebhookContext = Parameters<NextWebhookApiHandler>["2"];
export const createCmsOperations = async ({
context,
client,
productVariantChannels,
productVariantCmsKeys,
}: {
context: Pick<WebhookContext, "authData">;
client: Client;
productVariantChannels: string[];
productVariantCmsKeys: string[];
}) => {
const logger = createLogger({
productVariantChannels,
productVariantCmsKeys,
});
const settingsManager = createSettingsManager(client);
const [channelsSettingsParsed, providerInstancesSettingsParsed] = await Promise.all([
getChannelsSettings(settingsManager),
getProviderInstancesSettings(settingsManager),
]);
logger.debug({ channelsSettingsParsed, providerInstancesSettingsParsed }, "Fetched settings");
const productVariantCmsProviderInstances = productVariantCmsKeys.map((cmsKey) =>
getCmsIdFromSaleorItemKey(cmsKey)
);
const productVariantProviderInstancesToAlter = await getProductVariantProviderInstancesToAlter({
channelsSettingsParsed,
productVariantChannels,
productVariantCmsProviderInstances,
});
const allProductVariantProviderInstancesToAlter = [
...productVariantProviderInstancesToAlter.toCreate,
...productVariantProviderInstancesToAlter.toUpdate,
...productVariantProviderInstancesToAlter.toRemove,
];
if (!allProductVariantProviderInstancesToAlter.length) {
/*
* todo: use instead: throw new Error("The channel settings were not found.");
* continue with other provider instances
*/
return [];
}
const enabledProviderInstancesSettings = Object.values(providerInstancesSettingsParsed).filter(
(providerInstance) => allProductVariantProviderInstancesToAlter.includes(providerInstance.id)
);
const clientsOperations = enabledProviderInstancesSettings.reduce<CmsClientOperations[]>(
(acc, providerInstanceSettings) => {
const provider = cmsProviders[
providerInstanceSettings.providerName as CMSProvider
] as (typeof cmsProviders)[keyof typeof cmsProviders];
const validation =
providersSchemaSet[providerInstanceSettings.providerName as CMSProvider].safeParse(
providerInstanceSettings
);
if (!validation.success) {
/*
* todo: use instead: throw new Error(validation.error.message);
* continue with other provider instances
*/
logger.error("The provider instance settings validation failed.", {
error: validation.error.message,
});
return acc;
}
const config = validation.data;
if (provider) {
return [
...acc,
{
cmsProviderInstanceId: providerInstanceSettings.id,
// todo: fix validation to not pass config as any
operations: provider.create(config as any), // config without validation = providerInstanceSettings as any
operationType: getOperationType({
providerInstancesWithRequestedOperation: productVariantProviderInstancesToAlter,
providerInstanceId: providerInstanceSettings.id,
}),
},
];
}
return acc;
},
[] as CmsClientOperations[]
);
return clientsOperations;
};

View file

@ -1,3 +0,0 @@
export { createCmsOperations } from "./clients-operations";
export { executeCmsOperations } from "./clients-execution";
export { updateMetadata, batchUpdateMetadata } from "./metadata-execution";

View file

@ -1,131 +0,0 @@
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { Client } from "urql";
import {
DeleteMetadataDocument,
UpdateMetadataDocument,
WebhookProductVariantFragment,
} from "../../../../generated/graphql";
import { createCmsKeyForSaleorItem } from "./metadata";
import { createGraphQLClient } from "@saleor/apps-shared";
type WebhookContext = Parameters<NextWebhookApiHandler>["2"];
export type MetadataRecord = Record<string, string>;
const executeMetadataUpdateMutation = async ({
apiClient,
itemId,
cmsProviderInstanceIdsToCreate = {},
cmsProviderInstanceIdsToDelete = {},
}: {
apiClient: Client;
itemId: string;
cmsProviderInstanceIdsToCreate?: Record<string, string>;
cmsProviderInstanceIdsToDelete?: Record<string, string>;
}) => {
if (Object.keys(cmsProviderInstanceIdsToCreate).length) {
await apiClient
.mutation(UpdateMetadataDocument, {
id: itemId,
input: Object.entries(cmsProviderInstanceIdsToCreate).map(
([cmsProviderInstanceId, cmsProductVariantId]) => ({
key: createCmsKeyForSaleorItem(cmsProviderInstanceId),
value: cmsProductVariantId,
})
),
})
.toPromise();
}
if (Object.keys(cmsProviderInstanceIdsToDelete).length) {
await apiClient
.mutation(DeleteMetadataDocument, {
id: itemId,
keys: Object.entries(cmsProviderInstanceIdsToDelete).map(([cmsProviderInstanceId]) =>
createCmsKeyForSaleorItem(cmsProviderInstanceId)
),
})
.toPromise();
}
};
export const updateMetadata = async ({
context,
productVariant,
cmsProviderInstanceIdsToCreate,
cmsProviderInstanceIdsToDelete,
}: {
context: WebhookContext;
productVariant: WebhookProductVariantFragment;
cmsProviderInstanceIdsToCreate: Record<string, string>;
cmsProviderInstanceIdsToDelete: Record<string, string>;
}) => {
const { token, saleorApiUrl } = context.authData;
const apiClient = createGraphQLClient({ saleorApiUrl, token });
await executeMetadataUpdateMutation({
apiClient,
itemId: productVariant.id,
cmsProviderInstanceIdsToCreate,
cmsProviderInstanceIdsToDelete,
});
};
type ItemMetadataRecord = {
id: string;
cmsProviderInstanceIds: MetadataRecord;
};
export const batchUpdateMetadata = async ({
context,
variantCMSProviderInstanceIdsToCreate,
variantCMSProviderInstanceIdsToDelete,
}: {
context: Pick<WebhookContext, "authData">;
variantCMSProviderInstanceIdsToCreate: ItemMetadataRecord[];
variantCMSProviderInstanceIdsToDelete: ItemMetadataRecord[];
}) => {
const { token, saleorApiUrl } = context.authData;
const apiClient = createGraphQLClient({ saleorApiUrl, token });
const variantCMSProviderInstanceIdsToCreateMap = variantCMSProviderInstanceIdsToCreate.reduce(
(acc, { id, cmsProviderInstanceIds }) => ({
...acc,
[id]: {
...(acc[id] || {}),
...cmsProviderInstanceIds,
},
}),
{} as Record<string, MetadataRecord>
);
const variantCMSProviderInstanceIdsToDeleteMap = variantCMSProviderInstanceIdsToDelete.reduce(
(acc, { id, cmsProviderInstanceIds }) => ({
...acc,
[id]: {
...(acc[id] || {}),
...cmsProviderInstanceIds,
},
}),
{} as Record<string, MetadataRecord>
);
const mutationsToExecute = [
Object.entries(variantCMSProviderInstanceIdsToCreateMap).map(
([itemId, cmsProviderInstanceIdsToCreate]) =>
executeMetadataUpdateMutation({
apiClient,
itemId,
cmsProviderInstanceIdsToCreate,
})
),
Object.entries(variantCMSProviderInstanceIdsToDeleteMap).map(
([itemId, cmsProviderInstanceIdsToDelete]) =>
executeMetadataUpdateMutation({
apiClient,
itemId,
cmsProviderInstanceIdsToDelete,
})
),
];
await Promise.all(mutationsToExecute);
};

View file

@ -1,24 +0,0 @@
import { CMS_ID_KEY } from "../config";
type MetadataItem = Record<string, any> & { key: string; value: string };
export const getCmsKeysFromSaleorItem = (item?: { metadata: MetadataItem[] } | null) => {
return (
item?.metadata.filter((item) => item.key.startsWith(CMS_ID_KEY)).map((item) => item.key) || []
);
};
export const createCmsKeyForSaleorItem = (cmsProviderInstanceId: string) => {
return `${CMS_ID_KEY}_${cmsProviderInstanceId}`;
};
export const getCmsIdFromSaleorItemKey = (key: string) => {
return key.split("_")[1];
};
export const getCmsIdFromSaleorItem = (
item: Record<string, any> & { metadata: MetadataItem[] },
cmsProviderInstanceId: string
) =>
item.metadata.find((item) => item.key === createCmsKeyForSaleorItem(cmsProviderInstanceId))
?.value;

View file

@ -1,22 +0,0 @@
import { CmsOperations } from "../types";
import { ProductVariantProviderInstancesToAlter } from "./settings";
export const getOperationType = ({
providerInstancesWithRequestedOperation: { toCreate, toUpdate, toRemove },
providerInstanceId,
}: {
providerInstancesWithRequestedOperation: ProductVariantProviderInstancesToAlter;
providerInstanceId: string;
}): keyof CmsOperations => {
if (toCreate.includes(providerInstanceId)) {
return "createProduct";
}
if (toUpdate.includes(providerInstanceId)) {
return "updateProduct";
}
if (toRemove.includes(providerInstanceId)) {
return "deleteProduct";
}
return "updateProduct";
};

View file

@ -1,84 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { CMSSchemaChannels } from "../config";
import { getProductVariantProviderInstancesToAlter } from "./settings";
vi.mock("../../metadata", () => ({
createSettingsManager: () => ({}),
}));
describe("CMS Clients Operations", () => {
it("should return provider instance in create group when variant channel listing passed and channel provider instance not passed", async () => {
const channelsSettings = {
"default-channel": {
channelSlug: "default-channel",
enabledProviderInstances: ["first-provider"],
},
"other-channel": {
channelSlug: "other-channel",
enabledProviderInstances: ["first-provider", "second-provider"],
},
} as CMSSchemaChannels;
const providerInstances = await getProductVariantProviderInstancesToAlter({
channelsSettingsParsed: channelsSettings,
productVariantChannels: ["default-channel"], // function should infer that variant has ["first-provider"]
productVariantCmsProviderInstances: [],
});
expect(providerInstances).toEqual({
toCreate: ["first-provider"],
toUpdate: [],
toRemove: [],
});
});
it("should return provider instance in update group when variant channel listing passed and channel provider instance passed", async () => {
const channelsSettings = {
"default-channel": {
channelSlug: "default-channel",
enabledProviderInstances: ["first-provider"],
},
"other-channel": {
channelSlug: "other-channel",
enabledProviderInstances: ["first-provider", "second-provider"],
},
} as CMSSchemaChannels;
const providerInstances = await getProductVariantProviderInstancesToAlter({
channelsSettingsParsed: channelsSettings,
productVariantChannels: ["default-channel"], // function should infer that variant has ["first-provider"]
productVariantCmsProviderInstances: ["first-provider"],
});
expect(providerInstances).toEqual({
toCreate: [],
toUpdate: ["first-provider"],
toRemove: [],
});
});
it("should return provider instance in remove group when variant channel listing not passed and channel provider instance passed", async () => {
const channelsSettings = {
"default-channel": {
channelSlug: "default-channel",
enabledProviderInstances: ["first-provider"],
},
"other-channel": {
channelSlug: "other-channel",
enabledProviderInstances: ["first-provider", "second-provider"],
},
} as CMSSchemaChannels;
const providerInstances = await getProductVariantProviderInstancesToAlter({
channelsSettingsParsed: channelsSettings,
productVariantChannels: [], // function should infer that variant has []
productVariantCmsProviderInstances: ["first-provider"],
});
expect(providerInstances).toEqual({
toCreate: [],
toUpdate: [],
toRemove: ["first-provider"],
});
});
});

View file

@ -1,100 +0,0 @@
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
import { CMSSchemaChannels, CMSSchemaProviderInstances } from "../config";
export const getChannelsSettings = async (settingsManager: EncryptedMetadataManager) => {
const channelsSettings = await settingsManager.get("channels");
try {
const channelsSettingsParsed =
(channelsSettings && (JSON.parse(channelsSettings) as CMSSchemaChannels)) || {};
return channelsSettingsParsed;
} catch (e) {
return {};
}
};
export const getProviderInstancesSettings = async (settingsManager: EncryptedMetadataManager) => {
const providerInstancesSettings = await settingsManager.get("providerInstances");
try {
const providerInstancesSettingsParsed =
(providerInstancesSettings &&
(JSON.parse(providerInstancesSettings) as CMSSchemaProviderInstances)) ||
{};
return providerInstancesSettingsParsed;
} catch (e) {
return {};
}
};
const filterNotExistingProviderInstances = (
providerInstances: string[],
newProviderInstances: string[]
) => {
return newProviderInstances.filter(
(newProviderInstance) => !providerInstances.includes(newProviderInstance)
);
};
const mergeProviderInstances = (providerInstances: string[], newProviderInstances: string[]) => {
return [
...providerInstances,
...filterNotExistingProviderInstances(providerInstances, newProviderInstances),
];
};
export interface ProductVariantSingleChannelSettings {
enabledProviderInstances: string[];
channelSlug: string;
}
export interface ProductVariantProviderInstancesToAlter {
toCreate: string[];
toUpdate: string[];
toRemove: string[];
}
export interface ProductVariantProviderInstancesToAlterOpts {
channelsSettingsParsed: Record<string, ProductVariantSingleChannelSettings>;
productVariantChannels: string[];
productVariantCmsProviderInstances: string[];
}
/**
* Returns list of provider instances that have to have performed create, update or remove operations against them.
* The list is based on the channels that the product variant is assigned to and the cms provider instances indicating instances
* that the product variant has been already created in.
*/
export const getProductVariantProviderInstancesToAlter = async ({
channelsSettingsParsed,
productVariantChannels,
productVariantCmsProviderInstances,
}: ProductVariantProviderInstancesToAlterOpts) => {
const enabledChannelsForProductVariant = Object.values(channelsSettingsParsed).filter(
(channelSettings) =>
!!productVariantChannels.length &&
!!productVariantChannels.includes(channelSettings.channelSlug)
);
const channelsProvidersForProductVariant = enabledChannelsForProductVariant.reduce(
(acc, channelSettings) => mergeProviderInstances(acc, channelSettings.enabledProviderInstances),
[] as string[]
);
const productVariantCmsKeysNotYetInChannelsSettings = channelsProvidersForProductVariant.filter(
(cms) => !productVariantCmsProviderInstances.includes(cms)
);
const productVariantCmsKeysInChannelsSettings = productVariantCmsProviderInstances.filter((cms) =>
channelsProvidersForProductVariant.includes(cms)
);
const productVariantCmsKeysNoLongerInChannelsSettings = productVariantCmsProviderInstances.filter(
(cms) => !channelsProvidersForProductVariant.includes(cms)
);
return {
toCreate: productVariantCmsKeysNotYetInChannelsSettings,
toUpdate: productVariantCmsKeysInChannelsSettings,
toRemove: productVariantCmsKeysNoLongerInChannelsSettings,
};
};

View file

@ -1,23 +0,0 @@
import { z } from "zod";
import { ChannelFragment } from "../../../../generated/graphql";
export const channelCommonSchema = z.object({
channelSlug: z.string(),
});
export type ChannelCommonSchema = z.infer<typeof channelCommonSchema>;
export const channelSchema = z
.object({
enabledProviderInstances: z.array(z.string()),
requireSyncProviderInstances: z.array(z.string()).optional(),
})
.merge(channelCommonSchema);
export type ChannelSchema = z.infer<typeof channelSchema>;
export type SingleChannelSchema = ChannelSchema & ChannelCommonSchema;
export type MergedChannelSchema = SingleChannelSchema & {
channel: ChannelFragment;
};

View file

@ -1,18 +0,0 @@
import { z } from "zod";
import { channelSchema } from "./channels";
import { providerInstanceSchema } from "./providers";
export * from "./channels";
export * from "./providers";
export const CMS_ID_KEY = "cmsId";
export const cmsSchemaProviderInstances = z.record(z.string(), providerInstanceSchema);
export const cmsSchemaChannels = z.record(z.string(), channelSchema);
export const cmsSchema = z.object({
providerInstances: cmsSchemaProviderInstances,
channels: cmsSchemaChannels,
});
export type CMSSchemaProviderInstances = z.infer<typeof cmsSchemaProviderInstances>;
export type CMSSchemaChannels = z.infer<typeof cmsSchemaChannels>;

View file

@ -1,200 +0,0 @@
import { z } from "zod";
import { CreateProviderConfig } from "../types";
import { ContentfulIcon, DatocmsIcon, StrapiIcon } from "../../../assets";
type ProviderToken = {
name: string;
label: string;
helpText: string;
required?: boolean;
secret?: boolean;
};
type ProviderConfig = {
name: string;
label: string;
icon: React.ReactNode;
tokens: ProviderToken[];
};
type ProvidersConfig = Record<string, ProviderConfig>;
export const providersConfig = {
contentful: {
name: "contentful",
label: "Contentful",
icon: ContentfulIcon,
tokens: [
{
required: true,
secret: true,
name: "token",
label: "Token",
helpText:
'You can find this in your Contentful project, go to Settings > API Keys > Content Management Tokens > Generate Personal Token. More instructions at [Contentful "Authentication" documentation](https://www.contentful.com/developers/docs/references/authentication/).',
},
{
required: true,
name: "environment",
label: "Environment",
helpText:
"Environment of your content, e.g. master. You can find this in your Contentful project, go to Settings > Environments.",
},
{
required: true,
name: "spaceId",
label: "Space ID",
helpText:
"You can find this in your Contentful project, go to Settings > General Settings.",
},
{
required: true,
name: "contentId",
label: "Content ID",
helpText:
"You can find this in your Contentful project, go to Content Model > select Model > Content Type ID.",
},
{
required: true,
name: "locale",
label: "Locale",
helpText:
"Locale of your content, e.g. en-US. You can find this in your Contentful project, go to Settings > Locales.",
},
{
name: "baseUrl",
label: "Base URL",
helpText:
"Content management API URL of your Contentful project. If you leave this blank, default https://api.contentful.com will be used.",
},
{
name: "apiRequestsPerSecond",
label: "API requests per second",
helpText:
"API rate limits. The default is 7. Used in bulk products variants sync. Higher rate limits may speed up a little products variants bulk sync. Higher rate limit may apply depending on different Contentful plan, learn more at https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/api-rate-limits.",
},
],
},
strapi: {
name: "strapi",
label: "Strapi",
icon: StrapiIcon,
tokens: [
{
required: true,
name: "baseUrl",
label: "Base URL",
helpText: "API URL of your Strapi project. E.g. https://your-strapi-project/api.",
},
{
required: true,
secret: true,
name: "token",
label: "API Token (with full access)",
helpText:
'You can find this in your Strapi project settings, go to Settings > API Tokens and use full access token or create new one. More instructions at [Strapi "Managing API tokens" documentation](https://docs.strapi.io/user-docs/latest/settings/managing-global-settings.html#managing-api-tokens).',
},
{
required: true,
name: "contentTypeId",
label: "Content Type ID (plural)",
helpText:
'You can find this in your Strapi project, go to Content-Type Builder > select Content Type > click Edit > Use API ID (Plural). More instructions at [Strapi "Editing content types" documentation](https://docs.strapi.io/user-docs/content-type-builder/managing-content-types#editing-content-types).',
},
],
},
datocms: {
name: "datocms",
label: "DatoCMS",
icon: DatocmsIcon,
tokens: [
{
required: true,
secret: true,
name: "token",
label: "API Token (with access to Content Management API)",
helpText:
'You can find this in your DatoCMS project settings. More instructions at [DatoCMS "Authentication" documentation](https://www.datocms.com/docs/content-management-api/authentication).',
},
{
required: true,
name: "itemTypeId",
label: "Item Type ID (number)",
helpText:
"To find your Model ID go into Models -> Your product variant Model -> Edit Model button. The Model ID is in the right corner.",
},
{
name: "baseUrl",
label: "Base URL",
helpText:
"URL to your DatoCMS project. If you leave this blank, this URL will be inferred from your API Token.",
},
{
name: "environment",
label: "Environment",
helpText:
"Environment name. If you leave this blank, default environment will be used. You can find this in your DatoCMS project settings.",
},
],
},
} satisfies ProvidersConfig;
export type StrapiConfig = CreateProviderConfig<"strapi">;
export type ContentfulConfig = CreateProviderConfig<"contentful">;
export type DatocmsConfig = CreateProviderConfig<"datocms">;
export const strapiConfigSchema = z.object({
name: z.string().min(1),
token: z.string().min(1),
baseUrl: z.string().url().min(1),
contentTypeId: z.string().min(1),
});
export const contentfulConfigSchema = z.object({
name: z.string().min(1),
token: z.string().min(1),
environment: z.string().min(1),
spaceId: z.string().min(1),
locale: z.string().min(1),
contentId: z.string().min(1),
baseUrl: z.string().url().optional().or(z.literal("")),
apiRequestsPerSecond: z.number().optional().or(z.literal("")),
});
export const datocmsConfigSchema = z.object({
name: z.string().min(1),
token: z.string().min(1),
itemTypeId: z.number().min(1),
baseUrl: z.string().url().optional().or(z.literal("")),
environment: z.string(),
});
export const providerCommonSchema = z.object({
id: z.string(),
providerName: z.string(),
});
export type ProviderCommonSchema = z.infer<typeof providerCommonSchema>;
// todo: helper function so you dont have to merge manually
export const providersSchemaSet = {
strapi: strapiConfigSchema.merge(providerCommonSchema),
contentful: contentfulConfigSchema.merge(providerCommonSchema),
datocms: datocmsConfigSchema.merge(providerCommonSchema),
};
export type CMSProviderSchema = keyof typeof providersSchemaSet;
export const providersSchema = z.object(providersSchemaSet);
export type ProvidersSchema = z.infer<typeof providersSchema>;
export type SingleProviderSchema = ProvidersSchema[keyof ProvidersSchema] & ProviderCommonSchema;
export const providerInstanceSchema = z.union([
strapiConfigSchema.merge(providerCommonSchema),
contentfulConfigSchema.merge(providerCommonSchema),
datocmsConfigSchema.merge(providerCommonSchema),
]);
export type ProviderInstanceSchema = z.infer<typeof providerInstanceSchema>;

View file

@ -1,16 +0,0 @@
export const fetchWithRateLimit = async <A, R>(
args: A[],
fun: (arg: A) => Promise<R>,
requestPerSecondLimit: number
) => {
const delay = 1000 / requestPerSecondLimit;
const results: Promise<R>[] = [];
for (const arg of args) {
const result = fun(arg);
results.push(result);
await new Promise((resolve) => setTimeout(resolve, delay));
}
return await Promise.all(results);
};

View file

@ -1,3 +0,0 @@
export * from "./config";
export * from "./client";
export * from "./providers";

View file

@ -1,269 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import { ContentfulConfig, contentfulConfigSchema } from "../config";
import { CreateOperations, ProductResponse, ProductInput } from "../types";
import { createProvider } from "./create";
import { fetchWithRateLimit } from "../data-sync";
import { createLogger } from "@saleor/apps-shared";
const contentfulFetch = (endpoint: string, config: ContentfulConfig, options?: RequestInit) => {
const baseUrl = config.baseUrl || "https://api.contentful.com";
const token = config.token;
return fetch(`${baseUrl}${endpoint}`, {
...options,
headers: {
...options?.headers,
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
};
type ContentfulBody = {
fields: Record<string, any>;
};
type ContentfulResponse = {
message?: string;
fields?: Record<string, any>;
sys: {
id: string;
version?: number;
};
statusCode: number;
input: ProductInput;
};
const transformInputToBody = ({
input,
locale,
}: {
input: ProductInput;
locale: string;
}): ContentfulBody => {
const body = {
fields: {
saleor_id: {
[locale]: input.saleorId,
},
name: {
[locale]: input.name,
},
product_id: {
[locale]: input.productId,
},
product_slug: {
[locale]: input.productSlug,
},
product_name: {
[locale]: input.productName,
},
channels: {
[locale]: input.channels,
},
},
};
return body;
};
const transformCreateProductResponse = (response: ContentfulResponse): ProductResponse => {
if (response.message) {
return {
ok: false,
error: "Something went wrong!",
};
}
return {
ok: true,
data: {
id: response.sys.id,
saleorId: response.input.saleorId,
},
};
};
const getEntryEndpoint = ({
resourceId,
spaceId,
environment,
}: {
resourceId: string;
spaceId: string;
environment: string;
}): string => `/spaces/${spaceId}/environments/${environment}/entries/${resourceId}`;
const contentfulOperations: CreateOperations<ContentfulConfig> = (config) => {
const logger = createLogger({ cms: "contentful" });
const { environment, spaceId, contentId, locale, apiRequestsPerSecond } = config;
const requestPerSecondLimit = Number(apiRequestsPerSecond || 7);
const pingCMS = async () => {
const endpoint = `/spaces/${spaceId}`;
const response = await contentfulFetch(endpoint, config, { method: "GET" });
const respBody = await response.json();
logger.debug({ response, body: respBody }, "pingCMS response");
return {
ok: response.ok,
};
};
const createProductInCMS = async (input: ProductInput): Promise<ContentfulResponse> => {
// Contentful API does not auto generate resource ID during creation, it has to be provided.
const resourceId = uuidv4();
const body = transformInputToBody({ input, locale });
const endpoint = getEntryEndpoint({
resourceId,
environment,
spaceId,
});
const response = await contentfulFetch(endpoint, config, {
method: "PUT",
body: JSON.stringify(body),
headers: {
"X-Contentful-Content-Type": contentId,
},
});
logger.debug({ response }, "createProduct response");
const json = await response.json();
return {
...json,
statusCode: response.status,
input,
};
};
const updateProductInCMS = async (id: string, input: ProductInput) => {
const body = transformInputToBody({ input, locale });
const endpoint = getEntryEndpoint({
resourceId: id,
environment,
spaceId,
});
const getEntryResponse = await contentfulFetch(endpoint, config, { method: "GET" });
logger.debug({ getEntryResponse }, "updateProduct getEntryResponse");
const entry = await getEntryResponse.json();
logger.debug({ entry }, "updateProduct entry");
const response = await contentfulFetch(endpoint, config, {
method: "PUT",
body: JSON.stringify(body),
headers: {
"X-Contentful-Version": entry.sys.version,
},
});
logger.debug({ response }, "updateProduct response");
const json = await response.json();
return {
...json,
statusCode: response.status,
};
};
const deleteProductInCMS = async (id: string) => {
const endpoint = getEntryEndpoint({ resourceId: id, environment, spaceId });
return await contentfulFetch(endpoint, config, { method: "DELETE" });
};
const createBatchProductsInCMS = async (input: ProductInput[]) => {
// Contentful doesn't support batch creation of items, so we need to create them one by one
// Take into account rate limit
const firstResults = await fetchWithRateLimit(input, createProductInCMS, requestPerSecondLimit);
const failedWithLimitResults = firstResults.filter((result) => result.statusCode === 429);
// Retry with delay x2 if by any chance hit rate limit with HTTP 429
let secondResults: ContentfulResponse[] = [];
if (failedWithLimitResults.length > 0) {
logger.debug("createBatchProductsInCMS retrying failed by rate limit with delay x2");
secondResults = await fetchWithRateLimit(
failedWithLimitResults,
(result) => createProductInCMS(result.input),
requestPerSecondLimit / 2
);
}
return [...firstResults.filter((result) => result.statusCode !== 429), ...secondResults];
};
const deleteBatchProductsInCMS = async (ids: string[]) => {
// Contentful doesn't support batch deletion of items, so we need to delete them one by one
// Take into account rate limit
const firstResults = await fetchWithRateLimit(ids, deleteProductInCMS, requestPerSecondLimit);
const failedWithLimitResults = firstResults.filter((result) => result.status === 429);
// Retry with delay x2 if by any chance hit rate limit with HTTP 429
let secondResults: Response[] = [];
if (failedWithLimitResults.length > 0) {
logger.debug("deleteBatchProductsInCMS retrying failed by rate limit with delay x2");
secondResults = await fetchWithRateLimit(
failedWithLimitResults,
(result) => deleteProductInCMS(result.url),
requestPerSecondLimit / 2
);
}
return [...firstResults.filter((result) => result.status !== 429), ...secondResults];
};
return {
ping: async () => {
const response = await pingCMS();
logger.debug({ response }, "ping response");
return response;
},
createProduct: async ({ input }) => {
const result = await createProductInCMS(input);
logger.debug({ result }, "createProduct result");
return transformCreateProductResponse(result);
},
updateProduct: async ({ id, input }) => {
const result = await updateProductInCMS(id, input);
logger.debug({ result }, "updateProduct result");
return result;
},
deleteProduct: async ({ id }) => {
const response = await deleteProductInCMS(id);
logger.debug({ response }, "deleteProduct response");
return response;
},
createBatchProducts: async ({ input }) => {
const results = await createBatchProductsInCMS(input);
logger.debug({ results }, "createBatchProducts results");
return results.map((result) => transformCreateProductResponse(result));
},
deleteBatchProducts: async ({ ids }) => {
const results = await deleteBatchProductsInCMS(ids);
logger.debug({ results }, "deleteBatchProducts results");
},
};
};
export const contentfulProvider = createProvider(contentfulOperations, contentfulConfigSchema);

View file

@ -1,12 +0,0 @@
import { z } from "zod";
import { BaseConfig, CreateOperations, Provider } from "../types";
export const createProvider = <TConfig extends BaseConfig>(
operations: CreateOperations<TConfig>,
schema: z.ZodType<TConfig>
): Provider<TConfig> => {
return {
create: operations,
schema,
};
};

View file

@ -1,147 +0,0 @@
import { createProvider } from "./create";
import { CreateOperations, ProductInput, ProductResponse } from "../types";
import { ApiError, buildClient, SimpleSchemaTypes } from "@datocms/cma-client-node";
import { DatocmsConfig, datocmsConfigSchema } from "../config";
import { createLogger } from "@saleor/apps-shared";
const datocmsClient = (config: DatocmsConfig, options?: RequestInit) => {
const { baseUrl, token, environment } = config;
const clientEnvironment = environment ? { environment } : {};
const clientBaseUrl = baseUrl ? { baseUrl } : {};
return buildClient({
apiToken: token,
...clientEnvironment,
...clientBaseUrl,
});
};
const transformResponseError = (error: unknown): ProductResponse => {
if (error instanceof ApiError) {
return {
ok: false,
error: error.message,
};
} else {
return {
ok: false,
error: "Something went wrong!",
};
}
};
const transformResponseItem = (
item: SimpleSchemaTypes.Item,
input: ProductInput
): ProductResponse => {
return {
ok: true,
data: {
id: item.id,
saleorId: input.saleorId,
},
};
};
const datocmsOperations: CreateOperations<DatocmsConfig> = (config) => {
const logger = createLogger({ cms: "strapi" });
const client = datocmsClient(config);
const pingCMS = async () => client.users.findMe();
const createProductInCMS = async (input: ProductInput) =>
client.items.create({
item_type: {
id: String(config.itemTypeId),
type: "item_type",
},
saleor_id: input.saleorId,
name: input.name,
channels: JSON.stringify(input.channels),
product_id: input.productId,
product_name: input.productName,
product_slug: input.productSlug,
});
const updateProductInCMS = async (id: string, input: ProductInput) =>
client.items.update(id, {
saleor_id: input.saleorId,
name: input.name,
channels: JSON.stringify(input.channels),
product_id: input.productId,
product_name: input.productName,
product_slug: input.productSlug,
});
const deleteProductInCMS = async (id: string) => client.items.destroy(id);
const createBatchProductsInCMS = async (input: ProductInput[]) =>
// DatoCMS doesn't support batch creation of items, so we need to create them one by one
Promise.all(
input.map(async (item) => ({
id: await createProductInCMS(item),
input: item,
}))
);
const deleteBatchProductsInCMS = async (ids: string[]) =>
client.items.bulkDestroy({
items: ids.map((id) => ({ id, type: "item" })),
});
return {
ping: async () => {
try {
const response = await pingCMS();
logger.debug({ response }, "ping response");
if (!response.id) {
throw new Error();
}
return { ok: true };
} catch (error) {
return { ok: false };
}
},
createProduct: async ({ input }) => {
try {
const item = await createProductInCMS(input);
logger.debug({ item }, "createProduct response");
return transformResponseItem(item, input);
} catch (error) {
return transformResponseError(error);
}
},
updateProduct: async ({ id, input }) => {
const item = await updateProductInCMS(id, input);
logger.debug({ item }, "updateProduct response");
},
deleteProduct: async ({ id }) => {
const item = await deleteProductInCMS(id);
logger.debug({ item }, "deleteProduct response");
},
createBatchProducts: async ({ input }) => {
const items = await createBatchProductsInCMS(input);
logger.debug({ items }, "createBatchProducts response");
return items.map((item) => transformResponseItem(item.id, item.input));
},
deleteBatchProducts: async ({ ids }) => {
const items = await deleteBatchProductsInCMS(ids);
logger.debug({ items }, "deleteBatchProducts response");
},
};
};
export const datoCmsProvider = createProvider(datocmsOperations, datocmsConfigSchema);

View file

@ -1,11 +0,0 @@
import { contentfulProvider } from "./contentful";
import { strapiProvider } from "./strapi";
import { datoCmsProvider } from "./datocms";
export const cmsProviders = {
contentful: contentfulProvider,
strapi: strapiProvider,
datocms: datoCmsProvider,
};
export type CMSProvider = keyof typeof cmsProviders;

View file

@ -1,180 +0,0 @@
import { StrapiConfig, strapiConfigSchema } from "../config";
import { CreateOperations, ProductResponse, ProductInput } from "../types";
import { createProvider } from "./create";
import { createLogger } from "@saleor/apps-shared";
const strapiFetch = async (endpoint: string, config: StrapiConfig, options?: RequestInit) => {
const { baseUrl, token } = config;
return fetch(`${baseUrl}${endpoint}`, {
...options,
headers: {
...options?.headers,
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
};
type StrapiBody = {
data: Record<string, any> & { saleor_id: string };
};
const transformInputToBody = (input: ProductInput): StrapiBody => {
const body = {
data: {
saleor_id: input.saleorId,
name: input.name,
channels: input.channels,
product_id: input.productId,
product_name: input.productName,
product_slug: input.productSlug,
},
};
return body;
};
type StrapiResponse =
| {
data: null;
error: {
status: number;
name: string;
message: string;
details?: {
errors: unknown[];
};
};
}
| {
data: {
id: string;
attributes: Record<string, any>;
meta: Record<string, any>;
};
error: null;
};
const transformCreateProductResponse = (
response: StrapiResponse,
input: ProductInput
): ProductResponse => {
if (response.error) {
return {
ok: false,
error: "Something went wrong!",
};
}
return {
ok: true,
data: {
id: response.data.id,
saleorId: input.saleorId,
},
};
};
type CreateStrapiOperations = CreateOperations<StrapiConfig>;
export const strapiOperations: CreateStrapiOperations = (config) => {
const logger = createLogger({ cms: "strapi" });
const { contentTypeId } = config;
const pingCMS = async () => {
const response = await strapiFetch(`/${contentTypeId}`, config, {
method: "GET",
});
logger.debug({ response }, "pingCMS response");
return { ok: response.ok };
};
const createProductInCMS = async (input: ProductInput): Promise<StrapiResponse> => {
const body = transformInputToBody(input);
const response = await strapiFetch(`/${contentTypeId}`, config, {
method: "POST",
body: JSON.stringify(body),
});
logger.debug({ response }, "createProduct response");
return await response.json();
};
const updateProductInCMS = async (id: string, input: ProductInput) => {
const body = transformInputToBody(input);
return await strapiFetch(`/${contentTypeId}/${id}`, config, {
method: "PUT",
body: JSON.stringify(body),
});
};
const deleteProductInCMS = async (id: string) => {
return await strapiFetch(`/${contentTypeId}/${id}`, config, { method: "DELETE" });
};
const createBatchProductsInCMS = async (input: ProductInput[]) => {
// Strapi doesn't support batch creation of items, so we need to create them one by one
return await Promise.all(
input.map(async (product) => ({
response: await createProductInCMS(product),
input: product,
}))
);
};
const deleteBatchProductsInCMS = async (ids: string[]) => {
// Strapi doesn't support batch deletion of items, so we need to delete them one by one
return await Promise.all(ids.map((id) => deleteProductInCMS(id)));
};
return {
ping: async () => {
const response = await pingCMS();
logger.debug({ response }, "ping response");
return response;
},
createProduct: async ({ input }) => {
const result = await createProductInCMS(input);
logger.debug({ result }, "createProduct result");
return transformCreateProductResponse(result, input);
},
updateProduct: async ({ id, input }) => {
const response = await updateProductInCMS(id, input);
logger.debug({ response }, "updateProduct response");
return response;
},
deleteProduct: async ({ id }) => {
const response = await deleteProductInCMS(id);
logger.debug({ response }, "deleteProduct response");
return response;
},
createBatchProducts: async ({ input }) => {
const results = await createBatchProductsInCMS(input);
logger.debug({ results }, "createBatchProducts results");
return results.map((result) => transformCreateProductResponse(result.response, result.input));
},
deleteBatchProducts: async ({ ids }) => {
const responses = await deleteBatchProductsInCMS(ids);
logger.debug({ responses }, "deleteBatchProducts responses");
return responses;
},
};
};
export const strapiProvider = createProvider(strapiOperations, strapiConfigSchema);

View file

@ -1,63 +0,0 @@
import { z } from "zod";
import { ProvidersSchema, providersConfig } from "./config";
export type ProductInput = Record<string, any> & {
saleorId: string;
name: string;
productId: string;
productName: string;
productSlug: string;
channels: string[];
image?: string;
};
export type BaseResponse = { ok: boolean };
export type ProductResponseSuccess = { ok: true; data: { id: string; saleorId: string } };
export type ProductResponseError = { ok: false; error: string };
export type ProductResponse = ProductResponseSuccess | ProductResponseError;
export type CmsOperations = {
ping: () => Promise<BaseResponse>;
getProduct?: ({ id }: { id: string }) => Promise<Response>;
createProduct: ({ input }: { input: ProductInput }) => Promise<ProductResponse>;
updateProduct: ({ id, input }: { id: string; input: ProductInput }) => Promise<Response | void>;
deleteProduct: ({ id }: { id: string }) => Promise<Response | void>;
};
export type CmsBatchOperations = {
getAllProducts?: () => Promise<Response>;
createBatchProducts: ({ input }: { input: ProductInput[] }) => Promise<ProductResponse[]>;
deleteBatchProducts: ({ ids }: { ids: string[] }) => Promise<Response[] | void>;
};
export type CmsClientOperations = {
cmsProviderInstanceId: string;
operations: CmsOperations;
operationType: keyof CmsOperations;
};
export type CmsClientBatchOperations = {
cmsProviderInstanceId: string;
operations: CmsBatchOperations;
operationType: keyof CmsBatchOperations;
};
export type BaseConfig = {
name: string;
};
// * Generates the config based on the data supplied in the `providersConfig` variable.
export type CreateProviderConfig<TProviderName extends keyof typeof providersConfig> = Omit<
ProvidersSchema[TProviderName],
"id" | "providerName"
> &
BaseConfig;
export type CreateOperations<TConfig extends BaseConfig> = (
config: TConfig
) => CmsOperations & CmsBatchOperations;
export type Provider<TConfig extends BaseConfig> = {
create: CreateOperations<TConfig>;
schema: z.ZodType<TConfig>;
};

View file

@ -1,120 +0,0 @@
import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
import { Client } from "urql";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
FetchProductVariantMetadataDocument,
FetchProductVariantMetadataQuery,
UpdateAppMetadataDocument,
} from "../../generated/graphql";
import { createLogger } from "@saleor/apps-shared";
/*
* Function is using urql graphql client to fetch all available metadata.
* Before returning query result, we are transforming response to list of objects with key and value fields
* which can be used by the manager.
* Result of this query is cached by the manager.
*/
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
const logger = createLogger({
function: "fetchAllMetadata",
});
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
if (error) {
logger.debug("Error during fetching the metadata", error);
return [];
}
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
}
/*
* Mutate function takes urql client and metadata entries, and construct mutation to the API.
* Before data are send, additional query for required App ID is made.
* The manager will use updated entries returned by this mutation to update it's cache.
*/
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
const logger = createLogger({
function: "mutateMetadata",
});
// to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
.toPromise();
if (idQueryError) {
logger.debug("Could not fetch the app id", idQueryError);
throw new Error(
"Could not fetch the app id. Please check if auth data for the client are valid."
);
}
const appId = idQueryData?.app?.id;
if (!appId) {
logger.debug("Missing app id");
throw new Error("Could not fetch the app ID");
}
const { error: mutationError, data: mutationData } = await client
.mutation(UpdateAppMetadataDocument, {
id: appId,
input: metadata,
})
.toPromise();
if (mutationError) {
logger.debug("Mutation error", mutationError);
throw new Error(`Mutation error: ${mutationError.message}`);
}
return (
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
key: md.key,
value: md.value,
})) || []
);
}
export const createSettingsManager = (client: Client) => {
/*
* EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
* We recommend it for production, because all values are encrypted.
* If your use case require plain text values, you can use MetadataManager.
*/
return new EncryptedMetadataManager({
// Secret key should be randomly created for production and set as environment variable
encryptionKey: process.env.SECRET_KEY!,
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutateMetadata(client, metadata),
});
};
export async function fetchProductVariantMetadata(
client: Client,
productId: string
): Promise<MetadataEntry[]> {
const logger = createLogger({
function: "fetchProductVariantMetadata",
productId,
});
const { error, data } = await client
.query<FetchProductVariantMetadataQuery>(FetchProductVariantMetadataDocument, {
id: productId,
})
.toPromise();
if (error) {
logger.debug("Error during fetching product metadata", error);
return [];
}
return data?.productVariant?.metadata.map((md) => ({ key: md.key, value: md.value })) || [];
}

View file

@ -1,19 +0,0 @@
import React, { PropsWithChildren } from "react";
import dynamic from "next/dynamic";
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
/**
* Saleor App can be rendered only as a Saleor Dashboard iframe.
* All content is rendered after Dashboard exchanges auth with the app.
* Hence, there is no reason to render app server side.
*
* This component forces app to work in SPA-mode. It simplifies browser-only code and reduces need
* of using dynamic() calls
*
* You can use this wrapper selectively for some pages or remove it completely.
* It doesn't affect Saleor communication, but may cause problems with some client-only code.
*/
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
ssr: false,
});

View file

@ -1,48 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { AppBridgeState } from "@saleor/app-sdk/app-bridge";
import { render, waitFor } from "@testing-library/react";
import { ThemeSynchronizer } from "./theme-synchronizer";
const appBridgeState: AppBridgeState = {
ready: true,
token: "token",
domain: "some-domain.saleor.cloud",
theme: "dark",
path: "/",
locale: "en",
id: "app-id",
saleorApiUrl: "https://some-domain.saleor.cloud/graphql/",
};
const mockThemeChange = vi.fn();
vi.mock("@saleor/app-sdk/app-bridge", () => {
return {
useAppBridge() {
return {
appBridgeState: appBridgeState,
};
},
};
});
vi.mock("@saleor/macaw-ui", () => {
return {
useTheme() {
return {
setTheme: mockThemeChange,
themeType: "light",
};
},
};
});
describe("ThemeSynchronizer", () => {
it("Updates MacawUI theme when AppBridgeState theme changes", () => {
render(<ThemeSynchronizer />);
return waitFor(() => {
expect(mockThemeChange).toHaveBeenCalledWith("dark");
});
});
});

View file

@ -1,33 +0,0 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui";
import { memo, useEffect } from "react";
/**
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
* Macaw must be informed about this change from AppBridge.
*
* If you are not using Macaw, you can remove this.
*/
function _ThemeSynchronizer() {
const { appBridgeState } = useAppBridge();
const { setTheme, themeType } = useTheme();
useEffect(() => {
if (!setTheme || !appBridgeState?.theme) {
return;
}
if (themeType !== appBridgeState?.theme) {
setTheme(appBridgeState.theme);
/**
* Hack to fix macaw, which is going into infinite loop on light mode (probably de-sync local storage with react state)
* TODO Fix me when Macaw 2.0 is shipped
*/
window.localStorage.setItem("macaw-ui-theme", appBridgeState.theme);
}
}, [appBridgeState?.theme, setTheme, themeType]);
return null;
}
export const ThemeSynchronizer = memo(_ThemeSynchronizer);

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