From a9c26097f8fb7ff896dbe990ed7d6e112de9bea2 Mon Sep 17 00:00:00 2001 From: Tyrone Tudehope Date: Mon, 7 Aug 2023 11:45:18 +0200 Subject: [PATCH] feat(fe/admin): Functionality to update or change a schools internal school name --- backend/api/sqlx-data.json | 167 ++++++++++-------- backend/api/src/db/account.rs | 28 ++- backend/api/src/http/endpoints/account.rs | 37 +--- backend/api/src/http/endpoints/admin.rs | 96 +++++++++- .../entry/admin/src/schools/details/dom.rs | 101 ++++++++--- .../entry/admin/src/schools/details/mod.rs | 1 + .../schools/details/school_name/actions.rs | 70 ++++++++ .../src/schools/details/school_name/dom.rs | 122 +++++++++++++ .../src/schools/details/school_name/mod.rs | 3 + .../src/schools/details/school_name/state.rs | 39 ++++ .../entry/admin/src/schools/details/state.rs | 3 + .../apps/crates/entry/user/src/welcome/dom.rs | 2 +- .../apps/crates/utils/src/editable_field.rs | 22 ++- .../src/entry/admin/schools/school-details.ts | 38 ++-- shared/rust/src/api/endpoints/admin.rs | 23 ++- shared/rust/src/domain/admin.rs | 2 + shared/rust/src/domain/billing.rs | 6 +- shared/rust/src/domain/user.rs | 13 +- 18 files changed, 607 insertions(+), 166 deletions(-) create mode 100644 frontend/apps/crates/entry/admin/src/schools/details/school_name/actions.rs create mode 100644 frontend/apps/crates/entry/admin/src/schools/details/school_name/dom.rs create mode 100644 frontend/apps/crates/entry/admin/src/schools/details/school_name/mod.rs create mode 100644 frontend/apps/crates/entry/admin/src/schools/details/school_name/state.rs diff --git a/backend/api/sqlx-data.json b/backend/api/sqlx-data.json index 2a41665de9..bdb2d8a029 100644 --- a/backend/api/sqlx-data.json +++ b/backend/api/sqlx-data.json @@ -3871,6 +3871,30 @@ }, "query": "\nwith cte as (\n select id as \"resource_id\",\n creator_id,\n author_id,\n likes,\n views,\n live_up_to_date,\n case\n when $2 = 0 then resource.draft_id\n when $2 = 1 then resource.live_id\n end as \"draft_or_live_id\",\n published_at,\n rating,\n blocked,\n curated,\n is_premium\n from resource\n left join resource_admin_data \"admin\" on admin.resource_id = resource.id\n where id = $1\n)\nselect cte.resource_id as \"resource_id: ResourceId\",\n display_name,\n creator_id as \"creator_id: UserId\",\n author_id as \"author_id: UserId\",\n (select given_name || ' '::text || family_name\n from user_profile\n where user_profile.user_id = author_id) as \"author_name\",\n created_at,\n updated_at,\n published_at,\n privacy_level as \"privacy_level!: PrivacyLevel\",\n language,\n description,\n translated_description as \"translated_description!: Json>\",\n likes,\n views,\n live_up_to_date,\n locked,\n other_keywords,\n translated_keywords,\n rating as \"rating?: ResourceRating\",\n blocked as \"blocked\",\n exists(select 1 from resource_like where resource_id = $1 and user_id = $3) as \"is_liked!\",\n curated,\n is_premium as \"premium\",\n (\n select row(resource_data_module.id, kind, is_complete)\n from resource_data_module\n where resource_data_id = resource_data.id\n ) as \"cover?: (ModuleId, ModuleKind, bool)\",\n array(select row (category_id)\n from resource_data_category\n where resource_data_id = cte.draft_or_live_id) as \"categories!: Vec<(CategoryId,)>\",\n array(select row (affiliation_id)\n from resource_data_affiliation\n where resource_data_id = cte.draft_or_live_id) as \"affiliations!: Vec<(AffiliationId,)>\",\n array(select row (age_range_id)\n from resource_data_age_range\n where resource_data_id = cte.draft_or_live_id) as \"age_ranges!: Vec<(AgeRangeId,)>\",\n array(\n select row (rdr.id, rdr.display_name, resource_type_id, resource_content)\n from resource_data_resource \"rdr\"\n where rdr.resource_data_id = cte.draft_or_live_id\n ) as \"additional_resource!: Vec<(AddId, String, TypeId, Value)>\"\nfrom resource_data\n inner join cte on cte.draft_or_live_id = resource_data.id\n" }, + "39f55f10f1724f0dabb080c898e7314b6a792e0c0c0912e3572018335d3bd4f6": { + "describe": { + "columns": [ + { + "name": "id!: SchoolNameId", + "ordinal": 0, + "type_info": "Uuid" + }, + { + "name": "name!", + "ordinal": 1, + "type_info": "Text" + } + ], + "nullable": [ + false, + null + ], + "parameters": { + "Left": [] + } + }, + "query": "\nselect\n school_name.school_name_id as \"id!: SchoolNameId\",\n school_name.name::text as \"name!\"\nfrom school_name\nleft join school on school_name.school_name_id = school.internal_school_name_id\nwhere\n school.school_id is null\n" + }, "39f64cd116aebc8283a0b732722a5d198b6e48d255c1f3de2ad0919bee76fb4b": { "describe": { "columns": [], @@ -6814,56 +6838,6 @@ }, "query": "\nselect exists(select 1 from user_image_library where user_id = $1 and id = $2) as \"exists!\"\n " }, - "623f17401360608227b31698dad2ece2b30355867df2ae6bbf9b4a92703fda25": { - "describe": { - "columns": [ - { - "name": "plan_type?: PlanType", - "ordinal": 0, - "type_info": "Int2" - }, - { - "name": "subscription_status?: SubscriptionStatus", - "ordinal": 1, - "type_info": "Int2" - }, - { - "name": "is_admin!", - "ordinal": 2, - "type_info": "Bool" - }, - { - "name": "verified!", - "ordinal": 3, - "type_info": "Bool" - }, - { - "name": "school_id?: SchoolId", - "ordinal": 4, - "type_info": "Uuid" - }, - { - "name": "overdue!", - "ordinal": 5, - "type_info": "Bool" - } - ], - "nullable": [ - false, - false, - false, - false, - false, - null - ], - "parameters": { - "Left": [ - "Uuid" - ] - } - }, - "query": "\nselect\n subscription_plan.plan_type as \"plan_type?: PlanType\",\n subscription.status as \"subscription_status?: SubscriptionStatus\",\n user_account.admin as \"is_admin!\",\n user_account.verified as \"verified!\",\n school.school_id as \"school_id?: SchoolId\",\n case\n when subscription.amount_due > 0 then true\n else false\n end as \"overdue!\"\nfrom user_account\ninner join account using (account_id)\nleft join school using (account_id)\nleft join (\n select subscription.account_id, status, amount_due, subscription_plan_id\n from subscription\n join (\n select\n distinct on (account_id)\n account_id, subscription_id\n from subscription\n order by account_id, created_at desc\n ) as recent_subscription using (subscription_id)\n) as subscription using (account_id)\nleft join subscription_plan on subscription.subscription_plan_id = subscription_plan.plan_id\nwhere user_account.user_id = $1\n" - }, "62d96e4b30f7828cbc7255b3be93f16aa1a868bd5a9780ae80079dbbfe858694": { "describe": { "columns": [ @@ -9986,6 +9960,19 @@ }, "query": "\ninsert into course_data_category(course_data_id, category_id)\nselect $2, category_id\nfrom course_data_category\nwhere course_data_id = $1\n " }, + "96459ff3ea8e971231813b94ba45414bf7de30ef206dc9c12244774ab809332f": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + } + }, + "query": "update school set internal_school_name_id = $2 where school_id = $1" + }, "96569e9389d5fa1a63e58cd3f1b618d0edd302dab9da28604bd88276d2e344c1": { "describe": { "columns": [ @@ -10383,30 +10370,6 @@ }, "query": "\nselect exists (\n select 1\n from jig_like\n where\n jig_id = $1\n and user_id = $2\n) as \"exists!\"\n " }, - "9dc33431675342494dd797271a1544a4128a8eb6b4a142de5f533b326811743d": { - "describe": { - "columns": [ - { - "name": "id!: SchoolNameId", - "ordinal": 0, - "type_info": "Uuid" - }, - { - "name": "name!", - "ordinal": 1, - "type_info": "Text" - } - ], - "nullable": [ - false, - null - ], - "parameters": { - "Left": [] - } - }, - "query": "\nselect\n school_name_id as \"id!: SchoolNameId\",\n name::text as \"name!\"\nfrom school_name\n" - }, "9e0df70165968be7b982a6f1964daab57e2d65d634115cabe4187f3d6f386a9f": { "describe": { "columns": [ @@ -11825,6 +11788,62 @@ }, "query": "\n update user_asset_data\n set playlist_count = playlist_count + 1,\n total_asset_count = total_asset_count + 1\n from playlist\n where author_id = user_id and\n published_at is null and\n id = $1" }, + "b120a6c2b6055b96a909895d534f7a68e64c5e085e658eee06bb60739485be7a": { + "describe": { + "columns": [ + { + "name": "plan_type?: PlanType", + "ordinal": 0, + "type_info": "Int2" + }, + { + "name": "subscription_status?: SubscriptionStatus", + "ordinal": 1, + "type_info": "Int2" + }, + { + "name": "is_admin!", + "ordinal": 2, + "type_info": "Bool" + }, + { + "name": "verified!", + "ordinal": 3, + "type_info": "Bool" + }, + { + "name": "school_id?: SchoolId", + "ordinal": 4, + "type_info": "Uuid" + }, + { + "name": "school_name?", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "overdue!", + "ordinal": 6, + "type_info": "Bool" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + null, + null + ], + "parameters": { + "Left": [ + "Uuid" + ] + } + }, + "query": "\nselect\n subscription_plan.plan_type as \"plan_type?: PlanType\",\n subscription.status as \"subscription_status?: SubscriptionStatus\",\n user_account.admin as \"is_admin!\",\n user_account.verified as \"verified!\",\n school.school_id as \"school_id?: SchoolId\",\n school.school_name::text as \"school_name?\",\n case\n when subscription.amount_due > 0 then true\n else false\n end as \"overdue!\"\nfrom user_account\ninner join account using (account_id)\nleft join school using (account_id)\nleft join (\n select subscription.account_id, status, amount_due, subscription_plan_id\n from subscription\n join (\n select\n distinct on (account_id)\n account_id, subscription_id\n from subscription\n order by account_id, created_at desc\n ) as recent_subscription using (subscription_id)\n) as subscription using (account_id)\nleft join subscription_plan on subscription.subscription_plan_id = subscription_plan.plan_id\nwhere user_account.user_id = $1\n" + }, "b1513ce8a836f80987c480701be8ab9dbc124d8a4556503a4ad2dc77b1ae50d2": { "describe": { "columns": [ diff --git a/backend/api/src/db/account.rs b/backend/api/src/db/account.rs index 4fc192f0ff..d30a785967 100644 --- a/backend/api/src/db/account.rs +++ b/backend/api/src/db/account.rs @@ -289,6 +289,7 @@ select user_account.admin as "is_admin!", user_account.verified as "verified!", school.school_id as "school_id?: SchoolId", + school.school_name::text as "school_name?", case when subscription.amount_due > 0 then true else false @@ -690,15 +691,18 @@ where school_name_id = $1 .await } -pub async fn get_school_names(pool: &PgPool) -> sqlx::Result> { +pub async fn get_unused_school_names(pool: &PgPool) -> sqlx::Result> { sqlx::query_as!( SchoolName, // language=SQL r#" select - school_name_id as "id!: SchoolNameId", - name::text as "name!" + school_name.school_name_id as "id!: SchoolNameId", + school_name.name::text as "name!" from school_name +left join school on school_name.school_name_id = school.internal_school_name_id +where + school.school_id is null "#, ) .fetch_all(pool) @@ -818,6 +822,24 @@ pub async fn verify_school(pool: &PgPool, school_id: SchoolId, verified: bool) - Ok(()) } +#[instrument(skip(pool))] +pub async fn set_internal_school_name( + pool: &PgPool, + school_id: SchoolId, + school_name_id: SchoolNameId, +) -> sqlx::Result<()> { + sqlx::query!( + // language=SQL + r#"update school set internal_school_name_id = $2 where school_id = $1"#, + school_id as SchoolId, + school_name_id as SchoolNameId, + ) + .execute(pool) + .await?; + + Ok(()) +} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum AccountMember { Admin, diff --git a/backend/api/src/http/endpoints/account.rs b/backend/api/src/http/endpoints/account.rs index 78a7ee7620..96c6dd8f70 100644 --- a/backend/api/src/http/endpoints/account.rs +++ b/backend/api/src/http/endpoints/account.rs @@ -10,12 +10,12 @@ use ji_core::settings::RuntimeSettings; use shared::api::endpoints::account::{ DeleteSchoolAccount, GetIndividualAccount, GetSchoolAccount, UpdateSchoolAccount, }; -use shared::api::endpoints::admin::{GetAdminSchoolAccount, UpdateSchoolName}; +use shared::api::endpoints::admin::GetAdminSchoolAccount; use shared::api::{endpoints::account::CreateSchoolAccount, ApiEndpoint, PathParts}; use shared::domain::admin::GetAdminSchoolAccountResponse; use shared::domain::billing::{ AccountIfAuthorized, CreateSchoolAccountRequest, GetSchoolAccountResponse, - IndividualAccountResponse, SchoolId, SchoolNameId, SchoolNameValue, UpdateSchoolAccountRequest, + IndividualAccountResponse, SchoolId, UpdateSchoolAccountRequest, }; use shared::domain::UpdateNonNullable; use shared::error::{AccountError, IntoAnyhow}; @@ -111,35 +111,6 @@ async fn update_school_account( Ok(HttpResponse::Ok().finish()) } -#[instrument(skip_all)] -async fn update_school_name( - _auth: TokenUserWithScope, - db: Data, - path: Path, - req: Json<::Req>, -) -> Result::Err> { - let school_name_id = path.into_inner(); - - let new_name: SchoolNameValue = req.into_inner(); - - if db::account::check_renamed_school_name_exists( - db.as_ref(), - new_name.as_ref(), - &school_name_id, - ) - .await - .into_anyhow()? - { - return Err(AccountError::SchoolNameExists(new_name)); - } - - db::account::update_school_name(db.as_ref(), &school_name_id, new_name) - .await - .into_anyhow()?; - - Ok(HttpResponse::Ok().finish()) -} - async fn get_school_account( auth: TokenUser, db: Data, @@ -314,10 +285,6 @@ pub fn configure(cfg: &mut ServiceConfig) { .route() .to(update_school_account), ) - .route( - ::Path::PATH, - UpdateSchoolName::METHOD.route().to(update_school_name), - ) .route( ::Path::PATH, DeleteSchoolAccount::METHOD diff --git a/backend/api/src/http/endpoints/admin.rs b/backend/api/src/http/endpoints/admin.rs index c12f35908f..ef6ff43d62 100644 --- a/backend/api/src/http/endpoints/admin.rs +++ b/backend/api/src/http/endpoints/admin.rs @@ -11,12 +11,16 @@ use ji_core::settings::RuntimeSettings; use serde::ser::Serialize; use serde_derive::Deserialize; use shared::api::endpoints::admin::{ - GetSchoolNames, ImportSchoolNames, InviteUsers, SearchSchools, VerifySchool, + CreateSchoolName, GetSchoolNames, ImportSchoolNames, InviteUsers, SearchSchools, + SetInternalSchoolName, UpdateSchoolName, VerifySchool, }; use shared::domain::admin::{ InviteFailedReason, InviteSchoolUserFailure, InviteSchoolUsersResponse, SearchSchoolsResponse, }; -use shared::domain::billing::{SchoolId, UpdateSubscriptionPlansRequest}; +use shared::domain::billing::{ + SchoolId, SchoolNameId, SchoolNameValue, UpdateSubscriptionPlansRequest, +}; +use shared::error::AccountError; use shared::{ api::{ endpoints::admin::{self, CreateUpdateSubscriptionPlans}, @@ -30,6 +34,7 @@ use shared::{ error::IntoAnyhow, }; use sqlx::PgPool; +use tracing::instrument; use crate::{ db, error, @@ -222,6 +227,63 @@ async fn import_school_names( Ok((Json(exists), http::StatusCode::OK)) } +#[instrument(skip_all)] +async fn update_school_name( + _auth: TokenUserWithScope, + db: Data, + path: Path, + req: Json<::Req>, +) -> Result::Err> { + let school_name_id = path.into_inner(); + + let new_name: SchoolNameValue = req.into_inner(); + + if db::account::check_renamed_school_name_exists( + db.as_ref(), + new_name.as_ref(), + &school_name_id, + ) + .await + .into_anyhow()? + { + return Err(AccountError::SchoolNameExists(new_name)); + } + + db::account::update_school_name(db.as_ref(), &school_name_id, new_name) + .await + .into_anyhow()?; + + Ok(HttpResponse::Ok().finish()) +} + +#[instrument(skip_all)] +async fn create_school_name( + _auth: TokenUserWithScope, + db: Data, + req: Json<::Req>, +) -> Result< + ( + Json<::Res>, + http::StatusCode, + ), + ::Err, +> { + let school_name: SchoolNameValue = req.into_inner(); + + if db::account::check_school_name_exists(db.as_ref(), school_name.as_ref()) + .await + .into_anyhow()? + { + return Err(AccountError::SchoolNameExists(school_name)); + } + + let school_name_id = db::account::add_school_name(db.as_ref(), school_name.into()) + .await + .into_anyhow()?; + + Ok((Json(school_name_id), http::StatusCode::OK)) +} + async fn verify_school( _auth: TokenUserWithScope, db: Data, @@ -232,6 +294,20 @@ async fn verify_school( Ok(HttpResponse::Ok().finish()) } +async fn set_internal_school_name( + _auth: TokenUserWithScope, + db: Data, + path: Path, + Json(school_name_id): Json<::Req>, +) -> Result::Err> { + let school_id = path.into_inner(); + db::account::set_internal_school_name(db.as_ref(), school_id, school_name_id) + .await + .into_anyhow()?; + + Ok(HttpResponse::Ok().finish()) +} + async fn invite_school_user( pool: &PgPool, school_id: &SchoolId, @@ -309,7 +385,7 @@ async fn get_school_names( > { Ok(( Json( - db::account::get_school_names(db.as_ref()) + db::account::get_unused_school_names(db.as_ref()) .await .into_anyhow()?, ), @@ -340,10 +416,24 @@ pub fn configure(cfg: &mut ServiceConfig) { ::Path::PATH, ImportSchoolNames::METHOD.route().to(import_school_names), ) + .route( + ::Path::PATH, + UpdateSchoolName::METHOD.route().to(update_school_name), + ) + .route( + ::Path::PATH, + CreateSchoolName::METHOD.route().to(create_school_name), + ) .route( ::Path::PATH, VerifySchool::METHOD.route().to(verify_school), ) + .route( + ::Path::PATH, + SetInternalSchoolName::METHOD + .route() + .to(set_internal_school_name), + ) .route( ::Path::PATH, InviteUsers::METHOD.route().to(invite_school_users), diff --git a/frontend/apps/crates/entry/admin/src/schools/details/dom.rs b/frontend/apps/crates/entry/admin/src/schools/details/dom.rs index 152820fca5..3206497d50 100644 --- a/frontend/apps/crates/entry/admin/src/schools/details/dom.rs +++ b/frontend/apps/crates/entry/admin/src/schools/details/dom.rs @@ -1,3 +1,4 @@ +use crate::schools::details::school_name::state::SchoolNameState; use crate::schools::details::state::{CurrentAction, SchoolDetails}; use dominator::{clone, html, Dom}; use futures_signals::signal::Mutable; @@ -18,39 +19,83 @@ impl SchoolDetails { .child_signal(state.school.signal_cloned().map(clone!(state => move |school| { school.map(|school| { html!("admin-school-details", { + .prop_signal("editing_name", state.editing_name.signal_ref(|editing| editing.is_some())) .child(html!("window-loader-block", { .prop("slot", "loader") .prop_signal("visible", state.parent.loader.is_loading()) })) - .child(html!("div", { - .prop("slot", "buttons") - .child(html!("button-rect", { - .prop("kind", "text") - .prop("color", "blue") - .text("Cancel") - .event(clone!(state => move |_: events::Click| { - state.parent.navigate_to(AdminSchoolsRoute::Table); - })) - })) - .child(html!("button-rect", { - .prop("kind", "outline") - .prop("color", "blue") - .prop("disabled", school.verified) - .text("Verify school") - .event(clone!(state => move |_: events::Click| { - state.set_verified(); - })) - })) - .child(html!("button-rect", { - .prop("kind", "filled") - .prop("color", "blue") - .prop_signal("disabled", school.changed_signal().map(|changed| !changed)) - .text("Save school") - .event(clone!(state => move |_: events::Click| { - state.save_school(); - })) + .child_signal(state.editing_name.signal_cloned().map(clone!(state, school => move |editing| { + match editing { + Some(editing) => { + Some(html!("div", { + .prop("slot", "buttons") + .child(html!("button-rect", { + .prop("kind", "text") + .prop("color", "blue") + .text("Cancel") + .event(clone!(state => move |_: events::Click| { + state.editing_name.set(None); + })) + })) + .child(html!("button-rect", { + .prop("kind", "filled") + .prop("color", "blue") + .prop_signal("disabled", editing.changed_signal().map(|changed| !changed)) + .text("Save internal name") + .event(move |_: events::Click| { + match editing.new_name.get() { + Some(_) => editing.create_school_name(), + None => editing.update_school_name(), + } + }) + + })) + })) + }, + None => { + Some(html!("div", { + .prop("slot", "buttons") + .child(html!("button-rect", { + .prop("kind", "text") + .prop("color", "blue") + .text("Cancel") + .event(clone!(state => move |_: events::Click| { + state.parent.navigate_to(AdminSchoolsRoute::Table); + })) + })) + .child(html!("button-rect", { + .prop("kind", "outline") + .prop("color", "blue") + .prop("disabled", school.verified) + .text("Verify school") + .event(clone!(state => move |_: events::Click| { + state.set_verified(); + })) + })) + .child(html!("button-rect", { + .prop("kind", "outline") + .prop("color", "blue") + .text("Change internal name") + .event(clone!(state, school => move |_evt: events::Click| { + state.editing_name.set(Some(SchoolNameState::new(state.clone(), school.internal_school_name.clone()))); + })) + })) + .child(html!("button-rect", { + .prop("kind", "filled") + .prop("color", "blue") + .prop_signal("disabled", school.changed_signal().map(|changed| !changed)) + .text("Save school") + .event(clone!(state => move |_: events::Click| { + state.save_school(); + })) - })) + })) + })) + } + } + }))) + .child_signal(state.editing_name.signal_cloned().map(|editing| { + editing.map(|editing| editing.render("internal".into())) })) .child(html!("empty-fragment", { .prop("slot", "inputs") diff --git a/frontend/apps/crates/entry/admin/src/schools/details/mod.rs b/frontend/apps/crates/entry/admin/src/schools/details/mod.rs index 3942a60175..76948c09cb 100644 --- a/frontend/apps/crates/entry/admin/src/schools/details/mod.rs +++ b/frontend/apps/crates/entry/admin/src/schools/details/mod.rs @@ -1,3 +1,4 @@ pub mod actions; pub mod dom; +pub mod school_name; pub mod state; diff --git a/frontend/apps/crates/entry/admin/src/schools/details/school_name/actions.rs b/frontend/apps/crates/entry/admin/src/schools/details/school_name/actions.rs new file mode 100644 index 0000000000..dee9f73d1f --- /dev/null +++ b/frontend/apps/crates/entry/admin/src/schools/details/school_name/actions.rs @@ -0,0 +1,70 @@ +use crate::schools::details::school_name::state::SchoolNameState; +use dominator::clone; +use shared::api::endpoints; +use shared::domain::admin::{SchoolNamesPath, SetInternalSchoolNamePath, UpdateSchoolNamePath}; +use shared::domain::billing::SchoolNameId; +use std::rc::Rc; +use utils::{error_ext::ErrorExt, prelude::ApiEndpointExt, routes::AdminSchoolsRoute}; + +impl SchoolNameState { + pub fn load_data(self: &Rc) { + let state = Rc::clone(self); + state.parent.parent.loader.load(clone!(state => async move { + match endpoints::admin::GetSchoolNames::api_with_auth( + SchoolNamesPath(), + None, + ) + .await + { + Err(_) => todo!(), + Ok(school_names) => state.school_names.set(Some(school_names)) + } + })); + } + + pub fn update_school_name(self: &Rc) { + let state = Rc::clone(self); + let school_name = self.current_name.get().unwrap(); + + state.parent.parent.loader.load(clone!(state => async move { + if endpoints::admin::UpdateSchoolName::api_with_auth( + UpdateSchoolNamePath(school_name.id), + Some(school_name.name.trim().to_string().into()) + ).await.toast_on_err().is_ok() { + state.parent.parent.navigate_to(AdminSchoolsRoute::Table) + } + })); + } + + pub fn create_school_name(self: &Rc) { + let state = Rc::clone(self); + let school_name = self.new_name.get().unwrap(); + + state.parent.parent.loader.load(clone!(state => async move { + if let Ok(id) = endpoints::admin::CreateSchoolName::api_with_auth( + SchoolNamesPath(), + Some(school_name.trim().to_string().into()) + ).await.toast_on_err() { + if endpoints::admin::SetInternalSchoolName::api_with_auth( + SetInternalSchoolNamePath(state.parent.school_id), + Some(id) + ).await.toast_on_err().is_ok() { + state.parent.parent.navigate_to(AdminSchoolsRoute::Table) + } + } + })); + } + + pub fn change_internal_school_name(self: &Rc, school_name_id: SchoolNameId) { + let state = Rc::clone(self); + + state.parent.parent.loader.load(clone!(state => async move { + if endpoints::admin::SetInternalSchoolName::api_with_auth( + SetInternalSchoolNamePath(state.parent.school_id), + Some(school_name_id) + ).await.toast_on_err().is_ok() { + state.parent.parent.navigate_to(AdminSchoolsRoute::Table) + } + })); + } +} diff --git a/frontend/apps/crates/entry/admin/src/schools/details/school_name/dom.rs b/frontend/apps/crates/entry/admin/src/schools/details/school_name/dom.rs new file mode 100644 index 0000000000..816933c7da --- /dev/null +++ b/frontend/apps/crates/entry/admin/src/schools/details/school_name/dom.rs @@ -0,0 +1,122 @@ +use crate::schools::details::school_name::state::SchoolNameState; +use dominator::{clone, html, Dom}; +use futures_signals::map_ref; +use futures_signals::signal::SignalExt; +use std::rc::Rc; +use utils::events; +use utils::prelude::UnwrapJiExt; +use web_sys::HtmlInputElement; + +impl SchoolNameState { + pub fn render(self: Rc, slot: String) -> Dom { + let state = self; + + state.load_data(); + + let filtered_names = map_ref! { + let names = state.school_names.signal_cloned(), + let filter = state.filter_value.signal_cloned() + => { + match names { + Some(names) => { + names + .iter() + .cloned() + .filter(|school_name| school_name.name.to_lowercase().contains(&filter.to_lowercase())) + .take(25) + .collect::>() + }, + None => vec![] + } + } + } + .map(clone!(state => move |school_names| { + school_names.into_iter() + .map(|school_name| { + let school_name_id = school_name.id; + html!("div", { + .child(html!("button-rect", { + .prop("kind", "text") + .prop("color", "blue") + .prop("size", "small") + .text(&school_name.name) + .event(clone!(state => move |_evt: events::Click| { + state.change_internal_school_name(school_name_id); + })) + })) + }) + }) + .collect::>() + })) + .to_signal_vec(); + + html!("div", { + .prop("slot", slot) + .child(html!("h2", { + .text("Create a new internal school name") + })) + .child(html!("input-wrapper", { + .prop("label", "New school name") + .child(html!("input", { + .prop("type", "text") + .prop_signal("value", state.new_name.signal().map(|school_name| { + school_name.unwrap_or_default() + })) + .event(clone!(state => move |evt: events::Input| { + let new_name = evt.dyn_target::().unwrap_ji() + .value() + .to_string(); + state.new_name.set(Some(new_name)); + })) + })) + })) + .child_signal(state.current_name.signal().map(|name| name.is_some()).dedupe().map(clone!(state => move |has_school_name| { + if has_school_name { + Some(html!("empty-fragment", { + .child(html!("h2", { + .text("Or, update the current internal school name") + })) + .child(html!("input-wrapper", { + .prop("label", "Edit current school name") + .child(html!("input", { + .prop("type", "text") + .prop_signal("value", state.current_name.signal().map(|school_name| { + match school_name { + Some(school_name) => school_name.name, + None => String::new(), + } + })) + .event(clone!(state => move |evt: events::Input| { + let mut current_name = state.current_name.get().unwrap_ji(); + current_name.name = evt.dyn_target::().unwrap_ji() + .value() + .to_string(); + state.current_name.set(Some(current_name)); + state.new_name.set(None); + })) + })) + })) + })) + } else { + None + } + }))) + .child(html!("h2", { + .text("Or, use an existing school name") + })) + .child(html!("input-wrapper", { + .prop("label", "Filter") + .child(html!("input", { + .prop("type", "text") + .prop_signal("value", state.filter_value.signal_cloned()) + .event(clone!(state => move |evt: events::Input| { + state.filter_value.set(evt.dyn_target::().unwrap_ji() + .value() + .to_string()); + })) + })) + })) + .children_signal_vec(filtered_names) + }) + } +} diff --git a/frontend/apps/crates/entry/admin/src/schools/details/school_name/mod.rs b/frontend/apps/crates/entry/admin/src/schools/details/school_name/mod.rs new file mode 100644 index 0000000000..3942a60175 --- /dev/null +++ b/frontend/apps/crates/entry/admin/src/schools/details/school_name/mod.rs @@ -0,0 +1,3 @@ +pub mod actions; +pub mod dom; +pub mod state; diff --git a/frontend/apps/crates/entry/admin/src/schools/details/school_name/state.rs b/frontend/apps/crates/entry/admin/src/schools/details/school_name/state.rs new file mode 100644 index 0000000000..e3646fc1b7 --- /dev/null +++ b/frontend/apps/crates/entry/admin/src/schools/details/school_name/state.rs @@ -0,0 +1,39 @@ +use crate::schools::details::state::SchoolDetails; +use futures_signals::map_ref; +use futures_signals::signal::{Mutable, Signal}; +use shared::domain::billing::SchoolName; +use std::rc::Rc; +use utils::editable_field::{EditableField, Nullable}; + +pub struct SchoolNameState { + pub parent: Rc, + pub new_name: EditableField>, + pub current_name: EditableField>, + pub filter_value: Mutable, + pub school_names: Mutable>>, +} + +impl SchoolNameState { + pub fn new(parent: Rc, current_name: Option) -> Rc { + Rc::new(Self { + parent, + new_name: Default::default(), + current_name: current_name.into(), + filter_value: Default::default(), + school_names: Default::default(), + }) + } + + pub fn changed_signal(&self) -> impl Signal { + map_ref! { + let new_name = self.new_name.signal(), + let current_name = self.current_name.changed_signal() + => { + match new_name { + Some(_) => true, + None => *current_name, + } + } + } + } +} diff --git a/frontend/apps/crates/entry/admin/src/schools/details/state.rs b/frontend/apps/crates/entry/admin/src/schools/details/state.rs index b9d46cd2c1..43caf63525 100644 --- a/frontend/apps/crates/entry/admin/src/schools/details/state.rs +++ b/frontend/apps/crates/entry/admin/src/schools/details/state.rs @@ -1,3 +1,4 @@ +use crate::schools::details::school_name::state::SchoolNameState; use crate::schools::Schools; use futures_signals::map_ref; use futures_signals::signal::{Mutable, Signal}; @@ -20,6 +21,7 @@ pub struct SchoolDetails { pub users: MutableVec>, pub current_action: Mutable, pub errored_users: Mutable>, + pub editing_name: Mutable>>, } impl SchoolDetails { @@ -32,6 +34,7 @@ impl SchoolDetails { users: MutableVec::new(), current_action: Mutable::new(CurrentAction::Viewing), errored_users: Mutable::new(vec![]), + editing_name: Mutable::new(None), }) } } diff --git a/frontend/apps/crates/entry/user/src/welcome/dom.rs b/frontend/apps/crates/entry/user/src/welcome/dom.rs index 5f290a771f..ccd846b59e 100644 --- a/frontend/apps/crates/entry/user/src/welcome/dom.rs +++ b/frontend/apps/crates/entry/user/src/welcome/dom.rs @@ -16,7 +16,7 @@ fn get_add_teacher_form_link() -> String { let user = user.as_ref(); let user = user.unwrap_ji(); - let name_of_school = user.school_or_organization.clone().unwrap_or_default(); + let name_of_school = user.school_or_organization().clone().unwrap_or_default(); let email = user.email.clone(); let first_name = user.given_name.clone(); let last_name = user.family_name.clone(); diff --git a/frontend/apps/crates/utils/src/editable_field.rs b/frontend/apps/crates/utils/src/editable_field.rs index 24fd685c98..abd4651878 100644 --- a/frontend/apps/crates/utils/src/editable_field.rs +++ b/frontend/apps/crates/utils/src/editable_field.rs @@ -18,6 +18,15 @@ pub struct Nullable { value: Mutable>, } +impl Default for Nullable { + fn default() -> Self { + Self { + update: Default::default(), + value: Default::default(), + } + } +} + impl From for EditableField> { fn from(value: T) -> Self { Self { @@ -65,8 +74,19 @@ impl From> for EditableField> { fn from(value: Option) -> Self { Self { inner: Nullable { - update: Mutable::default(), value: Mutable::new(value), + ..Default::default() + }, + } + } +} + +impl Default for EditableField> { + fn default() -> Self { + Self { + inner: Nullable { + update: Mutable::default(), + value: Mutable::new(None), }, } } diff --git a/frontend/elements/src/entry/admin/schools/school-details.ts b/frontend/elements/src/entry/admin/schools/school-details.ts index 8fb49eb914..826032b2fc 100644 --- a/frontend/elements/src/entry/admin/schools/school-details.ts +++ b/frontend/elements/src/entry/admin/schools/school-details.ts @@ -1,4 +1,5 @@ -import { LitElement, html, css, customElement } from "lit-element"; +import {LitElement, html, css, customElement, property} from "lit-element"; +import {nothing} from "lit-html"; @customElement("admin-school-details") export class _ extends LitElement { @@ -50,6 +51,9 @@ export class _ extends LitElement { ` ]; + @property() + editing_name: boolean = false; + render() { return html`
@@ -61,18 +65,26 @@ export class _ extends LitElement {
-
-

School

- -
-
-

Account

- -
-
-

Members

- -
+ ${this.editing_name + ? html` +
+ +
+ ` + : html` +
+

School

+ +
+
+

Account

+ +
+
+

Members

+ +
+ `} `; } diff --git a/shared/rust/src/api/endpoints/admin.rs b/shared/rust/src/api/endpoints/admin.rs index 0ecd288cc9..b97324181e 100644 --- a/shared/rust/src/api/endpoints/admin.rs +++ b/shared/rust/src/api/endpoints/admin.rs @@ -3,9 +3,9 @@ use crate::domain::admin::{ AdminSchoolAccountPath, AdminSchoolsPath, AdminVerifySchoolPath, GetAdminSchoolAccountResponse, ImportSchoolNamesPath, InviteSchoolUsersPath, InviteSchoolUsersRequest, InviteSchoolUsersResponse, SchoolNamesPath, SearchSchoolsParams, SearchSchoolsResponse, - UpdateSchoolNamePath, VerifySchoolRequest, + SetInternalSchoolNamePath, UpdateSchoolNamePath, VerifySchoolRequest, }; -use crate::domain::billing::{SchoolName, SchoolNameValue}; +use crate::domain::billing::{SchoolName, SchoolNameId, SchoolNameValue}; use crate::error::AccountError; use crate::{ api::Method, @@ -116,3 +116,22 @@ impl ApiEndpoint for UpdateSchoolName { type Err = AccountError; const METHOD: Method = Method::Patch; } + +/// Create a school name +pub struct CreateSchoolName; +impl ApiEndpoint for CreateSchoolName { + type Path = SchoolNamesPath; + type Req = SchoolNameValue; + type Res = SchoolNameId; + type Err = AccountError; + const METHOD: Method = Method::Post; +} +/// Set a school name for a school +pub struct SetInternalSchoolName; +impl ApiEndpoint for SetInternalSchoolName { + type Path = SetInternalSchoolNamePath; + type Req = SchoolNameId; + type Res = (); + type Err = AccountError; + const METHOD: Method = Method::Patch; +} diff --git a/shared/rust/src/domain/admin.rs b/shared/rust/src/domain/admin.rs index 102cfb0c5c..1bb2384bb5 100644 --- a/shared/rust/src/domain/admin.rs +++ b/shared/rust/src/domain/admin.rs @@ -158,3 +158,5 @@ pub enum InviteFailedReason { make_path_parts!(SchoolNamesPath => "/v1/admin/school-names"); make_path_parts!(UpdateSchoolNamePath => "/v1/admin/school-names/{}" => SchoolNameId); + +make_path_parts!(SetInternalSchoolNamePath => "/v1/admin/schools/{}/school-name" => SchoolId); diff --git a/shared/rust/src/domain/billing.rs b/shared/rust/src/domain/billing.rs index bc609b643d..1c38b9b719 100644 --- a/shared/rust/src/domain/billing.rs +++ b/shared/rust/src/domain/billing.rs @@ -887,6 +887,8 @@ pub struct Account { pub struct UserAccountSummary { /// ID of the school if this is a School account pub school_id: Option, + /// Name of the school if this is a School account + pub school_name: Option, /// The type of plan the user's account is subscribed to pub plan_type: Option, /// Status of the accounts subscription, if any @@ -1040,8 +1042,8 @@ pub struct SchoolName { #[serde(transparent)] pub struct SchoolNameValue(String); -impl std::fmt::Display for SchoolNameValue { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for SchoolNameValue { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } diff --git a/shared/rust/src/domain/user.rs b/shared/rust/src/domain/user.rs index 039e569a8e..53f6650492 100644 --- a/shared/rust/src/domain/user.rs +++ b/shared/rust/src/domain/user.rs @@ -274,11 +274,16 @@ pub struct UserProfile { #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub account_summary: Option, +} - /// The schools name if user is part of school, otherwise same as organization. - #[serde(default)] - #[serde(skip_serializing_if = "Option::is_none")] - pub school_or_organization: Option, +impl UserProfile { + /// Returns the school or organization associated with this user if any. + pub fn school_or_organization(&self) -> Option<&str> { + self.account_summary + .as_ref() + .and_then(|summary| summary.school_name.as_deref()) + .or(self.organization.as_deref()) + } } /// User Response (used for Admin).