Skip to content
This repository was archived by the owner on Oct 20, 2023. It is now read-only.

Commit 90dfe05

Browse files
authoredFeb 20, 2019
@shopify/theme-product-form (#70)
* Add theme-product-form.js * Revert changes to theme-product * Add getUrlWithVariant method * Finish writing tests for current features * Add radio and checkbox compatibility * Update README.md * Update serialize array output * Ignore unused var in tests * Edits based on PR feedback * Add package to root README.md
1 parent 02150d9 commit 90dfe05

13 files changed

+1283
-20
lines changed
 

‎.travis.yml

+7-7
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
language: node_js
22
node_js:
3-
- "8.11.1"
3+
- '8.11.1'
44
cache:
55
yarn: true
66
directories:
7-
- node_modules
7+
- node_modules
88
script:
9-
- yarn bootstrap
10-
- yarn lint
11-
- yarn test
9+
- yarn bootstrap
10+
- yarn lint
11+
- yarn test
1212
notifications:
1313
email: false
1414
slack:
1515
secure: RvPL7N56A4CGjVaMdqm2Mcz9pPum7qDr1sn8lvSQvZdz0gHCA4340vhwbC+Ca7BpObE6hGGadsO6FrPVs6PXYQoISm5AW/WkSDmgKKebMxRrHqLrwmtn6Y3/jxNlIWsF5vaz8R/Teq204CGRclUMONfJHw7YbmNt7Qco2OYjU2BpRZo9DZ360nSWYFHa+Ohngh2hMHGWpQVBsYl5tayiy8l8C1/TPKU+Yo5j6rvmzAOviSd0mQtPiwWO5q7exJUJpoX0GKUK4vMFrrBSNdX9Q2LoCNH8RI9br58hHv3s2jeO06rEYV3txVhUViBrNwRY6SNv4Fy0eqFkstXJIXUy7nPVV10lkt/6vd9iRaW5Vansl4TMG4gP5wNRSiMqldxgntXT0geyHGFZezi1sO/wy5JJ7QtPzrLtR/7OpzOHfcHfEI9fvB2GH8/5Vs4vBBKT0rDf30nuhEbGlcvPWMIAnUVAMu6fmp7tdQtmYZC3pBhYAdVbnsddHF0yXcCvSSLUKOUWyxOwIqcjughFmoAzKlrDCl42MX2wohHNJr9ZDhYuu1e2eaVQ8ypg+Tkh6Ss/4lBjHrHFgF+YYoN1rTokA6ChCiTw8r8VEitrHkL/GT1vQKRvHr+f+PbbAfV4FCdLbv/k6OytMnsoPhmAQJ6tAcEbVqgiQouFaKN4Oh3PpeQ=
1616
on_success: change
1717
on_pull_requests: false
1818
template:
19-
- "%{repository_slug} (%{commit}) : %{message}"
20-
- "Build details: %{build_url}"
19+
- '%{repository_slug} (%{commit}) : %{message}'
20+
- 'Build details: %{build_url}'

‎README.md

+13-11
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,19 @@ Theme Scripts is a collection of handy utility libraries which assist theme deve
88
Theme scripts are uncoupled from a particular UI. Typically, they are used alongside a customized solution for a particular theme. For example, `@shopify/theme-cart` is a great way to interact with the Shopify Cart API and add and remove items, but it does not enforce a particular pattern to display or update the visual state of the cart.
99

1010
## Packages
11-
| package | | |
12-
| ------- | --- | --- |
13-
| theme-a11y | [README](packages/theme-a11y/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-a11y.svg)
14-
| theme-addresses | [README](packages/theme-addresses/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-addresses.svg)
15-
| theme-cart | [README](packages/theme-cart/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-cart.svg)
16-
| theme-currency | [README](packages/theme-currency/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-currency.svg)
17-
| theme-images | [README](packages/theme-images/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-images.svg)
18-
| theme-product | [README](packages/theme-product/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-product.svg)
19-
| theme-rte | [README](packages/theme-rte/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-rte.svg)
20-
| theme-sections | [README](packages/theme-sections/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-sections.svg)
21-
| theme-variants | [README](packages/theme-variants/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-variants.svg)
11+
12+
| package | | |
13+
| ------------------ | ----------------------------------------------- | ---------------------------------------------------------------------------- |
14+
| theme-a11y | [README](packages/theme-a11y/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-a11y.svg) |
15+
| theme-addresses | [README](packages/theme-addresses/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-addresses.svg) |
16+
| theme-cart | [README](packages/theme-cart/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-cart.svg) |
17+
| theme-currency | [README](packages/theme-currency/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-currency.svg) |
18+
| theme-images | [README](packages/theme-images/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-images.svg) |
19+
| theme-product | [README](packages/theme-product/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-product.svg) |
20+
| theme-product-form | [README](packages/theme-product-form/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-product-form.svg) |
21+
| theme-rte | [README](packages/theme-rte/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-rte.svg) |
22+
| theme-sections | [README](packages/theme-sections/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-sections.svg) |
23+
| theme-variants | [README](packages/theme-variants/README.md) | ![npm version](https://badge.fury.io/js/%40shopify%2Ftheme-variants.svg) |
2224

2325
## Contributing
2426

‎package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"main": "index.js",
77
"scripts": {
88
"test": "jest",
9-
"bootstrap": "yarn && lerna bootstrap",
9+
"bootstrap": "yarn && lerna bootstrap && lerna run build",
1010
"build": "lerna run build",
1111
"publish": "lerna publish",
1212
"lint": "yarn eslint ./"

‎packages/theme-product-form/README.md

+269
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
# @shopify/theme-product-form
2+
3+
Theme Product Form helps theme developers create and manage the state of their product forms. The library is decoupled from any rendering logic, allowing it to be used across any number of rendering engines, e.g. Liquid, VanillaJS, Handlebars, React, Vue.js, etc.
4+
5+
## Browser Support
6+
7+
This library is compatible with the following browsers:
8+
9+
| Chrome | Edge | Firefox | IE | Opera | Safari |
10+
| ------ | ---- | ------- | --- | ----- | ------ |
11+
|||| 11 |||
12+
13+
## Getting Started
14+
15+
Theme Scripts can be used in any theme project. To take advantage of the benefits of semantic versioning and easy updates, we recommend using NPM or Yarn to include them in your project:
16+
17+
```
18+
yarn add @shopify/theme-product-form
19+
```
20+
21+
or
22+
23+
```
24+
npm install --save @shopify/theme-product-from
25+
```
26+
27+
and then import the functions you wish to use through ES6 imports:
28+
29+
```
30+
import {ProductForm} from '@shopify/theme-product-form'
31+
```
32+
33+
If you prefer not to use a package manager, you can download the latest version of Theme Product Form and include it in your project manually from the following links:
34+
35+
- [theme-product-form.js](http://unpkg.com/@shopify/theme-product-form@latest/dist/theme-product-form.js)
36+
- [theme-product-form.min.js](http://unpkg.com/@shopify/theme-product-form@latest/dist/theme-product-form.min.js)
37+
38+
## Methods
39+
40+
### ProductForm(formElement, product, options)
41+
42+
Creates a new instance of a product form controller. This controller binds itself to form inputs and fires optional callback functions whenever the product form state changes.
43+
44+
A basic product form example in Liquid that is compatible with `ProductForm` would look something like the following:
45+
46+
```html
47+
{% form 'product', product, data-product-form: '', data-product-handle: product.handle %}
48+
{% unless product.has_only_default_variant %}
49+
{% for option in product.options_with_values %}
50+
<div class="js-enabled">
51+
52+
<label for="Option{{ option.position }}">
53+
{{ option.name }}
54+
</label>
55+
56+
{% comment %}
57+
All inputs that have `name=options[Name]` will be picked up by
58+
ProductForm, registered as an option input, and made available
59+
at ProductForm.optionsInputs
60+
{% comment %}
61+
62+
<select
63+
id="Option{{ option.position }}"
64+
name="options[{{ option.name | escape }}]">
65+
{% for value in option.values %}
66+
<option
67+
value="{{ value | escape }}"
68+
{% if option.selected_value == value %}selected="selected"{% endif %}>
69+
{{ value }}
70+
</option>
71+
{% endfor %}
72+
</select>
73+
</div>
74+
{% endfor %}
75+
{% endunless %}
76+
77+
{% comment %}
78+
In order for this form to submit, it needs to contain an input with name="id".
79+
ProductForm() will automatically create this input (or make sure it has the
80+
right value set if it already exists) on form submit based on the
81+
currently selected variant. However, if JS is disabled we need a fallback.
82+
83+
Include a single <select> element which allows users to select all variants
84+
as a fallback and present it only when JS is disabled. In addition,
85+
make sure you hide the option inputs declared above, like we do with the
86+
`.js-enabled` class which only shows its contents when JS is enabled.
87+
{% endcomment %}
88+
<noscript>
89+
<select name="id">
90+
{% for variant in product.variants %}
91+
<option
92+
{% if variant == current_variant %}selected="selected"{% endif %}
93+
{% unless variant.available %}disabled="disabled"{% endunless %}
94+
value="{{ variant.id }}">
95+
{{ variant.title }}
96+
</option>
97+
{% endfor %}
98+
</select>
99+
</noscript>
100+
101+
{% comment %}
102+
Any input with `name="quantity"` will be picked up by ProductForm and
103+
registered as a quantity input. If a quantity input is not included, a
104+
default quantity of 1 is assumed.
105+
{% endcomment %}
106+
<label for="Quantity">{{ 'products.product.quantity' | t }}</label>
107+
<input type="number" id="Quantity" name="quantity" value="1" min="1">
108+
109+
{% comment %}
110+
Line Item property inputs with `name="properties[NAME]"` will be picked up
111+
by ProductForm and registered as a properties input.
112+
{% endcomment %}
113+
<label for="Details">{{ 'products.product.details' | t }}</label>
114+
<textarea id="Details" name="properties[Details]"></textarea>
115+
116+
<button
117+
type="submit"
118+
{% unless current_variant.available %}disabled="disabled"{% endunless %}>
119+
{{ 'products.product.add_to_cart' | t }}
120+
</button>
121+
122+
{% comment %}
123+
Don't forget about the Dynamic Checkout buttons!
124+
https://help.shopify.com/en/themes/customization/store/dynamic-checkout-buttons
125+
{% endcomment %}
126+
{{ form | payment_button }}
127+
{% endform %}
128+
```
129+
130+
To create a new instance of a product form controller, include the following in your theme:
131+
132+
```js
133+
import { ProductForm } from '@shopify/theme-product-form';
134+
135+
const formElement = document.querySelector('[data-product-form]');
136+
const productHandle = formElement.dataset.productHandle;
137+
138+
// Fetch the product data from the .js endpoint because it includes
139+
// more data than the .json endpoint. Alternatively, you could inline the output
140+
// of {{ product | json }} inside a <script> tag, with the downside that the
141+
// data can never be cached by the browser.
142+
//
143+
// You will need to polyfill `fetch()` if you want to support IE11
144+
fetch(`/products/${productHandle}.js`)
145+
.then(response => {
146+
return response.json();
147+
})
148+
.then(productJSON => {
149+
const productForm = new ProductForm(formElement, productJSON, {
150+
onOptionChange
151+
});
152+
});
153+
154+
// This function is called whenever the user changes the value of an option input
155+
function onOptionChange(event) {
156+
const variant = event.dataset.variant;
157+
158+
if (variant === null) {
159+
// The combination of selected options does not have a matching variant
160+
} else if (variant && !variant.available) {
161+
// The combination of selected options has a matching variant but it is
162+
// currently unavailable
163+
} else if (variant && variant.available) {
164+
// The combination of selected options has a matching variant and it is
165+
// available
166+
}
167+
}
168+
```
169+
170+
#### options
171+
172+
The third argument that can be passed to `ProductForm()` is an options object.
173+
174+
The callbacks that can be specified in the options object are as follows:
175+
176+
- _options.onOptionChange:_ A callback method that is fired whenever the user changes the value of an option input. The callback receives the event object described below as an arguement.
177+
- _options.onQuantityChange:_ A callback method that is fired whenever the user changes the value of a quantity input. The callback receives the event object described below as an argument.
178+
- _options.onPropertyChange:_ A callback method that is fired whenever the user changes the value of a property input. The callback receives the event object described below as an argument.
179+
- _options.onFormSubmit:_ A callback method that is fired whenever the user submits the form. The callback receives the event object described below as an argument.
180+
181+
These options include several callback functions which are triggered on specific product form events. These functions receive the event as an argument, and that event includes the following payload:
182+
183+
- _event.dataset.options_: The serialized array of currently selected options returned by ProductForm.options()
184+
- _event.dataset.variant_: The variant object returned by ProductForm.variant()
185+
- _event.dataset.properties_: The serialized array of properties returned by ProductForm.properties()
186+
- _event.dataset.quantity_: The number returned by ProductForm.quantity()
187+
188+
### ProductForm.destroy()
189+
190+
Cleans up the instance of ProductForm and removes all event listeners it assigned. Useful for cleaning things up in the Theme Editor when a section gets unloaded and loaded again after changing a setting.
191+
192+
```js
193+
import {getUrlWithVariant, ProductForm} from '@shopify/theme-product-form';
194+
import {register} from '@shopify/theme-sections';
195+
196+
register('my-section', {
197+
onLoad: () => {
198+
...
199+
this.productForm = new ProductForm(formElement, productJSON, {onQuantityChange: this.onQuantityChange});
200+
...
201+
},
202+
203+
onUnload: () => {
204+
this.productForm.destroy();
205+
},
206+
207+
onQuantityChange: (event) => {
208+
// code to run whenever the product quantity is updated
209+
}
210+
})
211+
```
212+
213+
### ProductForm.options()
214+
215+
Getter that returns a serialized array of names and values of option inputs in the form.
216+
217+
```js
218+
const productForm = new ProductForm(formElement, productJSON);
219+
const currentOptions = productForm.options(); // [{name: 'First Name', value: 'Tobi'}, ...]
220+
```
221+
222+
### ProductForm.variant()
223+
224+
Getter that returns the variant that matches the currently selected options, or `null` if no match is found.
225+
226+
```js
227+
const productForm = new ProductForm(formElement, productJSON);
228+
const currentVariant = productForm.variant(); // { "id": 20230103745, "title": "Silver / 220 Volts / Small", ... }
229+
```
230+
231+
### ProductForm.properties()
232+
233+
Getter that returns a serialized array of names and values of property inputs in the form.
234+
235+
```js
236+
const productForm = new ProductForm(formElement, productJSON);
237+
const currentProperties = productForm.properties(); // [{name: 'Message', value: 'Hello world'}, ...]
238+
```
239+
240+
### ProductForm.quantity()
241+
242+
Getter that returns the value specified in the quantity input, or 1 if no quantity input exists.
243+
244+
```js
245+
...
246+
const productForm = new ProductForm(formElement, productJSON);
247+
const currentProperties = productForm.quantity(); // 1
248+
```
249+
250+
### getUrlWithVariant(baseUrl, variantId)
251+
252+
Utility function which returns a new URL with a `variant=` query parameter while not affecting other query parameters in the URL. Useful for replacing the browser history with the currently selected variant:
253+
254+
```js
255+
import { getUrlWithVariant, ProductForm } from '@shopify/theme-product-form';
256+
257+
const productForm = new ProductForm(formElement, productJSON, {
258+
onOptionChange
259+
});
260+
261+
function onOptionChange(event) {
262+
const variant = event.dataset.variant;
263+
264+
if (!variant) return;
265+
266+
const url = getUrlWithVariant(window.location.href, variant.id);
267+
window.history.replaceState({ path: url }, '', url);
268+
}
269+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
{
2+
"id": 6438462785,
3+
"title": "Ratio Eight",
4+
"handle": "ratio-eight",
5+
"description": "<h4>Technically speaking.</h4>\n<p><span><strong>Model: </strong>Eight</span><br> <span><strong>Edition: </strong>Walnut</span><br> <span><strong>Warranty:</strong> 10 years</span><br> <span><strong>Capacity:</strong> Brews up to 40oz (eight 5oz cups)</span><br> <span><strong>Dimensions:</strong> 13.5\" deep x 9\" wide x 14\" tall</span></p>\n<p>Each machine is hand assembled and tested in Portland, Oregon. Tht Eight has a powerful 1600 watt heating element in 110 (US &amp; Canada) or 220 volt. Eight's carafe holds Chemex paper filters or Able Kone stainless steel filter.</p>",
6+
"published_at": "2016-05-31T22:11:00-04:00",
7+
"created_at": "2016-05-31T22:26:04-04:00",
8+
"vendor": "tk-narrative",
9+
"type": "",
10+
"tags": [],
11+
"price": 52000,
12+
"price_min": 52000,
13+
"price_max": 60000,
14+
"available": true,
15+
"price_varies": true,
16+
"compare_at_price": 60000,
17+
"compare_at_price_min": 60000,
18+
"compare_at_price_max": 60000,
19+
"compare_at_price_varies": false,
20+
"variants": [
21+
{
22+
"id": 20230103745,
23+
"title": "Silver / 220 Volts / Small",
24+
"option1": "Silver",
25+
"option2": "220 Volts",
26+
"option3": "Small",
27+
"sku": "silver220",
28+
"requires_shipping": true,
29+
"taxable": true,
30+
"featured_image": null,
31+
"available": true,
32+
"name": "Ratio Eight - Silver / 220 Volts / Small",
33+
"public_title": "Silver / 220 Volts / Small",
34+
"options": ["Silver", "220 Volts", "Small"],
35+
"price": 52000,
36+
"weight": 0,
37+
"compare_at_price": 60000,
38+
"inventory_quantity": 1,
39+
"inventory_management": null,
40+
"inventory_policy": "deny",
41+
"barcode": ""
42+
},
43+
{
44+
"id": 20230103873,
45+
"title": "Nickel / 110 Volts / Large",
46+
"option1": "Nickel",
47+
"option2": "110 Volts",
48+
"option3": "Large",
49+
"sku": "nickel110",
50+
"requires_shipping": true,
51+
"taxable": true,
52+
"featured_image": {
53+
"id": 20684752449,
54+
"product_id": 6438462785,
55+
"position": 2,
56+
"created_at": "2017-02-22T07:59:09-05:00",
57+
"updated_at": "2017-11-24T11:07:59-05:00",
58+
"alt": null,
59+
"width": 1280,
60+
"height": 1280,
61+
"src": "https://cdn.shopify.com/s/files/1/1255/2693/products/HERO-M_ChampagneNickel.jpg?v=1511539679",
62+
"variant_ids": [20230103873, 31053240129]
63+
},
64+
"available": true,
65+
"name": "Ratio Eight - Nickel / 110 Volts / Large",
66+
"public_title": "Nickel / 110 Volts / Large",
67+
"options": ["Nickel", "110 Volts", "Large"],
68+
"price": 60000,
69+
"weight": 0,
70+
"compare_at_price": 60000,
71+
"inventory_quantity": 1,
72+
"inventory_management": null,
73+
"inventory_policy": "deny",
74+
"barcode": ""
75+
},
76+
{
77+
"id": 20230104065,
78+
"title": "Cobalt / 110 Volts / Large",
79+
"option1": "Cobalt",
80+
"option2": "110 Volts",
81+
"option3": "Large",
82+
"sku": "cobalt110",
83+
"requires_shipping": true,
84+
"taxable": true,
85+
"featured_image": {
86+
"id": 20684752705,
87+
"product_id": 6438462785,
88+
"position": 3,
89+
"created_at": "2017-02-22T07:59:10-05:00",
90+
"updated_at": "2017-11-24T11:07:59-05:00",
91+
"alt": null,
92+
"width": 1280,
93+
"height": 1280,
94+
"src": "https://cdn.shopify.com/s/files/1/1255/2693/products/HERO-M_DarkCobalt.jpg?v=1511539679",
95+
"variant_ids": [20230104065, 20230104129]
96+
},
97+
"available": true,
98+
"name": "Ratio Eight - Cobalt / 110 Volts / Large",
99+
"public_title": "Cobalt / 110 Volts / Large",
100+
"options": ["Cobalt", "110 Volts", "Large"],
101+
"price": 55000,
102+
"weight": 0,
103+
"compare_at_price": 60000,
104+
"inventory_quantity": 1,
105+
"inventory_management": null,
106+
"inventory_policy": "deny",
107+
"barcode": ""
108+
},
109+
{
110+
"id": 20230104129,
111+
"title": "Cobalt / 220 Volts / Large",
112+
"option1": "Cobalt",
113+
"option2": "220 Volts",
114+
"option3": "Large",
115+
"sku": "cobalt220",
116+
"requires_shipping": true,
117+
"taxable": true,
118+
"featured_image": {
119+
"id": 20684752705,
120+
"product_id": 6438462785,
121+
"position": 3,
122+
"created_at": "2017-02-22T07:59:10-05:00",
123+
"updated_at": "2017-11-24T11:07:59-05:00",
124+
"alt": null,
125+
"width": 1280,
126+
"height": 1280,
127+
"src": "https://cdn.shopify.com/s/files/1/1255/2693/products/HERO-M_DarkCobalt.jpg?v=1511539679",
128+
"variant_ids": [20230104065, 20230104129]
129+
},
130+
"available": true,
131+
"name": "Ratio Eight - Cobalt / 220 Volts / Large",
132+
"public_title": "Cobalt / 220 Volts / Large",
133+
"options": ["Cobalt", "220 Volts", "Large"],
134+
"price": 56000,
135+
"weight": 0,
136+
"compare_at_price": 60000,
137+
"inventory_quantity": 1,
138+
"inventory_management": null,
139+
"inventory_policy": "deny",
140+
"barcode": ""
141+
},
142+
{
143+
"id": 20230104193,
144+
"title": "White / 110 Volts / Large",
145+
"option1": "White",
146+
"option2": "110 Volts",
147+
"option3": "Large",
148+
"sku": "white110",
149+
"requires_shipping": true,
150+
"taxable": true,
151+
"featured_image": {
152+
"id": 20684753025,
153+
"product_id": 6438462785,
154+
"position": 4,
155+
"created_at": "2017-02-22T07:59:12-05:00",
156+
"updated_at": "2017-11-24T11:07:59-05:00",
157+
"alt": null,
158+
"width": 1280,
159+
"height": 1280,
160+
"src": "https://cdn.shopify.com/s/files/1/1255/2693/products/HERO-M_White.png?v=1511539679",
161+
"variant_ids": [20230104193, 20230104321]
162+
},
163+
"available": true,
164+
"name": "Ratio Eight - White / 110 Volts / Large",
165+
"public_title": "White / 110 Volts / Large",
166+
"options": ["White", "110 Volts", "Large"],
167+
"price": 57000,
168+
"weight": 0,
169+
"compare_at_price": 60000,
170+
"inventory_quantity": 1,
171+
"inventory_management": null,
172+
"inventory_policy": "deny",
173+
"barcode": ""
174+
},
175+
{
176+
"id": 20230104321,
177+
"title": "White / 220 Volts / Large",
178+
"option1": "White",
179+
"option2": "220 Volts",
180+
"option3": "Large",
181+
"sku": "white220",
182+
"requires_shipping": true,
183+
"taxable": true,
184+
"featured_image": {
185+
"id": 20684753025,
186+
"product_id": 6438462785,
187+
"position": 4,
188+
"created_at": "2017-02-22T07:59:12-05:00",
189+
"updated_at": "2017-11-24T11:07:59-05:00",
190+
"alt": null,
191+
"width": 1280,
192+
"height": 1280,
193+
"src": "https://cdn.shopify.com/s/files/1/1255/2693/products/HERO-M_White.png?v=1511539679",
194+
"variant_ids": [20230104193, 20230104321]
195+
},
196+
"available": true,
197+
"name": "Ratio Eight - White / 220 Volts / Large",
198+
"public_title": "White / 220 Volts / Large",
199+
"options": ["White", "220 Volts", "Large"],
200+
"price": 58000,
201+
"weight": 0,
202+
"compare_at_price": 60000,
203+
"inventory_quantity": 1,
204+
"inventory_management": null,
205+
"inventory_policy": "deny",
206+
"barcode": ""
207+
},
208+
{
209+
"id": 31053222465,
210+
"title": "Silver / 110 Volts / Large",
211+
"option1": "Silver",
212+
"option2": "110 Volts",
213+
"option3": "Large",
214+
"sku": "silver110",
215+
"requires_shipping": true,
216+
"taxable": true,
217+
"featured_image": null,
218+
"available": true,
219+
"name": "Ratio Eight - Silver / 110 Volts / Large",
220+
"public_title": "Silver / 110 Volts / Large",
221+
"options": ["Silver", "110 Volts", "Large"],
222+
"price": 52000,
223+
"weight": 0,
224+
"compare_at_price": 60000,
225+
"inventory_quantity": 1,
226+
"inventory_management": null,
227+
"inventory_policy": "deny",
228+
"barcode": ""
229+
},
230+
{
231+
"id": 31053240129,
232+
"title": "Nickel / 220 Volts / Large",
233+
"option1": "Nickel",
234+
"option2": "220 Volts",
235+
"option3": "Large",
236+
"sku": "nickel220",
237+
"requires_shipping": true,
238+
"taxable": true,
239+
"featured_image": {
240+
"id": 20684752449,
241+
"product_id": 6438462785,
242+
"position": 2,
243+
"created_at": "2017-02-22T07:59:09-05:00",
244+
"updated_at": "2017-11-24T11:07:59-05:00",
245+
"alt": null,
246+
"width": 1280,
247+
"height": 1280,
248+
"src": "https://cdn.shopify.com/s/files/1/1255/2693/products/HERO-M_ChampagneNickel.jpg?v=1511539679",
249+
"variant_ids": [20230103873, 31053240129]
250+
},
251+
"available": true,
252+
"name": "Ratio Eight - Nickel / 220 Volts / Large",
253+
"public_title": "Nickel / 220 Volts / Large",
254+
"options": ["Nickel", "220 Volts", "Large"],
255+
"price": 53000,
256+
"weight": 0,
257+
"compare_at_price": 60000,
258+
"inventory_quantity": 1,
259+
"inventory_management": null,
260+
"inventory_policy": "deny",
261+
"barcode": ""
262+
}
263+
],
264+
"images": [
265+
"//cdn.shopify.com/s/files/1/1255/2693/products/Slideshow_1.png?v=1511539679",
266+
"//cdn.shopify.com/s/files/1/1255/2693/products/HERO-M_ChampagneNickel.jpg?v=1511539679",
267+
"//cdn.shopify.com/s/files/1/1255/2693/products/HERO-M_DarkCobalt.jpg?v=1511539679",
268+
"//cdn.shopify.com/s/files/1/1255/2693/products/HERO-M_White.png?v=1511539679"
269+
],
270+
"featured_image": "//cdn.shopify.com/s/files/1/1255/2693/products/Slideshow_1.png?v=1511539679",
271+
"options": [
272+
{
273+
"name": "Color",
274+
"position": 1,
275+
"values": ["Silver", "Nickel", "Cobalt", "White"]
276+
},
277+
{
278+
"name": "Voltage",
279+
"position": 2,
280+
"values": ["220 Volts", "110 Volts"]
281+
},
282+
{
283+
"name": "Size",
284+
"position": 3,
285+
"values": ["Small", "Large"]
286+
}
287+
],
288+
"url": "/products/ratio-eight"
289+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
module.exports = {
2+
root: true,
3+
extends: [
4+
'plugin:shopify/esnext',
5+
'plugin:shopify/node',
6+
'plugin:shopify/prettier'
7+
],
8+
env: {
9+
browser: true,
10+
jest: true
11+
},
12+
rules: {
13+
'shopify/strict-component-boundaries': 'off'
14+
}
15+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
import Listeners from '../listeners';
6+
7+
describe('Listeners()', () => {
8+
test('is a constructor', () => {
9+
const listeners = new Listeners();
10+
11+
expect(listeners).toBeInstanceOf(Listeners);
12+
});
13+
});
14+
15+
describe('Listeners.add()', () => {
16+
test('add an event listener to the designated element', () => {
17+
document.body.innerHTML = '<button>A Button</button>';
18+
19+
const element = document.querySelector('button');
20+
const listeners = new Listeners();
21+
const listener = jest.fn();
22+
23+
listeners.add(element, 'click', listener);
24+
25+
element.click();
26+
27+
expect(listener).toHaveBeenCalledTimes(1);
28+
});
29+
30+
test('adds the listener to Listeners.entries', () => {
31+
document.body.innerHTML = '<button>A Button</button>';
32+
33+
const element = document.querySelector('button');
34+
const listeners = new Listeners();
35+
const listener = jest.fn();
36+
37+
listeners.add(element, 'click', listener);
38+
39+
expect(listeners.entries[0]).toMatchObject({fn: listener});
40+
});
41+
});
42+
43+
describe('Listeners.removeAll()', () => {
44+
test('removes all event listeners that have been added', () => {
45+
document.body.innerHTML = '<button>A Button</button>';
46+
47+
const element = document.querySelector('button');
48+
const listeners = new Listeners();
49+
const listener = jest.fn();
50+
51+
listeners.add(element, 'click', listener);
52+
element.click();
53+
expect(listener).toHaveBeenCalledTimes(1);
54+
55+
listeners.removeAll();
56+
element.click();
57+
expect(listener).toHaveBeenCalledTimes(1);
58+
expect(listeners.entries.length).toBe(0);
59+
});
60+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
const {getVariantFromSerializedArray} = require('@shopify/theme-product');
6+
const {getUrlWithVariant, ProductForm} = require('../theme-product-form');
7+
const productJSON = require('../__fixtures__/product-object.json');
8+
9+
const defaultVariant = productJSON.variants[0];
10+
const defaultQuantity = 3;
11+
const defaultOptions = [
12+
{name: 'Color', value: 'Silver'},
13+
{name: 'Voltage', value: '220 Volts'},
14+
{name: 'Size', value: 'Small'}
15+
];
16+
const defaultProperties = [
17+
{name: 'Message', value: 'derp'},
18+
{name: 'Hidden', value: 'something'},
19+
{name: 'Subscribe', value: 'true'}
20+
];
21+
22+
beforeEach(() => {
23+
document.body.innerHTML = `
24+
<form id="form">
25+
<select name="options[Color]">
26+
<option value="Silver" selected="selected">Silver</option>
27+
<option value="Nickel">Nickel</option>
28+
<option value="Cobalt">Cobalt</option>
29+
<option value="White">White</option>
30+
</select>
31+
32+
<select name="options[Voltage]">
33+
<option value="220 Volts" selected="selected">220 Volts</option>
34+
<option value="110 Volts">110 Volts</option>
35+
</select>
36+
37+
<input type="radio" name="options[Size]" value="Small" checked />
38+
<input type="radio" name="options[Size]" value="Large" />
39+
40+
<input type="number" name="quantity" value=3 />
41+
42+
<input type="text" name="properties[Message]" value="derp" />
43+
<input type="hidden" name="properties[Hidden]" value="something" />
44+
<input type="checkbox" name="properties[Subscribe]" value="true" checked/>
45+
</form>`;
46+
});
47+
48+
function expectFormEventDataset(
49+
event,
50+
{
51+
options = defaultOptions,
52+
quantity = defaultQuantity,
53+
properties = defaultProperties
54+
}
55+
) {
56+
expect(event.dataset.options).toMatchObject(options);
57+
expect(event.dataset.variant).toMatchObject(
58+
getVariantFromSerializedArray(productJSON, options)
59+
);
60+
expect(event.dataset.properties.length).toBe(properties.length);
61+
expect(event.dataset.properties).toMatchObject(properties);
62+
expect(event.dataset.quantity).toBe(quantity);
63+
}
64+
65+
describe('getUrlWithVariant()', () => {
66+
test('is a function exported by theme-product.js', () => {
67+
expect(typeof getUrlWithVariant).toBe('function');
68+
});
69+
70+
test('adds the "variant" query parameter if it does not exist in the URL', () => {
71+
expect(getUrlWithVariant('https://shop1.myshopify.com', 12345678)).toBe(
72+
'https://shop1.myshopify.com?variant=12345678'
73+
);
74+
});
75+
76+
test('replaces the value of the "variant" query parameter if it does already exist in the URL', () => {
77+
expect(
78+
getUrlWithVariant(
79+
'https://shop1.myshopify.com?variant=12345678',
80+
87654321
81+
)
82+
).toBe('https://shop1.myshopify.com?variant=87654321');
83+
});
84+
85+
test('only modifies the query parameter with the "variant" key', () => {
86+
expect(
87+
getUrlWithVariant(
88+
'https://shop1.myshopify.com?variant=12345678&id=12345678',
89+
87654321
90+
)
91+
).toBe('https://shop1.myshopify.com?variant=87654321&id=12345678');
92+
expect(
93+
getUrlWithVariant(
94+
'https://shop1.myshopify.com?id=12345678&variant=12345678',
95+
87654321
96+
)
97+
).toBe('https://shop1.myshopify.com?id=12345678&variant=87654321');
98+
expect(
99+
getUrlWithVariant('https://shop1.myshopify.com?id=12345678', 87654321)
100+
).toBe('https://shop1.myshopify.com?id=12345678&variant=87654321');
101+
});
102+
});
103+
104+
describe('ProductForm()', () => {
105+
test('is a constructor', () => {
106+
const element = document.getElementById('form');
107+
const productForm = new ProductForm(element, productJSON);
108+
109+
expect(productForm).toBeInstanceOf(ProductForm);
110+
});
111+
112+
test('throws an error if the first argument is not a DOM element', () => {
113+
expect(() => new ProductForm(null, productJSON)).toThrowError(TypeError);
114+
});
115+
116+
test('throws an error if the second argument is not a valid product object', () => {
117+
const element = document.getElementById('form');
118+
expect(() => new ProductForm(element)).toThrowError(TypeError);
119+
});
120+
121+
test('assigns the form element that was passed as the first argument to .element', () => {
122+
const element = document.getElementById('form');
123+
const productForm = new ProductForm(element, productJSON);
124+
125+
expect(productForm.element).toBe(element);
126+
});
127+
128+
test('assigns the product object that is passed as the second argument to .element', () => {
129+
const element = document.getElementById('form');
130+
const productForm = new ProductForm(element, productJSON);
131+
132+
expect(productForm.element).toBe(element);
133+
});
134+
135+
test('assigns all option inputs which have a name attribute that starts with "options" to .optionInputs', () => {
136+
const element = document.getElementById('form');
137+
const productForm = new ProductForm(element, productJSON);
138+
139+
expect(productForm.optionInputs.length).toBe(4);
140+
});
141+
142+
test('assign all quantity inputs which have the name attribute equal to "quantity" to .quantityInputs', () => {
143+
const element = document.getElementById('form');
144+
const productForm = new ProductForm(element, productJSON);
145+
146+
expect(productForm.quantityInputs.length).toBe(1);
147+
});
148+
149+
test('assign all property inputs which have the name attribute that starts with "properties" to .propertyInputs', () => {
150+
const element = document.getElementById('form');
151+
const productForm = new ProductForm(element, productJSON);
152+
153+
expect(productForm.propertyInputs.length).toBe(defaultProperties.length);
154+
});
155+
156+
test('calls the method assigned to the onOptionChange option when the value of an option input changes', () => {
157+
const element = document.getElementById('form');
158+
const colorSelect = element.querySelector('[name="options[Color]"]');
159+
const sizeSelects = element.querySelectorAll('[name="options[Size]"]');
160+
const changeEvent = new Event('change');
161+
const config = {
162+
onOptionChange: jest.fn()
163+
};
164+
const options = [
165+
{name: 'Color', value: 'Cobalt'},
166+
{name: 'Voltage', value: '220 Volts'},
167+
{name: 'Size', value: 'Large'}
168+
];
169+
170+
// eslint-disable-next-line no-unused-vars
171+
const productForm = new ProductForm(element, productJSON, config);
172+
173+
sizeSelects[0].removeAttribute('checked');
174+
sizeSelects[1].setAttribute('checked', true);
175+
colorSelect.value = options[0].value;
176+
colorSelect.dispatchEvent(changeEvent);
177+
sizeSelects[1].dispatchEvent(changeEvent);
178+
179+
expect(config.onOptionChange).toHaveBeenCalledWith(changeEvent);
180+
expectFormEventDataset(changeEvent, {options});
181+
});
182+
183+
test('calls the method assigned to the onQuantityChange option when the value of a quantity input changes', () => {
184+
const element = document.getElementById('form');
185+
const quantityElement = element.querySelector('[name="quantity"]');
186+
const changeEvent = new Event('change');
187+
const config = {
188+
onQuantityChange: jest.fn()
189+
};
190+
const quantity = 10;
191+
192+
// eslint-disable-next-line no-unused-vars
193+
const productForm = new ProductForm(element, productJSON, config);
194+
195+
quantityElement.value = quantity;
196+
quantityElement.dispatchEvent(changeEvent);
197+
198+
expect(config.onQuantityChange).toHaveBeenCalledWith(changeEvent);
199+
expectFormEventDataset(changeEvent, {quantity});
200+
});
201+
202+
test('calls the method assigned to the onPropertyChange option when the value of a property input changes', () => {
203+
const element = document.getElementById('form');
204+
const propertyElement = element.querySelector(
205+
'[name="properties[Message]"]'
206+
);
207+
const changeEvent = new Event('change');
208+
const config = {
209+
onPropertyChange: jest.fn()
210+
};
211+
const properties = [
212+
{name: 'Message', value: 'doh'},
213+
{name: 'Hidden', value: 'something'},
214+
{name: 'Subscribe', value: 'true'}
215+
];
216+
217+
// eslint-disable-next-line no-unused-vars
218+
const productForm = new ProductForm(element, productJSON, config);
219+
220+
propertyElement.value = properties[0].value;
221+
propertyElement.dispatchEvent(changeEvent);
222+
223+
expect(config.onPropertyChange).toHaveBeenCalledWith(changeEvent);
224+
expectFormEventDataset(changeEvent, {properties});
225+
});
226+
227+
test('calls the method assigned to the onFormSubmit option when the form is submitted', () => {
228+
const element = document.getElementById('form');
229+
const submitEvent = new Event('submit');
230+
const config = {
231+
onFormSubmit: jest.fn()
232+
};
233+
234+
const productForm = new ProductForm(element, productJSON, config);
235+
236+
productForm.element.dispatchEvent(submitEvent);
237+
238+
expect(config.onFormSubmit).toHaveBeenCalledWith(submitEvent);
239+
expectFormEventDataset(submitEvent, {});
240+
});
241+
});
242+
243+
describe('ProductForm.destroy()', () => {
244+
test('removes all event listeners that were assigned by ProductForm()', () => {
245+
const element = document.getElementById('form');
246+
const colorSelect = element.querySelector('[name="options[Color]"]');
247+
const quantityElement = element.querySelector('[name="quantity"]');
248+
const propertyElement = element.querySelector('[name^="properties"]');
249+
250+
const changeEvent = new Event('change');
251+
const submitEvent = new Event('submit');
252+
253+
const config = {
254+
onOptionChange: jest.fn(),
255+
onQuantityChange: jest.fn(),
256+
onPropertyChange: jest.fn(),
257+
onFormSubmit: jest.fn()
258+
};
259+
260+
const productForm = new ProductForm(element, productJSON, config);
261+
262+
productForm.destroy();
263+
264+
colorSelect.dispatchEvent(changeEvent);
265+
quantityElement.dispatchEvent(changeEvent);
266+
propertyElement.dispatchEvent(changeEvent);
267+
productForm.element.dispatchEvent(submitEvent);
268+
269+
expect(config.onOptionChange).not.toHaveBeenCalled();
270+
expect(config.onQuantityChange).not.toHaveBeenCalled();
271+
expect(config.onPropertyChange).not.toHaveBeenCalled();
272+
expect(config.onFormSubmit).not.toHaveBeenCalled();
273+
});
274+
});
275+
276+
describe('ProductForm.options()', () => {
277+
test('returns an ordered array of currently selected option values', () => {
278+
const element = document.getElementById('form');
279+
const productForm = new ProductForm(element, productJSON);
280+
281+
expect(productForm.options()).toMatchObject(defaultOptions);
282+
});
283+
});
284+
285+
describe('ProductForm.variant()', () => {
286+
test('returns the current variant of the form', () => {
287+
const element = document.getElementById('form');
288+
const productForm = new ProductForm(element, productJSON);
289+
290+
expect(productForm.variant()).toMatchObject(defaultVariant);
291+
});
292+
293+
test('returns null if the currently selected form options do not have a matching variant', () => {
294+
const element = document.getElementById('form');
295+
const colorSelect = element.querySelector('[name="options[Color]"]');
296+
const productForm = new ProductForm(element, productJSON);
297+
298+
colorSelect.value = 'Cobalt';
299+
300+
expect(productForm.variant()).toBe(null);
301+
});
302+
});
303+
304+
describe('ProductForm.properties()', () => {
305+
test('returns a collection of objects containing the name and value of properties', () => {
306+
const element = document.getElementById('form');
307+
const productForm = new ProductForm(element, productJSON);
308+
309+
expect(productForm.properties()).toMatchObject(defaultProperties);
310+
});
311+
});
312+
313+
describe('ProductForm.quantity()', () => {
314+
test('returns the current quantity specified in the form', () => {
315+
const element = document.getElementById('form');
316+
const productForm = new ProductForm(element, productJSON);
317+
318+
expect(productForm.quantity()).toBe(defaultQuantity);
319+
});
320+
321+
test('returns 1 if no quantity input exists in the form', () => {
322+
document.body.innerHTML = `<form id="form"></form>`;
323+
const element = document.getElementById('form');
324+
const productForm = new ProductForm(element, productJSON);
325+
326+
expect(productForm.quantity()).toBe(1);
327+
});
328+
});
+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export default function Listeners() {
2+
this.entries = [];
3+
}
4+
5+
Listeners.prototype.add = function(element, event, fn) {
6+
this.entries.push({ element: element, event: event, fn: fn });
7+
element.addEventListener(event, fn);
8+
};
9+
10+
Listeners.prototype.removeAll = function() {
11+
this.entries = this.entries.filter(function(listener) {
12+
listener.element.removeEventListener(listener.event, listener.fn);
13+
return false;
14+
});
15+
};
+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"name": "@shopify/theme-product-form",
3+
"version": "1.0.0",
4+
"description": "Theme Product Form helps theme developers create and manage the state of their product forms.",
5+
"main": "dist/theme-product-form.cjs.js",
6+
"module": "theme-product-form.js",
7+
"repository": "https://github.com/Shopify/theme-scripts/tree/master/packages/theme-product-form",
8+
"keywords": [
9+
"slate"
10+
],
11+
"author": "Shopify Inc.",
12+
"license": "MIT",
13+
"bugs": {
14+
"url": "https://github.com/Shopify/theme-scripts/issues"
15+
},
16+
"homepage": "https://github.com/Shopify/theme-scripts#readme",
17+
"scripts": {
18+
"prepublish": "npm run build",
19+
"build": "rollup --config",
20+
"size": "size-limit"
21+
},
22+
"dependencies": {
23+
"@shopify/theme-product": "^1.0.0"
24+
},
25+
"devDependencies": {
26+
"rollup": "^0.62.0",
27+
"rollup-plugin-uglify": "^4.0.0",
28+
"size-limit": "^0.18.3"
29+
},
30+
"size-limit": [
31+
{
32+
"path": "theme-product-form.js"
33+
}
34+
]
35+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { uglify } from 'rollup-plugin-uglify';
2+
3+
export default [
4+
{
5+
input: 'theme-product-form.js',
6+
output: {
7+
file: 'dist/theme-product-form.cjs.js',
8+
format: 'cjs'
9+
}
10+
},
11+
{
12+
input: 'theme-product-form.js',
13+
output: {
14+
file: 'dist/theme-product-form.js',
15+
format: 'iife',
16+
name: 'Shopify.theme.productForm'
17+
}
18+
},
19+
{
20+
input: 'theme-product-form.js',
21+
output: {
22+
file: 'dist/theme-product-form.min.js',
23+
format: 'iife',
24+
name: 'Shopify.theme.productForm'
25+
},
26+
plugins: [uglify()]
27+
}
28+
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import Listeners from './listeners';
2+
import { getVariantFromSerializedArray } from '@shopify/theme-product';
3+
4+
var selectors = {
5+
idInput: '[name="id"]',
6+
optionInput: '[name^="options"]',
7+
quantityInput: '[name="quantity"]',
8+
propertyInput: '[name^="properties"]'
9+
};
10+
11+
// Public Methods
12+
// -----------------------------------------------------------------------------
13+
14+
/**
15+
* Returns a URL with a variant ID query parameter. Useful for updating window.history
16+
* with a new URL based on the currently select product variant.
17+
* @param {string} url - The URL you wish to append the variant ID to
18+
* @param {number} id - The variant ID you wish to append to the URL
19+
* @returns {string} - The new url which includes the variant ID query parameter
20+
*/
21+
22+
export function getUrlWithVariant(url, id) {
23+
if (/variant=/.test(url)) {
24+
return url.replace(/(variant=)[^&]+/, '$1' + id);
25+
} else if (/\?/.test(url)) {
26+
return url.concat('&variant=').concat(id);
27+
}
28+
29+
return url.concat('?variant=').concat(id);
30+
}
31+
32+
/**
33+
* Constructor class that creates a new instance of a product form controller.
34+
*
35+
* @param {Element} element - DOM element which is equal to the <form> node wrapping product form inputs
36+
* @param {Object} product - A product object
37+
* @param {Object} options - Optional options object
38+
* @param {Function} options.onOptionChange - Callback for whenever an option input changes
39+
* @param {Function} options.onQuantityChange - Callback for whenever an quantity input changes
40+
* @param {Function} options.onPropertyChange - Callback for whenever a property input changes
41+
* @param {Function} options.onFormSubmit - Callback for whenever the product form is submitted
42+
*/
43+
export function ProductForm(element, product, options) {
44+
this.element = element;
45+
this.product = _validateProductObject(product);
46+
47+
options = options || {};
48+
49+
this._listeners = new Listeners();
50+
this._listeners.add(
51+
this.element,
52+
'submit',
53+
this._onSubmit.bind(this, options)
54+
);
55+
56+
this.optionInputs = this._initInputs(
57+
selectors.optionInput,
58+
options.onOptionChange
59+
);
60+
61+
this.quantityInputs = this._initInputs(
62+
selectors.quantityInput,
63+
options.onQuantityChange
64+
);
65+
66+
this.propertyInputs = this._initInputs(
67+
selectors.propertyInput,
68+
options.onPropertyChange
69+
);
70+
}
71+
72+
/**
73+
* Cleans up all event handlers that were assigned when the Product Form was constructed.
74+
* Useful for use when a section needs to be reloaded in the theme editor.
75+
*/
76+
ProductForm.prototype.destroy = function() {
77+
this._listeners.removeAll();
78+
};
79+
80+
/**
81+
* Getter method which returns the array of currently selected option values
82+
*
83+
* @returns {Array} An array of option values
84+
*/
85+
ProductForm.prototype.options = function() {
86+
return _serializeInputValues(this.optionInputs, function(item) {
87+
var regex = /(?:^(options\[))(.*?)(?:\])/;
88+
item.name = regex.exec(item.name)[2]; // Use just the value between 'options[' and ']'
89+
return item;
90+
});
91+
};
92+
93+
/**
94+
* Getter method which returns the currently selected variant, or `null` if variant
95+
* doesn't exist.
96+
*
97+
* @returns {Object|null} Variant object
98+
*/
99+
ProductForm.prototype.variant = function() {
100+
return getVariantFromSerializedArray(this.product, this.options());
101+
};
102+
103+
/**
104+
* Getter method which returns a collection of objects containing name and values
105+
* of property inputs
106+
*
107+
* @returns {Array} Collection of objects with name and value keys
108+
*/
109+
ProductForm.prototype.properties = function() {
110+
return _serializeInputValues(this.propertyInputs, function(item) {
111+
var regex = /(?:^(properties\[))(.*?)(?:\])/;
112+
item.name = regex.exec(item.name)[2]; // Use just the value between 'properties[' and ']'
113+
return item;
114+
});
115+
};
116+
117+
/**
118+
* Getter method which returns the current quantity or 1 if no quantity input is
119+
* included in the form
120+
*
121+
* @returns {Array} Collection of objects with name and value keys
122+
*/
123+
ProductForm.prototype.quantity = function() {
124+
return this.quantityInputs[0]
125+
? Number.parseInt(this.quantityInputs[0].value, 10)
126+
: 1;
127+
};
128+
129+
// Private Methods
130+
// -----------------------------------------------------------------------------
131+
ProductForm.prototype._setIdInputValue = function(value) {
132+
var idInputElement = this.element.querySelector(selectors.idInput);
133+
134+
if (!idInputElement) {
135+
idInputElement = document.createElement('input');
136+
idInputElement.type = 'hidden';
137+
idInputElement.name = 'id';
138+
this.element.appendChild(idInputElement);
139+
}
140+
141+
idInputElement.value = value.toString();
142+
};
143+
144+
ProductForm.prototype._onSubmit = function(options, event) {
145+
event.dataset = this._getProductFormEventData();
146+
147+
this._setIdInputValue(event.dataset.variant.id);
148+
149+
if (options.onFormSubmit) {
150+
options.onFormSubmit(event);
151+
}
152+
};
153+
154+
ProductForm.prototype._onFormEvent = function(cb) {
155+
if (typeof cb === 'undefined') {
156+
return Function.prototype;
157+
}
158+
159+
return function(event) {
160+
event.dataset = this._getProductFormEventData();
161+
cb(event);
162+
}.bind(this);
163+
};
164+
165+
ProductForm.prototype._initInputs = function(selector, cb) {
166+
var elements = Array.prototype.slice.call(
167+
this.element.querySelectorAll(selector)
168+
);
169+
170+
return elements.map(
171+
function(element) {
172+
this._listeners.add(element, 'change', this._onFormEvent(cb));
173+
return element;
174+
}.bind(this)
175+
);
176+
};
177+
178+
ProductForm.prototype._getProductFormEventData = function() {
179+
return {
180+
options: this.options(),
181+
variant: this.variant(),
182+
properties: this.properties(),
183+
quantity: this.quantity()
184+
};
185+
};
186+
187+
function _serializeInputValues(inputs, transform) {
188+
return inputs.reduce(function(options, input) {
189+
if (
190+
input.checked || // If input is a checked (means type radio or checkbox)
191+
(input.type !== 'radio' && input.type !== 'checkbox') // Or if its any other type of input
192+
) {
193+
options.push(transform({ name: input.name, value: input.value }));
194+
}
195+
196+
return options;
197+
}, []);
198+
}
199+
200+
function _validateProductObject(product) {
201+
if (typeof product !== 'object') {
202+
throw new TypeError(product + ' is not an object.');
203+
}
204+
205+
if (typeof product.variants[0].options === 'undefined') {
206+
throw new TypeError(
207+
'Product object is invalid. Make sure you use the product object that is output from {{ product | json }} or from the http://[your-product-url].js route'
208+
);
209+
}
210+
211+
return product;
212+
}

‎packages/theme-product/theme-product.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
/**
2+
* Returns a product JSON object when passed a product URL
3+
* @param {*} url
4+
*/
5+
export function getProductJson(handle) {
6+
return fetch('/product/' + handle + '.js').then(function(response) {
Has conversations. Original line has conversations.
7+
return response.json();
8+
});
9+
}
10+
111
/**
212
* Find a match in the project JSON (using a ID number) and return the variant (as an Object)
313
* @param {Object} product Product JSON object
@@ -79,7 +89,7 @@ function _createOptionArrayFromOptionCollection(product, collection) {
7989
/**
8090
* Check if the product data is a valid JS object
8191
* Error will be thrown if type is invalid
82-
* @param {Array} product Product JSON object
92+
* @param {object} product Product JSON object
8393
*/
8494
function _validateProductStructure(product) {
8595
if (typeof product !== 'object') {

0 commit comments

Comments
 (0)
This repository has been archived.