mirror of
https://github.com/enso-org/enso.git
synced 2024-09-11 13:15:52 +03:00
feat(183557950): Add ProjectsGrid View for Cloud Dashboard (#3857)
This PR is a draft PR while I learn EnsoGL. The eventual goal is to implement the projects list portion of the cloud dashboard in this PR. This PR will implement part of https://www.pivotaltracker.com/n/projects/2539513/stories/183557950 ### Important Notes This PR is still really rough and contains a lot of hacks & hard-coded values. The FRP usage is also likely to be suboptimal and need fixing.
This commit is contained in:
parent
0ad70c6332
commit
bd455ffabd
1
.gitignore
vendored
1
.gitignore
vendored
@ -24,6 +24,7 @@ wasm-pack.log
|
||||
generated/
|
||||
/target
|
||||
/build/rust/target/
|
||||
/rust-analyzer-target
|
||||
|
||||
###########
|
||||
## Scala ##
|
||||
|
@ -100,6 +100,12 @@
|
||||
- [Added a new component: Slider][3852]. It allows adjusting a numeric value
|
||||
with the mouse. The precision of these adjustments can be increased or
|
||||
decreased.
|
||||
- [Added ProjectsGrid view for Cloud Dashboard][3857]. It provides the first
|
||||
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
|
||||
and Web versions of the IDE.
|
||||
|
||||
[3857]: https://github.com/enso-org/enso/pull/3857
|
||||
|
||||
#### Enso Standard Library
|
||||
|
||||
|
63
Cargo.lock
generated
63
Cargo.lock
generated
@ -746,6 +746,12 @@ dependencies = [
|
||||
"rustc-demangle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base-encode"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a17bd29f7c70f32e9387f4d4acfa5ea7b7749ef784fb78cf382df97069337b8c"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.9.3"
|
||||
@ -1592,6 +1598,28 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "debug-scene-component-list-panel-view"
|
||||
version = "0.1.0"
|
||||
@ -2026,6 +2054,7 @@ dependencies = [
|
||||
name = "enso-debug-scene"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"debug-scene-cloud-dashboard",
|
||||
"debug-scene-component-list-panel-view",
|
||||
"debug-scene-icons",
|
||||
"debug-scene-interface",
|
||||
@ -2455,6 +2484,29 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ensogl"
|
||||
version = "0.1.0"
|
||||
@ -6531,6 +6583,17 @@ dependencies = [
|
||||
"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.8",
|
||||
"time 0.3.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symlink"
|
||||
version = "0.1.0"
|
||||
|
@ -11,5 +11,6 @@ crate-type = ["cdylib", "rlib"]
|
||||
debug-scene-component-list-panel-view = { path = "component-list-panel-view" }
|
||||
debug-scene-icons = { path = "icons" }
|
||||
debug-scene-interface = { path = "interface" }
|
||||
debug-scene-visualization = { path = "visualization" }
|
||||
debug-scene-text-grid-visualization = { path = "text-grid-visualization" }
|
||||
debug-scene-visualization = { path = "visualization" }
|
||||
debug-scene-cloud-dashboard = { path = "cloud-dashboard" }
|
||||
|
29
app/gui/view/debug_scene/cloud-dashboard/Cargo.toml
Normal file
29
app/gui/view/debug_scene/cloud-dashboard/Cargo.toml
Normal file
@ -0,0 +1,29 @@
|
||||
[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 = { version = "0.2.78", features = ["nightly"] }
|
||||
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
|
@ -0,0 +1,15 @@
|
||||
[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"
|
248
app/gui/view/debug_scene/cloud-dashboard/cloud-http/src/lib.rs
Normal file
248
app/gui/view/debug_scene/cloud-dashboard/cloud-http/src/lib.rs
Normal file
@ -0,0 +1,248 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
@ -0,0 +1,101 @@
|
||||
//! 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);
|
@ -0,0 +1,12 @@
|
||||
[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"
|
302
app/gui/view/debug_scene/cloud-dashboard/cloud-view/src/id.rs
Normal file
302
app/gui/view/debug_scene/cloud-dashboard/cloud-view/src/id.rs
Normal file
@ -0,0 +1,302 @@
|
||||
//! 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")"#);
|
||||
}
|
||||
}
|
108
app/gui/view/debug_scene/cloud-dashboard/cloud-view/src/lib.rs
Normal file
108
app/gui/view/debug_scene/cloud-dashboard/cloud-view/src/lib.rs
Normal file
@ -0,0 +1,108 @@
|
||||
//! 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
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
//! 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>,
|
||||
}
|
74
app/gui/view/debug_scene/cloud-dashboard/src/lib.rs
Normal file
74
app/gui/view/debug_scene/cloud-dashboard/src/lib.rs
Normal file
@ -0,0 +1,74 @@
|
||||
//! 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;
|
||||
|
||||
|
||||
|
||||
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);
|
||||
})
|
||||
}
|
708
app/gui/view/debug_scene/cloud-dashboard/src/projects_table.rs
Normal file
708
app/gui/view/debug_scene/cloud-dashboard/src/projects_table.rs
Normal file
@ -0,0 +1,708 @@
|
||||
//! 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,6 +22,7 @@
|
||||
// === 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_icons as icons;
|
||||
pub use debug_scene_interface as interface;
|
||||
|
@ -1,10 +1,10 @@
|
||||
# Options intended to be common for all developers.
|
||||
|
||||
wasm-size-limit: 14.95 MiB
|
||||
wasm-size-limit: 15.05 MiB
|
||||
|
||||
required-versions:
|
||||
cargo-watch: ^8.1.1
|
||||
node: =18.12.0
|
||||
node: =18.12.1
|
||||
wasm-pack: ^0.10.2
|
||||
# TODO [mwu]: Script can install `flatc` later on (if `conda` is present), so this is not required. However it should
|
||||
# be required, if `conda` is missing.
|
||||
|
@ -12,6 +12,7 @@
|
||||
#![feature(allocator_api)]
|
||||
#![feature(auto_traits)]
|
||||
#![feature(negative_impls)]
|
||||
#![feature(pattern)]
|
||||
// === Standard Linter Configuration ===
|
||||
#![deny(non_ascii_idents)]
|
||||
#![warn(unsafe_code)]
|
||||
|
@ -12,6 +12,7 @@ use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Deref;
|
||||
use std::rc::Rc;
|
||||
use std::str::pattern;
|
||||
|
||||
|
||||
|
||||
@ -21,6 +22,19 @@ use std::rc::Rc;
|
||||
|
||||
pub trait StringOps {
|
||||
fn is_enclosed(&self, first_char: char, last_char: char) -> bool;
|
||||
|
||||
/// Splits `self` twice. Once at the first occurrence of `start_marker` and once at the first
|
||||
/// occurence of `end_marker`. Returns a triple containing the split `self` as a prefix, middle,
|
||||
/// and suffix. If `self` could not be split twice, returns [`None`].
|
||||
///
|
||||
/// [`None`]: ::std::option::Option::None
|
||||
fn split_twice<'a, P>(
|
||||
&'a self,
|
||||
start_marker: P,
|
||||
end_marker: P,
|
||||
) -> Option<(&'a str, &'a str, &'a str)>
|
||||
where
|
||||
P: pattern::Pattern<'a>;
|
||||
}
|
||||
|
||||
impl<T: AsRef<str>> StringOps for T {
|
||||
@ -39,6 +53,20 @@ impl<T: AsRef<str>> StringOps for T {
|
||||
first == Some(first_char) && last == Some(last_char)
|
||||
}
|
||||
}
|
||||
|
||||
fn split_twice<'a, P>(
|
||||
&'a self,
|
||||
start_marker: P,
|
||||
end_marker: P,
|
||||
) -> Option<(&'a str, &'a str, &'a str)>
|
||||
where
|
||||
P: pattern::Pattern<'a>,
|
||||
{
|
||||
let text = self.as_ref();
|
||||
let (prefix, rest) = text.split_once(start_marker)?;
|
||||
let (mid, suffix) = rest.split_once(end_marker)?;
|
||||
Some((prefix, mid, suffix))
|
||||
}
|
||||
}
|
||||
|
||||
// ===========
|
||||
@ -476,5 +504,8 @@ mod tests {
|
||||
// === Edge case of matching single char string ===
|
||||
assert!("{".is_enclosed('{', '{'));
|
||||
assert!("【".is_enclosed('【', '【'));
|
||||
|
||||
// === Splitting a string twice ===
|
||||
assert!("a.b.c,d,e".split_twice('.', ',').unwrap() == ("a", "b.c", "d,e"));
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user