Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions apps/src/signIn/SectionCodeEntry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import TextField from '@code-dot-org/component-library/textField';
import {Button as MuiButton} from '@mui/material';
import React, {useState} from 'react';

import style from './signInStyles.module.scss';

export interface SectionCodeEntryProps {
// Field label, e.g. "Enter your 6 letter section code". Rendered as the
// TextField's own label so it matches the sign-in fields' labels exactly
// (same size, weight, padding, and top alignment).
sectionCodeLabel: string;
sectionCodePlaceholder: string;
defaultSectionCode: string;
goLabel: string;
// Where the GET form submits (student_user_new_path -> /users/new).
formAction: string;
}

const SectionCodeEntry: React.FunctionComponent<SectionCodeEntryProps> = ({
sectionCodeLabel,
sectionCodePlaceholder,
defaultSectionCode,
goLabel,
formAction,
}) => {
const [sectionCode, setSectionCode] = useState(defaultSectionCode || '');

return (
<div className={style.sectionCode}>
<form action={formAction} method="get" className={style.sectionCodeForm}>
<TextField
id="section_code"
className={style.sectionCodeField}
name="section_code"
label={sectionCodeLabel}
placeholder={sectionCodePlaceholder}
value={sectionCode}
onChange={e => setSectionCode(e.target.value)}
/>
<MuiButton
id="section_code_submit"
variant="contained"
color="primary"
type="submit"
>
{goLabel}
</MuiButton>
</form>
</div>
);
};

export default SectionCodeEntry;
152 changes: 152 additions & 0 deletions apps/src/signIn/SignInForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import Link from '@code-dot-org/component-library/link';
import TextField from '@code-dot-org/component-library/textField';
import {Button as MuiButton} from '@mui/material';
import React, {useEffect, useState} from 'react';

import RailsAuthenticityToken from '@cdo/apps/lib/util/RailsAuthenticityToken';
import {EVENTS} from '@cdo/apps/metrics/AnalyticsConstants';
import analyticsReporter from '@cdo/apps/metrics/AnalyticsReporter';
import {USER_RETURN_TO_SESSION_KEY} from '@cdo/apps/signUpFlow/signUpFlowConstants';

import style from './signInStyles.module.scss';

// The field names below (user[login], user[password], user[hashed_email])
// match what the Rails controller expects.

// These ids double as the DOM hooks the UI (Cucumber) tests drive:
// #signin (wrapper), #user_login, #user_password, #signin-button.
const LOGIN_FIELD_ID = 'user_login';
const PASSWORD_FIELD_ID = 'user_password';

export interface SignInFormProps {
// hashed_email is populated server-side (on failed-login re-render) and
// threaded through untouched -- sign-in never hashes the email client-side.
hashedEmail: string;
// Pre-populated login value (from @email), if any.
loginValue: string;
// Form action from the server (session_path) so regional/localized sign-in
// routes (e.g. /fa/users/sign_in) are preserved.
signInPath: string;
loginLabel: string;
passwordLabel: string;
signInLabel: string;
signUpLabel: string;
signUpPath: string;
// Whether the "Sign up" button should render (devise_mapping.registerable?).
showSignUp: boolean;
// Devise password-reset path; absent when the mapping is not recoverable.
forgotPasswordPath?: string;
forgotPasswordLabel?: string;
// Written to sessionStorage on mount when present, before any redirect.
userReturnTo?: string | null;
}

