Skip to content
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
69 changes: 68 additions & 1 deletion src/components/Form/StringField/StringField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,52 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import { StringField } from './StringField.tsx';

import type { StringFieldComboBoxProps } from './StringFieldComboBox.tsx';
import type { PasswordStrengthValue } from './StringFieldPassword.tsx';
import type { PasswordStrengthValue, StringFieldPasswordProps } from './StringFieldPassword.tsx';

type Story = StoryObj<typeof StringField>;

/** Typed to the combobox props so `allowCustomValue`, which the other variants lack, is readable in the decorator */
type ComboBoxStory = StoryObj<StringFieldComboBoxProps>;

/** Typed to the password props so `generatePassword`, which the other variants lack, may be passed */
type PasswordStory = StoryObj<StringFieldPasswordProps>;

/** Exactly 32 words, so that reducing a random byte modulo the list length stays uniform */
const PASSPHRASE_WORDS = [
'anchor',
'bramble',
'cinder',
'cobalt',
'dapple',
'drift',
'ember',
'fathom',
'flint',
'gable',
'garnet',
'harbor',
'hollow',
'ivory',
'jasper',
'kindle',
'lantern',
'marble',
'meadow',
'nectar',
'opal',
'pebble',
'quartz',
'ripple',
'saffron',
'thistle',
'timber',
'umber',
'velvet',
'willow',
'yonder',
'zephyr'
];

export default { component: StringField } as Meta<typeof StringField>;

export const Short: Story = {
Expand Down Expand Up @@ -98,6 +137,34 @@ export const PasswordWithStrength: Story = {
]
};

export const PasswordWithPassphraseGenerator: PasswordStory = {
decorators: [
(Story) => {
const [value, setValue] = useState<string | undefined>();
return (
<Story
args={{
calculateStrength: (password: string) => {
return Math.min(password.length, 4) as PasswordStrengthValue;
},
description: 'Lorem ipsum dolor sit amet consectetur adipisicing elit.',
generatePassword: () =>
Array.from(
crypto.getRandomValues(new Uint8Array(4)),
(byte) => PASSPHRASE_WORDS[byte % PASSPHRASE_WORDS.length]
).join('-'),
label: 'Password',
name: 'text',
setValue,
value,
variant: 'password'
}}
/>
);
}
]
};

