Skip to content

Port login page to Preact #9589

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 30, 2025
Merged
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
2 changes: 1 addition & 1 deletion gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ gulp.task('build-legacy-css', () =>
);

gulp.task('build-tailwind-css', () =>
buildCSS(['./h/static/styles/group-forms.css'], { tailwindConfig }),
buildCSS(['./h/static/styles/forms.css'], { tailwindConfig }),
);

gulp.task('build-css', gulp.parallel('build-legacy-css', 'build-tailwind-css'));
Expand Down
8 changes: 6 additions & 2 deletions h/assets.ini
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,15 @@ help_page_css =
styles/icomoon.css
styles/help-page.css

group_forms_css =
styles/group-forms.css
# Styles for Tailwind / Preact-based apps
forms_css =
styles/forms.css

group_forms_js =
scripts/group-forms.bundle.js

login_forms_js =
scripts/login-forms.bundle.js

orcid_css =
styles/orcid.css
26 changes: 25 additions & 1 deletion h/static/scripts/group-forms/components/forms/TextField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Input, Textarea } from '@hypothesis/frontend-shared';
import { useId, useState } from 'preact/hooks';

import ErrorNotice from '../ErrorNotice';
import Label from './Label';

function CharacterCounter({
Expand Down Expand Up @@ -29,6 +30,12 @@ export type TextFieldProps = {
/** The DOM element to render. */
type?: 'input' | 'textarea';

/** The type of input element, e.g., "text", "password", etc. */
inputType?: 'text' | 'password';

/** Name of the input field. */
name?: string;

/** Current value of the input. */
value: string;

Expand All @@ -55,11 +62,17 @@ export type TextFieldProps = {
/** True if this is a required field. */
required?: boolean;

/** True if the required indicator should be shown next to the label. */
showRequired?: boolean;

/** True if the field should be automatically focused on first render. */
autofocus?: boolean;

/** Additional classes to apply to the input element. */
classes?: string;

/** Optional error message for the field. */
fieldError?: string;
};

/**
Expand All @@ -68,14 +81,18 @@ export type TextFieldProps = {
*/
export default function TextField({
type = 'input',
inputType,
value,
onChangeValue,
minLength = 0,
maxLength,
label,
required = false,
showRequired = required,
autofocus = false,
classes = '',
name,
fieldError = '',
}: TextFieldProps) {
const id = useId();
const [hasCommitted, setHasCommitted] = useState(false);
Expand All @@ -99,7 +116,7 @@ export default function TextField({

return (
<div>
<Label htmlFor={id} text={label} required={required} />
<Label htmlFor={id} text={label} required={showRequired} />
<InputComponent
id={id}
onInput={handleInput}
Expand All @@ -110,6 +127,8 @@ export default function TextField({
autofocus={autofocus}
autocomplete="off"
required={required}
name={name}
type={inputType}
/>
{typeof maxLength === 'number' && (
<CharacterCounter
Expand All @@ -118,6 +137,11 @@ export default function TextField({
error={Boolean(error)}
/>
)}
{fieldError && !hasCommitted && (
<div className="mt-1">
<ErrorNotice message={fieldError} />
</div>
)}
</div>
);
}
44 changes: 44 additions & 0 deletions h/static/scripts/login-forms/components/AppRoot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useMemo } from 'preact/hooks';
import { Route, Switch } from 'wouter-preact';

import Router from '../../group-forms/components/Router';
import type { ConfigObject } from '../config';
import { Config } from '../config';
import { routes } from '../routes';
import LoginForm from './LoginForm';

export type AppRootProps = {
config: ConfigObject;
};

export default function AppRoot({ config }: AppRootProps) {
const stylesheetLinks = useMemo(
() =>
config.styles.map((stylesheetURL, index) => (
<link
key={`${stylesheetURL}${index}`}
rel="stylesheet"
href={stylesheetURL}
/>
)),
[config],
);

return (
<>
{stylesheetLinks}
<Config.Provider value={config}>
<Router>
<Switch>
<Route path={routes.login}>
<LoginForm />
</Route>
<Route>
<h1 data-testid="unknown-route">Page not found</h1>
</Route>
</Switch>
</Router>
</Config.Provider>
</>
);
}
64 changes: 64 additions & 0 deletions h/static/scripts/login-forms/components/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Button } from '@hypothesis/frontend-shared';
import { useContext, useState } from 'preact/hooks';

import FormContainer from '../../group-forms/components/forms/FormContainer';
import TextField from '../../group-forms/components/forms/TextField';
import { Config } from '../config';
import { routes } from '../routes';

export default function LoginForm() {
const config = useContext(Config)!;

const [username, setUsername] = useState(config.formData?.username ?? '');
const [password, setPassword] = useState(config.formData?.password ?? '');

return (
<FormContainer>
<form
action={routes.login}
method="POST"
data-testid="form"
className="max-w-[530px] mx-auto flex flex-col gap-y-4"
>
<input type="hidden" name="csrf_token" value={config.csrfToken} />
<TextField
type="input"
name="username"
value={username}
fieldError={config.formErrors?.username ?? ''}
onChangeValue={setUsername}
label="Username / email"
autofocus
required
showRequired={false}
/>
<TextField
type="input"
inputType="password"
name="password"
value={password}
fieldError={config.formErrors?.password ?? ''}
onChangeValue={setPassword}
label="Password"
required
showRequired={false}
/>
<div className="text-right">
<a
href={routes.forgotPassword}
className="text-grey-5 text-sm underline"
data-testid="forgot-password-link"
>
Forgot your password?
</a>
</div>
<div className="mb-8 pt-2 flex items-center gap-x-4">
<div className="grow" />
<Button type="submit" variant="primary" data-testid="button">
Log in
</Button>
</div>
</form>
</FormContainer>
);
}
85 changes: 85 additions & 0 deletions h/static/scripts/login-forms/components/test/AppRoot-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { checkAccessibility, mount } from '@hypothesis/frontend-testing';
import { useContext } from 'preact/hooks';

import { Config } from '../../config';
import { $imports, default as AppRoot } from '../AppRoot';

describe('AppRoot', () => {
let configContext;

const config = { styles: [] };

beforeEach(() => {
const mockComponent = name => {
function MockRoute() {
configContext = useContext(Config);
return null;
}
MockRoute.displayName = name;
return MockRoute;
};

configContext = null;

$imports.$mock({
'./LoginForm': mockComponent('LoginForm'),
});
});

afterEach(() => {
$imports.$restore();
});

function createComponent() {
return mount(<AppRoot config={config} />);
}

it('renders style links', () => {
config.styles = ['/static/styles/foo.css'];

const links = createComponent().find('link');

assert.equal(links.length, 1);
assert.equal(links.at(0).prop('rel'), 'stylesheet');
assert.equal(links.at(0).prop('href'), '/static/styles/foo.css');
});

/** Navigate to `path`, run `callback` and then reset the location. */
function navigate(path, callback) {
history.pushState({}, null, path);
try {
callback();
} finally {
history.back();
}
}

it('passes config to route', () => {
navigate('/login', () => {
createComponent();

assert.strictEqual(configContext, config);
});
});

[
{
path: '/Login',
selector: 'LoginForm',
},
].forEach(({ path, selector }) => {
it(`renders expected component for URL (${path})`, () => {
navigate(path, () => {
const wrapper = createComponent();
const component = wrapper.find(selector);

assert.isTrue(component.exists());
});
});
});

it(
'should pass a11y checks',
checkAccessibility({ content: createComponent }),
);
});
Loading
Loading