Skip to content

Commit c7dd6ea

Browse files
committed
Narrow the role denial to actions: keep the commit-page link live
The previous two commits disabled every commit affordance on a role-denied package, including "Create new revision" — which is a link, not a button. Supporting that meant inventing disabled-link semantics on a shared component: dropping the href, setting aria-disabled, and extending the stylesheet, none of which anything else needed. The rule is now: disable actions that cannot succeed, never navigation. Navigating to the commit page is harmless and useful — that page opens under a denial, shows the banner, and disables its own commit buttons with a tooltip, so the user can still read their changes, review the file list and write a message. The explanation belongs at the point of action, not one screen earlier. It also makes the packages list consistent rather than exceptional: it hides CommitAndPush, an action, and touches nothing navigational. So CreateNewRevision goes back to a plain link, and everything that existed only to disable it is reverted to its pre-branch state: the `disabled` prop on CtaLink, its pass-through in CreateNewRevision, and the [aria-disabled="true"] rules in button.css. Nothing else had picked any of them up. CommitAndPush on the detail page stays disabled with its tooltip, and the commit page is untouched. The page's tests keep the denied-vs-readable contrast and gain one pinning the new rule: the action bar's markup is sliced out of the page source and asserted to render the commit-page link plain — no `disabled`, no popover wrapper — while the action after it still carries its denial tooltip. A host -target test cannot render the Leptos view, and a boolean seam for a value that is always false would test a fiction rather than the markup someone would edit while "fixing the inconsistency".
1 parent fa32d56 commit c7dd6ea

5 files changed

Lines changed: 77 additions & 49 deletions

File tree

quilt-sync/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
### Changed
1515

16-
- The package page and the commit page now disable Commit and Commit-and-Push when the active role cannot read the package's bucket, explaining in a tooltip that names the role and points at switching it, instead of letting the commit fail with a storage error (<https://github.com/quiltdata/quilt-rs/pull/NNN>)
16+
- The commit buttons on the package page and the commit page are now disabled when the active role cannot read the package's bucket, explaining in a tooltip that names the role and points at switching it, instead of letting the commit fail with a storage error; opening the commit page and reviewing your changes there still works (<https://github.com/quiltdata/quilt-rs/pull/NNN>)
1717

1818
## [v0.20.0-alpha2] - 2026-07-28
1919

quilt-sync/ui/assets/css/components/button.css

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,12 @@
2020
box-shadow: var(--q-ui-shadows-button-hover);
2121
}
2222

