diff --git a/.env b/.env index e8f0ecca3..e83268bfe 100644 --- a/.env +++ b/.env @@ -2,4 +2,5 @@ REACT_APP_BACKEND_URL=https://localhost:5001/api REACT_APP_TEMPVAL=tempval API_URL=https://best.api.ever.com REACT_APP_GOOGLE_ANALYTICS=tempval -RECAPTCHA_SITE_KEY="6LeUO3ApAAAAAOC7F4v0qTsSwIR9mZu33SWjAAtM" \ No newline at end of file +RECAPTCHA_SITE_KEY="6LeUO3ApAAAAAOC7F4v0qTsSwIR9mZu33SWjAAtM" +VERSION=1.1.0 diff --git a/GITVERSION_PROPERTIES b/GITVERSION_PROPERTIES new file mode 100644 index 000000000..1408ab8e3 --- /dev/null +++ b/GITVERSION_PROPERTIES @@ -0,0 +1,35 @@ +{ + "Major": 1, + "Minor": 1, + "Patch": 0, + "PreReleaseTag": "beta.1", + "PreReleaseTagWithDash": "-beta.1", + "PreReleaseLabel": "beta", + "PreReleaseLabelWithDash": "-beta", + "PreReleaseNumber": 1, + "WeightedPreReleaseNumber": 30001, + "BuildMetaData": 47, + "BuildMetaDataPadded": "0047", + "FullBuildMetaData": "47.Branch.release-1.0.0.Sha.283d01e8424001490c227db838d965580c59935e", + "MajorMinorPatch": "1.0.0", + "SemVer": "1.0.0-beta.1", + "LegacySemVer": "1.0.0-beta1", + "LegacySemVerPadded": "1.0.0-beta0001", + "AssemblySemVer": "1.0.0.0", + "AssemblySemFileVer": "1.0.0.0", + "FullSemVer": "1.0.0-beta.1+47", + "InformationalVersion": "1.0.0-beta.1+47.Branch.release-1.0.0.Sha.283d01e8424001490c227db838d965580c59935e", + "BranchName": "release/1.0.0", + "EscapedBranchName": "release-1-0-0", + "Sha": "283d01e8424001490c227db838d965580c59935e", + "ShortSha": "283d01e", + "NuGetVersionV2": "1.0.0-beta0001", + "NuGetVersion": "1.0.0-beta0001", + "NuGetPreReleaseTagV2": "beta0001", + "NuGetPreReleaseTag": "beta0001", + "VersionSourceSha": "bd3aa573f4e0308049bbef8c6639abd2851495a0", + "CommitsSinceVersionSource": 47, + "CommitsSinceVersionSourcePadded": "0047", + "UncommittedChanges": 1, + "CommitDate": "2024-09-12" +} diff --git a/Jenkinsfile b/Jenkinsfile index 118701c22..dbb63a6bd 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -57,6 +57,7 @@ pipeline { sh "cat version" vers = readFile(file: 'version').trim() sh "echo ${vers}" + env.vers="${vers}" env.CODE_VERSION = readFile(file: 'version').trim() echo "${env.CODE_VERSION}" SEM_VERSION="${env.CODE_VERSION}" @@ -234,14 +235,15 @@ pipeline { } steps { script { - + git branch: 'master', credentialsId: 'test_git_user', url: 'git@github.com:ita-social-projects/StreetCode_Client.git' sh 'echo ${BRANCH_NAME}' sh "git checkout master" sh 'echo ${BRANCH_NAME}' - sh "git merge release/${env.SEM_VERSION}" - sh "npm version ${env.SEM_VERSION} -m 'Upgrade to %s as part of release'" - - sh "git push origin main" + sh 'git merge ${BRANCH_NAME}' + sh "npm version ${env.vers} --allow-same-version --no-git-tag-version" + sh "git add ." + sh "git commit -m 'Upgrade to %s as part of release'" + sh "git push origin master" } } diff --git a/__mocks__/@stores/root-store.tsx b/__mocks__/@stores/root-store.tsx index 193407898..021eb3058 100644 --- a/__mocks__/@stores/root-store.tsx +++ b/__mocks__/@stores/root-store.tsx @@ -25,6 +25,13 @@ export const useModalContext = () => ({ }, }); +export const useAudioContext = () => ({ + audio: { + base64: 'base64Mock', + mimeType: 'audio/mpeg', + }, +}); + export const useMobx = () => ({ newsStore: { updateNews: mockUpdateNews, diff --git a/config/webpack.dev.js b/config/webpack.dev.js index 972ea2752..e44e562cb 100644 --- a/config/webpack.dev.js +++ b/config/webpack.dev.js @@ -38,53 +38,57 @@ module.exports = { new Dotenv({ path: `./.env`, }), - // new GenerateSW({ - // skipWaiting: true, - // clientsClaim: true, - // maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, - // runtimeCaching: [ - // { - // urlPattern: /\.(?:js|css)$/, - // handler: 'CacheFirst', - // options: { - // cacheName: 'static-resources', - // expiration: { - // maxAgeSeconds: 60 * 60 * 24 * 7, // 1 week - // }, - // }, - // }, - // { - // urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/, - // handler: 'CacheFirst', - // options: { - // cacheName: 'image-resources', - // expiration: { - // maxAgeSeconds: 60 * 60 * 24 * 7, // 1 week - // }, - // }, - // }, - // { - // urlPattern: /\.(?:woff|woff2|ttf|otf)$/, - // handler:'CacheFirst', - // options: { - // cacheName: 'font-resources', - // expiration: { - // maxAgeSeconds: 60 * 60 * 24 * 90, // 3 months - // }, - // }, - // }, - // { - // urlPattern: /^https?.*/, - // handler: 'NetworkFirst', - // options: { - // cacheName: 'external-resources', - // expiration: { - // maxEntries: 260 - // }, - // }, - // }, - // ], - // }), + new GenerateSW({ + skipWaiting: true, + clientsClaim: true, + maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, + runtimeCaching: [ + { + urlPattern: /env-config\.js$/, + handler: 'NetworkOnly' + }, + { + urlPattern: /\.(?:js|css)$/, + handler: 'CacheFirst', + options: { + cacheName: 'static-resources', + expiration: { + maxAgeSeconds: 60 * 60 * 24 * 7, // 1 week + }, + }, + }, + { + urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/, + handler: 'CacheFirst', + options: { + cacheName: 'image-resources', + expiration: { + maxAgeSeconds: 60 * 60 * 24 * 7, // 1 week + }, + }, + }, + { + urlPattern: /\.(?:woff|woff2|ttf|otf)$/, + handler:'CacheFirst', + options: { + cacheName: 'font-resources', + expiration: { + maxAgeSeconds: 60 * 60 * 24 * 90, // 3 months + }, + }, + }, + { + urlPattern: /^https?.*/, + handler: 'NetworkFirst', + options: { + cacheName: 'external-resources', + expiration: { + maxEntries: 260 + }, + }, + }, + ], + }), ], optimization: { splitChunks: { diff --git a/config/webpack.prod.js b/config/webpack.prod.js index 31219862d..c39d6c22b 100644 --- a/config/webpack.prod.js +++ b/config/webpack.prod.js @@ -52,9 +52,13 @@ module.exports = { clientsClaim: true, maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, runtimeCaching: [ + { + urlPattern: /env-config\.js$/, + handler: 'NetworkOnly' + }, { urlPattern: /\.(?:js|css)$/, - handler: 'CacheFirst', + handler: 'NetworkFirst', options: { cacheName: 'static-resources', expiration: { @@ -65,7 +69,7 @@ module.exports = { }, { urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/, - handler: 'CacheFirst', + handler: 'StaleWhileRevalidate', options: { cacheName: 'image-resources', expiration: { @@ -75,7 +79,7 @@ module.exports = { }, { urlPattern: /\.(?:woff|woff2|ttf|otf)$/, - handler:'CacheFirst', + handler:'StaleWhileRevalidate', options: { cacheName: 'font-resources', expiration: { diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 378a94bf1..de24fbde7 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -4,6 +4,9 @@ server { root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html; + location ~ ^/(index.html|main.bundle.js){ + add_header Cache-Control "private, no-store, no-cache"; + } } error_page 500 502 503 504 /50x.html; location = /50x.html { diff --git a/package-lock.json b/package-lock.json index 895a7a8ba..33396d25a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "streetcode", - "version": "0.1.0", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { diff --git a/package.json b/package.json index e28a8690a..551e61be0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "streetcode", "author": "SoftServe IT Academy (https://career.softserveinc.com/uk-ua/)", "license": "MIT", - "version": "0.1.0", + "version": "1.1.0", "private": true, "dependencies": { "@ant-design/icons": "^4.8.0", diff --git a/public/env-config.js b/public/env-config.js index 111a97b97..afa332a89 100644 --- a/public/env-config.js +++ b/public/env-config.js @@ -3,4 +3,5 @@ window._env_ = { API_URL: "https://stageback.streetcode.com.ua/api", REACT_APP_GOOGLE_ANALYTICS: "REACT_APP_GOOGLE_ANALYTICS_VALUE", RECAPTCHA_SITE_KEY: "6LeUO3ApAAAAAOC7F4v0qTsSwIR9mZu33SWjAAtM", + VERSION: "1.1.0" } diff --git a/src/app/common/components/ArtGallery/ArtGalleryBlock.component.tsx b/src/app/common/components/ArtGallery/ArtGalleryBlock.component.tsx index 12e43ea33..3f2ccc3e7 100644 --- a/src/app/common/components/ArtGallery/ArtGalleryBlock.component.tsx +++ b/src/app/common/components/ArtGallery/ArtGalleryBlock.component.tsx @@ -90,13 +90,14 @@ const ArtGallery = ({ if (streetcodeIdValidAndFetchingRequired()) { secondRender.current = true; if (isFillArtsStore) { - await fetchAllToDefaultTemplate(getStreetCodeId !== -1 ? getStreetCodeId : parseId); + let currStreetcodeId = getStreetCodeId !== -1 ? getStreetCodeId : parseId; + const startingSlide = await fetchAllToDefaultTemplate(currStreetcodeId); - let currentSlide = 0; + let currentSlide = startingSlide ?? 0; while (currentSlide < MAX_SLIDES_AMOUNT) { try { // eslint-disable-next-line no-await-in-loop - await fetchAllArtSlidesByStreetcodeId(getStreetCodeId !== -1 ? getStreetCodeId : parseId, currentSlide); + await fetchAllArtSlidesByStreetcodeId(currStreetcodeId, currentSlide); currentSlide += amountOfSlides; } catch (error: unknown) { @@ -340,4 +341,4 @@ const ArtGallery = ({ ); }; -export default observer(ArtGallery); \ No newline at end of file +export default observer(ArtGallery); diff --git a/src/app/common/components/AudioPlayer/AudioPlayer.component.spec.tsx b/src/app/common/components/AudioPlayer/AudioPlayer.component.spec.tsx new file mode 100644 index 000000000..553685e13 --- /dev/null +++ b/src/app/common/components/AudioPlayer/AudioPlayer.component.spec.tsx @@ -0,0 +1,121 @@ +import { act } from 'react-dom/test-utils'; +import { + fireEvent, + render, + screen, +} from '@testing-library/react'; +import user from '@testing-library/user-event'; + +import '@testing-library/jest-dom'; + +import { AudioPlayer } from './AudioPlayer.component'; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: unknown) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => {}, + }), +}); + +jest.mock('@images/audio-player/PauseBtn.webp', () => 'test-file-stub'); +jest.mock('@images/audio-player/PlayBtn.webp', () => 'test-file-stub'); + +global.HTMLMediaElement.prototype.play = jest.fn(); +global.HTMLMediaElement.prototype.pause = jest.fn(); +global.HTMLMediaElement.prototype.addEventListener = jest.fn((event, callback) => { + if (event === 'ended') { + setTimeout(() => { + if (typeof callback === 'function') { + callback(new Event('ended')); + } + }, 0); + } +}); + +describe('AudioPlayer test', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be rendered', async () => { + const { container } = await render(); + + const audio = container.getElementsByTagName('audio'); + expect(audio).toHaveLength(1); + + const audioSlider = screen.getByRole('slider'); + expect(audioSlider).toBeInTheDocument(); + + const buttonPlayPause = container.getElementsByClassName('buttonContainer'); + expect(buttonPlayPause).toHaveLength(1); + + const icon = buttonPlayPause[0].getElementsByTagName('img'); + expect(icon).toHaveLength(1); + }); + + it('should immediately play', async () => { + const { container } = await render(); + + const audio = container.getElementsByTagName('audio')[0]; + const icon = container.getElementsByTagName('img')[0]; + + expect(audio.play).toHaveBeenCalled(); + expect(icon.alt).toBe('Пауза'); + }); + + it('should not immediately play', async () => { + const { container } = await render(); + + const audio = container.getElementsByTagName('audio')[0]; + const icon = container.getElementsByTagName('img')[0]; + + expect(audio.play).not.toHaveBeenCalled(); + expect(icon.alt).toBe('Програти'); + }); + + it('should play/pause audio and toggle button icon', async () => { + const { container } = await render(); + + const audio = container.getElementsByTagName('audio')[0]; + const buttonPlayPause = container.getElementsByClassName('buttonContainer')[0]; + const icon = buttonPlayPause.getElementsByTagName('img')[0]; + + act(() => { + user.click(icon); + }); + + expect(audio.play).toHaveBeenCalled(); + expect(icon.alt).toBe('Пауза'); + + act(() => { + user.click(icon); + }); + + expect(audio.pause).toHaveBeenCalled(); + expect(icon.alt).toBe('Програти'); + }); + + it('should change slider and audio position', async () => { + const { container } = await render(); + + const audio = container.getElementsByTagName('audio')[0]; + const audioSlider = screen.getByRole('slider') as HTMLInputElement; + + expect(audio.currentTime).toBe(0); + expect(audioSlider.value).toBe('0'); + + act(() => { + fireEvent.change(audioSlider, { target: { value: '50' } }); + }); + + expect(audio.currentTime).toBe(50); + expect(audioSlider.value).toBe('50'); + }); +}); diff --git a/src/app/common/components/AudioPlayer/AudioPlayer.component.tsx b/src/app/common/components/AudioPlayer/AudioPlayer.component.tsx index 436cce2fb..cbdf584d2 100644 --- a/src/app/common/components/AudioPlayer/AudioPlayer.component.tsx +++ b/src/app/common/components/AudioPlayer/AudioPlayer.component.tsx @@ -98,7 +98,7 @@ const AudioPlayer: React.FC<{ immediatelyPlay?: boolean }> = ({ immediatelyPlay useEffect(() => { resetAudio(); - if (!isPlaying) { + if (!isPlaying && immediatelyPlay) { play(); } }, [audioState]); diff --git a/src/app/common/components/ContactForm/ContactForm.component.spec.tsx b/src/app/common/components/ContactForm/ContactForm.component.spec.tsx new file mode 100644 index 000000000..916ca721b --- /dev/null +++ b/src/app/common/components/ContactForm/ContactForm.component.spec.tsx @@ -0,0 +1,215 @@ +import { + cleanup, fireEvent, render, screen, waitFor, +} from '@testing-library/react'; +import user from '@testing-library/user-event'; + +import EmailApi from '@/app/api/email/email.api'; + +import '@testing-library/jest-dom'; + +import ContactForm from './ContactForm.component'; + +// needed to render component without errors +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: unknown) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => {}, + }), +}); + +// mock ReCAPTCHA component +const onCaptchaMock = jest.fn(); +jest.mock('react-google-recaptcha', () => jest.fn(({ onChange }) => ( +
+ +
+))); + +// mock backend api calls +jest.mock('@/app/api/email/email.api', () => ({ + send: jest.fn(() => {}), +})); + +describe('ContactForm test', () => { + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + it('should be rendered', async () => { + render( + , + ); + + const textareaMessage = screen.getByPlaceholderText(/Наші серця/i); + const inputEmail = screen.getByPlaceholderText(/E-mail/i); + const buttonSend = screen.getByText(/Відправити/i); + + await waitFor(() => { + expect(textareaMessage).toBeInTheDocument(); + expect(inputEmail).toBeInTheDocument(); + expect(buttonSend).toBeInTheDocument(); + }); + }); + + it('should send Email with filled fields', async () => { + render( + , + ); + + // Arrange + const textareaMessage = screen.getByPlaceholderText(/Наші серця/i) as HTMLTextAreaElement; + const inputEmail = screen.getByPlaceholderText(/E-mail/i) as HTMLInputElement; + const captchaButton = screen.getByText(/Verify ReCAPTCHA/i); + const sendButton = screen.getByText(/Відправити/i); + + const message = 'Some interesting message'; + const email = 'test@mail.com'; + + // Act + await waitFor(async () => { + user.type(inputEmail, email); + user.type(textareaMessage, message); + user.click(captchaButton); + user.click(sendButton); + }); + + // Assert + expect(textareaMessage.value).toBe(message); + expect(inputEmail.value).toBe(email); + expect(onCaptchaMock).toHaveBeenCalled(); + expect(EmailApi.send).toHaveBeenCalled(); + }); + + it('should not send Email with invalid email field', async () => { + render( + , + ); + + // Arrange + const textareaMessage = screen.getByPlaceholderText(/Наші серця/i) as HTMLTextAreaElement; + const inputEmail = screen.getByPlaceholderText(/E-mail/i) as HTMLInputElement; + const captchaButton = screen.getByText(/Verify ReCAPTCHA/i); + const sendButton = screen.getByText(/Відправити/i); + + const message = 'Some interesting message'; + const email = 'invalid email'; + + // Act + await waitFor(async () => { + user.type(inputEmail, email); + user.type(textareaMessage, message); + user.click(captchaButton); + user.click(sendButton); + }); + + // Assert + expect(textareaMessage.value).toBe(message); + expect(inputEmail.value).toBe(email); + expect(onCaptchaMock).toHaveBeenCalled(); + expect(EmailApi.send).not.toHaveBeenCalled(); + }); + + it('should not send Email with epty message field', async () => { + render( + , + ); + + // Arrange + const textareaMessage = screen.getByPlaceholderText(/Наші серця/i) as HTMLTextAreaElement; + const inputEmail = screen.getByPlaceholderText(/E-mail/i) as HTMLInputElement; + const captchaButton = screen.getByText(/Verify ReCAPTCHA/i); + const sendButton = screen.getByText(/Відправити/i); + + const email = 'valid@email.com'; + + // Act + await waitFor(async () => { + user.type(inputEmail, email); + user.click(captchaButton); + user.click(sendButton); + }); + + // Assert + expect(textareaMessage.value).toBe(''); + expect(inputEmail.value).toBe(email); + expect(onCaptchaMock).toHaveBeenCalled(); + expect(EmailApi.send).not.toHaveBeenCalled(); + }); + + it('should not send Email without ReCAPTCHA', async () => { + render( + , + ); + + // Arrange + const textareaMessage = screen.getByPlaceholderText(/Наші серця/i) as HTMLTextAreaElement; + const inputEmail = screen.getByPlaceholderText(/E-mail/i) as HTMLInputElement; + const sendButton = screen.getByText(/Відправити/i); + + const message = 'Some interesting message'; + const email = 'valid@email.com'; + + // Act + await waitFor(async () => { + user.type(inputEmail, email); + user.type(textareaMessage, message); + user.click(sendButton); + }); + + // Assert + expect(inputEmail.value).toBe(email); + expect(textareaMessage.value).toBe(message); + expect(onCaptchaMock).not.toHaveBeenCalled(); + expect(EmailApi.send).not.toHaveBeenCalled(); + }); + + it('should check text amount restrictions and Email validation', async () => { + render( + , + ); + + // Arrange + const textareaMessage = screen.getByPlaceholderText(/Наші серця/i) as HTMLTextAreaElement; + const inputEmail = screen.getByPlaceholderText(/E-mail/i); + const buttonSend = screen.getByText(/Відправити/i); + + const descriptionRestriction = 500; + const invalidEmail = 'invalid@email.c'; + const text = 'String which excides text amount limit'; + const veryLongText = text.repeat(13); + + // Act + await waitFor(async () => { + user.type(inputEmail, invalidEmail); + + // 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(textareaMessage, { target: { value: veryLongText } }); + user.type(textareaMessage, text); + + user.click(buttonSend); + }); + + // Assert + const validationMessage = await screen.findByText(/E-mail може містити/i); + expect(validationMessage).toBeInTheDocument(); + expect(textareaMessage.value.length).toBe(descriptionRestriction); + expect(EmailApi.send).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/common/components/ContactForm/ContactForm.component.tsx b/src/app/common/components/ContactForm/ContactForm.component.tsx index 25a584515..d60900248 100644 --- a/src/app/common/components/ContactForm/ContactForm.component.tsx +++ b/src/app/common/components/ContactForm/ContactForm.component.tsx @@ -1,12 +1,13 @@ import './ContactForm.styles.scss'; -import { LegacyRef, forwardRef, useImperativeHandle, useRef, useState } from 'react'; +import { forwardRef, useImperativeHandle, useRef, useState } from 'react'; import ReCAPTCHA from 'react-google-recaptcha'; import { Button, Form, Input, message } from 'antd'; import EmailApi from '@/app/api/email/email.api'; import Email from '@/models/email/email.model'; + import { ERROR_MESSAGES } from '../../constants/error-messages.constants'; const MAX_SYMBOLS = 500; @@ -18,7 +19,7 @@ interface Props { const ContactForm = forwardRef((customClass: Props, ref) => { const [formData, setFormData] = useState({ email: '', message: '' }); const [isVerified, setIsVerified] = useState(false); - const [messageApi, messageContextHolder] = message.useMessage({maxCount: 3}); + const [messageApi, messageContextHolder] = message.useMessage({ maxCount: 3 }); const [form] = Form.useForm(); const recaptchaRef = useRef(null); const siteKey = window._env_.RECAPTCHA_SITE_KEY; @@ -40,44 +41,47 @@ const ContactForm = forwardRef((customClass: Props, ref) => { }, })); + const successMessage = () => { + messageApi.open({ + type: 'success', + content: 'Лист успішно надісланий', + }); + }; + + const errorMessage = (error: string) => { + messageApi.open({ + type: 'error', + content: error, + }); + }; + const onFinish = () => { if (isVerified) { const token = recaptchaRef?.current?.getValue(); - const newEmail: Email = { from: formData.email, content: formData.message, token: token }; + const newEmail: Email = { + from: formData.email, + source: 'сторінка Контакти', + content: formData.message, + token, + }; EmailApi.send(newEmail) .then(() => { successMessage(); }) .catch((error) => { - if (error === 429) { + if (error.status === 429) { errorMessage(MESSAGE_LIMIT); - } - else { + } else { errorMessage(SOMETHING_IS_WRONG); } }); recaptchaRef.current?.reset(); setIsVerified(false); - } - else { + } else { errorMessage(RECAPTCHA_CHECK); } }; - const successMessage = () => { - messageApi.open({ - type: 'success', - content: 'Лист успішно надісланий', - }); - }; - - const errorMessage = (message: string) => { - messageApi.open({ - type: 'error', - content: message, - }); - }; - return (
{messageContextHolder} @@ -109,6 +113,7 @@ const ContactForm = forwardRef((customClass: Props, ref) => { name="message" autoSize={{ minRows: 4, maxRows: 4 }} placeholder="Наші серця, очі та вуха відкриті до твоїх креативних повідомлень!" + showCount maxLength={MAX_SYMBOLS} onChange={handleChange} /> @@ -135,10 +140,11 @@ const ContactForm = forwardRef((customClass: Props, ref) => {
diff --git a/src/app/common/components/ContactForm/ContactForm.styles.scss b/src/app/common/components/ContactForm/ContactForm.styles.scss index 4d535d1f0..ccff3d2bd 100644 --- a/src/app/common/components/ContactForm/ContactForm.styles.scss +++ b/src/app/common/components/ContactForm/ContactForm.styles.scss @@ -10,6 +10,10 @@ border: none; box-shadow: none !important; } + + .ant-input-affix-wrapper > textarea.ant-input { + border: 1px solid #D3CDCA; + } } .formWrapper { @@ -49,14 +53,6 @@ .contactForm { @include mut.flexed($direction: column, $gap: 24px); - .custom-character-counter { - margin-bottom: -12px; - text-align: right; - font-family: "Roboto", "Helvetica Neue", sans-serif !important; - font-size: 16px; - color: #00000073; - } - textarea { resize: none; outline: none; @@ -80,15 +76,12 @@ overflow: auto !important; } + .ant-input-affix-wrapper > textarea.ant-input { + @include mut.with-font($font-family: ft.$roboto-font, $font-weight: 300, $font-size: 20px); + } + .textareaBlock { position: relative; - - .amountSymbols { - position: absolute; - bottom: f.pxToRem(10px); - right: f.pxToRem(10px); - color: #C4C4C4; - } } .ant-input:hover, @@ -115,9 +108,6 @@ background-color: #E04031; } - .ant-form-item .ant-form-item-explain-error { - } - button { @include mut.with-font($font-family: ft.$roboto-font, $font-weight: 500, $font-size: 20px); line-height: 23px; @@ -136,6 +126,10 @@ } } + .ant-input-show-count { + z-index: 0 !important; + } + .required-input { position: relative; } @@ -197,8 +191,6 @@ } .contactForm { - @include mut.flexed($direction: column, $gap: 16px); - button { width: 100%; padding: f.pxToRem(15px) 0; @@ -210,12 +202,8 @@ line-height: 21px; } - .ant-input-textarea-show-count::after { - top: f.pxToRem(110px); - } - - .ant-input-textarea-show-count .ant-input-data-count { - margin-bottom: f.pxToRem(-18px); + .ant-input-affix-wrapper > textarea.ant-input { + @include mut.with-font($font-family: ft.$roboto-font, $font-weight: 300, $font-size: 16px); } } } @@ -225,4 +213,8 @@ .formWrapper .formTitleContainer .formTitle { text-align: center; } -} \ No newline at end of file + + .ant-form-item-explain-error { + padding: 0 45px; + } +} diff --git a/src/app/common/components/CopyWithCopyright.component.tsx b/src/app/common/components/CopyWithCopyright.component.tsx index a57d26871..ab6b46399 100644 --- a/src/app/common/components/CopyWithCopyright.component.tsx +++ b/src/app/common/components/CopyWithCopyright.component.tsx @@ -3,13 +3,14 @@ import React, { useRef } from 'react'; import FRONTEND_ROUTES from '../constants/frontend-routes.constants'; interface Props { - children: React.ReactNode; - copyrightText: string; + children: React.ReactNode; + copyrightText: string; + className?: string; } const MIN_LENGTH = 20; -const CopyWithCopyright = ({ children, copyrightText }: Props) => { +const CopyWithCopyright = ({ children, copyrightText, className }: Props) => { const textRef = useRef(null); const handleCopy = (event: React.ClipboardEvent) => { @@ -28,7 +29,7 @@ const CopyWithCopyright = ({ children, copyrightText }: Props) => { }; return ( -
+
{children}
); diff --git a/src/app/common/components/ErrorBoundary/ErrorBoundary.spec.tsx b/src/app/common/components/ErrorBoundary/ErrorBoundary.spec.tsx new file mode 100644 index 000000000..85cf87e09 --- /dev/null +++ b/src/app/common/components/ErrorBoundary/ErrorBoundary.spec.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ErrorBoundary from './ErrorBoundary'; + +const ProblemChild = () => { + throw new Error('Error thrown from problem child'); +}; + +describe('ErrorBoundary', () => { + test('renders children without error', () => { + render( + Fallback
}> +
Child Component
+ + ); + + expect(screen.getByText('Child Component')).toBeInTheDocument(); + }); + + test('renders fallback UI on error', () => { + render( + Fallback
}> + + + ); + + expect(screen.getByText('Fallback')).toBeInTheDocument(); + }); + + test('sets hasError state to true on error', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + render( + Fallback}> + + + ); + + expect(screen.getByText('Fallback')).toBeInTheDocument(); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); +}); \ No newline at end of file diff --git a/src/app/common/components/ErrorBoundary/ErrorBoundary.ts b/src/app/common/components/ErrorBoundary/ErrorBoundary.ts new file mode 100644 index 000000000..20cbccc58 --- /dev/null +++ b/src/app/common/components/ErrorBoundary/ErrorBoundary.ts @@ -0,0 +1,35 @@ +import { ReactNode, Component, ErrorInfo } from "react"; + +interface Props { + children: ReactNode; + fallback: ReactNode; +} + +interface State { + hasError: boolean; +} + +class ErrorBoundary extends Component { + public state: State = { + hasError: false + }; + + public static getDerivedStateFromError(_: Error): State { + return { hasError: true }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('ErrorBoundary caught an error: ', error, errorInfo); + this.setState({ hasError: true }); + } + + public render() { + if (this.state.hasError) { + return this.props.fallback; + } + + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/src/app/common/components/modals/Partners/PartnersModal.component.tsx b/src/app/common/components/modals/Partners/PartnersModal.component.tsx index 7380f2e32..b81299e06 100644 --- a/src/app/common/components/modals/Partners/PartnersModal.component.tsx +++ b/src/app/common/components/modals/Partners/PartnersModal.component.tsx @@ -25,7 +25,7 @@ const PartnersModal = () => { const { setModal, modalsState: { partners } } = modalStore; const [form] = Form.useForm(); const [formData, setFormData] = useState({ email: '', message: '' }); - const [messageApi, messageContextHolder] = message.useMessage({maxCount: 3}); + const [messageApi, messageContextHolder] = message.useMessage({ maxCount: 3 }); const [isVerified, setIsVerified] = useState(false); const recaptchaRef = useRef(null); const siteKey = window._env_.RECAPTCHA_SITE_KEY; @@ -36,7 +36,12 @@ const PartnersModal = () => { const onFinish = () => { if (isVerified) { const token = recaptchaRef?.current?.getValue(); - const newEmail: Email = { from: formData.email, content: formData.message, token: token }; + const newEmail: Email = { + from: formData.email, + source: 'сторінка Партнери', + content: formData.message, + token, + }; EmailApi.send(newEmail) .then(() => { onCancel(); @@ -116,9 +121,7 @@ const PartnersModal = () => { footer={null} onCancel={onCancel} closeIcon={( - - - + )} > {messageContextHolder} @@ -181,6 +184,7 @@ const PartnersModal = () => { onChange={handleVerify} onExpired={handleExpiration} ref={recaptchaRef} + hl='uk' /> diff --git a/src/app/common/components/modals/Survey/SurveyModal.styles.scss b/src/app/common/components/modals/Survey/SurveyModal.styles.scss index 5ace7ce83..fc3dae222 100644 --- a/src/app/common/components/modals/Survey/SurveyModal.styles.scss +++ b/src/app/common/components/modals/Survey/SurveyModal.styles.scss @@ -105,7 +105,22 @@ $StbgImg: '@assets/images/donates/bgStrLogo.webp'; @media screen and (max-width: 767px) { .surveyModal { - height: auto; + @include mut.sizedImportant(470px, auto); + + &.ant-modal { + .ant-modal-content { + background-position: center; + } + + .ant-modal-close { + @include mut.sizedImportant(50px, 50px); + @include mut.positioned-as(absolute, $left: 92%, $top: -20px); + + .ant-modal-close-x svg { + transform: scale(0.8); + } + } + } .surveyModalContent { >h3 { @@ -127,13 +142,11 @@ $StbgImg: '@assets/images/donates/bgStrLogo.webp'; &.ant-modal { .ant-modal-content { @include mut.bg-image($StbgImg, 35%, no-repeat); - background-position: left f.pxToRem(102px) top f.pxToRem(171px); } .ant-modal-close { - @include mut.positioned-as($position: absolute, $left: 309px, $top: -15px); @include mut.sizedImportant(40px, 40px); - @include mut.circular(65px, c.$pure-white-color); + @include mut.positioned-as($position: absolute, $left: 309px, $top: -15px); .ant-modal-close-x svg { transform: scale(0.6); @@ -161,3 +174,26 @@ $StbgImg: '@assets/images/donates/bgStrLogo.webp'; } } } + +@media screen and (max-width: 360px) { + .surveyModal { + width: auto !important; + height: auto !important; + max-width: max(calc(100vw - 32px), 230px); + max-height: f.pxToRem(640px); + + &.ant-modal { + .ant-modal-close { + @include mut.positioned-as($position: absolute, $left: max(calc(100vw - 60px), 202px), $top: -15px); + } + } + + .surveyModalContent { + .surveyModalParagraph1, + .surveyModalParagraph2, + .surveyModalParagraph3 { + height: auto; + } + } + } +} diff --git a/src/app/common/components/modals/Tags/TagsSliderModal/TagsSliderModal.component.tsx b/src/app/common/components/modals/Tags/TagsSliderModal/TagsSliderModal.component.tsx index de21da8f9..9842a1584 100644 --- a/src/app/common/components/modals/Tags/TagsSliderModal/TagsSliderModal.component.tsx +++ b/src/app/common/components/modals/Tags/TagsSliderModal/TagsSliderModal.component.tsx @@ -54,12 +54,14 @@ const TagsSliderModal = ({ streetCodeid, activeTagId, setActiveTagId, showAllTag )); - const initialSlide = showAllTags ? getAllTagsArray.findIndex((tag) => tag.id === activeTagId) : getTagArray.findIndex((tag) => tag.id === activeTagId); + const initialSlide = showAllTags + ? getAllTagsArray.findIndex((tag) => tag.id === activeTagId) + : getTagArray.findIndex((tag) => tag.id === activeTagId); const sliderProps = { className: 'tagSliderClass', infinite: false, - slidesToSho: 1, + slidesToShow: 1, arrows: false, swipe: false, dots: false, diff --git a/src/app/common/components/modals/validators/imageValidator.tsx b/src/app/common/components/modals/validators/imageValidator.tsx new file mode 100644 index 000000000..1fad35a63 --- /dev/null +++ b/src/app/common/components/modals/validators/imageValidator.tsx @@ -0,0 +1,27 @@ +import { SUPPORTED_IMAGE_FILE_TYPES } from '@/app/common/constants/file-types.constants'; +import { RuleObject } from 'rc-field-form/lib/interface'; + +const imageValidator = (_: RuleObject, file: any): Promise => { + if (file) { + let name = ''; + if (file.file) { + name = file.file.name.toLowerCase(); + } else if (file.name) { + name = file.name.toLowerCase(); + } + + const allowedExtensions = ['.jpeg', '.png', '.webp', '.jpg']; + + if (allowedExtensions.some((ext) => name.endsWith(ext))) { + return Promise.resolve(); + } + + return Promise.reject(new Error('Тільки файли з розширенням webp, jpeg, png, jpg дозволені!')); + } + + return Promise.reject(); +}; + +export const checkImageFileType = (type: string | undefined) => type && SUPPORTED_IMAGE_FILE_TYPES.includes(type); + +export default imageValidator; diff --git a/src/app/common/components/modals/validators/socialLinkValidator.tsx b/src/app/common/components/modals/validators/socialLinkValidator.tsx new file mode 100644 index 000000000..98c218bb2 --- /dev/null +++ b/src/app/common/components/modals/validators/socialLinkValidator.tsx @@ -0,0 +1,36 @@ +/* eslint-disable import/extensions */ + +import { doesUrlContainSiteName, isInvalidUrl } from '@/app/common/utils/checkUrl'; +import SocialItem from '@/models/social-link/socialItem'; + +export default function validateSocialLink( + link: string, + socialOptions: SocialItem[], + logoTypes: string[], + sourceLinks: { logoType: T }[], + socialName: string, +): Promise { + if (!link || isInvalidUrl(link)) { + return Promise.reject(new Error( + 'Недійсний формат посилання', + )); + } + + const logotype = socialOptions.find((opt) => opt.value === socialName)?.logo; + if (logotype === undefined // we need this explicit check because it can pass when logotype is 0 + || logotype === null + || (!doesUrlContainSiteName(link, logoTypes[Number(logotype)]))) { + return Promise.reject(new Error( + 'Посилання не співпадає з вибраним текстом', + )); + } + + const doesLinkWithLogoTypeAlreadyExist = sourceLinks.some((obj) => obj.logoType === Number(logotype)); + if (doesLinkWithLogoTypeAlreadyExist) { + return Promise.reject(new Error( + 'Посилання на таку соціальну мережу вже додано', + )); + } + + return Promise.resolve(); +} diff --git a/src/app/common/components/withClearCache.tsx b/src/app/common/components/withClearCache.tsx new file mode 100644 index 000000000..5d7edec7b --- /dev/null +++ b/src/app/common/components/withClearCache.tsx @@ -0,0 +1,42 @@ +/* eslint-disable no-extra-boolean-cast */ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-underscore-dangle */ +import { useEffect, useState } from 'react'; + +const WithClearCache: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isLatestBuildDate, setIsLatestBuildDate] = useState(true); + + const refreshCacheAndReload = () => { + if (caches) { + caches.keys().then((names) => { + for (const name of names) { + caches.delete(name); + } + console.log('Cache is cleared'); + window.location.reload(); + }); + } + }; + + useEffect(() => { + const localVersion = localStorage.getItem('VERSION'); + const envVersion = window._env_.VERSION; + if (!!envVersion) { + const isVersionMatches = localVersion === envVersion; + setIsLatestBuildDate(isVersionMatches); + if (!isVersionMatches) { + localStorage.setItem('VERSION', envVersion); + refreshCacheAndReload(); + } + } else { + refreshCacheAndReload(); + } + }, [isLatestBuildDate]); + + if (isLatestBuildDate) { + return children; + } + return null; +}; + +export default WithClearCache; diff --git a/src/app/common/constants/file-types.constants.ts b/src/app/common/constants/file-types.constants.ts new file mode 100644 index 000000000..6f52b3785 --- /dev/null +++ b/src/app/common/constants/file-types.constants.ts @@ -0,0 +1,6 @@ +export const SUPPORTED_IMAGE_FILE_TYPES = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/webp', +]; diff --git a/src/app/common/constants/social.constants.ts b/src/app/common/constants/social.constants.ts index acd7296aa..08c28a26b 100644 --- a/src/app/common/constants/social.constants.ts +++ b/src/app/common/constants/social.constants.ts @@ -1,7 +1,7 @@ const SOCIAL_MEDIA = { FACEBOOK: 'https://www.facebook.com/streetcodeua', INSTAGRAM: 'https://www.instagram.com/streetcodeua', - TWITTER: 'https://twitter.com/streetcodeua', + X: 'https://x.com/streetcodeua', TELEGRAM: 'https://t.me/streetcodeua', TIKTOK: 'https://www.tiktok.com/@streetcodeua', YOUTUBE: 'https://www.youtube.com/@streetcodeua', diff --git a/src/app/common/utils/checkUrl.tsx b/src/app/common/utils/checkUrl.tsx new file mode 100644 index 000000000..b69ca19ad --- /dev/null +++ b/src/app/common/utils/checkUrl.tsx @@ -0,0 +1,22 @@ +export const isValidUrl = (url: string): boolean => { + try { + // eslint-disable-next-line no-new + new URL(url); + return true; + } catch (error) { + return false; + } +}; + +export const isInvalidUrl = (url: string): boolean => !isValidUrl(url); + +export const doesUrlContainSiteName = (link: string, siteName: string): boolean => { + let pattern: string; + if (siteName.toLowerCase() === 'youtube') { + pattern = '^https?:\\/\\/(www\\.)?(youtube\\.com|youtu\\.be)\\/?.*$'; + } else { + pattern = `^https?:\\/\\/(www\\.)?(?:[a-zA-Z0-9-]+\\.)?${siteName}\\.[a-zA-Z]{2,6}\\/?.*$`; + } + const regex = new RegExp(pattern); + return regex.test(link); +}; diff --git a/src/app/layout/app/App.component.tsx b/src/app/layout/app/App.component.tsx index 8275c63dc..28163d3be 100644 --- a/src/app/layout/app/App.component.tsx +++ b/src/app/layout/app/App.component.tsx @@ -34,7 +34,7 @@ const App = () => {
- + {(pathname !== FRONTEND_ROUTES.BASE) && ( )} diff --git a/src/app/layout/footer/Footer.component.tsx b/src/app/layout/footer/Footer.component.tsx index f35ba17e2..41ab3f668 100644 --- a/src/app/layout/footer/Footer.component.tsx +++ b/src/app/layout/footer/Footer.component.tsx @@ -81,7 +81,7 @@ const Footer = () => {
  • - +
  • diff --git a/src/app/stores/streetcode-art-slide-store.ts b/src/app/stores/streetcode-art-slide-store.ts index ac555e2f8..018fcd4e4 100644 --- a/src/app/stores/streetcode-art-slide-store.ts +++ b/src/app/stores/streetcode-art-slide-store.ts @@ -81,9 +81,13 @@ export default class StreetcodeArtSlideStore { } }; - public fetchAllToDefaultTemplate = async (streetcodeid: number) => { - const slidesCount = await StreetcodeArtApi.getAllCountByStreetcodeId(streetcodeid) - this.streetcodeArtSlides = new Array(slidesCount).fill(bindStreetcodeIdToDefaultSlide(streetcodeid), 0, slidesCount) + public fetchAllToDefaultTemplate = async (streetcodeId: number): Promise => { + if (this.streetcodeWasFetched.includes(streetcodeId)) {return;} + const startingPosition = this.streetcodeArtSlides.length; + const slidesCount = await StreetcodeArtApi.getAllCountByStreetcodeId(streetcodeId) + const placeholders = new Array(slidesCount).fill(bindStreetcodeIdToDefaultSlide(streetcodeId), 0, slidesCount); + this.streetcodeArtSlides.push(...placeholders); + return startingPosition; } public fetchAllArtSlidesByStreetcodeId = async (streetcodeid: number, startIndex: number) => { @@ -125,4 +129,4 @@ export default class StreetcodeArtSlideStore { return convertedSlide as unknown as StreetcodeArtSlideCreateUpdate; }); } -} \ No newline at end of file +} diff --git a/src/assets/images/catalog/loading.webp b/src/assets/images/catalog/loading.webp index 097a9a697..f215912dd 100644 Binary files a/src/assets/images/catalog/loading.webp and b/src/assets/images/catalog/loading.webp differ diff --git a/src/assets/sass/mixins/_utils.mixins.scss b/src/assets/sass/mixins/_utils.mixins.scss index 65f5f5437..404767dd5 100644 --- a/src/assets/sass/mixins/_utils.mixins.scss +++ b/src/assets/sass/mixins/_utils.mixins.scss @@ -22,22 +22,34 @@ @mixin set-positioning($top, $right, $bottom, $left) { - @if ($top == 'auto' or unit($top) == '%') { + @if (str-index('#{$top}', 'calc') + or str-index('#{$top}', 'max')) { + top: #{$top}; + } @else if ($top == 'auto' or unit($top) == '%') { top: $top; } @else { top: f.pxToRem($top); } - @if ($right == 'auto' or unit($right) == '%') { + @if (str-index('#{$right}', 'calc') + or str-index('#{$right}', 'max')) { + right: #{$right}; + } @else if ($right == 'auto' or unit($right) == '%') { right: $right; } @else { right: f.pxToRem($right); } - @if ($bottom == 'auto' or unit($bottom) == '%') { + @if (str-index('#{$bottom}', 'calc') + or str-index('#{$bottom}', 'max')) { + bottom: #{$bottom}; + } @else if ($bottom == 'auto' or unit($bottom) == '%') { bottom: $bottom; } @else { bottom: f.pxToRem($bottom); } - @if ($left == 'auto' or unit($left) == '%') { + @if (str-index('#{$left}', 'calc') + or str-index('#{$left}', 'max')) { + left: #{$left}; + } @else if ($left == 'auto' or unit($left) == '%') { left: $left; } @else { left: f.pxToRem($left); diff --git a/src/features/AboutUsPage/TeamMembers/TeamMembers.component.scss b/src/features/AboutUsPage/TeamMembers/TeamMembers.component.scss index d112fe552..f78da1106 100644 --- a/src/features/AboutUsPage/TeamMembers/TeamMembers.component.scss +++ b/src/features/AboutUsPage/TeamMembers/TeamMembers.component.scss @@ -44,7 +44,7 @@ transition: .5s; filter: c.$active-arrow-filter-color; &:hover { - filter: c.$selected-arrow-filter-color; + filter: c.$selected-arrow-filter-color; } } @@ -53,6 +53,7 @@ align-items: center; justify-content: center; margin: 0 auto; + -webkit-tap-highlight-color: transparent; } .squareParent{ @@ -60,11 +61,25 @@ } .teamMembersSlider { .swiper { + overflow: visible; padding: f.pxToRem(30px) 0; .swiper-slide{ width: fit-content; height: auto; } - } -} + .swiper-pagination { + bottom: 0; + .swiper-pagination-bullet { + @include mut.sized(8px, 8px); + background: c.$slider-base-dot-color; + border-radius: 0%; + transition: .5s; + } + + .swiper-pagination-bullet-active { + background: c.$dark-red-color; + } + } + } +} \ No newline at end of file diff --git a/src/features/AboutUsPage/TeamMembers/TeamPositionsSlider/TeamMembersSlider.component.tsx b/src/features/AboutUsPage/TeamMembers/TeamPositionsSlider/TeamMembersSlider.component.tsx index d3f18abed..91d8a5b6f 100644 --- a/src/features/AboutUsPage/TeamMembers/TeamPositionsSlider/TeamMembersSlider.component.tsx +++ b/src/features/AboutUsPage/TeamMembers/TeamPositionsSlider/TeamMembersSlider.component.tsx @@ -23,6 +23,7 @@ const TeamMemberSlider: React.FC = ({ team }) => { spaceBetween={20} slideToClickedSlide loop + pagination={{ clickable: true, }} > { team.map((member) => ( diff --git a/src/features/AboutUsPage/Vacancies/Vacancy/VacancyModal/VacancyModal.component.tsx b/src/features/AboutUsPage/Vacancies/Vacancy/VacancyModal/VacancyModal.component.tsx index e6981992e..305b6b578 100644 --- a/src/features/AboutUsPage/Vacancies/Vacancy/VacancyModal/VacancyModal.component.tsx +++ b/src/features/AboutUsPage/Vacancies/Vacancy/VacancyModal/VacancyModal.component.tsx @@ -33,10 +33,7 @@ const VacancyModal = ({ isOpen, setOpen, job }: Props) => { if (window.innerWidth > SCREEN_SIZES.phone) { return 400; } - if (window.innerWidth > SCREEN_SIZES.smallPhone) { - return 310; - } - return 270; + return window.innerWidth; }; return ( diff --git a/src/features/AboutUsPage/Vacancies/Vacancy/VacancyModal/VacancyModal.styles.scss b/src/features/AboutUsPage/Vacancies/Vacancy/VacancyModal/VacancyModal.styles.scss index 7e4c5c297..0797bddf5 100644 --- a/src/features/AboutUsPage/Vacancies/Vacancy/VacancyModal/VacancyModal.styles.scss +++ b/src/features/AboutUsPage/Vacancies/Vacancy/VacancyModal/VacancyModal.styles.scss @@ -97,5 +97,18 @@ align-items: center; } } + + @media screen and (max-width:s.$phone) { + .ant-modal-close { + top: -20px; + right: -10px; + @include mut.circular(50px, c.$pure-white-color); + filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25)); + } + + .ant-modal-close-x{ + transform: scale(0.75); + } + } } \ No newline at end of file diff --git a/src/features/AdditionalPages/ContactUsPage/MainBlock/ContactBlock/ContactBlock.component.tsx b/src/features/AdditionalPages/ContactUsPage/MainBlock/ContactBlock/ContactBlock.component.tsx index 64622cb00..2d8d1d579 100644 --- a/src/features/AdditionalPages/ContactUsPage/MainBlock/ContactBlock/ContactBlock.component.tsx +++ b/src/features/AdditionalPages/ContactUsPage/MainBlock/ContactBlock/ContactBlock.component.tsx @@ -39,7 +39,7 @@ const ContactBlock = () => (
    - +
    diff --git a/src/features/AdditionalPages/NewsPage/News.styles.scss b/src/features/AdditionalPages/NewsPage/News.styles.scss index fd07d1f4d..94220d658 100644 --- a/src/features/AdditionalPages/NewsPage/News.styles.scss +++ b/src/features/AdditionalPages/NewsPage/News.styles.scss @@ -151,6 +151,13 @@ } } +@media (hover: none) { + .newsContainer .randomNewsBlock .randomNewsLink .randomNewsTitleAndButtn .newsButtonContainer button:hover { + background: #E04031; + } +} + + @media screen and (max-width: 1024px) { .newsContainer { @include mut.rem-padded($bottom: 20px, $top: 94px, $left: 10px, $right: 10px); diff --git a/src/features/AdditionalPages/PrivatePolicyPage/SubSections/SubSectionFirst/SectionFirst.component.tsx b/src/features/AdditionalPages/PrivatePolicyPage/SubSections/SubSectionFirst/SectionFirst.component.tsx index d400a66da..296da6d0b 100644 --- a/src/features/AdditionalPages/PrivatePolicyPage/SubSections/SubSectionFirst/SectionFirst.component.tsx +++ b/src/features/AdditionalPages/PrivatePolicyPage/SubSections/SubSectionFirst/SectionFirst.component.tsx @@ -1,10 +1,12 @@ +import { Link } from "react-router-dom"; + const SectionFirst = () => (
    1. Загальні положення
    Управління платформою   - https://www.streetcode.com.ua + www.streetcode.com.ua   (далі — платформа) здійснюється Громадською організацією «Історична платформа» (ЄДРПОУ 44801186) — юридичною особою, diff --git a/src/features/AdditionalPages/PrivatePolicyPage/Title/Title.component.tsx b/src/features/AdditionalPages/PrivatePolicyPage/Title/Title.component.tsx index b8ed07fda..db3f54643 100644 --- a/src/features/AdditionalPages/PrivatePolicyPage/Title/Title.component.tsx +++ b/src/features/AdditionalPages/PrivatePolicyPage/Title/Title.component.tsx @@ -1,5 +1,7 @@ import './Title.styles.scss'; +import { Link } from 'react-router-dom'; + const Title = () => (
    @@ -7,7 +9,7 @@ const Title = () => (
    Політика конфіденційності
    та захисту персональних даних платформи  - www.streetcode.com.ua + www.streetcode.com.ua
    diff --git a/src/features/AdminPage/CategoriesPage/CategoriesPage.component.tsx b/src/features/AdminPage/CategoriesPage/CategoriesPage.component.tsx index dd008d123..e984e634e 100644 --- a/src/features/AdminPage/CategoriesPage/CategoriesPage.component.tsx +++ b/src/features/AdminPage/CategoriesPage/CategoriesPage.component.tsx @@ -27,7 +27,7 @@ const CategoriesMainPage: React.FC = observer(() => { sourcesStore.fetchSrcCategoriesAll(), ]).then(() => { sourcesStore?.srcCategoriesMap.forEach((val, key) => { - if (val.imageId !== null && val.imageId !== undefined) { + if (!!val.imageId && !val.image) { ImageStore.getImageById(val.imageId!).then((image) => { sourcesStore.srcCategoriesMap.set( key, @@ -97,7 +97,7 @@ const CategoriesMainPage: React.FC = observer(() => { modalStore.setConfirmationModal('confirmation'); } }, - 'Ви впевнені, що хочете видалити цю новину?', + 'Ви впевнені, що хочете видалити цю категорію?', ); }} /> diff --git a/src/features/AdminPage/CategoriesPage/CategoriesPage/CategoryAdminModal.component.tsx b/src/features/AdminPage/CategoriesPage/CategoriesPage/CategoryAdminModal.component.tsx index 211ac8e60..9dd9867e5 100644 --- a/src/features/AdminPage/CategoriesPage/CategoriesPage/CategoryAdminModal.component.tsx +++ b/src/features/AdminPage/CategoriesPage/CategoriesPage/CategoryAdminModal.component.tsx @@ -1,4 +1,5 @@ import '@features/AdminPage/AdminModal.styles.scss'; +import '@features/AdminPage/CategoriesPage/CategoriesPage/CategoryAdminModal.styles.scss'; import CancelBtn from '@images/utils/Cancel_btn.svg'; @@ -13,11 +14,13 @@ import Image from '@models/media/image.model'; import { SourceCategoryAdmin } from '@models/sources/sources.model'; import useMobx from '@stores/root-store'; +import imageValidator, { checkImageFileType } from '@/app/common/components/modals/validators/imageValidator'; + import { Button, Form, Input, message, Modal, Popover, UploadFile, } from 'antd'; -import { UploadFileStatus } from 'antd/es/upload/interface'; +import { UploadChangeParam, UploadFileStatus } from 'antd/es/upload/interface'; import base64ToUrl from '@/app/common/utils/base64ToUrl.utility'; @@ -124,21 +127,12 @@ const SourceModal: React.FC = ({ setFileList([]); }; - const getValueFromEvent = (e: any) => { - if (e && e.fileList) { - return e.fileList; - } if (e && e.file && e.fileList === undefined) { - return [e.file]; - } - return []; - }; - const handleOk = async () => { try { await form.validateFields(); - + const title = form.getFieldValue('title'); - + if (!title.trim()) { message.error("Будь ласка, заповніть всі обов'язкові поля та перевірте валідність ваших даних"); return; @@ -155,7 +149,14 @@ const SourceModal: React.FC = ({ message.error("Будь ласка, заповніть всі обов'язкові поля та перевірте валідність ваших даних"); } }; - + + const checkFile = (file: UploadFile) => checkImageFileType(file.type); + + const handleFileChange = async (param: UploadChangeParam>) => { + if (checkFile(param.file)) { + setFileList(param.fileList); + } + }; return ( <> @@ -163,7 +164,7 @@ const SourceModal: React.FC = ({ title={isEditing ? 'Редагувати категорію' : 'Додати нову категорію'} open={isModalVisible} onCancel={closeModal} - className="modalContainer" + className="modalContainer categoryModal" closeIcon={( @@ -177,25 +178,25 @@ const SourceModal: React.FC = ({ label="Назва: " rules={[{ required: true, message: 'Введіть назву' }]} > - + { - setFileList(param.fileList); - }} multiple={false} accept=".jpeg,.png,.jpg,.webp" listType="picture-card" maxCount={1} uploadTo="image" + beforeUpload={checkFile} + onChange={handleFileChange} fileList={fileList} onSuccessUpload={handleImageChange} onPreview={handlePreview} diff --git a/src/features/AdminPage/CategoriesPage/CategoriesPage/CategoryAdminModal.styles.scss b/src/features/AdminPage/CategoriesPage/CategoriesPage/CategoryAdminModal.styles.scss new file mode 100644 index 000000000..3d84e5c7a --- /dev/null +++ b/src/features/AdminPage/CategoriesPage/CategoriesPage/CategoryAdminModal.styles.scss @@ -0,0 +1,5 @@ +.categoryModal { + .ant-upload-list-item { + filter: grayscale(100%); + } +} diff --git a/src/features/AdminPage/ContextPage/ContextMainPage.component.tsx b/src/features/AdminPage/ContextPage/ContextMainPage.component.tsx index f25bbbaf2..25d8b8a79 100644 --- a/src/features/AdminPage/ContextPage/ContextMainPage.component.tsx +++ b/src/features/AdminPage/ContextPage/ContextMainPage.component.tsx @@ -61,7 +61,7 @@ const ContextMainPage: React.FC = observer(() => { modalStore.setConfirmationModal('confirmation'); } }, - 'Ви впевнені, що хочете видалити цей тег?', + 'Ви впевнені, що хочете видалити цей контекст?', ); }} /> diff --git a/src/features/AdminPage/JobsPage/JobsModal/JobsModal.component.spec.tsx b/src/features/AdminPage/JobsPage/JobsModal/JobsModal.component.spec.tsx index c25ce1bb3..0f9c37347 100644 --- a/src/features/AdminPage/JobsPage/JobsModal/JobsModal.component.spec.tsx +++ b/src/features/AdminPage/JobsPage/JobsModal/JobsModal.component.spec.tsx @@ -146,7 +146,7 @@ describe("JobsModal test", () => { // Assert expect(JobApi.create).toHaveBeenCalled(); expect(JobApi.create).toHaveBeenCalledWith(createJobWithRequiredOnly); - }, 10000); + }); it("should create job with all fields", async () => { // Arrange @@ -200,7 +200,7 @@ describe("JobsModal test", () => { // Assert expect(JobApi.create).toHaveBeenCalled(); expect(JobApi.create).toHaveBeenCalledWith(createJobWithAllFields); - }, 10000); + }); it("should edit vacancy data", async () => { // Arrange diff --git a/src/features/AdminPage/JobsPage/JobsModal/JobsModal.component.tsx b/src/features/AdminPage/JobsPage/JobsModal/JobsModal.component.tsx index c161fd598..71ad4f58d 100644 --- a/src/features/AdminPage/JobsPage/JobsModal/JobsModal.component.tsx +++ b/src/features/AdminPage/JobsPage/JobsModal/JobsModal.component.tsx @@ -33,7 +33,7 @@ const JobsModal = ({ open, setOpen, currentId }: Props) => { const emptyJob: Job = { title: form.getFieldValue("title"), description: "", - status: form.getFieldValue("status"), + status: form.getFieldValue("status") as string === "setActive", id: 0, salary: form.getFieldValue("salary"), }; @@ -62,10 +62,10 @@ const JobsModal = ({ open, setOpen, currentId }: Props) => { } else if (currentId === 0) { setStoredJob(emptyJob); form.setFieldsValue({ - title: storedJob.title, - status: storedJob.status ? "setActive" : "setInactive", - description: storedJob.description, - salary: storedJob.salary, + title: emptyJob.title, + status: emptyJob.status ? "setActive" : "setInactive", + description: emptyJob.description, + salary: emptyJob.salary, }); } }; @@ -146,7 +146,7 @@ const JobsModal = ({ open, setOpen, currentId }: Props) => {
    diff --git a/src/features/AdminPage/Login/AdminLogin.component.tsx b/src/features/AdminPage/Login/AdminLogin.component.tsx index e1944ec21..5f16c2838 100644 --- a/src/features/AdminPage/Login/AdminLogin.component.tsx +++ b/src/features/AdminPage/Login/AdminLogin.component.tsx @@ -13,7 +13,7 @@ import { ERROR_MESSAGES, INVALID_LOGIN_ATTEMPT } from '@/app/common/constants/er import FRONTEND_ROUTES from '@/app/common/constants/frontend-routes.constants'; import AuthService from '@/app/common/services/auth-service/AuthService'; -const AdminLogin:React.FC = () => { +const AdminLogin: React.FC = () => { const navigate = useNavigate(); const [form] = Form.useForm(); const [isVerified, setIsVerified] = useState(false); @@ -70,6 +70,7 @@ const AdminLogin:React.FC = () => { onChange={handleVerify} onExpired={handleExpiration} ref={recaptchaRef} + hl='uk' />
    diff --git a/src/features/AdminPage/NewStreetcode/InterestingFactsBlock/FactsAdminModal/InterestingFactsAdminModal.component.tsx b/src/features/AdminPage/NewStreetcode/InterestingFactsBlock/FactsAdminModal/InterestingFactsAdminModal.component.tsx index b13b90bb8..889628d50 100644 --- a/src/features/AdminPage/NewStreetcode/InterestingFactsBlock/FactsAdminModal/InterestingFactsAdminModal.component.tsx +++ b/src/features/AdminPage/NewStreetcode/InterestingFactsBlock/FactsAdminModal/InterestingFactsAdminModal.component.tsx @@ -8,6 +8,8 @@ import getNewMinNegativeId from '@app/common/utils/newIdForStore'; import CancelBtn from '@assets/images/utils/Cancel_btn.svg'; import useMobx from '@stores/root-store'; +import imageValidator, { checkImageFileType } from '@/app/common/components/modals/validators/imageValidator'; + import { Button, Form, Input, message, Modal, Popover, UploadFile, } from 'antd'; @@ -40,13 +42,10 @@ const InterestingFactsAdminModal = ({ fact, open, setModalOpen, onChange }: Prop const [previewOpen, setPreviewOpen] = useState(false); const [hasUploadedPhoto, setHasUploadedPhoto] = useState(false); - const checkFile = async (file: UploadFile) => { - return (file.type === 'image/jpeg') || (file.type === 'image/webp') - || (file.type === 'image/png') || (file.type === 'image/jpg'); - } + const checkFile = (file: UploadFile) => checkImageFileType(file.type); - const handleChange = async (param: UploadChangeParam>) => { - if (await checkFile(param.file)) { + const handleFileChange = async (param: UploadChangeParam>) => { + if (checkFile(param.file)) { setFileList(param.fileList); } } @@ -208,27 +207,7 @@ const InterestingFactsAdminModal = ({ fact, open, setModalOpen, onChange }: Prop name="image" rules={[ { required: true, message: 'Завантажте фото, будь ласка' }, - { - validator: (_, file) => { - if (file) { - console.log(file); - let name = ''; - if (file.file) { - name = file.file.name.toLowerCase(); - } else if (file.name) { - name = file.name.toLowerCase(); - } - if (name.endsWith('.jpeg') || name.endsWith('.png') || name.endsWith('.webp') - || name.endsWith('.jpg') || name === '') { - console.log(name) - return Promise.resolve(); - } - // eslint-disable-next-line max-len - return Promise.reject(Error('Тільки файли з розширенням webp, jpeg, png, jpg дозволені!')); - } - return Promise.reject(); - }, - }, + { validator: imageValidator }, ]} > { imageId.current = image.id; setHasUploadedPhoto(true); diff --git a/src/features/AdminPage/NewStreetcode/MainBlock/FileInputsPart.component.tsx b/src/features/AdminPage/NewStreetcode/MainBlock/FileInputsPart.component.tsx index 04c9a83be..cf063b00a 100644 --- a/src/features/AdminPage/NewStreetcode/MainBlock/FileInputsPart.component.tsx +++ b/src/features/AdminPage/NewStreetcode/MainBlock/FileInputsPart.component.tsx @@ -10,6 +10,7 @@ import { ModelState } from '@models/enums/model-state'; import Image, { ImageAssigment, ImageCreateUpdate } from '@models/media/image.model'; import { FormInstance, Modal, UploadFile } from 'antd'; +import { UploadChangeParam } from 'antd/es/upload'; import FormItem from 'antd/es/form/FormItem'; import AudiosApi from '@/app/api/media/audios.api'; @@ -19,6 +20,7 @@ import base64ToUrl from '@/app/common/utils/base64ToUrl.utility'; import Audio, { AudioUpdate } from '@/models/media/audio.model'; import PreviewFileModal from './PreviewFileModal/PreviewFileModal.component'; +import imageValidator, { checkImageFileType } from '@/app/common/components/modals/validators/imageValidator'; const convertFileToUploadFile = (file: Image | Audio) => { const newFileList: UploadFile = { @@ -193,34 +195,33 @@ const FileInputsPart = ({ form, onChange }: FileInputsPartProps) => { } }, []); + const checkFile = (file: UploadFile) => checkImageFileType(file.type); + + const handleAnimationChange = (param: UploadChangeParam>) => { + if (checkFile(param.file)) { + setAnimation(param.fileList); + } + }; + + const handleBlackAndWhiteChange = (param: UploadChangeParam>) => { + if (checkFile(param.file)) { + setBlackAndWhite(param.fileList); + } + }; + + const handleRelatedFigureChange = (param: UploadChangeParam>) => { + if (checkFile(param.file)) { + setRelatedFigure(param.fileList); + } + }; + return (
    { - if (file) { - let name = ''; - if (file.file) { - name = file.file.name.toLowerCase(); - } else if (file.name) { - name = file.name.toLowerCase(); - } - if (name.endsWith('.jpeg') || name.endsWith('.png') || name.endsWith('.webp') - || name.endsWith('.jpg') || name === '') { - return Promise.resolve(); - } - return Promise.reject( - Error('Дозволені тільки файли з розширенням .jpeg, .jpg, .png та .webp'), - ); - } - return Promise.resolve(); - }, - }, - ]} + rules={[{ validator: imageValidator }]} > { multiple={false} maxCount={1} fileList={animation} - beforeUpload={(file) => { - const isValid = (file.type === 'image/jpeg') || (file.type === 'image/webp') - || (file.type === 'image/png') || (file.type === 'image/jpg'); - if (!isValid) { - return Promise.reject(); - } - return Promise.resolve(); - }} + beforeUpload={checkFile} + onChange={handleAnimationChange} onPreview={handlePreview} uploadTo="image" onSuccessUpload={(file: Image | Audio) => { @@ -254,29 +249,12 @@ const FileInputsPart = ({ form, onChange }: FileInputsPartProps) => { { - if (file) { - let name = ''; - if (file.file) { - name = file.file.name.toLowerCase(); - } else if (file.name) { - name = file.name.toLowerCase(); - } - if (name.endsWith('.jpeg') || name.endsWith('.png') || name.endsWith('.webp') - || name.endsWith('.jpg') || name === '') { - return Promise.resolve(); - } - // eslint-disable-next-line max-len - return Promise.reject(Error('Тільки файли з розширенням webp, jpeg, png, jpg дозволені!')); - } - return Promise.reject(); + rules={[ + { + required: true, + message: 'Додайте зображення', }, - }, + { validator: imageValidator }, ]} > { listType="picture-card" maxCount={1} fileList={blackAndWhite} + beforeUpload={checkFile} + onChange={handleBlackAndWhiteChange} onPreview={handlePreview} uploadTo="image" - beforeUpload={(file) => { - const isValid = (file.type === 'image/jpeg') || (file.type === 'image/webp') - || (file.type === 'image/png') || (file.type === 'image/jpg'); - if (!isValid) { - return Promise.reject(); - } - return Promise.resolve(); - }} onSuccessUpload={(file: Image | Audio) => { handleFileUpload(file.id, 'blackAndWhiteId', 'imagesUpdate'); setBlackAndWhite([convertFileToUploadFile(file as Image)]); @@ -311,28 +283,7 @@ const FileInputsPart = ({ form, onChange }: FileInputsPartProps) => { { - if (file) { - let name = ''; - if (file.file) { - name = file.file.name.toLowerCase(); - } else if (file.name) { - name = file.name.toLowerCase(); - } - if (name.endsWith('.jpeg') || name.endsWith('.png') || name.endsWith('.webp') - || name.endsWith('.jpg') || name === '') { - setVisibleErrorRelatedFigure(false); - return Promise.resolve(); - } - setVisibleErrorRelatedFigure(true); - return Promise.resolve(); - } - return Promise.resolve(); - }, - }, - ]} + rules={[{ validator: imageValidator }]} > { fileList={relatedFigure} onPreview={handlePreview} uploadTo="image" - beforeUpload={(file) => { - const isValid = (file.type === 'image/jpeg') - || (file.type === 'image/png') - || (file.type === 'image/jpg' || (file.type === 'image/webp')); - if (!isValid) { - return Promise.reject(); - } - return Promise.resolve(); - }} + beforeUpload={checkFile} + onChange={handleRelatedFigureChange} onSuccessUpload={(file: Image | Audio) => { handleFileUpload(file.id, 'relatedFigureId', 'imagesUpdate'); setRelatedFigure([convertFileToUploadFile(file as Image)]); diff --git a/src/features/AdminPage/NewStreetcode/StreetcodeArtsBlock/components/Download.component.tsx b/src/features/AdminPage/NewStreetcode/StreetcodeArtsBlock/components/Download.component.tsx index 379422b26..c231da4eb 100644 --- a/src/features/AdminPage/NewStreetcode/StreetcodeArtsBlock/components/Download.component.tsx +++ b/src/features/AdminPage/NewStreetcode/StreetcodeArtsBlock/components/Download.component.tsx @@ -21,7 +21,7 @@ import Image from '@/models/media/image.model'; import Audio from '@/models/media/audio.model'; import PreviewImageModal from './PreviewImageModal/PreviewImageModal.component'; -import ArtGalleryTemplateStore from '@/app/stores/art-gallery-template-store'; +import { checkImageFileType } from '@/app/common/components/modals/validators/imageValidator'; const DownloadBlock = () => { const { id } = useParams(); @@ -143,19 +143,15 @@ const DownloadBlock = () => { setVisibleDeleteButton(false); }; - const handleBeforeUpload = async (file: UploadFile) => { - const isImage = ( - (file.type === 'image/jpeg') || - (file.type === 'image/webp') || - (file.type === 'image/png') || - (file.type === 'image/jpg') - ) + const handleBeforeUpload = (file: UploadFile) => { + const isImage = checkImageFileType(file.type); if (!isImage) { setVisibleError(true); } - + return isImage || Upload.LIST_IGNORE; - } + }; + return (
    ({ + 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 + // consider to rewrite this test because it is quite error prone + // if you uncomment this test more likely the previous or this one will fail by timeout + // I'm not sure what this is related to but it seems like there are some problems with screen cleanup + // 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); + // }, { timeout: 25_000 }); + // }, 30_000); + + 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 3425be4fe..e85409ea3 100644 --- a/src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.tsx +++ b/src/features/AdminPage/NewStreetcode/TimelineBlock/NewTimelineModal/NewTimelineModal.component.tsx @@ -3,9 +3,9 @@ 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 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, { @@ -40,6 +39,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 +49,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 +146,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 +176,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 +186,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 { @@ -232,7 +247,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" + /> @@ -244,6 +264,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" />
    -
    +