diff --git a/README.md b/README.md index 51151ccec..c843cd939 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,6 @@ As a result, the `master` branch must contain content approved by Magento archit * [Approved proposals](https://github.com/magento/architecture/pulls?utf8=%E2%9C%93&q=is%3Apr+is%3Amerged+) are represented by merged PRs. * [Declined proposals](https://github.com/magento/architecture/pulls?utf8=%E2%9C%93&q=is%3Apr+is%3Aunmerged+is%3Aclosed) are represented by closed (not merged) PRs. -## Glossary - -author -: a Magento core engineer, or any community member - -facilitator -: a Magento architect who makes sure the process is followed (Olga Koplyova @buskamuza) - ## The Workflow 1. Fork the repository and add or edit a document in your branch. @@ -48,7 +40,6 @@ Contributions are expected from Magento core engineers mostly, although the comm * Review the entire document by specified due date (if any) * If it is impossible, find a replacement - * Contact the facilitator in case you can't find a replacement * If the due date is unreasonable for the size of the document, agree on another due date with the author * Include a detailed feedback * Ensure the feedback is objective @@ -60,24 +51,3 @@ The implementation process is out of scope in this project. After approval of the document, a new discussion may be raised basing on the issues occurred during implementation. It is also possible in case of new informational updates that discover hidden sides of the future implementation. If it is the case, a new PR should be opened to update existing document. The PR should include explained reasons for the proposed change. - -## Architectural Discussions - -Architectural Discussions are public meetings open to anyone and taking place on a regular basis (**bi-weekly**). Topics for the Architectural Discussions should be proposed in advance. If no topics are proposed prior to the meeting, it may be canceled. - -### Meeting notes and topics - -Meeting notes and topics are available as [meeting notes issues](https://github.com/magento/architecture/issues?q=is%3Aissue+is%3Aopen+label%3A%22meeting+notes%22). - -Prior to November 14th, 2018, meeting minutes are available at sidebar of the [wiki](https://github.com/magento/architecture/wiki) - -### How to propose a topic? - -To propose a topic: -1. Find an issue for the next available date in the list of [meeting notes issues](https://github.com/magento/architecture/issues?q=is%3Aissue+is%3Aopen+label%3A%22meeting+notes%22) - 1. Add your topic as a comment in format described in the issue -2. If there is no such issue, please create one from the "Meeting Notes" template - 1. Calculate the date based on the last meeting note. Don't worry if the date is incorrect, one of the maintainers will fix it if necessary. Just make sure that it is a future date, so it's not missed - 1. Add your topic as a comment in format described in the issue template - -During the meeting, expect that the topics will be discussed in the order they are requested. Also, the discussions may be interrupted if requested time elapses. Please, include time for the discussion in the requested duration for your topic. diff --git a/design-documents/Add-Options-to-Allowed-Methods-in-Web-Api.md b/design-documents/Add-Options-to-Allowed-Methods-in-Web-Api.md new file mode 100644 index 000000000..e2e5e9b18 --- /dev/null +++ b/design-documents/Add-Options-to-Allowed-Methods-in-Web-Api.md @@ -0,0 +1,37 @@ +# Overview + +The `Web Api` currently only allows for GET, PUT, POST, and DELETE. I would like to add OPTIONS to this list. The module I am currently working on depends on another service connecting in though the web api using the OPTIONS HTTP method. + +## Details + +Here is where the allowed methods are listed. +https://github.com/magento/magento2/blob/2.2-develop/app/code/Magento/Webapi/etc/webapi.xsd#L27 + +``` + + + + + + + + + + +``` + +I propose it be changed to this: + +``` + + + + + + + + + + + +``` diff --git a/design-documents/abstract-method-removal.md b/design-documents/abstract-method-removal.md index 64d3cc132..dfe991e09 100644 --- a/design-documents/abstract-method-removal.md +++ b/design-documents/abstract-method-removal.md @@ -25,7 +25,7 @@ There are still a few core integrations (Offline payments and PayPal), also some The desired state for 2.4 Magento release: - Core integrations do not use `AbstractMethod` (old integrations marked as deprecated). -- All related to `AbsractMethod` infrastructure code is marked as deprecated (which not marked yet) +- All related to `AbstractMethod` infrastructure code is marked as deprecated (which not marked yet) - All appropriate methods/functions trigger `E_DEPRECATED` warning (https://devdocs.magento.com/guides/v2.3/contributor-guide/backward-compatible-development/#deprecation). @@ -34,4 +34,4 @@ The desired state for 2.5 Magento release: ## Summary -The 3rd party developers will have a full minor-release cycle to rework their integrations. As each payment integration has a lot of specific implementation details, own flow and data set for payment operations, there is no unified approach to provide tools for code migration from `AbstractMethod` to Payment Provider Gateway. \ No newline at end of file +The 3rd party developers will have a full minor-release cycle to rework their integrations. As each payment integration has a lot of specific implementation details, own flow and data set for payment operations, there is no unified approach to provide tools for code migration from `AbstractMethod` to Payment Provider Gateway. diff --git a/design-documents/cache/jmeter-test/clean-cache-test.jmx b/design-documents/cache/jmeter-test/clean-cache-test.jmx new file mode 100644 index 000000000..3231c4ecf --- /dev/null +++ b/design-documents/cache/jmeter-test/clean-cache-test.jmx @@ -0,0 +1,971 @@ + + + + + + false + true + false + + + + host + ${__P(host,magento.test)} + = + + + request_protocol + ${__P(request_protocol,http)} + = + + + admin_path + ${__P(admin_path,admin)} + = + + + admin_password + ${__P(admin_password,123123q)} + = + + + admin_user + ${__P(admin_user,admin)} + = + + + base_path + ${__P(base_path,/)} + = + + + form_key + ${__P(form_key,uVEW54r8kKday8Wk)} + = + + + admin_users_distribution_per_admin_pool + ${__P(admin_users_distribution_per_admin_pool,1)} + = + + + flush_cache_timer_milliseconds + ${__P(flush_cache_timer_milliseconds,20000)} + = + + + target_concurrency + ${__P(target_concurrency,100)} + = + + + ramp_up_time_min + ${__P(ramp_up_time_min,10)} + = + + + rump_up_steps + ${__P(rump_up_steps,20)} + = + + + hold_target_time_minutes + ${__P(hold_target_time_minutes,10)} + = + + + + + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + true + + + + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + true + + + + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + true + + + + 10000 + false + + + + + false + false + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + true + + + + 500 + false + + + + + false + false + + + + + + + ${host} + + 60000 + 200000 + ${request_protocol} + utf-8 + + Java + mpaf/tool/fragments/ce/http_request_default.jmx + 4 + + + + + + Accept-Language + en-US,en;q=0.5 + + + Accept + application/json,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 + + + User-Agent + Mozilla/5.0 (Windows NT 6.1; WOW64; rv:27.0) Gecko/20100101 Firefox/27.0 + + + Accept-Encoding + gzip, deflate + + + mpaf/tool/fragments/ce/http_header_manager.jmx + + + + stoptest + + false + 1 + + 1 + 1 + 1384333221000 + 1384333221000 + false + + + + + + + + 30 + ${host} + / + false + 0 + true + true + + + ${form_key} + ${host} + ${base_path} + false + 0 + true + true + + + true + + + + +props.remove("category_url_key"); +props.remove("category_url_keys_list"); +props.remove("category_name"); +props.remove("category_names_list"); +props.remove("simple_products_list"); +props.remove("simple_products_list_for_edit"); +props.remove("configurable_products_list"); +props.remove("configurable_products_list_for_edit"); +props.remove("users"); +props.remove("customer_emails_list"); +props.remove("categories"); +props.remove("cms_pages"); +props.remove("cms_blocks"); +props.remove("coupon_codes"); + +/* This is only used when admin is enabled. */ +props.put("activeAdminThread", ""); + +/* Set the environment - at this time '01' or '02' */ +String path = "${host}"; +String environment = path.substring(4, 6); +props.put("environment", environment); + + + false + + + + Boolean stopTestOnError (String error) { + log.error(error); + System.out.println(error); + SampleResult.setStopTest(true); + return false; +} + +if ("${host}" == "1") { + return stopTestOnError("\"host\" parameter is not defined. Please define host parameter as: \"-Jhost=example.com\""); +} + +String path = "${base_path}"; +String slash = "/"; +if (!slash.equals(path.substring(path.length() -1)) || !slash.equals(path.substring(0, 1))) { + return stopTestOnError("\"base_path\" parameter is invalid. It must start and end with \"/\""); +} + + + + false + + + + + + + + + + + 60000 + 200000 + ${request_protocol} + + ${base_path}${admin_path} + GET + true + false + true + false + false + + + + + + Welcome + <title>Magento Admin</title> + + Assertion.response_data + false + 2 + + + + false + admin_form_key + <input name="form_key" type="hidden" value="([^'"]+)" /> + $1$ + + 1 + + + + + ^.+$ + + Assertion.response_data + false + 1 + variable + admin_form_key + + + + + + + + true + + = + true + dummy + + + true + ${admin_form_key} + = + true + form_key + + + true + ${admin_password} + = + true + login[password] + + + true + ${admin_user} + = + true + login[username] + + + + + + 60000 + 200000 + ${request_protocol} + + ${base_path}${admin_path}/admin/dashboard/ + POST + true + false + true + false + Java + false + + + + + + <title>Dashboard / Magento Admin</title> + + Assertion.response_data + false + 2 + + + + false + admin_form_key + <input name="form_key" type="hidden" value="([^'"]+)" /> + $1$ + + 1 + + + + + + + + + + + false + ${admin_form_key} + = + true + form_key + + + + + + 60000 + 200000 + ${request_protocol} + + ${base_path}${admin_path}/admin/user/roleGrid/limit/200/?ajax=true&isAjax=true + POST + true + false + true + false + false + + + + + + + false + import java.util.regex.Pattern; + import java.util.regex.Matcher; + import java.util.LinkedList; + + LinkedList adminUserList = new LinkedList(); + String response = new String(data); + Pattern pattern = Pattern.compile("<td\\W*?data-column=.username[^>]*?>\\W*?(\\w+)\\W*?<"); + Matcher matcher = pattern.matcher(response); + + while (matcher.find()) { + adminUserList.add(matcher.group(1)); + } + +// adminUserList.poll(); + props.put("adminUserList", adminUserList); + props.put("adminUserListIterator", adminUserList.descendingIterator()); + + + + + + + + + continue + ${target_concurrency} + ${ramp_up_time_min} + ${rump_up_steps} + ${hold_target_time_minutes} + + + M + + + + + + + ${host} + + + + ${request_protocol} + + /contact/ + GET + true + false + true + false + false + + + + + true + + + + false + {"query":"query resolveUrl($urlKey: String!) {\n urlResolver(url: $urlKey) {\n type\n id\n }\n}","variables":{"urlKey":"/"},"operationName":"resolveUrl"} + = + + + + ${host} + + 60000 + 200000 + ${request_protocol} + + /graphql + POST + true + false + true + false + false + + + + + + + Content-Type + application/json + + + Accept + */* + + + + + + + + continue + + false + -1 + + 1 + 1 + 1505803944000 + 1505803944000 + false + + + + + + true + -1 + + + + ${flush_cache_timer_milliseconds} + + + + + function getFormKeyFromResponse() + { + var url = prev.getUrlAsString(), + responseCode = prev.getResponseCode(), + formKey = null; + searchPattern = /var FORM_KEY = '(.+)'/; + if (responseCode == "200" && url) { + response = prev.getResponseDataAsString(); + formKey = response && response.match(searchPattern) ? response.match(searchPattern)[1] : null; + } + return formKey; + } + + formKey = vars.get("form_key_storage"); + + currentFormKey = getFormKeyFromResponse(); + + if (currentFormKey != null && currentFormKey != formKey) { + vars.put("form_key_storage", currentFormKey); + } + + javascript + + + + + + + + formKey = vars.get("form_key_storage"); + if (formKey + && sampler.getClass().getName() == 'org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy' + && sampler.getMethod() == "POST") + { + arguments = sampler.getArguments(); + for (i=0; i<arguments.getArgumentCount(); i++) + { + argument = arguments.getArgument(i); + if (argument.getName() == 'form_key' && argument.getValue() != formKey) { + log.info("admin form key updated: " + argument.getValue() + " => " + formKey); + argument.setValue(formKey); + } + } + } + + javascript + + + + + + + + false + + + + + + get-admin-email + + + + +adminUserList = props.get("adminUserList"); +adminUserListIterator = props.get("adminUserListIterator"); +adminUsersDistribution = Integer.parseInt(vars.get("admin_users_distribution_per_admin_pool")); + +if (adminUsersDistribution == 1) { + adminUser = adminUserList.poll(); +} else { + if (!adminUserListIterator.hasNext()) { + adminUserListIterator = adminUserList.descendingIterator(); + } + + adminUser = adminUserListIterator.next(); +} + +if (adminUser == null) { + SampleResult.setResponseMessage("adminUser list is empty"); + SampleResult.setResponseData("adminUser list is empty","UTF-8"); + IsSuccess=false; + SampleResult.setSuccessful(false); + SampleResult.setStopThread(true); +} +vars.put("admin_user", adminUser); + + + + true + + + + + + + + + + 60000 + 200000 + ${request_protocol} + + ${base_path}${admin_path}/admin/ + GET + true + false + true + false + false + + + + + + Welcome + <title>Magento Admin</title> + + Assertion.response_data + false + 2 + + + + false + admin_form_key + <input name="form_key" type="hidden" value="([^'"]+)" /> + $1$ + + 1 + + + + + ^.+$ + + Assertion.response_data + false + 1 + variable + admin_form_key + + + + + + + + true + + = + true + dummy + + + true + ${admin_form_key} + = + true + form_key + + + true + ${admin_password} + = + true + login[password] + + + true + ${admin_user} + = + true + login[username] + + + + + + 60000 + 200000 + ${request_protocol} + + ${base_path}${admin_path}/admin/dashboard/ + POST + true + false + true + false + Java + false + + + + + false + admin_form_key + <input name="form_key" type="hidden" value="([^'"]+)" /> + $1$ + + 1 + + + + + + + + + + 60000 + 200000 + ${request_protocol} + + ${base_path}${admin_path}/admin/cache/flushSystem + GET + true + false + true + false + false + + + + + + The Magento cache storage has been flushed + + Assertion.response_data + false + 2 + + + + + + + + + + + 60000 + 200000 + ${request_protocol} + + ${base_path}${admin_path}/admin/auth/logout/ + GET + true + false + true + false + false + + + + + false + + + + adminUsersDistribution = Integer.parseInt(vars.get("admin_users_distribution_per_admin_pool")); + if (adminUsersDistribution == 1) { + adminUserList = props.get("adminUserList"); + adminUserList.add(vars.get("admin_user")); + } + + + + + + + + false + + saveConfig + + + true + true + true + + true + true + true + true + false + true + true + false + false + false + true + false + false + false + true + 0 + true + true + true + true + true + true + + + + 20 + true + + + + + false + false + + + + + diff --git a/design-documents/cache/non-blocking-stale-cache.md b/design-documents/cache/non-blocking-stale-cache.md new file mode 100644 index 000000000..1d534e1e2 --- /dev/null +++ b/design-documents/cache/non-blocking-stale-cache.md @@ -0,0 +1,68 @@ +# Non blocking cache writing mechanism + +### Terms + +* Stale cache - the previous version of cache that customer will receive until a new version will be written in cache. +* Block cache - Magento cache type, typically consist of cached html output. +* Config cache - Magento cache type, usually consist of cached config data. +* Cache revalidation - process of cache invalidation and writing fresh one in cache storage. +* Lookup for lock, lookup timeout - time that takes to recheck if new version of cache already written. +* stale-while-revalidate - mechanism that send old cache while new one is on the generation phase. +* FPC - full page cache. + +### Overview + +Currently, we have two cache types(Block and Config) that uses lock mechanism to avoid parallel cache generation and excessive resource utilization. +So every time we want to generate and write a new cache, we acquire a lock and parallel process wait until lock released. +When the lock is released, customer get fresh data from storage. + +We usually think that trade-off with lock waiting is acceptable from the performance side. +But the larger amount of Blocks or Cache merchant has, the more time he will wait in locks. +In some scenarios, we could wait **numbers of keys** * **lookup timeout** amount of time in parallel process. +We noticed that in some rare cases merchant can have hundreds keys in Block/Config cache, so even small lookup timeout for lock may cost seconds. + +We have couple public issue that shows possible pointed limitations of this approach - https://github.com/magento/magento2/issues/22824 https://github.com/magento/magento2/issues/22504 + +[IvanChepurnyi](https://github.com/IvanChepurnyi) in his [PR](https://github.com/magento/magento2/pull/22829) - proposed to use non-locking way of cache generation a.k.a. *stale-while-revalidate*. +The purpose of this PR is to wrap all things up, determine all A.C., discuss approach with community and deliver solid solution. + +### Design + +This approach is well known and already used in some popular libraries, i.e. [Varnish](https://info.varnish-software.com/blog/grace-varnish-4-stale-while-revalidate-semantics-varnish). +Basically, we will send stale cache while we generating a new one. That will free us from needs to wait until any locks will be released, except the case when we have completely empty cache storage. + +To do that, we need to extend current Magento\Framework\Cache\LockGuardedCacheLoader that can revalidate the cache only in blocking way. +Also we should not rewrite business logic of Config and Block cache, but rather implement solid separate API/SPI. + +To keep efficiency, we still should use locking mechanism when we have empty cache storage to write a very first version of cache. + +#### Acceptance Criteria Fulfillment + +1. New code should be compatible with current DOD https://devdocs.magento.com/guides/v2.3/contributor-guide/contributing_dod.html + 1. Orchestration of cache manipulation(load/save logic) should be separated from business logic of specific class, i.e. Magento\Config\App\Config\Type\System. + 1. Functional Backward Compatibility. + Feature should be optional and disabled by default. Since cache freshness of blocks and config data may be critical for our merchants, we should introduce a new config variable that will enable/disable the feature. +1. Stale data should have TTL. +Since we don't want to have a constant copy of the stale cache and it is not designed to exist for a long time, I propose to have up to 10 minutes of TTL. +TTL should be added when we save stale data. +1. Efficiency - means no parallel cache generating/writing process either with stale cache or a fresh one. +Basically, only one process should generate or write cache. +1. We keep only one(last) copy of the stale cache over time. +1. If customer decide to enable stale cache functionality it should be used for all cache types that uses locking mechanism. +1. While we send stale data, Block and FPC should disabled to prevent stale data to be cached as main. + +### Prototype or Proof of Concept + +PR with working functionality for config cache - https://github.com/magento/magento2/pull/22829 +PR needs to be reworked to fulfill the acceptance criteria. + +### Data size and Performance Requirements + +1. At least the same efficiency when we don't have any cache. +1. Better efficiency with stale cache in comparison to lock waiting. + +Performance test is added - [jmeter-test](jmeter-test/clean-cache-test.jmx). +To let this work please: +1. Enable account sharing +2. Disable secret keys in url +3. Use web server url rewrite diff --git a/design-documents/database/sequence-functionality-in-magento.md b/design-documents/database/sequence-functionality-in-magento.md new file mode 100644 index 000000000..05f36073c --- /dev/null +++ b/design-documents/database/sequence-functionality-in-magento.md @@ -0,0 +1,132 @@ +## Sequence functionality in Magento + +### Terms + +**Entity UUID** - universally unique identifier, user-defined OR auto-generated by system + +### Overview + +#### Sequence mechanism +In Magento there is a sequence mechanism, which provides system-generated entity identifier. We already have this mechanism for the following entities: + +**Magento CE (SalesSequence):** +* Order +* Invoice +* Shipment +* Credit memo + +**Magento EE (Staging):** +* Product +* Category +* CMS page +* CMS block +* Catalog rule +* Sales rule +* Product bundle option +* Product bundle section + +As you can see in Magento EE we have much more entities that use sequence mechanism. +This causes bugs when CE specific functionality are not fully compatible with the EE, due to the fact that some entities use sequence mechanism in EE. +This affects the contributions of community members. + +#### Staging specific database changes +The following entities in EE even use the new primary key for main entity tables - **_row_id_**: +* Product +* Category +* CMS page +* CMS block +* Catalog rule +* Sales rule + +These changes were introduced by the Staging functionality. +The part of functionality that is responsible for updating of foreign keys is hidden in schema Recurring scripts not even in Staging modules: +* app/code/Magento/AdvancedSalesRule/Setup/Recurring.php +* app/code/Magento/Banner/Setup/Recurring.php +* app/code/Magento/CatalogEvent/Setup/Recurring.php +* app/code/Magento/CatalogPermissions/Setup/Recurring.php +* app/code/Magento/GiftRegistry/Setup/Recurring.php +* app/code/Magento/Reminder/Setup/Recurring.php +* app/code/Magento/TargetRule/Setup/Recurring.php +* app/code/Magento/VersionsCms/Setup/Recurring.php +* app/code/Magento/VisualMerchandiser/Setup/Recurring.php + +The solution is implemented declaratively using DI, but it still causes some issues with upgrade in EE. + +#### Declarative Schema issue +Due to specific install/upgrade flow, when we use Declarative Schema, most of Staging foreign keys recreates on each upgrade. + +We have 3 ways to explicitly declare new Staging sequence foreign keys: + +1. Add 10 new dependencies into 4 Staging modules +2. Introduce about 10 new modules to solve all new dependencies +3. Move database changes from Staging modules into corresponding CE modules + +## Solution + +The simplest way to do without backward-incompatible changes which can be implemented in a patch release is the option one - add 10 new dependencies into 4 Staging modules. +This may be a temporary solution of the existing issue. + +* CatalogRuleStaging/composer.json +```json +{ + "magento/module-banner": "*" +} +``` + +* CatalogStaging/composer.json +```json +{ + "magento/module-catalog-event": "*", + "magento/module-catalog-permissions": "*", + "magento/module-gift-registry": "*", + "magento/module-target-rule": "*", + "magento/module-visual-merchandiser": "*" +} +``` + +* CmsStaging/composer.json +```json +{ + "magento/module-versions-cms": "*" +} +``` + +* SalesRuleStaging/composer.json +```json +{ + "magento/module-banner": "*", + "magento/module-reminder": "*", + "magento/module-advanced-sales-rule": "*" +} +``` +Only 9 of them are unique, but now 7 of them are dependencies for Enterprise module, which is de facto mandatory for EE edition. +This means that these 7 modules cannot be disabled any way and only 2 modules will be locked to disable by Staging. + +* Enterprise/composer.json +```json +{ + "require": { + "magento/module-banner": "*", + "magento/module-catalog-event": "*", + "magento/module-catalog-permissions": "*", + "magento/module-gift-registry": "*", + "magento/module-reminder": "*", + "magento/module-target-rule": "*", + "magento/module-versions-cms": "*" + } +} +``` +## Alternative way + +Let's look at pros and cons of moving all the sequence functionality into CE: + +**PROS** +* All significant changes for the entity tables will be in CE, in the corresponding modules +* We collect all the sequence functionality in one place - CE. + We may even think of moving it into the Framework and replace it with an UUID mechanism +* This will simplify contributions of community members and increase compatibility of their modules + +**CONS** +* For some entity tables, we will have unnecessary changes in CE + + diff --git a/design-documents/graph-ql/extensibility.md b/design-documents/graph-ql/best-practice/extensibility.md similarity index 100% rename from design-documents/graph-ql/extensibility.md rename to design-documents/graph-ql/best-practice/extensibility.md diff --git a/design-documents/graph-ql/best-practice/id-fields-object-types.md b/design-documents/graph-ql/best-practice/id-fields-object-types.md new file mode 100644 index 000000000..a4097fd32 --- /dev/null +++ b/design-documents/graph-ql/best-practice/id-fields-object-types.md @@ -0,0 +1,26 @@ +# ID Fields in Object Types + +When a GraphQL Object Type is used to represent an entity that can be referenced by ID, it _should_ follow these best-practices. + +## Best Practices + +### Do + +- Use the `ID` scalar type +- Make the `ID` field non-nullable (syntax: `ID!`) +- Use the field name `uid` + - [Historical context for the field name `uid`](id-improvement-plan.md) + - Most GraphQL client libs will need to be made aware of this field name for caching + - https://www.apollographql.com/docs/react/caching/cache-configuration/#assigning-unique-identifiers + - https://formidable.com/open-source/urql/docs/graphcache/normalized-caching/#key-generation +- Include an ID field anytime an Object Type _could_ have an ID field + +### Do Not +- Include info in the client-facing description describing how the field is encoded/decoded (should be opaque) + - Example: The client shouldn't know if an `ID` is a base64-encoded integer +- Use `String` or `Int` type +- Use duplicate IDs across a concrete type. In other words, if the client wants to produce a cache key, the concatenation of a `__typename` + `uid` field should _always_ be unique + +## Examples where an ID is not helpful + +- Wrapper types, like `SearchResultPageInfo` \ No newline at end of file diff --git a/design-documents/graph-ql/lightweight-resolver.md b/design-documents/graph-ql/best-practice/lightweight-resolver.md similarity index 100% rename from design-documents/graph-ql/lightweight-resolver.md rename to design-documents/graph-ql/best-practice/lightweight-resolver.md diff --git a/design-documents/graph-ql/best-practice/nullability.md b/design-documents/graph-ql/best-practice/nullability.md new file mode 100644 index 000000000..9624db4a2 --- /dev/null +++ b/design-documents/graph-ql/best-practice/nullability.md @@ -0,0 +1,216 @@ +# Nullability in GraphQL + +By default, all field values in GraphQL are _nullable_ + +```graphql +type Query { + product(id: ID): Product # Server can also return "null" +} +``` + +GraphQL provides a wrapper/modifier that can be used to disallow null values. It's opt-in on a per-field basis + +```graphql +type Query { + product(id: ID): Product! # Server cannot return "null" +} +``` + +## Why non-nullable? + +Non-nullable fields help form a contract with clients that makes it safe for them to work with a field's value without having to inspect its shape for possible `null` values. + +For clients that are written in a language with a static type system, the compiler will force them to wrap their code in conditionals anytime they're accessing a possibly null field. These checks can become tedious if every single field is nullable. + +For clients that are written in more dynamic languages (JavaScript), developers need to be very aware of nullable fields in the schema to prevent trying to accessing data on a `null` value. This leads to lots of defensive coding (or bugs). + +Making fields non-nullable alleviates some of these issues for clients. + +## Why nullable? + +When a field's resolver has an error, GraphQL requires that the value of the field be set to `null`. But, if a field is non-nullable, a `null` value would break that contract. + +Instead, an error in a resolver for a non-nullable field propagates up to the nearest parent field. It that field is also non-nullable, the error propagates up to the next parent field. This propagation continues until either: + +- A nullable parent field is found +- We reach the root `Query` object + + A non-nullable type in the wrong place can end up causing a client to lose more data farther up the response tree, even though that data may have been resolved without errors. + +_[Further reading on the relationship between non-null types and errors](http://spec.graphql.org/draft/#sec-Errors-and-Non-Nullability)_ + +### Example: Bad use of non-nullable field + +In this example, a client wants to query for data to render a product details page. But, because the `related_products` field is non-nullable, the client will lose access to data like `name` and `price` if `related_products` throws any errors. + +Ideally, a UI would get the critical product data, render the page, and just not render the "Related Products" display. + +**Schema**: +```graphql +type Query { + product(id: ID): Product +} + +type Product { + id: ID! + name: String! + price: Money! + description: String! + url: String! + related_products: RelatedProducts! +} +``` +**Query**: +```graphql +query ProductDetailsPage($id: ID) { + product(id: $id) { + id + name + price + description + # related_products can't be null. When the backing service is + # down/unreachable at the time this query runs, we can't fulfill + # the contract + related_products { + items { + id + name + price + url + } + } + } +} +``` + +**Response** + +Note that no data was obtained by the client for `product` +```js +{ + "data": { + "product": null + }, + "errors": [ + // details about failure in related_products field populated here + ] +} +``` + +## Backwards Compatibility + +- It is a safe, non-breaking change to move a field from nullable to non-nullable +- It is an unsafe, breaking change to move a field from non-nullable to nullable + +With this in mind, when in doubt, the safer route is to make a field nullable, with the agreement that we'll iterate and improve schemas as we get feedback on usage from clients. + +**Note**: The [GraphQL reference implementation](https://github.com/graphql/graphql-js) has a `findBreakingChanges` feature that can be inspected to see a [list of all known schema changes that cause breaks for clients](https://github.com/graphql/graphql-js/blob/2232ebdef828566f3add3ed2a31709d3c1710c0e/src/utilities/findBreakingChanges.js#L41-L67). It [explicitly notes](https://github.com/graphql/graphql-js/blob/2232ebdef828566f3add3ed2a31709d3c1710c0e/src/utilities/findBreakingChanges.js#L461) that moving from a nullable value to a non-nullable value of the same type is safe. + +## Recommendations + +### Top-level Query fields should always be nullable + +Because of error propagation, if an error occurs in a non-null field on the `Query` root, the client will lose data from all other top-level queries. + +```graphql +type Query { + # If a client queries for both fields, and one of them fails, + # the client will receive no data + product(id: ID): Product! + category(id: ID): Category! +} +``` + +### Primary keys (id field) should always be non-nullable + +It very rarely makes sense to have a resource that _can_ have an ID but might not. + +IDs are extremely important for caching in most GraphQL clients, so it's worthwhile to be safe here. + +```graphql +type Product { + id: ID! # Rarely makes sense for this to be nullable +} +``` + +### Scalars should be non-nullable when required in backing data source + +Scalars form the leaves of a response in GraphQL, and are the bits of data the client really cares about. Because of this, we should strive to make scalars non-nullable when possible. + +The exceptions to this rule are: + +- A scalar field _should_ be nullable if it can be empty in the application/backing data source +- A scalar field _should_ be nullable if it's likely to come from a different service than its parent object (fairly rare) + + +### Consider the Parent Type + +If you're not dealing with an `id` field or a top-level `Query` field, the most important question to ask is: + +**Is the parent object still usable if this field has an error?** + +#### Example: Parent still usable with field error + +```graphql +type Product { + # Recommended products are not critical data on a product page, and a UI can represent + # a product safely without related products, so we keep the field nullable + recommended_products: ProductRecommendations +} +``` + +#### Example: Parent not usable with field error + +```graphql +type Product { + # A user would not be able to add a product to the cart from the Product + # details page if this field fails, because it may have required options. + # We make the field's type non-nullable + options: ProductOptions! +} +``` + +### List Types + +List fields have [2 distinct forms of nullability](http://spec.graphql.org/draft/#sec-Combining-List-and-Non-Null): + +1. Field nullability (same as all other fields) +2. List item nullability (whether a list can have `null` items inside it) + +These forms can be composed together in various ways: +```graphql +type Example { + foo1: [Foo] # foo1 can be null or a list. If list, can have nulls in it + foo2: [Foo!]! # foo2 must be a list, and every entry must be a 'Foo' + foo3: [Foo!] # foo3 can be a list or null. If list, every item must be a 'Foo' + foo4: [Foo]! # foo4 must be a list, and the list can have null values +} +``` + +To decide whether the _field_ should be nullable, use the other recommendations provided in this document. + +When deciding whether _List items_ should be nullable, the most important question to ask is: + +**"Is the parent object still usable if at least one item in this list has an error?"** + +#### Example: Parent not usable if an item in List has an error + +```graphql +type Product { + # Note: The "!" inside of the List ([]) means the list items are non-nullable + # Making the list items non-nullable guarantees to the client that, if they receive + # a list of product options, it will be complete/without errors + options: [ProductOption!]! +} +``` + +#### Example: Parent still usable if an item in List has an error + +```graphql +type Product { + # The absence of a "!" inside the list means that we could fail + # to fetch a nested field in a related product, and it won't + # impact our ability to render the rest of the product page + related_products: [RelatedProduct] +} +``` \ No newline at end of file diff --git a/design-documents/graph-ql/versioning.md b/design-documents/graph-ql/best-practice/versioning.md similarity index 100% rename from design-documents/graph-ql/versioning.md rename to design-documents/graph-ql/best-practice/versioning.md diff --git a/design-documents/graph-ql/coverage/AddProductsToCart.graphqls b/design-documents/graph-ql/coverage/AddProductsToCart.graphqls deleted file mode 100644 index 345fee93b..000000000 --- a/design-documents/graph-ql/coverage/AddProductsToCart.graphqls +++ /dev/null @@ -1,20 +0,0 @@ -type Mutation { - addProductsToCart(cart_id: String!, cart_items: [CartItemInput!]!): AddProductsToCartOutput -} - -input CartItemInput { - sku: String! # already in use - quantity: Float # already in use - parent_sku: String, # will not be used in deprecated methods - selected_options: [String!] # will not be used in deprecated methods - entered_options: [EnteredOptionInput!] # will not be used in deprecated methods -} - -input EnteredOptionInput { - id: String! - value: String! -} - -type AddProductsToCartOutput { - cart: Cart! -} diff --git a/design-documents/graph-ql/coverage/Cart.graphqls b/design-documents/graph-ql/coverage/Cart.graphqls deleted file mode 100644 index 2cf44bac2..000000000 --- a/design-documents/graph-ql/coverage/Cart.graphqls +++ /dev/null @@ -1,59 +0,0 @@ -type Query { - cart(input: CartQueryInput): CartQueryOutput -} - -input CartQueryInput { - cart_id: String! -} - -type CartQueryOutput { - cart: Cart -} - -type Cart { - id: String! - - line_items_count: Int! - items_quantity: Float! - - selected_payment_method: CheckoutPaymentMethod - available_payment_methods: [CheckoutPaymentMethod]! - - customer: CheckoutCustomer - customer_notes: String - - gift_cards_amount_used: Money - applied_gift_cards: [CartGiftCard] - - is_multishipping: Boolean! - is_virtual: Boolean! -} - -type CheckoutCustomer { - is_guest: Boolean! - email: String! - prefix: String - first_name: String! - last_name: String! - middle_name: String - suffix: String - gender: GenderEnum - date_of_birth: String - vat_number: String # Do we need it at all on storefront? Do we need more details -} - -enum GenderEnum { - MALE - FEMALE -} - -type CheckoutPaymentMethod { - code: String! - label: String! - balance: Money - sort_order: Int -} - -type CartGiftCard { - code: String! -} diff --git a/design-documents/graph-ql/coverage/Wishlist.graphqls b/design-documents/graph-ql/coverage/Wishlist.graphqls deleted file mode 100644 index 513b3db8d..000000000 --- a/design-documents/graph-ql/coverage/Wishlist.graphqls +++ /dev/null @@ -1,68 +0,0 @@ -type Query { - wishlist: WishlistOutput @deprecated(reason: "Moved under `Customer` `wishlists`") -} - -type Mutation { - createWishlist(name: String!): ID # Multiple wishlists Commerce functionality - removeWishlist(id: ID!): Boolean # Commerce fucntionality - in Opens Source we assume customer always has one wishlist - addProductsToWishlist(wishlist_id: ID!, wishlist_items: [WishlistItemInput!]!): AddProductsToWishlistOutput - removeProductsFromWishlist(wishlist_id: ID!, wishlist_items_ids: [ID!]!): RemoveProductsFromWishlistOutput - updateProductsInWishlist(wishlist_id: ID!, wishlist_items: [WishlistItemUpdateInput!]!): UpdateProductsInWishlistOutput -} - -type Customer { - wishlist: Wishlist! @doc(description: "Customer wishlist") # Commerce will extend filed with required `id` argument `wishlists(ids: ID!)` - wishlists: [Wishlist!]! @doc(description: "Customer multiple wishlists") # Multiple wishlists Commerce functionality -} - -type Wishlist { - id: ID - items: [WishlistItem] - items_count: Int - sharing_code: String - updated_at: String - #name: String should be added in Commerce edition -} - -input WishlistItemUpdateInput { - wishlist_item_id: ID - quantity: Float - selected_options: [String!] - entered_options: [EnteredOptionInput!] -} - -type AddProductsToWishlistOutput { - wishlist: Wishlist! -} - -type RemoveProductsFromWishlistOutput { - wishlist: Wishlist! -} - -type UpdateProductsInWishlistOutput { - wishlist: Wishlist! -} - -input WishlistItemInput { - sku: String - quantity: Float - parent_sku: String, - selected_options: [String!] - entered_options: [EnteredOptionInput!] -} - -type WishlistOutput @deprecated(reason: "Deprecated: `Wishlist` type should be used instead") { - items: [WishlistItem] @deprecated - items_count: Int @deprecated - name: String @deprecated - sharing_code: String @deprecated - updated_at: String @deprecated -} - -type WishlistItem { - id: ID - qty: Float - description: String - added_at: String - product: ProductInterface -} diff --git a/design-documents/graph-ql/coverage/add-items-to-cart.md b/design-documents/graph-ql/coverage/add-items-to-cart.md deleted file mode 100644 index 3f58d4981..000000000 --- a/design-documents/graph-ql/coverage/add-items-to-cart.md +++ /dev/null @@ -1,47 +0,0 @@ -**Overview** - -:warning: Current proposal is deprecated in favor of [add-items-to-cart-single-mutation.md](add-items-to-cart-single-mutation.md) - -As a Magento developer, I need to manipulate the shopping cart via GraphQL so that I can programmatically create orders on behalf of a shopper. - -GraphQL needs to provide sufficient mutations (ways to create/update/delete data) for a developer to build out the storefront checkout experience for a shopper. - -**Use cases:** -- Both guest and registered shoppers can add new items to cart -- Both guest and registered shoppers can update item qty in cart -- Both guest and registered shoppers can remove items from cart -- Both guest and registered shoppers can update the configuration (for a configurable product) or quantity of a previously added configurable product in cart - - Edit Item link > Product page > Update configuration or qty > Update Cart - -**Main decision points:** - -- Separate mutations for each product type while adding items to cart. Each operation will be supporting bulk use case -- Uniform interface for guest vs customer -- Separate mutations for each checkout step - - Create empty cart - - Add items to cart - - Set shipment method - - Set payment method - - Set addresses - - Same granularity for updates and removals -- Possibility to combine mutations for checkout steps -- Can create "order in one call" mutation in the future if needed -- Hashed IDs for cart items -- Single input object -- Async nature of the flow must be supported on the client side (via AJAX calls) -- Server-side asynchronous mutations can be supported in the future on framework level in a way similar to Async REST. - -**Proposed schema for adding items to cart:** - -- [AddSimpleProductToCart](add-items-to-cart/AddSimpleProductToCart.graphqls) -- [AddBundleProductToCart](add-items-to-cart/AddBundleProductToCart.graphqls) -- [AddConfigurableProductToCart](add-items-to-cart/AddConfigurableProductToCart.graphqls) -- [AddDownloadableProductToCart](add-items-to-cart/AddDownloadableProductToCart.graphqls) -- [AddGiftCardProductToCart](add-items-to-cart/AddGiftCardProductToCart.graphqls) -- [AddGroupedProductToCart](add-items-to-cart/AddGroupedProductToCart.graphqls) -- [AddVirtualProductToCart](add-items-to-cart/AddVirtualProductToCart.graphqls) - - -**My Account area impacted:** -- Cart -- Minicart diff --git a/design-documents/graph-ql/coverage/add-items-to-cart/AddBundleProductToCart.graphqls b/design-documents/graph-ql/coverage/add-items-to-cart/AddBundleProductToCart.graphqls deleted file mode 100644 index e92f12223..000000000 --- a/design-documents/graph-ql/coverage/add-items-to-cart/AddBundleProductToCart.graphqls +++ /dev/null @@ -1,59 +0,0 @@ -type Mutation { - addBundleProductsToCart(input: AddBundleProductsToCartInput): AddBundleProductsToCartOutput - updateBundleProductsInCart(input: UpdateBundleProductsInCartInput): UpdateBundleProductsInCartOutput -} - -input UpdateBundleProductsInCartInput { - cart_id: String! - cartItems: [UpdateBundleProductCartItemInput!]! -} - -input UpdateBundleProductCartItemInput { - details: UpdateCartItemDetailsInput! - bundle_options:[BundleOptionInput!] - customizable_options:[CustomizableOptionInput] -} - -input AddBundleProductsToCartInput { - cart_id: String! - cartItems: [BundleProductCartItemInput!]! -} - -input BundleProductCartItemInput { - details: CartItemDetailsInput! - bundle_options:[BundleOptionInput!]! - customizable_options:[CustomizableOptionInput!] -} - -input BundleOptionInput { - id: Int! - quantity: Float! - value: [String!]! -} - -type AddBundleProductsToCartOutput { - cart: Cart! -} - -type BundleCartItem implements CartItemInterface { - customizable_options: [SelectedCustomizableOption]! - bundle_options: [SelectedBundleOption!]! -} - -type SelectedBundleOption { - id: Int! - label: String! - type: String! - # No quantity here even though it is set on option level in the input - values: [SelectedBundleOptionValue!]! - sort_order: Int! -} - -type SelectedBundleOptionValue { - id: Int! - label: String! - quantity: Float! # Quantity is displayed on option value level, while is set on option level - price: CartItemSelectedOptionValuePrice! - sort_order: Int! -} - diff --git a/design-documents/graph-ql/coverage/add-items-to-cart/AddConfigurableProductToCart.graphqls b/design-documents/graph-ql/coverage/add-items-to-cart/AddConfigurableProductToCart.graphqls deleted file mode 100644 index 7dc069af0..000000000 --- a/design-documents/graph-ql/coverage/add-items-to-cart/AddConfigurableProductToCart.graphqls +++ /dev/null @@ -1,43 +0,0 @@ -type Mutation { - addConfigurableProductsToCart(input: AddConfigurableProductsToCartInput): AddConfigurableProductsToCartOutput - updateConfigurableProductsInCart(input: UpdateConfigurableProductsInCartInput): UpdateConfigurableProductsInCartOutput -} - -input UpdateConfigurableProductsInCartInput { - cart_id: String! - cartItems: [UpdateConfigurableProductCartItemInput!]! -} - -input UpdateConfigurableProductCartItemInput { - details: UpdateCartItemDetailsInput! - variant_sku: String - customizable_options:[CustomizableOptionInput] -} - -input AddConfigurableProductsToCartInput { - cart_id: String! - cartItems: [ConfigurableProductCartItemInput!]! -} - -input ConfigurableProductCartItemInput { - details: CartItemDetailsInput! - variant_sku: String! - customizable_options:[CustomizableOptionInput!] -} - -type AddConfigurableProductsToCartOutput { - cart: Cart! -} - -type ConfigurableCartItem implements CartItemInterface { - customizable_options: [SelectedCustomizableOption]! - configurable_options: [SelectedConfigurableOption!]! -} - -type SelectedConfigurableOption { - id: Int! - option_label: String! - value_id: Int! - value_label: String! -} - diff --git a/design-documents/graph-ql/coverage/add-items-to-cart/AddDownloadableProductToCart.graphqls b/design-documents/graph-ql/coverage/add-items-to-cart/AddDownloadableProductToCart.graphqls deleted file mode 100644 index 5ac44c289..000000000 --- a/design-documents/graph-ql/coverage/add-items-to-cart/AddDownloadableProductToCart.graphqls +++ /dev/null @@ -1,45 +0,0 @@ -type Mutation { - addDownloadableProductsToCart(input: AddDownloadableProductsToCartInput): AddDownloadableProductsToCartOutput - updateDownloadableProductsInCart(input: UpdateDownloadableProductsInCartInput): UpdateDownloadableProductsInCartOutput -} - -input UpdateDownloadableProductsInCartInput { - cart_id: String! - cartItems: [UpdateDownloadableProductCartItemInput!]! -} - -input UpdateDownloadableProductCartItemInput { - details: UpdateCartItemDetailsInput! - downloadable_links: [DownloadableLinksInput] - customizable_options:[CustomizableOptionInput] -} - -input AddDownloadableProductsToCartInput { - cart_id: String! - cartItems: [DownloadableProductCartItemInput!]! -} - -input DownloadableProductCartItemInput { - details: CartItemDetailsInput! - downloadable_links: [DownloadableLinksInput!] - customizable_options:[CustomizableOptionInput!] -} - -input DownloadableLinksInput { - id: [Int!]! -} - -type AddDownloadableProductsToCartOutput { - cart: Cart! -} - -type DownloadableCartItem implements CartItemInterface { - links_label: String! - links: [DownloadableCartItemLink!]! - configurable_options: [SelectedConfigurableOption!]! -} - -type DownloadableCartItemLink { - id: Int! - label: String! -} diff --git a/design-documents/graph-ql/coverage/add-items-to-cart/AddGiftCardProductToCart.graphqls b/design-documents/graph-ql/coverage/add-items-to-cart/AddGiftCardProductToCart.graphqls deleted file mode 100644 index 92ae1ddb6..000000000 --- a/design-documents/graph-ql/coverage/add-items-to-cart/AddGiftCardProductToCart.graphqls +++ /dev/null @@ -1,49 +0,0 @@ -type Mutation { - addGiftCardProductsToCart(input: AddGiftCardProductsToCartInput): AddGiftCardProductsToCartOutput - updateGiftCardProductsInCart(input: UpdateGiftCardProductsInCartInput): UpdateGiftCardProductsInCartOutput -} - -input UpdateGiftCardProductsInCartInput { - cart_id: String! - cartItems: [UpdateGiftCardProductCartItemInput!]! -} - -input UpdateGiftCardProductCartItemInput { - details: UpdateCartItemDetailsInput! - sender_name: String - recepient_name: String - amount: MoneyInput # Do we need complex type here or just Float? - message: String - customizable_options:[CustomizableOptionInput] -} - -input AddGiftCardProductsToCartInput { - cart_id: String! - cartItems: [GiftCardProductCartItemInput!]! -} - -input GiftCardProductCartItemInput { - details: CartItemDetailsInput! - sender_name: String! - recepient_name: String! - amount: MoneyInput! # Do we need complex type here or just Float? - message: String - customizable_options:[CustomizableOptionInput!] -} - -input MoneyInput { - value: Float @doc(description: "A number expressing a monetary value") - currency: CurrencyEnum @doc(description: "A three-letter currency code, such as USD or EUR") -} - -type AddGiftCardProductsToCartOutput { - cart: Cart! -} - -type GiftCardCartItem implements CartItemInterface { - sender_name: String! - recepient_name: String! - amount: Money! - message: String - customizable_options: [SelectedCustomizableOption]! -} diff --git a/design-documents/graph-ql/coverage/add-items-to-cart/AddGroupedProductToCart.graphqls b/design-documents/graph-ql/coverage/add-items-to-cart/AddGroupedProductToCart.graphqls deleted file mode 100644 index 38f97184f..000000000 --- a/design-documents/graph-ql/coverage/add-items-to-cart/AddGroupedProductToCart.graphqls +++ /dev/null @@ -1,27 +0,0 @@ -type Mutation { - addGroupedProductsToCart(input: AddGroupedProductsToCartInput): AddGroupedProductsToCartOutput - updateGroupedProductsInCart(input: UpdateGroupedProductsInCartInput): UpdateGroupedProductsInCartOutput -} - -input UpdateGroupedProductsInCartInput { - cart_id: String! - cartItems: [UpdateGroupedProductCartItemInput!]! -} - -input UpdateGroupedProductCartItemInput { - details: UpdateCartItemDetailsInput! -} - -input AddGroupedProductsToCartInput { - cart_id: String! - cartItems: [GroupedProductCartItemInput!]! -} - -input GroupedProductCartItemInput { - details: CartItemDetailsInput! - # the difference from simple products is that grouped products do not support customizable options -} - -type AddGroupedProductsToCartOutput { - cart: Cart! -} diff --git a/design-documents/graph-ql/coverage/add-items-to-cart/AddSimpleProductToCart.graphqls b/design-documents/graph-ql/coverage/add-items-to-cart/AddSimpleProductToCart.graphqls deleted file mode 100644 index 8a3d6963f..000000000 --- a/design-documents/graph-ql/coverage/add-items-to-cart/AddSimpleProductToCart.graphqls +++ /dev/null @@ -1,78 +0,0 @@ -type Mutation { - addSimpleProductsToCart(input: AddSimpleProductsToCartInput): AddSimpleProductsToCartOutput - updateSimpleProductsInCart(input: UpdateSimpleProductsInCartInput): UpdateSimpleProductsInCartOutput -} - -input UpdateSimpleProductsInCartInput { - cart_id: String! - cartItems: [UpdateSimpleProductCartItemInput!]! -} - -input UpdateSimpleProductCartItemInput { - details: UpdateCartItemDetailsInput! - customizable_options:[CustomizableOptionInput] -} - -input AddSimpleProductsToCartInput { - cart_id: String! - cartItems: [SimpleProductCartItemInput!]! -} - -input SimpleProductCartItemInput { - details: CartItemDetailsInput! - customizable_options:[CustomizableOptionInput!] -} - -input CartItemDetailsInput { - sku: String! - quantity: Float! -} - -input UpdateCartItemDetailsInput { - cart_item_id: String! - quantity: Float -} - -input CustomizableOptionInput { - id: Int! - value: String! -} - -type AddSimpleProductsToCartOutput { - cart: Cart! -} - -type Cart { - items: [CartItemInterface] -} - -interface CartItemInterface @typeResolver(class: "Magento\\CatalogCheckoutGraphQl\\Model\\CartItemInterfaceTypeResolverComposite") { - id: String! # Hashed cart item ID - qty: Float! - product: ProductInterface! -} - -type SimpleCartItem implements CartItemInterface { - customizable_options: [SelectedCustomizableOption] -} - -type SelectedCustomizableOption { - id: Int! - label: String! - type: String! - values: [SelectedCustomizableOptionValue!]! - sort_order: Int! -} - -type SelectedCustomizableOptionValue { - id: Int - label: String! - price: CartItemSelectedOptionValuePrice! - sort_order: Int! -} - -type CartItemSelectedOptionValuePrice { - value: Float! - units: String! - type: PriceTypeEnum! -} diff --git a/design-documents/graph-ql/coverage/add-items-to-cart/AddVirtualProductToCart.graphqls b/design-documents/graph-ql/coverage/add-items-to-cart/AddVirtualProductToCart.graphqls deleted file mode 100644 index 76013ada1..000000000 --- a/design-documents/graph-ql/coverage/add-items-to-cart/AddVirtualProductToCart.graphqls +++ /dev/null @@ -1,34 +0,0 @@ -type Mutation { - # for now this mutation is identical to addSimpleProductsToCart and exists as a syntax sugar. Also it allows product type based customizations - addVirtualProductsToCart(input: AddVirtualProductsToCartInput): AddVirtualProductsToCartOutput - updateVirtualProductsInCart(input: UpdateVirtualProductsInCartInput): UpdateVirtualProductsInCartOutput -} - -input UpdateVirtualProductsInCartInput { - cart_id: String! - cartItems: [UpdateVirtualProductCartItemInput!]! -} - -input UpdateVirtualProductCartItemInput { - details: UpdateCartItemDetailsInput! - customizable_options:[CustomizableOptionInput] -} - -input AddVirtualProductsToCartInput { - cart_id: String! - cartItems: [VirtualProductCartItemInput!]! -} - -input VirtualProductCartItemInput { - details: CartItemDetailsInput! - customizable_options:[CustomizableOptionInput!] -} - -type AddVirtualProductsToCartOutput { - cart: Cart! -} - -# Custom cart item type can be used to customize rendering when there are no physical producs available, e.g. skip shipping -type VirtualCartItem implements CartItemInterface { - customizable_options: [SelectedCustomizableOption] -} diff --git a/design-documents/graph-ql/coverage/add-items-to-cart/RemoveItemFromCart.graphqls b/design-documents/graph-ql/coverage/add-items-to-cart/RemoveItemFromCart.graphqls deleted file mode 100644 index f1039fc86..000000000 --- a/design-documents/graph-ql/coverage/add-items-to-cart/RemoveItemFromCart.graphqls +++ /dev/null @@ -1,12 +0,0 @@ -type Mutation { - removeItemFromCart(input: RemoveItemFromCartInput): RemoveItemFromCartOutput -} - -input RemoveItemFromCartInput { - cart_id: String! - cart_item_id: String! -} - -type RemoveItemFromCartOutput { - cart: Cart! -} diff --git a/design-documents/graph-ql/coverage/company-credit.md b/design-documents/graph-ql/coverage/b2b/company-credit.md similarity index 75% rename from design-documents/graph-ql/coverage/company-credit.md rename to design-documents/graph-ql/coverage/b2b/company-credit.md index c94094aff..b0b1000f4 100644 --- a/design-documents/graph-ql/coverage/company-credit.md +++ b/design-documents/graph-ql/coverage/b2b/company-credit.md @@ -13,16 +13,17 @@ type Company { ###### Begin: Defining new types ###### type CompanyCreditHistory { items: [CompanyCreditOperation]! @doc(description: "An array of company credit operations") - page_info: SearchResultPageInfo! @doc(description: "Metadata for pagination rendering") - total_count: Int @doc(description: "The number of the company credit operations matching the specified filter") + page_info: SearchResultPageInfo @doc(description: "Metadata for pagination rendering") + total_count: Int! @doc(description: "The number of the company credit operations matching the specified filter") } type CompanyCreditOperation { + uid: ID! @doc(description: "Unique identifier") # id of the log entry date: String! @doc(description: "The date of the company credit operation") type: CompanyCreditOperationType! @doc(description: "The type of the company credit operation") - amount: Money @doc(description: "The amount fo the company credit operation") + amount: Money! @doc(description: "The amount fo the company credit operation") balance: CompanyCredit! @doc(description: "Credit balance after the company credit operation") - purchase_order: String @doc(description: "Purchase order number associated with the company credit operation") + custom_reference_number: String @doc(description: "Custom reference number associated with the company credit operation") updated_by: CompanyCreditOperationUser! @doc(description: "The user submitting the company credit operation") } @@ -53,7 +54,7 @@ enum CompanyCreditOperationUserType { input CompanyCreditHistoryFilterInput { operation_type: CompanyCreditOperationType @doc(description: "Enum filter by the type of the company credit operation") - purchase_order: String @doc(description: "Free text filter by the purchase order number associated with the company credit operation") + custom_reference_number: String @doc(description: "Free text filter by the custom reference number associated with the company credit operation") updated_by: String @doc(description: "Free text filter by the name of the person submitting the company credit operation") } ###### End: Defining new types ###### @@ -61,4 +62,3 @@ input CompanyCreditHistoryFilterInput { ``` - diff --git a/design-documents/graph-ql/coverage/company.md b/design-documents/graph-ql/coverage/b2b/company.md similarity index 61% rename from design-documents/graph-ql/coverage/company.md rename to design-documents/graph-ql/coverage/b2b/company.md index b349a8fd9..8d411c184 100644 --- a/design-documents/graph-ql/coverage/company.md +++ b/design-documents/graph-ql/coverage/b2b/company.md @@ -2,26 +2,32 @@ `Sales representative` is an admin user(merchant side) who is assigned to work with the company. Thus we can't use exsting `Customer` type for sales representative +# Behavioral Notes +When a feature is disabled (e.g. Company) queries/mutations related to that feature should return a null response, along with an error indicating that the feature is not available. + +Update/Create mutations should return the entity they acted upon. +Delete mutations return a field indicating success or failure. In the case of failure, an error should be returned indicating the error that occurred (suitable for storefront). + # Queries ```graphql type Query { - company: Company @doc(description: "Returns all information about the current Company.") - checkCompanyEmail(email: String!): CompanyEmailCheckResponse @doc(description: "Returns result of validation whether provided email address is valid for a new Company registration or not.") - checkCompanyAdminEmail(email: String!): CompanyAdminEmailCheckResponse @doc(description: "Returns result of validation whether provided email address is valid for a Company Administrator registration or not.") - checkCompanyUserEmail(email: String!): CompanyUserEmailCheckResponse @doc(description: "Returns an object with result of validation whether provided email address is valid for a new Customer - Company User - registration or not.") - checkCompanyRoleName(name: String!): CompanyRoleNameCheckResponse @doc(description: "Returns result of validation whether provided Role name is available.") + company: Company @doc(description: "Company assigned to the currently authenticated user") + isCompanyEmailAvailable(email: String!): IsCompanyEmailAvailableOutput @doc(description: "Check if an email is valid for company registration") + isCompanyAdminEmailAvailable(email: String!): IsCompanyAdminEmailAvailableOutput @doc(description: "Check if an email is valid for company admin registration") + isCompanyUserEmailAvailable(email: String!): IsCompanyUserEmailAvailableOutput @doc(description: "Check if an email is valid for company user registration") + isCompanyRoleNameAvailable(name: String!): IsCompanyRoleNameAvailableOutput @doc(description: "Check if a role name is valid for company") } type Company @doc(description: "Company entity output data schema.") { - id: ID! @doc(description: "Company id.") - name: String! @doc(description: "Company name.") - email: String! @doc(description: "Company email address.") + uid: ID! @doc(description: "Company id.") + name: String @doc(description: "Company name.") + email: String @doc(description: "Company email address.") legal_name: String @doc(description: "Company legal name.") - vat_id: String @doc(description: "Company VAT/TAX id.") + vat_tax_id: String @doc(description: "Company VAT/TAX id.") reseller_id: String @doc(description: "Company re-seller id.") - legal_address: CompanyLegalAddress! @doc(description: "Company legal address.") - company_admin: Customer! @doc(description: "An object containing information about Company Administrator.") + legal_address: CompanyLegalAddress @doc(description: "Company legal address.") + company_admin: Customer @doc(description: "An object containing information about Company Administrator.") sales_representative: CompanySalesRepresentative @doc(description: "Company sales representative.") payment_methods: [String] @doc(description: "List of payment methods available for a Company.") users( @@ -29,101 +35,101 @@ type Company @doc(description: "Company entity output data schema.") { pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. Defaults to 20."), currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1."), ): CompanyUsers @doc(description: "Information about the company users.") - user(id: ID): Customer @doc(description: "Returns company user for current authenticated Customer or, if id provided, for specific one.") + user(uid: ID!): Customer @doc(description: "Returns company user by id.") roles( pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. Optional. Defaults to 20."), currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1."), ): CompanyRoles! @doc(description: "Returns the list of defined roles at Company.") - role(id: ID!): CompanyRole @doc(description: "Returns company role by id.") - acl_resources: [CompanyAclResource]! @doc(description: "Returns the list of all permission resources.") - hierarchy: CompanyHierarchyOutput! @doc(description: "Returns the complete data about company structure.") - team(id: ID!): CompanyTeam @doc(description: "Returns company team data by id.") + role(uid: ID!): CompanyRole @doc(description: "Returns company role by id.") + acl_resources: [CompanyAclResource] @doc(description: "Returns the list of all permission resources.") + structure( + root_id: ID = 0 @doc(description: "Tree depth to begin query") + depth: Int = 10 @doc(description: "Specifies how deeply results are fetched") + ): CompanyStructure @doc(description: "Company structure of teams and customers in depth-first order") + team(uid: ID!): CompanyTeam @doc(description: "Returns company team data by id.") } type CompanyLegalAddress @doc(description: "Company legal address output data schema.") { - street: [String]! @doc(description: "An array of strings that defines the Company's street address.") - city: String! @doc(description: "City name.") - region: CustomerAddressRegion! @doc(description: "An object containing region data for the Company.") - country_code: CountryCodeEnum! @doc(description: "Country code.") - postcode: String! @doc(description: "ZIP/postal code.") - telephone: String! @doc(description: "Company's phone number.") + street: [String] @doc(description: "An array of strings that defines the Company's street address.") + city: String @doc(description: "City name.") + region: CustomerAddressRegion @doc(description: "An object containing region data for the Company.") + country_code: CountryCodeEnum @doc(description: "Country code.") + postcode: String @doc(description: "ZIP/postal code.") + telephone: String @doc(description: "Company's phone number.") } type CompanyAdmin @doc(description: "Company Administrator (Customer with corresponding privileges) output data schema.") { - id: ID! @doc(description: "Company Administrator's id.") - email: String! @doc(description: "Company Administrator email address.") - firstname: String! @doc(description: "Company Administrator first name.") - lastname: String! @doc(description: "Company Administrator last name.") + uid: ID! @doc(description: "Company Administrator's id.") + email: String @doc(description: "Company Administrator email address.") + firstname: String @doc(description: "Company Administrator first name.") + lastname: String @doc(description: "Company Administrator last name.") job_title: String @doc(description: "Company Administrator job title.") gender: Int @doc(description: "Company Administrator gender.") } type CompanySalesRepresentative @doc(description: "Company sales representative information output data schema.") { - email: String! @doc(description: "Sales representative email address.") - firstname: String! @doc(description: "Sales representative first name.") - lastname: String! @doc(description: "Sales representative last name.") + email: String @doc(description: "Sales representative email address.") + firstname: String @doc(description: "Sales representative first name.") + lastname: String @doc(description: "Sales representative last name.") } type CompanyUsers @doc(description: "Output data schema for an object returned by a Company users search query.") { - items: [Customer] @doc(description: "An array of 'CompanyUser' objects that match the specified search criteria.") - total_count: Int @doc(description: "The number of objects returned.") + items: [Customer]! @doc(description: "An array of 'CompanyUser' objects that match the specified search criteria.") + total_count: Int! @doc(description: "The number of objects returned.") page_info: SearchResultPageInfo @doc(description: "Pagination meta data.") } type CompanyRoles @doc(description: "Output data schema for an object returned by a Company roles search query.") { - items: [CompanyRole] @doc(description: "A list of company roles that match the specified search criteria.") - total_count: Int @doc(description: "The total number of objects matching the specified filter.") + items: [CompanyRole]! @doc(description: "A list of company roles that match the specified search criteria.") + total_count: Int! @doc(description: "The total number of objects matching the specified filter.") page_info: SearchResultPageInfo @doc(description: "Pagination meta data.") } type CompanyRole @doc(description: "Company role output data schema returned in response to a query by Role id.") { - id: ID! @doc(description: "Role id.") - name: String! @doc(description: "Role name.") - users_count: Int @doc(description: "Total number of Users with such Role within Company Hierarchy.") - permissions: [String] @doc(description: "A list of permission resources defined for a Role.") + uid: ID! @doc(description: "Role id.") + name: String @doc(description: "Role name.") + users_count: Int @doc(description: "Total number of Users with such Role within Company Structure.") + permissions: [CompanyAclResource] @doc(description: "A list of permission resources defined for a Role.") } type CompanyAclResource @doc(description: "Output data schema for an object with Role permission resource information.") { - id: ID! @doc(description: "ACL resource id.") - text: String! @doc(description: "ACL resource label.") - sortOrder: Int! @doc(description: "ACL resource sort order.") + uid: ID! @doc(description: "ACL resource id.") + text: String @doc(description: "ACL resource label.") + sort_order: Int @doc(description: "ACL resource sort order.") children: [CompanyAclResource!] @doc(description: "An array of sub-resources.") } -type CompanyRoleNameCheckResponse @doc(description: "Response object schema for a role name validation query.") { - isNameValid: Boolean! @doc(description: "Role name validation result") +type IsCompanyRoleNameAvailableOutput @doc(description: "Response object schema for a role name validation query.") { + is_role_name_available: Boolean! @doc(description: "Role name validation result") } -type CompanyUserEmailCheckResponse @doc(description: "Response object schema for a Company User email validation query.") { - isEmailValid: Boolean! @doc(description: "Email validation result") +type IsCompanyUserEmailAvailableOutput @doc(description: "Response object schema for a Company User email validation query.") { + is_email_available: Boolean! @doc(description: "Email validation result") } -type CompanyAdminEmailCheckResponse @doc(description: "Response object schema for a Company Admin email validation query.") { - isEmailValid: Boolean! @doc(description: "Email validation result") +type IsCompanyAdminEmailAvailableOutput @doc(description: "Response object schema for a Company Admin email validation query.") { + is_email_available: Boolean! @doc(description: "Email validation result") } -type CompanyEmailCheckResponse @doc(description: "Response object schema for a Company email validation query.") { - isEmailValid: Boolean! @doc(description: "Email validation result") +type IsCompanyEmailAvailableOutput @doc(description: "Response object schema for a Company email validation query.") { + is_email_available: Boolean! @doc(description: "Email validation result") } -type CompanyHierarchyOutput @doc(description: "Response object schema for a Company Hierarchy query.") { - structure: CompanyHierarchyElement @doc(description: "An array of Company structure elements.") - isEditable: Boolean @doc(description: "Flag that defines whether Company Hierarchy can be changed by current User or not.") - max_nesting: Int @doc(description: "Indicator of maximun nesting of elements within a whole Company Hierarchy.") +union CompanyStructureEntity = CompanyTeam | Customer + +type CompanyStructureItem @doc(description: "Company Team and Customer structure") { + uid: ID! @doc(description: "ID of the item in the hierarchy") + parent_id: ID @doc(description: "ID of the parent item in the hierarchy") + entity: CompanyStructureEntity } -type CompanyHierarchyElement @doc(description: "Company Hierarchy element output data schema.") { - id: ID! @doc(description: "Hierarchy element id.") - tree_id: ID! @doc(description: "The hierarchical id of the element within a structure. Used for changing element's position in hierarchy.") - type: String! @doc(description: "Hierarchy element type: 'customer' or a 'team'.") - text: String! @doc(description: "Hierarchy element name.") - description: String @doc(description: "Hierarchy element description.") - children: [CompanyHierarchyElement!] @doc(description: "An array of child elements.") +type CompanyStructure { + items: [CompanyStructureItem] } type CompanyTeam @doc(description: "Company Team entity output data schema.") { - id: ID! @doc(description: "Team id.") - name: String! @doc(description: "Team name.") + uid: ID! @doc(description: "Team id.") + name: String @doc(description: "Team name.") description: String @doc(description: "Team description.") } @@ -145,14 +151,14 @@ type Mutation { updateCompany(input: CompanyUpdateInput!): UpdateCompanyOutput @doc(description:"Update Company information.") createCompanyUser(input: CompanyUserCreateInput!): CreateCompanyUserOutput @doc(description:"Create new Company User (Customer assigned to Company).") updateCompanyUser(input: CompanyUserUpdateInput!): UpdateCompanyUserOutput @doc(description:"Update Company User information.") - deleteCompanyUser(id: ID!): DeleteCompanyUserOutput @doc(description:"Delete Company User by ID.") + deleteCompanyUser(uid: ID!): DeleteCompanyUserOutput @doc(description:"Delete Company User by ID.") createCompanyRole(input: CompanyRoleCreateInput!): CreateCompanyRoleOutput @doc(description:"Create new Company role.") updateCompanyRole(input: CompanyRoleUpdateInput!): UpdateCompanyRoleOutput @doc(description:"Update Company role data.") - deleteCompanyRole(id: ID!): DeleteCompanyRoleOutput @doc(description:"Delete Company Role by ID.") - updateCompanyHierarchy(input: CompanyHierarchyUpdateInput!): UpdateCompanyHierarchyOutput @doc(description:"Update Company Hierarchy element's parent node assignment.") + deleteCompanyRole(uid: ID!): DeleteCompanyRoleOutput @doc(description:"Delete Company Role by ID.") + updateCompanyStructure(input: CompanyStructureUpdateInput!): UpdateCompanyStructureOutput @doc(description:"Update Company Structure element's parent node assignment.") createCompanyTeam(input: CompanyTeamCreateInput!): CreateCompanyTeamOutput @doc(description:"Create Company Team.") updateCompanyTeam(input: CompanyTeamUpdateInput!): UpdateCompanyTeamOutput @doc(description:"Update Company Team data.") - deleteCompanyTeam(id: ID!): DeleteCompanyTeamOutput @doc(description:"Delete Company Team entity by ID.") + deleteCompanyTeam(uid: ID!): DeleteCompanyTeamOutput @doc(description:"Delete Company Team entity by ID.") } type CreateCompanyTeamOutput @doc(description: "Create company team output data schema.") { @@ -164,7 +170,7 @@ type UpdateCompanyTeamOutput @doc(description: "Update company team output data } type DeleteCompanyTeamOutput @doc(description: "Delete company team output data schema.") { - status: Boolean! @doc(description: "Status of delete operation: true - success; false - fail.") + success: Boolean! @doc(description: "Indicates whether or not the delete operation succeeded.") } type CreateCompanyOutput @doc(description: "Create company output data schema.") { @@ -184,26 +190,25 @@ type UpdateCompanyUserOutput @doc(description: "Update company user output data } type DeleteCompanyUserOutput @doc(description: "Delete company user output data schema.") { - status: Boolean! @doc(description: "Status of delete operation: true - success; false - fail.") + success: Boolean! @doc(description: "Indicates whether or not the delete operation succeeded.") } type CreateCompanyRoleOutput @doc(description: "Create company role output data schema.") { - user: CompanyRole! @doc(description: "New company role instance.") + role: CompanyRole! @doc(description: "New company role instance.") } type UpdateCompanyRoleOutput @doc(description: "Update company role output data schema.") { - user: CompanyRole! @doc(description: "Updated company role instance.") + role: CompanyRole! @doc(description: "Updated company role instance.") } type DeleteCompanyRoleOutput @doc(description: "Delete company role output data schema.") { - status: Boolean! @doc(description: "Status of delete operation: true - success; false - fail.") + success: Boolean! @doc(description: "Indicates whether or not the delete operation succeeded.") } -type UpdateCompanyHierarchyOutput @doc(description: "Update company hierarchy output data schema.") { - status: Boolean! @doc(description: "Status of update operation: true - success; false - fail.") +type UpdateCompanyStructureOutput @doc(description: "Update company structure output data schema.") { + company: Company! @doc(description: "Updated company instance.") } - input CompanyCreateInput @doc(description: "Defines the Company input data schema for creating a new entity."){ company_name: String! @doc(description: "Company name. Required.") company_email: String! @doc(description: "Company email address. Required.") @@ -257,11 +262,11 @@ input CompanyUserCreateInput @doc(description: "Defines the input data schema fo email: String! @doc(description: "Company user's email address. Required.") telephone: String! @doc(description: "Company user's phone number. Required.") status: Int! @doc(description: "Company user's status ID. Required.") - target_id: ID @doc(description: "A target structure element ID within a Company's Hierarchy for a user to be assigned to.") + target_id: ID @doc(description: "A target structure element ID within a Company's Structure for a user to be assigned to.") } input CompanyUserUpdateInput @doc(description: "Defines the input data schema for updating an existing Customer - Company user.") { - id: ID! @doc(description: "Company user's ID (Customer ID). Required.") + uid: ID! @doc(description: "Company user's ID (Customer ID). Required.") role_id: ID @doc(description: "Company user's role ID.") status: Int @doc(description: "Company user's status ID.") job_title: String @doc(description: "Company user's job title.") @@ -277,24 +282,24 @@ input CompanyRoleCreateInput @doc(description: "Defines the input data schema fo } input CompanyRoleUpdateInput @doc(description: "Defines the input data schema for updating an existing Company role.") { - id: ID! @doc(description: "Role ID. Required.") + uid: ID! @doc(description: "Role ID. Required.") name: String @doc(description: "Role name.") permissions: [String!] @doc(description: "A list of Role permission resources. Array value for a field, if provided, should consist only of string values.") } -input CompanyHierarchyUpdateInput @doc(description: "Defines the input data schema for updating the Company Hierarchy.") { - tree_id: ID! @doc(description: "Company Hierarchy element's hierarchical ID that is being moved to another parent. Required.") - parent_tree_id: ID! @doc(description: "A target parent element tree ID within a Company's Hierarchy. Required.") +input CompanyStructureUpdateInput @doc(description: "Defines the input data schema for updating the Company Structure.") { + tree_id: ID! @doc(description: "Company Structure element's hierarchical ID that is being moved to another parent. Required.") + parent_tree_id: ID! @doc(description: "A target parent element tree ID within a Company's Structure. Required.") } input CompanyTeamCreateInput @doc(description: "Defines the input data schema for creating a new Company team.") { name: String! @doc(description: "Team name. Required.") description: String @doc(description: "Team description.") - target_id: ID @doc(description: "A target structure element ID within a Company's Hierarchy for a team to be assigned to.") + target_id: ID @doc(description: "A target structure element ID within a Company's Structure for a team to be assigned to.") } input CompanyTeamUpdateInput @doc(description: "Defines the input data schema for updating an existing Company team.") { - id: ID! @doc(description: "Team ID. Required.") + uid: ID! @doc(description: "Team ID. Required.") name: String @doc(description: "Team name.") description: String @doc(description: "Team description.") } @@ -304,10 +309,10 @@ input CompanyTeamUpdateInput @doc(description: "Defines the input data schema fo ```graphql type Customer { - job_title: String! @doc(description: "Company User job title.") - role: CompanyRole! @doc(description: "Company User role data (includes permissions).") - team: CompanyTeam! @doc(description: "Company User team data.") - telephone: String! @doc(description: "Company User phone number.") - status: CompanyUserStatusEnum! @doc(description: "Company User status.") + job_title: String @doc(description: "Company User job title.") + role: CompanyRole @doc(description: "Company User role data (includes permissions).") + team: CompanyTeam @doc(description: "Company User team data.") + telephone: String @doc(description: "Company User phone number.") + status: CompanyUserStatusEnum @doc(description: "Company User status.") } -``` \ No newline at end of file +``` diff --git a/design-documents/graph-ql/coverage/b2b/negotiableQuotes.graphqls b/design-documents/graph-ql/coverage/b2b/negotiableQuotes.graphqls new file mode 100644 index 000000000..080d109de --- /dev/null +++ b/design-documents/graph-ql/coverage/b2b/negotiableQuotes.graphqls @@ -0,0 +1,520 @@ +type Query { + # Implementation Note: Negotiable Quotes belong to the Query type, + # rather than the Customer type, because managers + # in a company can see quotes belonging to their + # employees. If `Customer.negotiable_quotes` is desirable for buyer's + # that aren't managers, we can always add that on + negotiableQuote(uid: ID!): NegotiableQuote @doc(description: "Get a buyer's negotiable quote by ID") + negotiableQuotes( + filter: NegotiableQuoteFilterInput, + pageSize: Int = 20, + currentPage: Int = 1 + sort: NegotiableQuoteSortInput + ): NegotiableQuotesOutput @doc(description: "A (optionally filtered) list of negotiable quotes viewable by the logged-in customer") +} + +type NegotiableQuotesOutput { + items: [NegotiableQuote!]! + page_info: SearchResultPageInfo! + total_count: Int! + sort_fields: SortFields +} + +# Coverage missing: +# - Negotiable Quote Checkout Process (once seller has approved the quote) +# - File Upload (depends on reaching consensus on file upload design - design is in internal wiki) +type Mutation { + # https://docs.magento.com/user-guide/sales/quote-request.html + requestNegotiableQuote( + input: RequestNegotiableQuoteInput! + ): RequestNegotiableQuoteOutput @doc(description: "Request a new negotiable quote for a buyer") + + # https://docs.magento.com/user-guide/customers/account-dashboard-quotes-negotiate.html#change-the-quantity + updateNegotiableQuoteQuantities( + input: UpdateNegotiableQuoteQuantitiesInput! + ): UpdateNegotiableQuoteItemsQuantityOutput @doc(description: "Change the quantity of 1 or more items already in a negotiable quote") + + # Covers "Delete Line Item" section of https://docs.magento.com/user-guide/customers/account-dashboard-quotes-negotiate.html#tabbed-sections + removeNegotiableQuoteItems( + input: RemoveNegotiableQuoteItemsInput! + ): RemoveNegotiableQuoteItemsOutput @doc(description: "Remove 1 or more products from a Negotiable Quote") + + # Covers first half of https://docs.magento.com/user-guide/customers/account-dashboard-quotes.html#cancel-a-quote-request + closeNegotiableQuotes( + input: CloseNegotiableQuotesInput! + ): CloseNegotiableQuotesOutput @doc(description: "Mark a negotiable quote as closed, leaving it visible in the storefront") + + # Covers second half of https://docs.magento.com/user-guide/customers/account-dashboard-quotes.html#cancel-a-quote-request + deleteNegotiableQuotes( + input: DeleteNegotiableQuotesInput! + ): DeleteNegotiableQuotesOutput @doc(description: "Delete a negotiable quote, removing it from the display in the storefront") + + sendNegotiableQuoteForReview( + input: SendNegotiableQuoteForReviewInput! + ) : SendNegotiableQuoteForReviewOutput @doc(description: "Send the negotiable quote for review to the seller") + + # Covers "Select Existing Address" in https://docs.magento.com/user-guide/customers/account-dashboard-quotes-negotiate.html#shipping-information + # "New Address" flow is covered by Mutation.createCustomerAddress + setNegotiableQuoteShippingAddress( + input: SetNegotiableQuoteShippingAddressInput! + ): SetNegotiableQuoteShippingAddressOutput @doc(description: "Assign one of buyers' existing addresses to a negotiable quote") + + setNegotiableQuoteBillingAddress( + input: SetNegotiableQuoteBillingAddressInput! + ): SetNegotiableQuoteBillingAddressOutput @doc(description: "Assign a billing address to a negotiable quote") + + setNegotiableQuoteShippingMethods( + input: SetNegotiableQuoteShippingMethodsInput! + ): SetNegotiableQuoteShippingMethodsOutput @doc(description: "Assign the shipping methods on the negotiable quote") + + setNegotiableQuotePaymentMethod( + input: SetNegotiableQuotePaymentMethodInput! + ): SetNegotiableQuotePaymentMethodOutput @doc(description: "Set the payment method on the negotiable quote") + + placeNegotiableQuoteOrder( + input: PlaceNegotiableQuoteOrderInput + ): PlaceNegotiableQuoteOrderOutput @doc(description: "Place an order using the negotiable quote") + + # Pending decision on design of file upload (design doc still pending decisions) + # addNegotiableQuoteFiles( + # input: AddNegotiableQuoteFilesInput! + # ): AddNegotiableQuoteFilesInput +} + +interface NegotiableQuoteUidNonFatalResultInterface { + quote_uid: ID! +} + +type NegotiableQuoteUidOperationSuccess implements NegotiableQuoteUidNonFatalResultInterface { +} + +type NegotiableQuoteInvalidStateError implements ErrorInterface { +} + +type AddNegotiableQuoteItemsOutput { + quote: NegotiableQuote + # TODO: We'll probably want to add the same + # errors that can happen when adding an item + # to a normal cart object, but will also have + # some additional errors (i.e can't add an item + # when a negotiable quote is in some statuses) +} + +input SetNegotiableQuoteShippingAddressInput { + quote_uid: ID! @doc(description: "ID obtained from NegotiableQuote type") + customer_address_id: ID @deprecated(reason: "Use `NegotiableQuoteShippingAddressInput.customer_address_uid` instead") + shipping_addresses: [NegotiableQuoteShippingAddressInput!] +} + +input NegotiableQuoteShippingAddressInput { + customer_address_uid: ID + address: NegotiableQuoteAddressInput + customer_notes: String +} + +input SetNegotiableQuoteBillingAddressInput { + quote_uid: ID! @doc(description: "ID obtained from NegotiableQuote type") + billing_address: NegotiableQuoteBillingAddressInput! +} + +input NegotiableQuoteBillingAddressInput { + customer_address_uid: ID + address: NegotiableQuoteAddressInput + use_for_shipping: Boolean @doc(description: "Indicates whether to additionally set the shipping address based on the provided billing address") + same_as_shipping: Boolean @doc(description: "Indicates whether to set the billing address based on the existing shipping address on the negotiable quote") +} + +input NegotiableQuoteAddressInput { + firstname: String! + lastname: String! + company: String + street: [String!]! + city: String! + region: String + region_id: Int + postcode: String + country_code: String! + telephone: String + save_in_address_book: Boolean +} + +input SetNegotiableQuoteShippingMethodsInput { + quote_uid: ID! + shipping_methods: [ShippingMethodInput!]! +} + +type SetNegotiableQuoteShippingMethodsOutput { + quote: NegotiableQuote +} + +input SendNegotiableQuoteForReviewInput { + quote_uid: ID! @doc(description: "ID obtained from NegotiableQuote type") + comment: NegotiableQuoteCommentInput +} + +input PlaceNegotiableQuoteOrderInput { + quote_uid: ID! @doc(description: "ID obtained from NegotiableQuote type") +} + +type PlaceNegotiableQuoteOrderOutput { + order: Order! +} + +type SendNegotiableQuoteForReviewOutput { + quote: NegotiableQuote +} + +type SetNegotiableQuoteShippingAddressOutput { + quote: NegotiableQuote +} + +input AddNegotiableQuoteItemsInput { + quote_uid: ID! @doc(description: "UID from a negotiable quote object") + # Implementation Note: This *should* be compatible with the new, single + # add to cart mutation. https://github.com/magento/architecture/blob/master/design-documents/graph-ql/coverage/AddProductsToCart.graphqls + cart_items: [CartItemInput!]! +} + +input DeleteNegotiableQuotesInput { + quote_uids: [ID!]! @doc(description: "A List of UIDs obtained from negotiable quote types") +} + +union DeleteNegotiableQuoteError = NegotiableQuoteInvalidStateError | NoSuchEntityUidError | InternalError + +type DeleteNegotiableQuoteOperationFailure { + quote_uid: ID! + errors: [DeleteNegotiableQuoteError!]! +} + +union DeleteNegotiableQuoteOperationResult = NegotiableQuoteUidOperationSuccess | DeleteNegotiableQuoteOperationFailure + +type DeleteNegotiableQuotesOutput { + result_status: BatchMutationStatus! + operation_results: [DeleteNegotiableQuoteOperationResult!]! + # Implementation Note: We don't make the deleted quotes accessible + # because "deleted" means they're hidden from the storefront UI. They will + # still be visible (and labeled as deleted) for a Seller in the admin + negotiable_quotes( + filter: NegotiableQuoteFilterInput, + pageSize: Int = 20, + currentPage: Int = 1 + sort: NegotiableQuoteSortInput + ): NegotiableQuotesOutput @doc(description: "List of negotiable quotes available to customer") +} + +input CloseNegotiableQuotesInput { + quote_uids: [ID!]! @doc(description: "A List of IDs from negotiable quote objects") +} + +union CloseNegotiableQuoteError = NegotiableQuoteInvalidStateError | NoSuchEntityUidError | InternalError + +type CloseNegotiableQuoteOperationFailure { + quote_uid: ID! + errors: [CloseNegotiableQuoteError!]! +} + +union CloseNegotiableQuoteOperationResult = NegotiableQuoteUidOperationSuccess | CloseNegotiableQuoteOperationFailure + +type CloseNegotiableQuotesOutput { + result_status: BatchMutationStatus! + operation_results: [CloseNegotiableQuoteOperationResult!]! + closed_quotes: [NegotiableQuote!] @doc(description: "An array containing the negotiable quotes that were just closed") @deprecated(reason: "Replaced with operation_results") + #optionally display all negotiable quotes + negotiable_quotes( + filter: NegotiableQuoteFilterInput, + pageSize: Int = 20, + currentPage: Int = 1 + sort: NegotiableQuoteSortInput + ): NegotiableQuotesOutput @doc(description: "A (optionally filtered) list of negotiable quotes viewable by the logged-in customer") +} + +input RemoveNegotiableQuoteItemsInput { + quote_uid: ID! + quote_item_uids: [ID!]! @doc(description:"Cart Item uids") +} + +input UpdateNegotiableQuoteQuantitiesInput { + quote_uid: ID! + items: [NegotiableQuoteItemQuantityInput!]! +} + +input NegotiableQuoteItemQuantityInput { + quote_item_uid: ID! @doc(description:"Cart Item uid") + quantity: Float! +} + +input RequestNegotiableQuoteInput { + quote_uid: ID! @doc(description: "Cart uid") + quote_name: String! + comment: NegotiableQuoteCommentInput! + # files (attachments) to be added at a later date when file upload design has been finalized +} + +input NegotiableQuoteCommentInput { + comment: String! + # files (attachments) to be added at a later date when file upload design has been finalized +} + +type NegotiableQuoteComment { + uid: ID! + created_at: String! + author: NegotiableQuoteUser! + creator_type: NegotiableQuoteCommentCreatorType! + text: String! @doc(description: "A simple (non-html) comment submitted by a seller or buyer") +} + +enum NegotiableQuoteCommentCreatorType { + BUYER + SELLER +} + +type NegotiableQuote { + uid: ID! + name: String! + items: [CartItemInterface!] + # Attachment Support is dependent on headless File Upload design + # attachments: [AttachmentContent] + comments: [NegotiableQuoteComment!] + history: [NegotiableQuoteHistoryEntry!] + applied_coupons: [AppliedCoupon] @doc(description:"An array of `AppliedCoupon` objects. Each object contains the `code` text attribute, which specifies the coupon code") + email: String + shipping_addresses: [NegotiableQuoteShippingAddress]! + billing_address: NegotiableQuoteBillingAddress + available_payment_methods: [NegotiableQuoteAvailablePaymentMethod] + selected_payment_method: NegotiableQuoteSelectedPaymentMethod + prices: NegotiableQuotePrices + total_quantity: Float! + is_virtual: Boolean! + buyer: NegotiableQuoteUser! + created_at: String @doc(description: "Timestamp indicating when the negotiable quote was created.") + updated_at: String @doc(description: "Timestamp indicating when the negotiable quote was updated.") + status: NegotiableQuoteStatus! +} + +type NegotiableQuoteShippingAddress implements NegotiableQuoteAddressInterface { + available_shipping_methods: [AvailableShippingMethod] + selected_shipping_method: SelectedShippingMethod +} + +type NegotiableQuoteBillingAddress implements NegotiableQuoteAddressInterface { +} + +interface NegotiableQuoteAddressInterface { + firstname: String! + lastname: String! + company: String + street: [String!]! + city: String! + region: NegotiableQuoteAddressRegion + postcode: String + country: NegotiableQuoteAddressCountry! + telephone: String +} + +type NegotiableQuoteAddressRegion { + code: String + label: String + region_id: Int +} + +type NegotiableQuoteAddressCountry { + code: String! + label: String! +} + +# Implementation Note: Using the values from the "Buyer Status" column of the state mapping table in devdocs. +# These states are identical between storefront/admin, but we name them differently for buyers. +# https://devdocs.magento.com/guides/v2.4/b2b/negotiable-quote.html#quote-statuses +enum NegotiableQuoteStatus { + SUBMITTED + PENDING + UPDATED + OPEN + ORDERED + CLOSED + DECLINED + EXPIRED +} + +input NegotiableQuoteFilterInput { + quote_uid: FilterEqualTypeInput @doc(description: "Filter by quote UID(s)") + name: FilterMatchTypeInput @doc(description: "Filter by negotiable quote name") +} + +input NegotiableQuoteSortInput { + sort_field: NegotiableQuoteSortableField! + sort_direction: SortEnum! +} + +enum NegotiableQuoteSortableField { + QUOTE_NAME + CREATED_AT + UPDATED_AT +} + +type NegotiableQuoteHistoryEntry { + uid: ID! + author: NegotiableQuoteUser! + change_type: NegotiableQuoteHistoryEntryChangeType! + created_at: String + changes: NegotiableQuoteHistoryChanges +} + +type NegotiableQuotePrices implements QuotePricesInterface { + initial_grand_total: Money! +} + +type NegotiableQuoteItemPrices implements QuoteItemPricesInterface { + initial_price: Money! + initial_row_total: Money! + initial_row_total_including_tax: Money! +} + +# Note: Most of these HistoryChange types were built based on looking through UI code in Luma, because we don't have existing API +# support for the Negotiable Quote history log feature. If we find these types aren't ideal during implementation, +# let's iterate rather than stay glued to them. +# +# Resources +# Diffing class: https://github.com/magento/magento2b2b/blob/5547298c3dde48234ce74ed8ae9000fce3fe01b1/app/code/Magento/NegotiableQuote/Model/History/DiffProcessor.php +# Block for UI: https://github.com/magento/magento2b2b/blob/0e791b5f7cd604ee6ee40b7225807c01b7f70cf2/app/code/Magento/NegotiableQuote/Block/Quote/History.php +# Usage in UI: https://github.com/magento/magento2b2b/blob/0e791b5f7cd604ee6ee40b7225807c01b7f70cf2/app/code/Magento/NegotiableQuote/view/base/templates/quote/history.phtml +# +# There is currently no support for the `Custom Log` feature supported by the Luma UI, but we can add it on +# https://github.com/magento/magento2b2b/blob/0e791b5f7cd604ee6ee40b7225807c01b7f70cf2/app/code/Magento/NegotiableQuote/view/base/templates/quote/history.phtml#L316-L363 +# +# Implementation Notes: +# - Fields in NegotiableQuoteHistoryChanges should be null when no change is present +# - An Object Type is used, rather than a list, because the UI likely wants +# to render some of these differently from others in the UI, and a List would require us to guess +# at the desired ordering for the client. This is also more extensible with less risk of breaking +# changes (compared to extending a union or shared interface) +type NegotiableQuoteHistoryChanges { + statuses: NegotiableQuoteHistoryStatusesChange + comment_added: NegotiableQuoteHistoryCommentChange + total: NegotiableQuoteHistoryTotalChange + expiration: NegotiableQuoteHistoryExpirationChange + products_changed: NegotiableQuoteHistoryProductsChange + products_removed: NegotiableQuoteHistoryProductsRemovedChange + products_added: NegotiableQuoteHistoryProductsAddedChange + custom_changes: NegotiableQuoteCustomLogChange +} + +# Usage in Luma: https://github.com/magento/magento2b2b/blob/0e791b5f7cd604ee6ee40b7225807c01b7f70cf2/app/code/Magento/NegotiableQuote/view/base/templates/quote/history.phtml#L40-L73 +type NegotiableQuoteHistoryStatusChange { + old_status: NegotiableQuoteStatus @doc(description: "Will be null for the first history entry on a negotiable quote") + new_status: NegotiableQuoteStatus! @doc(description: "Negotiable quote history new status.") +} + +# Usage in Luma: https://github.com/magento/magento2b2b/blob/0e791b5f7cd604ee6ee40b7225807c01b7f70cf2/app/code/Magento/NegotiableQuote/view/base/templates/quote/history.phtml#L48 +type NegotiableQuoteHistoryStatusesChange { + changes: [NegotiableQuoteHistoryStatusChange!]! +} + +# Usage in Luma: https://github.com/magento/magento2b2b/blob/0e791b5f7cd604ee6ee40b7225807c01b7f70cf2/app/code/Magento/NegotiableQuote/view/base/templates/quote/history.phtml#L239-L258 +type NegotiableQuoteHistoryCommentChange { + # Implementation Note: Comments are append-only and don't support editing, which explains + # the absence of `old_*` and `new_*` fields + comment: String! @doc(description: "A simple (non-html) comment submitted by a seller or buyer") + # Need to add attachment support when file upload has been designed/implemented +} + +# Usage in Luma: https://github.com/magento/magento2b2b/blob/0e791b5f7cd604ee6ee40b7225807c01b7f70cf2/app/code/Magento/NegotiableQuote/view/base/templates/quote/history.phtml#L259-L294 +type NegotiableQuoteHistoryTotalChange { + old_price: Money + new_price: Money +} + +# Usage in Luma: https://github.com/magento/magento2b2b/blob/0e791b5f7cd604ee6ee40b7225807c01b7f70cf2/app/code/Magento/NegotiableQuote/view/base/templates/quote/history.phtml#L74-L103 +type NegotiableQuoteHistoryExpirationChange { + old_expiration: String @doc(description: "Old quote expiration. Will be 'null' if not previously set") + new_expiration: String! +} + +# Usage in Luma: https://github.com/magento/magento2b2b/blob/0e791b5f7cd604ee6ee40b7225807c01b7f70cf2/app/code/Magento/NegotiableQuote/view/base/templates/quote/history.phtml#L104-L127 +type NegotiableQuoteHistoryProductsChange { + # TODO: List of changes for both product quantities and selected options + # TODO: needs to model qty_changed and options_changed +} + +# Usage in Luma: https://github.com/magento/magento2b2b/blob/0e791b5f7cd604ee6ee40b7225807c01b7f70cf2/app/code/Magento/NegotiableQuote/view/base/templates/quote/history.phtml#L132-L146 +type NegotiableQuoteHistoryProductsRemovedChange { + # Open Question: Should `removed_from_catalog` ID type represent the SKU or the product id? + removed_from_catalog_product_uids: [ID!] @doc(description: "List of product UIDs removed from seller's catalog") + removed_from_quote_products: [ProductInterface!] @doc(description: "List of products removed by a buyer or seller") +} + +# Usage in Luma: https://github.com/magento/magento2b2b/blob/0e791b5f7cd604ee6ee40b7225807c01b7f70cf2/app/code/Magento/NegotiableQuote/view/base/templates/quote/history.phtml#L148-L169 +type NegotiableQuoteHistoryProductsAddedChange { + # TODO: List of products added and their respective options. +} + +# Usage in Luma: https://github.com/magento/magento2b2b/blob/0e791b5f7cd604ee6ee40b7225807c01b7f70cf2/app/code/Magento/NegotiableQuote/view/base/templates/quote/history.phtml#L172-L197 +type NegotiableQuoteHistoryAddressChange { + # TODO +} +# Usage in Luma: https://github.com/magento/magento2b2b/blob/0e791b5f7cd604ee6ee40b7225807c01b7f70cf2/app/code/Magento/NegotiableQuote/view/base/templates/quote/history.phtml#L316-L363 +type NegotiableQuoteCustomLogChange @doc(description: "Custom log entries added by 3rd party extensions") { + title: String! + old_value: String + new_value: String! +} + +enum NegotiableQuoteHistoryEntryChangeType { + CREATED + UPDATED + CLOSED + UPDATED_BY_SYSTEM +} + +type RequestNegotiableQuoteOutput { + quote: NegotiableQuote +} + +type RemoveNegotiableQuoteItemsOutput { + quote: NegotiableQuote +} + +type UpdateNegotiableQuoteItemsQuantityOutput { + quote: NegotiableQuote +} + +# Implementation Note: We don't want to expose the `Customer` object of the Seller to the client, and we don't +# have much of a permissions model in the storefront to limit access by field. We're using a limited view +# instead, excluding the ID because a Seller's ID shouldn't be exposed to the client. +type NegotiableQuoteUser @doc(description: "A limited view of a Buyer or Seller in the negotiable quote process") { + firstname: String! + lastname: String! +} + +type StoreConfig { + is_negotiable_quote_active: Boolean @doc(description: "Indicates if negotiable quote functionality is enabled.") +} + +type SetNegotiableQuoteBillingAddressOutput { + quote: NegotiableQuote +} + +input SetNegotiableQuotePaymentMethodInput { + quote_uid: ID! @doc(description: "ID obtained from NegotiableQuote object") + payment_method: NegotiableQuotePaymentMethodInput! +} + +input NegotiableQuotePaymentMethodInput { + code: String! @doc(description:"Payment method code") + purchase_order_number: String @doc(description:"Purchase order number") +} + +type SetNegotiableQuotePaymentMethodOutput { + quote: NegotiableQuote +} + +type NegotiableQuoteAvailablePaymentMethod { + code: String! @doc(description: "The payment method code") + title: String! @doc(description: "The payment method title.") +} + +type NegotiableQuoteSelectedPaymentMethod { + code: String! @doc(description: "The payment method code") + title: String! @doc(description: "The payment method title.") + purchase_order_number: String @doc(description: "The purchase order number.") +} diff --git a/design-documents/graph-ql/coverage/b2b/purchase-order.graphqls b/design-documents/graph-ql/coverage/b2b/purchase-order.graphqls new file mode 100644 index 000000000..10ce53fcc --- /dev/null +++ b/design-documents/graph-ql/coverage/b2b/purchase-order.graphqls @@ -0,0 +1,314 @@ +type Mutation { + addPurchaseOrderComment(input: AddPurchaseOrderCommentInput!): AddPurchaseOrderCommentOutput @doc(description: "Add a comment to an existing purchase order") + validatePurchaseOrder(input: ValidatePurchaseOrderInput!): ValidatePurchaseOrderOutput @doc(description: "Validate purchase order") + approvePurchaseOrder(input: ApprovePurchaseOrderInput!): ApprovePurchaseOrderOutput @doc(description: "Approve purchase order") + cancelPurchaseOrder(input: CancelPurchaseOrderInput!): CancelPurchaseOrderOutput @doc(description: "Cancel purchase order") + rejectPurchaseOrder(input: RejectPurchaseOrderInput!): RejectPurchaseOrderOutput @doc(description: "Reject purchase order") + createPurchaseOrderApprovalRule(input: CreatePurchaseOrderApprovalRuleInput!): CreatePurchaseOrderApprovalRuleOutput @doc(description: "Create purchase order approval rule") + updatePurchaseOrderApprovalRule(input: UpdatePurchaseOrderApprovalRuleInput!): UpdatePurchaseOrderApprovalRuleOutput @doc(description: "Update existing purchase order approval rule") + deletePurchaseOrderApprovalRule(input: DeletePurchaseOrderApprovalRuleInput!): DeletePurchaseOrderApprovalRuleOutput @doc(description: "Delete existing purchase order approval rule") +} + +type CreatePurchaseOrderApprovalRuleOutput { + approval_rule: PurchaseOrderApprovalRule @doc(description: "Created purchase order approval rule") + approval_rules(currentPage: Int = 1, pageSize: Int = 20): PurchaseOrderApprovalRules @doc(description: "A list of purchase order approval rules visible to the customer") +} + +type UpdatePurchaseOrderApprovalRuleOutput { + approval_rule: PurchaseOrderApprovalRule @doc(description: "Updated purchase order approval rule") + approval_rules(currentPage: Int = 1, pageSize: Int = 20): PurchaseOrderApprovalRules @doc(description: "A list of purchase order approval rules visible to the customer") +} + +type DeletePurchaseOrderApprovalRuleOutput { + approval_rules(currentPage: Int = 1, pageSize: Int = 20): PurchaseOrderApprovalRules @doc(description: "A list of purchase order approval rules visible to the customer") +} + +input CreatePurchaseOrderApprovalRuleInput { + approval_rule: PurchaseOrderApprovalRuleInput! @doc(description: "Purchase order approval rule data") +} + +input UpdatePurchaseOrderApprovalRuleInput { + approval_rule_uid: ID! @doc(description: "Purchase order approval rule ID") + approval_rule: PurchaseOrderApprovalRuleInput! @doc(description: "Purchase order approval rule data") +} + +input DeletePurchaseOrderApprovalRuleInput { + approval_rule_uid: ID! @doc(description: "Purchase order approval rule ID") +} + +input PurchaseOrderApprovalRuleInput { + name: String! @doc(description: "Purchase order approval rule name") + description: String @doc(description: "Purchase order approval rule description") + applies_to: [ID!]! @doc(description: "A list of B2B user roles to which this purchase order approval rule should be applied. In case when empty array is provided, the rule will be applied to all user roles in the system, including those created in the future") + type: PurchaseOrderApprovalRuleType! @doc(description: "Purchase order approval rule type") + status: PurchaseOrderApprovalRuleStatus! @doc(description: "Purchase order approval rule status") + condition: CreatePurchaseOrderApprovalRuleConditionInput! @doc(description: "Purchase order approval rule condition") + requires_approval_from: [ID!]! @doc(description: "A list of B2B user roles that can approve this purchase order approval rule") +} + +input CreatePurchaseOrderApprovalRuleConditionInput { + operator: PurchaseOrderApprovalRuleConditionOperator! @doc(description: "Purchase order approval rule condition operator") + amount: CreatePurchaseOrderApprovalRuleConditionAmountInput @doc(description: "Purchase order approval rule condition ammount. Is mutually exclusive with condition quantity") + quantity: Int @doc(description: "Purchase order approval rule condition quantity. Is mutually exclusive with condition amount") +} + +input CreatePurchaseOrderApprovalRuleConditionAmountInput { + value: Float! @doc(description: "Purchase order approval rule condition ammount value") + currency: CurrencyEnum! @doc(description: "Purchase order approval rule condition ammount currency") +} + +input ValidatePurchaseOrderInput { + purchase_order_uid: ID! +} + +type ValidatePurchaseOrderOutput { + purchase_order: PurchaseOrder +} + +input ApprovePurchaseOrderInput { + purchase_order_uid: ID! +} + +type ApprovePurchaseOrderOutput { + purchase_order: PurchaseOrder +} + +input CancelPurchaseOrderInput { + purchase_order_uid: ID! +} + +type CancelPurchaseOrderOutput { + purchase_order: PurchaseOrder +} + +input RejectPurchaseOrderInput { + purchase_order_uid: ID! +} + +type RejectPurchaseOrderOutput { + purchase_order: PurchaseOrder +} + +input AddPurchaseOrderCommentInput { + purchase_order_uid: ID! + comment: String! +} + +type AddPurchaseOrderCommentOutput { + purchase_order: PurchaseOrder + comment: PurchaseOrderComment +} + +type Customer { + purchase_orders(filter: PurchaseOrdersFilterInput, currentPage: Int = 1, pageSize: Int = 20): PurchaseOrders @doc(description: "A list of purchase orders visible to the customer") + purchase_order(uid: ID!): PurchaseOrder @doc(description: "Purchase order details") + purchase_order_approval_rules(currentPage: Int = 1, pageSize: Int = 20): PurchaseOrderApprovalRules @doc(description: "A list of purchase order approval rules visible to the customer") + purchase_order_approval_rule(uid: ID!): PurchaseOrderApprovalRule @doc(description: "Purchase order approval rule details") + purchase_order_approval_rule_metadata: PurchaseOrderApprovalRuleMetadata @doc(description: "Purchase order approval rule metadata which is can be used for rule edit form rendering") + purchase_orders_enabled: Boolean! @doc(description: "Whether purchase orders functionality is enabled for current customer. Takes into account global and company-level settings") +} + +type PurchaseOrderApprovalRuleMetadata { + available_applies_to: [CompanyRole]! @doc(description: "A list of B2B user roles that the rule can be applied to") + available_condition_currencies: [CurrencyEnum]! @doc(description: "A list of currencies that can be used to create approval rules based on ammounts, for example shipping cost rules") + available_requires_approval_from: [CompanyRole]! @doc(description: "A list of B2B user roles that can be specified as approvers for the approval rules") +} + +input PurchaseOrdersFilterInput { + status: PurchaseOrderStatus @doc(description: "Filter by the status of the purchase order") + createdBy: FilterStringTypeInput @doc(description: "Filter by the name of the user who created the purchase order") + createdDate: FilterRangeTypeInput @doc(description: "Filter by the creation date of the purchase order") +} + +type PurchaseOrders { + items: [PurchaseOrder]! + page_info: SearchResultPageInfo + total_count: Int +} + +type PurchaseOrderApprovalRules { + items: [PurchaseOrderApprovalRule]! + page_info: SearchResultPageInfo + total_count: Int +} + +type PurchaseOrderApprovalRule { + uid: ID! @doc(description: "Unique identifier for the purcahse order approval rule") + name: String! @doc(description: "Name of the purcahse order approval rule") + status: PurchaseOrderApprovalRuleStatus! @doc(description: "Status of the purcahse order approval rule") + type: PurchaseOrderApprovalRuleType! @doc(description: "Type of the purcahse order approval rule") + created_by: String! @doc(description: "The name of the user who created the purcahse order approval rule") + applies_to: String! @doc(description: "The name of the user(s) affected by the the purcahse order approval rule") + approver: String! @doc(description: "The name of the user who needs to approve purchase orders that trigger the approval rule") + condition: PurchaseOrderApprovalRuleConditionInterface! @doc(description: "Condition which triggers the approval rule") +} + +interface PurchaseOrderApprovalRuleConditionInterface { + operator: PurchaseOrderApprovalRuleConditionOperator! @doc(description: "The operator to be used for evaluation of the approval rule condition") +} + +enum PurchaseOrderApprovalRuleConditionOperator { + MORE_THAN + LESS_THAN + MORE_THAN_OR_EQUAL_TO + LESS_THAN_OR_EQUAL_TO +} + +type PurchaseOrderApprovalRuleConditionAmount implements PurchaseOrderApprovalRuleConditionInterface { + amount: Money! @doc(description: "The amount to be be used for evaluation of the approval rule condition") +} + +type PurchaseOrderApprovalRuleConditionQuantity implements PurchaseOrderApprovalRuleConditionInterface { + quantity: Int! @doc(description: "The quantity to be be used for evaluation of the approval rule condition") +} + +enum PurchaseOrderApprovalRuleStatus { + ENABLED + DISABLED +} + +enum PurchaseOrderApprovalRuleType { + ORDER_TOTAL + SHIPPING_COST + NUMBER_OF_SKUS +} + +type PurchaseOrder { + uid: ID! @doc(description: "Unique identifier for the purcahse order") + number: String! @doc(description: "The purchase order number") + order: CustomerOrder @doc(description: "The reference to the order placed based on the purchase order") + purchase_order_date: String! @doc(description: "The date the purchase order was created") + created_by: String! @doc(description: "The name of the user who created the purchase order") + status: PurchaseOrderStatus! @doc(description: "The current status of the purcahse order") + total: PurchaseOrderTotal @doc(description: "Contains details about the calculated totals for the purchase order") + comments(currentPage: Int = 1, pageSize: Int = 20): PurchaseOrderComments @doc(description: "Purchase order comments") + payment_methods: [PaymentMethod] @doc(description: "Payment details for the purchase order") + shipping_address: CustomerAddress @doc(description: "The shipping address for the purchase order") + billing_address: CustomerAddress @doc(description: "The billing address for the purchase order") + carrier: String @doc(description: "The shipping carrier for the purchase order delivery") + shipping_method: String @doc(description: "The delivery method for the purchase order") + items(currentPage: Int = 1, pageSize: Int = 20): PurchaseOrderItems @doc(description: "Items that belong to the purchase order") + history_log(currentPage: Int = 1, pageSize: Int = 20): PurchaseOrderHistoryLog @doc(description: "The log of the events related to the purchase order") + approval_flow: PurchaseOrderApprovalFlow @doc(description: "The log of the events related to the purchase order approval flow") + available_actions: [PurchaseOrderAction] @doc(description: "Purcahse order actions available to the customer. Can be used to display action buttons on the client") +} + +enum PurchaseOrderAction { + REJECT + CANCEL + VALIDATE + APPROVE +} + +type PurchaseOrderApprovalFlow { + items: [PurchaseOrderApprovalFlowItem]! +} + +type PurchaseOrderApprovalFlowItem { + uid: ID! @doc(description: "Unique identifier of the purchase order flow item.") + title: String! @doc(description: "Summary of the event related to purchase order approval flow") + description: String! @doc(description: "Description of the event related to purchase order approval flow") + status: PurchaseOrderApprovalFlowItemStatus! @doc(description: "Status associated with the event related to purchase order approval flow") +} + +enum PurchaseOrderApprovalFlowItemStatus { + PENDING + APPROVED + REJECTED +} + +type PurchaseOrderComments { + items: [PurchaseOrderComment]! + page_info: SearchResultPageInfo + total_count: Int! +} + +type PurchaseOrderComment { + uid: ID! @doc(description: "Unique identifier of the comment.") + created_at: String! @doc(description: "The date and time when the comment was created") + author: PurchaseOrderCommentAuthor! @doc(description: "The name of the user who left the comment") + text: String! @doc(description: "The text of the comment") +} + +type PurchaseOrderCommentAuthor { + firstname: String! @doc(description: "First name of the user who left the purchase order comment") + lastname: String! @doc(description: "Last name of the user who left the purchase order comment") +} + +type PurchaseOrderHistoryLog { + items: [PurchaseOrderHistoryItem]! + page_info: SearchResultPageInfo + total_count: Int +} + +type PurchaseOrderHistoryItem { + uid: ID! @doc(description: "Unique identifier of the purchase rder history item.") + created_at: String! @doc(description: "The date and time when the event happened.") + description: String! @doc(description: "Description of the event.") +} + +type PurchaseOrderItems { + items: [PurchaseOrderItemInterface]! + page_info: SearchResultPageInfo + total_count: Int! +} + +type PurchaseOrderTotal @doc(description: "Contains details about the sales total amounts used to calculate the final price") { + subtotal: Money! @doc(description: "The subtotal of the purchase order, excluding shipping, discounts, and taxes") + discounts: [Discount] @doc(description: "The applied discounts to the purchase order") + estimated_total_tax: Money! @doc(description: "The amount of tax applied to the purchase order") + estimated_taxes: [TaxItem] @doc(description: "The purchase order tax details") + grand_total: Money! @doc(description: "The final total amount, including shipping, discounts, and taxes") + base_grand_total: Money! @doc(description: "The final base grand total amount in the base currency") + total_shipping: Money! @doc(description: "The shipping amount for the order") + shipping_handling: ShippingHandling @doc(description: "Contains details about the shipping and handling costs for the purchase purchase order") +} + +interface PurchaseOrderItemInterface @doc(description: "Purchase order item details") { + uid: ID! @doc(description: "The unique identifier of the purchase order item") + product_name: String @doc(description: "The name of the base product") + product_sku: String! @doc(description: "The SKU of the base product") + product_url_key: String @doc(description: "URL key of the base product") + product_sale_price: Money! @doc(description: "The sale price of the base product, including selected options") + discounts: [Discount] @doc(description: "The final discount information for the product") + selected_options: [PurchaseOrderItemOption] @doc(description: "The selected options for the base product, such as color or size") + entered_options: [PurchaseOrderItemOption] @doc(description: "The entered option for the base product, such as a logo or image") + quantity: Float! @doc(description: "The number of units for this item") +} + +type PurchaseOrderItem implements PurchaseOrderItemInterface { + +} + +type PurchaseOrderBundleItem implements PurchaseOrderItemInterface { + parent_product_sku: String! @doc(description: "SKU of the bundle itself") + parent_product_quantity: Float! @doc(description: "Quantity of the bundle itself") +} + +type PurchaseOrderItemOption @doc(description: "Represents purcahse order item options") { + uid: ID! @doc(description: "The unique ID of the option") + label: String! @doc(description: "The label of the option") + value: String! @doc(description: "The value of the option") + sort_order: Int! @doc(description: "The sort order of the option") +} + +enum PurchaseOrderStatus { + PENDING + APPROVAL_REQUIRED + APPROVED + ORDER_IN_PROGRESS + ORDER_PLACED + ORDER_FAILED + REJECTED + CANCELED +} + +type CustomerAddress { + # This field must be added to the CustomerAddress type definition directly in CustomerGraphQl module + country: Country @doc(description: "The customer's country") +} + +input CartItemInput { + # This field must be added to the CartItemInput type definition directly in QuoteGraphQl module + parent_quantity: Float @doc(description: "Parent quantity can be used when adding complex product to cart. For example bundle products") +} diff --git a/design-documents/graph-ql/coverage/b2b/purchase-order.md b/design-documents/graph-ql/coverage/b2b/purchase-order.md new file mode 100644 index 000000000..13ea3a9c9 --- /dev/null +++ b/design-documents/graph-ql/coverage/b2b/purchase-order.md @@ -0,0 +1,645 @@ +## Use Cases + +### View "My Purchase Orders" list + +```graphql +{ + customer { + purchase_orders( + filter: {createdBy: {eq: "Active User Name"}}, + currentPage: 1, + pageSize: 10 + ) { + items { + uid + number + order { + number + } + purchase_order_date + created_by + status + total { + grand_total { + currency + value + } + } + } + total_count + page_info { + current_page + page_size + total_pages + } + } + } +} +``` +### View "Requires My Approval" list of purchase orders + +```graphql +{ + customer { + purchase_orders( + filter: {status: APPROVAL_REQUIRED}, + currentPage: 1, + pageSize: 10 + ) { + items { + uid + number + order { + number + } + purchase_order_date + created_by + status + total { + grand_total { + currency + value + } + } + } + total_count + page_info { + current_page + page_size + total_pages + } + } + } +} +``` +### View "Company Purchase Orders" list + +```graphql +{ + customer { + purchase_orders( + currentPage: 1, + pageSize: 10 + ) { + items { + uid + number + order { + number + } + purchase_order_date + created_by + status + total { + grand_total { + currency + value + } + } + } + total_count + page_info { + current_page + page_size + total_pages + } + } + } +} +``` +### View purchase order details + +The query should allow to fetch the following data: + - Items with pagination + - Basic details + - Totals + - Shipping Address + - Billing Address + - Payment Method + - Shipping Method + - Comments + - History Log + - Approval Flow + - Available actions (actions, customer can execute on purchase order) + +```graphql +{ + customer { + purchase_order(uid: "abc234hsasdfa") { + uid + created_by + purchase_order_date + number + order { + number + } + status + total { + subtotal { + currency + value + } + estimated_taxes { + amount { + currency + value + } + rate + title + } + grand_total { + currency + value + } + shipping_handling { + total_amount { + currency + value + } + } + } + items(currentPage: 1, pageSize: 10) { + total_count + page_info { + current_page + page_size + total_pages + } + items { + uid + product_name + product_sku + product_url_key + product_type + product_sale_price { + currency + value + } + quantity + selected_options { + uid + label + value + } + entered_options { + uid + label + value + } + discounts { + amount { + currency + value + } + label + } + } + } + payment_methods { + name + type + additional_data { + name + value + } + } + billing_address { + firstname + lastname + street + city + region { + region + } + postcode + country { + full_name_locale + } + telephone + } + carrier + shipping_method + shipping_address { + firstname + lastname + street + city + region { + region + } + postcode + country { + full_name_locale + } + telephone + } + comments(currentPage: 2, pageSize: 10) { + page_info { + current_page + page_size + total_pages + } + total_count + items { + uid + timestamp + author { + firstname + lastname + } + text + } + } + history_log(currentPage: 1, pageSize: 10) { + page_info { + current_page + page_size + total_pages + } + total_count + items { + uid + timestamp + description + } + } + approval_flow { + items { + uid + status + title + description + } + } + available_actions + } + } +} +``` + +### Add items to cart from purchase order + +Get details about purchase items order items that need to be added to cart: + +```graphql +{ + customer { + purchase_order(uid: "abc234hsasdfa") { + uid + items(currentPage: 1, pageSize: 1000) { + items { + uid + product_sku + quantity + selected_options { + uid + } + entered_options { + uid + value + } + ... on PurchaseOrderBundleItem { + parent_product_sku + parent_product_quantity + } + } + } + } + } +} +``` + +Using the results from the previous request, execute the following mutation to add items to cart. This will work for all product types except for bundle. + +```graphql +mutation { + addProductsToCart( + cartId: "existing-cart-id-id", + cartItems: [ + { + sku: "simple-hat", + quantity: 2, + selected_options: [ + "hash based on custom option for the select type goes here" + ], + entered_options: [ + { + uid: "hash from custom phrase option ID", + value: "Custom Hat" + } + ] + } + ] + ) { + cart { + items { + id + } + } + } +} +``` + +Bundle product is added to cart by specifying `parent_sku` and `parent_quantity`. +`CartItemInput` needs to be extended with a new field `parent_quantity` directly in `QuoteGraphQl` module. + +```graphql +mutation { + addProductsToCart( + cartId: "existing-cart-id-id", + cartItems: [ + { + sku: "fan-kit-hat", + parent_sku: "fan-kit", + quantity: 2, + parent_quantity: 3, + selected_options: [ + "hash based on custom option for the select type goes here. Must be identical for all bundle children" + ], + entered_options: [ + { + uid: "hash based on custom phrase option goes here. Must be identical for all bundle children", + value: "Custom Text" + } + ] + }, + { + sku: "fan-kit-scarf", + parent_sku: "fan-kit", + quantity: 1, + parent_quantity: 3, + selected_options: [ + "hash based on custom option for the select type goes here. Must be identical for all bundle children" + ], + entered_options: [ + { + uid: "hash based on custom phrase option goes here. Must be identical for all bundle children", + value: "Custom Text" + } + ] + } + ] + ) { + cart { + items { + id + } + } + } +} +``` + +### Add purchase order comment + +In the mutation response, it is possible to request just created comment, or the whole purchase order, depending on the client needs. + +```graphql +mutation { + addPurchaseOrderComment( + input: { + purchase_order_uid: "h2l1k23gpw", + comment: "Purchase order comment" + } + ) { + comment { + uid + author { + firstname + lastname + } + text + timestamp + } + purchase_order { + uid + } + } +} +``` + +### Reject purchase order + +```graphql +mutation { + rejectPurchaseOrder(input: {purchase_order_uid: "asdghwl324a"}) { + purchase_order { + uid + status + } + } +} +``` + +### Cancel purchase order + +```graphql +mutation { + cancelPurchaseOrder(input: {purchase_order_uid: "asdghwl324a"}) { + purchase_order { + uid + status + } + } +} +``` + +### Approve purchase order + +```graphql +mutation { + approvePurchaseOrder(input: {purchase_order_uid: "asdghwl324a"}) { + purchase_order { + uid + status + } + } +} +``` + +### Store config + +The following settings and combined and exposed as a single customer field: +- If purchase order is enabled on global level +- If purchase order enabled on company level +- If companies are enabled on global level + +```graphql +{ + customer { + purchase_orders_enabled + } +} +``` + +### View a list of approval rules + +```graphql +{ + customer { + purchase_order_approval_rules( + currentPage: 1, + pageSize: 10 + ) { + items { + uid + name + status + type + created_by + applies_to + approver + } + total_count + page_info { + current_page + page_size + total_pages + } + } + } +} +``` + +### View approval rule details + +```graphql +{ + customer { + purchase_order_approval_rule(uid: "abc2710fsdlfh") { + uid + name + status + type + created_by + applies_to + approver + condition { + operator + ... on PurchaseOrderApprovalRuleConditionAmount { + amount { + value + currency + } + } + ... on PurchaseOrderApprovalRuleConditionQuantity { + quantity + } + } + } + } +} +``` + +### Create new approval rule + +To render the rule creation and update forms, some metadata is required: + +```graphql +{ + customer { + purchase_order_approval_rule_metadata { + available_applies_to { + id + name + } + available_requires_approval_from { + id + name + } + available_condition_currencies + } + } +} +``` + +Create a new rule with the condition based on shipping cost. In the response it is possible to request the rule just created, or a list of all rules available to the current user for viewing. + +```graphql +mutation { + createPurchaseOrderApprovalRule( + input: { + approval_rule: { + name: "Junior Buyer Orders" + description: "The rule applies to junior buyers" + applies_to: ["vah234gwy3"] + type: SHIPPING_COST + condition: { + operator: MORE_THAN_OR_EQUAL_TO + amount: { + currency: USD + value: 1000.50 + } + } + requires_approval_from: ["ghwldsfh237s", "fhsk23h49kl"] + } + } + ) { + approval_rule { + uid + } + approval_rules(currentPage: 1, pageSize: 10) { + items { + uid + } + } + } +} +``` + +### Update approval rule + +Similarly to creation of the rule, metadata for the form rendering needs to be fetched first. + +The next mutation demonstrates updating of an existing rule with the condition based on the number of SKUs. + +```graphql +mutation { + updatePurchaseOrderApprovalRule( + input: { + approval_rule_uid: "gasdgfhwlr234sdfla" + approval_rule: { + name: "Junior Buyer Orders" + description: "The rule applies to junior buyers" + applies_to: ["vah234gwy3"] + type: NUMBER_OF_SKUS + condition: { + operator: MORE_THAN + quantity: 100 + } + requires_approval_from: ["ghwldsfh237s"] + } + } + ) { + approval_rule { + uid + } + approval_rules(currentPage: 1, pageSize: 10) { + items { + uid + } + } + } +} +``` + +### Delete approval rule + +```graphql +mutation { + deletePurchaseOrderApprovalRule( + input: { + approval_rule_uid: "gabh572kfhs" + } + ) { + approval_rules(currentPage: 1, pageSize: 10) { + items { + uid + } + } + } +} +``` + +## Implementation details + +The purchase order GraphQL schema depends on Sales and Customer GraphQL schemas. diff --git a/design-documents/graph-ql/coverage/b2b/requisitionList.graphqls b/design-documents/graph-ql/coverage/b2b/requisitionList.graphqls new file mode 100644 index 000000000..af2aa9817 --- /dev/null +++ b/design-documents/graph-ql/coverage/b2b/requisitionList.graphqls @@ -0,0 +1,259 @@ +# Requistion List Flow User Flow +# +# 1.1. Create a requistion List at Customer's My Account section using `createRequistionList` Mutation +# +# 1.2. From catalog pages search for products and add products to already available list with `addProductsToRequisitionList` +# mutation or create a list with `createRequistionList` and on success add products with `addProductsToRequisitionList` +# +# 1.3. Select a requistion list from My Account and customer query `requisitionLists` with id filter will fetch requistion +# list details +# +# 1.4 Select a list of items from the requisition List and perform `addRequisitionListItemToCart`, so the items are copied +# to cart +# +# 1.5 perform regular checkout flow +# +# 2. In requistion list view, `exportRequisitionList` query will generate a CSV file with particular requistion list data +# +# 3. In requistion list view, `updateRequistionList` mutatation can be used to update or rename the list +# +# 4. In requistion list view, one can edit items quantity, options etc or delete items with `deleteRequisitionListItems` and +# `updateRequisitionListItems` mutation +# +# 5. In requistion list view, select requistion list items and move them , copy them to different requistion list with +# `moveItemsFromRequisitionList` and `copyItemsFromRequisitionList` mutations respectively +# + +type Customer { + requisition_lists( + pageSize: Int = 20, + currentPage: Int = 1, + filter: RequisitionListFilterInput + ): RequisitionLists @doc(description: "Get Requisition Lists of customer") +} + +type RequisitionLists @doc(description: "Provides Customer's Requisition Lists") { + items: [RequisitionList]! @doc(description: "List of Requisition Lists") + page_info: SearchResultPageInfo @doc(description: "Page Information for pagination") + total_count: Int! @doc(description: "Total count of Requisition Lists") +} + +type RequisitionList @doc(description: "Requisition List Type") { + uid: ID! @doc(description: "Unique Identifier of Requisition List") + name: String! @doc(description: "Name of the list") + description: String @doc(description: "Description of the list") + items( + currentPage: Int = 1, + pageSize: Int = 20 + ): RequistionListItems + items_count: Int! @doc(description: "Number of items in list") + updated_at: String @doc(description: "Latest Activity") +} + +type RequistionListItems { + items: [RequisitionListItemInterface]! @doc(description: "Requisition List items list") + page_info: SearchResultPageInfo + total_pages: Int! @doc(description: "total count of req list items") +} + +interface RequisitionListItemInterface @doc(description: "Interface type for Requisition List Item") { + uid: ID! @doc(description:"Unique Identifier of Requisition List Item") + product: ProductInterface! + quantity: Float! @doc(description: "Quantity added") + customizable_options: [SelectedCustomizableOption] @doc(description: "custom Option selected") +} + +type SimpleRequisitionListItem implements RequisitionListItemInterface +@doc(description: "Requisition List Item Implementation that for Simple and Virtual Products") { +} + +type GiftCardRequisitionListItem implements RequisitionListItemInterface +@doc(description: "Requisition List Item Implementation that for GiftCard Products") { + gift_card_options: GiftCardOptions! +} + +type GiftCardOptions { + sender_name: String + sender_email: String + recipient_name: String + recipient_email: String + amount: Money + custom_giftcard_amount: Money + message: String +} + +type GroupedProductRequisitionListItem implements RequisitionListItemInterface { + grouped_products: [GroupedProductItem!]! +} + +type DownloadableRequisitionListItem implements RequisitionListItemInterface +@doc(description: "Requisition List Item Implementation that for Downloadable Products") { + uid: ID! @doc(description: "Unique Identifier of Requisition List Item") + product: ProductInterface! + quantity: Float! @doc(description: "Quantity added") + customizable_options: [SelectedCustomizableOption] @doc(description: "custom Option selected") + links: [DownloadableProductLinks] @doc(description: "DownloadableProductLinks defines characteristics of a downloadable product") + samples: [DownloadableProductSamples] @doc(description: "DownloadableProductSamples defines characteristics of a downloadable product") +} + +type BundleRequisitionListItem implements RequisitionListItemInterface +@doc(description: "Requisition List Item Implementation that for Bundle Products") { + uid: ID! @doc(description: "Unique Identifier of Requisition List Item") + product: ProductInterface! + quantity: Float! @doc(description: "Quantity added") + customizable_options: [SelectedCustomizableOption] @doc(description: "custom Option selected") + bundle_options: [SelectedBundleOption]! @doc(description: "selected bundle options") +} + +type ConfigurableRequisitionListItem implements RequisitionListItemInterface +@doc(description: "Requisition List Item Implementation that for Configurable Products") { + uid: ID! @doc(description: "Unique Identifier of Requisition List Item") + product: ProductInterface! + quantity: Float! @doc(description: "Quantity added") + customizable_options: [SelectedCustomizableOption] @doc(description: "custom Option selected") + configurable_options: [SelectedConfigurableOption] @doc(description: "Configurable options selected") +} + +type Mutation { + createRequisitionList( + input: CreateRequisitionListInput + ): CreateRequisitionListOutput @doc(description: "Create Empty Requisition List") + + updateRequisitionList( + requisitionListUid: ID!, @doc(description: "unique Id of requisition list") + input: UpdateRequisitionListInput + ): UpdateRequisitionListOutput @doc(description: "Rename a requisition list and change description") + + updateRequisitionListItems( + requisitionListUid: ID!, @doc(description: "unique Id of requisition list") + requisitionListItems: [UpdateRequisitionListItemsInput!]! @doc(description: "Items to be updated from requisition list") + ): UpdateRequisitionListItemsOutput @doc(description: "Update Items in requisition list") + + deleteRequisitionList( + requisitionListUid: ID! @doc(description: "unique Id of requisition list") + ): DeleteRequisitionListOutput @doc(description: "Delete a requisition list with Id") + + addProductsToRequisitionList( + requisitionListUid: ID!, @doc(description: "unique Id of requisition list") + requisitionListItems: [RequisitionListItemsInput!]! @doc(description: "Products to be added to requisition list") + ): AddProductsToRequisitionListOutput @doc(description: "Add items to requisition list") + + deleteRequisitionListItems( + requisitionListUid: ID!, @doc(description: "unique Id of requisition list") + requisitionListItemUids: [ID!]! @doc(description: "unique Ids of Items to be deleted from requisition list") + ): DeleteRequisitionListItemsOutput @doc(description: "Delete Items in requisition list") + + addRequisitionListItemsToCart( + requisitionListUid: ID!, @doc(description: "unique Id of requisition list") + requisitionListItemUids: [ID!] @doc(description: "selected requisition list items that are to be added. Not providing this parameter will all items from req. list to the cart") + ): AddRequisitionListItemsToCartOutput @doc(description: "Add Requisition List Items To Customer Cart") + + copyItemsBetweenRequisitionLists( + sourceRequisitionListUid: ID!, @doc(description: "unique Id of source requisition list") + destinationRequisitionListUid: ID, @doc(description: "unique Id of destination requisition list") # If null new requisition list will be created + requisitionListItem: CopyItemsBetweenRequisitionListsInput + ): CopyItemsBetweenRequisitionListsOutput @doc(description: "Copy Items from Requisition List to another requisition list") + + moveItemsBetweenRequisitionLists( + sourceRequisitionListUid: ID!, @doc(description: "unique Id of source requisition list") + destinationRequisitionListUid: ID, @doc(description: "unique Id of destination requisition list") # If null new requisition list will be created + requisitionListItem: MoveItemsBetweenRequisitionListsInput + ): MoveItemsBetweenRequisitionListsOutput @doc(description: "Move Items from Requisition List to another requisition List") + + clearCustomerCart( + cartUid: String! @doc(description: "masked Cart Id") + ): ClearCustomerCartOutput @doc(description: "Clears the cart items") +} + +type CreateRequisitionListInput { + name: String! @doc(description: "name for the list") + description: String @doc(description: "description For the list") +} + +type UpdateRequisitionListInput { + name: String! @doc(description: "new name for list") + description: String @doc(description: "new description For the List") +} + +type CopyItemsBetweenRequisitionListsInput { + requisitionListItemUids: [ID!]! @doc(description: "selected requisition list items that are to be copied from source") +} + +type MoveItemsBetweenRequisitionListsInput { + requisitionListItemUids: [ID!]! @doc(description: "selected requisition list items that are to be moved from source") +} + +type DeleteRequisitionListItemsOutput { + status: Boolean! + requisiton_list: RequisitionList +} + +type UpdateRequisitionListItemsOutput { + requisiton_list: RequisitionList +} + +type AddProductsToRequisitionListOutput { + requisiton_list: RequisitionList +} + +input RequisitionListFilterInput { + requisitionListUid: FilterEqualTypeInput, @doc(description: "Filter Customer Requisition lists with an requisition list ID or list of requisition list IDs") + name: FilterMatchTypeInput @doc(description: "Filter by display name of the Requisition list") +} + +input RequisitionListItemsInput { + sku: String! + quantity: Float! + selected_options: [ID!] @doc(description: "selected option ID") + entered_options: [EnteredOptionInput!] @doc(description: "entered Options ID") +} + +input UpdateRequisitionListItemsInput { + selected_options: [ID!] @doc(description: "selected option ID") + entered_options: [EnteredOptionInput!] @doc(description: "entered Options ID") + quantity: Float +} + +type CreateRequisitionListOutput { + requisiton_list: RequisitionList +} + +type UpdateRequisitionListOutput { + requisiton_list: RequisitionList +} + +type DeleteRequisitionListOutput { + status: Boolean! + requisition_list: RequisitionList +} + +type AddRequisitionListItemsToCartOutput { + status: Boolean! + add_requisition_list_items_to_cart_user_errors: [AddRequisitionListItemToCartUserError]! + cart: Cart # since requisition list is not mutated it is not part of the output +} + +type AddRequisitionListItemToCartUserError { + message: String! + type: AddRequisitionListItemToCartUserErrorType! +} + +enum AddRequisitionListItemToCartUserErrorType { + OUT_OF_STOCK + MAX_QTY_FOR_USER + NOT_AVAILABLE +} + +type CopyItemsBetweenRequisitionListsOutput { + requisiton_list: RequisitionList @doc(description: "Destination Requisition List")# since source requisition list is not mutated it is not part of the output +} + +type MoveItemsBetweenRequisitionListsOutput { + source_requisiton_list: RequisitionList @doc(description: "Source Requisition List") + destination_requisiton_list: RequisitionList @doc(description: "Destination Requisition List") +} + +type ClearCustomerCartOutput { + status: Boolean! + cart: Cart +} diff --git a/design-documents/graph-ql/coverage/cart/AddProductsToCart.graphqls b/design-documents/graph-ql/coverage/cart/AddProductsToCart.graphqls new file mode 100644 index 000000000..7d64c1882 --- /dev/null +++ b/design-documents/graph-ql/coverage/cart/AddProductsToCart.graphqls @@ -0,0 +1,22 @@ +type Mutation { + addProductsToCart(cartId: String!, cartItems: [CartItemInput!]!): AddProductsToCartOutput +} + +input CartItemInput { + sku: String! # already in use + quantity: Float # already in use + parent_sku: String, # will not be used in deprecated methods + parent_quantity: Float, # will not be used in deprecated methods + selected_options: [ID!] # will not be used in deprecated methods + entered_options: [EnteredOptionInput!] # will not be used in deprecated methods +} + +# Place this input type in GraphQl Module to avoid dependency between WishlistGraphQl and QuoteGraphQl (And possible cross dependencies in the future). +input EnteredOptionInput { + uid: ID! + value: String! +} + +type AddProductsToCartOutput { + cart: Cart! +} diff --git a/design-documents/graph-ql/coverage/cart/Cart.graphqls b/design-documents/graph-ql/coverage/cart/Cart.graphqls new file mode 100644 index 000000000..e50fdf455 --- /dev/null +++ b/design-documents/graph-ql/coverage/cart/Cart.graphqls @@ -0,0 +1,195 @@ +type Query { + cart(input: CartQueryInput): CartQueryOutput +} + +input CartQueryInput { + cart_id: ID! +} + +type CartQueryOutput { + cart: Cart +} + +type Cart { + id: ID! @doc(description: "The ID of the cart.") @deprecated(reason: "use uid") + uid: ID! @doc(description: "The unique ID of the cart.") + items: [CartItemInterface] @deprecated(reason: "The `items` field is deprecated. Use `items_v2` instead.") + items_v2( + currentPage: Int = 1 @doc(description: "current page of the customer cart items. default is 1") + pageSize: Int = 20 @doc(description: "page size for the customer cart items. default is 20") + ): CartItems! @doc(description: "Cart items") + applied_coupons: [AppliedCoupon] @doc(description:"An array of `AppliedCoupon` objects. Each object contains the `code` text attribute, which specifies the coupon code. By default Magento supports only one coupon.") + email: String + shipping_addresses: [ShippingCartAddress]! + billing_address: BillingCartAddress + available_payment_methods: [AvailablePaymentMethod] @doc(description: "Available payment methods") + selected_payment_method: SelectedPaymentMethod + prices: CartPrices + total_quantity: Float! + is_virtual: Boolean! +} + +type CartItems { + items: [CartItemInterface]! @doc(description: "Cart items list") + page_info: SearchResultPageInfo + total_count: Int! +} + +type AvailablePaymentMethod { + code: String! @doc(description: "The payment method code") + title: String! @doc(description: "The payment method title.") +} + +type SelectedPaymentMethod { + code: String! @doc(description: "The payment method code") + title: String! @doc(description: "The payment method title.") + purchase_order_number: String @doc(description: "The purchase order number.") +} + +interface CartItemInterface @typeResolver(class: "Magento\\QuoteGraphQl\\Model\\Resolver\\CartItemTypeResolver") { + id: String! @deprecated(reason: "use uid") + uid: ID! @doc(description: "The unique ID of the cart item.") + quantity: Float! + prices: CartItemPrices + product: ProductInterface! + customizable_options: [SelectedCustomizableOption] +} + +type SimpleCartItem implements CartItemInterface @doc(description: "Simple Cart Item") { +} + +type VirtualCartItem implements CartItemInterface @doc(description: "Virtual Cart Item") { +} + +type ConfigurableCartItem implements CartItemInterface { + configured_variant: SimpleProduct! @doc(description: "Simple product corresponding to configured product.") + configurable_options: [SelectedConfigurableOption!] @deprecated(reason: "use configured_options") + configured_options: [SelectedConfigurableOption!] @doc(description: "Configured options for product") +} + +type DownloadableCartItem implements CartItemInterface @doc(description: "Downloadable Cart Item") { + links: [DownloadableProductLinks] @deprecated(description: "Type was renamed from plural to singular, also link now has different ID type") + links_v2: [DownloadableProductLink] @doc(description: "An array containing information about the selected links") + samples: [DownloadableProductSamples] @doc(description: "DownloadableProductSamples defines characteristics of a downloadable product") +} + +type BundleCartItem implements CartItemInterface { + bundle_options: [SelectedBundleOption!] +} + +type GiftCardCartItem implements CartItemInterface { + sender_name: String! + recipient_name: String! + amount: SelectedGiftCardAmount + message: String +} + +type SelectedConfigurableOption { + # Hash which includes option ID, option type and slected values. Can be used to move configurable product to wishlist or gift registry + id_v2: ID! @deprecated(reason: "use uid") + id: Int! @deprecated(reason: "use uid") + uid: ID! @doc(description: "The unique ID of the cart.") + option_label: String! + value_id: Int! + value_label: String! +} + +type DownloadableProductLink @doc(description: "Defines characteristics of a downloadable product") { + # Hash based on option type and slected link. Can be used to move downloadable product to wishlist or gift registry + id: ID! @deprecated(reason: "use uid") + uid: ID! @doc(description: "The unique ID of the cart.") + title: String @doc(description: "The display name of the link") + sort_order: Int @doc(description: "A number indicating the sort order") + price: Float @doc(description: "The price of the downloadable product") + sample_url: String @doc(description: "URL to the downloadable sample") +} + +type DownloadableProductSamples @doc(description: "DownloadableProductSamples defines characteristics of a downloadable product") { + title: String @doc(description: "The display name of the sample") + sort_order: Int @doc(description: "A number indicating the sort order") + sample_url: String @doc(description: "URL to the downloadable sample") +} + +type SelectedBundleOption { + id: Int! @deprecated(reason: "use uid") + uid: ID! @doc(description: "The unique ID of the cart.") + label: String! + type: String! + values: [SelectedBundleOptionValue!]! +} + +type SelectedBundleOptionValue { + id: Int! @deprecated(reason: "use uid") + uid: ID! @doc(description: "The unique ID of the cart.") + label: String! + price: Float! + quantity: Float! + child_sku: String! +} + +type SelectedCustomizableOption { + # Hash which includes option ID, option type and slected values. Can be used to move a product to wishlist or gift registry + id_v2: ID! @deprecated(reason: "use uid") + id: Int! @deprecated(reason: "use uid") + uid: ID! @doc(description: "The unique ID of the selected option.") + label: String! + is_required: Boolean! + values: [SelectedCustomizableOptionValue!]! + sort_order: Int! +} + +type SelectedCustomizableOptionValue { + id: Int! @deprecated(reason: "use uid") + uid: ID! @doc(description: "The unique ID of the selected option.") + label: String! + value: String! + price: CartItemSelectedOptionValuePrice! +} + +type CartItemSelectedOptionValuePrice { + value: Float! + units: String! + type: PriceTypeEnum! +} + +type SelectedGiftCardAmount { + # Hash from the type of the option and value + id: ID! @deprecated(reason: "use uid") + uid: ID! @doc(description: "The unique ID of the selected option.") + value: Money! +} + +enum PriceTypeEnum { + FIXED + PERCENT + DYNAMIC +} + +type CheckoutCustomer { + is_guest: Boolean! + email: String! + prefix: String + first_name: String! + last_name: String! + middle_name: String + suffix: String + gender: GenderEnum + date_of_birth: String + vat_number: String # Do we need it at all on storefront? Do we need more details +} + +enum GenderEnum { + MALE + FEMALE +} + +type CheckoutPaymentMethod { + code: String! + label: String! + balance: Money + sort_order: Int +} + +type CartGiftCard { + code: String! +} diff --git a/design-documents/graph-ql/coverage/CartAddressOperations.graphqls b/design-documents/graph-ql/coverage/cart/CartAddressOperations.graphqls similarity index 90% rename from design-documents/graph-ql/coverage/CartAddressOperations.graphqls rename to design-documents/graph-ql/coverage/cart/CartAddressOperations.graphqls index 42630f71d..9b2622938 100644 --- a/design-documents/graph-ql/coverage/CartAddressOperations.graphqls +++ b/design-documents/graph-ql/coverage/cart/CartAddressOperations.graphqls @@ -22,7 +22,8 @@ input SetBillingAddressOnCartInput { input BillingAddressInput { customer_address_id: Int address: CartAddressInput - use_for_shipping: Boolean + use_for_shipping: Boolean @doc(description: "Indicates whether to additionally set the shipping address based on the provided billing address") + same_as_shipping: Boolean @doc(description: "Indicates whether to set the billing address based on the existing shipping address on the cart") } input SetShippingAddressesOnCartInput { diff --git a/design-documents/graph-ql/coverage/CartPrices.graphqls b/design-documents/graph-ql/coverage/cart/CartPrices.graphqls similarity index 100% rename from design-documents/graph-ql/coverage/CartPrices.graphqls rename to design-documents/graph-ql/coverage/cart/CartPrices.graphqls diff --git a/design-documents/graph-ql/coverage/CartPromotions.graphqls b/design-documents/graph-ql/coverage/cart/CartPromotions.graphqls similarity index 100% rename from design-documents/graph-ql/coverage/CartPromotions.graphqls rename to design-documents/graph-ql/coverage/cart/CartPromotions.graphqls diff --git a/design-documents/graph-ql/coverage/CouponOperations.graphqls b/design-documents/graph-ql/coverage/cart/CouponOperations.graphqls similarity index 100% rename from design-documents/graph-ql/coverage/CouponOperations.graphqls rename to design-documents/graph-ql/coverage/cart/CouponOperations.graphqls diff --git a/design-documents/graph-ql/coverage/add-items-to-cart-single-mutation.md b/design-documents/graph-ql/coverage/cart/add-items-to-cart-single-mutation.md similarity index 79% rename from design-documents/graph-ql/coverage/add-items-to-cart-single-mutation.md rename to design-documents/graph-ql/coverage/cart/add-items-to-cart-single-mutation.md index 16822c1d9..dc8f7915a 100644 --- a/design-documents/graph-ql/coverage/add-items-to-cart-single-mutation.md +++ b/design-documents/graph-ql/coverage/cart/add-items-to-cart-single-mutation.md @@ -31,12 +31,12 @@ Each product may have options. Option can be of 2 types (see example below): ] ``` -We can consider "Selected Option" and "ID for Entered Option" as UUID. They meet the criteria: +We can consider "Selected Option" and "ID for Entered Option" as a unique identifier. They meet the criteria: - "Selected Option" represents option value, while "ID for Entered Option" represents option - Must be unique across different options - Returned from server -- Used by client as is +- Used by client as is (opaque) Selected options can be used for: - Customizable options such as dropdwon, radiobutton, checkbox, etc @@ -51,14 +51,22 @@ Entered options: #### Option implementation -Product schema should be extended in order to provide option identifier (aka first iteration of "UUID"). -Until introducing UUID lets name this identifier as *"id_v2" +Product schema should be extended in order to provide option identifier (aka first iteration of "UUID"). This field will be named `uid` (unique identifier, or universal ID). -Option *id_v2* is `base64` encoded string, that encodes details for each option and in most cases can be presented as +--- +**Note on Field Name** +`uid` was chosen for several reasons: +- `id` is already reserved as an `Int` field in most types, and we want to avoid breaking changes +- `uuid` implies a specific encoding algorithm, and the client shouldn't know or care +- `id_v2` has a temporary-sounding name (`id_v2` makes me think `id_v3` will be coming). However, there is no need to change field names when changing the format of a field that uses the `ID` type, because clients are not meant to be parsing/formatting/inspecting `ID` values. The `uid` field can hold an integer or a base64-encoded value today, and a real UUID in the future, and it will _not_ be a breaking change. `ID` values are always serialized to a string +--- + + +For product options, for now, *uid* is a `base64` encoded string, wrapper in an `ID` type, that encodes details for each option and in most cases can be presented as `base64("//")` -For example, for customizable drop-down option "Color(id = 1), with values Red(id = 1), Green(id = 2)" id_v2 for Color:Red will looks like `"Y3VzdG9tLW9wdGlvbi8xLzE=" => base64("custom-option/1/1")` +For example, for customizable drop-down option "Color(id = 1), with values Red(id = 1), Green(id = 2)" `uid` for Color:Red will looks like `"Y3VzdG9tLW9wdGlvbi8xLzE=" => base64("custom-option/1/1")` -Here is a GQL query that shows how to add a new field "id_v2: String!" to cover existing cases: +Here is a GQL query that shows how to add a new field "uid: ID!" to cover existing cases: ``` graphql @@ -73,7 +81,7 @@ query { ... on CustomizableRadioOption { title value { - id_v2 # introduce new id_v2 field in CustomizableRadioValue + uid # introduce new uid field in CustomizableRadioValue option_type_id title } @@ -81,7 +89,7 @@ query { ... on CustomizableDropDownOption { title value { - id_v2 # introduce new id_v2 field in CustomizableDropDownValue + uid # introduce new uid field in CustomizableDropDownValue # see \Magento\QuoteGraphQl\Model\Cart\BuyRequest\CustomizableOptionsDataProvider option_type_id title @@ -92,7 +100,7 @@ query { } ... on ConfigurableProduct { variants { attributes { - id_v2 # introduce new id_v2 field in ConfigurableAttributeOption (format: configurable//) + uid # introduce new uid field in ConfigurableAttributeOption (format: configurable//) # see \Magento\ConfigurableProductGraphQl\Model\Cart\BuyRequest\SuperAttributeDataProvider code value_index @@ -100,7 +108,7 @@ query { } } ... on DownloadableProduct { downloadable_product_links { - id_v2 # introduce new id_v2 field in DownloadableProductLinks (format: downloadable/link/) + uid # introduce new uid field in DownloadableProductLinks (format: downloadable/link/) # see \Magento\DownloadableGraphQl\Model\Cart\BuyRequest\DownloadableLinksDataProvider title } @@ -109,7 +117,7 @@ query { sku title options { - id_v2 # introduce new id_v2 field in BundleItemOption (format: bundle///) + uid # introduce new uid field in BundleItemOption (format: bundle///) # see \Magento\BundleGraphQl\Model\Cart\BuyRequest\BundleDataProvider id label @@ -117,7 +125,7 @@ query { } } ... on GiftCardProduct { giftcard_amounts { - id_v2 # introduce new id_v2 field in GiftCardAmounts (format: giftcard/...TBD) + uid # introduce new uid field in GiftCardAmounts (format: giftcard/...TBD) # see \Magento\GiftCard\Model\Quote\Item\CartItemProcessor::convertToBuyRequest value_id website_id @@ -154,7 +162,7 @@ query { In this example we want to add _personalized blue cup to cart_ to cart. - - `selected_options` - predefined and selected by customer options. `base64` encoding will help to use UUID in future. + - `selected_options` - predefined and selected by customer options. `base64` encoding is temporary until Magento supports UUIDs, but this is unknown to the client and purely a server implementation detail. :warning: The encoded value will be returned from server and should be used by client as is. In this example values will be following: diff --git a/design-documents/graph-ql/coverage/shared-cart.md b/design-documents/graph-ql/coverage/cart/shared-cart.md similarity index 100% rename from design-documents/graph-ql/coverage/shared-cart.md rename to design-documents/graph-ql/coverage/cart/shared-cart.md diff --git a/design-documents/graph-ql/coverage/catalog.graphqls b/design-documents/graph-ql/coverage/catalog.graphqls new file mode 100644 index 000000000..6579abc1f --- /dev/null +++ b/design-documents/graph-ql/coverage/catalog.graphqls @@ -0,0 +1,509 @@ +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. + +type Query { + products ( + search: String @doc(description: "Performs a full-text search using the specified key words."), + filter: ProductAttributeFilterInput @doc(description: "Identifies which product attributes to search for and return."), + pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. This attribute is optional."), + currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1."), + sort: ProductAttributeSortInput @doc(description: "Specifies which attributes to sort on, and whether to return the results in ascending or descending order.") + ): Products + @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Products") @doc(description: "The products query searches for products that match the criteria specified in the search and filter attributes.") @cache(cacheIdentity: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Identity") + category ( + # Implementation Note: For back-compat reasons, this query must: + # - Accept _either_ `uid` or `id` + # - Set a field error when both `uid` or `id` are used + id: Int @deprecated(reason: "Use the `uid` argument instead") + uid: ID @doc(description: "Unique identifier from objects implementing `CategoryInterface`") + ): CategoryTree + @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\CategoryTree") @doc(description: "The category query searches for categories that match the criteria specified in the search and filter attributes.") @deprecated(reason: "Use 'categoryList' query instead of 'category' query") @cache(cacheIdentity: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\CategoryTreeIdentity") + categoryList( + filters: CategoryFilterInput @doc(description: "Identifies which Category filter inputs to search for and return.") + ): [CategoryTree] @doc(description: "Returns an array of categories based on the specified filters.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\CategoryList") @cache(cacheIdentity: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\CategoriesIdentity") + categories ( + filters: CategoryFilterInput @doc(description: "Identifies which Category filter inputs to search for and return.") + pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. This attribute is optional.") + currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1.") + ): CategoryResult @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\CategoriesQuery") @cache(cacheIdentity: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\CategoriesIdentity") +} + +type Price @doc(description: "Price is deprecated, replaced by ProductPrice. The Price object defines the price of a product as well as any tax-related adjustments.") { + amount: Money @deprecated(reason: "Price is deprecated, use ProductPrice.") @doc(description: "The price of a product plus a three-letter currency code.") + adjustments: [PriceAdjustment] @deprecated(reason: "Price is deprecated, use ProductPrice.") @doc(description: "An array that provides information about tax, weee, or weee_tax adjustments.") +} + +type PriceAdjustment @doc(description: "PriceAdjustment is deprecated. Taxes will be included or excluded in the price. The PricedAdjustment object defines the amount of money to apply as an adjustment, the type of adjustment to apply, and whether the item is included or excluded from the adjustment.") { + amount: Money @doc(description: "The amount of the price adjustment and its currency code.") + code: PriceAdjustmentCodesEnum @deprecated(reason: "PriceAdjustment is deprecated.") @doc(description: "Indicates whether the adjustment involves tax, weee, or weee_tax.") + description: PriceAdjustmentDescriptionEnum @deprecated(reason: "PriceAdjustment is deprecated.") @doc(description: "Indicates whether the entity described by the code attribute is included or excluded from the adjustment.") +} + +enum PriceAdjustmentCodesEnum @doc(description: "PriceAdjustment.code is deprecated. This enumeration contains values defined in modules other than the Catalog module.") { +} + +enum PriceAdjustmentDescriptionEnum @doc(description: "PriceAdjustmentDescriptionEnum is deprecated. This enumeration states whether a price adjustment is included or excluded.") { + INCLUDED + EXCLUDED +} + +enum PriceTypeEnum @doc(description: "This enumeration the price type.") { + FIXED + PERCENT + DYNAMIC +} + +type ProductPrices @doc(description: "ProductPrices is deprecated, replaced by PriceRange. The ProductPrices object contains the regular price of an item, as well as its minimum and maximum prices. Only composite products, which include bundle, configurable, and grouped products, can contain a minimum and maximum price.") { + minimalPrice: Price @deprecated(reason: "Use PriceRange.minimum_price.") @doc(description: "The lowest possible final price for all the options defined within a composite product. If you are specifying a price range, this would be the from value.") + maximalPrice: Price @deprecated(reason: "Use PriceRange.maximum_price.") @doc(description: "The highest possible final price for all the options defined within a composite product. If you are specifying a price range, this would be the to value.") + regularPrice: Price @deprecated(reason: "Use regular_price from PriceRange.minimum_price or PriceRange.maximum_price.") @doc(description: "The base price of a product.") +} + +type PriceRange @doc(description: "Price range for a product. If the product has a single price, the minimum and maximum price will be the same."){ + minimum_price: ProductPrice! @doc(description: "The lowest possible price for the product.") + maximum_price: ProductPrice @doc(description: "The highest possible price for the product.") +} + +type ProductPrice @doc(description: "Represents a product price.") { + regular_price: Money! @doc(description: "The regular price of the product.") + final_price: Money! @doc(description: "The final price of the product after discounts applied.") + discount: ProductDiscount @doc(description: "The price discount. Represents the difference between the regular and final price.") +} + +type ProductDiscount @doc(description: "A discount applied to a product price.") { + percent_off: Float @doc(description: "The discount expressed a percentage.") + amount_off: Float @doc(description: "The actual value of the discount.") +} + +type ProductLinks implements ProductLinksInterface @doc(description: "ProductLinks is an implementation of ProductLinksInterface.") { +} + +interface ProductLinksInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\ProductLinkTypeResolverComposite") @doc(description:"ProductLinks contains information about linked products, including the link type and product type of each item.") { + sku: String @doc(description: "The identifier of the linked product.") + link_type: String @doc(description: "One of related, associated, upsell, or crosssell.") + linked_product_sku: String @doc(description: "The SKU of the linked product.") + linked_product_type: String @doc(description: "The type of linked product (simple, virtual, bundle, downloadable, grouped, configurable).") + position: Int @doc(description: "The position within the list of product links.") +} + +interface ProductInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\ProductInterfaceTypeResolverComposite") @doc(description: "The ProductInterface contains attributes that are common to all types of products. Note that descriptions may not be available for custom and EAV attributes.") { + id: Int @deprecated(description: "Use the `uid` field instead") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\EntityIdToId") + uid: ID! @doc(description: "Unique identifier for objects implementing `ProductInterface`") + name: String @doc(description: "The product name. Customers use this name to identify the product.") + sku: String @doc(description: "A number or code assigned to a product to identify the product, options, price, and manufacturer.") + description: ComplexTextValue @doc(description: "Detailed information about the product. The value can include simple HTML tags.") @resolver(class: "\\Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductComplexTextAttribute") + short_description: ComplexTextValue @doc(description: "A short description of the product. Its use depends on the theme.") @resolver(class: "\\Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductComplexTextAttribute") + special_price: Float @doc(description: "The discounted price of the product.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\SpecialPrice") + special_from_date: String @doc(description: "The beginning date that a product has a special price.") + special_to_date: String @doc(description: "The end date that a product has a special price.") + attribute_set_id: Int @doc(description: "The attribute set assigned to the product.") + meta_title: String @doc(description: "A string that is displayed in the title bar and tab of the browser and in search results lists.") + meta_keyword: String @doc(description: "A comma-separated list of keywords that are visible only to search engines.") + meta_description: String @doc(description: "A brief overview of the product for search results listings, maximum 255 characters.") + image: ProductImage @doc(description: "The relative path to the main image on the product page.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductImage") + small_image: ProductImage @doc(description: "The relative path to the small image, which is used on catalog pages.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductImage") + thumbnail: ProductImage @doc(description: "The relative path to the product's thumbnail image.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\ProductImage") + new_from_date: String @doc(description: "The beginning date for new product listings, and determines if the product is featured as a new product.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\NewFromTo") + new_to_date: String @doc(description: "The end date for new product listings.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\NewFromTo") + tier_price: Float @deprecated(reason: "Use price_tiers for product tier price information.") @doc(description: "The price when tier pricing is in effect and the items purchased threshold has been reached.") + options_container: String @doc(description: "If the product has multiple options, determines where they appear on the product page.") + created_at: String @doc(description: "Timestamp indicating when the product was created.") + updated_at: String @doc(description: "Timestamp indicating when the product was updated.") + country_of_manufacture: String @doc(description: "The product's country of origin.") + type_id: String @doc(description: "One of simple, virtual, bundle, downloadable, grouped, or configurable.") @deprecated(reason: "Use __typename instead.") + websites: [Website] @doc(description: "An array of websites in which the product is available.") @deprecated(reason: "The field should not be used on the storefront.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Websites") + product_links: [ProductLinksInterface] @doc(description: "An array of ProductLinks objects.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\BatchProductLinks") + media_gallery_entries: [MediaGalleryEntry] @deprecated(reason: "Use product's `media_gallery` instead") @doc(description: "An array of MediaGalleryEntry objects.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\MediaGalleryEntries") + price: ProductPrices @deprecated(reason: "Use price_range for product price information.") @doc(description: "A ProductPrices object, indicating the price of an item.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Price") + price_range: PriceRange! @doc(description: "A PriceRange object, indicating the range of prices for the product") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\PriceRange") + gift_message_available: String @doc(description: "Indicates whether a gift message is available.") + manufacturer: Int @doc(description: "A number representing the product's manufacturer.") + categories: [CategoryInterface] @doc(description: "The categories assigned to a product.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Categories") @cache(cacheIdentity: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\CategoriesIdentity") + canonical_url: String @doc(description: "Relative canonical URL. This value is returned only if the system setting 'Use Canonical Link Meta Tag For Products' is enabled") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\CanonicalUrl") + media_gallery: [MediaGalleryInterface] @doc(description: "An array of Media Gallery objects.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\MediaGallery") +} + +interface PhysicalProductInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\ProductInterfaceTypeResolverComposite") @doc(description: "PhysicalProductInterface contains attributes specific to tangible products.") { + weight: Float @doc(description: "The weight of the item, in units defined by the store.") +} + +type CustomizableAreaOption implements CustomizableOptionInterface @doc(description: "CustomizableAreaOption contains information about a text area that is defined as part of a customizable option.") { + value: CustomizableAreaValue @doc(description: "An object that defines a text area.") + product_sku: String @doc(description: "The Stock Keeping Unit of the base product.") +} + +type CustomizableAreaValue @doc(description: "CustomizableAreaValue defines the price and sku of a product whose page contains a customized text area.") { + price: Float @doc(description: "The price assigned to this option.") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC.") + sku: String @doc(description: "The Stock Keeping Unit for this option.") + max_characters: Int @doc(description: "The maximum number of characters that can be entered for this customizable option.") + uid: ID! @doc(description: "A string that encodes option details.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\CustomizableEnteredOptionValueUid") # A Base64 string that encodes option details. +} + +type CategoryTree implements CategoryInterface @doc(description: "Category Tree implementation.") { + children: [CategoryTree] @doc(description: "Child categories tree.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\CategoryTree") +} + +type CategoryResult @doc(description: "A collection of CategoryTree objects and pagination information.") { + items: [CategoryTree] @doc(description: "A list of categories that match the filter criteria.") + page_info: SearchResultPageInfo @doc(description: "An object that includes the page_info and currentPage values specified in the query.") + total_count: Int @doc(description: "The total number of categories that match the criteria.") +} + +type CustomizableDateOption implements CustomizableOptionInterface @doc(description: "CustomizableDateOption contains information about a date picker that is defined as part of a customizable option.") { + value: CustomizableDateValue @doc(description: "An object that defines a date field in a customizable option.") + product_sku: String @doc(description: "The Stock Keeping Unit of the base product.") +} + +type CustomizableDateValue @doc(description: "CustomizableDateValue defines the price and sku of a product whose page contains a customized date picker.") { + price: Float @doc(description: "The price assigned to this option.") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC.") + sku: String @doc(description: "The Stock Keeping Unit for this option.") + uid: ID! @doc(description: "A string that encodes option details.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\CustomizableEnteredOptionValueUid") # A Base64 string that encodes option details. +} + +type CustomizableDropDownOption implements CustomizableOptionInterface @doc(description: "CustomizableDropDownOption contains information about a drop down menu that is defined as part of a customizable option.") { + value: [CustomizableDropDownValue] @doc(description: "An array that defines the set of options for a drop down menu.") +} + +type CustomizableDropDownValue @doc(description: "CustomizableDropDownValue defines the price and sku of a product whose page contains a customized drop down menu.") { + option_type_id: Int @doc(description: "The ID assigned to the value.") + price: Float @doc(description: "The price assigned to this option.") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC.") + sku: String @doc(description: "The Stock Keeping Unit for this option.") + title: String @doc(description: "The display name for this option.") + sort_order: Int @doc(description: "The order in which the option is displayed.") + uid: ID! @doc(description: "A string that encodes option details.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\CustomizableSelectedOptionValueUid") # A Base64 string that encodes option details. +} + +type CustomizableMultipleOption implements CustomizableOptionInterface @doc(description: "CustomizableMultipleOption contains information about a multiselect that is defined as part of a customizable option.") { + value: [CustomizableMultipleValue] @doc(description: "An array that defines the set of options for a multiselect.") +} + +type CustomizableMultipleValue @doc(description: "CustomizableMultipleValue defines the price and sku of a product whose page contains a customized multiselect.") { + option_type_id: Int @doc(description: "The ID assigned to the value.") + price: Float @doc(description: "The price assigned to this option.") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC.") + sku: String @doc(description: "The Stock Keeping Unit for this option.") + title: String @doc(description: "The display name for this option.") + sort_order: Int @doc(description: "The order in which the option is displayed.") + uid: ID! @doc(description: "A string that encodes option details.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\CustomizableSelectedOptionValueUid") +} + +type CustomizableFieldOption implements CustomizableOptionInterface @doc(description: "CustomizableFieldOption contains information about a text field that is defined as part of a customizable option.") { + value: CustomizableFieldValue @doc(description: "An object that defines a text field.") + product_sku: String @doc(description: "The Stock Keeping Unit of the base product.") +} + +type CustomizableFieldValue @doc(description: "CustomizableFieldValue defines the price and sku of a product whose page contains a customized text field.") { + price: Float @doc(description: "The price of the custom value.") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC.") + sku: String @doc(description: "The Stock Keeping Unit for this option.") + max_characters: Int @doc(description: "The maximum number of characters that can be entered for this customizable option.") + uid: ID! @doc(description: "A string that encodes option details.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\CustomizableEnteredOptionValueUid") # A Base64 string that encodes option details. +} + +type CustomizableFileOption implements CustomizableOptionInterface @doc(description: "CustomizableFileOption contains information about a file picker that is defined as part of a customizable option.") { + value: CustomizableFileValue @doc(description: "An object that defines a file value.") + product_sku: String @doc(description: "The Stock Keeping Unit of the base product.") +} + +type CustomizableFileValue @doc(description: "CustomizableFileValue defines the price and sku of a product whose page contains a customized file picker.") { + price: Float @doc(description: "The price assigned to this option.") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC.") + sku: String @doc(description: "The Stock Keeping Unit for this option.") + file_extension: String @doc(description: "The file extension to accept.") + image_size_x: Int @doc(description: "The maximum width of an image.") + image_size_y: Int @doc(description: "The maximum height of an image.") + uid: ID! @doc(description: "A string that encodes option details.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\CustomizableEnteredOptionValueUid") # A Base64 string that encodes option details. +} + +interface MediaGalleryInterface @doc(description: "Contains basic information about a product image or video.") @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\MediaGalleryTypeResolver") { + url: String @doc(description: "The URL of the product image or video.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\MediaGallery\\Url") + label: String @doc(description: "The label of the product image or video.") + position: Int @doc(description: "The media item's position after it has been sorted.") + disabled: Boolean @doc(description: "Whether the image is hidden from view.") +} + +type ProductImage implements MediaGalleryInterface @doc(description: "Product image information. Contains the image URL and label.") { +} + +type ProductVideo implements MediaGalleryInterface @doc(description: "Contains information about a product video.") { + video_content: ProductMediaGalleryEntriesVideoContent @doc(description: "Contains a ProductMediaGalleryEntriesVideoContent object.") +} + +interface CustomizableOptionInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\CustomizableOptionTypeResolver") @doc(description: "The CustomizableOptionInterface contains basic information about a customizable option. It can be implemented by several types of configurable options.") { + title: String @doc(description: "The display name for this option.") + required: Boolean @doc(description: "Indicates whether the option is required.") + sort_order: Int @doc(description: "The order in which the option is displayed.") + option_id: Int @doc(description: "Option ID.") +} + +interface CustomizableProductInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\ProductInterfaceTypeResolverComposite") @doc(description: "CustomizableProductInterface contains information about customizable product options.") { + options: [CustomizableOptionInterface] @doc(description: "An array of options for a customizable product.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Options") +} + +interface CategoryInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\CategoryInterfaceTypeResolver") @doc(description: "CategoryInterface contains the full set of attributes that can be returned in a category search.") { + id: Int @deprecated(reason: "Use `CategoryInterface.uid instead") + uid: ID! @doc(description: "Unique identifier for a Category") + description: String @doc(description: "An optional description of the category.") @resolver(class: "\\Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\CategoryHtmlAttribute") + name: String @doc(description: "The display name of the category.") + path: String @doc(description: "Category Path.") + path_in_store: String @doc(description: "Category path in store.") + url_key: String @doc(description: "The url key assigned to the category.") + url_path: String @doc(description: "The url path assigned to the category.") + canonical_url: String @doc(description: "Relative canonical URL. This value is returned only if the system setting 'Use Canonical Link Meta Tag For Categories' is enabled") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\CanonicalUrl") + position: Int @doc(description: "The position of the category relative to other categories at the same level in tree.") + level: Int @doc(description: "Indicates the depth of the category within the tree.") + created_at: String @doc(description: "Timestamp indicating when the category was created.") + updated_at: String @doc(description: "Timestamp indicating when the category was updated.") + product_count: Int @doc(description: "The number of products in the category that are marked as visible. By default, in complex products, parent products are visible, but their child products are not.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\ProductsCount") + default_sort_by: String @doc(description: "The attribute to use for sorting.") + products( + pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. This attribute is optional."), + currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1."), + sort: ProductAttributeSortInput @doc(description: "Specifies which attributes to sort on, and whether to return the results in ascending or descending order.") + ): CategoryProducts @doc(description: "The list of products assigned to the category.") @cache(cacheIdentity: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\Identity") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\Products") + breadcrumbs: [Breadcrumb] @doc(description: "Breadcrumbs, parent categories info.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\Breadcrumbs") +} + +type Breadcrumb @doc(description: "Breadcrumb item."){ + category_id: Int @deprecated(reason: "Use the `category_interface_uid` field instead") + category_interface_uid: ID! @doc(description: "Unique identifier from objects implementing CategoryInterface") + category_name: String @doc(description: "Category name.") + category_level: Int @doc(description: "Category level.") + category_url_key: String @doc(description: "Category URL key.") + category_url_path: String @doc(description: "Category URL path.") +} + +type CustomizableRadioOption implements CustomizableOptionInterface @doc(description: "CustomizableRadioOption contains information about a set of radio buttons that are defined as part of a customizable option.") { + value: [CustomizableRadioValue] @doc(description: "An array that defines a set of radio buttons.") +} + +type CustomizableRadioValue @doc(description: "CustomizableRadioValue defines the price and sku of a product whose page contains a customized set of radio buttons.") { + option_type_id: Int @doc(description: "The ID assigned to the value.") + price: Float @doc(description: "The price assigned to this option.") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC.") + sku: String @doc(description: "The Stock Keeping Unit for this option.") + title: String @doc(description: "The display name for this option.") + sort_order: Int @doc(description: "The order in which the radio button is displayed.") + uid: ID! @doc(description: "A string that encodes option details.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\CustomizableSelectedOptionValueUid") # A Base64 string that encodes option details. +} + +type CustomizableCheckboxOption implements CustomizableOptionInterface @doc(description: "CustomizableCheckbbixOption contains information about a set of checkbox values that are defined as part of a customizable option.") { + value: [CustomizableCheckboxValue] @doc(description: "An array that defines a set of checkbox values.") +} + +type CustomizableCheckboxValue @doc(description: "CustomizableCheckboxValue defines the price and sku of a product whose page contains a customized set of checkbox values.") { + option_type_id: Int @doc(description: "The ID assigned to the value.") + price: Float @doc(description: "The price assigned to this option.") + price_type: PriceTypeEnum @doc(description: "FIXED, PERCENT, or DYNAMIC.") + sku: String @doc(description: "The Stock Keeping Unit for this option.") + title: String @doc(description: "The display name for this option.") + sort_order: Int @doc(description: "The order in which the checkbox value is displayed.") + uid: ID! @doc(description: "A string that encodes option details.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Product\\CustomizableSelectedOptionValueUid") # A Base64 string that encodes option details. +} + +type VirtualProduct implements ProductInterface, CustomizableProductInterface @doc(description: "A virtual product is non-tangible product that does not require shipping and is not kept in inventory.") { +} + +type SimpleProduct implements ProductInterface, PhysicalProductInterface, CustomizableProductInterface @doc(description: "A simple product is tangible and are usually sold as single units or in fixed quantities.") +{ +} + +type Products @doc(description: "The Products object is the top-level object returned in a product search.") { + items: [ProductInterface] @doc(description: "An array of products that match the specified search criteria.") + page_info: SearchResultPageInfo @doc(description: "An object that includes the page_info and currentPage values specified in the query.") + total_count: Int @doc(description: "The number of products that are marked as visible. By default, in complex products, parent products are visible, but their child products are not.") + filters: [LayerFilter] @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\LayerFilters") @doc(description: "Layered navigation filters array.") @deprecated(reason: "Use aggregations instead") + aggregations (filter: AggregationsFilterInput): [Aggregation] @doc(description: "Layered navigation aggregations with filters.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Aggregations") + sort_fields: SortFields @doc(description: "An object that includes the default sort field and all available sort fields.") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\Category\\SortFields") +} + +input AggregationsFilterInput @doc(description: "An input object that specifies the filters used in product aggregations.") { + category: AggregationsCategoryFilterInput @doc(description: "Filter category aggregations in layered navigation.") +} + +input AggregationsCategoryFilterInput @doc(description: "Filter category aggregations in layered navigation."){ + includeDirectChildrenOnly: Boolean = false @doc(description: "Indicates whether to include only direct subcategories or all children categories at all levels.") +} + +type CategoryProducts @doc(description: "The category products object returned in the Category query.") { + items: [ProductInterface] @doc(description: "An array of products that are assigned to the category.") + page_info: SearchResultPageInfo @doc(description: "An object that includes the page_info and currentPage values specified in the query.") + total_count: Int @doc(description: "The number of products in the category that are marked as visible. By default, in complex products, parent products are visible, but their child products are not.") +} + +input ProductAttributeFilterInput @doc(description: "ProductAttributeFilterInput defines the filters to be used in the search. A filter contains at least one attribute, a comparison operator, and the value that is being searched for.") { + category_id: FilterEqualTypeInput @doc(description: "Filter product by category id") +} + +input CategoryFilterInput @doc(description: "CategoryFilterInput defines the filters to be used in the search. A filter contains at least one attribute, a comparison operator, and the value that is being searched for.") +{ + ids: FilterEqualTypeInput @doc(description: "Filter by category ID that uniquely identifies the category.") + url_key: FilterEqualTypeInput @doc(description: "Filter by the part of the URL that identifies the category.") + name: FilterMatchTypeInput @doc(description: "Filter by the display name of the category.") + url_path: FilterEqualTypeInput @doc(description: "Filter by the URL path for the category.") +} + +input ProductFilterInput @doc(description: "ProductFilterInput is deprecated, use @ProductAttributeFilterInput instead. ProductFilterInput defines the filters to be used in the search. A filter contains at least one attribute, a comparison operator, and the value that is being searched for.") { + name: FilterTypeInput @doc(description: "The product name. Customers use this name to identify the product.") + sku: FilterTypeInput @doc(description: "A number or code assigned to a product to identify the product, options, price, and manufacturer.") + description: FilterTypeInput @doc(description: "Detailed information about the product. The value can include simple HTML tags.") + short_description: FilterTypeInput @doc(description: "A short description of the product. Its use depends on the theme.") + price: FilterTypeInput @doc(description: "The price of an item.") + special_price: FilterTypeInput @doc(description: "The discounted price of the product. Do not include the currency code.") + special_from_date: FilterTypeInput @doc(description: "The beginning date that a product has a special price.") + special_to_date: FilterTypeInput @doc(description: "The end date that a product has a special price.") + weight: FilterTypeInput @doc(description: "The weight of the item, in units defined by the store.") + manufacturer: FilterTypeInput @doc(description: "A number representing the product's manufacturer.") + meta_title: FilterTypeInput @doc(description: "A string that is displayed in the title bar and tab of the browser and in search results lists.") + meta_keyword: FilterTypeInput @doc(description: "A comma-separated list of keywords that are visible only to search engines.") + meta_description: FilterTypeInput @doc(description: "A brief overview of the product for search results listings, maximum 255 characters.") + image: FilterTypeInput @doc(description: "The relative path to the main image on the product page.") + small_image: FilterTypeInput @doc(description: "The relative path to the small image, which is used on catalog pages.") + thumbnail: FilterTypeInput @doc(description: "The relative path to the product's thumbnail image.") + tier_price: FilterTypeInput @doc(description: "The price when tier pricing is in effect and the items purchased threshold has been reached.") + news_from_date: FilterTypeInput @doc(description: "The beginning date for new product listings, and determines if the product is featured as a new product.") + news_to_date: FilterTypeInput @doc(description: "The end date for new product listings.") + custom_layout_update: FilterTypeInput @doc(description: "XML code that is applied as a layout update to the product page.") + min_price: FilterTypeInput @doc(description:"The numeric minimal price of the product. Do not include the currency code.") + max_price: FilterTypeInput @doc(description:"The numeric maximal price of the product. Do not include the currency code.") + category_id: FilterTypeInput @doc(description: "Category ID the product belongs to.") + options_container: FilterTypeInput @doc(description: "If the product has multiple options, determines where they appear on the product page.") + required_options: FilterTypeInput @doc(description: "Indicates whether the product has required options.") + has_options: FilterTypeInput @doc(description: "Indicates whether additional attributes have been created for the product.") + image_label: FilterTypeInput @doc(description: "The label assigned to a product image.") + small_image_label: FilterTypeInput @doc(description: "The label assigned to a product's small image.") + thumbnail_label: FilterTypeInput @doc(description: "The label assigned to a product's thumbnail image.") + created_at: FilterTypeInput @doc(description: "Timestamp indicating when the product was created.") + updated_at: FilterTypeInput @doc(description: "Timestamp indicating when the product was updated.") + country_of_manufacture: FilterTypeInput @doc(description: "The product's country of origin.") + custom_layout: FilterTypeInput @doc(description: "The name of a custom layout.") + gift_message_available: FilterTypeInput @doc(description: "Indicates whether a gift message is available.") + or: ProductFilterInput @doc(description: "The keyword required to perform a logical OR comparison.") +} + +type ProductMediaGalleryEntriesContent @doc(description: "ProductMediaGalleryEntriesContent contains an image in base64 format and basic information about the image.") { + base64_encoded_data: String @doc(description: "The image in base64 format.") + type: String @doc(description: "The MIME type of the file, such as image/png.") + name: String @doc(description: "The file name of the image.") +} + +type ProductMediaGalleryEntriesVideoContent @doc(description: "ProductMediaGalleryEntriesVideoContent contains a link to a video file and basic information about the video.") { + media_type: String @doc(description: "Must be external-video.") + video_provider: String @doc(description: "Describes the video source.") + video_url: String @doc(description: "The URL to the video.") + video_title: String @doc(description: "The title of the video.") + video_description: String @doc(description: "A description of the video.") + video_metadata: String @doc(description: "Optional data about the video.") +} + +input ProductSortInput @doc(description: "ProductSortInput is deprecated, use @ProductAttributeSortInput instead. ProductSortInput specifies the attribute to use for sorting search results and indicates whether the results are sorted in ascending or descending order.") { + name: SortEnum @doc(description: "The product name. Customers use this name to identify the product.") + sku: SortEnum @doc(description: "A number or code assigned to a product to identify the product, options, price, and manufacturer.") + description: SortEnum @doc(description: "Detailed information about the product. The value can include simple HTML tags.") + short_description: SortEnum @doc(description: "A short description of the product. Its use depends on the theme.") + price: SortEnum @doc(description: "The price of the item.") + special_price: SortEnum @doc(description: "The discounted price of the product.") + special_from_date: SortEnum @doc(description: "The beginning date that a product has a special price.") + special_to_date: SortEnum @doc(description: "The end date that a product has a special price.") + weight: SortEnum @doc(description: "The weight of the item, in units defined by the store.") + manufacturer: SortEnum @doc(description: "A number representing the product's manufacturer.") + meta_title: SortEnum @doc(description: "A string that is displayed in the title bar and tab of the browser and in search results lists.") + meta_keyword: SortEnum @doc(description: "A comma-separated list of keywords that are visible only to search engines.") + meta_description: SortEnum @doc(description: "A brief overview of the product for search results listings, maximum 255 characters.") + image: SortEnum @doc(description: "The relative path to the main image on the product page.") + small_image: SortEnum @doc(description: "The relative path to the small image, which is used on catalog pages.") + thumbnail: SortEnum @doc(description: "The relative path to the product's thumbnail image.") + tier_price: SortEnum @doc(description: "The price when tier pricing is in effect and the items purchased threshold has been reached.") + news_from_date: SortEnum @doc(description: "The beginning date for new product listings, and determines if the product is featured as a new product.") + news_to_date: SortEnum @doc(description: "The end date for new product listings.") + custom_layout_update: SortEnum @doc(description: "XML code that is applied as a layout update to the product page.") + options_container: SortEnum @doc(description: "If the product has multiple options, determines where they appear on the product page.") + required_options: SortEnum @doc(description: "Indicates whether the product has required options.") + has_options: SortEnum @doc(description: "Indicates whether additional attributes have been created for the product.") + image_label: SortEnum @doc(description: "The label assigned to a product image.") + small_image_label: SortEnum @doc(description: "The label assigned to a product's small image.") + thumbnail_label: SortEnum @doc(description: "The label assigned to a product's thumbnail image.") + created_at: SortEnum @doc(description: "Timestamp indicating when the product was created.") + updated_at: SortEnum @doc(description: "Timestamp indicating when the product was updated.") + country_of_manufacture: SortEnum @doc(description: "The product's country of origin.") + custom_layout: SortEnum @doc(description: "The name of a custom layout.") + gift_message_available: SortEnum @doc(description: "Indicates whether a gift message is available.") +} + +input ProductAttributeSortInput @doc(description: "ProductAttributeSortInput specifies the attribute to use for sorting search results and indicates whether the results are sorted in ascending or descending order. It's possible to sort products using searchable attributes with enabled 'Use in Filter Options' option") +{ + relevance: SortEnum @doc(description: "Sort by the search relevance score (default).") + position: SortEnum @doc(description: "Sort by the position assigned to each product.") +} + +type MediaGalleryEntry @doc(description: "MediaGalleryEntry defines characteristics about images and videos associated with a specific product.") { + id: Int @doc(description: "The identifier assigned to the object.") + media_type: String @doc(description: "image or video.") + label: String @doc(description: "The alt text displayed on the UI when the user points to the image.") + position: Int @doc(description: "The media item's position after it has been sorted.") + disabled: Boolean @doc(description: "Whether the image is hidden from view.") + types: [String] @doc(description: "Array of image types. It can have the following values: image, small_image, thumbnail.") + file: String @doc(description: "The path of the image on the server.") + content: ProductMediaGalleryEntriesContent @doc(description: "Contains a ProductMediaGalleryEntriesContent object.") + video_content: ProductMediaGalleryEntriesVideoContent @doc(description: "Contains a ProductMediaGalleryEntriesVideoContent object.") +} + +type LayerFilter { + name: String @doc(description: "Layered navigation filter name.") @deprecated(reason: "Use Aggregation.label instead.") + request_var: String @doc(description: "Request variable name for filter query.") @deprecated(reason: "Use Aggregation.attribute_code instead.") + filter_items_count: Int @doc(description: "Count of filter items in filter group.") @deprecated(reason: "Use Aggregation.count instead.") + filter_items: [LayerFilterItemInterface] @doc(description: "Array of filter items.") @deprecated(reason: "Use Aggregation.options instead.") +} + +interface LayerFilterItemInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\LayerFilterItemTypeResolverComposite") { + label: String @doc(description: "Filter label.") @deprecated(reason: "Use AggregationOption.label instead.") + value_string: String @doc(description: "Value for filter request variable to be used in query.") @deprecated(reason: "Use AggregationOption.value instead.") + items_count: Int @doc(description: "Count of items by filter.") @deprecated(reason: "Use AggregationOption.count instead.") +} + +type LayerFilterItem implements LayerFilterItemInterface { + +} + +type Aggregation @doc(description: "A bucket that contains information for each filterable option (such as price, category ID, and custom attributes).") { + count: Int @doc(description: "The number of options in the aggregation group.") + label: String @doc(description: "The aggregation display name.") + attribute_code: String! @doc(description: "Attribute code of the aggregation group.") + options: [AggregationOption] @doc(description: "Array of options for the aggregation.") +} + +interface AggregationOptionInterface @typeResolver(class: "Magento\\CatalogGraphQl\\Model\\AggregationOptionTypeResolverComposite") { + count: Int @doc(description: "The number of items that match the aggregation option.") + label: String @doc(description: "Aggregation option display label.") + value: String! @doc(description: "The internal ID that represents the value of the option.") +} + +type AggregationOption implements AggregationOptionInterface { + +} + +type SortField { + value: String @doc(description: "Attribute code of sort field.") + label: String @doc(description: "Label of sort field.") +} + +type SortFields @doc(description: "SortFields contains a default value for sort fields and all available sort fields.") { + default: String @doc(description: "Default value of sort fields.") + options: [SortField] @doc(description: "Available sort fields.") +} + +type StoreConfig @doc(description: "The type contains information about a store config.") { + product_url_suffix : String @doc(description: "Product URL Suffix.") + category_url_suffix : String @doc(description: "Category URL Suffix.") + title_separator : String @doc(description: "Page Title Separator.") + list_mode : String @doc(description: "List Mode.") + grid_per_page_values : String @doc(description: "Products per Page on Grid Allowed Values.") + list_per_page_values : String @doc(description: "Products per Page on List Allowed Values.") + grid_per_page : Int @doc(description: "Products per Page on Grid Default Value.") + list_per_page : Int @doc(description: "Products per Page on List Default Value.") + catalog_default_sort_by : String @doc(description: "Default Sort By.") + root_category_id: Int @doc(description: "The ID of the root category") @resolver(class: "Magento\\CatalogGraphQl\\Model\\Resolver\\RootCategoryId") +} \ No newline at end of file diff --git a/design-documents/graph-ql/coverage/CategoryFiltering.graphqls b/design-documents/graph-ql/coverage/catalog/CategoryFiltering.graphqls similarity index 100% rename from design-documents/graph-ql/coverage/CategoryFiltering.graphqls rename to design-documents/graph-ql/coverage/catalog/CategoryFiltering.graphqls diff --git a/design-documents/graph-ql/coverage/ProductPrices.graphqls b/design-documents/graph-ql/coverage/catalog/ProductPrices.graphqls similarity index 100% rename from design-documents/graph-ql/coverage/ProductPrices.graphqls rename to design-documents/graph-ql/coverage/catalog/ProductPrices.graphqls diff --git a/design-documents/graph-ql/coverage/catalog/Swatches.graphqls b/design-documents/graph-ql/coverage/catalog/Swatches.graphqls new file mode 100644 index 000000000..f81a2b4be --- /dev/null +++ b/design-documents/graph-ql/coverage/catalog/Swatches.graphqls @@ -0,0 +1,19 @@ +type ConfigurableProductOptionsValues { + swatch_data: SwatchDataInterface @doc(description: "Swatch data for configurable product option") +} + +interface SwatchDataInterface { + value: String @doc(description: "Value of swatch item (HEX color code, image link or textual value)") +} + +type ImageSwatchData implements SwatchDataInterface { + thumbnail: String @doc(description: "Thumbnail swatch image URL") +} + +type TextSwatchData implements SwatchDataInterface { + +} + +type ColorSwatchData implements SwatchDataInterface { + +} diff --git a/design-documents/graph-ql/coverage/catalog/compare-list.graphqls b/design-documents/graph-ql/coverage/catalog/compare-list.graphqls index ce856dd26..19fa250ab 100644 --- a/design-documents/graph-ql/coverage/catalog/compare-list.graphqls +++ b/design-documents/graph-ql/coverage/catalog/compare-list.graphqls @@ -1,20 +1,17 @@ type ComparableItem { - productId: ID! @doc(description: "Product Id") - name: String! @doc(description: "Product name") - sku: String! @doc(description: "Product SKU") - priceRange: ProductPriceRange! @doc(description: "Product prices") - canonical_url: String @doc(description: "Product URL") - images: [ProductImage]! @doc(description: "Product Images") - values: [ProductAttribute]! @doc(description: "Product comparable attributes") + uid: ID! + product: ProductInterface! + attributes: [ProductAttribute]! @doc(description: "Product comparable attributes") } type ComparableAttribute { + uid: ID! code: String! @doc(description: "Attribute code ") - title: String! @doc(description: "Addibute display title") + label: String! @doc(description: "Attribute label") } type CompareList { - list_id: ID! @doc(description: " Compare list id") + uid: ID! @doc(description: " Compare list unique id") items: [ComparableItem] @doc(description: "Comparable products") attributes: [ComparableAttribute] @doc(description: "Comparable attributes, provides codes and titles for the attributes") } @@ -24,22 +21,35 @@ type Customer { } type Query { - compareList(id: ID!): CompareList @doc(description: "Compare list") + compareList(uid: ID!): CompareList @doc(description: "Compare list") } type Mutation { - addItemsToCompareList( - id: ID! - items: [ID!] + createCompareList(input: CreateCompareListInput): CompareList @doc(description: "Creates a new compare list. For a logged in user, the created list is assigned to the user") + addProductsToCompareList( + input: AddProductsToCompareListInput ): CompareList - removeItemsFromCompareList( - id: ID! - items: [ID!] + removeProductsFromCompareList( + input: RemoveProductsFromCompareListInput ): CompareList - assignCompareListToCustomer(customerId: ID!, listId: ID!): Boolean + assignCompareListToCustomer(uid: ID!): CompareList # Customer token needs to be passed + deleteCompareList(uid: ID!): DeleteCompareListOutput } -schema { - query: Query, - mutation: Mutation +input CreateCompareListInput { + products: [ID!] +} + +input AddProductsToCompareListInput { + uid: ID!, + products: [ID!]! +} + +input RemoveProductsFromCompareListInput { + uid: ID!, + products: [ID!]! +} + +type DeleteCompareListOutput { + result: Boolean! } diff --git a/design-documents/graph-ql/coverage/catalog/compare-list.md b/design-documents/graph-ql/coverage/catalog/compare-list.md index 1307b54cc..678f478bc 100644 --- a/design-documents/graph-ql/coverage/catalog/compare-list.md +++ b/design-documents/graph-ql/coverage/catalog/compare-list.md @@ -1,11 +1,28 @@ # Use cases -## Guest scenario +## User scenario (Guest/ Logged in) + +Use createCompareList mutation to create a new compare list. The server should create a new list with the items added and return the list_id. The clients can use this list_id for futher operations. + +# Create Compare List +```graphql +{ + mutation { + createCompareList(input: { + products: ["123", "456"] + }) #products optional + } { + list_id + items { + sku + } + } +} +``` +* For a guest user, new list will be created +* For a logged in user, exisiting list_id will be returned + -* A buyer can create a new compare list for the selected -products by calling mutation `addItemsToCompareList` with -the list of product ids and ID which will identify the list. -Compare list ID is client generated identifier. ```graphql { mutation { @@ -29,10 +46,11 @@ Compare list ID is client generated identifier. } ``` * If the registered customer does not have an active list then null will be returned. -This means the client has to generate and send a new ID if the compare list functionality requested. -* If the buyer calls addItemsToCompareList with a new ID the previous list will be abandoned. -For the registered user an active compare list will be replaced with a new one. +``` +assignCompareListToCustomer(uid: ID!): CompareList +``` +mutation can be used to assign a guest compare list to a registered customer. * A buyer can modify the existing list by calling mutation: * `addItemsToCompareList` to add new items to compare list. @@ -74,6 +92,10 @@ preconfigured at the backoffice. * Compare list could be assigned to the registered customer after login or account creation. +## Removing stale comparison list +* Introduce a mutation to removeComparisonList(id: ID!): Boolean, which clients can use to remove the list once the session expires +* A cron job to remove staled entries beyond certain time. + ![compare-list.graphqls](compare-list/compare-list.png) # Non functional requirements: @@ -83,3 +105,25 @@ preconfigured at the backoffice. Guest compare list business logic not implemented yet. Additional development required. +## DB changes + +To the existing table structure for compare list, list_id will be added +``` +catalog_compare_item +------------------------------------------------------------------------------ +| catalog_compare_item_id | visitor_id | customer_id | product_id | store_id | list_id +============================================================================== +``` + +This dependency can be solved by managing the compare list state in a new table +``` +catalog_compare_list +------------------------------------------------------------------------------ +| list_id (varchar) (primary)| visitor_id | customer_id +============================================================================== +``` + +and adding list_id field to catalog_compare_item table. + +* encoded list_id will be used for client communications # \Magento\Framework\Math\Random::getUniqueHash can be used for hashes +* For visitors created via GraphQl, session will be null. diff --git a/design-documents/graph-ql/coverage/catalog/configurable-options-selection.graphqls b/design-documents/graph-ql/coverage/catalog/configurable-options-selection.graphqls new file mode 100644 index 000000000..52c7a9c2d --- /dev/null +++ b/design-documents/graph-ql/coverage/catalog/configurable-options-selection.graphqls @@ -0,0 +1,28 @@ +type ConfigurableProduct { + configurable_product_options_selection(configurableOptionValueUids: [ID!]): ConfigurableProductOptionsSelection @doc(description: "Specified configurable product options selection") + variants: [ConfigurableVariant] @deprecated(reason: "Use configurable_options_selection_metadata instead.") @doc(description: "An array of variants of products") @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\ConfigurableVariant") + configurable_options: [ConfigurableProductOptions] @deprecated(reason: "Use configurable_options_selection_metadata instead.") @doc(description: "An array of linked simple product items") @resolver(class: "Magento\\ConfigurableProductGraphQl\\Model\\Resolver\\Options") +} + +type ConfigurableProductOptionsSelection @doc(description: "Metadata corresponding to the configurable options selection.") +{ + configurable_options: [ConfigurableProductOption!] @doc(description: "Configurable options available for further selection based on current selection.") + media_gallery: [MediaGalleryInterface!] @doc(description: "Product images and videos corresponding to the specified configurable options selection.") + variant: SimpleProduct @doc(description: "Variant represented by the specified configurable options selection. It is expected to be null, until selections are made for each configurable option.") +} + +type ConfigurableProductOption { + uid: ID! + attribute_code: String! + label: String! + values: [ConfigurableProductOptionValue!] +} + +type ConfigurableProductOptionValue { + uid: ID! # Encoding of this uid should respect encode_algm(format: configurable//) + # refer to https://github.com/magento/architecture/blob/master/design-documents/graph-ql/coverage/cart/add-items-to-cart-single-mutation.md#single-mutation-for-adding-products-to-cart + is_available: Boolean! # When display out of stock is enabled, if the option value is out of stock, this will return false + is_default: Boolean! + label: String! + swatch: SwatchDataInterface +} diff --git a/design-documents/graph-ql/coverage/catalog/configurable-options-selection.md b/design-documents/graph-ql/coverage/catalog/configurable-options-selection.md new file mode 100644 index 000000000..c83dc3330 --- /dev/null +++ b/design-documents/graph-ql/coverage/catalog/configurable-options-selection.md @@ -0,0 +1,211 @@ +## Use cases + +### Render configurable option values available for selection on the product page + +User navigates to the configurable product page. Option values available for selection are rendered on the page. + +```graphql +{ + products(filter: {sku: {eq: "configurable-sku"}}) { + items { + description { + html + } + name + ... on ConfigurableProduct { + configurable_options { + attribute_code + label + values { + uid + is_available_for_selection + value_index + label + swatch_data { + value + } + use_default_value + } + } + configurable_options_selection_metadata { + options_available_for_selection { + option_value_uids + attribute_code + } + media_gallery { + url + label + position + disabled + } + } + } + } + } +} + +``` + +The user makes a selection for the first option and the list of option values available for selection is updated for the remaining options. +The images and videos relevant for the selection are also updated. + +```graphql +{ + products(filter: {sku: {eq: "configurable-sku"}}) { + items { + ... on ConfigurableProduct { + configurable_options_selection_metadata( + selectedConfigurableOptionValues: ["hash from selected option value compatible with single mutation"] + ) { + options_available_for_selection { + option_value_uids + attribute_code + } + media_gallery { + url + label + position + disabled + } + } + } + } + } +} +``` + +### User opens URL leading to configurable product page and configurable option selections are specified in the URL + +In this case URL will have to be resolved first: + +```graphql +{ + urlResolver(url: "http://magento.instance/configurable_product.html?configurable_options[0]=first-selection-hash&configurable_options[1]=second-selection-hash") { + id + type + } +} +``` + +Then the product data along with available selections can be requested in a single query: + +```graphql +{ + products(filter: {sku: {eq: "resolved-sku"}}) { + items { + description { + html + } + name + ... on ConfigurableProduct { + configurable_options { + attribute_code + label + values { + uid + is_available_for_selection + value_index + label + swatch_data { + value + } + use_default_value + } + } + configurable_options_selection_metadata( + selectedConfigurableOptionValues: ["hash from selected option value compatible with single mutation", "hash from another option value compatible with single mutation"] + ) { + options_available_for_selection { + option_value_uids + attribute_code + } + media_gallery { + url + label + position + disabled + } + variant { + sku + } + } + } + } + } +} +``` + +### Add to cart + +After the user makes final selection, the corresponding simple product data becomes available and the product can now be added to cart. + +```graphql +{ + products(filter: {sku: {eq: "configurable-sku"}}) { + items { + ... on ConfigurableProduct { + configurable_options_selection_metadata( + selectedConfigurableOptionValues: ["hash from selected option value compatible with single mutation", "hash from another option value compatible with single mutation"] + ) { + options_available_for_selection { + option_value_uids + attribute_code + } + media_gallery { + url + label + position + disabled + } + variant { + sku + } + } + } + } + } +} +``` + +Information about variant is taken from previous query result and used to add configurable product to cart. + +### Render configurable option values available for selection on the category page + +In case when the facet filter was used on the category page, for example to search "Red" shorts, it would be a good idea to display available sizes in "Red" for each product on the page. This can be achieved with the following query: + +```graphql +{ + products(filter: {category_id: {eq: "shorts category ID"}}) { + items { + name + sku + ... on ConfigurableProduct { + configurable_options_selection_metadata( + selectedConfigurableOptionValues: ["hash from selected red color option compatible with single mutation"] + ) { + options_available_for_selection { + option_value_uids + attribute_code + } + media_gallery { + url + label + position + disabled + } + } + } + } + } +} +``` + +### Extension points + +`ConfigurableOptionsSelectionMetadata` type can be extended to support additional use cases, which are not currently supported by Magento like: + - Price range for the variants based on configurable options selection + - Low stock notification based on configurable options selection + +### Long term vision + +In the future all option types will be unified to support additional use cases like conflicting custom options, or price range based on custom + configurable options selection. The new query will be introduced on the top level, and current solution being specific to configurable options only will be deprecated. diff --git a/design-documents/graph-ql/coverage/catalog/gift-card.md b/design-documents/graph-ql/coverage/catalog/gift-card.md new file mode 100644 index 000000000..8b80d0b4c --- /dev/null +++ b/design-documents/graph-ql/coverage/catalog/gift-card.md @@ -0,0 +1,82 @@ +# Problem + +1. For a gift card, 6 entered options are possible. Sender name/email, Recipient name/email, Message, Custom gift card amount. + Not all fields are needed/required for the clients to fill in. This options metadata should be made available to the clients when adding a giftcard product to cart. +2. When clients send this data they need a uid to distinguish each option and uid is not supposed to be generated on the client side. So when we pass in the options metadata a server generated uid should be made available to the clients. + +## Expecations + +This is in reference to this PR [Solution Architecture](https://github.com/magento/architecture/blob/master/design-documents/graph-ql/coverage/add-items-to-cart-single-mutation.md) +on single mutation for adding items to cart. So any changes proposed should respect the input interfaces described in this PR. + +## Proposal + +Add a new field gift_card_options under the GiftCardProduct type. The values can leverage the exisiting CustomizableOptionInterface. + +``` +type GiftCardProduct { + gift_card_options: [CustomizableOptionInterface!]! +} +``` + +On querying the gift_card_options the sample response should look like this. + +``` +{ + gift_card_options: [ + { + title: "Sender Name", + required: true + "__typename": "CustomizableFieldOption" + value: { + uid: "Y3VzdG9tLW9wdGlvbi8xNzE" #base64_encode(giftcard/Magento\GiftCard\Model\Giftcard\Option::KEY_SENDER_NAME) + } + }, + { + title: "Sender Email", + required: true + "__typename": "CustomizableFieldOption" + value: { + uid: "Y3VzdG9tLW9wdGlvbi8xNzE" #base64_encode(giftcard/Magento\GiftCard\Model\Giftcard\Option::KEY_SENDER_EMAIL) + } + }, + # Recipient name and email should not be returned if the product type is Physical. + { + title: "Recipient Name", + required: true + "__typename": "CustomizableFieldOption" + value: { + uid: "Y3VzdG9tLW9wdGlvbi8xNzE" #base64_encode(giftcard/Magento\GiftCard\Model\Giftcard\Option::KEY_RECIPIENT_NAME) + } + }, + { + title: "Recipient Email", + required: true + "__typename": "CustomizableFieldOption" + value: { + uid: "Y3VzdG9tLW9wdGlvbi8xNzE" #base64_encode(giftcard/Magento\GiftCard\Model\Giftcard\Option::KEY_RECIPIENT_EMAIL) + } + }, + # Message can be optional/required + { + title: "Message", + required: false/true + "__typename": "CustomizableFieldOption" + value: { + uid: "Y3VzdG9tLW9wdGlvbi8xNzE" #base64_encode(giftcard/Magento\GiftCard\Model\Giftcard\Option::KEY_MESSAGE) + } + }, + # Custom giftcard amount can be optional/required + { + title: "Custom Giftcard Amount", + required: false/true + "__typename": "CustomizableFieldOption" + value: { + uid: "Y3VzdG9tLW9wdGlvbi8xNzE" #base64_encode(giftcard/Magento\GiftCard\Model\Giftcard\Option::KEY_CUSTOM_GIFTCARD_AMOUNT) + } + }, + ] +} +``` +This enables the clients to know what options to fill in along with the uids. +When the gift card is added to the cart the entered options can be passed in the same way as [Solution Architecture](https://github.com/magento/architecture/blob/master/design-documents/graph-ql/coverage/add-items-to-cart-single-mutation.md). diff --git a/design-documents/graph-ql/coverage/inconsistency.md b/design-documents/graph-ql/coverage/catalog/inconsistency.md similarity index 100% rename from design-documents/graph-ql/coverage/inconsistency.md rename to design-documents/graph-ql/coverage/catalog/inconsistency.md diff --git a/design-documents/graph-ql/coverage/product-reviews.graphqls b/design-documents/graph-ql/coverage/catalog/product-reviews.graphqls similarity index 92% rename from design-documents/graph-ql/coverage/product-reviews.graphqls rename to design-documents/graph-ql/coverage/catalog/product-reviews.graphqls index 30cfd6e81..a723a5220 100644 --- a/design-documents/graph-ql/coverage/product-reviews.graphqls +++ b/design-documents/graph-ql/coverage/catalog/product-reviews.graphqls @@ -44,13 +44,13 @@ type ProductReviewRatingsMetadata { } type ProductReviewRatingMetadata { - id: String! @doc(description: "Base 64 encoded rating id.") + uid: ID! @doc(description: "Base 64 encoded rating uid.") name: String! @doc(description: "The review rating name for example quality, price") values: [ProductReviewRatingValueMetadata!]! @doc(description: "List of product review ratings sorted based on position.") } type ProductReviewRatingValueMetadata { - value_id: String! @doc(description: "Base 64 encoded rating value id.") + value_uid: ID! @doc(description: "Base 64 encoded rating value uid.") value: String! @doc(description: "e.g Good, Perfect, 3, 4, 5") } @@ -71,6 +71,6 @@ input CreateProductReviewInput { } type ProductReviewRatingInput { - id: String! @doc(description: "Base 64 encoded rating id.") - value_id: String! @doc(description: "Base 64 encoded rating value id.") + uid: ID! @doc(description: "Base 64 encoded rating uid.") + value_uid: ID! @doc(description: "Base 64 encoded rating value uid.") } diff --git a/design-documents/graph-ql/coverage/catalog/remove-product-attributes.md b/design-documents/graph-ql/coverage/catalog/remove-product-attributes.md new file mode 100644 index 000000000..fc6b032db --- /dev/null +++ b/design-documents/graph-ql/coverage/catalog/remove-product-attributes.md @@ -0,0 +1,45 @@ +# Remove product dynamic attributes from ProductInterface + +## Current state + +Currently, Magento exposes EAV attributes as part of the product schema as top-level fields of ProductInterface. As consequences: +* The merchant can change the GraphQL from the admin, which makes it hard to cache GraphQL schema. +* Regular product interface overloaded with fields and almost unreadable. +* It is quite often practice to create product programmatically, so the number of attributes could be really excessive with tens thousands of attributes. +* Two Magento instances do not have the same GraphQL schema. As a result, it is near impossible to build a simple client/SDK, which could be easily transferred from one instance to another. +* It is impossible to aggregate the several Magento instances behind the common [BFF](https://docs.microsoft.com/en-us/azure/architecture/patterns/backends-for-frontends). + +## Proposed solution + +We should to remove attributes dynamic attributes, +except system attributes which makes sense for the storefront, +from the product top level and use a [dynamic container](https://github.com/magento/architecture/blob/master/design-documents/graph-ql/custom-attributes-container.md) instead. + + +```graphql +## ProductInteface with system EAV attributes +type ProductInterface { + uid: ID + name: String + sku: String + attribute_set_id: Int + description: ComplexTextValue + short_description: ComplexTextValue + meta_title: String + meta_keyword: String + meta_description: String + created_at: String + updated_at: String + custom_attributes: [CustomAttribute] + ... +} +``` + +## System EAV attributes that should be deprecated at the storefront + +* `special_price: Float` These fields should not be used a the source of the discount since the discount also could be caused by group price or rule price, which are not reflected in this field. +The same is true for `special_from_date: String` and `special_to_date`. + +* `options_container: String` - this field just an exposure of `catalog_product_entity` table field, information about product options could be explicitly retrieved from the corresponding field. +* `manufacturer: Int` - just a sample attribute from Magento 1.x, we can move it to the custom attributes container. +the same is true for `country_of_manufacture: String` diff --git a/design-documents/graph-ql/framework/attributes-metadata.md b/design-documents/graph-ql/coverage/custom-attributes/attributes-metadata.md similarity index 100% rename from design-documents/graph-ql/framework/attributes-metadata.md rename to design-documents/graph-ql/coverage/custom-attributes/attributes-metadata.md diff --git a/design-documents/graph-ql/custom-attributes-container.md b/design-documents/graph-ql/coverage/custom-attributes/custom-attributes-container.md similarity index 100% rename from design-documents/graph-ql/custom-attributes-container.md rename to design-documents/graph-ql/coverage/custom-attributes/custom-attributes-container.md diff --git a/design-documents/graph-ql/coverage/customer-address-pagination-change.graphqls b/design-documents/graph-ql/coverage/customer-address-pagination-change.graphqls new file mode 100644 index 000000000..1c5092faa --- /dev/null +++ b/design-documents/graph-ql/coverage/customer-address-pagination-change.graphqls @@ -0,0 +1,14 @@ +# +type Customer { + addresses: CustomerAddresses @deprecated(reason: "The `address` field is deprecated. Use `addresses_v2` instead.") + addresses_v2( + currentPage: Int = 1 @doc(description: "current page of the customer address list. default is 1") + pageSize: Int = 10 @doc(description: "page size for the customer address list. default is 10") + ): CustomerAddresses! +} + +type CustomerAddresses { + items: [CustomerAddress]! @doc(description: "List of customer address") + page_info: SearchResultPageInfo + total_count: Int! +} diff --git a/design-documents/graph-ql/coverage/customer-orders.md b/design-documents/graph-ql/coverage/customer-orders.md deleted file mode 100644 index e6c4e4adf..000000000 --- a/design-documents/graph-ql/coverage/customer-orders.md +++ /dev/null @@ -1,273 +0,0 @@ -# Customer Orders API - -# Overview - -The GraphQL API should provide a possibility to retrieve orders, shipments, invoices, credit memos for the logged in customer. The current schema allows fetching only simple order details and does not provide a possibility to fetch order details by a number. - -The proposed solution is deprecation of `customerOrders` query and addition of `orders` field to the `customer` query: - -```graphql -@doc("Query to return a list of all customer orders") -type Customer { - orders ( - filter: CustomerOrdersFilterInput - currentPage: Int = 1 @doc("current page of the customer order list. default is 1") - pageSize: Int = 20 @doc("page size for the customer orders list. default is 20") - ): CustomerOrders -} - -@doc("Collection of customer orders") -type CustomerOrders { - items: [CustomerOrder]! @doc("collection of customer orders that contains individual order details.") - page_info: SearchResultPageInfo - total_count: Int @doc("the total count of customer orders") -} -``` - -```graphql -@doc("Allows to extend the list of search criteria for customer orders") -input CustomerOrdersFilterInput { - number: String @doc("Order number. Allows to filter orders by fully or partial entered number") - status: String @("Order status") - createdDate: FilterRangeTypeInput - total: CustomerOrdersAmountFilterInput - salesItem: SalesItemFilterInput -} - -@doc("Provides order total amount search filter") -input CustomerOrdersAmountFilterInput { - min: Float @doc("Minimum total order amount in store view currency") - max: Float @doc("Maximum total order amount in store view currency") -} - -@doc("Allows to extend the list of search criteria for order items") -input SalesItemFilterInput { - name: String @doc("Order item name. Allows to filter orders by fully or partial entered order item name") - sku: String @doc("Order item SKU. Allows to filter orders by fully or partial entered order item SKU") -} -``` - -> Right now, we don't introduce the filter input for such entities like invoice, credit memo, shipment as all these entities are related to the same order and GraphQL resolvers anyway at first resolve the order and after that other child entities. But, in future, such filter input can be introduced as optional argument. - -## Order Type Schema - -The proposed type for the customer order might look like: - -```graphql -@doc("Customer order details") -type CustomerOrder { - id: ID! @doc("the ID of the order, used for API purposes") - order_date: String! @doc("date when the order was placed") - status: String! @doc("current status of the order") - number: String! @doc("sequential order number") - items: [OrderItem]! @doc("collection of all the items purchased") - total: OrderTotal! @doc("total amount details for the order") - invoices: [Invoice]! @doc("invoice list for the order") - credit_memos: [CreditMemo]! @doc("credit memo list for the order") - shipments: [OrderShipment]! @doc("shipment list for the order") - payment_methods: [PaymentMethod]! @doc("payment details for the order") - shipping_address: CustomerAddress! @doc("shipping address for the order") - billing_address: CustomerAddress! @doc("billing address for the order") - carrier: String! @doc("shipping carrier for the order delivery") - method: String! @doc("shipping method for the order") -} -``` - -The `id` will be a `base64_encode(increment_id)` which in future can be replaced by UUID. - -> The order `status` should be filtered in the same way as for Luma via `Order Status` and `Visible On Storefront` configuration - -### Order Item - -The order items will be presented as separate interface which will have multiple implementations for invoice, shipment and credit memo types. - -```graphql -@doc("Interface to reprent order/invoice/shipment/credit memo items") -interface SalesItemInterface { - product_name: String @doc("name of the base product") - product_sku: String! @doc("SKU of the base product") - product_url: String @doc("URL of the base product") - product_sale_price: Money! @doc("sale price for the base product including selected options") - discounts: [Discount] @doc("final discount information for the base product including discounts on options") - parent_product_name: String @doc("name of parent product like configurable or bundle") - parent_product_sku: String @doc("SKU of parent product like configurable or bundle") - parent_product_url: String @doc("URL of parent product in the catalog") - selected_options: [SalesItemSelectedOption] @doc("selected options for the base product. for e.g color, size etc.") - entered_options: [SalesItemEnteredOption] @doc("entered option for the base product. for e.g logo image etc.") -} - -@doc("Represents sales item selected options") -type SalesItemSelectedOption { - id: ID! @doc("ID of the option") - label: String! @doc("name of the option") - value_labels: [String]! @doc("list of option value labels") -} - -@doc("Represents sales item entered options") -type SalesItemEnteredOption { - id: ID! @doc("ID of the option") - label: String! @doc("name of the option") - value: String! @doc("value of the option") -} -``` - -The `id` will be a `base64_encode(option_id)` which in future can be replaced by UUID. - -The `SalesItemInterface` will be implemented by the following types: - -```graphql -@doc("Order Product implementation of OrderProductInterface") -type OrderItem implements SalesItemInterface { - quantity_ordered: Float @doc("number of items") - quantity_shipped: Float @doc("number of shipped items") - quantity_refunded: Float @doc("number of refunded items") - quantity_invoiced: Float @doc("number of invoiced items") - quantity_canceled: Float @doc("number of cancelled items") - quantity_returned: Float @doc("number of returned items") - status: String @doc("the status of order item") -} -``` - -### Payment Method Schema - -To provide more customization for different payment solutions, the payment method will be represented by own type instead of simple string: - -```graphql -@doc("Payment method used to pay for the order") -type PaymentMethod { - name: String! @doc("payment method name for e.g Braintree, Authorize etc.") - type: String! @doc("payment method type used to pay for the order for e.g Credit Card, PayPal etc.") - additional_data: [KeyValue] @doc("additional data per payment method type") -} -``` - -> The payment `additional_data` should be filtered in the same way as for Luma via `privateInfoKeys` and `paymentInfoKeys` to not expose sensitive information. - -### Amounts Schema - -As entities like order, invoice, credit memo might have complex amounts type: - -```graphql -@doc("Interface to provide sales amounts") -interface SalesTotalAmountInterface { - subtotal: Money! @doc("subtotal amount excluding, shipping, discounts and tax") - discounts: [Discount] @doc("applied discounts") - total_tax: Money! @doc("total tax amount") - taxes: [TaxItem]! @doc("order taxes details") - grand_total: Money! @doc("final total amount including shipping and taxes") - base_grand_total: Money! @doc("final total amount in base currency") -} -​ -@doc("Order total amounts details") -type OrderTotal implements SalesTotalAmountInterface { - total_shipping: Money! @doc("order shipping amount") - shipping_handling: ShippingHandling! @doc("shipping and handling for the order") -} - -@doc("Shipping handling details") -type ShippingHandling { - total_amount: Money! @doc("shipping total amount") - amount_inc_tax: Money @doc("shipping amount including tax") - amount_exc_tax: Money @doc("shipping amount excluding tax") - taxes: [TaxItem]! @doc("shipping taxes details") -} - -@doc("Tax item details") -type TaxItem { - amount: Money! @doc("tax amount") - title: String! @doc("tax item title") - rate: Float @doc("tax item rate") -} -``` - -## Invoice Type Schema - -The invoice entity will have the similar to the order schema: - -```graphql -@doc("Invoice details") -type Invoice { - id: ID! @doc("the ID of the invoice, used for API purposes") - number: String! @doc("sequential invoice number") - total: InvoiceTotal! @doc("invoice total amount details") - items: [InvoiceItem]! @doc("invoiced product details") -} - -@doc("Invoice item details") -type InvoiceItem implements SalesItemInterface{ - quantity_invoiced: Float! @doc("number of invoiced items") -} - -@doc("Invoice total amount details") -type InvoiceTotal implements SalesTotalAmountInterface { - total_shipping: Money! @doc("order shipping amount") - shipping_handling: ShippingHandling! @doc("shipping and handling for the order") -} -``` - -The `id` will be a `base64_encode(increment_id)` which in future can be replaced by UUID. - -## Refund Type Schema - -The credit memo entity will have the similar to the order and invoice schema: - -```graphql -@doc("Credit memo details") -type CreditMemo { - id: ID! @doc("the ID of the credit memo, used for API purposes") - number: String! @doc("sequential credit memo number") - items: [CreditMemoItem]! @doc("items refunded") - total: CredtiMemoTotal! @doc("refund total amount details") -} - -@doc("Credit memo item details") -type CreditMemoItem implements SalesItemInterface{ - quantity_refunded: Float! @doc("number of refunded items") -} - -@doc("Credit memo price details") -type CredtiMemoTotal implements SalesTotalAmountInterface { - -} -``` - -The `id` will be a `base64_encode_encode(increment_id)` which in future can be replaced by UUID. - -## Shipment Type Schema - -```graphql -@doc("Order shipment details") -type OrderShipment { - id: ID! @doc("the ID of the shipment, used for API purposes") - number: String! @doc("sequential credit shipment number") - tracking: [ShipmentTracking] @doc("shipment tracking details") - items: [ShipmentItem]! @doc("items included in the shipment") -} - -@doc("Order shipment item details") -type ShipmentItem implements SalesItemInterface{ - quantity_shipped: Float! @doc("number of shipped items") -} - -@doc("Order shipment tracking details") -type ShipmentTracking { - method: String! @doc("shipping method for the order") - carrier: String! @doc("shipping carrier for the order delivery") - number: String @doc("tracking number of the order shipment") - link: String @doc("tracking link of the order shipment") -} -``` - -The `id` will be a `base64_encode(increment_id)` which in future can be replaced by UUID. - -## Additional Types - -The `KeyValue` type will provide a possibility to use key-value pairs: - -```graphql -@doc("The key-value type") -type KeyValue { - name: String @doc("name part of the name/value pair") - value: String @doc("value part of the name/value pair") -} -``` diff --git a/design-documents/graph-ql/coverage/customer.graphqls b/design-documents/graph-ql/coverage/customer.graphqls new file mode 100644 index 000000000..959c93483 --- /dev/null +++ b/design-documents/graph-ql/coverage/customer.graphqls @@ -0,0 +1,427 @@ +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. + +type StoreConfig { + required_character_classes_number : String @doc(description: "The number of different character classes required in a password (lowercase, uppercase, digits, special characters).") + minimum_password_length : String @doc(description: "The minimum number of characters required for a valid password.") + autocomplete_on_storefront : Boolean @doc(description: "Enable autocomplete on login and forgot password forms") +} + +type Query { + customer: Customer @resolver(class: "Magento\\CustomerGraphQl\\Model\\Resolver\\Customer") @doc(description: "The customer query returns information about a customer account") @cache(cacheable: false) + isEmailAvailable ( + email: String! @doc(description: "The new customer email") + ): IsEmailAvailableOutput @resolver(class: "Magento\\CustomerGraphQl\\Model\\Resolver\\IsEmailAvailable") +} + +type Mutation { + generateCustomerToken(email: String!, password: String!): CustomerToken @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\GenerateCustomerToken") @doc(description:"Retrieve the customer token") + changeCustomerPassword(currentPassword: String!, newPassword: String!): Customer @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\ChangePassword") @doc(description:"Changes the password for the logged-in customer") + createCustomer (input: CustomerInput!): CustomerOutput @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\CreateCustomer") @doc(description:"Create customer account") + createCustomerV2 (input: CustomerCreateInput!): CustomerOutput @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\CreateCustomer") @doc(description:"Create customer account") + updateCustomer (input: CustomerInput!): CustomerOutput @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\UpdateCustomer") @doc(description:"Deprecated. Use UpdateCustomerV2 instead.") + updateCustomerV2 (input: CustomerUpdateInput!): CustomerOutput @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\UpdateCustomer") @doc(description:"Update the customer's personal information") + revokeCustomerToken: RevokeCustomerTokenOutput @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\RevokeCustomerToken") @doc(description:"Revoke the customer token") + createCustomerAddress(input: CustomerAddressInput!): CustomerAddress @resolver(class: "Magento\\CustomerGraphQl\\Model\\Resolver\\CreateCustomerAddress") @doc(description: "Create customer address") + updateCustomerAddress(id: Int!, input: CustomerAddressInput): CustomerAddress @resolver(class: "Magento\\CustomerGraphQl\\Model\\Resolver\\UpdateCustomerAddress") @doc(description: "Update customer address") + deleteCustomerAddress(id: Int!): Boolean @resolver(class: "Magento\\CustomerGraphQl\\Model\\Resolver\\DeleteCustomerAddress") @doc(description: "Delete customer address") + requestPasswordResetEmail(email: String!): Boolean @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\RequestPasswordResetEmail") @doc(description: "Request an email with a reset password token for the registered customer identified by the specified email.") + resetPassword(email: String!, resetPasswordToken: String!, newPassword: String!): Boolean @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\ResetPassword") @doc(description: "Reset a customer's password using the reset password token that the customer received in an email after requesting it using requestPasswordResetEmail.") + updateCustomerEmail(email: String!, password: String!): CustomerOutput @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\UpdateCustomerEmail") @doc(description: "") +} + +input CustomerAddressInput { + firstname: String @doc(description: "The first name of the person associated with the shipping/billing address") + lastname: String @doc(description: "The family name of the person associated with the shipping/billing address") + company: String @doc(description: "The customer's company") + telephone: String @doc(description: "The telephone number") + street: [String] @doc(description: "An array of strings that define the street number and name") + city: String @doc(description: "The city or town") + region: CustomerAddressRegionInput @doc(description: "An object containing the region name, region code, and region ID") + postcode: String @doc(description: "The customer's ZIP or postal code") + country_id: CountryCodeEnum @doc(description: "Deprecated: use `country_code` instead.") + country_code: CountryCodeEnum @doc(description: "The customer's country") + default_shipping: Boolean @doc(description: "Indicates whether the address is the default shipping address") + default_billing: Boolean @doc(description: "Indicates whether the address is the default billing address") + fax: String @doc(description: "The fax number") + middlename: String @doc(description: "The middle name of the person associated with the shipping/billing address") + prefix: String @doc(description: "An honorific, such as Dr., Mr., or Mrs.") + suffix: String @doc(description: "A value such as Sr., Jr., or III") + vat_id: String @doc(description: "The customer's Tax/VAT number (for corporate customers)") + custom_attributes: [CustomerAddressAttributeInput] @doc(description: "Deprecated: Custom attributes should not be put into container.") +} + +input CustomerAddressRegionInput @doc(description: "CustomerAddressRegionInput defines the customer's state or province") { + region_code: String @doc(description: "The address region code") + region: String @doc(description: "The state or province name") + region_id: Int @doc(description: "The unique ID for a pre-defined region") +} + +input CustomerAddressAttributeInput { + attribute_code: String! @doc(description: "Attribute code") + value: String! @doc(description: "Attribute value") +} + +type CustomerToken { + token: String @doc(description: "The customer token") +} + +input CustomerInput { + prefix: String @doc(description: "An honorific, such as Dr., Mr., or Mrs.") + firstname: String @doc(description: "The customer's first name") + middlename: String @doc(description: "The customer's middle name") + lastname: String @doc(description: "The customer's family name") + suffix: String @doc(description: "A value such as Sr., Jr., or III") + email: String @doc(description: "The customer's email address. Required for customer creation") + dob: String @doc(description: "Deprecated: Use `date_of_birth` instead") + date_of_birth: String @doc(description: "The customer's date of birth") + taxvat: String @doc(description: "The customer's Tax/VAT number (for corporate customers)") + gender: Int @doc(description: "The customer's gender (Male - 1, Female - 2)") + password: String @doc(description: "The customer's password") + is_subscribed: Boolean @doc(description: "Indicates whether the customer is subscribed to the company's newsletter") +} + +input CustomerCreateInput { + prefix: String @doc(description: "An honorific, such as Dr., Mr., or Mrs.") + firstname: String! @doc(description: "The customer's first name") + middlename: String @doc(description: "The customer's middle name") + lastname: String! @doc(description: "The customer's family name") + suffix: String @doc(description: "A value such as Sr., Jr., or III") + email: String! @doc(description: "The customer's email address. Required for customer creation") + dob: String @doc(description: "Deprecated: Use `date_of_birth` instead") + date_of_birth: String @doc(description: "The customer's date of birth") + taxvat: String @doc(description: "The customer's Tax/VAT number (for corporate customers)") + gender: Int @doc(description: "The customer's gender (Male - 1, Female - 2)") + password: String @doc(description: "The customer's password") + is_subscribed: Boolean @doc(description: "Indicates whether the customer is subscribed to the company's newsletter") +} + +input CustomerUpdateInput { + date_of_birth: String @doc(description: "The customer's date of birth") + dob: String @doc(description: "Deprecated: Use `date_of_birth` instead") + firstname: String @doc(description: "The customer's first name") + gender: Int @doc(description: "The customer's gender (Male - 1, Female - 2)") + is_subscribed: Boolean @doc(description: "Indicates whether the customer is subscribed to the company's newsletter") + lastname: String @doc(description: "The customer's family name") + middlename: String @doc(description: "The customer's middle name") + prefix: String @doc(description: "An honorific, such as Dr., Mr., or Mrs.") + suffix: String @doc(description: "A value such as Sr., Jr., or III") + taxvat: String @doc(description: "The customer's Tax/VAT number (for corporate customers)") +} + +type CustomerOutput { + customer: Customer! +} + +type RevokeCustomerTokenOutput { + result: Boolean! +} + +type Customer @doc(description: "Customer defines the customer name and address and other details") { + created_at: String @doc(description: "Timestamp indicating when the account was created") + group_id: Int @deprecated(reason: "Customer group should not be exposed in the storefront scenarios") + prefix: String @doc(description: "An honorific, such as Dr., Mr., or Mrs.") + firstname: String @doc(description: "The customer's first name") + middlename: String @doc(description: "The customer's middle name") + lastname: String @doc(description: "The customer's family name") + suffix: String @doc(description: "A value such as Sr., Jr., or III") + email: String @doc(description: "The customer's email address. Required") + default_billing: String @doc(description: "The ID assigned to the billing address") + default_shipping: String @doc(description: "The ID assigned to the shipping address") + dob: String @doc(description: "The customer's date of birth") @deprecated(reason: "Use `date_of_birth` instead") + date_of_birth: String @doc(description: "The customer's date of birth") + taxvat: String @doc(description: "The customer's Value-added tax (VAT) number (for corporate customers)") + id: Int @deprecated(reason: "id is not needed as part of Customer because on server side it can be identified based on customer token used for authentication. There is no need to know customer ID on the client side.") + uid: ID! @doc(description: "Unique identifier for a Customer") + is_subscribed: Boolean @doc(description: "Indicates whether the customer is subscribed to the company's newsletter") @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\IsSubscribed") + addresses: [CustomerAddress] @doc(description: "An array containing the customer's shipping and billing addresses") @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\CustomerAddresses") + gender: Int @doc(description: "The customer's gender (Male - 1, Female - 2)") +} + +type CustomerAddress @doc(description: "CustomerAddress contains detailed information about a customer's billing and shipping addresses"){ + id: Int @doc(description: "The ID assigned to the address object") + customer_id: Int @doc(description: "The customer ID") @deprecated(reason: "customer_id is not needed as part of CustomerAddress, address ID (id) is unique identifier for the addresses.") + region: CustomerAddressRegion @doc(description: "An object containing the region name, region code, and region ID") + region_id: Int @doc(description: "The unique ID for a pre-defined region") + country_id: String @doc(description: "The customer's country") @deprecated(reason: "Use `country_code` instead.") + country_code: CountryCodeEnum @doc(description: "The customer's country") + street: [String] @doc(description: "An array of strings that define the street number and name") + company: String @doc(description: "The customer's company") + telephone: String @doc(description: "The telephone number") + fax: String @doc(description: "The fax number") + postcode: String @doc(description: "The customer's ZIP or postal code") + city: String @doc(description: "The city or town") + firstname: String @doc(description: "The first name of the person associated with the shipping/billing address") + lastname: String @doc(description: "The family name of the person associated with the shipping/billing address") + middlename: String @doc(description: "The middle name of the person associated with the shipping/billing address") + prefix: String @doc(description: "An honorific, such as Dr., Mr., or Mrs.") + suffix: String @doc(description: "A value such as Sr., Jr., or III") + vat_id: String @doc(description: "The customer's Value-added tax (VAT) number (for corporate customers)") + default_shipping: Boolean @doc(description: "Indicates whether the address is the default shipping address") + default_billing: Boolean @doc(description: "Indicates whether the address is the default billing address") + custom_attributes: [CustomerAddressAttribute] @deprecated(reason: "Custom attributes should not be put into container") + extension_attributes: [CustomerAddressAttribute] @doc(description: "Address extension attributes") +} + +type CustomerAddressRegion @doc(description: "CustomerAddressRegion defines the customer's state or province") { + region_code: String @doc(description: "The address region code") + region: String @doc(description: "The state or province name") + region_id: Int @doc(description: "The unique ID for a pre-defined region") +} + +type CustomerAddressAttribute { + attribute_code: String @doc(description: "Attribute code") + value: String @doc(description: "Attribute value") +} + +type IsEmailAvailableOutput { + is_email_available: Boolean @doc(description: "Is email availabel value") +} + +enum CountryCodeEnum @doc(description: "The list of countries codes") { + AF @doc(description: "Afghanistan") + AX @doc(description: "Åland Islands") + AL @doc(description: "Albania") + DZ @doc(description: "Algeria") + AS @doc(description: "American Samoa") + AD @doc(description: "Andorra") + AO @doc(description: "Angola") + AI @doc(description: "Anguilla") + AQ @doc(description: "Antarctica") + AG @doc(description: "Antigua & Barbuda") + AR @doc(description: "Argentina") + AM @doc(description: "Armenia") + AW @doc(description: "Aruba") + AU @doc(description: "Australia") + AT @doc(description: "Austria") + AZ @doc(description: "Azerbaijan") + BS @doc(description: "Bahamas") + BH @doc(description: "Bahrain") + BD @doc(description: "Bangladesh") + BB @doc(description: "Barbados") + BY @doc(description: "Belarus") + BE @doc(description: "Belgium") + BZ @doc(description: "Belize") + BJ @doc(description: "Benin") + BM @doc(description: "Bermuda") + BT @doc(description: "Bhutan") + BO @doc(description: "Bolivia") + BA @doc(description: "Bosnia & Herzegovina") + BW @doc(description: "Botswana") + BV @doc(description: "Bouvet Island") + BR @doc(description: "Brazil") + IO @doc(description: "British Indian Ocean Territory") + VG @doc(description: "British Virgin Islands") + BN @doc(description: "Brunei") + BG @doc(description: "Bulgaria") + BF @doc(description: "Burkina Faso") + BI @doc(description: "Burundi") + KH @doc(description: "Cambodia") + CM @doc(description: "Cameroon") + CA @doc(description: "Canada") + CV @doc(description: "Cape Verde") + KY @doc(description: "Cayman Islands") + CF @doc(description: "Central African Republic") + TD @doc(description: "Chad") + CL @doc(description: "Chile") + CN @doc(description: "China") + CX @doc(description: "Christmas Island") + CC @doc(description: "Cocos (Keeling) Islands") + CO @doc(description: "Colombia") + KM @doc(description: "Comoros") + CG @doc(description: "Congo-Brazzaville") + CD @doc(description: "Congo-Kinshasa") + CK @doc(description: "Cook Islands") + CR @doc(description: "Costa Rica") + CI @doc(description: "Côte d’Ivoire") + HR @doc(description: "Croatia") + CU @doc(description: "Cuba") + CY @doc(description: "Cyprus") + CZ @doc(description: "Czech Republic") + DK @doc(description: "Denmark") + DJ @doc(description: "Djibouti") + DM @doc(description: "Dominica") + DO @doc(description: "Dominican Republic") + EC @doc(description: "Ecuador") + EG @doc(description: "Egypt") + SV @doc(description: "El Salvador") + GQ @doc(description: "Equatorial Guinea") + ER @doc(description: "Eritrea") + EE @doc(description: "Estonia") + ET @doc(description: "Ethiopia") + FK @doc(description: "Falkland Islands") + FO @doc(description: "Faroe Islands") + FJ @doc(description: "Fiji") + FI @doc(description: "Finland") + FR @doc(description: "France") + GF @doc(description: "French Guiana") + PF @doc(description: "French Polynesia") + TF @doc(description: "French Southern Territories") + GA @doc(description: "Gabon") + GM @doc(description: "Gambia") + GE @doc(description: "Georgia") + DE @doc(description: "Germany") + GH @doc(description: "Ghana") + GI @doc(description: "Gibraltar") + GR @doc(description: "Greece") + GL @doc(description: "Greenland") + GD @doc(description: "Grenada") + GP @doc(description: "Guadeloupe") + GU @doc(description: "Guam") + GT @doc(description: "Guatemala") + GG @doc(description: "Guernsey") + GN @doc(description: "Guinea") + GW @doc(description: "Guinea-Bissau") + GY @doc(description: "Guyana") + HT @doc(description: "Haiti") + HM @doc(description: "Heard & McDonald Islands") + HN @doc(description: "Honduras") + HK @doc(description: "Hong Kong SAR China") + HU @doc(description: "Hungary") + IS @doc(description: "Iceland") + IN @doc(description: "India") + ID @doc(description: "Indonesia") + IR @doc(description: "Iran") + IQ @doc(description: "Iraq") + IE @doc(description: "Ireland") + IM @doc(description: "Isle of Man") + IL @doc(description: "Israel") + IT @doc(description: "Italy") + JM @doc(description: "Jamaica") + JP @doc(description: "Japan") + JE @doc(description: "Jersey") + JO @doc(description: "Jordan") + KZ @doc(description: "Kazakhstan") + KE @doc(description: "Kenya") + KI @doc(description: "Kiribati") + KW @doc(description: "Kuwait") + KG @doc(description: "Kyrgyzstan") + LA @doc(description: "Laos") + LV @doc(description: "Latvia") + LB @doc(description: "Lebanon") + LS @doc(description: "Lesotho") + LR @doc(description: "Liberia") + LY @doc(description: "Libya") + LI @doc(description: "Liechtenstein") + LT @doc(description: "Lithuania") + LU @doc(description: "Luxembourg") + MO @doc(description: "Macau SAR China") + MK @doc(description: "Macedonia") + MG @doc(description: "Madagascar") + MW @doc(description: "Malawi") + MY @doc(description: "Malaysia") + MV @doc(description: "Maldives") + ML @doc(description: "Mali") + MT @doc(description: "Malta") + MH @doc(description: "Marshall Islands") + MQ @doc(description: "Martinique") + MR @doc(description: "Mauritania") + MU @doc(description: "Mauritius") + YT @doc(description: "Mayotte") + MX @doc(description: "Mexico") + FM @doc(description: "Micronesia") + MD @doc(description: "Moldova") + MC @doc(description: "Monaco") + MN @doc(description: "Mongolia") + ME @doc(description: "Montenegro") + MS @doc(description: "Montserrat") + MA @doc(description: "Morocco") + MZ @doc(description: "Mozambique") + MM @doc(description: "Myanmar (Burma)") + NA @doc(description: "Namibia") + NR @doc(description: "Nauru") + NP @doc(description: "Nepal") + NL @doc(description: "Netherlands") + AN @doc(description: "Netherlands Antilles") + NC @doc(description: "New Caledonia") + NZ @doc(description: "New Zealand") + NI @doc(description: "Nicaragua") + NE @doc(description: "Niger") + NG @doc(description: "Nigeria") + NU @doc(description: "Niue") + NF @doc(description: "Norfolk Island") + MP @doc(description: "Northern Mariana Islands") + KP @doc(description: "North Korea") + NO @doc(description: "Norway") + OM @doc(description: "Oman") + PK @doc(description: "Pakistan") + PW @doc(description: "Palau") + PS @doc(description: "Palestinian Territories") + PA @doc(description: "Panama") + PG @doc(description: "Papua New Guinea") + PY @doc(description: "Paraguay") + PE @doc(description: "Peru") + PH @doc(description: "Philippines") + PN @doc(description: "Pitcairn Islands") + PL @doc(description: "Poland") + PT @doc(description: "Portugal") + QA @doc(description: "Qatar") + RE @doc(description: "Réunion") + RO @doc(description: "Romania") + RU @doc(description: "Russia") + RW @doc(description: "Rwanda") + WS @doc(description: "Samoa") + SM @doc(description: "San Marino") + ST @doc(description: "São Tomé & Príncipe") + SA @doc(description: "Saudi Arabia") + SN @doc(description: "Senegal") + RS @doc(description: "Serbia") + SC @doc(description: "Seychelles") + SL @doc(description: "Sierra Leone") + SG @doc(description: "Singapore") + SK @doc(description: "Slovakia") + SI @doc(description: "Slovenia") + SB @doc(description: "Solomon Islands") + SO @doc(description: "Somalia") + ZA @doc(description: "South Africa") + GS @doc(description: "South Georgia & South Sandwich Islands") + KR @doc(description: "South Korea") + ES @doc(description: "Spain") + LK @doc(description: "Sri Lanka") + BL @doc(description: "St. Barthélemy") + SH @doc(description: "St. Helena") + KN @doc(description: "St. Kitts & Nevis") + LC @doc(description: "St. Lucia") + MF @doc(description: "St. Martin") + PM @doc(description: "St. Pierre & Miquelon") + VC @doc(description: "St. Vincent & Grenadines") + SD @doc(description: "Sudan") + SR @doc(description: "Suriname") + SJ @doc(description: "Svalbard & Jan Mayen") + SZ @doc(description: "Swaziland") + SE @doc(description: "Sweden") + CH @doc(description: "Switzerland") + SY @doc(description: "Syria") + TW @doc(description: "Taiwan") + TJ @doc(description: "Tajikistan") + TZ @doc(description: "Tanzania") + TH @doc(description: "Thailand") + TL @doc(description: "Timor-Leste") + TG @doc(description: "Togo") + TK @doc(description: "Tokelau") + TO @doc(description: "Tonga") + TT @doc(description: "Trinidad & Tobago") + TN @doc(description: "Tunisia") + TR @doc(description: "Turkey") + TM @doc(description: "Turkmenistan") + TC @doc(description: "Turks & Caicos Islands") + TV @doc(description: "Tuvalu") + UG @doc(description: "Uganda") + UA @doc(description: "Ukraine") + AE @doc(description: "United Arab Emirates") + GB @doc(description: "United Kingdom") + US @doc(description: "United States") + UY @doc(description: "Uruguay") + UM @doc(description: "U.S. Outlying Islands") + VI @doc(description: "U.S. Virgin Islands") + UZ @doc(description: "Uzbekistan") + VU @doc(description: "Vanuatu") + VA @doc(description: "Vatican City") + VE @doc(description: "Venezuela") + VN @doc(description: "Vietnam") + WF @doc(description: "Wallis & Futuna") + EH @doc(description: "Western Sahara") + YE @doc(description: "Yemen") + ZM @doc(description: "Zambia") + ZW @doc(description: "Zimbabwe") +} \ No newline at end of file diff --git a/design-documents/graph-ql/coverage/customer/SubscribeEmailToNewsletter.graphqls b/design-documents/graph-ql/coverage/customer/SubscribeEmailToNewsletter.graphqls new file mode 100644 index 000000000..c8bb2de5b --- /dev/null +++ b/design-documents/graph-ql/coverage/customer/SubscribeEmailToNewsletter.graphqls @@ -0,0 +1,14 @@ +type Mutation { + subscribeEmailToNewsletter(email: String!): SubscribeEmailToNewsletterOutput @doc(description:"Adds an email into a newsletter subscription") +} + +type SubscribeEmailToNewsletterOutput { + status: SubscriptionStatusesEnum @doc(description: "Returns a status of subscription") +} + +enum SubscriptionStatusesEnum { + NOT_ACTIVE + SUBSCRIBED + UNSUBSCRIBED + UNCONFIRMED +} diff --git a/design-documents/graph-ql/coverage/customer/Wishlist.graphqls b/design-documents/graph-ql/coverage/customer/Wishlist.graphqls new file mode 100644 index 000000000..1aeeb76ed --- /dev/null +++ b/design-documents/graph-ql/coverage/customer/Wishlist.graphqls @@ -0,0 +1,192 @@ +type Mutation { + createWishlist(input: CreateWishlistInput!): CreateWishlistOutput # Multiple wishlists Commerce functionality + deleteWishlist(wishlistUid: ID!): DeleteWishlistOutput # Commerce fucntionality - in Opens Source we assume customer always has one wishlist + addProductsToWishlist(wishlistId: ID!, wishlistItems: [WishlistItemInput!]!): AddProductsToWishlistOutput + removeProductsFromWishlist(wishlistId: ID!, wishlistItemsIds: [ID!]!): RemoveProductsFromWishlistOutput + updateProductsInWishlist(wishlistId: ID!, wishlistItems: [WishlistItemUpdateInput!]!): UpdateProductsInWishlistOutput + copyProductsBetweenWishlists(sourceWishlistUid: ID!, destinationWishlistUid: ID!, wishlistItems: [WishlistItemCopyInput!]!): CopyProductsBetweenWishlistsOutput @doc(description: "Copy a product to the wish list") + moveProductsBetweenWishlists(sourceWishlistUid: ID!, destinationWishlistUid: ID!, wishlistItems: [WishlistItemMoveInput!]!): MoveProductsBetweenWishlistsOutput @doc(description: "Move products from one wish list to another") + updateWishlist( wishlistUid: ID!, input: UpdateWishlistInput!): UpdateWishlistOutput @doc(description: "Change the name and visibility of the specified wish list") + addWishlistItemsToCart( + wishlistUid: ID!, @doc(description: "unique Id of wishlist") + wishlistItemUids: [ID!] @doc(description: "Optional param. selected wish list items that are to be added") + ): AddWishlistItemsToCartOutput @doc(description: "Add Requisition List Items To Customer Cart") +} + +type Customer { + wishlist: Wishlist! @deprecated(reason: "Use `Customer.wishlists` or `Customer.wishlist_v2") + wishlist_v2(id: ID!): Wishlist # This query will be added in the ce + wishlists: [Wishlist!]! @doc(description: "Customer wishlists are limited to a max of 1 wishlist in Magento Open Source") # This query will be added in the ce +} + +type Wishlist { + id: ID + items: [WishlistItem] @deprecated(reason: "Use field `items_v2` from type `Wishlist` instead") + items_v2( + currentPage: Int = 1, + pageSize: Int = 20 + ): WishlistItems @doc(description: "An array of items in the customer's wishlist") + items_count: Int + sharing_code: String + updated_at: String + name: String @doc(description: "Avaialble in Commerce edition only") + visibility: WishlistVisibilityEnum! +} + +type WishlistItems { + items: [WishlistItemInterface]! @doc(description: "Wishlist items list") + page_info: SearchResultPageInfo + total_pages: Int! @doc(description: "total count of wishlist items") +} + +input CreateWishlistInput { + name: String! + visibility: WishlistVisibilityEnum! +} + +input UpdateWishlistInput { + name: String + visibility: WishlistVisibilityEnum +} + +type WishlistItems { + items: [WishlistItemInterface]! + page_info: SearchResultPageInfo + total_count: Int! +} + +input WishlistItemUpdateInput { + wishlist_item_id: ID! + quantity: Float + description: String + selected_options: [ID!] + entered_options: [EnteredOptionInput!] +} + +type AddProductsToWishlistOutput { + wishlist: Wishlist! +} + +type RemoveProductsFromWishlistOutput { + status: Boolean! + wishlist: Wishlist! +} + +type UpdateProductsInWishlistOutput { + wishlist: Wishlist! +} + +type AddWishlistItemsToCartOutput { + status: Boolean! + add_wishlist_items_to_cart_user_errors: [AddWishlistItemUserError]! +} + +type AddWishlistItemUserError { + message: String! + type: AddWishlistItemUserErrorType! +} + +enum AddWishlistItemUserErrorType { + OUT_OF_STOCK + MAX_QTY_FOR_USER + NOT_AVAILABLE +} + +input WishlistItemInput { + sku: String! + quantity: Float! + parent_sku: String, + parent_quantity: Float, + selected_options: [ID!] + entered_options: [EnteredOptionInput!] +} + +interface WishlistItemInterface { + id: ID + quantity: Float + description: String + added_at: String + product: ProductInterface + customizable_options: [SelectedCustomizableOption] +} + +type SimpleWishlistItem implements WishlistItemInterface @doc(description: "Simple Wishlist Item") { +} + +type VirtualWishlistItem implements WishlistItemInterface @doc(description: "Virtual Wishlist Item") { +} + +type ConfigurableWishlistItem implements WishlistItemInterface { + child_sku: String! @doc(description: "SKU of the simple product corresponding to a set of selected configurable options.") + configurable_options: [SelectedConfigurableOption!] +} + +type DownloadableWishlistItem implements WishlistItemInterface @doc(description: "Downloadable Wishlist Item") { + links_v2: [DownloadableProductLink] @doc(description: "An array containing information about the selected links") + samples: [DownloadableProductSamples] @doc(description: "DownloadableProductSamples defines characteristics of a downloadable product") +} + +type BundleWishlistItem implements WishlistItemInterface { + bundle_options: [SelectedBundleOption!] +} + +type GiftCardWishlistItem implements WishlistItemInterface { + gift_card_options: GiftCardOptions! +} + +type GiftCardOptions { + sender_name: String + sender_email: String + recipient_name: String + recipient_email: String + amount: Money + custom_giftcard_amount: Money + message: String +} + +type GroupedProductWishlistItem implements WishlistItemInterface { + grouped_products: [GroupedProductItem!]! +} + +enum WishlistVisibilityEnum @doc(description: "This enumeration defines the wish list visibility types") { + PUBLIC + PRIVATE +} + +type CreateWishlistOutput { + wishlist: Wishlist! +} + +type DeleteWishlistOutput { + status: Boolean! + wishlists: [Wishlist!]! +} + +input WishlistItemCopyInput { + wishlist_item_id: ID! @doc(description: "The ID of the item to be copied") + quantity: Float @doc(description: "The quantity of this item to copy to the destination wish list. This value can't be greater than the quantity in the source wish list.") +} + +input WishlistItemMoveInput { + wishlist_item_id: ID! @doc(description: "The ID of the item to be moved") + quantity: Float @doc(description: "The quantity of this item to move to the destination wish list. This value can't be greater than the quantity in the source wish list.") +} + +type UpdateWishlistOutput { + wishlist: Wishlist +} + +type CopyProductsBetweenWishlistsOutput { + source_wishlist: Wishlist! + destination_wishlist: Wishlist! +} + +type MoveProductsBetweenWishlistsOutput { + source_wishlist: Wishlist! + destination_wishlist: Wishlist! +} + +type StoreConfig { + maximum_number_of_wishlists: Int @doc(description: "If multiple wish lists are enabled, the maximum number of wish lists the customer can have") + enable_multiple_wishlists: Boolean @doc(description: "Indicates whether customers can have multiple wish lists.") +} diff --git a/design-documents/graph-ql/coverage/customer-email-password-update.md b/design-documents/graph-ql/coverage/customer/customer-email-password-update.md similarity index 100% rename from design-documents/graph-ql/coverage/customer-email-password-update.md rename to design-documents/graph-ql/coverage/customer/customer-email-password-update.md diff --git a/design-documents/graph-ql/coverage/customer/customer-orders.md b/design-documents/graph-ql/coverage/customer/customer-orders.md new file mode 100644 index 000000000..6472608fe --- /dev/null +++ b/design-documents/graph-ql/coverage/customer/customer-orders.md @@ -0,0 +1,412 @@ +# Customer Orders API + +# Overview + +The GraphQL API should provide a possibility to retrieve orders, shipments, invoices, credit memos for the logged in customer. The current schema allows fetching only simple order details and does not provide a possibility to fetch order details by a number. + +The proposed solution is deprecation of `customerOrders` query and addition of `orders` field to the `customer` query: + +```graphql +@doc("Query to return a list of all customer orders") +type Customer { + orders ( + filter: CustomerOrdersFilterInput + currentPage: Int = 1 @doc("current page of the customer order list. default is 1") + pageSize: Int = 20 @doc("page size for the customer orders list. default is 20") + ): CustomerOrders +} + +@doc("Collection of customer orders") +type CustomerOrders { + items: [CustomerOrder]! @doc("collection of customer orders that contains individual order details.") + page_info: SearchResultPageInfo + total_count: Int @doc("the total count of customer orders") +} +``` + +```graphql +@doc("Allows to extend the list of search criteria for customer orders") +input CustomerOrdersFilterInput { + number: FilterStringTypeInput @doc("Order number. Allows to filter orders by fully or partial entered number") + status: String @("Order status") + createdDate: FilterRangeTypeInput + total: CustomerOrdersAmountFilterInput + salesItem: SalesItemFilterInput +} + +@doc("Provides order total amount search filter") +input CustomerOrdersAmountFilterInput { + min: Float @doc("Minimum total order amount in store view currency") + max: Float @doc("Maximum total order amount in store view currency") +} + +@doc("Allows to extend the list of search criteria for order items") +input SalesItemFilterInput { + name: String @doc("Order item name. Allows to filter orders by fully or partial entered order item name") + sku: String @doc("Order item SKU. Allows to filter orders by fully or partial entered order item SKU") +} + +@doc( "Defines a filter for an input string.") +input FilterStringTypeInput { + in: [String] @doc("Filters items that are exactly the same as entries specified in an array of strings.") + eq: String @doc("Filters items that are exactly the same as the specified string.") + match: String @doc("Defines a filter that performs a fuzzy search using the specified string.") +} +``` + +> Right now, we don't introduce the filter input for such entities like invoice, credit memo, shipment as all these entities are related to the same order and GraphQL resolvers anyway at first resolve the order and after that other child entities. But, in future, such filter input can be introduced as optional argument. + +## Order Type Schema + +The proposed type for the customer order might look like: + +```graphql +@doc("Customer order details") +type CustomerOrder { + id: ID! @doc("the ID of the order, used for API purposes") + order_date: String! @doc("date when the order was placed") + status: String! @doc("current status of the order") + number: String! @doc("sequential order number") + items: [OrderItemInterface] @doc("collection of all the items purchased") @deprecated("The `items` field is derecated. Use `items_v2` instead.") + items_v2( + currentPage: Int = 1 @doc("current page of the customer order item list. default is 1") + pageSize: Int = 20 @doc("page size for the customer orders item list. default is 20") + ): OrderItems! @doc("Collection of all of the purchased items") + total: OrderTotal @doc("total amount details for the order") + invoices: [Invoice] @doc("invoice list for the order") + credit_memos: [CreditMemo] @doc("credit memo list for the order") + shipments: [OrderShipment] @doc("shipment list for the order") + payment_methods: [OrderPaymentMethod] @doc("payment details for the order") + shipping_address: OrderAddress @doc("shipping address for the order") + billing_address: OrderAddress @doc("billing address for the order") + carrier: String @doc("shipping carrier for the order delivery") + shipping_method: String @doc("shipping method for the order") + comments: [SalesCommentItem] @doc("comments on the order") +} +``` + +The `id` will be a `base64_encode(increment_id)` which in future can be replaced by UUID. + +> The order `status` should be filtered in the same way as for Luma via `Order Status` and `Visible On Storefront` configuration + +### Order Item +The order items will be presented as separate interface which will have multiple implementations for invoice, shipment and credit memo types. +The order items will be paginated. + +```graphql +@doc("Grpahql Order Item Output Wrapper") +type OrderItems { + items: [OrderItem]! @doc("List of orders items") + page_info: SearchResultPageInfo + total_count: Int! +} +``` + +```graphql +interface OrderItemInterface @doc("Order item details") { + id: ID! @doc("Order item unique identifier") #base64encode(orderItemId) + product_name: String @doc("name of the base product") + product_sku: String! @doc("SKU of the base product") + product_url_key: String @doc("URL key of the base product") + product_type: String @doc("Type of product (e.g. simple, configurable, bundle)") + status: String @doc("the status of order item") + product_sale_price: Money! @doc("sale price for the base product including selected options") + discounts: [Discount] @doc("final discount information for the base product including discounts on options") + selected_options: [OrderItemOption] @doc("selected options for the base product. for e.g color, size etc.") + entered_options: [OrderItemOption] @doc("entered option for the base product. for e.g logo image etc.") + quantity_ordered: Float @doc("number of items") + quantity_shipped: Float @doc("number of shipped items") + quantity_refunded: Float @doc("number of refunded items") + quantity_invoiced: Float @doc("number of invoiced items") + quantity_canceled: Float @doc("number of cancelled items") + quantity_returned: Float @doc("number of returned items") +} + +type OrderItem implements OrderItemInterface { +} + +type BundleOrderItem implements OrderItemInterface { + bundle_options: [ItemSelectedBundleOption] @doc("A list of bundle options that are assigned to the bundle product") +} + +type ItemSelectedBundleOption { + id: ID! @doc(description: "The unique identifier of the option") + label: String! @doc(description: "The label of the option") + values: [ItemSelectedBundleOptionValue] @doc(description: "A list of products that represent the values of the parent option") +} + +type ItemSelectedBundleOptionValue { + id: ID! @doc("unique identifier of option value") + product_name: String! @doc("product name for option value") + product_sku: String! @doc("product sku for option value") + quantity: Float! @doc("quantitity of value selected") + price: Money! @doc("Option value price. price for single quantity") +} + +type DownloadableOrderItem implements OrderItemInterface { + downloadable_links: [DownloadableItemsLinks] @doc(description: "A list of downloadable links that are ordered from the downloadable product") +} + +type DownloadableItemsLinks @doc(description: "DownloadableProductLinks defines characteristics of a downloadable product") { + title: String @doc(description: "The display name of the link") + sort_order: Int @doc(description: "A number indicating the sort order") + uid: ID! @doc(description: "A string that encodes option details.") +} + +type GiftCardOrderItem implements OrderItemInterface { + gift_card: GiftCardItem @doc(description: "Selected gift card properties for an order item") +} +type GiftCardItem { + sender_name: String @doc(description: "Entered gift card sender name") + sender_email: String @doc(description: "Entered gift card sender email") + recipient_name: String @doc(description: "Entered gift card recipient name") + recipient_email: String @doc(description: "Entered gift card recipient email") + message: String @doc(description: "Entered gift card message intended for the recipient") +} + +@doc("Represents order item options like selected or entered") +type OrderItemOption { + label: String! @doc("name of the option") + value: String! @doc("value of the option") +} +``` +### Payment Method Schema + +To provide more customization for different payment solutions, the payment method will be represented by own type instead of simple string: + +```graphql +@doc("Payment method used to pay for the order") +type OrderPaymentMethod { + name: String! @doc("payment method name for e.g Braintree, Authorize etc.") + type: String! @doc("payment method type used to pay for the order for e.g Credit Card, PayPal etc.") + additional_data: [KeyValue] @doc("additional data per payment method type") +} +``` + +> The payment `additional_data` should be filtered in the same way as for Luma via `privateInfoKeys` and `paymentInfoKeys` to not expose sensitive information. + +### Amounts Schema + +As entities like order, invoice, credit memo might have complex amounts type: + +```graphql +@doc("Order total amounts details") +type OrderTotal { + subtotal: Money! @doc("subtotal amount excluding, shipping, discounts and tax") + discounts: [Discount] @doc("applied discounts") + total_tax: Money! @doc("total tax amount") + taxes: [TaxItem] @doc("order taxes details") + grand_total: Money! @doc("final total amount including shipping and taxes") + base_grand_total: Money! @doc("final total amount in base currency") + total_shipping: Money! @doc("order shipping amount") + shipping_handling: ShippingHandling @doc("shipping and handling for the order") +} + +@doc("Shipping handling details") +type ShippingHandling { + total_amount: Money! @doc("shipping total amount") + amount_including_tax: Money @doc("shipping amount including tax") + amount_excluding_tax: Money @doc("shipping amount excluding tax") + taxes: [TaxItem] @doc("shipping taxes details") + discounts: [ShippingDiscount] @doc("The applied discounts to the shipping) +} + +type ShippingDiscount @doc(description:"Defines an individual shipping discount. This discount can be applied to shipping.") { + amount: Money! @doc(description:"The amount of the discount") +} + +@doc("Tax item details") +type TaxItem { + amount: Money! @doc("tax amount") + title: String! @doc("tax item title") + rate: Float @doc("tax item rate") +} +``` + +## Invoice Type Schema + +The invoice entity will have the similar to the order schema: +The `id` will be a `base64_encode(increment_id)` which in future can be replaced by UUID. +```graphql +@doc("Invoice details") +type Invoice { + id: ID! @doc("the ID of the invoice, used for API purposes") + number: String! @doc("sequential invoice number") + total: InvoiceTotal @doc("invoice total amount details") + items: [InvoiceItemInterface] @doc("invoiced product details") + comments: [SalesCommentItem] @doc("comments on the invoice") +} + +@doc("Invoice item details") +interface InvoiceItemInterface { + id: ID! @doc("invoice item unique identifier") #base64encode(invoiceItemId) + order_item: OrderItemInterface @doc("associated order item") + product_name: String @doc("name of the base product") + product_sku: String! @doc("SKU of the base product") + product_sale_price: Money! @doc("sale price for the base product including selected options") + discounts: [Discount] @doc("final discount information for the base product including discounts on options") + quantity_invoiced: Float @doc("number of invoiced items") +} + +type InvoiceItem implements InvoiceItemInterface { +} + +type BundleInvoiceItem implements InvoiceItemInterface { + bundle_options: [ItemSelectedBundleOption] @doc("A list of bundle options that are assigned to the bundle product") +} + +type DownloadableInvoiceItem implements InvoiceItemInterface { + downloadable_links: [DownloadableItemsLinks] @doc(description: "A list of downloadable links that are invoiced from the downloadable product") +} + +type GiftCardInvoiceItem implements InvoiceItemInterface { + gift_card: GiftCardItem @doc(description: "Selected gift card properties for an invoice item") +} + +@doc("Invoice total amount details") +type InvoiceTotal { + subtotal: Money! @doc("subtotal amount excluding, shipping, discounts and tax") + discounts: [Discount] @doc("applied discounts") + total_tax: Money! @doc("total tax amount") + taxes: [TaxItem] @doc("order taxes details") + grand_total: Money! @doc("final total amount including shipping and taxes") + base_grand_total: Money! @doc("final total amount in base currency") + total_shipping: Money! @doc("order shipping amount") + shipping_handling: ShippingHandling @doc("shipping and handling for the order") +} +``` + +## Refund Type Schema + +The credit memo entity will have the similar to the order and invoice schema: +The `id` will be a `base64encode(increment_id)` which in future can be replaced by UUID. + +```graphql +@doc("Credit memo details") +type CreditMemo { + id: ID! @doc("the ID of the credit memo, used for API purposes") + number: String! @doc("sequential credit memo number") + items: [CreditMemoItemInterface] @doc("items refunded") + total: CreditMemoTotal @doc("refund total amount details") + comments: [SalesCommentItem] @doc("comments on the credit memo") +} + +@doc("Credit memo item details") +interface CreditMemoItemInterface { + id: ID! @doc("Credit memo item unique identifier") #base64encode(creditMemoItemId) + order_item: OrderItemInterface @doc("associated order item") + product_name: String @doc("name of the base product") + product_sku: String! @doc("SKU of the base product") + product_sale_price: Money! @doc("sale price for the base product including selected options") + discounts: [Discount] @doc("final discount information for the base product including discounts on options") + quantity_invoiced: Float @doc("number of invoiced items") +} + +type CreditMemoItem implements CreditMemoItemInterface { +} + +type BundleCreditMemoItem implements CreditMemoIntemInterface { + bundle_options: [ItemSelectedBundleOption] +} + +type DownloadableCreditMemoItem implements CreditMemoItemInterface { + downloadable_links: [DownloadableItemsLinks] @doc(description: "A list of downloadable links that are refunded from the downloadable product") +} + +type GiftCardCreditMemoItem implements CreditMemoItemInterface { + gift_card: GiftCardItem @doc(description: "Selected gift card properties for a credit memo item") +} + +@doc("Credit memo price details") +type CreditMemoTotal { + subtotal: Money! @doc("subtotal amount excluding, shipping, discounts and tax") + discounts: [Discount] @doc("applied discounts") + total_tax: Money! @doc("total tax amount") + taxes: [TaxItem] @doc("order taxes details") + grand_total: Money! @doc("final total amount including shipping and taxes") + base_grand_total: Money! @doc("final total amount in base currency") +} +``` + +## Shipment Type Schema +The `id` will be a `base64_encode(increment_id)` which in future can be replaced by UUID. +```graphql +@doc("Order shipment details") +type OrderShipment { + id: ID! @doc("the ID of the shipment, used for API purposes") + number: String! @doc("sequential credit shipment number") + tracking: [ShipmentTracking] @doc("shipment tracking details") + items: [ShipmentItemInterface] @doc("items included in the shipment") + comments: [SalesCommentItem] @doc("comments on the shipment") +} + +@doc("Order shipment item details") +interface ShipmentItemInterface { + id: ID! @doc("Shipment item unique identifier") #base64encode(shipmentItemId) + order_item: OrderItemInterface @doc("associated order item") + product_name: String @doc("name of the base product") + product_sku: String! @doc("SKU of the base product") + product_sale_price: Money! @doc("sale price for the base product") + quantity_shipped: Float! @doc("number of shipped items") +} + +type ShipmentItem implements ShipmentItemInterface { +} + +type BundleShipmentItem implements ShipmentItemInterface { + bundle_options: [ItemSelectedBundleOption] +} + +type GiftCardShipmentItem implements ShipmentItemInterface { + gift_card: GiftCardItem @doc(description: "Selected gift card properties for a shipment item") +} + +@doc("Order shipment tracking details") +type ShipmentTracking { + title: String! @doc("shipment tracking title") + carrier: String! @doc("shipping carrier for the order delivery") + number: String @doc("tracking number of the order shipment") +} +``` + +## SalesCommentItem type +```graphql +type SalesCommentItem { + timestamp: String! @doc("The timestamp of the comment") + message: String! @doc("the comment message") +} +``` + +## OrderAddress type +```graphql + +type OrderAddress @doc(description: "OrderAddress contains detailed information about the order billing and shipping addresses"){ + firstname: String! @doc(description: "The first name of the person associated with the shipping/billing address") + lastname: String! @doc(description: "The family name of the person associated with the shipping/billing address") + middlename: String @doc(description: "The middle name of the person associated with the shipping/billing address") + region: String @doc(description: "The state or province name") + region_id: ID @doc(description: "The unique ID for a pre-defined region") + country_code: CountryCodeEnum @doc(description: "The customer's order country") + street: [String!] @doc(description: "An array of strings that define the street number and name") + company: String @doc(description: "The customer's order company") + telephone: String! @doc(description: "The telephone number") + fax: String @doc(description: "The fax number") + postcode: String @doc(description: "The customer's order ZIP or postal code") + city: String! @doc(description: "The city or town") + prefix: String @doc(description: "An honorific, such as Dr., Mr., or Mrs.") + suffix: String @doc(description: "A value such as Sr., Jr., or III") + vat_id: String @doc(description: "The customer's Value-added tax (VAT) number (for corporate customers)") +} +``` + +## Additional Types + +The `KeyValue` type will provide a possibility to use key-value pairs: + +```graphql +@doc("The key-value type") +type KeyValue { + name: String @doc("name part of the name/value pair") + value: String @doc("value part of the name/value pair") +} +``` diff --git a/design-documents/graph-ql/coverage/customer-password-reset.md b/design-documents/graph-ql/coverage/customer/customer-password-reset.md similarity index 100% rename from design-documents/graph-ql/coverage/customer-password-reset.md rename to design-documents/graph-ql/coverage/customer/customer-password-reset.md diff --git a/design-documents/graph-ql/coverage/customer-reorder.graphqls b/design-documents/graph-ql/coverage/customer/customer-reorder.graphqls similarity index 100% rename from design-documents/graph-ql/coverage/customer-reorder.graphqls rename to design-documents/graph-ql/coverage/customer/customer-reorder.graphqls diff --git a/design-documents/graph-ql/coverage/customer/customer.graphqls b/design-documents/graph-ql/coverage/customer/customer.graphqls new file mode 100644 index 000000000..8568d7593 --- /dev/null +++ b/design-documents/graph-ql/coverage/customer/customer.graphqls @@ -0,0 +1,396 @@ +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. + +type StoreConfig { + required_character_classes_number : String @doc(description: "The number of different character classes required in a password (lowercase, uppercase, digits, special characters).") + minimum_password_length : String @doc(description: "The minimum number of characters required for a valid password.") + autocomplete_on_storefront : Boolean @doc(description: "Enable autocomplete on login and forgot password forms") +} + +type Query { + customer: Customer @resolver(class: "Magento\\CustomerGraphQl\\Model\\Resolver\\Customer") @doc(description: "The customer query returns information about a customer account") @cache(cacheable: false) + isEmailAvailable ( + email: String! @doc(description: "The new customer email") + ): IsEmailAvailableOutput @resolver(class: "Magento\\CustomerGraphQl\\Model\\Resolver\\IsEmailAvailable") +} + +type Mutation { + generateCustomerToken(email: String!, password: String!): CustomerToken @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\GenerateCustomerToken") @doc(description:"Retrieve the customer token") + changeCustomerPassword(currentPassword: String!, newPassword: String!): Customer @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\ChangePassword") @doc(description:"Changes the password for the logged-in customer") + createCustomer (input: CustomerInput!): CustomerOutput @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\CreateCustomer") @doc(description:"Create customer account") + updateCustomer (input: CustomerInput!): CustomerOutput @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\UpdateCustomer") @doc(description:"Update the customer's personal information") + revokeCustomerToken: RevokeCustomerTokenOutput @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\RevokeCustomerToken") @doc(description:"Revoke the customer token") + createCustomerAddress(input: CustomerAddressInput!): CustomerAddress @resolver(class: "Magento\\CustomerGraphQl\\Model\\Resolver\\CreateCustomerAddress") @doc(description: "Create customer address") + updateCustomerAddress(uid: ID!, input: CustomerAddressInput): CustomerAddress @resolver(class: "Magento\\CustomerGraphQl\\Model\\Resolver\\UpdateCustomerAddress") @doc(description: "Update customer address") + deleteCustomerAddress(uid: ID!): Boolean @resolver(class: "Magento\\CustomerGraphQl\\Model\\Resolver\\DeleteCustomerAddress") @doc(description: "Delete customer address") + requestPasswordResetEmail(email: String!): Boolean @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\RequestPasswordResetEmail") @doc(description: "Request an email with a reset password token for the registered customer identified by the specified email.") + resetPassword(email: String!, resetPasswordToken: String!, newPassword: String!): Boolean @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\ResetPassword") @doc(description: "Reset a customer's password using the reset password token that the customer received in an email after requesting it using requestPasswordResetEmail.") +} + +input CustomerAddressInput { + firstname: String @doc(description: "The first name of the person associated with the shipping/billing address") + lastname: String @doc(description: "The family name of the person associated with the shipping/billing address") + company: String @doc(description: "The customer's company") + telephone: String @doc(description: "The telephone number") + street: [String] @doc(description: "An array of strings that define the street number and name") + city: String @doc(description: "The city or town") + region: CustomerAddressRegionInput @doc(description: "An object containing the region name, region code, and region ID") + postcode: String @doc(description: "The customer's ZIP or postal code") + country_id: CountryCodeEnum @doc(description: "Deprecated: use `country_code` instead.") + country_code: CountryCodeEnum @doc(description: "The customer's country") + default_shipping: Boolean @doc(description: "Indicates whether the address is the default shipping address") + default_billing: Boolean @doc(description: "Indicates whether the address is the default billing address") + fax: String @doc(description: "The fax number") + middlename: String @doc(description: "The middle name of the person associated with the shipping/billing address") + prefix: String @doc(description: "An honorific, such as Dr., Mr., or Mrs.") + suffix: String @doc(description: "A value such as Sr., Jr., or III") + vat_id: String @doc(description: "The customer's Tax/VAT number (for corporate customers)") + custom_attributes: [CustomerAddressAttributeInput] @doc(description: "Deprecated: Custom attributes should not be put into container.") +} + +input CustomerAddressRegionInput @doc(description: "CustomerAddressRegionInput defines the customer's state or province") { + region_code: String @doc(description: "The address region code") + region: String @doc(description: "The state or province name") + region_uid: ID @doc(description: "The unique ID for a pre-defined region") +} + +input CustomerAddressAttributeInput { + attribute_code: String! @doc(description: "Attribute code") + value: String! @doc(description: "Attribute value") +} + +type CustomerToken { + token: String @doc(description: "The customer token") +} + +input CustomerInput { + prefix: String @doc(description: "An honorific, such as Dr., Mr., or Mrs.") + firstname: String @doc(description: "The customer's first name") + middlename: String @doc(description: "The customer's middle name") + lastname: String @doc(description: "The customer's family name") + suffix: String @doc(description: "A value such as Sr., Jr., or III") + email: String @doc(description: "The customer's email address. Required for customer creation") + dob: String @doc(description: "Deprecated: Use `date_of_birth` instead") + date_of_birth: String @doc(description: "The customer's date of birth") + taxvat: String @doc(description: "The customer's Tax/VAT number (for corporate customers)") + gender: Int @doc(description: "The customer's gender (Male - 1, Female - 2)") + password: String @doc(description: "The customer's password") + is_subscribed: Boolean @doc(description: "Indicates whether the customer is subscribed to the company's newsletter") +} + +type CustomerOutput { + customer: Customer! +} + +type RevokeCustomerTokenOutput { + result: Boolean! +} + +type Customer @doc(description: "Customer defines the customer name and address and other details") { + created_at: String @doc(description: "Timestamp indicating when the account was created") + group_uid: ID @deprecated(reason: "Customer group should not be exposed in the storefront scenarios") + prefix: String @doc(description: "An honorific, such as Dr., Mr., or Mrs.") + firstname: String @doc(description: "The customer's first name") + middlename: String @doc(description: "The customer's middle name") + lastname: String @doc(description: "The customer's family name") + suffix: String @doc(description: "A value such as Sr., Jr., or III") + email: String @doc(description: "The customer's email address. Required") + default_billing: String @doc(description: "The ID assigned to the billing address") + default_shipping: String @doc(description: "The ID assigned to the shipping address") + dob: String @doc(description: "The customer's date of birth") @deprecated(reason: "Use `date_of_birth` instead") + date_of_birth: String @doc(description: "The customer's date of birth") + taxvat: String @doc(description: "The customer's Value-added tax (VAT) number (for corporate customers)") + uid: ID @doc(description: "The ID assigned to the customer") @deprecated(reason: "id is not needed as part of Customer because on server side it can be identified based on customer token used for authentication. There is no need to know customer ID on the client side.") + is_subscribed: Boolean @doc(description: "Indicates whether the customer is subscribed to the company's newsletter") @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\IsSubscribed") + addresses: [CustomerAddress] @doc(description: "An array containing the customer's shipping and billing addresses") @resolver(class: "\\Magento\\CustomerGraphQl\\Model\\Resolver\\CustomerAddresses") + gender: Int @doc(description: "The customer's gender (Male - 1, Female - 2)") +} + +type CustomerAddress @doc(description: "CustomerAddress contains detailed information about a customer's billing and shipping addresses"){ + uid: ID @doc(description: "The ID assigned to the address object") + customer_uid: ID @doc(description: "The customer ID") @deprecated(reason: "customer_id is not needed as part of CustomerAddress, address ID (id) is unique identifier for the addresses.") + region: CustomerAddressRegion @doc(description: "An object containing the region name, region code, and region ID") + region_uid: ID @doc(description: "The unique ID for a pre-defined region") @deprecated(reason: "Use `region` instead.") + country_id: String @doc(description: "The customer's country") @deprecated(reason: "Use `country_code` instead.") + country_code: CountryCodeEnum @doc(description: "The customer's country") @deprecated(reason: "Use `country` instead.") + country: Country @doc(description: "The customer's country") + street: [String] @doc(description: "An array of strings that define the street number and name") + company: String @doc(description: "The customer's company") + telephone: String @doc(description: "The telephone number") + fax: String @doc(description: "The fax number") + postcode: String @doc(description: "The customer's ZIP or postal code") + city: String @doc(description: "The city or town") + firstname: String @doc(description: "The first name of the person associated with the shipping/billing address") + lastname: String @doc(description: "The family name of the person associated with the shipping/billing address") + middlename: String @doc(description: "The middle name of the person associated with the shipping/billing address") + prefix: String @doc(description: "An honorific, such as Dr., Mr., or Mrs.") + suffix: String @doc(description: "A value such as Sr., Jr., or III") + vat_id: String @doc(description: "The customer's Value-added tax (VAT) number (for corporate customers)") + default_shipping: Boolean @doc(description: "Indicates whether the address is the default shipping address") + default_billing: Boolean @doc(description: "Indicates whether the address is the default billing address") + custom_attributes: [CustomerAddressAttribute] @deprecated(reason: "Custom attributes should not be put into container") + extension_attributes: [CustomerAddressAttribute] @doc(description: "Address extension attributes") +} + +type CustomerAddressRegion @doc(description: "CustomerAddressRegion defines the customer's state or province") { + region_code: String @doc(description: "The address region code") + region: String @doc(description: "The state or province name") + region_uid: ID @doc(description: "The unique ID for a pre-defined region") +} + +type CustomerAddressAttribute { + attribute_code: String @doc(description: "Attribute code") + value: String @doc(description: "Attribute value") +} + +type IsEmailAvailableOutput { + is_email_available: Boolean @doc(description: "Is email availabel value") +} + +enum CountryCodeEnum @doc(description: "The list of countries codes") { + AF @doc(description: "Afghanistan") + AX @doc(description: "Åland Islands") + AL @doc(description: "Albania") + DZ @doc(description: "Algeria") + AS @doc(description: "American Samoa") + AD @doc(description: "Andorra") + AO @doc(description: "Angola") + AI @doc(description: "Anguilla") + AQ @doc(description: "Antarctica") + AG @doc(description: "Antigua & Barbuda") + AR @doc(description: "Argentina") + AM @doc(description: "Armenia") + AW @doc(description: "Aruba") + AU @doc(description: "Australia") + AT @doc(description: "Austria") + AZ @doc(description: "Azerbaijan") + BS @doc(description: "Bahamas") + BH @doc(description: "Bahrain") + BD @doc(description: "Bangladesh") + BB @doc(description: "Barbados") + BY @doc(description: "Belarus") + BE @doc(description: "Belgium") + BZ @doc(description: "Belize") + BJ @doc(description: "Benin") + BM @doc(description: "Bermuda") + BT @doc(description: "Bhutan") + BO @doc(description: "Bolivia") + BA @doc(description: "Bosnia & Herzegovina") + BW @doc(description: "Botswana") + BV @doc(description: "Bouvet Island") + BR @doc(description: "Brazil") + IO @doc(description: "British Indian Ocean Territory") + VG @doc(description: "British Virgin Islands") + BN @doc(description: "Brunei") + BG @doc(description: "Bulgaria") + BF @doc(description: "Burkina Faso") + BI @doc(description: "Burundi") + KH @doc(description: "Cambodia") + CM @doc(description: "Cameroon") + CA @doc(description: "Canada") + CV @doc(description: "Cape Verde") + KY @doc(description: "Cayman Islands") + CF @doc(description: "Central African Republic") + TD @doc(description: "Chad") + CL @doc(description: "Chile") + CN @doc(description: "China") + CX @doc(description: "Christmas Island") + CC @doc(description: "Cocos (Keeling) Islands") + CO @doc(description: "Colombia") + KM @doc(description: "Comoros") + CG @doc(description: "Congo-Brazzaville") + CD @doc(description: "Congo-Kinshasa") + CK @doc(description: "Cook Islands") + CR @doc(description: "Costa Rica") + CI @doc(description: "Côte d’Ivoire") + HR @doc(description: "Croatia") + CU @doc(description: "Cuba") + CY @doc(description: "Cyprus") + CZ @doc(description: "Czech Republic") + DK @doc(description: "Denmark") + DJ @doc(description: "Djibouti") + DM @doc(description: "Dominica") + DO @doc(description: "Dominican Republic") + EC @doc(description: "Ecuador") + EG @doc(description: "Egypt") + SV @doc(description: "El Salvador") + GQ @doc(description: "Equatorial Guinea") + ER @doc(description: "Eritrea") + EE @doc(description: "Estonia") + ET @doc(description: "Ethiopia") + FK @doc(description: "Falkland Islands") + FO @doc(description: "Faroe Islands") + FJ @doc(description: "Fiji") + FI @doc(description: "Finland") + FR @doc(description: "France") + GF @doc(description: "French Guiana") + PF @doc(description: "French Polynesia") + TF @doc(description: "French Southern Territories") + GA @doc(description: "Gabon") + GM @doc(description: "Gambia") + GE @doc(description: "Georgia") + DE @doc(description: "Germany") + GH @doc(description: "Ghana") + GI @doc(description: "Gibraltar") + GR @doc(description: "Greece") + GL @doc(description: "Greenland") + GD @doc(description: "Grenada") + GP @doc(description: "Guadeloupe") + GU @doc(description: "Guam") + GT @doc(description: "Guatemala") + GG @doc(description: "Guernsey") + GN @doc(description: "Guinea") + GW @doc(description: "Guinea-Bissau") + GY @doc(description: "Guyana") + HT @doc(description: "Haiti") + HM @doc(description: "Heard & McDonald Islands") + HN @doc(description: "Honduras") + HK @doc(description: "Hong Kong SAR China") + HU @doc(description: "Hungary") + IS @doc(description: "Iceland") + IN @doc(description: "India") + ID @doc(description: "Indonesia") + IR @doc(description: "Iran") + IQ @doc(description: "Iraq") + IE @doc(description: "Ireland") + IM @doc(description: "Isle of Man") + IL @doc(description: "Israel") + IT @doc(description: "Italy") + JM @doc(description: "Jamaica") + JP @doc(description: "Japan") + JE @doc(description: "Jersey") + JO @doc(description: "Jordan") + KZ @doc(description: "Kazakhstan") + KE @doc(description: "Kenya") + KI @doc(description: "Kiribati") + KW @doc(description: "Kuwait") + KG @doc(description: "Kyrgyzstan") + LA @doc(description: "Laos") + LV @doc(description: "Latvia") + LB @doc(description: "Lebanon") + LS @doc(description: "Lesotho") + LR @doc(description: "Liberia") + LY @doc(description: "Libya") + LI @doc(description: "Liechtenstein") + LT @doc(description: "Lithuania") + LU @doc(description: "Luxembourg") + MO @doc(description: "Macau SAR China") + MK @doc(description: "Macedonia") + MG @doc(description: "Madagascar") + MW @doc(description: "Malawi") + MY @doc(description: "Malaysia") + MV @doc(description: "Maldives") + ML @doc(description: "Mali") + MT @doc(description: "Malta") + MH @doc(description: "Marshall Islands") + MQ @doc(description: "Martinique") + MR @doc(description: "Mauritania") + MU @doc(description: "Mauritius") + YT @doc(description: "Mayotte") + MX @doc(description: "Mexico") + FM @doc(description: "Micronesia") + MD @doc(description: "Moldova") + MC @doc(description: "Monaco") + MN @doc(description: "Mongolia") + ME @doc(description: "Montenegro") + MS @doc(description: "Montserrat") + MA @doc(description: "Morocco") + MZ @doc(description: "Mozambique") + MM @doc(description: "Myanmar (Burma)") + NA @doc(description: "Namibia") + NR @doc(description: "Nauru") + NP @doc(description: "Nepal") + NL @doc(description: "Netherlands") + AN @doc(description: "Netherlands Antilles") + NC @doc(description: "New Caledonia") + NZ @doc(description: "New Zealand") + NI @doc(description: "Nicaragua") + NE @doc(description: "Niger") + NG @doc(description: "Nigeria") + NU @doc(description: "Niue") + NF @doc(description: "Norfolk Island") + MP @doc(description: "Northern Mariana Islands") + KP @doc(description: "North Korea") + NO @doc(description: "Norway") + OM @doc(description: "Oman") + PK @doc(description: "Pakistan") + PW @doc(description: "Palau") + PS @doc(description: "Palestinian Territories") + PA @doc(description: "Panama") + PG @doc(description: "Papua New Guinea") + PY @doc(description: "Paraguay") + PE @doc(description: "Peru") + PH @doc(description: "Philippines") + PN @doc(description: "Pitcairn Islands") + PL @doc(description: "Poland") + PT @doc(description: "Portugal") + QA @doc(description: "Qatar") + RE @doc(description: "Réunion") + RO @doc(description: "Romania") + RU @doc(description: "Russia") + RW @doc(description: "Rwanda") + WS @doc(description: "Samoa") + SM @doc(description: "San Marino") + ST @doc(description: "São Tomé & Príncipe") + SA @doc(description: "Saudi Arabia") + SN @doc(description: "Senegal") + RS @doc(description: "Serbia") + SC @doc(description: "Seychelles") + SL @doc(description: "Sierra Leone") + SG @doc(description: "Singapore") + SK @doc(description: "Slovakia") + SI @doc(description: "Slovenia") + SB @doc(description: "Solomon Islands") + SO @doc(description: "Somalia") + ZA @doc(description: "South Africa") + GS @doc(description: "South Georgia & South Sandwich Islands") + KR @doc(description: "South Korea") + ES @doc(description: "Spain") + LK @doc(description: "Sri Lanka") + BL @doc(description: "St. Barthélemy") + SH @doc(description: "St. Helena") + KN @doc(description: "St. Kitts & Nevis") + LC @doc(description: "St. Lucia") + MF @doc(description: "St. Martin") + PM @doc(description: "St. Pierre & Miquelon") + VC @doc(description: "St. Vincent & Grenadines") + SD @doc(description: "Sudan") + SR @doc(description: "Suriname") + SJ @doc(description: "Svalbard & Jan Mayen") + SZ @doc(description: "Swaziland") + SE @doc(description: "Sweden") + CH @doc(description: "Switzerland") + SY @doc(description: "Syria") + TW @doc(description: "Taiwan") + TJ @doc(description: "Tajikistan") + TZ @doc(description: "Tanzania") + TH @doc(description: "Thailand") + TL @doc(description: "Timor-Leste") + TG @doc(description: "Togo") + TK @doc(description: "Tokelau") + TO @doc(description: "Tonga") + TT @doc(description: "Trinidad & Tobago") + TN @doc(description: "Tunisia") + TR @doc(description: "Turkey") + TM @doc(description: "Turkmenistan") + TC @doc(description: "Turks & Caicos Islands") + TV @doc(description: "Tuvalu") + UG @doc(description: "Uganda") + UA @doc(description: "Ukraine") + AE @doc(description: "United Arab Emirates") + GB @doc(description: "United Kingdom") + US @doc(description: "United States") + UY @doc(description: "Uruguay") + UM @doc(description: "U.S. Outlying Islands") + VI @doc(description: "U.S. Virgin Islands") + UZ @doc(description: "Uzbekistan") + VU @doc(description: "Vanuatu") + VA @doc(description: "Vatican City") + VE @doc(description: "Venezuela") + VN @doc(description: "Vietnam") + WF @doc(description: "Wallis & Futuna") + EH @doc(description: "Western Sahara") + YE @doc(description: "Yemen") + ZM @doc(description: "Zambia") + ZW @doc(description: "Zimbabwe") +} diff --git a/design-documents/graph-ql/coverage/customer/gift-registry.graphqls b/design-documents/graph-ql/coverage/customer/gift-registry.graphqls new file mode 100644 index 000000000..e90acb10e --- /dev/null +++ b/design-documents/graph-ql/coverage/customer/gift-registry.graphqls @@ -0,0 +1,361 @@ +type Customer { + gift_registries: [GiftRegistry] + gift_registry(uid: ID!): GiftRegistry +} + +type Query { + giftRegistryTypes: [GiftRegistryType] @doc(description: "Get a list of available gift registry types") + giftRegistryEmailSearch( + email: String! @doc(description: "The registrant's email") + ): [GiftRegistrySearchResult] @doc(description: "Search for gift registries by specifying a registrant email address") + giftRegistryIdSearch( + giftRegistryUid: ID! @doc(description: "The ID of the gift registry") + ): [GiftRegistrySearchResult] @doc(description: "Search for gift registries by specifying a registry URL key") + giftRegistryTypeSearch( + firstName: String! @doc(description: "The first name of the registrant") + lastName: String! @doc(description: "The last name of the registrant") + typeUid: String @doc(description: "The type UID of the registry") + ): [GiftRegistrySearchResult] @doc(description: "Search for gift registries by specifying the registrant name and registry type ID") + giftRegistry(giftRegistryUid: ID!): GiftRegistry @doc(description: "This query is intended for guests and some fields of GiftRegistry will not be availalbe") +} + +type Mutation { + # All mutations below should only be accessible to the registry owner. Guest users should be getting authorization error + + createGiftRegistry(giftRegistry: CreateGiftRegistryInput!): CreateGiftRegistryOutput + updateGiftRegistry(giftRegistryUid: ID!, giftRegistry: UpdateGiftRegistryInput!): UpdateGiftRegistryOutput + removeGiftRegistry(giftRegistryUid: ID!): RemoveGiftRegistryOutput + + addGiftRegistryItems(giftRegistryUid: ID!, items: [AddGiftRegistryItemInput!]!): AddGiftRegistryItemsOutput @doc(description: "Adds individual items to the gift registry") + moveCartItemsToGiftRegistry(cartUid: ID!, giftRegistryUid: ID!): MoveCartItemsToGiftRegistryOutput @doc(description: "Moves all items from cart to the gift registry") + + removeGiftRegistryItems(giftRegistryUid: ID!, itemUids: [ID!]!): RemoveGiftRegistryItemsOutput + updateGiftRegistryItems(giftRegistryUid: ID!, items: [UpdateGiftRegistryItemInput!]!): UpdateGiftRegistryItemsOutput + + addGiftRegistryRegistrants(giftRegistryUid: ID!, registrants: [AddGiftRegistryRegistrantInput!]!): AddGiftRegistryRegistrantsOutput + updateGiftRegistryRegistrants(giftRegistryUid: ID!, registrants: [UpdateGiftRegistryRegistrantInput!]!): UpdateGiftRegistryRegistrantsOutput + removeGiftRegistryRegistrants(giftRegistryUid: ID!, registrantUids: [ID!]!): RemoveGiftRegistryRegistrantsOutput + + shareGiftRegistry(giftRegistryUid: ID!, sender: ShareGiftRegistrySenderInput!, invitees: [ShareGiftRegistryInviteeInput!]!): ShareGiftRegistryOutput +} + +type GiftRegistrySearchResult { + gift_registry_uid: ID! @doc(description: "The URL key of the gift registry") + name: String! + event_title: String! @doc(description: "The title given to the event") + type: String @doc(description: "The type of event being held") + location: String @doc(description: "The location of the event") + event_date: String @doc(description: "The date of the event") +} + +input ShareGiftRegistryInviteeInput +{ + name: String! + email: String! +} + +input ShareGiftRegistrySenderInput +{ + name: String! + message: String! +} + +input AddGiftRegistryItemInput { + sku: String! + quantity: Float! + parent_sku: String, + parent_quantity: Float, + note: String, + # see https://github.com/magento/architecture/blob/master/design-documents/graph-ql/coverage/add-items-to-cart-single-mutation.md + selected_options: [String!] + # see https://github.com/magento/architecture/blob/master/design-documents/graph-ql/coverage/add-items-to-cart-single-mutation.md + entered_options: [EnteredOptionInput!] +} + +# Should be defined in scope of https://github.com/magento/architecture/blob/master/design-documents/graph-ql/coverage/add-items-to-cart-single-mutation.md +input EnteredOptionInput { + uid: ID! + value: String! +} + +input UpdateGiftRegistryItemInput { + gift_registry_item_uid: ID! + quantity: Float! + note: String +} + +input UpdateGiftRegistryInput { + event_name: String + message: String + privacy_settings: GiftRegistryPrivacySettings + status: GiftRegistryStatus + shipping_address: GiftRegistryShippingAddressInput + dynamic_attributes: [GiftRegistryDynamicAttributeInput!] @doc(description: "As a result of the update, only the values of provided attributes will be affected. If the attribute is missing in the request, its value will not be changed") +} + +input CreateGiftRegistryInput { + gift_registry_uid: ID @doc(description: "Optional uid, can be generated on the client and used for sending multiple gift-registry related mutations in a single request. For example, create registry and immediatly add items or registrants.") + event_name: String! + gift_registry_type_uid: ID! + message: String! + privacy_settings: GiftRegistryPrivacySettings! + status: GiftRegistryStatus! + shipping_address: GiftRegistryShippingAddressInput + registrants: [AddGiftRegistryRegistrantInput!]! + dynamic_attributes: [GiftRegistryDynamicAttributeInput] +} + +input GiftRegistryShippingAddressInput @doc(description: "Either address data or address ID should be provided. In case both are provided, validation will fail") { + address_data: CustomerAddressInput + address_id: ID +} + +input UpdateGiftRegistryRegistrantInput { + gift_registry_registrant_uid: ID + first_name: String + last_name: String + email: String + dynamic_attributes: [GiftRegistryDynamicAttributeInput] @doc(description: "As a result of the update, only the values of provided attributes will be affected. If the attribute is missing in the request, its value will not be changed") +} + +input AddGiftRegistryRegistrantInput { + first_name: String! + last_name: String! + email: String! + dynamic_attributes: [GiftRegistryDynamicAttributeInput] +} + +input GiftRegistryDynamicAttributeInput { + code: ID! + value: String! +} + +interface GiftRegistryErrorsInterface { + errors: [GiftRegistryItemError]! +} + +interface GiftRegistryItemErrorInterface { + error: GiftRegistryItemError +} + +interface GiftRegistryOutputInterface { + gift_registry: GiftRegistry +} + +type CreateGiftRegistryOutput implements GiftRegistryOutputInterface { +} + +type UpdateGiftRegistryOutput implements GiftRegistryOutputInterface { +} + +type RemoveGiftRegistryOutput { + is_removed: Boolean! +} + +type AddGiftRegistryItemsOutput implements GiftRegistryOutputInterface, GiftRegistryItemErrorInterface { +} + +type MoveCartItemsToGiftRegistryOutput implements GiftRegistryOutputInterface, GiftRegistryErrorsInterface { +} + +type GiftRegistryItemError { + message: String! + product_uid: ID + gift_registry_item_uid: ID + gift_registry_uid: ID + code: GiftRegistryItemErrorType! +} + +enum GiftRegistryItemErrorType { + OUT_OF_STOCK + NOT_FOUND @doc(description: "Used for exceptions like EntityNotFound") + UNDEFINED @doc(description: "Used for other exceptions like db connection failures or other exceptions") +} + +type RemoveGiftRegistryItemsOutput implements GiftRegistryOutputInterface, GiftRegistryItemErrorInterface { +} + +type UpdateGiftRegistryItemsOutput implements GiftRegistryOutputInterface, GiftRegistryItemErrorInterface { +} + +type AddGiftRegistryRegistrantsOutput implements GiftRegistryOutputInterface { +} + +type UpdateGiftRegistryRegistrantsOutput implements GiftRegistryOutputInterface { +} + +type RemoveGiftRegistryRegistrantsOutput implements GiftRegistryOutputInterface { +} + +type ShareGiftRegistryOutput { + is_shared: Boolean! +} + +type GiftRegistryType { + uid: ID! + label: String! + dynamic_attributes_metadata: [GiftRegistryDynamicAttributeMetadataInterface] +} + +interface GiftRegistryDynamicAttributeMetadataInterface { + code: ID! + input_type: String! + attribute_group: String! + label: String! + is_required: Boolean! + sort_order: Int +} + +type GiftRegistryTextAttributeMetadata implements GiftRegistryDynamicAttributeMetadataInterface { +} + +type GiftRegistrySelectAttributeMetadata implements GiftRegistryDynamicAttributeMetadataInterface, GiftRegistrySelectAttributeMetadataInterface { +} + +type GiftRegistryDateAttributeMetadata implements GiftRegistryDynamicAttributeMetadataInterface { + format: GiftRegistryDateAttributeFormat! +} + +enum GiftRegistryDateAttributeFormat { + SHORT + MEDIUM + LONG + FULL +} + +type GiftRegistryCountryAttributeMetadata implements GiftRegistryDynamicAttributeMetadataInterface, GiftRegistrySelectAttributeMetadataInterface { + show_region: Boolean! +} +type GiftRegistryRegionAttributeMetadata implements GiftRegistryDynamicAttributeMetadataInterface, GiftRegistrySelectAttributeMetadataInterface { +} + +type GiftRegistryEventCountryAttributeMetadata implements GiftRegistryDynamicAttributeMetadataInterface, GiftRegistrySearcheableAttributeMetadataInterface, GiftRegistrySelectAttributeMetadataInterface { + show_region: Boolean! +} + +type GiftRegistryEventRegionAttributeMetadata implements GiftRegistryDynamicAttributeMetadataInterface, GiftRegistrySearcheableAttributeMetadataInterface, GiftRegistrySelectAttributeMetadataInterface { +} + +type GiftRegistryEventDateAttributeMetadata implements GiftRegistryDynamicAttributeMetadataInterface, GiftRegistrySearcheableAttributeMetadataInterface { + format: GiftRegistryDateAttributeFormat! +} + +type GiftRegistryEventLocationAttributeMetadata implements GiftRegistryDynamicAttributeMetadataInterface, GiftRegistrySearcheableAttributeMetadataInterface { +} + +type GiftRegistryRoleAttributeMetadata implements GiftRegistryDynamicAttributeMetadataInterface, GiftRegistrySearcheableAttributeMetadataInterface, GiftRegistrySelectAttributeMetadataInterface { +} + +interface GiftRegistrySearcheableAttributeMetadataInterface { + is_searcheable: Boolean! +} + +interface GiftRegistrySelectAttributeMetadataInterface { + options: [GiftRegistrySelectAttributeOptionMetadata] +} + +type GiftRegistrySelectAttributeOptionMetadata { + code: ID! + label: String! + is_default: Boolean +} + +type GiftRegistry { + uid: ID! + event_name: String! + type: GiftRegistryType + message: String! + created_at: String! @doc(description: "Creation date") + privacy_settings: GiftRegistryPrivacySettings! @doc(description: "Accessible to the registry owner only") + status: GiftRegistryStatus! @doc(description: "Accessible to the registry owner only") + owner_name: String! + registrants: [GiftRegistryRegistrant] + shipping_address: CustomerAddress @doc(description: "Accessible to the registry owner only") + dynamic_attributes: [GiftRegistryDynamicAttribute] + items: [GiftRegistryItemInterface] +} + +interface GiftRegistryItemInterface { + uid: ID! + quantity: Float! + quantity_fulfilled: Float! + note: String + date_added: String! + product: ProductInterface + customizable_options: [SelectedCustomizableOption] # added support for customizable options +} + +type GiftRegistryItem implements GiftRegistryItemInterface { +} + +type BundleGiftRegistryItem implements GiftRegistryItemInterface { + bundle_options: [SelectedBundleOption!] +} + +type ConfigurableGiftRegistryItem implements GiftRegistryItemInterface { + child_sku: String! @doc(description: "SKU of the simple product corresponding to a set of selected configurable options.") + configurable_options: [SelectedConfigurableOption!] +} + +# Not currently supported by Magento core +type DownloadableGiftRegistryItem implements GiftRegistryItemInterface { + links: [DownloadableProductLink] + samples: [DownloadableProductSamples] +} + +# Not currently supported by Magento core +type VirtualGiftRegistryItem implements GiftRegistryItemInterface { +} + +# Not currently supported by Magento core +type GiftCardGiftRegistryItem implements GiftRegistryItemInterface { + sender_name: String! + recipient_name: String! + amount: SelectedGiftCardAmount + message: String +} + +interface GiftRegistryDynamicAttributeInterface { + code: ID! + label: String! + value: String! +} + +type GiftRegistryDynamicAttribute implements GiftRegistryDynamicAttributeInterface { + group: GiftRegistryDynamicAttributeGroup! +} + +type GiftRegistryRegistrantDynamicAttribute implements GiftRegistryDynamicAttributeInterface { + +} + +enum GiftRegistryDynamicAttributeGroup { + GENERAL_INFORMATION + EVENT_INFORMATION + PRIVACY_SETTINGS + DETAILED_INFORMATION + SHIPPING_ADDRESS +} + +type GiftRegistryRegistrant { + uid: ID! + firstname: String! + lastname: String! + email: String! @doc(description: "Accessible to the registry owner only") + dynamic_attributes: [GiftRegistryRegistrantDynamicAttribute] +} + +enum GiftRegistryStatus { + ACTIVE + INACTIVE +} + +enum GiftRegistryPrivacySettings { + PRIVATE + PUBLIC +} + +type StoreConfig { + magento_giftregistry_general_enabled : String + magento_giftregistry_general_max_registrant : String +} diff --git a/design-documents/graph-ql/coverage/customer/gift-registry.md b/design-documents/graph-ql/coverage/customer/gift-registry.md new file mode 100644 index 000000000..14d497dc5 --- /dev/null +++ b/design-documents/graph-ql/coverage/customer/gift-registry.md @@ -0,0 +1,864 @@ +## Configuration + +The following configurations must be exposed via existing `storeConfig` query: +- Enable gift registry +- Maximum registrants + +## Use Cases + +### Registered customer creates a new gift registry + +First, get a list of available gift registry types and dynamic attributes metadata to render the gift registry creation form: +```graphql +{ + giftRegistryTypes { + uid + label + dynamic_attributes_metadata { + code + label + attribute_group + input_type + is_required + sort_order + ... on GiftRegistryCountryAttributeMetadata { + show_region + } + ... on GiftRegistryEventCountryAttributeMetadata { + show_region + } + ... on GiftRegistrySearcheableAttributeMetadataInterface { + is_searcheable + } + ... on GiftRegistryEventDateAttributeMetadata { + format + } + ... on GiftRegistrySelectAttributeMetadataInterface { + options { + code + is_default + label + } + } + } + } +} +``` + +Then create a new gift registry based on the user input. Registrants are added using a separate mutation, which can be sent in the same request if gift registry ID is client-side generated. +```graphql +# In real query only one should be provided: existing address ID OR address data +mutation CreateGiftRegistryWithRegistrants($giftRegistryData: CreateGiftRegistryInput!, $giftRegistryUid: ID!, $registrantsData: [AddGiftRegistryRegistrantInput!]!) { + createGiftRegistry(giftRegistry: $giftRegistryData) { + gift_registry { + uid + event_name + shipping_address { + id + street + } + } + } + addGiftRegistryRegistrants(giftRegistryUid: $giftRegistryUid, registrants: $registrantsData) { + gift_registry { + registrants { + iud + first_name + last_name + email + dynamic_attributes { + code + value + label + } + } + } + } +} +``` +The following JSON represents query variables for the `CreateGiftRegistryWithRegistrants` mutation above. +```json +{ + "giftRegistryUid": "optional client-generated UID", + "giftRegistryData": { + "uid": "optional client-generated UID", + "event_name": "My Birthday", + "giftRegistryTypeUid": "2", + "message": "Pleas come to my birthday", + "privacy_settings":"PUBLIC", + "status": "ACTIVE", + "shipping_address": { + "address_id": 3, + "address_data": { + "firstname": "John", + "lastname": "Doe", + "street": ["123 Some Avenue"], + "city": "Austin", + "region": { + "region_code": "TX" + }, + "postcode": "78758", + "company": "Magento", + "country_code": "US" + } + }, + "dynamic_attributes": [ + { + "code": "event_country", + "value": "US" + }, + { + "code": "event_date", + "value": "6/2/20" + } + ] + }, + "registrantsData": [ + { + "email": "John@example.com", + "first_name": "John", + "last_name": "Roller", + "dynamic_attributes": [ + { + "code": "diet", + "value": "none" + } + ] + } + ] +} +``` + +### Gift registry owner views the list of the gift registries created earlier + +```graphql +{ + customer { + gift_registries { + uid + event_name + created_at + message + } + } +} +``` + +### Gift registry owner views and modifies an existing gift registry on edit page + +Render the edit gift registry form and populate it with current gift registry properties: + +```graphql +{ + customer { + gift_registry(giftRegistryUid: "ID obtained from the list query") { + event_name + message + privacy_settings + status + registrants { + uid + first_name + last_name + email + dynamic_attributes { + code + label + value + } + } + shipping_address { + id + firstname + lastname + company + street + city + region { + region + region_code + } + postcode + country_code + telephone + fax + } + dynamic_attributes { + code + group + label + value + } + type { + label + dynamic_attributes_metadata { + code + label + attribute_group + input_type + is_required + sort_order + ... on GiftRegistryCountryAttributeMetadata { + show_region + } + ... on GiftRegistryEventCountryAttributeMetadata { + show_region + } + ... on GiftRegistryEventDateAttributeMetadata { + format + } + ... on GiftRegistrySelectAttributeMetadataInterface { + options { + code + is_default + label + } + } + } + } + } + } +} +``` + +Modify gift registry data: + +```graphql +mutation UpdateGiftRegistryWithRegistrants($giftRegistryUid: ID!, $giftRegistryData: UpdateGiftRegistryInput!, $registrantsData: [UpdateGiftRegistryRegistrantInput!]!) { + updateGiftRegistry(giftRegistryUid: $giftRegistryUid, giftRegistry: $giftRegistryData) { + gift_registry { + uid + event_name + shipping_address { + id + street + } + } + } + updateGiftRegistryRegistrants(giftRegistryUid: $giftRegistryUid, registrants: $registrantsData) { + gift_registry { + registrants { + uid + first_name + last_name + email + dynamic_attributes { + code + value + label + } + } + } + } +} +``` +The JSON below should be used as query variable for the gift registry update mutation above: +```json +{ + "giftRegistryUid": "existing-gift-registry-ID", + "giftRegistryData": { + "event_name": "My Birthday", + "giftRegistryTypeUid": "2", + "message": "Pleas come to my birthday", + "privacy_settings":"PUBLIC", + "status": "ACTIVE", + "shipping_address": { + "address_id": 3, + "address_data": { + "firstname": "John", + "lastname": "Doe", + "street": ["123 Some Avenue"], + "city": "Austin", + "region": { + "region_code": "TX" + }, + "postcode": "78758", + "company": "Magento", + "country_code": "US" + } + }, + "dynamic_attributes": [ + { + "code": "event_country", + "value": "US" + }, + { + "code": "event_date", + "value": "6/2/20" + } + ] + }, + "registrantsData": [ + { + "uid": "existing-registrant-uid", + "email": "John@example.com", + "first_name": "John", + "last_name": "Roller", + "dynamic_attributes": [ + { + "code": "diet", + "value": "none" + } + ] + } + ] +} +``` + +### Gift registry owner removes an existing gift registry + +```graphql +mutation RemoveGiftRegistry($giftRegistryUid: ID!) { + removeGiftRegistry(giftRegistryUid: $giftRegistryUid) { + is_removed + } +} +``` + +### Adding items to the gift registry + +It should be possible to additems to the gift registry from the product/category page, cart or wishlist. +Selected and entered options are specified in a form of hash, which is based on option type, selected values and potentially quantity. +It is critical to have ability to avoid the hash generation on the client, that is why it must be accessible through products, cart, wishlist and gift registry queries. + +#### Getting details about cart item which needs to be added to gift registry + +```graphql +{ + customerCart { + items { + uid + quantity + product { + sku + } + customizable_options { + uid + } + ... on BundleCartItem { + bundle_options { + values { + child_sku + quantity + } + } + } + ... on ConfigurableCartItem { + child_sku + configurable_options { + uid + } + } + ... on DownloadableCartItem { + links_v2 { + uid + } + } + ... on GiftCardCartItem { + amount { + uid + } + } + } + } +} +``` + +#### Getting details about wishlist item which needs to be added to gift registry + +Note that if the item is not fully configured, the user must be redirected to the product page to complete selections before the item can be added to the gift registry. + +Query structure is almost identical to the query for getting items + +```graphql +{ + customer { + wishlist { + items { + uid + quantity + product { + sku + } + customizable_options { + uid + } + ... on BundleWishlistItem { + bundle_options { + values { + child_sku + quantity + } + } + } + ... on ConfigurableWishlistItem { + child_sku + configurable_options { + uid + } + } + ... on DownloadableWishlistItem { + links_v2 { + uid + } + } + ... on GiftCardWishlistItem { + uid + } + } + } + } +} +``` + +#### Executing mutation to add items to gift registry + +Based on that information we can send a mutation to add the selected item to gift registry. + +```graphql +mutation AddGiftRegistryItems($giftRegistryUid: ID!, $giftRegistryItems: [AddGiftRegistryItemInput!]!) { + addGiftRegistryItems(giftRegistryUid: $giftRegistryUid, items: $giftRegistryItems) { + gift_registry { + event_name + items { + uid + product { + name + thumbnail { + url + } + } + selected_customizable_options { + uid + is_required + label + sort_order + values { + uid + price { + type + units + value + } + value + label + } + } + added_at + note + quantity + quantity_fulfilled + ... on BundleGiftRegistryItem { + selected_bundle_options { + uid + label + type + values { + uid + label + price + quantity + } + } + } + ... on ConfigurableGiftRegistryItem { + selected_configurable_options { + uid + option_label + value_id + value_label + } + } + ... on DownloadableGiftRegistryItem { + links { + price + sample_url + sort_order + title + } + samples { + sample_url + sort_order + title + } + } + ... on GiftCardGiftRegistryItem { + sender_name + recipient_name + amount { + currency + value + } + message + } + } + } + } +} +``` + +The following JSON should be provided as query variables for the mutation above: + +Simple product with custom options: + +```json +{ + "giftRegistryUid": "existing-gift-registry-uid", + "giftRegistryItems": [ + { + "sku": "simple-hat", + "quantity": 2, + "note": "Really like this color", + "selected_options": [ + "hash based on custom option for the select type goes here" + ], + "entered_options": [ + { + "uid": "hash from custom phrase option UID", + "value": "Custom Hat" + } + ] + } + ] +} +``` + +Configurable product with custom options: +```json +{ + "giftRegistryUid": "existing-gift-registry-uid", + "giftRegistryItems": [ + { + "sku": "custom-hat", + "quantity": 2, + "note": "Really like this color", + "selected_options": [ + "hash from the color configurable option ID and its value ID", + "hash from the size configurable option ID and its value ID", + "hash based on custom option for the select type goes here" + ], + "entered_options": [ + { + "uid": "hash from custom phrase option UID", + "value": "Custom Hat" + } + ] + } + ] +} +``` + +Bundle product with custom options: + +```json +{ + "giftRegistryUid": "existing-gift-registry-uid", + "giftRegistryItems": [ + { + "sku": "fan-kit-hat", + "parent_sku": "fan-kit", + "quantity": 2, + "parent_quantity": 3, + "note": "Really like this color. Must be identical for all bundle children.", + "selected_options": [ + "hash based on custom option for the select type goes here. Must be identical for all bundle children." + ], + "entered_options": [ + { + "uid": "hash based on custom phrase option goes here. Must be identical for all bundle children.", + "value": "Custom Text" + } + ] + }, + { + "sku": "fan-kit-scarf", + "parent_sku": "fan-kit", + "quantity": 1, + "parent_quantity": 3, + "note": "Really like this color. Must be identical for all bundle children.", + "selected_options": [ + "hash based on custom option for the select type goes here. Must be identical for all bundle children." + ], + "entered_options": [ + { + "uid": "hash based on custom phrase option goes here. Must be identical for all bundle children.", + "value": "Custom Text" + } + ] + } + ] +} +``` + +Downloadable product with custom options: +```json +{ + "giftRegistryUid": "existing-gift-registry-uid", + "giftRegistryItems": [ + { + "sku": "downloadable-product", + "quantity": 2, + "selected_options": [ + "hash from the selected link A", + "hash from the selected link B", + "hash from the selected custom option" + ], + "entered_options": [ + { + "uid": "hash from custom text option UID", + "value": "Custom Entry" + } + ] + } + ] +} +``` + +Gift card product with custom options: +```json +{ + "giftRegistryUid": "existing-gift-registry-uid", + "giftRegistryItems": [ + { + "sku": "giftcard-product", + "quantity": 2, + "selected_options": [ + "hash from the selected gift card amount", + "hash from the selected custom option" + ], + "entered_options": [ + { + "uid": "hash from custom text option UID", + "value": "Custom Entry" + } + ] + } + ] +} +``` + +Virtual card product with custom options: +```json +{ + "giftRegistryUid": "existing-gift-registry-uid", + "giftRegistryItems": [ + { + "sku": "virtual-product", + "quantity": 2, + "selected_options": [ + "hash from the selected custom option" + ], + "entered_options": [ + { + "uid": "hash from custom text option ID", + "value": "Custom Entry" + } + ] + } + ] +} +``` + +### Gift registry visitor adds items from the gift registry to the cart + +The following query returns enough data to add product to cart or wishlist. +```graphql +{ + giftRegistry(uid: "existing-gift-registry-uid") { + items { + uid + quantity + product { + sku + } + customizable_options { + uid + } + ... on BundleGiftRegistryItem { + bundle_options { + values { + child_sku + quantity + } + } + } + ... on ConfigurableGiftRegistryItem { + child_sku + configurable_options { + uid + } + } + ... on DownloadableGiftRegistryItem { + links { + uid + } + } + ... on GiftCardGiftRegistryItem { + uid + } + } + } +} +``` + +### Gift registry owner removes items from an existing gift registry + +```graphql +mutation RemoveGiftRegistryItem($giftRegistryUid: ID!, $itemUids: [ID!]!) { + removeGiftRegistryItems(giftRegistryUid: $giftRegistryUid, itemUids: $itemUids) { + gift_registry { + items { + uid + } + } + } +} +``` + +Query variables: +```json +{ + "giftRegistryUid": "existing-gift-registry-uid", + "itemIds": ["item-one-uid", "item-two-uid"] +} +``` + +### Gift registry visitor searches a gift registry by the recipient name + +First, storefront application retrieves gift registry search form metadata: + +```graphql +{ + giftRegistryTypes { + uid + label + dynamic_attributes_metadata { + code + label + attribute_group + input_type + is_required + sort_order + ... on GiftRegistryCountryAttributeMetadata { + show_region + } + ... on GiftRegistryEventCountryAttributeMetadata { + show_region + } + ... on GiftRegistrySearcheableAttributeMetadataInterface { + is_searcheable + } + ... on GiftRegistryEventDateAttributeMetadata { + format + } + ... on GiftRegistrySelectAttributeMetadataInterface { + options { + code + is_default + label + } + } + } + } +} +``` + +Search by registrant name and dynamic attributes. + +Explicitly excluded scenarios: search by ID and by email. + +```graphql +{ + giftRegistrySearch( + registrantFirstname: "John", + registrantLastname: "Roller", + giftRegistryTypeUid: "2", + searchableDynamicAttributes: [{code: "event_country", value: "US"}] + ) { + uid + event_name + dynamic_attributes { + code + group + label + value + } + } +} +``` + +### Gift registry owner shares a gift registry with friends + +When gift registry is shared with the invitees, the email they receive will contain a link. +This link will contain gift registry hash as query parameter and should lead to the page processed by the storefront application. +The application should parse the URL, extract gift registry ID hash and query gift registry details by ID. + +```graphql +mutation ShareGiftRegistry($giftRegistryUid: ID!, $sender: ShareGiftRegistrySenderInput!, $invitees: [ShareGiftRegistryInviteeInput!]!) { + shareGiftRegistry(giftRegistryUid: $uid, sender: $sender, invitees: $invitees) { + is_shared + } +} +``` + +The following JSON should be provided as query variables: + +```json +{ + "uid": "existing-gift-registry-uid", + "sender": { + "message": "Hi, please come to my birth day", + "name": "John Roller" + }, + "invitees": [ + { + "email": "invitee@example.com", + "name": "John Doe" + } + ] +} +``` + +### Gift registry visitor opens a gift registry using the link from email + +```graphql +{ + giftRegistry(uid: "ID obtained from the invitation link in email") { + event_name + message + dynamic_attributes { + code + group + label + value + } + type { + label + dynamic_attributes_metadata { + code + label + attribute_group + input_type + is_required + sort_order + ... on GiftRegistryCountryAttributeMetadata { + show_region + } + ... on GiftRegistryEventCountryAttributeMetadata { + show_region + } + ... on GiftRegistryEventDateAttributeMetadata { + format + } + ... on GiftRegistrySelectAttributeMetadataInterface { + options { + code + is_default + label + } + } + } + } + } +} +``` diff --git a/design-documents/graph-ql/coverage/gift-wrapping.md b/design-documents/graph-ql/coverage/customer/gift-wrapping.md similarity index 80% rename from design-documents/graph-ql/coverage/gift-wrapping.md rename to design-documents/graph-ql/coverage/customer/gift-wrapping.md index 897ac4098..f1f9c4da5 100644 --- a/design-documents/graph-ql/coverage/gift-wrapping.md +++ b/design-documents/graph-ql/coverage/customer/gift-wrapping.md @@ -41,10 +41,13 @@ type GiftCardCartItem { } type SalesItemInterface { - gift_wrapping: GiftWrapping @doc(description: "The selected gift wrapping for the order item") gift_message: GiftMessage @doc(description: "The entered gift message for the order item") } +interface OrderItemInterface { + gift_wrapping: GiftWrapping @resolver(class: "Magento\\GiftWrappingGraphQl\\Model\\Resolver\\Order\\Item\\GiftWrapping") @doc(description: "The selected gift wrapping for the order item") +} + type CustomerOrder { gift_wrapping: GiftWrapping @doc(description: "The selected gift wrapping for the order") printed_card_included: Boolean! @doc(description: "Whether customer requested printed card for the order") @@ -56,25 +59,40 @@ type CartPrices { gift_options: GiftOptionsPrices @doc(description: "The list of prices for the selected gift options") } -type SalesTotalsInterface { - gift_options: GiftOptionsPrices @doc(description: "The list of prices for the selected gift options") -} ###### End: Extending existing types ###### -###### Begin: Defining new types ###### -type GiftWrapping { - id: ID! @doc(description: "Gift wrapping unique identifier") - design: String! @doc(description: "Gift wrapping design name") - price: Money! @doc(description: "Gift wrapping price") - image: GiftWrappingImage! @doc(description: "Gift wrapping preview image") +###### StoreConfig ###### +type StoreConfig { + allow_gift_wrapping_on_order: String @doc(description: "Allow Gift Wrapping on Order Level") + allow_gift_wrapping_on_order_items: String @doc(description: "Allow Gift Wrapping for Order Items") + allow_gift_receipt: String @doc(description: "Allow Gift Receipt") + allow_printed_card: String @doc(description: "Allow Printed Card") + printed_card_price: String @doc(description: "Default Price for Printed Card") + cart_gift_wrapping: String @doc(description: "Display Gift Wrapping Prices") + cart_printed_card: String @doc(description: "Display Printed Card Prices") + sales_gift_wrapping: String @doc(description: "Display Gift Wrapping Prices") + sales_printed_card: String @doc(description: "Display Printed Card Prices") } +###### End ###### + + + + +###### Begin: Defining new types ###### type GiftWrappingImage { label: String! @doc(description: "Gift wrapping preview image label") url: String! @doc(description: "Gift wrapping preview image URL") } +type GiftWrapping { + id: ID! @doc(description: "Gift wrapping unique identifier") + design: String! @doc(description: "Gift wrapping design name") + price: Money! @doc(description: "Gift wrapping price") + image: GiftWrappingImage @doc(description: "Gift wrapping preview image") +} + type GiftOptionsPrices { gift_wrapping_for_order: Money @doc(description: "Price of the gift wrapping for the whole order") gift_wrapping_for_items: Money @doc(description: "Price of the gift wrapping for all individual order items") @@ -82,7 +100,7 @@ type GiftOptionsPrices { } type GiftMessage { - to: String! @doc(description: "Recepient name") + to: String! @doc(description: "Recipient name") from: String! @doc(description: "Sender name") message: String! @doc(description: "Gift message text") } @@ -99,11 +117,11 @@ The following gift options need to be whitelisted in the `storeConfig` query. Se
- + Magento\Config\Model\Config\Source\Yesno - + Magento\Config\Model\Config\Source\Yesno @@ -178,8 +196,17 @@ type CartItemUpdateInput { } type Mutation { - setGiftOptionsOnCart(cart_id: String!, gift_message: GiftMessageInput, gift_wrapping_id: ID, gift_receipt_included: Boolean, printed_card_included: Boolean): SetGiftOptionsOnCartOutput @doc(description: "Set gift options like gift wrapping or gift message for the entire cart") + setGiftOptionsOnCart(input: SetGiftOptionsOnCartInput): SetGiftOptionsOnCartOutput @doc(description: "Set gift options like gift wrapping or gift message for the entire cart") } + +input SetGiftOptionsOnCartInput{ + cart_id: String! @doc(description:"The unique ID that identifies the shopper's cart") + gift_message: GiftMessageInput @doc(description: "Gift message details for the cart") + gift_wrapping_id: ID @doc(description: "The unique identifier of the gift wrapping to be used for the cart") + printed_card_included: Boolean! @doc(description: "Whether customer requested printed card for the cart") + gift_receipt_included: Boolean! @doc(description: "Whether customer requested gift receipt for the cart") +} + ###### End: Extending existing types ###### @@ -188,7 +215,7 @@ type SetGiftOptionsOnCartOutput { cart: Cart! @doc(description: "The modified cart object") } -type GiftMessageInput { +input GiftMessageInput { to: String! @doc(description: "Recepient name") from: String! @doc(description: "Sender name") message: String! @doc(description: "Gift message text") diff --git a/design-documents/graph-ql/coverage/customer/login-as-customer.graphqls b/design-documents/graph-ql/coverage/customer/login-as-customer.graphqls new file mode 100644 index 000000000..9437478db --- /dev/null +++ b/design-documents/graph-ql/coverage/customer/login-as-customer.graphqls @@ -0,0 +1,28 @@ +type Mutation { + generateCustomerTokenAsAdmin(input: GenerateCustomerTokenAsAdminInput!): GenerateCustomerTokenAsAdminOutput +} + +input GenerateCustomerTokenAsAdminInput +{ + customer_email: String! +} + +type GenerateCustomerTokenAsAdminOutput +{ + customer_token: String! +} + +type Customer +{ + allow_remote_shopping_assistance: Boolean! +} + +input CustomerCreateInput +{ + allow_remote_shopping_assistance: Boolean +} + +input CustomerUpdateInput +{ + allow_remote_shopping_assistance: Boolean +} diff --git a/design-documents/graph-ql/coverage/customer/login-as-customer.md b/design-documents/graph-ql/coverage/customer/login-as-customer.md new file mode 100644 index 000000000..6e410c12c --- /dev/null +++ b/design-documents/graph-ql/coverage/customer/login-as-customer.md @@ -0,0 +1,39 @@ +## Use cases + +### Admin user obtains customer token + +Admin user is expected to be authenticated using admin token. While there is no GraphQL Mutation for retrieving the admin token, REST should be used (see [example](https://devdocs.magento.com/guides/v2.4/graphql/queries/index.html#staging)). + +Login as Customer link should contain short-lived and valid once token +Application can exchange this token via POST request to the REST endpoint to receive admin authentication token which can be used to obtain customer authentication token using GraphQL `generateCustomerTokenAsAdmin` query. + +Admin bearer token must be provided in the `Authorization` header along with the following mutation. The admin user associated with that token must have `Login as Customer` (`Magento_LoginAsCustomer::login`) permissions. As usual, the store context is determined based on `Store` header in the request. + +```graphql +mutation { + generateCustomerTokenAsAdmin( + input: { + customer_email: "customer@example.com" + } + ) { + customer_token + } +} +``` + +### Customer can manage and view "Allow remote shopping assistance" flag + +This flag should be added to `Customer`, `CustomerCreateInput` and `CustomerUpdateInput`. + +## Additional requirements + 1. A new `LoginAsCustomerGraphQL` module should be created. + 2. `Magento_LoginAsCustomer::login` permission must be declared in `LoginAsCustomer` module. + 3. The following store config settings must be honored, but should not be exposed as part of `StoreConfig` GraphQL query: +```xml + + + 0 + + +``` + 4. Customer field `allow_remote_shopping_assistance` must be taken into account diff --git a/design-documents/graph-ql/coverage/customer/reward-points.graphqls b/design-documents/graph-ql/coverage/customer/reward-points.graphqls new file mode 100644 index 000000000..9e79c74f5 --- /dev/null +++ b/design-documents/graph-ql/coverage/customer/reward-points.graphqls @@ -0,0 +1,75 @@ +type Customer { + reward_points: RewardPoints @doc(description: "Customer reward points details") +} + +type Cart { + applied_reward_points: RewardPointsAmount @doc(description: "Reward points ammount applied to the cart") +} + +type Mutation { + applyRewardPointsToCart(cartUid: ID!): ApplyRewardPointsToCartOutput @doc(description: "Apply all available points up to the cart total. Partial redemption is not available") + removeRewardPointsFromCart(cartUid: ID!): RemoveRewardPointsFromCartOutput @doc(description: "Cancel usage of the previously applied reward points to cart") +} + +type ApplyRewardPointsToCartOutput { + cart: Cart! +} + +type RemoveRewardPointsFromCartOutput { + cart: Cart! +} + +type RewardPoints { + balance: RewardPointsAmount @doc(description: "Current reward points balance") + exchange_rates: RewardPointsExchangeRates @doc(description: "Current reward points exchange rates") + subscription_status: RewardPointsSubscriptionStatus @doc(description: "Reward points related email subscription status") + balance_history: [RewardPointsBalanceHistoryItem!] @doc(description: "Reward points balance history. This field will be set to null if disabled in the admin") +} + +type RewardPointsAmount { + points: Float! @doc(description: "Reward points amount in points") + money: Money! @doc(description: "Reward points amount in store currency") +} + +type RewardPointsExchangeRates @doc (description: "Exchange rates depend on the customer group"){ + earning: RewardPointsRate @doc(description: "How many points are earned for a given amount spent") + redemption: RewardPointsRate @doc(description: "How many points must be redeemed to get a given amount of currency discount at the checkout") +} + +type RewardPointsRate { + points: Float! @doc(description: "The number of points for exchange rate. For earnings this this the number of points earned. For redemption this is the number of points needed for redemption.") + currency_amount: Float! @doc(description: "The money value for exchange rate. For earnings this is amount spent to earn the specified points. For redemption this is the amount of money the number of points represents.") +} + +type RewardPointsSubscriptionStatus { + balance_updates: SubscriptionStatus! @doc(description: "Customer subscription status to 'Reward points balance updates' emails") + points_expiration_notifications: SubscriptionStatus! @doc(description: "Customer subscription status to 'Reward points expiration notifications' emails") +} + +enum SubscriptionStatus { + SUBSCRIBED + NOT_SUBSCRIBED +} + +type RewardPointsBalanceHistoryItem { + balance: RewardPointsAmount @doc(description: "Reward points balance after the completion of the transaction") + points_change: Float! @doc(description: "Number of points added or deducted in the transaction") + change_reason: String! @doc(description: "Reason for balance change") + date: String! @doc(description: "Transaction date") +} + +type StoreConfig { + magento_reward_general_is_enabled: String @doc(description: "Reward points functionality status: enabled/disabled") + magento_reward_general_is_enabled_on_front: String @doc(description: "Reward points functionality status on the storefront: enabled/disabled") + magento_reward_general_publish_history: String @doc(description: "Enable reward points history for the customer") + magento_reward_general_min_points_balance: String @doc(description: "Reward points redemption minimum threshold") + magento_reward_points_order: String @doc(description: "Whether customer earns points for shopping according to the reward point exchange rate. In Luma this also controls whether to show a message in shopping cart about the rewards points earned for the purchase, as well as the customer’s current reward point balance") + magento_reward_points_register: String @doc(description: "Number of points customer gets for registration") + magento_reward_points_newsletter: String @doc(description: "Number of points for newsletter subscription") + magento_reward_points_invitation_customer: String @doc(description: "Number of points for referral, when invitee registers on the site") + magento_reward_points_invitation_customer_limit: String @doc(description: "Maximum number of registration referrals that will qualify for rewards") + magento_reward_points_invitation_order: String @doc(description: "Number of points for referral, when invitee places an initial order on the site") + magento_reward_points_invitation_order_limit: String @doc(description: "Maximum number of order placements by invitees that will qualify for rewards") + magento_reward_points_review: String @doc(description: "Number of points for writing a review") + magento_reward_points_review_limit: String @doc(description: "Maximum number of reviews that will qualify for the rewards") +} diff --git a/design-documents/graph-ql/coverage/customer/reward-points.md b/design-documents/graph-ql/coverage/customer/reward-points.md new file mode 100644 index 000000000..1aeb86b3e --- /dev/null +++ b/design-documents/graph-ql/coverage/customer/reward-points.md @@ -0,0 +1,134 @@ +## Configuration + +See https://docs.magento.com/user-guide/configuration/customers/reward-points.html + +The following settings should be accessible via `storeConfig` query: +- Reward points functionality status: enabled/disabled +- Reward points functionality status on the storefront: enabled/disabled +- Enable reward points history for the customer +- Reward points redemption minimum threshold +- Whether customer earns points for shopping according to the reward point exchange rate. In Luma this also controls whether to show a message in shopping cart about the rewards points earned for the purchase, as well as the customer’s current reward point balance +- Number of points customer gets for registration +- Number of points for newsletter subscription +- Number of points for referral, when invitee registers on the site +- Maximum number of registration referrals that will qualify for rewards +- Number of points for referral, when invitee places an initial order on the site +- Maximum number of order placements by invitees that will qualify for rewards +- Number of points for writing a review +- Maximum number of reviews that will qualify for the rewards + +Scenarios which may need these settings include: +- Reward program promotions and details +- Customer registration +- Rendering of the reward points section in the customer account + +```graphql +{ + storeConfig { + magento_reward_general_is_enabled + magento_reward_general_is_enabled_on_front + magento_reward_general_publish_history + magento_reward_general_min_points_balance + magento_reward_points_order + magento_reward_points_register + magento_reward_points_newsletter + magento_reward_points_invitation_customer + magento_reward_points_invitation_customer_limit + magento_reward_points_invitation_order + magento_reward_points_invitation_order_limit + magento_reward_points_review + magento_reward_points_review_limit + } +} +``` + +## Use cases + +### View reward points information in customer account + +The following information should be available to customer in his account when reward points functionality is enabled on the site: + - Balance in points and currency + - Exchange rate from points to currency (redemption rate) + - Exchange rate from currency to points (earning rate) + - Balance history, should include the following fields: + - Balance in points + - Amount in currency + - Balance change in points + - Reason for balance change + - Date + - "Balance Updates" email subscription status + - "Points Expiration Notification" email subscription status + + ```graphql +{ + customer { + reward_points { + balance { + points + money { + value + currency + } + } + exchange_rates { + earning + redemption + } + subscription_status { + balance_updates + points_expiration_notifications + } + balance_history { + balance { + points + money { + value + currency + } + } + points_change + change_reason + date + } + } + } +} +``` + +### Apply/remove reward points to/from cart and view applied reward points summary + +Apply reward points to the cart and view applied reward points balance: + +```graphql +mutation { + applyRewardPointsToCart(cartId: "existing-cart-id") { + cart { + applied_reward_points { + money { + currency + value + } + points + } + } + } +} +``` + +Remove applied reward points from the cart and view applied balance: + +```graphql +mutation { + removeRewardPointsFromCart(cartId: "existing-cart-id") { + cart { + applied_reward_points { + money { + currency + value + } + points + } + } + } +} +``` diff --git a/design-documents/graph-ql/coverage/wishlist.md b/design-documents/graph-ql/coverage/customer/wishlist.md similarity index 100% rename from design-documents/graph-ql/coverage/wishlist.md rename to design-documents/graph-ql/coverage/customer/wishlist.md diff --git a/design-documents/graph-ql/coverage/deprecate-createEmptyCart-add-createGuestCart.md b/design-documents/graph-ql/coverage/deprecate-createEmptyCart-add-createGuestCart.md new file mode 100644 index 000000000..e8175a5a8 --- /dev/null +++ b/design-documents/graph-ql/coverage/deprecate-createEmptyCart-add-createGuestCart.md @@ -0,0 +1,49 @@ +# Deprecate and replace `Mutation.createEmptyCart` + +## What + +- Mark `Mutation.createEmptyCart` as deprecated +- Add `Mutation.createGuestCart` as a replacement that includes `Cart` in its return type + +## Why + +This PR started from a Slack discussion about mutations and client-side caching. At one point [@sirugh](https://github.com/sirugh) mentioned: + +> This makes me wonder if the createCart mutation should return a cart type instead of just the id + +There are 2 problems that I'm aware of with the current schema returning just an `ID` instead of the `Cart` type: + +1. Requires manual cache handling with libraries like Apollo, to link the `ID` back to a `Cart` object +2. Requires 2 round trips for the client to get cart data for a guest. Even though the cart is empty, it still has defaults the pwa-studio components will use to render an empty cart + +During discussions, it was also noted that `createEmptyCart` has some behavior that's not clearly described by our schema. That is, If a customer is logged-in and has items in the cart, `createEmptyCart` returns an ID referencing a cart that is _not_ empty, which is extremely unintuitive. It was mentioned that the `pwa-studio` code [aliases this query](https://github.com/magento/pwa-studio/blob/38d652a4fbc797a4ac8ac0c3efa611003152c090/packages/venia-ui/lib/queries/createCart.graphql#L4) because of that. + +## Intended Usage +Because the UI knows if it has a token for a customer, `Query.cart` should be used by the UI to get the cart for a logged-in user. If there is _not_ a customer token present, the UI should use `createGuestCart`, which will return a `Cart` object, the same return type as `Query.cart`. + + +## Proposed Changes + +```diff +type Mutation { ++ createGuestCart( ++ input: CreateGuestCartInput ++ ): CreateGuestCartOutput @doc(description: "Create a new shopping cart") + createEmptyCart( + input: createEmptyCartInput ++ ): String @deprecated(reason: "Use `Mutation.createGuestCart`, or `Query.cart` for logged-in shoppers") +- ): String @doc(description:"Creates an empty shopping cart for a guest or logged in user") +} + ++input CreateGuestCartInput { ++ cart_uid: ID @doc(description: "Optional client-generated ID") ++} ++ ++type CreateGuestCartOutput { ++ cart: Cart ++} + +input createEmptyCartInput { + cart_id: String +} +``` diff --git a/design-documents/graph-ql/coverage/dynamic-blocks.graphqls b/design-documents/graph-ql/coverage/dynamic-blocks.graphqls new file mode 100644 index 000000000..5c9115f1b --- /dev/null +++ b/design-documents/graph-ql/coverage/dynamic-blocks.graphqls @@ -0,0 +1,40 @@ +type DynamicBlock { + uid: ID! @doc(description: "The unique ID of the dynamic block") + content: ComplexTextValue! @doc(description: "Contains the renderable HTML code of the dynamic block") +} + +# We don't need name, is_enabled, locations, ga_creative, catalog_price_rule_ids, cart_price_rules_ids, locations on storefront + +type DynamicBlocks { + items: [DynamicBlock]! @doc(description: "An array containing individual dynamic blocks") + page_info: SearchResultPageInfo @doc(description: "Metadata for pagination rendering") + total_count: Int! @doc(description: "The number of returned dynamic blocks") +} + +input DynamicBlocksFilterInput { + type: DynamicBlockTypeEnum! @doc(description: "A value indicating the type of dynamic block to filter on") + locations: [DynamicBlockLocationEnum!] @doc(description: "An array indicating the locations the dynamic block can be placed") # Blocks for all locations will be displayed if not supplied + dynamic_block_uids: [ID!] # This value appplies when DynamicBlockTypeEnum is set to SPECIFIED +} + +enum DynamicBlockTypeEnum { + SPECIFIED + CART_PRICE_RULE_RELATED + CATALOG_PRICE_RULE_RELATED +} + +enum DynamicBlockLocationEnum { + CONTENT + HEADER + FOOTER + LEFT + RIGHT +} + +type Query { + dynamic_blocks( + input: DynamicBlocksFilterInput + pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. The default is 20") + currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1"), + ): DynamicBlocks! +} diff --git a/design-documents/graph-ql/coverage/dynamic-blocks.md b/design-documents/graph-ql/coverage/dynamic-blocks.md new file mode 100644 index 000000000..c8dbb18db --- /dev/null +++ b/design-documents/graph-ql/coverage/dynamic-blocks.md @@ -0,0 +1,56 @@ +## Dynamic Blocks + +Dynamic blocks can be inserted in CMS pages and blocks using widgets. Below is the sample markup of the widget inserted on the page. + +``` +{{widget type="Magento\Banner\Block\Widget\Banner" display_mode="fixed" types="content" rotate="random" banner_ids="1" template="widget/block.phtml" unique_id="34f6520f51ca9b79c91d1f56b5b453adc4d9ff235a7c9f5b04d1b0557584c5bc"}} +``` + +In Luma dynamic blocks rendered to a JavaScript component definition that does request to a backend to load content of the dynamic block. Here is an example of a JavaScript component definition. + +``` +
+ +
+``` +By parsing `
` we get the following relevant data: + +* `data-banner-id` is the id of the actual widget that contains the dynamic blocks + +* `data-ids="1,3"` are the ids of the dynamic blocks + +* `data-rotate="random"` is an obsolete behavior that we chose not to have in GraphQl + + +Ideally we want these to be rendered with graphql uid and not be exposed with real numeric ids coming from the database. + +Next the PWA will receive similar JavaScript component definition that can be parsed to extract parameters and make a [GraphQL query](./dynamic-blocks.graphqls) to load dynamic blocks. + +```graphql +{ + dynamic_blocks( + input: {type: SPECIFIED, locations: [CONTENT], dynamic_block_uids: ["MQ==", "Mg=="]} + pageSize:10 + currentPage: 1 + ) { + items { + uid + content { + html + } + } + page_info { + current_page + page_size + total_pages + } + total_count + } +} +``` diff --git a/design-documents/graph-ql/coverage/mergeCarts-optional-destination.md b/design-documents/graph-ql/coverage/mergeCarts-optional-destination.md new file mode 100644 index 000000000..1107ceede --- /dev/null +++ b/design-documents/graph-ql/coverage/mergeCarts-optional-destination.md @@ -0,0 +1,40 @@ +# `mergeCarts` - Make `destination_cart_id` optional + +## What + +- Make the `destination_cart_id` argument optional in the `mergeCarts` mutation + +## Why + +This came up in a discussion with [@sirugh](https://github.com/sirugh) from the [`pwa-studio`](https://github.com/magento/pwa-studio) team. + +When a user logs in (creates a new token), one of the first things the UI needs to do is merge the current guest cart (if items are present) into the customer account's cart. + +If `destination_cart_id` is required, this requires 3 round trips: + +1. Call for `Mutation.generateCustomerToken` +2. Call for `Query.cart` to get customer cart ID +3. Call for `Mutation.mergeCarts` to merge guest cart ID into customer cart + +Because a customer can only have a single cart, and this API only works for authenticated users, `destination_cart_id` is an unnecessary requirement here. If we make it optional, the login + cart upgrade for UI can happen in a single request: + +```graphql +# Mutations run serially, in-order. So `mergeCarts` will only execute +# if `generateCustomerToken` succeeds +mutation LoginAndMergeCarts($email: String!, $password: String!, $guestCartID: String!) { + generateCustomerToken(email: $email, password: $password) { + token + } + mergeCarts(source_cart_id: $guestCartID) { + ID + } +} +``` + +## Proposed Change +```diff +type Mutation { +- mergeCarts(source_cart_id: String!, destination_cart_id: String!): Cart! ++ mergeCarts(source_cart_id: String!, destination_cart_id: String): Cart! +} +``` \ No newline at end of file diff --git a/design-documents/graph-ql/coverage/payment/payflowpro-vault.md b/design-documents/graph-ql/coverage/payment/payflowpro-vault.md new file mode 100644 index 000000000..cefbda45f --- /dev/null +++ b/design-documents/graph-ql/coverage/payment/payflowpro-vault.md @@ -0,0 +1,32 @@ +# Overview + +In the scope of extending the GraphQL coverage, we need to add Vault support to PayPal Payflow Pro payment integration. + +## Mutations + +We need to extend existing `PayflowProInput` by adding a possibility to store a Vault token: + +```graphql +input PayflowProInput @doc(description:"Required input for Payflow Pro and Payments Pro payment methods.") { + cc_details: CreditCardDetailsInput! @doc(description: "Required input for credit card related information") + is_active_payment_token_enabler: Boolean! @doc(description:"States whether an entered by a customer credit/debit card should be tokenized for later usage. Required only if Vault is enabled for PayPal Payflow Pro payment integration.") +} +``` + +The `is_active_payment_token_enabler` would specify that credit card details should be tokenized by a payment gateway, and a payment token can be used for further purchases. + +The next needed modification is extend of the existing `PaymentMethodInput` by adding Vault payment method for PayPal Payflow Pro. + +```graphql +input PaymentMethodInput { + payflowpro_cc_vault: VaultTokenInput +} +``` + +The `VaultInput` provides a generic type for all payment integrations with the Vault support. + +```graphql +input VaultTokenInput @doc(description:"Required input for payment methods with Vault support.") { + public_hash: String! @doc(description: "The public hash of the payment token") +} +``` \ No newline at end of file diff --git a/design-documents/graph-ql/coverage/quote.graphqls b/design-documents/graph-ql/coverage/quote.graphqls new file mode 100644 index 000000000..e2b1b1110 --- /dev/null +++ b/design-documents/graph-ql/coverage/quote.graphqls @@ -0,0 +1,425 @@ +# Copyright © Magento, Inc. All rights reserved. +# See COPYING.txt for license details. + +type Query { + cart(cart_id: String!): Cart @doc(description:"Returns information about shopping cart") @cache(cacheable: false) + customerCart: Cart! @doc(description:"Returns information about the customer shopping cart") @cache(cacheable: false) +} + +type Mutation { + createEmptyCart(input: createEmptyCartInput): String @doc(description:"Creates an empty shopping cart for a guest or logged in user") + addSimpleProductsToCart(input: AddSimpleProductsToCartInput): AddSimpleProductsToCartOutput + addVirtualProductsToCart(input: AddVirtualProductsToCartInput): AddVirtualProductsToCartOutput + applyCouponToCart(input: ApplyCouponToCartInput): ApplyCouponToCartOutput + removeCouponFromCart(input: RemoveCouponFromCartInput): RemoveCouponFromCartOutput + updateCartItems(input: UpdateCartItemsInput): UpdateCartItemsOutput + removeItemFromCart(input: RemoveItemFromCartInput): RemoveItemFromCartOutput + setShippingAddressesOnCart(input: SetShippingAddressesOnCartInput): SetShippingAddressesOnCartOutput + setBillingAddressOnCart(input: SetBillingAddressOnCartInput): SetBillingAddressOnCartOutput + setShippingMethodsOnCart(input: SetShippingMethodsOnCartInput): SetShippingMethodsOnCartOutput + setPaymentMethodOnCart(input: SetPaymentMethodOnCartInput): SetPaymentMethodOnCartOutput + setGuestEmailOnCart(input: SetGuestEmailOnCartInput): SetGuestEmailOnCartOutput + setPaymentMethodAndPlaceOrder(input: SetPaymentMethodAndPlaceOrderInput): PlaceOrderOutput @deprecated(reason: "Should use setPaymentMethodOnCart and placeOrder mutations in single request.") + mergeCarts(source_cart_id: String!, destination_cart_id: String!): Cart! @doc(description:"Merges the source cart into the destination cart") + placeOrder(input: PlaceOrderInput): PlaceOrderOutput + addProductsToCart(cartId: String!, cartItems: [CartItemInput!]!): AddProductsToCartOutput @doc(description:"Add any type of product to the cart") +} + +input createEmptyCartInput { + cart_id: String +} + +input AddSimpleProductsToCartInput { + cart_id: String! + cart_items: [SimpleProductCartItemInput!]! +} + +input SimpleProductCartItemInput { + data: CartItemInput! + customizable_options:[CustomizableOptionInput!] +} + +input AddVirtualProductsToCartInput { + cart_id: String! + cart_items: [VirtualProductCartItemInput!]! +} + +input VirtualProductCartItemInput { + data: CartItemInput! + customizable_options:[CustomizableOptionInput!] +} + +input CartItemInput { + sku: String! + quantity: Float! + parent_sku: String @doc(description: "For child products, the SKU of its parent product") + selected_options: [ID!] @doc(description: "The selected options for the base product, such as color or size") + entered_options: [EnteredOptionInput!] @doc(description: "An array of entered options for the base product, such as personalization text") +} + +input CustomizableOptionInput { + id: Int! + value_string: String! +} + +input ApplyCouponToCartInput { + cart_id: String! + coupon_code: String! +} + +input UpdateCartItemsInput { + cart_id: String! + cart_items: [CartItemUpdateInput!]! +} + +input CartItemUpdateInput { + # Implementation Note: For back-compat reasons, the following rules should be applied to + # a resolver handling `CartItemUpdateInput` (see https://github.com/magento/architecture/pull/424) + # + # 1. Either `cart_item_id` or `cart_item_uid` _must_ be provided. If both are absent, raise + # a field error advising the client that `cart_item_uid` is required + # 2. If _both_ `cart_item_id` and `cart_item_uid` are provided, raise a field error advising + # the client to only use `cart_item_uid` + # + # GraphQL does not provide a way for an `input` field to allow use of only 1 field or another, + # which is why we're enforcing non-nullability in the resolver, instead of via the schema language. + # When `cart_item_id` is removed in a future version, we can mark `cart_item_uid` as non-nullable + # with minimal disruption + cart_item_id: Int @deprecated(reason: "Use the `cart_item_uid` field instead") + cart_item_interface_uid: ID @doc(description: "Required field. Unique Identifier from objects implementing `CartItemInterface`") + quantity: Float + customizable_options: [CustomizableOptionInput!] +} + +input RemoveItemFromCartInput { + cart_id: String! + # Implementation Note: For back-compat reasons, the following rules should be applied to + # a resolver handling `RemoveItemFromCartInput` (see https://github.com/magento/architecture/pull/424) + # + # 1. Either `cart_item_id` or `cart_item_uid` _must_ be provided. If both are absent, raise + # a field error advising the client that `cart_item_uid` is required + # 2. If _both_ `cart_item_id` and `cart_item_uid` are provided, raise a field error advising + # the client to only use `cart_item_uid` + # + # GraphQL does not provide a way for an `input` field to allow use of only 1 field or another, + # which is why we're enforcing non-nullability in the resolver, instead of via the schema language. + # When `cart_item_id` is removed in a future version, we can mark `cart_item_uid` as non-nullable + # with minimal disruption + cart_item_id: Int @deprecated(reason: "Use the `cart_item_uid` field instead") + cart_item_interface_uid: ID @doc(description: "Required field. Unique Identifier from objects implementing `CartItemInterface`") +} + +input SetShippingAddressesOnCartInput { + cart_id: String! + shipping_addresses: [ShippingAddressInput!]! +} + +input ShippingAddressInput { + customer_address_id: Int # If provided then will be used address from address book + address: CartAddressInput + customer_notes: String +} + +input SetBillingAddressOnCartInput { + cart_id: String! + billing_address: BillingAddressInput! +} + +input BillingAddressInput { + customer_address_id: Int + address: CartAddressInput + use_for_shipping: Boolean @doc(description: "Deprecated: use `same_as_shipping` field instead") + same_as_shipping: Boolean @doc(description: "Set billing address same as shipping") +} + +input CartAddressInput { + firstname: String! + lastname: String! + company: String + street: [String!]! + city: String! + region: String + region_id: Int + postcode: String + country_code: String! + telephone: String! + save_in_address_book: Boolean @doc(description: "Determines whether to save the address in the customer's address book. The default value is true") +} + +input SetShippingMethodsOnCartInput { + cart_id: String! + shipping_methods: [ShippingMethodInput!]! +} + +input ShippingMethodInput { + carrier_code: String! + method_code: String! +} + +input SetPaymentMethodAndPlaceOrderInput { + cart_id: String! + payment_method: PaymentMethodInput! +} + +input PlaceOrderInput { + cart_id: String! +} + +input SetPaymentMethodOnCartInput { + cart_id: String! + payment_method: PaymentMethodInput! +} + +input PaymentMethodInput { + code: String! @doc(description:"Payment method code") + purchase_order_number: String @doc(description:"Purchase order number") +} + +input SetGuestEmailOnCartInput { + cart_id: String! + email: String! +} + +interface QuotePricesInterface { + grand_total: Money + subtotal_including_tax: Money + subtotal_excluding_tax: Money + discount: CartDiscount @deprecated(reason: "Use discounts instead ") + subtotal_with_discount_excluding_tax: Money + applied_taxes: [CartTaxItem] + discounts: [Discount] @doc(description:"An array of applied discounts") +} + +type CartPrices implements QuotePricesInterface { +} + +type CartTaxItem { + amount: Money! + label: String! +} + +type CartDiscount { + amount: Money! + label: [String!]! +} + +type SetPaymentMethodOnCartOutput { + cart: Cart! +} + +type SetBillingAddressOnCartOutput { + cart: Cart! +} + +type SetShippingAddressesOnCartOutput { + cart: Cart! +} + +type SetShippingMethodsOnCartOutput { + cart: Cart! +} + +type ApplyCouponToCartOutput { + cart: Cart! +} + +type PlaceOrderOutput { + order: Order! +} + +type Cart { + id: ID! @doc(description: "The ID of the cart.") + items: [CartItemInterface] + applied_coupon: AppliedCoupon @doc(description:"An array of coupons that have been applied to the cart") @deprecated(reason: "Use applied_coupons instead ") + applied_coupons: [AppliedCoupon] @doc(description:"An array of `AppliedCoupon` objects. Each object contains the `code` text attribute, which specifies the coupon code") + email: String + shipping_addresses: [ShippingCartAddress]! + billing_address: BillingCartAddress + available_payment_methods: [AvailablePaymentMethod] @doc(description: "Available payment methods") + selected_payment_method: SelectedPaymentMethod + prices: CartPrices + total_quantity: Float! + is_virtual: Boolean! +} + +interface CartAddressInterface { + firstname: String! + lastname: String! + company: String + street: [String!]! + city: String! + region: CartAddressRegion + postcode: String + country: CartAddressCountry! + telephone: String! +} + +type ShippingCartAddress implements CartAddressInterface { + available_shipping_methods: [AvailableShippingMethod] + selected_shipping_method: SelectedShippingMethod + customer_notes: String + items_weight: Float @deprecated(reason: "This information shoud not be exposed on frontend") + cart_items: [CartItemQuantity] @deprecated(reason: "`cart_items_v2` should be used instead") + cart_items_v2: [CartItemInterface] +} + +type BillingCartAddress implements CartAddressInterface { + customer_notes: String @deprecated (reason: "The field is used only in shipping address") +} + +type CartItemQuantity @deprecated(description:"Deprecated: `cart_items` field of `ShippingCartAddress` returns now `CartItemInterface` instead of `CartItemQuantity`") { + cart_item_id: Int! @deprecated(reason: "`cart_items` field of `ShippingCartAddress` returns now `CartItemInterface` instead of `CartItemQuantity`") + quantity: Float! @deprecated(reason: "`cart_items` field of `ShippingCartAddress` returns now `CartItemInterface` instead of `CartItemQuantity`") +} + +type CartAddressRegion { + code: String + label: String + region_id: Int +} + +type CartAddressCountry { + code: String! + label: String! +} + +type SelectedShippingMethod { + carrier_code: String! + method_code: String! + carrier_title: String! + method_title: String! + amount: Money! + base_amount: Money @deprecated(reason: "The field should not be used on the storefront") +} + +type AvailableShippingMethod { + carrier_code: String! + carrier_title: String! + method_code: String @doc(description: "Could be null if method is not available") + method_title: String @doc(description: "Could be null if method is not available") + error_message: String + amount: Money! + base_amount: Money @deprecated(reason: "The field should not be used on the storefront") + price_excl_tax: Money! + price_incl_tax: Money! + available: Boolean! +} + +type AvailablePaymentMethod { + code: String! @doc(description: "The payment method code") + title: String! @doc(description: "The payment method title.") +} + +type SelectedPaymentMethod { + code: String! @doc(description: "The payment method code") + title: String! @doc(description: "The payment method title.") + purchase_order_number: String @doc(description: "The purchase order number.") +} + +type AppliedCoupon { + code: String! +} + +input RemoveCouponFromCartInput { + cart_id: String! +} + +type RemoveCouponFromCartOutput { + cart: Cart +} + +type AddSimpleProductsToCartOutput { + cart: Cart! +} + +type AddVirtualProductsToCartOutput { + cart: Cart! +} + +type UpdateCartItemsOutput { + cart: Cart! +} + +type RemoveItemFromCartOutput { + cart: Cart! +} + +type SetGuestEmailOnCartOutput { + cart: Cart! +} + +type SimpleCartItem implements CartItemInterface @doc(description: "Simple Cart Item") { + customizable_options: [SelectedCustomizableOption] +} + +type VirtualCartItem implements CartItemInterface @doc(description: "Virtual Cart Item") { + customizable_options: [SelectedCustomizableOption] +} + +interface CartItemInterface { + id: String! @deprecated(reason: "Use CartItemInterface.uid instead") + uid: ID! @doc(description: "Unique identifier for a Cart Item") + quantity: Float! + prices: QuoteItemPricesInterface + product: ProductInterface! +} + +type Discount @doc(description:"Defines an individual discount. A discount can be applied to the cart as a whole or to an item.") { + amount: Money! @doc(description:"The amount of the discount") + label: String! @doc(description:"A description of the discount") +} + +type QuoteItemPricesInterface { + price: Money! @doc(description:"Item price that might include tax depending on display settings for cart") + fixed_product_taxes: [FixedProductTax] @doc(description:"Applied FPT to the cart item") + row_total: Money! + row_total_including_tax: Money! + discounts: [Discount] @doc(description:"An array of discounts to be applied to the cart item") + total_item_discount: Money @doc(description:"The total of all discounts applied to the item") +} + +type CartItemPrices implements QuoteItemPricesInterface { +} + +type SelectedCustomizableOption { + id: Int! + label: String! + is_required: Boolean! + values: [SelectedCustomizableOptionValue!]! + sort_order: Int! +} + +type SelectedCustomizableOptionValue { + id: Int! + label: String! + value: String! + price: CartItemSelectedOptionValuePrice! +} + +type CartItemSelectedOptionValuePrice { + value: Float! + units: String! + type: PriceTypeEnum! +} + +type Order { + order_number: String! + order_id: String @deprecated(reason: "The order_id field is deprecated, use order_number instead.") +} + +type CartUserInputError @doc(description:"An error encountered while adding an item to the the cart.") { + message: String! @doc(description: "A localized error message") + code: CartUserInputErrorType! @doc(description: "Cart-specific error code") +} + +type AddProductsToCartOutput { + cart: Cart! @doc(description: "The cart after products have been added") + user_errors: [CartUserInputError!]! @doc(description: "An error encountered while adding an item to the cart.") +} + +enum CartUserInputErrorType { + PRODUCT_NOT_FOUND + NOT_SALABLE + INSUFFICIENT_STOCK + UNDEFINED +} diff --git a/design-documents/graph-ql/coverage/re-captcha.graphqls b/design-documents/graph-ql/coverage/re-captcha.graphqls new file mode 100644 index 000000000..404304bc5 --- /dev/null +++ b/design-documents/graph-ql/coverage/re-captcha.graphqls @@ -0,0 +1,44 @@ +enum ReCaptchaFormEnum { + PLACE_ORDER + CONTACT + CUSTOMER_LOGIN + CUSTOMER_FORGOT_PASSWORD + CUSTOMER_CREATE + CUSTOMER_EDIT + NEWSLETTER + PRODUCT_REVIEW + SENDFRIEND + BRAINTREE +} + +type ReCaptchaConfigurationV3 { + website_key: String! + @doc( + description: "The website key that is created when you register your Google reCAPTCHA account" + ) + minimum_score: Float! + @doc( + description: "The minimum score that identifies a user interaction as a potential risk" + ) + badge_position: String! + @doc( + description: "The position of the invisible reCAPTCHA badge on each page" + ) + language_code: String + @doc( + description: "A two-character code that specifies the language that is used for Google reCAPTCHA text and messaging." + ) + failure_message: String! + @doc( + description: "The message that appears to the user if validation fails" + ) + forms: [ReCaptchaFormEnum!]! + @doc(description: "A list of forms that have reCAPTCHA V3 enabled") +} + +# Google reCAPTCHA config - will return null if v3 invisible is not configured +# for at least one Storefront form. +type Query { + recaptchaV3Config: ReCaptchaConfigurationV3 + @doc(description: "Google reCAPTCHA V3-Invisible Configuration") +} diff --git a/design-documents/graph-ql/coverage/returns.graphqls b/design-documents/graph-ql/coverage/returns.graphqls new file mode 100644 index 000000000..1249c534e --- /dev/null +++ b/design-documents/graph-ql/coverage/returns.graphqls @@ -0,0 +1,219 @@ +type Query{ + # See https://github.com/magento/architecture/blob/master/design-documents/graph-ql/framework/attributes-metadata.md + pageSpecificCustomAttributes( + page_type: CustomAttributesPageEnum! + ): CustomAttributeMetadata +} + +type Mutation { + requestReturn(input: RequestReturnInput!): RequestReturnOutput @doc(description: "Create a new return.") + addReturnComment(input: AddReturnCommentInput!): AddReturnCommentOutput @doc(description: "Add a comment to an existing return.") + addReturnTracking(input: AddReturnTrackingInput!): AddReturnTrackingOutput @doc(description: "Add tracking information to the return.") + removeReturnTracking(input: RemoveReturnTrackingInput!): RemoveReturnTrackingOutput @doc(description: "Remove a single tracked shipment from the return.") +} + +input RequestReturnInput { + order_id: ID! + contact_email: String + items: [RequestReturnItemInput!]! + comment_text: String +} + +input RequestReturnItemInput { + order_item_id: ID! @doc(description: "ID of the order item associated with the return.") + quantity_to_return: Float! @doc(description: "The quantity of the item requested to be returned.") + selected_custom_attributes: [ID!] @doc(description: "Values of return item attributes defined by the merchant, e.g. select attributes.") + entered_custom_attributes: [EnteredCustomAttributeInput!] @doc(description: "Values of return item attributes defined by the merchant, e.g. file or text attributes.") +} + +input EnteredCustomAttributeInput { + uid: ID! + value: String! +} + +type RequestReturnOutput { + return: Return + returns( + pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. Defaults to 20."), + currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1."), + ): Returns @doc(description: "Information about the customer returns.") +} + +input AddReturnCommentInput { + return_id: ID! + comment_text: String! +} + +type AddReturnCommentOutput { + return: Return +} + +input AddReturnTrackingInput { + return_id: ID! + carrier_id: ID! + tracking_number: String! +} + +type AddReturnTrackingOutput { + return: Return + return_shipping_tracking: ReturnShippingTracking +} + +input RemoveReturnTrackingInput { + return_shipping_tracking_id: ID! +} + +type RemoveReturnTrackingOutput { + return: Return +} + +enum CustomAttributesPageEnum { + RETURN_ITEM_EDIT_FORM + RETURN_ITEMS_LISTING + # See https://github.com/magento/architecture/blob/master/design-documents/graph-ql/framework/attributes-metadata.md + # PRODUCTS_COMPARE + # PRODUCTS_LISTING + # ADVANCED_CATALOG_SEARCH +} + +type Customer { + returns( + pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. Defaults to 20."), + currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1."), + ): Returns @doc(description: "Information about the customer returns.") + return(uid: ID!): Return @doc(description: "Get customer return details by its ID.") +} + +type CustomerOrder { + returns( + pageSize: Int = 20 @doc(description: "Specifies the maximum number of results to return at once. Defaults to 20."), + currentPage: Int = 1 @doc(description: "Specifies which page of results to return. The default value is 1."), + ): Returns @doc(description: "Returns associated with this order.") + items_eligible_for_return: [OrderItemInterface] @doc(description: "A list of order items eligible for return.") +} + +type OrderItemInterface { + eligible_for_return: Boolean @doc(description: "Indicates whether the order item is eligible for return.") +} + +type Returns { + items: [Return] @doc(description: "List of returns") + page_info: SearchResultPageInfo @doc(description: "Pagination metadata") + total_count: Int @doc(description: "Total count of customer returns") +} + +type Return @doc(description: "Customer return") { + uid: ID! + number: String! @doc(description: "Human-readable return number") + order: CustomerOrder @doc(description: "The order associated with the return.") + created_at: String! @doc(description: "The date when the return was requested.") + customer: ReturnCustomer! @doc(description: "The data for the customer who created the return request") + status: ReturnStatus @doc(description: "Return status.") + shipping: ReturnShipping @doc(description: "Shipping information for the return.") + comments: [ReturnComment] @doc(description: "A list of comments posted for the return.") + items: [ReturnItem] @doc(description: "A list of items being returned.") + available_shipping_carriers: [ReturnShippingCarrier] @doc(description: "A list of shipping carriers available for returns.") +} + +type ReturnCustomer @doc(description: "The Customer information for the return.") { + email: String! @doc(description: "Customer email address.") + firstname: String @doc(description: "Customer first name.") + lastname: String @doc(description: "Customer last name.") +} + +type ReturnItem { + uid: ID! + order_item: OrderItemInterface! @doc(description: "Order item provides access to the product being returned, including selected/entered options information.") + custom_attributes: [CustomAttribute] @doc(description: "Return item custom attributes, which are marked by the admin to be visible on the storefront.") + request_quantity: Float! @doc(description: "The quantity of the item requested to be returned.") + quantity: Float! @doc(description: "The quantity of the items authorized by the merchant to be returned .") + status: ReturnItemStatus! @doc(description: "The return status of the item being returned.") +} + +# See https://github.com/magento/architecture/blob/master/design-documents/graph-ql/custom-attributes-container.md +type CustomAttribute { + uid: ID! + label: String! + value: String! @doc(description: "JSON encoded value of the attribute.") +} + +type ReturnComment { + uid: ID! @doc(description: "Comment ID.") + author_name: String! @doc(description: "The name of the author who posted the comment.") + created_at: String! @doc(description: "The date and time when the comment was posted.") + text: String! @doc(description: "The comment text.") +} + +type ReturnShipping { + address: ReturnShippingAddress @doc(description: "Return shipping address, which is specified by the admin.") + tracking(uid: ID): [ReturnShippingTracking] @doc(description: "Tracking information for all or a single tracking record when ID is provided.") +} + +type ReturnShippingCarrier { + uid: ID! + label: String! +} + +type ReturnShippingTracking { + uid: ID! + carrier: ReturnShippingCarrier! + tracking_number: String! + status: ReturnShippingTrackingStatus +} + +type ReturnShippingTrackingStatus { + text: String! + type: ReturnShippingTrackingStatusType! +} + +enum ReturnShippingTrackingStatusType { + INFORMATION + ERROR +} + +type ReturnShippingAddress { + contact_name: String + street: [String]! + city: String! + region: Region! + postcode: String! + country: Country! + telephone: String +} + +enum ReturnStatus { + PENDING + AUTHORIZED + PARTIALLY_AUTHORIZED + RECEIVED + PARTIALLY_RECEIVED + APPROVED + PARTIALLY_APPROVED + REJECTED + PARTIALLY_REJECTED + DENIED + PROCESSED_AND_CLOSED + CLOSED +} + +enum ReturnItemStatus { + PENDING + AUTHORIZED + RECEIVED + APPROVED + REJECTED + DENIED +} + +type StoreConfig { + # sales_magento/rma/enabled + returns_enabled: String! @doc(description: "Returns functionality status on the storefront: enabled/disabled.") +} + +type Attribute { + uid: ID! +} + +type AttributeOption { + uid: ID! +} diff --git a/design-documents/graph-ql/coverage/returns.md b/design-documents/graph-ql/coverage/returns.md new file mode 100644 index 000000000..40290236e --- /dev/null +++ b/design-documents/graph-ql/coverage/returns.md @@ -0,0 +1,373 @@ +## Configuration + +The following settings should be accessible via `storeConfig` query: +- Returns functionality status on the storefront: enabled/disabled + +Scenarios which may need these settings include: +- Rendering of the returns section in the customer account + +```graphql +{ + storeConfig { + sales_magento_rma_enabled + } +} +``` + +## Use cases + +### View return list with pagination in customer account + +```graphql +{ + customer { + returns(pageSize: 10, currentPage: 2) { + items { + id + creation_date + customer_name + status + } + page_info { + current_page + page_size + total_pages + } + total_count + } + } +} +``` + +### View return list with pagination in order details + +```graphql +{ + customer { + orders(filter: {number: {eq: "00000008"}}) { + items { + returns(pageSize: 10, currentPage: 1) { + items { + id + creation_date + customer_name + status + } + page_info { + current_page + page_size + total_pages + } + total_count + } + } + } + } +} +``` + +### View return details + +```graphql +{ + customer { + return(id: "23as452gsa") { + number + order { + number + } + creation_date + customer_email + customer_name + status + comments { + id + text + created_at + created_by + } + items { + id + order_item { + product_sku + product_name + } + custom_attributes { + id + label + value + } + request_quantity + quantity + status + } + shipping { + tracking { + id + carrier { + label + } + tracking_number + } + address { + contact_name + street + city + region { + code + } + country { + full_name_english + } + postcode + telephone + } + } + } + } +} +``` + +### Create a return request + +#### Determine whether any order items are eligible for return + +There is a need to know if any order items are eligible for return. In the Luma example this is dictating whether "Return" link will be displayed on the order details page. + +```graphql +{ + customer { + orders(filter: {number: {eq: "00000008"}}) { + items { + items_eligible_for_return { + id + product_name + } + } + } + } +} +``` + +Alternative approach is to use a flag added to order items: + +```graphql +{ + customer { + orders(filter: {number: {eq: "00000008"}}) { + items { + items { + id + eligible_for_return + } + } + } + } +} +``` + +#### Render return form with dynamic RMA attributes + +```graphql +{ + pageSpecificCustomAttributes(page_type: RETURN_ITEM_EDIT_FORM) { + items { + id + attribute_code + attribute_type + input_type + attribute_options { + id + label + value + } + } + } +} +``` + +Existing schema of `Attribute` and `AttributeOption` must be extended to provide `ID` field, which will be used in mutations to specify custom attribute values for returns. + +#### Determine which order items are eligible for return + +```graphql +{ + customer { + orders(filter: {number: {eq: "00000008"}}) { + items { + items_eligible_for_return { + id + product_name + } + } + } + } +} +``` + +#### Submit a return request with multiple items and comments + +```graphql +mutation { + requestReturn( + input: { + order_id: 12345 + contact_email: "returnemail@magento.com" + items: [ + { + order_item_id: "absdfj2l3415", + quantity_to_return: 1, + selected_custom_attributes: ["encoded-custom-select-attribute-value-id"], + entered_custom_attributes: [{id: "encoded-custom-text-attribute-id", value: "Custom attribute value"}] + } + ], + comment_text: "Return comment" + } + ) { + return { + id + items { + id + quantity + request_quantity + order_item { + product_sku + product_name + } + custom_attributes { + id + label + value + } + } + comments { + created_at + created_by + text + } + } + } +} +``` + +Alternatively, the collection of returns associated with the order can be requested. + +### Leave a return comment + +```graphql +mutation { + addReturnComment( + input: { + return_id: "23as452gsa", + comment_text: "Another return comment" + } + ) { + return { + id + comments { + created_at + created_by + text + } + } + } +} +``` + +### Create a return for guest order + +Guest orders are not accessible via GraphQL yet, but the schema of returns will be identical to the one for customer orders. + +### Specify shipping and tracking + +When return is authorized by the admin user, the customer can specify shipping and tracking information. + +First, the client needs to get shipping carriers that can be used for returns: + +```graphql +{ + customer { + return(id: "asdgah2341") { + available_shipping_carriers { + id + label + } + } + } +} +``` + +Then tracking information can be submitted: + +```graphql +mutation { + addReturnTracking( + input: { + return_id: "23as452gsa", + carrier_id: "carrier-id", + tracking_number: "4234213" + } + ) { + return { + shipping { + tracking { + id + carrier { + label + } + tracking_number + } + } + } + } +} +``` + +If the user decides to view the status of the specific tracking item, it can be retrieved using the following query: + +```graphql +{ + customer { + return(id: "23as452gsa") { + shipping { + tracking(id: "return-tracking-id") { + id + carrier { + label + } + tracking_number + status { + text + type + } + } + } + } + } +} +``` + +In case the return shipping needs to be removed, the following mutation can be used: + +```graphql +mutation { + removeReturnTracking( + input: { + return_shipping_tracking_id: "return-tracking-id" + } + ) { + return { + shipping { + tracking { + id + carrier { + label + } + tracking_number + } + } + } + } +} + +``` diff --git a/design-documents/graph-ql/coverage/routing/available-stores.md b/design-documents/graph-ql/coverage/routing/available-stores.md new file mode 100644 index 000000000..caebce390 --- /dev/null +++ b/design-documents/graph-ql/coverage/routing/available-stores.md @@ -0,0 +1,75 @@ +# Get available stores for website + +### Use case: +Implementing a store switcher; it is necessary to know which stores are available and some basic info about them (i.e. store code) + +### Security Requirement: +Based on the store code passed via header (or default), returns the storeConfig for other stores available under the same website. + +This query MUST NOT expose other websites or stores available under websites other than the current website. + +### Proposed schema +```graphql +type Query { + availableStores: [StoreConfig] @doc(description: "Get a list of available store views and their config information.") +} + +# Existing schema +type StoreConfig @doc(description: "The type contains information about a store config") { + id : Int @doc(description: "The ID number assigned to the store") + code : String @doc(description: "A code assigned to the store to identify it") + website_id : Int @doc(description: "The ID number assigned to the website store belongs") + locale : String @doc(description: "Store locale") + base_currency_code : String @doc(description: "Base currency code") + default_display_currency_code : String @doc(description: "Default display currency code") + timezone : String @doc(description: "Timezone of the store") + weight_unit : String @doc(description: "The unit of weight") + base_url : String @doc(description: "Base URL for the store") + base_link_url : String @doc(description: "Base link URL for the store") + base_static_url : String @doc(description: "Base static URL for the store") + base_media_url : String @doc(description: "Base media URL for the store") + secure_base_url : String @doc(description: "Secure base URL for the store") + secure_base_link_url : String @doc(description: "Secure base link URL for the store") + secure_base_static_url : String @doc(description: "Secure base static URL for the store") + secure_base_media_url : String @doc(description: "Secure base media URL for the store") + store_name : String @doc(description: "Name of the store") + # ... more fields added from other modules and 3rd parties +} +``` + +Sample query: +```graphql +query { + availableStores { + id + code + locale + timezone + base_url + } +} +``` + +Sample response: +```json +{ + "data": { + "availableStores": [ + { + "id": 1, + "code": "default", + "locale": "en_US", + "timezone": "America/Chicago", + "base_url": "http://magento.test/" + }, + { + "id": 2, + "code": "German", + "locale": "de_DE", + "timezone": "Europe/Berlin", + "base_url": "http://magento.test/" + } + ] + } +} +``` diff --git a/design-documents/graph-ql/coverage/storefront-route.md b/design-documents/graph-ql/coverage/routing/storefront-route.md similarity index 100% rename from design-documents/graph-ql/coverage/storefront-route.md rename to design-documents/graph-ql/coverage/routing/storefront-route.md diff --git a/design-documents/graph-ql/coverage/url-resolver-identifier.md b/design-documents/graph-ql/coverage/routing/url-resolver-identifier.md similarity index 100% rename from design-documents/graph-ql/coverage/url-resolver-identifier.md rename to design-documents/graph-ql/coverage/routing/url-resolver-identifier.md diff --git a/design-documents/graph-ql/coverage/search/layered-navigation-filter-names-change/layered_navigation.png b/design-documents/graph-ql/coverage/search/layered-navigation-filter-names-change/layered_navigation.png new file mode 100644 index 000000000..08c199080 Binary files /dev/null and b/design-documents/graph-ql/coverage/search/layered-navigation-filter-names-change/layered_navigation.png differ diff --git a/design-documents/graph-ql/coverage/search/product_filter_and_search_changes.md b/design-documents/graph-ql/coverage/search/product_filter_and_search_changes.md new file mode 100644 index 000000000..abff54aa4 --- /dev/null +++ b/design-documents/graph-ql/coverage/search/product_filter_and_search_changes.md @@ -0,0 +1,308 @@ +# Proposed changes for Product search and filtering + +### Changes to Product filter input +**Current list of available filtering and sorting** + +This list is currently hardcoded in the GrahpQl schema, we will be removing this list and using a dynamic list of attributes. + +*Notable: We will remove the ability to combine conditions using "or".* + +``` +name: FilterTypeInput +sku: FilterTypeInput +description: FilterTypeInput +short_description: FilterTypeInput +price: FilterTypeInput +special_price: FilterTypeInput +special_from_date: FilterTypeInput +special_to_date: FilterTypeInput +weight: FilterTypeInput +manufacturer: FilterTypeInput +meta_title: FilterTypeInput +meta_keyword: FilterTypeInput +meta_description: FilterTypeInput +image: FilterTypeInput +small_image: FilterTypeInput +thumbnail: FilterTypeInput +tier_price: FilterTypeInput +news_from_date: FilterTypeInput +news_to_date: FilterTypeInput +custom_layout_update: FilterTypeInput +min_price: FilterTypeInput +max_price: FilterTypeInput +category_id: FilterTypeInput +options_container: FilterTypeInput +required_options: FilterTypeInput +has_options: FilterTypeInput +image_label: FilterTypeInput +small_image_label: FilterTypeInput +thumbnail_label: FilterTypeInput +created_at: FilterTypeInput +updated_at: FilterTypeInput +country_of_manufacture: FilterTypeInput +custom_layout: FilterTypeInput +gift_message_available: FilterTypeInput +or: ProductFilterInput +``` + +**New available filter options (On fresh Magento installation)** +``` +category_id: FilterEqualTypeInput +description: FilterMatchTypeInput +name: FilterMatchTypeInput +price: FilterRangeTypeInput +short_description: FilterMatchTypeInput +sku: FilterEqualTypeInput +(Additional custom attributes): (filter type determined by attribute type) +``` + +Additionally FilterTypeInput will be replaced with more specific filter types that limit the types of comparisons that can be done based on the attribute type. +**Existing filter type** +``` +FilterTypeInput: +eq | String +finset | [String] +from | String +gt | String +gteq | String +in | [String] +like | String +lt | String +lteq | String +moreq | String +neq | String +notnull | String +null | String +to | String +nin | [String] +``` +**New filter types** +``` +FilterEqualTypeInput (eq: String | in: [String]) +FilterMatchTypeInput (match: String) +FilterRangeTypeInput (from: String | to: String) +``` + +## Changes to Product sort input + +**Current sort options** + +Similar to filtering this hardcoded list will be replaced with a dynamic list of attributes that can be used for sorting. +``` +name: SortEnum +sku: SortEnum +description: SortEnum +short_description: SortEnum +price: SortEnum +special_price: SortEnum +special_from_date: SortEnum +special_to_date: SortEnum +weight: SortEnum +manufacturer: SortEnum +meta_title: SortEnum +meta_keyword: SortEnum +meta_description: SortEnum +image: SortEnum +small_image: SortEnum +thumbnail: SortEnum +tier_price: SortEnum +news_from_date: SortEnum +news_to_date: SortEnum +custom_layout_update: SortEnum +options_container: SortEnum +required_options: SortEnum +has_options: SortEnum +image_label: SortEnum +small_image_label: SortEnum +thumbnail_label: SortEnum +created_at: SortEnum +updated_at: SortEnum +country_of_manufacture: SortEnum +custom_layout: SortEnum +gift_message_available: SortEnum +``` + +#### New available sort options (on fresh Magento installation) +``` +relevance: SortEnum +name: SortEnum +position: SortEnum +price: SortEnum +(addition attributes that are available to use for sorting) +``` +If no sort order is requested, results will be sorted by `relevance DESC` by default. + + +## Changes to Layered Navigation Output + +Currently the schema for layered navigation is very specific to how you would render it in Luma and magento internal attributes. It doesn't make a lot of sense for GraphQl. + +![Layered Navigation](layered-navigation-filter-names-change/layered_navigation.png) + +**Use cases:** +- Reading relevant filters after a product search and displaying them +- The UI logic has to be able to loop through attributes and list them as sections +- Each attribute has multiple and at least one value to be rendered. A value of an attribute can then be used to be filtered by in a product search, by using it's ID value where it's label is only used for display purposes. + + + +**Current schema:** + +- Query and return value: +```graphql +filters { + filter_items_count + name + request_var + filter_items { + items_count + label + value_string + } + } +``` + + +**Problems in the current schema:** + +- `filters->name` it's actually the filter label intended for display and rendering +- `filters->request_var` it's actually the filter name used in product filtering. this is not a HTTP request anymore, it's graphql. +- `filters->filter_items->value_string` it's actually the comparison ID value that we use in product filtering. Indeed is a string type for now because all attributes are. We don't make that distinction and when we will the 'value_string' won't make any sense. + +It is used as: +```graphql + products( + filter: { + request_var: {eq: "value_string"} + } + } +``` + +**Proposed schema:** + +We will deprecate the `filters` return object and replace it with `aggregations` + +```graphql +aggregations { + count + label + attribute_code + options { + count + label + value + } + } +``` + + +**Example output** + +```graphql +"aggregations": [ + { + "count": 2, + "label": "Price", + "attribute_code": "price", + "options": [ + { + "count": 3, + "label": "*-100", + "value": "*_100" + }, + { + "count": 2, + "label": "100-*", + "value": "100_*" + } + ] + }, + { + "count": 3, + "label": "Category", + "attribute_code": "category_id", + "options": [ + { + "count": 5, + "label": "Category 1", + "value": "3" + }, + { + "count": 1, + "label": "Category 1.1", + "value": "4" + }, + { + "count": 1, + "label": "Category 1.1.2", + "value": "6" + }, + ] + }, + ], +``` + +Example Query: +```graphql +{ + products( + filter: { + category_id: {eq:"3" } + mysize: {eq:"17" } + } + pageSize:10 + currentPage:1 + sort: { + name :ASC + } + ) { + items { + sku + name + } + aggregations { + count + label + attribute_code + options { + count + label + value + } + } + page_info { + current_page + page_size + total_pages + } + total_count + items { + sku + url_key + } + } +} + +``` + +## Changes to customAttributeMetadata query output + +customAttributeMetadata returns an array of attributes `[Attribute]` + +```graphql +Attribute: { + attribute_code: String + + attribute_options: [AttributeOption] + + attribute_type: String + + entity_type: String +} +``` + +`attribute_type` only tells us the value type of the attribute (e.g. int, float, string, etc) + +We propose to add an additional field (`input_type`), that will explain which UI type should be used for the attribute. (e.g. multiselect, price, checkbox, etc) +This information can then be used to determine what type of filter applies to a particular aggregation option so the client knows how to filter it. + diff --git a/design-documents/graph-ql/storefront-api.md b/design-documents/graph-ql/coverage/search/storefront-api.md similarity index 100% rename from design-documents/graph-ql/storefront-api.md rename to design-documents/graph-ql/coverage/search/storefront-api.md diff --git a/design-documents/graph-ql/coverage/shipping/store-pickup.graphqls b/design-documents/graph-ql/coverage/shipping/store-pickup.graphqls new file mode 100644 index 000000000..a4db6f882 --- /dev/null +++ b/design-documents/graph-ql/coverage/shipping/store-pickup.graphqls @@ -0,0 +1,75 @@ +type Query { + pickupLocations ( + area: AreaInput, + filters: PickupLocationFilterInput, + sort: PickupLocationSortInput, + pageSize: Int = 20, + currentPage: Int = 1, + productsInfo: [ProductInfoInput] + ): PickupLocations +} + +input AreaInput { + # This type is added for extensibility + search_term: String! # Depending on the distance calculation algorithm selected in the admin, this field will require ZIP code (for offline mode) or arbitrary part of the address (for Google mode). IMPORTANT: Current mode must be exposed as part of storeConfig query and used on the client to display different hints for the input field + radius: Int # This field is not part of MVP and can be added later. IMPORTANT: Radius units must be exposed as part of storeConfig query and displayed on the client +} + +type PickupLocations { + items: [PickupLocation]! + page_info: SearchResultPageInfo + total_count: Int +} + +input PickupLocationFilterInput { + name: FilterTypeInput + pickup_location_code: FilterTypeInput + country_id: FilterTypeInput + postcode: FilterTypeInput + region: FilterTypeInput + region_id: FilterTypeInput + city: FilterTypeInput + street: FilterTypeInput +} + +input PickupLocationSortInput { + name: SortEnum + pickup_location_code: SortEnum + distance: SortEnum + country_id: SortEnum + region: SortEnum + region_id: SortEnum + city: SortEnum + street: SortEnum + postcode: SortEnum + longitude: SortEnum + latitude: SortEnum + email: SortEnum + fax: SortEnum + phone: SortEnum + contact_name: SortEnum + description: SortEnum +} + +type PickupLocation { + pickup_location_code: String + name: String! # In the admin is called Frontend Name + description: String! # In the admin is called Frontend Description + email: String + fax: String + contact_name: String + latitude: Float + longitude: Float + country_id: String + region_id: Int + region: String + city: String + street: String + postcode: String + phone: String +} + +# Used in products assignment intersection search - select Pickup Locations which can be used to deliver all products in the request. +input ProductInfoInput { + sku: String! +} diff --git a/design-documents/graph-ql/coverage/store-pickup.graphqls b/design-documents/graph-ql/coverage/store-pickup.graphqls deleted file mode 100644 index 772484150..000000000 --- a/design-documents/graph-ql/coverage/store-pickup.graphqls +++ /dev/null @@ -1,29 +0,0 @@ -type Query { - pickupLocations ( - filter: PickupLocationFilterInput, - pageSize: Int = 20, - currentPage: Int = 1 - ): PickupLocations -} - -input PickupLocationFilterInput { - # This type is added for extensibility - search_term: String! # Depending on the distance calculation algorithm selected in the admin, this field will require ZIP code (for offline mode) or arbitrary part of the address (for Google mode). IMPORTANT: Current mode must be exposed as part of storeConfig query and used on the client to display different hints for the input field - radius: Int # This field is not part of MVP and can be added later. IMPORTANT: Radius units must be exposed as part of storeConfig query and displayed on the client -} - -type PickupLocations { - items: [PickupLocation]! - page_info: SearchResultPageInfo - total_count: Int -} - -type PickupLocation { - name: String! # In the admin is called Frontend Name - description: String! # In the admin is called Frontend Description - country: String! - region: String! - city: String! - street: String! - postcode: String! -} diff --git a/design-documents/graph-ql/directives.graphqls b/design-documents/graph-ql/directives.graphqls new file mode 100644 index 000000000..1f25bedd9 --- /dev/null +++ b/design-documents/graph-ql/directives.graphqls @@ -0,0 +1,39 @@ +directive @doc(description: String="") on QUERY + | MUTATION + | FIELD + | FRAGMENT_DEFINITION + | FRAGMENT_SPREAD + | INLINE_FRAGMENT + | SCHEMA + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + +directive @resolver(class: String="") on QUERY + | MUTATION + | FIELD + | FRAGMENT_DEFINITION + | FRAGMENT_SPREAD + | INLINE_FRAGMENT + | SCHEMA + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + +directive @typeResolver(class: String="") on UNION + | INTERFACE + | OBJECT + +directive @cache(cacheIdentity: String="" cacheable: Boolean=true) on QUERY diff --git a/design-documents/graph-ql/batch-resolver.md b/design-documents/graph-ql/framework/batch-resolver.md similarity index 100% rename from design-documents/graph-ql/batch-resolver.md rename to design-documents/graph-ql/framework/batch-resolver.md diff --git a/design-documents/graph-ql/id-improvement-plan.md b/design-documents/graph-ql/id-improvement-plan.md new file mode 100644 index 000000000..b5bd00144 --- /dev/null +++ b/design-documents/graph-ql/id-improvement-plan.md @@ -0,0 +1,227 @@ +# ID Improvement Plan + +We've recently agreed on some standardization to how object identifiers are represented in Magento GraphQL schemas, which will require some changes to existing code to reach the desired state. + +This work _must not introduce any breaking changes_. + +## Approved Proposals + +- [Propose renaming id_v2 to something more permanent, and change type #396](https://github.com/magento/architecture/pull/396) +- [Add document with suggestions for ID fields #395](https://github.com/magento/architecture/pull/395) + +Between these 2 proposals, agreement was reached on the following guidelines: + +- Identifier fields and arguments _must_ use the `ID` scalar type +- Identifier fields in Object Types _must_ be non-nullable (`!`) +- Identifier fields in Object Types _must_ have the field name `uid` +- Arguments that accept a `uid` value (either `Query` or `Mutation`) _must_ have `uid` in the argument name_ +- All objects representing entities that _can_ be addressed by ID _must_ have a `uid` field +- All `ID` values _must_ be unique to their type (no collisions on IDs) + +## Terminology +- **Primary Identifier**: An ID owned by the current Object Type (ex: `Product.id`) +- **Foreign Identifier**: An ID referencing another Object Type (ex: `PaymentMethodInput.code`) + +## Work that needs to get done + +### Object Types and Interfaces with a _Primary Identifier_ + +I found 51 Objects/Interfaces in the Open-Source schema that will need these changes. + +#### Changes needed +- Add a new, non-nullable field named `uid` with type `ID` +- Resolve the `uid` type to the same value as the object's existing _primary identifier_ field +- Deprecate the existing _primary identifier_ field, with a message directing developers to the `uid` field + +#### Example Change +```diff +interface ProductInterface { +- id: Int ++ id: Int @deprecated(reason: "Use the 'uid' field instead") ++ uid: ID! +} +``` + +### Fields with 1 or more _Foreign Identifier_ argument + +I found 10 arguments across 8 fields in the Open-Source schema that will need these changes. + +#### Changed Needed +- For each argument that's a _foreign identifier_ + - Add a new argument with a name of the format `{ForeignObject}_uid`, where `ForeignObject` is the name of the Object Type with the matching `uid` field + - Deprecate the existing argument, with a message directing developers to the new argument + - Update the resolver to use _either_ the new or old argument, but throw a validation error when both are used + - The new argument must have the same nullability as the old argument + +#### Example Change +```diff +type Query { +- cart(cart_id: String!): Cart ++ cart( ++ cart_id: String! @deprecated(reason: "Use the 'cart_uid' argument instead") ++ cart_uid: ID! @doc(description: "ID from the Cart.uid field") ++ ): Cart +} +``` + +### Input Object Types with 1 or more _Foreign Identifier_ fields + +I found 49 fields across 44 Input Object Types in the Open-Source schema that will need these changes. + +#### Changes Needed +- For each field that's a _foreign identifier_ + - Add a new field with a name of the format `{ForeignObject}_uid`, where `ForeignObject` is the name of the Object Type with the matching `uid` field + - Deprecate the existing field, with a message directing developers to the new field + - Update all related resolvers to use _either_ the new or old field, but throw a validation error when both are used + - The new field must have the same nullability as the old field + +#### Example Change +```diff +input CartItemInput { +- sku: String! ++ sku: String! @deprecated(reason: "Use the CartItemInput.product_uid field instead") ++ product_uid: ID! @doc(description: "ID from the ProductInterface.uid field") +} +``` + +## Suggested Grouping of Work + +Ideally, whether this work is done all at once or incrementally, we'll make sure that changes are made in groups. That is, anytime a _primary identifier_ field changes on an Object/Interface, the same changeset should include changes to all places the matching _foreign identifier_ field/argument is used. + +For example, when the `uid` field is added to the `Cart` type in the `Magento/QuoteGraphQl` module, all input objects and arguments that reference the old `cart_id` should be updated to include the new `cart_uid` field. + +### Example Breakdown of Work +
+Click to Expand + +| Object/Interface | Primary Identifier Field | References | +|-------------------|--------------------------|------------------------------------| +| Cart | id | Mutation.mergeCarts | +| | | Query.cart | +| | | AddBundleProductsToCartInput | +| | | AddConfigurableProductsToCartInput | +| | | AddDownloadableProductsToCartInput | +| | | AddSimpleProductsToCartInput | +| | | AddVirtualProductsToCartInput | +| | | ApplyCouponToCartInput | +| | | ApplyGiftCardToCartInput | +| | | ApplyStoreCreditToCartInput | +| | | createEmptyCartInput | +| | | HostedProUrlInput | +| | | PayflowLinkTokenInput | +| | | PayflowProResponseInput | +| | | PayflowProTokenInput | +| | | PaypalExpressTokenInput | +| | | PlaceOrderInput | +| | | RemoveCouponFromCartInput | +| | | RemoveGiftCardFromCartInput | +| | | RemoveItemFromCartInput | +| | | RemoveStoreCreditFromCartInput | +| | | SetBillingAddressOnCartInput | +| | | SetGuestEmailOnCartInput | +| | | SetPaymentMethodAndPlaceOrderInput | +| | | SetPaymentMethodOnCartInput | +| | | SetShippingAddressesOnCartInput | +| | | SetShippingMethodsOnCartInput | +| | | UpdateCartItemsInput | +| | | | +| ProductInterface | id | CartItemInput | +| | | ProductSortInput | +| | | SendEmailToFriendInput | +| | | ConfigurableProductCartItemInput | +| | | ProductFilterInput | +| | | BundleProduct | +| | | ConfigurableProduct | +| | | DownloadableProduct | +| | | GiftCardProduct | +| | | GroupedProduct | +| | | SimpleProduct | +| | | VirtualProduct | +| | | ProductAttributeFilterInput | +| | | ConfigurableProductOptions | +| | | CustomizableAreaOption | +| | | BundleItem | +| | | CustomizableAreaValue | +| | | CustomizableCheckboxValue | +| | | CustomizableDateOption | +| | | CustomizableDateValue | +| | | CustomizableDropDownValue | +| | | CustomizableFieldOption | +| | | CustomizableFieldValue | +| | | CustomizableFileOption | +| | | CustomizableFileValue | +| | | CustomizableMultipleValue | +| | | CustomizableRadioValue | +| | | ProductLinks | +| | | ProductLinksInterface | +| | | | +| | | | +| CategoryInterface | id | CategoryTree | +| | | ProductFilterInput | +| | | CategoryFilterInput | +| | | StoreConfig | +| | | Breadcrumb | +| | | ProductAttributeFilterInput | +| | | | +| CartItemInterface | id | ConfigurableCartItem | +| | | DownloadableCartItem | +| | | SimpleCartItem | +| | | VirtualCartItem | +| | | BundleCartItem | +| | | CartItemUpdateInput | +| | | RemoveItemFromCartInput | + +
+ + +## Breaking Change Risks + +The following changes need to be avoided to ensure we're not breaking backwards compatibility: +- The nullability of existing fields/arguments _must not change_ +- The return type of existing fields _must not change_ +- Existing fields/arguments _must not be removed_ + +## Current State Breakdown (Open-Source) +
+Click to Expand + +- 51 Object Types and Interfaces with a _primary identifier_ + | Primary Identifier Field Name | Count | + |-------------------------------|-------| + | id | 34 | + | option_id | 10 | + | option_type_id | 4 | + | order_number | 1 | + | value_id | 1 | + | agreement_id | 1 | + +- 10 arguments across 8 different fields take a _foreign identifier_ + | Foreign Identifier Argument Name | Count | + |----------------------------------|-------| + | id | 4 | + | cart_id | 1 | + | identifier | 1 | + | identifiers | 1 | + | source_cart_id | 1 | + | destination_cart_id | 1 | + | orderNumber | 1 | + +- 49 Input Object fields across 44 different Input Objects take a _foreign identifier_ + | Foreign Identifier Field Name | Count | + |-------------------------------|-------| + | cart_id | 26 | + | sku | 3 | + | attribute_code | 2 | + | cart_item_id | 2 | + | code | 2 | + | customer_address_id | 2 | + | carrier_code | 1 | + | category_id | 1 | + | link_id | 1 | + | method_code | 1 | + | parent_sku | 1 | + | product_id | 1 | + | region_code | 1 | + | variant_sku | 1 | + +
\ No newline at end of file diff --git a/design-documents/graph-ql/improved-caching.md b/design-documents/graph-ql/improved-caching.md new file mode 100644 index 000000000..791f0b22a --- /dev/null +++ b/design-documents/graph-ql/improved-caching.md @@ -0,0 +1,82 @@ +## Current caching +For GraphQL, we create a full query cache, which has results stored under unique keys as described in the [dev docs](https://devdocs.magento.com/guides/v2.4/graphql/caching.html). + +These unique keys are a combination of several factors which define the scope of a request. Currently, this is the formula used for GraphQL: + +```` +[Store Header Value] + [Content-Currency Header Value] +```` + +All three types of cache (FPC, Varnish, and Fastly) know about these headers and use them to compute their cache keys. + +## The problem +This strategy only allows for all the components of the cache key to have public values, and for a user knowing those values to not pose any security concerns. +There is, however, what we will call "private data" which should not be exposed, or at the very least it should not be able to be reproduced/guessed easily if it is. + +Take Customer Group as an example: At no point in Luma do we expose Customer Group. Instead, we compute a hash using Store and Currency but also including Customer Group and a salt: + +```` +hash([Store] + [Currency] + [CustomerGroup] + [Salt]) +```` + +Using a salted hash hides the Customer Group from the user but still allows it to be considered as a cache key. +This works in Luma because we put that value into a cookie called `X-Magento-Vary`, which Luma sends with each request for the VCL code to use to do cache lookups. +In this case it is the server that computes the cache key for Varnish/Fastly. +Luma also stores Store and Currency in cookies, but never the value of Customer Group. + +PWA and GraphQL, however, use a cookieless approach. +PWA currently knows about Store and Currency, but not about the private components of the cache key. Because of this, we explicitly bypass the cache for logged-in customers in order to hit Magento and retrieve correct results. +Also, each time we change what is used for the cache key (such as adding Customer Group), we have to change the VCL in Fastly and Varnish. + +Additionally, we missed the fact that the cache node, since it uses these values as components of its own cache key, should respond with this `Vary` header for the browser cache: +```` +Vary: Store, Content-Currency +```` + +The Fastly VCL code for GraphQL currently has a bug where it returns the same `Vary` header as it does for non-GraphQL requests (which use a cookie to store `X-Magento-Vary`): +```` +Vary: Accept-Encoding, Cookie +```` + +All components/headers that compose the cache key should be included in the `Vary` header read by the browser cache. + +## The Solution +On every request, our GraphQL framework will compute a salted and hashed cache key using the same factors as the Luma `X-Magento-Vary` cookie and return it in a header on the response. +```` +X-Magento-Cache-Id: 85a0b196524c60eaeb7c87d1aa4708a3fb20c6a1 +```` + +PWA will capture this header and send it back on following GraphQL requests, which the cache node will then use as the key. +This requires a VCL change, but only once; the VCL code will not care about the method used to generate the header, so even if that changes no further updates would be required. + +PWA will continue to capture the header on every response, and when it makes a new request it will send the most recent value it has received. +This way, if something happens that changes a customer's cache key such as updating their shipping address, PWA will immediately pick it up and use the correct key on the next request. + +A different value for `X-Magento-Cache-Id` can be sent by PWA than Magento calculates for the response, such as when a user changes their currency (which does not use a mutation). +When this happens, we cannot cache the response under the `X-Magento-Cache-Id` that was on the request, or else incorrect results will be returned for other requests also using that initial value. +To avoid this issue, the VCL code will compare the `X-Magento-Cache-Id` values on the request and the response and not store the result in the cache if they do not match. + +The cache node will also respond with a proper `Vary` header for the browser cache to use: +```` +Vary: Store, Content-Currency, Authorization, X-Magento-Cache-Id +```` +There is no mutation for changing Store or Currency, so they need to remain in `Vary` for the browser cache to know to use them as keys. +Likewise, a customer logging out does not use a mutation; instead, PWA just stops sending the `Authorization` header. The browser cache needs to know that this could cause a change in result values, so `Authorization` also needs to be in `Vary`. Of note: The VCL code will not consider the full bearer token when doing a cache lookup, just whether it exists on the request at all. + +Like `X-Magento-Vary`, the hash calculation for the `X-Magento-Cache-Id` header will use an unpredictably random salt. To ensure this salt is consistent between requests, we will store it in the environment configuration. +However, as this will happen as part of the first GraphQL request without a pre-existing salt, multiple attempts to update the config could occur simultaneously before the first finishes. +To avoid issues with concurrent writes to the same file, we will use a lock when writing to `app/etc/env.php` while adding the salt. +Generating this value automatically instead of through an admin interaction will avoid adding a step to the upgrade process and allow us to ensure the salt is sufficiently random. + +For built-in FPC, there will be no changes other than outputting `X-Magento-Cache-Id` and the proper `Vary`. + +This should account for all issues listed above except for a major possible security flaw explained below. + +### The Solution's major flaw +We're caching the whole query, requests hit the caching server first, and we don't (yet) have an auth session service, nor is there a differentiator between auth token and session token. This means we can't currently validate the bearer token before the cache node checks for a hit. + +This is not a problem in Luma because it has blocks, and blocks marked as private aren't cached. We use separate ajax to populate those after we retrieve the page from the cache. +Unfortunately, GraphQL doesn't have any equivalent to Luma's blocks. We could say that a node has a set of blocks (Example: `product {block { subblock }}`), but our current use cases are catalog and prices, which we could populate and not cache. +For category permissions and shared catalogs, suddenly the whole response stops being cacheable because the products and categories might be different. + +We couldn't find a viable solution for this without having some high availability service that checks session token, which will only be possible after having full oauth2 protocol in 2.5. diff --git a/design-documents/graph-ql/mutation-error-design.md b/design-documents/graph-ql/mutation-error-design.md new file mode 100644 index 000000000..3ea6f8f28 --- /dev/null +++ b/design-documents/graph-ql/mutation-error-design.md @@ -0,0 +1,154 @@ +# Mutation Error Design + +Mutations are _the_ single way to modify application state in a GraphQL API. Mutations are where the large majority of our errors are going to happen, as a result of changing some underlying data. + +Today, the Magento 2 GraphQL schema does not do a great job of describing what could go wrong as a result of running a mutation. We should fix that! + +## Example of the problem + +Take this simplified schema to add an item to a shopper's cart: + +```graphql +type Mutation { + addItemToCart(sku: String!, quantity: Float): Cart +} +``` + +This schema only describes the happy path of adding an item to the cart. But some things can go wrong when adding an item to the cart, and we know what many of those things are: + +- Item went out of stock since the product page was loaded +- Merchant disabled the product since the product page was loaded +- Cart hit the max quantity allowed of item per customer. Full qty selected was not added, but some were +- Extensions can add additional rules that would limit adding an item to the cart + +Now put yourself in the shoes of a UI developer: how do you know all the error states your UI needs to cover? For most of the Magento 2 mutations today, you would need run the mutation itself (or dig through PHP) to see what will be returned in the `errors` key of the GraphQL response: + +```js +{ + "data": { + "cart": { + // cart data here + } + }, + "errors": [{ + "message": "Item 'cool-hat' not added to cart (Out of Stock)", + "path": ["addItemToCart"] + }] +} +``` + +This has some problems: + +1. It's unreasonable to expect a UI developer to exercise every possible input to a resolver to find the error states manually +2. These errors are not enforced by the schema, so they're not versioned with the schema. +3. If the UI wants to customize the message for a specific error state, they'd have to match on the exact response string, because there's no concept of error codes +4. Internationalization becomes challenging because the error states/strings are not known ahead of time + +In the world of headless UIs, it's going to be critical for our errors to be discoverable, translatable, and versioned + +## Solution: Design Mutation errors into the schema + +If we move our known error states for various mutations into the schema design itself, we'll get a few benefits: + +1. Versioning of errors +2. Discoverability of error states for UI devs + +### Example: Fixing our `addItemToCart` mutation + +In the previous example of `addItemToCart`, we had the following schema: + +```graphql +type Mutation { + addItemToCart(sku: String!, quantity: Float): Cart +} +``` + +Let's design some known error states directly into the API + +```graphql +type Mutation { + addItemToCart(sku: String!, quantity: Float): AddItemToCartOutput +} + +type AddItemToCartOutput { + # can still get the entire state of the cart post-mutation + cart: Cart + # can directly access errors that a shopper should be notified about + add_item_user_errors: [AddItemUserError!]! +} + +type AddItemUserError { + # if a UI has their own text for message, they can just + # not use this field, but serves as a descriptive default + message: string! + type: AddItemUserErrorType! +} + +# This enum is just an example. Non-exhaustive, and of course +# not all errors we'd want a specific type for +enum AddItemUserErrorType { + OUT_OF_STOCK + MAX_QTY_FOR_USER + NOT_AVAILABLE +} +``` + +With this new schema, we get the following query and response: + +```graphql +mutation { + addItemToCart(sku: "abc", quantity: 1) { + cart { + items { + # select item fields + } + } + + add_item_user_errors { + message + type + } + } +} +``` + +```js +{ + "data": { + "cart": { + // cart data here + }, + "add_item_user_errors": [{ + "message": "Item 'cool-hat' not added to cart (Out of Stock)", + "type": "OUT_OF_STOCK" + }] + } +} +``` +## Should we still use the `errors` key at all? + +Yes! _But_, we should consider the `errors` key to be of similar use to the `catch` clause when using `try/catch` in a programming language. + +The `errors` key should be for _exceptional_ circumstances, not for describing well-known states in an ecommerce app. It's unlikely you'd write this application code on the backend: + +```javascript +try { + var product = addItemToCart('sku'); + return product; +} catch (error) { + if (error.code === 'OUT_OF_STOCK') { + // + } else if (error.code === 'MAX_QTY_FOR_USER') { + // + } else { + // something else failed, maybe the DB connection? + } +} +``` + +Instead, you would likely have your `addItemToCart` function return something that describes the various possible results of the operation. + +## Open Questions + +1. Should we be consistent with the field name that represents these first-class errors? `user_errors` vs something like `add_to_cart_user_errors` +2. Should there be a basic interface that mutations should implement to enforce the pattern of returning a list of user errors? diff --git a/design-documents/graph-ql/naming-conventions.md b/design-documents/graph-ql/naming-conventions.md new file mode 100644 index 000000000..526ce4676 --- /dev/null +++ b/design-documents/graph-ql/naming-conventions.md @@ -0,0 +1,86 @@ +# GraphQL Naming Conventions + +The following naming guidelines should be followed when adding or modifying schemas in the Magento platform. + +These guidelines are based on the conventions already used within the schema. Because of this, there will be places where the style diverges from more common styles found in the GraphQL community. + +## Object/Interface Types + +Object/Interface names should always be _PascalCase_. + +```graphql +# PascalCase +type TwoWords {} +interface TwoWords {} +``` + +## Object/Input Field Names + +Object/Input field names should always be _snake_case_. + +```graphql +type Query { + # snake_case + two_words: String +} + +input Data { + # snake_case + two_words: String +} +``` + +## Arguments + +Argument names should always be _camelCase_. + +```graphql +type Query { + example( + # camelCase + twoWords: String + ): String +} +``` + +## Top-level Fields on Query and Mutation + +Top-level fields on Query and Mutation should always be _camelCase_. + +```graphql +type Query { + # camelCase + twoWords: TwoWords +} + +type Mutation { + # camelCase + twoWords(input: TwoWordsInput!): TwoWordsOutput +} +``` + +## ID Fields + +These rules should apply to any field assigned the scalar type `ID`, both in Object Types and Input Object Types. + +There are two types of `ID` fields: + +- **Primary Identifier** : An ID owned by the current Object Type +- **Foreign Identifier**: An ID referencing another Object Type + +A _Primary Identifier_ should _always_ be given the field name `uid`. +A _Foreign Identifier_ should _always_ be given the field name `source_object_uid`, where `source_object` is a _snake_case_ string referring to the type that owns the `ID` (either an Object or an Interface) + +```graphql +type ProductInterface { + # Good, ProductInterface owns `uid` + uid: ID! +} + +type ConfigurableProductOptions { + # Good, field refers to ID on `ProductInterface` + product_interface_uid: ID! +} +``` + +For additional context on ID naming requirements, see [the ID Improvement Plan](https://github.com/magento/architecture/blob/master/design-documents/graph-ql/id-improvement-plan.md_) \ No newline at end of file diff --git a/design-documents/graph-ql/result-status.graphqls b/design-documents/graph-ql/result-status.graphqls new file mode 100644 index 000000000..036ce1a66 --- /dev/null +++ b/design-documents/graph-ql/result-status.graphqls @@ -0,0 +1,16 @@ +enum BatchMutationStatus { + SUCCESS + FAILURE + MIXED_RESULTS +} + +interface ErrorInterface { + message: String! +} + +type NoSuchEntityUidError implements ErrorInterface { + uid: ID! +} + +type InternalError implements ErrorInterface { +} diff --git a/design-documents/img/jwt-api-authorization.png b/design-documents/img/jwt-api-authorization.png new file mode 100644 index 000000000..565812d3c Binary files /dev/null and b/design-documents/img/jwt-api-authorization.png differ diff --git a/design-documents/img/jwt-class-diagram.png b/design-documents/img/jwt-class-diagram.png new file mode 100644 index 000000000..c1f7a3a7b Binary files /dev/null and b/design-documents/img/jwt-class-diagram.png differ diff --git a/design-documents/img/jwt-data-exchange.png b/design-documents/img/jwt-data-exchange.png new file mode 100644 index 000000000..cf52a5933 Binary files /dev/null and b/design-documents/img/jwt-data-exchange.png differ diff --git a/design-documents/img/jwt-data-verification.png b/design-documents/img/jwt-data-verification.png new file mode 100644 index 000000000..af69df6f3 Binary files /dev/null and b/design-documents/img/jwt-data-verification.png differ diff --git a/design-documents/img/jwt-user-authorization.png b/design-documents/img/jwt-user-authorization.png new file mode 100644 index 000000000..040ca0ba4 Binary files /dev/null and b/design-documents/img/jwt-user-authorization.png differ diff --git a/design-documents/jwt-support.md b/design-documents/jwt-support.md new file mode 100644 index 000000000..a9d9e7571 --- /dev/null +++ b/design-documents/jwt-support.md @@ -0,0 +1,239 @@ +# Support of JWT authentication out-of-box + +## Overview + +JSON Web Token (JWT) is an open standard ([RFC 7519](https://tools.ietf.org/html/rfc7519)) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed (also, can be encrypted). JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA. + +The main use cases of JWT usage: + +#### Client side sessions + +![Client side sessions](img/jwt-user-authorization.png) + +JWT contains user's "session" representation. After successful login, all user session data (like ID, permissions, etc.) is stored in JWT token and user's permissions and allowed operations can be verified just based on information from JWT. + +#### API authorization + +![API authorization](img/jwt-api-authorization.png) + +Allows for client application to make API calls to needed resource with JWT as authorization token. The resource application can verify needed permissions from JWT claims. Authorization server can be separate application or the as resource server. In first case, the resource application should retrieve the secret key from the authorization server or send JWT to the authorization server for the verification. + +#### Data exchange + +![Data exchange](img/jwt-data-exchange.png) + +Allows to built communication between multiple servers. An authorization server shares secret keys between servers or servers can send JWT to the authorization server for the future verification. + +#### Data verification + +![Data verification](img/jwt-data-verification.png) + +Allows to verify if data is received from trusted source. The diagram shows RSA keys usage for content encryption but different types of keys can be used for the data verification like octet strings, key files, X.509 Certificates, etc. + +The JWT structure has three main parts: + + - `header` - contains information about signature verification algorithms + - `payload` - contains data (JWT-claims), the RFC defines a list of standard optional claims (https://tools.ietf.org/html/rfc7519#section-4.1) + - `signature` - is used for data verification and represented as a hash of encoded `header` and encoded payload and secret key + +And in general JWT looks like this: `header`.`payload`.`signature`. + +The JWT usage became more and more popular for authentication and data verification purposes and Magento has a lot of integrations with different 3rd party systens, we should support JWT creation/validation/parsing out-of-box. Another benefit, that JWT is language agnostic and token generaten with one programming language can be parsed with another, the only key should be shared between applications. + +## Solution + +We do not need to implement own solution for key generation, parsing tokens, encryption/decryption, etc. [There are](https://jwt.io/#libraries) multiple PHP libraries which support all needed operations and algorithms and we need to create own wrappers. + +[PHP JWT Framework](https://github.com/web-token/jwt-framework) - is proposed as a library because: + - implements all algorithms from RFC + - provides implementation for JWS, JWT, JWE, JWA, JWK, JSON Web Key Thumbprint, Unencoded Payload Option + - supports different serialization modes + - supports multiple compression methods + - full support of JSON Web Key Set + - has a good documentation + - supports detached payload, multiple signatures, nested tokens + +## Implementation + +The following diagram represents needed interfaces to unify the workflow for JWT usage. + +![Class diagram](img/jwt-class-diagram.png) + +The `\Magento\Framework\Jwt\KeyGeneratorInterface` provides a possibility to use different types of key generators like: Magento deployment secret key, X.509 certificates or RSA keys. +```php +interface KeyGeneratorInterface +{ + public function generate(): Jwk; +} + +class CryptKey implements KeyGeneratorInterface +{ + public function __construct(SecretKeyFactory $keyFactory, DeploymentConfig $deploymentConfig) + { + $this->deploymentConfig = $deploymentConfig; + $this->keyFactory = $keyFactory; + } + + public function generate(): Jwk + { + $secret = (string) $this->deploymentConfig->get('crypt/key'); + return $this->keyFactory->create($secret); + } +} +``` + +The `\Magento\Framework\Jwt\ManagementInterface` is a base abstraction for JWT encoding/decoding/verification: +```php +interface ManagementInterface +{ + /** + * Generates JWT in header.payload.signature format. + * + * @param array $claims + * @return string + * @throws \Exception + */ + public function encode(array $claims): string; + + /** + * Parses JWT and returns payload. + * + * @param string $token + * @return array + */ + public function decode(string $token): array; +} +``` + +It has implementations for JWS and JWE. The default preference is JWS implementation and looks like this: +```php +class Management implements ManagementInterface +{ + public function __construct( + KeyGeneratorInterface $keyGenerator, + SerializerInterface $serializer, + AlgorithmFactory $algorithmFactory, + Json $json, + Manager $claimCheckerManager, + BuilderFactory $builderFactory + ) { + $this->keyGenerator = $keyGenerator; + $this->serializer = $serializer; + $this->algorithmFactory = $algorithmFactory; + $this->json = $json; + $this->claimCheckerManager = $claimCheckerManager; + $this->builderFactory = $builderFactory; + } + + public function encode(array $claims): string + { + // as payload represented by url encode64 on json string, + // the same claims structure with different key's order will get different payload hash + ksort($claims); + $payload = $this->json->serialize($claims); + + $jwsBuilder = $this->builderFactory->create($this->algorithmFactory->getAlgorithmManager()); + $jws = $jwsBuilder->create() + ->withPayload($payload) + ->addSignature( + $this->keyGenerator->generate()->getKey(), + [ + 'alg' => $this->algorithmFactory->getAlgorithmName(), + 'typ' => 'JWT' + ] + ) + ->build(); + + return $this->serializer->serialize(new Jwt($jws)); + } + + public function decode(string $token): array + { + $jws = $this->serializer->unserialize($token) + ->getToken(); + + if (!$this->verify($jws)) { + throw new \InvalidArgumentException('JWT signature verification failed'); + } + + return $this->json->unserialize($jws->getPayload()); + } + + private function verify(CoreJwt $jws): bool + { + $verifier = $this->getVerifier(); + if (!$verifier->verifyWithKey($jws, $this->keyGenerator->generate()->getKey(), 0)) { + return false; + }; + + $payload = $this->json->unserialize($jws->getPayload()); + $this->claimCheckerManager->check($payload); + + return true; + } +} +``` + +### Claims validation + +The `\Magento\Framework\Jwt\ClaimCheckerManager` provides a possibility to validate different set of claims like issuer, token, expiration time, audience. The list of claim checkers can be provided via `di.xml` and each checker should implement `\Magento\Framework\Jwt\ClaimCheckerInterface`. +The list of claims can be configured via `di.xml`. +```xml + + + + Magento\Framework\Jwt\ClaimChecker\ExpirationTime + + + exp + + + +``` + +If `mandatoryClaims` argument is not specified and needed claim is not presented in the payload the check for this claim will be skipped. + +The following test shows how different types of claim checkers can be used for the validation: +```php +public function testCheck(): void +{ + $claims = [ + 'iss' => 'dev', + 'iat' => 1561564372, + 'exp' => 1593100372, + 'aud' => 'dev', + 'sub' => 'test', + 'key' => 'value' + ]; + + /** @var ClaimCheckerManager $claimCheckerManager */ + $claimCheckerManager = $objectManager->create( + ClaimCheckerManager::class, + [ + 'checkers' => [ + IssuerChecker::class, + ExpirationTimeChecker::class, + IssuedAtChecker::class + ] + ] + ); + + $checked = $claimCheckerManager->check($claims, ['iss', 'iat', 'exp']); + self::assertEquals( + [ + 'iss' => 'dev', + 'iat' => 1561564372, + 'exp' => 1593100372, + ], + $checked + ); +} +``` + +### Custom key generators + +The `\Magento\Framework\Jwt\KeyGeneratorInterface` provides a possibility to create custom key generators like based on random string, API keys, etc. The default implementation provides key generators based on env `crypt/key` (`\Magento\Framework\Jwt\KeyGenerator\CryptKey`) and simple string (`\Magento\Framework\Jwt\KeyGenerator\StringKey`). + +## Summary + +The proposed functionality can be added in a patch release. The introduced interfaces can be marked as @api in the next minor release. diff --git a/design-documents/media/catalog-images.md b/design-documents/media/catalog-images.md new file mode 100644 index 000000000..58cf2c54a --- /dev/null +++ b/design-documents/media/catalog-images.md @@ -0,0 +1,298 @@ +# Catalog Images + +The document provides the vision for the desired state of assets management in Magento. +It covers currently existing managed Magento environments, or the ones envisioned in the future: + +* Magento Cloud with Fastly CDN +* Magento Cloud + AEM Assets as DAM provider + * Including Dynamic Media with Akamai CDN + +Disclaimer: Other types of setup (e.g., on-prem + different CDN/DAM) can be supported in the similar fashion. +Current document just covers the above systems in more details to not grow in size exponentially. +There is no intention to limit usage of other types of setup. + +- [Terminology](#terminology) +- [Scenarios](#scenarios) + * [Asset Management](#asset-management) + * [Assign an image to a product](#assign-an-image-to-a-product) + * [Display an image on product details or products list page](#display-an-image-on-product-details-or-products-list-page) +- [Asset Transformations](#asset-transformations) + * [Magento: Image Transformations](#magento-image-transformations) + * [Fastly: Image Transformations](#fastly-image-transformations) + * [AEM Assets: Image Transformations](#aem-assets-image-transformations) +- [Watermarking](#watermarking) + * [Magento: Watermarking](#magento-watermarking) + * [Fastly: Watermarking](#fastly-watermarking) + * [AEM Assets: Watermarking](#aem-assets-watermarking) +- [Placeholders](#placeholders) + * [Magento: Placeholders](#magento-placeholders) + * [Fastly: Placeholders](#fastly-placeholders) + * [AEM Assets: Placeholders](#aem-assets-placeholders) +- [Risks](#risks) + * [Sync full image URL from Magento Admin to Store Front](#sync-full-image-url-from-magento-admin-to-store-front) + * [Full offload of image transformations to DAM/CDN](#full-offload-of-image-transformations-to-dam-cdn) + * [Storefront application provides only original URL](#storefront-application-provides-only-original-url) +- [Questions](#questions) +- [Breaking Changes](#breaking-changes) + +## Terminology + +* **Asset** - anything that exists in a binary format and comes with the right to use. + * **Image**, **video** are types of assets +* [DAM (Digital Asset Management)](https://en.wikipedia.org/wiki/Digital_asset_management) - a system responsible for asset management (store, create, update, delete, organize) +* [CDN (Content delivery network)](https://en.wikipedia.org/wiki/Content_delivery_network) - a system responsible for content delivery. In scope of this document, for delivery of images and video. +* **Asset delivery** - providing publicly available URL for the asset. Usually involves CDN. This is different from asset management, which focuses on the admin side of interactions with the assets, while delivery is the cliend side of it. +* **Image transformation** - resizing, rotation, watermarking and other automated transformations on an original image. + * Image transformation is responsibility of either DAM or CDN. This includes resizing, rotation, watermarking and so on. + * Client should be able to fetch transformed images by its original URL with additional parameters + * Transformation parameters may differ based on the CDN/DAM providing the transformation service +* **Magento back office (Magento Admin)** - in scope of this document, a system for products and categories management. + * Links assets to products and categories + * Provides basic functionality for images and video transformation. Long-term, should be offloaded to specialized systems (DAM, CDN) +* **Catalog Store-Front Application** - application providing product and category information suitable for store-front client scenarios. + * Serves URLs of original assets. + * Should not be aware of asset management functionality (no knowledge about underlying integration with external DAM systems). + * Might have Base CDN URL. This is similar to current Base Media URL and serves the same purpose, but might exist in case both sources of assets should be supported (Magento and CDN). + +## Scenarios + +![Asset flow](https://app.lucidchart.com/publicSegments/view/21c14319-73c6-4bb4-9d5f-e71a11a58321/image.png) + +Detailed steps of the asset flow are described below. + +### Asset Management + +1. Step 1: Asset is uploaded to DAM +2. Step 2: DAM may perform transformations + +### Assign an image to a product + +1. Step 3: Admin opens product edit page +2. Step 4: Admin selects necessary image using asset picker UI (provided as part of DAM integration) and saves the product + * Image is linked to the product as provided by DAM + * Image path relative to DAM base URL is stored as image path + * Asset is assigned to the product in DAM +3. Step 5: Asset relation is synced to Storefront service in a form of full URL to the asset, as part of product data + * URL to the **original** image is synced + * Each asset may also include specialized type: `thumbnail`, `small`, etc. The type is not related to the image size or quality and only helps the client understand where the image is supposed to be displayed + * The asset itself is not synced to the Storefront and is stored in its original location (in the system responsible for its management: either external DAM or Magento Back Admin) + +### Display an image on product details or products list page + +1. Step 6: User opens PDP (product details page) +2. Step 7: PWA application loads and requests product details from GraphQL application +3. Step 8: GraphQL application requests product details (including asset URLs) from the SF service. + * SF service returns full image URL of the **original** image +5. Step 9: PWA application fetches asset from the CDN by the provided URL + * PWA may include transformation parameters +6. Step 10 (optional): CDN fetches the asset from the origin, if not cached +7. Step 11 (optional): Origin (DAM) may perform necessary transformations +8. Step 12 (optional): CDN may perform necessary transformations + +## Asset Transformations + +Asset transformation is responsibility of either DAM or CDN, depending on the system setup. +Both services may provide some level of transformations. +CDN usually provides more basic transformations (resize, rotation, crop, etc), while DAM may provide more smart transformations (e.g., smart crop). +Client application (PWA) does not care which part is performing transformations, it must only follow supported URL format when including transformation parameters. + +In the first phase (and until further necessary) it is assumed that client application (PWA) is responsible for knowing format of transformed image URL, and so client developer should have knowledge of which DAM/CDN it works with. +As a more smart step, backend application (via GraphQL) can provide information necessary for URL formatting. + +Image transformation can be done by web server on the Magento side (e.g., Store-Front), but this looks less efficient than using a CDN. +In the first phase, this is not going to be supported. +This may cause performance issues on pages with many assets loaded (such as product listing), but it is assumed that production systems should use CDN with image transformation support. +Scenario with no CDN is assumed to be a development workflow and loading unresized images is considered less critical in this situation, especially assuming [Images Upload Configuration](./img/images-upload-config.png) allows to cap image size. + +Note: watermarking is a special case of asset transformation. See next section for its coverage. + +### Magento: Image Transformations + +Magento supports the following transformations for images: + +1. resize +2. rotate +3. set/change quality +4. set background + +See `\Magento\Catalog\Model\Product\Image` for details. + +### Fastly: Image Transformations + +Fastly provides image transformation features with [Fastly IO](https://www.fastly.com/io): + +1. Convert format +2. Rotation +3. Crop +4. Set background color +5. Set quality +6. Montage (Combine up to four images into a single displayed image.) + +and others. See https://docs.fastly.com/en/guides/image-optimization-api for detailed supported parameters. + +Provided features fully cover Magento capabilities. + +### AEM Assets: Image Transformations + +AEM Assets work in integration with Dynamic Media (DM) to deliver assets, and DM provides asset transformation capabilities. +DM uses Akamai as CDN. Does it provide additional image transformation capabilities? Are those even needed taking into account that DM provides broad range of features? + +1. DefaultImage - it allows the client to specify default image. Might be useful for placeholder implementation. +2. Resize: crop, scale, size, etc +3. Rotate and flip +4. Quality +5. Background color +6. Change image format + +and many others. +See [Dynamic Media Image Serving and Rendering API - Command reference](https://docs.adobe.com/content/help/en/dynamic-media-developer-resources/image-serving-api/image-serving-api/http-protocol-reference/command-reference/c-command-reference.html) for more details. + +In addition, [Image Presets](https://docs.adobe.com/content/help/en/experience-manager-65/assets/dynamic/managing-image-presets.html) with image adjustments can be created in advance and then requested by the client. + +## Watermarking + +Watermarking is a special case of image transformation. +It is special in a way that the **server** decides when and which watermark to apply. +All other transformations are initiated by the client. + +### Magento: Watermarking + +Applied during image transformations. +See `\Magento\Catalog\Model\Product\Image` for details. + +### Fastly: Watermarking + +See "Overlay" in https://docs.fastly.com/en/guides/image-optimization-api + +Overlay must be specified via `x-fastly-imageopto-overlay` header rather than via a URL parameter, which allows the server control it. +To make sure UX is acceptable, the workflow should be described in more details, taking into account Magento scopes. + +### AEM Assets: Watermarking + +Watermarking +- [AEM Assets Watermarking](https://docs.adobe.com/content/help/en/experience-manager-65/assets/administer/watermarking.html) + +Scoping? + +## Placeholders + +Placeholders are used in the following cases: + +1. A product has no image assigned to it. +2. An image assigned to the product is unavailable (due to delays caused by distributed architecture or accidentally) + +Expectations from the placeholder delivery: + +1. Client application can distinguish a placeholder from a real product image. This gives the client an ability to control what (if anything) should be displayed as placeholder. +2. Placeholder image should be possible to cache on the client side and reuse in different places. +3. If possible, the system (Magento?) should provide placeholder URL, so the client can use it if desired (not all clients may want to use admin-configured placeholders). + +Based on the above, Catalog service should not handle placeholders and try to provide them if product image is absent. +Instead, product image data should be empty, and system configuration GraphQL API should provide URL for placeholders by type. +To fully support external DAM systems, it should be possible to specify placeholder URLs in Magento system configuration, in addition to existing "upload file" option. +Configuration API should return full URL to the placeholder when requested by the client. This can be: + +1. URL pointing to Magento application for default placeholder image (example: `https://static.base.url/catalog/product/placeholder.jpg`) +2. URL pointing to uploaded placeholder (example: `https://media.base.url/catalog/product/placeholder.jpg`) +3. URL pointing to external DAM/CDN (example: `https://external.dam.com/my-magento/catalog/product/placeholder.jpg`) + +The client should not assume the placeholder base URL is the same as Media Base URL. +It is responsibility of the client application to handle situation where an image assigned to a product is absent in the storage and handle this situation gracefully. + +### Magento: Placeholders + +Magento allows to specify a placeholder image for each type of the product image (base, small, thumbnail, swatch) per store view. + +Magento validates whether the file is present on the disk at the moment of URL generation, and provides URL to a placeholder image if the file is absent. +This covers two different use cases: + +1. The image has never been specified for the product, so there is no image available and no file is actually validated for presence. +2. The imaghe has been assigned to the product, but then has disappeared from the storage (due to a mistake, failure or otherwise). + +Existing behavior limits the system to using local storage for the assets as it has a hard dependency on the local file system, and makes it difficult to fetch images from external DAM systems. +As mentioned before, placeholder URLs should be supported. + +### AEM Assets: Placeholders + +When integrating with AEM Assets or another external DAM system: + +1. Content author uploads and publishes placeholder asset in the DAM system +2. Magento admin references the published placeholder URL in Magento system configuration +3. Client application requests the placeholder URL via Magento GraphQL API to place where a product image can't be loaded + +In addition, Dynamic Media supports "DefaultImage", which allows the client to specify default image. +This can be used as another fallback path in case an image is absent. + +See https://docs.adobe.com/content/help/en/dynamic-media-developer-resources/image-serving-api/image-serving-api/http-protocol-reference/command-reference/c-command-reference.html + +## Risks + +This section summarizes potential issues with certain scenarios. + +### Sync full image URL from Magento Admin to Store Front + +**Risk**: Changing Base Media URL will require full resync of Catalog to update image URLs. +**Mitigation**: Follow this procedure for changing Base Media URL: + +1. Setup infrastructure so that new URL redirects to the old URL if asset doesn't exist. +2. Wait for the sync to finish. +3. Disable/drop old URL. + +**Alternative**: Sync path to the assets and Base Media URL, add logic of composing full URL to the Storefront application. + +The decision of selecting full sync approach is based on: + +1. It is expected that Base Media URL is a rare event. +2. Mitigation steps are straightforward and may be necessary anyways. +3. Additional logic adds complexity. In this case, potential complexity is in: + 1. Fallback for websites -> stores -> store views + 2. In the future, Magento needs to be integrated with DAM. In case of integration in mixed mode (where both Magento-managed assets and DAM-managed assets are supported), the logic of composing the URL becomes even more complex as it requires Storefront to have knowledge of assets relation to products. + +Base on the above, it looks more reasonable to have simple logic on Storefront side than trying to avoid full resync. + +### Full offload of image transformations to DAM/CDN + +**Risks**: Systems with no CDN/DAM will get performance hit on pages with many assets (such as product listing). +**Mitigation**: Possible options: + +1. Upload pre-resized thumbnail images. +2. Utilize "Images Upload Configuration" to ensure huge images are not uploaded. + +In case the feature is highly requested, it can be implemented on the level of web server. +There was PoC made for resizing imaged by Nginx. + +### Storefront application provides only original URL + +**Risks**: + +1. Client application may use incorrect transformed URL. +2. Clients may need to be rewritten when switching to a different CDN/DAM. +3. Clients may be broken when switching to a different CDN/DAM. + +**Mitigation**: Align client development with infrastructure of the back office. + +**Alternative**: Provide information necessary for forming correct transformation URL by Storefront API. + +Then client can rely on this information to build correct transformed URL dynamically and doesn't need to know about CDN/DAM used behind. +Example: +``` +transformationParams: + - width: w + - height: h + - quality: q + +url = origUrl + '?' + transformationParams[width] + '=100&' + transformationParams[height] + '=100&' + transformationParams[quality] + '=80' + +> https://my.dam.com/catalog/product/my/product.jpg?w=100&h=100&q=80 +``` + +## Questions + +1. Do we sync full image URL to SF or provide Base CDN URL as configuration for the store? + 1. What do wee do with secure/unsecure URLs in case of full URL? + +## Breaking Changes + +1. Current GraphQL returns transformed images instead of originals (❗ validate this. Might be just broken URL, as it's not clear which exact transformation GraphQL provides from the entire list). Problems with this approach: + 1. Transformation depends on Magento theme, which is beyond GraphQL scope (GraphQL knows nothing about old Magento themes, like Luma). + 2. GraphQL clients can't perform or request necessary transformation, only predefined (by irrelevant Magento themes) transformations are provided. diff --git a/design-documents/media/img/images-upload-config.png b/design-documents/media/img/images-upload-config.png new file mode 100644 index 000000000..977805b37 Binary files /dev/null and b/design-documents/media/img/images-upload-config.png differ diff --git a/design-documents/message-queue/first-class-queue-configuration.md b/design-documents/message-queue/first-class-queue-configuration.md new file mode 100644 index 000000000..7d7ae1408 --- /dev/null +++ b/design-documents/message-queue/first-class-queue-configuration.md @@ -0,0 +1,142 @@ +# First Class Queue Configuration + +This proposal intends to expose a first-class "queue" object in `queue_topology.xml` of a module and provides backward compatibility considerations for prior versions of Magento before this change. + +## Overview + +The Message Broker, RabbitMQ, supports `arguments` that are defined at queue creation time that dictate certain behaviors of the resulting queue. Currently (as of v2.4.0), Magento infers what queues will be created from properties of the `exchange.binding` defined in any given module's `queue_topology.xml`. + +Unfortunately, the current implementation of `queue_topology.xml` does not expose a queue object to specify arguments at queue creation time, preventing utilization of the configurable behavior of RabbitMQ queues. + +## Use Cases + +- As a developer, I would like to configure my queues with additional arguments. +- As a developer, I would like to achieve High Availability with my queueing system. + +### The Scenario + +In order to support High Availability message processing, we would like to utilize RabbitMQ's [`x-dead-letter-exchange`](https://www.rabbitmq.com/dlx.html) and [`x-message-ttl`](https://www.rabbitmq.com/ttl.html) arguments for queues. Achieving High Availability with these two arguments would require two queues: + +1. A primary queue that processes messages. +2. A secondary "retry" queue with a defined `x-message-ttl` and `x-dead-letter-exchange` arguments. + +The first queue's consumers attempt to process its messages, and then upon failure conditions, publish "retryable" messages (via an exchange) into the "retry" queue. + +After the retry queue's TTL passes, the now "expired" message is then moved (**without being consumed**) as a dead-letter back to the primary queue (via the x-dead-letter-exchange). + +In the end, this results in messages being retryable in whatever mechanism a developer wishes, based upon whatever failure conditions a developer desires. + +## Design + +An additional object would need to be added to the `queue_topology.xml` API, called a `queue`. A `queue` supports the following properties: + +```txt +name +connection +durable +autoDelete +arguments +``` + +These arguments are consistent with the existing arguments for exchanges and bindings and should feel very familiar to developers accustomed to the current syntax. + +### Matching Behavior + +Since there is pre-existing behavior defined for queue creation from `exchange.binding` there must be a matching mechanism that determines which mechanism of queue-creation wins. + +A queue is considered a "match" (and therefore overrules the prior `exchange.binding` behavior) when the first-class queues `name` and `connection` exactly match (string comparison, case-sensitive) a queue which would be created by `exchange.binding`. That is to say, the bindings -> queue generator mechanism should not attempt to create queues where the `queue` with the same name and connection exists. + +### Sample Configurations + +- [A Simple Configuration](#a-simple-configuration) +- [First-Class Queue Priority](#first-class-queue-priority) +- [Queue-Connection Mismatch](#queue-connection-mismatch) + +#### A Simple Configuration + +This configuration would result in a single queue: `some-queue` which is configured by the queue object. + +```xml + + +    +        +           arg-value +           my-dlx +        +    + +``` + +#### First-Class Queue Priority + +When a `queue` and an existing `exchange.binding` collide via the described matching mechanism, a single queue results: `some-queue` as described in the below case. + +```xml + + +    +        +    +    +        +           arg-value +           my-dlx +        +    + +``` + +#### Queue-Connection Mismatch + +If a `queue` is defined with a connection that is different from the `exchange` that would bind to it via the old queue-creation mechanism, two queues are created, one in each connection. We should likely warn in this scenario, as likely this is an unintentional behavior. A suggested message (appearing during `setup:upgrade`) is: + +> `some-queue` is defined in two connections: `amqp` and `db`. If this intentional, this message can be ignored. + +```xml + + +    +        +    +    +        +           arg-value +           my-dlx +        +    + +``` + +## Backwards Compatability + +Backward compatibility is well-defined and should be as follows: + +### No Queue Defined + +If no `queue` is defined, the behavior is as before, a single queue is created in the defined connection. + +```xml + + +    +        +    + +``` + +## Open Questions + +1. What about `autoDelete` differences? +     I suspect that connection/name is a sufficient matching pair, so this case may be irrelevant. +2. What about `durable` differences? +     I suspect that connection/name is a sufficient matching pair, so this case may be irrelevant. diff --git a/design-documents/storefront/Readme.md b/design-documents/storefront/Readme.md new file mode 100644 index 000000000..25bf15c97 --- /dev/null +++ b/design-documents/storefront/Readme.md @@ -0,0 +1,15 @@ +# Storefront + +This section of documents contains architectural and design decisions related to separation of Magento Storefront as a separate application. + +## High-Level Vision of Application Separation + +![monolith to services separation](https://app.lucidchart.com/publicSegments/view/24d251f7-ba0e-4c90-9190-3f397ad650b2/image.png) + +Where **Domain** is application domain, such as Catalog, Customer, etc. +See [Magento Service Isolation Vision](../service-isolation.md) for more details on domains and services. + +## Resources + +1. [Magento Service Isolation Vision](../service-isolation.md) +2. [Catalog Storefront project overview](https://github.com/magento/catalog-storefront/wiki/Catalog-Storefront-Service) diff --git a/design-documents/storefront/catalog/product-options-and-variants.md b/design-documents/storefront/catalog/product-options-and-variants.md new file mode 100644 index 000000000..e928d88ff --- /dev/null +++ b/design-documents/storefront/catalog/product-options-and-variants.md @@ -0,0 +1,515 @@ + +## Document purpose + +Product options are a powerful instrument that allows a shopper to customize and (or) personalize a product before adding the product to the cart. + +Introduction of the storefront API brings us a unique opportunity to revisit the options management, address the existing limitation, and address known issues: + +* Reduce redundancy caused by variants matrix creation. Magento creates variant and simple product for each intersection of options and option values selected to represent the configurable product. Not all the cases in real require a product creation, so extracting the variant as an entity could reduce system load caused by the number of products. + +* Allow to set up a dependency between the different options of a simple product based on previously selected option values. +Currently, simple and bundle products do not support dependencies between option values. +An example, bundle product that represents a computer in your store may need to correlate the list of the available motherboards with the socket of the selected processor. + +* Support grouped B2B prices at the options level. There is no way to specify a special price for a customer group for simple, downloadable, and some of the bundled options of the product. + +* Although all the options which represent product variants are pretty similar and most of them have similar properties, the options not generalized in Magento. As a result, it makes the option management more complicated, and for some cases such as option displaying, causes frontend rendering logic duplication. The ultimate goal after the options API revision is to build a generalized view that should to significantly reduce the complexity of the options domain for the storefront application. + +### Taxonomy of the options + +Due to their origin and structure options segregates on two main subtypes. + +**Product Option Variants** & **Shopper Input Options**. + +* The first subtype - **Product Option Variants**, is much often used and represents product configuration, which was predefined by a merchant. And so, product customization could be described by the selection of these predefined options, which, in their turn, creates product variants, where each variant represents the selection of one or many option values. +This document will be focused on the first subtype, Product Option Variants, it just briefly covers the reasons for the options separation, due to the intention do not overload this document with information about the domain. + +* Another subtype **Shopper Input Option**s represent an approach to personalizing a product before adding it to the cart by adding custom images, entering text so on. Gift Cards with customer-defined amounts could be treated as one of the cases the Shopper Input Options. These subtype of options do not provide any predefined values, it provides constraints for the input instead like, max number of symbols, a range for amount, or allowed extensions for files. Shopper Input options do not have variants, could not be associated with a product or inventory record, but may have a reference on price. Due to the excessive list of differences from product options variants, this option subtype is out of the document. The document just points out that the options segregation by the mentioned above criteria should happen, especially to respect checkout API that we released recently. Anyway, Shopper Input Options should have their own representation as a product top-level property. + +*Note: Most of the logic that we use to associate with Magento product types (configurable, bundle, downloadable, etc) in fact is the logic of the different options.* + +### Definitions + +* **ProductOption** - represents a product characteristic which allows editing before adding to cart. +ProductOption has to have a label, information on how option values should be displayed, +and is this option mandatory. +* **ProductOptionValue**s - belong to a particular option as to a group and represent selections which allowed by the option. +Option value could by display label, also one or may values could be pre-selected. +* **ProductVariant** - represent the final selection of one or multiple option values that will characterize the customized product in a shopping cart. +Depends on the business scenario, a particular product variant could be linked with an existing product, +with a price, or an inventory record, or no be linked to any. +Even with no entities associated with the variant, it still has great value for a catalog because the presence of the variant says that such composition of options and their values described by the variant makes sense so that it can be purchased. + +## Actual usages + +The application distinguishes two approaches to manage options: + +**Approach 1, "One to many"**: Multiple options selections lead a shopper to a selection of the single variation. + +Example: configurable products +![](https://app.lucidchart.com/publicSegments/view/26cd0b67-13c1-44e4-8b61-cada36c67010/image.png) + +**Approach 2, "One to one"**: The selection of multiple options leads to multiple product variants. + +Examples: bundle product, customizable product, downloadable products. +![](https://app.lucidchart.com/publicSegments/view/fea3f950-6f2f-4e46-90e4-f8ee4b6877f7/image.png) +Both of approaches could be used together. + +Example: configurable product with customizable option. + +## Modeling Options and Variants Data Objects + +The great advantage of Magento 2 - modularity was not a real thing for the product options during Magento the lifetime. +Since times of Magento 1.x option prices coupled into options, inventory records ignored even options with assigned SKUs. +The only thing that had to play it together "Bundle" product was designed too complex to treat it as universal solution it had to become. + +That's why, with the storefront project, we have an opportunity to resolve it. + +Taking into account the Magento experience (good, bad, ugly) and solutions proposed on the market. +I could define the set of requirements that I would want to see in a new options implementation: + +* Options are a predefined list of possible product customizations. This list belongs to the product exclusively; it is measurable, and limited only by the business requirements. So it always easy to return even a complete set of options for rendering PDP initially. + +* Variants are a unique intersection of one or may option values. The variant matrix does not belong to a product as property. The variant matrix is stored managed separately from products. The primary role of variants is to distinguish the possible intersections of options from impossible. +The variants matrix is never meant to be returned to the storefront as is. The variants matrix will be used for filtering options allowed at the storefront after one or many options selected. + +### Variants, Prices & Inventory +Variants could link the options intersection with a product, a price, or an inventory record but any of these relations is not mandatory. +So, a variant can request data from the different domains if such data assigned. +Such a decision brings us unseen before the level of the flexibility, since there is no difference between product price and variant price, and as the consequence variant price, could be included in B2B pricing. + +We do not have such behaviors either Magento 1 or 2 and as a result, the whole layer of product types such as bundle and downloadable do not support B2B prices or special prices for options. + +In most cases, the product variant of configurable and bundle products may represent the child product, and via versa. +But it does not mean that two different variants can not reference the same product. For instance, two different bundle products may contain the same product, refer to the same inventory but have different prices. + + +### Enhanced option values + +To reach a better user experience, +the option could be extended with an image that represents it +or info URL that provides additional information about option value. + +[Magento: Swatches](https://docs.magento.com/user-guide/catalog/swatches.html) + +[Magento: Downloadable products, options with samples](https://docs.magento.com/user-guide/catalog/product-create-downloadable.html) + +Both types of resources could be specified as attributes of `ProductOptionValue`. + +```proto +syntax = "proto3"; + +message Product { + repeated ProductOption options = 100; +} + +message ProductOptionValue { + string id = 1; + string label = 2; + string sortOrder = 3; + string isDefault = 4; + string imageUrl = 5; + string infoUrl = 6; +} + +message ProductOption { + string id = 1; + string label = 2; + string sortOrder = 3; + string isRequired = 4; + string renderType = 6; + repeated ProductOptionValue values = 5; +} + +message ProductVariant { + repeated string optionValues = 1; + string id = 2; + string productId = 3; + string productIdentifierInPricing = 500; #* + string productIdentifierInInventory = 600; #* +} +#* to avoid unnecessary network calls the variant has to know does it have a link on another domain, type, and proper name of a field representing this link TBD. +``` + +![](https://app.lucidchart.com/publicSegments/view/eed59f85-7d04-46ac-9d7e-eaa77073017e/image.png) + +### Explaining data and operations through the pseudo SQL + +Lets review pseudo SQL schema, which implements the picture described above. +This is not the instruction for implementation, tables intentionally do not have all the fields of data objects. +The example proposed to show the relations and operations that we have in the domain. + +```sql +create table products ( + object_id char(36) not null, + data json not null, + primary key (object_id) +); +``` +Table `products` stores registry of products. + +```sql +create table product_variant_matrix ( + value_id char(36) not null, + object_id char(36) not null, + primary key (value_id, object_id) +); + +alter table product_variant_matrix + add foreign key fk_product_variant_matrix_value_id (value_id) references product_option_values(value_id); +``` + +`product_variant_matrix` - Stores the correlation between option values and variants, by using this correlation, we can say which of option combination is real. + +The following script models data from the picture above. + +```sql +set @product_data := ' +{ + "id": "t-shirt", + "options": { + "color": { + "label": "Color", + "values": { + "red": { + "label": "Red" + }, + "green": { + "label": "Green" + } + } + }, + "size" : { + "label": "Size", + "values": { + "m": { + "label": "M" + }, + "l": { + "label": "L" + } + } + } + } +} +'; + +insert into products (object_id, data) values ('t-shirt', @product_data); + +insert into product_variant_matrix (value, object_id) +values + ('t-shirt:options.size.values.l', 'l-red'), ('t-shirt:options.color.values.red', 'l-red'), + ('t-shirt:options.size.values.m', 'm-red'), ('t-shirt:options.color.values.red', 'm-red'), + ('t-shirt:options.size.values.m', 'm-green'), ('t-shirt:options.color.values.green', 'm-green') +; +``` + +So far, all looks pretty nice with such an approach product may return information for all available options with the single request. +```sql +mysql> select + -> json_pretty(data->>'$.options.*') as options + -> from products p where p.object_id = 't-shirt'\G +*************************** 1. row *************************** +options: [ + { + "label": "Size", + "values": { + "l": { + "label": "L" + }, + "m": { + "label": "M" + } + } + }, + { + "label": "Color", + "values": { + "red": { + "label": "Red" + }, + "green": { + "label": "Green" + } + } + } +] +1 row in set (0.00 sec) +``` + +Let's assume that we have chosen one option value from the list. +Starting this point we can look into variants to analyze remaining options. +From the proposed example, we have chosen "Size": "M". + +```sql +mysql> select t.* + -> from ( + -> select + -> object_id, value, count(1) over (partition by object_id) as weight + -> from product_variant_matrix + -> ) as t + -> where value in ('t-shirt:options.size.values.m'); ++-----------+-------------------------------+--------+ +| object_id | value | weight | ++-----------+-------------------------------+--------+ +| m-green | t-shirt:options.size.values.m | 2 | +| m-red | t-shirt:options.size.values.m | 2 | ++-----------+-------------------------------+--------+ +2 rows in set (0.00 sec) + +``` + +As you may see, our selection has matched two variants. +Both variants have weight two, +which means that we have to match at least two values to match the whole variant, +but we used only one, +which means that we can request the remaining options, +that correspond to our current selection. + +```sql +mysql> select distinct value + -> from product_variant_matrix pvm + -> where pvm.object_id in ('m-green', 'm-red') + -> and value not in ('t-shirt:options.size.values.m'); ++------------------------------------+ +| value | ++------------------------------------+ +| t-shirt:options.color.values.green | +| t-shirt:options.color.values.red | ++------------------------------------+ +2 rows in set (0.01 sec) +``` + +The remaining option values could be found in values assigned to the matched variants minus values that we selected at the previous step. + +```sql +mysql> select + -> json_pretty( + -> json_extract( + -> data, + -> '$.options.color.label', + -> '$.options.color.values.green', + -> '$.options.color.values.red' + -> ) + -> ) as options + -> from products\G +*************************** 1. row *************************** +options: [ + "Color", + { + "label": "Green" + }, + { + "label": "Red" + } +] +1 row in set (0.00 sec) +``` + +*Note: To achieve more advanced behavior, the variants could be "uneven" inside the single product. + They may have different "weight". + For instance, you would like to track only t-shirts XL: size separately for some reason (a different price or stock). The example above focused on covering the main case scenario. Still, the approach, overall, is meant to support extending the logic of resolving option values onto a variant under the hood.* +![](https://app.lucidchart.com/publicSegments/view/de9972a8-f630-4400-aacb-d3d9858862cf/image.png) + +### Storefront API + +#### Import API + +* Product import API should accept options as a part of the Product message. +* Because product variant matrix not more belongs to Product message, we have to design import API, which will accept ProductVariant messages. + +#### Read API + +As were mentioned previously, options belong to a product, full options list can be retrieved from a product. + +Variants do not expose at the storefront but help to filter options after one or several variants were selected. + + + +Such behavior was recently [approved for configurable product](https://github.com/magento/architecture/pull/394). +And since the most complex part of designing API was done the main thing we have to do in the scope of this +chapter - generalize the behavior to support not only configurable products. + +As an input our API has to accept option values which were selected by a shopper. +With the response API has to return: +* List of options and option values that remains avaialble. +* List of images & videos that should be used on PDP. +* List of products that were exactly matched by the selected options. +```proto +syntax = "proto3"; + +message OptionSelectionRequest +{ + string storeViewId = 1; + repeated string values = 2; +} +message OptionResponse { + repeated ProductOption options = 1; + repeated MediaGallery gallery = 2; + repeated ProductVarinat matchedVariants = 3; +} + +service OptionSearchService { + rpc GetOptions(OptionSelection) returns (OptionResponse); +} + +``` + +In the perfect world, variants should not appear at the presentation level. Still, the storefront lies under the presentation, so it has to provide a way to retrieve variants depends on the scenario efficiently. +So far, we can imagine the following cases: +* match the variants which correspond, and do not contradict, the merchant selection - such API. +* match the variants which exactly matched with merchant selection. +* get all variants which contain at least one of merchant selection. +* get all variants that belong to a product. This method is code-sugar for the previous one because all the product variants could be retrieved by using the previous method in case of passing all the options values which belong to the product. + +```proto +syntax = "proto3"; +productId +message VariantResponse { + repeated ProductVarinat matchedVariants = 3; +} + +message ProductRequest { + string productId = 1; + string storeViewId = 2; +} + +service VaraintSearchService { + rpc GetVariantsMatch(OptionSelection) returns (VariantResponse); + rpc GetVariantsExactlyMatch(OptionSelection) returns (VariantResponse); + rpc GetVariantsInclude(OptionSelection) returns (VariantResponse); + rpc GetProductVariants(ProductRequest) returns (VariantResponse); +} +``` + +## Proposal cross references + +This proposal continues the idea of product options unification and aligned with previous design decisions that were made in this area. + +* [Single mutation for adding products to cart](https://github.com/magento/architecture/blob/master/design-documents/graph-ql/coverage/add-items-to-cart-single-mutation.md) +* [Configurable options selection](https://github.com/magento/architecture/blob/master/design-documents/graph-ql/coverage/catalog/configurable-options-selection.md) +* [Gift Registry](https://github.com/magento/architecture/blob/master/design-documents/graph-ql/coverage/gift-registry.md) + + +## Question & Answers + +### How to express two configurable products that share the same attribute set? + +*Comment: Although this case natively supported by Magento, +it could be less common than we used to think since the two regular t-shorts +from Walmart will have different sets of values for sizes and colors depends +on the manufacturer. proof https://www.walmart.com/search/?query=tshirt* + + +#### Product #1 +```json +{ + "id": "a82deb7a-dee7-48e7-ab34-75026e576fab", + "name": "Fruit of the Loom Men's Short Sleeve Assorted Crew T-Shirt", + "options": { + "color": { + "label": "Color", + "values": { + "red": { + "label": "Red" + }, + "green": { + "label": "Green" + } + } + }, + "size" : { + "label": "Size", + "values": { + "m": { + "label": "M" + }, + "l": { + "label": "L" + } + } + } + } +} +``` + +#### Product 1 variants +```json +[ + { + "variantId": "500d0366-777f-4a45-92b6-8e5197ce9992", + "optionValueIds": [ + "a82deb7a-dee7-48e7-ab34-75026e576fab:color/red", "a82deb7a-dee7-48e7-ab34-75026e576fab:size/l" + ], + "productId": "8b6be8b0-2e21-4763-806c-f383a8591d21" + }, + { + "variantId": "b843e139-aa04-44d0-a9a7-b439a17ce941", + "optionValueIds": [ + "a82deb7a-dee7-48e7-ab34-75026e576fab:color/green", "a82deb7a-dee7-48e7-ab34-75026e576fab:size/m" + ], + "productId": "96a5a8ed-7cfe-4626-be29-f60fe0bf7b33" + } +] +``` + +#### Product 2 +```json +{ + "id": "747d8c9b-e5fc-437a-8263-271dd8352976", + "name": "George Men's Assorted Crew T-Shirt", + "options": { + "color": { + "label": "Color", + "values": { + "red": { + "label": "Red" + }, + "green": { + "label": "Green" + } + } + }, + "size" : { + "label": "Size", + "values": { + "m": { + "label": "M" + }, + "l": { + "label": "L" + } + } + } + } +} +``` + +#### Product 2 variants +```json +[ + { + "variantId": "edbb59fb-f303-4970-9f03-889e71374a90", + "optionValueIds": [ + "747d8c9b-e5fc-437a-8263-271dd8352976:color/green", "747d8c9b-e5fc-437a-8263-271dd8352976:size/l" + ], + "productId": "7f4db047-604f-41ce-8998-a015f578e023" + }, + { + "variantId": "2c865d16-1723-49a3-8fd1-9b46613b5c12", + "optionValueIds": [ + "747d8c9b-e5fc-437a-8263-271dd8352976:color/red", "747d8c9b-e5fc-437a-8263-271dd8352976:size/m" + ], + "productId": "8eab9d67-791a-4c34-bc30-8bd034856ee2" + } +] +``` + +## How to return all variants for the product + +*Comment: A client should never request all the variants within a single call +the number of such variants is unpredictable, +and could significantly affect store performance.* + +@see `rpc:GetProductVariants` + diff --git a/design-documents/storefront/configuraiton-propagation.md b/design-documents/storefront/configuraiton-propagation.md new file mode 100644 index 000000000..2532fa6a8 --- /dev/null +++ b/design-documents/storefront/configuraiton-propagation.md @@ -0,0 +1,91 @@ +# Configuration Propagation from Admin Panel to Storefront + +## Problem Statement + +Existing Magento monolith is responsible for the whole cycle of data and user workflow. +Magento Admin Panel contains configuration settings for all areas of the application, including Admin Panel behavior, storefront UI, etc. + +With Storefront being a separate service (or set of services), should configuration settings in Magento Admin Panel be propagated to storefront service? +If yes, in what way? + +## Configuration Propagation Guidelines + +This document covers general agreements for decisions about configuration propagation from Admin Panel to storefront. +A separate design decision should be made and documented for specific use cases. + +There are three use cases for the configuration, based on its impact. + +Magento configuration is divided into three categories, based on impact on Storefront services: + +1. Admin configuration - impacts Admin Panel behavior +2. Data configuration - impacts data provided by Storefront +3. UI Configuration - impacts UI representation of Storefront data + +### 1. Configuration that impacts Admin Panel behavior + +Examples: admin ACLs that allow or deny parts of functionality for the admin user, reindex on update or by schedule. + +This type of configuration has nothing to do with Storefront and should not be propagated to storefront, as well as should not be exposed via export API. + +### 2. Configuration that impacts data provided by Storefront + +Example: Base Media URL is used for calculation of media image URLs returned by Storefront API. + +This type of configuration should be propagated to Storefront in one of the following ways: + +1. Pre-calculate final data on back office side and provide final result to the Storefront during synchronization. Minimize synchronization by updating only entities that have been really affected. + 1. Pros: simpler implementation of Storefront due to eliminated necessity for Storefront to keep knowledge about additional configuration, including data calculation algorithms. + 2. Cons: massive (up to full) reindexation necessary in case the configuration is changed. +2. Move/duplicate calculation logic in the Storefront service based on original data is synced from the back office. + 1. Pros and cons are opposite to option 1. So this option is valuable in case of expected frequent change of configuration that impacts a lot of data. + +To choose the right approach in a specific case, consider the following: + +1. How frequently the configuration is expected to change? + 1. Frequent configuration changes leading every time to massive reindexation may be unacceptable. + 2. Configuration changes expected a few times in the store lifespan may not be worth additional complexity on the Storefront side and full reindexation may be better in this case. +2. Is it acceptable to have significant delay in data propagation after the configuration change? + 1. Changing Base URL may be not a big issue, especially if a redirect can be setup. So it may be acceptable to have URLs to be fully updated in a few hours. + 2. Changes in prices, on the other side, may not stand long delays. +3. Do 3rd-party systems provide similar configuration? + 1. If 3rd-party systems don't have equivalent configuration, how will it be populated in the Storefront service? It might be better to avoid Magento-specific concepts to simplify integrations, and instead provide indexed data to the Storefront service. + 1. If a configuration option is pretty common among 3rd-party systems, it may make sense to reflect it in the Storefront service. + +Expected consequences are described in the decision document for each case. +For example, time for configuration propagation in case reindexation is chosen, logic duplication/complexity in case configuration is propagated to Storefront application, performance impact for Storefront read API or for synchronization. + +### 3. Configuration that impacts UI representation of Storefront data + +Example: number of products on products listing page. + +This kind of configuration has nothing to do with Storefront data itself, but is still necessary for the client to know how to display the data. + +This section describes possible implementation options. +Both options are possible and acceptable. The correct option should be selected based on the specific use case and client requirements. +For other considered options see [older revision](https://github.com/magento/architecture/blob/48f2db6cc9f18a50b181b6a6b76cb0dbc81722cb/design-documents/storefront/configuraiton-propagation.md) of the document. + +#### 3.1. Configuration is Responsibility of the Client + +Client application (such as PWA) is responsible for the UI configuration, either hard-coded or by means of a service. + +Justification: Storefront service is responsible for providing data, client applications may vary significantly and may just want to hard-code many of the options or take settings from different sources. +Until it is confirmed by the client developers (PWA, AEM, other teams) that UI Configuration API backed by Magento system configuration is necessary, Storefront efforts should not focus on supporting such API. + +UI configuration is not synced from Magento Admin Panel to Storefront. + +#### 3.2. Configuration is Provided by Magento Back Office API + +Rely on current GraphQL API for providing UI Configuration. +No additional Store Front service is created to serve such configuration. +GraphQL entry point can proxy to the Magento Back Office GraphQL for simplicity in API usage. + +Two options are possible, second expands on top of the first one. + +Client holds knowledge about two sources (one for data and one for config) and handles requests. +This can be the first step. + +![Service Configuration - Direct Back Office API](https://app.lucidchart.com/publicSegments/view/aeae7ddc-7a1f-4c94-88aa-2309dca63c05/image.png) + +GraphQL handles requests routing to either Storefront domain service (for data) or to Magento Back Office GraphQL (for UI Config). + +![Service Configuration - GraphQL Proxy](https://app.lucidchart.com/publicSegments/view/775a580e-fdb0-4bda-a532-eef04767396a/image.png) diff --git a/design-documents/storefront/pricing.md b/design-documents/storefront/pricing.md new file mode 100644 index 000000000..826f6d9ec --- /dev/null +++ b/design-documents/storefront/pricing.md @@ -0,0 +1,355 @@ +## Problem statement + +The final price of the product in Magento monolith depends on multiple variables such as current customer group, current +website, qty of items in the shopping cart and current date/time. +Magento monolith calculates all possible permutations of prices in advance and store them in `price index`. These +calculations are expensive and may not be done in reasonable time for large catalogs. + +Few problematic use cases from real merchants: + +- Each customer in the system has unique prices for very few products. Per-customer prices are result of a physical contract with pricing included. +The system has 20,000 customer groups, 10,000 products, promotions are excluded from the system. Magento will generate 200,000,000xCOUNT_OF_WEBSITES records to handle all possible combinations for this merchant. For one website it will consume 17,8 GB of space for data and 22.3 GB for index(40 GB total). Due to external promotion system, prices are synchronized periodically. The synchronization process consumes a lot of resources and time and eventually space. + +- Customer groups are used for many things, but they are also a multiplier for prices +The system has 56 stores, 58 customer groups, 29,000 products, most customer groups are not global and used on one website only. Existing `price index` contains 25,000,000 records. The reindex process takes more than 7 hours. Customer groups are also used for promotions, cms content, product availability, B2B company, tax status and of course pricing. The real count of the prices is 26 times smaller. Potentially, reindex process may take 16 minutes. + +The impact form cases describe above will be doubled(or even tripled) if we introduce a new storefront service with existing index structure inside. Thus, we need some other way to work with prices in storefront. + +## Glossary +- Customer group - allocate each customer to a group and control store behaviour(catalog restrictions, pricing, discounts, B2B, CMS, payments, shipping, etc) according to which group a customer belongs to +- Website, Store View - abstract Magento scopes - https://docs.magento.com/user-guide/configuration/scope.html . Magento modules and third-party extensions may utilize this scope as they want. +- Base price - the initial product price. Different values could be set per website in Magento monolith. +- Tier price - have two meanings `customer-specific price` and `volume based price`. +- Special price - time based price for the product. Original price is strikedout in the UI. (e.g. was ~~$100.00~~, now $99.00) +- Catalog Rule - Magento functionality that allows to apply the same discount to multiple products + + +## Goals + +- Support up to 5,000,000 products and 15,000 customer groups +- Establish efficient sync of prices between Magento monolith and Magento storefront +- Reduce the size of pricing index +- Provide reliable support for personalized prices + +## Solution + +All price dimensions aren't used exclusively for pricing: +multiple `websites` may have the same price, but represent different web domains; `customer group` could represent the company in b2b scenarios or class of customer service; `product` may have different pricing on one website only. +The solution is to reuse data where possible and make separation of dimensions, so `websites` or `customer groups` which are not a part of pricing will not trigger `price index` explosion. + +### Price books + +The `price book`(price list) is a new entity that holds a list of product's prices for a sub-set of catalog. +The `price book` is a non-scoped entity, but it may hold information about linked customers, websites, etc. +Instead of direct lookup in price index by customer_group, website and product, we will detect the customer's price book first, then we will extract two price books from the index: default and current `price book`. +The resulting product price will be the value from current price book if it's exists, otherwise the product price will be extracted from default price book: + +![Price books diagram](pricing/pricebooks.png) + +Guidelines: +* There is no need to resolve price book on each HTTP call. Resolved price book could be stored in JWT during "login" step +and reused for consequent requests. +* In order to minimize "query-time" work, customer should have exactly one resolved price book. + + +### Default price book + +A `default price book` is a predefined system price book that contains `base prices` for __ALL__ products in the system. User-defined `price books` may contain only sub-set of products. +Default `price book` should be used as fallback storage if the price for a specific product doesn't exist in other resolved pricebook. + +Other words, there is always some price for sku in `default price book`. + +### Synchronization with monolith + +One of the goals of `price books` is to speedup reindex process. Existing reindex process lives in the monolith and +prepares the prices for luma storefront exclusively. The data produced by this indexer is useless for the new storefront, +so old indexer should be disabled for the installation which uses the new storefront exclusively. + +The following diagram shows the pricing structure for a given product, website and customer group. It also shows respective +`price books` on the storefront. + +![Pricing structure](pricing/pricing-structure.png) + +- t1 - New product was created. Every product should have some base price, so new product also introduce one price in the system. + - Monolith fires `price_changed` event for new product. Event specifies product_id and default website scope. + - Storefront receives new base price and put it in the default price book +- t2 - New price for customer group was introduced on the monolith side + - Monolith detects change in product price and fires `price_changed` event. Event specifies product_id, specified websites and customer group scope. + - Message broker checks if price book for specified customer group exists on storefront and create new price book if needed + - Message broker assigns product to the new price book and set appropriate price +- t3-t7 + - Monolith detects products matched by the rule and fire `price_changed` events for those products. Event includes information about affected customer groups and websites. "Product by rule" matches are stored in cache for the later use. + - Message broker call monolith for prices of affected products + - Monolith calculates prices based on the cache (product matches) + - Message broker store prices in price book + +Price calculations should be done on the monolith side (see appendix for calculation details). These calculations should +be made in runtime based on raw data from the database. +The critical part is to have very granular price detection mechanism which should be firing `price_changed` events for +specific websites, customer groups, products and sub-products. Example: if product price was changed for single customer +group, only this customer group should be present in event and price calculations must be done also for single customer group only. + +![Integration option 1](pricing/integration-option1.png) + +Pros: +- Simplicity +- Use existing Magento EAV and catalog rule storages + +Cons: +- Hard to reuse `Catalog Rules` functionality with third-party PIMs + +#### Other integration options + +__All calculations in message broker__ + +![Integration option 2](pricing/integration-option2.png) + +Pros: +- Easy to integrate `Catalog Rule` functionality with third-party PIMs + +Cons: +- Stateful message broker (includes EAV data, catalog rules and matched product cache) +- One more copy of catalog will take some system resources +- Dependencies between asynchronous tasks in message broker. Not necessary a bad thing, but definitely introduces additional complexity + +__All calculations in storefront__ + +![Integration option 3](pricing/integration-option3.png) + +Pros: +- Storefront will handle `cart rule` functionality, so probably we may reuse the same services for catalog rules. + +Cons: +- Storefront is designed to be lightweight. Additional functionality there may reduce performance of storefront. + +Notes: +- It's possible to isolate catalog rule calculations and move only them on storefront, other calculations could be done in MB + + +### Price book API + +```proto +syntax = "proto3"; + +package magento.pricing.api; + +// Creates a new price book +// All fields are required. +// Throws invalid argument error if some argument is missing +message PriceBookInput { + // Client side generated price book ID + string id = 1; + + // Price book name (e.g. "10% off on selected products") + string name = 2; + + // Customer groups associated with price book + // A combination of customer group and website must be unique. Error will be returned in case when combination is + // already occupied by another price book. + repeated string customer_groups = 3; + + // Websites associated with price book + // A combination of customer group and website must be unique. Error will be returned in case when combination is + // already occupied by another price book. + repeated string websites = 4; +} + +message PriceBookDeleteInput { + string id = 1; +} + +message AssignProductsInput { + message ProductPriceInput { + string product_id = 1; + float price = 2; + float regular_price = 3; + } + repeated ProductPriceInput prices = 1; +} + +message UnassignProducts { + repeated string product_ids = 1; +} + +message PriceBookCreateResult { + int32 status = 1; +} + +message PriceBookDeleteResult { + int32 status = 1; +} + +message PriceBookAssignProductsResult { + int32 status = 1; +} + +message PriceBookUnassignProductsResult { + int32 status = 1; +} + + +message GetPricesInput { + string price_book_id = 1; + repeated string product_ids = 2; +} + +message GetPricesOutput { + message ProductPrice { + string product_id = 2; + // Price without applied discounts + float regular_price = 3; + // Price with applied discounts + float price = 4; + float minimum_price = 5; + float maximum_price = 6; + } + + repeated ProductPrice prices = 1; +} + + +service PriceBook { + rpc create(PriceBookInput) returns (PriceBookCreateResult); + rpc delete(PriceBookDeleteInput) returns (PriceBookDeleteResult); + rpc assignProducts(PriceBookInput) returns (PriceBookAssignProductsResult); + rpc unassignProducts(UnassignProducts) returns (PriceBookUnassignProductsResult); + rpc getPrices(GetPricesInput) returns (GetPricesOutput); +} +``` + +### Customer tags instead of customer groups + +Magento monolith uses `customer groups` for customer segmentation globally. There is one-to-many relation between customer groups and customers, +so customer could be a member of excatly one group only. However, in modern world, each customer could be a +member of different groups based on current behavior. For example, pricing system works with wholesale and regular buyers, +but recommendation system works with different groups of customers which are based on gender, age, ML-generated groups, etc. + +In order to provide more flexibility in customer segmentation, we may introduce many-to-many. Also, having `customer groups` +which are not bound to pricing functionality make them looks like a regular tags. Thus, we may also rename them to tags: + +![Customer tags](pricing/customer-tags.png) + +### Complex products support + +The `minimum prices` of complex products calculated based on variation's prices, variation's availability and variation's stock. +Having variations as a separate products makes `minimum price` and `maximum price` dependent on products which may not +be visible for the current group or in a current catalog. Example: configurable product contains variation #1 - price $10, + 2 - price $9 and 3 - price $12. Let's imagine that variation #2 is visible for people with "VIP access" only, +then the desired `minimum price` of configurable product for basic access will be $10, for "VIP access" - $9. However, there is +only one price book in the system, so we can hold only one value. + +This happens because parent product and variation are separate products which could be assigned to different access lists +and price books. In order to mitigate this issue products should be isolated, so product options fully define complex products. + +The case from example above could be handled by two independent configurable products with different sets of variations +for different access lists. + +Details will be provided in the separate proposal. + +### Prices fallback + +A most efficient way to work with prices on catalog scenarios is to create a prices' projection in `catalog` service. This +way we can retrieve prices in one query together with other product's information. + +This approach will work fine till some limits. There are always some limits on data we can handle in any service. The +`catalog` service is not an exception. It's designed to handle a large amount of products, but product itself is not infinite. +The target number of EAV attributes in the product is `300`, the limit of underlying storage is `10,000` attributes per product. +If we move closer to the limits, system may slow down and eventually fail. + +These limits mean that we can't implement `personalized pricing` feature by storing all prices in product documents. +Even a large list of price books will be challenging for such approach. For such extreme use cases we may introduce a +`prices fallback` on a service which is designed to work with the large amount of prices. + +![Price fallback](pricing/pricing-fallback.png) + +Consequences: +- Aggregation functions like facets in search service will not work properly for products with large amount of prices. Only default or limited number of prices will be available for faceting. +- Second request obviously affects performance. A good news, performance will be affected only in queries which fetch products with large amount of prices. Other queries or slices with small products will not be affected. + + +## Scenarios + +#### Simple + +- Admin sets a `base price` for the `simple` product +- `product listing`, `PDP` and `checkout` scenarios contain the `base price` +- Customer or guest are able to buy the product for the `base price` + +#### Customer specific price + +- Admin sets a `base price` for the `simple` product +- Admin sets a `customer-specific price` for the same product +- `product listing`, `PDP` and `checkout` scenarios contain `base price` for guests and `customer-specific price` for selected customer +- Customer is able to buy the product for the `customer-specific price` + +#### Complex product pricing + +- Admin creates a `configurable` product and assign different `base prices` to variations +- `product listing` scenario contains the minimum price of the variations +- `PDP` scenario contains the minimum price of the variation and the price of currently selected variation +- `product listing` scenario contains the price of currently selected variation +- Customer or guest are able to buy the product variation for the price of selected variation + +#### Special prices + +- Admin sets a `base price` for the `simple` product +- Admin sets a `special price` for the same product for the current date +- `product listing`, `PDP` and `checkout` scenarios contain `special price` +- Customers and guests are able to buy the product for the `special price` + +## Appendix + +### Price calculation algorithm for single website and customer group +```php +getSpecialPriceFrom >= $time && $product->getSpecialPriceTo <= $time) + ? $product->getSpecialPrice + : 0; + + $fixedPrice = min($product->getBasePrice(), $specialPrice); + + $groupPrice = $product->getGroupPrice($customerGroup); + if ($groupPrice) { + $fixedPrice = ($groupPrice->getType() == 'percentage') + ? $product->getBasePrice() - $product->getBasePrice() / 100 * $groupPrice->getValue() + : $groupPrice->getValue; + + } + return $fixedPrice; +}; + + +$fixedPrices = array_map($fixedCalculator, $product->getChildren()); +$fixedPrices[] = $fixedCalculator($product); + +$minFixedPrice = min($fixedPrices); + +// DISCOUNT CALCULATIONS. WE ASSUME THAT ALL RULES ALREADY RESOLVED AND WE HAVE A LIST OF RULES PER PRODUCT +$discountCalculator = function ($product) use ($customerGroup, $time) { + $discountedPrice = $product->getBasePrice(); + foreach ($product->getRules()->sortByPriority() as $rule) { + if ($rule->getDateFrom >= $time && $rule->getDateTo <= $time) { + $discountedPrice = $rule->getDiscount()->getType() == 'percentage' + ? $discountedPrice - $discountedPrice / 100 * $rule->getDiscount()->getValue() + : $discountedPrice - $rule->getDiscount()->getValue(); + + if ($rule->hasStopConsequentRules) { + break; + } + } + } + return $discountedPrice; +}; + +$discountedPrices = array_map($discountCalculator, $product->getChildren()); +$discountedPrices[] = $discountCalculator($product); +$minDiscountedPrice = min($discountedPrices); + +$finalPrice = min($minFixedPrice, $minDiscountedPrice); +``` + diff --git a/design-documents/storefront/pricing/customer-tags.png b/design-documents/storefront/pricing/customer-tags.png new file mode 100644 index 000000000..12eed5480 Binary files /dev/null and b/design-documents/storefront/pricing/customer-tags.png differ diff --git a/design-documents/storefront/pricing/integration-option1.png b/design-documents/storefront/pricing/integration-option1.png new file mode 100644 index 000000000..042e7c6a1 Binary files /dev/null and b/design-documents/storefront/pricing/integration-option1.png differ diff --git a/design-documents/storefront/pricing/integration-option2.png b/design-documents/storefront/pricing/integration-option2.png new file mode 100644 index 000000000..2c5323cd6 Binary files /dev/null and b/design-documents/storefront/pricing/integration-option2.png differ diff --git a/design-documents/storefront/pricing/integration-option3.png b/design-documents/storefront/pricing/integration-option3.png new file mode 100644 index 000000000..2ec5e634b Binary files /dev/null and b/design-documents/storefront/pricing/integration-option3.png differ diff --git a/design-documents/storefront/pricing/pricebooks.png b/design-documents/storefront/pricing/pricebooks.png new file mode 100644 index 000000000..c01754be6 Binary files /dev/null and b/design-documents/storefront/pricing/pricebooks.png differ diff --git a/design-documents/storefront/pricing/pricing-fallback.png b/design-documents/storefront/pricing/pricing-fallback.png new file mode 100644 index 000000000..22a6381ab Binary files /dev/null and b/design-documents/storefront/pricing/pricing-fallback.png differ diff --git a/design-documents/storefront/pricing/pricing-structure.png b/design-documents/storefront/pricing/pricing-structure.png new file mode 100644 index 000000000..0c5d8693d Binary files /dev/null and b/design-documents/storefront/pricing/pricing-structure.png differ diff --git a/design-documents/testing/functional/versioning-and-backward-compatibility-policy.md b/design-documents/testing/functional/versioning-and-backward-compatibility-policy.md index a52710d10..246a2f6c3 100644 --- a/design-documents/testing/functional/versioning-and-backward-compatibility-policy.md +++ b/design-documents/testing/functional/versioning-and-backward-compatibility-policy.md @@ -1,117 +1,4 @@ # Magento MFTF test versioning and backward compatibility policy - -## Goals and requirements -1. Release MFTF tests as a separate magento package on repo.magento.com. -2. Define the versioning strategy for MFTF test packages. -3. Outline what is considered a backward incompatible change to MFTF tests. -4. List of what should be implemented. - -## Backwards compatibility definition for MFTF tests - -When a test undergoes changes, but achieves the same testing results as before and remains compatible with potential test customizations, this is defined as a 'backwards compatible' change. - -Types of changes: - -- **Test Flow change (Test/ActionGroup)** - A backwards compatible modification of a test flow would not diminish the original set of actions in the test. Some changes may change action's sequence (behavior), but they allow any extension to achieve the same test results without changing the test extension (e.g a 'merge file'). -- **Test Entity change (Data/Section/Page/Metadata)** - Compatible modifications of entities are 1) adding new entities or 2) updating a `value` of an existing entity in a way where the test will **NOT** require updates. -- **Test Annotation change** - Annotations can be changed without limitation and will always be considered a backward compatible change, but removing or changing a `` annotation will be considered a backward incompatible change. -- Changes which delete and/or rename a (Test/Action Group/Data/Metadata/Page/Section/Action)'s `id` attribute will be considered a backward incompatible change. Changing a reference to a data entity will also be considered a backward incompatible change. - -## Versioning policy - -The approach of defining what each release should include was taken from [Semantic Versioning](https://semver.org/). - -3-component version numbers ---------------------------- - - X.Y.Z - | | | - | | +-- Backward Compatible changes (bug fixes) - | +---- Backward Compatible changes (new features) - +------ Backward Incompatible changes - -### Z release - -Patch version **Z** MUST be incremented if only backward compatible changes to tests are introduced. -For instance: a fix which aims to resolve test flakiness. This can be done by updating an unreliable selector, adding a `wait` to an element, or updating a data entity value. - -### Y release - -Minor version **Y** MUST be incremented if a new, backwards compatible test or test entity is introduced. -It MUST be incremented if any test or test entity is marked as `deprecated`. -It MAY include patch level changes. Patch version MUST be reset to 0 when the minor version is incremented. - -### X release - -Major version **X** MUST be incremented if any backwards incompatible changes are introduced to a test or test entity. -It MAY include minor and patch level changes. Patch and minor version MUST be reset to 0 when the major version is incremented. - -## Implementation tasks - -1. Add Semantic Version analyzer to be able automatically define the release type of the MFTF tests package. -2. Update publication infrastructure to exclude tests from `magento2-module` package type. -3. Introduce publication functionality for publishing `magento2-test-module` package type. -4. Create a metapackage with test packages specifically for each Magento edition. - -## Version increase matrix - -|Entity Type|Change|Version Increase| -|---|---|---| -|ActionGroup|`` added|MINOR -| |`` removed|MAJOR -| |`` `` added|MINOR -| |`` `` removed|MAJOR -| |`` `` type changed|PATCH -| |`` `` attribute changed|PATCH -| |`` `` with `defaultValue`added|MINOR -| |`` `` without `defaultValue` added|MAJOR -| |`` `` removed|MAJOR -| |`` `` changed|MAJOR -|Data|`` added|MINOR -| |`` removed|MAJOR -| |`` `` added|MINOR -| |`` `` removed|MAJOR -| |`` `` `` removed|PATCH -| |`` `` added|MINOR -| |`` `` removed|MAJOR -| |`` `` added|MAJOR -| |`` `` removed|MAJOR -| |`` `` added|MAJOR -| |`` `` removed|MAJOR -|Metadata|`` added|MINOR -| |`` removed|MAJOR -| |`` changed|MINOR -|Page|`` added|MINOR -| |`` removed|MAJOR -| |`` `
` added|MINOR -| |`` `
` removed|MAJOR -|Section|`
` added|MINOR -| |`
` removed|MAJOR -| |`
` `` added|MINOR -| |`
` `` removed|MAJOR -| |`
` `` `selector` changed|PATCH -| |`
` `` `type` changed|PATCH -| |`
` `` `parameterized` changed|MAJOR -|Test|`` added|MINOR -| |`` removed|MAJOR -| |`` `` added|MINOR -| |`` `` removed|MAJOR -| |`` `` changed|PATCH -| |`` `` sequence changed|MAJOR -| |`` `` type (`click`, `fillField`, etc) changed|PATCH -| |`` `` `ref` changed|MAJOR -| |`` (before/after) `` added|MINOR -| |`` (before/after) `` removed|MAJOR -| |`` (before/after) `` changed|PATCH -| |`` (before/after) `` `ref` changed|MINOR -| |`` (before/after) `` sequence changed|MAJOR -| |`` (before/after) `` type (`click`, `fillField`, etc) changed|PATCH -| |`` (before/after) `` `ref` changed|MAJOR -| |`` `` `` added|PATCH -| |`` `` `` changed|PATCH -| |`` `` `` GROUP removed|MAJOR - ---------------------------- - - ⃰ - `` refers to any of the available [MFTF Actions](https://github.com/magento/magento2-functional-testing-framework/blob/develop/docs/test/actions.md). +Refer to: +https://devdocs.magento.com/guides/v2.4/extension-dev-guide/versioning/mftf-tests-codebase-changes.html