Skip to content

Commit

Permalink
Extract form field components into separate modules
Browse files Browse the repository at this point in the history
Extract the `CharacterCounter`, `Label`, `TextField` and `Star` components into
separate modules for future re-use and so they can have their own tests. In the
process add a default value for the `type` prop and make `maxLength` optional so
there are fewer required props for common use cases.

Change the existing CreateEditGroupForm tests to operate on the exposed API of
`TextField` rather than its implementation details.
  • Loading branch information
robertknight committed Oct 15, 2024
1 parent 649506e commit 1e280b6
Show file tree
Hide file tree
Showing 6 changed files with 279 additions and 271 deletions.
122 changes: 3 additions & 119 deletions h/static/scripts/group-forms/components/CreateEditGroupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { useEffect, useId, useMemo, useState } from 'preact/hooks';
import {
Button,
CancelIcon,
Input,
RadioGroup,
Textarea,
ModalDialog,
useWarnOnPageUnload,
} from '@hypothesis/frontend-shared';
Expand All @@ -18,124 +16,12 @@ import type {
} from '../utils/api';
import { pluralize } from '../utils/pluralize';
import { setLocation } from '../utils/set-location';
import Star from './forms/Star';
import Label from './forms/Label';
import TextField from './forms/TextField';
import SaveStateIcon from './SaveStateIcon';
import WarningDialog from './WarningDialog';

function Star() {
return <span className="text-brand">*</span>;
}

function CharacterCounter({
value,
limit,
testid,
error = false,
}: {
value: number;
limit: number;
testid: string;
error?: boolean;
}) {
return (
<div className="flex">
<div className="grow" />
<span
data-testid={testid}
className={error ? 'text-red-error font-bold' : undefined}
>
{value}/{limit}
</span>
</div>
);
}

function Label({
id,
htmlFor,
text,
required,
}: {
id?: string;
htmlFor?: string;
text: string;
required?: boolean;
}) {
return (
<label className="font-bold" id={id} htmlFor={htmlFor}>
{text}
{required && <Star />}
</label>
);
}

function TextField({
type,
value,
onChangeValue,
minLength = 0,
maxLength,
label,
testid,
required = false,
autofocus = false,
classes = '',
}: {
type: 'input' | 'textarea';
value: string;
onChangeValue: (newValue: string) => void;
minLength?: number;
maxLength: number;
label: string;
testid: string;
required?: boolean;
autofocus?: boolean;
classes?: string;
}) {
const id = useId();
const [hasCommitted, setHasCommitted] = useState(false);

const handleInput = (e: InputEvent) => {
onChangeValue((e.target as HTMLInputElement).value);
};

const handleChange = (e: Event) => {
setHasCommitted(true);
};

let error = '';
if ([...value].length > maxLength) {
error = `Must be ${maxLength} characters or less.`;
} else if ([...value].length < minLength && hasCommitted) {
error = `Must be ${minLength} characters or more.`;
}

const InputComponent = type === 'input' ? Input : Textarea;

return (
<div className="mb-4">
<Label htmlFor={id} text={label} required={required} />
<InputComponent
id={id}
onInput={handleInput}
onChange={handleChange}
error={error}
value={value}
classes={classes}
autofocus={autofocus}
autocomplete="off"
required={required}
data-testid={testid}
/>
<CharacterCounter
value={[...value].length}
limit={maxLength}
testid={`charcounter-${testid}`}
error={Boolean(error)}
/>
</div>
);
}

/**
* Dialog that warns users about existing annotations in a group being exposed
* or hidden from public view when the group type is changed.
Expand Down Expand Up @@ -347,7 +233,6 @@ export default function CreateEditGroupForm() {
minLength={3}
maxLength={25}
label="Name"
testid="name"
autofocus
required
/>
Expand All @@ -357,7 +242,6 @@ export default function CreateEditGroupForm() {
onChangeValue={handleChangeDescription}
maxLength={250}
label="Description"
testid="description"
classes="h-24"
/>

Expand Down
25 changes: 25 additions & 0 deletions h/static/scripts/group-forms/components/forms/Label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Star from './Star';

/**
* A label for a form field.
*
* This includes an indicator for whether the field is required or not.
*/
export default function Label({
id,
htmlFor,
text,
required,
}: {
id?: string;
htmlFor?: string;
text: string;
required?: boolean;
}) {
return (
<label className="font-bold" id={id} htmlFor={htmlFor}>
{text}
{required && <Star />}
</label>
);
}
6 changes: 6 additions & 0 deletions h/static/scripts/group-forms/components/forms/Star.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* A star icon denoting a required input field.
*/
export default function Star() {
return <span className="text-brand">*</span>;
}
124 changes: 124 additions & 0 deletions h/static/scripts/group-forms/components/forms/TextField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { useId, useState } from 'preact/hooks';
import { Input, Textarea } from '@hypothesis/frontend-shared';

