diff --git a/src/components/form/Lookup/Language/LanguageLookup.graphql b/src/components/form/Lookup/Language/LanguageLookup.graphql index d784371f29..df327b704b 100644 --- a/src/components/form/Lookup/Language/LanguageLookup.graphql +++ b/src/components/form/Lookup/Language/LanguageLookup.graphql @@ -16,4 +16,13 @@ fragment LanguageLookupItem on Language { displayName { value } + ethnologue { + code { + value + } + } + registryOfLanguageVarietiesCode { + value + } + } diff --git a/src/components/form/Lookup/LookupField.test.tsx b/src/components/form/Lookup/LookupField.test.tsx new file mode 100644 index 0000000000..837925c8c4 --- /dev/null +++ b/src/components/form/Lookup/LookupField.test.tsx @@ -0,0 +1,427 @@ +/** + * Unit tests for pure logic extracted from LookupField.tsx. + * + * The component itself requires Apollo, useSession, useField, etc., so we + * follow the same extraction pattern used in form-error-handling.test.ts and + * CreateLanguageEngagement.test.tsx: pull each logical unit into a plain + * function and test it in isolation. + */ + +/* eslint-disable react/display-name */ +import { render } from '@testing-library/react'; +import { isEqualBy, isListEqualBy } from '../util'; +import { + applyFilterOptionsCustomLogic, + computeIsOpen, + computeLookupDisplayName, + mergeOptions, + resolveOptionContent, +} from './LookupField'; + +// --------------------------------------------------------------------------- +// Helpers shared across test suites +// --------------------------------------------------------------------------- + +interface Item { + id: string; + label: string; +} + +const makeItem = (id: string, label = `Item ${id}`): Item => ({ id, label }); + +const compareById = (item: Item) => item.id; + +// --------------------------------------------------------------------------- +// getOptionLabel +// --------------------------------------------------------------------------- + +/** + * Recreates the closure inside LookupField: + * const getOptionLabel = (val: T | string) => + * typeof val === 'string' ? val : getOptionLabelProp(val) ?? ''; + */ +const makeGetOptionLabel = + (getOptionLabelProp: (option: Item) => string | null | undefined) => + (val: Item | string): string => + typeof val === 'string' ? val : getOptionLabelProp(val) ?? ''; + +describe('getOptionLabel', () => { + const getOptionLabel = makeGetOptionLabel((item) => item.label); + + it('returns string values as-is (freeSolo path)', () => { + expect(getOptionLabel('raw input')).toBe('raw input'); + }); + + it('returns the label for a typed option', () => { + expect(getOptionLabel(makeItem('1', 'English'))).toBe('English'); + }); + + it('falls back to empty string when getOptionLabelProp returns null', () => { + const fn = makeGetOptionLabel(() => null); + expect(fn(makeItem('1'))).toBe(''); + }); + + it('falls back to empty string when getOptionLabelProp returns undefined', () => { + const fn = makeGetOptionLabel(() => undefined); + expect(fn(makeItem('1'))).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// selectedText +// --------------------------------------------------------------------------- + +/** + * Recreates: + * const selectedText = multiple || !(field.value as T | '') + * ? '' + * : getOptionLabel(field.value as T); + */ +const computeSelectedText = ( + multiple: boolean, + value: Item | null | undefined, + getOptionLabel: (val: Item | string) => string +): string => (multiple || !value ? '' : getOptionLabel(value)); + +describe('selectedText', () => { + const getOptionLabel = makeGetOptionLabel((item) => item.label); + + it('is empty string when multiple=true even with a value', () => { + expect( + computeSelectedText(true, makeItem('1', 'English'), getOptionLabel) + ).toBe(''); + }); + + it('is empty string when value is null', () => { + expect(computeSelectedText(false, null, getOptionLabel)).toBe(''); + }); + + it('is empty string when value is undefined', () => { + expect(computeSelectedText(false, undefined, getOptionLabel)).toBe(''); + }); + + it('returns the option label when single-select and value is set', () => { + expect( + computeSelectedText(false, makeItem('1', 'Spanish'), getOptionLabel) + ).toBe('Spanish'); + }); +}); + +// --------------------------------------------------------------------------- +// open logic (computeIsOpen from LookupField) +// --------------------------------------------------------------------------- + +describe('open logic', () => { + it('is false when field is not active', () => { + expect(computeIsOpen(false, 'Eng', '', false)).toBe(false); + }); + + it('is true when active and input differs from selectedText (searching)', () => { + expect(computeIsOpen(true, 'Eng', '', false)).toBe(true); + }); + + it('is false when active but input equals selectedText (item already selected)', () => { + expect(computeIsOpen(true, 'English', 'English', false)).toBe(false); + }); + + it('is true when active and input is empty but initialOptions exist', () => { + expect(computeIsOpen(true, '', '', true)).toBe(true); + }); + + it('is false when active and input is empty and no initialOptions', () => { + expect(computeIsOpen(true, '', '', false)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// options merging (mergeOptions from LookupField) +// --------------------------------------------------------------------------- + +describe('options merging', () => { + it('returns only selected items when there are no search results or initial items', () => { + const item = makeItem('1', 'English'); + const result = mergeOptions({ + multiple: false, + value: item, + compareBy: compareById, + }); + expect(result).toEqual([item]); + }); + + it('returns empty array when no value, results, or initial items', () => { + const result = mergeOptions({ + multiple: false, + value: null, + compareBy: compareById, + }); + expect(result).toEqual([]); + }); + + it('merges search results with selected item', () => { + const selected = makeItem('1', 'English'); + const fromSearch = makeItem('2', 'Spanish'); + const result = mergeOptions({ + multiple: false, + value: selected, + searchResults: [fromSearch], + compareBy: compareById, + }); + expect(result.map((i) => i.id)).toEqual(['2', '1']); + }); + + it('deduplicates selected item that also appears in search results', () => { + const item = makeItem('1', 'English'); + const result = mergeOptions({ + multiple: false, + value: item, + searchResults: [item, makeItem('2', 'Spanish')], + compareBy: compareById, + }); + // id '1' should appear only once + const ids = result.map((i) => i.id); + expect(ids.filter((id) => id === '1')).toHaveLength(1); + }); + + it('merges initial items when no search results', () => { + const initial = [makeItem('1'), makeItem('2')]; + const result = mergeOptions({ + multiple: false, + value: null, + initialItems: initial, + compareBy: compareById, + }); + expect(result.map((i) => i.id)).toEqual(['1', '2']); + }); + + it('supports multiple selection: all selected items are included', () => { + const selected = [makeItem('1'), makeItem('2')]; + const result = mergeOptions({ + multiple: true, + value: selected, + compareBy: compareById, + }); + expect(result.map((i) => i.id)).toEqual(['1', '2']); + }); + + it('deduplicates across search results and initial items', () => { + const shared = makeItem('1', 'English'); + const result = mergeOptions({ + multiple: false, + value: null, + searchResults: [shared, makeItem('2')], + initialItems: [shared, makeItem('3')], + compareBy: compareById, + }); + const ids = result.map((i) => i.id); + expect(ids.filter((id) => id === '1')).toHaveLength(1); + expect(ids).toContain('2'); + expect(ids).toContain('3'); + }); +}); + +// --------------------------------------------------------------------------- +// filterOptions — sort + freeSolo append (applyFilterOptionsCustomLogic from LookupField) +// --------------------------------------------------------------------------- +// +// MUI's own createFilterOptions text-filtering is intentionally not re-tested here. + +describe('filterOptions custom logic', () => { + const getOptionLabel = makeGetOptionLabel((item) => item.label); + + it('passes options through untouched when freeSolo is false', () => { + const options = [makeItem('1', 'English'), makeItem('2', 'Spanish')]; + const result = applyFilterOptionsCustomLogic({ + options, + inputValue: 'NewLang', + freeSolo: false, + getOptionLabel, + }); + expect(result).toEqual(options); + }); + + it('appends raw inputValue as a string when freeSolo and no exact match', () => { + const options = [makeItem('1', 'English')]; + const result = applyFilterOptionsCustomLogic({ + options, + inputValue: 'NewLang', + freeSolo: true, + getOptionLabel, + }); + expect(result[result.length - 1]).toBe('NewLang'); + }); + + it('does not append when an exact label match exists', () => { + const options = [makeItem('1', 'English')]; + const result = applyFilterOptionsCustomLogic({ + options, + inputValue: 'English', + freeSolo: true, + getOptionLabel, + }); + expect(result.every((o) => typeof o !== 'string')).toBe(true); + }); + + it('does not append when inputValue is empty', () => { + const options = [makeItem('1', 'English')]; + const result = applyFilterOptionsCustomLogic({ + options, + inputValue: '', + freeSolo: true, + getOptionLabel, + }); + expect(result.every((o) => typeof o !== 'string')).toBe(true); + }); + + it('does not append when search is loading', () => { + const options = [makeItem('1', 'English')]; + const result = applyFilterOptionsCustomLogic({ + options, + inputValue: 'NewLang', + freeSolo: true, + searchResultsLoading: true, + getOptionLabel, + }); + expect(result.every((o) => typeof o !== 'string')).toBe(true); + }); + + it('applies sortComparator before the freeSolo-append check', () => { + const sortComparator = (a: Item, b: Item) => + a.label.startsWith('Z') ? 1 : b.label.startsWith('Z') ? -1 : 0; + + const options = [ + makeItem('1', 'Zulu'), + makeItem('2', 'Amharic'), + makeItem('3', 'Bengali'), + ]; + const result = applyFilterOptionsCustomLogic({ + options, + inputValue: '', + sortComparator, + getOptionLabel, + }); + const labels = result.map((o) => (typeof o === 'string' ? o : o.label)); + expect(labels[labels.length - 1]).toBe('Zulu'); + }); + + it('sorts then appends freeSolo string at the very end', () => { + const sortComparator = (a: Item, b: Item) => + a.label.startsWith('Z') ? 1 : b.label.startsWith('Z') ? -1 : 0; + const options = [makeItem('1', 'Zulu'), makeItem('2', 'Amharic')]; + const result = applyFilterOptionsCustomLogic({ + options, + inputValue: 'NewLang', + freeSolo: true, + sortComparator, + getOptionLabel, + }); + // Sorted: Amharic, Zulu — then freeSolo string appended last + expect(result[result.length - 1]).toBe('NewLang'); + expect((result[result.length - 2] as Item).label).toBe('Zulu'); + }); +}); + +// --------------------------------------------------------------------------- +// renderOption content selection (resolveOptionContent from LookupField) +// --------------------------------------------------------------------------- + +describe('renderOption content', () => { + const getOptionLabel = makeGetOptionLabel((item) => item.label); + + it('shows Create "X" label for a string option (freeSolo new item)', () => { + const content = resolveOptionContent('NewLang', getOptionLabel); + expect(content).toBe('Create "NewLang"'); + }); + + it('delegates to renderOptionContent when provided', () => { + const renderOptionContent = (item: Item) => ( + {item.label} + ); + const item = makeItem('1', 'English'); + const { getByTestId } = render( + <> + {resolveOptionContent(item, getOptionLabel, renderOptionContent)} + + ); + expect(getByTestId('custom')).toHaveTextContent('English'); + }); + + it('falls back to getOptionLabel when renderOptionContent is not provided', () => { + const item = makeItem('1', 'Spanish'); + const content = resolveOptionContent(item, getOptionLabel); + expect(content).toBe('Spanish'); + }); +}); + +// --------------------------------------------------------------------------- +// LookupField.createFor — displayName convention (computeLookupDisplayName from LookupField) +// --------------------------------------------------------------------------- + +describe('LookupField.createFor displayName', () => { + it('produces Lookup(Language) for resource "Language"', () => { + expect(computeLookupDisplayName('Language')).toBe('Lookup(Language)'); + }); + + it('camelCases multi-word resources', () => { + expect(computeLookupDisplayName('field-region')).toBe( + 'Lookup(FieldRegion)' + ); + }); + + it('handles already-camelCased resource names', () => { + expect(computeLookupDisplayName('fieldZone')).toBe('Lookup(FieldZone)'); + }); +}); + +describe('isEqualBy', () => { + const isEqual = isEqualBy(compareById); + + it('returns true for two items with the same id', () => { + expect(isEqual(makeItem('1', 'English'), makeItem('1', 'Updated'))).toBe( + true + ); + }); + + it('returns false for two items with different ids', () => { + expect(isEqual(makeItem('1'), makeItem('2'))).toBe(false); + }); + + it('returns false when one value is null', () => { + expect(isEqual(makeItem('1'), null)).toBe(false); + }); +}); + +describe('isListEqualBy', () => { + const isListEqual = isListEqualBy(compareById); + + it('returns true for lists with the same ids in any order', () => { + expect( + isListEqual( + [makeItem('1'), makeItem('2')], + [makeItem('2'), makeItem('1')] + ) + ).toBe(true); + }); + + it('returns false when lists differ by one item', () => { + expect( + isListEqual( + [makeItem('1'), makeItem('2')], + [makeItem('1'), makeItem('3')] + ) + ).toBe(false); + }); + + it('returns false for lists of different lengths', () => { + expect(isListEqual([makeItem('1')], [makeItem('1'), makeItem('2')])).toBe( + false + ); + }); + + it('returns true for two empty lists', () => { + expect(isListEqual([], [])).toBe(true); + }); + + it('returns false when one list is null', () => { + expect(isListEqual([makeItem('1')], null)).toBe(false); + }); +}); diff --git a/src/components/form/Lookup/LookupField.tsx b/src/components/form/Lookup/LookupField.tsx index 99c425ec39..a8ed4c8d58 100644 --- a/src/components/form/Lookup/LookupField.tsx +++ b/src/components/form/Lookup/LookupField.tsx @@ -17,6 +17,7 @@ import { import { camelCase, last, uniqBy, upperFirst } from 'lodash'; import { ComponentType, + ReactNode, useCallback, useEffect, useMemo, @@ -35,6 +36,97 @@ interface QueryResult { search: { items: ReadonlyArray }; } +// --------------------------------------------------------------------------- +// Pure helpers — exported for unit testing in isolation +// --------------------------------------------------------------------------- + +/** Returns true when the autocomplete popup should be open. */ +export const computeIsOpen = ( + active: boolean, + input: string, + selectedText: string, + hasInitialOptions: boolean +): boolean => + active && + ((!!input && input !== selectedText) || (!input && hasInitialOptions)); + +/** Merges search results, initial items, and current selection, deduplicating by compareBy. */ +export const mergeOptions = ({ + multiple, + value, + searchResults, + initialItems, + compareBy, +}: { + multiple: boolean; + value: T | readonly T[] | null; + searchResults?: readonly T[]; + initialItems?: readonly T[]; + compareBy: (item: T) => unknown; +}): T[] => { + const selected = multiple + ? Array.isArray(value) + ? value.slice() + : ([] as T[]) + : value + ? [value as T] + : []; + + if (!searchResults?.length && !initialItems?.length) { + return selected; + } + + return uniqBy( + [...(searchResults ?? []), ...(initialItems ?? []), ...selected], + compareBy + ); +}; + +/** Applies caller-provided sort and optional freeSolo-append steps to an already-filtered option list. */ +export const applyFilterOptionsCustomLogic = ({ + options, + inputValue, + freeSolo = false, + searchResultsLoading = false, + sortComparator, + getOptionLabel, +}: { + options: T[]; + inputValue: string; + freeSolo?: boolean; + searchResultsLoading?: boolean; + sortComparator?: (a: T, b: T) => number; + getOptionLabel: (val: T | string) => string; +}): Array => { + const sorted = sortComparator ? [...options].sort(sortComparator) : options; + + if ( + !freeSolo || + searchResultsLoading || + inputValue === '' || + sorted.map(getOptionLabel).includes(inputValue) + ) { + return sorted; + } + + return [...sorted, inputValue]; +}; + +/** Resolves what content to render inside a single Autocomplete option
  • . */ +export const resolveOptionContent = ( + option: T | string, + getOptionLabel: (val: T | string) => string, + renderOptionContent?: (option: T) => ReactNode +): ReactNode => { + if (typeof option === 'string') return `Create "${option}"`; + if (renderOptionContent) return renderOptionContent(option); + return getOptionLabel(option); +}; + +/** Derives the displayName used by LookupField.createFor. */ +export const computeLookupDisplayName = (resource: string): string => + `Lookup(${upperFirst(camelCase(resource))})`; + export type LookupFieldProps< T, Multiple extends boolean | undefined, @@ -49,6 +141,7 @@ export type LookupFieldProps< 'helperText' | 'label' | 'autoFocus' | 'variant' | 'margin' > & { lookupDocument: DocumentNode, { query: string }>; + initialOptions?: { options?: readonly T[] }; ChipProps?: ChipProps; CreateDialogForm?: ComponentType< Except, 'onSubmit'> @@ -57,7 +150,10 @@ export type LookupFieldProps< getInitialValues?: (val: string) => Partial; getOptionLabel: (option: T) => string | null | undefined; createPower?: Power; - initialOptions?: { options?: readonly T[] }; + /** Render the content inside each option's
  • element instead of the default label. */ + renderOptionContent?: (option: T) => ReactNode; + /** Sort visible options after filtering. Already-engaged items can be pushed to the bottom. */ + sortComparator?: (a: T, b: T) => number; } & Except< AutocompleteProps, | 'value' @@ -82,6 +178,7 @@ export function LookupField< multiple, defaultValue, lookupDocument, + initialOptions, ChipProps, autoFocus, helperText, @@ -95,7 +192,8 @@ export function LookupField< variant, createPower, margin, - initialOptions: initial, + renderOptionContent, + sortComparator, ...props }: LookupFieldProps) { const { powers } = useSession(); @@ -143,7 +241,7 @@ export function LookupField< }); // Not just for the first load, but every network request const searchResultsLoading = isNetworkRequestInFlight(networkStatus); - const initialOptionsLoading = initial && !initial.options; + const initialOptionsLoading = !!initialOptions && !initialOptions.options; const [createDialogState, createDialogItem, createInitialValues] = useDialog>(); @@ -169,34 +267,32 @@ export function LookupField< // Only open the popup if focused and // (searching for an item or have initial options). - const open = - !!meta.active && - ((input && input !== selectedText) || (!input && !!initial)); + const open = computeIsOpen( + !!meta.active, + input, + selectedText, + !!initialOptions + ); // Augment results with currently selected items to indicate that // they are still valid (and to prevent MUI warning) - const options = useMemo(() => { - const selected = multiple - ? (field.value as readonly T[]) - : (field.value as T | null) - ? [field.value as T] - : []; - const searchResults = data?.search.items; - const initialItems = initial?.options; - - if (!searchResults?.length && !initialItems?.length) { - return selected; // optimization for no results - } - - const merged = [ - ...(searchResults ?? []), - ...(initialItems ?? []), - ...selected, - ]; - - // Filter out duplicates caused by selected items also appearing in search results. - return uniqBy(merged, compareBy); - }, [data?.search.items, initial?.options, field.value, compareBy, multiple]); + const options = useMemo( + () => + mergeOptions({ + multiple: !!multiple, + value: field.value as T | readonly T[] | null, + searchResults: data?.search.items, + initialItems: initialOptions?.options, + compareBy, + }), + [ + data?.search.items, + initialOptions?.options, + field.value, + compareBy, + multiple, + ] + ); const autocomplete = ( @@ -230,39 +326,23 @@ export function LookupField< freeSolo={freeSolo} renderOption={(props, option, _ownerState) => (
  • - {typeof option === 'string' - ? `Create "${option}"` - : getOptionLabel(option)} + {resolveOptionContent(option, getOptionLabel, renderOptionContent)}
  • )} 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 - // but we don't want to show these options if the don't match the - // current input text. - // Note: `filterSelectedOptions` could still make sense either way - // separate from this code below. It could be thought of as a "stricter" - // filter that not only removes unrelated results but also related - // results that have already been selected. + // Apply default MUI text filtering first. Even though the API filters for + // us, we add selected options back and need to hide unrelated ones. const filtered = createFilterOptions()(options, params); - if ( - !freeSolo || - searchResultsLoading || // item could be returned with request in flight - params.inputValue === '' || - filtered.map(getOptionLabel).includes(params.inputValue) - ) { - return filtered; - } - - // If freeSolo is enabled and the input value doesn't match an existing - // or previously selected option, add it to the list. i.e. 'Add "X"'. - return [ - ...filtered, - // We want to allow strings for new options, - // which may differ from T. We handle them in renderOption. - params.inputValue as T, - ]; + // Apply sort + optional freeSolo-append via the extracted helper. + return applyFilterOptionsCustomLogic({ + options: filtered, + inputValue: params.inputValue, + freeSolo, + searchResultsLoading, + sortComparator, + getOptionLabel, + }) as T[]; }} // FF for some reason doesn't handle defaultValue correctly value={((field.value as Val | null) || meta.defaultValue) as Val} @@ -403,7 +483,7 @@ LookupField.createFor = < /> ); }; - Comp.displayName = `Lookup(${upperFirst(camelCase(resource))})`; + Comp.displayName = computeLookupDisplayName(resource); Comp.isEqual = isEqualBy(compareBy); Comp.isListEqual = isListEqualBy(compareBy); return Comp; diff --git a/src/scenes/Engagement/LanguageEngagement/Create/CreateLanguageEngagement.test.tsx b/src/scenes/Engagement/LanguageEngagement/Create/CreateLanguageEngagement.test.tsx new file mode 100644 index 0000000000..8715ef2e77 --- /dev/null +++ b/src/scenes/Engagement/LanguageEngagement/Create/CreateLanguageEngagement.test.tsx @@ -0,0 +1,388 @@ +import { render, screen } from '@testing-library/react'; +import { Form } from 'react-final-form'; +import type { LanguageLookupItem } from '../../../../components/form/Lookup'; +import { + createGetOptionDisabled, + createSortComparator, +} from './CreateLanguageEngagement'; + +/* eslint-disable react/display-name */ + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const makeLanguage = ( + id: string, + overrides: { + name?: string | null; + displayName?: string | null; + ethnologueCode?: string | null; + rolvCode?: string | null; + } = {} +): LanguageLookupItem => ({ + __typename: 'Language' as const, + id, + name: { + __typename: 'SecuredString', + value: overrides.name === undefined ? `Language ${id}` : overrides.name!, + }, + displayName: { + __typename: 'SecuredString', + value: + overrides.displayName === undefined + ? `Display ${id}` + : overrides.displayName!, + }, + ethnologue: { + __typename: 'EthnologueLanguage', + code: { + __typename: 'SecuredStringNullable', + value: + overrides.ethnologueCode === undefined + ? `eth${id}` + : overrides.ethnologueCode, + }, + }, + registryOfLanguageVarietiesCode: { + __typename: 'SecuredStringNullable', + value: overrides.rolvCode === undefined ? `rolv${id}` : overrides.rolvCode, + }, +}); + +// --------------------------------------------------------------------------- +// Sort comparator (createSortComparator from CreateLanguageEngagement) +// --------------------------------------------------------------------------- + +describe('sortComparator', () => { + const engaged = ['lang-1', 'lang-2']; + const sort = createSortComparator(engaged); + + it('returns 0 for two non-engaged languages', () => { + const a = makeLanguage('lang-3'); + const b = makeLanguage('lang-4'); + expect(sort(a, b)).toBe(0); + }); + + it('returns 0 for two engaged languages', () => { + const a = makeLanguage('lang-1'); + const b = makeLanguage('lang-2'); + expect(sort(a, b)).toBe(0); + }); + + it('sorts engaged language after non-engaged (a engaged)', () => { + const a = makeLanguage('lang-1'); // engaged + const b = makeLanguage('lang-3'); // not engaged + expect(sort(a, b)).toBeGreaterThan(0); + }); + + it('sorts engaged language after non-engaged (b engaged)', () => { + const a = makeLanguage('lang-3'); // not engaged + const b = makeLanguage('lang-1'); // engaged + expect(sort(a, b)).toBeLessThan(0); + }); + + it('is antisymmetric: sort(a,b) === -sort(b,a)', () => { + const engagedLang = makeLanguage('lang-1'); + const notEngaged = makeLanguage('lang-3'); + expect(sort(engagedLang, notEngaged)).toBe(-sort(notEngaged, engagedLang)); + }); + + it('uses the provided engagedLanguageIds at call time, not stale closure', () => { + const firstSort = createSortComparator(['lang-1']); + const secondSort = createSortComparator(['lang-2']); // different list + const a = makeLanguage('lang-1'); + const b = makeLanguage('lang-2'); + // With firstSort, lang-1 is engaged → goes after lang-2 + expect(firstSort(a, b)).toBeGreaterThan(0); + // With secondSort, lang-2 is engaged → goes after lang-1 + expect(secondSort(a, b)).toBeLessThan(0); + }); + + it('treats empty engaged list as all equal', () => { + const sortEmpty = createSortComparator([]); + const a = makeLanguage('lang-1'); + const b = makeLanguage('lang-2'); + expect(sortEmpty(a, b)).toBe(0); + }); + + it('stable-sorts a mixed list with engaged languages at the end', () => { + const sortFn = createSortComparator(['lang-2', 'lang-4']); + const langs = [ + makeLanguage('lang-1'), + makeLanguage('lang-2'), // engaged + makeLanguage('lang-3'), + makeLanguage('lang-4'), // engaged + ]; + const sorted = [...langs].sort(sortFn); + const ids = sorted.map((l) => l.id); + expect(ids.indexOf('lang-2')).toBeGreaterThan(ids.indexOf('lang-1')); + expect(ids.indexOf('lang-2')).toBeGreaterThan(ids.indexOf('lang-3')); + expect(ids.indexOf('lang-4')).toBeGreaterThan(ids.indexOf('lang-1')); + expect(ids.indexOf('lang-4')).toBeGreaterThan(ids.indexOf('lang-3')); + }); +}); + +// --------------------------------------------------------------------------- +// getOptionDisabled (createGetOptionDisabled from CreateLanguageEngagement) +// --------------------------------------------------------------------------- + +describe('getOptionDisabled', () => { + const engagedIds = ['lang-1', 'lang-2']; + const isDisabled = createGetOptionDisabled(engagedIds); + + it('returns true for an already-engaged language', () => { + expect(isDisabled(makeLanguage('lang-1'))).toBe(true); + }); + + it('returns true for a second engaged language', () => { + expect(isDisabled(makeLanguage('lang-2'))).toBe(true); + }); + + it('returns false for a non-engaged language', () => { + expect(isDisabled(makeLanguage('lang-3'))).toBe(false); + }); + + it('returns false when engagedLanguageIds is empty', () => { + const isDisabledEmpty = createGetOptionDisabled([]); + expect(isDisabledEmpty(makeLanguage('lang-1'))).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// renderOptionContent (re-implemented locally with data-testid attributes +// rather than CSS classes; tests structural/logic behaviour not styling) +// --------------------------------------------------------------------------- + +const makeRenderOptionContent = + (engagedLanguageIds: readonly string[]) => (option: LanguageLookupItem) => { + const row = ( + + + {option.name.value ?? option.displayName.value} + + + {option.ethnologue.code.value ?? '-'} + + + {option.registryOfLanguageVarietiesCode.value ?? '-'} + + + ); + + if (engagedLanguageIds.includes(option.id)) { + return ( + + {row} + + ); + } + return row; + }; + +describe('renderOptionContent', () => { + const engagedIds = ['lang-1']; + + it('renders name and codes for a non-engaged language', () => { + const renderOption = makeRenderOptionContent(engagedIds); + const lang = makeLanguage('lang-2', { + ethnologueCode: 'abc', + rolvCode: 'R123', + }); + const { getByTestId, queryByTestId } = render(<>{renderOption(lang)}); + + expect(getByTestId('option-name')).toHaveTextContent('Language lang-2'); + expect(getByTestId('option-eth')).toHaveTextContent('abc'); + expect(getByTestId('option-rolv')).toHaveTextContent('R123'); + expect(queryByTestId('engaged-tooltip-wrapper')).toBeNull(); + }); + + it('prefers name over displayName when both are set', () => { + const renderOption = makeRenderOptionContent([]); + const lang = makeLanguage('lang-6', { + name: 'Primary Name', + displayName: 'Fallback Name', + }); + const { getByTestId } = render(<>{renderOption(lang)}); + expect(getByTestId('option-name')).toHaveTextContent('Primary Name'); + expect(getByTestId('option-name')).not.toHaveTextContent('Fallback Name'); + }); + + it('falls back to displayName when name value is null', () => { + const renderOption = makeRenderOptionContent([]); + const lang = makeLanguage('lang-3', { + name: null, + displayName: 'My Display', + }); + const { getByTestId } = render(<>{renderOption(lang)}); + expect(getByTestId('option-name')).toHaveTextContent('My Display'); + }); + + it('renders empty option name when both name and displayName values are null', () => { + const renderOption = makeRenderOptionContent([]); + const lang = makeLanguage('lang-7', { name: null, displayName: null }); + const { getByTestId } = render(<>{renderOption(lang)}); + // Neither value is present; the span renders but is empty + expect(getByTestId('option-name').textContent).toBe(''); + }); + + it('falls back to "-" when ethnologue code is null', () => { + const renderOption = makeRenderOptionContent([]); + const lang = makeLanguage('lang-4', { ethnologueCode: null }); + const { getByTestId } = render(<>{renderOption(lang)}); + expect(getByTestId('option-eth')).toHaveTextContent('-'); + }); + + it('falls back to "-" when ROLV code is null', () => { + const renderOption = makeRenderOptionContent([]); + const lang = makeLanguage('lang-5', { rolvCode: null }); + const { getByTestId } = render(<>{renderOption(lang)}); + expect(getByTestId('option-rolv')).toHaveTextContent('-'); + }); + + it('wraps engaged language option in a tooltip indicator', () => { + const renderOption = makeRenderOptionContent(engagedIds); + const lang = makeLanguage('lang-1'); + const { getByTestId } = render(<>{renderOption(lang)}); + expect(getByTestId('engaged-tooltip-wrapper')).toBeInTheDocument(); + expect(getByTestId('option-row')).toBeInTheDocument(); + }); + + it('tooltip wrapper carries the "Already added to this project" title', () => { + const renderOption = makeRenderOptionContent(engagedIds); + const lang = makeLanguage('lang-1'); + const { getByTestId } = render(<>{renderOption(lang)}); + expect(getByTestId('engaged-tooltip-wrapper')).toHaveAttribute( + 'title', + 'Already added to this project' + ); + }); + + it('still renders codes inside the engaged tooltip wrapper', () => { + const renderOption = makeRenderOptionContent(engagedIds); + const lang = makeLanguage('lang-1', { + ethnologueCode: 'zzz', + rolvCode: 'R001', + }); + const { getByTestId } = render(<>{renderOption(lang)}); + expect(getByTestId('option-eth')).toHaveTextContent('zzz'); + expect(getByTestId('option-rolv')).toHaveTextContent('R001'); + }); + + it('does not wrap non-engaged language in a tooltip indicator', () => { + const renderOption = makeRenderOptionContent(engagedIds); + const lang = makeLanguage('lang-2'); + const { queryByTestId } = render(<>{renderOption(lang)}); + expect(queryByTestId('engaged-tooltip-wrapper')).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Helper text codes (FormContent via react-final-form context) +// --------------------------------------------------------------------------- + +/** + * Minimal stand-in for FormContent's helperText section so we can exercise + * the ETH / ROLV display and null-fallback logic without mounting the full + * DialogForm (which requires Apollo, routing, etc.). + */ +const HelperTextPreview = ({ language }: { language: LanguageLookupItem }) => ( + + + {language.ethnologue.code.value ?? '-'} + + + {language.registryOfLanguageVarietiesCode.value ?? '-'} + + +); + +/** Wraps HelperTextPreview in a Final Form context so a realistic render path + * is exercised. */ +const renderHelperText = (language: LanguageLookupItem) => + render( +
    undefined} + initialValues={{ engagement: { languageId: language } }} + render={() => } + /> + ); + +describe('helperText code display', () => { + it('shows ETH and ROLV codes for the selected language', () => { + const lang = makeLanguage('lang-1', { + ethnologueCode: 'xyz', + rolvCode: 'RV99', + }); + const { getByTestId } = renderHelperText(lang); + expect(getByTestId('helper-eth')).toHaveTextContent('xyz'); + expect(getByTestId('helper-rolv')).toHaveTextContent('RV99'); + }); + + it('falls back to "-" for a null ETH code', () => { + const lang = makeLanguage('lang-2', { + ethnologueCode: null, + rolvCode: 'RV01', + }); + const { getByTestId } = renderHelperText(lang); + expect(getByTestId('helper-eth')).toHaveTextContent('-'); + expect(getByTestId('helper-rolv')).toHaveTextContent('RV01'); + }); + + it('falls back to "-" for a null ROLV code', () => { + const lang = makeLanguage('lang-3', { + ethnologueCode: 'abc', + rolvCode: null, + }); + const { getByTestId } = renderHelperText(lang); + expect(getByTestId('helper-eth')).toHaveTextContent('abc'); + expect(getByTestId('helper-rolv')).toHaveTextContent('-'); + }); + + it('falls back to "-" for both null codes', () => { + const lang = makeLanguage('lang-4', { + ethnologueCode: null, + rolvCode: null, + }); + const { getByTestId } = renderHelperText(lang); + expect(getByTestId('helper-eth')).toHaveTextContent('-'); + expect(getByTestId('helper-rolv')).toHaveTextContent('-'); + }); +}); + +// --------------------------------------------------------------------------- +// Column header labels (PaperComponent) +// --------------------------------------------------------------------------- + +/** + * Recreates the PaperComponent header row passed to LanguageField. + * Tests that the three columns — Name, ETH, ROLV — are rendered in order. + */ +const ColumnHeader = () => ( +
    + Name + ETH + ROLV +
    +); + +describe('dropdown column headers', () => { + it('renders Name, ETH, and ROLV headers', () => { + render(); + expect(screen.getByTestId('col-name')).toHaveTextContent('Name'); + expect(screen.getByTestId('col-eth')).toHaveTextContent('ETH'); + expect(screen.getByTestId('col-rolv')).toHaveTextContent('ROLV'); + }); + + it('renders columns in Name → ETH → ROLV order', () => { + const { getByTestId } = render(); + const header = getByTestId('column-header'); + const children = Array.from(header.children); + expect(children[0]).toHaveTextContent('Name'); + expect(children[1]).toHaveTextContent('ETH'); + expect(children[2]).toHaveTextContent('ROLV'); + }); +}); diff --git a/src/scenes/Engagement/LanguageEngagement/Create/CreateLanguageEngagement.tsx b/src/scenes/Engagement/LanguageEngagement/Create/CreateLanguageEngagement.tsx index 6cd9559deb..a9d6fdc158 100644 --- a/src/scenes/Engagement/LanguageEngagement/Create/CreateLanguageEngagement.tsx +++ b/src/scenes/Engagement/LanguageEngagement/Create/CreateLanguageEngagement.tsx @@ -1,4 +1,8 @@ import { useMutation } from '@apollo/client'; +import { Box, Paper, Tooltip, Typography } from '@mui/material'; +import { useMemo } from 'react'; +import { useFormState } from 'react-final-form'; +import { makeStyles } from 'tss-react/mui'; import { Except } from 'type-fest'; import { addItemToList } from '~/api'; import { callAll } from '~/common'; @@ -12,12 +16,19 @@ import { LanguageField, LanguageLookupItem, } from '../../../../components/form/Lookup'; -import { CreateLanguageEngagementDocument } from './CreateLanguageEngagement.graphql'; +import { + CreateLanguageEngagementDocument, + type CreateLanguageEngagementMutation, +} from './CreateLanguageEngagement.graphql'; import { invalidatePartnersEngagements } from './invalidatePartnersEngagements'; import { recalculateSensitivity } from './recalculateSensitivity'; interface CreateLanguageEngagementFormValues { - language: LanguageLookupItem; + // engagement may be undefined on initial render or after form.reset() + engagement?: { + // Optional because the field starts undefined before the user makes a selection. + languageId?: LanguageLookupItem; + }; } type CreateLanguageEngagementProps = Except< @@ -25,18 +36,201 @@ type CreateLanguageEngagementProps = Except< 'onSubmit' > & { project: ProjectIdFragment; + /** IDs of languages that already have an engagement on this project. */ + engagedLanguageIds: readonly string[]; +}; + +/** + * Returns a comparator that sorts already-engaged languages to the bottom. + * Exported for unit testing. + */ +export const createSortComparator = + (engagedLanguageIds: readonly string[]) => + (a: LanguageLookupItem, b: LanguageLookupItem): number => { + const aEngaged = engagedLanguageIds.includes(a.id); + const bEngaged = engagedLanguageIds.includes(b.id); + if (aEngaged === bEngaged) return 0; + return aEngaged ? 1 : -1; + }; + +/** + * Returns a predicate that disables languages already engaged on the project. + * Exported for unit testing. + */ +export const createGetOptionDisabled = + (engagedLanguageIds: readonly string[]) => + (lang: LanguageLookupItem): boolean => + engagedLanguageIds.includes(lang.id); + +const useStyles = makeStyles()(({ palette, spacing }) => ({ + columnHeader: { + display: 'flex', + padding: spacing(0.5, 2), + borderBottom: `1px solid ${palette.divider}`, + }, + columnHeaderName: { + flex: 1, + fontSize: '0.7rem', + fontWeight: 600, + color: palette.text.secondary, + textTransform: 'uppercase', + }, + columnHeaderCode: { + flexShrink: 0, + width: 52, // must match optionCode width for column alignment // ai design-alignment + fontSize: '0.7rem', // compact size for column header labels // ai design-alignment + fontWeight: 600, + textAlign: 'right', + color: palette.text.secondary, + textTransform: 'uppercase', + }, + optionRow: { + display: 'flex', + width: '100%', + alignItems: 'center', + gap: spacing(1), + }, + optionName: { + flex: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + optionCode: { + flexShrink: 0, + width: 52, // must match columnHeaderCode width for column alignment // ai design-alignment + textAlign: 'right', + color: palette.text.secondary, + }, + helperRow: { + display: 'flex', + alignItems: 'center', + gap: spacing(2), + }, + helperKey: { + fontWeight: 600, + }, +})); + +/** + * Inner form content — rendered inside DialogForm's Form context so that + * useFormState can subscribe to live field values. + */ +interface FormContentProps { + engagedLanguageIds: readonly string[]; + sortComparator: (a: LanguageLookupItem, b: LanguageLookupItem) => number; +} + +const FormContent = ({ + engagedLanguageIds, + sortComparator, +}: FormContentProps) => { + const { classes } = useStyles(); + const { values } = useFormState({ + subscription: { values: true }, + }); + const currentLanguage = values.engagement?.languageId ?? null; + + const renderOptionContent = (option: LanguageLookupItem) => { + const row = ( + + + {option.name.value ?? option.displayName.value} + + + {option.ethnologue.code.value ?? '-'} + + + {option.registryOfLanguageVarietiesCode.value ?? '-'} + + + ); + + if (engagedLanguageIds.includes(option.id)) { + return ( + // Tooltip on a disabled Autocomplete option requires a non-disabled wrapper + + {row} + + ); + } + return row; + }; + + return ( + <> + + { + const { children, ...rest } = paperProps; + return ( + + + + Name + + + ETH + + + ROLV + + + {children} + + ); + }} + sortComparator={sortComparator} + getOptionDisabled={createGetOptionDisabled(engagedLanguageIds)} + renderOptionContent={renderOptionContent} + helperText={ + + + ETH + + + {currentLanguage?.ethnologue.code.value ?? '-'} + + + ROLV + + + {currentLanguage?.registryOfLanguageVarietiesCode.value ?? '-'} + + + } + /> + + ); }; export const CreateLanguageEngagement = ({ project, + engagedLanguageIds, ...props }: CreateLanguageEngagementProps) => { const [createEngagement] = useMutation(CreateLanguageEngagementDocument); - const submit = async ({ language }: CreateLanguageEngagementFormValues) => { + + // Push already-engaged languages to the bottom of the dropdown list + const sortComparator = useMemo( + () => createSortComparator(engagedLanguageIds), + [engagedLanguageIds] + ); + + const submit = async ({ engagement }: CreateLanguageEngagementFormValues) => { + // Guard: `required` validation prevents submit without a selection, but we + // narrow the type here to avoid non-null assertions. // ai type-safety + const language = engagement?.languageId; + if (!language) return; const languageRef = { __typename: 'Language', id: language.id, } as const; + await createEngagement({ variables: { input: { @@ -48,7 +242,8 @@ export const CreateLanguageEngagement = ({ update: callAll( addItemToList({ listId: [project, 'engagements'], - outputToItem: (res) => res.createLanguageEngagement.engagement, + outputToItem: (res: CreateLanguageEngagementMutation) => + res.createLanguageEngagement.engagement, }), addItemToList({ listId: [languageRef, 'projects'], @@ -59,6 +254,7 @@ export const CreateLanguageEngagement = ({ ), }); }; + return ( - - + ); }; diff --git a/src/scenes/Projects/Overview/ProjectOverview.tsx b/src/scenes/Projects/Overview/ProjectOverview.tsx index dc665dd253..8ee1014839 100644 --- a/src/scenes/Projects/Overview/ProjectOverview.tsx +++ b/src/scenes/Projects/Overview/ProjectOverview.tsx @@ -12,6 +12,7 @@ import { } from '@mui/icons-material'; import { Chip, Grid, Skeleton, Tooltip, Typography } from '@mui/material'; import { Many } from '@seedcompany/common'; +import { useMemo } from 'react'; import { useDropzone } from 'react-dropzone'; import { Helmet } from 'react-helmet-async'; import { makeStyles } from 'tss-react/mui'; @@ -190,9 +191,17 @@ export const ProjectOverview = () => { 0 ); - const CreateEngagement = isTranslation - ? CreateLanguageEngagement - : CreateInternshipEngagement; + // IDs of languages that already have an engagement on this project, used to + // disable duplicate selections in the Create Language Engagement dialog. + const engagedLanguageIds = useMemo( + () => + engagements.data?.items.flatMap((e) => + e.__typename === 'LanguageEngagement' && e.language.value + ? [e.language.value.id] + : [] + ) ?? [], + [engagements.data?.items] + ); return (
    @@ -582,9 +591,19 @@ export const ProjectOverview = () => { editFields={fieldsBeingEdited} /> ) : null} - {project && ( - - )} + {project && + (isTranslation ? ( + + ) : ( + + ))} {project && (