mirror of
https://github.com/enso-org/enso.git
synced 2024-11-23 08:08:34 +03:00
chore(183909391): Remove existing Cloud dashboard code (#4047)
* chore(183909391): Remove Cloud dashboard * update changelog
This commit is contained in:
parent
8853053020
commit
4ea1880dec
@ -114,11 +114,15 @@
|
|||||||
steps towards migrating the Cloud Dashboard from the existing React (web-only)
|
steps towards migrating the Cloud Dashboard from the existing React (web-only)
|
||||||
implementation towards a shared structure that can be used in both the Desktop
|
implementation towards a shared structure that can be used in both the Desktop
|
||||||
and Web versions of the IDE.
|
and Web versions of the IDE.
|
||||||
|
- [Removed Cloud Dashboard][4047]. The Cloud Dashboard was being rewritten in
|
||||||
|
EnsoGL but after internal discussion we've decided to rewrite it in React,
|
||||||
|
with a shared implementation between the Desktop and Web versions of the IDE.
|
||||||
- [Added a new component: Dropdown][3985]. A list of selectable labeled entries,
|
- [Added a new component: Dropdown][3985]. A list of selectable labeled entries,
|
||||||
suitable for single and multi-select scenarios.
|
suitable for single and multi-select scenarios.
|
||||||
|
|
||||||
[3857]: https://github.com/enso-org/enso/pull/3857
|
[3857]: https://github.com/enso-org/enso/pull/3857
|
||||||
[3985]: https://github.com/enso-org/enso/pull/3985
|
[3985]: https://github.com/enso-org/enso/pull/3985
|
||||||
|
[4047]: https://github.com/enso-org/enso/pull/4047
|
||||||
|
|
||||||
#### Enso Standard Library
|
#### Enso Standard Library
|
||||||
|
|
||||||
|
63
Cargo.lock
generated
63
Cargo.lock
generated
@ -754,12 +754,6 @@ dependencies = [
|
|||||||
"rustc-demangle",
|
"rustc-demangle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base-encode"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a17bd29f7c70f32e9387f4d4acfa5ea7b7749ef784fb78cf382df97069337b8c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.9.3"
|
version = "0.9.3"
|
||||||
@ -1562,28 +1556,6 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eaa37046cc0f6c3cc6090fbdbf73ef0b8ef4cfcc37f6befc0020f63e8cf121e1"
|
checksum = "eaa37046cc0f6c3cc6090fbdbf73ef0b8ef4cfcc37f6befc0020f63e8cf121e1"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "debug-scene-cloud-dashboard"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"enso-frp",
|
|
||||||
"enso-profiler",
|
|
||||||
"enso_cloud_http",
|
|
||||||
"enso_cloud_view",
|
|
||||||
"ensogl",
|
|
||||||
"ensogl-core",
|
|
||||||
"ensogl-grid-view",
|
|
||||||
"ensogl-hardcoded-theme",
|
|
||||||
"ensogl-selector",
|
|
||||||
"ensogl-text",
|
|
||||||
"ensogl-text-msdf",
|
|
||||||
"ide-view-component-list-panel",
|
|
||||||
"ide-view-graph-editor",
|
|
||||||
"js-sys",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "debug-scene-component-list-panel-view"
|
name = "debug-scene-component-list-panel-view"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@ -2059,7 +2031,6 @@ dependencies = [
|
|||||||
name = "enso-debug-scene"
|
name = "enso-debug-scene"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"debug-scene-cloud-dashboard",
|
|
||||||
"debug-scene-component-list-panel-view",
|
"debug-scene-component-list-panel-view",
|
||||||
"debug-scene-documentation",
|
"debug-scene-documentation",
|
||||||
"debug-scene-icons",
|
"debug-scene-icons",
|
||||||
@ -2553,29 +2524,6 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "enso_cloud_http"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"enso-prelude",
|
|
||||||
"enso_cloud_view",
|
|
||||||
"headers",
|
|
||||||
"http",
|
|
||||||
"reqwest",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "enso_cloud_view"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"enso-prelude",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"svix-ksuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ensogl"
|
name = "ensogl"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@ -6742,17 +6690,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "svix-ksuid"
|
|
||||||
version = "0.6.0"
|
|
||||||
source = "git+https://github.com/svix/rust-ksuid#16468002c4da26932ca1004fcae190f543565b78"
|
|
||||||
dependencies = [
|
|
||||||
"base-encode",
|
|
||||||
"byteorder",
|
|
||||||
"getrandom 0.2.7",
|
|
||||||
"time 0.3.14",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "symlink"
|
name = "symlink"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
@ -14,4 +14,3 @@ debug-scene-icons = { path = "icons" }
|
|||||||
debug-scene-interface = { path = "interface" }
|
debug-scene-interface = { path = "interface" }
|
||||||
debug-scene-text-grid-visualization = { path = "text-grid-visualization" }
|
debug-scene-text-grid-visualization = { path = "text-grid-visualization" }
|
||||||
debug-scene-visualization = { path = "visualization" }
|
debug-scene-visualization = { path = "visualization" }
|
||||||
debug-scene-cloud-dashboard = { path = "cloud-dashboard" }
|
|
||||||
|
@ -1,29 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "debug-scene-cloud-dashboard"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib", "rlib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
enso_cloud_http = { path = "./cloud-http" }
|
|
||||||
enso_cloud_view = { path = "./cloud-view" }
|
|
||||||
ide-view-graph-editor = { path = "../../../../../app/gui/view/graph-editor" }
|
|
||||||
enso-frp = { path = "../../../../../lib/rust/frp" }
|
|
||||||
enso-profiler = { path = "../../../../../lib/rust/profiler" }
|
|
||||||
ensogl-core = { path = "../../../../../lib/rust/ensogl/core" }
|
|
||||||
ensogl-hardcoded-theme = { path = "../../../../../lib/rust/ensogl/app/theme/hardcoded" }
|
|
||||||
ensogl-grid-view = { path = "../../../../../lib/rust/ensogl/component/grid-view" }
|
|
||||||
ensogl-selector = { path = "../../../../../lib/rust/ensogl/component/selector" }
|
|
||||||
ensogl-text = { path = "../../../../../lib/rust/ensogl/component/text" }
|
|
||||||
ensogl-text-msdf = { path = "../../../../../lib/rust/ensogl/component/text/src/font/msdf" }
|
|
||||||
ide-view-component-list-panel = { path = "../../component-browser/component-list-panel" }
|
|
||||||
wasm-bindgen = { workspace = true }
|
|
||||||
js-sys = { version = "0.3" }
|
|
||||||
ensogl = { path = "../../../../../lib/rust/ensogl" }
|
|
||||||
wasm-bindgen-futures = { version = "0.4.8", default-features = false }
|
|
||||||
|
|
||||||
# Stop wasm-pack from running wasm-opt, because we run it from our build scripts in order to customize options.
|
|
||||||
[package.metadata.wasm-pack.profile.release]
|
|
||||||
wasm-opt = false
|
|
@ -1,15 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "enso_cloud_http"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
enso-prelude = { path = "../../../../../../lib/rust/prelude" }
|
|
||||||
enso_cloud_view = { path = "../cloud-view", default-features = false }
|
|
||||||
http = { version = "0.2.8", default-features = false }
|
|
||||||
reqwest = { version = "0.11.12", default-features = false, features = ["json"] }
|
|
||||||
serde = { version = "1.0.144", default-features = false, features = ["std"] }
|
|
||||||
headers = { version = "0.3.8", default-features = false }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
serde_json = "1.0.85"
|
|
@ -1,248 +0,0 @@
|
|||||||
//! Crate containing types for interacting with the Cloud dashboard.
|
|
||||||
//!
|
|
||||||
//! - [`Client`] is used to make requests to the Cloud dashboard API.
|
|
||||||
//! - Responses returned by the Cloud dashboard API are deserialized from JSON into strongly-typed
|
|
||||||
//! structs, defined in the [`response`] module.
|
|
||||||
|
|
||||||
// === Standard Linter Configuration ===
|
|
||||||
#![deny(non_ascii_idents)]
|
|
||||||
#![warn(unsafe_code)]
|
|
||||||
#![allow(clippy::bool_to_int_with_if)]
|
|
||||||
#![allow(clippy::let_and_return)]
|
|
||||||
// === Non-Standard Linter Configuration ===
|
|
||||||
#![deny(keyword_idents)]
|
|
||||||
#![deny(macro_use_extern_crate)]
|
|
||||||
#![deny(missing_abi)]
|
|
||||||
#![deny(pointer_structural_match)]
|
|
||||||
#![deny(unsafe_op_in_unsafe_fn)]
|
|
||||||
#![deny(unconditional_recursion)]
|
|
||||||
#![warn(missing_docs)]
|
|
||||||
#![warn(absolute_paths_not_starting_with_crate)]
|
|
||||||
#![warn(elided_lifetimes_in_paths)]
|
|
||||||
#![warn(explicit_outlives_requirements)]
|
|
||||||
#![warn(missing_copy_implementations)]
|
|
||||||
#![warn(missing_debug_implementations)]
|
|
||||||
#![warn(noop_method_call)]
|
|
||||||
#![warn(single_use_lifetimes)]
|
|
||||||
#![warn(trivial_casts)]
|
|
||||||
#![warn(trivial_numeric_casts)]
|
|
||||||
#![warn(unused_extern_crates)]
|
|
||||||
#![warn(unused_import_braces)]
|
|
||||||
#![warn(unused_lifetimes)]
|
|
||||||
#![warn(unused_qualifications)]
|
|
||||||
#![warn(variant_size_differences)]
|
|
||||||
#![warn(unreachable_pub)]
|
|
||||||
|
|
||||||
use enso_cloud_view::prelude::*;
|
|
||||||
use enso_prelude::*;
|
|
||||||
|
|
||||||
use headers::authorization;
|
|
||||||
use response::Route;
|
|
||||||
|
|
||||||
|
|
||||||
// ==============
|
|
||||||
// === Export ===
|
|
||||||
// ==============
|
|
||||||
|
|
||||||
pub mod response;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ==============
|
|
||||||
// === Client ===
|
|
||||||
// ==============
|
|
||||||
|
|
||||||
/// Client used to make HTTP requests to the Cloud API.
|
|
||||||
///
|
|
||||||
/// This struct provides convenience functions like [`Route::list_projects`] which let you send HTTP
|
|
||||||
/// requests to the Cloud API without building HTTP requests manually or dealing with details like
|
|
||||||
/// adding HTTP headers for authorization, etc. The convenience functions return strongly-typed
|
|
||||||
/// responses that are automatically deserialized from JSON.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct Client {
|
|
||||||
/// The URL that Cloud API requests are sent to, minus the relative path to an API endpoint.
|
|
||||||
///
|
|
||||||
/// [`Route`]s appended to this URL as a relative path to form the full URL of a request. Each
|
|
||||||
/// API endpoint has an associated [`Route`].
|
|
||||||
///
|
|
||||||
/// This URL is constructed differently for different environments. For example, in production
|
|
||||||
/// this URL is usually our public domain (e.g., `https://cloud.enso.org`), in staging it is an
|
|
||||||
/// AWS API Gateway URL (e.g., `https://12345678a.execute-api.us-west-1.amazonaws.com`), and in
|
|
||||||
/// development it is a local URL (e.g., `http://localhost:8080`).
|
|
||||||
///
|
|
||||||
/// If you are developing against a deployed environment, the AWS API Gateway URL can be
|
|
||||||
/// looking in the Terraform output logs after a successful deployment.
|
|
||||||
base_url: reqwest::Url,
|
|
||||||
/// The JSON Web Token (JWT) used to authenticate requests to our API.
|
|
||||||
token: AccessToken,
|
|
||||||
/// Underlying HTTP client used to make requests.
|
|
||||||
///
|
|
||||||
/// This struct wraps the HTTP client and hides the details of how requestts are made, so that
|
|
||||||
/// we can provide a simpler API to users of this crate. For example, use the
|
|
||||||
/// [`Client::list_projects`] method to make requests to the [`Route::ListProjects`] endpoint.
|
|
||||||
http: reqwest::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Main `impl` ===
|
|
||||||
|
|
||||||
impl Client {
|
|
||||||
/// Creates a new instance of the Cloud API [`Client`].
|
|
||||||
///
|
|
||||||
/// For more info about how the arguments are used, see the documentation of the [`Client`]
|
|
||||||
/// struct and its fields, or the type documentation for the arguments.
|
|
||||||
pub fn new(base_url: reqwest::Url, token: AccessToken) -> Result<Self, Error> {
|
|
||||||
let http = reqwest::Client::new();
|
|
||||||
Ok(Self { base_url, token, http })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the list of [`Project`]s the user has access to.
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
pub async fn list_projects(&self) -> Result<response::project::ListProjects, Error> {
|
|
||||||
let route = Route::ListProjects;
|
|
||||||
let response = self.try_request(route).await?;
|
|
||||||
let projects = response.json().await?;
|
|
||||||
Ok(projects)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Internal `impl` ===
|
|
||||||
|
|
||||||
impl Client {
|
|
||||||
/// Converts the [`Route`] into an HTTP [`Request`], executes it, and returns the [`Response`].
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an [`Error`] if:
|
|
||||||
/// - the [`Request`] could not be built,
|
|
||||||
/// - the [`Request`] could not be executed (e.g., due to a network error),
|
|
||||||
/// - the [`Response`] did not have a successful (i.e., 2xx) HTTP status code,
|
|
||||||
///
|
|
||||||
/// [`Request`]: ::reqwest::Request
|
|
||||||
/// [`Response`]: ::reqwest::Response
|
|
||||||
async fn try_request(&self, route: Route) -> Result<reqwest::Response, Error> {
|
|
||||||
let method = route.method();
|
|
||||||
let relative_path = route.to_string();
|
|
||||||
let mut url = self.base_url.clone();
|
|
||||||
url.set_path(&relative_path);
|
|
||||||
|
|
||||||
let request = self.http.request(method, url);
|
|
||||||
let request = request.bearer_auth(&self.token);
|
|
||||||
let request = request.build()?;
|
|
||||||
|
|
||||||
let mut response = self.http.execute(request).await?;
|
|
||||||
if !response.status().is_success() {
|
|
||||||
response = handle_error_response(response).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a base URL for the Cloud Dashboard (as described in the documentation of the [`Client`]
|
|
||||||
/// struct) for an instance of the Cloud Dashboard running on AWS API Gateway.
|
|
||||||
///
|
|
||||||
/// Use this function to connect to the Cloud Dashboard API when running a staging or testing
|
|
||||||
/// deployment.
|
|
||||||
pub fn base_url_for_api_gateway(
|
|
||||||
api_gateway_id: ApiGatewayId,
|
|
||||||
aws_region: AwsRegion,
|
|
||||||
) -> Result<reqwest::Url, Error> {
|
|
||||||
let url = format!("https://{api_gateway_id}.execute-api.{aws_region}.amazonaws.com").parse()?;
|
|
||||||
Ok(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts an unsuccessful HTTP [`Response`] into an [`Error`], or returns the [`Response`].
|
|
||||||
///
|
|
||||||
/// This function exists to make user-facing errors for HTTP requests more informative by including
|
|
||||||
/// the HTTP response body in the error message, where possible.
|
|
||||||
///
|
|
||||||
/// [`Response`]: ::reqwest::Response
|
|
||||||
async fn handle_error_response(response: reqwest::Response) -> Result<reqwest::Response, Error> {
|
|
||||||
if let Some(e) = response.error_for_status_ref().err() {
|
|
||||||
match response.text().await {
|
|
||||||
Ok(body) => {
|
|
||||||
let e = format!("Error \"{e:?}\" with error message body: {body}");
|
|
||||||
Err(e)?
|
|
||||||
}
|
|
||||||
Err(body_error) => {
|
|
||||||
let e = format!("Failed to get error response body: \"{e:?}\"; {body_error}");
|
|
||||||
Err(e)?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ====================
|
|
||||||
// === ApiGatewayId ===
|
|
||||||
// ====================
|
|
||||||
|
|
||||||
/// Identifier of which Cloud API deployment a request is intended to go to (e.g., a development,
|
|
||||||
/// staging, production, or other deployment).
|
|
||||||
///
|
|
||||||
/// This value is the AWS API Gateway ID of the deployment. The API Gateway ID is a unique
|
|
||||||
/// identifier generated by AWS when the API is deployed for the first time. It is always available
|
|
||||||
/// in the output logs of a successful deployment via our Terraform scripts. It can also be found by
|
|
||||||
/// manually navigating to the API Gateway in the AWS web UI.
|
|
||||||
#[derive(Clone, Debug, Display)]
|
|
||||||
pub struct ApiGatewayId(pub String);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =================
|
|
||||||
// === AwsRegion ===
|
|
||||||
// =================
|
|
||||||
|
|
||||||
/// AWS region in which the Cloud API is deployed.
|
|
||||||
///
|
|
||||||
/// This corresponds to the region of the AWS API Gateway identified by the [`ApiGatewayId`] used
|
|
||||||
/// when making requests to the Cloud API via the [`Client`]. This value can be found in our
|
|
||||||
/// Terraform output logs after a successful deployment of the Cloud API.
|
|
||||||
#[derive(Clone, Copy, Debug, Display)]
|
|
||||||
#[allow(missing_docs)]
|
|
||||||
pub enum AwsRegion {
|
|
||||||
#[display(fmt = "eu-west-1")]
|
|
||||||
EuWest1,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ===================
|
|
||||||
// === AccessToken ===
|
|
||||||
// ===================
|
|
||||||
|
|
||||||
/// The JSON Web Token (JWT) used to authenticate requests to our API.
|
|
||||||
///
|
|
||||||
/// Requests without a token are rejected as unauthorized. A token determines which Cloud user is
|
|
||||||
/// making a request, what permissions they have, and what resources they can access.
|
|
||||||
///
|
|
||||||
/// The token can be obtained from the browser storage after the user logs in. Do so via the APIs
|
|
||||||
/// provided by the [`aws-amplify`] JavaScript library.
|
|
||||||
///
|
|
||||||
/// [`aws-amplify`]: https://docs.amplify.aws/lib/auth/getting-started/q/platform/js/
|
|
||||||
#[derive(Clone, Debug, Display)]
|
|
||||||
#[display(fmt = "{}", "bearer.token()")]
|
|
||||||
pub struct AccessToken {
|
|
||||||
bearer: headers::Authorization<authorization::Bearer>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AccessToken {
|
|
||||||
/// Creates a new [`AccessToken`] from the given [`str`] slice.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns an error if the value of the [`str`] slice is not a properly-formatted JSON Web
|
|
||||||
/// Token (JWT). Note that we do not return an error if the token is expired, etc. We only check
|
|
||||||
/// that it is properly formatted.
|
|
||||||
pub fn new(token: &str) -> Result<Self, Error> {
|
|
||||||
let bearer = headers::Authorization::bearer(token)?;
|
|
||||||
let token = Self { bearer };
|
|
||||||
Ok(token)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,101 +0,0 @@
|
|||||||
//! Module containing response types for the Cloud HTTP API.
|
|
||||||
//!
|
|
||||||
//! These types are used both on the server and the client side. On the server side, our Lambdas
|
|
||||||
//! return these types in response to requests. On the client side, we deserialize HTTP response
|
|
||||||
//! bodies to these types. By using identical models on both the server and client side, we avoid
|
|
||||||
//! mismatches between API specification and implementation. This does, however, mean that we have
|
|
||||||
//! to upgrade server and client side deployments in lockstep.
|
|
||||||
|
|
||||||
use enso_prelude::*;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// === `declare_routes_and_responses` Macro ===
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// This is a macro that is used to define the [`Route`]s to our Cloud API endpoints and the
|
|
||||||
/// responses returned by the Cloud API for requests to those endpoints.
|
|
||||||
///
|
|
||||||
/// The macro exists to keep the names of the [`Route`]s and their response structs in sync, rather
|
|
||||||
/// than for any code generation purposes. This is because each [`Route`] has a unique response type
|
|
||||||
/// and structure, so we can't use the macro to reduce code duplication.
|
|
||||||
macro_rules! declare_routes_and_responses {
|
|
||||||
(
|
|
||||||
$list_projects:ident
|
|
||||||
) => {
|
|
||||||
// =============
|
|
||||||
// === Route ===
|
|
||||||
// =============
|
|
||||||
|
|
||||||
/// A combination of the HTTP method and the relative URL path to an endpoint of our Cloud
|
|
||||||
/// API.
|
|
||||||
///
|
|
||||||
/// Instead of constructing HTTP requests manually using this enum, use the convenience
|
|
||||||
/// methods on [`Client`] to send requests to the Cloud API instead -- it's simpler.
|
|
||||||
///
|
|
||||||
/// Each variant in this enum corresponds to an available endpoint in our Cloud API. The
|
|
||||||
/// [`Display`] impl of this enum provides the relative path to the endpoint. When combined
|
|
||||||
/// with the base URL of our Cloud API (see [`Client`] for details), this becomes the full
|
|
||||||
/// URL that requests should be sent to. The [`Route::method`] method provides the HTTP
|
|
||||||
/// method that the request should be sent as.
|
|
||||||
#[derive(Clone, Copy, Debug, Display)]
|
|
||||||
#[allow(missing_docs)]
|
|
||||||
pub(crate) enum Route {
|
|
||||||
/// Endpoint for listing the user's projects. The response type for this endpoint is
|
|
||||||
/// [`response::project::$list_projects`].
|
|
||||||
#[display(fmt = "/projects")]
|
|
||||||
$list_projects,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Main `impl` ===
|
|
||||||
|
|
||||||
impl Route {
|
|
||||||
/// Returns the [`http::Method`] used to interact with this route.
|
|
||||||
///
|
|
||||||
/// [`http::Method`]: ::http::Method
|
|
||||||
pub(crate) fn method(&self) -> http::Method {
|
|
||||||
match self {
|
|
||||||
Self::$list_projects => http::Method::GET,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ===============
|
|
||||||
// === Project ===
|
|
||||||
// ===============
|
|
||||||
|
|
||||||
/// Module containing response types for [`Project`]-related [`Route`]s.
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
pub mod project {
|
|
||||||
use enso_cloud_view as view;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ====================
|
|
||||||
// === ListProjects ===
|
|
||||||
// ====================
|
|
||||||
|
|
||||||
/// A response for a successful request to the [`ListProjects`] [`Route`].
|
|
||||||
///
|
|
||||||
/// [`ListProjects`]: crate::Route::$list_projects
|
|
||||||
/// [`Route`]: crate::Route
|
|
||||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
|
||||||
#[allow(missing_docs)]
|
|
||||||
pub struct $list_projects {
|
|
||||||
/// A list of all [`Project`]s that the user has access to. The list may be empty,
|
|
||||||
/// if the user has access to no [`Project`]s or no [`Project`]s have been
|
|
||||||
/// created.
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
pub projects: Vec<view::project::Project>,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
declare_routes_and_responses!(ListProjects);
|
|
@ -1,12 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "enso_cloud_view"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
enso-prelude = { path = "../../../../../../lib/rust/prelude" }
|
|
||||||
serde = { version = "1.0.144", features = ["derive"] }
|
|
||||||
svix-ksuid = { git = "https://github.com/svix/rust-ksuid" }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
serde_json = "1.0.85"
|
|
@ -1,302 +0,0 @@
|
|||||||
//! Module containing an implementation of a unique identifier type.
|
|
||||||
//!
|
|
||||||
//! See [`Id`] for details on identifiers in general, and [`IdVariant`] for instructions on how to
|
|
||||||
//! implement new identifier variants (e.g., [`ProjectId`], [`OrganizationId`]).
|
|
||||||
|
|
||||||
use enso_prelude::*;
|
|
||||||
|
|
||||||
use serde::de;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =================
|
|
||||||
// === Constants ===
|
|
||||||
// =================
|
|
||||||
|
|
||||||
/// When manually implementing [`Deserialize`] for a struct, [`serde`] requires that we provide a
|
|
||||||
/// user-facing name for the struct being deserialized, for error messages.
|
|
||||||
///
|
|
||||||
/// This constant provides the user-facing name of the [`Id`] struct for deserialization error
|
|
||||||
/// messages.
|
|
||||||
///
|
|
||||||
/// [`Deserialize`]: ::serde::de::Deserialize
|
|
||||||
const ID_STRUCT_NAME: &str = stringify!(Id);
|
|
||||||
/// Separator used in string representations of [`Id`]s. See [`Id`] for details.
|
|
||||||
pub const ID_PREFIX_SEPARATOR: &str = "-";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ======================
|
|
||||||
// === OrganizationId ===
|
|
||||||
// ======================
|
|
||||||
|
|
||||||
/// Unique [`Id`] for an organization.
|
|
||||||
pub type OrganizationId = Id<OrganizationIdVariant>;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =================
|
|
||||||
// === ProjectId ===
|
|
||||||
// =================
|
|
||||||
|
|
||||||
/// Unique [`Id`] for a [`Project`].
|
|
||||||
///
|
|
||||||
/// [`Project`]: crate::project::Project
|
|
||||||
pub type ProjectId = Id<ProjectIdVariant>;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =================
|
|
||||||
// === IdVariant ===
|
|
||||||
// =================
|
|
||||||
|
|
||||||
/// Our [`Id<T>`] struct provides a convenient implementation of a unique identifier, but it is
|
|
||||||
/// generic. We want our identifiers to be type-safe (i.e., so we don't accidentally use a
|
|
||||||
/// [`ProjectId`] where we intended to use an [`OrganizationId`]), so our [`Id`] struct has a marker
|
|
||||||
/// field to keep track of the type of the [`Id`]. Any struct implementing this [`IdVariant`] trait
|
|
||||||
/// can thus be used as a marker, by passing it as a type parameter to [`Id::<T>`] (e.g.,
|
|
||||||
/// [`Id::<ProjectIdVariant>`] becomes a [`Project`] identifier).
|
|
||||||
///
|
|
||||||
/// By implementing this trait on a struct, you are creating a new variant of identifier marker. An
|
|
||||||
/// [`Id<T>`] parameterized with this marker will then have all the useful methods and traits
|
|
||||||
/// implemented by the [`Id`] struct available to it. For example, your [`Id`] variant will have
|
|
||||||
/// [`Display`], [`Serialize`], etc.
|
|
||||||
///
|
|
||||||
/// To keep things simple, we recommend that you implement this trait on an empty newtype struct (
|
|
||||||
/// see [`ProjectIdVariant`] for an example). Then provide a type alias for an [`Id::<T>`]
|
|
||||||
/// parameterized with said struct (see [`ProjectId`] for an example). Then use only the type alias.
|
|
||||||
///
|
|
||||||
/// [`Serialize`]: ::serde::Serialize
|
|
||||||
/// [`Project`]: crate::project::Project
|
|
||||||
pub trait IdVariant {
|
|
||||||
/// Prefix applied to serialized representations of the [`Id`].
|
|
||||||
///
|
|
||||||
/// For example, the [`ProjectId`] type has a prefix of `"proj"`, so the serialized
|
|
||||||
/// representation of a [`ProjectId`] will look like `"proj-27xJM00p8jWoL2qByTo6tQfciWC"`.
|
|
||||||
const PREFIX: &'static str;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Trait `impl`s ===
|
|
||||||
|
|
||||||
// Implement IdVariant for Id<T> itself so that the associated items of the marker type are
|
|
||||||
// available through Id itself (i.e., so you can do `ProjectId::PREFIX` rather than doing
|
|
||||||
// `ProjectIdVariant::PREFIX`).
|
|
||||||
impl<T> IdVariant for Id<T>
|
|
||||||
where T: IdVariant
|
|
||||||
{
|
|
||||||
const PREFIX: &'static str = T::PREFIX;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =============================
|
|
||||||
// === OrganizationIdVariant ===
|
|
||||||
// =============================
|
|
||||||
|
|
||||||
/// IdVariant struct that indicates an identifier is for an organization.
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub struct OrganizationIdVariant(PhantomData<()>);
|
|
||||||
|
|
||||||
|
|
||||||
// === Trait `impl`s ===
|
|
||||||
|
|
||||||
impl IdVariant for OrganizationIdVariant {
|
|
||||||
const PREFIX: &'static str = "org";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ========================
|
|
||||||
// === ProjectIdVariant ===
|
|
||||||
// ========================
|
|
||||||
|
|
||||||
/// Marker struct that indicates an identifier is for a [`Project`].
|
|
||||||
///
|
|
||||||
/// [`Project`]: crate::project::Project
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub struct ProjectIdVariant(PhantomData<()>);
|
|
||||||
|
|
||||||
|
|
||||||
// === Trait `impl`s ===
|
|
||||||
|
|
||||||
impl IdVariant for ProjectIdVariant {
|
|
||||||
const PREFIX: &'static str = "proj";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ==========
|
|
||||||
// === Id ===
|
|
||||||
// ==========
|
|
||||||
|
|
||||||
/// An identifer that is used to uniquely identify entities in the Cloud backend (e.g.,
|
|
||||||
/// [`Project`]s).
|
|
||||||
///
|
|
||||||
/// Internally, this is a [K-Sortable Unique IDentifier] (KSUID). KSUIDs are a variant of unique
|
|
||||||
/// identifier that have some nice properties that are useful to us. For example, KSUIDs are
|
|
||||||
/// naturally ordered by generation time because they incorporate a timestamp as part of the
|
|
||||||
/// identifier. This is desirable because it means that entities are stored in our database sorted
|
|
||||||
/// by their creation time, which means we don't have to sort our entities after fetching them.
|
|
||||||
///
|
|
||||||
/// Example:
|
|
||||||
/// ```
|
|
||||||
/// # use enso_prelude::*;
|
|
||||||
/// let id = enso_cloud_view::id::ProjectId::from_str("proj-27xJM00p8jWoL2qByTo6tQfciWC").unwrap();
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// An [`Id`] string contains: the type of the entity this identifier is for (e.g.
|
|
||||||
/// [`ProjectId::PREFIX`] for [`Project`], [`OrganizationId::PREFIX`] for organization, etc.),
|
|
||||||
/// followed by the [`ID_PREFIX_SEPARATOR`], then the base62-encoded KSUID.
|
|
||||||
///
|
|
||||||
/// Using KSUIDs for identifiers gives several useful properties:
|
|
||||||
/// 1. Naturally ordered by generation time;
|
|
||||||
/// 2. Collision-free, coordination-free, dependency-free;
|
|
||||||
/// 3. Highly portable representations.
|
|
||||||
///
|
|
||||||
/// [K-Sortable Unique IDentifier]: https://github.com/segmentio/ksuid#what-is-a-ksuid
|
|
||||||
/// [`Project`]: crate::project::Project
|
|
||||||
#[derive(Clone, Copy, Derivative, Display)]
|
|
||||||
#[display(bound = "T: IdVariant")]
|
|
||||||
#[display(fmt = "{}{}{}", "T::PREFIX", "ID_PREFIX_SEPARATOR", "ksuid")]
|
|
||||||
#[derivative(PartialEq, PartialOrd, Eq, Ord)]
|
|
||||||
pub struct Id<T> {
|
|
||||||
/// The inner [`svix_ksuid::Ksuid`] value for this identifier.
|
|
||||||
pub ksuid: svix_ksuid::Ksuid,
|
|
||||||
/// Denotes the target entity type for this identifier; for internal use.
|
|
||||||
#[derivative(PartialEq = "ignore", PartialOrd = "ignore", Ord = "ignore")]
|
|
||||||
pub marker: PhantomData<T>,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Trait `impl`s` ===
|
|
||||||
|
|
||||||
impl<T> serde::Serialize for Id<T>
|
|
||||||
where Id<T>: Display
|
|
||||||
{
|
|
||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where S: serde::Serializer {
|
|
||||||
serializer.serialize_newtype_struct(ID_STRUCT_NAME, &self.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> FromStr for Id<T>
|
|
||||||
where T: IdVariant
|
|
||||||
{
|
|
||||||
type Err = crate::Error;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
let ksuid_str = match s.split_twice(T::PREFIX, ID_PREFIX_SEPARATOR) {
|
|
||||||
Some(("", "", s)) => s,
|
|
||||||
_ => Err(format!("\"{s}\" is not an {ID_STRUCT_NAME}."))?,
|
|
||||||
};
|
|
||||||
let ksuid = svix_ksuid::Ksuid::from_str(ksuid_str)
|
|
||||||
.map_err(|_| format!("\"{ksuid_str}\" is not a Ksuid."))?;
|
|
||||||
let marker = PhantomData;
|
|
||||||
Ok(Self { ksuid, marker })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This implementation is provided manually because [`Id`] is a dash-separated string, which
|
|
||||||
/// [`serde`] can't derive [`Deserialize`] for. By manually implementing [`Deserialize`], we can
|
|
||||||
/// delegate deserialization to our [`FromStr`] implementation which can parse an [`Id`] from a
|
|
||||||
/// [`String`] by splitting it into its [`PREFIX`] and [`Ksuid`] components.
|
|
||||||
///
|
|
||||||
/// [`Deserialize`]: ::serde::de::Deserialize
|
|
||||||
/// [`PREFIX`]: crate::id::IdVariant::PREFIX
|
|
||||||
/// [`Ksuid`]: ::svix_ksuid::Ksuid
|
|
||||||
impl<'a, T> serde::Deserialize<'a> for Id<T>
|
|
||||||
where Id<T>: FromStr
|
|
||||||
{
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
||||||
where D: serde::Deserializer<'a> {
|
|
||||||
struct IdVisitor<T>(PhantomData<T>);
|
|
||||||
|
|
||||||
impl<'a, T> de::Visitor<'a> for IdVisitor<T>
|
|
||||||
where Id<T>: FromStr
|
|
||||||
{
|
|
||||||
type Value = Id<T>;
|
|
||||||
|
|
||||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.write_str("identifier")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
|
|
||||||
where D: serde::Deserializer<'a> {
|
|
||||||
deserializer.deserialize_any(IdVisitor(PhantomData))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
|
||||||
where E: de::Error {
|
|
||||||
v.parse().map_err(|_| {
|
|
||||||
let unexpected = de::Unexpected::Str(v);
|
|
||||||
de::Error::invalid_value(unexpected, &"identifier string")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deserializer.deserialize_any(IdVisitor(PhantomData))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> Debug for Id<T>
|
|
||||||
where T: IdVariant
|
|
||||||
{
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_tuple(ID_STRUCT_NAME).field(&format_args!("{}", self)).finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =============
|
|
||||||
// === Tests ===
|
|
||||||
// =============
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use enso_prelude::*;
|
|
||||||
|
|
||||||
use crate::id;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_id_from_str() {
|
|
||||||
fn check(id_str: &str, expected_str: &str) {
|
|
||||||
let result = id::OrganizationId::from_str(id_str);
|
|
||||||
let result = result.map(|v| v.to_string());
|
|
||||||
let result = result.map_err(|e| e.to_string());
|
|
||||||
let debug_str = format!("{:?}", result);
|
|
||||||
assert_eq!(debug_str, expected_str);
|
|
||||||
}
|
|
||||||
|
|
||||||
check("not_a_valid_id", r#"Err("\"not_a_valid_id\" is not an Id.")"#);
|
|
||||||
check("org-", r#"Err("\"\" is not a Ksuid.")"#);
|
|
||||||
check("org-27xJM00p8jWoL2qByTo6tQfciWC", r#"Ok("org-27xJM00p8jWoL2qByTo6tQfciWC")"#);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_serialize_id() {
|
|
||||||
fn check(id_str: &str, expected_str: &str) {
|
|
||||||
let id = id::OrganizationId::from_str(id_str).unwrap();
|
|
||||||
let result = serde_json::to_string(&id);
|
|
||||||
let debug_str = format!("{:?}", result);
|
|
||||||
assert_eq!(debug_str, expected_str);
|
|
||||||
}
|
|
||||||
|
|
||||||
check("org-27xJM00p8jWoL2qByTo6tQfciWC", r#"Ok("\"org-27xJM00p8jWoL2qByTo6tQfciWC\"")"#);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_deserialize_id() {
|
|
||||||
fn check(id_str: &str, expected_str: &str) {
|
|
||||||
let result = serde_json::from_str::<id::OrganizationId>(id_str);
|
|
||||||
let result = result.map(|v| v.to_string());
|
|
||||||
let debug_str = format!("{:?}", result);
|
|
||||||
assert_eq!(debug_str, expected_str);
|
|
||||||
}
|
|
||||||
|
|
||||||
check(r#""org-27xJM00p8jWoL2qByTo6tQfciWC""#, r#"Ok("org-27xJM00p8jWoL2qByTo6tQfciWC")"#);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,108 +0,0 @@
|
|||||||
//! Crate containing types defining the API used by the Cloud dashboard.
|
|
||||||
//!
|
|
||||||
//! The types defined in this crate are slimmed down versions of the types defined in the private
|
|
||||||
//! `enso_cloud_lambdas` crate. The reason this crate exists, rather than both the Cloud frontend
|
|
||||||
//! and Cloud backend using those types is because they contain implementation details that should
|
|
||||||
//! not be user-visible for security reasons, or aren't necessary for the Cloud frontend to operate.
|
|
||||||
//! Thus, this crate contains representations of the same types that exist in `enso_cloud_lambdas`,
|
|
||||||
//! but with only the minimum information necessary. The full types in `enso_cloud_lambdas` are then
|
|
||||||
//! built on top of these definitions.
|
|
||||||
//!
|
|
||||||
//! The types in this crate are used by the:
|
|
||||||
//! - **Cloud frontend** when *requests* are send to the Cloud backend,
|
|
||||||
//! - **Cloud backend** when *requests* are deserialized prior to being handled,
|
|
||||||
//! - **Cloud backend** when *responses* are serialized prior to being returned to the Cloud
|
|
||||||
//! frontend,
|
|
||||||
//! - **Cloud frontend** when *responses* are deserialized.
|
|
||||||
//!
|
|
||||||
//! Defining the types in this shared crate keeps our API *definition* and *usage* consistent in
|
|
||||||
//! both the frontend and the backend.
|
|
||||||
|
|
||||||
// === Standard Linter Configuration ===
|
|
||||||
#![deny(non_ascii_idents)]
|
|
||||||
#![warn(unsafe_code)]
|
|
||||||
#![allow(clippy::bool_to_int_with_if)]
|
|
||||||
#![allow(clippy::let_and_return)]
|
|
||||||
// === Non-Standard Linter Configuration ===
|
|
||||||
#![deny(keyword_idents)]
|
|
||||||
#![deny(macro_use_extern_crate)]
|
|
||||||
#![deny(missing_abi)]
|
|
||||||
#![deny(pointer_structural_match)]
|
|
||||||
#![deny(unsafe_op_in_unsafe_fn)]
|
|
||||||
#![deny(unconditional_recursion)]
|
|
||||||
#![warn(missing_docs)]
|
|
||||||
#![warn(absolute_paths_not_starting_with_crate)]
|
|
||||||
#![warn(elided_lifetimes_in_paths)]
|
|
||||||
#![warn(explicit_outlives_requirements)]
|
|
||||||
#![warn(missing_copy_implementations)]
|
|
||||||
#![warn(missing_debug_implementations)]
|
|
||||||
#![warn(noop_method_call)]
|
|
||||||
#![warn(single_use_lifetimes)]
|
|
||||||
#![warn(trivial_casts)]
|
|
||||||
#![warn(trivial_numeric_casts)]
|
|
||||||
#![warn(unused_extern_crates)]
|
|
||||||
#![warn(unused_import_braces)]
|
|
||||||
#![warn(unused_lifetimes)]
|
|
||||||
#![warn(unused_qualifications)]
|
|
||||||
#![warn(variant_size_differences)]
|
|
||||||
#![warn(unreachable_pub)]
|
|
||||||
|
|
||||||
use std::error;
|
|
||||||
|
|
||||||
|
|
||||||
// ==============
|
|
||||||
// === Export ===
|
|
||||||
// ==============
|
|
||||||
|
|
||||||
pub mod id;
|
|
||||||
pub mod project;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ===============
|
|
||||||
// === Prelude ===
|
|
||||||
// ===============
|
|
||||||
|
|
||||||
/// A module to let consumers of this crate import common Cloud-related types.
|
|
||||||
///
|
|
||||||
/// For example, the [`Error`] type is used in almost all Cloud-related crates so it is desirable
|
|
||||||
/// to always have it in scope. Glob-importing this module makes this easy to do.
|
|
||||||
pub mod prelude {
|
|
||||||
pub use crate::Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =============
|
|
||||||
// === Error ===
|
|
||||||
// =============
|
|
||||||
|
|
||||||
/// Type alias for a thread-safe boxed [`Error`].
|
|
||||||
///
|
|
||||||
/// Convert your error to this type when your error can not be recovered from, or no further context
|
|
||||||
/// can be added to it.
|
|
||||||
///
|
|
||||||
/// [`Error`]: ::std::error::Error
|
|
||||||
pub type Error = Box<dyn error::Error + Send + Sync + 'static>;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ==============================================
|
|
||||||
// === `separate_pascal_case_str_with_spaces` ===
|
|
||||||
// ==============================================
|
|
||||||
|
|
||||||
/// Takes a [`&str`] containing a Pascal-case identifier (e.g. `"UsagePlan"`) and
|
|
||||||
/// returns a [`String`] with the individual words of the identifier separated by a
|
|
||||||
/// single whitespace (e.g., `"Usage Plan"`).
|
|
||||||
pub fn separate_pascal_case_str_with_spaces(s: &str) -> String {
|
|
||||||
let mut new = String::with_capacity(s.len());
|
|
||||||
let mut first_char = true;
|
|
||||||
for c in s.chars() {
|
|
||||||
if c.is_uppercase() && !first_char {
|
|
||||||
new.push(' ');
|
|
||||||
}
|
|
||||||
new.push(c);
|
|
||||||
first_char = false;
|
|
||||||
}
|
|
||||||
new
|
|
||||||
}
|
|
@ -1,98 +0,0 @@
|
|||||||
//! Model definitions for [`Project`] and related types.
|
|
||||||
|
|
||||||
use enso_prelude::*;
|
|
||||||
|
|
||||||
use crate::id;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ================
|
|
||||||
// === StateTag ===
|
|
||||||
// ================
|
|
||||||
|
|
||||||
/// Enum representing what state a Cloud backend [`Project`] is in.
|
|
||||||
///
|
|
||||||
/// A [`Project`]'s state has a lot of implementation details that are irrelevant to the Cloud
|
|
||||||
/// frontend. So this enum contains only the tag used to discriminate between different variants.
|
|
||||||
/// This is returned in API responses from the backend where the content of the state is irrelevant
|
|
||||||
/// or should not be public, and we're just interested in *which* state the [`Project`] is in.
|
|
||||||
#[derive(Debug, serde::Deserialize, Eq, serde::Serialize, Clone, Copy, PartialEq)]
|
|
||||||
#[serde(tag = "type")]
|
|
||||||
#[allow(missing_docs)]
|
|
||||||
pub enum StateTag {
|
|
||||||
New,
|
|
||||||
Created,
|
|
||||||
OpenInProgress,
|
|
||||||
Opened,
|
|
||||||
Closed,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Trait `impl`s ===
|
|
||||||
|
|
||||||
impl Display for StateTag {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
let debug_str = format!("{:?}", self);
|
|
||||||
let display_str = crate::separate_pascal_case_str_with_spaces(&debug_str);
|
|
||||||
write!(f, "{display_str}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ===========
|
|
||||||
// === Ami ===
|
|
||||||
// ===========
|
|
||||||
|
|
||||||
/// Struct that describes Amazon Machine Image identifier.
|
|
||||||
#[derive(Clone, Debug, serde::Serialize, Into, Display, PartialEq, Eq)]
|
|
||||||
pub struct Ami(pub String);
|
|
||||||
|
|
||||||
|
|
||||||
// === Trait `impl`s ===
|
|
||||||
|
|
||||||
impl FromStr for Ami {
|
|
||||||
type Err = crate::Error;
|
|
||||||
|
|
||||||
fn from_str(ami: &str) -> Result<Self, Self::Err> {
|
|
||||||
/// A valid ami ID starts with `ami-` prefix.
|
|
||||||
const AMI_PREFIX: &str = "ami-";
|
|
||||||
|
|
||||||
if !ami.starts_with(AMI_PREFIX) {
|
|
||||||
return Err(format!("Bad Ami format: {}", ami))?;
|
|
||||||
};
|
|
||||||
Ok(Self(ami.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> serde::Deserialize<'a> for Ami {
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
||||||
where D: serde::Deserializer<'a> {
|
|
||||||
let s = String::deserialize(deserializer)?;
|
|
||||||
FromStr::from_str(&s).map_err(serde::de::Error::custom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ===============
|
|
||||||
// === Project ===
|
|
||||||
// ===============
|
|
||||||
|
|
||||||
/// A representation of a project in the Cloud IDE.
|
|
||||||
///
|
|
||||||
/// This is returned as part of responses to cloud API endpoints. It simplified to avoid leaking
|
|
||||||
/// implementation details via the HTTP API. An example of the implementation details we wish to
|
|
||||||
/// avoid leaking is the ID of the internal EC2 instance that the project is running on. Users don't
|
|
||||||
/// need to know these details, as we proxy requests to the correct instance through our API
|
|
||||||
/// gateway.
|
|
||||||
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
#[allow(missing_docs)]
|
|
||||||
pub struct Project {
|
|
||||||
pub organization_id: id::OrganizationId,
|
|
||||||
pub project_id: id::ProjectId,
|
|
||||||
pub name: String,
|
|
||||||
pub state: StateTag,
|
|
||||||
pub ami: Option<Ami>,
|
|
||||||
}
|
|
@ -1,77 +0,0 @@
|
|||||||
//! Cloud dashboard scene showing a table of projects.
|
|
||||||
|
|
||||||
#![recursion_limit = "512"]
|
|
||||||
// === Features ===
|
|
||||||
#![allow(incomplete_features)]
|
|
||||||
#![feature(negative_impls)]
|
|
||||||
#![feature(associated_type_defaults)]
|
|
||||||
#![feature(cell_update)]
|
|
||||||
#![feature(const_type_id)]
|
|
||||||
#![feature(drain_filter)]
|
|
||||||
#![feature(entry_insert)]
|
|
||||||
#![feature(fn_traits)]
|
|
||||||
#![feature(marker_trait_attr)]
|
|
||||||
#![feature(specialization)]
|
|
||||||
#![feature(trait_alias)]
|
|
||||||
#![feature(type_alias_impl_trait)]
|
|
||||||
#![feature(unboxed_closures)]
|
|
||||||
#![feature(trace_macros)]
|
|
||||||
#![feature(const_trait_impl)]
|
|
||||||
#![feature(slice_as_chunks)]
|
|
||||||
#![feature(variant_count)]
|
|
||||||
// === Standard Linter Configuration ===
|
|
||||||
#![deny(non_ascii_idents)]
|
|
||||||
#![warn(unsafe_code)]
|
|
||||||
#![allow(clippy::bool_to_int_with_if)]
|
|
||||||
#![allow(clippy::let_and_return)]
|
|
||||||
// === Non-Standard Linter Configuration ===
|
|
||||||
#![allow(clippy::option_map_unit_fn)]
|
|
||||||
#![allow(clippy::precedence)]
|
|
||||||
#![allow(dead_code)]
|
|
||||||
#![deny(unconditional_recursion)]
|
|
||||||
#![warn(missing_copy_implementations)]
|
|
||||||
#![warn(missing_debug_implementations)]
|
|
||||||
#![warn(missing_docs)]
|
|
||||||
#![warn(trivial_casts)]
|
|
||||||
#![warn(trivial_numeric_casts)]
|
|
||||||
#![warn(unused_import_braces)]
|
|
||||||
#![warn(unused_qualifications)]
|
|
||||||
|
|
||||||
use ensogl::prelude::*;
|
|
||||||
use wasm_bindgen::prelude::*;
|
|
||||||
|
|
||||||
use ensogl_core::application::Application;
|
|
||||||
use ensogl_core::display::object::ObjectOps;
|
|
||||||
use ensogl_hardcoded_theme as theme;
|
|
||||||
|
|
||||||
|
|
||||||
// ==============
|
|
||||||
// === Export ===
|
|
||||||
// ==============
|
|
||||||
|
|
||||||
pub mod projects_table;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ===================
|
|
||||||
// === Entry Point ===
|
|
||||||
// ===================
|
|
||||||
|
|
||||||
/// The example entry point.
|
|
||||||
#[entry_point]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn main() {
|
|
||||||
ensogl_text_msdf::run_once_initialized(|| {
|
|
||||||
let app = Application::new("root");
|
|
||||||
theme::builtin::light::register(&app);
|
|
||||||
theme::builtin::light::enable(&app);
|
|
||||||
|
|
||||||
let scene = &app.display.default_scene;
|
|
||||||
|
|
||||||
let projects_table = app.new_view::<projects_table::View>();
|
|
||||||
let projects_table = projects_table.init().expect("Failed to initialize projects table.");
|
|
||||||
scene.add_child(&projects_table);
|
|
||||||
|
|
||||||
std::mem::forget(projects_table);
|
|
||||||
})
|
|
||||||
}
|
|
@ -1,708 +0,0 @@
|
|||||||
//! Module containing the [`ProjectsTable`], which is used to display a list of [`Project`]s in the
|
|
||||||
//! Cloud dashboard.
|
|
||||||
//!
|
|
||||||
//! The list of [`Project`]s, as rendered, contains information about the state of the [`Project`],
|
|
||||||
//! the data associated with the [`Project`], the permissions assigned to it, etc. Buttons for
|
|
||||||
//! interacting with the [`Project`]s (e.g., starting and stopping the [`Project`]s) are also
|
|
||||||
//! rendered.
|
|
||||||
//!
|
|
||||||
//! [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
|
|
||||||
use enso_cloud_view::prelude::*;
|
|
||||||
use ensogl::prelude::*;
|
|
||||||
|
|
||||||
use enso_cloud_view as view;
|
|
||||||
use ensogl::application;
|
|
||||||
use ensogl::data::color;
|
|
||||||
use ensogl_core::application::Application;
|
|
||||||
use ensogl_core::display;
|
|
||||||
use ensogl_core::frp;
|
|
||||||
use ensogl_grid_view::simple::EntryModel;
|
|
||||||
use ensogl_grid_view::Col;
|
|
||||||
use ensogl_grid_view::Row;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =================
|
|
||||||
// === Constants ===
|
|
||||||
// =================
|
|
||||||
|
|
||||||
/// The width of a single entry in the [`ProjectsTable`], in pixels.
|
|
||||||
// FIXME [NP]: https://www.pivotaltracker.com/story/show/183909467
|
|
||||||
// Move the style constants to StyleWatchFrp variables.
|
|
||||||
const ENTRY_WIDTH: f32 = 130.0;
|
|
||||||
/// The height of a single entry in the [`ProjectsTable`], in pixels.
|
|
||||||
// FIXME [NP]: https://www.pivotaltracker.com/story/show/183909467
|
|
||||||
// Move the style constants to StyleWatchFrp variables.
|
|
||||||
const ENTRY_HEIGHT: f32 = 28.0;
|
|
||||||
/// The grid is intended to take up 20% of the viewport height, expressed as a fraction;
|
|
||||||
// FIXME [NP]: https://www.pivotaltracker.com/story/show/183909467
|
|
||||||
// Move the style constants to StyleWatchFrp variables.
|
|
||||||
const GRID_HEIGHT_RATIO: f32 = 0.2;
|
|
||||||
/// The top margin of the [`ProjectsTable`], in pixels.
|
|
||||||
// FIXME [NP]: https://www.pivotaltracker.com/story/show/183909467
|
|
||||||
// Move the style constants to StyleWatchFrp variables.
|
|
||||||
const TOP_MARGIN: f32 = 10f32;
|
|
||||||
/// The bottom margin of the [`ProjectsTable`], in pixels.
|
|
||||||
// FIXME [NP]: https://www.pivotaltracker.com/story/show/183909467
|
|
||||||
// Move the style constants to StyleWatchFrp variables.
|
|
||||||
const BOTTOM_MARGIN: f32 = 10f32;
|
|
||||||
/// The left margin of the [`ProjectsTable`], in pixels.
|
|
||||||
// FIXME [NP]: https://www.pivotaltracker.com/story/show/183909467
|
|
||||||
// Move the style constants to StyleWatchFrp variables.
|
|
||||||
const LEFT_MARGIN: f32 = 10f32;
|
|
||||||
/// The right margin of the [`ProjectsTable`], in pixels.
|
|
||||||
// FIXME [NP]: https://www.pivotaltracker.com/story/show/183909467
|
|
||||||
// Move the style constants to StyleWatchFrp variables.
|
|
||||||
const RIGHT_MARGIN: f32 = 10f32;
|
|
||||||
/// The combined horizontal margin of the [`ProjectsTable`], in pixels.
|
|
||||||
const HORIZONTAL_MARGIN: f32 = LEFT_MARGIN + RIGHT_MARGIN;
|
|
||||||
/// The combined vertical margin of the [`ProjectsTable`], in pixels.
|
|
||||||
const VERTICAL_MARGIN: f32 = TOP_MARGIN + BOTTOM_MARGIN;
|
|
||||||
|
|
||||||
/// In the future, we want to display the last modification time of a [`Project`]. For now, the API
|
|
||||||
/// does not provide that information so we use a placeholder value.
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
// TODO [NP]: https://www.pivotaltracker.com/story/show/183909494
|
|
||||||
// Remove the unused columns from the `"Projects" Table`.
|
|
||||||
const LAST_MODIFIED: &str = "2022-10-08 13:30";
|
|
||||||
/// In the future, we want to display icons for users/groups with access to the [`Project`] and
|
|
||||||
/// their corresponding permissions (e.g., read/write/execute).
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
// TODO [NP]: https://www.pivotaltracker.com/story/show/183909494
|
|
||||||
// Remove the unused columns from the `"Projects" Table`.
|
|
||||||
const SHARED_WITH: &str = "Baron von Münchhausen (Read/Write)";
|
|
||||||
/// In the future, we want to display icons for the [`Project`]'s labels. Labels may be user-defined
|
|
||||||
/// or system-defined (e.g., labels indicating high resource usage or outdated version).
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
// TODO [NP]: https://www.pivotaltracker.com/story/show/183909420
|
|
||||||
// `"Home Screen" User` can see a `"running" Label` for any currently running `Project`s.
|
|
||||||
const LABELS: &str = "(!) outdated version";
|
|
||||||
/// In the future, we want to display icons for datasets associated with the [`Project`], as well as
|
|
||||||
/// what permissions are set on the dataset, from the user's perspective.
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
// TODO [NP]: https://www.pivotaltracker.com/story/show/183909494
|
|
||||||
// Remove the unused columns from the `"Projects" Table`.
|
|
||||||
const DATA_ACCESS: &str = "./user_data";
|
|
||||||
/// In the future, we want to display which usage plan the [`Project`] is configured for (e.g.,
|
|
||||||
/// "Interactive" or cron-style, etc.).
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
// TODO [NP]: https://www.pivotaltracker.com/story/show/183909494
|
|
||||||
// Remove the unused columns from the `"Projects" Table`.
|
|
||||||
const USAGE_PLAN: &str = "Interactive";
|
|
||||||
|
|
||||||
/// ID of the Amazon API Gateway serving the Cloud API.
|
|
||||||
const API_GATEWAY_ID: &str = "7aqkn3tnbc";
|
|
||||||
/// The AWS region in which the Amazon API Gateway referenced by [`API_GATEWAY_ID`] is deployed.
|
|
||||||
///
|
|
||||||
/// [`API_GATEWAY_ID`]: crate::projects_table::API_GATEWAY_ID
|
|
||||||
const AWS_REGION: enso_cloud_http::AwsRegion = enso_cloud_http::AwsRegion::EuWest1;
|
|
||||||
/// Access token used to authenticate requests to the Cloud API.
|
|
||||||
// TODO [NP]: https://www.pivotaltracker.com/story/show/183909294
|
|
||||||
// `"Home Screen" User` is authenticated using the browser-stored `Access Token`.
|
|
||||||
const TOKEN: &str = "eyJraWQiOiJiVjd1ZExrWTkxU2lVUWRpWVhDSVByRitoSTRYVHlOYTQ2TXhJRDlmY3EwPSIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiI0YjBhMzExNy1hNmI5LTRkYmYtOGEyMC0wZGZmNWE4NjA2ZGYiLCJjb2duaXRvOmdyb3VwcyI6WyJldS13ZXN0LTFfOUt5Y3UyU2JEX0dvb2dsZSJdLCJpc3MiOiJodHRwczpcL1wvY29nbml0by1pZHAuZXUtd2VzdC0xLmFtYXpvbmF3cy5jb21cL2V1LXdlc3QtMV85S3ljdTJTYkQiLCJ2ZXJzaW9uIjoyLCJjbGllbnRfaWQiOiI0ajliZnM4ZTc0MTVlcmY4MmwxMjl2MHFoZSIsIm9yaWdpbl9qdGkiOiI4MDYxMDkxMS01OGVlLTRjYzctYjU0Ny1lNmZjNzY2OTMwNmYiLCJ0b2tlbl91c2UiOiJhY2Nlc3MiLCJzY29wZSI6Im9wZW5pZCBlbWFpbCIsImF1dGhfdGltZSI6MTY2ODUyNzkwNiwiZXhwIjoxNjY5NjczOTAyLCJpYXQiOjE2Njk2NzAzMDIsImp0aSI6IjUyYzE3NzkzLTA2YmYtNDkxYi1iYmExLWZjMTI0MTUxZTQ1NSIsInVzZXJuYW1lIjoiR29vZ2xlXzEwNDA4MDA2MTY5NTg0NDYwMDk2OCJ9.KsFezaQrtDOJiy-edAJEtWH0hXE5SfBQGczazgvXUGMY4xluKZMle_Q0x2myFxu_1-eb8ND8M-2nOU9Kz09hKLrxqiJI6BRZrAlNKk0B6c2mtJM-OXS5Nyvs83xTfjHJPnBOPD6qadDZPx82FZkiI99HCiSEn1s1lyMy9-GPAHcIFa-PMDtrf0mzAbJUnCCfJPjxz49003FKTThOLCBVvjrgiTCCYIzvF96ERIVL2bMJ08bQEIwsbHxFvONgHh1yjGFjYDT0JC6OXZraQ3wFQFOnCwL33sijtn8_p9b3f22UttbaOFg03V-I7tSx5YUTiVDJHWEKYqlmm_fHUFWlVw";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ==================================
|
|
||||||
// === define_columns_enum! macro ===
|
|
||||||
// ==================================
|
|
||||||
|
|
||||||
/// A helper macro for defining the [`Columns`] enum.
|
|
||||||
///
|
|
||||||
/// The variants of this enum are defined only at the location where the enum is invoked. This
|
|
||||||
/// ensures that all the functions implemented on this enum always cover all the variants of the
|
|
||||||
/// enum, and are defined programmatically rather than manually. This helps avoid drift and bugs due
|
|
||||||
/// to programmer error.
|
|
||||||
macro_rules! define_columns_enum {
|
|
||||||
($($column_name:ident),*) => {
|
|
||||||
// ===============
|
|
||||||
// === Columns ===
|
|
||||||
// ===============
|
|
||||||
|
|
||||||
/// Names of the columns that we want to display in the grid view. The [`Display`]
|
|
||||||
/// implementation for this enum represents the user-facing values shown in the grid
|
|
||||||
/// headers.
|
|
||||||
///
|
|
||||||
/// These columns correspond roughly, but not exactly, to the fields of the [`Project`]
|
|
||||||
/// struct. The differences are mainly in the fact that we display some properties of the
|
|
||||||
/// [`Project`] as icons (e.g., the [`state`]).
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
/// [`state`]: ::enso_cloud_view::project::Project.state
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
#[allow(missing_docs)]
|
|
||||||
pub enum Columns {
|
|
||||||
$($column_name,)+
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Main `impl` ===
|
|
||||||
|
|
||||||
impl Columns {
|
|
||||||
/// Number of columns that we want to display in the [`ProjectsTable`] view. This
|
|
||||||
/// corresponds to the number of variants in this enum, since each variant is a column
|
|
||||||
/// of data that we want to display.
|
|
||||||
const LEN: usize = mem::variant_count::<Self>();
|
|
||||||
|
|
||||||
/// Returns the [`Columns`] variant corresponding to the given discriminant value.
|
|
||||||
///
|
|
||||||
/// If the discriminant is out of bounds, returns `None`.
|
|
||||||
///
|
|
||||||
/// Example:
|
|
||||||
/// ```rust
|
|
||||||
/// # use debug_scene_cloud_dashboard::projects_table::Columns;
|
|
||||||
/// assert_eq!(Columns::from_discriminant(0), Some(Columns::Projects));
|
|
||||||
/// assert_eq!(Columns::from_discriminant(1), Some(Columns::LastModified));
|
|
||||||
/// ```
|
|
||||||
pub fn from_discriminant(discriminant: usize) -> Option<Self> {
|
|
||||||
// It is non-trivial to write a macro that can use `match` for this purpose, because
|
|
||||||
// a macro can't return both the pattern and the expression parts of a `match` arm
|
|
||||||
// (i.e., `x => y`). So instead we use an iterator over the values `0..` combined
|
|
||||||
// with `if` statements. This is functionally equivalent and is optimized by the
|
|
||||||
// compiler to the same instructions.
|
|
||||||
let mut i = 0..;
|
|
||||||
$(if discriminant == i.next().unwrap() { Some(Self::$column_name) } else)*
|
|
||||||
{ unreachable!() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
define_columns_enum!(Projects, LastModified, SharedWith, Labels, DataAccess, UsagePlan);
|
|
||||||
|
|
||||||
|
|
||||||
// === Trait `impl`s ===
|
|
||||||
|
|
||||||
impl Display for Columns {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
let debug_str = format!("{:?}", self);
|
|
||||||
let display_str = view::separate_pascal_case_str_with_spaces(&debug_str);
|
|
||||||
write!(f, "{display_str}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =====================
|
|
||||||
// === ProjectsTable ===
|
|
||||||
// =====================
|
|
||||||
|
|
||||||
/// A table of [`Project`]s displayed in the Cloud dashboard.
|
|
||||||
///
|
|
||||||
/// The table contains information about the [`Project`]s (e.g. their names, their state, etc.) as
|
|
||||||
/// well as components for interacting with the [`Project`]s (e.g. buttons to start/stop) the
|
|
||||||
/// projects, etc.
|
|
||||||
///
|
|
||||||
/// Under the hood, we use a scrollable grid since the user may have more [`Project`]s than can fit
|
|
||||||
/// on the screen. We use a grid with headers since the user needs to know which [`Project`]
|
|
||||||
/// property each column represents.
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
pub type ProjectsTable = ensogl_grid_view::simple::SimpleScrollableSelectableGridViewWithHeaders;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ================
|
|
||||||
// === Position ===
|
|
||||||
// ================
|
|
||||||
|
|
||||||
/// The row and column coordinates of a cell in the [`ProjectsTable`].
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
|
||||||
pub struct Position {
|
|
||||||
row: Row,
|
|
||||||
column: Col,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Trait `impl`s ===
|
|
||||||
|
|
||||||
impl From<(Row, Col)> for Position {
|
|
||||||
fn from((row, column): (Row, Col)) -> Self {
|
|
||||||
Self { row, column }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Position> for (Row, Col) {
|
|
||||||
fn from(Position { row, column }: Position) -> Self {
|
|
||||||
(row, column)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ===========
|
|
||||||
// === FRP ===
|
|
||||||
// ===========
|
|
||||||
|
|
||||||
ensogl_core::define_endpoints_2! {
|
|
||||||
Input {
|
|
||||||
set_projects(Rc<Vec<view::project::Project>>),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =============
|
|
||||||
// === Model ===
|
|
||||||
// =============
|
|
||||||
|
|
||||||
#[derive(Clone, CloneRef, Debug)]
|
|
||||||
struct Model {
|
|
||||||
display_object: display::object::Instance,
|
|
||||||
projects_table: ProjectsTable,
|
|
||||||
projects: ide_view_graph_editor::SharedVec<view::project::Project>,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Internal `impl` ===
|
|
||||||
|
|
||||||
impl Model {
|
|
||||||
fn new(app: &Application) -> Self {
|
|
||||||
let display_object = display::object::Instance::new();
|
|
||||||
let projects_table = ProjectsTable::new(app);
|
|
||||||
let projects = ide_view_graph_editor::SharedVec::new();
|
|
||||||
display_object.add_child(&projects_table);
|
|
||||||
Self { display_object, projects_table, projects }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn model_for_entry(&self, position: Position) -> Option<(Position, EntryModel)> {
|
|
||||||
let Position { row, .. } = position;
|
|
||||||
// If the row is the first one in the table, we're trying to render a header so we use that
|
|
||||||
// model instead.
|
|
||||||
if row == 0 {
|
|
||||||
let entry_model = self.header_entry_model(position);
|
|
||||||
Some((position, entry_model))
|
|
||||||
} else {
|
|
||||||
let idx = self.project_index_for_entry(position)?;
|
|
||||||
let column = column_for_entry(position)?;
|
|
||||||
let project = &self.projects.raw.borrow()[idx];
|
|
||||||
let entry_model = project_entry_model(project, column);
|
|
||||||
Some((position, entry_model))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the index of the [`Project`] in the list of [`Project`]s that corresponds to the
|
|
||||||
/// provided [`Position`] of the [`ProjectsTable`].
|
|
||||||
///
|
|
||||||
/// Aside from the header row, each row of the [`ProjectsTable`] contains data on a [`Project`]
|
|
||||||
/// from the backing [`Model.projects`] list. But the index of the row doesn't correspond to the
|
|
||||||
/// index in the table, since the header row doesn't contain data on a [`Project`]. So this
|
|
||||||
/// function performs a conversion between the two indices.
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
fn project_index_for_entry(&self, position: Position) -> Option<usize> {
|
|
||||||
let Position { row, .. } = position;
|
|
||||||
// The rows of the grid are zero-indexed, but the first row is the header row, so we need to
|
|
||||||
// subtract 1 to get the index of the project we want to display.
|
|
||||||
let idx = row.checked_sub(1)?;
|
|
||||||
if idx >= self.projects.len() {
|
|
||||||
warn!(
|
|
||||||
"Attempted to display entry at index {idx}, but we only have data up to index {}.",
|
|
||||||
self.projects.len()
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resize_grid_to_shape(&self, shape: &ensogl::display::scene::Shape) -> Vector2<f32> {
|
|
||||||
let screen_size = Vector2::from(shape);
|
|
||||||
let margin = Vector2::new(HORIZONTAL_MARGIN, VERTICAL_MARGIN);
|
|
||||||
let size = screen_size - margin;
|
|
||||||
size
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reposition_grid_to_shape(&self, shape: &ensogl::display::scene::Shape) {
|
|
||||||
let screen_size = Vector2::from(shape);
|
|
||||||
let screen_size_halved = screen_size / 2.0;
|
|
||||||
let viewport_min_y = -screen_size_halved.y + BOTTOM_MARGIN;
|
|
||||||
let viewport_min_x = -screen_size_halved.x + LEFT_MARGIN;
|
|
||||||
let grid_height = screen_size.y * GRID_HEIGHT_RATIO;
|
|
||||||
let grid_min_x = viewport_min_x;
|
|
||||||
let grid_max_y = viewport_min_y + grid_height;
|
|
||||||
let position = Vector2::new(grid_min_x, grid_max_y);
|
|
||||||
self.projects_table.set_xy(position);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn refit_entries_to_shape(&self, shape: &ensogl::display::scene::Shape) -> Vector2<f32> {
|
|
||||||
let width = shape.width / Columns::LEN as f32;
|
|
||||||
let size = Vector2(width, ENTRY_HEIGHT);
|
|
||||||
size
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the information about what section the requested visible entry is in.
|
|
||||||
///
|
|
||||||
/// The grid view wants to know what to display as a header of the top of the viewport in the
|
|
||||||
/// current column. Therefore, it asks about information about the section the topmost visible
|
|
||||||
/// entry is in.
|
|
||||||
///
|
|
||||||
/// `section_info_needed` assumes that there may be more than one section, therefore it needs to
|
|
||||||
/// know the span of rows belonging to the returned section (so it knows when to ask about
|
|
||||||
/// another one).
|
|
||||||
///
|
|
||||||
/// Since we use separate tables to display separate types of data in the dashboard, this table
|
|
||||||
/// only ever has one section. So it should always return the range `0..=self.rows()`.
|
|
||||||
fn position_to_requested_section(&self, position: Position) -> (Range<Row>, Col, EntryModel) {
|
|
||||||
/// The first row in the section the requested visible entry is in.
|
|
||||||
///
|
|
||||||
/// This is always `0` because we only have one section, so the first row is the first
|
|
||||||
/// visible one in the table.
|
|
||||||
const SECTION_START: usize = 0;
|
|
||||||
/// The last row in the section the requested visible entry is in is an inclusive value, so
|
|
||||||
/// we need to increment `self.rows()` by 1 to get it, since `self.rows()` isn't including
|
|
||||||
/// the header row.
|
|
||||||
const HEADER_OFFSET: usize = 1;
|
|
||||||
|
|
||||||
let Position { column, .. } = position;
|
|
||||||
let position = (SECTION_START, column).into();
|
|
||||||
let model = self.header_entry_model(position);
|
|
||||||
let section_end = self.projects.len() + HEADER_OFFSET;
|
|
||||||
let section_range = SECTION_START..section_end;
|
|
||||||
(section_range, column, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn header_entry_model(&self, position: Position) -> EntryModel {
|
|
||||||
let Position { row, .. } = position;
|
|
||||||
assert!(row == 0, "Header row was {row}, but it is expected to be first row in the table.");
|
|
||||||
let column = column_for_entry(position);
|
|
||||||
let entry_model = column.map(|column| EntryModel {
|
|
||||||
text: column.to_string().into(),
|
|
||||||
disabled: Immutable(true),
|
|
||||||
override_width: Immutable(None),
|
|
||||||
});
|
|
||||||
let entry_model = entry_model.unwrap_or_else(invalid_entry_model);
|
|
||||||
entry_model
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Setter `impl` ===
|
|
||||||
|
|
||||||
impl Model {
|
|
||||||
fn set_projects(&self, projects: Rc<Vec<view::project::Project>>) -> (Row, Col) {
|
|
||||||
*self.projects.raw.borrow_mut() = projects.to_vec();
|
|
||||||
|
|
||||||
let rows = self.rows();
|
|
||||||
let cols = Columns::LEN;
|
|
||||||
(rows, cols)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns an [`EntryModel`] representing a [`Project`] in the [`ProjectsTable`].
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
fn project_entry_model(project: &view::project::Project, column: Columns) -> EntryModel {
|
|
||||||
// Map the requested column to the corresponding field of the `Project` struct.
|
|
||||||
let model = match column {
|
|
||||||
Columns::Projects => {
|
|
||||||
// TODO [NP]: https://www.pivotaltracker.com/story/show/183909391
|
|
||||||
// `"Home Screen" User` can see an `Icon` representing the `Project`'s state.
|
|
||||||
let state = &project.state;
|
|
||||||
let name = &project.name;
|
|
||||||
let state = match state {
|
|
||||||
view::project::StateTag::New => "New".to_string(),
|
|
||||||
view::project::StateTag::Created => "Created".to_string(),
|
|
||||||
view::project::StateTag::OpenInProgress => "OpenInProgress".to_string(),
|
|
||||||
view::project::StateTag::Opened => "Opened".to_string(),
|
|
||||||
view::project::StateTag::Closed => "Closed".to_string(),
|
|
||||||
};
|
|
||||||
format!("({state}) {name}")
|
|
||||||
}
|
|
||||||
Columns::LastModified => LAST_MODIFIED.to_string(),
|
|
||||||
Columns::SharedWith => SHARED_WITH.to_string(),
|
|
||||||
// TODO [NP]: https://www.pivotaltracker.com/story/show/183909420
|
|
||||||
// `"Home Screen" User` can see a `"running" Label` for any currently running
|
|
||||||
// `Project`s.
|
|
||||||
Columns::Labels => LABELS.to_string(),
|
|
||||||
Columns::DataAccess => DATA_ACCESS.to_string(),
|
|
||||||
Columns::UsagePlan => USAGE_PLAN.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
EntryModel {
|
|
||||||
text: model.into(),
|
|
||||||
disabled: Immutable(false),
|
|
||||||
override_width: Immutable(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns an [`EntryModel`] representing an "invalid" entry, which is used to fill in the table in
|
|
||||||
/// the event that we request a [`Position`] that is out of bounds.
|
|
||||||
fn invalid_entry_model() -> EntryModel {
|
|
||||||
EntryModel {
|
|
||||||
text: "Invalid entry".into(),
|
|
||||||
disabled: Immutable(false),
|
|
||||||
override_width: Immutable(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Getter `impl`s ===
|
|
||||||
|
|
||||||
impl Model {
|
|
||||||
/// Returns the number of [`ProjectsTable`] rows needed to display all the data in this
|
|
||||||
/// [`Model`].
|
|
||||||
///
|
|
||||||
/// The number of rows is equal to the number of [`Project`]s in the [`Model`] plus one, because
|
|
||||||
/// the first row is the header row.
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
fn rows(&self) -> usize {
|
|
||||||
self.projects.len() + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ============
|
|
||||||
// === View ===
|
|
||||||
// ============
|
|
||||||
|
|
||||||
/// The view implementation for the Cloud dashboard.
|
|
||||||
///
|
|
||||||
/// This is the combination of the [`ProjectsTable`] controlling the rendering of the table of
|
|
||||||
/// [`Project`]s, the [`Model`] containing the data to be displayed, and the [`Frp`] network used to
|
|
||||||
/// update the [`Model`] with network-fetched [`Project`]s data and re-render the [`ProjectsTable`]
|
|
||||||
/// accordingly.
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
#[derive(Clone, Debug, Deref)]
|
|
||||||
pub struct View {
|
|
||||||
#[deref]
|
|
||||||
frp: Frp,
|
|
||||||
model: Model,
|
|
||||||
app: Application,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Internal `impl` ===
|
|
||||||
|
|
||||||
impl View {
|
|
||||||
fn new(app: Application) -> Self {
|
|
||||||
let frp = Frp::new();
|
|
||||||
let model = Model::new(&app);
|
|
||||||
Self { frp, model, app }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn init(self) -> Result<Self, Error> {
|
|
||||||
let frp = &self.frp;
|
|
||||||
let model = &self.model;
|
|
||||||
let app = &self.app;
|
|
||||||
let root = &model.display_object;
|
|
||||||
let input = &frp.public.input;
|
|
||||||
|
|
||||||
self.init_projects_table_model_data_loading();
|
|
||||||
self.init_projects_table_grid_resizing(app);
|
|
||||||
self.init_projects_table_entries_models();
|
|
||||||
self.init_projects_table_grid();
|
|
||||||
self.init_projects_table_header();
|
|
||||||
self.init_event_tracing(app);
|
|
||||||
|
|
||||||
app.display.add_child(root);
|
|
||||||
|
|
||||||
// FIXME [NP]: https://www.pivotaltracker.com/story/show/183909432
|
|
||||||
// Rather than pass this error up, display the error in this view.
|
|
||||||
populate_table_with_data(input.clone_ref())?;
|
|
||||||
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_projects_table_model_data_loading(&self) {
|
|
||||||
let frp = &self.frp;
|
|
||||||
let network = &frp.network;
|
|
||||||
let model = &self.model;
|
|
||||||
let input = &frp.public.input;
|
|
||||||
let projects_table = &model.projects_table;
|
|
||||||
|
|
||||||
frp::extend! { network
|
|
||||||
grid_size <- input.set_projects.map(f!((projects) model.set_projects(projects.clone_ref())));
|
|
||||||
projects_table.resize_grid <+ grid_size;
|
|
||||||
projects_table.reset_entries <+ grid_size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_projects_table_grid_resizing(&self, app: &Application) {
|
|
||||||
let network = &self.frp.network;
|
|
||||||
let model = &self.model;
|
|
||||||
let scene = &app.display.default_scene;
|
|
||||||
let projects_table = &model.projects_table;
|
|
||||||
let scroll_frp = projects_table.scroll_frp();
|
|
||||||
|
|
||||||
frp::extend! { network
|
|
||||||
scroll_frp.resize <+ scene.frp.shape.map(f!((shape) model.resize_grid_to_shape(shape)));
|
|
||||||
eval scene.frp.shape ((shape) model.reposition_grid_to_shape(shape));
|
|
||||||
projects_table.set_entries_size <+ scene.frp.shape.map(f!((shape) model.refit_entries_to_shape(shape)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_projects_table_entries_models(&self) {
|
|
||||||
let network = &self.frp.network;
|
|
||||||
let model = &self.model;
|
|
||||||
let projects_table = &model.projects_table;
|
|
||||||
|
|
||||||
frp::extend! { network
|
|
||||||
// We want to work with our `Position` struct rather than a coordinate pair, so convert.
|
|
||||||
needed_entries <- projects_table.model_for_entry_needed.map(|position| Position::from(*position));
|
|
||||||
projects_table.model_for_entry <+
|
|
||||||
needed_entries.filter_map(f!((position) model.model_for_entry(*position).map(|(position, entry_model)| {
|
|
||||||
let (row, col) = position.into();
|
|
||||||
(row, col, entry_model)
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_projects_table_grid(&self) {
|
|
||||||
let model = &self.model;
|
|
||||||
let projects_table = &model.projects_table;
|
|
||||||
|
|
||||||
let entry_size = Vector2(ENTRY_WIDTH, ENTRY_HEIGHT);
|
|
||||||
projects_table.set_entries_size(entry_size);
|
|
||||||
let params = ensogl_grid_view::simple::EntryParams {
|
|
||||||
// FIXME [NP]: https://www.pivotaltracker.com/story/show/183909458
|
|
||||||
// Move the EntryParams values to StyleWatchFrp values.
|
|
||||||
bg_color: color::Lcha::transparent(),
|
|
||||||
// FIXME [NP]: https://www.pivotaltracker.com/story/show/183909458
|
|
||||||
// Move the EntryParams values to StyleWatchFrp values.
|
|
||||||
bg_margin: 1.0,
|
|
||||||
// FIXME [NP]: https://www.pivotaltracker.com/story/show/183909458
|
|
||||||
// Move the EntryParams values to StyleWatchFrp values.
|
|
||||||
// TODO [NP]: https://www.pivotaltracker.com/story/show/183909450
|
|
||||||
// `"Home Screen" User` can see `Project` row is highlighted when hovering
|
|
||||||
// over the row.
|
|
||||||
hover_color: color::Lcha::from(color::Rgba(
|
|
||||||
62f32 / u8::MAX as f32,
|
|
||||||
81f32 / u8::MAX as f32,
|
|
||||||
95f32 / u8::MAX as f32,
|
|
||||||
0.05,
|
|
||||||
)),
|
|
||||||
// FIXME [NP]: https://www.pivotaltracker.com/story/show/183909458
|
|
||||||
// Move the EntryParams values to StyleWatchFrp values.
|
|
||||||
selection_color: color::Lcha::from(color::Rgba(1.0, 0.0, 0.0, 1.0)),
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
projects_table.set_entries_params(params);
|
|
||||||
let row = model.rows();
|
|
||||||
let col = Columns::LEN;
|
|
||||||
projects_table.reset_entries(row, col);
|
|
||||||
projects_table.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_projects_table_header(&self) {
|
|
||||||
let model = &self.model;
|
|
||||||
let network = self.frp.network();
|
|
||||||
let header_frp = model.projects_table.header_frp();
|
|
||||||
|
|
||||||
frp::extend! { network
|
|
||||||
requested_section <- header_frp.section_info_needed.map(f!((position) model.position_to_requested_section((*position).into())));
|
|
||||||
header_frp.section_info <+ requested_section;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_event_tracing(&self, app: &Application) {
|
|
||||||
let frp = &self.frp;
|
|
||||||
let network = &frp.network;
|
|
||||||
let model = &self.model;
|
|
||||||
let projects_table = &model.projects_table;
|
|
||||||
let input = &frp.public.input;
|
|
||||||
let scene = &app.display.default_scene;
|
|
||||||
|
|
||||||
frp::extend! { network
|
|
||||||
trace input.set_projects;
|
|
||||||
trace projects_table.model_for_entry;
|
|
||||||
trace scene.frp.shape;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn populate_table_with_data(input: api::public::Input) -> Result<(), Error> {
|
|
||||||
let api_gateway_id = enso_cloud_http::ApiGatewayId(API_GATEWAY_ID.to_string());
|
|
||||||
let token = enso_cloud_http::AccessToken::new(TOKEN)?;
|
|
||||||
let base_url = enso_cloud_http::base_url_for_api_gateway(api_gateway_id, AWS_REGION)?;
|
|
||||||
let client = enso_cloud_http::Client::new(base_url, token)?;
|
|
||||||
get_projects(client, input);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the [`Columns`] variant for the entry at the given [`Position`], letting us select over
|
|
||||||
/// what field of data from a [`Project`] we want to display.
|
|
||||||
///
|
|
||||||
/// [`Project`]: ::enso_cloud_view::project::Project
|
|
||||||
fn column_for_entry(position: Position) -> Option<Columns> {
|
|
||||||
let Position { column, .. } = position;
|
|
||||||
let column = match Columns::from_discriminant(column) {
|
|
||||||
Some(column) => column,
|
|
||||||
None => {
|
|
||||||
warn!("Attempted to display entry at column {column}, but we the table only has {} columns.", Columns::LEN);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Some(column)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_projects(client: enso_cloud_http::Client, input: crate::projects_table::api::public::Input) {
|
|
||||||
// FIXME [NP]: https://www.pivotaltracker.com/story/show/183909482
|
|
||||||
// Replace `wasm_bindgen_futures` with the futures runtime used throughout the
|
|
||||||
// remainder of the project.
|
|
||||||
wasm_bindgen_futures::spawn_local(async move {
|
|
||||||
let response = client.list_projects().await.unwrap();
|
|
||||||
let projects = response.projects;
|
|
||||||
let projects = Rc::new(projects);
|
|
||||||
input.set_projects(projects);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// === Trait `impl`s ===
|
|
||||||
|
|
||||||
impl display::Object for View {
|
|
||||||
fn display_object(&self) -> &display::object::Instance {
|
|
||||||
&self.model.display_object
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FrpNetworkProvider for View {
|
|
||||||
fn network(&self) -> &frp::Network {
|
|
||||||
&self.frp.network
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl application::View for View {
|
|
||||||
fn label() -> &'static str {
|
|
||||||
"grid::View"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new(app: &Application) -> Self {
|
|
||||||
let app = app.clone_ref();
|
|
||||||
Self::new(app)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn app(&self) -> &Application {
|
|
||||||
&self.app
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =============
|
|
||||||
// === Tests ===
|
|
||||||
// =============
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::projects_table;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_columns_display() {
|
|
||||||
assert_eq!(projects_table::Columns::Projects.to_string(), "Projects");
|
|
||||||
assert_eq!(projects_table::Columns::LastModified.to_string(), "Last Modified");
|
|
||||||
assert_eq!(projects_table::Columns::SharedWith.to_string(), "Shared With");
|
|
||||||
assert_eq!(projects_table::Columns::Labels.to_string(), "Labels");
|
|
||||||
assert_eq!(projects_table::Columns::DataAccess.to_string(), "Data Access");
|
|
||||||
assert_eq!(projects_table::Columns::UsagePlan.to_string(), "Usage Plan");
|
|
||||||
}
|
|
||||||
}
|
|
@ -22,7 +22,6 @@
|
|||||||
// === Export ===
|
// === Export ===
|
||||||
// ==============
|
// ==============
|
||||||
|
|
||||||
pub use debug_scene_cloud_dashboard as cloud_dashboard;
|
|
||||||
pub use debug_scene_component_list_panel_view as new_component_list_panel_view;
|
pub use debug_scene_component_list_panel_view as new_component_list_panel_view;
|
||||||
pub use debug_scene_documentation as documentation;
|
pub use debug_scene_documentation as documentation;
|
||||||
pub use debug_scene_icons as icons;
|
pub use debug_scene_icons as icons;
|
||||||
|
Loading…
Reference in New Issue
Block a user