Skip to content
Merged
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
1 change: 1 addition & 0 deletions crates/ui/e2e/pages/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const ROUTES = [
"/ui/batch",
"/ui/compartments",
"/ui/search-parameters",
"/ui/terminology",
"/ui/queries",
"/ui/history",
"/ui/search",
Expand Down
4 changes: 2 additions & 2 deletions crates/ui/src/bulk_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ pub async fn page(
principal: Option<Extension<helios_auth::Principal>>,
) -> Response {
let i18n = I18n::new(locale);
let status = current_status(state.version, rv.0, &rt);
let status = current_status(&state, rv.0, &rt);
let user_key = settings_user_key(principal.as_deref());
let resource_types = state.compartments.resource_type_names(&rt.id, rv.0).await;
let active_count = load_jobs(&state, &user_key, &rt.id)
Expand Down Expand Up @@ -428,7 +428,7 @@ pub async fn active(
principal: Option<Extension<helios_auth::Principal>>,
) -> Response {
let i18n = I18n::new(locale);
let status = current_status(state.version, rv.0, &rt);
let status = current_status(&state, rv.0, &rt);
let user_key = settings_user_key(principal.as_deref());
let jobs = load_jobs(&state, &user_key, &rt.id).await;
let mut entries: Vec<(String, ExportJob)> = jobs
Expand Down
4 changes: 2 additions & 2 deletions crates/ui/src/bulk_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ pub async fn page(
principal: Option<Extension<helios_auth::Principal>>,
) -> Response {
let i18n = I18n::new(locale);
let status = current_status(state.version, rv.0, &rt);
let status = current_status(&state, rv.0, &rt);
let available = state.settings.is_some();
let user_key = settings_user_key(principal.as_deref());

Expand Down Expand Up @@ -368,7 +368,7 @@ pub async fn detail(
Path(id): Path<String>,
) -> Response {
let i18n = I18n::new(locale);
let status = current_status(state.version, rv.0, &rt);
let status = current_status(&state, rv.0, &rt);
let user_key = settings_user_key(principal.as_deref());

let Some(s) = load_one(&state, &user_key, &rt.id, &id).await else {
Expand Down
2 changes: 1 addition & 1 deletion crates/ui/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ pub async fn page(
Query(query): Query<EditorQuery>,
) -> Response {
render(EditorPage {
status: crate::current_status(state.version, rv.0, &rt),
status: crate::current_status(&state, rv.0, &rt),
i18n: I18n::new(locale),
active_page: "editor",
resource_type: query.resource_type.unwrap_or_else(|| "Patient".to_string()),
Expand Down
122 changes: 99 additions & 23 deletions crates/ui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,44 @@ pub(crate) struct Status {
/// Whether the subscriptions engine is advertised — the sidebar entry and
/// the operator page only appear when it is (#580).
subscriptions_enabled: bool,
/// The safe navigation state derived from `HFS_TERMINOLOGY_SERVER` (#611).
/// The raw value is never exposed to templates unless it is a valid HTTP(S)
/// base URL.
terminology: TerminologyNavigation,
}

enum TerminologyNavigation {
Unconfigured,
Invalid,
Valid(String),
}

impl TerminologyNavigation {
fn from_config(value: Option<&str>) -> Self {
let Some(raw) = value else {
return Self::Unconfigured;
};

if raw.is_empty() || raw.trim() != raw {
return Self::Invalid;
}

let Ok(url) = reqwest::Url::parse(raw) else {
return Self::Invalid;
};
let valid = matches!(url.scheme(), "http" | "https")
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none();

if valid {
Self::Valid(raw.to_string())
} else {
Self::Invalid
}
}
}

impl Status {
Expand Down Expand Up @@ -330,6 +368,21 @@ impl Status {
self.subscriptions_enabled
}

/// A browser-safe terminology destination, when the configured value is a
/// valid absolute HTTP(S) URL (#611).
pub(crate) fn terminology_url(&self) -> Option<&str> {
match &self.terminology {
TerminologyNavigation::Valid(url) => Some(url),
TerminologyNavigation::Unconfigured | TerminologyNavigation::Invalid => None,
}
}

/// Whether the environment variable exists but cannot be used as a safe
/// browser destination (#611).
pub(crate) fn terminology_invalid(&self) -> bool {
matches!(self.terminology, TerminologyNavigation::Invalid)
}

/// The effective tenant id, for the `hfs-tenant` meta tag browser calls
/// read (#344).
pub(crate) fn tenant_id(&self) -> &str {
Expand Down Expand Up @@ -554,6 +607,16 @@ struct ResourcesPage {
show_save: bool,
}

/// Explains how to configure terminology navigation, or why the configured
/// value cannot be used (#611).
#[derive(Template)]
#[template(path = "pages/terminology.html")]
struct TerminologyPage {
status: Status,
i18n: I18n,
active_page: &'static str,
}

/// Saved FHIR queries page (#234). The shell is server-rendered; the list is
/// hydrated client-side from `/_user/settings` by `assets/saved-queries.js`,
/// the same per-user document (and fetch pattern) the theme toggle uses.
Expand Down Expand Up @@ -754,6 +817,7 @@ pub fn mount_with_conformance_source(
.route("/ui/queries", get(queries))
.route("/ui/queries/params", get(query_params_catalog))
.route("/ui/search-parameters", get(search_parameters))
.route("/ui/terminology", get(terminology_page))
.route("/ui/compartments", get(compartments_page))
// Batch/Transaction workspace (#476): upload → preflight → response.
.route("/ui/batch", get(batch_page))
Expand Down Expand Up @@ -1110,15 +1174,7 @@ async fn index(
.filter(|f| !f.is_empty() && f.chars().all(|c| c.is_ascii_alphanumeric()));
render(
build_index_page(
state.version,
locale,
types,
window,
all_types,
spec_types,
focus,
rv.0,
&rt,
&state, locale, types, window, all_types, spec_types, focus, rv.0, &rt,
)
.await,
)
Expand All @@ -1133,7 +1189,7 @@ async fn search(
) -> Response {
let resource_types = state.compartments.resource_type_names(&rt.id, rv.0).await;
render(SearchPage {
status: current_status(state.version, rv.0, &rt),
status: current_status(&state, rv.0, &rt),
i18n: I18n::new(locale),
active_page: "search",
nl: (*state.nl).clone(),
Expand All @@ -1152,7 +1208,7 @@ async fn queries(
) -> Response {
let resource_types = state.compartments.resource_type_names(&rt.id, rv.0).await;
render(QueriesPage {
status: current_status(state.version, rv.0, &rt),
status: current_status(&state, rv.0, &rt),
i18n: I18n::new(locale),
active_page: "queries",
resource_types,
Expand All @@ -1174,7 +1230,7 @@ async fn resources(
) -> Response {
let resource_types = state.compartments.resource_type_names(&rt.id, rv.0).await;
render(ResourcesPage {
status: current_status(state.version, rv.0, &rt),
status: current_status(&state, rv.0, &rt),
i18n: I18n::new(locale),
active_page: "resources",
nl: (*state.nl).clone(),
Expand All @@ -1186,6 +1242,21 @@ async fn resources(
})
}

/// Terminology setup state (#611). The sidebar links here when the environment
/// variable is absent or cannot be used as a safe browser destination.
async fn terminology_page(
State(state): State<WebState>,
locale: RequestLocale,
rv: RequestVersion,
rt: RequestTenant,
) -> Response {
render(TerminologyPage {
status: current_status(&state, rv.0, &rt),
i18n: I18n::new(locale),
active_page: "terminology",
})
}

/// Query string for the Resources page: an optional pre-selected type, so the
/// nav submenu can deep-link `/ui/resources?type=Observation`.
#[derive(Deserialize, Default)]
Expand Down Expand Up @@ -1269,7 +1340,7 @@ async fn search_parameters(
.snapshot(&rt.id, query.fhir_version())
.await;
render(SearchParametersPage {
status: current_status(state.version, rv.0, &rt),
status: current_status(&state, rv.0, &rt),
i18n: I18n::new(locale),
active_page: "search-parameters",
view: search_params::build_view(&snapshot, &query),
Expand Down Expand Up @@ -1301,7 +1372,7 @@ async fn batch_page(
rt: RequestTenant,
) -> Response {
render(BatchPage {
status: current_status(state.version, rv.0, &rt),
status: current_status(&state, rv.0, &rt),
i18n: I18n::new(locale),
active_page: "batch",
})
Expand Down Expand Up @@ -1333,7 +1404,7 @@ async fn compartments_page(
.await;
match compartments::build_view(&query, &defs) {
Some(view) => render(CompartmentsPage {
status: current_status(state.version, rv.0, &rt),
status: current_status(&state, rv.0, &rt),
i18n: I18n::new(locale),
active_page: "compartments",
view,
Expand All @@ -1342,7 +1413,7 @@ async fn compartments_page(
// without an outbound token, #320) — a warning, not a 404. The failed
// fetch is not cached, so the next request re-attempts it.
None => render(CompartmentsDegradedPage {
status: current_status(state.version, rv.0, &rt),
status: current_status(&state, rv.0, &rt),
i18n: I18n::new(locale),
active_page: "compartments",
}),
Expand All @@ -1358,14 +1429,14 @@ async fn status(
rt: RequestTenant,
HxRequest(is_htmx): HxRequest,
) -> Response {
let status = current_status(state.version, rv.0, &rt);
let status = current_status(&state, rv.0, &rt);
let i18n = I18n::new(locale);
if is_htmx {
render(StatusPartial { status, i18n })
} else {
render(
build_index_page(
state.version,
&state,
locale,
Vec::new(),
DashboardWindow::default(),
Expand All @@ -1388,7 +1459,7 @@ async fn history_page(
rt: RequestTenant,
) -> Response {
render(HistoryPage {
status: current_status(state.version, rv.0, &rt),
status: current_status(&state, rv.0, &rt),
i18n: I18n::new(locale),
active_page: "history",
})
Expand Down Expand Up @@ -1454,7 +1525,7 @@ async fn history_diff(locale: RequestLocale, axum::Form(form): axum::Form<DiffFo
/// says so explicitly rather than presenting invented numbers as real (#555).
#[allow(clippy::too_many_arguments)]
async fn build_index_page(
version: &'static str,
state: &WebState,
locale: RequestLocale,
types: Vec<String>,
window: DashboardWindow,
Expand All @@ -1464,7 +1535,7 @@ async fn build_index_page(
fhir_version: helios_fhir::FhirVersion,
tenant: &RequestTenant,
) -> IndexPage {
let status = current_status(version, fhir_version, tenant);
let status = current_status(state, fhir_version, tenant);
let i18n = I18n::new(locale);
let live =
helios_observability::dashboard::snapshot(window, &tenant.id, &types, all_types).await;
Expand Down Expand Up @@ -2012,18 +2083,19 @@ fn bucket_floor_utc(ts: DateTime<Utc>, bucket_seconds: i64) -> DateTime<Utc> {
}

pub(crate) fn current_status(
version: &'static str,
state: &WebState,
fhir_version: helios_fhir::FhirVersion,
tenant: &RequestTenant,
) -> Status {
Status {
version,
version: state.version,
checked_at: unix_timestamp_seconds(),
fhir_version,
tenant_id: tenant.id.clone(),
tenant_display: tenant.display.clone(),
show_tenant_picker: tenant.multi,
subscriptions_enabled: helios_observability::subscriptions::enabled(),
terminology: TerminologyNavigation::from_config(state.terminology.as_deref()),
}
}

Expand Down Expand Up @@ -2070,6 +2142,7 @@ mod tests {
tenant_display: None,
show_tenant_picker: true,
subscriptions_enabled: false,
terminology: TerminologyNavigation::Unconfigured,
},
metrics: dash.metrics,
chart: dash.chart,
Expand Down Expand Up @@ -2216,6 +2289,7 @@ mod tests {
tenant_display: None,
show_tenant_picker: true,
subscriptions_enabled: false,
terminology: TerminologyNavigation::Unconfigured,
},
i18n: i18n("en"),
}
Expand Down Expand Up @@ -2273,6 +2347,7 @@ mod tests {
tenant_display: None,
show_tenant_picker: true,
subscriptions_enabled: false,
terminology: TerminologyNavigation::Unconfigured,
},
i18n: i18n("en"),
active_page: "queries",
Expand Down Expand Up @@ -2317,6 +2392,7 @@ mod tests {
tenant_display: None,
show_tenant_picker: true,
subscriptions_enabled: false,
terminology: TerminologyNavigation::Unconfigured,
},
i18n: i18n("es"),
active_page: "queries",
Expand Down
2 changes: 1 addition & 1 deletion crates/ui/src/subscriptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ pub async fn page(
};

render(SubscriptionsPage {
status: crate::current_status(state.version, rv.0, &rt),
status: crate::current_status(&state, rv.0, &rt),
i18n: I18n::new(locale),
active_page: "subscriptions",
available,
Expand Down
2 changes: 1 addition & 1 deletion crates/ui/src/tenants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ pub async fn page(
Query(query): Query<TenantsQuery>,
) -> Response {
let i18n = I18n::new(locale);
let status = current_status(state.version, rv.0, &rt);
let status = current_status(&state, rv.0, &rt);

let Some(storage) = state.tenants.as_ref() else {
return render(TenantsPage {
Expand Down
12 changes: 10 additions & 2 deletions crates/ui/templates/layouts/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,18 @@
</span>

<div class="nav__section">{{ i18n.t("nav-section-conditional") }}</div>
<span class="nav-item nav-item--soon" title="{{ i18n.t("nav-terminology") }}">
{% if let Some(url) = status.terminology_url() %}
<a class="nav-item" href="{{ url }}" target="_blank" rel="noopener noreferrer" hx-boost="false"
aria-label="{{ i18n.t("nav-terminology-new-window") }}" title="{{ i18n.t("nav-terminology-new-window") }}">
<span class="icon">{% include "icons/book.svg" %}</span>
<span class="nav-item__label">{{ i18n.t("nav-terminology") }}</span>
</span>
</a>
{% else %}
<a class="nav-item" href="/ui/terminology"{% if active_page == "terminology" %} aria-current="page"{% endif %} title="{{ i18n.t("nav-terminology") }}">
<span class="icon">{% include "icons/book.svg" %}</span>
<span class="nav-item__label">{{ i18n.t("nav-terminology") }}</span>
</a>
{% endif %}
<!-- Visible only when the subscriptions engine is advertised (#580). -->
{% if status.subscriptions_enabled() %}
<a class="nav-item" href="/ui/subscriptions"{% if active_page == "subscriptions" %} aria-current="page"{% endif %} title="{{ i18n.t("nav-subscriptions") }}">
Expand Down
Loading
Loading