Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

frontend: Show feature flags in topbar and separate page #1144

Merged
merged 4 commits into from
Nov 13, 2020
Merged
Show file tree
Hide file tree
Changes from 3 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
10 changes: 7 additions & 3 deletions src/db/types.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
use postgres_types::{FromSql, ToSql};
use serde::Serialize;

#[derive(Debug, Clone, PartialEq, Serialize, FromSql, ToSql)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, FromSql, ToSql)]
#[postgres(name = "feature")]
pub struct Feature {
name: String,
subfeatures: Vec<String>,
pub(crate) name: String,
pub(crate) subfeatures: Vec<String>,
}

impl Feature {
pub fn new(name: String, subfeatures: Vec<String>) -> Self {
Feature { name, subfeatures }
}

pub fn is_private(&self) -> bool {
self.name.starts_with('_')
}
}
5 changes: 5 additions & 0 deletions src/test/fakes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,11 @@ impl<'a> FakeRelease<'a> {
self
}

pub(crate) fn features(mut self, features: HashMap<String, Vec<String>>) -> Self {
self.package.features = features;
self
}

/// Returns the release_id
pub(crate) fn create(self) -> Result<i32, Error> {
use std::fs;
Expand Down
129 changes: 129 additions & 0 deletions src/web/crate_details.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ mod tests {
use crate::test::{wrapper, TestDatabase};
use failure::Error;
use kuchiki::traits::TendrilSink;
use std::collections::HashMap;

fn assert_last_successful_build_equals(
db: &TestDatabase,
Expand Down Expand Up @@ -741,4 +742,132 @@ mod tests {
Ok(())
});
}

#[test]
fn feature_flags_report_empty() {
wrapper(|env| {
env.fake_release()
.name("library")
.version("0.1.0")
.features(HashMap::new())
.create()?;

let page = kuchiki::parse_html().one(
env.frontend()
.get("/crate/library/0.1.0/features")
.send()?
.text()?,
);
assert!(page.select_first(r#"p[data-id="empty-features"]"#).is_ok());
Ok(())
});
}

#[test]
fn feature_private_feature_flags_are_hidden() {
wrapper(|env| {
let features = [("_private".into(), Vec::new())]
.iter()
.cloned()
.collect::<HashMap<String, Vec<String>>>();
env.fake_release()
.name("library")
.version("0.1.0")
.features(features)
.create()?;

let page = kuchiki::parse_html().one(
env.frontend()
.get("/crate/library/0.1.0/features")
.send()?
.text()?,
);
assert!(page.select_first(r#"p[data-id="empty-features"]"#).is_ok());
Ok(())
});
}

#[test]
fn feature_flags_without_default() {
wrapper(|env| {
let features = [("feature1".into(), Vec::new())]
.iter()
.cloned()
.collect::<HashMap<String, Vec<String>>>();
env.fake_release()
.name("library")
.version("0.1.0")
.features(features)
.create()?;

let page = kuchiki::parse_html().one(
env.frontend()
.get("/crate/library/0.1.0/features")
.send()?
.text()?,
);
assert!(page.select_first(r#"p[data-id="empty-features"]"#).is_err());
let def_len = page
.select_first(r#"b[data-id="default-feature-len"]"#)
.unwrap();
assert_eq!(def_len.text_contents(), "0");
Ok(())
});
}

#[test]
fn feature_flags_with_nested_default() {
wrapper(|env| {
let features = [
("default".into(), vec!["feature1".into()]),
("feature1".into(), vec!["feature2".into()]),
("feature2".into(), Vec::new()),
]
.iter()
.cloned()
.collect::<HashMap<String, Vec<String>>>();
env.fake_release()
.name("library")
.version("0.1.0")
.features(features)
.create()?;

let page = kuchiki::parse_html().one(
env.frontend()
.get("/crate/library/0.1.0/features")
.send()?
.text()?,
);
assert!(page.select_first(r#"p[data-id="empty-features"]"#).is_err());
let def_len = page
.select_first(r#"b[data-id="default-feature-len"]"#)
.unwrap();
assert_eq!(def_len.text_contents(), "3");
Ok(())
});
}

#[test]
fn feature_flags_report_null() {
wrapper(|env| {
let id = env
.fake_release()
.name("library")
.version("0.1.0")
.create()?;

env.db()
.conn()
.query("UPDATE releases SET features = NULL WHERE id = $1", &[&id])?;

let page = kuchiki::parse_html().one(
env.frontend()
.get("/crate/library/0.1.0/features")
.send()?
.text()?,
);
assert!(page.select_first(r#"p[data-id="null-features"]"#).is_ok());
Ok(())
});
}
}
88 changes: 88 additions & 0 deletions src/web/features.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
use crate::db::types::Feature;
use crate::{
db::Pool,
impl_webpage,
web::{page::WebPage, MetaData},
};
use iron::{IronResult, Request, Response};
use router::Router;
use serde::Serialize;
use std::collections::{HashMap, VecDeque};

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct FeaturesPage {
metadata: MetaData,
features: Option<Vec<Feature>>,
default_len: usize,
}

impl_webpage! {
FeaturesPage = "crate/features.html",
}

pub fn build_features_handler(req: &mut Request) -> IronResult<Response> {
let router = extension!(req, Router);
let name = cexpect!(req, router.find("name"));
let version = cexpect!(req, router.find("version"));

let mut conn = extension!(req, Pool).get()?;
let rows = ctry!(
req,
conn.query(
"SELECT releases.features FROM releases
INNER JOIN crates ON crates.id = releases.crate_id
WHERE crates.name = $1 AND releases.version = $2",
&[&name, &version]
)
);

let row = cexpect!(req, rows.get(0));

let mut default_len = 0;
let features = row
.get::<'_, usize, Option<Vec<Feature>>>(0)
.map(|raw| {
raw.into_iter()
.filter(|feature| !feature.is_private())
.map(|feature| (feature.name.clone(), feature))
.collect::<HashMap<String, Feature>>()
})
.map(|mut feature_map| {
almusil marked this conversation as resolved.
Show resolved Hide resolved
let mut features = get_tree_structure_from_default(&mut feature_map);
let mut remaining = feature_map
.into_iter()
.map(|(_, feature)| feature)
.collect::<Vec<Feature>>();
remaining.sort_by_key(|feature| feature.subfeatures.len());
almusil marked this conversation as resolved.
Show resolved Hide resolved

default_len = features.len();

features.extend(remaining.into_iter().rev());
features
});

FeaturesPage {
metadata: cexpect!(req, MetaData::from_crate(&mut conn, &name, &version)),
features,
default_len,
}
.into_response(req)
}

fn get_tree_structure_from_default(feature_map: &mut HashMap<String, Feature>) -> Vec<Feature> {
let mut features = Vec::new();
let mut queue: VecDeque<String> = VecDeque::new();

queue.push_back("default".into());
while !queue.is_empty() {
let name = queue.pop_front().unwrap();
if let Some(feature) = feature_map.remove(&name) {
feature
.subfeatures
.iter()
.for_each(|sub| queue.push_back(sub.clone()));
features.push(feature);
}
}
features
}
1 change: 1 addition & 0 deletions src/web/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ mod builds;
mod crate_details;
mod error;
mod extensions;
mod features;
mod file;
pub(crate) mod metrics;
mod releases;
Expand Down
4 changes: 4 additions & 0 deletions src/web/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ pub(super) fn build_routes() -> Routes {
"/crate/:name/:version/builds/:id",
super::builds::build_list_handler,
);
routes.internal_page(
"/crate/:name/:version/features",
super::features::build_features_handler,
);
routes.internal_page(
"/crate/:name/:version/source",
SimpleRedirect::new(|url| url.set_path(&format!("{}/", url.path()))),
Expand Down
76 changes: 76 additions & 0 deletions templates/crate/features.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{%- extends "base.html" -%}
{%- import "header/package_navigation.html" as navigation -%}

{%- block title -%}
{{ macros::doc_title(name=metadata.name, version=metadata.version) }}
{%- endblock title -%}

{%- block topbar -%}
{%- set latest_version = "" -%}
{%- set latest_path = "" -%}
{%- set target = "" -%}
{%- set inner_path = metadata.target_name ~ "/index.html" -%}
{%- set is_latest_version = true -%}
{%- set is_prerelease = false -%}
{%- include "rustdoc/topbar.html" -%}
{%- endblock topbar -%}

{%- block header -%}
{{ navigation::package_navigation(metadata=metadata, active_tab="features") }}
{%- endblock header -%}

{%- block body -%}
<div class="container package-page-container">
<div class="pure-g">
<div class="pure-u-1 pure-u-sm-7-24 pure-u-md-5-24">
<div class="pure-menu package-menu">
<ul class="pure-menu-list">
<li class="pure-menu-heading">Feature flags</li>
{%- if features -%}
{%- for feature in features -%}
<li class="pure-menu-item">
<a href="#{{ feature.name }}" class="pure-menu-link" style="text-align:center;">
{{ feature.name }}
</a>
</li>
{%- endfor -%}
{%- elif features is iterable -%}
<li class="pure-menu-item">
<span style="font-size: 13px;">This release does not have any feature flags.</span>
</li>
{%- else -%}
<li class="pure-menu-item">
<span style="font-size: 13px;">Feature flags data are not available for this release.</span>
</li>
{%- endif -%}
</ul>
</div>
</div>

<div class="pure-u-1 pure-u-sm-17-24 pure-u-md-19-24 package-details" id="main">
<h1>{{ metadata.name }}</h1>
{%- if features -%}
<p>This version has <b>{{ features | length }}</b> feature flags, <b data-id="default-feature-len">{{ default_len }}</b> of them enabled by <b>default</b>.</p>
{%- for feature in features -%}
<h3 id="{{ feature.name }}">{{ feature.name }}</h3>
<ul class="pure-menu-list">
{%- if feature.subfeatures -%}
{%- for subfeature in feature.subfeatures -%}
<li class="pure-menu-item">
<span>{{ subfeature }}</span>
</li>
{%- endfor -%}
{%- else -%}
<p>This feature flag does not enable additional features.</p>
{%- endif -%}
</ul>
{%- endfor -%}
{%- elif features is iterable -%}
<p data-id="empty-features">This release does not have any feature flags.</p>
{%- else -%}
<p data-id="null-features">Feature flags data are not available for this release.</p>
{%- endif -%}
</div>
</div>
</div>
{%- endblock body -%}
10 changes: 10 additions & 0 deletions templates/header/package_navigation.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* `crate`
* `source`
* `builds`
* `features`

Note: `false` here is acting as a pseudo-null value since you can't directly construct null values
and tera requires all parameters without defaults to be filled
Expand Down Expand Up @@ -85,6 +86,15 @@ <h1 id="crate-title">
<span class="title"> Builds</span>
</a>
</li>

{# The features tab #}
<li class="pure-menu-item">
<a href="/crate/{{ crate_path | safe }}/features"
almusil marked this conversation as resolved.
Show resolved Hide resolved
class="pure-menu-link{% if active_tab == 'features' %} pure-menu-active{% endif %}">
{{ "flag" | fas }}
<span class="title">Feature flags</span>
</a>
</li>
</ul>
</div>
</div>
Expand Down
8 changes: 8 additions & 0 deletions templates/rustdoc/topbar.html
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,14 @@
</li>
{%- endfor -%}
</ul>
</li>{#
Display the features available in current build
#}
<li class="pure-menu-item">
<a href="{{ crate_url | safe }}/features" title="Browse available feature flags of {{ metadata.name }}-{{ metadata.version }}" class="pure-menu-link">
jyn514 marked this conversation as resolved.
Show resolved Hide resolved
{{ "flag" | fas }}
<span class="title">Feature flags</span>
</a>
</li>
</ul>

Expand Down