export const Select: Story = {
decorators: [
(Story) => {
Expand Down
68 changes: 68 additions & 0 deletions src/components/Form/StringField/StringFieldPassword.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useState } from 'react';

import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';

import { StringFieldPassword } from './StringFieldPassword.tsx';

import type { StringFieldPasswordProps } from './StringFieldPassword.tsx';

const TestStringFieldPassword = ({
generatePassword,
readOnly
}: Pick<StringFieldPasswordProps, 'generatePassword' | 'readOnly'>) => {
const [error, setError] = useState<string[] | undefined>();
const [value, setValue] = useState<string | undefined>();
return (
<StringFieldPassword
error={error}
generatePassword={generatePassword}
kind="string"
label="password-field"
name="password-field"
readOnly={readOnly}
setError={setError}
setValue={setValue}
value={value}
variant="password"
/>
);
};

describe('StringFieldPassword', () => {
const getInput = () => screen.getByLabelText<HTMLInputElement>('password-field');
const getGenerateButton = () => screen.getByRole('button', { name: 'Generate Passphrase' });

it('should not render the generate button when generatePassword is not provided', () => {
render(<TestStringFieldPassword />);
expect(() => getGenerateButton()).toThrow();
});

it('should fill the field with the generated passphrase when the button is clicked', async () => {
render(<TestStringFieldPassword generatePassword={() => 'hunter2'} />);
await userEvent.click(getGenerateButton());
expect(getInput()).toHaveValue('hunter2');
});

it('should reveal the generated passphrase', async () => {
render(<TestStringFieldPassword generatePassword={() => 'hunter2'} />);
expect(getInput()).toHaveAttribute('type', 'password');
await userEvent.click(getGenerateButton());
expect(getInput()).toHaveAttribute('type', 'text');
});

it('should allow reaching the generate button with the keyboard', async () => {
render(<TestStringFieldPassword generatePassword={() => 'hunter2'} />);
getInput().focus();
await userEvent.tab();
expect(getGenerateButton()).toHaveFocus();
await userEvent.keyboard('{Enter}');
expect(getInput()).toHaveValue('hunter2');
});

it('should disable the generate button when the field is read-only', () => {
render(<TestStringFieldPassword readOnly generatePassword={() => 'hunter2'} />);
expect(getGenerateButton()).toBeDisabled();
});
});
42 changes: 39 additions & 3 deletions src/components/Form/StringField/StringFieldPassword.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react';

import type { StringFormField } from '@douglasneuroinformatics/libui-form-types';
import { EyeIcon, EyeOffIcon } from 'lucide-react';
import { EyeIcon, EyeOffIcon, SparklesIcon } from 'lucide-react';
import { motion } from 'motion/react';

import { Input, Label } from '#components';
import { Input, Label, Tooltip } from '#components';
import { useTranslation } from '#hooks';
import { cn } from '#utils';

import { FieldGroup } from '../FieldGroup/FieldGroup.tsx';
Expand All @@ -14,13 +15,23 @@ import type { BaseFieldComponentProps } from '../types.ts';
export type PasswordStrengthValue = 0 | 1 | 2 | 3 | 4;

export type StringFieldPasswordProps = BaseFieldComponentProps<string> &
Extract<StringFormField, { variant: 'password' }>;
Extract<StringFormField, { variant: 'password' }> & {
/**
* A function used to generate a passphrase for this field. When provided, a button is rendered
* in the input that fills the field with the returned value and reveals it.
*
* Declared here rather than coming from `StringFormField`, and should be removed once the
* form types package publishes it on the password variant.
*/
generatePassword?: (this: void) => string;
};

export const StringFieldPassword = ({
calculateStrength,
description,
disabled,
error,
generatePassword,
label,
name,
readOnly,
Expand All @@ -29,6 +40,7 @@ export const StringFieldPassword = ({
}: StringFieldPasswordProps) => {
const [strength, setStrength] = useState<null | PasswordStrengthValue>(calculateStrength ? 0 : null);
const [show, setShow] = useState(false);
const { t } = useTranslation();
useEffect(() => {
if (calculateStrength) {
setStrength(value ? calculateStrength(value) : 0);
Expand All @@ -43,13 +55,37 @@ export const StringFieldPassword = ({
</FieldGroup.Row>
<FieldGroup.Row>
<Input
className={cn(generatePassword ? 'pr-10' : 'pr-8')}
disabled={disabled || readOnly}
id={name}
name={name}
type={show ? 'text' : 'password'}
value={value ?? ''}
onChange={(event) => setValue(event.target.value)}
/>
{generatePassword && (
<Tooltip>
<Tooltip.Trigger
aria-label={t({ en: 'Generate Passphrase', fr: 'Générer une phrase de passe' })}
// `p-0` neutralizes the padding the button size variant adds, so the geometry matches
// the adjacent toggle rather than squeezing the icon inside the fixed width.
className="text-muted-foreground absolute right-8 flex h-full w-8 items-center justify-center p-0"
disabled={disabled || readOnly}
type="button"
variant="ghost"
onClick={() => {
setValue(generatePassword());
// A generated passphrase the user cannot read is of little use, so reveal it.
setShow(true);
}}
>
<SparklesIcon />
</Tooltip.Trigger>
<Tooltip.Content>
<p>{t({ en: 'Generate a passphrase', fr: 'Générer une phrase de passe' })}</p>
</Tooltip.Content>
</Tooltip>
)}
<button
className="text-muted-foreground absolute right-0 flex h-full w-8 items-center justify-center"
disabled={disabled || readOnly}
Expand Down
Loading