diff --git a/i18n/en.js b/i18n/en.js index 14675a36..fbd1228e 100644 --- a/i18n/en.js +++ b/i18n/en.js @@ -14,6 +14,7 @@ export default { roleRequired: 'Add at least 1 role', topicRequired: 'Add at least 1 topic', duplicatedTag: "This tag already exists, you can't create it", + categoryRequired: 'A category is required', }, nav: { mySkills: 'My skills', @@ -38,7 +39,9 @@ export default { category: 'Category', categories: 'Categories', categoriesList: 'Categories list', + selectCategoryPlaceholder: 'Select a category', approve: 'Approve this skill', + createSkill: 'Create a new skill', topics: 'Associated topics', addTags: 'Add tags', placeHolderDescription: 'Set the description', diff --git a/i18n/fr.js b/i18n/fr.js index 37f2e57d..904bc94b 100644 --- a/i18n/fr.js +++ b/i18n/fr.js @@ -14,6 +14,7 @@ export default { roleRequired: 'Ajoutez au minimum 1 rôle', topicRequired: 'Ajoutez au minimum 1 sujet', duplicatedTag: 'Ce tag existe déjà, vous ne pouvez pas le créer', + categoryRequired: 'Une catégorie est obligatoire', }, admin: { deleteSkill: 'Supprimer de Skillz', @@ -30,11 +31,13 @@ export default { category: 'Catégorie', categories: 'Catégories', categoriesList: 'Liste des catégories', + selectCategoryPlaceholder: 'Sélectionner une catégorie', topics: 'Sujets associés', addTags: 'Ajouter des tags', placeHolderDescription: 'Modifier la description', save: 'Sauvegarder', approve: 'Approuver ce skill', + createSkill: 'Créer une nouvelle compétence', description: 'Description', name: 'Nom', notification: { diff --git a/src/components/atoms/CustomSelect/CustomSelect.tsx b/src/components/atoms/CustomSelect/CustomSelect.tsx index 16d8b3d0..15754039 100644 --- a/src/components/atoms/CustomSelect/CustomSelect.tsx +++ b/src/components/atoms/CustomSelect/CustomSelect.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { useDarkMode } from '../../../providers/DarkModeProvider' import styles from './CustomSelect.module.css' +import { RiErrorWarningFill } from 'react-icons/ri' type CustomSelectProps = { choices: any[] @@ -9,6 +10,8 @@ type CustomSelectProps = { selectedChoice?: any placeholder: string readOnly?: boolean + error?: boolean + errorMessage?: string onChange: (choice: any) => void } @@ -16,12 +19,18 @@ export const customSelectClasses = { base: 'w-auto z-10 h-12', placeholder: { parent: { - base: 'bg-light-light w-full p-3 appearance-none rounded-lg border border-solid border-light-dark max-h-16 text-ellipsis overflow-hidden', + base: 'bg-light-light w-full p-3 appearance-none rounded-lg border border-solid max-h-16 text-ellipsis overflow-hidden', dark: 'dark:bg-dark-light dark:border-dark-light dark:border-dark-graybutton', hover: 'hover:bg-light-dark hover:border-light-graybutton hover:dark:bg-dark-radargrid', readonly: 'cursor-pointer bg-rightDropdown', + error: 'border-light-red', + noError: 'border-light-dark', }, }, + error: { + parent: 'flex flex-row items-center mb-1', + base: 'text-light-red pl-1 text-sm', + }, dropdown: { base: 'flex flex-row justify-center w-full duration-500', opened: 'h-0', @@ -45,6 +54,8 @@ const CustomSelect = ({ selectedChoice, placeholder, readOnly = false, + error = false, + errorMessage, onChange, }: CustomSelectProps) => { const [opened, setOpened] = useState(false) @@ -63,11 +74,23 @@ const CustomSelect = ({ return (
+ {error && ( +
+ +

+ {errorMessage} +

+
+ )}
setOpened(!opened)} > diff --git a/src/components/organisms/CreateSkillAdmin.tsx b/src/components/organisms/CreateSkillAdmin.tsx new file mode 100644 index 00000000..31fff3e3 --- /dev/null +++ b/src/components/organisms/CreateSkillAdmin.tsx @@ -0,0 +1,346 @@ +import { useMutation, useQuery } from '@apollo/client' +import { useAuth0 } from '@auth0/auth0-react' +import React, { useState } from 'react' +import { + GetAllCategoriesAllPropertiesQuery, + GetTopicsInfosQuery, + InsertSkillMutationMutation, + SearchAllTagsQuery, +} from '../../generated/graphql' +import { + INSERT_SKILL_MUTATION, + ADD_SKILL_TO_TOPIC, + INSERT_SKILL_TO_TAG, + UPDATE_SKILL_DESCRIPTION, +} from '../../graphql/mutations/skills' +import { GET_ALL_CATEGORIES_ALL_PROPERTIES } from '../../graphql/queries/categories' +import { GET_TOPICS_INFOS } from '../../graphql/queries/topics' +import { SEARCH_IN_ALL_TAGS } from '../../graphql/queries/skills' +import { INSERT_NEW_TAG } from '../../graphql/mutations/tags' +import { displayNotification } from '../../utils/displayNotification' +import Button from '../atoms/Button' +import CustomSelect from '../atoms/CustomSelect/CustomSelect' +import Loading from '../molecules/Loading' +import TextArea from '../atoms/TextArea' +import ErrorPage from '../templates/ErrorPage' +import Topics from '../molecules/Topics' +import Chip from '../atoms/Chip' +import AutoCompleteList from '../atoms/AutoCompleteList' +import { useI18n } from '../../providers/I18nProvider' +import { RiErrorWarningFill } from 'react-icons/ri' + +type CreateSkillAdminProps = { + initialName?: string + closeModal: () => void +} + +const CreateSkillAdmin = ({ + initialName = '', + closeModal, +}: CreateSkillAdminProps) => { + const { t } = useI18n() + const { user } = useAuth0() + + const [name, setName] = useState(initialName) + const [description, setDescription] = useState('') + const [selectedCategory, setSelectedCategory] = useState(undefined) + const [selectedTopics, setSelectedTopics] = useState([]) + const [tagInput, setTagInput] = useState('') + const [selectedTags, setSelectedTags] = useState< + { id?: number; name: string }[] + >([]) + + const { + data: categories, + loading: loadingCategories, + error: errorCategories, + } = useQuery( + GET_ALL_CATEGORIES_ALL_PROPERTIES, + { + context: { + headers: { + 'x-hasura-role': 'skillz-admins', + }, + }, + } + ) + + const { data: topicsData, loading: loadingTopics } = + useQuery(GET_TOPICS_INFOS, { + context: { + headers: { + 'x-hasura-role': 'skillz-admins', + }, + }, + }) + + const { data: searchAllTags } = useQuery( + SEARCH_IN_ALL_TAGS, + { + fetchPolicy: 'network-only', + variables: { + search: `%${tagInput}%`, + tagIds: selectedTags + .map((t) => t.id) + .filter((id) => id !== undefined), + }, + } + ) + + const [insertSkill] = useMutation( + INSERT_SKILL_MUTATION, + { + context: { + headers: { + 'x-hasura-role': 'skillz-admins', + }, + }, + } + ) + const [addTopic] = useMutation(ADD_SKILL_TO_TOPIC, { + context: { + headers: { + 'x-hasura-role': 'skillz-admins', + }, + }, + }) + const [addTag] = useMutation(INSERT_SKILL_TO_TAG, { + context: { + headers: { + 'x-hasura-role': 'skillz-admins', + }, + }, + }) + const [insertNewTag] = useMutation(INSERT_NEW_TAG, { + context: { + headers: { + 'x-hasura-role': 'skillz-admins', + }, + }, + }) + const [updateDescription] = useMutation(UPDATE_SKILL_DESCRIPTION, { + context: { + headers: { + 'x-hasura-role': 'skillz-admins', + }, + }, + }) + + const createSkillAction = async () => { + if (!name || name.trim() === '') { + displayNotification(t('admin.notification.nameEmpty'), 'red', 5000) + return + } + if (!selectedCategory) { + displayNotification(t('error.categoryRequired'), 'red', 5000) + return + } + + try { + const { data } = await insertSkill({ + variables: { + name: name, + categoryId: selectedCategory.id, + }, + }) + + const newSkillId = data.insert_Skill.returning[0].id + + if (description.trim() !== '') { + await updateDescription({ + variables: { skillId: newSkillId, desc: description }, + }) + } + + for (const topicId of selectedTopics) { + await addTopic({ + variables: { skillId: newSkillId, topicId }, + }) + } + + for (const tag of selectedTags) { + let tagId = tag.id + if (!tagId) { + const res = await insertNewTag({ + variables: { + tagName: tag.name, + creatorEmail: user.email, + }, + }) + tagId = res.data.insert_Tag.returning[0].id + } + await addTag({ + variables: { skillId: newSkillId, tagId }, + }) + } + + displayNotification( + t('skills.addSkillSuccess').replace('%skill%', name), + 'green', + 5000 + ) + closeModal() + } catch { + displayNotification(t('error.insertSkillError'), 'red', 5000) + } + } + + const handleAddTag = (tagName: string, addNew: boolean) => { + if (addNew) { + setSelectedTags([...selectedTags, { name: tagName }]) + } else { + const tag = searchAllTags.Tag.find((t) => t.name === tagName) + if (tag) { + setSelectedTags([ + ...selectedTags, + { id: tag.id, name: tag.name }, + ]) + } + } + setTagInput('') + } + + const handleRemoveTag = (tagName: string) => { + setSelectedTags(selectedTags.filter((t) => t.name !== tagName)) + } + + if (loadingCategories || loadingTopics) { + return + } + + if (errorCategories) { + return + } + + const canSave = + name && + selectedCategory && + selectedTags.length > 0 && + selectedTopics.length > 0 && + description + + return ( +
+
+

+ {t('admin.createSkill')} +

+
+
+
+

{t('admin.name')}

+ +
+ +
+

{t('admin.description')}

+ +
+ +
+

{t('admin.category')}

+ x.label} + keyFn={(x) => x.id} + choices={categories.Category ?? []} + selectedChoice={selectedCategory} + placeholder={t('admin.selectCategoryPlaceholder')} + error={selectedCategory === undefined} + errorMessage={t('error.requiredField')} + onChange={(category) => setSelectedCategory(category)} + /> +
+ +
+
+
+

{t('skills.tags.tags')}

+
+ {selectedTags.length === 0 && ( +
+ +

+ {t('error.tagRequired')} +

+
+ )} +
+
+ {selectedTags.map((tag, i) => ( +
+ handleRemoveTag(tag.name)} + > + {tag.name} + +
+ ))} +
+
+ setTagInput(e.target.value)} + placeholder={t('admin.addTags')} + /> + tag.name) ?? [] + } + onChange={(tag, addNew) => + handleAddTag(tag, addNew) + } + search={tagInput} + newType={t('skills.tags.create')} + /> +
+
+ + ({ + id: topic.id, + name: topic.name, + }))} + selectedTopics={selectedTopics} + error={selectedTopics.length === 0} + title={t('admin.topics')} + addCallback={(topic) => + setSelectedTopics([...selectedTopics, topic.id]) + } + removeCallback={(topic) => + setSelectedTopics( + selectedTopics.filter((id) => id !== topic.id) + ) + } + /> +
+
+ +
+
+ ) +} + +export default CreateSkillAdmin diff --git a/src/graphql/mutations/tags.ts b/src/graphql/mutations/tags.ts index 79e0dc67..83d94cda 100644 --- a/src/graphql/mutations/tags.ts +++ b/src/graphql/mutations/tags.ts @@ -7,6 +7,9 @@ export const INSERT_NEW_TAG = gql` objects: { name: $tagName, creator: $creatorEmail } ) { affected_rows + returning { + id + } } } ` diff --git a/src/pages/admin/skills.tsx b/src/pages/admin/skills.tsx index 76231146..d856e091 100644 --- a/src/pages/admin/skills.tsx +++ b/src/pages/admin/skills.tsx @@ -11,11 +11,14 @@ import { GET_ALL_VERIFIED_SKILL } from '../../graphql/queries/skills' import { FetchedSkill } from '../../utils/types' import DuplicateSkillAdmin from '../../components/organisms/DuplicateSkillAdmin' import EditSkillAdmin from '../../components/organisms/EditSkillAdmin' +import CreateSkillAdmin from '../../components/organisms/CreateSkillAdmin' import { useI18n } from '../../providers/I18nProvider' +import Button from '../../components/atoms/Button' enum AdminEditSkillType { EDIT = 'EDIT', DUPLICATE = 'DUPLICATE', + CREATE = 'CREATE', } export default function AdminSkillsPage() { @@ -33,9 +36,12 @@ export default function AdminSkillsPage() { null ) const [modalType, setModalType] = useState(null) + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) const closeModal = () => { setSelectedSkill(null) + setIsCreateModalOpen(false) + setModalType(null) refetch() } @@ -64,18 +70,30 @@ export default function AdminSkillsPage() { setSelectedSkill(skill) } + const onCreateClick = () => { + setModalType(AdminEditSkillType.CREATE) + setIsCreateModalOpen(true) + } + return (
- +
+
+ +
+ +
{!loading && (
@@ -163,7 +181,7 @@ export default function AdminSkillsPage() { )}
- {selectedSkill && ( + {(selectedSkill || isCreateModalOpen) && ( {modalType === AdminEditSkillType.EDIT && ( )} + {modalType === AdminEditSkillType.CREATE && ( + + )} )} diff --git a/test/unit/createskilladmin.test.tsx b/test/unit/createskilladmin.test.tsx new file mode 100644 index 00000000..a78daf33 --- /dev/null +++ b/test/unit/createskilladmin.test.tsx @@ -0,0 +1,452 @@ +import '@testing-library/jest-dom' +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import { MockedProvider } from '@apollo/client/testing' +import CreateSkillAdmin from '../../src/components/organisms/CreateSkillAdmin' +import { DarkModeProvider } from '../../src/providers/DarkModeProvider' +import { I18nContext } from '../../src/providers/I18nProvider' +import { GET_ALL_CATEGORIES_ALL_PROPERTIES } from '../../src/graphql/queries/categories' +import { GET_TOPICS_INFOS } from '../../src/graphql/queries/topics' +import { SEARCH_IN_ALL_TAGS } from '../../src/graphql/queries/skills' +import { + INSERT_SKILL_MUTATION, + UPDATE_SKILL_DESCRIPTION, + ADD_SKILL_TO_TOPIC, + INSERT_SKILL_TO_TAG, +} from '../../src/graphql/mutations/skills' +import { INSERT_NEW_TAG } from '../../src/graphql/mutations/tags' +import { displayNotification } from '../../src/utils/displayNotification' + +jest.mock('@auth0/auth0-react', () => ({ + useAuth0: () => ({ + user: { email: 'test@zenika.com' }, + }), +})) + +jest.mock('../../src/utils/displayNotification', () => ({ + displayNotification: jest.fn(), +})) + +const mockT = (path: string) => { + const keys: Record = { + 'admin.createSkill': 'Create a new skill', + 'admin.name': 'Name', + 'admin.description': 'Description', + 'admin.category': 'Category', + 'skills.tags.tags': 'Tags', + 'admin.topics': 'Topics', + 'admin.save': 'Save', + 'error.requiredField': 'Required', + 'admin.selectCategoryPlaceholder': 'Select a category', + 'admin.addTags': 'Add tags', + 'skills.tags.create': 'Create', + 'skills.addSkillSuccess': 'Skill %skill% created', + } + return keys[path] || path +} + +const mocks: any[] = [ + { + request: { + query: GET_ALL_CATEGORIES_ALL_PROPERTIES, + context: { headers: { 'x-hasura-role': 'skillz-admins' } }, + }, + result: { + data: { + Category: [ + { + id: 'cat-1', + label: 'Category 1', + x: 0, + y: 0, + color: '#000', + index: 0, + description: '', + }, + ], + }, + }, + }, + { + request: { + query: GET_TOPICS_INFOS, + context: { headers: { 'x-hasura-role': 'skillz-admins' } }, + }, + result: { + data: { + Topic: [{ id: 'topic-1', name: 'Topic 1', type: 'technical' }], + }, + }, + }, + { + request: { + query: SEARCH_IN_ALL_TAGS, + variables: { search: '%Tag 1%', tagIds: [] }, + }, + result: { + data: { + Tag: [{ id: 1, name: 'Tag 1' }], + }, + }, + }, + { + request: { + query: SEARCH_IN_ALL_TAGS, + variables: { search: '%%', tagIds: [] }, + }, + result: { + data: { + Tag: [{ id: 1, name: 'Tag 1' }], + }, + }, + }, + { + request: { + query: SEARCH_IN_ALL_TAGS, + variables: { search: '%%', tagIds: [] }, + }, + result: { + data: { + Tag: [{ id: 1, name: 'Tag 1' }], + }, + }, + }, + { + request: { + query: SEARCH_IN_ALL_TAGS, + variables: { search: '%%', tagIds: [] }, + }, + result: { + data: { + Tag: [{ id: 1, name: 'Tag 1' }], + }, + }, + }, + { + request: { + query: SEARCH_IN_ALL_TAGS, + variables: { search: '%%', tagIds: [1] }, + }, + result: { + data: { + Tag: [], + }, + }, + }, + { + request: { + query: SEARCH_IN_ALL_TAGS, + variables: { search: '%New Tag%', tagIds: [] }, + }, + result: { + data: { + Tag: [], + }, + }, + }, + { + request: { + query: SEARCH_IN_ALL_TAGS, + variables: { search: '%%', tagIds: [2] }, + }, + result: { + data: { + Tag: [], + }, + }, + }, + { + request: { + query: INSERT_SKILL_MUTATION, + variables: { name: 'New Skill', categoryId: 'cat-1' }, + context: { headers: { 'x-hasura-role': 'skillz-admins' } }, + }, + result: { + data: { + insert_Skill: { + returning: [{ id: 'new-skill-id', name: 'New Skill' }], + }, + }, + }, + }, + { + request: { + query: UPDATE_SKILL_DESCRIPTION, + variables: { skillId: 'new-skill-id', desc: 'A great skill' }, + context: { headers: { 'x-hasura-role': 'skillz-admins' } }, + }, + result: { + data: { + update_Skill: { + affected_rows: 1, + returning: [{ description: 'A great skill' }], + }, + }, + }, + }, + { + request: { + query: ADD_SKILL_TO_TOPIC, + variables: { skillId: 'new-skill-id', topicId: 'topic-1' }, + context: { headers: { 'x-hasura-role': 'skillz-admins' } }, + }, + result: { + data: { + insert_SkillTopic: { + affected_rows: 1, + returning: [ + { skillId: 'new-skill-id', topicId: 'topic-1' }, + ], + }, + }, + }, + }, + { + request: { + query: INSERT_SKILL_TO_TAG, + variables: { skillId: 'new-skill-id', tagId: 1 }, + context: { headers: { 'x-hasura-role': 'skillz-admins' } }, + }, + result: { + data: { + insert_SkillTag: { + affected_rows: 1, + }, + }, + }, + }, + { + request: { + query: INSERT_NEW_TAG, + variables: { tagName: 'New Tag', creatorEmail: 'test@zenika.com' }, + context: { headers: { 'x-hasura-role': 'skillz-admins' } }, + }, + result: { + data: { + insert_Tag: { + affected_rows: 1, + returning: [{ id: 2 }], + }, + }, + }, + }, + { + request: { + query: INSERT_SKILL_TO_TAG, + variables: { skillId: 'new-skill-id', tagId: 2 }, + context: { headers: { 'x-hasura-role': 'skillz-admins' } }, + }, + result: { + data: { + insert_SkillTag: { + affected_rows: 1, + }, + }, + }, + }, +] + +const renderWithProviders = ( + ui: React.ReactElement, + customMocks: any[] = mocks +) => { + return render( + + + + {ui} + + + + ) +} + +describe('CreateSkillAdmin', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders all fields and performs creation with existing tag', async () => { + const closeModal = jest.fn() + const { container } = renderWithProviders( + + ) + + await waitFor(() => + expect(screen.queryByText('Loading...')).not.toBeInTheDocument() + ) + + expect(screen.getByText('Create a new skill')).toBeInTheDocument() + + const nameInput = container.querySelector( + 'textarea[name="skillName"]' + ) as HTMLTextAreaElement + fireEvent.change(nameInput, { target: { value: 'New Skill' } }) + + const descInput = container.querySelector( + 'textarea[name="skillDescription"]' + ) as HTMLTextAreaElement + fireEvent.change(descInput, { target: { value: 'A great skill' } }) + + const categorySelect = screen.getByText('Select a category') + fireEvent.click(categorySelect) + const categoryOption = await screen.findByText('Category 1') + fireEvent.click(categoryOption) + + const tagInput = screen.getByPlaceholderText('Add tags') + fireEvent.change(tagInput, { target: { value: 'Tag 1' } }) + + const tagOption = await screen.findByText('Tag 1', { selector: 'div' }) + fireEvent.click(tagOption) + + const topicOption = screen.getByText('Topic 1') + fireEvent.click(topicOption) + + const saveButton = screen.getByText('Save') + expect(saveButton.closest('button')).not.toBeDisabled() + + await act(async () => { + fireEvent.click(saveButton) + }) + + await waitFor(() => expect(closeModal).toHaveBeenCalled()) + expect(displayNotification).toHaveBeenCalledWith( + expect.stringContaining('New Skill'), + 'green', + 5000 + ) + }) + + it('performs creation with a new tag', async () => { + const closeModal = jest.fn() + const { container } = renderWithProviders( + + ) + + await waitFor(() => + expect(screen.queryByText('Loading...')).not.toBeInTheDocument() + ) + + fireEvent.change( + container.querySelector( + 'textarea[name="skillName"]' + ) as HTMLTextAreaElement, + { target: { value: 'New Skill' } } + ) + fireEvent.change( + container.querySelector( + 'textarea[name="skillDescription"]' + ) as HTMLTextAreaElement, + { target: { value: 'A great skill' } } + ) + + fireEvent.click(screen.getByText('Select a category')) + fireEvent.click(await screen.findByText('Category 1')) + + const tagInput = screen.getByPlaceholderText('Add tags') + fireEvent.change(tagInput, { target: { value: 'New Tag' } }) + + const createOption = await screen.findByText('Create') + fireEvent.click(createOption) + + fireEvent.click(screen.getByText('Topic 1')) + + await act(async () => { + fireEvent.click(screen.getByText('Save')) + }) + + await waitFor(() => expect(closeModal).toHaveBeenCalled()) + expect(displayNotification).toHaveBeenCalledWith( + expect.stringContaining('New Skill'), + 'green', + 5000 + ) + }) + + it('disables save button if fields are missing', async () => { + renderWithProviders() + await waitFor(() => + expect(screen.queryByText('Loading...')).not.toBeInTheDocument() + ) + + const saveButton = screen.getByText('Save').closest('button') + expect(saveButton).toBeDisabled() + }) + + it('uses initialName if provided', async () => { + renderWithProviders( + + ) + await waitFor(() => + expect(screen.queryByText('Loading...')).not.toBeInTheDocument() + ) + + const nameInput = screen.getByDisplayValue('Initial Name') + expect(nameInput).toBeInTheDocument() + expect(nameInput).toHaveAttribute('name', 'skillName') + }) + + it('displays error notification if creation fails', async () => { + const errorMocks: any[] = [ + ...mocks.slice(0, 7), + { + request: { + query: INSERT_SKILL_MUTATION, + variables: { name: 'New Skill', categoryId: 'cat-1' }, + context: { headers: { 'x-hasura-role': 'skillz-admins' } }, + }, + error: new Error('Forced Error'), + }, + ] + + const closeModal = jest.fn() + const { container } = renderWithProviders( + , + errorMocks + ) + + await waitFor(() => + expect(screen.queryByText('Loading...')).not.toBeInTheDocument() + ) + + fireEvent.change( + container.querySelector( + 'textarea[name="skillName"]' + ) as HTMLTextAreaElement, + { target: { value: 'New Skill' } } + ) + fireEvent.change( + container.querySelector( + 'textarea[name="skillDescription"]' + ) as HTMLTextAreaElement, + { target: { value: 'A great skill' } } + ) + + fireEvent.click(screen.getByText('Select a category')) + fireEvent.click(await screen.findByText('Category 1')) + + fireEvent.change(screen.getByPlaceholderText('Add tags'), { + target: { value: 'Tag 1' }, + }) + fireEvent.click(await screen.findByText('Tag 1', { selector: 'div' })) + + fireEvent.click(screen.getByText('Topic 1')) + + await act(async () => { + fireEvent.click(screen.getByText('Save')) + }) + + await waitFor(() => + expect(displayNotification).toHaveBeenCalledWith( + expect.any(String), + 'red', + 5000 + ) + ) + expect(closeModal).not.toHaveBeenCalled() + }) +})