23-
/* An anchor has no `disabled` attribute, so an inert link (see `CtaLink`)
24-
states itself with `aria-disabled` and gets the same treatment. */
25-
&[disabled],
26-
&[aria-disabled="true"] {
23+
&[disabled] {
2724
opacity: 0.7;
2825
cursor: not-allowed;
2926
}
3027

31-
&[disabled]:hover,
32-
&[aria-disabled="true"]:hover {
28+
&[disabled]:hover {
3329
background-color: #fff;
3430
box-shadow: var(--q-ui-shadows-button);
3531
}
@@ -39,8 +35,7 @@
3935
background: var(--q-ui-palette-primary);
4036
}
4137

42-
&.primary[disabled]:hover,
43-
&.primary[aria-disabled="true"]:hover {
38+
&.primary[disabled]:hover {
4439
color: #fff;
4540
background: var(--q-ui-palette-primary);
4641
}

quilt-sync/ui/src/components/buttons/base/cta.rs

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,23 +35,13 @@ pub fn CtaLink(
3535
href: String,
3636
#[prop(optional)] icon: Option<&'static str>,
3737
#[prop(optional, into)] primary: MaybeProp<bool>,
38-
/// Render the link inert. An anchor is not a form control, so there is no
39-
/// `disabled` attribute to set: instead the `href` is dropped — an anchor
40-
/// without one is neither a hyperlink nor keyboard-focusable — and
41-
/// `aria-disabled` says the same to assistive tech. The stylesheet keys
42-
/// the dimmed, not-allowed treatment off `[aria-disabled="true"]`
43-
/// alongside the `[disabled]` it already applies to buttons.
44-
#[prop(optional, into)]
45-
disabled: MaybeProp<bool>,
4638
children: Children,
4739
) -> impl IntoView {
48-
let is_disabled = Signal::derive(move || disabled.get().unwrap_or(false));
4940
view! {
5041
<a
5142
class="qui-button large"
5243
class:primary=move || primary.get().unwrap_or(false)
53-
href=move || (!is_disabled.get()).then(|| href.clone())
54-
aria-disabled=move || is_disabled.get().then_some("true")
44+
href=href
5545
>
5646
<span>{children()}</span>
5747
{icon.map(|src| view! { <img class="qui-icon" src=src /> })}

quilt-sync/ui/src/components/buttons/create_new_revision.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@ const KIND: ButtonKind = ButtonKind::CreateNewRevision;
88
pub fn CreateNewRevision(
99
href: String,
1010
#[prop(optional, into)] primary: MaybeProp<bool>,
11-
#[prop(optional, into)] disabled: MaybeProp<bool>,
1211
) -> impl IntoView {
1312
view! {
14-
<CtaLink href=href icon=KIND.icon() primary=primary disabled=disabled>
13+
<CtaLink href=href icon=KIND.icon() primary=primary>
1514
{KIND.label()}
1615
</CtaLink>
1716
}

quilt-sync/ui/src/pages/installed_package/content.rs

Lines changed: 71 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -199,10 +199,11 @@ pub(super) fn InstalledPackageContent(
199199
// Push is offered only when there's a remote and something to ship.
200200
let is_publishable = has_origin
201201
&& (status == "ahead" || (status == "up_to_date" && has_changes) || status == "local");
202-
// A role denial disables both action-bar affordances (see
203-
// `commit_affordance_disabled`) and gives them a shared tooltip. Disabled,
204-
// not hidden: the user opened this page deliberately, and a button that
205-
// simply vanishes explains nothing.
202+
// A role denial disables the action bar's *action* — Commit and Push —
203+
// and gives it a tooltip. Disabled, not hidden: the user opened this page
204+
// deliberately, and a button that simply vanishes explains nothing.
205+
// "Create new revision" is navigation and stays live (see
206+
// `commit_affordance_disabled`).
206207
let commit_denied = commit_affordance_disabled(no_access_reason.as_deref());
207208
let commit_hint = util::commit_denied_hint(no_access_reason.as_deref());
208209

@@ -374,21 +375,11 @@ pub(super) fn InstalledPackageContent(
374375
);
375376
view! {
376377
<div class="qui-actionbar">
377-
{with_popover(
378-
commit_hint.clone(),
379-
view! {
380-
<buttons::CreateNewRevision
381-
href=href
382-
primary=revision_primary
383-
disabled=commit_denied
384-
/>
385-
}
386-
.into_any(),
387-
)}
378+
<buttons::CreateNewRevision href=href primary=revision_primary />
388379
{is_publishable.then(|| view! {
389380
<span class="actions-divider">"or"</span>
390381
{with_popover(
391-
commit_hint.clone(),
382+
commit_hint,
392383
view! {
393384
<buttons::CommitAndPush
394385
on_click=on_publish
@@ -445,16 +436,21 @@ pub(super) fn InstalledPackageContent(
445436
}
446437
}
447438

448-
/// Whether this page's commit affordances — "Create new revision", which is
449-
/// the way into the commit page, and "Commit and Push" — must be inert.
439+
/// Whether this page's commit *action* — "Commit and Push" — must be inert.
450440
///
451441
/// Committing looks like offline work but is not: the workflow quality gate
452442
/// reads the bucket's `.quilt/workflows/config.yml` before any manifest is
453443
/// written, so under a role that cannot read the bucket every commit is a 403.
454-
/// The packages *list* hides its affordance instead — a list row carries its
455-
/// own visible denial reason, and an inert button there would be noise — but a
456-
/// page the user navigated to must disable and explain, which is what
457-
/// [`crate::util::commit_denied_hint`] supplies.
444+
/// A page the user navigated to disables and explains rather than hiding,
445+
/// which is what [`crate::util::commit_denied_hint`] supplies. (The packages
446+
/// *list* hides its affordance instead: a list row carries its own visible
447+
/// denial reason, and an inert button there would be noise.)
448+
///
449+
/// Deliberately narrow: this gates actions that cannot succeed, never
450+
/// navigation. "Create new revision" is a link to the commit page, and that
451+
/// page opens fine under a denial — it shows the banner and disables its own
452+
/// commit buttons — so the user can still read their changes and the
453+
/// explanation lands at the point of action instead of one screen earlier.
458454
///
459455
/// A pure seam so the contrast (denied disables, readable does not) is
460456
/// testable without a DOM, matching `publish_affordance` on the list page.
@@ -485,23 +481,71 @@ mod tests {
485481

486482
const DENIED: &str = "Current role ReadOnly has no access to this bucket";
487483

488-
/// A denied bucket makes the page's commit affordances inert, and — the
484+
/// The page's own source, so the markup rule below can be checked without
485+
/// a DOM: these tests run on the host target, where the Leptos view is
486+
/// never rendered.
487+
const SOURCE: &str = include_str!("content.rs");
488+
489+
/// A denied bucket makes the page's commit *action* inert, and — the
489490
/// contrast that keeps this test honest — an otherwise identical package
490-
/// on a readable bucket leaves them live.
491+
/// on a readable bucket leaves it live.
491492
#[test]
492-
fn a_denied_bucket_disables_the_commit_affordances_a_readable_one_does_not() {
493+
fn a_denied_bucket_disables_the_commit_action_a_readable_one_does_not() {
493494
assert!(commit_affordance_disabled(Some(DENIED)));
494495
assert!(!commit_affordance_disabled(None));
495496
}
496497

497-
/// The disabled affordances are not silent: the same denial supplies the
498+
/// The rule is "disable actions that cannot succeed, never navigation".
499+
/// "Create new revision" is a link to the commit page, which opens fine
500+
/// under a denial and explains itself there, so it must stay a plain,
501+
/// followable link — no `disabled`, no tooltip wrapper pre-empting it one
502+
/// screen early. Pinned against the markup because that is where someone
503+
/// would undo it while "fixing the inconsistency" with Commit and Push.
504+
#[test]
505+
fn the_link_to_the_commit_page_is_never_disabled() {
506+
let bar = action_bar_markup();
507+
let start = bar
508+
.find("<buttons::CreateNewRevision")
509+
.expect("the action bar still renders the commit-page link");
510+
let (before, from_link) = bar.split_at(start);
511+
let element = &from_link[..from_link
512+
.find("/>")
513+
.expect("the link element is self-closing")];
514+
515+
assert!(
516+
!element.contains("disabled"),
517+
"the commit-page link must stay followable, got: {element}"
518+
);
519+
assert!(
520+
!before.contains("with_popover"),
521+
"the commit-page link must not be wrapped in a denial tooltip"
522+
);
523+
// The contrast: the action *after* it does carry one, so this test
524+
// cannot pass merely because every tooltip was deleted.
525+
assert!(
526+
from_link.contains("with_popover"),
527+
"Commit and Push still explains its denial"
528+
);
529+
}
530+
531+
/// The disabled action is not silent: the same denial supplies the
498532
/// tooltip naming the role and the fix, and a readable bucket has none.
499533
#[test]
500-
fn the_disabled_commit_affordances_explain_themselves() {
534+
fn the_disabled_commit_action_explains_itself() {
501535
assert_eq!(
502536
commit_denied_hint(Some(DENIED)).as_deref(),
503537
Some("Current role ReadOnly has no access to this bucket. Switch role to commit.")
504538
);
505539
assert_eq!(commit_denied_hint(None), None);
506540
}
541+
542+
/// The action bar's markup, sliced out of [`SOURCE`] so the assertions
543+
/// above never read this test module's own text.
544+
fn action_bar_markup() -> &'static str {
545+
let start = SOURCE
546+
.find(r#"<div class="qui-actionbar">"#)
547+
.expect("the page still renders an action bar");
548+
let bar = &SOURCE[start..];
549+
&bar[..bar.find("</div>").expect("the action bar is closed")]
550+
}
507551
}

0 commit comments

Comments
 (0)