From 5e66fa6e7dc7dc6bbcd268a905c55b3f2a0e9b38 Mon Sep 17 00:00:00 2001 From: datron Date: Mon, 13 Apr 2026 18:11:55 +0530 Subject: [PATCH 1/3] feat: add breadcrumbs and superposition platform links Signed-off-by: datron --- crates/frontend/src/components.rs | 1 + crates/frontend/src/components/breadcrumbs.rs | 171 ++++++++++++++++++ crates/frontend/src/components/side_nav.rs | 2 +- crates/frontend/src/hoc/layout.rs | 2 + crates/frontend/src/types.rs | 12 ++ crates/superposition/src/main.rs | 2 + 6 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 crates/frontend/src/components/breadcrumbs.rs diff --git a/crates/frontend/src/components.rs b/crates/frontend/src/components.rs index 901b8923a..2318f726d 100644 --- a/crates/frontend/src/components.rs +++ b/crates/frontend/src/components.rs @@ -1,6 +1,7 @@ pub mod alert; pub mod authz; pub mod badge; +pub mod breadcrumbs; pub mod button; pub mod change_form; pub mod change_summary; diff --git a/crates/frontend/src/components/breadcrumbs.rs b/crates/frontend/src/components/breadcrumbs.rs new file mode 100644 index 000000000..791087561 --- /dev/null +++ b/crates/frontend/src/components/breadcrumbs.rs @@ -0,0 +1,171 @@ +use leptos::*; +use leptos_router::{A, use_location}; + +use crate::types::{BreadcrumbSegment, OrganisationId, Workspace}; +use crate::utils::use_url_base; + +const SKIP_SEGMENTS: [&str; 4] = ["admin", "action", "authz", "org-authz"]; + +/// Maps a URL path segment to a human-readable label. +/// Returns None for segments that should be skipped (like "action"). +fn segment_to_label(segment: &str) -> Option { + let title_segments = [ + "types", + "audit-log", + "default-config", + "experiment-groups", + "compare", + "config", + "dimensions", + "experiments", + "function", + "resolve", + "secrets", + "variables", + "versions", + "webhooks", + "workspaces", + "overrides", + "create", + "edit", + ]; + match segment { + x if !title_segments.contains(&x) => Some(segment.to_string()), + x => Some( + x.split('-') + .map(|s| { + let mut chars = s.chars(); + match chars.next() { + Some(first) => { + first.to_uppercase().collect::() + chars.as_str() + } + None => String::new(), + } + }) + .collect::>() + .join(" "), + ), + } +} + +/// Builds the breadcrumb trail from the current URL path. +fn build_breadcrumbs(path: &str, base: &str) -> Vec { + // Strip the base prefix if present + let path = path.strip_prefix(base).unwrap_or(path); + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + + let mut previous_segments = String::new(); + let mut breadcrumbs = Vec::new(); + for (i, segment) in segments.iter().enumerate() { + let is_current = i == segments.len() - 1; + if !SKIP_SEGMENTS.contains(segment) { + if let Some(label) = segment_to_label(segment) { + breadcrumbs.push(BreadcrumbSegment { + label, + href: if is_current { + String::new() + } else { + let current = if previous_segments == "/admin" { + "organisations" + } else if previous_segments + .split("/") + .filter(|s| !s.is_empty()) + .collect::>() + .len() + == 2 + { + "workspaces" + } else { + segment + }; + format!("{}{}/{}", base, previous_segments, current) + }, + is_current, + }); + } + } + previous_segments.push('/'); + previous_segments.push_str(segment); + } + breadcrumbs +} + +/// Global navigation breadcrumbs component. +/// Displays a breadcrumb trail based on the current URL path. +/// Structure: >> >>
>> +#[component] +pub fn Breadcrumbs() -> impl IntoView { + let location = use_location(); + let base = use_url_base(); + + // Check if we have org/workspace context (for workspace-level pages) + let org_context = use_context::>(); + let workspace_context = use_context::>(); + + // Only show breadcrumbs if we have context (org or workspace level) + let has_context = org_context.is_some() || workspace_context.is_some(); + + let breadcrumbs = Signal::derive(move || { + if !has_context { + return Vec::new(); + } + let path = location.pathname.get(); + build_breadcrumbs(&path, &base) + }); + + view! { + }> + + + } +} + +/// Renders a single breadcrumb item - either a link or plain text. +#[component] +fn BreadcrumbItem(item: BreadcrumbSegment) -> impl IntoView { + // is_current means this is the last item (current page), so no separator needed + let show_separator = !item.is_current; + + view! { +
+ {if item.is_current || item.href.is_empty() { + view! { {item.label.clone()} } + .into_view() + } else { + view! { + + {item.label.clone()} + + } + .into_view() + }} + {if show_separator { + view! { + + + + } + .into_view() + } else { + ().into_view() + }} +
+ } +} diff --git a/crates/frontend/src/components/side_nav.rs b/crates/frontend/src/components/side_nav.rs index c2eb2411a..c0a0d3016 100644 --- a/crates/frontend/src/components/side_nav.rs +++ b/crates/frontend/src/components/side_nav.rs @@ -341,7 +341,7 @@ pub fn NavComponent( >
Superposition Platform diff --git a/crates/frontend/src/hoc/layout.rs b/crates/frontend/src/hoc/layout.rs index 1b41ca8a9..8b665d4ea 100644 --- a/crates/frontend/src/hoc/layout.rs +++ b/crates/frontend/src/hoc/layout.rs @@ -1,6 +1,7 @@ use crate::{ api::workspaces, components::{ + breadcrumbs::Breadcrumbs, side_nav::{OrgSideNav, SideNav, SideNavCollapsedProvider}, skeleton::{Skeleton, SkeletonVariant}, toast::Toast, @@ -37,6 +38,7 @@ pub fn use_org() -> Signal { pub fn CommonLayout(children: Children) -> impl IntoView { view! {
+ {children()}
{move || { diff --git a/crates/frontend/src/types.rs b/crates/frontend/src/types.rs index fbdd37c82..e1c312164 100644 --- a/crates/frontend/src/types.rs +++ b/crates/frontend/src/types.rs @@ -168,6 +168,18 @@ pub struct BreadCrums { pub is_link: bool, } +/// Breadcrumb segment for global navigation breadcrumbs. +/// Represents a single item in the breadcrumb trail. +#[derive(Debug, Clone)] +pub struct BreadcrumbSegment { + /// Human-readable label for the breadcrumb + pub label: String, + /// URL to navigate to when clicked (empty for current page) + pub href: String, + /// Whether this is the current/active page (non-clickable) + pub is_current: bool, +} + #[derive(Debug, Clone, Deserialize)] pub struct ErrorResponse { pub message: String, diff --git a/crates/superposition/src/main.rs b/crates/superposition/src/main.rs index 44d32f407..70136a21b 100644 --- a/crates/superposition/src/main.rs +++ b/crates/superposition/src/main.rs @@ -174,7 +174,9 @@ async fn main() -> Result<()> { .service(web::redirect("/", ui_redirect_path.to_string())) .service(web::redirect("/admin", ui_redirect_path.to_string())) .service(web::redirect("/admin/", ui_redirect_path.to_string())) + .service(web::redirect("/admin/{org_id}", "workspaces")) .service(web::redirect("/admin/{org_id}/", "workspaces")) + .service(web::redirect("/admin/{org_id}/{tenant}", "default-config")) .service(web::redirect("/admin/{org_id}/{tenant}/", "default-config")) /***************************** UI Routes ******************************/ .route("/fxn/{tail:.*}", leptos_actix::handle_server_fns()) From 9623ab21219b80f12e05484bb28e954cf7da48bc Mon Sep 17 00:00:00 2001 From: datron Date: Mon, 27 Apr 2026 16:34:38 +0530 Subject: [PATCH 2/3] feat: segments for frontend routing and breadcrumbs Signed-off-by: datron --- crates/frontend/src/app.rs | 240 +++++++++++++++--- crates/frontend/src/components/breadcrumbs.rs | 156 +++++++----- crates/frontend/src/types.rs | 118 +++++++++ 3 files changed, 417 insertions(+), 97 deletions(-) diff --git a/crates/frontend/src/app.rs b/crates/frontend/src/app.rs index cac33620b..c3dea451b 100755 --- a/crates/frontend/src/app.rs +++ b/crates/frontend/src/app.rs @@ -35,7 +35,7 @@ use crate::pages::{ webhooks::Webhooks, workspace::Workspace, }; -use crate::types::Envs; +use crate::types::{Envs, RoutePart, RouteSegment, join_route_parts}; #[component] pub fn App(app_envs: Envs) -> impl IntoView { @@ -117,7 +117,10 @@ pub fn App(app_envs: Envs) -> impl IntoView { @@ -129,7 +132,14 @@ pub fn App(app_envs: Envs) -> impl IntoView { @@ -139,102 +149,258 @@ pub fn App(app_envs: Envs) -> impl IntoView { } /> - - + + - + - - + + - + - - + + - + - + - + - - + + - + - + - - + + - + - + - - + + - - + + // Option { - let title_segments = [ - "types", - "audit-log", - "default-config", - "experiment-groups", - "compare", - "config", - "dimensions", - "experiments", - "function", - "resolve", - "secrets", - "variables", - "versions", - "webhooks", - "workspaces", - "overrides", - "create", - "edit", - ]; - match segment { - x if !title_segments.contains(&x) => Some(segment.to_string()), - x => Some( - x.split('-') - .map(|s| { - let mut chars = s.chars(); - match chars.next() { - Some(first) => { - first.to_uppercase().collect::() + chars.as_str() - } - None => String::new(), +fn segment_to_label(segment: &str) -> String { + let is_known_segment = RouteSegment::from_str(segment).is_ok(); + + if !is_known_segment { + segment.to_string() + } else { + segment + .split('-') + .map(|s| { + let mut chars = s.chars(); + match chars.next() { + Some(first) => { + first.to_uppercase().collect::() + chars.as_str() } - }) - .collect::>() - .join(" "), - ), + None => String::new(), + } + }) + .collect::>() + .join(" ") } } @@ -58,31 +47,78 @@ fn build_breadcrumbs(path: &str, base: &str) -> Vec { let mut breadcrumbs = Vec::new(); for (i, segment) in segments.iter().enumerate() { let is_current = i == segments.len() - 1; - if !SKIP_SEGMENTS.contains(segment) { - if let Some(label) = segment_to_label(segment) { + let is_skipped = RouteSegment::try_from_str(segment) + .map(|seg| SKIP_SEGMENTS.contains(&seg)) + .unwrap_or(false); + + let is_config_versions_config = + *segment == "config" && segments.get(i + 1).copied() == Some("versions"); + if is_config_versions_config { + previous_segments.push('/'); + previous_segments.push_str(segment); + continue; + } + + if !is_skipped { + let label = segment_to_label(segment); + let is_versions_in_config_flow = *segment == "versions" + && i > 0 + && segments.get(i - 1).copied() == Some("config"); + + if previous_segments == "/admin" { breadcrumbs.push(BreadcrumbSegment { - label, - href: if is_current { - String::new() - } else { - let current = if previous_segments == "/admin" { - "organisations" - } else if previous_segments - .split("/") - .filter(|s| !s.is_empty()) - .collect::>() - .len() - == 2 - { - "workspaces" - } else { - segment - }; - format!("{}{}/{}", base, previous_segments, current) - }, - is_current, + label: "Organisations".to_string(), + href: format!("{}{}/organisations", base, previous_segments), + is_current: false, }); } + if previous_segments + .split("/") + .filter(|s| !s.is_empty()) + .collect::>() + .len() + == 3 + { + breadcrumbs.insert( + 2, + BreadcrumbSegment { + label: "Workspaces".to_string(), + href: { + let proper_url_length = previous_segments + .rfind("/") + .unwrap_or(previous_segments.len()); + format!( + "{}{}/workspaces", + base, + &previous_segments[..proper_url_length] + ) + }, + is_current: false, + }, + ); + } + + breadcrumbs.push(BreadcrumbSegment { + label: if is_versions_in_config_flow { + "Versions".to_string() + } else { + label + }, + href: if is_current + || previous_segments == "/admin" + || previous_segments + .split("/") + .filter(|s| !s.is_empty()) + .collect::>() + .len() + == 2 + { + String::new() + } else { + format!("{}{}/{}", base, previous_segments, segment) + }, + is_current, + }); } previous_segments.push('/'); previous_segments.push_str(segment); diff --git a/crates/frontend/src/types.rs b/crates/frontend/src/types.rs index e1c312164..ba6412b33 100644 --- a/crates/frontend/src/types.rs +++ b/crates/frontend/src/types.rs @@ -1,3 +1,5 @@ +use std::str::FromStr; + use derive_more::{Deref, DerefMut}; use leptos::{ReadSignal, WriteSignal}; use serde::{Deserialize, Serialize}; @@ -296,3 +298,119 @@ impl DropdownOption for DimensionTypeOptions { self.to_string() } } + +#[derive( + Copy, + Clone, + Debug, + PartialEq, + Eq, + Hash, + strum_macros::Display, + strum_macros::EnumString, + strum_macros::EnumIter, +)] +pub enum RouteSegment { + #[strum(serialize = "types")] + Types, + #[strum(serialize = "audit-log")] + AuditLog, + #[strum(serialize = "default-config")] + DefaultConfig, + #[strum(serialize = "experiment-groups")] + ExperimentGroups, + #[strum(serialize = "compare")] + Compare, + #[strum(serialize = "config")] + Config, + #[strum(serialize = "dimensions")] + Dimensions, + #[strum(serialize = "experiments")] + Experiments, + #[strum(serialize = "function")] + Function, + #[strum(serialize = "resolve")] + Resolve, + #[strum(serialize = "secrets")] + Secrets, + #[strum(serialize = "variables")] + Variables, + #[strum(serialize = "versions")] + Versions, + #[strum(serialize = "webhooks")] + Webhooks, + #[strum(serialize = "workspaces")] + Workspaces, + #[strum(serialize = "overrides")] + Overrides, + #[strum(serialize = "create")] + Create, + #[strum(serialize = "edit")] + Edit, + #[strum(serialize = "admin")] + Admin, + #[strum(serialize = "settings")] + Settings, + #[strum(serialize = "authz")] + Authz, + #[strum(serialize = "organisations")] + Organisations, + #[strum(serialize = "action")] + Action, + #[strum(serialize = "org-authz")] + OrgAuthz, +} + +impl RouteSegment { + pub fn try_from_str(value: &str) -> Result { + Self::from_str(value) + } +} + +/// A route part can be either a static segment or a dynamic `:param`. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub enum RoutePart { + Static(RouteSegment), + Dynamic(&'static str), +} + +impl RoutePart { + pub fn to_path_part(&self) -> String { + match self { + Self::Static(seg) => seg.to_string(), + Self::Dynamic(name) => { + let trimmed = name.trim_start_matches(':'); + format!(":{trimmed}") + } + } + } +} + +impl From for RoutePart { + fn from(value: RouteSegment) -> Self { + Self::Static(value) + } +} + +impl From<&'static str> for RoutePart { + fn from(value: &'static str) -> Self { + if let Ok(seg) = RouteSegment::from_str(value) { + Self::Static(seg) + } else { + Self::Dynamic(value.trim_start_matches(':')) + } + } +} + +/// Join static and dynamic route parts with `/`. +pub fn join_route_parts(parts: I) -> String +where + I: IntoIterator, + T: Into, +{ + parts + .into_iter() + .map(|p| p.into().to_path_part()) + .collect::>() + .join("/") +} From 7b3ad9a4a1aaa2c69e29b39b79e85eb283faff8d Mon Sep 17 00:00:00 2001 From: "Ankit.Mahato" Date: Tue, 5 May 2026 18:43:51 +0530 Subject: [PATCH 3/3] fix: breadcrums navigation --- crates/frontend/src/components/breadcrumbs.rs | 53 +++++-------------- 1 file changed, 13 insertions(+), 40 deletions(-) diff --git a/crates/frontend/src/components/breadcrumbs.rs b/crates/frontend/src/components/breadcrumbs.rs index 9ea54427e..5e7246125 100644 --- a/crates/frontend/src/components/breadcrumbs.rs +++ b/crates/frontend/src/components/breadcrumbs.rs @@ -65,38 +65,23 @@ fn build_breadcrumbs(path: &str, base: &str) -> Vec { && i > 0 && segments.get(i - 1).copied() == Some("config"); - if previous_segments == "/admin" { + if i == 1 { breadcrumbs.push(BreadcrumbSegment { label: "Organisations".to_string(), - href: format!("{}{}/organisations", base, previous_segments), + href: format!("{}/admin/organisations", base), is_current: false, }); } - if previous_segments - .split("/") - .filter(|s| !s.is_empty()) - .collect::>() - .len() - == 3 - { - breadcrumbs.insert( - 2, - BreadcrumbSegment { - label: "Workspaces".to_string(), - href: { - let proper_url_length = previous_segments - .rfind("/") - .unwrap_or(previous_segments.len()); - format!( - "{}{}/workspaces", - base, - &previous_segments[..proper_url_length] - ) - }, - is_current: false, - }, - ); - } + + let href = if is_current { + String::new() + } else if i == 1 { + format!("{}/admin/{}/workspaces", base, segment) + } else if i == 2 { + format!("{}{}/{}/default-config", base, previous_segments, segment) + } else { + format!("{}{}/{}", base, previous_segments, segment) + }; breadcrumbs.push(BreadcrumbSegment { label: if is_versions_in_config_flow { @@ -104,19 +89,7 @@ fn build_breadcrumbs(path: &str, base: &str) -> Vec { } else { label }, - href: if is_current - || previous_segments == "/admin" - || previous_segments - .split("/") - .filter(|s| !s.is_empty()) - .collect::>() - .len() - == 2 - { - String::new() - } else { - format!("{}{}/{}", base, previous_segments, segment) - }, + href, is_current, }); }