diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml index b7c53800..892cf25d 100644 --- a/.github/workflows/ui.yml +++ b/.github/workflows/ui.yml @@ -12,4 +12,24 @@ jobs: if: github.ref_name == github.event.repository.default_branch || github.event_name != 'push' || github.ref_type == 'tag' secrets: inherit with: - install-before-publish: true \ No newline at end of file + install-before-publish: true + # extened default list of exclusions + sonar-exclusions: > + artifacts/**, + docs/**, + dist/**, + examples/**, + resources/bigtest/interactors/**, + resources/bigtest/network/**, + LICENSE, + ci/**, + node_modules/**, + src/test/**, + src/common/constants/**, + jest.config.*, + tsconfig.*, + jest.config.*, + vite.config.*, + **/*.md, + **/*.scss, + **/*.json \ No newline at end of file diff --git a/src/common/hooks/useComplexLookupSearchResults.ts b/src/common/hooks/useComplexLookupSearchResults.ts new file mode 100644 index 00000000..474f839a --- /dev/null +++ b/src/common/hooks/useComplexLookupSearchResults.ts @@ -0,0 +1,60 @@ +import { useCallback, useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; +import { useIntl } from 'react-intl'; +import { type Row } from '@components/Table'; +import { useSearchContext } from '@common/hooks/useSearchContext'; +import { ComplexLookupSearchResultsProps } from '@components/ComplexLookupField/ComplexLookupSearchResults'; +import state from '@state'; + +export const useComplexLookupSearchResults = ({ + onTitleClick, + tableConfig, + searchResultsFormatter, +}: ComplexLookupSearchResultsProps) => { + const { onAssignRecord } = useSearchContext(); + const data = useRecoilValue(state.search.data); + const sourceData = useRecoilValue(state.search.sourceData); + const { formatMessage } = useIntl(); + + const applyActionItems = useCallback( + (rows: Row[]): Row[] => + rows?.map(row => { + const formattedRow: Row = { ...row }; + + Object.entries(tableConfig.columns).forEach(([key, column]) => { + formattedRow[key] = { + ...row[key], + children: column.formatter + ? column.formatter({ row, formatMessage, onAssign: onAssignRecord, onTitleClick }) + : row[key].label, + }; + }); + + return formattedRow; + }), + [onAssignRecord, tableConfig], + ); + + const formattedData = useMemo( + () => applyActionItems(searchResultsFormatter(data || [], sourceData || [])), + [applyActionItems, data], + ); + + const listHeader = useMemo( + () => + Object.keys(tableConfig.columns).reduce((accum, key) => { + const { label, position, className } = (tableConfig.columns[key] || {}) as SearchResultsTableColumn; + + accum[key] = { + label: label ? formatMessage({ id: label }) : '', + position: position, + className: className, + }; + + return accum; + }, {} as Row), + [tableConfig], + ); + + return { formattedData, listHeader }; +}; diff --git a/src/common/hooks/useMarcData.ts b/src/common/hooks/useMarcData.ts index 7698026f..5be3525a 100644 --- a/src/common/hooks/useMarcData.ts +++ b/src/common/hooks/useMarcData.ts @@ -4,9 +4,9 @@ import { StatusType } from '@common/constants/status.constants'; import { UserNotificationFactory } from '@common/services/userNotification'; import state from '@state'; -export const useMarcData = (markState: RecoilState) => { - const setMarcPreviewData = useSetRecoilState(markState); - const clearMarcData = useResetRecoilState(markState); +export const useMarcData = (marcState: RecoilState) => { + const setMarcPreviewData = useSetRecoilState(marcState); + const clearMarcData = useResetRecoilState(marcState); const setIsLoading = useSetRecoilState(state.loadingState.isLoading); const setStatus = useSetRecoilState(state.status.commonMessages); diff --git a/src/common/services/schema/schemaWithDuplicates.service.ts b/src/common/services/schema/schemaWithDuplicates.service.ts index 88bc8904..52178790 100644 --- a/src/common/services/schema/schemaWithDuplicates.service.ts +++ b/src/common/services/schema/schemaWithDuplicates.service.ts @@ -10,8 +10,8 @@ export class SchemaWithDuplicatesService implements ISchemaWithDuplicatesService constructor( private schema: Map, - private selectedEntriesService: ISelectedEntries, - private entryPropertiesGeneratorService?: IEntryPropertiesGeneratorService, + private readonly selectedEntriesService: ISelectedEntries, + private readonly entryPropertiesGeneratorService?: IEntryPropertiesGeneratorService, ) { this.set(schema); this.isManualDuplication = true; @@ -107,7 +107,7 @@ export class SchemaWithDuplicatesService implements ISchemaWithDuplicatesService if (!entry || entry.cloneOf) return; const { children } = entry; - let updatedEntryUuid = newUuids?.[index] || uuidv4(); + let updatedEntryUuid = newUuids?.[index] ?? uuidv4(); const isFirstAssociatedEntryElem = parentEntry?.dependsOn && newUuids && index === 0; if (isFirstAssociatedEntryElem) { @@ -126,7 +126,7 @@ export class SchemaWithDuplicatesService implements ISchemaWithDuplicatesService newUuids, }); - if (controlledByEntry && controlledByEntry?.uuid) { + if (controlledByEntry?.uuid) { this.schema.set(controlledByEntry.uuid, controlledByEntry); } diff --git a/src/common/services/userValues/userValueTypes/simpleLookup.ts b/src/common/services/userValues/userValueTypes/simpleLookup.ts index 6a43cfea..9c854ef8 100644 --- a/src/common/services/userValues/userValueTypes/simpleLookup.ts +++ b/src/common/services/userValues/userValueTypes/simpleLookup.ts @@ -11,8 +11,8 @@ export class SimpleLookupUserValueService extends UserValueType implements IUser private contents?: UserValueContents[]; constructor( - private apiClient: IApiClient, - private cacheService: ILookupCacheService, + private readonly apiClient: IApiClient, + private readonly cacheService: ILookupCacheService, ) { super(); } @@ -60,7 +60,7 @@ export class SimpleLookupUserValueService extends UserValueType implements IUser } this.value = { - uuid: uuid || '', + uuid: uuid ?? '', contents: this.contents, }; @@ -70,7 +70,7 @@ export class SimpleLookupUserValueService extends UserValueType implements IUser private checkDefaultGroupValues(groupUri?: string, itemUri?: string) { if (!groupUri || !itemUri) return false; - return (DEFAULT_GROUP_VALUES as DefaultGroupValues)[groupUri as string]?.value === itemUri; + return (DEFAULT_GROUP_VALUES as DefaultGroupValues)[groupUri]?.value === itemUri; } private generateContentItem({ @@ -100,7 +100,7 @@ export class SimpleLookupUserValueService extends UserValueType implements IUser ?.find( ({ label: optionLabel, value }) => value.uri === mappedUri || value.label === label || optionLabel === label, ); - const selectedLabel = typesMap && itemUri ? loadedOption?.label || itemUri : loadedOption?.label || label; + const selectedLabel = typesMap && itemUri ? (loadedOption?.label ?? itemUri) : (loadedOption?.label ?? label); const contentItem = { label: selectedLabel, diff --git a/src/components/ComplexLookupField/ComplexLookupSearchResults.tsx b/src/components/ComplexLookupField/ComplexLookupSearchResults.tsx index 41b43549..b3c05f6b 100644 --- a/src/components/ComplexLookupField/ComplexLookupSearchResults.tsx +++ b/src/components/ComplexLookupField/ComplexLookupSearchResults.tsx @@ -1,11 +1,8 @@ -import { FC, useCallback, useMemo } from 'react'; -import { useRecoilValue } from 'recoil'; -import { FormattedMessage, useIntl } from 'react-intl'; +import { FC } from 'react'; import { TableFlex, type Row } from '@components/Table'; -import { useSearchContext } from '@common/hooks/useSearchContext'; -import state from '@state'; +import { useComplexLookupSearchResults } from '@common/hooks/useComplexLookupSearchResults'; -type ComplexLookupSearchResultsProps = { +export type ComplexLookupSearchResultsProps = { onTitleClick?: (id: string, title?: string, headingType?: string) => void; tableConfig: SearchResultsTableConfig; searchResultsFormatter: (data: any[], sourceData?: SourceDataDTO) => Row[]; @@ -16,50 +13,11 @@ export const ComplexLookupSearchResults: FC = ( tableConfig, searchResultsFormatter, }) => { - const { onAssignRecord } = useSearchContext(); - const data = useRecoilValue(state.search.data); - const sourceData = useRecoilValue(state.search.sourceData); - const { formatMessage } = useIntl(); - - const applyActionItems = useCallback( - (rows: Row[]): Row[] => - rows.map(row => { - const formattedRow: Row = { ...row }; - - Object.entries(tableConfig.columns).forEach(([key, column]) => { - formattedRow[key] = { - ...row[key], - children: column.formatter - ? column.formatter({ row, formatMessage, onAssign: onAssignRecord, onTitleClick }) - : row[key].label, - }; - }); - - return formattedRow; - }), - [onAssignRecord, tableConfig], - ); - - const formattedData = useMemo( - () => applyActionItems(searchResultsFormatter(data || [], sourceData || [])), - [applyActionItems, data], - ); - - const listHeader = useMemo( - () => - Object.keys(tableConfig.columns).reduce((accum, key) => { - const { label, position, className } = (tableConfig.columns[key] || {}) as SearchResultsTableColumn; - - accum[key] = { - label: label ? : '', - position: position, - className: className, - }; - - return accum; - }, {} as Row), - [tableConfig], - ); + const { listHeader, formattedData } = useComplexLookupSearchResults({ + onTitleClick, + tableConfig, + searchResultsFormatter, + }); return (
diff --git a/src/components/DatePicker/DatePicker.tsx b/src/components/DatePicker/DatePicker.tsx index 137827c7..64fc6dc6 100644 --- a/src/components/DatePicker/DatePicker.tsx +++ b/src/components/DatePicker/DatePicker.tsx @@ -3,13 +3,21 @@ import './DatePicker.scss'; type DatePickerProps = { id: string; + ['data-testid']?: string; placeholder?: string; name?: string; value?: string; onChange?: (value: string) => void | Dispatch>; }; -export const DatePicker: FC = ({ id, value, onChange, name, placeholder }) => { +export const DatePicker: FC = ({ + id, + value, + onChange, + name, + placeholder, + 'data-testid': dataTestId, +}) => { const handleChange = (event: ChangeEvent) => { onChange?.(event.target.value); }; @@ -18,6 +26,7 @@ export const DatePicker: FC = ({ id, value, onChange, name, pla
= ({ return isOpen && portalElement ? createPortal( <> -
+
{showCloseIconButton && ( diff --git a/src/test/__tests__/common/hooks/useComplesLookupSearchResults.test.ts b/src/test/__tests__/common/hooks/useComplesLookupSearchResults.test.ts new file mode 100644 index 00000000..9e0e9f16 --- /dev/null +++ b/src/test/__tests__/common/hooks/useComplesLookupSearchResults.test.ts @@ -0,0 +1,86 @@ +import { useRecoilValue } from 'recoil'; +import { renderHook } from '@testing-library/react'; +import { useSearchContext } from '@common/hooks/useSearchContext'; +import { useComplexLookupSearchResults } from '@common/hooks/useComplexLookupSearchResults'; +import { ComplexLookupSearchResultsProps } from '@components/ComplexLookupField/ComplexLookupSearchResults'; +import { Row } from '@components/Table'; + +jest.mock('recoil'); +jest.mock('@common/hooks/useSearchContext', () => ({ + useSearchContext: jest.fn(), +})); + +const data = [ + { + id: '1', + name: { label: 'Item 1' }, + description: { label: 'Description 1' }, + }, +]; +const sourceData = [ + { + id: '1', + name: 'Item 1', + description: 'Description 1', + }, +]; +const tableConfig = { + columns: { + name: { + label: 'name.label', + position: 1, + formatter: ({ row }: any) => row.name.label, + }, + description: { + label: 'description.label', + position: 2, + }, + }, +}; +const searchResultsFormatter = (data: Row[]) => data; + +describe('useComplesLookupSearchResults', () => { + beforeEach(() => { + (useSearchContext as jest.Mock).mockReturnValue({ + onAssignRecord: jest.fn(), + }); + (useRecoilValue as jest.Mock).mockReturnValueOnce(data).mockReturnValueOnce(sourceData); + }); + + it('returns "formattedData" and "listHeader"', () => { + const props: ComplexLookupSearchResultsProps = { + onTitleClick: jest.fn(), + tableConfig, + searchResultsFormatter, + }; + + const { result } = renderHook(() => useComplexLookupSearchResults(props)); + + expect(result.current.formattedData).toEqual([ + { + id: '1', + name: { + label: 'Item 1', + children: 'Item 1', + }, + description: { + label: 'Description 1', + children: 'Description 1', + }, + }, + ]); + + expect(result.current.listHeader).toEqual({ + name: { + label: 'name.label', + position: 1, + className: undefined, + }, + description: { + label: 'description.label', + position: 2, + className: undefined, + }, + }); + }); +}); diff --git a/src/test/__tests__/common/hooks/useSearchFilterLookupOptions.test.ts b/src/test/__tests__/common/hooks/useSearchFilterLookupOptions.test.ts new file mode 100644 index 00000000..41275448 --- /dev/null +++ b/src/test/__tests__/common/hooks/useSearchFilterLookupOptions.test.ts @@ -0,0 +1,100 @@ +import { useRecoilValue } from 'recoil'; +import { renderHook } from '@testing-library/react'; +import { useSearchFilterLookupOptions } from '@common/hooks/useSearchFilterLookupOptions'; + +jest.mock('recoil'); + +describe('useSearchFilterLookupOptions', () => { + const mockUseRecoilValue = useRecoilValue as jest.Mock; + const facet = 'testFacet'; + + function testUseSearchFilterLookupOptions( + options: { facet?: string; hasMappedSourceData?: boolean }, + { sourceData, facetsData }: { sourceData: SourceDataDTO | null; facetsData: FacetsDTO }, + testResult: FilterLookupOption[], + ) { + mockUseRecoilValue.mockReturnValueOnce(sourceData).mockReturnValueOnce(facetsData); + + const { result } = renderHook(() => useSearchFilterLookupOptions(options)); + + expect(result.current.options).toEqual(testResult); + } + + it('returns empty options when facet is undefined', () => { + const options = { hasMappedSourceData: true }; + const sourceData = [] as SourceDataDTO; + const facetsData = {}; + const testResult = [] as FilterLookupOption[]; + + testUseSearchFilterLookupOptions(options, { sourceData, facetsData }, testResult); + }); + + it('returns empty options when facetsData does not contain the facet', () => { + const options = { facet, hasMappedSourceData: true }; + const sourceData = [] as SourceDataDTO; + const facetsData = { otherFacet: { values: [] } } as unknown as FacetsDTO; + const testResult = [] as FilterLookupOption[]; + + testUseSearchFilterLookupOptions(options, { sourceData, facetsData }, testResult); + }); + + it('maps options correctly when hasMappedSourceData is true', () => { + const options = { facet, hasMappedSourceData: true }; + const sourceData = [ + { id: '1', name: 'Name 1' }, + { id: '2', name: 'Name 2' }, + ] as SourceDataDTO; + const facetsData = { + testFacet: { + values: [ + { id: '1', totalRecords: 10 }, + { id: '2', totalRecords: 20 }, + ], + }, + } as unknown as FacetsDTO; + const testResult = [ + { label: 'Name 1', subLabel: '(10)', value: { id: '1' } }, + { label: 'Name 2', subLabel: '(20)', value: { id: '2' } }, + ] as FilterLookupOption[]; + + testUseSearchFilterLookupOptions(options, { sourceData, facetsData }, testResult); + }); + + it('maps options correctly when hasMappedSourceData is false', () => { + const options = { facet, hasMappedSourceData: false }; + const sourceData = [] as SourceDataDTO; + const facetsData = { + testFacet: { + values: [ + { id: '1', totalRecords: 10 }, + { id: '2', totalRecords: 20 }, + ], + }, + } as unknown as FacetsDTO; + const testResult = [ + { label: '1', subLabel: '(10)', value: { id: '1' } }, + { label: '2', subLabel: '(20)', value: { id: '2' } }, + ] as FilterLookupOption[]; + + testUseSearchFilterLookupOptions(options, { sourceData, facetsData }, testResult); + }); + + it('handles empty sourceData', () => { + const options = { facet, hasMappedSourceData: true }; + const sourceData = [] as SourceDataDTO; + const facetsData = { + testFacet: { + values: [{ id: '1', totalRecords: 10 }], + }, + } as unknown as FacetsDTO; + const testResult = [ + { + label: 'ld.notSpecified', + subLabel: '(10)', + value: { id: '1' }, + }, + ] as FilterLookupOption[]; + + testUseSearchFilterLookupOptions(options, { sourceData, facetsData }, testResult); + }); +}); diff --git a/src/test/__tests__/components/ComplexLookupField.test.tsx b/src/test/__tests__/components/ComplexLookupField/ComplexLookupField.test.tsx similarity index 100% rename from src/test/__tests__/components/ComplexLookupField.test.tsx rename to src/test/__tests__/components/ComplexLookupField/ComplexLookupField.test.tsx diff --git a/src/test/__tests__/components/ComplexLookupField/ComplexLookupSearchResults.test.tsx b/src/test/__tests__/components/ComplexLookupField/ComplexLookupSearchResults.test.tsx new file mode 100644 index 00000000..56db1850 --- /dev/null +++ b/src/test/__tests__/components/ComplexLookupField/ComplexLookupSearchResults.test.tsx @@ -0,0 +1,40 @@ +import { render } from '@testing-library/react'; +import { useComplexLookupSearchResults } from '@common/hooks/useComplexLookupSearchResults'; +import { ComplexLookupSearchResults } from '@components/ComplexLookupField/ComplexLookupSearchResults'; +import { TableFlex } from '@components/Table'; + +jest.mock('@components/Table'); +jest.mock('@common/hooks/useComplexLookupSearchResults'); + +const listHeader = ['Column 1', 'Column 2']; +const formattedData = [ + { id: '1', values: ['Data 1', 'Data 2'] }, + { id: '2', values: ['Data 3', 'Data 4'] }, +]; + +const onTitleClick = jest.fn(); +const searchResultsFormatter = jest.fn(); +const tableConfig = {} as SearchResultsTableConfig; + +describe('ComplexLookupSearchResults', () => { + it('renders "TableFlex" component with the required props', () => { + (useComplexLookupSearchResults as jest.Mock).mockReturnValue({ + listHeader, + formattedData, + }); + (TableFlex as jest.Mock).mockReturnValue(
Mock TableFlex
); + + render( + , + ); + + expect(TableFlex as jest.Mock).toHaveBeenCalledWith( + { header: listHeader, data: formattedData, className: 'results-list' }, + {}, + ); + }); +}); diff --git a/src/test/__tests__/components/DatePicker.test.tsx b/src/test/__tests__/components/DatePicker.test.tsx new file mode 100644 index 00000000..08ecbf0d --- /dev/null +++ b/src/test/__tests__/components/DatePicker.test.tsx @@ -0,0 +1,42 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { DatePicker } from '@components/DatePicker/DatePicker'; + +describe('DatePicker Component', () => { + it('renders the input element with correct attributes', () => { + render( {}} />); + + const input = screen.getByTestId('date-picker-input-test-date'); + + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('type', 'date'); + expect(input).toHaveAttribute('id', 'test-date'); + expect(input).toHaveAttribute('value', '2024-11-11'); + }); + + it('calls onChange handler when input value changes', () => { + const handleChange = jest.fn(); + + render(); + + const input = screen.getByTestId('date-picker-input-test-date'); + fireEvent.change(input, { target: { value: '2024-12-25' } }); + + expect(handleChange).toHaveBeenCalledWith('2024-12-25'); + }); + + it('renders with optional placeholder and name props', () => { + render( + {}} + placeholder="Select a date" + name="datePicker" + />, + ); + + const input = screen.getByPlaceholderText('Select a date'); + + expect(input).toHaveAttribute('name', 'datePicker'); + }); +}); diff --git a/src/test/__tests__/components/DateRange.test.tsx b/src/test/__tests__/components/DateRange.test.tsx new file mode 100644 index 00000000..d576d028 --- /dev/null +++ b/src/test/__tests__/components/DateRange.test.tsx @@ -0,0 +1,64 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { DateRange } from '@components/DateRange'; + +describe('DateRange Component', () => { + it('renders without crashing', () => { + render(); + + expect(screen.getByTestId('testFacet-start')).toBeInTheDocument(); + expect(screen.getByTestId('testFacet-end')).toBeInTheDocument(); + expect(screen.getByTestId('testFacet-apply')).toBeInTheDocument(); + }); + + it('has initial empty dateStart and dateEnd', () => { + render(); + + const startDate: HTMLInputElement = screen.getByTestId('testFacet-start'); + const endDate: HTMLInputElement = screen.getByTestId('testFacet-end'); + + expect(startDate.value).toBe(''); + expect(endDate.value).toBe(''); + }); + + it('updates dateStart and dateEnd on change', () => { + render(); + + const startDate: HTMLInputElement = screen.getByTestId('testFacet-start'); + const endDate: HTMLInputElement = screen.getByTestId('testFacet-end'); + + fireEvent.change(startDate, { target: { value: '2024-01-01' } }); + fireEvent.change(endDate, { target: { value: '2024-01-31' } }); + + expect(startDate.value).toBe('2024-01-01'); + expect(endDate.value).toBe('2024-01-31'); + }); + + it('calls onSubmit with correct values when apply button is clicked', () => { + const mockSubmit = jest.fn(); + render(); + + const startDate: HTMLInputElement = screen.getByTestId('testFacet-start'); + const endDate: HTMLInputElement = screen.getByTestId('testFacet-end'); + const applyButton = screen.getByTestId('testFacet-apply'); + + fireEvent.change(startDate, { target: { value: '2024-01-01' } }); + fireEvent.change(endDate, { target: { value: '2024-01-31' } }); + fireEvent.click(applyButton); + + expect(mockSubmit).toHaveBeenCalledWith('testFacet', { + dateStart: '2024-01-01', + dateEnd: '2024-01-31', + }); + }); + + it('does not call onSubmit if facet is not provided', () => { + const mockSubmit = jest.fn(); + render(); + + const applyButton = screen.getByTestId('undefined-apply'); + + fireEvent.click(applyButton); + + expect(mockSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/__tests__/components/EditControlPane.test.tsx b/src/test/__tests__/components/EditControlPane.test.tsx index f4112ba8..b6e21e07 100644 --- a/src/test/__tests__/components/EditControlPane.test.tsx +++ b/src/test/__tests__/components/EditControlPane.test.tsx @@ -1,7 +1,6 @@ -import '@src/test/__mocks__/common/hooks/useNavigateToEditPage.mock'; import { navigateAsDuplicate } from '@src/test/__mocks__/common/hooks/useNavigateToEditPage.mock'; import { EditControlPane } from '@components/EditControlPane'; -import { fireEvent, render } from '@testing-library/react'; +import { act, fireEvent, render } from '@testing-library/react'; import { RouterProvider, createMemoryRouter } from 'react-router'; import { RecoilRoot } from 'recoil'; import * as recordsApi from '@common/api/records.api'; @@ -36,20 +35,24 @@ describe('EditControlPane', () => { const getMarcRecordMock = (jest.spyOn(recordsApi, 'getMarcRecord') as any).mockImplementation(() => Promise.resolve(null), ); - + const { findByText, findByTestId } = renderWrapper(); - fireEvent.click(await findByTestId('edit-control-actions-toggle')); - fireEvent.click(await findByText('ld.viewMarc')); + await act(async () => { + fireEvent.click(await findByTestId('edit-control-actions-toggle')); + fireEvent.click(await findByText('ld.viewMarc')); + }); expect(getMarcRecordMock).toHaveBeenCalled(); }); - test('handles duplicate resource navigation', async () => { + test('handles duplicate resource navigation', async () => { const { findByText, findByTestId } = renderWrapper(); - fireEvent.click(await findByTestId('edit-control-actions-toggle')); - fireEvent.click(await findByText('ld.duplicate')); + await act(async () => { + fireEvent.click(await findByTestId('edit-control-actions-toggle')); + fireEvent.click(await findByText('ld.duplicate')); + }); expect(navigateAsDuplicate).toHaveBeenCalled(); });