const SignInForm: React.FunctionComponent<SignInFormProps> = ({
hashedEmail,
loginValue,
signInPath,
loginLabel,
passwordLabel,
signInLabel,
signUpLabel,
signUpPath,
showSignUp,
forgotPasswordPath,
forgotPasswordLabel,
userReturnTo,
}) => {
const [login, setLogin] = useState(loginValue || '');
const [password, setPassword] = useState('');

// Match the legacy autofocus behavior: focus the login field when there is
// no pre-filled email, otherwise focus the password field. Done via an
// effect rather than the autoFocus prop (which jsx-a11y disallows).
const emailPrefilled = (loginValue || '') !== '';

useEffect(() => {
if (userReturnTo) {
sessionStorage.setItem(USER_RETURN_TO_SESSION_KEY, userReturnTo);
}
}, [userReturnTo]);

useEffect(() => {
const id = emailPrefilled ? PASSWORD_FIELD_ID : LOGIN_FIELD_ID;
document.getElementById(id)?.focus();
}, [emailPrefilled]);

return (
<div id="signin" className={style.signInColumn}>
<div className={style.formArea}>
{/*
Do NOT set id="new_user" on this form. devise/sessions/new.js hooks
form#new_user's submit to run window.dashboard.hashEmail against
#user_hashed_email (which this React form doesn't have), which breaks
the native POST and login never completes. The region-versioned action
is verified server-side via the mount's data-sign-in-path instead.
*/}
<form action={signInPath} method="post" className={style.form}>
<RailsAuthenticityToken />
<input type="hidden" name="user[hashed_email]" value={hashedEmail} />

<TextField
id={LOGIN_FIELD_ID}
className={style.field}
name="user[login]"
label={loginLabel}
value={login}
onChange={e => setLogin(e.target.value)}
autoComplete="username"
/>
<TextField
id={PASSWORD_FIELD_ID}
className={style.field}
name="user[password]"
label={passwordLabel}
inputType="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
/>

{forgotPasswordPath && forgotPasswordLabel && (
<Link
className={style.forgotPassword}
href={forgotPasswordPath}
type="secondary"
size="s"
>
{forgotPasswordLabel}
</Link>
)}

<MuiButton
id="signin-button"
variant="contained"
color="primary"
type="submit"
>
{signInLabel}
</MuiButton>
</form>

{showSignUp && (
<MuiButton
variant="outlined"
color="secondary"
href={signUpPath}
onClick={() =>
analyticsReporter.sendEvent(
EVENTS.LOGIN_PAGE_CREATE_ACCOUNT_CLICKED,
{}
)
}
>
{signUpLabel}
</MuiButton>
)}
</div>
</div>
);
};

export default SignInForm;
59 changes: 59 additions & 0 deletions apps/src/signIn/signInStyles.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Layout for the sign-in page. The DSCO/MUI components own their own colors
// through semantic tokens, so this module owns layout only -- there are no raw
// hex values or legacy SCSS color variables here.

.signInColumn {
display: flex;
// Match the legacy #signin min-width so the sign-in and OAuth columns split
// the row evenly. Without it the OAuth column (flex-grow: 2, uncapped) grows
// to fill the extra space and dwarfs the sign-in form.
flex-grow: 2;
min-width: 450px;
}

.formArea {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}

.form {
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0;
}

.field {
width: 100%;

input {
width: 100%;
}
}

.forgotPassword {
align-self: flex-start;
}

.sectionCode {
display: flex;
flex-direction: column;
gap: 0.5rem;
}

.sectionCodeForm {
display: flex;
align-items: flex-end;
gap: 0.5rem;
margin: 0;
}

.sectionCodeField {
flex-grow: 1;

input {
width: 100%;
}
}
64 changes: 56 additions & 8 deletions apps/src/sites/studio/pages/devise/sessions/_login.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,69 @@
import {Typography} from '@mui/material';
import $ from 'jquery';
import React from 'react';

import {EVENTS} from '@cdo/apps/metrics/AnalyticsConstants';
import analyticsReporter from '@cdo/apps/metrics/AnalyticsReporter';
import {USER_RETURN_TO_SESSION_KEY} from '@cdo/apps/signUpFlow/signUpFlowConstants';
import getScriptData from '@cdo/apps/util/getScriptData';
import SectionCodeEntry from '@cdo/apps/signIn/SectionCodeEntry';
import SignInForm from '@cdo/apps/signIn/SignInForm';
import {createReactRoot} from '@cdo/apps/util/createReactRoot';

