From 5a592e92932749a4a1222b2b17c88c709929fdc8 Mon Sep 17 00:00:00 2001 From: Mendy Berger <12537668+MendyBerger@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:45:21 -0400 Subject: [PATCH] feat: Added names to jig-codes --- backend/api/sqlx-data.json | 38 +++++++ backend/api/src/db/jig.rs | 24 ++++ backend/api/src/db/jig/codes.rs | 35 +++++- backend/api/src/http/endpoints/jig.rs | 4 + backend/api/src/http/endpoints/jig/codes.rs | 17 +++ backend/api/tests/integration/jig/codes.rs | 16 +++ .../integration__jig__codes__create-2.snap | 2 +- .../classroom/src/codes/jig_codes/actions.rs | 17 ++- .../classroom/src/codes/jig_codes/dom.rs | 80 +++++++++++-- .../classroom/src/codes/jig_codes/styles.css | 105 +++++++++++------- shared/rust/src/domain/jig/codes.rs | 17 ++- 11 files changed, 299 insertions(+), 56 deletions(-) diff --git a/backend/api/sqlx-data.json b/backend/api/sqlx-data.json index da25992aa..1fd0e7c91 100644 --- a/backend/api/sqlx-data.json +++ b/backend/api/sqlx-data.json @@ -1130,6 +1130,23 @@ }, "query": "select id as \"id: AudioId\" from user_audio_library order by created_at desc" }, + "16f8910b20347fe554aa439d7e678c377d5798bf89a14267614e1d47521bc8b0": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int4", + "Bool", + "Text", + "Int2", + "Bool", + "Bool" + ] + } + }, + "query": "\n update jig_code\n set name = case when $2 then $3 else name end,\n direction = coalesce($4, direction),\n scoring = coalesce($5, scoring),\n drag_assist = coalesce($6, drag_assist)\n where code = $1\n " + }, "172c8df92f296df4a816c4c9d77c091c56919f84f54bc1cc1174060aff2d64c6": { "describe": { "columns": [ @@ -12248,6 +12265,27 @@ }, "query": "\nselect author_id as \"author_id: UserId\",\n published_at as \"published_at?\"\nfrom playlist\nwhere id = $1\n " }, + "b7d2bcf9ab498de0fa50e3b47d3e5498541bffde1c02345a178747824d0318ad": { + "describe": { + "columns": [ + { + "name": "authed!", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Uuid", + "Int4" + ] + } + }, + "query": "\n select exists (\n select 1 from jig_code where creator_id = $1 and code = $2\n ) as \"authed!\"\n \n " + }, "b7d83cfd90ef7d1f18b8f070a52bccb1dc58d040a1e012e776c9cd47704efc27": { "describe": { "columns": [], diff --git a/backend/api/src/db/jig.rs b/backend/api/src/db/jig.rs index c8d46205e..3a4f72828 100644 --- a/backend/api/src/db/jig.rs +++ b/backend/api/src/db/jig.rs @@ -1,6 +1,7 @@ use crate::translate::translate_text; use anyhow::Context; use serde_json::value::Value; +use shared::domain::jig::codes::JigCode; use shared::domain::jig::{AdminJigExport, JigUpdateAdminDataRequest}; use shared::domain::module::StableModuleId; use shared::domain::playlist::{PlaylistAdminData, PlaylistRating}; @@ -1898,6 +1899,29 @@ select exists ( Ok(()) } +pub async fn is_users_code(db: &PgPool, user_id: UserId, code: JigCode) -> Result<(), error::Auth> { + let authed = sqlx::query!( + //language=SQL + r#" + select exists ( + select 1 from jig_code where creator_id = $1 and code = $2 + ) as "authed!" + + "#, + user_id.0, + code.0 + ) + .fetch_one(db) + .await? + .authed; + + if !authed { + return Err(error::Auth::Forbidden); + } + + Ok(()) +} + async fn update_draft_or_live( conn: &mut PgConnection, jig_data_id: Uuid, diff --git a/backend/api/src/db/jig/codes.rs b/backend/api/src/db/jig/codes.rs index 7bd027830..8eaeb9270 100644 --- a/backend/api/src/db/jig/codes.rs +++ b/backend/api/src/db/jig/codes.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Duration, Utc}; use rand::{rngs::ThreadRng, Rng}; use shared::config::{JIG_PLAYER_SESSION_CODE_MAX, JIG_PLAYER_SESSION_VALID_DURATION_SECS}; use shared::domain::jig::codes::{ - JigCodeListRequest, JigCodeSessionResponse, JigPlayerSessionCreateRequest, + JigCodeListRequest, JigCodeSessionResponse, JigCodeUpdateRequest, JigPlayerSessionCreateRequest, }; use shared::domain::jig::{ codes::{JigCode, JigCodeResponse, JigPlaySession}, @@ -75,6 +75,39 @@ returning created_at as "created_at: DateTime" Err(anyhow::anyhow!("Maximum retries reached for creating a new jig session").into()) } +pub async fn update( + db: &PgPool, + code: JigCode, + opts: &JigCodeUpdateRequest, +) -> Result<(), error::JigCode> { + let name = opts.name.clone(); + let direction = opts.settings.as_ref().map(|opts| opts.direction); + let scoring = opts.settings.as_ref().map(|opts| opts.scoring); + let drag_assist = opts.settings.as_ref().map(|opts| opts.drag_assist); + + sqlx::query!( + //language=SQL + r#" + update jig_code + set name = case when $2 then $3 else name end, + direction = coalesce($4, direction), + scoring = coalesce($5, scoring), + drag_assist = coalesce($6, drag_assist) + where code = $1 + "#, + code.0, + name.is_some(), + name.flatten(), + direction.map(|d| d as i16), + scoring, + drag_assist, + ) + .execute(db) + .await?; + + Ok(()) +} + fn session_create_error_or_continue(db_err: Box) -> Result<(), error::JigCode> { let constraint = db_err.downcast_ref::().constraint(); diff --git a/backend/api/src/http/endpoints/jig.rs b/backend/api/src/http/endpoints/jig.rs index f67c819af..bd571ba0b 100644 --- a/backend/api/src/http/endpoints/jig.rs +++ b/backend/api/src/http/endpoints/jig.rs @@ -573,6 +573,10 @@ pub fn configure(cfg: &mut ServiceConfig) { ::Path::PATH, jig::codes::Create::METHOD.route().to(codes::create), ) + .route( + ::Path::PATH, + jig::codes::Update::METHOD.route().to(codes::update), + ) .route( ::Path::PATH, jig::codes::JigCodeList::METHOD diff --git a/backend/api/src/http/endpoints/jig/codes.rs b/backend/api/src/http/endpoints/jig/codes.rs index 0e01fc2c3..30625b372 100644 --- a/backend/api/src/http/endpoints/jig/codes.rs +++ b/backend/api/src/http/endpoints/jig/codes.rs @@ -26,6 +26,23 @@ pub async fn create( Ok(HttpResponse::Created().json(jig_code)) } +pub async fn update( + db: Data, + claims: TokenUser, + path: web::Path, + req: Json<::Req>, +) -> Result { + let code = path.into_inner(); + let req = req.into_inner(); + let user_id = claims.user_id(); + + db::jig::is_users_code(&*db, user_id, code).await?; + + db::jig::codes::update(&db, code, &req).await?; + + Ok(HttpResponse::NoContent().into()) +} + /// Get all jig codes for user. pub async fn list_user_codes( db: Data, diff --git a/backend/api/tests/integration/jig/codes.rs b/backend/api/tests/integration/jig/codes.rs index 0a07a678f..3abcf0242 100644 --- a/backend/api/tests/integration/jig/codes.rs +++ b/backend/api/tests/integration/jig/codes.rs @@ -39,6 +39,22 @@ async fn create_and_list(port: u16) -> anyhow::Result<()> { insta::assert_json_snapshot!(format!("{}-1",name), body, { ".**.index" => "[index]", ".**.created_at" => "[timestamp]", ".**.expires_at" => "[timestamp]" }); + let resp = client + .patch(&format!( + "http://0.0.0.0:{}/v1/jig/codes/{}", + port, + body.index.to_string() + )) + .json(&serde_json::json!({ + "name": "test-name" + })) + .login() + .send() + .await? + .error_for_status()?; + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let _resp = client .post(&format!("http://0.0.0.0:{}/v1/jig/codes", port)) .json(&serde_json::json!({ diff --git a/backend/api/tests/integration/jig/snapshots/integration__jig__codes__create-2.snap b/backend/api/tests/integration/jig/snapshots/integration__jig__codes__create-2.snap index d5dbf7876..1e0af6856 100644 --- a/backend/api/tests/integration/jig/snapshots/integration__jig__codes__create-2.snap +++ b/backend/api/tests/integration/jig/snapshots/integration__jig__codes__create-2.snap @@ -31,7 +31,7 @@ expression: body { "index": "[index]", "jig_id": "3a71522a-cd77-11eb-8dc1-af3e35f7c743", - "name": null, + "name": "test-name", "settings": { "direction": "rtl", "scoring": false, diff --git a/frontend/apps/crates/entry/classroom/src/codes/jig_codes/actions.rs b/frontend/apps/crates/entry/classroom/src/codes/jig_codes/actions.rs index 809537c7e..876fcfcc3 100644 --- a/frontend/apps/crates/entry/classroom/src/codes/jig_codes/actions.rs +++ b/frontend/apps/crates/entry/classroom/src/codes/jig_codes/actions.rs @@ -6,7 +6,9 @@ use futures::join; use shared::{ api::endpoints, domain::jig::{ - codes::{JigCodeListPath, JigCodeListRequest}, + codes::{ + JigCode, JigCodeListPath, JigCodeListRequest, JigCodeUpdatePath, JigCodeUpdateRequest, + }, JigGetLivePath, }, }; @@ -41,4 +43,17 @@ impl JigCodes { let jig = bail_on_err!(jig); self.jig.set(Some(jig)); } + + pub fn save_name(self: &Rc, code: JigCode, new_name: String) { + spawn_local(async move { + let req = JigCodeUpdateRequest { + name: Some(Some(new_name)), + settings: None, + }; + let _ = + endpoints::jig::codes::Update::api_with_auth(JigCodeUpdatePath(code), Some(req)) + .await + .toast_on_err(); + }); + } } diff --git a/frontend/apps/crates/entry/classroom/src/codes/jig_codes/dom.rs b/frontend/apps/crates/entry/classroom/src/codes/jig_codes/dom.rs index b627ecf16..e7bbcf452 100644 --- a/frontend/apps/crates/entry/classroom/src/codes/jig_codes/dom.rs +++ b/frontend/apps/crates/entry/classroom/src/codes/jig_codes/dom.rs @@ -1,11 +1,14 @@ use components::asset_card::render_asset_card; -use dominator::{clone, html, DomBuilder}; -use futures_signals::{signal::SignalExt, signal_vec::SignalVecExt}; +use dominator::{clone, html, DomBuilder, EventOptions}; +use futures_signals::{ + signal::{not, Mutable, SignalExt}, + signal_vec::SignalVecExt, +}; use shared::domain::jig::TextDirection; use std::rc::Rc; use utils::{ component::Component, - date_formatters, link, + date_formatters, events, routes::{ClassroomCodesRoute, ClassroomRoute, Route}, }; use web_sys::ShadowRoot; @@ -32,10 +35,10 @@ impl Component for Rc { .class("codes") .child(html!("div", { .class("header") - // .child(html!("span", { - // .class("cell") - // .text("Name") - // })) + .child(html!("span", { + .class("cell") + .text("Name") + })) .child(html!("span", { .class("cell") .text("Code") @@ -58,12 +61,65 @@ impl Component for Rc { // })) })) .children_signal_vec(state.codes.signal_vec_cloned().map(clone!(state => move |code| { - link!(Route::Classroom(ClassroomRoute::Codes(ClassroomCodesRoute::JigCodeSession(state.jig_id, code.index))), { + let editing = Mutable::new(false); + let original_name = Mutable::new(code.name.clone().unwrap_or_default()); + let name = Mutable::new(original_name.get_cloned()); + let route = Route::Classroom(ClassroomRoute::Codes(ClassroomCodesRoute::JigCodeSession(state.jig_id, code.index))); + html!("a", { .class("code") - // .child(html!("span", { - // .class("cell") - // .text(&code.name.unwrap_or_default()) - // })) + .prop("href", route.to_string()) + .event_with_options(&dominator::EventOptions { preventable: true, bubbles: true }, clone!(editing => move |e:events::Click| { + e.prevent_default(); + if !editing.get() { + route.go_to(); + } + })) + .child(html!("div", { + .class("cell") + .class("name") + .child(html!("input", { + .prop_signal("readOnly", not(editing.signal())) + .prop_signal("value", name.signal_cloned()) + .focused_signal(editing.signal()) + })) + .child(html!("div", { + .class("actions") + .event_with_options(&EventOptions { preventable: true, bubbles: true }, move |e: events::Click| { + e.stop_propagation(); + e.prevent_default(); + }) + .children_signal_vec(editing.signal().map(clone!(state => move |e| match e { + false => vec![ + html!("fa-button", { + .prop("icon", "fa-regular fa-pen-to-square") + .prop("title", "Edit") + .event(clone!(editing => move |_: events::Click| { + editing.set(true); + })) + }) + ], + true => vec![ + html!("fa-button", { + .prop("icon", "fa-regular fa-floppy-disk") + .prop("title", "Save") + .event(clone!(state, editing, name, original_name => move |_: events::Click| { + editing.set(false); + original_name.set(name.get_cloned()); + state.save_name(code.index, name.get_cloned()); + })) + }), + html!("fa-button", { + .prop("icon", "fa-regular fa-xmark") + .prop("title", "Cancel") + .event(clone!(editing, name, original_name => move |_: events::Click| { + editing.set(false); + name.set(original_name.get_cloned()); + })) + }), + ], + })).to_signal_vec()) + })) + })) .child(html!("span", { .class("cell") .text(&code.index.to_string()) diff --git a/frontend/apps/crates/entry/classroom/src/codes/jig_codes/styles.css b/frontend/apps/crates/entry/classroom/src/codes/jig_codes/styles.css index 947e38cbc..ac08032ea 100644 --- a/frontend/apps/crates/entry/classroom/src/codes/jig_codes/styles.css +++ b/frontend/apps/crates/entry/classroom/src/codes/jig_codes/styles.css @@ -4,50 +4,77 @@ gap: 40px; justify-items: center; } +.code:not(:first-child) .cell, +.header:not(:first-child) .cell { + border-top: var(--border); +} +.cell:not(:first-child), +.header .cell:not(:first-child) { + border-left: var(--border); +} .codes { display: grid; - grid-template-columns: auto auto auto auto; + grid-template-columns: auto auto auto auto auto; --border: solid 1px #00000010; border: var(--border); border-radius: 8px; overflow: hidden; background-color: #fff; -} -.codes .header { - display: contents; -} -.codes .header .cell { - padding: 6px 10px; - font-size: 12px; -} -.codes .code { - text-decoration: none; - display: contents; -} -.codes .code .cell { - padding: 10px; -} -.codes .code:hover .cell { - background-color: var(--light-blue-1); -} -.codes .code .cell, -.codes .code:active .cell, -.codes .code:visited .cell, -.codes .code:hover .cell { - color: var(--main-blue); - text-align: center; - display: grid; - place-content: center; -} -.codes .code:not(:first-child) .cell, -.codes .header:not(:first-child) .cell { - border-top: var(--border); -} -.codes .code .cell:not(:first-child), -.codes .header .cell:not(:first-child) { - border-left: var(--border); -} -.codes .code .cell.created-at { - font-size: 12px; - color: gray; + + .header { + display: contents; + } + .header .cell { + padding: 6px 10px; + font-size: 12px; + } + .code { + text-decoration: none; + display: contents; + + &:hover .cell { + background-color: var(--light-blue-1); + } + + .cell, + &:active .cell, + &:visited .cell, + &:hover .cell { + color: var(--main-blue); + padding: 10px; + text-align: center; + display: grid; + place-content: center; + } + .cell { + &.name { + display: grid; + grid-template-columns: 1fr 36px; + gap: 5px; + + input { + border: none; + padding: 0; + color: inherit; + box-sizing: border-box; + background-color: transparent; + cursor: inherit; + } + input:not(:read-only) { + border: solid black 1px; + color: black; + cursor: text; + } + .actions { + display: flex; + gap: 5px; + justify-content: end; + } + } + &.created-at { + font-size: 12px; + color: gray; + } + } + } } diff --git a/shared/rust/src/domain/jig/codes.rs b/shared/rust/src/domain/jig/codes.rs index 32717d827..61ed75477 100644 --- a/shared/rust/src/domain/jig/codes.rs +++ b/shared/rust/src/domain/jig/codes.rs @@ -24,7 +24,7 @@ impl ToString for JigCode { make_path_parts!(JigPlayerSessionCreatePath => "/v1/jig/codes"); -/// Request to create a player session for a jig. +/// Request to create a jig code. #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct JigPlayerSessionCreateRequest { @@ -38,7 +38,7 @@ pub struct JigPlayerSessionCreateRequest { pub settings: JigPlayerSettings, } -/// Request to create a player session for a jig. +/// Response from creating a jig code. #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct JigPlayerSessionCreateResponse { @@ -46,6 +46,19 @@ pub struct JigPlayerSessionCreateResponse { pub index: JigCode, } +make_path_parts!(JigCodeUpdatePath => "/v1/jig/codes/{}" => JigCode); + +/// Request to update a jig code. +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct JigCodeUpdateRequest { + /// Display name + pub name: Option>, + + /// Settings for the session + pub settings: Option, +} + /// Over-the-wire representation of a jig player session #[derive(Serialize, Deserialize, Debug, Clone)] pub struct JigCodeResponse {