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
158 changes: 158 additions & 0 deletions src/components/form/Lookup/Language/LanguageField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { MockedProvider } from '@apollo/client/testing';
import type { MockedResponse } from '@apollo/client/testing';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Form } from 'react-final-form';
import { LanguageField } from './LanguageField';
import { LanguageLookupDocument } from './LanguageLookup.graphql';

jest.mock('../../../Session', () => ({
useSession: () => ({ powers: [] }),
}));

jest.mock('../../../../scenes/Languages/Create', () => ({
CreateLanguage: () => null,
}));

// ─── Helpers ──────────────────────────────────────────────────────────────────

const makeLang = (overrides?: object) => ({
__typename: 'Language',
id: 'lang-1',
name: { __typename: 'SecuredString', value: 'English' },
displayName: { __typename: 'SecuredString', value: 'English' },
ethnologue: {
__typename: 'EthnologueLanguage',
code: { __typename: 'SecuredStringNullable', value: 'eng' },
},
registryOfLanguageVarietiesCode: {
__typename: 'SecuredStringNullable',
value: 'lv12345',
},
...overrides,
});

const searchMock = (query: string, items = [makeLang()]): MockedResponse => ({
request: { query: LanguageLookupDocument, variables: { query } },
result: { data: { search: { __typename: 'SearchOutput', items } } },
});

const setup = (mocks: readonly MockedResponse[] = []) => {
render(
<MockedProvider mocks={mocks}>
<Form
onSubmit={() => {
// noop
}}
>
{({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<LanguageField name="language" label="Language" />
</form>
)}
</Form>
</MockedProvider>
);
return screen.getByRole('combobox');
};

// Focuses the input and changes its value in one shot, triggering a single
// Apollo query rather than one per character (as userEvent.type would do).
const search = (input: HTMLElement, query: string) => {
fireEvent.focus(input);
fireEvent.change(input, { target: { value: query } });
};

// ─── Tests ────────────────────────────────────────────────────────────────────

describe('LanguageField', () => {
it('renders the input', () => {
const input = setup();
expect(input).toBeInTheDocument();
});

describe('column headers', () => {
it('shows ETH and ROLV headers when the dropdown opens', async () => {
const input = setup([searchMock('Engl')]);
search(input, 'Engl');

await waitFor(() => {
expect(screen.getByText('ETH')).toBeInTheDocument();
expect(screen.getByText('ROLV')).toBeInTheDocument();
});
});
});

describe('option rows', () => {
it('shows the language name', async () => {
const input = setup([searchMock('Engl')]);
search(input, 'Engl');

await waitFor(() => {
expect(screen.getByText('English')).toBeInTheDocument();
});
});

it('shows the ETH code', async () => {
const input = setup([searchMock('Engl')]);
search(input, 'Engl');

await waitFor(() => {
expect(screen.getByText('eng')).toBeInTheDocument();
});
});

it('shows the ROLV code', async () => {
const input = setup([searchMock('Engl')]);
search(input, 'Engl');

await waitFor(() => {
expect(screen.getByText('lv12345')).toBeInTheDocument();
});
});

it('falls back to displayName when name is null', async () => {
const lang = makeLang({
name: { __typename: 'SecuredString', value: null },
displayName: {
__typename: 'SecuredString',
value: 'Display-Only Name',
},
});
const input = setup([searchMock('Disp', [lang])]);
search(input, 'Disp');

await waitFor(() => {
expect(screen.getByText('Display-Only Name')).toBeInTheDocument();
});
});

it('shows all results with their respective codes', async () => {
// Both names contain 'Engl' so MUI's client-side filter passes them through
const langs = [
makeLang(),
makeLang({
id: 'lang-2',
name: { __typename: 'SecuredString', value: 'English Creole' },
displayName: { __typename: 'SecuredString', value: 'English Creole' },
ethnologue: {
__typename: 'EthnologueLanguage',
code: { __typename: 'SecuredStringNullable', value: 'cpe' },
},
registryOfLanguageVarietiesCode: {
__typename: 'SecuredStringNullable',
value: 'lv99999',
},
}),
];
const input = setup([searchMock('Engl', langs)]);
search(input, 'Engl');

await waitFor(() => {
expect(screen.getByText('eng')).toBeInTheDocument();
expect(screen.getByText('lv12345')).toBeInTheDocument();
expect(screen.getByText('cpe')).toBeInTheDocument();
expect(screen.getByText('lv99999')).toBeInTheDocument();
});
});
});
});
79 changes: 79 additions & 0 deletions src/components/form/Lookup/Language/LanguageField.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Box, Paper, PaperProps } from '@mui/material';
import { CreateLanguage as CreateLanguageType } from '~/api/schema.graphql';
import { CreateLanguage } from '../../../../scenes/Languages/Create';
import { LanguageFormValues } from '../../../../scenes/Languages/LanguageForm';
Expand All @@ -7,6 +8,34 @@ import {
LanguageLookupDocument,
} from './LanguageLookup.graphql';

