diff --git a/web/src/crate_web.gleam b/web/src/crate_web.gleam index e341533..a95e1d7 100644 --- a/web/src/crate_web.gleam +++ b/web/src/crate_web.gleam @@ -66,6 +66,7 @@ fn init(_flags) -> #(Model, Effect(Msg)) { browse_filters_open: False, inbox: model.InboxLoading, confirm_logout: False, + confirm_apply: None, ignored_proposals: initial_ignored_proposals(), overlap: None, pressing: model.PressingLoading, diff --git a/web/src/crate_web/browser.gleam b/web/src/crate_web/browser.gleam index d6a652e..9124c37 100644 --- a/web/src/crate_web/browser.gleam +++ b/web/src/crate_web/browser.gleam @@ -17,6 +17,12 @@ pub fn navigate(url: String) -> Nil @external(javascript, "./ffi.ts", "origin") pub fn origin() -> String +/// Today's UTC calendar date as "YYYY-MM-DD", matching the day slice off +/// entry `updatedAt` timestamps - so the feed's relative-day labels compare +/// like with like. +@external(javascript, "./ffi.ts", "today") +pub fn today() -> String + @external(javascript, "./ffi.ts", "debounce") pub fn debounce(channel: String, ms: Int, callback: fn() -> Nil) -> Nil diff --git a/web/src/crate_web/ffi.ts b/web/src/crate_web/ffi.ts index a71f156..3a2018e 100644 --- a/web/src/crate_web/ffi.ts +++ b/web/src/crate_web/ffi.ts @@ -164,6 +164,11 @@ export function origin(): string { return globalThis.location?.origin ?? ""; } +// UTC calendar date, matching the day slice off server ISO timestamps. +export function today(): string { + return new Date().toISOString().slice(0, 10); +} + export function copyToClipboard(path: string, cb: (ok: boolean) => void): void { const url = (globalThis.location?.origin ?? "") + path; if (!navigator.clipboard?.writeText) { diff --git a/web/src/crate_web/model.gleam b/web/src/crate_web/model.gleam index f0dfbdf..9f79015 100644 --- a/web/src/crate_web/model.gleam +++ b/web/src/crate_web/model.gleam @@ -76,6 +76,10 @@ pub const confirm_timeout_ms = 4000 /// can tell a tap on it apart from a tap elsewhere. pub const remove_button_id = "record-remove-btn" +/// DOM id of the settings DISCONNECT (Discogs) button, same reasoning as +/// `remove_button_id`. +pub const discogs_disconnect_button_id = "settings-discogs-disconnect-btn" + /// DOM id of the settings LOG OUT button, same reasoning as `remove_button_id`. pub const logout_button_id = "settings-logout-btn" @@ -662,6 +666,12 @@ pub fn proposal_route_id(proposal_uri: String) -> String { uri.percent_encode(proposal_uri) } +/// DOM id of a proposal's APPLY/ADOPT confirm button, so the outside-click +/// watcher can tell a tap on it apart from a tap on any other card's. +pub fn apply_button_id(proposal_uri: String) -> String { + "apply-btn-" <> proposal_route_id(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 { @@ -780,6 +790,11 @@ pub type Model { inbox: Inbox, // Armed like `confirm_remove`: first tap arms, second tap executes. confirm_logout: Bool, + // The uri of the proposal currently armed for APPLY/ADOPT (inbox card or + // proposal detail, whichever fired the arm), same two-tap reasoning as + // `confirm_remove`; `None` means nothing armed. A single field rather + // than per-card state since only one proposal can be armed at a time. + confirm_apply: Option(String), // Locally-ignored edit-proposal uri+cid pairs, loaded from // `prefs.ignored_proposals_key` at init; see `add_ignored_proposal`. ignored_proposals: List(#(String, String)), @@ -802,7 +817,9 @@ pub type Model { } /// Discogs seed/import state: the autocomplete query and its paged results, -/// the connected account, and whether an import is in flight. +/// the connected account, and which of the two import runs (if any) is in +/// flight. Collection and wantlist imports get their own flag so triggering +/// one never shows IMPORTING… on the other's button too. pub type Discogs { Discogs( query: String, @@ -812,7 +829,14 @@ pub type Discogs { page: Int, pages: Int, username: Option(String), - importing: Bool, + importing_collection: Bool, + importing_wantlist: Bool, + // Armed like `confirm_remove`: first tap arms, second tap executes. + confirm_disconnect: Bool, + // The last import/disconnect failure, shown inline on the Discogs card + // rather than only as a transient global notice; cleared on the next + // attempt or a successful one. + error: Option(String), artist_query: String, artist_results: List(ArtistHit), artist_id: Option(String), @@ -828,9 +852,18 @@ pub fn blank_discogs() -> Discogs { page: 1, pages: 0, username: None, - importing: False, + importing_collection: False, + importing_wantlist: False, + confirm_disconnect: False, + error: None, artist_query: "", artist_results: [], artist_id: None, ) } + +/// Whether either Discogs import is currently in flight, for callers that +/// don't need to know which. +pub fn discogs_importing(discogs: Discogs) -> Bool { + discogs.importing_collection || discogs.importing_wantlist +} diff --git a/web/src/crate_web/msg.gleam b/web/src/crate_web/msg.gleam index 56c0a13..e49a94a 100644 --- a/web/src/crate_web/msg.gleam +++ b/web/src/crate_web/msg.gleam @@ -199,4 +199,20 @@ pub type Msg { /// `catalog.getRelease`'s response, for a pressing detail page opened /// directly rather than off a browse card already sitting in `model.browse`. GotPressing(Result(BrowseRelease, ApiError)) + /// Re-run the pressing detail fetch after a failed load. + RetryPressing + /// Re-run the network feed's first-page fetch after a failed load. + RetryFeed + /// Re-run a public crate's fetch after a failed load; targets whichever + /// `/u/:handle` route is currently open. + RetryPublicCrate + /// Re-run a public record's fetch after a failed load; targets whichever + /// `/u/:handle/record/:entryId` route is currently open. + RetryPublicRecord + /// First tap on an inbox card's APPLY or a proposal detail's ADOPT: arms + /// the confirm, same two-tap reasoning as `ArmRemove`/`ArmLogout`. + ArmApplyProposal(uri: String) + DisarmApplyProposal + ArmDiscogsDisconnect + DisarmDiscogsDisconnect } diff --git a/web/src/crate_web/pages/edit_inbox.gleam b/web/src/crate_web/pages/edit_inbox.gleam index b16c554..89cce79 100644 --- a/web/src/crate_web/pages/edit_inbox.gleam +++ b/web/src/crate_web/pages/edit_inbox.gleam @@ -10,10 +10,13 @@ import crate_web/model.{ EditProposalDetail, InboxFailed, InboxLoaded, InboxLoading, ProposalApplied, ProposalApplying, ProposalReviewing, } -import crate_web/msg.{type Msg, ApplyProposal, IgnoreProposal, OnRouteChange} +import crate_web/msg.{ + type Msg, ApplyProposal, ArmApplyProposal, IgnoreProposal, OnRouteChange, +} import crate_web/route import crate_web/ui/controls as ctl import crate_web/ui/covers as cov +import crate_web/ui/states import gleam/int import gleam/list import gleam/option.{type Option, None, Some} @@ -23,13 +26,20 @@ import lustre/element.{type Element, text} import lustre/element/html import lustre/event +/// Shared with `pages/edit_proposal`'s ADOPT action: both fire the same +/// `catalog.applyEditProposal` write, so the confirm names the same +/// consequence either way. +pub const apply_confirm_label = "MINT PERMANENTLY?" + pub fn view(model: Model) -> Element(Msg) { - html.div([attr.class("body page-scroll")], [inbox_view(model.inbox)]) + html.div([attr.class("body page-scroll")], [ + inbox_view(model.inbox, model.confirm_apply), + ]) } -fn inbox_view(inbox: Inbox) -> Element(Msg) { +fn inbox_view(inbox: Inbox, confirm_apply: Option(String)) -> Element(Msg) { case inbox { - InboxLoading -> element.none() + InboxLoading -> states.loading_page() InboxFailed -> cov.card([ html.p([attr.class("hint")], [text("Couldn't load your edit inbox.")]), @@ -46,7 +56,8 @@ fn inbox_view(inbox: Inbox) -> Element(Msg) { ), ]), ]) - InboxLoaded(cards) -> html.div([], [intro(cards), ..list.map(cards, card)]) + InboxLoaded(cards) -> + html.div([], [intro(cards), ..list.map(cards, card(_, confirm_apply))]) } } @@ -66,14 +77,15 @@ fn plural(n: Int) -> String { } } -fn card(item: InboxCard) -> Element(Msg) { +fn card(item: InboxCard, confirm_apply: Option(String)) -> Element(Msg) { case item.status { ProposalApplied(..) -> applied_strip(item.proposal) - ProposalReviewing | ProposalApplying -> proposal_card(item) + ProposalReviewing | ProposalApplying -> + proposal_card(item, confirm_apply == Some(item.proposal.uri)) } } -fn proposal_card(item: InboxCard) -> Element(Msg) { +fn proposal_card(item: InboxCard, armed: Bool) -> Element(Msg) { let p = item.proposal let applying = item.status == ProposalApplying cov.card([ @@ -97,7 +109,7 @@ fn proposal_card(item: InboxCard) -> Element(Msg) { diff_rows(p), ], ), - actions(p.uri, p.cid, applying), + actions(p.uri, p.cid, applying, armed), ]) } @@ -180,15 +192,25 @@ fn display_list(value: Option(List(String))) -> String { } } -fn actions(uri: String, cid: String, applying: Bool) -> Element(Msg) { +fn actions( + uri: String, + cid: String, + applying: Bool, + armed: Bool, +) -> Element(Msg) { html.div([attr.class("proposal-actions")], [ - ctl.button(apply_label(applying), ctl.Primary, [ - event.on_click(ApplyProposal(uri, cid)), - attr.disabled(applying), - ]), + ctl.confirm_button( + apply_label(applying), + apply_confirm_label, + ctl.Primary, + armed, + ArmApplyProposal(uri), + ApplyProposal(uri, cid), + [attr.id(model.apply_button_id(uri)), attr.disabled(applying)], + ), ctl.button("IGNORE", ctl.Ghost, [ event.on_click(IgnoreProposal(uri)), - attr.disabled(applying), + attr.disabled(applying || armed), ]), ]) } diff --git a/web/src/crate_web/pages/edit_proposal.gleam b/web/src/crate_web/pages/edit_proposal.gleam index 0be5d23..5e60ecb 100644 --- a/web/src/crate_web/pages/edit_proposal.gleam +++ b/web/src/crate_web/pages/edit_proposal.gleam @@ -3,11 +3,11 @@ //// the same Adopt/Ignore actions as the inbox card itself. import crate_web/model.{ - type EditProposal, type InboxCard, ProposalApplied, ProposalApplying, - PublicCrate, + type EditProposal, type InboxCard, type Model, ProposalApplied, + ProposalApplying, PublicCrate, } -import crate_web/msg.{type Msg, ApplyProposal, IgnoreProposal} -import crate_web/pages/edit_inbox +import crate_web/msg.{type Msg, ApplyProposal, ArmApplyProposal, IgnoreProposal} +import crate_web/pages/edit_inbox.{apply_confirm_label} import crate_web/plural import crate_web/route import crate_web/ui/controls as ctl @@ -26,21 +26,21 @@ import lustre/event /// `model.ProposalFields`), used for the "N OF M FIELDS" section band. const tracked_field_count = 5 -pub fn view(card: InboxCard) -> Element(Msg) { +pub fn view(model: Model, card: InboxCard) -> Element(Msg) { case card.status { ProposalApplied(..) -> edit_inbox.applied_strip(card.proposal) - _ -> detail_view(card) + _ -> detail_view(card, model.confirm_apply == Some(card.proposal.uri)) } } -fn detail_view(card: InboxCard) -> Element(Msg) { +fn detail_view(card: InboxCard, armed: Bool) -> 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), + detail_actions(p.uri, p.cid, applying, armed), html.p([attr.class("hint proposal-detail-footnote")], [ text( "Adopting re-points your record to their version. Nothing merges " @@ -229,16 +229,29 @@ fn minted_on(created_at: String) -> String { string.slice(created_at, 0, 10) } -fn detail_actions(uri: String, cid: String, applying: Bool) -> Element(Msg) { +fn detail_actions( + uri: String, + cid: String, + applying: Bool, + armed: 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.confirm_button( + adopt_label(applying), + apply_confirm_label, + ctl.Primary, + armed, + ArmApplyProposal(uri), + ApplyProposal(uri, cid), + [ + attr.id(model.apply_button_id(uri)), + attr.disabled(applying), + attr.class("btn--block"), + ], + ), ctl.button("IGNORE", ctl.Ghost, [ event.on_click(IgnoreProposal(uri)), - attr.disabled(applying), + attr.disabled(applying || armed), attr.class("btn--block"), ]), ]) diff --git a/web/src/crate_web/pages/feed.gleam b/web/src/crate_web/pages/feed.gleam index 7e0cd52..e62f156 100644 --- a/web/src/crate_web/pages/feed.gleam +++ b/web/src/crate_web/pages/feed.gleam @@ -9,14 +9,16 @@ import crate/gen/feed/get_feed_skeleton.{ FeedItemReasonReasonImport, FeedItemReasonReasonSingle, FeedItemReasonReasonSubjectConverge, } +import crate_web/browser import crate_web/model.{ type EntryDetail, type Model, Browse, FeedFailed, FeedLoaded, FeedLoading, PressingDetail, PublicRecord, } -import crate_web/msg.{type Msg, FeedShowMore} +import crate_web/msg.{type Msg, FeedShowMore, RetryFeed} import crate_web/pages/crate import crate_web/plural import crate_web/provenance +import crate_web/relative_date import crate_web/route import crate_web/ui/app_bar as bar import crate_web/ui/covers as cov @@ -32,7 +34,8 @@ import lustre/element/html pub fn view(model: Model) -> Element(Msg) { case model.feed { FeedLoading -> states.loading_page() - FeedFailed -> states.failed_page("Couldn't load the feed right now.") + FeedFailed -> + states.failed_page_retry("Couldn't load the feed right now.", RetryFeed) FeedLoaded([], _, _) -> empty_state() FeedLoaded(items, cursor, fallback) -> loaded_state(model, items, cursor, fallback) @@ -55,7 +58,7 @@ fn loaded_state( ], list.flatten([ fallback_label(fallback), - feed_rows(model, items), + feed_rows(model, items, browser.today()), [loading_marker(model.feed_loading_more)], ]), ), @@ -82,13 +85,17 @@ fn loading_marker(loading_more: Bool) -> Element(Msg) { /// Fold the items into rows, inserting a day separator whenever a hydrated /// item's day differs from the running one. An unhydrated item (no day yet) /// never opens a new day: it renders under whatever day is currently open. -fn feed_rows(model: Model, items: List(FeedItem)) -> List(Element(Msg)) { +fn feed_rows( + model: Model, + items: List(FeedItem), + today: String, +) -> List(Element(Msg)) { let #(_, rows) = list.fold(items, #(None, []), fn(acc, item) { let #(current_day, out) = acc let day = model.feed_item_day(item, model.feed_entries) let out = case day { - Some(d) if day != current_day -> [day_separator(d), ..out] + Some(d) if day != current_day -> [day_separator(d, today), ..out] _ -> out } #(option.or(day, current_day), [feed_row(model, item), ..out]) @@ -96,8 +103,8 @@ fn feed_rows(model: Model, items: List(FeedItem)) -> List(Element(Msg)) { list.reverse(rows) } -fn day_separator(day: String) -> Element(Msg) { - html.p([attr.class("feed-day")], [text(day)]) +fn day_separator(day: String, today: String) -> Element(Msg) { + html.p([attr.class("feed-day")], [text(relative_date.label(day, today))]) } fn feed_row(model: Model, item: FeedItem) -> Element(Msg) { @@ -119,8 +126,9 @@ fn single_row( None -> skeleton_row() Some(#(ref, fe)) -> { let snap = model.entry_snapshot(fe.entry) + let verb = feed_verb_phrase(reason.action, list.length(item.entries)) html.div([attr.class("feed-row")], [ - row_head(fe.handle, "@" <> fe.handle <> " " <> reason.action), + row_head(fe.handle, "@" <> fe.handle <> " " <> verb), cov.cover_row( route.to_path(PublicRecord(fe.handle, ref.entry_id)), snap.title <> snap.artist_display, @@ -156,12 +164,28 @@ fn batch_row( "@" <> fe.handle <> " " - <> reason.action - <> " " - <> plural.count_noun(list.length(item.entries), "record") + <> feed_verb_phrase(reason.action, list.length(item.entries)) }) } +/// A real English sentence for a shelf event's raw `action` string, always +/// with an object (never a bare status word standing in as a verb - see the +/// feed regression this replaced, "@handle owned"). `_` covers any action +/// the server starts emitting that this map hasn't caught up to yet, rather +/// than rendering a fragment. +fn feed_verb_phrase(action: String, count: Int) -> String { + let noun = plural.count_noun(count, "record") + case action { + "owned" | "acquired" -> "added " <> noun + "wanted" -> "marked " <> noun <> " as wanted" + "sold" | "dropped" -> "moved " <> noun <> " to history" + "regraded" -> "regraded " <> noun + "rated" -> "rated " <> noun + "amended" -> "amended " <> noun + _ -> "updated " <> noun + } +} + fn import_row( model: Model, item: FeedItem, @@ -199,14 +223,22 @@ fn cover_strip(model: Model, item: FeedItem) -> Element(Msg) { [attr.class("feed-strip")], list.map(item.entries, fn(ref) { case lookup(model, ref) { - Some(fe) -> + Some(fe) -> { + let snap = model.entry_snapshot(fe.entry) html.a( [ attr.class("feed-strip__thumb"), attr.href(route.to_path(PublicRecord(fe.handle, ref.entry_id))), ], - [cov.thumb(model.entry_snapshot(fe.entry).thumb_url)], + [ + cov.thumb_with_fallback( + snap.thumb_url, + snap.title <> snap.artist_display, + snap.title, + ), + ], ) + } None -> html.div([attr.class("feed-strip__thumb feed-skeleton-thumb")], []) } diff --git a/web/src/crate_web/pages/pressing.gleam b/web/src/crate_web/pages/pressing.gleam index 7cae5b9..9141d13 100644 --- a/web/src/crate_web/pages/pressing.gleam +++ b/web/src/crate_web/pages/pressing.gleam @@ -7,7 +7,7 @@ import crate_web/model.{ type BrowseRelease, type Model, PressingFailed, PressingLoaded, PressingLoading, PublicCrate, } -import crate_web/msg.{type Msg} +import crate_web/msg.{type Msg, RetryPressing} import crate_web/pages/browse import crate_web/route import crate_web/ui/controls as ctl @@ -24,7 +24,10 @@ pub fn view(model: Model) -> Element(Msg) { case model.pressing { PressingLoading -> states.loading_page() PressingFailed -> - states.failed_page("Couldn't load that pressing right now.") + states.failed_page_retry( + "Couldn't load that pressing right now.", + RetryPressing, + ) PressingLoaded(row) -> loaded_state(model, row) } } diff --git a/web/src/crate_web/pages/public_crate.gleam b/web/src/crate_web/pages/public_crate.gleam index a6c2fe2..aa281d2 100644 --- a/web/src/crate_web/pages/public_crate.gleam +++ b/web/src/crate_web/pages/public_crate.gleam @@ -6,7 +6,7 @@ import crate_web/model.{ type CrateOverlap, type Entry, type Model, Actor, ActorCrate, LoggedIn, LoggedOut, PublicRecord, ShelfFailed, ShelfLoaded, ShelfLoading, crate_of, } -import crate_web/msg.{type Msg, ToggleFollow} +import crate_web/msg.{type Msg, RetryPublicCrate, ToggleFollow} import crate_web/plural import crate_web/route import crate_web/ui/app_bar as bar @@ -62,11 +62,28 @@ fn loaded_state( html.div([attr.class("list-screen")], [ hero(model, handle, list.length(items)), html.div([attr.class("body list-body")], [ - html.div([attr.class("list-scroll")], [grid(handle, items)]), + html.div([attr.class("list-scroll")], [grid(handle, items), footer(model)]), ]), ]) } +/// A shared link's only non-record exit today is the header's back arrow; +/// a signed-out visitor gets nothing telling them what crate even is. Shown +/// only there - a signed-in visitor already has their own crate one tap +/// away and doesn't need the pitch. +fn footer(model: Model) -> Element(Msg) { + case model.auth { + LoggedIn(_) -> element.none() + LoggedOut -> + cov.card([ + html.p([attr.class("hint")], [ + text("crate is a vinyl collection, stored on your own PDS"), + ]), + ctl.link_button("START YOUR OWN CRATE", ctl.Ghost, "/"), + ]) + } +} + fn hero(model: Model, handle: String, count: Int) -> Element(Msg) { html.div([attr.class("hero")], [ html.div([attr.class("hero__bar hero__bar--public")], [ @@ -179,18 +196,22 @@ fn empty_state(model: Model, handle: String) -> Element(Msg) { html.p([attr.class("empty")], [ text("Nothing public in @" <> handle <> "'s crate yet."), ]), + footer(model), ]), ]), ]) } +// Retryable, not a dead end: a signed-out visitor here has no account and +// no menu, so a transient failure must not strand them on this page. fn failed_state(model: Model, handle: String) -> Element(Msg) { html.div([attr.class("list-screen")], [ hero(model, handle, 0), html.div([attr.class("body list-body")], [ html.div([attr.class("list-scroll")], [ - states.error_sticker( + states.error_sticker_retry( "Couldn't load @" <> handle <> "'s crate right now.", + RetryPublicCrate, ), ]), ]), diff --git a/web/src/crate_web/pages/public_record.gleam b/web/src/crate_web/pages/public_record.gleam index f414d1b..d70217f 100644 --- a/web/src/crate_web/pages/public_record.gleam +++ b/web/src/crate_web/pages/public_record.gleam @@ -7,7 +7,7 @@ import crate_web/model.{ type Entry, type Model, type ReleaseInfo, EntryDetailFailed, EntryDetailLoaded, EntryDetailLoading, } -import crate_web/msg.{type Msg} +import crate_web/msg.{type Msg, RetryPublicRecord} import crate_web/pages/record.{ condition, cover_fmt, detail_rows, notes, release_chips, } @@ -68,8 +68,12 @@ fn titleblock(handle: String, entry: Entry) -> Element(Msg) { ]) } +// Same reasoning as public_crate's failed_state: a signed-out visitor has +// no other way back to this record, so the retry matters more here than on +// an authed page with a whole app around it. fn failed_state(handle: String) -> Element(Msg) { - states.failed_page( + states.failed_page_retry( "Couldn't load that record from @" <> handle <> "'s crate.", + RetryPublicRecord, ) } diff --git a/web/src/crate_web/pages/settings.gleam b/web/src/crate_web/pages/settings.gleam index 4104525..913018c 100644 --- a/web/src/crate_web/pages/settings.gleam +++ b/web/src/crate_web/pages/settings.gleam @@ -2,10 +2,12 @@ //// here from Add), a link into the edit inbox with its pending count, and log //// out. Reached by tapping the app-bar avatar. -import crate_web/model.{type Model, type Theme, EditInbox, LoggedIn} +import crate_web/model.{ + type Discogs, type Model, type Theme, EditInbox, LoggedIn, +} import crate_web/msg.{ - type Msg, ArmLogout, DiscogsConnect, DiscogsDisconnect, DiscogsImport, - DiscogsImportWantlist, Logout, SetTheme, + type Msg, ArmDiscogsDisconnect, ArmLogout, DiscogsConnect, DiscogsDisconnect, + DiscogsImport, DiscogsImportWantlist, Logout, SetTheme, } import crate_web/route import crate_web/ui/app_bar as bar @@ -85,15 +87,15 @@ fn inbox_row(model: Model) -> Element(Msg) { // Same armed pattern as the record-detail REMOVE action (see ArmRemove). fn logout_button(confirm_logout: Bool) -> Element(Msg) { - let #(label, on_click) = case confirm_logout { - True -> #("REALLY LOG OUT?", Logout) - False -> #("LOG OUT", ArmLogout) - } - ctl.button(label, ctl.Danger, [ - attr.id(model.logout_button_id), - attr.class("btn--block"), - event.on_click(on_click), - ]) + ctl.confirm_button( + "LOG OUT", + "REALLY LOG OUT?", + ctl.Danger, + confirm_logout, + ArmLogout, + Logout, + [attr.id(model.logout_button_id), attr.class("btn--block")], + ) } fn discogs_account_view(model: Model) -> Element(Msg) { @@ -114,34 +116,69 @@ fn discogs_account_view(model: Model) -> Element(Msg) { html.span([attr.class("item-meta")], [ text("Connected as " <> username), ]), - ctl.button( - case model.discogs.importing { - True -> "IMPORTING…" - False -> "IMPORT COLLECTION" - }, + import_button( + "IMPORT COLLECTION", ctl.Dark, - [ - attr.class("btn--import"), - attr.disabled(model.discogs.importing), - event.on_click(DiscogsImport), - ], + model.discogs.importing_collection, + model.discogs, + DiscogsImport, ), - ctl.button( - case model.discogs.importing { - True -> "IMPORTING…" - False -> "IMPORT WANTLIST" - }, + import_button( + "IMPORT WANTLIST", ctl.Ghost, - [ - attr.class("btn--import"), - attr.disabled(model.discogs.importing), - event.on_click(DiscogsImportWantlist), - ], + model.discogs.importing_wantlist, + model.discogs, + DiscogsImportWantlist, ), - ctl.button("DISCONNECT", ctl.Ghost, [ - event.on_click(DiscogsDisconnect), - ]), + disconnect_button(model.discogs.confirm_disconnect), ]) }, + discogs_error(model.discogs.error), ]) } + +/// One import button, blocked while either import is running (only one +/// Discogs import can run at a time) but only ever labeled IMPORTING… for +/// the run it actually started. +fn import_button( + label: String, + variant: ctl.Btn, + running: Bool, + discogs: Discogs, + on_click: Msg, +) -> Element(Msg) { + ctl.button( + case running { + True -> "IMPORTING…" + False -> label + }, + variant, + [ + attr.class("btn--import"), + attr.disabled(model.discogs_importing(discogs)), + event.on_click(on_click), + ], + ) +} + +fn disconnect_button(confirm_disconnect: Bool) -> Element(Msg) { + ctl.confirm_button( + "DISCONNECT", + "REALLY DISCONNECT?", + ctl.Danger, + confirm_disconnect, + ArmDiscogsDisconnect, + DiscogsDisconnect, + [attr.id(model.discogs_disconnect_button_id)], + ) +} + +fn discogs_error(error: option.Option(String)) -> Element(Msg) { + case error { + option.Some(message) -> + html.p([attr.class("hint"), attr.attribute("role", "alert")], [ + text(message), + ]) + option.None -> element.none() + } +} diff --git a/web/src/crate_web/relative_date.gleam b/web/src/crate_web/relative_date.gleam new file mode 100644 index 0000000..0c41cde --- /dev/null +++ b/web/src/crate_web/relative_date.gleam @@ -0,0 +1,63 @@ +//// Relative day labels ("TODAY", "YESTERDAY", "3 DAYS AGO") for an ISO +//// "YYYY-MM-DD" date, compared against another ISO date standing in for +//// "today". Pure: no clock reads here, callers supply both sides (see +//// `browser.today`). + +import gleam/int +import gleam/result +import gleam/string + +/// `day`, relative to `today` - both "YYYY-MM-DD". Falls back to `day` +/// unchanged for anything more than a week old, in the future, or +/// malformed, rather than guessing at a label. +pub fn label(day: String, today: String) -> String { + case days_between(day, today) { + Ok(0) -> "TODAY" + Ok(1) -> "YESTERDAY" + Ok(n) if n > 1 && n <= 6 -> int.to_string(n) <> " DAYS AGO" + _ -> day + } +} + +/// `today`'s day number minus `day`'s, via a proleptic-Gregorian day count +/// (Howard Hinnant's `days_from_civil`) - no calendar reconstruction needed +/// since only the difference matters. +fn days_between(day: String, today: String) -> Result(Int, Nil) { + use #(dy, dm, dd) <- result.try(parse_ymd(day)) + use #(ty, tm, td) <- result.try(parse_ymd(today)) + Ok(days_from_civil(ty, tm, td) - days_from_civil(dy, dm, dd)) +} + +fn parse_ymd(date: String) -> Result(#(Int, Int, Int), Nil) { + case string.split(date, "-") { + [y, m, d] -> { + use year <- result.try(int.parse(y)) + use month <- result.try(int.parse(m)) + use day <- result.try(int.parse(d)) + Ok(#(year, month, day)) + } + _ -> Error(Nil) + } +} + +fn days_from_civil(y: Int, m: Int, d: Int) -> Int { + let shifted_year = case m <= 2 { + True -> y - 1 + False -> y + } + let era = + case shifted_year >= 0 { + True -> shifted_year + False -> shifted_year - 399 + } + / 400 + let year_of_era = shifted_year - era * 400 + let month_index = case m > 2 { + True -> m - 3 + False -> m + 9 + } + let day_of_year = { 153 * month_index + 2 } / 5 + d - 1 + let day_of_era = + year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year + era * 146_097 + day_of_era - 719_468 +} diff --git a/web/src/crate_web/update.gleam b/web/src/crate_web/update.gleam index f1759ad..951368e 100644 --- a/web/src/crate_web/update.gleam +++ b/web/src/crate_web/update.gleam @@ -79,7 +79,8 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { msg.GotDiscogsStatus(result) -> add.got_discogs_status(model, result) msg.DiscogsConnect -> add.discogs_connect_msg(model) msg.DiscogsDisconnect -> add.discogs_disconnect_msg(model) - msg.GotDiscogsDisconnect(_) -> add.got_discogs_disconnect(model) + msg.GotDiscogsDisconnect(result) -> + add.got_discogs_disconnect(model, result) msg.DiscogsImport -> add.discogs_import_msg(model) msg.DiscogsImportWantlist -> add.discogs_import_wantlist_msg(model) msg.GotDiscogsImport(result) -> add.got_discogs_import(model, result) @@ -130,11 +131,21 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { msg.GotApplyProposal(uri, result) -> inbox.got_apply_proposal(model, uri, result) msg.IgnoreProposal(uri) -> inbox.ignore_proposal(model, uri) + msg.ArmApplyProposal(uri) -> inbox.arm_apply_proposal(model, uri) + msg.DisarmApplyProposal -> inbox.disarm_apply_proposal(model) msg.GotFeedSkeleton(result) -> feed.got_feed_skeleton(model, result) msg.GotFeedEntry(actor, entry_id, result) -> feed.got_feed_entry(model, actor, entry_id, result) msg.FeedShowMore -> feed.feed_show_more(model) msg.GotFeedMore(result) -> feed.got_feed_more(model, result) + msg.RetryFeed -> feed.retry_feed(model) + + msg.RetryPressing -> browse.retry_pressing(model) + msg.RetryPublicCrate -> shelf.retry_public_crate(model) + msg.RetryPublicRecord -> shelf.retry_public_record(model) + + msg.ArmDiscogsDisconnect -> add.arm_discogs_disconnect(model) + msg.DisarmDiscogsDisconnect -> add.disarm_discogs_disconnect(model) } } diff --git a/web/src/crate_web/update/add.gleam b/web/src/crate_web/update/add.gleam index 0fc0926..f77c236 100644 --- a/web/src/crate_web/update/add.gleam +++ b/web/src/crate_web/update/add.gleam @@ -1,3 +1,4 @@ +import atproto_core/xrpc import crate_web/effects.{ add_item, discogs_connect, discogs_disconnect, discogs_import, discogs_import_wantlist, discogs_search, load_shelf, @@ -7,7 +8,9 @@ import crate_web/model.{ type Model, Discogs, Form, Model, blank_form, } import crate_web/money -import crate_web/msg.{type Msg, ArtistSearch, DiscogsSearch} +import crate_web/msg.{ + type Msg, ArtistSearch, DisarmDiscogsDisconnect, DiscogsSearch, +} import crate_web/update/common.{failed, succeeded, write_error} import gleam/int import gleam/list @@ -256,62 +259,135 @@ pub fn discogs_connect_msg(model: Model) -> #(Model, Effect(Msg)) { #(model, discogs_connect()) } +/// The confirm's second tap: disarms and fires the write. `username` stays +/// put until the response lands, so a failed disconnect never shows the +/// account as gone when the credentials are still live server-side. pub fn discogs_disconnect_msg(model: Model) -> #(Model, Effect(Msg)) { #( - Model(..model, discogs: Discogs(..model.discogs, username: None)), - discogs_disconnect(), + Model( + ..model, + discogs: Discogs(..model.discogs, confirm_disconnect: False, error: None), + ), + effect.batch([ + discogs_disconnect(), + effects.disarm_confirm(model.discogs_disconnect_button_id), + ]), ) } -pub fn got_discogs_disconnect(model: Model) -> #(Model, Effect(Msg)) { - #(model, effect.none()) -} - -pub fn discogs_import_msg(model: Model) -> #(Model, Effect(Msg)) { +pub fn arm_discogs_disconnect(model: Model) -> #(Model, Effect(Msg)) { #( - Model( - ..model, - discogs: Discogs(..model.discogs, importing: True), - notice: None, + Model(..model, discogs: Discogs(..model.discogs, confirm_disconnect: True)), + effects.arm_confirm( + model.discogs_disconnect_button_id, + "confirm-discogs-disconnect", + DisarmDiscogsDisconnect, ), - discogs_import(), ) } -pub fn discogs_import_wantlist_msg(model: Model) -> #(Model, Effect(Msg)) { +pub fn disarm_discogs_disconnect(model: Model) -> #(Model, Effect(Msg)) { #( - Model( - ..model, - discogs: Discogs(..model.discogs, importing: True), - notice: None, - ), - discogs_import_wantlist(), + Model(..model, discogs: Discogs(..model.discogs, confirm_disconnect: False)), + effects.disarm_confirm(model.discogs_disconnect_button_id), ) } +pub fn got_discogs_disconnect( + model: Model, + result: Result(Nil, msg.ApiError), +) -> #(Model, Effect(Msg)) { + case result { + Ok(Nil) -> #( + Model(..model, discogs: Discogs(..model.discogs, username: None)), + effect.none(), + ) + Error(e) -> + discogs_write_error(model, e, "Couldn't disconnect Discogs. Try again.") + } +} + +/// Guards against a double-fire (tapping IMPORT COLLECTION and IMPORT +/// WANTLIST back to back): the first import already in flight wins. +pub fn discogs_import_msg(model: Model) -> #(Model, Effect(Msg)) { + case model.discogs_importing(model.discogs) { + True -> #(model, effect.none()) + False -> #( + Model( + ..model, + discogs: Discogs( + ..model.discogs, + importing_collection: True, + error: None, + ), + notice: None, + ), + discogs_import(), + ) + } +} + +pub fn discogs_import_wantlist_msg(model: Model) -> #(Model, Effect(Msg)) { + case model.discogs_importing(model.discogs) { + True -> #(model, effect.none()) + False -> #( + Model( + ..model, + discogs: Discogs(..model.discogs, importing_wantlist: True, error: None), + notice: None, + ), + discogs_import_wantlist(), + ) + } +} + pub fn got_discogs_import( model: Model, result: Result(ImportRun, msg.ApiError), ) -> #(Model, Effect(Msg)) { + let idle = + Discogs( + ..model.discogs, + importing_collection: False, + importing_wantlist: False, + ) case result { Ok(run) -> #( Model( ..model, - discogs: Discogs(..model.discogs, importing: False), + discogs: idle, busy: True, notice: succeeded(import_summary(run)), ), load_shelf(None, model.view), ) Error(e) -> - write_error( - Model(..model, discogs: Discogs(..model.discogs, importing: False)), + discogs_write_error( + Model(..model, discogs: idle), e, "Import failed. Run it again to resume.", ) } } +/// A Discogs-card-scoped counterpart to `write_error`: a session expiry +/// still logs the visitor out globally, but anything else lands in +/// `discogs.error` (rendered right on the card) instead of the generic +/// toast, so the failure keeps its context. +fn discogs_write_error( + model: Model, + error: msg.ApiError, + fallback: String, +) -> #(Model, Effect(Msg)) { + case error { + xrpc.BadStatus(status: 401, ..) -> write_error(model, error, fallback) + _ -> #( + Model(..model, discogs: Discogs(..model.discogs, error: Some(fallback))), + effect.none(), + ) + } +} + pub fn use_discogs( model: Model, result: DiscogsResult, diff --git a/web/src/crate_web/update/browse.gleam b/web/src/crate_web/update/browse.gleam index 7954e6a..b4f5bcf 100644 --- a/web/src/crate_web/update/browse.gleam +++ b/web/src/crate_web/update/browse.gleam @@ -1,12 +1,13 @@ import atproto_core/xrpc import crate/gen/catalog/list_releases import crate_web/effects.{ - browse_add, follow_user, load_browse, load_crate_overlap, search_browse, - unfollow_user, + browse_add, follow_user, load_browse, load_crate_overlap, load_pressing, + search_browse, unfollow_user, } import crate_web/model.{ type BrowseRelease, type Model, CrateOverlap, LoggedIn, LoggedOut, Model, Own, - OwnCrate, PressingFailed, PressingLoaded, ShelfLoaded, + OwnCrate, PressingDetail, PressingFailed, PressingLoaded, PressingLoading, + ShelfLoaded, } import crate_web/msg.{type Msg} import crate_web/update/common.{failed, write_error} @@ -165,6 +166,18 @@ pub fn got_pressing( } } +/// Re-fire `catalog.getRelease` for whichever pressing route is open; a +/// no-op if the visitor has since navigated away. +pub fn retry_pressing(model: Model) -> #(Model, Effect(Msg)) { + case model.route { + PressingDetail(did, rkey) -> #( + Model(..model, pressing: PressingLoading), + load_pressing(model.release_uri(did, rkey)), + ) + _ -> #(model, effect.none()) + } +} + pub fn got_crate_overlap( model: Model, result: Result(msg.CrateOverlapData, msg.ApiError), diff --git a/web/src/crate_web/update/feed.gleam b/web/src/crate_web/update/feed.gleam index e14375c..4b79bb9 100644 --- a/web/src/crate_web/update/feed.gleam +++ b/web/src/crate_web/update/feed.gleam @@ -1,11 +1,11 @@ import crate/gen/feed/get_feed_skeleton.{type FeedItem} import crate_web/effects.{load_entry, load_feed} -import crate_web/model.{type Model, FeedFailed, FeedLoaded, Model} +import crate_web/model.{type Model, FeedFailed, FeedLoaded, FeedLoading, Model} import crate_web/msg.{type FeedSkeletonData, type Msg, GotFeedEntry} import crate_web/update/common.{detail_of, failed} import gleam/dict import gleam/list -import gleam/option.{Some} +import gleam/option.{None, Some} import lustre/effect.{type Effect} pub fn got_feed_skeleton( @@ -52,6 +52,11 @@ pub fn got_feed_entry( } } +/// Re-run the feed's first-page fetch after a failed load. +pub fn retry_feed(model: Model) -> #(Model, Effect(Msg)) { + #(Model(..model, feed: FeedLoading), load_feed(None)) +} + pub fn feed_show_more(model: Model) -> #(Model, Effect(Msg)) { case model.feed { FeedLoaded(_, Some(cursor), _) if !model.feed_loading_more -> #( diff --git a/web/src/crate_web/update/inbox.gleam b/web/src/crate_web/update/inbox.gleam index 0317c7b..a4f37ba 100644 --- a/web/src/crate_web/update/inbox.gleam +++ b/web/src/crate_web/update/inbox.gleam @@ -4,15 +4,45 @@ import crate_web/model.{ type EditProposal, type Model, InboxLoaded, LoggedOut, Model, Own, OwnCrate, ProposalApplied, ProposalApplying, ProposalReviewing, ShelfLoaded, } -import crate_web/msg.{type AppliedProposal, type Msg} +import crate_web/msg.{type AppliedProposal, type Msg, DisarmApplyProposal} import crate_web/route import crate_web/update/common.{failed, write_error} import gleam/list -import gleam/option.{type Option, None} +import gleam/option.{type Option, None, Some} import gleam/result import lustre/effect.{type Effect} import modem +/// First tap on APPLY/ADOPT: arms the confirm. Disarms whatever proposal was +/// previously armed first, so switching cards without confirming never +/// leaves a stale outside-click watcher registered against the old one. +pub fn arm_apply_proposal(model: Model, uri: String) -> #(Model, Effect(Msg)) { + let cleanup = case model.confirm_apply { + Some(prev) if prev != uri -> + effects.disarm_confirm(model.apply_button_id(prev)) + _ -> effect.none() + } + #( + Model(..model, confirm_apply: Some(uri)), + effect.batch([ + cleanup, + effects.arm_confirm( + model.apply_button_id(uri), + "confirm-apply-proposal", + DisarmApplyProposal, + ), + ]), + ) +} + +pub fn disarm_apply_proposal(model: Model) -> #(Model, Effect(Msg)) { + let cleanup = case model.confirm_apply { + Some(uri) -> effects.disarm_confirm(model.apply_button_id(uri)) + None -> effect.none() + } + #(Model(..model, confirm_apply: None), cleanup) +} + /// Proposals ignored on a previous visit never make it into the loaded inbox /// at all (see `ignore_proposal`). pub fn got_edit_inbox( @@ -71,9 +101,17 @@ pub fn apply_proposal( uri: String, cid: String, ) -> #(Model, Effect(Msg)) { + let disarm_effect = case model.confirm_apply { + Some(armed_uri) -> effects.disarm_confirm(model.apply_button_id(armed_uri)) + None -> effect.none() + } #( - Model(..model, inbox: set_card_status(model.inbox, uri, ProposalApplying)), - apply_edit_proposal(uri, cid), + Model( + ..model, + inbox: set_card_status(model.inbox, uri, ProposalApplying), + confirm_apply: None, + ), + effect.batch([apply_edit_proposal(uri, cid), disarm_effect]), ) } @@ -113,15 +151,20 @@ pub fn got_apply_proposal( pub fn ignore_proposal(model: Model, uri: String) -> #(Model, Effect(Msg)) { let cid = find_proposal_cid(model.inbox, uri) |> option.unwrap("") let ignored = model.add_ignored_proposal(model.ignored_proposals, uri, cid) + let #(disarmed, disarm_effect) = case model.confirm_apply == Some(uri) { + True -> disarm_apply_proposal(model) + False -> #(model, effect.none()) + } #( Model( - ..model, + ..disarmed, inbox: remove_card(model.inbox, uri), ignored_proposals: ignored, ), effect.batch([ effects.persist_ignored_proposals(ignored), leave_proposal_detail(model.route, uri), + disarm_effect, ]), ) } diff --git a/web/src/crate_web/update/routing.gleam b/web/src/crate_web/update/routing.gleam index bcaaa0a..f52cfec 100644 --- a/web/src/crate_web/update/routing.gleam +++ b/web/src/crate_web/update/routing.gleam @@ -2,9 +2,9 @@ import crate_web/effects.{ discogs_status, load_browse, load_entry, load_feed, load_shelf, } import crate_web/model.{ - type Model, Add, Browse, EditInbox, EditProposalDetail, Feed, FeedLoading, - Model, PressingDetail, PressingLoading, PublicCrate, PublicRecord, Record, - RecordAmend, Scan, ScanDone, crate_of, set_crate_loading_if_absent, + type Model, Add, Browse, Discogs, EditInbox, EditProposalDetail, Feed, + FeedLoading, Model, PressingDetail, PressingLoading, PublicCrate, PublicRecord, + Record, RecordAmend, Scan, ScanDone, crate_of, set_crate_loading_if_absent, } import crate_web/msg.{type Msg, GotEntry} import crate_web/update/browse.{overlap_effect} @@ -43,13 +43,14 @@ pub fn on_route_change( editing: False, confirm_remove: False, confirm_logout: False, + confirm_apply: None, entry_detail: model.EntryDetailLoading, busy: True, ), effect.batch([ scan.leaving_scan(model), load_entry(None, entry_id, GotEntry), - disarm_confirms(), + disarm_confirms(model), ]), ) @@ -67,6 +68,7 @@ pub fn on_route_change( editing: False, confirm_remove: False, confirm_logout: False, + confirm_apply: None, ) let shelf_effect = case crate_of(model, model.OwnCrate) { model.ShelfLoaded(_, _) -> effect.none() @@ -74,7 +76,11 @@ pub fn on_route_change( } #( updated, - effect.batch([scan.leaving_scan(model), disarm_confirms(), shelf_effect]), + effect.batch([ + scan.leaving_scan(model), + disarm_confirms(model), + shelf_effect, + ]), ) } @@ -88,6 +94,7 @@ pub fn on_route_change( route: PressingDetail(did, rkey), selected: None, confirm_logout: False, + confirm_apply: None, browse_adding: None, pressing: case existing { Ok(row) -> model.PressingLoaded(row) @@ -96,7 +103,7 @@ pub fn on_route_change( ), effect.batch([ scan.leaving_scan(model), - disarm_confirms(), + disarm_confirms(model), case existing { Ok(_) -> effect.none() Error(_) -> effects.load_pressing(uri) @@ -115,9 +122,13 @@ pub fn on_route_change( editing: False, confirm_remove: False, confirm_logout: False, + confirm_apply: None, browse_adding: None, ) - #(updated, effect.batch([scan.leaving_scan(model), disarm_confirms()])) + #( + updated, + effect.batch([scan.leaving_scan(model), disarm_confirms(model)]), + ) } // Add refreshes Discogs connection status; scan starts/stops the camera; @@ -133,6 +144,8 @@ pub fn on_route_change( editing: False, confirm_remove: False, confirm_logout: False, + confirm_apply: None, + discogs: Discogs(..model.discogs, confirm_disconnect: False), browse_adding: None, busy: case route { Browse | EditInbox | EditProposalDetail(_) -> True @@ -169,7 +182,7 @@ pub fn on_route_change( updated, effect.batch([ scan.leaving_scan(model), - disarm_confirms(), + disarm_confirms(model), case route { Add -> discogs_status() Scan -> @@ -195,12 +208,18 @@ pub fn on_route_change( } } -/// Both armed confirms reset on every route change; drop their outside-click +/// Every armed confirm resets on every route change; drop their outside-click /// watchers too so a stale one never lingers on a button that just left the -/// page. -fn disarm_confirms() -> Effect(Msg) { +/// page. `model` here is the pre-route-change model, so a still-armed +/// APPLY/ADOPT's button id (dynamic per proposal) can actually be found. +fn disarm_confirms(model: Model) -> Effect(Msg) { effect.batch([ effects.disarm_confirm(model.remove_button_id), effects.disarm_confirm(model.logout_button_id), + effects.disarm_confirm(model.discogs_disconnect_button_id), + case model.confirm_apply { + Some(uri) -> effects.disarm_confirm(model.apply_button_id(uri)) + None -> effect.none() + }, ]) } diff --git a/web/src/crate_web/update/shelf.gleam b/web/src/crate_web/update/shelf.gleam index 2aa5400..95f3359 100644 --- a/web/src/crate_web/update/shelf.gleam +++ b/web/src/crate_web/update/shelf.gleam @@ -1,15 +1,16 @@ import atproto_core/xrpc import crate/gen/shelf/list_entries import crate_web/effects.{ - copy_record_link, entry_action, load_avatar, load_shelf, load_shelf_more, + copy_record_link, entry_action, load_avatar, load_entry, load_shelf, + load_shelf_more, } import crate_web/model.{ type Entry, type Model, Actor, ActorCrate, EntryDetail, EntryDetailFailed, - EntryDetailLoaded, LoggedIn, LoggedOut, Model, Own, OwnCrate, PublicCrate, - PublicRecord, RecordAmend, ShelfFailed, ShelfLoaded, ShelfLoading, - crate_window_size, set_crate, + EntryDetailLoaded, EntryDetailLoading, LoggedIn, LoggedOut, Model, Own, + OwnCrate, PublicCrate, PublicRecord, RecordAmend, ShelfFailed, ShelfLoaded, + ShelfLoading, crate_window_size, set_crate, } -import crate_web/msg.{type Msg, type ShelfData} +import crate_web/msg.{type Msg, type ShelfData, GotEntry} import crate_web/route import crate_web/update/browse.{overlap_effect} import crate_web/update/common.{after_write, failed, seed_amend_draft, succeeded} @@ -82,6 +83,30 @@ pub fn retry_shelf(model: Model) -> #(Model, Effect(Msg)) { ) } +/// Re-fire the public crate fetch for whichever `/u/:handle` route is open; +/// a no-op if the visitor has since navigated away. +pub fn retry_public_crate(model: Model) -> #(Model, Effect(Msg)) { + case model.route { + PublicCrate(handle) -> #( + set_crate(model, ActorCrate(handle), ShelfLoading), + load_shelf(Some(handle), "current"), + ) + _ -> #(model, effect.none()) + } +} + +/// Re-fire the public record fetch for whichever `/u/:handle/record/:id` +/// route is open; a no-op if the visitor has since navigated away. +pub fn retry_public_record(model: Model) -> #(Model, Effect(Msg)) { + case model.route { + PublicRecord(handle, entry_id) -> #( + Model(..model, entry_detail: EntryDetailLoading), + load_entry(Some(handle), entry_id, GotEntry), + ) + _ -> #(model, effect.none()) + } +} + pub fn clear_notice(model: Model) -> #(Model, Effect(Msg)) { #(Model(..model, notice: None), effect.none()) } diff --git a/web/src/crate_web/view.gleam b/web/src/crate_web/view.gleam index 5890d2f..671f3c6 100644 --- a/web/src/crate_web/view.gleam +++ b/web/src/crate_web/view.gleam @@ -204,12 +204,12 @@ fn edit_proposal_page(model: Model, id: String) -> Element(Msg) { case model.inbox { model.InboxLoaded(_) -> case model.find_proposal_card(model.inbox, id) { - Some(card) -> edit_proposal.view(card) + Some(card) -> edit_proposal.view(model, 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.InboxLoading -> states.loading_page() model.InboxFailed -> edit_inbox.view(model) } } diff --git a/web/test/edit_inbox_test.gleam b/web/test/edit_inbox_test.gleam index 7e9779a..ed7d907 100644 --- a/web/test/edit_inbox_test.gleam +++ b/web/test/edit_inbox_test.gleam @@ -5,8 +5,8 @@ import crate_web/model.{ InboxLoading, Model, ProposalApplied, ProposalApplying, ProposalReviewing, } import crate_web/msg.{ - AppliedProposal, ApplyProposal, GotApplyProposal, GotEditInbox, IgnoreProposal, - OnRouteChange, + AppliedProposal, ApplyProposal, ArmApplyProposal, DisarmApplyProposal, + GotApplyProposal, GotEditInbox, IgnoreProposal, OnRouteChange, } import crate_web/pages/edit_inbox import crate_web/update.{update} @@ -54,6 +54,14 @@ pub fn route_change_to_edit_inbox_starts_loading_and_dispatches_a_load_test() { assert effect != support.empty_effect() } +pub fn edit_inbox_loading_state_shows_a_fetching_placeholder_test() { + let html = + Model(..logged_in(), inbox: InboxLoading) + |> edit_inbox.view + |> element.to_string + assert string.contains(html, "FETCHING") +} + pub fn got_edit_inbox_ok_stores_cards_as_reviewing_test() { let proposal = an_edit_proposal("at://proposer/edit/1") let #(model, _) = update(logged_in(), GotEditInbox(Ok([proposal]))) @@ -89,6 +97,38 @@ pub fn apply_proposal_marks_the_matching_card_applying_test() { assert effect != support.empty_effect() } +pub fn apply_proposal_clears_any_armed_confirm_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let seeded = + Model( + ..logged_in(), + inbox: InboxLoaded([InboxCard(proposal:, status: ProposalReviewing)]), + confirm_apply: Some("at://proposer/edit/1"), + ) + let #(model, _) = + update(seeded, ApplyProposal("at://proposer/edit/1", "cid-1")) + assert model.confirm_apply == None +} + +pub fn arm_apply_proposal_then_disarm_clears_confirm_test() { + let #(armed, _) = + update(logged_in(), ArmApplyProposal("at://proposer/edit/1")) + assert armed.confirm_apply == Some("at://proposer/edit/1") + let #(after, _) = update(armed, DisarmApplyProposal) + assert after.confirm_apply == None +} + +// Arming a second card without confirming the first must disarm the first +// rather than leaving both outside-click watchers registered. +pub fn arming_a_different_proposal_replaces_the_armed_one_test() { + let #(first, _) = + update(logged_in(), ArmApplyProposal("at://proposer/edit/1")) + let #(second, effect) = + update(first, ArmApplyProposal("at://proposer/edit/2")) + assert second.confirm_apply == Some("at://proposer/edit/2") + assert effect != support.empty_effect() +} + pub fn got_apply_proposal_ok_marks_the_card_applied_and_refreshes_the_shelf_test() { let proposal = an_edit_proposal("at://proposer/edit/1") let seeded = @@ -144,6 +184,34 @@ fn rendered_inbox(cards: List(InboxCard)) -> String { |> element.to_string } +pub fn apply_button_shows_the_permanence_confirm_when_armed_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let html = + Model( + ..logged_in(), + inbox: InboxLoaded([InboxCard(proposal:, status: ProposalReviewing)]), + confirm_apply: Some("at://proposer/edit/1"), + ) + |> edit_inbox.view + |> element.to_string + assert string.contains(html, edit_inbox.apply_confirm_label) + assert !string.contains(html, "✓ APPLY") +} + +// A different card's arm state must not bleed into this one's rendering. +pub fn apply_button_stays_unarmed_for_a_different_confirm_apply_uri_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let html = + Model( + ..logged_in(), + inbox: InboxLoaded([InboxCard(proposal:, status: ProposalReviewing)]), + confirm_apply: Some("at://proposer/edit/OTHER"), + ) + |> edit_inbox.view + |> element.to_string + assert !string.contains(html, edit_inbox.apply_confirm_label) +} + 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)]) diff --git a/web/test/edit_proposal_test.gleam b/web/test/edit_proposal_test.gleam index ac97738..c80a58c 100644 --- a/web/test/edit_proposal_test.gleam +++ b/web/test/edit_proposal_test.gleam @@ -1,13 +1,15 @@ import crate/gen/catalog/edit as catalog_edit import crate/gen/catalog/list_edit_proposals import crate_web/model.{ - type EditProposal, InboxCard, InboxFailed, InboxLoaded, InboxLoading, + type EditProposal, InboxCard, InboxFailed, InboxLoaded, InboxLoading, Model, ProposalApplied, ProposalReviewing, } +import crate_web/pages/edit_inbox import crate_web/pages/edit_proposal import gleam/option.{None, Some} import gleam/string import lustre/element +import support.{logged_in} fn an_edit_proposal(uri: String) -> EditProposal { list_edit_proposals.ProposalRow( @@ -68,7 +70,7 @@ pub fn find_proposal_card_is_none_while_loading_or_failed_test() { fn rendered(proposal: EditProposal) -> String { InboxCard(proposal:, status: ProposalReviewing) - |> edit_proposal.view + |> edit_proposal.view(logged_in(), _) |> element.to_string } @@ -162,8 +164,23 @@ 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 + |> edit_proposal.view(logged_in(), _) |> element.to_string assert string.contains(html, "class=\"applied-strip\"") assert !string.contains(html, "ADOPT THIS VERSION") } + +// ADOPT and inbox APPLY fire the same write, so they share one confirm +// label (edit_inbox.apply_confirm_label). +pub fn adopt_button_shows_the_permanence_confirm_when_armed_test() { + let proposal = an_edit_proposal("at://proposer/edit/1") + let html = + InboxCard(proposal:, status: ProposalReviewing) + |> edit_proposal.view( + Model(..logged_in(), confirm_apply: Some("at://proposer/edit/1")), + _, + ) + |> element.to_string + assert string.contains(html, edit_inbox.apply_confirm_label) + assert !string.contains(html, "ADOPT THIS VERSION") +} diff --git a/web/test/feed_test.gleam b/web/test/feed_test.gleam index 1493c73..c999062 100644 --- a/web/test/feed_test.gleam +++ b/web/test/feed_test.gleam @@ -12,7 +12,7 @@ import crate_web/model.{ } import crate_web/msg.{ EntryDetailData, FeedShowMore, FeedSkeletonData, GotFeedEntry, GotFeedMore, - GotFeedSkeleton, OnRouteChange, + GotFeedSkeleton, OnRouteChange, RetryFeed, } import crate_web/pages/feed import crate_web/update.{update} @@ -120,6 +120,15 @@ pub fn feed_failed_state_shows_the_couldnt_load_copy_test() { let html = render(Model(..logged_in(), feed: FeedFailed)) assert string.contains(html, "error-state__sticker") assert string.contains(html, "load the feed right now") + // Retryable: unlike `states.failed_page`, this must not be a dead end. + assert string.contains(html, "RETRY") +} + +pub fn retry_feed_re_fetches_and_goes_back_to_loading_test() { + let #(model, effect) = + update(Model(..logged_in(), feed: FeedFailed), RetryFeed) + assert model.feed == FeedLoading + assert effect != empty_effect() } pub fn got_feed_skeleton_error_marks_failed_and_notices_test() { @@ -138,7 +147,7 @@ pub fn got_feed_skeleton_error_marks_failed_and_notices_test() { pub fn single_reason_hydrated_renders_header_and_cover_row_test() { let html = render( - with_feed([single("did:a", "e0", "added")], False, [ + with_feed([single("did:a", "e0", "owned")], False, [ #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), ]), ) @@ -152,13 +161,75 @@ pub fn actor_batch_hydrated_renders_count_and_strip_test() { let refs = [ref("did:a", "e0"), ref("did:a", "e1")] let html = render( - with_feed([batch(refs, "added")], False, [ + with_feed([batch(refs, "owned")], False, [ #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), #(#("did:a", "e1"), fe("alice.test", "2026-07-18T11:00:00Z")), ]), ) assert string.contains(html, "@alice.test added 2 records") assert string.contains(html, "class=\"feed-strip\"") + // Letter-tile fallback, not the old empty box (cov.thumb had no fallback). + assert string.contains(html, "cover-tile--row") + assert !string.contains(html, "cover-empty") +} + +// --- verb map: every sentence gets a real verb and an object ------------- + +pub fn wanted_action_renders_marked_as_wanted_test() { + let html = + render( + with_feed([single("did:a", "e0", "wanted")], False, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + ]), + ) + assert string.contains(html, "@alice.test marked 1 record as wanted") +} + +pub fn dropped_action_renders_moved_to_history_test() { + let refs = [ref("did:a", "e0"), ref("did:a", "e1")] + let html = + render( + with_feed([batch(refs, "dropped")], False, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + #(#("did:a", "e1"), fe("alice.test", "2026-07-18T11:00:00Z")), + ]), + ) + assert string.contains(html, "@alice.test moved 2 records to history") +} + +// An action this map hasn't caught up to yet must still render a full +// sentence with an object, never a bare fragment or the raw action code. +pub fn unknown_action_falls_back_to_a_generic_verb_with_an_object_test() { + let html = + render( + with_feed([single("did:a", "e0", "frobnicated")], False, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + ]), + ) + assert string.contains(html, "@alice.test updated 1 record") + assert !string.contains(html, "frobnicated") +} + +pub fn verb_phrase_pluralises_the_object_correctly_test() { + let one = + render( + with_feed([single("did:a", "e0", "owned")], False, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + ]), + ) + assert string.contains(one, "1 record") + assert !string.contains(one, "1 records") + + let refs = [ref("did:a", "e0"), ref("did:a", "e1"), ref("did:a", "e2")] + let many = + render( + with_feed([batch(refs, "owned")], False, [ + #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")), + #(#("did:a", "e1"), fe("alice.test", "2026-07-18T11:00:00Z")), + #(#("did:a", "e2"), fe("alice.test", "2026-07-18T12:00:00Z")), + ]), + ) + assert string.contains(many, "3 records") } pub fn subject_converge_hydrated_links_to_the_pressing_test() { @@ -196,8 +267,8 @@ pub fn import_reason_via_line_reflects_the_source_test() { pub fn every_reason_renders_a_skeleton_when_unhydrated_test() { let refs = [ref("did:a", "e0"), ref("did:b", "e1")] [ - single("did:a", "e0", "added"), - batch(refs, "added"), + single("did:a", "e0", "owned"), + batch(refs, "owned"), converge(refs, "at://did:x/dev.mokkenstorm.crate.catalog.release/rk1"), import_item(refs, Some("discogs")), ] @@ -214,7 +285,7 @@ pub fn fallback_label_shows_only_when_true_test() { [True, False] |> list.each(fn(fallback) { let html = - render(with_feed([single("did:a", "e0", "added")], fallback, hydrated)) + render(with_feed([single("did:a", "e0", "owned")], fallback, hydrated)) assert string.contains(html, "FROM THE WIDER NETWORK") == fallback }) } @@ -222,7 +293,7 @@ pub fn fallback_label_shows_only_when_true_test() { // --- day separators ------------------------------------------------------ pub fn day_separator_count_reflects_distinct_and_same_hydrated_days_test() { - let items = [single("did:a", "e0", "added"), single("did:a", "e1", "added")] + let items = [single("did:a", "e0", "owned"), single("did:a", "e1", "owned")] let e0 = #(#("did:a", "e0"), fe("alice.test", "2026-07-18T10:00:00Z")) let different_days = @@ -255,7 +326,7 @@ pub fn on_route_change_feed_sets_loading_and_fires_a_load_test() { } pub fn got_feed_skeleton_stores_items_and_hydrates_test() { - let data = FeedSkeletonData([single("did:a", "e0", "added")], False, None) + let data = FeedSkeletonData([single("did:a", "e0", "owned")], False, None) let #(model, effect) = update(logged_in(), GotFeedSkeleton(Ok(data))) let assert FeedLoaded(items, _, _) = model.feed assert list.length(items) == 1 @@ -297,7 +368,7 @@ pub fn got_feed_more_appends_and_dedupes_by_first_ref_test() { Model( ..logged_in(), feed: FeedLoaded( - [single("did:a", "e0", "added"), single("did:a", "e1", "added")], + [single("did:a", "e0", "owned"), single("did:a", "e1", "owned")], Some("cursor1"), False, ), @@ -306,7 +377,7 @@ pub fn got_feed_more_appends_and_dedupes_by_first_ref_test() { // The next page repeats e1 (fold shifted between pages) and adds e2. let next = FeedSkeletonData( - [single("did:a", "e1", "added"), single("did:a", "e2", "added")], + [single("did:a", "e1", "owned"), single("did:a", "e2", "owned")], False, None, ) @@ -324,7 +395,7 @@ fn item_keys(items: List(FeedItem)) -> List(#(String, String)) { } pub fn feed_show_more_fetches_only_with_a_cursor_and_not_already_loading_test() { - let item = single("did:a", "e0", "added") + let item = single("did:a", "e0", "owned") [ #(FeedLoaded([item], Some("c"), False), False, True), #(FeedLoaded([item], None, False), False, False), @@ -343,7 +414,7 @@ pub fn feed_show_more_fetches_only_with_a_cursor_and_not_already_loading_test() // --- pure helpers -------------------------------------------------------- pub fn feed_item_day_takes_the_newest_hydrated_day_test() { - let item = batch([ref("did:a", "e0"), ref("did:a", "e1")], "added") + let item = batch([ref("did:a", "e0"), ref("did:a", "e1")], "owned") let cache = dict.from_list([ #(#("did:a", "e0"), fe("alice.test", "2026-07-17T10:00:00Z")), @@ -353,12 +424,12 @@ pub fn feed_item_day_takes_the_newest_hydrated_day_test() { } pub fn feed_item_day_is_none_when_unhydrated_test() { - let item = single("did:a", "e0", "added") + let item = single("did:a", "e0", "owned") assert model.feed_item_day(item, dict.new()) == None } pub fn feed_item_key_is_the_first_ref_pair_test() { - let item = batch([ref("did:a", "e0"), ref("did:b", "e1")], "added") + let item = batch([ref("did:a", "e0"), ref("did:b", "e1")], "owned") assert model.feed_item_key(item) == Some(#("did:a", "e0")) } @@ -366,7 +437,7 @@ pub fn feed_item_key_is_none_for_an_empty_item_test() { let item = FeedItem( entries: [], - reason: FeedItemReasonReasonSingle(ReasonSingle("added")), + reason: FeedItemReasonReasonSingle(ReasonSingle("owned")), ) assert model.feed_item_key(item) == None } diff --git a/web/test/pressing_test.gleam b/web/test/pressing_test.gleam index 809712f..3ff4c38 100644 --- a/web/test/pressing_test.gleam +++ b/web/test/pressing_test.gleam @@ -7,7 +7,7 @@ import crate_web/model.{ type BrowseRelease, Model, PressingDetail, PressingFailed, PressingLoaded, PressingLoading, } -import crate_web/msg.{GotBrowseAdd, GotPressing, OnRouteChange} +import crate_web/msg.{GotBrowseAdd, GotPressing, OnRouteChange, RetryPressing} import crate_web/route import crate_web/update.{update} import crate_web/view @@ -158,6 +158,27 @@ pub fn pressing_page_renders_failed_state_test() { |> view.view |> element.to_string assert string.contains(html, "COULDN") + // Retryable, not a dead end. + assert string.contains(html, "RETRY") +} + +pub fn retry_pressing_re_fetches_for_the_open_route_test() { + let seeded = + Model( + ..logged_in(), + route: PressingDetail("did:plc:abc", "3jz"), + pressing: PressingFailed, + ) + let #(model, effect) = update(seeded, RetryPressing) + assert model.pressing == PressingLoading + assert effect != empty_effect() +} + +pub fn retry_pressing_off_the_pressing_route_is_a_no_op_test() { + let #(model, effect) = + update(Model(..logged_in(), pressing: PressingFailed), RetryPressing) + assert model.pressing == PressingFailed + assert effect == empty_effect() } pub fn browse_card_links_to_the_pressing_page_test() { diff --git a/web/test/public_crate_test.gleam b/web/test/public_crate_test.gleam index 41a895b..a63ef9b 100644 --- a/web/test/public_crate_test.gleam +++ b/web/test/public_crate_test.gleam @@ -8,7 +8,7 @@ import crate_web/model.{ import crate_web/msg.{ CopyRecordLink, CrateOverlapData, EntryDetailData, GotActorShelf, GotCrateOverlap, GotEntry, GotFollow, GotUnfollow, LinkCopied, OnRouteChange, - ShelfData, ToggleFollow, + RetryPublicCrate, RetryPublicRecord, ShelfData, ToggleFollow, } import crate_web/route import crate_web/update.{update} @@ -128,6 +128,78 @@ pub fn got_public_shelf_error_marks_failed_test() { assert crate_of(model, ActorCrate("alice.test")) == ShelfFailed } +// A signed-out visitor here has no account and no menu, so this must not be +// a dead end. +pub fn public_crate_failed_state_offers_a_retry_test() { + let seeded = + set_crate( + Model(..base(), auth: LoggedOut, route: PublicCrate("alice.test")), + ActorCrate("alice.test"), + ShelfFailed, + ) + let html = view.view(seeded) |> element.to_string + assert string.contains(html, "RETRY") +} + +pub fn retry_public_crate_re_fetches_for_the_open_route_test() { + let seeded = + set_crate( + Model(..base(), route: PublicCrate("alice.test")), + ActorCrate("alice.test"), + ShelfFailed, + ) + let #(model, effect) = update(seeded, RetryPublicCrate) + assert crate_of(model, ActorCrate("alice.test")) == ShelfLoading + assert effect != empty_effect() +} + +pub fn retry_public_crate_off_the_public_crate_route_is_a_no_op_test() { + let #(model, effect) = update(base(), RetryPublicCrate) + assert effect == empty_effect() + assert model == base() +} + +pub fn retry_public_record_re_fetches_for_the_open_route_test() { + let seeded = + Model( + ..base(), + route: PublicRecord("alice.test", "e9"), + entry_detail: EntryDetailFailed, + ) + let #(model, effect) = update(seeded, RetryPublicRecord) + assert model.entry_detail == EntryDetailLoading + assert effect != empty_effect() +} + +pub fn retry_public_record_off_the_public_record_route_is_a_no_op_test() { + let #(model, effect) = update(base(), RetryPublicRecord) + assert effect == empty_effect() + assert model == base() +} + +// The signed-out visitor's only exit from a shared crate link; a logged-in +// visitor already has their own crate, so the pitch is skipped for them. +pub fn public_crate_footer_cta_shown_only_when_logged_out_test() { + let handle = "alice.test" + let logged_out = + set_crate( + Model(..base(), auth: LoggedOut, route: PublicCrate(handle)), + ActorCrate(handle), + a_public_shelf(handle), + ) + let logged_out_html = view.view(logged_out) |> element.to_string + assert string.contains(logged_out_html, "START YOUR OWN CRATE") + + let signed_in = + set_crate( + Model(..base(), auth: LoggedIn("bob.test"), route: PublicCrate(handle)), + ActorCrate(handle), + a_public_shelf(handle), + ) + let signed_in_html = view.view(signed_in) |> element.to_string + assert !string.contains(signed_in_html, "START YOUR OWN CRATE") +} + pub fn got_entry_ok_loads_the_detail_test() { let #(model, _) = update( diff --git a/web/test/relative_date_test.gleam b/web/test/relative_date_test.gleam new file mode 100644 index 0000000..a97b96f --- /dev/null +++ b/web/test/relative_date_test.gleam @@ -0,0 +1,37 @@ +import crate_web/relative_date + +pub fn same_day_is_today_test() { + assert relative_date.label("2026-08-10", "2026-08-10") == "TODAY" +} + +pub fn one_day_back_is_yesterday_test() { + assert relative_date.label("2026-08-09", "2026-08-10") == "YESTERDAY" +} + +// The subtraction has to cross a month boundary correctly, not just count +// day-of-month digits. +pub fn yesterday_crosses_a_month_boundary_test() { + assert relative_date.label("2026-07-31", "2026-08-01") == "YESTERDAY" +} + +pub fn a_few_days_back_counts_up_test() { + assert relative_date.label("2026-08-05", "2026-08-10") == "5 DAYS AGO" +} + +pub fn six_days_back_is_the_last_relative_bucket_test() { + assert relative_date.label("2026-08-04", "2026-08-10") == "6 DAYS AGO" +} + +// Past the week-old cutoff, falls back to the raw ISO date rather than +// guessing at a label. +pub fn a_week_or_more_back_falls_back_to_the_raw_date_test() { + assert relative_date.label("2026-08-03", "2026-08-10") == "2026-08-03" +} + +pub fn a_future_day_falls_back_to_the_raw_date_test() { + assert relative_date.label("2026-08-11", "2026-08-10") == "2026-08-11" +} + +pub fn a_malformed_date_falls_back_to_itself_test() { + assert relative_date.label("not-a-date", "2026-08-10") == "not-a-date" +} diff --git a/web/test/settings_test.gleam b/web/test/settings_test.gleam index fd4eeac..e9894f2 100644 --- a/web/test/settings_test.gleam +++ b/web/test/settings_test.gleam @@ -1,10 +1,12 @@ import crate/gen/discogs/import_collection import crate_web/model.{ - Dark, Discogs, Failure, Model, Notice, Settings, Success, blank_discogs, + type Model, Dark, Discogs, Failure, Model, Notice, Settings, Success, + blank_discogs, } import crate_web/msg.{ - ArmLogout, DisarmLogout, DiscogsImportWantlist, GotDiscogsImport, - GotDiscogsStatus, + ArmDiscogsDisconnect, ArmLogout, DisarmDiscogsDisconnect, DisarmLogout, + DiscogsDisconnect, DiscogsImport, DiscogsImportWantlist, GotDiscogsDisconnect, + GotDiscogsImport, GotDiscogsStatus, } import crate_web/pages/settings import crate_web/route @@ -15,13 +17,20 @@ import gleam/uri import lustre/element import support.{logged_in} +fn connected() -> Model { + Model( + ..logged_in(), + discogs: Discogs(..blank_discogs(), username: Some("crate-digger")), + ) +} + pub fn discogs_status_sets_username_test() { let #(model, _) = update(logged_in(), GotDiscogsStatus(Ok(Some("crate-digger")))) assert model.discogs.username == Some("crate-digger") } -pub fn discogs_import_wantlist_marks_importing_and_clears_notice_test() { +pub fn discogs_import_wantlist_marks_only_the_wantlist_flag_test() { let model = Model( ..logged_in(), @@ -29,13 +38,37 @@ pub fn discogs_import_wantlist_marks_importing_and_clears_notice_test() { discogs: blank_discogs(), ) let #(after, _) = update(model, DiscogsImportWantlist) - assert after.discogs.importing == True + assert after.discogs.importing_wantlist == True + assert after.discogs.importing_collection == False assert after.notice == None } -pub fn import_result_summarises_partial_run_test() { +pub fn discogs_import_marks_only_the_collection_flag_test() { + let #(after, _) = + update(Model(..logged_in(), discogs: blank_discogs()), DiscogsImport) + assert after.discogs.importing_collection == True + assert after.discogs.importing_wantlist == False +} + +// A stray double-tap (or the two buttons fired back to back) must not run +// two imports at once, whichever one already claimed the flag. +pub fn a_second_import_is_a_no_op_while_one_is_running_test() { + let running = + Model( + ..logged_in(), + discogs: Discogs(..blank_discogs(), importing_collection: True), + ) + let #(after_wantlist, effect) = update(running, DiscogsImportWantlist) + assert after_wantlist.discogs.importing_wantlist == False + assert effect == support.empty_effect() +} + +pub fn import_result_summarises_partial_run_and_clears_both_flags_test() { let importing = - Model(..logged_in(), discogs: Discogs(..blank_discogs(), importing: True)) + Model( + ..logged_in(), + discogs: Discogs(..blank_discogs(), importing_collection: True), + ) let #(model, _) = update( importing, @@ -49,7 +82,8 @@ pub fn import_result_summarises_partial_run_test() { )), ), ) - assert model.discogs.importing == False + assert model.discogs.importing_collection == False + assert model.discogs.importing_wantlist == False assert model.notice == Some(Notice( Success, @@ -78,6 +112,18 @@ pub fn import_result_complete_run_has_no_continue_hint_test() { )) } +pub fn import_error_clears_flags_and_sets_the_discogs_error_region_test() { + let importing = + Model( + ..logged_in(), + discogs: Discogs(..blank_discogs(), importing_wantlist: True), + ) + let #(model, _) = + update(importing, GotDiscogsImport(Error(support.network_error()))) + assert model.discogs.importing_wantlist == False + assert model.discogs.error == Some("Import failed. Run it again to resume.") +} + pub fn settings_route_round_trips_test() { assert route.to_path(Settings) == "/settings" let assert Ok(target) = uri.parse("/settings") @@ -107,3 +153,73 @@ pub fn arm_logout_then_disarm_logout_clears_confirm_test() { let #(after, _) = update(armed, DisarmLogout) assert after.confirm_logout == False } + +pub fn logout_button_swaps_to_the_confirm_copy_when_armed_test() { + let html = + Model(..logged_in(), confirm_logout: True) + |> settings.view + |> element.to_string + assert string.contains(html, "REALLY LOG OUT?") +} + +pub fn arm_discogs_disconnect_then_disarm_clears_confirm_test() { + let #(armed, _) = update(connected(), ArmDiscogsDisconnect) + assert armed.discogs.confirm_disconnect == True + let #(after, _) = update(armed, DisarmDiscogsDisconnect) + assert after.discogs.confirm_disconnect == False +} + +pub fn disconnect_button_swaps_to_the_confirm_copy_when_armed_test() { + let html = + Model( + ..connected(), + discogs: Discogs(..connected().discogs, confirm_disconnect: True), + ) + |> settings.view + |> element.to_string + assert string.contains(html, "REALLY DISCONNECT?") +} + +// The confirm's second tap fires the write but leaves `username` alone - +// only a confirmed server response severs the account, so a failed write +// never shows a still-connected account as gone. +pub fn discogs_disconnect_msg_disarms_but_does_not_clear_username_yet_test() { + let armed = + Model( + ..connected(), + discogs: Discogs(..connected().discogs, confirm_disconnect: True), + ) + let #(model, effect) = update(armed, DiscogsDisconnect) + assert model.discogs.confirm_disconnect == False + assert model.discogs.username == Some("crate-digger") + assert effect != support.empty_effect() +} + +pub fn got_discogs_disconnect_ok_clears_the_username_test() { + let #(model, _) = update(connected(), GotDiscogsDisconnect(Ok(Nil))) + assert model.discogs.username == None +} + +pub fn got_discogs_disconnect_error_keeps_the_account_and_sets_an_error_test() { + let #(model, _) = + update(connected(), GotDiscogsDisconnect(Error(support.network_error()))) + assert model.discogs.username == Some("crate-digger") + assert model.discogs.error == Some("Couldn't disconnect Discogs. Try again.") +} + +pub fn discogs_error_renders_as_an_alert_region_test() { + let html = + Model( + ..connected(), + discogs: Discogs(..connected().discogs, error: Some("Import failed.")), + ) + |> settings.view + |> element.to_string + assert string.contains(html, "role=\"alert\"") + assert string.contains(html, "Import failed.") +} + +pub fn discogs_error_is_absent_by_default_test() { + let html = settings.view(connected()) |> element.to_string + assert !string.contains(html, "role=\"alert\"") +} diff --git a/web/test/support.gleam b/web/test/support.gleam index 418fad3..e62802d 100644 --- a/web/test/support.gleam +++ b/web/test/support.gleam @@ -75,6 +75,7 @@ pub fn base() -> Model { browse_filters_open: False, inbox: model.InboxLoading, confirm_logout: False, + confirm_apply: None, ignored_proposals: [], overlap: None, pressing: model.PressingLoading,