The resultできあがり
Nothing on the page changed. What changed is what a line of the cart is made of: { id: 4, quantity: 2 } instead of a copy of the product's name and price.
ページは何も変わっていません。変わったのはカートの行の中身で、商品の名前と価格の写しの代わりに{ id: 4, quantity: 2 }を持ちます。
Change the codeコードを変える
In addToCart, a new line no longer copies the name and the price:
addToCartで、新しい行に名前と価格を写すのをやめます。
} else {
this.cart = this.cart.concat({ id: product.id, quantity: 1 });
}
And three row getters, before "cart.*.lineTotal", put them back:
代わりに、"cart.*.lineTotal"の前に置く3つの行getterがそれを取り戻します。
get "cart.*.product"() {
const id = this["cart.*.id"];
return this.products.find(product => product.id === id);
},
get "cart.*.name"() {
return this["cart.*.product"]?.name ?? "…";
},
get "cart.*.price"() {
return this["cart.*.product"]?.price ?? 0;
},
get "cart.*.lineTotal"() {
The cart template and "cart.*.lineTotal" stay as they are.
カートのテンプレートと"cart.*.lineTotal"はそのままです。
How it worksしくみ
A copy goes stale写しは古くなる
The catalog lives in a file now, and a file changes: a price goes up, a name gets corrected. A line that copied the name and the price keeps the old ones. In the next step the cart outlives the page — a customer may come back days later — so this would really happen. Keep each fact in one place: the catalog knows the products, and the cart knows what the customer chose.
商品一覧はファイルに移り、ファイルは変わります。価格が上がり、名前が直されます。名前と価格を写した行は、古いままです。次のステップでカートはページより長く生きる(お客さんが何日も後に戻ってくるかもしれない)ので、これは実際に起こります。1つの事実は1か所に置きましょう。商品を知っているのは商品一覧で、カートが知っているのはお客さんが選んだものです。
The template can't tellテンプレートには区別がつかない
{{ .name }} in a cart row used to read a field of the line; now it reads the getter "cart.*.name". "cart.*.lineTotal" reads this["cart.*.price"] the same way. Neither changed — the idea of step 8's products getter, one level down: a path is a name for a value, and whether the value is stored or computed is the state's business.
カートの行の{{ .name }}は、行のフィールドを読んでいましたが、今はgetterの"cart.*.name"を読みます。"cart.*.lineTotal"も同じようにthis["cart.*.price"]を読みます。どちらも変わっていません。ステップ8のproductsのgetterと同じ考え方を、1段下で使っています。パスは値の名前で、値が保存されたものか計算されたものかはstateの側の事情です。
A row getter that reads another list別のリストを読む行getter
"cart.*.product" finds the product with the line's id. It reads this.products, so it depends on the catalog: when the catalog arrives or changes, every line looks its product up again. ?. and ?? cover a line whose product isn't known — before the catalog arrives, or after a product is removed from the file — with “…” and a price of 0 instead of an error.
"cart.*.product"は、行のIDを持つ商品を探します。this.productsを読むので、商品一覧に依存します。商品一覧が届いたり変わったりすると、どの行も商品を引き直します。?.と??は、商品が分からない行(商品一覧が届く前や、商品がファイルから消えた後)を、エラーではなく「…」と価格0で受け止めます。
Show the unit price on each line. After the name, add <span>${{ .price|toFixed(2) }} each</span>: .price is the getter you just wrote, read like any other path.
各行に単価を出してみましょう。名前の後に<span>${{ .price|toFixed(2) }} each</span>を加えます。.priceは今書いたgetterで、ほかのパスと同じように読めます。
Common mistakesよくある間違い
Reading through a product that isn't there無い商品の先を読む
Write return this["cart.*.product"].name; without ?. and this step still works: a line can only be added from the catalog, so its product is always found. From the next step on, though, the saved cart is restored before the catalog arrives. Then every line's name fails with Cannot read properties of undefined (reading 'name') in the console. Write the getter for the moment the data isn't there yet.
?.を付けずにreturn this["cart.*.product"].name;と書いても、このステップは動きます。行は商品一覧からしか足せないので、商品は必ず見つかるからです。けれども次のステップからは、保存したカートが商品一覧より先に戻ってきます。そのとき、どの行の名前もコンソールにCannot read properties of undefined (reading 'name')を出して失敗します。getterは、データがまだ無い瞬間のことも考えて書きます。
The whole fileファイル全体
The complete index.html of this step. npx @wcstack/lint index.html reports 0 error(s), 0 warning(s), 0 info for it.
このステップのindex.html全体です。npx @wcstack/lint index.htmlの結果は0 error(s), 0 warning(s), 0 infoです。
Show index.htmlindex.htmlを表示
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Little Roastery</title>
<link rel="stylesheet" href="https://wcstack.github.io/tutorial/shop.css">
<script type="module" src="https://esm.run/@wcstack/state@4/auto"></script>
<script type="module" src="https://esm.run/@wcstack/fetch@4/auto"></script>
</head>
<body>
<wcs-state>
<script type="module">
/** @typedef {{ id: number, name: string, category: string, description: string, price: number, stock: number, icon: string }} Product */
export default {
/** @type {Product[] | null} */
catalog: null,
loading: false,
loadError: null,
reload: false,
cart: [],
query: "",
category: "all",
get products() {
return this.catalog ?? [];
},
get catalogReady() {
return this.catalog !== null;
},
get "products.*.matches"() {
const query = this.query.trim().toLowerCase();
return (this.category === "all" || this["products.*.category"] === this.category) &&
this["products.*.name"].toLowerCase().includes(query);
},
get matchCount() {
return this.$getAll("products.*.matches", []).filter(Boolean).length;
},
get "cart.*.product"() {
const id = this["cart.*.id"];
return this.products.find(product => product.id === id);
},
get "cart.*.name"() {
return this["cart.*.product"]?.name ?? "…";
},
get "cart.*.price"() {
return this["cart.*.product"]?.price ?? 0;
},
get "cart.*.lineTotal"() {
return this["cart.*.price"] * this["cart.*.quantity"];
},
get itemCount() {
return this.$getAll("cart.*.quantity", []).reduce((sum, n) => sum + n, 0);
},
get subtotal() {
return this.$getAll("cart.*.lineTotal", []).reduce((sum, n) => sum + n, 0);
},
get shipping() {
return this.subtotal >= 50 ? 0 : 5;
},
get total() {
return this.subtotal + this.shipping;
},
retry() {
this.reload = true;
},
addToCart(event, index) {
const product = this.products[index];
const line = this.cart.findIndex(item => item.id === product.id);
if (line >= 0) {
this[`cart.${line}.quantity`] += 1;
} else {
this.cart = this.cart.concat({ id: product.id, quantity: 1 });
}
},
increase(event, index) {
this[`cart.${index}.quantity`] += 1;
},
decrease(event, index) {
this[`cart.${index}.quantity`] -= 1;
},
remove(event, index) {
this.cart = this.cart.toSpliced(index, 1);
}
};
</script>
</wcs-state>
<wcs-fetch url="https://wcstack.github.io/tutorial/data/products.json"
data-wcs="value: catalog; loading: loading; error: loadError; trigger: reload"></wcs-fetch>
<header class="shop-header">
<h1>Little Roastery</h1>
<p class="cart-badge">Cart: <span data-wcs="textContent: itemCount"></span></p>
</header>
<p class="status" data-wcs="hidden: loading|not">Loading products…</p>
<p class="status error" data-wcs="hidden: loadError|not">
Couldn't load the products.
<button type="button" data-wcs="onclick: retry">Try again</button>
</p>
<main class="shop" data-wcs="hidden: catalogReady|not">
<section class="catalog">
<h2>Products</h2>
<div class="toolbar">
<input type="search" placeholder="Search products" aria-label="Search products" data-wcs="value: query">
<fieldset class="categories">
<legend class="visually-hidden">Category</legend>
<label><input type="radio" name="category" value="all" data-wcs="radio: category"> All</label>
<label><input type="radio" name="category" value="coffee" data-wcs="radio: category"> Coffee</label>
<label><input type="radio" name="category" value="tea" data-wcs="radio: category"> Tea</label>
<label><input type="radio" name="category" value="tools" data-wcs="radio: category"> Tools</label>
</fieldset>
</div>
<div class="grid">
<template data-wcs="for: products">
<article class="card" data-wcs="hidden: .matches|not; class.sold-out: .stock|eq(0)">
<div class="card-icon">{{ .icon }}</div>
<h3>{{ .name }}</h3>
<p>{{ .description }}</p>
<p class="price">${{ .price|toFixed(2) }}</p>
<p class="badge" data-wcs="hidden: .stock|ne(0)">Sold out</p>
<button type="button" data-wcs="onclick: addToCart; disabled: .stock|eq(0)">Add to cart</button>
</article>
</template>
</div>
<p class="no-results" data-wcs="hidden: matchCount|ne(0)">No products match.</p>
</section>
<aside class="cart">
<h2>Cart</h2>
<template data-wcs="if: cart.length|eq(0)">
<p class="empty">Your cart is empty.</p>
</template>
<template data-wcs="else:">
<ul class="cart-lines">
<template data-wcs="for: cart">
<li>
<span class="line-name">{{ .name }}</span>
<span class="line-total">${{ .lineTotal|toFixed(2) }}</span>
<span class="stepper">
<button type="button" aria-label="Decrease" data-wcs="onclick: decrease; disabled: .quantity|le(1)">−</button>
<span>{{ .quantity }}</span>
<button type="button" aria-label="Increase" data-wcs="onclick: increase">+</button>
</span>
<button type="button" class="remove" data-wcs="onclick: remove">Remove</button>
</li>
</template>
</ul>
<dl class="totals">
<dt>Subtotal</dt><dd>${{ subtotal|toFixed(2) }}</dd>
<dt>Shipping</dt><dd>${{ shipping|toFixed(2) }}</dd>
<dt class="grand">Total</dt><dd class="grand">${{ total|toFixed(2) }}</dd>
</dl>
<p class="hint" data-wcs="hidden: shipping|eq(0)">Free shipping on orders of $50 or more.</p>
</template>
</aside>
</main>
</body>
</html>