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
1 change: 1 addition & 0 deletions src/__fixtures__/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import { Settings } from 'common/models/settings';
export const settingsFixture: Settings = {
allowNotifications: true,
brightness: 0,
language: 'en',
};
20 changes: 20 additions & 0 deletions src/common/components/Input/SelectInput.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.ls-input-select-wrapper {
width: 100%;

display: flex;
flex-direction: column;

ion-select {
&.ion-invalid {
color: var(--ion-color-danger);
}
}

.ls-input-select-error {
padding: 0.375rem 0 0.375rem 0;

border-top-color: var(--ion-color-danger);
border-top-width: 1px;
border-top-style: solid;
}
}
66 changes: 66 additions & 0 deletions src/common/components/Input/SelectInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { IonSelect, IonText, SelectCustomEvent } from '@ionic/react';
import { ComponentPropsWithoutRef } from 'react';
import { useField } from 'formik';
import classNames from 'classnames';

import './SelectInput.scss';
import { PropsWithTestId } from '../types';

/**
* Properties for the `SelectInput` component.
* @see {@link PropsWithTestId}
* @see {@link IonSelect}
*/
interface SelectInputProps
extends PropsWithTestId,
Omit<ComponentPropsWithoutRef<typeof IonSelect>, 'name'>,
Required<Pick<ComponentPropsWithoutRef<typeof IonSelect>, 'name'>> {}

/**
* The `SelectInput` component renders a standardized wrapper of the `IonSelect`
* component which is integrated with Formik.
*
* Accepts a collection of `IonSelectOption` components as `children`.
*
* @param {SelectInputProps} props - Component properties.
* @returns {JSX.Element} JSX
*/
const SelectInput = ({
className,
name,
onIonChange,
testid = 'input-select',
...selectProps
}: SelectInputProps): JSX.Element => {
const [field, meta, helpers] = useField(name);

const onChange = async (e: SelectCustomEvent) => {
await helpers.setValue(e.detail.value);
onIonChange?.(e);
};

return (
<div className="ls-input-select-wrapper">
<IonSelect
className={classNames(
'ls-input-select',
className,
{ 'ion-touched': meta.touched },
{ 'ion-invalid': meta.error },
{ 'ion-valid': meta.touched && !meta.error },
)}
onIonChange={onChange}
data-testid={testid}
{...field}
{...selectProps}
></IonSelect>
{meta.error && (
<IonText color="danger" className="ls-input-select-error text-xs font-normal">
{meta.error}
</IonText>
)}
</div>
);
};

export default SelectInput;
68 changes: 68 additions & 0 deletions src/common/components/Input/__tests__/SelectInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { IonSelectOption } from '@ionic/react';
import { Form, Formik } from 'formik';
import userEvent from '@testing-library/user-event';

import { render, screen, waitFor } from 'test/test-utils';

import SelectInput from '../SelectInput';

describe('SelectInput', () => {
it('should render successfully', async () => {
// ARRANGE
render(
<Formik initialValues={{ selectInput: '' }} onSubmit={() => {}}>
<Form>
<SelectInput name="selectInput" testid="input">
<IonSelectOption value="a">Able</IonSelectOption>
</SelectInput>
</Form>
</Formik>,
);
await screen.findByTestId('input');

// ASSERT
expect(screen.getByTestId('input')).toBeDefined();
});

it('should change value', async () => {
// ARRANGE
const value = 'a';
let submittedValue = '';
render(
<Formik
initialValues={{ selectInput: value }}
onSubmit={(values) => {
submittedValue = values.selectInput;
}}
>
{(formikProps) => (
<Form>
<SelectInput
name="selectInput"
interface="popover"
testid="input"
onIonChange={() => formikProps.submitForm()}
>
<IonSelectOption value="a">Alpha</IonSelectOption>
<IonSelectOption value="b">Bravo</IonSelectOption>
</SelectInput>
</Form>
)}
</Formik>,
);
await screen.findByTestId('input');

// ACT
// open the select
await userEvent.click(screen.getByTestId('input'));
await waitFor(() => expect(screen.getAllByRole('radio').length).toBe(2));
// select the second option
await userEvent.click(screen.getAllByRole('radio')[1]);
await waitFor(() => expect(submittedValue).toBe('b'));

// ASSERT
expect(screen.getByTestId('input')).toBeDefined();
expect(submittedValue).toBe('b');
});
});
1 change: 1 addition & 0 deletions src/common/models/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
export type Settings = {
allowNotifications: boolean;
brightness: number;
language: string;
};
19 changes: 19 additions & 0 deletions src/common/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,23 @@ export enum StorageKey {
export const DEFAULT_SETTINGS: Settings = {
allowNotifications: true,
brightness: 50,
language: 'en',
};

/**
* Available languages.
*/
export const LANGUAGES = [
{
code: 'en',
value: 'English',
},
{
code: 'es',
value: 'Spanish',
},
{
code: 'fr',
value: 'French',
},
];
31 changes: 27 additions & 4 deletions src/pages/Account/components/Settings/SettingsForm.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { IonItem, IonLabel, IonListHeader } from '@ionic/react';
import { IonItem, IonLabel, IonListHeader, IonSelectOption } from '@ionic/react';
import classNames from 'classnames';
import { Form, Formik } from 'formik';
import { boolean, number, object } from 'yup';
import { boolean, number, object, string } from 'yup';
import orderBy from 'lodash/orderBy';
import map from 'lodash/map';

import { useGetSettings } from 'common/api/useGetSettings';
import { BaseComponentProps } from 'common/components/types';
import { LANGUAGES } from 'common/utils/constants';
import { Settings } from 'common/models/settings';
import { useGetSettings } from 'common/api/useGetSettings';
import { useUpdateSettings } from 'common/api/useUpdateSettings';
import { useProgress } from 'common/hooks/useProgress';
import { useToasts } from 'common/hooks/useToasts';
Expand All @@ -15,19 +18,21 @@ import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton';
import List from 'common/components/List/List';
import RangeInput from 'common/components/Input/RangeInput';
import Icon, { IconName } from 'common/components/Icon/Icon';
import SelectInput from 'common/components/Input/SelectInput';

/**
* Settings form values.
* @see {@link Settings}
*/
type SettingsFormValues = Pick<Settings, 'allowNotifications' | 'brightness'>;
type SettingsFormValues = Pick<Settings, 'allowNotifications' | 'brightness' | 'language'>;

/**
* Settings form validation schema.
*/
const validationSchema = object<SettingsFormValues>({
allowNotifications: boolean(),
brightness: number().min(0).max(100),
language: string().oneOf(map(LANGUAGES, 'code')),
});

/**
Expand Down Expand Up @@ -70,6 +75,7 @@ const SettingsForm = ({
initialValues={{
allowNotifications: settings.allowNotifications,
brightness: settings.brightness,
language: settings.language,
}}
onSubmit={(values, { setSubmitting }) => {
setProgress(true);
Expand Down Expand Up @@ -130,6 +136,23 @@ const SettingsForm = ({
<Icon icon={IconName.Plus} slot="end" />
</RangeInput>
</IonItem>

<IonItem className="text-sm font-medium">
<SelectInput
name="language"
label="Language"
interface="popover"
disabled={isSubmitting}
onIonChange={() => submitForm()}
testid={`${testid}-field-language`}
>
{orderBy(LANGUAGES, ['value']).map((language) => (
<IonSelectOption key={language.code} value={language.code}>
{language.value}
</IonSelectOption>
))}
</SelectInput>
</IonItem>
</List>
</Form>
)}
Expand Down