$(document).ready(() => {
const userReturnTo = getScriptData('userReturnTo');
// Page title (full-width, above both columns). Rendered as MUI Typography so
// it matches the design system rather than the legacy global <h2> styles.
const titleMount = document.getElementById('sign-in-title');
if (titleMount) {
createReactRoot(
<Typography variant="h2" component="h2" gutterBottom>
{titleMount.dataset.title}
</Typography>,
titleMount,
{legacyReactDomRender: true}
);
}

if (userReturnTo) {
sessionStorage.setItem(USER_RETURN_TO_SESSION_KEY, userReturnTo);
const signInMount = document.getElementById('sign-in-page-layout');
if (signInMount) {
const data = signInMount.dataset;
createReactRoot(
<SignInForm
hashedEmail={data.hashedEmail || ''}
loginValue={data.loginValue || ''}
signInPath={data.signInPath}
loginLabel={data.loginLabel}
passwordLabel={data.passwordLabel}
signInLabel={data.signInLabel}
signUpLabel={data.signUpLabel}
signUpPath={data.signUpPath}
showSignUp={data.showSignUp === 'true'}
forgotPasswordPath={data.forgotPasswordPath || undefined}
forgotPasswordLabel={data.forgotPasswordLabel}
userReturnTo={data.userReturnTo || null}
/>,
signInMount,
{legacyReactDomRender: true}
);
}

document.getElementById('user_signup').addEventListener('click', () => {
analyticsReporter.sendEvent(EVENTS.LOGIN_PAGE_CREATE_ACCOUNT_CLICKED, {});
});
// Only present on the sessions (sign-in) page.
const sectionCodeMount = document.getElementById('section-code-entry-mount');
if (sectionCodeMount) {
const data = sectionCodeMount.dataset;
createReactRoot(
<SectionCodeEntry
sectionCodeLabel={data.sectionCodeLabel}
sectionCodePlaceholder={data.sectionCodePlaceholder}
defaultSectionCode={data.defaultSectionCode || ''}
goLabel={data.goLabel}
formAction={data.formAction}
/>,
sectionCodeMount,
{legacyReactDomRender: true}
);
}

// Course blocks remain server-rendered HAML for now, so keep their
// click analytics wiring here until they are migrated separately.
const courseBlocks = document.querySelectorAll('.courseblock-tall');
courseBlocks.forEach(courseBlock => {
const courseTitle = courseBlock.querySelector('h3').textContent;
Expand Down
46 changes: 46 additions & 0 deletions apps/test/unit/signIn/SectionCodeEntryTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {render, screen} from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import SectionCodeEntry, {
SectionCodeEntryProps,
} from '@cdo/apps/signIn/SectionCodeEntry';

const DEFAULT_PROPS: SectionCodeEntryProps = {
sectionCodeLabel: 'Enter your 6 letter section code',
sectionCodePlaceholder: 'Section Code (ABCDEF)',
defaultSectionCode: '',
goLabel: 'Go',
formAction: '/users/new',
};

describe('SectionCodeEntry', () => {
function renderEntry(overrides: Partial<SectionCodeEntryProps> = {}) {
return render(<SectionCodeEntry {...DEFAULT_PROPS} {...overrides} />);
}

it('renders the labeled section-code input and Go button', () => {
renderEntry();

expect(
screen.getByRole('textbox', {name: DEFAULT_PROPS.sectionCodeLabel})
).toHaveAttribute('name', 'section_code');
screen.getByRole('button', {name: DEFAULT_PROPS.goLabel});
});

it('pre-populates the input with defaultSectionCode when provided', () => {
renderEntry({defaultSectionCode: 'ABCDEF'});
expect(
screen.getByRole('textbox', {name: DEFAULT_PROPS.sectionCodeLabel})
).toHaveValue('ABCDEF');
});

it('submits via GET to the provided form action', () => {
renderEntry({formAction: '/users/new'});
const form = screen
.getByRole('button', {name: DEFAULT_PROPS.goLabel})
.closest('form');
expect(form).toHaveAttribute('action', '/users/new');
expect(form).toHaveAttribute('method', 'get');
});
});
Loading
Loading