fix: wire edit forms for Datastar form submission and add CLI tests

Add {contentType: 'form'} to all edit template @put() calls so form
inputs are submitted by name rather than as Datastar signals. Replace
the quick notes text input on the brew edit page with toggle pill
buttons matching the add form. Add CLI tests for brew and cup update
commands.
This commit is contained in:
Jon Seager 2026-02-10 18:54:11 +00:00
parent 5f99ca0953
commit cb28a6f8bb
No known key found for this signature in database
11 changed files with 167 additions and 21 deletions

View file

@ -15,7 +15,7 @@
data-signals:_submit-error="''"
data-signals:_roast-date="'{{ roast_date }}'"
data-signals:_amount="{{ amount }}"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/bags/{{ id }}')"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/bags/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Bag updated'); window.location.href = evt.detail.response.headers.get('location') || '/bags/{{ id }}' }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"

View file

@ -19,8 +19,13 @@
data-signals:_water-volume="{{ water_volume }}"
data-signals:_water-temp="{{ water_temp }}"
data-signals:_brew-time="{{ brew_time }}"
data-signals:_quick-notes="'{{ quick_notes }}'"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/brews/{{ id }}')"
data-signals:_qn-good="{% if quick_notes.contains("good") %}true{% else %}false{% endif %}"
data-signals:_qn-too-fast="{% if quick_notes.contains("too-fast") %}true{% else %}false{% endif %}"
data-signals:_qn-too-slow="{% if quick_notes.contains("too-slow") %}true{% else %}false{% endif %}"
data-signals:_qn-too-hot="{% if quick_notes.contains("too-hot") %}true{% else %}false{% endif %}"
data-signals:_qn-under-extracted="{% if quick_notes.contains("under-extracted") %}true{% else %}false{% endif %}"
data-signals:_qn-over-extracted="{% if quick_notes.contains("over-extracted") %}true{% else %}false{% endif %}"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/brews/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Brew updated'); window.location.href = evt.detail.response.headers.get('location') || '/brews/{{ id }}' }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"
@ -301,19 +306,55 @@
<!-- Quick Notes -->
<div>
<h4 class="text-sm font-semibold text-text mb-3">Quick Notes</h4>
<label class="flex flex-col gap-1 text-sm">
<span
class="text-xs font-semibold text-text-muted uppercase tracking-wide"
>Notes (comma separated)</span
<div class="flex flex-wrap gap-2">
<button
type="button"
data-on:click="$_qnGood = !$_qnGood"
data-attr:class="$_qnGood ? 'pill pill-success cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Good
</button>
<button
type="button"
data-on:click="$_qnTooFast = !$_qnTooFast"
data-attr:class="$_qnTooFast ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Fast
</button>
<button
type="button"
data-on:click="$_qnTooSlow = !$_qnTooSlow"
data-attr:class="$_qnTooSlow ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Slow
</button>
<button
type="button"
data-on:click="$_qnTooHot = !$_qnTooHot"
data-attr:class="$_qnTooHot ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Too Hot
</button>
<button
type="button"
data-on:click="$_qnUnderExtracted = !$_qnUnderExtracted"
data-attr:class="$_qnUnderExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Under Extracted
</button>
<button
type="button"
data-on:click="$_qnOverExtracted = !$_qnOverExtracted"
data-attr:class="$_qnOverExtracted ? 'pill pill-warning cursor-pointer select-none transition' : 'pill pill-muted cursor-pointer select-none transition'"
>
Over Extracted
</button>
</div>
<input
type="text"
type="hidden"
name="quick_notes"
class="input-field"
placeholder="good, too-fast, under-extracted"
data-bind:_quick-notes
data-attr:value="[$_qnGood && 'good', $_qnTooFast && 'too-fast', $_qnTooSlow && 'too-slow', $_qnTooHot && 'too-hot', $_qnUnderExtracted && 'under-extracted', $_qnOverExtracted && 'over-extracted'].filter(Boolean).join(',')"
/>
</label>
</div>
{{ img::deferred_upload("edit-brew-image", "Brew Image") }}

View file

@ -20,7 +20,7 @@
data-signals:_latitude="{{ latitude }}"
data-signals:_longitude="{{ longitude }}"
data-signals:_website="'{{ website }}'"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/cafes/{{ id }}')"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/cafes/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Cafe updated'); window.location.href = evt.detail.response.headers.get('location') || '/cafes/{{ id }}' }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"

View file

@ -14,7 +14,7 @@
class="flex flex-col gap-4"
data-signals:_submitting="false"
data-signals:_submit-error="''"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/cups/{{ id }}')"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/cups/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Cup updated'); window.location.href = evt.detail.response.headers.get('location') || '/cups/{{ id }}' }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"

View file