import Label from './Label';
import Star from './Star';

function CharacterCounter({
value,
limit,
error = false,
}: {
value: number;
limit: number;
error?: boolean;
}) {
return (
<div className="flex">
<div className="grow" />
<span
data-testid="char-counter"
className={error ? 'text-red-error font-bold' : undefined}
>
{value}/{limit}
</span>
</div>
);
}

export type TextFieldProps = {
/** The DOM element to render. */
type?: 'input' | 'textarea';

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

/** Callback invoked when the field's value is changed. */
onChangeValue: (newValue: string) => void;

/**
* Minimum number of characters that this field must have.
*
* This is a count of Unicode code points, not UTF-16 code units.
*/
minLength?: number;

/**
* Maximum number of characters that may be entered.
*
* This is a count of Unicode code points, not UTF-16 code units.
*/
maxLength?: number;

/** Text for the label shown next to the field. */
label: string;

/** True if this is a required field. */
required?: boolean;

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

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

/**
* A single or multi-line text field with an associated label and optional
* character limit indicator.
*/
export default function TextField({
type = 'input',
value,
onChangeValue,
minLength = 0,
maxLength,
label,
required = false,
autofocus = false,
classes = '',
}: TextFieldProps) {
const id = useId();
const [hasCommitted, setHasCommitted] = useState(false);

const handleInput = (e: InputEvent) => {
onChangeValue((e.target as HTMLInputElement).value);
};

const handleChange = (e: Event) => {
setHasCommitted(true);
};

let error = '';
if (typeof maxLength === 'number' && [...value].length > maxLength) {
error = `Must be ${maxLength} characters or less.`;
} else if ([...value].length < minLength && hasCommitted) {
error = `Must be ${minLength} characters or more.`;
}

const InputComponent = type === 'input' ? Input : Textarea;

return (
<div className="mb-4">
<Label htmlFor={id} text={label} required={required} />
<InputComponent
id={id}
onInput={handleInput}
onChange={handleChange}
error={error}
value={value}
classes={classes}
autofocus={autofocus}
autocomplete="off"
required={required}
/>
{typeof maxLength === 'number' && (
<CharacterCounter
value={[...value].length}
limit={maxLength}
error={Boolean(error)}
/>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { mount } from 'enzyme';

import TextField from '../TextField';

function unicodeLen(value) {
return [...value].length;
}

describe('TextField', () => {
[
{
value: '',
minLength: 0,
maxLength: 10,
error: null,
},
{
value: 'abc',
minLength: 0,
maxLength: 10,
error: null,
},
// Too few characters
{
value: 'a',
minLength: 2,
maxLength: 10,
error: 'Must be 2 characters or more.',
},
// Too many characters
{
value: 'abc',
minLength: 0,
maxLength: 2,
error: 'Must be 2 characters or less.',
},
].forEach(({ value, minLength, maxLength, error }) => {
it('displays character count and sets field error', () => {
const wrapper = mount(
<TextField value={value} minLength={minLength} maxLength={maxLength} />,
);

// The "too few characters" message is not shown until a change has
// been committed in the field.
wrapper.find('input').simulate('change');

const expectError = error !== null;

const count = wrapper.find('[data-testid="char-counter"]');
const expectedText = `${unicodeLen(value)}/${maxLength}`;
assert.equal(count.text(), expectedText);
assert.equal(count.hasClass('text-red-error'), expectError);
assert.equal(wrapper.find('Input').prop('error'), error ?? '');
});
});

it('invokes callback when text is entered', () => {
const onChange = sinon.stub();
const wrapper = mount(<TextField value="" onChangeValue={onChange} />);

wrapper.find('input').getDOMNode().value = 'foo';
wrapper.find('input').simulate('input');

assert.calledWith(onChange, 'foo');
});

it('defers checking for too few characters until first commit', () => {
const wrapper = mount(<TextField value="" minLength={5} />);

// Don't warn about too few characters before the user attempts to enter
// an initial value.
assert.equal(wrapper.find('Input').prop('error'), '');

wrapper.find('input').simulate('change');

assert.equal(
wrapper.find('Input').prop('error'),
'Must be 5 characters or more.',
);
});
});
Loading

0 comments on commit 1e280b6

Please sign in to comment.