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:
Nikita Pekin 2022-12-03 23:41:56 -05:00 committed by GitHub
parent 0ad70c6332
commit bd455ffabd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1803 additions and 4 deletions

1
.gitignore vendored
View File

@ -24,6 +24,7 @@ wasm-pack.log
generated/
/target
/build/rust/target/
/rust-analyzer-target
###########
## Scala ##

View File

@ -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
View File

@ -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"

View File

@ -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" }

View 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

View File

@ -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"

View 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)
}
}

View File

@ -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);

View File

@ -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"

View 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")"#);
}
}

View 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
}

View File

@ -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>,
}

View 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);
})
}

View 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");
}
}

View File

@ -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;

View File

@ -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.

View File

@ -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)]

View File

@ -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"));
}
}

2
run
View File

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e # Exit on error.
# Get the directory of the script, as per https://stackoverflow.com/a/246128