const ETH_COLUMN_WIDTH = 48; // includes right padding
const ROLV_COLUMN_WIDTH = 72; // includes right padding

const LanguageDropdownPaper = (props: PaperProps) => (
<Paper {...props} sx={{ ...props.sx, minWidth: 480 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
px: 2,
py: 0.5,
borderBottom: 1,
borderColor: 'divider',
typography: 'caption',
color: 'text.secondary',
userSelect: 'none',
}}
>
<Box sx={{ flex: 1 }}>Language</Box>
<Box sx={{ width: ETH_COLUMN_WIDTH, textAlign: 'right' }}>ETH</Box>
<Box sx={{ width: ROLV_COLUMN_WIDTH, textAlign: 'right', ml: 1 }}>
ROLV
</Box>
</Box>
{props.children}
</Paper>
);

export const LanguageField = LookupField.createFor<
Language,
LanguageFormValues<CreateLanguageType>
Expand All @@ -18,4 +47,54 @@ export const LanguageField = LookupField.createFor<
getOptionLabel: (option) => option.name.value ?? option.displayName.value,
CreateDialogForm: CreateLanguage,
getInitialValues: (name) => ({ name, displayName: name }),
PaperComponent: LanguageDropdownPaper,
renderOption: (props, option) => (
<li {...props}>
{typeof option === 'string' ? (
`Create "${option}"`
) : (
<Box
sx={{
display: 'flex',
alignItems: 'center',
width: '100%',
overflow: 'hidden',
}}
>
<Box
sx={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{option.name.value ?? option.displayName.value}
</Box>
<Box
sx={{
width: ETH_COLUMN_WIDTH,
textAlign: 'right',
color: 'text.secondary',
typography: 'body2',
}}
>
{option.ethnologue.code.value}
</Box>
<Box
sx={{
width: ROLV_COLUMN_WIDTH,
textAlign: 'right',
ml: 1,
color: 'text.secondary',
typography: 'body2',
}}
>
{option.registryOfLanguageVarietiesCode.value}
</Box>
</Box>
)}
</li>
),
});
8 changes: 8 additions & 0 deletions src/components/form/Lookup/Language/LanguageLookup.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,12 @@ fragment LanguageLookupItem on Language {
displayName {
value
}
ethnologue {
code {
value
}
}
registryOfLanguageVarietiesCode {
value
}
}
19 changes: 12 additions & 7 deletions src/components/form/Lookup/LookupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function LookupField<
createPower,
margin,
initialOptions: initial,
renderOption: renderOptionOverride,
...props
}: LookupFieldProps<T, Multiple, DisableClearable, CreateFormValues>) {
const { powers } = useSession();
Expand Down Expand Up @@ -228,13 +229,17 @@ export function LookupField<
options={options}
getOptionLabel={getOptionLabel}
freeSolo={freeSolo}
renderOption={(props, option, _ownerState) => (
<li {...props}>
{typeof option === 'string'
? `Create "${option}"`
: getOptionLabel(option)}
</li>
)}
renderOption={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(renderOptionOverride as any) ??
((props, option, _ownerState) => (
<li {...props}>
{typeof option === 'string'
? `Create "${option}"`
: getOptionLabel(option)}
</li>
))
}
filterOptions={(options, params) => {
// Apply default filtering. Even though the API filters for us, we add
// the currently selected options back in because they are still valid
Expand Down
Loading