From 3be8f006917c896613fcabb6492953727411a698 Mon Sep 17 00:00:00 2001 From: Daria Kravchuk Date: Tue, 30 Jul 2024 19:08:40 +0300 Subject: [PATCH 1/9] updated placeholder and required field indicator in CategoryAdminModal component --- .../CategoriesPage/CategoryAdminModal.component.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/AdminPage/CategoriesPage/CategoriesPage/CategoryAdminModal.component.tsx b/src/features/AdminPage/CategoriesPage/CategoriesPage/CategoryAdminModal.component.tsx index 3e4058d4c..0b7a28285 100644 --- a/src/features/AdminPage/CategoriesPage/CategoriesPage/CategoryAdminModal.component.tsx +++ b/src/features/AdminPage/CategoriesPage/CategoriesPage/CategoryAdminModal.component.tsx @@ -178,14 +178,13 @@ const SourceModal: React.FC = ({ label="Назва: " rules={[{ required: true, message: 'Введіть назву' }]} > - + Date: Wed, 31 Jul 2024 09:27:36 +0300 Subject: [PATCH 2/9] updated inconsistent warning message upon closing modal window --- .../TeamPositionsModal/TeamPositionsAdminModal.component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/AdminPage/TeamPositionsPage/TeamPositionsModal/TeamPositionsAdminModal.component.tsx b/src/features/AdminPage/TeamPositionsPage/TeamPositionsModal/TeamPositionsAdminModal.component.tsx index a5e1c9a36..88068b732 100644 --- a/src/features/AdminPage/TeamPositionsPage/TeamPositionsModal/TeamPositionsAdminModal.component.tsx +++ b/src/features/AdminPage/TeamPositionsPage/TeamPositionsModal/TeamPositionsAdminModal.component.tsx @@ -105,7 +105,7 @@ const TeamPositionsAdminModalComponent: React.FC = obse maskClosable centered closeIcon={( - + )} From 4fd542d4e174df60400a65384b0628c4d94fc7d8 Mon Sep 17 00:00:00 2001 From: Daria Kravchuk Date: Thu, 1 Aug 2024 12:11:02 +0300 Subject: [PATCH 3/9] fixed photo disappearance when clicking outside modal without saving --- .../NewsPage/NewsModal/NewsModal.component.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/features/AdminPage/NewsPage/NewsModal/NewsModal.component.tsx b/src/features/AdminPage/NewsPage/NewsModal/NewsModal.component.tsx index 4b8ac15ce..1d95adbd7 100644 --- a/src/features/AdminPage/NewsPage/NewsModal/NewsModal.component.tsx +++ b/src/features/AdminPage/NewsPage/NewsModal/NewsModal.component.tsx @@ -65,6 +65,7 @@ const NewsModal: React.FC<{ const [actionSuccess, setActionSuccess] = useState(false); const [waitingForApiResponse, setWaitingForApiResponse] = useState(false); const [editorCharacterCount, setEditorCharacterCount] = useState(0); + const [removedImage, setRemovedImage] = useState(undefined); message.config({ top: 100, @@ -124,6 +125,7 @@ const NewsModal: React.FC<{ }, [newsItem, open, form]); const removeImage = () => { + setRemovedImage(image.current); imageId.current = undefined; image.current = undefined; if (newsItem) { @@ -137,6 +139,7 @@ const NewsModal: React.FC<{ setIsModalOpen(false); setTextIsPresent(false); setTextIsChanged(false); + setRemovedImage(undefined); editorRef.current?.editor?.setText(''); } }; @@ -144,6 +147,14 @@ const NewsModal: React.FC<{ const closeModal = () => { if (!waitingForApiResponse) { setIsModalOpen(false); + if (removedImage) { + imageId.current = removedImage.id; + image.current = removedImage; + if (newsItem) { + newsItem.image = removedImage; + } + setRemovedImage(undefined); + } } }; @@ -214,6 +225,7 @@ const NewsModal: React.FC<{ afterSubmit(news); } setActionSuccess(true); + setRemovedImage(undefined); } catch (e: unknown) { message.error('Не вдалось оновити/створити новину. Спробуйте ще раз.'); setWaitingForApiResponse(false); From f4b3dc6238041d83bc6a241cd298c04bbbb3791a Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 30 Jul 2024 21:48:56 +0300 Subject: [PATCH 4/9] feat: fixed multiple choice of chronological context in streetcode Editor --- .../NewTimelineModal.component.tsx | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.tsx b/src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.tsx index 437cb1aae..0054dc2da 100644 --- a/src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.tsx +++ b/src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.tsx @@ -3,7 +3,7 @@ import './NewTimelineModal.style.scss'; import '@features/AdminPage/AdminModal.styles.scss'; import { observer } from 'mobx-react-lite'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import getNewMinNegativeId from '@app/common/utils/newIdForStore'; import useMobx from '@app/stores/root-store'; import CancelBtn from '@assets/images/utils/Cancel_btn.svg'; @@ -40,6 +40,9 @@ const NewTimelineModal: React.FC = observer(({ timelineIt const [errorMessage, setErrorMessage] = useState(''); const [tagInput, setTagInput] = useState(''); + const [selectContextOpen, setSelectContextOpen] = useState(false); + + const selectInputContainerRef = useRef(null); const MAX_LENGTH = { title: 26, @@ -47,6 +50,8 @@ const NewTimelineModal: React.FC = observer(({ timelineIt context: 50, }; + const MAX_CONTEXTS_COUNT = 1; + const getErrorMessage = (maxLength: number = MAX_LENGTH.context) => `Довжина не повинна перевищувати ${maxLength} символів`; const { onContextKeyDown, handleSearch } = createTagValidator( MAX_LENGTH.context, @@ -142,7 +147,7 @@ const NewTimelineModal: React.FC = observer(({ timelineIt onChange('timeline', formValues); }; - const onContextSelect = (value: string) => { + const onContextSelect = useCallback((value: string) => { const index = historicalContextStore.historicalContextArray.findIndex((c) => c.title === value); if (index < 0) { if (value.length > MAX_LENGTH.context) { @@ -172,9 +177,9 @@ const NewTimelineModal: React.FC = observer(({ timelineIt setTagInput(''); setErrorMessage(''); onChange('historicalContexts', selectedContext.current); - }; + }, [historicalContextStore, onChange, MAX_LENGTH.context, form, getErrorMessage]); - const onContextDeselect = (value: string) => { + const onContextDeselect = useCallback((value: string) => { const historicalContext = selectedContext.current.find((x) => x.title === value) as HistoricalContextUpdate; if (historicalContext?.isPersisted) { historicalContext.modelState = ModelState.Deleted; @@ -182,7 +187,18 @@ const NewTimelineModal: React.FC = observer(({ timelineIt selectedContext.current = selectedContext.current.filter((s) => s.title !== value); } onChange('historicalContexts', selectedContext.current); - }; + }, [selectedContext, onChange]); + + useEffect(() => { + if (selectInputContainerRef.current) { + const notDeletedContextsCount = selectedContext.current.filter((c) => (c as HistoricalContextUpdate).modelState !== ModelState.Deleted).length; + const input = selectInputContainerRef.current.querySelector('input'); + setSelectContextOpen(notDeletedContextsCount < MAX_CONTEXTS_COUNT); + if (input) { + input.disabled = notDeletedContextsCount >= MAX_CONTEXTS_COUNT; + } + } + }, [selectedContext.current.length, open, onContextDeselect, onContextSelect]); const handleOk = async () => { try { @@ -271,7 +287,7 @@ const NewTimelineModal: React.FC = observer(({ timelineIt -
+
= observer(({ timelineIt > Date: Fri, 9 Aug 2024 14:22:05 +0300 Subject: [PATCH 9/9] feat: NewTimeLineModal tests added --- .../NewTimelineModal.component.spec.tsx | 293 ++++++++++++++++++ .../NewTimelineModal.component.tsx | 21 +- 2 files changed, 310 insertions(+), 4 deletions(-) create mode 100644 src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.spec.tsx diff --git a/src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.spec.tsx b/src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.spec.tsx new file mode 100644 index 000000000..9dd3333a6 --- /dev/null +++ b/src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.spec.tsx @@ -0,0 +1,293 @@ +import { + cleanup, fireEvent, render, screen, waitFor, +} from '@testing-library/react'; +import user from '@testing-library/user-event'; + +import TimelineItem, { DateViewPattern, HistoricalContextUpdate } from '@/models/timeline/chronology.model'; + +import '@testing-library/jest-dom'; + +import NewTimelineModal from './NewTimelineModal.component'; + +const mockTimeLine: TimelineItem = { + id: 1, + title: 'mockTitle', + description: 'mockDescription', + date: '2020-02-20T00:00:00.000Z', + dateViewPattern: DateViewPattern.DateMonthYear, + historicalContexts: [], +}; + +// needed to render component without errors +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: any) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => {}, + }), +}); + +jest.mock('@/app/common/components/Editor/QEditor.component', () => ({ + __esModule: true, + default: jest.fn((props) => { + const { value, onChange, maxChars } = props; + const valueToSet = value ?? ''; + const handleOnChange = (newValue: string) => { + onChange(newValue.slice(0, 3000)); + }; + return ( +
+ handleOnChange(e.target.value)} + maxLength={maxChars} + /> +
+ ); + }), +})); + +const addTimelineMock = jest.fn(); +jest.mock('@stores/root-store', () => ({ + __esModule: true, + default: jest.fn(() => ({ + timelineItemStore: { + getTimelineItemArray: [], + addTimeline: addTimelineMock, + timelineItemMap: new Map(), + }, + historicalContextStore: { + historicalContextArray: [ + { id: 1, title: 'context 1' }, + { id: 2, title: 'context 2' }, + ], + fetchHistoricalContextAll: jest.fn(), + }, + })), +})); + +const open = true; +const setOpen = () => {}; +const onChangeMock = jest.fn(); + +describe('NewTimelineModal test', () => { + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + it('should be rendered', async () => { + render( + , + ); + + const inputTitle = screen.getByTestId('input-title'); + const selectDate = screen.getByTestId('select-date'); + const datePicker = screen.getByTestId('date-picker'); + const selectContext = screen.getByTestId('select-context'); + const textareaDescription = screen.getByTestId('textarea-description'); + const buttonSave = screen.getByTestId('button-save'); + + await waitFor(() => { + expect(inputTitle).toBeInTheDocument(); + expect(selectDate).toBeInTheDocument(); + expect(datePicker).toBeInTheDocument(); + expect(selectContext).toBeInTheDocument(); + expect(textareaDescription).toBeInTheDocument(); + expect(buttonSave).toBeInTheDocument(); + }); + }); + + it('should create timeline with required fields only', async () => { + render( + , + ); + + // Arrange + const inputTitle = screen.getByTestId('input-title'); + const datePicker = screen.getByTestId('date-picker'); + const textareaDescription = screen.getByTestId('textarea-description'); + const buttonSave = screen.getByTestId('button-save'); + + const createTimelineWithRequiredOnly: TimelineItem = { + id: -1, + title: 'title', + description: 'description', + date: '2024-08-08T00:00:00.000Z', + dateViewPattern: DateViewPattern.DateMonthYear, + historicalContexts: [], + }; + + // Act + await waitFor(() => { + user.type(inputTitle, createTimelineWithRequiredOnly.title); + fireEvent.mouseDown(datePicker); + fireEvent.change(datePicker, { target: { value: '2024, 8 August' } }); + fireEvent.click(document.querySelectorAll('.ant-picker-cell-selected')[0]); + user.type(textareaDescription, createTimelineWithRequiredOnly.description!); + user.click(buttonSave); + }); + + // Assert + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalled(); + expect(addTimelineMock).toHaveBeenCalled(); + expect(addTimelineMock).toHaveBeenCalledWith(createTimelineWithRequiredOnly); + }); + }); + + it('should create timeline with all fields', async () => { + render( + , + ); + + // Arrange + const inputTitle = screen.getByTestId('input-title'); + const selectDate = screen.getByTestId('select-date'); + const datePicker = screen.getByTestId('date-picker'); + // If try to get by testId test doesn't work. Don't know why :( + // const selectContext = screen.getByTestId('select-context'); + const selectContext = screen.getByRole('combobox', { + name: /Контекст/i, + }); + const textareaDescription = screen.getByTestId('textarea-description'); + const buttonSave = screen.getByTestId('button-save'); + + const context: HistoricalContextUpdate = { id: 1, title: 'context 1', modelState: 0 }; + const createJobWithAllFields: TimelineItem = { + id: -1, + title: 'title', + description: 'description', + date: '2024-08-08T00:00:00.000Z', + dateViewPattern: DateViewPattern.DateMonthYear, + historicalContexts: [context], + }; + + // Act + await waitFor(() => { + user.type(inputTitle, createJobWithAllFields.title); + + user.click(selectDate); + user.click(screen.getByTitle('Рік, день місяць')!); + // user.click(document.querySelector('.ant-select-selection-item')!); + + user.click(datePicker); + fireEvent.change(datePicker, { target: { value: '2024, 8 August' } }); + user.click(document.querySelectorAll('.ant-picker-cell-selected')[0]); + + user.click(selectContext); + user.click(screen.getByTitle('context 1')); + + user.type(textareaDescription, createJobWithAllFields.description!); + user.click(buttonSave); + }); + + // Assert + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalled(); + expect(addTimelineMock).toHaveBeenCalled(); + expect(addTimelineMock).toHaveBeenCalledWith(createJobWithAllFields); + }); + }); + + // TODO: consider adding check for editiong the date type and date itself + it('should edit timeline data', async () => { + render( + , + ); + + const inputTitle = screen.getByTestId('input-title'); + const selectContext = screen.getByRole('combobox', { + name: /Контекст/i, + }); + const textareaDescription = screen.getByTestId('textarea-description'); + const buttonSave = screen.getByTestId('button-save'); + + const editedTimeLine = { + title: 'edited title', + description: 'edited description', + historicalContexts: [{ id: 2, modelState: 0, title: 'context 2' }], + }; + + await waitFor(() => { + user.clear(inputTitle); + user.clear(textareaDescription); + }); + + await waitFor(async () => { + user.type(inputTitle, editedTimeLine.title); + await waitFor(() => { + expect(onChangeMock).toHaveBeenLastCalledWith('title', editedTimeLine.title); + }); + + user.type(textareaDescription, editedTimeLine.description); + await waitFor(() => { + expect(onChangeMock).toHaveBeenLastCalledWith('description', editedTimeLine.description); + }); + + user.click(selectContext); + user.click(screen.getByTitle('context 2')); + expect(onChangeMock).toHaveBeenLastCalledWith('historicalContexts', editedTimeLine.historicalContexts); + + user.click(buttonSave); + }); + }); + + it('should check text amount restrictions', async () => { + render( + , + ); + + // Arrange + const inputTitle = screen.getByTestId('input-title'); + const textareaDescription = screen.getByTestId('textarea-description') as HTMLTextAreaElement; + + const titleRestriction = 26; + const descriptionRestriction = 400; + const text = 'String which excides text amount limit'; + const longText = text; + const veryLongText = text.repeat(11); + + // Act + await waitFor(() => { + user.type(inputTitle, longText); + + // user.type() takes too much time to input all the text, so fireEvent.change() partially + // fills description and user.type() tries to exceed text amount restrictions + fireEvent.change(textareaDescription, { target: { value: veryLongText } }); + user.type(textareaDescription, longText); + }); + + // Assert + expect(inputTitle.getAttribute('value')).toHaveLength(titleRestriction); + expect(textareaDescription.value.length).toBe(descriptionRestriction); + }); +}); diff --git a/src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.tsx b/src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.tsx index 0054dc2da..e9b6c2680 100644 --- a/src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.tsx +++ b/src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.tsx @@ -5,7 +5,7 @@ import '@features/AdminPage/AdminModal.styles.scss'; import { observer } from 'mobx-react-lite'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import getNewMinNegativeId from '@app/common/utils/newIdForStore'; -import useMobx from '@app/stores/root-store'; +import useMobx from '@stores/root-store'; import CancelBtn from '@assets/images/utils/Cancel_btn.svg'; import { ModelState } from '@models/enums/model-state'; import dayjs from 'dayjs'; @@ -14,7 +14,6 @@ import { Button, DatePicker, Form, Input, message, Modal, Popover, Select, } from 'antd'; -import TextArea from 'antd/es/input/TextArea'; import createTagValidator from '@/app/common/utils/selectValidation.utility'; import TimelineItem, { @@ -249,7 +248,12 @@ const NewTimelineModal: React.FC = observer(({ timelineIt label="Назва: " rules={[{ required: true, message: 'Введіть назву', max: MAX_LENGTH.title }]} > - onChange('title', e.target.value)} /> + onChange('title', e.target.value)} + data-testid="input-title" + />
@@ -261,6 +265,7 @@ const NewTimelineModal: React.FC = observer(({ timelineIt setDateTimePickerType(val); onChange('date', val); }} + data-testid="select-date" /> = observer(({ timelineIt ? 'yyyy' : 'yyyy, mm')} onChange={(value) => onChange('date', value?.toString())} + data-testid="date-picker" />
@@ -293,6 +299,7 @@ const NewTimelineModal: React.FC = observer(({ timelineIt label="Контекст: " validateStatus={errorMessage ? 'error' : ''} help={errorMessage} + data-testid="select-context" >