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 && (
+
+ )}
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.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}
+
+
+ ))}
+
+
+
+
+
({
+ 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()
+ })
+})