Skip to content

Commit 3c611a3

Browse files
committed
Note when the selected workflow overrides the previous revision's stamp
1 parent 6bbb747 commit 3c611a3

1 file changed

Lines changed: 248 additions & 7 deletions

File tree

quilt-sync/ui/src/pages/commit.rs

Lines changed: 248 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ use leptos_router::hooks::{use_navigate, use_query_map};
33

44
use quilt_uri::S3PackageUri;
55

6-
use crate::commands::{self, CommitData, CommitWorkflows, EntryData, WorkflowInfo, WorkflowIntent};
6+
use crate::commands::{
7+
self, CommitData, CommitWorkflows, EntryData, WorkflowData, WorkflowInfo, WorkflowIntent,
8+
};
79
use crate::components::buttons;
810
use crate::components::layout::{BreadcrumbItem, BreadcrumbLink};
911
use crate::components::{
@@ -100,8 +102,8 @@ fn CommitContent(
100102
// model whose option list carries, per entry, the `WorkflowIntent` it
101103
// submits. The selected index into that list is the whole client-side
102104
// state.
103-
let previous_workflow_id = data.workflow.as_ref().and_then(|w| w.id.clone());
104-
let wf_view = build_workflow_view(&data.workflows, previous_workflow_id.as_deref());
105+
let previous_workflow = PreviousWorkflow::from_stamp(data.workflow.as_ref());
106+
let wf_view = build_workflow_view(&data.workflows, previous_workflow.preselect_id());
105107
// `wf_view.initial` is the single source of truth for the starting
106108
// selection: it both seeds this signal (which submit reads) and is passed
107109
// to `WorkflowSection` to render the `selected` attribute, so display and
@@ -114,6 +116,25 @@ fn CommitContent(
114116
let workflow_intents: Vec<WorkflowIntent> =
115117
wf_view.options.iter().map(|o| o.intent.clone()).collect();
116118

119+
// Neutral override note: recomputed as the selection changes, it surfaces
120+
// when the current pick diverges from what the previous revision stamped
121+
// (the bucket default winning over the previous pick in `preselected_index`
122+
// can silently override the user's earlier choice). Only the `Available`
123+
// state declares workflows to name; the other states carry an empty list
124+
// and never render the note.
125+
let note_workflows: Vec<WorkflowInfo> = match &data.workflows {
126+
CommitWorkflows::Available { workflows, .. } => workflows.clone(),
127+
CommitWorkflows::NotConfigured | CommitWorkflows::Unavailable => Vec::new(),
128+
};
129+
let note_intents = workflow_intents.clone();
130+
let workflow_note = Memo::new(move |_| {
131+
let current = note_intents
132+
.get(selected_workflow.get())
133+
.cloned()
134+
.unwrap_or(WorkflowIntent::BucketDefault);
135+
previous_workflow_note(&previous_workflow, &current, &note_workflows)
136+
});
137+
117138
// Filtered entries
118139
let entries_for_view = entries.clone();
119140
let filtered_entries = Memo::new(move |_| {
@@ -202,6 +223,7 @@ fn CommitContent(
202223
<WorkflowSection
203224
view=wf_view
204225
selected=selected_workflow
226+
note=workflow_note
205227
/>
206228

207229
// ── Namespace (readonly) ──
@@ -374,6 +396,79 @@ fn workflow_label(w: &WorkflowInfo) -> String {
374396
w.name.clone().unwrap_or_else(|| w.id.clone())
375397
}
376398

399+
/// The previous revision's workflow stamp, distinguishing the three cases the
400+
/// override note must tell apart.
401+
///
402+
/// `preselected_index` only needs the concrete `Named` id, but the note also
403+
/// cares whether the previous revision was never pushed versus explicitly
404+
/// stamped with no workflow — so this is the one place that distinction lives.
405+
#[derive(Clone, Debug, PartialEq, Eq)]
406+
enum PreviousWorkflow {
407+
NeverPushed,
408+
ExplicitNone,
409+
Named(String),
410+
}
411+
412+
impl PreviousWorkflow {
413+
/// Derive from the previous revision's stamp: absent → never pushed, present
414+
/// with no id → an explicit no-workflow choice, present with an id → that
415+
/// workflow.
416+
fn from_stamp(workflow: Option<&WorkflowData>) -> Self {
417+
match workflow {
418+
None => Self::NeverPushed,
419+
Some(WorkflowData { id: None }) => Self::ExplicitNone,
420+
Some(WorkflowData { id: Some(id) }) => Self::Named(id.clone()),
421+
}
422+
}
423+
424+
/// The workflow id `preselected_index` treats as the previous pick: only a
425+
/// concrete named workflow, collapsing never-pushed and explicit-none (the
426+
/// same behavior preselection had before this note existed).
427+
fn preselect_id(&self) -> Option<&str> {
428+
match self {
429+
Self::Named(id) => Some(id),
430+
Self::NeverPushed | Self::ExplicitNone => None,
431+
}
432+
}
433+
}
434+
435+
/// Neutral note shown under the dropdown when the currently-selected workflow
436+
/// diverges from what the previous revision stamped, so the override (the
437+
/// bucket default winning over the previous pick in `preselected_index`) is
438+
/// visible. `None` means no divergence to report.
439+
///
440+
/// Pure and signal-free: the render layer recomputes it whenever the selection
441+
/// changes.
442+
fn previous_workflow_note(
443+
previous: &PreviousWorkflow,
444+
current: &WorkflowIntent,
445+
workflows: &[WorkflowInfo],
446+
) -> Option<String> {
447+
match previous {
448+
// Nothing was ever stamped, so nothing can be overridden.
449+
PreviousWorkflow::NeverPushed => None,
450+
PreviousWorkflow::ExplicitNone => match current {
451+
WorkflowIntent::NoWorkflow => None,
452+
WorkflowIntent::BucketDefault | WorkflowIntent::Named(_) => {
453+
Some("The previous revision used no workflow.".to_string())
454+
}
455+
},
456+
PreviousWorkflow::Named(prev) => {
457+
if matches!(current, WorkflowIntent::Named(id) if id == prev) {
458+
None
459+
} else {
460+
let label = workflows
461+
.iter()
462+
.find(|w| &w.id == prev)
463+
.map_or_else(|| prev.clone(), workflow_label);
464+
Some(format!(
465+
"The previous revision used the \"{label}\" workflow."
466+
))
467+
}
468+
}
469+
}
470+
}
471+
377472
/// Build the dropdown's option list for the [`CommitWorkflows::Available`]
378473
/// state, matching the web catalog's order:
379474
/// - index 0: `None` → [`WorkflowIntent::NoWorkflow`], disabled when a workflow
@@ -563,7 +658,13 @@ fn build_workflow_view(
563658
// ── Workflow section ──
564659

565660
#[component]
566-
fn WorkflowSection(view: WorkflowView, selected: RwSignal<usize>) -> impl IntoView {
661+
fn WorkflowSection(
662+
view: WorkflowView,
663+
selected: RwSignal<usize>,
664+
/// Neutral divergence note, live over `selected`. Rendered only in the
665+
/// `Available` state; the other states have no dropdown to override.
666+
note: Memo<Option<String>>,
667+
) -> impl IntoView {
567668
let WorkflowView {
568669
kind,
569670
options,
@@ -573,7 +674,7 @@ fn WorkflowSection(view: WorkflowView, selected: RwSignal<usize>) -> impl IntoVi
573674
match kind {
574675
WorkflowViewKind::Available {
575676
is_workflow_required,
576-
} => workflow_dropdown(options, selected, initial, is_workflow_required).into_any(),
677+
} => workflow_dropdown(options, selected, initial, is_workflow_required, note).into_any(),
577678
// Ungoverned bucket: a single disabled `None`, plus a hint explaining
578679
// why there is no choice to make. Submit already carries `BucketDefault`
579680
// via `options[0]`.
@@ -611,6 +712,7 @@ fn workflow_dropdown(
611712
selected: RwSignal<usize>,
612713
initial: usize,
613714
is_workflow_required: bool,
715+
note: Memo<Option<String>>,
614716
) -> impl IntoView {
615717
// Intents indexed by option position — used to decide whether the current
616718
// selection is the (disabled) `None` item, which drives the required hint.
@@ -661,6 +763,11 @@ fn workflow_dropdown(
661763
<Show when=show_required_hint>
662764
<span class="error">"Workflow is required for this bucket."</span>
663765
</Show>
766+
// Neutral note: appears when the current pick diverges from the
767+
// previous revision's stamp, and disappears when they match again.
768+
{move || {
769+
note.get().map(|text| view! { <p class="hint">{text}</p> })
770+
}}
664771
</div>
665772
}
666773
}
@@ -968,9 +1075,10 @@ fn JsonEditor(id: &'static str, initial_value: String) -> impl IntoView {
9681075
#[cfg(test)]
9691076
mod tests {
9701077
use super::{
971-
WorkflowOption, WorkflowViewKind, build_workflow_view, preselected_index, workflow_options,
1078+
PreviousWorkflow, WorkflowOption, WorkflowViewKind, build_workflow_view, preselected_index,
1079+
previous_workflow_note, workflow_options,
9721080
};
973-
use crate::commands::{CommitWorkflows, WorkflowInfo, WorkflowIntent};
1081+
use crate::commands::{CommitWorkflows, WorkflowData, WorkflowInfo, WorkflowIntent};
9741082

9751083
fn wf(id: &str, name: Option<&str>) -> WorkflowInfo {
9761084
WorkflowInfo {
@@ -1255,6 +1363,139 @@ mod tests {
12551363
assert_eq!(opts[idx].label, "Alpha WF (default)");
12561364
}
12571365

1366+
// ── Previous-workflow override note (pure) ──
1367+
1368+
#[test]
1369+
fn note_never_pushed_is_silent() {
1370+
// No prior stamp → nothing can be overridden, regardless of selection.
1371+
let wfs = vec![wf("alpha", Some("Alpha WF"))];
1372+
for current in [
1373+
WorkflowIntent::BucketDefault,
1374+
WorkflowIntent::NoWorkflow,
1375+
WorkflowIntent::Named("alpha".to_string()),
1376+
] {
1377+
assert_eq!(
1378+
previous_workflow_note(&PreviousWorkflow::NeverPushed, &current, &wfs),
1379+
None
1380+
);
1381+
}
1382+
}
1383+
1384+
#[test]
1385+
fn note_explicit_none_silent_when_current_is_no_workflow() {
1386+
let wfs = vec![wf("alpha", Some("Alpha WF"))];
1387+
assert_eq!(
1388+
previous_workflow_note(
1389+
&PreviousWorkflow::ExplicitNone,
1390+
&WorkflowIntent::NoWorkflow,
1391+
&wfs
1392+
),
1393+
None
1394+
);
1395+
}
1396+
1397+
#[test]
1398+
fn note_explicit_none_flags_divergence_to_a_workflow() {
1399+
let wfs = vec![wf("alpha", Some("Alpha WF"))];
1400+
// Current selection is a named workflow → the previous no-workflow is
1401+
// being overridden.
1402+
assert_eq!(
1403+
previous_workflow_note(
1404+
&PreviousWorkflow::ExplicitNone,
1405+
&WorkflowIntent::Named("alpha".to_string()),
1406+
&wfs
1407+
),
1408+
Some("The previous revision used no workflow.".to_string())
1409+
);
1410+
// BucketDefault likewise diverges from an explicit no-workflow.
1411+
assert_eq!(
1412+
previous_workflow_note(
1413+
&PreviousWorkflow::ExplicitNone,
1414+
&WorkflowIntent::BucketDefault,
1415+
&wfs
1416+
),
1417+
Some("The previous revision used no workflow.".to_string())
1418+
);
1419+
}
1420+
1421+
#[test]
1422+
fn note_named_silent_when_current_matches_previous() {
1423+
let wfs = vec![wf("alpha", Some("Alpha WF"))];
1424+
assert_eq!(
1425+
previous_workflow_note(
1426+
&PreviousWorkflow::Named("alpha".to_string()),
1427+
&WorkflowIntent::Named("alpha".to_string()),
1428+
&wfs
1429+
),
1430+
None
1431+
);
1432+
}
1433+
1434+
#[test]
1435+
fn note_named_uses_workflow_name_when_in_list() {
1436+
let wfs = vec![wf("alpha", Some("Alpha WF")), wf("beta", None)];
1437+
// Current selection differs (another named workflow) → show the
1438+
// previous workflow's display name.
1439+
assert_eq!(
1440+
previous_workflow_note(
1441+
&PreviousWorkflow::Named("alpha".to_string()),
1442+
&WorkflowIntent::Named("beta".to_string()),
1443+
&wfs
1444+
),
1445+
Some("The previous revision used the \"Alpha WF\" workflow.".to_string())
1446+
);
1447+
// BucketDefault also diverges from the previous named pick; unnamed
1448+
// workflow falls back to its id as the label.
1449+
assert_eq!(
1450+
previous_workflow_note(
1451+
&PreviousWorkflow::Named("beta".to_string()),
1452+
&WorkflowIntent::BucketDefault,
1453+
&wfs
1454+
),
1455+
Some("The previous revision used the \"beta\" workflow.".to_string())
1456+
);
1457+
}
1458+
1459+
#[test]
1460+
fn note_named_falls_back_to_id_when_absent_from_list() {
1461+
// Previous workflow id no longer declared → use the raw id in the note.
1462+
let wfs = vec![wf("alpha", Some("Alpha WF"))];
1463+
assert_eq!(
1464+
previous_workflow_note(
1465+
&PreviousWorkflow::Named("ghost".to_string()),
1466+
&WorkflowIntent::Named("alpha".to_string()),
1467+
&wfs
1468+
),
1469+
Some("The previous revision used the \"ghost\" workflow.".to_string())
1470+
);
1471+
}
1472+
1473+
#[test]
1474+
fn previous_workflow_from_stamp_and_preselect_id() {
1475+
// Never pushed / explicit-none collapse to no preselect id; a named
1476+
// stamp yields exactly that id (preselection behavior is unchanged).
1477+
assert_eq!(
1478+
PreviousWorkflow::from_stamp(None),
1479+
PreviousWorkflow::NeverPushed
1480+
);
1481+
assert_eq!(
1482+
PreviousWorkflow::from_stamp(Some(&WorkflowData { id: None })),
1483+
PreviousWorkflow::ExplicitNone
1484+
);
1485+
assert_eq!(
1486+
PreviousWorkflow::from_stamp(Some(&WorkflowData {
1487+
id: Some("alpha".to_string())
1488+
})),
1489+
PreviousWorkflow::Named("alpha".to_string())
1490+
);
1491+
assert_eq!(PreviousWorkflow::NeverPushed.preselect_id(), None);
1492+
assert_eq!(PreviousWorkflow::ExplicitNone.preselect_id(), None);
1493+
assert_eq!(
1494+
PreviousWorkflow::Named("alpha".to_string()).preselect_id(),
1495+
Some("alpha")
1496+
);
1497+
}
1498+
12581499
#[test]
12591500
fn view_non_available_states_submit_bucket_default() {
12601501
// The submit path reads `options[selected].intent`; both non-Available

0 commit comments

Comments
 (0)