@ -16,7 +16,7 @@
data-signals:_submit-error="''"
data-signals:_make="'{{ make }}'"
data-signals:_model="'{{ model }}'"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/gear/{{ id }}')"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/gear/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Gear updated'); window.location.href = evt.detail.response.headers.get('location') || '/gear/{{ id }}' }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"

View file

@ -20,7 +20,7 @@
data-signals:_producer="'{{ producer }}'"
data-signals:_process="'{{ process }}'"
data-signals:_tasting-notes="'{{ tasting_notes }}'"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasts/{{ id }}')"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasts/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roast updated'); window.location.href = evt.detail.response.headers.get('location') || '/roasts/{{ id }}' }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"

View file

@ -18,7 +18,7 @@
data-signals:_country="'{{ country }}'"
data-signals:_city="'{{ city }}'"
data-signals:_homepage="'{{ homepage }}'"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasters/{{ id }}')"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasters/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roaster updated'); window.location.href = evt.detail.response.headers.get('location') || '/roasters/{{ id }}' }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"

View file

@ -1,6 +1,7 @@
use crate::helpers::{
create_bag, create_gear, create_roast, create_roaster, create_token, run_brewlog,
create_bag, create_brew, create_gear, create_roast, create_roaster, create_token, run_brewlog,
};
use crate::test_macros::define_cli_auth_test;
#[test]
fn brew_add_creates_brew_via_api() {
@ -235,3 +236,45 @@ fn brew_delete_removes_brew() {
"brew get should fail after deletion"
);
}
define_cli_auth_test!(
test_update_brew_requires_authentication,
&["brew", "update", "--id", "123", "--coffee-weight", "16.0"]
);
#[test]
fn test_update_brew_with_authentication() {
let token = create_token("test-update-brew");
let roaster_id = create_roaster("Update Brew Roaster", &token);
let roast_id = create_roast(&roaster_id, "Update Brew Roast", &token);
let bag_id = create_bag(&roast_id, &token);
let grinder_id = create_gear("grinder", "Weber", "Key", &token);
let brewer_id = create_gear("brewer", "Origami", "Air", &token);
let brew_id = create_brew(&bag_id, &grinder_id, &brewer_id, &token);
let output = run_brewlog(
&[
"brew",
"update",
"--id",
&brew_id,
"--coffee-weight",
"16.0",
"--water-temp",
"94.0",
],
&[("BREWLOG_TOKEN", &token)],
);
assert!(
output.status.success(),
"brew update should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let brew: serde_json::Value = serde_json::from_str(&stdout).expect("Should output valid JSON");
assert_eq!(brew["coffee_weight"], 16.0);
assert_eq!(brew["water_temp"], 94.0);
}

36
tests/cli/cups_cli.rs Normal file
View file

@ -0,0 +1,36 @@
use crate::helpers::{
create_cafe, create_cup, create_roast, create_roaster, create_token, run_brewlog,
};
use crate::test_macros::define_cli_auth_test;
define_cli_auth_test!(
test_update_cup_requires_authentication,
&["cup", "update", "--id", "123", "--roast-id", "1"]
);
#[test]
fn test_update_cup_with_authentication() {
let token = create_token("test-update-cup");
let roaster_id = create_roaster("Update Cup Roaster", &token);
let roast_id = create_roast(&roaster_id, "Update Cup Roast", &token);
let cafe_id = create_cafe("Update Cup Cafe", "London", "UK", "51.5", "-0.1", &token);
let cup_id = create_cup(&roast_id, &cafe_id, &token);
// Create a second roast to update the cup to
let roast_id_2 = create_roast(&roaster_id, "Update Cup Roast 2", &token);
let output = run_brewlog(
&["cup", "update", "--id", &cup_id, "--roast-id", &roast_id_2],
&[("BREWLOG_TOKEN", &token)],
);
assert!(
output.status.success(),
"cup update should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let cup: serde_json::Value = serde_json::from_str(&stdout).expect("Should output valid JSON");
assert!(cup["id"].is_i64(), "cup should have an id");
}

View file

@ -316,3 +316,28 @@ pub fn create_gear(category: &str, make: &str, model: &str, token: &str) -> Stri
"gear",
)
}
pub fn create_brew(bag_id: &str, grinder_id: &str, brewer_id: &str, token: &str) -> String {
create_entity_cli(
&[
"brew",
"add",
"--bag-id",
bag_id,
"--grinder-id",
grinder_id,
"--brewer-id",
brewer_id,
],
token,
"brew",
)
}
pub fn create_cup(roast_id: &str, cafe_id: &str, token: &str) -> String {
create_entity_cli(
&["cup", "add", "--roast-id", roast_id, "--cafe-id", cafe_id],
token,
"cup",
)
}

View file

@ -2,6 +2,7 @@ pub mod backup_cli;
pub mod bags_cli;
pub mod brews_cli;
pub mod cafes_cli;
pub mod cups_cli;
pub mod gear_cli;
pub mod helpers;
pub mod roasters_cli;