Archived
The admin is Thymeleaf too: no JavaScript framework left in the repo
The last React went with this. /admin and /admin/catering are pages of forms; every write is a POST and a redirect back, so the back button and reload do what they look like they do, a double-tap cannot repeat an upload, and there is no client-side state to lose — a reload is always the truth. The /api/admin/** endpoints went too: they existed for the React screen, and their logic now lives in Catalogue (extracted from the two deleted JSON controllers) and CateringMenu, which the pages call. THE TABLE EDITOR IS THE INTERESTING PART, because a catering table cannot be edited a field at a time — a column heading, its price and the entries beneath it only mean anything together. One form holds the whole table and every button submits it; `name="do"` says which was pressed and its value carries the position (`remove-column:2`). "Add a column" therefore arrives with every cell the editor has typed, adds the column to what arrived plus an empty entry on every line, and re-renders. Nothing typed is lost, and only Save writes — so a half-built table with a blank heading never reaches the live page. A failed save comes back the same way, with the work still in the form and the reason above it; a redirect would throw the work away and leave them guessing which cell the message was about. Spring binds `lines[2].values[1]` into the right cell, which flat repeated parameters could not promise. Reordering moved to the server, where it always belonged: the browser used to compute the new order and send the whole list back, and now "move this up" arrives as an action. Same for arranging photos — one endpoint takes the key and -1/1/0 (earlier, later, remove), because those three buttons are the same edit. frontend/ became styles/: node, Tailwind and nothing else. It exists because Tailwind needs a compiler and the alternative is a hand-written stylesheet; there is no bundler and no framework. The admin's controls are @utility classes (v4 will only let you @apply a registered utility, and only a utility can take the `file:` variant the photo pickers use) — the same buttons the React screen had, from the same class strings it composed. Also fixed .gitignore, which still named frontend/: with styles/ unlisted, `git add -A` staged 1,626 files of node_modules. Verified against a running container, not only in tests: pressing "+ Column" returns the draft with an unsaved cell intact, a new column and a matching new entry on the line, "Not saved yet" — and the live page unchanged; Save then writes both columns with the price parsed from "48". Renaming and moving an item land on the products page. Deleting a category that is in use is refused with the sentence naming it. Removing the only photo of an item is refused, and that button is already disabled in the page. 51 tests (10 new): the form binding, the flash on success and on refusal, a structural button writing nothing, and the whole admin surface closed to anonymous visitors. PlatformContractTest's routing assertion now says what is true — an unknown path 404s, and so does /admin when no identity provider is configured, because AdminController only exists under OIDC.
This commit is contained in:
@@ -1,151 +0,0 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.itsthevine.web.domain.Category;
|
||||
import com.itsthevine.web.domain.CategoryRepository;
|
||||
import com.itsthevine.web.domain.Product;
|
||||
import com.itsthevine.web.domain.ProductRepository;
|
||||
|
||||
/**
|
||||
* The filter buttons, editable. Gated on OIDC for the same reason as the product admin: with no
|
||||
* identity provider configured these endpoints shouldn't exist at all.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/categories")
|
||||
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
||||
public class AdminCategoryController {
|
||||
|
||||
private final CategoryRepository categories;
|
||||
private final ProductRepository products;
|
||||
|
||||
public AdminCategoryController(CategoryRepository categories, ProductRepository products) {
|
||||
this.categories = categories;
|
||||
this.products = products;
|
||||
}
|
||||
|
||||
/** {@code used} tells the editor whether deleting it would strand anything. */
|
||||
public record AdminView(Long id, String name, int position, long used) {}
|
||||
|
||||
public record Name(String name) {}
|
||||
|
||||
public record Order(List<Long> ids) {}
|
||||
|
||||
@GetMapping
|
||||
@Transactional(readOnly = true)
|
||||
public List<AdminView> list() {
|
||||
List<Product> all = products.findAllByOrderByPositionAsc();
|
||||
return categories.findAllByOrderByPositionAsc().stream()
|
||||
.map(c -> new AdminView(c.getId(), c.getName(), c.getPosition(), count(all, c.getName())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Transactional
|
||||
public AdminView create(@RequestBody Name body) {
|
||||
String name = required(body.name());
|
||||
categories.findByNameIgnoreCase(name).ifPresent(existing -> {
|
||||
throw new IllegalStateException("There's already a " + existing.getName() + " category.");
|
||||
});
|
||||
int last = categories.findAllByOrderByPositionAsc().stream()
|
||||
.mapToInt(Category::getPosition).max().orElse(0);
|
||||
Category saved = categories.save(new Category(name, last + 1));
|
||||
return new AdminView(saved.getId(), saved.getName(), saved.getPosition(), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renaming carries the products with it. They store the category by name, so without this the
|
||||
* rename would orphan everything filed under the old one — it would drop off the filter and
|
||||
* reappear at the end as an unlisted category.
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
@Transactional
|
||||
public AdminView rename(@PathVariable Long id, @RequestBody Name body) {
|
||||
Category category = find(id);
|
||||
String name = required(body.name());
|
||||
categories.findByNameIgnoreCase(name)
|
||||
.filter(other -> !other.getId().equals(id))
|
||||
.ifPresent(other -> {
|
||||
throw new IllegalStateException("There's already a " + other.getName() + " category.");
|
||||
});
|
||||
|
||||
String previous = category.getName();
|
||||
category.rename(name);
|
||||
categories.save(category);
|
||||
|
||||
List<Product> filed = products.findAllByCategoryOrderByPositionAsc(previous);
|
||||
filed.forEach(p -> p.describe(p.getName(), name));
|
||||
products.saveAll(filed);
|
||||
|
||||
return new AdminView(category.getId(), category.getName(), category.getPosition(), filed.size());
|
||||
}
|
||||
|
||||
@PutMapping("/order")
|
||||
@Transactional
|
||||
public List<AdminView> reorder(@RequestBody Order order) {
|
||||
List<Category> all = categories.findAllByOrderByPositionAsc();
|
||||
List<Category> arranged = new ArrayList<>();
|
||||
for (Long id : order.ids()) {
|
||||
all.stream().filter(c -> c.getId().equals(id)).findFirst().ifPresent(arranged::add);
|
||||
}
|
||||
all.stream().filter(c -> !arranged.contains(c)).forEach(arranged::add);
|
||||
|
||||
int position = 1;
|
||||
for (Category category : arranged) {
|
||||
category.moveTo(position++);
|
||||
}
|
||||
categories.saveAll(arranged);
|
||||
|
||||
List<Product> everything = products.findAllByOrderByPositionAsc();
|
||||
return arranged.stream()
|
||||
.map(c -> new AdminView(c.getId(), c.getName(), c.getPosition(), count(everything, c.getName())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
|
||||
Category category = find(id);
|
||||
long used = count(products.findAllByOrderByPositionAsc(), category.getName());
|
||||
if (used > 0) {
|
||||
// Refuse rather than cascade: deleting the button shouldn't quietly decide what happens to
|
||||
// the items behind it.
|
||||
throw new IllegalStateException(
|
||||
used + " item" + (used == 1 ? " is" : "s are") + " still filed under "
|
||||
+ category.getName() + ". Move them first.");
|
||||
}
|
||||
categories.delete(category);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
private Category find(Long id) {
|
||||
return categories.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("That category no longer exists."));
|
||||
}
|
||||
|
||||
private static long count(List<Product> all, String category) {
|
||||
return all.stream().filter(p -> p.getCategory().equalsIgnoreCase(category)).count();
|
||||
}
|
||||
|
||||
private static String required(String value) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalArgumentException("Please give the category a name.");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,327 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
/**
|
||||
* The catering tables, editable from the site — prices move, and moving them shouldn't need a deploy.
|
||||
* The catering price tables, editable as forms.
|
||||
*
|
||||
* <p>Conditional on OIDC for the same reason as the product and category admins: with no identity
|
||||
* provider configured the platform runs its permit-all chain, so an unconditional controller here
|
||||
* would publish price writes to the open internet on any deployment that forgot to wire Authentik.
|
||||
* Gated this way, "no auth configured" means these endpoints simply don't exist.
|
||||
* <p>A table is edited and saved whole, which is the same rule the React screen followed and for the
|
||||
* same reason: a column heading, its price and the entries beneath it only mean anything together, so
|
||||
* they have to be added, moved and removed together. {@code CateringPackage#arrange} refuses an
|
||||
* arrangement whose lines and columns disagree.
|
||||
*
|
||||
* <p>A table is saved whole rather than field by field. That isn't a shortcut — the columns and the
|
||||
* values under them only mean anything together, so they have to arrive together (see
|
||||
* {@code CateringPackage#arrange}).
|
||||
* <p>The interesting part is doing that without JavaScript. One form holds the whole table, and its
|
||||
* buttons all submit it — {@code name="do"} says which one was pressed. "Add a column" therefore arrives
|
||||
* with every cell the editor has typed so far, adds the column to what arrived, and re-renders; nothing
|
||||
* typed is lost, and nothing is written until Save. The alternative — a link that adds a column
|
||||
* server-side — would have to either discard the unsaved edits or write a half-built table to the live
|
||||
* page.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/catering")
|
||||
@Controller
|
||||
@RequestMapping("/admin/catering")
|
||||
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
||||
public class AdminCateringController {
|
||||
|
||||
private final CateringMenu menu;
|
||||
private final CateringMenu catering;
|
||||
|
||||
public AdminCateringController(CateringMenu menu) {
|
||||
this.menu = menu;
|
||||
public AdminCateringController(CateringMenu catering) {
|
||||
this.catering = catering;
|
||||
}
|
||||
|
||||
public record Name(String name) {}
|
||||
/**
|
||||
* One table, as the form posts it back.
|
||||
*
|
||||
* <p>A form-backing object rather than a pile of {@code @RequestParam} lists, because the cells are a
|
||||
* grid: Spring binds {@code lines[2].values[1]} into exactly the right place, whereas flat repeated
|
||||
* parameters would rely on the browser's submission order to keep the grid square.
|
||||
*/
|
||||
public static class TableForm {
|
||||
|
||||
public record Order(List<Long> ids) {}
|
||||
private String name = "";
|
||||
private String blurb = "";
|
||||
private List<ColumnForm> columns = new ArrayList<>();
|
||||
private List<LineForm> lines = new ArrayList<>();
|
||||
private List<String> notes = new ArrayList<>();
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
|
||||
public String getBlurb() { return blurb; }
|
||||
public void setBlurb(String blurb) { this.blurb = blurb; }
|
||||
|
||||
public List<ColumnForm> getColumns() { return columns; }
|
||||
public void setColumns(List<ColumnForm> columns) { this.columns = columns; }
|
||||
|
||||
public List<LineForm> getLines() { return lines; }
|
||||
public void setLines(List<LineForm> lines) { this.lines = lines; }
|
||||
|
||||
public List<String> getNotes() { return notes; }
|
||||
public void setNotes(List<String> notes) { this.notes = notes; }
|
||||
}
|
||||
|
||||
public static class ColumnForm {
|
||||
|
||||
/** Null for a column the editor has just added and not yet saved. */
|
||||
private Long id;
|
||||
private String label = "";
|
||||
private String price = "";
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public String getLabel() { return label; }
|
||||
public void setLabel(String label) { this.label = label; }
|
||||
|
||||
public String getPrice() { return price; }
|
||||
public void setPrice(String price) { this.price = price; }
|
||||
}
|
||||
|
||||
public static class LineForm {
|
||||
|
||||
private Long id;
|
||||
private String label = "";
|
||||
private List<String> values = new ArrayList<>();
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public String getLabel() { return label; }
|
||||
public void setLabel(String label) { this.label = label; }
|
||||
|
||||
public List<String> getValues() { return values; }
|
||||
public void setValues(List<String> values) { this.values = values; }
|
||||
}
|
||||
|
||||
/** The tables, listed: rename or fill one in on its own page, and set the page's own notes here. */
|
||||
@GetMapping
|
||||
public CateringMenu.MenuView all() {
|
||||
return menu.everything();
|
||||
public String tables(Model model) {
|
||||
model.addAttribute("menu", catering.everything());
|
||||
return "admin/catering";
|
||||
}
|
||||
|
||||
@PostMapping("/packages")
|
||||
public CateringMenu.PackageView add(@RequestBody Name body) {
|
||||
return menu.add(body.name());
|
||||
/**
|
||||
* One table's editor.
|
||||
*
|
||||
* <p>A page per table rather than every table on one screen: a table is saved whole, so the thing
|
||||
* being edited and the thing being saved should be the same thing you can see.
|
||||
*/
|
||||
@GetMapping("/tables/{id}")
|
||||
public String edit(@PathVariable Long id, Model model) {
|
||||
CateringMenu.PackageView table = catering.everything().packages().stream()
|
||||
.filter(p -> p.id().equals(id))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (table == null) {
|
||||
return "redirect:/admin/catering";
|
||||
}
|
||||
model.addAttribute("table", formOf(table));
|
||||
model.addAttribute("tableId", id);
|
||||
return "admin/table";
|
||||
}
|
||||
|
||||
@PutMapping("/packages/{id}")
|
||||
public CateringMenu.PackageView save(@PathVariable Long id,
|
||||
@RequestBody CateringMenu.PackageEdit edit) {
|
||||
return menu.save(id, edit);
|
||||
@PostMapping("/tables")
|
||||
public String add(@RequestParam String name, RedirectAttributes flash) {
|
||||
try {
|
||||
catering.add(name);
|
||||
flash.addFlashAttribute("done", "Added the " + name.trim() + " table. Give it a column and a line.");
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
flash.addFlashAttribute("problem", e.getMessage());
|
||||
}
|
||||
return "redirect:/admin/catering";
|
||||
}
|
||||
|
||||
/** Mapped above {@code /packages/{id}} by Spring's literal-beats-template rule, as with products. */
|
||||
@PutMapping("/packages/order")
|
||||
public List<CateringMenu.PackageView> reorder(@RequestBody Order order) {
|
||||
return menu.reorder(order.ids());
|
||||
/**
|
||||
* Save, or restructure and come back.
|
||||
*
|
||||
* @param action which button was pressed: {@code save}, {@code add-column}, {@code add-line}, or one
|
||||
* of {@code remove-column}/{@code move-column}/{@code remove-line}/{@code move-line}
|
||||
* with the position after a colon ({@code move-column:2:-1}). A button can only send
|
||||
* its own name and value, so the value carries the argument.
|
||||
*/
|
||||
@PostMapping("/tables/{id}")
|
||||
public String save(@PathVariable Long id,
|
||||
@ModelAttribute("table") TableForm form,
|
||||
@RequestParam(name = "do", defaultValue = "save") String action,
|
||||
Model model,
|
||||
RedirectAttributes flash) {
|
||||
if (!action.equals("save")) {
|
||||
restructure(form, action);
|
||||
// Deliberately NOT a redirect: this is a draft, not a saved state. Re-rendering the form the
|
||||
// editor is looking at keeps every cell they have typed; writing it now would put a column
|
||||
// headed "" on the live page, and the table refuses that anyway.
|
||||
model.addAttribute("tableId", id);
|
||||
model.addAttribute("unsaved", true);
|
||||
return "admin/table";
|
||||
}
|
||||
|
||||
try {
|
||||
catering.save(id, new CateringMenu.PackageEdit(
|
||||
form.getName(),
|
||||
form.getBlurb(),
|
||||
form.getColumns().stream()
|
||||
.map(c -> new CateringMenu.TierEdit(c.getId(), c.getLabel(), c.getPrice()))
|
||||
.toList(),
|
||||
form.getLines().stream()
|
||||
.map(l -> new CateringMenu.RowEdit(l.getId(), l.getLabel(), l.getValues()))
|
||||
.toList(),
|
||||
form.getNotes()));
|
||||
flash.addFlashAttribute("done", "Saved the " + form.getName().trim() + " table.");
|
||||
return "redirect:/admin/catering";
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
// Back to the form with what they typed, and the reason. A redirect here would throw away the
|
||||
// work and leave them guessing which cell the message was about.
|
||||
model.addAttribute("tableId", id);
|
||||
model.addAttribute("unsaved", true);
|
||||
model.addAttribute("problem", e.getMessage());
|
||||
return "admin/table";
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/packages/{id}")
|
||||
public ResponseEntity<Map<String, Object>> remove(@PathVariable Long id) {
|
||||
menu.remove(id);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
@PostMapping("/tables/{id}/move")
|
||||
public String move(@PathVariable Long id, @RequestParam int by) {
|
||||
List<Long> ids = new ArrayList<>(catering.everything().packages().stream()
|
||||
.map(CateringMenu.PackageView::id).toList());
|
||||
int at = ids.indexOf(id);
|
||||
int to = at + by;
|
||||
if (at >= 0 && to >= 0 && to < ids.size()) {
|
||||
swap(ids, at, to);
|
||||
catering.reorder(ids);
|
||||
}
|
||||
return "redirect:/admin/catering";
|
||||
}
|
||||
|
||||
/** The page's own footnotes: the full list the editor is looking at. */
|
||||
@PutMapping("/notes")
|
||||
public List<String> notes(@RequestBody List<String> bodies) {
|
||||
return menu.replaceNotes(bodies);
|
||||
@PostMapping("/tables/{id}/delete")
|
||||
public String remove(@PathVariable Long id, RedirectAttributes flash) {
|
||||
try {
|
||||
catering.remove(id);
|
||||
flash.addFlashAttribute("done", "Table deleted.");
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
flash.addFlashAttribute("problem", e.getMessage());
|
||||
}
|
||||
return "redirect:/admin/catering";
|
||||
}
|
||||
|
||||
@PostMapping("/notes")
|
||||
public String notes(@RequestParam(name = "notes", required = false) List<String> notes,
|
||||
RedirectAttributes flash) {
|
||||
try {
|
||||
catering.replaceNotes(notes == null ? List.of() : notes);
|
||||
flash.addFlashAttribute("done", "Saved the notes for the page.");
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
flash.addFlashAttribute("problem", e.getMessage());
|
||||
}
|
||||
return "redirect:/admin/catering";
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a structural button to the draft that arrived.
|
||||
*
|
||||
* <p>Adding a column adds an empty entry to every line, and removing one takes its entries with it,
|
||||
* which is the invariant the aggregate insists on. Doing it here rather than in the browser is the
|
||||
* whole point: there is one implementation of "a table has as many entries per line as it has
|
||||
* columns", and it is in Java.
|
||||
*/
|
||||
private static void restructure(TableForm form, String action) {
|
||||
String[] parts = action.split(":");
|
||||
String what = parts[0];
|
||||
int at = parts.length > 1 ? Integer.parseInt(parts[1]) : -1;
|
||||
int by = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
|
||||
|
||||
switch (what) {
|
||||
case "add-column" -> {
|
||||
form.getColumns().add(new ColumnForm());
|
||||
form.getLines().forEach(line -> line.getValues().add(""));
|
||||
}
|
||||
case "remove-column" -> {
|
||||
if (inRange(at, form.getColumns().size())) {
|
||||
form.getColumns().remove(at);
|
||||
form.getLines().forEach(line -> {
|
||||
if (inRange(at, line.getValues().size())) {
|
||||
line.getValues().remove(at);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
case "move-column" -> {
|
||||
int to = at + by;
|
||||
if (inRange(at, form.getColumns().size()) && inRange(to, form.getColumns().size())) {
|
||||
swap(form.getColumns(), at, to);
|
||||
form.getLines().forEach(line -> swap(line.getValues(), at, to));
|
||||
}
|
||||
}
|
||||
case "add-line" -> {
|
||||
LineForm line = new LineForm();
|
||||
form.getColumns().forEach(column -> line.getValues().add(""));
|
||||
form.getLines().add(line);
|
||||
}
|
||||
case "remove-line" -> {
|
||||
if (inRange(at, form.getLines().size())) {
|
||||
form.getLines().remove(at);
|
||||
}
|
||||
}
|
||||
case "move-line" -> {
|
||||
int to = at + by;
|
||||
if (inRange(at, form.getLines().size()) && inRange(to, form.getLines().size())) {
|
||||
swap(form.getLines(), at, to);
|
||||
}
|
||||
}
|
||||
case "add-note" -> form.getNotes().add("");
|
||||
case "remove-note" -> {
|
||||
if (inRange(at, form.getNotes().size())) {
|
||||
form.getNotes().remove(at);
|
||||
}
|
||||
}
|
||||
// An unknown action is a stale page or a hand-edited form: leave the draft exactly as it is
|
||||
// rather than guessing at an edit nobody asked for.
|
||||
default -> { }
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored table, as a form to edit. */
|
||||
private static TableForm formOf(CateringMenu.PackageView table) {
|
||||
TableForm form = new TableForm();
|
||||
form.setName(table.name());
|
||||
form.setBlurb(table.blurb() == null ? "" : table.blurb());
|
||||
form.setColumns(table.tiers().stream().map(tier -> {
|
||||
ColumnForm column = new ColumnForm();
|
||||
column.setId(tier.id());
|
||||
column.setLabel(tier.label());
|
||||
// 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());
|
||||
return column;
|
||||
}).collect(Collectors.toCollection(ArrayList::new)));
|
||||
form.setLines(table.rows().stream().map(row -> {
|
||||
LineForm line = new LineForm();
|
||||
line.setId(row.id());
|
||||
line.setLabel(row.label());
|
||||
line.setValues(new ArrayList<>(row.values()));
|
||||
return line;
|
||||
}).collect(Collectors.toCollection(ArrayList::new)));
|
||||
form.setNotes(new ArrayList<>(table.notes()));
|
||||
return form;
|
||||
}
|
||||
|
||||
private static boolean inRange(int at, int size) {
|
||||
return at >= 0 && at < size;
|
||||
}
|
||||
|
||||
private static <T> void swap(List<T> items, int a, int b) {
|
||||
T held = items.get(a);
|
||||
items.set(a, items.get(b));
|
||||
items.set(b, held);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
/**
|
||||
* The catalogue, editable by the person who bakes it — as pages and form posts.
|
||||
*
|
||||
* <p>Every write is POST, then a redirect back to the page it came from. That is not ceremony: it means
|
||||
* the browser's back button and reload do what they look like they do, a double-tap can't repeat an
|
||||
* upload, and there is no client-side state to lose. The message the editor reads afterwards travels as
|
||||
* a flash attribute.
|
||||
*
|
||||
* <p>The whole controller is conditional on OIDC being switched on, the same as the JSON admin it
|
||||
* replaced. That is deliberate belt-and-braces: the platform's permit-all filter chain is what runs when
|
||||
* {@code platform.security.mode} is unset, so if these pages existed unconditionally a deployment that
|
||||
* forgot to configure Authentik would be publishing catalogue writes to the open internet. Gated this
|
||||
* way, "no auth configured" means "no admin" — the paths 404 like any other unknown URL.
|
||||
*/
|
||||
@Controller
|
||||
@RequestMapping("/admin")
|
||||
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
||||
public class AdminController {
|
||||
|
||||
private final Catalogue catalogue;
|
||||
|
||||
public AdminController(Catalogue catalogue) {
|
||||
this.catalogue = catalogue;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String catalogue(Model model) {
|
||||
model.addAttribute("items", catalogue.items());
|
||||
model.addAttribute("filters", catalogue.filters());
|
||||
return "admin/catalogue";
|
||||
}
|
||||
|
||||
// --- items ---------------------------------------------------------------
|
||||
|
||||
@PostMapping("/items")
|
||||
public String add(@RequestParam String name,
|
||||
@RequestParam String category,
|
||||
@RequestParam(name = "photos", required = false) List<MultipartFile> photos,
|
||||
RedirectAttributes flash) {
|
||||
return run(flash, () -> {
|
||||
catalogue.addItem(name, category, photos);
|
||||
flash.addFlashAttribute("done", "Added " + name.trim() + " to the top of the page.");
|
||||
});
|
||||
}
|
||||
|
||||
@PostMapping("/items/{id}")
|
||||
public String describe(@PathVariable Long id,
|
||||
@RequestParam String name,
|
||||
@RequestParam String category,
|
||||
RedirectAttributes flash) {
|
||||
return run(flash, () -> {
|
||||
catalogue.describeItem(id, name, category);
|
||||
flash.addFlashAttribute("done", "Saved " + name.trim() + ".");
|
||||
});
|
||||
}
|
||||
|
||||
@PostMapping("/items/{id}/photos")
|
||||
public String addPhotos(@PathVariable Long id,
|
||||
@RequestParam(name = "photos", required = false) List<MultipartFile> photos,
|
||||
RedirectAttributes flash) {
|
||||
return run(flash, () -> {
|
||||
catalogue.addPhotos(id, photos);
|
||||
flash.addFlashAttribute("done", "Photos added.");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param move {@code -1} or {@code 1} to shuffle the photo along, {@code 0} to remove it. One
|
||||
* endpoint for the three buttons under a photo, because they are the same edit — which
|
||||
* photos, in which order — and the server is what decides the resulting list.
|
||||
*/
|
||||
@PostMapping("/items/{id}/photos/arrange")
|
||||
public String arrangePhoto(@PathVariable Long id,
|
||||
@RequestParam String key,
|
||||
@RequestParam int move,
|
||||
RedirectAttributes flash) {
|
||||
return run(flash, () -> {
|
||||
if (move == 0) {
|
||||
catalogue.removePhoto(id, key);
|
||||
} else {
|
||||
catalogue.movePhoto(id, key, move);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@PostMapping("/items/{id}/move")
|
||||
public String move(@PathVariable Long id, @RequestParam int by, RedirectAttributes flash) {
|
||||
return run(flash, () -> catalogue.moveItem(id, by));
|
||||
}
|
||||
|
||||
@PostMapping("/items/{id}/delete")
|
||||
public String remove(@PathVariable Long id, RedirectAttributes flash) {
|
||||
return run(flash, () -> {
|
||||
catalogue.removeItem(id);
|
||||
flash.addFlashAttribute("done", "Removed from the products page.");
|
||||
});
|
||||
}
|
||||
|
||||
// --- filters -------------------------------------------------------------
|
||||
|
||||
@PostMapping("/categories")
|
||||
public String addFilter(@RequestParam String name, RedirectAttributes flash) {
|
||||
return run(flash, () -> {
|
||||
catalogue.addFilter(name);
|
||||
flash.addFlashAttribute("done", "Added the " + name.trim() + " category.");
|
||||
});
|
||||
}
|
||||
|
||||
@PostMapping("/categories/{id}")
|
||||
public String renameFilter(@PathVariable Long id, @RequestParam String name, RedirectAttributes flash) {
|
||||
return run(flash, () -> {
|
||||
catalogue.renameFilter(id, name);
|
||||
flash.addFlashAttribute("done", "Renamed to " + name.trim() + ", and everything filed under it moved too.");
|
||||
});
|
||||
}
|
||||
|
||||
@PostMapping("/categories/{id}/move")
|
||||
public String moveFilter(@PathVariable Long id, @RequestParam int by, RedirectAttributes flash) {
|
||||
return run(flash, () -> catalogue.moveFilter(id, by));
|
||||
}
|
||||
|
||||
@PostMapping("/categories/{id}/delete")
|
||||
public String removeFilter(@PathVariable Long id, RedirectAttributes flash) {
|
||||
return run(flash, () -> {
|
||||
catalogue.removeFilter(id);
|
||||
flash.addFlashAttribute("done", "Category deleted.");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one edit and comes back to the page.
|
||||
*
|
||||
* <p>The two exception types are the vocabulary the domain already speaks — {@code
|
||||
* IllegalArgumentException} for "that isn't a usable value", {@code IllegalStateException} for "not
|
||||
* while things are like this" — and both carry a sentence written for the editor to read. The
|
||||
* platform's exception handler turns them into JSON for the API; here they belong on the page.
|
||||
*/
|
||||
private String run(RedirectAttributes flash, Runnable edit) {
|
||||
try {
|
||||
edit.run();
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
flash.addFlashAttribute("problem", e.getMessage());
|
||||
}
|
||||
return "redirect:/admin";
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.itsthevine.web.domain.Product;
|
||||
import com.itsthevine.web.domain.ProductRepository;
|
||||
|
||||
/**
|
||||
* Editing the catalogue from the site, so a new cake is a photo and a name rather than a migration.
|
||||
*
|
||||
* The whole controller is conditional on OIDC being switched on. That is deliberate belt-and-braces:
|
||||
* the platform's permit-all filter chain is what runs when {@code platform.security.mode} is unset,
|
||||
* so if these endpoints existed unconditionally a deployment that forgot to configure Authentik
|
||||
* would be publishing catalogue writes to the open internet. Gated this way, "no auth configured"
|
||||
* means "no admin endpoints" — they 404 like any other unknown path, which is also what the platform
|
||||
* web contract expects of {@code /api/**}.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/products")
|
||||
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
||||
public class AdminProductController {
|
||||
|
||||
private final ProductRepository products;
|
||||
private final ProductPhotoService photos;
|
||||
private final ProductCatalog catalog;
|
||||
|
||||
public AdminProductController(ProductRepository products, ProductPhotoService photos, ProductCatalog catalog) {
|
||||
this.products = products;
|
||||
this.photos = photos;
|
||||
this.catalog = catalog;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the editor sees: the catalogue in display order.
|
||||
*
|
||||
* {@code images} and {@code keys} are the same photos in the same order — the URLs to show and the
|
||||
* identifiers to arrange by. The public view only needs the former, but an editor rearranging
|
||||
* photos has to name them back to us, and the URL is a rendering of the key rather than the key
|
||||
* itself.
|
||||
*/
|
||||
public record AdminView(Long id, String name, String category, int position,
|
||||
List<String> images, List<String> keys) {}
|
||||
|
||||
public record Details(String name, String category) {}
|
||||
|
||||
public record Order(List<Long> ids) {}
|
||||
|
||||
@GetMapping
|
||||
@Transactional(readOnly = true)
|
||||
public List<AdminView> list() {
|
||||
return products.findAllByOrderByPositionAsc().stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* New items go to the front — the newest work is what's worth showing first, and it saves the
|
||||
* editor a reorder after every upload.
|
||||
*/
|
||||
@PostMapping
|
||||
@Transactional
|
||||
public AdminView create(@RequestParam String name,
|
||||
@RequestParam String category,
|
||||
@RequestParam("photos") List<MultipartFile> files) {
|
||||
String cleanName = required(name, "Please give it a name.");
|
||||
String cleanCategory = required(category, "Please choose a category.");
|
||||
if (files == null || files.isEmpty()) {
|
||||
throw new IllegalArgumentException("Please add at least one photo.");
|
||||
}
|
||||
|
||||
List<String> keys = new ArrayList<>();
|
||||
for (MultipartFile file : files) {
|
||||
keys.add(photos.store(bytes(file), file.getOriginalFilename(), cleanName));
|
||||
}
|
||||
|
||||
Product saved = products.save(new Product(cleanName, cleanCategory, 0, keys));
|
||||
renumberWithFirst(saved);
|
||||
return toView(saved);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Transactional
|
||||
public AdminView describe(@PathVariable Long id, @RequestBody Details details) {
|
||||
Product product = find(id);
|
||||
product.describe(required(details.name(), "Please give it a name."),
|
||||
required(details.category(), "Please choose a category."));
|
||||
return toView(products.save(product));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/photos")
|
||||
@Transactional
|
||||
public AdminView addPhotos(@PathVariable Long id, @RequestParam("photos") List<MultipartFile> files) {
|
||||
Product product = find(id);
|
||||
if (files == null || files.isEmpty()) {
|
||||
throw new IllegalArgumentException("Please choose a photo to add.");
|
||||
}
|
||||
List<String> keys = new ArrayList<>(product.getImageKeys());
|
||||
for (MultipartFile file : files) {
|
||||
keys.add(photos.store(bytes(file), file.getOriginalFilename(), product.getName()));
|
||||
}
|
||||
product.replacePhotos(keys);
|
||||
return toView(products.save(product));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reordering and removal both arrive as the full list the editor arranged, so the stored order is
|
||||
* whatever they last saw rather than the result of replaying moves.
|
||||
*/
|
||||
@PutMapping("/{id}/photos")
|
||||
@Transactional
|
||||
public AdminView arrangePhotos(@PathVariable Long id, @RequestBody List<String> keys) {
|
||||
Product product = find(id);
|
||||
List<String> existing = product.getImageKeys();
|
||||
List<String> arranged = keys.stream().filter(existing::contains).distinct().toList();
|
||||
if (arranged.isEmpty()) {
|
||||
throw new IllegalArgumentException("An item needs at least one photo.");
|
||||
}
|
||||
product.replacePhotos(arranged);
|
||||
return toView(products.save(product));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
|
||||
products.delete(find(id));
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
}
|
||||
|
||||
/** The ids in the order they should appear; anything omitted keeps its relative place after them. */
|
||||
@PutMapping("/order")
|
||||
@Transactional
|
||||
public List<AdminView> reorder(@RequestBody Order order) {
|
||||
List<Product> all = products.findAllByOrderByPositionAsc();
|
||||
List<Product> arranged = new ArrayList<>();
|
||||
for (Long id : order.ids()) {
|
||||
all.stream().filter(p -> p.getId().equals(id)).findFirst().ifPresent(arranged::add);
|
||||
}
|
||||
all.stream().filter(p -> !arranged.contains(p)).forEach(arranged::add);
|
||||
renumber(arranged);
|
||||
return arranged.stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
private void renumberWithFirst(Product first) {
|
||||
List<Product> arranged = new ArrayList<>();
|
||||
arranged.add(first);
|
||||
products.findAllByOrderByPositionAsc().stream()
|
||||
.filter(p -> !p.getId().equals(first.getId()))
|
||||
.forEach(arranged::add);
|
||||
renumber(arranged);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code product.position} has no unique constraint, so ordering is a full renumber rather than a
|
||||
* swap — forty rows, once in a while, from one editor.
|
||||
*/
|
||||
private void renumber(List<Product> arranged) {
|
||||
int position = 1;
|
||||
for (Product product : arranged) {
|
||||
product.moveTo(position++);
|
||||
}
|
||||
products.saveAll(arranged);
|
||||
}
|
||||
|
||||
private Product find(Long id) {
|
||||
return products.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("That item no longer exists."));
|
||||
}
|
||||
|
||||
private static byte[] bytes(MultipartFile file) {
|
||||
try {
|
||||
return file.getBytes();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Could not read the uploaded photo.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String required(String value, String message) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Reuses the catalogue's URL building so admin and public pages can never disagree about a photo. */
|
||||
private AdminView toView(Product product) {
|
||||
ProductCatalog.ProductView view = catalog.view(product);
|
||||
return new AdminView(view.id(), view.name(), view.category(), product.getPosition(),
|
||||
view.images(), List.copyOf(product.getImageKeys()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.itsthevine.web.domain.Category;
|
||||
import com.itsthevine.web.domain.CategoryRepository;
|
||||
import com.itsthevine.web.domain.Product;
|
||||
import com.itsthevine.web.domain.ProductRepository;
|
||||
|
||||
/**
|
||||
* Editing the catalogue: what's on the products page, in what order, under which filter, with which
|
||||
* photos.
|
||||
*
|
||||
* <p>This is the logic that used to sit in {@code AdminProductController} and
|
||||
* {@code AdminCategoryController} when the admin was a React screen talking JSON. The screen is now
|
||||
* server-rendered forms, and a form can only POST — so "move this up" arrives as an action rather than
|
||||
* as the whole re-ordered list the browser had arranged. The reordering therefore happens here, which is
|
||||
* where it should always have been: the browser was only ever telling us what it had already worked out.
|
||||
*/
|
||||
@Service
|
||||
public class Catalogue {
|
||||
|
||||
private final ProductRepository products;
|
||||
private final CategoryRepository categories;
|
||||
private final ProductPhotoService photos;
|
||||
private final SitePhotos urls;
|
||||
|
||||
public Catalogue(ProductRepository products, CategoryRepository categories,
|
||||
ProductPhotoService photos, SitePhotos urls) {
|
||||
this.products = products;
|
||||
this.categories = categories;
|
||||
this.photos = photos;
|
||||
this.urls = urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* A photo, as the editor needs it: the URL to look at and the key to name it by. Same pairing the
|
||||
* React screen kept as two parallel arrays — a template can just walk the pairs.
|
||||
*/
|
||||
public record Photo(String key, String url) {}
|
||||
|
||||
public record Item(Long id, String name, String category, List<Photo> photos) {}
|
||||
|
||||
/** {@code used} tells the editor whether deleting a filter would strand anything. */
|
||||
public record Filter(Long id, String name, long used) {}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Item> items() {
|
||||
return products.findAllByOrderByPositionAsc().stream().map(this::toItem).toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Filter> filters() {
|
||||
List<Product> all = products.findAllByOrderByPositionAsc();
|
||||
return categories.findAllByOrderByPositionAsc().stream()
|
||||
.map(c -> new Filter(c.getId(), c.getName(), count(all, c.getName())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// --- items ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* New items go to the front — the newest work is what's worth showing first, and it saves the editor
|
||||
* a reorder after every upload.
|
||||
*/
|
||||
@Transactional
|
||||
public void addItem(String name, String category, List<MultipartFile> files) {
|
||||
String cleanName = required(name, "Please give it a name.");
|
||||
String cleanCategory = required(category, "Please choose a category.");
|
||||
List<MultipartFile> chosen = real(files);
|
||||
if (chosen.isEmpty()) {
|
||||
throw new IllegalArgumentException("Please add at least one photo.");
|
||||
}
|
||||
|
||||
List<String> keys = new ArrayList<>();
|
||||
for (MultipartFile file : chosen) {
|
||||
keys.add(photos.store(bytes(file), file.getOriginalFilename(), cleanName));
|
||||
}
|
||||
|
||||
Product saved = products.save(new Product(cleanName, cleanCategory, 0, keys));
|
||||
List<Product> arranged = new ArrayList<>();
|
||||
arranged.add(saved);
|
||||
products.findAllByOrderByPositionAsc().stream()
|
||||
.filter(p -> !p.getId().equals(saved.getId()))
|
||||
.forEach(arranged::add);
|
||||
renumber(arranged);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void describeItem(Long id, String name, String category) {
|
||||
Product product = item(id);
|
||||
product.describe(required(name, "Please give it a name."),
|
||||
required(category, "Please choose a category."));
|
||||
products.save(product);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void addPhotos(Long id, List<MultipartFile> files) {
|
||||
Product product = item(id);
|
||||
List<MultipartFile> chosen = real(files);
|
||||
if (chosen.isEmpty()) {
|
||||
throw new IllegalArgumentException("Please choose a photo to add.");
|
||||
}
|
||||
List<String> keys = new ArrayList<>(product.getImageKeys());
|
||||
for (MultipartFile file : chosen) {
|
||||
keys.add(photos.store(bytes(file), file.getOriginalFilename(), product.getName()));
|
||||
}
|
||||
product.replacePhotos(keys);
|
||||
products.save(product);
|
||||
}
|
||||
|
||||
/** Order matters: the first photo is the one the products page leads with. */
|
||||
@Transactional
|
||||
public void movePhoto(Long id, String key, int delta) {
|
||||
Product product = item(id);
|
||||
List<String> keys = new ArrayList<>(product.getImageKeys());
|
||||
int at = keys.indexOf(key);
|
||||
if (at < 0) {
|
||||
throw new IllegalArgumentException("That photo isn't on this item any more. Reload the page.");
|
||||
}
|
||||
int to = at + delta;
|
||||
if (to < 0 || to >= keys.size()) {
|
||||
// Already at an end. Nothing to do, and nothing to complain about — the button that asked
|
||||
// for this is disabled in the page anyway.
|
||||
return;
|
||||
}
|
||||
swap(keys, at, to);
|
||||
product.replacePhotos(keys);
|
||||
products.save(product);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void removePhoto(Long id, String key) {
|
||||
Product product = item(id);
|
||||
List<String> keys = new ArrayList<>(product.getImageKeys());
|
||||
if (!keys.remove(key)) {
|
||||
throw new IllegalArgumentException("That photo isn't on this item any more. Reload the page.");
|
||||
}
|
||||
if (keys.isEmpty()) {
|
||||
// The card would have nothing to show. Deleting the item is a different, deliberate act.
|
||||
throw new IllegalArgumentException("An item needs at least one photo.");
|
||||
}
|
||||
product.replacePhotos(keys);
|
||||
products.save(product);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void removeItem(Long id) {
|
||||
products.delete(item(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void moveItem(Long id, int delta) {
|
||||
List<Product> all = products.findAllByOrderByPositionAsc();
|
||||
int at = at(all.stream().map(Product::getId).toList(), id, "That item no longer exists.");
|
||||
int to = at + delta;
|
||||
if (to < 0 || to >= all.size()) {
|
||||
return;
|
||||
}
|
||||
swap(all, at, to);
|
||||
renumber(all);
|
||||
}
|
||||
|
||||
// --- filters -------------------------------------------------------------
|
||||
|
||||
@Transactional
|
||||
public void addFilter(String name) {
|
||||
String clean = required(name, "Please give the category a name.");
|
||||
categories.findByNameIgnoreCase(clean).ifPresent(existing -> {
|
||||
throw new IllegalStateException("There's already a " + existing.getName() + " category.");
|
||||
});
|
||||
int last = categories.findAllByOrderByPositionAsc().stream()
|
||||
.mapToInt(Category::getPosition).max().orElse(0);
|
||||
categories.save(new Category(clean, last + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renaming carries the products with it. They store the category by name, so without this the rename
|
||||
* would orphan everything filed under the old one — it would drop off the filter and reappear at the
|
||||
* end as an unlisted category.
|
||||
*/
|
||||
@Transactional
|
||||
public void renameFilter(Long id, String name) {
|
||||
Category category = filter(id);
|
||||
String clean = required(name, "Please give the category a name.");
|
||||
categories.findByNameIgnoreCase(clean)
|
||||
.filter(other -> !other.getId().equals(id))
|
||||
.ifPresent(other -> {
|
||||
throw new IllegalStateException("There's already a " + other.getName() + " category.");
|
||||
});
|
||||
|
||||
String previous = category.getName();
|
||||
category.rename(clean);
|
||||
categories.save(category);
|
||||
|
||||
List<Product> filed = products.findAllByCategoryOrderByPositionAsc(previous);
|
||||
filed.forEach(p -> p.describe(p.getName(), clean));
|
||||
products.saveAll(filed);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void moveFilter(Long id, int delta) {
|
||||
List<Category> all = categories.findAllByOrderByPositionAsc();
|
||||
int at = at(all.stream().map(Category::getId).toList(), id, "That category no longer exists.");
|
||||
int to = at + delta;
|
||||
if (to < 0 || to >= all.size()) {
|
||||
return;
|
||||
}
|
||||
swap(all, at, to);
|
||||
int position = 1;
|
||||
for (Category category : all) {
|
||||
category.moveTo(position++);
|
||||
}
|
||||
categories.saveAll(all);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void removeFilter(Long id) {
|
||||
Category category = filter(id);
|
||||
long used = count(products.findAllByOrderByPositionAsc(), category.getName());
|
||||
if (used > 0) {
|
||||
// Refuse rather than cascade: deleting the button shouldn't quietly decide what happens to
|
||||
// the items behind it.
|
||||
throw new IllegalStateException(used + " item" + (used == 1 ? " is" : "s are")
|
||||
+ " still filed under " + category.getName() + ". Move them first.");
|
||||
}
|
||||
categories.delete(category);
|
||||
}
|
||||
|
||||
// --- plumbing ------------------------------------------------------------
|
||||
|
||||
/** Reuses the catalogue's URL building so the admin and the shop front agree about a photo. */
|
||||
private Item toItem(Product product) {
|
||||
List<Photo> pictures = product.getImageKeys().stream()
|
||||
.map(key -> new Photo(key, urls.of(key)))
|
||||
.toList();
|
||||
return new Item(product.getId(), product.getName(), product.getCategory(), pictures);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code product.position} has no unique constraint, so ordering is a full renumber rather than a
|
||||
* swap — forty rows, once in a while, from one editor.
|
||||
*/
|
||||
private void renumber(List<Product> arranged) {
|
||||
int position = 1;
|
||||
for (Product product : arranged) {
|
||||
product.moveTo(position++);
|
||||
}
|
||||
products.saveAll(arranged);
|
||||
}
|
||||
|
||||
private Product item(Long id) {
|
||||
return products.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("That item no longer exists."));
|
||||
}
|
||||
|
||||
private Category filter(Long id) {
|
||||
return categories.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("That category no longer exists."));
|
||||
}
|
||||
|
||||
private static int at(List<Long> ids, Long id, String gone) {
|
||||
int at = ids.indexOf(id);
|
||||
if (at < 0) {
|
||||
throw new IllegalArgumentException(gone);
|
||||
}
|
||||
return at;
|
||||
}
|
||||
|
||||
private static <T> void swap(List<T> items, int a, int b) {
|
||||
T held = items.get(a);
|
||||
items.set(a, items.get(b));
|
||||
items.set(b, held);
|
||||
}
|
||||
|
||||
private static long count(List<Product> all, String category) {
|
||||
return all.stream().filter(p -> p.getCategory().equalsIgnoreCase(category)).count();
|
||||
}
|
||||
|
||||
/** An empty file input still posts a part, with no filename and no bytes. */
|
||||
private static List<MultipartFile> real(List<MultipartFile> files) {
|
||||
return files == null ? List.of() : files.stream().filter(f -> !f.isEmpty()).toList();
|
||||
}
|
||||
|
||||
private static byte[] bytes(MultipartFile file) {
|
||||
try {
|
||||
return file.getBytes();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Could not read the uploaded photo.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String required(String value, String message) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -130,18 +130,6 @@ public class SiteController {
|
||||
return "contact";
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin is still a React screen, and this is the one route that serves it.
|
||||
*
|
||||
* <p>The platform's SPA fallback used to do this for every extension-less path, which is why it's
|
||||
* switched off in application.yaml: with the site server-rendered, forwarding an unknown URL to a
|
||||
* JavaScript shell would answer a typo with a blank page and a 200 instead of the site's own 404.
|
||||
*/
|
||||
@GetMapping("/admin")
|
||||
public String admin() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
|
||||
private void contactMeta(Model model) {
|
||||
meta(model, "/contact", "Contact us · " + NAME,
|
||||
"Get in touch with The Vine Coffeehouse + Bakery, 215 E Main Street, Princeville, "
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{admin/layout :: page('The catalogue', ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content">
|
||||
|
||||
<!--/* The filter buttons. Renaming one carries everything filed under it, which is why the rename is a
|
||||
form of its own rather than an inline edit that might get half-submitted. */-->
|
||||
<section class="card">
|
||||
<h2 class="card-heading">Categories</h2>
|
||||
<p class="mt-1 text-sm text-bakery-600">
|
||||
These are the filter buttons on the products page, in this order. Renaming one moves everything
|
||||
filed under it too.
|
||||
</p>
|
||||
|
||||
<ul class="mt-3 divide-y divide-bakery-100">
|
||||
<li th:each="filter, f : ${filters}" class="flex flex-wrap items-center gap-2 py-2">
|
||||
<div class="flex gap-1">
|
||||
<form method="post" th:action="@{/admin/categories/{id}/move(id=${filter.id})}">
|
||||
<input type="hidden" name="by" value="-1">
|
||||
<button type="submit" class="btn-icon" th:disabled="${f.first}"
|
||||
th:aria-label="|Move ${filter.name} up|">↑</button>
|
||||
</form>
|
||||
<form method="post" th:action="@{/admin/categories/{id}/move(id=${filter.id})}">
|
||||
<input type="hidden" name="by" value="1">
|
||||
<button type="submit" class="btn-icon" th:disabled="${f.last}"
|
||||
th:aria-label="|Move ${filter.name} down|">↓</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form method="post" th:action="@{/admin/categories/{id}(id=${filter.id})}"
|
||||
class="flex flex-1 min-w-60 items-center gap-2">
|
||||
<label class="flex-1">
|
||||
<span class="sr-only" th:text="|Name of the ${filter.name} category|">Name</span>
|
||||
<input class="field" name="name" th:value="${filter.name}" required>
|
||||
</label>
|
||||
<button type="submit" class="btn-secondary">Rename</button>
|
||||
</form>
|
||||
|
||||
<span class="text-sm text-bakery-500 whitespace-nowrap"
|
||||
th:text="|${filter.used} item${filter.used == 1 ? '' : 's'}|">0 items</span>
|
||||
|
||||
<form method="post" th:action="@{/admin/categories/{id}/delete(id=${filter.id})}">
|
||||
<button type="submit" class="btn-danger" th:disabled="${filter.used > 0}"
|
||||
th:title="${filter.used > 0} ? 'Move its items somewhere else first' : 'Delete'"
|
||||
th:aria-label="|Delete the ${filter.name} category|">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form method="post" action="/admin/categories" class="mt-3 flex gap-2">
|
||||
<label class="flex-1">
|
||||
<span class="sr-only">New category</span>
|
||||
<input class="field" name="name" placeholder="New category" required>
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">Add</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!--/* Adding an item. enctype matters: without it the browser posts filenames instead of files. */-->
|
||||
<section class="card">
|
||||
<h2 class="card-heading">Add something new</h2>
|
||||
<p class="mt-1 text-sm text-bakery-600">
|
||||
New items go to the top of the products page. Photos are resized, stripped of their EXIF (including
|
||||
the location your phone put in them) and converted on upload, so this can take a few seconds each.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/admin/items" enctype="multipart/form-data" class="mt-3 space-y-3">
|
||||
<div class="grid gap-2 sm:grid-cols-[1fr_12rem]">
|
||||
<label class="block">
|
||||
<span class="sr-only">What is it?</span>
|
||||
<input class="field" name="name" placeholder="What is it? e.g. Chocolate drip cake" required>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="sr-only">Category</span>
|
||||
<select class="field" name="category" required>
|
||||
<option th:each="filter : ${filters}" th:value="${filter.name}" th:text="${filter.name}">Cakes</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input type="file" name="photos" accept="image/*" multiple required
|
||||
class="text-sm text-bakery-800 file:btn file:btn-secondary file:mr-3">
|
||||
<button type="submit" class="btn-primary">Add to the page</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!--/* The catalogue itself. One card per item, and every control on it is a form: there is no
|
||||
client-side state here, so a reload is always the truth. */-->
|
||||
<section>
|
||||
<h2 class="card-heading" th:text="|On the page (${#lists.size(items)})|">On the page</h2>
|
||||
|
||||
<ul class="mt-3 space-y-3">
|
||||
<li th:each="item, i : ${items}" class="card">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<div class="flex sm:flex-col gap-1 sm:pt-1">
|
||||
<form method="post" th:action="@{/admin/items/{id}/move(id=${item.id})}">
|
||||
<input type="hidden" name="by" value="-1">
|
||||
<button type="submit" class="btn-icon" th:disabled="${i.first}"
|
||||
th:aria-label="|Move ${item.name} up|">↑</button>
|
||||
</form>
|
||||
<form method="post" th:action="@{/admin/items/{id}/move(id=${item.id})}">
|
||||
<input type="hidden" name="by" value="1">
|
||||
<button type="submit" class="btn-icon" th:disabled="${i.last}"
|
||||
th:aria-label="|Move ${item.name} down|">↓</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 space-y-3">
|
||||
<form method="post" th:action="@{/admin/items/{id}(id=${item.id})}"
|
||||
class="grid gap-2 sm:grid-cols-[1fr_12rem_auto]">
|
||||
<label class="block">
|
||||
<span class="sr-only">Name</span>
|
||||
<input class="field" name="name" th:value="${item.name}" required>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="sr-only">Category</span>
|
||||
<select class="field" name="category">
|
||||
<!--/* An item can sit in a category nobody defined; don't silently retype it. */-->
|
||||
<option th:if="${!#lists.contains(filters.![name], item.category)}"
|
||||
th:value="${item.category}" th:text="${item.category}" selected>Uncategorised</option>
|
||||
<option th:each="filter : ${filters}" th:value="${filter.name}" th:text="${filter.name}"
|
||||
th:selected="${filter.name == item.category}">Cakes</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-secondary">Save</button>
|
||||
</form>
|
||||
|
||||
<!--/* Photos, in the order the products page shows them: the first is the one the card
|
||||
leads with. Left/right rather than a drag target, which is far easier to hit on a
|
||||
phone. */-->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<figure th:each="photo, p : ${item.photos}" class="w-28">
|
||||
<img th:src="${photo.url}" alt="" loading="lazy"
|
||||
class="w-28 h-28 rounded-md object-cover border border-bakery-200 bg-bakery-100">
|
||||
<figcaption class="mt-1 flex items-center justify-between gap-1">
|
||||
<div class="flex gap-1">
|
||||
<form method="post" th:action="@{/admin/items/{id}/photos/arrange(id=${item.id})}">
|
||||
<input type="hidden" name="key" th:value="${photo.key}">
|
||||
<input type="hidden" name="move" value="-1">
|
||||
<button type="submit" class="btn-icon" th:disabled="${p.first}"
|
||||
aria-label="Move photo earlier">←</button>
|
||||
</form>
|
||||
<form method="post" th:action="@{/admin/items/{id}/photos/arrange(id=${item.id})}">
|
||||
<input type="hidden" name="key" th:value="${photo.key}">
|
||||
<input type="hidden" name="move" value="1">
|
||||
<button type="submit" class="btn-icon" th:disabled="${p.last}"
|
||||
aria-label="Move photo later">→</button>
|
||||
</form>
|
||||
</div>
|
||||
<form method="post" th:action="@{/admin/items/{id}/photos/arrange(id=${item.id})}">
|
||||
<input type="hidden" name="key" th:value="${photo.key}">
|
||||
<input type="hidden" name="move" value="0">
|
||||
<!--/* The server refuses to leave an item with no photos; saying so up front beats an
|
||||
error message. */-->
|
||||
<button type="submit" class="btn-icon" th:disabled="${#lists.size(item.photos) == 1}"
|
||||
th:title="${#lists.size(item.photos) == 1} ? 'An item needs at least one photo' : 'Remove photo'"
|
||||
aria-label="Remove photo">×</button>
|
||||
</form>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<form method="post" th:action="@{/admin/items/{id}/photos(id=${item.id})}"
|
||||
enctype="multipart/form-data" class="flex flex-wrap items-center gap-2">
|
||||
<input type="file" name="photos" accept="image/*" multiple required
|
||||
class="text-sm text-bakery-800 file:btn file:btn-secondary file:mr-3">
|
||||
<button type="submit" class="btn-secondary">Add photos</button>
|
||||
</form>
|
||||
<form method="post" th:action="@{/admin/items/{id}/delete(id=${item.id})}" class="ml-auto">
|
||||
<button type="submit" class="btn-danger"
|
||||
th:aria-label="|Remove ${item.name} from the products page|">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p th:if="${#lists.isEmpty(items)}" class="mt-3 text-bakery-600">
|
||||
Nothing here yet — add something above.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{admin/layout :: page('Goodie boxes & catering', ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content">
|
||||
|
||||
<section>
|
||||
<h2 class="card-heading">The price tables</h2>
|
||||
<p class="mt-1 text-sm text-bakery-600">
|
||||
In the order they appear on the page. Open one to change its columns, its prices or what's in it.
|
||||
A table with no columns or no lines stays off the public page until it has both.
|
||||
</p>
|
||||
|
||||
<ul class="mt-3 space-y-3">
|
||||
<li th:each="table, t : ${menu.packages}" class="card flex flex-wrap items-center gap-3">
|
||||
<div class="flex gap-1">
|
||||
<form method="post" th:action="@{/admin/catering/tables/{id}/move(id=${table.id})}">
|
||||
<input type="hidden" name="by" value="-1">
|
||||
<button type="submit" class="btn-icon" th:disabled="${t.first}"
|
||||
th:aria-label="|Move the ${table.name} table up|">↑</button>
|
||||
</form>
|
||||
<form method="post" th:action="@{/admin/catering/tables/{id}/move(id=${table.id})}">
|
||||
<input type="hidden" name="by" value="1">
|
||||
<button type="submit" class="btn-icon" th:disabled="${t.last}"
|
||||
th:aria-label="|Move the ${table.name} table down|">↓</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-60">
|
||||
<a th:href="@{/admin/catering/tables/{id}(id=${table.id})}"
|
||||
class="font-adbhashitha text-lg text-bakery-900 underline underline-offset-4"
|
||||
th:text="${table.name}">Office</a>
|
||||
<p class="text-sm text-bakery-600">
|
||||
<span th:text="|${#lists.size(table.tiers)} column${#lists.size(table.tiers) == 1 ? '' : 's'}|">3 columns</span>,
|
||||
<span th:text="|${#lists.size(table.rows)} line${#lists.size(table.rows) == 1 ? '' : 's'}|">3 lines</span>
|
||||
<span th:if="${#lists.isEmpty(table.tiers) or #lists.isEmpty(table.rows)}"
|
||||
class="text-bakery-700"> — not on the page yet</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a th:href="@{/admin/catering/tables/{id}(id=${table.id})}" class="btn-secondary">Edit</a>
|
||||
|
||||
<form method="post" th:action="@{/admin/catering/tables/{id}/delete(id=${table.id})}">
|
||||
<button type="submit" class="btn-danger"
|
||||
th:aria-label="|Delete the ${table.name} table|">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p th:if="${#lists.isEmpty(menu.packages)}" class="mt-3 text-bakery-600">
|
||||
No tables yet. The catering page will tell people to call instead until there is one.
|
||||
</p>
|
||||
|
||||
<form method="post" action="/admin/catering/tables" class="mt-4 flex gap-2">
|
||||
<label class="flex-1">
|
||||
<span class="sr-only">New table</span>
|
||||
<input class="field" name="name" placeholder="New table, e.g. Graduation parties" required>
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">Add</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!--/* The page's own terms, as opposed to the small print under one table. Replaced as a whole list:
|
||||
deleting one is an omission, which is the same rule the tables follow. */-->
|
||||
<section class="card">
|
||||
<h2 class="card-heading">Under the whole page</h2>
|
||||
<p class="mt-1 text-sm text-bakery-600">Terms that apply whichever table someone is reading.</p>
|
||||
|
||||
<form method="post" action="/admin/catering/notes" class="mt-3 space-y-2">
|
||||
<label th:each="note : ${menu.notes}" class="block">
|
||||
<span class="sr-only">Note</span>
|
||||
<textarea class="field min-h-[3.25rem]" rows="2" name="notes" th:text="${note}"></textarea>
|
||||
</label>
|
||||
<!--/* An empty box is how you delete one: blank notes are dropped on save. */-->
|
||||
<label class="block">
|
||||
<span class="sr-only">Another note</span>
|
||||
<textarea class="field min-h-[3.25rem]" rows="2" name="notes" placeholder="Add another note"></textarea>
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">Save these notes</button>
|
||||
<p class="text-sm text-bakery-600">Clearing a box and saving removes that note.</p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<!--/*
|
||||
The admin's shell.
|
||||
|
||||
It deliberately doesn't wear the site's chrome: the public nav would offer an editor links away from
|
||||
what they were doing, and the opening hours in the footer are noise on a screen whose whole job is the
|
||||
catalogue. Same stylesheet, same brand.
|
||||
|
||||
Getting here at all means signing in — /admin/** is an authenticated path, so an unknown visitor is
|
||||
sent to Authentik before any of this renders.
|
||||
*/-->
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:fragment="page(title, content)">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex">
|
||||
<link rel="icon" href="/images/resources/logo_L.png">
|
||||
<title th:text="|${title} · The Vine|">The Vine — admin</title>
|
||||
<link rel="stylesheet" th:href="|/css/site.css?v=${build}|">
|
||||
</head>
|
||||
<body>
|
||||
<div class="min-h-screen bg-bakery-50">
|
||||
<div class="mx-auto max-w-5xl px-4 py-10 sm:px-6">
|
||||
|
||||
<header class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="font-lejour text-4xl text-bakery-700">The Vine</h1>
|
||||
<nav class="mt-1 flex flex-wrap gap-4 text-sm">
|
||||
<a href="/admin" class="text-bakery-700 underline underline-offset-4 hover:text-bakery-900">The catalogue</a>
|
||||
<a href="/admin/catering" class="text-bakery-700 underline underline-offset-4 hover:text-bakery-900">Goodie boxes & catering</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/products" class="btn-secondary">View the site</a>
|
||||
<!--/* A real form post: the platform's logout expects one, and it also ends the Authentik
|
||||
session — a link would leave you signed in at the identity provider and straight back in
|
||||
on the next click. */-->
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="btn-secondary">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!--/* One place for the outcome of the last edit, whichever page posted it. */-->
|
||||
<div th:if="${problem}" role="alert"
|
||||
class="mt-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800"
|
||||
th:text="${problem}">Something did not save.</div>
|
||||
<div th:if="${done}" role="status"
|
||||
class="mt-6 rounded-md border border-bakery-200 bg-white px-4 py-3 text-sm text-bakery-800"
|
||||
th:text="${done}">Saved.</div>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<div th:replace="${content}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{admin/layout :: page(${table.name} + ' table', ~{::content})}">
|
||||
<body>
|
||||
<div th:fragment="content">
|
||||
<!--/*
|
||||
One price table, in one form.
|
||||
|
||||
Every button in here submits this form. `name="do"` says which was pressed, and its value carries the
|
||||
position it applies to (`remove-column:2`, `move-line:0:1`) — a button can only send its own name and
|
||||
value, so that is where the argument goes.
|
||||
|
||||
Only "Save" writes anything. The structural buttons come back with the table you were looking at, plus
|
||||
or minus a column or a line, and every cell you had typed still in it: adding a column adds an empty
|
||||
entry to every line, and removing one takes its entries with it, so the grid stays square. That
|
||||
alignment is the invariant CateringPackage#arrange refuses to break, and doing it on the server means
|
||||
there is one implementation of it rather than one here and one in a browser.
|
||||
*/-->
|
||||
<form method="post" th:action="@{/admin/catering/tables/{id}(id=${tableId})}" th:object="${table}">
|
||||
|
||||
<div class="card">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 class="card-heading">This table</h2>
|
||||
<a href="/admin/catering" class="text-sm text-bakery-700 underline underline-offset-4">All tables</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-2 sm:grid-cols-[14rem_1fr]">
|
||||
<label class="block">
|
||||
<span class="sr-only">Table name</span>
|
||||
<input class="field" th:field="*{name}" placeholder="Weddings" required>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="sr-only">A line under the heading</span>
|
||||
<input class="field" th:field="*{blurb}" placeholder="Optional — a line under the heading">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!--/* Wide tables scroll here rather than making the page scroll sideways. */-->
|
||||
<div class="mt-4 -mx-4 overflow-x-auto px-4">
|
||||
<table class="w-full border-separate border-spacing-1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="w-48 text-left text-sm font-medium text-bakery-600">What they get</th>
|
||||
<th th:each="column, c : *{columns}" scope="col" class="min-w-44 align-top">
|
||||
<input type="hidden" th:field="*{columns[__${c.index}__].id}">
|
||||
<input class="field" th:field="*{columns[__${c.index}__].label}" placeholder="Small"
|
||||
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}|">
|
||||
<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>
|
||||
<button type="submit" name="do" th:value="|move-column:${c.index}:1|" class="btn-icon"
|
||||
th:disabled="${c.last}" aria-label="Move this column right">→</button>
|
||||
<button type="submit" name="do" th:value="|remove-column:${c.index}|" class="btn-icon"
|
||||
title="Removes this column and its entries on every line"
|
||||
aria-label="Remove this column">×</button>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="w-28 align-top">
|
||||
<button type="submit" name="do" value="add-column" class="btn-secondary">+ Column</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="line, l : *{lines}">
|
||||
<th scope="row" class="text-left align-top">
|
||||
<input type="hidden" th:field="*{lines[__${l.index}__].id}">
|
||||
<input class="field" th:field="*{lines[__${l.index}__].label}" placeholder="Mini muffins"
|
||||
th:aria-label="|Name of line ${l.count}|">
|
||||
</th>
|
||||
<td th:each="value, v : ${line.values}" class="align-top">
|
||||
<input class="field" th:field="*{lines[__${l.index}__].values[__${v.index}__]}" placeholder="—"
|
||||
th:aria-label="|Line ${l.count}, column ${v.count}|">
|
||||
</td>
|
||||
<td class="align-top">
|
||||
<div class="flex gap-1">
|
||||
<button type="submit" name="do" th:value="|move-line:${l.index}:-1|" class="btn-icon"
|
||||
th:disabled="${l.first}" aria-label="Move this line up">↑</button>
|
||||
<button type="submit" name="do" th:value="|move-line:${l.index}:1|" class="btn-icon"
|
||||
th:disabled="${l.last}" aria-label="Move this line down">↓</button>
|
||||
<button type="submit" name="do" th:value="|remove-line:${l.index}|" class="btn-icon"
|
||||
aria-label="Remove this line">×</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button type="submit" name="do" value="add-line" class="btn-secondary mt-1">+ Line</button>
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="text-sm text-bakery-600">
|
||||
Small print under this table — minimums, what can't be mixed, how delivery is charged.
|
||||
</p>
|
||||
<div class="mt-2 space-y-2">
|
||||
<div th:each="note, n : *{notes}" class="flex items-start gap-2">
|
||||
<label class="flex-1">
|
||||
<span class="sr-only" th:text="|Note ${n.count}|">Note</span>
|
||||
<textarea class="field min-h-[3.25rem]" rows="2" th:field="*{notes[__${n.index}__]}"></textarea>
|
||||
</label>
|
||||
<button type="submit" name="do" th:value="|remove-note:${n.index}|" class="btn-icon mt-1"
|
||||
aria-label="Remove this note">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" name="do" value="add-note" class="btn-secondary mt-2">+ Note</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-bakery-100 pt-3">
|
||||
<button type="submit" name="do" value="save" class="btn-primary">Save this table</button>
|
||||
<a th:href="@{/admin/catering/tables/{id}(id=${tableId})}" class="btn-secondary">Start again</a>
|
||||
<span th:if="${unsaved}" class="text-sm text-bakery-700">
|
||||
Not saved yet — press Save when the table looks right.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,150 +0,0 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
/**
|
||||
* The catering admin over HTTP, signed in: the paths, the JSON field names and the shape of a refusal.
|
||||
*
|
||||
* <p>{@code CateringMenuTest} covers what the tables mean; this covers the surface the admin screen
|
||||
* actually calls. Both matter — a table can be modelled perfectly and still be unreachable because a
|
||||
* URL has a typo in it, and the screen reads the sentence out of a ProblemDetail rather than showing
|
||||
* a status code.
|
||||
*/
|
||||
@SpringBootTest(properties = {
|
||||
"SECURITY_MODE=OIDC",
|
||||
// Stated outright rather than via issuer-uri, which would fetch a discovery document at
|
||||
// startup — that needs the network and a real identity provider. (As in AdminSecurityTest.)
|
||||
"spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize",
|
||||
"spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token",
|
||||
"spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks",
|
||||
"spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo",
|
||||
"spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username",
|
||||
"spring.security.oauth2.client.registration.authentik.client-id=test",
|
||||
"spring.security.oauth2.client.registration.authentik.client-secret=test",
|
||||
"spring.security.oauth2.client.registration.authentik.scope=openid,profile,email",
|
||||
"spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code",
|
||||
"spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"platform.storage.access-key=test",
|
||||
"platform.storage.secret-key=test"
|
||||
})
|
||||
@Testcontainers
|
||||
// Rolled back per test, so each one starts from the seeded page. MockMvc runs the controller on this
|
||||
// thread, which is what lets the test's transaction wrap the whole request.
|
||||
@Transactional
|
||||
class AdminCateringApiTest {
|
||||
|
||||
@Container
|
||||
@ServiceConnection
|
||||
static PostgreSQLContainer<?> postgres =
|
||||
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// .apply(springSecurity()) is not optional: webAppContextSetup alone leaves the filter chain out.
|
||||
mvc = MockMvcBuilders.webAppContextSetup(context)
|
||||
.apply(SecurityMockMvcConfigurers.springSecurity())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void handsTheEditorEveryTableWithItsColumnsPricedAndItsLinesFilledIn() throws Exception {
|
||||
mvc.perform(get("/api/admin/catering").with(user("morissa")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.packages.length()").value(3))
|
||||
.andExpect(jsonPath("$.packages[0].name").value("Office"))
|
||||
// Written out, not a number the browser would have to format.
|
||||
.andExpect(jsonPath("$.packages[0].tiers[0].price").value("$24"))
|
||||
.andExpect(jsonPath("$.packages[0].rows[0].label").value("Mini muffins"))
|
||||
.andExpect(jsonPath("$.packages[0].rows[0].values[1]").value("18 items"))
|
||||
.andExpect(jsonPath("$.notes.length()").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void savesAWholeTableAtOnce() throws Exception {
|
||||
String office = """
|
||||
{"name":"Office boxes","blurb":"For meetings.",
|
||||
"tiers":[{"id":null,"label":"Dozen","price":"$18.50"}],
|
||||
"rows":[{"id":null,"label":"Mini muffins","values":["12 items"]}],
|
||||
"notes":["Two days' notice, please."]}
|
||||
""";
|
||||
|
||||
mvc.perform(put("/api/admin/catering/packages/1").with(user("morissa")).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(office))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.name").value("Office boxes"))
|
||||
// The column and line were new; they come back with ids so the next save edits them
|
||||
// rather than adding more.
|
||||
.andExpect(jsonPath("$.tiers[0].id").isNumber())
|
||||
.andExpect(jsonPath("$.tiers[0].price").value("$18.50"))
|
||||
.andExpect(jsonPath("$.rows[0].id").isNumber())
|
||||
.andExpect(jsonPath("$.notes[0]").value("Two days' notice, please."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesAnArrangementThatWouldMisprintThePricesAndSaysWhy() throws Exception {
|
||||
// Two columns, three entries on the line: exactly the mistake that shifts a box's contents.
|
||||
String crooked = """
|
||||
{"name":"Parties",
|
||||
"tiers":[{"id":null,"label":"Small","price":"54"},{"id":null,"label":"Large","price":"98"}],
|
||||
"rows":[{"id":null,"label":"Cake","values":["6 in","8 in","10 in"]}],
|
||||
"notes":[]}
|
||||
""";
|
||||
|
||||
mvc.perform(put("/api/admin/catering/packages/2").with(user("morissa")).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content(crooked))
|
||||
.andExpect(status().isBadRequest())
|
||||
// `detail` is the field the SPA shows the editor.
|
||||
.andExpect(jsonPath("$.detail")
|
||||
.value(containsString("3 entries but the table has 2 columns")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reordersTheTablesFromItsOwnPathRatherThanReadingOrderAsAnId() throws Exception {
|
||||
// /packages/order and /packages/{id} are both PUT; Spring's literal-beats-template rule is
|
||||
// what keeps "order" from arriving as a table id.
|
||||
mvc.perform(put("/api/admin/catering/packages/order").with(user("morissa")).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"ids\":[3,1,2]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].name").value("Weddings"))
|
||||
.andExpect(jsonPath("$[2].name").value("Parties"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replacesThePageNotesWithTheListItWasGiven() throws Exception {
|
||||
mvc.perform(put("/api/admin/catering/notes").with(user("morissa")).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("[\"Prices may change.\",\" \"]"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(1))
|
||||
.andExpect(jsonPath("$[0]").value("Prices may change."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
/**
|
||||
* The admin, signed in and driven the way a browser drives it: form posts and redirects.
|
||||
*
|
||||
* <p>These replace the JSON admin's tests. The screen used to be React talking to {@code
|
||||
* /api/admin/**}; it is now Thymeleaf forms, so what is worth asserting is that a form arrives bound
|
||||
* correctly, that an edit lands, that a refusal comes back readable rather than as a stack trace, and
|
||||
* that a structural button changes the draft without writing it.
|
||||
*
|
||||
* <p>Runs with {@code SECURITY_MODE=OIDC}, because that is the only condition under which the admin
|
||||
* exists at all.
|
||||
*/
|
||||
@SpringBootTest(properties = {
|
||||
"SECURITY_MODE=OIDC",
|
||||
// Endpoints stated outright rather than an issuer-uri, which would make Spring fetch the
|
||||
// discovery document at startup — that needs the network and a real identity provider.
|
||||
"spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize",
|
||||
"spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token",
|
||||
"spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks",
|
||||
"spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo",
|
||||
"spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username",
|
||||
"spring.security.oauth2.client.registration.authentik.client-id=test",
|
||||
"spring.security.oauth2.client.registration.authentik.client-secret=test",
|
||||
"spring.security.oauth2.client.registration.authentik.scope=openid,profile,email",
|
||||
"spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code",
|
||||
"spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"platform.storage.access-key=test",
|
||||
"platform.storage.secret-key=test"
|
||||
})
|
||||
@Testcontainers
|
||||
// Rolled back per test, so each starts from the seeded catalogue. MockMvc runs the controller on this
|
||||
// thread, which is what lets the test's transaction wrap the whole request.
|
||||
@Transactional
|
||||
class AdminPagesTest {
|
||||
|
||||
@Container
|
||||
@ServiceConnection
|
||||
static PostgreSQLContainer<?> postgres =
|
||||
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// .apply(springSecurity()) is not optional: webAppContextSetup alone leaves the filter chain out.
|
||||
mvc = MockMvcBuilders.webAppContextSetup(context)
|
||||
.apply(SecurityMockMvcConfigurers.springSecurity())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theCatalogueScreenShowsWhatIsOnThePageWithItsPhotos() throws Exception {
|
||||
mvc.perform(get("/admin").with(user("morissa")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("76th Birthday Cake")))
|
||||
// The photo URL and the key beside it: the key is what the arrange buttons name it by.
|
||||
.andExpect(content().string(containsString("products/76th_birthday_cake.webp")))
|
||||
.andExpect(content().string(containsString("On the page (40)")))
|
||||
// The filter list, with the count that decides whether Delete is offered.
|
||||
.andExpect(content().string(containsString("Cookies")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renamingAnItemLandsAndSaysSo() throws Exception {
|
||||
mvc.perform(post("/admin/items/1").with(user("morissa")).with(csrf())
|
||||
.param("name", "76th Birthday Cake (chocolate)")
|
||||
.param("category", "Cakes"))
|
||||
.andExpect(status().is3xxRedirection())
|
||||
.andExpect(redirectedUrl("/admin"))
|
||||
.andExpect(flash().attribute("done", containsString("Saved")));
|
||||
|
||||
mvc.perform(get("/admin").with(user("morissa")))
|
||||
.andExpect(content().string(containsString("76th Birthday Cake (chocolate)")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRefusalComesBackAsASentenceTheEditorCanActOn() throws Exception {
|
||||
// Cookies has items filed under it, and deleting the button shouldn't decide what happens to them.
|
||||
mvc.perform(post("/admin/categories/1/delete").with(user("morissa")).with(csrf()))
|
||||
.andExpect(redirectedUrl("/admin"))
|
||||
.andExpect(flash().attribute("problem", containsString("still filed under Cookies")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void movingAnItemUpFromTheTopIsNotAnError() throws Exception {
|
||||
// The button is disabled in the page, but a stale page could still post this.
|
||||
mvc.perform(post("/admin/items/1/move").with(user("morissa")).with(csrf()).param("by", "-1"))
|
||||
.andExpect(redirectedUrl("/admin"))
|
||||
.andExpect(flash().attributeCount(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theTableEditorRendersTheStoredTableAsAForm() throws Exception {
|
||||
mvc.perform(get("/admin/catering/tables/1").with(user("morissa")))
|
||||
.andExpect(status().isOk())
|
||||
// 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=\"18 items\"")))
|
||||
// The price round-trips as text: it came out "$24" and goes back the same way.
|
||||
.andExpect(content().string(containsString("value=\"$24\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void savingTheTableWritesEveryCell() throws Exception {
|
||||
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
|
||||
.param("do", "save")
|
||||
.param("name", "Office boxes")
|
||||
.param("blurb", "For meetings.")
|
||||
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$26")
|
||||
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
|
||||
.param("lines[0].values[0]", "14 items")
|
||||
.param("notes[0]", "Two days' notice, please."))
|
||||
.andExpect(redirectedUrl("/admin/catering"))
|
||||
.andExpect(flash().attribute("done", containsString("Saved the Office boxes table")));
|
||||
|
||||
mvc.perform(get("/api/catering"))
|
||||
.andExpect(content().string(containsString("Office boxes")))
|
||||
.andExpect(content().string(containsString("$26")))
|
||||
.andExpect(content().string(containsString("14 items")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addingAColumnKeepsWhatWasTypedAndWritesNothing() throws Exception {
|
||||
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
|
||||
.param("do", "add-column")
|
||||
.param("name", "Office")
|
||||
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$24")
|
||||
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
|
||||
// A cell edited but not yet saved: it has to survive the round trip.
|
||||
.param("lines[0].values[0]", "13 items"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("value=\"13 items\"")))
|
||||
// The new column exists in the form, and every line grew an entry to match it.
|
||||
.andExpect(content().string(containsString("name=\"columns[1].label\"")))
|
||||
.andExpect(content().string(containsString("name=\"lines[0].values[1]\"")))
|
||||
.andExpect(content().string(containsString("Not saved yet")));
|
||||
|
||||
// And nothing was written: the live page still says what it said.
|
||||
mvc.perform(get("/api/catering"))
|
||||
.andExpect(content().string(containsString("12 items")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void removingAColumnTakesItsCellsOutOfEveryLine() throws Exception {
|
||||
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
|
||||
.param("do", "remove-column:0")
|
||||
.param("name", "Office")
|
||||
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$24")
|
||||
.param("columns[1].id", "2").param("columns[1].label", "Medium").param("columns[1].price", "$32")
|
||||
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
|
||||
.param("lines[0].values[0]", "12 items")
|
||||
.param("lines[0].values[1]", "18 items"))
|
||||
.andExpect(status().isOk())
|
||||
// What is left is the Medium column and, under it, Medium's entry — not Small's.
|
||||
.andExpect(content().string(containsString("value=\"Medium\"")))
|
||||
.andExpect(content().string(containsString("value=\"18 items\"")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(containsString("value=\"12 items\""))))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(containsString("name=\"columns[1].label\""))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPriceThatIsntAPriceComesBackWithTheWorkStillInTheForm() throws Exception {
|
||||
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
|
||||
.param("do", "save")
|
||||
.param("name", "Office")
|
||||
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "ask us")
|
||||
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
|
||||
.param("lines[0].values[0]", "12 items"))
|
||||
// Not a redirect: a redirect would throw the work away and leave them guessing.
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("isn't a price")))
|
||||
.andExpect(content().string(containsString("value=\"ask us\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void thePageNotesAreReplacedByWhatTheFormSubmits() throws Exception {
|
||||
mvc.perform(post("/admin/catering/notes").with(user("morissa")).with(csrf())
|
||||
.param("notes", "Prices may change.")
|
||||
.param("notes", " ")
|
||||
.param("notes", "Two weeks' notice for a wedding."))
|
||||
.andExpect(redirectedUrl("/admin/catering"));
|
||||
|
||||
mvc.perform(get("/api/catering"))
|
||||
.andExpect(content().string(containsString("Two weeks' notice for a wedding.")))
|
||||
// The blank box was a deletion, not a note: two notes came back, not three.
|
||||
.andExpect(content().string(containsString("Prices may change.")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.not(containsString("\" \""))));
|
||||
}
|
||||
}
|
||||
@@ -70,21 +70,24 @@ class AdminSecurityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyAdminApiIsClosedToAnonymousVisitors() throws Exception {
|
||||
// csrf() on the writes, so these assert AUTHORIZATION (401), not a missing token.
|
||||
mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(get("/api/admin/categories")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(get("/api/admin/catering")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(post("/api/admin/products").with(csrf())).andExpect(status().isUnauthorized());
|
||||
mvc.perform(post("/api/admin/categories").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"x\"}"))
|
||||
void everyAdminWriteIsClosedToAnonymousVisitors() throws Exception {
|
||||
// csrf() on the writes, so these assert AUTHORIZATION, not a missing token. The admin is pages
|
||||
// and form posts now, so this is the whole surface — there is no JSON admin left to guard.
|
||||
mvc.perform(get("/admin")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(get("/admin/catering")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(get("/admin/catering/tables/1")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(post("/admin/items").with(csrf()).param("name", "Free cake").param("category", "Cakes"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
mvc.perform(post("/admin/items/1/delete").with(csrf())).andExpect(status().isUnauthorized());
|
||||
mvc.perform(post("/admin/categories").with(csrf()).param("name", "x"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
// The prices are the one thing on this site a stranger would most enjoy editing.
|
||||
mvc.perform(put("/api/admin/catering/packages/1").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"Free\",\"tiers\":[],\"rows\":[],\"notes\":[]}"))
|
||||
mvc.perform(post("/admin/catering/tables/1").with(csrf())
|
||||
.param("do", "save").param("name", "Free")
|
||||
.param("columns[0].label", "Any").param("columns[0].price", "0")
|
||||
.param("lines[0].label", "Everything").param("lines[0].values[0]", "yes"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
mvc.perform(put("/api/admin/catering/notes").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("[\"anything\"]"))
|
||||
mvc.perform(post("/admin/catering/notes").with(csrf()).param("notes", "anything"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@@ -92,10 +95,12 @@ class AdminSecurityTest {
|
||||
void theAdminPageRedirectsABrowserToLogin() throws Exception {
|
||||
// Protecting /admin server-side is what makes sign-in work: a browser opening it is bounced to
|
||||
// Authentik and comes back signed in. The redirect only fires for a request that prefers HTML —
|
||||
// the platform answers */* (a fetch/XHR) with a bare 401 so the SPA can handle it — so this
|
||||
// must send a browser's Accept header to see the 302. (Verified against a running container.)
|
||||
// the platform answers */* with a bare 401, which is why the checks above see 401 and this one
|
||||
// has to send a browser's Accept header to see the 302. (Verified against a running container.)
|
||||
mvc.perform(get("/admin").header("Accept", "text/html,application/xhtml+xml"))
|
||||
.andExpect(status().is3xxRedirection());
|
||||
mvc.perform(get("/admin/catering").header("Accept", "text/html,application/xhtml+xml"))
|
||||
.andExpect(status().is3xxRedirection());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@@ -25,10 +24,9 @@ import org.testcontainers.utility.DockerImageName;
|
||||
* <p>It used to extend {@code PlatformWebContract} from platform-starter-test and inherit these five
|
||||
* assertions verbatim. It can't any more, and the reason is worth stating: the shared contract asserts
|
||||
* that an unknown extension-less path forwards to {@code /index.html}, because it was written when every
|
||||
* app on the platform was a React SPA. This one is server-rendered now — {@code /index.html} holds
|
||||
* nothing but the admin shell — so forwarding a mistyped URL there would answer with a blank page and a
|
||||
* 200 instead of the site's own 404. The contract's own test methods are package-private, so the
|
||||
* assertion cannot be overridden from here.
|
||||
* app on the platform was a React SPA. There is no SPA here at all now — no shell to forward to — so
|
||||
* that assertion describes an app this no longer is. The contract's own test methods are package-private,
|
||||
* so it cannot be overridden from here.
|
||||
*
|
||||
* <p>The other four are restated below unchanged, so this app still fails the build on the regression
|
||||
* the contract exists for (an {@code /api} typo answering with a page and a 200).
|
||||
@@ -92,13 +90,12 @@ class PlatformContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unknown route is a 404, and only /admin serves the SPA shell")
|
||||
void routingIsServerSideExceptForTheAdmin() throws Exception {
|
||||
@DisplayName("routing is server-side: an unknown path is a 404, and so is /admin with no identity provider")
|
||||
void routingIsServerSide() throws Exception {
|
||||
mvc.perform(get("/some/client/side/route")).andExpect(status().isNotFound());
|
||||
// Assert the forward TARGET, not the body: MockMvc records a forward rather than executing it,
|
||||
// so the body is empty here by design — which also means this passes before the admin is built.
|
||||
mvc.perform(get("/admin"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(forwardedUrl("/index.html"));
|
||||
// No SECURITY_MODE here, so AdminController does not exist — "no Authentik configured" means "no
|
||||
// admin", and it 404s like any other unknown path rather than exposing catalogue writes.
|
||||
// AdminSecurityTest covers the other half: with OIDC on, /admin exists and needs a login.
|
||||
mvc.perform(get("/admin")).andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user