diff --git a/apps/src/sites/studio/pages/sections/show.js b/apps/src/sites/studio/pages/sections/show.js index 04a7c1583120f..91b005bf5327d 100644 --- a/apps/src/sites/studio/pages/sections/show.js +++ b/apps/src/sites/studio/pages/sections/show.js @@ -1,63 +1,23 @@ -import clientState from '@cdo/apps/code-studio/clientState.js'; - -const script = document.querySelector('script[data-section]'); -const sectionData = JSON.parse(script.dataset['section']); -const {loginType, loginTypeWord, loginTypePicture, pairingAllowed} = - sectionData; - -$(function () { - // Select name. - $('ul.students li').click(function () { - $('ul.students li').removeClass('selected'); - $(this).addClass('selected'); - $('input#user_id').val($(this).attr('id')); - - if (loginType === loginTypeWord) { - // Clear the password. - $('#secret_words').val(''); - } else if (loginType === loginTypePicture) { - // Deselect picture. - $('ul.pictures li').removeClass('selected'); - } - - // Disable the login button. - $('#login_button').prop('disabled', true); - - // Hide the pairing checkbox - $('#pairing_checkbox').hide(); - - // Reveal the secret section... - $('#secret').hide().slideDown(); - - // ...and simultaneously fade in the login button. - $('#login_button').fadeIn(); - }); - - // Select secret picture. - $('ul.pictures li').click(function () { - $('ul.pictures li').removeClass('selected'); - $(this).addClass('selected'); - $('input#secret_picture_id').val($(this).attr('id')); - - // Show the pairing checkbox - if (pairingAllowed) { - $('#pairing_checkbox').show(); - } - - // Enable the login button. - $('#login_button').prop('disabled', false); - }); - - // Type something in password box. - $('#secret_words').keyup(function () { - // Show the pairing checkbox - if (pairingAllowed) { - $('#pairing_checkbox').show(); - } - - // Enable the login button. - $('#login_button').prop('disabled', false); - }); - - $('.section-user-sign-in').on('submit', clientState.reset); -}); +import React from 'react'; + +import SectionSignIn from '@cdo/apps/templates/sections/SectionSignIn'; +import {createReactRoot} from '@cdo/apps/util/createReactRoot'; + +function mount() { + const script = document.querySelector('script[data-section]'); + const data = JSON.parse(script.dataset.section); + + createReactRoot( + , + document.getElementById('section-sign-in'), + {legacyReactDomRender: true} + ); +} + +// The entry script tag precedes the mount-point div in the view, so wait for +// the document to finish parsing before looking it up. +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mount); +} else { + mount(); +} diff --git a/apps/src/templates/sections/SectionSignIn.jsx b/apps/src/templates/sections/SectionSignIn.jsx new file mode 100644 index 0000000000000..14e3df47aec80 --- /dev/null +++ b/apps/src/templates/sections/SectionSignIn.jsx @@ -0,0 +1,186 @@ +import Checkbox from '@code-dot-org/component-library/checkbox'; +import Chips from '@code-dot-org/component-library/chips'; +import TextField from '@code-dot-org/component-library/textField'; +import {Typography as MuiTypography, Button as MuiButton} from '@mui/material'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React, {useState} from 'react'; + +import clientState from '@cdo/apps/code-studio/clientState'; + +import styles from './section-sign-in.module.scss'; + +/** + * Design-system version of the student "sign in to a section" page + * (sections#show). Students pick their name, then a secret picture or word, and + * submit. Built from DSCO components; the form is still a native POST (with the + * Rails CSRF token) so the server-side authentication flow is unchanged. The + * translated copy, student/picture data, CSRF token, and submit path are + * supplied by the server. + */ +export default function SectionSignIn({ + submitPath, + authenticityToken, + welcome, + nameInstruction, + pictureInstruction, + wordInstruction, + pairProgramming, + loginLabel, + students, + secretPictures, + loginType, + loginTypePicture, + loginTypeWord, + pairingAllowed, +}) { + const [userId, setUserId] = useState(null); + const [pictureId, setPictureId] = useState(null); + const [secretWords, setSecretWords] = useState(''); + const [pairing, setPairing] = useState(false); + + const isPicture = loginType === loginTypePicture; + const isWord = loginType === loginTypeWord; + + const nameChosen = userId !== null; + let secretChosen = false; + if (isPicture) { + secretChosen = pictureId !== null; + } else if (isWord) { + secretChosen = secretWords.trim().length > 0; + } + const showPairing = pairingAllowed && secretChosen; + + // The DSCO Chips group is multi-select; drive it as single-select by keeping + // at most one value. `setValues` receives the next array (the clicked chip + // added, or removed when the current one is clicked again). Picking a + // (different) name resets the secret-picture/word selection, per the legacy + // flow. + const onSelectName = nextValues => { + const added = nextValues.find(value => value !== String(userId)); + setUserId(added ? Number(added) : null); + setPictureId(null); + setSecretWords(''); + setPairing(false); + }; + + return ( +
+ + {welcome} + + +
clientState.reset()} + > + + + + + + {nameInstruction} + + ({ + value: String(student.id), + label: student.name, + }))} + values={userId !== null ? [String(userId)] : []} + setValues={onSelectName} + /> + + {nameChosen && isPicture && ( +
+ + {pictureInstruction} + +
    + {secretPictures.map(picture => ( +
  • + +
  • + ))} +
+
+ )} + + {nameChosen && isWord && ( +
+ + {wordInstruction} + + setSecretWords(e.target.value)} + autoComplete="off" + className={styles.wordInput} + /> +
+ )} + + {showPairing && ( + setPairing(e.target.checked)} + label={pairProgramming} + /> + )} + + {nameChosen && ( + + {loginLabel} + + )} + +
+ ); +} + +SectionSignIn.propTypes = { + submitPath: PropTypes.string.isRequired, + authenticityToken: PropTypes.string.isRequired, + welcome: PropTypes.string.isRequired, + nameInstruction: PropTypes.string.isRequired, + pictureInstruction: PropTypes.string, + wordInstruction: PropTypes.string, + pairProgramming: PropTypes.string.isRequired, + loginLabel: PropTypes.string.isRequired, + students: PropTypes.arrayOf( + PropTypes.shape({id: PropTypes.number, name: PropTypes.string}) + ).isRequired, + secretPictures: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + path: PropTypes.string, + name: PropTypes.string, + }) + ), + loginType: PropTypes.string.isRequired, + loginTypePicture: PropTypes.string.isRequired, + loginTypeWord: PropTypes.string.isRequired, + pairingAllowed: PropTypes.bool, +}; diff --git a/apps/src/templates/sections/section-sign-in.module.scss b/apps/src/templates/sections/section-sign-in.module.scss new file mode 100644 index 0000000000000..8e86e3d73f590 --- /dev/null +++ b/apps/src/templates/sections/section-sign-in.module.scss @@ -0,0 +1,54 @@ +.container { + max-width: 960px; +} + +.form { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1rem; +} + +.tiles { + list-style-type: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +// Picture-login tiles: image thumbnails. +.tile { + border: 1px solid var(--borders-neutral-primary); + border-radius: 5px; + background-color: var(--background-neutral-secondary); + padding: 5px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: var(--background-neutral-tertiary); + } + + img { + display: block; + } +} + +.tileSelected, +.tileSelected:hover { + background-color: var(--background-brand-teal-light); +} + +.secret { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.wordInput { + max-width: 320px; +} diff --git a/dashboard/app/assets/stylesheets/application.scss b/dashboard/app/assets/stylesheets/application.scss index 23e5b2c852d3e..6d9216a71aee2 100644 --- a/dashboard/app/assets/stylesheets/application.scss +++ b/dashboard/app/assets/stylesheets/application.scss @@ -1865,46 +1865,6 @@ a.download-video { margin: 10px 0; } -#signinsection { - ul.students, ul.pictures { - margin-left: 0; - } - - ul.students li, - ul.pictures li { - list-style-type: none; - border: 1px solid $lighter_gray; - border-radius: 5px; - background-color: $lightest_gray; - float: left; - margin: 5px; - padding: 5px; - cursor: pointer; - } - - ul.students li:hover, - ul.pictures li:hover { - background-color: $light_yellow; - } - - ul.students li.selected, - ul.pictures li.selected { - background-color: $yellow; - } - - #pairing_checkbox { - margin: 5px 0; - label { - font-size: 17.5px; - } - label, input { - margin: 5px; - display: inline-block; - vertical-align: middle; - } - } -} - .flex-container { display: flex; justify-content: space-between; diff --git a/dashboard/app/views/sections/show.html.haml b/dashboard/app/views/sections/show.html.haml index e9cf79e885007..25911bb27d6d1 100644 --- a/dashboard/app/views/sections/show.html.haml +++ b/dashboard/app/views/sections/show.html.haml @@ -1,65 +1,29 @@ - @page_title = @section.name :ruby + picture_login = @section.login_type == Section::LOGIN_TYPE_PICTURE section_data = { + submitPath: log_in_section_path(id: @section.code), + authenticityToken: form_authenticity_token, + welcome: t('signinsection.welcome', section_name: @section.name), + nameInstruction: "#{t('signinsection.name')}*", + pictureInstruction: t('signinsection.picture'), + wordInstruction: t('signinsection.words'), + pairProgramming: t('signinsection.pair_programming'), + loginLabel: t('signinsection.login'), loginType: @section.login_type, loginTypePicture: Section::LOGIN_TYPE_PICTURE, loginTypeWord: Section::LOGIN_TYPE_WORD, - pairingAllowed: @section.pairing_allowed + pairingAllowed: @section.pairing_allowed, + students: @section.name_safe_students.map {|student| {id: student.id, name: student.name}}, + secretPictures: picture_login ? @secret_pictures.map {|p| {id: p.id, path: image_path(p.path), name: p.name}} : [] }.to_json %script{src: webpack_asset_path('js/sections/show.js'), data: {section: section_data}} -#signinsection - %h2 - =t('signinsection.welcome', section_name: @section.name) +#section-sign-in - %div#user - %h4.instructions - = succeed '*' do - = t('signinsection.name') - %ul.students - - @section.name_safe_students.each do |student| - %li{id: student.id} - = student.name - .clear - - = form_tag(log_in_section_path(id: @section.code), class: 'section-user-sign-in') do - = hidden_field_tag :secret_picture_id - = hidden_field_tag :user_id - - - if @section.login_type == Section::LOGIN_TYPE_PICTURE - #secret{style: 'display: none;'} - %h4.instructions= t('signinsection.picture') - %ul.pictures - - @secret_pictures.each do |secret_picture| - %li{id: secret_picture.id} - = image_tag secret_picture.path, width: 66, alt: secret_picture.name - .clear - - - if @section.login_type == Section::LOGIN_TYPE_WORD - #secret{style: 'display: none;'} - %h4.instructions= t('signinsection.words') - = text_field_tag :secret_words, nil, autocomplete: 'off' - .clear - - #pairing_checkbox{style: 'display: none'} - = check_box_tag :show_pairing_dialog - = label_tag :show_pairing_dialog, t('signinsection.pair_programming') - - = button_tag t('signinsection.login'), - id: 'login_button', - style: 'display:none', - class: 'btn btn-primary', - data: {disable_with: I18n.t('signinsection.signing_in')} - - %br/ - %br/ - %div.span{ :style => "margin-left: auto"} - != "* " - %span - != t('signinsection.student_privacy_markdown', student_privacy_blog: 'https://code.org/privacy', markdown: true) - -%br/ -%br/ -%br/ +%div.span{style: 'margin-left: auto'} + != "* " +%span + != t('signinsection.student_privacy_markdown', student_privacy_blog: 'https://code.org/privacy', markdown: true)