From e53966bb62d230ecee0a4ad331f592178f77ab58 Mon Sep 17 00:00:00 2001 From: Ilya Khait Date: Tue, 28 Nov 2023 15:57:50 +0100 Subject: [PATCH] AfO Register (#398) * Implement repository & tests (WiP) * Add AfO service, tests & bibliography routes and tabs * Add tests & format * Impelment AfO Register search forms * Implement search & async options (WiP) * Update & fix tests * Implement suggestions (WiP) * Adjust search, sorting & form (WiP) * Correct Markdown string output * Update queries & interface, refactor * Refactor search form * Add AfO Register & bibliography intro, style * Implement AfO Reg. display in Fragmentarium * Implement fragment injection, link, style, refactor & update tests * Fix CSS style * Refactor * Refactor more * Add missing space * Update repository & service tests * Add factory & display tests * Add search results display test * Add text select tests * Add fragment records test & fix display * Add & update tests * Extend repository tests * Update bibliography tests * Add repository test * Extend search form tests --- .../application/AfoRegisterService.test.ts | 63 +++++ .../application/AfoRegisterService.ts | 40 +++ src/afo-register/domain/Record.test.ts | 48 ++++ src/afo-register/domain/Record.ts | 113 ++++++++ .../AfoRegisterRepository.test.ts | 190 +++++++++++++ .../infrastructure/AfoRegisterRepository.ts | 70 +++++ .../ui/AfoRegisterDisplay.test.tsx | 57 ++++ src/afo-register/ui/AfoRegisterDisplay.tsx | 39 +++ .../ui/AfoRegisterFragmentRecords.test.tsx | 43 +++ .../ui/AfoRegisterFragmentRecords.tsx | 41 +++ .../ui/AfoRegisterSearch.test.tsx | 68 +++++ src/afo-register/ui/AfoRegisterSearch.tsx | 58 ++++ .../ui/AfoRegisterSearchForm.test.tsx | 148 ++++++++++ src/afo-register/ui/AfoRegisterSearchForm.tsx | 263 ++++++++++++++++++ src/afo-register/ui/AfoRegisterSearchPage.tsx | 66 +++++ .../ui/AfoRegisterTextSelect.test.tsx | 59 ++++ src/afo-register/ui/AfoRegisterTextSelect.tsx | 108 +++++++ .../BibliographyRepository.test.ts | 4 +- src/bibliography/ui/Bibliography.css | 13 + src/bibliography/ui/Bibliography.test.tsx | 94 +++++-- src/bibliography/ui/Bibliography.tsx | 122 ++++++-- src/bibliography/ui/BibliographySearch.tsx | 6 +- .../ui/BibliographySearchForm.tsx | 2 +- src/dictionary/ui/display/WordDisplay.tsx | 11 +- src/fragmentarium/domain/Fragment.test.ts | 1 + src/fragmentarium/domain/FragmentDtos.ts | 3 + src/fragmentarium/domain/fragment.ts | 4 + .../infrastructure/FragmentRepository.ts | 1 + .../ui/fragment/CuneiformFragment.test.tsx | 4 + .../ui/fragment/CuneiformFragment.tsx | 7 + .../ui/fragment/FragmentView.test.tsx | 4 + .../ui/fragment/FragmentView.tsx | 4 + src/fragmentarium/ui/info/Info.tsx | 22 +- src/fragmentarium/ui/info/info.sass | 6 + src/http/ApiClient.ts | 4 +- src/index.tsx | 8 +- src/query/FragmentQuery.ts | 1 + src/router/bibliographyRoutes.tsx | 51 +++- src/router/fragmentariumRoutes.tsx | 4 + src/router/router.tsx | 2 + src/router/sitemap.test.tsx | 6 + src/test-support/AppDriver.tsx | 5 + src/test-support/afo-register-fixtures.ts | 72 +++++ src/test-support/fragment-fixtures.ts | 1 + src/test-support/test-fragment.ts | 2 + src/test-support/utils.ts | 3 +- 46 files changed, 1868 insertions(+), 73 deletions(-) create mode 100644 src/afo-register/application/AfoRegisterService.test.ts create mode 100644 src/afo-register/application/AfoRegisterService.ts create mode 100644 src/afo-register/domain/Record.test.ts create mode 100644 src/afo-register/domain/Record.ts create mode 100644 src/afo-register/infrastructure/AfoRegisterRepository.test.ts create mode 100644 src/afo-register/infrastructure/AfoRegisterRepository.ts create mode 100644 src/afo-register/ui/AfoRegisterDisplay.test.tsx create mode 100644 src/afo-register/ui/AfoRegisterDisplay.tsx create mode 100644 src/afo-register/ui/AfoRegisterFragmentRecords.test.tsx create mode 100644 src/afo-register/ui/AfoRegisterFragmentRecords.tsx create mode 100644 src/afo-register/ui/AfoRegisterSearch.test.tsx create mode 100644 src/afo-register/ui/AfoRegisterSearch.tsx create mode 100644 src/afo-register/ui/AfoRegisterSearchForm.test.tsx create mode 100644 src/afo-register/ui/AfoRegisterSearchForm.tsx create mode 100644 src/afo-register/ui/AfoRegisterSearchPage.tsx create mode 100644 src/afo-register/ui/AfoRegisterTextSelect.test.tsx create mode 100644 src/afo-register/ui/AfoRegisterTextSelect.tsx create mode 100644 src/test-support/afo-register-fixtures.ts diff --git a/src/afo-register/application/AfoRegisterService.test.ts b/src/afo-register/application/AfoRegisterService.test.ts new file mode 100644 index 000000000..a600d3398 --- /dev/null +++ b/src/afo-register/application/AfoRegisterService.test.ts @@ -0,0 +1,63 @@ +import { testDelegation, TestData } from 'test-support/utils' +import AfoRegisterRepository from 'afo-register/infrastructure/AfoRegisterRepository' +import AfoRegisterRecord, { + AfoRegisterRecordSuggestion, +} from 'afo-register/domain/Record' +import AfoRegisterService from 'afo-register/application/AfoRegisterService' +import { stringify } from 'query-string' + +jest.mock('afo-register/infrastructure/AfoRegisterRepository') +const afoRegisterRepository = new (AfoRegisterRepository as jest.Mock)() + +const afoRegisterService = new AfoRegisterService(afoRegisterRepository) + +const resultStub = { + afoNumber: 'AfO 1', + page: '2', + text: 'some text', + textNumber: '5', + discussedBy: '', + discussedByNotes: '', + linesDiscussed: '', + fragmentNumbers: undefined, +} + +const suggestionResultStub = { + text: 'some text', + textNumbers: undefined, +} + +const query = { afoNumber: resultStub.afoNumber, page: resultStub.page } +const entry = new AfoRegisterRecord(resultStub) +const suggestionEntry = new AfoRegisterRecordSuggestion(suggestionResultStub) + +const testData: TestData[] = [ + new TestData( + 'search', + [stringify(query)], + afoRegisterRepository.search, + [entry], + [stringify(query), undefined], + Promise.resolve([entry]) + ), + new TestData( + 'searchTextsAndNumbers', + [['text1', 'number1']], + afoRegisterRepository.searchTextsAndNumbers, + [entry], + [['text1', 'number1']], + Promise.resolve([entry]) + ), + new TestData( + 'searchSuggestions', + ['suggestion query'], + afoRegisterRepository.searchSuggestions, + [suggestionEntry], + ['suggestion query'], + Promise.resolve([suggestionEntry]) + ), +] + +describe('AfoRegisterService', () => { + testDelegation(afoRegisterService, testData) +}) diff --git a/src/afo-register/application/AfoRegisterService.ts b/src/afo-register/application/AfoRegisterService.ts new file mode 100644 index 000000000..4ee326ec1 --- /dev/null +++ b/src/afo-register/application/AfoRegisterService.ts @@ -0,0 +1,40 @@ +import Promise from 'bluebird' +import AfoRegisterRecord, { + AfoRegisterRecordSuggestion, +} from 'afo-register/domain/Record' +import AfoRegisterRepository from 'afo-register/infrastructure/AfoRegisterRepository' +import FragmentService from 'fragmentarium/application/FragmentService' + +export interface afoRegisterSearch { + search(query: string): Promise + searchSuggestions( + query: string + ): Promise +} + +export default class AfoRegisterService implements afoRegisterSearch { + private readonly afoRegisterRepository: AfoRegisterRepository + + constructor(afoRegisterRepository: AfoRegisterRepository) { + this.afoRegisterRepository = afoRegisterRepository + } + + search( + query: string, + fragmentService?: FragmentService + ): Promise { + return this.afoRegisterRepository.search(query, fragmentService) + } + + searchTextsAndNumbers( + query: readonly string[] + ): Promise { + return this.afoRegisterRepository.searchTextsAndNumbers(query) + } + + searchSuggestions( + query: string + ): Promise { + return this.afoRegisterRepository.searchSuggestions(query) + } +} diff --git a/src/afo-register/domain/Record.test.ts b/src/afo-register/domain/Record.test.ts new file mode 100644 index 000000000..8aa3728df --- /dev/null +++ b/src/afo-register/domain/Record.test.ts @@ -0,0 +1,48 @@ +import AfoRegisterRecord from 'afo-register/domain/Record' + +describe('AfoRegisterRecord', () => { + const mockRecord = { + afoNumber: '123', + page: '456', + text: 'Sample text', + textNumber: '789', + linesDiscussed: 'Some lines', + discussedBy: 'John Doe', + discussedByNotes: 'Notes by John', + fragmentNumbers: ['BM777'], + } + + describe('constructor', () => { + describe('constructor', () => { + it('should initialize properties correctly', () => { + const record = new AfoRegisterRecord(mockRecord) + expect(record.afoNumber).toEqual('123') + expect(record.page).toEqual('456') + expect(record.text).toEqual('Sample text') + expect(record.textNumber).toEqual('789') + expect(record.linesDiscussed).toEqual('Some lines') + expect(record.discussedBy).toEqual('John Doe') + expect(record.discussedByNotes).toEqual('Notes by John') + }) + }) + }) + + describe('Converts to Markdown string', () => { + it('Returns the correct markdown string with fragments', () => { + const record = new AfoRegisterRecord(mockRecord) + const result = record.toMarkdownString() + expect(result).toEqual( + 'Sample text 789 ([BM777](/fragmentarium/BM777)), Some lines: John Doe Notes by John[123, 456]' + ) + }) + + it('Handles sup tags correctly', () => { + const recordWithSup = new AfoRegisterRecord({ + ...mockRecord, + text: 'Sample^text^', + }) + const result = recordWithSup.toMarkdownString() + expect(result).toContain('text') + }) + }) +}) diff --git a/src/afo-register/domain/Record.ts b/src/afo-register/domain/Record.ts new file mode 100644 index 000000000..4f1027a44 --- /dev/null +++ b/src/afo-register/domain/Record.ts @@ -0,0 +1,113 @@ +import produce, { Draft, immerable } from 'immer' + +interface RecordData { + readonly afoNumber: string + readonly page: string + readonly text: string + readonly textNumber: string + readonly linesDiscussed?: string + readonly discussedBy?: string + readonly discussedByNotes?: string + readonly fragmentNumbers?: string[] +} + +export class AfoRegisterRecordSuggestion { + [immerable] = true + + readonly text: string + readonly textNumbers?: string[] + + constructor({ + text, + textNumbers, + }: { + readonly text: string + readonly textNumbers?: string[] + }) { + this.text = text + this.textNumbers = textNumbers + } +} + +export default class AfoRegisterRecord { + [immerable] = true + + readonly afoNumber: string + readonly page: string + readonly text: string + readonly textNumber: string + readonly linesDiscussed?: string + readonly discussedBy?: string + readonly discussedByNotes?: string + readonly fragmentNumbers?: string[] + + constructor({ + afoNumber, + page, + text, + textNumber, + linesDiscussed, + discussedBy, + discussedByNotes, + fragmentNumbers, + }: RecordData) { + this.afoNumber = afoNumber + this.page = page + this.text = text + this.textNumber = textNumber + this.linesDiscussed = linesDiscussed + this.discussedBy = discussedBy + this.discussedByNotes = discussedByNotes + this.fragmentNumbers = fragmentNumbers + } + + toMarkdownString(): string { + const textNumber = this.textNumberToMarkdownString() + const fragments = this.fragmentsToMarkdownString() + const linesDiscussed = this.linesDiscussedToMarkdownString() + const discussedBy = this.discussedByToMarkdownString() + const discussedByNotes = this.discussedByNotesToMarkdownString() + const footnote = this.footnotesToMarkdownString() + const result = `${this.text}${textNumber}${fragments}${linesDiscussed}${discussedBy}${discussedByNotes}${footnote}` + return result.replace(/\^([^^]+)\^/g, '$1') + } + + setFragmentNumbers(fragmentNumbers: string[]): AfoRegisterRecord { + return produce(this, (draft: Draft) => { + draft.fragmentNumbers = fragmentNumbers + }) + } + + private textNumberToMarkdownString(): string { + return `${this.textNumber ? ` ${this.textNumber}` : ''}` + } + + private fragmentsToMarkdownString(): string { + if (!this.fragmentNumbers || this.fragmentNumbers?.length < 1) { + return '' + } + const fragmentsString = this.fragmentNumbers + .map( + (fragmentNumber) => + `[${fragmentNumber}](/fragmentarium/${fragmentNumber})` + ) + .join(', ') + return ` (${fragmentsString})` + } + + private linesDiscussedToMarkdownString(): string { + return this.linesDiscussed ? `, ${this.linesDiscussed}` : '' + } + + private discussedByToMarkdownString(): string { + return this.discussedBy ? `: ${this.discussedBy}` : '' + } + + private discussedByNotesToMarkdownString(): string { + return this.discussedByNotes ? ` ${this.discussedByNotes}` : '' + } + + private footnotesToMarkdownString(): string { + return `[${this.afoNumber}, ${this.page}]` + } +} diff --git a/src/afo-register/infrastructure/AfoRegisterRepository.test.ts b/src/afo-register/infrastructure/AfoRegisterRepository.test.ts new file mode 100644 index 000000000..f7cbbab0f --- /dev/null +++ b/src/afo-register/infrastructure/AfoRegisterRepository.test.ts @@ -0,0 +1,190 @@ +import { testDelegation, TestData } from 'test-support/utils' +import AfoRegisterRepository from 'afo-register/infrastructure/AfoRegisterRepository' +import AfoRegisterRecord, { + AfoRegisterRecordSuggestion, +} from 'afo-register/domain/Record' +import { stringify } from 'query-string' +import ApiClient from 'http/ApiClient' +import Bluebird from 'bluebird' +import FragmentService from 'fragmentarium/application/FragmentService' +import { QueryItem } from 'query/QueryResult' + +jest.mock('http/ApiClient') +jest.mock('fragmentarium/application/FragmentService') + +const apiClient = new (ApiClient as jest.Mock>)() +const fragmentService = new (FragmentService as jest.Mock< + jest.Mocked +>)() +const afoRegisterRepository = new AfoRegisterRepository(apiClient) + +const resultStub = { + afoNumber: 'AfO 1', + page: '2', + text: 'some text', + textNumber: '5', + discussedBy: '', + discussedByNotes: '', + linesDiscussed: '', +} + +const query = { afoNumber: resultStub.afoNumber, page: resultStub.page } +const entry = new AfoRegisterRecord(resultStub) +const suggestionEntry = new AfoRegisterRecordSuggestion({ + text: 'some text', + textNumbers: undefined, +}) + +const testData: TestData[] = [ + new TestData( + 'search', + [ + stringify({ + afoNumber: 'AfO 1', + page: '2', + }), + ], + apiClient.fetchJson, + [entry], + [`/afo-register?${stringify(query)}`, false], + Promise.resolve([resultStub]) + ), + new TestData( + 'searchTextsAndNumbers', + [['text1', 'number1']], + apiClient.postJson, + [entry], + ['/afo-register/texts-numbers', ['text1', 'number1'], false], + Promise.resolve([resultStub]) + ), + new TestData( + 'searchSuggestions', + ['suggestion query'], + apiClient.fetchJson, + [suggestionEntry], + ['/afo-register/suggestions?text_query=suggestion query', false], + Promise.resolve([resultStub]) + ), +] +describe('afoRegisterService', () => + testDelegation(afoRegisterRepository, testData)) + +describe('AfoRegisterRepository - search', () => { + it('handles search without fragmentService', async () => { + apiClient.fetchJson.mockReturnValue(Bluebird.resolve([entry])) + const response = await afoRegisterRepository.search(stringify(query)) + expect(response).toEqual([entry]) + expect(apiClient.fetchJson).toHaveBeenCalledWith( + `/afo-register?${stringify(query)}`, + false + ) + }) + + it('handles different query strings', async () => { + const query2 = { afoNumber: 'AfO 2', page: '3' } + const entry2 = new AfoRegisterRecord({ + text: 'Some text', + textNumber: '22', + afoNumber: 'AfO 2', + page: '3', + }) + apiClient.fetchJson.mockResolvedValueOnce([resultStub, entry2]) + const response = await afoRegisterRepository.search(stringify(query2)) + expect(response).toEqual([entry, entry2]) + }) + + it('handles empty response', async () => { + apiClient.fetchJson.mockResolvedValueOnce([]) + const response = await afoRegisterRepository.search(stringify(query)) + expect(response).toEqual([]) + }) + + it('handles API errors', async () => { + apiClient.fetchJson.mockRejectedValueOnce(new Error('API Error')) + await expect( + afoRegisterRepository.search(stringify(query)) + ).rejects.toThrow('API Error') + }) +}) + +describe('AfoRegisterRepository - searchTextsAndNumbers', () => { + it('handles various text and number combinations', async () => { + const query2 = ['text2', 'number2'] + const entry2 = new AfoRegisterRecord({ + ...resultStub, + text: 'text2', + textNumber: 'number2', + }) + apiClient.postJson.mockResolvedValueOnce([resultStub, entry2]) + const response = await afoRegisterRepository.searchTextsAndNumbers(query2) + expect(response).toEqual([entry, entry2]) + }) + + it('handles empty response', async () => { + apiClient.postJson.mockResolvedValueOnce([]) + const response = await afoRegisterRepository.searchTextsAndNumbers([ + 'text1', + 'number1', + ]) + expect(response).toEqual([]) + }) + + it('handles API errors', async () => { + apiClient.postJson.mockRejectedValueOnce(new Error('API Error')) + await expect( + afoRegisterRepository.searchTextsAndNumbers(['text1', 'number1']) + ).rejects.toThrow('API Error') + }) +}) + +describe('AfoRegisterRepository - searchSuggestions', () => { + it('handles different query strings', async () => { + const query2 = 'different suggestion query' + const suggestionEntry2 = new AfoRegisterRecordSuggestion({ + ...resultStub, + text: 'different text', + }) + apiClient.fetchJson.mockResolvedValueOnce([resultStub, suggestionEntry2]) + const response = await afoRegisterRepository.searchSuggestions(query2) + expect(response).toEqual([suggestionEntry, suggestionEntry2]) + }) + + it('handles empty response', async () => { + apiClient.fetchJson.mockResolvedValueOnce([]) + const response = await afoRegisterRepository.searchSuggestions( + 'suggestion query' + ) + expect(response).toEqual([]) + }) + + it('handles API errors', async () => { + apiClient.fetchJson.mockRejectedValueOnce(new Error('API Error')) + await expect( + afoRegisterRepository.searchSuggestions('suggestion query') + ).rejects.toThrow('API Error') + }) +}) + +describe('AfoRegisterRepository - search with fragmentService', () => { + it('injects fragment references when fragmentService is provided', async () => { + const modifiedEntry = { ...entry, fragmentNumbers: ['Frag1', 'Frag2'] } + fragmentService.query.mockReturnValueOnce( + Bluebird.resolve({ + items: [ + { museumNumber: 'Frag1' }, + { museumNumber: 'Frag2' }, + ] as QueryItem[], + matchCountTotal: 2, + }) + ) + apiClient.fetchJson.mockResolvedValueOnce([resultStub]) + const response = await afoRegisterRepository.search( + stringify(query), + fragmentService + ) + expect(response).toEqual([modifiedEntry]) + expect(fragmentService.query).toHaveBeenCalledWith({ + traditionalReferences: entry.text + ' ' + entry.textNumber, + }) + }) +}) diff --git a/src/afo-register/infrastructure/AfoRegisterRepository.ts b/src/afo-register/infrastructure/AfoRegisterRepository.ts new file mode 100644 index 000000000..f3e5b24e9 --- /dev/null +++ b/src/afo-register/infrastructure/AfoRegisterRepository.ts @@ -0,0 +1,70 @@ +import AfoRegisterRecord, { + AfoRegisterRecordSuggestion, +} from 'afo-register/domain/Record' +import Promise from 'bluebird' +import FragmentService from 'fragmentarium/application/FragmentService' +import ApiClient from 'http/ApiClient' + +function createAfoRegisterRecord(data): AfoRegisterRecord { + return new AfoRegisterRecord(data) +} + +function createAfoRegisterRecordSuggestion(data): AfoRegisterRecordSuggestion { + return new AfoRegisterRecordSuggestion(data) +} + +function injectFragmentReferecesToRecord( + record: AfoRegisterRecord, + fragmentService: FragmentService +): Promise { + const { text, textNumber } = record + return fragmentService + .query({ traditionalReferences: text + ' ' + textNumber }) + .then((queryResult) => { + return record.setFragmentNumbers( + queryResult.items.map((item) => item.museumNumber) + ) + }) +} + +export default class AfoRegisterRepository { + private readonly apiClient: ApiClient + + constructor(apiClient: ApiClient) { + this.apiClient = apiClient + } + + search( + query: string, + fragmentService?: FragmentService + ): Promise { + return this.apiClient + .fetchJson(`/afo-register?${query}`, false) + .then((result) => result.map(createAfoRegisterRecord)) + .then((records) => { + if (fragmentService) { + return Promise.all( + records.map((record) => + injectFragmentReferecesToRecord(record, fragmentService) + ) + ) + } else { + return records + } + }) + } + + searchTextsAndNumbers( + query: readonly string[] + ): Promise { + return this.apiClient + .postJson(`/afo-register/texts-numbers`, query, false) + .then((result) => result.map(createAfoRegisterRecord)) + } + + searchSuggestions(query: string): Promise { + return this.apiClient + .fetchJson(`/afo-register/suggestions?text_query=${query}`, false) + .then((result) => result.map(createAfoRegisterRecordSuggestion)) + } +} diff --git a/src/afo-register/ui/AfoRegisterDisplay.test.tsx b/src/afo-register/ui/AfoRegisterDisplay.test.tsx new file mode 100644 index 000000000..238c95bfc --- /dev/null +++ b/src/afo-register/ui/AfoRegisterDisplay.test.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import { act, render, screen, waitFor } from '@testing-library/react' +import { + AfoRegisterRecordDisplay, + AfoRegisterRecordsListDisplay, +} from './AfoRegisterDisplay' +import { afoRegisterRecordFactory } from 'test-support/afo-register-fixtures' +import { waitForSpinnerToBeRemoved } from 'test-support/waitForSpinnerToBeRemoved' + +const mockRecord = afoRegisterRecordFactory.build() +const anotherMockRecord = afoRegisterRecordFactory.build() + +describe('AfoRegisterRecordDisplay', () => { + it('renders correctly', async () => { + await act(async () => { + render( +
+ +
+ ) + await waitForSpinnerToBeRemoved(screen) + await waitFor(() => { + const { text, textNumber } = mockRecord + const item = screen.getByTestId('item') + expect(item).toHaveTextContent(text) + expect(item).toHaveTextContent(textNumber) + }) + }) + }) +}) + +describe('AfoRegisterRecordsListDisplay', () => { + it('displays no records message when empty', () => { + render() + expect(screen.getByText('No records found')).toBeInTheDocument() + }) + + it('renders records correctly', async () => { + await act(async () => { + render( + + ) + await waitForSpinnerToBeRemoved(screen) + await waitFor(() => { + expect(screen.getAllByRole('listitem').length).toBe(2) + screen.getAllByRole('listitem').forEach((item, index) => { + expect(item).toHaveClass('afo-register-records__list-item') + const { text, textNumber } = [mockRecord, anotherMockRecord][index] + expect(item).toHaveTextContent(text) + expect(item).toHaveTextContent(textNumber) + }) + }) + }) + }) +}) diff --git a/src/afo-register/ui/AfoRegisterDisplay.tsx b/src/afo-register/ui/AfoRegisterDisplay.tsx new file mode 100644 index 000000000..54630ee4f --- /dev/null +++ b/src/afo-register/ui/AfoRegisterDisplay.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import AfoRegisterRecord from 'afo-register/domain/Record' +import MarkdownAndHtmlToHtml from 'common/MarkdownAndHtmlToHtml' + +export function AfoRegisterRecordDisplay({ + record, + index, +}: { + record: AfoRegisterRecord + index: string | number +}): JSX.Element { + return ( + + ) +} + +export function AfoRegisterRecordsListDisplay({ + records, + ...props +}: { + records: readonly AfoRegisterRecord[] +} & React.OlHTMLAttributes): JSX.Element { + if (records.length < 1) { + return

No records found

+ } + return ( +
    + {records.map((record, index) => ( +
  1. + +
  2. + ))} +
+ ) +} diff --git a/src/afo-register/ui/AfoRegisterFragmentRecords.test.tsx b/src/afo-register/ui/AfoRegisterFragmentRecords.test.tsx new file mode 100644 index 000000000..6487b2f01 --- /dev/null +++ b/src/afo-register/ui/AfoRegisterFragmentRecords.test.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { render, waitFor, screen, act } from '@testing-library/react' +import AfoRegisterFragmentRecords from './AfoRegisterFragmentRecords' +import AfoRegisterService from 'afo-register/application/AfoRegisterService' +import { fragmentFactory } from 'test-support/fragment-fixtures' +import { afoRegisterRecordFactory } from 'test-support/afo-register-fixtures' + +jest.mock('afo-register/application/AfoRegisterService') + +describe('AfoRegisterFragmentRecords', () => { + const mockRecords = [ + afoRegisterRecordFactory.build(), + afoRegisterRecordFactory.build(), + ] + + it('fetches records and displays them', async () => { + const mockService = new (AfoRegisterService as jest.Mock< + jest.Mocked + >)() + mockService.searchTextsAndNumbers.mockResolvedValue(mockRecords) + + const fragment = fragmentFactory.build({ + traditionalReferences: mockRecords.map( + (record) => record.text + ' ' + record.textNumber + ), + }) + await act(async () => { + render( + + ) + }) + await waitFor(() => { + mockRecords.forEach(async (record) => { + expect( + await screen.findByText(record.text + ' ' + record.textNumber) + ).toBeInTheDocument() + }) + }) + }) +}) diff --git a/src/afo-register/ui/AfoRegisterFragmentRecords.tsx b/src/afo-register/ui/AfoRegisterFragmentRecords.tsx new file mode 100644 index 000000000..758d84224 --- /dev/null +++ b/src/afo-register/ui/AfoRegisterFragmentRecords.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import AfoRegisterService from 'afo-register/application/AfoRegisterService' +import { Fragment } from 'fragmentarium/domain/fragment' +import withData from 'http/withData' +import AfoRegisterRecord from 'afo-register/domain/Record' +import _ from 'lodash' +import { AfoRegisterRecordsListDisplay } from './AfoRegisterDisplay' + +function AfoRegisterFragmentRecords({ + data, +}: { + data: { records: readonly AfoRegisterRecord[] } +}): JSX.Element { + return ( + + ) +} + +export default withData< + unknown, + { + afoRegisterService: AfoRegisterService + fragment: Fragment + }, + { records: readonly AfoRegisterRecord[] } +>( + AfoRegisterFragmentRecords, + (props) => { + return props.afoRegisterService + .searchTextsAndNumbers(props.fragment.traditionalReferences) + .then((records) => ({ records })) + }, + { + watch: (props) => [...props.fragment.traditionalReferences], + filter: (props) => !_.isEmpty(props.fragment.traditionalReferences), + defaultData: () => ({ records: [] }), + } +) diff --git a/src/afo-register/ui/AfoRegisterSearch.test.tsx b/src/afo-register/ui/AfoRegisterSearch.test.tsx new file mode 100644 index 000000000..582a02b5f --- /dev/null +++ b/src/afo-register/ui/AfoRegisterSearch.test.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import { render, screen, waitFor, act } from '@testing-library/react' +import AfoRegisterService from 'afo-register/application/AfoRegisterService' +import AfoRegisterSearch from 'afo-register/ui/AfoRegisterSearch' +import { waitForSpinnerToBeRemoved } from 'test-support/waitForSpinnerToBeRemoved' +import FragmentService from 'fragmentarium/application/FragmentService' +import Bluebird from 'bluebird' +import { afoRegisterRecordFactory } from 'test-support/afo-register-fixtures' + +jest.mock('afo-register/application/AfoRegisterService') +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: jest.fn(), +})) + +const afoRegisterService = new (AfoRegisterService as jest.Mock< + jest.Mocked +>)() + +const fragmentService = new (FragmentService as jest.Mock< + jest.Mocked +>)() + +describe('AfO Register search display', () => { + const mockQuery = { text: 'testText', textNumber: 'testNumber' } + let record + + beforeEach(() => { + jest.clearAllMocks() + record = afoRegisterRecordFactory.build() + }) + + it('renders correctly with initial state', async () => { + afoRegisterService.search.mockReturnValue(Bluebird.resolve([record])) + await act(async () => { + render( + + ) + await waitForSpinnerToBeRemoved(screen) + await waitFor(() => { + const item = screen.getByRole('listitem') + expect(item).toBeVisible() + expect(item).toHaveTextContent(record.text) + }) + }) + }) + + it('renders correctly with empty result', async () => { + afoRegisterService.search.mockReturnValue(Bluebird.resolve([])) + await act(async () => { + render( + + ) + await waitForSpinnerToBeRemoved(screen) + await waitFor(() => { + expect(screen.getByText('No records found')).toBeVisible() + }) + }) + }) +}) diff --git a/src/afo-register/ui/AfoRegisterSearch.tsx b/src/afo-register/ui/AfoRegisterSearch.tsx new file mode 100644 index 000000000..5f612b0f9 --- /dev/null +++ b/src/afo-register/ui/AfoRegisterSearch.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import _ from 'lodash' + +import withData from 'http/withData' + +import AfoRegisterRecord from 'afo-register/domain/Record' +import AfoRegisterService from 'afo-register/application/AfoRegisterService' +import { LiteratureRedirectBox } from 'common/LiteratureRedirectBox' +import { AfoRegisterQuery } from './AfoRegisterSearchForm' +import { stringify } from 'query-string' +import { AfoRegisterRecordsListDisplay } from './AfoRegisterDisplay' +import FragmentService from 'fragmentarium/application/FragmentService' + +export const AfoRegisterRedirectBox = ( + +) + +function AfoRegisterSearch({ data }: { data: readonly AfoRegisterRecord[] }) { + return ( + <> + + {data.length > 0 && AfoRegisterRedirectBox} + + ) +} + +export default withData< + unknown, + { + afoRegisterService: AfoRegisterService + fragmentService: FragmentService + query: AfoRegisterQuery + }, + readonly AfoRegisterRecord[] +>( + AfoRegisterSearch, + (props) => + props.afoRegisterService.search( + stringify(props.query), + props.fragmentService + ), + { + watch: (props) => [props.query], + filter: (props) => !_.isEmpty(props.query), + defaultData: () => [], + } +) diff --git a/src/afo-register/ui/AfoRegisterSearchForm.test.tsx b/src/afo-register/ui/AfoRegisterSearchForm.test.tsx new file mode 100644 index 000000000..d447f592e --- /dev/null +++ b/src/afo-register/ui/AfoRegisterSearchForm.test.tsx @@ -0,0 +1,148 @@ +import React from 'react' +import { render, fireEvent, waitFor, screen, act } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import AfoRegisterSearchForm from './AfoRegisterSearchForm' +import AfoRegisterService from 'afo-register/application/AfoRegisterService' +import { Router } from 'react-router-dom' +import { createMemoryHistory } from 'history' +import Bluebird from 'bluebird' +import { AfoRegisterRecordSuggestion } from 'afo-register/domain/Record' + +jest.mock('afo-register/application/AfoRegisterService') +jest.mock('fragmentarium/application/FragmentService') +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: jest.fn(), + }), +})) + +async function renderWithRouter( + children: JSX.Element, + path?: string +): Promise { + const history = createMemoryHistory() + path && history.push(path) + await act(async () => { + render({children}) + }) +} + +describe('AfoRegisterSearch Component Tests', () => { + let afoRegisterServiceMock + + beforeEach(() => { + afoRegisterServiceMock = new (AfoRegisterService as jest.Mock< + jest.Mocked + >)() + afoRegisterServiceMock.searchSuggestions.mockReturnValue( + Bluebird.resolve([ + new AfoRegisterRecordSuggestion({ + text: 'Sample text', + textNumbers: ['1', '2', '3', '4'], + }), + ]) + ) + }) + + const mockQueryProp = { + text: 'Sample Text', + textNumber: '1', + } + + test('renders without crashing', async () => { + await renderWithRouter( + + ) + await waitFor(() => { + expect(screen.getByPlaceholderText('Number')).toBeInTheDocument() + }) + }) + + test('handles form submission correctly', async () => { + const historyMock = { push: jest.fn() } + jest + // eslint-disable-next-line @typescript-eslint/no-var-requires + .spyOn(require('react-router-dom'), 'useHistory') + .mockReturnValue(historyMock) + + await renderWithRouter( + + ) + + const submitButton = screen.getByRole('button', { name: /search/i }) + + userEvent.click(submitButton) + + await waitFor(() => { + const expectedUrl = '?text=Sample%20Text&textNumber=1' + expect(historyMock.push).toHaveBeenCalledWith(expectedUrl) + }) + }) + + test('updates state on input change', async () => { + await renderWithRouter( + + ) + const textNumberInput = screen.getByPlaceholderText('Number') + + fireEvent.change(textNumberInput, { target: { value: '456' } }) + + await waitFor(() => { + expect(expect(screen.getByDisplayValue('456')).toBeInTheDocument()) + }) + }) + + test('fetchTextNumberOptions updates options correctly', async () => { + afoRegisterServiceMock.searchSuggestions.mockReturnValue( + Bluebird.resolve([ + new AfoRegisterRecordSuggestion({ + text: 'Test', + textNumbers: ['111', '222'], + }), + ]) + ) + await renderWithRouter( + + ) + await act(async () => { + const exactSwitch = screen.getByLabelText('Exact number') + userEvent.click(exactSwitch) + }) + await act(async () => { + const numberInput = screen.getByLabelText('select-text-number') + userEvent.click(numberInput) + }) + await waitFor(() => { + expect(screen.getByText('—')).toBeInTheDocument() + expect(screen.getByText('111')).toBeInTheDocument() + expect(screen.getByText('222')).toBeInTheDocument() + }) + }) + + test('handles condition when textNumber is not set', async () => { + const queryProp = { text: 'Some text', textNumber: '' } + + await renderWithRouter( + + ) + await waitFor(() => { + expect(screen.getByPlaceholderText('Number')).toHaveValue('') + }) + }) +}) diff --git a/src/afo-register/ui/AfoRegisterSearchForm.tsx b/src/afo-register/ui/AfoRegisterSearchForm.tsx new file mode 100644 index 000000000..dc5dc9f0f --- /dev/null +++ b/src/afo-register/ui/AfoRegisterSearchForm.tsx @@ -0,0 +1,263 @@ +import React, { useEffect, useState } from 'react' +import { stringify } from 'query-string' +import _ from 'lodash' +import { Form, Button, Row, Col } from 'react-bootstrap' +import { RouteComponentProps, withRouter, useHistory } from 'react-router-dom' +import { AfoRegisterRecordSuggestion } from 'afo-register/domain/Record' +import AfoRegisterService from 'afo-register/application/AfoRegisterService' +import Select from 'react-select' +import Promise from 'bluebird' +import AfoRegisterTextSelect from 'afo-register/ui/AfoRegisterTextSelect' + +export type AfoRegisterQuery = { text: string; textNumber: string } + +interface TextNumberOption { + label: string + value: string +} + +type FormProps = { + queryProp: AfoRegisterQuery + afoRegisterService: AfoRegisterService +} & RouteComponentProps + +interface TextOrPublicationSelectProps { + query: AfoRegisterQuery + setQuery: React.Dispatch> + searchTextSuggestions: ( + text: string + ) => Promise + textNumberOptions: TextNumberOption[] + setTextNumberOptions: React.Dispatch> +} + +function updateQuery(queryProp: AfoRegisterQuery): AfoRegisterQuery { + return { + ...queryProp, + textNumber: _.trim(queryProp.textNumber, '"'), + } +} + +async function fetchTextNumberOptions( + query: AfoRegisterQuery, + textNumberOptions: TextNumberOption[], + setTextNumberOptions: React.Dispatch< + React.SetStateAction + >, + afoRegisterService: AfoRegisterService +): Promise { + const suggestions = await searchTextSuggestions( + query.text, + afoRegisterService + ) + const suggestion = suggestions.find( + (suggestion) => suggestion.text === query.text + ) + if ( + suggestion && + suggestion.textNumbers && + textNumberOptions.length !== suggestion.textNumbers.length + 1 + ) { + loadTextNumberOptions(suggestion.textNumbers, setTextNumberOptions) + } +} + +function loadTextNumberOptions( + textNumbers: string[] = [], + setTextNumberOptions: React.Dispatch> +): void { + setTextNumberOptions([ + { label: '—', value: '' }, + ...textNumbers.map(makeTextNumberOption).filter((option) => option.label), + ]) +} + +function makeTextNumberOption(textNumber: string): TextNumberOption { + return { label: textNumber, value: textNumber } +} + +function searchTextSuggestions( + queryText: string, + afoRegisterService: AfoRegisterService +): Promise { + if (queryText.replace(/\s/g, '').length > 1) { + return afoRegisterService.searchSuggestions(queryText) + } + return Promise.resolve([]) +} + +function makeTextSelectValue( + query: AfoRegisterQuery, + textNumberOptions: TextNumberOption[] +): AfoRegisterRecordSuggestion | null { + return query.text + ? new AfoRegisterRecordSuggestion({ + text: query.text, + textNumbers: textNumberOptions.map((option) => option.value), + }) + : null +} + +function AfoRegisterSearch({ queryProp, afoRegisterService }: FormProps) { + const [query, setQuery] = useState(updateQuery(queryProp)) + const [textNumberOptions, setTextNumberOptions] = useState< + Array<{ label: string; value: string }> + >([makeTextNumberOption(queryProp.textNumber)]) + const [isTextNumberSelect, setIsTextNumberSelect] = useState( + !!queryProp.textNumber && + queryProp.textNumber.length === query.textNumber.length + 2 + ) + const history = useHistory() + + useEffect(() => { + if (query.text) { + fetchTextNumberOptions( + query, + textNumberOptions, + setTextNumberOptions, + afoRegisterService + ) + } + }, [query, textNumberOptions, setTextNumberOptions, afoRegisterService]) + + function submit(event) { + event.preventDefault() + const _query = { ...query } + if (isTextNumberSelect) { + _query.textNumber = `"${query.textNumber}"` + } + history.push(`?${stringify(_query)}`) + } + + return ( +
+ + + + + searchTextSuggestions(text, afoRegisterService) + } + textNumberOptions={textNumberOptions} + setTextNumberOptions={setTextNumberOptions} + /> + + + + + + + + + + + + + + +
+ ) +} + +function TextOrPublicationSelect({ + query, + setQuery, + searchTextSuggestions, + textNumberOptions, + setTextNumberOptions, +}: TextOrPublicationSelectProps): JSX.Element { + return ( + { + loadTextNumberOptions( + suggestion.textNumbers || [], + setTextNumberOptions + ) + setQuery({ text: suggestion.text, textNumber: '' }) + }} + searchSuggestions={searchTextSuggestions} + isClearable={true} + /> + ) +} + +interface TextNumberFieldProps { + query: AfoRegisterQuery + setQuery: React.Dispatch> + textNumberOptions: TextNumberOption[] + isTextNumberSelect: boolean +} + +function TextNumberField({ + query, + setQuery, + textNumberOptions, + isTextNumberSelect, +}: TextNumberFieldProps): JSX.Element { + return isTextNumberSelect ? ( +