Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions crates/rest/src/handlers/user_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -326,22 +326,28 @@ fn parse_if_match(conditional: &ConditionalHeaders) -> RestResult<EntityTagPreco
}

/// Checks a parsed precondition against the currently stored `version`, where
/// version `0` means "no document exists yet".
/// version `0` means "no document has been written yet".
///
/// Version `0` is mapped to "no current representation" so the shared evaluator
/// gives the semantics this endpoint already had: `*` requires an existing
/// document, and a concrete tag never matches a document that does not exist.
/// The GET handler serves that state as a *real representation* — `{}` with
/// `ETag: "0"` — so the validator it hands out must round-trip: a concrete
/// `If-Match: "0"` matches version 0, or a fresh user's read-then-conditional-
/// write could never succeed (#442). `If-Match: *` keeps requiring an actually
/// stored document, per its "unless it does not exist" semantics.
fn check_if_match(precondition: &EntityTagPrecondition, version: i64) -> RestResult<()> {
let current = (version > 0).then(|| version.to_string());
if precondition.if_match_satisfied(current.as_deref()) {
let satisfied = match precondition {
EntityTagPrecondition::Tags(_) => {
precondition.if_match_satisfied(Some(&version.to_string()))
}
_ => precondition.if_match_satisfied((version > 0).then(|| version.to_string()).as_deref()),
};
if satisfied {
return Ok(());
}

let message = match current {
Some(current) => {
format!("If-Match precondition failed: current settings version is {current}")
}
None => "If-Match precondition failed: no settings document exists yet".to_string(),
let message = if version > 0 {
format!("If-Match precondition failed: current settings version is {version}")
} else {
"If-Match precondition failed: no settings document exists yet".to_string()
};
Err(RestError::PreconditionFailed { message })
}
Expand Down
39 changes: 39 additions & 0 deletions crates/rest/tests/user_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,45 @@ async fn patch_null_deletes_a_key() {
assert_eq!(get.json::<Value>(), json!({"theme": "dark"}));
}

/// #442: `GET` serves the empty document as a real representation (`{}` with
/// `ETag: "0"`), so the validator it hands out must round-trip — a fresh
/// user's read-then-conditional-write deadlocked on 412 forever otherwise.
#[tokio::test]
async fn if_match_zero_writes_the_first_version() {
let server = create_test_server();

let fresh = server.get("/_user/settings").await;
assert_eq!(fresh.header("etag"), HeaderValue::from_static("\"0\""));

let created = server
.patch("/_user/settings")
.add_header(IF_MATCH, HeaderValue::from_static("\"0\""))
.json(&json!({"recentSearches": [{"query": "/Patient?name=x"}]}))
.await;
created.assert_status_ok();
assert_eq!(created.header("etag"), HeaderValue::from_static("\"1\""));

// And once a version exists, "0" is genuinely stale again.
let stale = server
.patch("/_user/settings")
.add_header(IF_MATCH, HeaderValue::from_static("\"0\""))
.json(&json!({"a": 1}))
.await;
assert_eq!(stale.status_code(), StatusCode::PRECONDITION_FAILED);
}

/// `If-Match: *` still requires an actually stored document.
#[tokio::test]
async fn if_match_star_still_requires_an_existing_document() {
let server = create_test_server();
let conflict = server
.patch("/_user/settings")
.add_header(IF_MATCH, HeaderValue::from_static("*"))
.json(&json!({"a": 1}))
.await;
assert_eq!(conflict.status_code(), StatusCode::PRECONDITION_FAILED);
}

#[tokio::test]
async fn stale_if_match_is_rejected_with_412() {
let server = create_test_server();
Expand Down
179 changes: 52 additions & 127 deletions crates/ui/assets/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,25 @@ body {
margin: 0;
min-height: 100vh;
display: grid;
grid-template-columns: 295px 1fr;
grid-template-columns: 76px 1fr;
font-size: 15px;
line-height: 1.2;
}

/* The fixed sidebar sits outside the grid flow, so the flow columns need
explicit placement — otherwise the pane auto-fills the rail column. */
.pane {
grid-column: 2;
}

body.has-nav-panel .nav-panel {
grid-column: 2;
}

body.has-nav-panel .pane {
grid-column: 3;
}

svg {
display: block;
}
Expand All @@ -119,13 +133,29 @@ svg {
/* ---------- Sidebar ---------- */

.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 40;
box-sizing: border-box;
width: 295px;
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px 24px;
border-right: 1px solid var(--divider);
box-shadow: 1px 0 0 var(--divider-glint);
min-height: 100vh;
background: var(--bg);
overflow-y: auto;
overflow-x: hidden;
transition: width 0.15s ease;
}

/* Expanded (hover / keyboard focus): overlay the content with a lift shadow. */
.sidebar:hover,
.sidebar:focus-within {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.14);
}

.brand {
Expand Down Expand Up @@ -218,71 +248,36 @@ a.nav-item:hover {
white-space: nowrap;
}

/* ---------- Collapse toggle + collapsed (icon-only) rail ---------- */
/* ---------- Collapsed (icon-only) rail ---------- */

.brand__text {
flex: 1;
min-width: 0;
}

.nav-toggle {
display: grid;
place-items: center;
width: 32px;
height: 32px;
flex: none;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--muted);
cursor: pointer;
}

.nav-toggle:hover {
background: var(--accent-soft);
color: var(--text-strong);
}

/*
* The toggle icon is directional (a panel + chevron). The base glyph points
* right (= expand), which is correct for the collapsed rail; while expanded,
* mirror it so the chevron points left (= collapse).
* Collapsed rail: the resting state, whenever the pointer and the keyboard
* focus are both elsewhere (#438 dropped the explicit toggle). Labels are
* visually hidden but kept in the accessibility tree so screen readers still
* announce them; a `title` on each item gives a hover tooltip, and hovering
* or focusing the rail expands it in place.
*/
.nav-toggle .icon {
transform: scaleX(-1);
}

html[data-nav="collapsed"] .nav-toggle .icon {
transform: none;
}

/*
* Collapsed rail: driven by `data-nav="collapsed"` on <html> (set by nav.js
* before first paint), and forced on narrow viewports (see the media query
* below). Labels are visually hidden but kept in the accessibility tree so
* screen readers still announce them; a `title` on each item gives a hover
* tooltip.
*/
html[data-nav="collapsed"] body {
grid-template-columns: 76px 1fr;
}

html[data-nav="collapsed"] .sidebar {
.sidebar:not(:hover):not(:focus-within) {
width: 76px;
padding: 20px 14px;
align-items: center;
}

html[data-nav="collapsed"] .brand {
.sidebar:not(:hover):not(:focus-within) .brand {
flex-direction: column;
gap: 10px;
}

html[data-nav="collapsed"] .brand__text {
.sidebar:not(:hover):not(:focus-within) .brand__text {
display: none;
}

html[data-nav="collapsed"] .nav__section {
.sidebar:not(:hover):not(:focus-within) .nav__section {
/* Group headers are decorative; drop them entirely when collapsed. */
height: 1px;
margin: 8px 0;
Expand All @@ -291,23 +286,23 @@ html[data-nav="collapsed"] .nav__section {
border-top: 1px solid var(--divider);
}

html[data-nav="collapsed"] .nav-item {
.sidebar:not(:hover):not(:focus-within) .nav-item {
width: 48px;
padding: 0;
justify-content: center;
}

html[data-nav="collapsed"] .selector {
.sidebar:not(:hover):not(:focus-within) .selector {
width: 48px;
padding: 8px;
justify-content: center;
}

/* Hide the text of labels/selectors without removing them from the a11y tree. */
html[data-nav="collapsed"] .nav-item__label,
html[data-nav="collapsed"] .selector__label,
html[data-nav="collapsed"] .selector__chevrons,
html[data-nav="collapsed"] .brand__version {
.sidebar:not(:hover):not(:focus-within) .nav-item__label,
.sidebar:not(:hover):not(:focus-within) .selector__label,
.sidebar:not(:hover):not(:focus-within) .selector__chevrons,
.sidebar:not(:hover):not(:focus-within) .brand__version {
position: absolute;
width: 1px;
height: 1px;
Expand Down Expand Up @@ -1048,65 +1043,6 @@ button.pill {
/* ---------- Narrow screens: stack the shell ---------- */

@media (max-width: 900px) {
/*
Narrow viewports force the collapsed icon rail regardless of the persisted
preference. Reuse the collapsed-state rules by mapping them onto the base
selectors here (the `data-nav` attribute still controls wide screens).
*/
body {
grid-template-columns: 76px 1fr;
}

.sidebar {
padding: 20px 14px;
align-items: center;
}

.brand {
flex-direction: column;
gap: 10px;
}

.brand__text,
.nav-toggle {
display: none;
}

.nav__section {
height: 1px;
margin: 8px 0;
overflow: hidden;
text-indent: -999px;
border-top: 1px solid var(--divider);
}

.nav-item {
width: 48px;
padding: 0;
justify-content: center;
}

.selector {
width: 48px;
padding: 8px;
justify-content: center;
}

.nav-item__label,
.selector__label,
.selector__chevrons,
.brand__version {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
Expand Down Expand Up @@ -3574,7 +3510,7 @@ button.filter-rail__item {

/* Three columns when a nav panel is present: sidebar, the panel, content. */
body.has-nav-panel {
grid-template-columns: 250px 232px 1fr;
grid-template-columns: 76px 232px 1fr;
}

.nav-panel {
Expand Down Expand Up @@ -3667,7 +3603,8 @@ body.has-nav-panel {
}

@media (max-width: 1100px) {
body.has-nav-panel { grid-template-columns: 250px 1fr; }
body.has-nav-panel { grid-template-columns: 76px 1fr; }
body.has-nav-panel .pane { grid-column: 2; }
.nav-panel { display: none; }
}

Expand All @@ -3688,18 +3625,6 @@ body.has-nav-panel {
font-weight: 500;
}

/* ---------------------------------------------------------------------------
Collapsed sidebar + the Resources type panel. The sidebar collapse itself is
driven by `data-nav="collapsed"` (the block near the top of this file). Here
we only add the 3-column grid variant: when the type panel is present, the
collapsed sidebar rail must keep the panel and content in their own columns
(otherwise the content wraps below the two rails).
--------------------------------------------------------------------------- */
html[data-nav="collapsed"] body.has-nav-panel {
grid-template-columns: 76px 232px 1fr;
}
html[data-nav="collapsed"] .menu__panel { left: 0; }

/* OR value stacks (#414): one input per comma alternative. */
.builder-row__values {
display: flex;
Expand Down
Loading
Loading