Archived
Catering, and a pure Java/Spring site: Thymeleaf front to back #10
@@ -78,6 +78,7 @@ public class AdminCateringController {
|
||||
private Long id;
|
||||
private String label = "";
|
||||
private String price = "";
|
||||
private String serves = "";
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
@@ -87,6 +88,9 @@ public class AdminCateringController {
|
||||
|
||||
public String getPrice() { return price; }
|
||||
public void setPrice(String price) { this.price = price; }
|
||||
|
||||
public String getServes() { return serves; }
|
||||
public void setServes(String serves) { this.serves = serves; }
|
||||
}
|
||||
|
||||
public static class LineForm {
|
||||
@@ -172,7 +176,7 @@ public class AdminCateringController {
|
||||
form.getName(),
|
||||
form.getBlurb(),
|
||||
form.getColumns().stream()
|
||||
.map(c -> new CateringMenu.TierEdit(c.getId(), c.getLabel(), c.getPrice()))
|
||||
.map(c -> new CateringMenu.TierEdit(c.getId(), c.getLabel(), c.getPrice(), c.getServes()))
|
||||
.toList(),
|
||||
form.getLines().stream()
|
||||
.map(l -> new CateringMenu.RowEdit(l.getId(), l.getLabel(), l.getValues()))
|
||||
@@ -302,6 +306,7 @@ public class AdminCateringController {
|
||||
// The price comes back written out ("$24") and goes out again as whatever is left in the box;
|
||||
// Money reads either.
|
||||
column.setPrice(tier.price() == null ? "" : tier.price());
|
||||
column.setServes(tier.serves() == null ? "" : tier.serves());
|
||||
return column;
|
||||
}).collect(Collectors.toCollection(ArrayList::new)));
|
||||
form.setLines(table.rows().stream().map(row -> {
|
||||
|
||||
@@ -34,8 +34,11 @@ public class CateringMenu {
|
||||
this.notes = notes;
|
||||
}
|
||||
|
||||
/** A column. {@code price} is ready to print ("$24"), and null when the column doesn't state one. */
|
||||
public record TierView(Long id, String label, String price) {}
|
||||
/**
|
||||
* A column. {@code price} is ready to print ("$24") and null when the column doesn't state one;
|
||||
* {@code serves} is roughly how many people it feeds, and null when the label already says.
|
||||
*/
|
||||
public record TierView(Long id, String label, String price, String serves) {}
|
||||
|
||||
/** A line, with one entry per column, in column order. */
|
||||
public record RowView(Long id, String label, List<String> values) {}
|
||||
@@ -48,7 +51,7 @@ public class CateringMenu {
|
||||
|
||||
// What the admin screen sends back. A whole table at a time — see CateringPackage#arrange.
|
||||
// `price` is the raw text from the box ("24", "$24.50", ""); Money decides what it means.
|
||||
public record TierEdit(Long id, String label, String price) {}
|
||||
public record TierEdit(Long id, String label, String price, String serves) {}
|
||||
|
||||
public record RowEdit(Long id, String label, List<String> values) {}
|
||||
|
||||
@@ -99,7 +102,7 @@ public class CateringMenu {
|
||||
table.replaceNotes(clean(edit.notes()));
|
||||
table.arrange(
|
||||
orEmpty(edit.tiers()).stream()
|
||||
.map(t -> new CateringPackage.Heading(t.id(), t.label(), t.price()))
|
||||
.map(t -> new CateringPackage.Heading(t.id(), t.label(), t.price(), t.serves()))
|
||||
.toList(),
|
||||
orEmpty(edit.rows()).stream()
|
||||
.map(r -> new CateringPackage.Line(r.id(), r.label(), orEmpty(r.values())))
|
||||
@@ -171,7 +174,7 @@ public class CateringMenu {
|
||||
|
||||
private static PackageView toView(CateringPackage table) {
|
||||
List<TierView> tiers = table.getTiers().stream()
|
||||
.map(t -> new TierView(t.getId(), t.getLabel(), t.getPrice()))
|
||||
.map(t -> new TierView(t.getId(), t.getLabel(), t.getPrice(), t.getServes()))
|
||||
.toList();
|
||||
List<RowView> rows = table.getRows().stream()
|
||||
.map(r -> new RowView(r.getId(), r.getLabel(), r.getValues()))
|
||||
|
||||
@@ -78,7 +78,7 @@ public class CateringPackage extends BaseEntity {
|
||||
* A column as the editor left it — its heading and its price. A null {@code id} is one they just
|
||||
* added. (Named for the heading rather than the column so as not to shadow {@code @Column}.)
|
||||
*/
|
||||
public record Heading(Long id, String label, String price) {}
|
||||
public record Heading(Long id, String label, String price, String serves) {}
|
||||
|
||||
/** A line as the editor left it, with one value per column — blanks included. */
|
||||
public record Line(Long id, String label, List<String> values) {}
|
||||
@@ -136,7 +136,7 @@ public class CateringPackage extends BaseEntity {
|
||||
int columnNumber = 1;
|
||||
for (Heading column : columns) {
|
||||
CateringTier tier = column.id() == null ? new CateringTier(this) : tier(column.id());
|
||||
tier.describe(column.label(), column.price());
|
||||
tier.describe(column.label(), column.price(), column.serves());
|
||||
tier.moveTo(columnNumber++);
|
||||
arrangedTiers.add(tier);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,16 @@ public class CateringTier extends BaseEntity {
|
||||
@Column(name = "price_cents")
|
||||
private Integer priceCents;
|
||||
|
||||
/**
|
||||
* Roughly how many people this column feeds — "About 6–8 people".
|
||||
*
|
||||
* <p>Free text, and optional. A column named "Small" says nothing about how many it feeds, which is
|
||||
* the question anyone ordering for an office actually has; a column named "125 people" has already
|
||||
* answered it and leaves this blank rather than saying it twice.
|
||||
*/
|
||||
@Column(length = 80)
|
||||
private String serves;
|
||||
|
||||
@Column(name = "position", nullable = false)
|
||||
private int position;
|
||||
|
||||
@@ -42,11 +52,16 @@ public class CateringTier extends BaseEntity {
|
||||
this.cateringPackage = cateringPackage;
|
||||
}
|
||||
|
||||
/** @param price as the editor typed it; empty for a column that doesn't state one */
|
||||
void describe(String label, String price) {
|
||||
/**
|
||||
* @param price as the editor typed it; empty for a column that doesn't state one
|
||||
* @param serves roughly how many people, or empty
|
||||
*/
|
||||
void describe(String label, String price, String serves) {
|
||||
this.label = Text.required(label, 120,
|
||||
"Every column needs a heading — a size like \"Large\", or who it feeds like \"15–20 people\".");
|
||||
this.priceCents = Money.cents(price);
|
||||
String trimmed = Text.optional(serves, 80);
|
||||
this.serves = trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
void moveTo(int position) {
|
||||
@@ -66,6 +81,10 @@ public class CateringTier extends BaseEntity {
|
||||
return Money.format(priceCents);
|
||||
}
|
||||
|
||||
public String getServes() {
|
||||
return serves;
|
||||
}
|
||||
|
||||
public int getPosition() {
|
||||
return position;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
-- Two things the spreadsheet never said out loud, and the page was getting wrong.
|
||||
--
|
||||
-- 1. AN OFFICE BOX IS A TOTAL, NOT AN ASSORTMENT. A Small box is twelve items, mixed in sixes from the
|
||||
-- minis — not twelve muffins AND twelve scones AND some cinnamon rolls, which is what three ticked
|
||||
-- lines on a card claimed. The bakery confirmed it: mix them, in sixes, to the size's item count.
|
||||
--
|
||||
-- The fix keeps the shape everything else uses rather than inventing a mode for this one table: the
|
||||
-- alternative goes INTO the line, the way "Cupcakes or sugar cookies" already does in the parties and
|
||||
-- wedding tables. One line, "Any mix of…", and the size's cell carries the count. So the rule for the
|
||||
-- whole page is now sayable in one sentence: lines are things you get (and), and a choice within a
|
||||
-- line is written into its name (or).
|
||||
--
|
||||
-- 2. "SMALL" DOESN'T TELL ANYONE HOW MANY PEOPLE IT FEEDS. The party and wedding tables size themselves
|
||||
-- by head count; the office table sizes itself by box. `serves` gives every column somewhere to say
|
||||
-- it, and the admin gets a box for it. The office numbers below are estimates from the item counts and
|
||||
-- are meant to be corrected by the people who pack the boxes.
|
||||
|
||||
alter table catering_tier add column serves varchar(80);
|
||||
|
||||
-- --- 1. the office box's contents ---------------------------------------------------------------------
|
||||
|
||||
update catering_row r
|
||||
set label = 'Any mix of mini muffins, mini scones and mini cinnamon rolls'
|
||||
from catering_package p
|
||||
where p.id = r.package_id
|
||||
and p.name = 'Office boxes'
|
||||
and r.label = 'Mini muffins';
|
||||
|
||||
update catering_row_value v set value = '12 items'
|
||||
from catering_row r
|
||||
where r.id = v.row_id and r.label like 'Any mix of%' and v.position = 0 and v.value = 'A dozen';
|
||||
update catering_row_value v set value = '18 items'
|
||||
from catering_row r
|
||||
where r.id = v.row_id and r.label like 'Any mix of%' and v.position = 1 and v.value = 'Eighteen';
|
||||
update catering_row_value v set value = '24 items'
|
||||
from catering_row r
|
||||
where r.id = v.row_id and r.label like 'Any mix of%' and v.position = 2 and v.value = 'Two dozen';
|
||||
|
||||
-- The other two lines are now named inside the first one. Guarded on the wording V6 left, so a line the
|
||||
-- bakery has since edited stays and can be tidied by hand.
|
||||
delete from catering_row r
|
||||
using catering_package p
|
||||
where p.id = r.package_id
|
||||
and p.name = 'Office boxes'
|
||||
and r.label in ('Mini scones', 'Mini cinnamon rolls');
|
||||
|
||||
-- "6+6" was the spreadsheet showing how a dozen scones splits by flavour. With one mixed line, that
|
||||
-- belongs in the note, where the bakery's own minimum already lives.
|
||||
update catering_package_note
|
||||
set body = 'Baked in sixes, so a dozen can be six muffins and six scones.'
|
||||
where body = 'Everything is baked in sixes, so each item comes in multiples of six.';
|
||||
|
||||
-- --- 2. how many people each column feeds -------------------------------------------------------------
|
||||
|
||||
update catering_tier t set serves = 'About 6–8 people'
|
||||
from catering_package p
|
||||
where p.id = t.package_id and p.name = 'Office boxes' and t.label = 'Small' and t.serves is null;
|
||||
update catering_tier t set serves = 'About 10–12 people'
|
||||
from catering_package p
|
||||
where p.id = t.package_id and p.name = 'Office boxes' and t.label = 'Medium' and t.serves is null;
|
||||
update catering_tier t set serves = 'About 15–20 people'
|
||||
from catering_package p
|
||||
where p.id = t.package_id and p.name = 'Office boxes' and t.label = 'Large' and t.serves is null;
|
||||
|
||||
-- The party and wedding columns are already named by head count ("15–20 people", "125 people"), so a
|
||||
-- `serves` line under the price would say it twice. Left null on purpose.
|
||||
@@ -47,6 +47,9 @@
|
||||
th:aria-label="|Heading for column ${c.count}|">
|
||||
<input class="field mt-1" th:field="*{columns[__${c.index}__].price}"
|
||||
placeholder="$24 — leave empty to ask" th:aria-label="|Price for column ${c.count}|">
|
||||
<!--/* Optional: only worth filling in when the heading isn't already a head count. */-->
|
||||
<input class="field mt-1" th:field="*{columns[__${c.index}__].serves}"
|
||||
placeholder="Serves — optional" th:aria-label="|How many people column ${c.count} feeds|">
|
||||
<div class="mt-1 flex justify-center gap-1">
|
||||
<button type="submit" name="do" th:value="|move-column:${c.index}:-1|" class="btn-icon"
|
||||
th:disabled="${c.first}" aria-label="Move this column left">←</button>
|
||||
|
||||
@@ -73,6 +73,9 @@
|
||||
<p th:if="${tier.price}" class="mt-2 font-adbhashitha text-4xl text-bakery-900" th:text="${tier.price}">$24</p>
|
||||
<!--/* No price means "ask us" — say so rather than leaving a hole in the card. */-->
|
||||
<p th:unless="${tier.price}" class="mt-2 font-adbhashitha text-2xl text-bakery-700">Ask us</p>
|
||||
<!--/* "Small" doesn't tell anyone how many it feeds. A column whose heading is already a
|
||||
head count leaves this blank rather than saying it twice. */-->
|
||||
<p th:if="${tier.serves}" class="mt-1 text-sm text-bakery-600" th:text="${tier.serves}">About 6–8 people</p>
|
||||
|
||||
<ul class="mt-6 space-y-3 text-bakery-800">
|
||||
<li th:each="row : ${table.rows}" class="flex gap-3">
|
||||
|
||||
@@ -128,7 +128,7 @@ class AdminPagesTest {
|
||||
// Indexed names are what let Spring bind the grid back into the right cells.
|
||||
.andExpect(content().string(containsString("name=\"columns[0].label\"")))
|
||||
.andExpect(content().string(containsString("name=\"lines[0].values[1]\"")))
|
||||
.andExpect(content().string(containsString("value=\"Eighteen\"")))
|
||||
.andExpect(content().string(containsString("value=\"18 items\"")))
|
||||
// The price round-trips as text: it came out "$24" and goes back the same way.
|
||||
.andExpect(content().string(containsString("value=\"$24\"")));
|
||||
}
|
||||
@@ -170,7 +170,7 @@ class AdminPagesTest {
|
||||
|
||||
// And nothing was written: the live page still says what it said.
|
||||
mvc.perform(get("/api/catering"))
|
||||
.andExpect(content().string(containsString("A dozen")));
|
||||
.andExpect(content().string(containsString("12 items")));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -69,12 +69,18 @@ class CateringMenuTest {
|
||||
.containsExactly("Small", "Medium", "Large");
|
||||
assertThat(office.tiers()).extracting(CateringMenu.TierView::price)
|
||||
.containsExactly("$24", "$32", "$40");
|
||||
// One line, not three: a Small box is twelve items mixed in sixes, not twelve of each thing. The
|
||||
// choice is written into the line's name, which is the same rule the other two tables follow.
|
||||
assertThat(office.rows()).extracting(CateringMenu.RowView::label)
|
||||
.containsExactly("Mini muffins", "Mini scones", "Mini cinnamon rolls");
|
||||
assertThat(office.rows().get(0).values()).containsExactly("A dozen", "Eighteen", "Two dozen");
|
||||
assertThat(office.rows().get(1).values().get(2)).isEqualTo("Two dozen, in up to four flavors");
|
||||
.containsExactly("Any mix of mini muffins, mini scones and mini cinnamon rolls");
|
||||
assertThat(office.rows().get(0).values()).containsExactly("12 items", "18 items", "24 items");
|
||||
// "Small" says nothing about how many it feeds; this does.
|
||||
assertThat(office.tiers()).extracting(CateringMenu.TierView::serves)
|
||||
.containsExactly("About 6–8 people", "About 10–12 people", "About 15–20 people");
|
||||
// The party and wedding columns are named by head count already, so they don't repeat it.
|
||||
assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::serves).containsOnlyNulls();
|
||||
assertThat(office.blurb()).contains("morning meeting");
|
||||
assertThat(office.notes()).anySatisfy(note -> assertThat(note).contains("baked in sixes"));
|
||||
assertThat(office.notes()).anySatisfy(note -> assertThat(note).contains("Baked in sixes"));
|
||||
|
||||
assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::label)
|
||||
.containsExactly("15–20 people", "20–30 people", "30–40 people");
|
||||
@@ -129,11 +135,17 @@ class CateringMenuTest {
|
||||
|
||||
@Test
|
||||
void keepsBlankCellsRatherThanCollapsingThem() {
|
||||
// "Mini cinnamon rolls" is named but not quantified in the spreadsheet. Dropping its blanks
|
||||
// would shorten the line and shift everything after it.
|
||||
CateringMenu.RowView rolls = catering.menu().packages().get(0).rows().get(2);
|
||||
assertThat(rolls.label()).isEqualTo("Mini cinnamon rolls");
|
||||
assertThat(rolls.values()).containsExactly("", "", "");
|
||||
// No seeded line is blank across any more, so this uses a table of its own making: a blank cell
|
||||
// still has to survive a save, because dropping one would shorten the line and shift the rest.
|
||||
CateringMenu.PackageView fresh = catering.add("Trays");
|
||||
catering.save(fresh.id(), new CateringMenu.PackageEdit("Trays", null,
|
||||
List.of(new CateringMenu.TierEdit(null, "Small", "10", null),
|
||||
new CateringMenu.TierEdit(null, "Large", "20", null)),
|
||||
List.of(new CateringMenu.RowEdit(null, "Seasonal extras", List.of("", "A dozen"))),
|
||||
List.of()));
|
||||
|
||||
CateringMenu.RowView row = catering.everything().packages().getLast().rows().getFirst();
|
||||
assertThat(row.values()).containsExactly("", "A dozen");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -185,7 +197,7 @@ class CateringMenuTest {
|
||||
CateringMenu.PackageView saved = catering.everything().packages().get(0);
|
||||
assertThat(saved.tiers()).extracting(CateringMenu.TierView::label).containsExactly("Small", "Large");
|
||||
assertThat(saved.tiers()).extracting(CateringMenu.TierView::id).doesNotContain(medium);
|
||||
assertThat(saved.rows().get(0).values()).containsExactly("A dozen", "Two dozen");
|
||||
assertThat(saved.rows().get(0).values()).containsExactly("12 items", "24 items");
|
||||
assertThat(saved.rows()).allSatisfy(row -> assertThat(row.values()).hasSize(2));
|
||||
}
|
||||
|
||||
@@ -201,7 +213,7 @@ class CateringMenuTest {
|
||||
|
||||
assertThatThrownBy(() -> catering.save(office.id(), half))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Mini muffins")
|
||||
.hasMessageContaining("Any mix of mini muffins")
|
||||
.hasMessageContaining("3 entries but the table has 2 columns");
|
||||
}
|
||||
|
||||
@@ -211,7 +223,7 @@ class CateringMenuTest {
|
||||
Long muffins = office.rows().get(0).id();
|
||||
|
||||
List<CateringMenu.RowEdit> rows = new ArrayList<>(asLines(office));
|
||||
rows.set(0, new CateringMenu.RowEdit(muffins, "Mini muffins", List.of("A dozen", "Twenty", "Two dozen")));
|
||||
rows.set(0, new CateringMenu.RowEdit(muffins, "Mini muffins", List.of("12 items", "20 items", "24 items")));
|
||||
catering.save(office.id(), new CateringMenu.PackageEdit(
|
||||
"Office boxes", "For meetings and staff mornings.", asEdits(office), rows, office.notes()));
|
||||
|
||||
@@ -220,7 +232,7 @@ class CateringMenuTest {
|
||||
assertThat(saved.blurb()).isEqualTo("For meetings and staff mornings.");
|
||||
// Same line, edited — not a new line that happens to read the same.
|
||||
assertThat(saved.rows().get(0).id()).isEqualTo(muffins);
|
||||
assertThat(saved.rows().get(0).values()).containsExactly("A dozen", "Twenty", "Two dozen");
|
||||
assertThat(saved.rows().get(0).values()).containsExactly("12 items", "20 items", "24 items");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -234,7 +246,7 @@ class CateringMenuTest {
|
||||
|
||||
// Once it has a column and a line, it's a price table and it belongs on the page.
|
||||
catering.save(fresh.id(), new CateringMenu.PackageEdit("Holiday boxes", null,
|
||||
List.of(new CateringMenu.TierEdit(null, "Dozen", "18")),
|
||||
List.of(new CateringMenu.TierEdit(null, "Dozen", "18", "About 6 people")),
|
||||
List.of(new CateringMenu.RowEdit(null, "Frosted cut-outs", List.of("12 items"))),
|
||||
List.of()));
|
||||
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
|
||||
@@ -304,7 +316,7 @@ class CateringMenuTest {
|
||||
/** Note the round trip: what came back as "$24" goes out again as "$24" and must still mean 2400. */
|
||||
private static List<CateringMenu.TierEdit> asEdits(CateringMenu.PackageView table) {
|
||||
return table.tiers().stream()
|
||||
.map(t -> new CateringMenu.TierEdit(t.id(), t.label(), t.price()))
|
||||
.map(t -> new CateringMenu.TierEdit(t.id(), t.label(), t.price(), t.serves()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -328,13 +340,13 @@ class CateringMenuTest {
|
||||
|
||||
private static CateringMenu.PackageEdit withColumnPrice(CateringMenu.PackageView table, String price) {
|
||||
List<CateringMenu.TierEdit> tiers = new ArrayList<>(asEdits(table));
|
||||
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), tiers.get(0).label(), price));
|
||||
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), tiers.get(0).label(), price, null));
|
||||
return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, asLines(table), table.notes());
|
||||
}
|
||||
|
||||
private static CateringMenu.PackageEdit withColumnLabel(CateringMenu.PackageView table, String label) {
|
||||
List<CateringMenu.TierEdit> tiers = new ArrayList<>(asEdits(table));
|
||||
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), label, tiers.get(0).price()));
|
||||
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), label, tiers.get(0).price(), null));
|
||||
return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, asLines(table), table.notes());
|
||||
}
|
||||
|
||||
|
||||
@@ -102,8 +102,11 @@ class SiteControllerTest {
|
||||
.andExpect(content().string(containsString("$24")))
|
||||
.andExpect(content().string(containsString("$236")))
|
||||
// A cell and a note, in the wording a customer reads rather than the spreadsheet's.
|
||||
.andExpect(content().string(containsString("Eighteen, in two or three flavors")))
|
||||
.andExpect(content().string(containsString("baked in sixes")))
|
||||
// The office box is a total mixed in sixes, not three separate things.
|
||||
.andExpect(content().string(containsString("Any mix of mini muffins")))
|
||||
.andExpect(content().string(containsString("18 items")))
|
||||
.andExpect(content().string(containsString("About 6–8 people")))
|
||||
.andExpect(content().string(containsString("Baked in sixes")))
|
||||
// One card per size, and one enquiry link per table — not one per card, which would have
|
||||
// read "Ask about the 15–20 people".
|
||||
// The size label is in the HTML as written; the small caps are CSS.
|
||||
|
||||
Reference in New Issue
Block a user