From 37bcbf6c0cca133b0ce8e5a621c080e4141df20c Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Fri, 17 Jul 2026 10:40:43 +0200 Subject: [PATCH] feat(web): add edit proposal detail screen --- web/css/10-edit-inbox.css | 5 + web/css/19-edit-proposal.css | 99 +++++++ web/src/at_record_web/model.gleam | 21 ++ web/src/at_record_web/pages/edit_inbox.gleam | 48 ++-- .../at_record_web/pages/edit_proposal.gleam | 243 ++++++++++++++++++ web/src/at_record_web/route.gleam | 6 +- web/src/at_record_web/update.gleam | 46 +++- web/src/at_record_web/view.gleam | 20 +- web/test/edit_inbox_test.gleam | 16 +- web/test/edit_proposal_test.gleam | 127 +++++++++ 10 files changed, 596 insertions(+), 35 deletions(-) create mode 100644 web/css/19-edit-proposal.css create mode 100644 web/src/at_record_web/pages/edit_proposal.gleam create mode 100644 web/test/edit_proposal_test.gleam diff --git a/web/css/10-edit-inbox.css b/web/css/10-edit-inbox.css index 91097e3..39ea99f 100644 --- a/web/css/10-edit-inbox.css +++ b/web/css/10-edit-inbox.css @@ -5,6 +5,11 @@ letter-spacing: 0.3px; margin: 0 0 14px; } +.proposal-card__link { + display: block; + color: inherit; + text-decoration: none; +} .proposal-card__header { display: flex; flex-direction: column; diff --git a/web/css/19-edit-proposal.css b/web/css/19-edit-proposal.css new file mode 100644 index 0000000..f49ddb5 --- /dev/null +++ b/web/css/19-edit-proposal.css @@ -0,0 +1,99 @@ +/* --- proposal detail: context row --------------------------------------- */ +.proposal-context { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} +.proposal-context__info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.proposal-context__title { + font: 400 18px/1.2 var(--display); + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.proposal-context__by { + font: 400 12px/1.4 var(--mono); + color: var(--ink-muted); + margin: 0; +} +.handle-link { + color: var(--ink); + text-decoration: underline; + font-weight: 700; +} + +/* --- section band: a full-width ink chip used as a section header ------ */ +.section-band { + background: var(--ink); + color: var(--cream-fixed); + border: 2px solid var(--ink); + padding: 8px 12px; + font: 700 11px/1.2 var(--mono); + letter-spacing: 1px; + text-transform: uppercase; + margin: 0 0 12px; +} + +/* --- proposal detail: field diff panel ---------------------------------- */ +.proposal-diff-panel { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} +.diff-row__old--danger { + color: var(--danger); +} +.diff-row__old--empty { + color: var(--ink-muted); + text-decoration: none; +} +.diff-row__new--owned { + color: var(--owned); +} +.diff-hidden-note { + margin: 0; +} + +/* --- proposal detail: version context ----------------------------------- */ +.version-context { + display: flex; + flex-direction: column; + gap: 10px; + border: 2px dashed var(--ink); + padding: 14px 16px; + margin-bottom: 16px; +} +.version-row { + display: flex; + align-items: center; + gap: 10px; +} +.version-row__caption { + font: 700 12px/1.3 var(--mono); + color: var(--ink-muted); +} + +/* --- proposal detail: actions + footnote -------------------------------- */ +.proposal-detail-actions { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 8px; +} +.proposal-detail-actions .btn--primary { + border-width: 2.5px; + box-shadow: var(--shadow-md); + padding: 15px 18px; +} +.proposal-detail-footnote { + margin: 0; +} diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index 171a835..1b91b86 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -5,6 +5,7 @@ import gleam/int import gleam/list import gleam/option.{type Option, None} import gleam/set +import gleam/uri pub type Auth { LoggedOut @@ -21,6 +22,8 @@ pub type Route { Record(entry_id: String) Browse EditInbox + /// One proposal's detail screen, off `EditInbox`. + EditProposalDetail(id: String) Settings /// Another user's public crate, read-only and requiring no auth. PublicCrate(handle: String) @@ -455,6 +458,24 @@ pub fn inbox_pending_count(inbox: Inbox) -> Int { } } +/// The `EditProposalDetail` route's path id for a proposal: its own `uri` is +/// an AT-URI (slashes, colons), so it's percent-encoded to survive as a +/// single path segment. +pub fn proposal_route_id(proposal_uri: String) -> String { + uri.percent_encode(proposal_uri) +} + +/// The inbox card whose proposal resolves to this route id, if any. +pub fn find_proposal_card(inbox: Inbox, id: String) -> Option(InboxCard) { + case inbox { + InboxLoaded(cards) -> + cards + |> list.find(fn(c) { proposal_route_id(c.proposal.uri) == id }) + |> option.from_result + InboxLoading | InboxFailed -> None + } +} + /// Locally-ignored `catalog.edit` proposals, as uri+cid pairs (the same /// strongRef shape the proposal itself carries), persisted client-side only /// via `prefs.ignored_proposals_key` - a repo record would make "ignored" diff --git a/web/src/at_record_web/pages/edit_inbox.gleam b/web/src/at_record_web/pages/edit_inbox.gleam index 49c02b5..30b2f11 100644 --- a/web/src/at_record_web/pages/edit_inbox.gleam +++ b/web/src/at_record_web/pages/edit_inbox.gleam @@ -7,10 +7,11 @@ import at_record_web/model.{ type EditProposal, type Inbox, type InboxCard, type Model, EditInbox, - InboxFailed, InboxLoaded, InboxLoading, ProposalApplied, ProposalApplying, - ProposalReviewing, + EditProposalDetail, InboxFailed, InboxLoaded, InboxLoading, ProposalApplied, + ProposalApplying, ProposalReviewing, } import at_record_web/msg.{type Msg, ApplyProposal, IgnoreProposal, OnRouteChange} +import at_record_web/route import at_record_web/ui/controls as ctl import at_record_web/ui/covers as cov import gleam/int @@ -38,7 +39,7 @@ fn inbox_view(inbox: Inbox) -> Element(Msg) { ]) InboxLoaded([]) -> html.p([attr.class("hint")], [ - text("No proposals against your catalog records right now."), + text("No proposals against your pressings right now."), ]) InboxLoaded(cards) -> html.div([], [intro(cards), ..list.map(cards, card)]) } @@ -48,10 +49,7 @@ fn intro(cards: List(InboxCard)) -> Element(Msg) { let n = list.length(cards) html.p([attr.class("inbox-intro")], [ text( - int.to_string(n) - <> " proposal" - <> plural(n) - <> " against your catalog records", + int.to_string(n) <> " proposal" <> plural(n) <> " against your pressings", ), ]) } @@ -74,14 +72,26 @@ fn proposal_card(item: InboxCard) -> Element(Msg) { let p = item.proposal let applying = item.status == ProposalApplying cov.card([ - html.div([attr.class("proposal-card__header")], [ - html.span([attr.class("proposal-card__title")], [text(p.release_title)]), - html.span([attr.class("proposal-card__by")], [ - text(proposed_by(p.proposer_handle)), - ]), - ]), - html.hr([attr.class("proposal-divider")]), - diff_rows(p), + html.a( + [ + attr.class("proposal-card__link"), + attr.href( + route.to_path(EditProposalDetail(model.proposal_route_id(p.uri))), + ), + ], + [ + html.div([attr.class("proposal-card__header")], [ + html.span([attr.class("proposal-card__title")], [ + text(p.release_title), + ]), + html.span([attr.class("proposal-card__by")], [ + text(proposed_by(p.proposer_handle)), + ]), + ]), + html.hr([attr.class("proposal-divider")]), + diff_rows(p), + ], + ), actions(p.uri, p.cid, applying), ]) } @@ -183,10 +193,14 @@ fn apply_label(applying: Bool) -> String { } } -fn applied_strip(p: EditProposal) -> Element(Msg) { +/// Also reused by `pages/edit_proposal` when its detail route is opened for +/// an already-applied card (e.g. a stale link). +pub fn applied_strip(p: EditProposal) -> Element(Msg) { html.div([attr.class("applied-strip")], [ text( - "Applied: minted a superseding release for \"" <> p.release_title <> "\"", + "Applied: minted a superseding version of this pressing (\"" + <> p.release_title + <> "\")", ), ]) } diff --git a/web/src/at_record_web/pages/edit_proposal.gleam b/web/src/at_record_web/pages/edit_proposal.gleam new file mode 100644 index 0000000..63bc60c --- /dev/null +++ b/web/src/at_record_web/pages/edit_proposal.gleam @@ -0,0 +1,243 @@ +//// The "Suggested Fix" detail screen for a single edit proposal, opened by +//// tapping a card in the edit inbox: full field diff, version context, and +//// the same Adopt/Ignore actions as the inbox card itself. + +import at_record_web/model.{ + type EditProposal, type InboxCard, ProposalApplied, ProposalApplying, + PublicCrate, +} +import at_record_web/msg.{type Msg, ApplyProposal, IgnoreProposal} +import at_record_web/pages/edit_inbox +import at_record_web/route +import at_record_web/ui/controls as ctl +import at_record_web/ui/covers as cov +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html +import lustre/event + +/// The number of fields a `catalog.edit` proposal can diff (see +/// `model.ProposalFields`), used for the "N OF M FIELDS" section band. +const tracked_field_count = 5 + +pub fn view(card: InboxCard) -> Element(Msg) { + case card.status { + ProposalApplied(..) -> edit_inbox.applied_strip(card.proposal) + _ -> detail_view(card) + } +} + +fn detail_view(card: InboxCard) -> Element(Msg) { + let p = card.proposal + let applying = card.status == ProposalApplying + html.div([attr.class("body page-scroll")], [ + context_row(p), + field_diff_panel(p), + version_context_panel(p), + detail_actions(p.uri, p.cid, applying), + html.p([attr.class("hint proposal-detail-footnote")], [ + text( + "Adopting re-points your record to their version. Nothing merges " + <> "and nothing is lost: your current version stays live for anyone " + <> "whose strategy prefers it.", + ), + ]), + ]) +} + +fn context_row(p: EditProposal) -> Element(Msg) { + let color = cov.cover_color(p.release_title) + html.div([attr.class("proposal-context")], [ + html.div([attr.class("cover-tile cover-tile--row cover-tile--" <> color)], [ + cov.tile_art(None, p.release_title), + ]), + html.div([attr.class("proposal-context__info")], [ + html.span([attr.class("proposal-context__title")], [ + text(p.release_title), + ]), + proposer_line(p), + ]), + ]) +} + +fn proposer_line(p: EditProposal) -> Element(Msg) { + case p.proposer_handle { + Some(handle) -> + html.p([attr.class("proposal-context__by")], [ + html.a( + [ + attr.class("handle-link"), + attr.href(route.to_path(PublicCrate(handle))), + ], + [text("@" <> handle)], + ), + text(" suggests changes to your pressing"), + ]) + None -> + html.p([attr.class("proposal-context__by")], [ + text("An unresolved handle suggests changes to your pressing"), + ]) + } +} + +fn field_diff_panel(p: EditProposal) -> Element(Msg) { + let rows = + option.values([ + diff_row("TITLE", p.current.title, p.fields.title), + diff_row("RELEASED", p.current.released, p.fields.released), + diff_row("COUNTRY", p.current.country, p.fields.country), + diff_row_list("GENRES", p.current.genres, p.fields.genres), + diff_row_list("STYLES", p.current.styles, p.fields.styles), + ]) + let changed = list.length(rows) + let hidden = tracked_field_count - changed + let panel_rows = case hidden > 0 { + True -> list.append(rows, [hidden_note(hidden)]) + False -> rows + } + html.div([], [ + section_band("PROPOSED CHANGES", changed, tracked_field_count), + html.div([attr.class("card proposal-diff-panel")], panel_rows), + ]) +} + +fn section_band(label: String, n: Int, m: Int) -> Element(Msg) { + html.p([attr.class("section-band")], [ + text( + label + <> " · " + <> int.to_string(n) + <> " OF " + <> int.to_string(m) + <> " FIELDS", + ), + ]) +} + +fn hidden_note(hidden: Int) -> Element(Msg) { + html.p([attr.class("hint diff-hidden-note")], [ + text( + int.to_string(hidden) <> " unchanged field" <> plural(hidden) <> " hidden", + ), + ]) +} + +fn plural(n: Int) -> String { + case n { + 1 -> "" + _ -> "s" + } +} + +fn diff_row( + label: String, + old: Option(String), + new: Option(String), +) -> Option(Element(Msg)) { + option.then(new, fn(value) { + case old == Some(value) { + True -> None + False -> Some(diff_row_element(label, old, value)) + } + }) +} + +fn diff_row_list( + label: String, + old: Option(List(String)), + new: Option(List(String)), +) -> Option(Element(Msg)) { + option.then(new, fn(items) { + case old == Some(items) { + True -> None + False -> { + let old_str = case old { + Some([]) | None -> None + Some(values) -> Some(string.join(values, ", ")) + } + Some(diff_row_element(label, old_str, string.join(items, ", "))) + } + } + }) +} + +fn diff_row_element( + label: String, + old: Option(String), + new: String, +) -> Element(Msg) { + html.div([attr.class("diff-row")], [ + html.span([attr.class("diff-row__label")], [text(label)]), + old_value(old), + html.span([attr.class("diff-row__arrow")], [text("→")]), + html.span([attr.class("diff-row__new diff-row__new--owned")], [text(new)]), + ]) +} + +/// A field that had no prior value renders a plain muted placeholder rather +/// than the danger/strikethrough treatment, since there's nothing being +/// removed - only filled in. +fn old_value(old: Option(String)) -> Element(Msg) { + case old { + Some(value) -> + html.span([attr.class("diff-row__old diff-row__old--danger")], [ + text(value), + ]) + None -> + html.span([attr.class("diff-row__old diff-row__old--empty")], [ + text("(empty)"), + ]) + } +} + +fn version_context_panel(p: EditProposal) -> Element(Msg) { + html.div([attr.class("version-context")], [ + version_row("YOURS", ctl.Owned, "current version"), + version_row( + "THEIRS", + ctl.Wanted, + "superseding version · minted " <> minted_on(p.created_at), + ), + ]) +} + +fn version_row( + label: String, + variant: ctl.Variant, + caption: String, +) -> Element(Msg) { + html.div([attr.class("version-row")], [ + ctl.chip(label, variant), + html.span([attr.class("version-row__caption")], [text(caption)]), + ]) +} + +fn minted_on(created_at: String) -> String { + string.slice(created_at, 0, 10) +} + +fn detail_actions(uri: String, cid: String, applying: Bool) -> Element(Msg) { + html.div([attr.class("proposal-detail-actions")], [ + ctl.button(adopt_label(applying), ctl.Primary, [ + event.on_click(ApplyProposal(uri, cid)), + attr.disabled(applying), + attr.class("btn--block"), + ]), + ctl.button("IGNORE", ctl.Ghost, [ + event.on_click(IgnoreProposal(uri)), + attr.disabled(applying), + attr.class("btn--block"), + ]), + ]) +} + +fn adopt_label(applying: Bool) -> String { + case applying { + True -> "ADOPTING…" + False -> "ADOPT THIS VERSION" + } +} diff --git a/web/src/at_record_web/route.gleam b/web/src/at_record_web/route.gleam index 4bb77b0..d7f88db 100644 --- a/web/src/at_record_web/route.gleam +++ b/web/src/at_record_web/route.gleam @@ -1,8 +1,8 @@ //// URL <-> Route mapping for modem. import at_record_web/model.{ - type Route, Add, Browse, Crate, EditInbox, PublicCrate, PublicRecord, Record, - Scan, ScanDone, ScanReview, Settings, + type Route, Add, Browse, Crate, EditInbox, EditProposalDetail, PublicCrate, + PublicRecord, Record, Scan, ScanDone, ScanReview, Settings, } import gleam/uri.{type Uri} @@ -14,6 +14,7 @@ pub fn parse(target: Uri) -> Route { ["scan", "done"] -> ScanDone ["browse"] -> Browse ["inbox"] -> EditInbox + ["inbox", id] -> EditProposalDetail(id) ["settings"] -> Settings ["record", entry_id] -> Record(entry_id) ["u", handle] -> PublicCrate(handle) @@ -31,6 +32,7 @@ pub fn to_path(route: Route) -> String { ScanDone -> "/scan/done" Browse -> "/browse" EditInbox -> "/inbox" + EditProposalDetail(id) -> "/inbox/" <> id Settings -> "/settings" Record(entry_id) -> "/record/" <> entry_id PublicCrate(handle) -> "/u/" <> handle diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index 0602cf9..fbc0655 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -7,12 +7,12 @@ import at_record_web/effects.{ } import at_record_web/model.{ type BrowseRelease, type Entry, type Model, type Notice, Add, Browse, Discogs, - EditInbox, Entry, Failure, Form, InboxLoaded, InboxLoading, LoggedIn, - LoggedOut, Model, Notice, ProposalApplied, ProposalApplying, ProposalReviewing, - PublicCrate, PublicEntryFailed, PublicEntryLoaded, PublicEntryLoading, - PublicRecord, PublicShelfFailed, PublicShelfLoaded, PublicShelfLoading, Record, - Scan, ScanDone, ScanReview, ShelfFailed, ShelfLoaded, ShelfLoading, Success, - Warning, blank_form, crate_window_size, + EditInbox, EditProposalDetail, Entry, Failure, Form, InboxLoaded, InboxLoading, + LoggedIn, LoggedOut, Model, Notice, ProposalApplied, ProposalApplying, + ProposalReviewing, PublicCrate, PublicEntryFailed, PublicEntryLoaded, + PublicEntryLoading, PublicRecord, PublicShelfFailed, PublicShelfLoaded, + PublicShelfLoading, Record, Scan, ScanDone, ScanReview, ShelfFailed, + ShelfLoaded, ShelfLoading, Success, Warning, blank_form, crate_window_size, } import at_record_web/money import at_record_web/msg.{ @@ -104,12 +104,12 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { release_info: None, browse_adding: None, busy: case route { - Browse | EditInbox -> True + Browse | EditInbox | EditProposalDetail(_) -> True _ -> model.busy }, scan:, inbox: case route { - EditInbox -> InboxLoading + EditInbox | EditProposalDetail(_) -> InboxLoading _ -> model.inbox }, public_shelf: case route { @@ -129,7 +129,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { Scan -> effect.batch([start_capture(scan.mode), effects.scan_seen()]) Browse -> load_browse() - EditInbox -> effects.load_edit_inbox() + EditInbox | EditProposalDetail(_) -> effects.load_edit_inbox() PublicCrate(handle) -> load_public_shelf(handle) PublicRecord(handle, entry_id) -> load_public_entry(handle, entry_id) @@ -1078,7 +1078,10 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { ..model, inbox: set_card_status(model.inbox, uri, ProposalApplied(release_uri)), ), - load_shelf(model.view), + effect.batch([ + load_shelf(model.view), + leave_proposal_detail(model.route, uri), + ]), ) GotApplyProposal(uri, Error(e)) -> write_error( @@ -1103,7 +1106,10 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { inbox: remove_card(model.inbox, uri), ignored_proposals: ignored, ), - effects.persist_ignored_proposals(ignored), + effect.batch([ + effects.persist_ignored_proposals(ignored), + leave_proposal_detail(model.route, uri), + ]), ) } @@ -1156,6 +1162,24 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { } } +/// After acting on a proposal from its own detail route, return to the +/// inbox list; a no-op when the action fired from the list itself (already +/// on `EditInbox`), so the list page's own apply/ignore isn't disrupted by a +/// redundant navigation and inbox refetch. +fn leave_proposal_detail( + current_route: model.Route, + uri: String, +) -> Effect(Msg) { + case current_route { + EditProposalDetail(id) -> + case model.proposal_route_id(uri) == id { + True -> modem.push(route.to_path(EditInbox), None, None) + False -> effect.none() + } + _ -> effect.none() + } +} + fn find_proposal_cid(inbox: model.Inbox, uri: String) -> Option(String) { case inbox { InboxLoaded(cards) -> diff --git a/web/src/at_record_web/view.gleam b/web/src/at_record_web/view.gleam index d602978..0194bdf 100644 --- a/web/src/at_record_web/view.gleam +++ b/web/src/at_record_web/view.gleam @@ -3,14 +3,16 @@ import at_record_web/model.{ type Entry, type Model, type Notice, type NoticeLevel, Add, Browse, Crate, - EditInbox, Failure, Info, LoggedIn, LoggedOut, Notice, PublicCrate, - PublicRecord, Record, Scan, ScanDone, ScanReview, Settings, Success, Warning, + EditInbox, EditProposalDetail, Failure, Info, LoggedIn, LoggedOut, Notice, + PublicCrate, PublicRecord, Record, Scan, ScanDone, ScanReview, Settings, + Success, Warning, } import at_record_web/msg.{type Msg, ClearNotice} import at_record_web/pages/add import at_record_web/pages/browse import at_record_web/pages/crate import at_record_web/pages/edit_inbox +import at_record_web/pages/edit_proposal import at_record_web/pages/login import at_record_web/pages/public_crate import at_record_web/pages/public_record @@ -80,6 +82,18 @@ fn page(model: Model) -> Element(Msg) { ScanDone -> scan_done.view(model) Browse -> browse.view(model) EditInbox -> edit_inbox.view(model) + EditProposalDetail(id) -> + case model.inbox { + model.InboxLoaded(_) -> + case model.find_proposal_card(model.inbox, id) { + Some(card) -> edit_proposal.view(card) + // Unknown/expired id (stale link, or already ignored elsewhere): + // fall back to the list rather than a dead end. + None -> edit_inbox.view(model) + } + model.InboxLoading -> element.none() + model.InboxFailed -> edit_inbox.view(model) + } Settings -> settings.view(model) Record(entry_id) -> case find_entry(model.entries(model), entry_id) { @@ -113,6 +127,8 @@ fn app_bar(model: Model) -> Element(Msg) { ScanDone -> back_bar("IMPORT COMPLETE") Browse -> back_bar("BROWSE") EditInbox -> back_bar("EDIT INBOX") + EditProposalDetail(_) -> + back_bar_to("SUGGESTED FIX", route.to_path(EditInbox)) Settings -> back_bar("SETTINGS") Record(_) -> back_bar("RECORD") // Dispatched by `public_app_bar` before `authed_view`/`app_bar` are reached. diff --git a/web/test/edit_inbox_test.gleam b/web/test/edit_inbox_test.gleam index ea6e3c4..36f5c7b 100644 --- a/web/test/edit_inbox_test.gleam +++ b/web/test/edit_inbox_test.gleam @@ -145,6 +145,16 @@ fn rendered_inbox(cards: List(InboxCard)) -> String { |> element.to_string } +pub fn edit_inbox_view_wraps_the_card_in_a_link_to_its_detail_route_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let html = rendered_inbox([InboxCard(proposal:, status: ProposalReviewing)]) + let expected_href = + "href=\"/inbox/" <> model.proposal_route_id("at://proposer/edit/1") <> "\"" + assert string.contains(html, expected_href) + // The apply/ignore buttons stay outside the link, sibling to it. + assert string.contains(html, "class=\"proposal-actions\"") +} + pub fn edit_inbox_view_renders_diff_row_old_arrow_new_and_the_proposer_test() { let proposal = an_edit_proposal("at://proposer/edit/1") let html = rendered_inbox([InboxCard(proposal:, status: ProposalReviewing)]) @@ -189,7 +199,7 @@ pub fn edit_inbox_view_renders_the_applied_confirmation_strip_test() { // Element rendering HTML-escapes quotes, so match the escaped form. assert string.contains( html, - "Applied: minted a superseding release for "Spiderland"", + "Applied: minted a superseding version of this pressing ("Spiderland")", ) // No diff/actions for an already-applied card. assert !string.contains(html, "class=\"proposal-actions\"") @@ -203,13 +213,13 @@ pub fn edit_inbox_view_intro_counts_the_loaded_cards_test() { InboxCard(proposal: a, status: ProposalReviewing), InboxCard(proposal: b, status: ProposalReviewing), ]) - assert string.contains(html, "2 proposals against your catalog records") + assert string.contains(html, "2 proposals against your pressings") } pub fn edit_inbox_view_empty_state_has_no_intro_line_test() { let html = rendered_inbox([]) assert !string.contains(html, "class=\"inbox-intro\"") - assert string.contains(html, "No proposals against your catalog records") + assert string.contains(html, "No proposals against your pressings") } pub fn inbox_pending_count_is_zero_before_the_inbox_has_ever_loaded_test() { diff --git a/web/test/edit_proposal_test.gleam b/web/test/edit_proposal_test.gleam new file mode 100644 index 0000000..093f835 --- /dev/null +++ b/web/test/edit_proposal_test.gleam @@ -0,0 +1,127 @@ +import at_record_web/model.{ + type EditProposal, EditProposal, InboxCard, InboxFailed, InboxLoaded, + InboxLoading, ProposalApplied, ProposalFields, ProposalReviewing, +} +import at_record_web/pages/edit_proposal +import gleam/option.{None, Some} +import gleam/string +import lustre/element + +fn an_edit_proposal(uri: String) -> EditProposal { + EditProposal( + uri:, + cid: "cid-1", + proposer_did: "did:plc:bob", + proposer_handle: Some("bob.test"), + target_uri: "at://did:plc:alice/dev.mokkenstorm.crate.catalog.release/r1", + release_title: "Spiderland", + current: ProposalFields( + title: Some("Spiderland"), + released: Some("1991"), + country: Some("US"), + genres: Some(["Rock"]), + styles: None, + ), + fields: ProposalFields( + title: Some("Spiderland (Remastered)"), + released: None, + country: None, + genres: None, + styles: None, + ), + created_at: "2026-02-01T00:00:00Z", + ) +} + +pub fn proposal_route_id_percent_encodes_the_at_uri_test() { + let id = model.proposal_route_id("at://did:plc:bob/collection/rkey1") + // No literal slashes/colons survive, so the id stays one path segment. + assert !string.contains(id, "/") + assert !string.contains(id, ":") +} + +pub fn find_proposal_card_matches_by_route_id_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let card = InboxCard(proposal:, status: ProposalReviewing) + let id = model.proposal_route_id("at://proposer/edit/1") + assert model.find_proposal_card(InboxLoaded([card]), id) == Some(card) +} + +pub fn find_proposal_card_is_none_for_an_unknown_id_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let card = InboxCard(proposal:, status: ProposalReviewing) + assert model.find_proposal_card(InboxLoaded([card]), "unknown") == None +} + +pub fn find_proposal_card_is_none_while_loading_or_failed_test() { + assert model.find_proposal_card(InboxLoading, "any") == None + assert model.find_proposal_card(InboxFailed, "any") == None +} + +fn rendered(proposal: EditProposal) -> String { + InboxCard(proposal:, status: ProposalReviewing) + |> edit_proposal.view + |> element.to_string +} + +pub fn view_renders_the_section_band_with_changed_and_tracked_field_counts_test() { + let html = rendered(an_edit_proposal("at://proposer/edit/1")) + // Only `title` differs; 5 fields are tracked in total. + assert string.contains(html, "PROPOSED CHANGES · 1 OF 5 FIELDS") + assert string.contains(html, "4 unchanged fields hidden") +} + +pub fn view_colors_old_values_danger_and_new_values_owned_test() { + let html = rendered(an_edit_proposal("at://proposer/edit/1")) + assert string.contains(html, "diff-row__old--danger") + assert string.contains(html, "diff-row__new--owned") + assert string.contains(html, "Spiderland (Remastered)") +} + +pub fn view_shows_an_empty_placeholder_for_a_previously_unset_field_test() { + let proposal = + EditProposal( + ..an_edit_proposal("at://proposer/edit/1"), + current: ProposalFields( + title: None, + released: None, + country: None, + genres: None, + styles: None, + ), + ) + let html = rendered(proposal) + assert string.contains(html, "(empty)") + assert string.contains(html, "diff-row__old--empty") +} + +pub fn view_links_the_proposer_handle_to_their_public_crate_test() { + let html = rendered(an_edit_proposal("at://proposer/edit/1")) + assert string.contains(html, "href=\"/u/bob.test\"") + assert string.contains(html, "suggests changes to your pressing") +} + +pub fn view_renders_the_version_context_panel_test() { + let html = rendered(an_edit_proposal("at://proposer/edit/1")) + assert string.contains(html, "class=\"version-context\"") + assert string.contains(html, "YOURS") + assert string.contains(html, "current version") + assert string.contains(html, "THEIRS") + assert string.contains(html, "superseding version · minted 2026-02-01") +} + +pub fn view_wires_the_adopt_and_ignore_actions_test() { + let html = rendered(an_edit_proposal("at://proposer/edit/1")) + assert string.contains(html, "ADOPT THIS VERSION") + assert string.contains(html, "IGNORE") +} + +pub fn view_falls_back_to_the_applied_strip_for_an_applied_card_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let html = + InboxCard(proposal:, status: ProposalApplied("at://alice/release/rNEW")) + |> edit_proposal.view + |> element.to_string + assert string.contains(html, "class=\"applied-strip\"") + assert !string.contains(html, "ADOPT THIS VERSION") +} -- 2.51.2