From 38ddb70f680d9de80026c5e02352d107cb78d63a Mon Sep 17 00:00:00 2001 From: "Christopher D. Gokey" Date: Tue, 6 May 2025 11:51:48 -0400 Subject: [PATCH 1/9] MMT-4002: Intiial commit for custom widget changes. --- .../CustomTitleFieldTemplate.test.jsx | 2 +- .../__tests__/JsonPreview.test.jsx | 6 +- .../js/components/KeywordForm/KeywordForm.jsx | 57 ++-- .../__tests__/KeywordForm.test.jsx | 36 ++- .../js/components/KeywordTree/KeywordTree.jsx | 38 ++- .../__tests__/KeywordTree.test.jsx | 60 +++++ .../KeywordTreeContextMenu.jsx | 1 - .../KeywordTreeCustomNode.jsx | 48 +++- .../__tests__/KeywordTreeCustomNode.test.jsx | 46 ++++ .../KmsConceptSchemeSelector.jsx | 45 ++-- .../KmsConceptSchemeSelector.test.jsx | 158 +++++------ .../KmsConceptSelectionEditModal.jsx | 212 +++++++++++++++ .../KmsConceptSelectionEditModal.scss | 48 ++++ .../KmsConceptSelectionEditModal.test.jsx | 250 ++++++++++++++++++ .../KmsConceptSelectionWidget.jsx | 181 +++++++++++++ .../KmsConceptSelectionWidget.scss | 49 ++++ .../KMSConceptSelectionWidget.test.jsx | 168 ++++++++++++ .../KmsConceptVersionSelector.jsx | 25 +- .../KmsConceptVersionSelector.test.jsx | 8 +- .../KeywordManagerPage/KeywordManagerPage.jsx | 35 ++- .../schemas/uiSchemas/keywords/editKeyword.js | 14 +- .../__tests__/getKmsConceptFullPaths.test.js | 48 ++++ .../__tests__/getKmsConceptSchemes.test.js | 10 +- .../utils/__tests__/getKmsKeywordTree.test.js | 30 +++ static/src/js/utils/getKmsConceptFullPaths.js | 46 ++++ static/src/js/utils/getKmsConceptSchemes.js | 1 + static/src/js/utils/getKmsKeywordTree.js | 16 +- 27 files changed, 1447 insertions(+), 191 deletions(-) create mode 100644 static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx create mode 100644 static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.scss create mode 100644 static/src/js/components/KmsConceptSelectionEditModal/__tests__/KmsConceptSelectionEditModal.test.jsx create mode 100644 static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.jsx create mode 100644 static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.scss create mode 100644 static/src/js/components/KmsConceptSelectionWidget/__tests__/KMSConceptSelectionWidget.test.jsx create mode 100644 static/src/js/utils/__tests__/getKmsConceptFullPaths.test.js create mode 100644 static/src/js/utils/getKmsConceptFullPaths.js diff --git a/static/src/js/components/CustomTitleFieldTemplate/__tests__/CustomTitleFieldTemplate.test.jsx b/static/src/js/components/CustomTitleFieldTemplate/__tests__/CustomTitleFieldTemplate.test.jsx index c315d50ec..cfb15b3d3 100644 --- a/static/src/js/components/CustomTitleFieldTemplate/__tests__/CustomTitleFieldTemplate.test.jsx +++ b/static/src/js/components/CustomTitleFieldTemplate/__tests__/CustomTitleFieldTemplate.test.jsx @@ -56,7 +56,7 @@ describe('CustomTitleFieldTemplate', () => { }) describe('when a title field with hide-header set to true', () => { - it('renders it with no header', () => { + test('renders it with no header', () => { setup({ uiSchema: { 'ui:hide-header': true diff --git a/static/src/js/components/JsonPreview/__tests__/JsonPreview.test.jsx b/static/src/js/components/JsonPreview/__tests__/JsonPreview.test.jsx index 0fc4f5ceb..ccae5670c 100644 --- a/static/src/js/components/JsonPreview/__tests__/JsonPreview.test.jsx +++ b/static/src/js/components/JsonPreview/__tests__/JsonPreview.test.jsx @@ -17,7 +17,7 @@ const setup = (draft = undefined) => { describe('JsonPreview Component', () => { describe('when draft is not present in the context', () => { - it('renders JSONPretty', () => { + test('renders JSONPretty', () => { setup() expect(JSONPretty).toHaveBeenCalledTimes(1) @@ -28,7 +28,7 @@ describe('JsonPreview Component', () => { }) describe('when ummMetadata is not present in draft', () => { - it('renders JSONPretty', () => { + test('renders JSONPretty', () => { setup({}) expect(JSONPretty).toHaveBeenCalledTimes(1) @@ -39,7 +39,7 @@ describe('JsonPreview Component', () => { }) describe('when draft metadata exists', () => { - it('renders JSONPretty', () => { + test('renders JSONPretty', () => { setup({ ummMetadata: { Name: 'Mock Name' diff --git a/static/src/js/components/KeywordForm/KeywordForm.jsx b/static/src/js/components/KeywordForm/KeywordForm.jsx index 5f91d7ddb..42f8ee1e7 100644 --- a/static/src/js/components/KeywordForm/KeywordForm.jsx +++ b/static/src/js/components/KeywordForm/KeywordForm.jsx @@ -1,20 +1,23 @@ -import React, { useState, useEffect } from 'react' -import PropTypes from 'prop-types' -import validator from '@rjsf/validator-ajv8' import Form from '@rjsf/core' +import validator from '@rjsf/validator-ajv8' +import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' import CustomArrayTemplate from '@/js/components/CustomArrayFieldTemplate/CustomArrayFieldTemplate' import CustomFieldTemplate from '@/js/components/CustomFieldTemplate/CustomFieldTemplate' import CustomTextareaWidget from '@/js/components/CustomTextareaWidget/CustomTextareaWidget' import CustomTextWidget from '@/js/components/CustomTextWidget/CustomTextWidget' import GridLayout from '@/js/components/GridLayout/GridLayout' - import editKeywordsUiSchema from '@/js/schemas/uiSchemas/keywords/editKeyword' import keywordSchema from '@/js/schemas/umm/keywordSchema' +import KmsConceptSelectionWidget from '../KmsConceptSelectionWidget/KmsConceptSelectionWidget' + const KeywordForm = ({ initialData, - onFormDataChange + onFormDataChange, + scheme, + version }) => { const [formData, setFormData] = useState(initialData) @@ -23,11 +26,12 @@ const KeywordForm = ({ }, [initialData]) const fields = { + kmsConceptSelection: KmsConceptSelectionWidget, layout: GridLayout } const widgets = { - TextareaWidget: CustomTextareaWidget, - TextWidget: CustomTextWidget + TextWidget: CustomTextWidget, + TextareaWidget: CustomTextareaWidget } const templates = { ArrayFieldTemplate: CustomArrayTemplate, @@ -55,6 +59,12 @@ const KeywordForm = ({ uiSchema={editKeywordsUiSchema} formData={formData} onChange={handleChange} + formContext={ + { + scheme, + version + } + } // OnSubmit={handleSubmit} validator={validator} > @@ -75,29 +85,36 @@ KeywordForm.defaultProps = { KeywordForm.propTypes = { initialData: PropTypes.shape({ - KeywordUUID: PropTypes.string, - BroaderKeyword: PropTypes.string, - NarrowerKeywords: PropTypes.arrayOf(PropTypes.shape({ - NarrowerUUID: PropTypes.string - })), - PreferredLabel: PropTypes.string, AlternateLabels: PropTypes.arrayOf(PropTypes.shape({ LabelName: PropTypes.string, LabelType: PropTypes.string })), + BroaderKeyword: PropTypes.string, + ChangeLogs: PropTypes.string, Definition: PropTypes.string, DefinitionReference: PropTypes.string, - Resources: PropTypes.arrayOf(PropTypes.shape({ - ResourceType: PropTypes.string, - ResourceUri: PropTypes.string + KeywordUUID: PropTypes.string, + NarrowerKeywords: PropTypes.arrayOf(PropTypes.shape({ + NarrowerUUID: PropTypes.string })), + PreferredLabel: PropTypes.string, RelatedKeywords: PropTypes.arrayOf(PropTypes.shape({ - UUID: PropTypes.string, - RelationshipType: PropTypes.string + RelationshipType: PropTypes.string, + UUID: PropTypes.string })), - ChangeLogs: PropTypes.string + Resources: PropTypes.arrayOf(PropTypes.shape({ + ResourceType: PropTypes.string, + ResourceUri: PropTypes.string + })) }), - onFormDataChange: PropTypes.func + onFormDataChange: PropTypes.func, + scheme: PropTypes.shape({ + name: PropTypes.string + }).isRequired, + version: PropTypes.shape({ + version: PropTypes.string, + version_type: PropTypes.string + }).isRequired } export default KeywordForm diff --git a/static/src/js/components/KeywordForm/__tests__/KeywordForm.test.jsx b/static/src/js/components/KeywordForm/__tests__/KeywordForm.test.jsx index 412967ad1..97d97b752 100644 --- a/static/src/js/components/KeywordForm/__tests__/KeywordForm.test.jsx +++ b/static/src/js/components/KeywordForm/__tests__/KeywordForm.test.jsx @@ -1,16 +1,17 @@ -import React from 'react' import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' import { describe, - test, expect, + test, vi } from 'vitest' -import userEvent from '@testing-library/user-event' + import KeywordForm from '../KeywordForm' vi.mock('@/js/utils/getUmmSchema', () => ({ @@ -40,12 +41,22 @@ describe('when KeywordForm is rendered', () => { } test('should display the form title', () => { - render() + render() + expect(screen.getByText('Edit Keyword')).toBeInTheDocument() }) test('should render the form with initial data', () => { - render() + render() + expect(screen.getByDisplayValue('Test Keyword')).toBeInTheDocument() expect(screen.getByDisplayValue('This is a test keyword')).toBeInTheDocument() }) @@ -58,6 +69,8 @@ describe('when user types in the form', () => { render() @@ -80,10 +93,19 @@ describe('when user types in the form', () => { describe('when initialData prop changes', () => { test('should update the form', () => { - const { rerender } = render() + const { rerender } = render() expect(screen.getByDisplayValue('Initial Keyword')).toBeInTheDocument() - rerender() + rerender() + expect(screen.getByDisplayValue('Updated Keyword')).toBeInTheDocument() }) }) diff --git a/static/src/js/components/KeywordTree/KeywordTree.jsx b/static/src/js/components/KeywordTree/KeywordTree.jsx index 539138ad2..a88b9a03b 100644 --- a/static/src/js/components/KeywordTree/KeywordTree.jsx +++ b/static/src/js/components/KeywordTree/KeywordTree.jsx @@ -66,7 +66,7 @@ import './KeywordTree.scss' * ); */ export const KeywordTree = ({ - data, onNodeClick, onNodeEdit + data, onNodeClick, onNodeEdit, searchTerm, selectedNodeId, openAll }) => { const [treeData, setTreeData] = useState(Array.isArray(data) ? data : [data]) const treeRef = useRef(null) @@ -93,13 +93,26 @@ export const KeywordTree = ({ useEffect(() => { if (treeRef.current && treeData.length > 0) { - const tree = treeRef.current - const rootNode = tree.get(treeData[0].id) - if (rootNode) { - rootNode.open() + if (openAll) { + treeRef.current.openAll() + } else if (selectedNodeId) { + treeRef.current.openParents(selectedNodeId) + setTimeout(() => { // Delay to potentially allow tree updates + const node = treeRef.current.get(selectedNodeId) + if (node) { + treeRef.current.select(selectedNodeId) + treeRef.current.scrollTo(selectedNodeId, 'center') + } + }, 0) + } else { + const tree = treeRef.current + const rootNode = tree.get(treeData[0].id) + if (rootNode) { + rootNode.open() + } } } - }, [treeData]) + }, [treeData, openAll]) const closeAllDescendants = (node) => { if (node.isOpen) { @@ -214,9 +227,9 @@ export const KeywordTree = ({ node={node} onAdd={handleAdd} onDelete={handleDelete} + searchTerm={searchTerm} setContextMenu={setContextMenu} onToggle={handleToggle} - onClick={onNodeClick} onEdit={onNodeEdit} onNodeClick={onNodeClick} handleAdd={handleAdd} @@ -264,11 +277,20 @@ const NodeShape = { } NodeShape.children = PropTypes.arrayOf(PropTypes.shape(NodeShape)) +KeywordTree.defaultProps = { + searchTerm: null, + selectedNodeId: null, + openAll: false +} + KeywordTree.propTypes = { + selectedNodeId: PropTypes.string, data: PropTypes.oneOfType([ PropTypes.shape(NodeShape), PropTypes.arrayOf(PropTypes.shape(NodeShape)) ]).isRequired, onNodeClick: PropTypes.func.isRequired, - onNodeEdit: PropTypes.func.isRequired + onNodeEdit: PropTypes.func.isRequired, + searchTerm: PropTypes.string, + openAll: PropTypes.bool } diff --git a/static/src/js/components/KeywordTree/__tests__/KeywordTree.test.jsx b/static/src/js/components/KeywordTree/__tests__/KeywordTree.test.jsx index c5bbf833e..148aebc70 100644 --- a/static/src/js/components/KeywordTree/__tests__/KeywordTree.test.jsx +++ b/static/src/js/components/KeywordTree/__tests__/KeywordTree.test.jsx @@ -713,4 +713,64 @@ describe('KeywordTree component', () => { }) }) }) + + describe('When opening the tree', () => { + const treeData = [ + { + id: '1', + key: '1', + title: 'Root', + children: [ + { + id: '2', + key: '2', + title: 'Node 2', + children: [ + { + id: '3', + key: '3', + title: 'Node 3', + children: [] + } + ] + } + ] + } + ] + test('should open all nodes when openAll is true', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByText('Node 3')).toBeVisible() + }) + + expect(screen.getByText('Root')).toBeVisible() + expect(screen.getByText('Node 3')).toBeVisible() + }) + + test('should scroll to selected node when selectedNodeId is provided', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByText('Node 2')).toBeVisible() + }) + + expect(screen.getByText('Node 2')).toBeVisible() + expect(screen.queryByText('Node 3')).not.toBeInTheDocument() + }) + }) }) diff --git a/static/src/js/components/KeywordTreeContextMenu/KeywordTreeContextMenu.jsx b/static/src/js/components/KeywordTreeContextMenu/KeywordTreeContextMenu.jsx index 9cb3c8154..c4a39b5c1 100644 --- a/static/src/js/components/KeywordTreeContextMenu/KeywordTreeContextMenu.jsx +++ b/static/src/js/components/KeywordTreeContextMenu/KeywordTreeContextMenu.jsx @@ -102,7 +102,6 @@ export const KeywordTreeContextMenu = ({ } onKeyDown={ (e) => { - console.log('KeyDown event triggered', e.key) if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() option.action() diff --git a/static/src/js/components/KeywordTreeCustomNode/KeywordTreeCustomNode.jsx b/static/src/js/components/KeywordTreeCustomNode/KeywordTreeCustomNode.jsx index f8a2c7759..e3e4245cb 100644 --- a/static/src/js/components/KeywordTreeCustomNode/KeywordTreeCustomNode.jsx +++ b/static/src/js/components/KeywordTreeCustomNode/KeywordTreeCustomNode.jsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react' import PropTypes from 'prop-types' +import React, { useState } from 'react' import './KeywordTreeCustomNode.scss' @@ -47,6 +47,7 @@ export const KeywordTreeCustomNode = ({ onEdit, onNodeClick, onToggle, + searchTerm, setContextMenu, style }) => { @@ -82,6 +83,36 @@ export const KeywordTreeCustomNode = ({ setContextMenu(newContextMenu) } + let backgroundColor = 'transparent' + if (node.isSelected) { + backgroundColor = '#99ccff' + } else if (isHovered) { + backgroundColor = '#cce5ff' + } + + const highlightSearchTerm = (text, term) => { + if (!term) { + // Return the original text if there is no search term + return text + } + + // Define a regular expression to match the search term, case-insensitive + const regex = new RegExp(`(${term})`, 'gi') + // Split the text by the search term regex + const parts = text.split(regex) + + // Map over each part, applying tags to the matched term parts + return parts.map((part, index) => { + const key = `${part}-${index}` // This still uses index but further distinguished with text content + + if (regex.test(part)) { + return {part} + } + + return part + }) + } + return (
- {node.data.title} + {highlightSearchTerm(node.data.title, searchTerm)}
@@ -159,18 +190,21 @@ KeywordTreeCustomNode.propTypes = { children: NodeShape.children }).isRequired, isOpen: PropTypes.bool.isRequired, + isSelected: PropTypes.bool.isRequired, toggle: PropTypes.func.isRequired, id: PropTypes.string.isRequired }).isRequired, + style: PropTypes.shape({}), onDelete: PropTypes.func.isRequired, - onEdit: PropTypes.func.isRequired, - onNodeClick: PropTypes.func.isRequired, - onToggle: PropTypes.func.isRequired, + searchTerm: PropTypes.string, setContextMenu: PropTypes.func.isRequired, - style: PropTypes.shape({}) + onToggle: PropTypes.func.isRequired, + onEdit: PropTypes.func.isRequired, + onNodeClick: PropTypes.func.isRequired } KeywordTreeCustomNode.defaultProps = { dragHandle: null, + searchTerm: null, style: {} } diff --git a/static/src/js/components/KeywordTreeCustomNode/__tests__/KeywordTreeCustomNode.test.jsx b/static/src/js/components/KeywordTreeCustomNode/__tests__/KeywordTreeCustomNode.test.jsx index 3759b2c5b..12dfeb7cb 100644 --- a/static/src/js/components/KeywordTreeCustomNode/__tests__/KeywordTreeCustomNode.test.jsx +++ b/static/src/js/components/KeywordTreeCustomNode/__tests__/KeywordTreeCustomNode.test.jsx @@ -215,4 +215,50 @@ describe('KeywordTreeCustomNode component', () => { expect(defaultProps.onDelete).toHaveBeenCalledWith('1') }) }) + + describe('when a search pattern is provided', () => { + describe('when a match occurs', () => { + test('should highlight matched search term in node title', () => { + const propsWithSearchTerm = { + ...defaultProps, + searchTerm: 'Node' + } + render() + + const highlightedText = screen.getByText((content, element) => element.tagName.toLowerCase() === 'strong' && content === 'Node') + + expect(highlightedText).toBeInTheDocument() + }) + }) + + describe('when a match does not occur', () => { + test('renders node title without changes', () => { + const propsWithNoMatchTerm = { + ...defaultProps, + searchTerm: 'NoMatch' + } + render() + + const regularText = screen.getByText('Node 1') + + expect(regularText).toBeInTheDocument() + expect(regularText.tagName.toLowerCase()).not.toBe('strong') + }) + }) + + describe('when search term is empty', () => { + test('should render node title without changes', () => { + const propsWithEmptySearchTerm = { + ...defaultProps, + searchTerm: '' + } + render() + + const regularText = screen.getByText('Node 1') + + expect(regularText).toBeInTheDocument() + expect(regularText.tagName.toLowerCase()).not.toBe('strong') + }) + }) + }) }) diff --git a/static/src/js/components/KmsConceptSchemeSelector/KmsConceptSchemeSelector.jsx b/static/src/js/components/KmsConceptSchemeSelector/KmsConceptSchemeSelector.jsx index 329a335b1..deb03bbe6 100644 --- a/static/src/js/components/KmsConceptSchemeSelector/KmsConceptSchemeSelector.jsx +++ b/static/src/js/components/KmsConceptSchemeSelector/KmsConceptSchemeSelector.jsx @@ -1,8 +1,7 @@ -import React, { useState, useEffect } from 'react' import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' import Select from 'react-select' -import Row from 'react-bootstrap/Row' -import Col from 'react-bootstrap/Col' + import getKmsConceptSchemes from '@/js/utils/getKmsConceptSchemes' /** @@ -15,7 +14,7 @@ import getKmsConceptSchemes from '@/js/utils/getKmsConceptSchemes' * @param {string} props.version - The version of KMS to fetch schemes for * @param {function} props.onSchemeSelect - Callback function triggered when a scheme is selected */ -const KmsConceptSchemeSelector = ({ version, onSchemeSelect }) => { +const KmsConceptSchemeSelector = ({ version, defaultScheme, onSchemeSelect }) => { // State for storing the list of schemes const [schemes, setSchemes] = useState([]) // State for storing the currently selected scheme @@ -55,14 +54,12 @@ const KmsConceptSchemeSelector = ({ version, onSchemeSelect }) => { // Select the first option if (options.length > 0) { - const firstOption = options[0] - setSelectedScheme(firstOption) - onSchemeSelect({ - name: firstOption.value, - longName: firstOption.label, - updateDate: firstOption.updateDate, - csvHeaders: firstOption.csvHeaders - }) + if (defaultScheme) { + const matchingScheme = options.find((option) => option.value === defaultScheme?.name) + if (matchingScheme) { + setSelectedScheme(matchingScheme) + } + } } setLoading(false) @@ -91,23 +88,20 @@ const KmsConceptSchemeSelector = ({ version, onSchemeSelect }) => { } return ( - - -
- ) } KmsConceptSchemeSelector.propTypes = { + defaultScheme: PropTypes.shape({ + name: PropTypes.string + }), version: PropTypes.shape({ version: PropTypes.string, version_type: PropTypes.string @@ -116,6 +110,7 @@ KmsConceptSchemeSelector.propTypes = { } KmsConceptSchemeSelector.defaultProps = { + defaultScheme: null, version: null, onSchemeSelect: () => {} } diff --git a/static/src/js/components/KmsConceptSchemeSelector/__tests__/KmsConceptSchemeSelector.test.jsx b/static/src/js/components/KmsConceptSchemeSelector/__tests__/KmsConceptSchemeSelector.test.jsx index 0a98d5ab2..9679bebba 100644 --- a/static/src/js/components/KmsConceptSchemeSelector/__tests__/KmsConceptSchemeSelector.test.jsx +++ b/static/src/js/components/KmsConceptSchemeSelector/__tests__/KmsConceptSchemeSelector.test.jsx @@ -34,7 +34,7 @@ describe('KmsConceptSchemeSelector', () => { describe('when component is initially rendered', () => { test('should display loading state', () => { render() - expect(screen.getByText('Loading schemes...')).toBeInTheDocument() + expect(screen.getByText('Select scheme...')).toBeInTheDocument() }) }) @@ -74,33 +74,6 @@ describe('KmsConceptSchemeSelector', () => { expect(getKmsConceptSchemes).toHaveBeenCalledWith(mockVersion) }) - - test('should select first scheme by default and call onSchemeSelect', async () => { - const mockSchemes = [ - { - name: 'Scheme 1', - updateDate: '2023-01-01', - csvHeaders: ['header1', 'header2'] - }, - { - name: 'Scheme 2', - updateDate: '2023-01-02', - csvHeaders: ['header3', 'header4'] - } - ] - getKmsConceptSchemes.mockResolvedValue({ schemes: mockSchemes }) - - render() - - await waitFor(() => { - expect(mockOnSchemeSelect).toHaveBeenCalledWith({ - name: 'Scheme 1', - longName: 'Scheme 1', - updateDate: '2023-01-01', - csvHeaders: ['header1', 'header2'] - }) - }) - }) }) describe('when user selects a new scheme', () => { @@ -121,12 +94,29 @@ describe('KmsConceptSchemeSelector', () => { render() + // Open the dropdown + const selectElement = screen.getByRole('combobox') + await userEvent.click(selectElement) + + // Assert options are in the document await waitFor(() => { - expect(screen.getByText('Scheme 1')).toBeInTheDocument() + expect(screen.getByText('Select scheme...')).toBeInTheDocument() }) - await userEvent.click(screen.getByText('Scheme 1')) - await userEvent.click(screen.getByText('Scheme 2')) + // Wait for options to appear + await waitFor(() => { + const option1 = screen.getByRole('option', { name: 'Scheme 1' }) + expect(option1).toBeInTheDocument() + }) + + // Select the first option + const option1 = screen.getByText('Scheme 1') + await userEvent.click(option1) + + // Reopen dropdown and select 'Scheme 2' + await userEvent.click(selectElement) + const option2 = screen.getByRole('option', { name: 'Scheme 2' }) + await userEvent.click(option2) expect(mockOnSchemeSelect).toHaveBeenCalledWith({ name: 'Scheme 2', @@ -148,7 +138,7 @@ describe('KmsConceptSchemeSelector', () => { expect(console.error).toHaveBeenCalledWith('Error fetching schemes:', expect.any(Error)) }) - expect(screen.getByText('Loading schemes...')).toBeInTheDocument() + expect(screen.getByText('Select scheme...')).toBeInTheDocument() }) }) @@ -170,7 +160,7 @@ describe('KmsConceptSchemeSelector', () => { /> ) await waitFor(() => { - expect(screen.getByText('Scheme 1')).toBeInTheDocument() + expect(screen.getByText('Select scheme...')).toBeInTheDocument() }) rerender() @@ -178,7 +168,7 @@ describe('KmsConceptSchemeSelector', () => { await waitFor(() => {}) expect(screen.queryByText('Scheme 1')).toBeNull() - expect(screen.getByText('Loading schemes...')).toBeInTheDocument() + expect(screen.getByText('Select scheme...')).toBeInTheDocument() expect(getKmsConceptSchemes).toHaveBeenCalledTimes(1) }) }) @@ -206,8 +196,12 @@ describe('KmsConceptSchemeSelector', () => { render() + const selectElement = screen.getByRole('combobox') + await userEvent.click(selectElement) + await waitFor(() => { - expect(screen.queryByText('Loading schemes...')).not.toBeInTheDocument() + const options = screen.getAllByRole('option') + expect(options.length).toBe(3) }) expect(screen.getByText('A Scheme')).toBeInTheDocument() @@ -222,13 +216,6 @@ describe('KmsConceptSchemeSelector', () => { expect(dropdownOptions[0]).toHaveTextContent('A Scheme') expect(dropdownOptions[1]).toHaveTextContent('B Scheme') expect(dropdownOptions[2]).toHaveTextContent('C Scheme') - - expect(mockOnSchemeSelect).toHaveBeenCalledWith({ - name: 'A Scheme', - longName: 'A Scheme', - updateDate: '2023-01-02', - csvHeaders: ['header3', 'header4'] - }) }) }) @@ -255,8 +242,12 @@ describe('KmsConceptSchemeSelector', () => { render() + const selectElement = screen.getByRole('combobox') + await userEvent.click(selectElement) + await waitFor(() => { - expect(screen.queryByText('Loading schemes...')).not.toBeInTheDocument() + const options = screen.getAllByRole('option') + expect(options.length).toBe(3) }) expect(screen.getByText('A Scheme')).toBeInTheDocument() @@ -285,35 +276,6 @@ describe('KmsConceptSchemeSelector', () => { expect(mockOnSchemeSelect).not.toHaveBeenCalled() }) - - test('should select first option when schemes are loaded', async () => { - const mockSchemes = [ - { - name: 'Scheme 1', - updateDate: '2023-01-01', - csvHeaders: ['header1'] - }, - { - name: 'Scheme 2', - updateDate: '2023-01-02', - csvHeaders: ['header2'] - } - ] - getKmsConceptSchemes.mockResolvedValue({ schemes: mockSchemes }) - - render() - - await waitFor(() => { - expect(screen.getByText('Scheme 1')).toBeInTheDocument() - }) - - expect(mockOnSchemeSelect).toHaveBeenCalledWith({ - name: 'Scheme 1', - longName: 'Scheme 1', - updateDate: '2023-01-01', - csvHeaders: ['header1'] - }) - }) }) describe('when onSchemeSelect prop is not provided', () => { @@ -341,7 +303,7 @@ describe('KmsConceptSchemeSelector', () => { // Check if the correct option is selected await waitFor(() => { - expect(screen.getByText('Scheme 1')).toBeInTheDocument() + expect(screen.getByText('Select scheme...')).toBeInTheDocument() }) // Attempt to change the selected scheme @@ -383,8 +345,13 @@ describe('KmsConceptSchemeSelector', () => { onSchemeSelect={mockOnSchemeSelect} /> ) + + let selectElement = screen.getByRole('combobox') + await userEvent.click(selectElement) + await waitFor(() => { - expect(screen.getByText('Initial Scheme')).toBeInTheDocument() + const options = screen.getAllByRole('option') + expect(options.length).toBe(1) }) getKmsConceptSchemes.mockResolvedValueOnce({ schemes: updatedMockSchemes }) @@ -400,6 +367,15 @@ describe('KmsConceptSchemeSelector', () => { /> ) + selectElement = screen.getByRole('combobox') + + await userEvent.click(selectElement) + + await waitFor(() => { + const options = screen.getAllByRole('option') + expect(options.length).toBe(1) + }) + await waitFor(() => { expect(screen.getByText('Updated Scheme')).toBeInTheDocument() }) @@ -407,12 +383,36 @@ describe('KmsConceptSchemeSelector', () => { expect(getKmsConceptSchemes).toHaveBeenCalledTimes(2) expect(getKmsConceptSchemes).toHaveBeenNthCalledWith(1, mockVersion) expect(getKmsConceptSchemes).toHaveBeenNthCalledWith(2, updatedVersion) + }) + }) - expect(mockOnSchemeSelect).toHaveBeenCalledWith({ - name: 'Updated Scheme', - longName: 'Updated Scheme', - updateDate: '2023-01-02', - csvHeaders: ['header2'] + describe('when defaultScheme matches an option', () => { + test('should select the default scheme initially', async () => { + const mockSchemes = [ + { + name: 'Scheme 1', + updateDate: '2023-01-01', + csvHeaders: ['header1', 'header2'] + }, + { + name: 'Scheme 2', + updateDate: '2023-01-02', + csvHeaders: ['header3', 'header4'] + } + ] + getKmsConceptSchemes.mockResolvedValue({ schemes: mockSchemes }) + + render( + + ) + + await waitFor(() => { + const selectElement = screen.getByText('Scheme 1') + expect(selectElement).toBeInTheDocument() }) }) }) diff --git a/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx b/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx new file mode 100644 index 000000000..a1561a876 --- /dev/null +++ b/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx @@ -0,0 +1,212 @@ +import PropTypes from 'prop-types' +import React, { + useCallback, + useEffect, + useState +} from 'react' + +import CustomModal from '@/js/components/CustomModal/CustomModal' +import { KeywordTree } from '@/js/components/KeywordTree/KeywordTree' +import KmsConceptSchemeSelector from '@/js/components/KmsConceptSchemeSelector/KmsConceptSchemeSelector' +import getKmsKeywordTree from '@/js/utils/getKmsKeywordTree' + +import './KmsConceptSelectionEditModal.scss' +import { + KeywordTreePlaceHolder +} from '@/js/components/KeywordTreePlaceHolder/KeywordTreePlaceHolder' + +/** + * KmsConceptSelectionEditModal component provides an interface to edit keyword selections + * within a modal. It allows users to select a scheme, search for keywords, and apply their changes. + * + * @param {Object} props - React component props. + * @param {boolean} props.show - Determines if the modal is visible. + * @param {Function} props.toggleModal - Function to toggle the modal visibility. + * @param {string} props.uuid - UUID of the selected keyword. + * @param {Object} props.version - Object containing version details like version and version_type. + * @param {Object} props.scheme - Object containing scheme details. + * @param {Function} props.handleAcceptChanges - Callback function when changes are accepted. + * @returns {JSX.Element} The complete modal component for editing keyword selections. + */ +export const KmsConceptSelectionEditModal = ({ + handleAcceptChanges, + scheme, + show, + toggleModal, + uuid, + version +}) => { + const [treeData, setTreeData] = useState(null) + const [selectedScheme, setSelectedScheme] = useState(scheme) + const [selectedKeyword, setSelectedKeyword] = useState(uuid) + const [isTreeLoading, setIsTreeLoading] = useState(false) + const [treeMessage, setTreeMessage] = useState('') + const [searchPattern, setSearchPattern] = useState('') + const [searchPatternApplied, setSearchPatternApplied] = useState('') + + const fetchTreeData = async () => { + if (version && scheme) { + setIsTreeLoading(true) + + try { + const data = await getKmsKeywordTree(version, selectedScheme, searchPatternApplied) + if (data) { + setTreeData(data) + } else { + setTreeData(null) + setTreeMessage('No results.') + } + } catch (error) { + console.error('Error fetching keyword tree:', error) + setTreeData(null) + setTreeMessage('Failed to load the tree. Please try again.') + } finally { + setIsTreeLoading(false) + } + } else { + setTreeMessage('Select a version and scheme to load the tree') + } + } + + useEffect(() => { + if (version && selectedScheme) { + setTreeMessage('Loading...') + fetchTreeData(version, selectedScheme, searchPatternApplied) + } + }, [show, version, selectedScheme, searchPatternApplied]) + + const onSchemeSelect = useCallback((schemeInfo) => { + setSelectedScheme(schemeInfo) + setTreeData(null) + }, []) + + useEffect(() => { + if (show) { + setTreeMessage('Loading...') + fetchTreeData(version, selectedScheme, searchPatternApplied) + } + }, [show]) + + const onHandleSelectKeyword = (value) => { + setSelectedKeyword(value) + } + + const onHandleAcceptChanges = () => { + handleAcceptChanges(selectedKeyword) + toggleModal(false) + } + + // New function to handle search input change + const onHandleSearchInputChange = (event) => { + setSearchPattern(event.target.value) + + if (event.target.value === '') { + setSearchPatternApplied('') + } + } + + const onHandleApplyFilteredSearch = () => { + setSearchPatternApplied(searchPattern) + } + + const onHandleKeyDown = (event) => { + if (event.key === 'Enter') { + setSearchPatternApplied(searchPattern) + } + } + + const renderTree = () => { + if (isTreeLoading) { + return + } + + const schemeSelectorId = selectedScheme?.name + + return ( +
+
+ + +
+
+ + +
+ { + treeData ? ( + + ) : + } +
+ ) + } + + return ( + { toggleModal(false) } + }, + { + label: 'Accept', + variant: 'primary', + onClick: onHandleAcceptChanges + } + ] + } + /> + ) +} + +KmsConceptSelectionEditModal.propTypes = { + handleAcceptChanges: PropTypes.func.isRequired, + scheme: PropTypes.shape({ + name: PropTypes.string + }).isRequired, + show: PropTypes.bool.isRequired, + toggleModal: PropTypes.func.isRequired, + uuid: PropTypes.string.isRequired, + version: PropTypes.shape({ + version: PropTypes.string, + version_type: PropTypes.string + }).isRequired +} diff --git a/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.scss b/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.scss new file mode 100644 index 000000000..1ec549c95 --- /dev/null +++ b/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.scss @@ -0,0 +1,48 @@ +@use "sass:map"; + +@import '../../../css/vendor/bootstrap/variables'; + +.kms-concept-selection-edit-modal { + &__search-input { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + inline-size: 80%; + + &:focus { + border-color: #007bff; + outline: none; + } + } + + &__scheme-selector { + display: flex; + align-items: center; + padding-block-end: 8px; + } + + &__label { + font-weight: bold; + padding-inline-end: 8px; + } + + &__tree-wrapper { + display: flex; + align-items: center; + margin-block-end: 12px; + } + + &__tree-placeholder { + display: flex; + align-items: center; + justify-content: center; + border: 1px solid $gray-300; + border-radius: 4px; + block-size: 300px; + inline-size: 100%; + } + + &__apply-button { + margin-inline-start: 10px; + } +} \ No newline at end of file diff --git a/static/src/js/components/KmsConceptSelectionEditModal/__tests__/KmsConceptSelectionEditModal.test.jsx b/static/src/js/components/KmsConceptSelectionEditModal/__tests__/KmsConceptSelectionEditModal.test.jsx new file mode 100644 index 000000000..e269df0f8 --- /dev/null +++ b/static/src/js/components/KmsConceptSelectionEditModal/__tests__/KmsConceptSelectionEditModal.test.jsx @@ -0,0 +1,250 @@ +import { + fireEvent, + render, + screen, + waitFor +} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import PropTypes from 'prop-types' +import React from 'react' +import { + beforeEach, + describe, + expect, + vi +} from 'vitest' + +import { + KmsConceptSelectionEditModal +} from '@/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal' +import getKmsKeywordTree from '@/js/utils/getKmsKeywordTree' + +vi.mock('@/js/components/KeywordTree/KeywordTree', () => { + const MockKeywordTree = ({ onNodeClick }) => ( + + ) + + MockKeywordTree.propTypes = { + onNodeClick: PropTypes.func.isRequired + } + + return { KeywordTree: MockKeywordTree } +}) + +vi.mock('@/js/components/KmsConceptSchemeSelector/KmsConceptSchemeSelector', () => { + const MockKmsConceptSchemeSelector = ({ onSchemeSelect }) => ( + + ) + + MockKmsConceptSchemeSelector.propTypes = { + onSchemeSelect: PropTypes.func.isRequired + } + + return { default: MockKmsConceptSchemeSelector } +}) + +vi.mock('@/js/utils/getKmsKeywordTree', () => ({ + default: vi.fn(() => Promise.resolve([{ id: 'mock-tree-data' }])) +})) + +describe('KmsConceptSelectionEditModal', () => { + const defaultProps = { + show: true, + toggleModal: vi.fn(), + uuid: 'test-uuid', + version: { + version: '1.0', + version_type: 'published' + }, + scheme: { name: 'Test Scheme' }, + handleAcceptChanges: vi.fn() + } + + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + describe('when the modal appears', () => { + test('should render the header', async () => { + render() + const modalHeader = await screen.findByText('Edit Keyword') + expect(modalHeader).toBeInTheDocument() + }) + + test('should render the scheme selector', async () => { + render() + await waitFor(() => { + expect(screen.getByTestId('scheme-selector')).toBeTruthy() + }) + }) + + test('should render the keyword tree', async () => { + render() + await waitFor(() => { + expect(screen.getByTestId('keyword-tree')).toBeTruthy() + }) + }) + }) + + describe('when the modal is hidden', () => { + test('should not show the modal', async () => { + render() + await waitFor(() => { + expect(screen.queryByTestId('custom-modal')).not.toBeInTheDocument() + }) + }) + }) + + describe('when user selects a scheme', () => { + test('shows tree is loading', async () => { + render() + const schemeSelector = await screen.findByTestId('scheme-selector') + fireEvent.change(schemeSelector) + await waitFor(() => { + expect(screen.getByText('Loading...')).toBeTruthy() + }) + }) + + test('should render tree when a scheme is selected', async () => { + render() + const schemeSelector = await screen.findByTestId('scheme-selector') + fireEvent.change(schemeSelector) + await waitFor(() => { + expect(screen.getByTestId('keyword-tree')).toBeTruthy() + }) + }) + }) + + describe('when user accept changes', () => { + test('should responds with the uuid of the selected keyword', async () => { + const handleAcceptChangesMock = vi.fn() + const props = { + ...defaultProps, + handleAcceptChanges: handleAcceptChangesMock + } + + render() + + await waitFor(async () => { + const keywordTree = screen.getByTestId('keyword-tree') + await userEvent.click(keywordTree) + }) + + // Find and click the Accept button + const acceptButton = screen.getByText('Accept') + await userEvent.click(acceptButton) + + // Check if handleAcceptChanges was called with the new keyword value + expect(handleAcceptChangesMock).toHaveBeenCalledWith('mock-uuid') + }) + }) + + describe('when user cancels changes', () => { + test('should close the modal', async () => { + render() + await waitFor(async () => { + const cancelButton = screen.getByText('Cancel') + await userEvent.click(cancelButton) + expect(defaultProps.toggleModal).toHaveBeenCalledWith(false) + }) + }) + }) + + describe('when the user searches', () => { + describe('when apply button is pressed', () => { + test('should fetch the tree and includes search term', async () => { + render() + const searchInput = await screen.findByPlaceholderText('Search by Pattern or UUID') + fireEvent.change(searchInput, { target: { value: 'test search' } }) + const applyButton = screen.getByText('Apply') + fireEvent.click(applyButton) + await waitFor(() => { + expect(getKmsKeywordTree).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'test search') + }) + }) + }) + + describe('when enter is pressed', () => { + test('should fetch the tree and includes search term', async () => { + render() + const searchInput = await screen.findByPlaceholderText('Search by Pattern or UUID') + fireEvent.change(searchInput, { target: { value: 'test search' } }) + fireEvent.keyDown(searchInput, { key: 'Enter' }) + await waitFor(() => { + expect(getKmsKeywordTree).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'test search') + }) + }) + }) + + describe('when fetch returns no results', () => { + test('should show no results', async () => { + vi.mocked(getKmsKeywordTree).mockResolvedValue(null) + render() + const searchInput = await screen.findByPlaceholderText('Search by Pattern or UUID') + await userEvent.type(searchInput, 'test pattern') + const applyButton = screen.getByText('Apply') + await userEvent.click(applyButton) + await waitFor(() => { + expect(screen.getByText('No results.')).toBeInTheDocument() + }) + }) + }) + + test('should handle fetch errors', async () => { + vi.mocked(getKmsKeywordTree).mockRejectedValue(new Error('Fetch error')) + render() + await waitFor(() => { + expect(screen.getByText('Failed to load the tree. Please try again.')).toBeInTheDocument() + }) + }) + + test('should handling clearing the search box', async () => { + render() + const textInput = await screen.findByPlaceholderText('Search by Pattern or UUID') + fireEvent.change(textInput, { target: { value: 'test pattern' } }) + fireEvent.change(textInput, { target: { value: '' } }) + await waitFor(() => { + expect(getKmsKeywordTree).toHaveBeenCalledWith(expect.anything(), expect.anything(), '') + }) + }) + }) + + describe('when no version is provided', () => { + test('displays correct message', async () => { + const propsWithoutVersion = { + ...defaultProps, + version: null + } + render() + await waitFor(() => { + expect(screen.getByText('Select a version and scheme to load the tree')).toBeInTheDocument() + }) + }) + }) + + describe('when no scheme is provided', () => { + test('displays correct message', async () => { + const propsWithoutScheme = { + ...defaultProps, + scheme: null + } + render() + await waitFor(() => { + expect(screen.getByText('Select a version and scheme to load the tree')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.jsx b/static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.jsx new file mode 100644 index 000000000..542eb29b6 --- /dev/null +++ b/static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.jsx @@ -0,0 +1,181 @@ +import { startCase } from 'lodash-es' +import PropTypes from 'prop-types' +import React, { + useEffect, + useRef, + useState +} from 'react' +import { FaPencilAlt } from 'react-icons/fa' + +import Button from '@/js/components/Button/Button' +import { + KmsConceptSelectionEditModal +} from '@/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal' +import { getKmsConceptFullPaths } from '@/js/utils/getKmsConceptFullPaths' + +import CustomWidgetWrapper from '../CustomWidgetWrapper/CustomWidgetWrapper' + +import './KmsConceptSelectionWidget.scss' + +/** + * KmsConceptSelectionWidget component for selecting and editing keyword concepts + * within a user interface. It uses a modal to allow keyword selection and then + * displays the selected keyword and its full path. + * + * @component + * @param {Object} props - Component properties. + * @param {string} props.id - Unique identifier for the widget. + * @param {string} props.label - Label displayed for the widget. + * @param {Function} props.onChange - Callback function to handle changes. + * @param {Object} props.registry - Contains form context information. + * @param {boolean} [props.required=false] - Specifies if the field is required. + * @param {Object} props.schema - Schema containing field constraints. + * @param {Object} props.uiSchema - UI schema allowing customization of display. + * @param {string|number} [props.value=''] - Current value of the widget. + * @returns {JSX.Element} The rendered component. + */ +const KmsConceptSelectionWidget = ({ + id, + label, + onChange, + registry, + required, + schema, + uiSchema, + value +}) => { + const [keywordLabel, setKeywordLabel] = useState('') + const [fullPath, setFullPath] = useState('') + const [showEditModal, setShowEditModal] = useState(false) + const [error, setError] = useState('') + const inputScrollRef = useRef(null) + const focusRef = useRef(null) + + const { formContext } = registry + const { version, scheme } = formContext + + const { maxLength, description } = schema + + let title = startCase(label.split(/-/)[0]) + if (uiSchema['ui:title']) { + title = uiSchema['ui:title'] + } + + useEffect(() => { + const fetchFullPaths = async () => { + try { + let fullPaths = await getKmsConceptFullPaths(value) + const lastField = fullPaths[0].split('|').pop() + fullPaths = fullPaths.map((path) => path.replaceAll('|', ' > ')) + setKeywordLabel(lastField) + setFullPath(fullPaths.join('\n')) + } catch (errorObj) { + console.error(`Error fetching keyword for ${value}`, errorObj) + setError(`Error fetching keyword for ${value}`) + } + } + + if (value !== null && value.trim() !== '') { + fetchFullPaths() + } + }, [value]) + + const toggleEditModal = (nextState) => { + setShowEditModal(nextState) + } + + return ( + +
+ + {keywordLabel || 'No value set'} + +
+ + { + error && ( +
+ {error} +
+ ) + } + {' '} + { + onChange(uuidValue) + } + } + /> +
+ ) +} + +KmsConceptSelectionWidget.defaultProps = { + value: '', + required: false +} + +KmsConceptSelectionWidget.propTypes = { + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + registry: PropTypes.shape({ + formContext: PropTypes.shape({ + focusField: PropTypes.string, + setFocusField: PropTypes.func, + version: PropTypes.shape({ + version: PropTypes.string, + version_type: PropTypes.string + }).isRequired, + scheme: PropTypes.shape({ + name: PropTypes.string + }).isRequired + }).isRequired + }).isRequired, + required: PropTypes.bool, + schema: PropTypes.shape({ + description: PropTypes.string, + maxLength: PropTypes.number, + type: PropTypes.string + }).isRequired, + uiSchema: PropTypes.shape({ + 'ui:title': PropTypes.string + }).isRequired, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]) +} + +export default KmsConceptSelectionWidget diff --git a/static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.scss b/static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.scss new file mode 100644 index 000000000..e086e7f2f --- /dev/null +++ b/static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.scss @@ -0,0 +1,49 @@ +.kms-concept-selection-widget { + &__container { + display: inline-flex; + align-items: center; + line-height: 1; + } + + &__label { + color: #333; + font-size: 14px; + margin-inline-end: 8px; + } + + &__edit-button { + display: inline-flex; + align-items: center; + border: none; + margin: 0; + background: none; + color: #007bff; + cursor: pointer; + padding-inline-start: 5px; + transition: color 0.2s ease-in-out; + + &:hover, + &:focus { + color: #0056b3; + } + } + + &__input { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + inline-size: 80%; + transition: border-color 0.2s ease; + + &:focus { + border-color: #007bff; + outline: none; + } + } + + &__compact-alert { + padding: 0.5rem 1rem; + font-size: 0.875rem; + margin-block-end: 0.5rem; + } +} \ No newline at end of file diff --git a/static/src/js/components/KmsConceptSelectionWidget/__tests__/KMSConceptSelectionWidget.test.jsx b/static/src/js/components/KmsConceptSelectionWidget/__tests__/KMSConceptSelectionWidget.test.jsx new file mode 100644 index 000000000..c476877b1 --- /dev/null +++ b/static/src/js/components/KmsConceptSelectionWidget/__tests__/KMSConceptSelectionWidget.test.jsx @@ -0,0 +1,168 @@ +import { + fireEvent, + render, + screen, + waitFor +} from '@testing-library/react' +import PropTypes from 'prop-types' +import React from 'react' +import { Button } from 'react-bootstrap' +import { + beforeEach, + describe, + vi +} from 'vitest' + +import KmsConceptSelectionWidget from '@/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget' +import { getKmsConceptFullPaths } from '@/js/utils/getKmsConceptFullPaths' + +// Mock the `getKmsConceptFullPaths` and Button components +vi.mock('@/js/utils/getKmsConceptFullPaths') + +vi.mock('react-icons/fa', () => ({ + FaPencilAlt: () => , + FaInfoCircle: () => , + FaTimes: () => +})) + +vi.mock('@/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal', () => { + const KmsConceptSelectionEditModal = ({ show, handleAcceptChanges, toggleModal }) => ( + show ? ( +
+

Handle Change Modal

+ + {/* Add a button to simulate modal close using toggleModal */} + +
+ ) : null + ) + + KmsConceptSelectionEditModal.propTypes = { + show: PropTypes.bool.isRequired, + handleAcceptChanges: PropTypes.func.isRequired, + toggleModal: PropTypes.func.isRequired + } + + return { KmsConceptSelectionEditModal } +}) + +describe('KmsConceptSelectionWidget', () => { + beforeEach(() => { + getKmsConceptFullPaths.mockClear() + getKmsConceptFullPaths.mockResolvedValue(['ScienceKeywords | ATMOSPHERE | TORNADOES']) + }) + + const renderComponent = (props = {}) => render( + + ) + + describe('when a user first loads the page', () => { + test('should render the page', async () => { + renderComponent() + await waitFor(() => { + expect(screen.getByText(/tornadoes/i)).toBeInTheDocument() + }) + }) + + test('should fetch and display the correct keyword', async () => { + renderComponent() + expect(getKmsConceptFullPaths).toHaveBeenCalledWith('test-uuid') + await waitFor(async () => { + expect(await screen.findByText('TORNADOES')).toBeInTheDocument() + }) + }) + + test('should show the full path as a title attribute', async () => { + renderComponent() + expect(getKmsConceptFullPaths).toHaveBeenCalledWith('test-uuid') + await waitFor(() => { + const keywordElement = screen.getByText(/tornadoes/i) + const expectedTitle = /ScienceKeywords\s*>\s*ATMOSPHERE\s*>\s*TORNADOES/ + expect(keywordElement).toHaveAttribute('title', expect.stringMatching(expectedTitle)) + }) + }) + }) + + describe('when a user clicks the edit icon', () => { + test('should open the edit modal', async () => { + renderComponent() + const button = screen.getByRole('button', { name: /edit/i }) + fireEvent.click(button) + await waitFor(() => { + expect(screen.getByText('Handle Change Modal')).toBeInTheDocument() + }) + }) + + test('should toggle the edit modal state correctly', async () => { + renderComponent() + + // Open the modal + const editButton = screen.getByRole('button', { name: /edit/i }) + fireEvent.click(editButton) + expect(screen.getByText('Handle Change Modal')).toBeInTheDocument() + + // Simulate clicking the custom cancel button in the modal + const cancelButton = screen.getByRole('button', { name: /cancel/i }) + fireEvent.click(cancelButton) + + // Wait for the modal to no longer be in the document + await waitFor(() => { + expect(screen.queryByText('Handle Change Modal')).toBeNull() + }) + }) + }) + + describe('when a user accepts changes', () => { + test('should call onChange when accepting the updated keyword from the modal', async () => { + const onChangeMock = vi.fn() + renderComponent({ onChange: onChangeMock }) + + fireEvent.click(screen.getByRole('button', { name: /edit/i })) + fireEvent.click(screen.getByText('Accept Change')) + + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalledWith('new-uuid') + }) + }) + }) + + describe('when the user encounters a network error', () => { + test('should handle errors by adding a notification', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + getKmsConceptFullPaths.mockRejectedValue(new Error('Failed to fetch full paths')) + + renderComponent() + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Error fetching keyword for test-uuid') + }) + }) + }) +}) diff --git a/static/src/js/components/KmsConceptVersionSelector/KmsConceptVersionSelector.jsx b/static/src/js/components/KmsConceptVersionSelector/KmsConceptVersionSelector.jsx index 2e93bb899..9647b1423 100644 --- a/static/src/js/components/KmsConceptVersionSelector/KmsConceptVersionSelector.jsx +++ b/static/src/js/components/KmsConceptVersionSelector/KmsConceptVersionSelector.jsx @@ -1,8 +1,7 @@ -import React, { useState, useEffect } from 'react' import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' import Select from 'react-select' -import Row from 'react-bootstrap/Row' -import Col from 'react-bootstrap/Col' + import getKmsConceptVersions from '@/js/utils/getKmsConceptVersions' /** @@ -90,19 +89,13 @@ const KmsConceptVersionSelector = ({ onVersionSelect }) => { } return ( - - -
- ) } diff --git a/static/src/js/components/KmsConceptVersionSelector/__tests__/KmsConceptVersionSelector.test.jsx b/static/src/js/components/KmsConceptVersionSelector/__tests__/KmsConceptVersionSelector.test.jsx index 7f68e1375..9898194e5 100644 --- a/static/src/js/components/KmsConceptVersionSelector/__tests__/KmsConceptVersionSelector.test.jsx +++ b/static/src/js/components/KmsConceptVersionSelector/__tests__/KmsConceptVersionSelector.test.jsx @@ -1,13 +1,15 @@ -import React from 'react' import { + fireEvent, render, screen, - fireEvent, waitFor } from '@testing-library/react' -import { vi } from 'vitest' import userEvent from '@testing-library/user-event' +import React from 'react' +import { vi } from 'vitest' + import getKmsConceptVersions from '@/js/utils/getKmsConceptVersions' + import KmsConceptVersionSelector from '../KmsConceptVersionSelector' vi.mock('@/js/utils/getKmsConceptVersions') diff --git a/static/src/js/pages/KeywordManagerPage/KeywordManagerPage.jsx b/static/src/js/pages/KeywordManagerPage/KeywordManagerPage.jsx index 11346d6b4..106cc0f4a 100644 --- a/static/src/js/pages/KeywordManagerPage/KeywordManagerPage.jsx +++ b/static/src/js/pages/KeywordManagerPage/KeywordManagerPage.jsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect } from 'react' +import { Col, Row } from 'react-bootstrap' import { FaPlus } from 'react-icons/fa' import { getApplicationConfig } from 'sharedUtils/getConfig' @@ -162,7 +163,13 @@ const KeywordManagerPage = () => { } if (selectedKeywordData) { - return + return ( + + ) } return null @@ -231,7 +238,13 @@ const KeywordManagerPage = () => { > Version: - + + +
+ +
+ +
- + + +
+ +
+ +
diff --git a/static/src/js/schemas/uiSchemas/keywords/editKeyword.js b/static/src/js/schemas/uiSchemas/keywords/editKeyword.js index 6a7a5daa4..86a071e9f 100644 --- a/static/src/js/schemas/uiSchemas/keywords/editKeyword.js +++ b/static/src/js/schemas/uiSchemas/keywords/editKeyword.js @@ -1,6 +1,7 @@ import CustomSelectWidget from '@/js/components/CustomSelectWidget/CustomSelectWidget' import CustomTextareaWidget from '@/js/components/CustomTextareaWidget/CustomTextareaWidget' import CustomTextWidget from '@/js/components/CustomTextWidget/CustomTextWidget' +import KmsConceptSelectionWidget from '@/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget' const editKeywordsUiSchema = { 'ui:submitButtonOptions': { @@ -88,7 +89,7 @@ const editKeywordsUiSchema = { 'ui:disabled': true }, BroaderKeyword: { - 'ui:widget': CustomTextWidget + 'ui:widget': KmsConceptSelectionWidget }, NarrowerKeywords: { items: { @@ -102,10 +103,11 @@ const editKeywordsUiSchema = { } } ] + }, + NarrowerUUID: { + 'ui:widget': KmsConceptSelectionWidget, + 'ui:title': 'Keyword' } - }, - NarrowerUUID: { - 'ui:widget': CustomTextWidget } }, PreferredLabel: { @@ -194,8 +196,8 @@ const editKeywordsUiSchema = { 'ui:widget': CustomSelectWidget }, UUID: { - 'ui:widget': CustomTextWidget, - 'ui:readonly': true + 'ui:widget': KmsConceptSelectionWidget, + 'ui:title': 'Keyword' } } }, diff --git a/static/src/js/utils/__tests__/getKmsConceptFullPaths.test.js b/static/src/js/utils/__tests__/getKmsConceptFullPaths.test.js new file mode 100644 index 000000000..0c124f9d9 --- /dev/null +++ b/static/src/js/utils/__tests__/getKmsConceptFullPaths.test.js @@ -0,0 +1,48 @@ +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest' + +import { getKmsConceptFullPaths } from '@/js/utils/getKmsConceptFullPaths' + +// Mocking fetch +global.fetch = vi.fn(() => Promise.resolve({ + ok: true, + text: () => Promise.resolve(` + + Chained Operations + + `) +})) + +describe('getKmsConceptFullPaths', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + fetch.mockClear() + }) + + it('should fetch and return full paths from KMS', async () => { + const value = 'test-uuid' + const expectedResult = ['Chained Operations'] + + const result = await getKmsConceptFullPaths(value) + + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledWith(expect.stringContaining(`/concept_fullpaths/concept_uuid/${value}`), { method: 'GET' }) + + expect(result).toEqual(expectedResult) + }) + + it('should throw an error if fetch fails', async () => { + fetch.mockImplementationOnce(() => Promise.resolve({ + ok: false, + status: 404 + })) + + await expect(getKmsConceptFullPaths('bad-uuid')).rejects.toThrow('getConceptFullPaths HTTP error! status: 404') + }) +}) diff --git a/static/src/js/utils/__tests__/getKmsConceptSchemes.test.js b/static/src/js/utils/__tests__/getKmsConceptSchemes.test.js index 9d7afb3d1..9a2244376 100644 --- a/static/src/js/utils/__tests__/getKmsConceptSchemes.test.js +++ b/static/src/js/utils/__tests__/getKmsConceptSchemes.test.js @@ -1,12 +1,14 @@ import { + afterEach, + beforeEach, describe, - test, expect, - vi, - beforeEach, - afterEach + test, + vi } from 'vitest' + import { getApplicationConfig } from 'sharedUtils/getConfig' + import getKmsConceptSchemes from '../getKmsConceptSchemes' vi.mock('sharedUtils/getConfig', () => ({ diff --git a/static/src/js/utils/__tests__/getKmsKeywordTree.test.js b/static/src/js/utils/__tests__/getKmsKeywordTree.test.js index 2685c57b4..68a5c4920 100644 --- a/static/src/js/utils/__tests__/getKmsKeywordTree.test.js +++ b/static/src/js/utils/__tests__/getKmsKeywordTree.test.js @@ -344,4 +344,34 @@ describe('getKmsKeywordTree', () => { }) }) }) + + describe('when providing a search pattern', () => { + test('should append search pattern to the endpoint URL', async () => { + const mockResponse = { + tree: { + treeData: [{ children: [{}] }] + } + } + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse) + }) + + const searchPattern = 'someSearchPattern' + await getKmsKeywordTree( + { + version: '21.0', + version_type: 'draft' + }, + { name: 'idnnode' }, + searchPattern + ) + + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/tree/concept_scheme/idnnode?version=21.0&filter=someSearchPattern', + { method: 'GET' } + ) + }) + }) }) diff --git a/static/src/js/utils/getKmsConceptFullPaths.js b/static/src/js/utils/getKmsConceptFullPaths.js new file mode 100644 index 000000000..631e1d615 --- /dev/null +++ b/static/src/js/utils/getKmsConceptFullPaths.js @@ -0,0 +1,46 @@ +import { XMLParser } from 'fast-xml-parser' +import { castArray } from 'lodash-es' + +import { getApplicationConfig } from 'sharedUtils/getConfig' + +/** + * Fetches the full path(s) for a given KMS concept UUID and returns them as an array of strings. + * + * @async + * @function getKmsConceptFullPaths + * @param {string} value - The UUID of the KMS concept whose full paths are to be fetched. + * @returns {Promise} A promise that resolves to an array of full paths as strings. + * @throws Will throw an error if the fetch operation or XML parsing fails. + */ +const getKmsConceptFullPaths = async (value) => { + const { kmsHost } = getApplicationConfig() + try { + // Fetch data from KMS server + const response = await fetch(`${kmsHost}/concept_fullpaths/concept_uuid/${value}`, { + method: 'GET' + }) + + if (!response.ok) { + throw new Error(`getConceptFullPaths HTTP error! status: ${response.status}`) + } + + const xmlText = await response.text() + + // Parse XML to JavaScript object + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_' + }) + const result = parser.parse(xmlText) + + // Ensure we always have an array of FullPath objects + const fullPaths = castArray(result.FullPaths.FullPath) + + return fullPaths.map((path) => (path['#text'])) + } catch (error) { + console.error('Error fetching KMS concept schemes:', error) + throw error + } +} + +export { getKmsConceptFullPaths } diff --git a/static/src/js/utils/getKmsConceptSchemes.js b/static/src/js/utils/getKmsConceptSchemes.js index 7b1dcbb9d..52766c352 100644 --- a/static/src/js/utils/getKmsConceptSchemes.js +++ b/static/src/js/utils/getKmsConceptSchemes.js @@ -1,4 +1,5 @@ import { XMLParser } from 'fast-xml-parser' + import { getApplicationConfig } from 'sharedUtils/getConfig' /** diff --git a/static/src/js/utils/getKmsKeywordTree.js b/static/src/js/utils/getKmsKeywordTree.js index e002bd543..fe601df10 100644 --- a/static/src/js/utils/getKmsKeywordTree.js +++ b/static/src/js/utils/getKmsKeywordTree.js @@ -1,3 +1,5 @@ +import { castArray } from 'lodash-es' + import { getApplicationConfig } from 'sharedUtils/getConfig' /** @@ -6,6 +8,7 @@ import { getApplicationConfig } from 'sharedUtils/getConfig' * @returns {Object} A new node with added ID. */ const addIdsToNodes = (node) => { + if (!node) return null const newNode = { ...node, id: node.key || node.title @@ -36,7 +39,7 @@ const addIdsToNodes = (node) => { * console.error('Failed to get keyword tree:', error); * } */ -const getKmsKeywordTree = async (version, scheme) => { +const getKmsKeywordTree = async (version, scheme, searchPattern) => { const { kmsHost } = getApplicationConfig() try { // In case of published version, use 'published' instead of the version label @@ -47,8 +50,13 @@ const getKmsKeywordTree = async (version, scheme) => { const schemeParam = encodeURIComponent(scheme.name) + let endpoint = `${kmsHost}/tree/concept_scheme/${schemeParam}?version=${versionParam}` + if (searchPattern && searchPattern.trim() !== '') { + endpoint += `&filter=${searchPattern}` + } + // Fetch data from KMS server - const response = await fetch(`${kmsHost}/tree/concept_scheme/${schemeParam}?version=${versionParam}`, { + const response = await fetch(endpoint, { method: 'GET' }) @@ -59,7 +67,9 @@ const getKmsKeywordTree = async (version, scheme) => { const json = await response.json() // Add ids to all nodes in the tree - const treeWithIds = addIdsToNodes(json.tree.treeData[0].children[0]) + const childrenArray = castArray(json.tree.treeData[0].children) + const firstChild = childrenArray[0] + const treeWithIds = addIdsToNodes(firstChild) return treeWithIds } catch (error) { From 814e22958a7be798c17ac2f85dc0130adce2b795 Mon Sep 17 00:00:00 2001 From: "Christopher D. Gokey" Date: Tue, 6 May 2025 13:45:54 -0400 Subject: [PATCH 2/9] MMT-4017 and MMT-4002: Updated to how searching is handled in the tree. --- .../js/components/KeywordTree/KeywordTree.jsx | 1 + .../KmsConceptSelectionEditModal.jsx | 27 +++++++++---------- .../KmsConceptSelectionWidget.jsx | 15 ++++++----- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/static/src/js/components/KeywordTree/KeywordTree.jsx b/static/src/js/components/KeywordTree/KeywordTree.jsx index a88b9a03b..ed3ec0cfd 100644 --- a/static/src/js/components/KeywordTree/KeywordTree.jsx +++ b/static/src/js/components/KeywordTree/KeywordTree.jsx @@ -91,6 +91,7 @@ export const KeywordTree = ({ } }, []) + // Effect to manage tree expansion or node selection useEffect(() => { if (treeRef.current && treeData.length > 0) { if (openAll) { diff --git a/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx b/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx index a1561a876..d7edf22e4 100644 --- a/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx +++ b/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx @@ -2,7 +2,8 @@ import PropTypes from 'prop-types' import React, { useCallback, useEffect, - useState + useState, + useRef } from 'react' import CustomModal from '@/js/components/CustomModal/CustomModal' @@ -42,14 +43,14 @@ export const KmsConceptSelectionEditModal = ({ const [isTreeLoading, setIsTreeLoading] = useState(false) const [treeMessage, setTreeMessage] = useState('') const [searchPattern, setSearchPattern] = useState('') - const [searchPatternApplied, setSearchPatternApplied] = useState('') + const searchInputRef = useRef(null) const fetchTreeData = async () => { if (version && scheme) { setIsTreeLoading(true) try { - const data = await getKmsKeywordTree(version, selectedScheme, searchPatternApplied) + const data = await getKmsKeywordTree(version, selectedScheme, searchPattern) if (data) { setTreeData(data) } else { @@ -71,9 +72,9 @@ export const KmsConceptSelectionEditModal = ({ useEffect(() => { if (version && selectedScheme) { setTreeMessage('Loading...') - fetchTreeData(version, selectedScheme, searchPatternApplied) + fetchTreeData(version, selectedScheme, searchPattern) } - }, [show, version, selectedScheme, searchPatternApplied]) + }, [show, version, selectedScheme, searchPattern]) const onSchemeSelect = useCallback((schemeInfo) => { setSelectedScheme(schemeInfo) @@ -83,7 +84,7 @@ export const KmsConceptSelectionEditModal = ({ useEffect(() => { if (show) { setTreeMessage('Loading...') - fetchTreeData(version, selectedScheme, searchPatternApplied) + fetchTreeData(version, selectedScheme, searchPattern) } }, [show]) @@ -98,20 +99,18 @@ export const KmsConceptSelectionEditModal = ({ // New function to handle search input change const onHandleSearchInputChange = (event) => { - setSearchPattern(event.target.value) - if (event.target.value === '') { - setSearchPatternApplied('') + setSearchPattern('') } } const onHandleApplyFilteredSearch = () => { - setSearchPatternApplied(searchPattern) + setSearchPattern(searchInputRef.current.value) } const onHandleKeyDown = (event) => { if (event.key === 'Enter') { - setSearchPatternApplied(searchPattern) + setSearchPattern(searchInputRef.current.value) } } @@ -146,7 +145,7 @@ export const KmsConceptSelectionEditModal = ({ onKeyDown={onHandleKeyDown} placeholder="Search by Pattern or UUID" type="text" - value={searchPattern} + ref={searchInputRef} /> +
) }) @@ -35,11 +46,22 @@ vi.mock('@/js/utils/createFormDataFromRdf') vi.mock('@/js/components/KeywordForm/KeywordForm', () => ({ __esModule: true, - default: ({ initialData }) => ( + default: vi.fn(({ initialData, onFormDataChange }) => (
{JSON.stringify(initialData, null, 2)}
+
- ) + )) })) vi.mock('@/js/components/MetadataPreviewPlaceholder/MetadataPreviewPlaceholder', () => ({ @@ -621,5 +643,45 @@ describe('KeywordManagerPage component', () => { expect(mockCreateFormDataFromRdf).toHaveBeenCalledWith('') }) }) + + test('should update selected keyword data when handleAddNarrower is called', async () => { + const { user } = setup() + + // Select version and scheme + await waitFor(() => { + expect(screen.getByTestId('version-selector')).toBeInTheDocument() + }) + + const versionSelector = screen.getByTestId('version-selector') + await user.selectOptions(versionSelector, '2.0') + + await waitFor(() => { + expect(screen.getByTestId('scheme-selector')).toBeInTheDocument() + }) + + const schemeSelector = screen.getByTestId('scheme-selector') + await user.selectOptions(schemeSelector, 'scheme1') + + // Wait for the tree to load + await waitFor(() => { + expect(screen.getByTestId('keyword-tree')).toBeInTheDocument() + }) + + // Find and click the "Add Narrower" button + const addNarrowerButton = screen.getByText('Add Narrower') + await user.click(addNarrowerButton) + + // Wait for the KeywordForm to be rendered + let keywordFormContent + await waitFor(() => { + keywordFormContent = screen.getByTestId('keyword-form').textContent + expect(keywordFormContent).toBeTruthy() + }) + + // Now perform the individual assertions + expect(keywordFormContent).toContain('"KeywordUUID": "new-id"') + expect(keywordFormContent).toContain('"PreferredLabel": "New Keyword"') + expect(keywordFormContent).toContain('"BroaderKeyword": "parent-id"') + }) }) }) From 96201975bddb1830588c295aa249f2b9564a54ce Mon Sep 17 00:00:00 2001 From: "Christopher D. Gokey" Date: Wed, 7 May 2025 11:16:33 -0400 Subject: [PATCH 5/9] MMT-4002: Rebased with main. --- .../CustomTitleFieldTemplate.test.jsx | 2 +- .../__tests__/JsonPreview.test.jsx | 6 +- .../js/components/KeywordForm/KeywordForm.jsx | 59 +++-- .../__tests__/KeywordForm.test.jsx | 36 ++- .../js/components/KeywordTree/KeywordTree.jsx | 38 ++- .../__tests__/KeywordTree.test.jsx | 60 +++++ .../KeywordTreeContextMenu.jsx | 1 - .../KeywordTreeCustomNode.jsx | 48 +++- .../__tests__/KeywordTreeCustomNode.test.jsx | 46 ++++ .../KmsConceptSchemeSelector.jsx | 45 ++-- .../KmsConceptSchemeSelector.test.jsx | 158 +++++------ .../KmsConceptSelectionEditModal.jsx | 212 +++++++++++++++ .../KmsConceptSelectionEditModal.scss | 48 ++++ .../KmsConceptSelectionEditModal.test.jsx | 250 ++++++++++++++++++ .../KmsConceptSelectionWidget.jsx | 181 +++++++++++++ .../KmsConceptSelectionWidget.scss | 49 ++++ .../KMSConceptSelectionWidget.test.jsx | 168 ++++++++++++ .../KmsConceptVersionSelector.jsx | 25 +- .../KmsConceptVersionSelector.test.jsx | 8 +- .../KeywordManagerPage/KeywordManagerPage.jsx | 31 ++- .../__tests__/KeywordManagerPage.test.jsx | 3 +- .../schemas/uiSchemas/keywords/editKeyword.js | 34 ++- static/src/js/schemas/umm/keywordSchema.js | 12 +- .../utils/__tests__/createFormDataFromRdf.js | 6 +- .../__tests__/getKmsConceptFullPaths.test.js | 48 ++++ .../__tests__/getKmsConceptSchemes.test.js | 10 +- .../utils/__tests__/getKmsKeywordTree.test.js | 30 +++ static/src/js/utils/createFormDataFromRdf.js | 7 +- static/src/js/utils/getKmsConceptFullPaths.js | 46 ++++ static/src/js/utils/getKmsConceptSchemes.js | 1 + static/src/js/utils/getKmsKeywordTree.js | 16 +- 31 files changed, 1483 insertions(+), 201 deletions(-) create mode 100644 static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx create mode 100644 static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.scss create mode 100644 static/src/js/components/KmsConceptSelectionEditModal/__tests__/KmsConceptSelectionEditModal.test.jsx create mode 100644 static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.jsx create mode 100644 static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.scss create mode 100644 static/src/js/components/KmsConceptSelectionWidget/__tests__/KMSConceptSelectionWidget.test.jsx create mode 100644 static/src/js/utils/__tests__/getKmsConceptFullPaths.test.js create mode 100644 static/src/js/utils/getKmsConceptFullPaths.js diff --git a/static/src/js/components/CustomTitleFieldTemplate/__tests__/CustomTitleFieldTemplate.test.jsx b/static/src/js/components/CustomTitleFieldTemplate/__tests__/CustomTitleFieldTemplate.test.jsx index c315d50ec..cfb15b3d3 100644 --- a/static/src/js/components/CustomTitleFieldTemplate/__tests__/CustomTitleFieldTemplate.test.jsx +++ b/static/src/js/components/CustomTitleFieldTemplate/__tests__/CustomTitleFieldTemplate.test.jsx @@ -56,7 +56,7 @@ describe('CustomTitleFieldTemplate', () => { }) describe('when a title field with hide-header set to true', () => { - it('renders it with no header', () => { + test('renders it with no header', () => { setup({ uiSchema: { 'ui:hide-header': true diff --git a/static/src/js/components/JsonPreview/__tests__/JsonPreview.test.jsx b/static/src/js/components/JsonPreview/__tests__/JsonPreview.test.jsx index 0fc4f5ceb..ccae5670c 100644 --- a/static/src/js/components/JsonPreview/__tests__/JsonPreview.test.jsx +++ b/static/src/js/components/JsonPreview/__tests__/JsonPreview.test.jsx @@ -17,7 +17,7 @@ const setup = (draft = undefined) => { describe('JsonPreview Component', () => { describe('when draft is not present in the context', () => { - it('renders JSONPretty', () => { + test('renders JSONPretty', () => { setup() expect(JSONPretty).toHaveBeenCalledTimes(1) @@ -28,7 +28,7 @@ describe('JsonPreview Component', () => { }) describe('when ummMetadata is not present in draft', () => { - it('renders JSONPretty', () => { + test('renders JSONPretty', () => { setup({}) expect(JSONPretty).toHaveBeenCalledTimes(1) @@ -39,7 +39,7 @@ describe('JsonPreview Component', () => { }) describe('when draft metadata exists', () => { - it('renders JSONPretty', () => { + test('renders JSONPretty', () => { setup({ ummMetadata: { Name: 'Mock Name' diff --git a/static/src/js/components/KeywordForm/KeywordForm.jsx b/static/src/js/components/KeywordForm/KeywordForm.jsx index 5f91d7ddb..2ce49e061 100644 --- a/static/src/js/components/KeywordForm/KeywordForm.jsx +++ b/static/src/js/components/KeywordForm/KeywordForm.jsx @@ -1,20 +1,23 @@ -import React, { useState, useEffect } from 'react' -import PropTypes from 'prop-types' -import validator from '@rjsf/validator-ajv8' import Form from '@rjsf/core' +import validator from '@rjsf/validator-ajv8' +import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' import CustomArrayTemplate from '@/js/components/CustomArrayFieldTemplate/CustomArrayFieldTemplate' import CustomFieldTemplate from '@/js/components/CustomFieldTemplate/CustomFieldTemplate' import CustomTextareaWidget from '@/js/components/CustomTextareaWidget/CustomTextareaWidget' import CustomTextWidget from '@/js/components/CustomTextWidget/CustomTextWidget' import GridLayout from '@/js/components/GridLayout/GridLayout' - import editKeywordsUiSchema from '@/js/schemas/uiSchemas/keywords/editKeyword' import keywordSchema from '@/js/schemas/umm/keywordSchema' +import KmsConceptSelectionWidget from '../KmsConceptSelectionWidget/KmsConceptSelectionWidget' + const KeywordForm = ({ initialData, - onFormDataChange + onFormDataChange, + scheme, + version }) => { const [formData, setFormData] = useState(initialData) @@ -23,11 +26,12 @@ const KeywordForm = ({ }, [initialData]) const fields = { + kmsConceptSelection: KmsConceptSelectionWidget, layout: GridLayout } const widgets = { - TextareaWidget: CustomTextareaWidget, - TextWidget: CustomTextWidget + TextWidget: CustomTextWidget, + TextareaWidget: CustomTextareaWidget } const templates = { ArrayFieldTemplate: CustomArrayTemplate, @@ -55,6 +59,12 @@ const KeywordForm = ({ uiSchema={editKeywordsUiSchema} formData={formData} onChange={handleChange} + formContext={ + { + scheme, + version + } + } // OnSubmit={handleSubmit} validator={validator} > @@ -75,29 +85,38 @@ KeywordForm.defaultProps = { KeywordForm.propTypes = { initialData: PropTypes.shape({ - KeywordUUID: PropTypes.string, - BroaderKeyword: PropTypes.string, - NarrowerKeywords: PropTypes.arrayOf(PropTypes.shape({ - NarrowerUUID: PropTypes.string - })), - PreferredLabel: PropTypes.string, AlternateLabels: PropTypes.arrayOf(PropTypes.shape({ LabelName: PropTypes.string, LabelType: PropTypes.string })), + BroaderKeywords: PropTypes.arrayOf(PropTypes.shape({ + BroaderUUID: PropTypes.string + })), + ChangeLogs: PropTypes.string, Definition: PropTypes.string, DefinitionReference: PropTypes.string, - Resources: PropTypes.arrayOf(PropTypes.shape({ - ResourceType: PropTypes.string, - ResourceUri: PropTypes.string + KeywordUUID: PropTypes.string, + NarrowerKeywords: PropTypes.arrayOf(PropTypes.shape({ + NarrowerUUID: PropTypes.string })), + PreferredLabel: PropTypes.string, RelatedKeywords: PropTypes.arrayOf(PropTypes.shape({ - UUID: PropTypes.string, - RelationshipType: PropTypes.string + RelationshipType: PropTypes.string, + UUID: PropTypes.string })), - ChangeLogs: PropTypes.string + Resources: PropTypes.arrayOf(PropTypes.shape({ + ResourceType: PropTypes.string, + ResourceUri: PropTypes.string + })) }), - onFormDataChange: PropTypes.func + onFormDataChange: PropTypes.func, + scheme: PropTypes.shape({ + name: PropTypes.string + }).isRequired, + version: PropTypes.shape({ + version: PropTypes.string, + version_type: PropTypes.string + }).isRequired } export default KeywordForm diff --git a/static/src/js/components/KeywordForm/__tests__/KeywordForm.test.jsx b/static/src/js/components/KeywordForm/__tests__/KeywordForm.test.jsx index 412967ad1..97d97b752 100644 --- a/static/src/js/components/KeywordForm/__tests__/KeywordForm.test.jsx +++ b/static/src/js/components/KeywordForm/__tests__/KeywordForm.test.jsx @@ -1,16 +1,17 @@ -import React from 'react' import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' import { describe, - test, expect, + test, vi } from 'vitest' -import userEvent from '@testing-library/user-event' + import KeywordForm from '../KeywordForm' vi.mock('@/js/utils/getUmmSchema', () => ({ @@ -40,12 +41,22 @@ describe('when KeywordForm is rendered', () => { } test('should display the form title', () => { - render() + render() + expect(screen.getByText('Edit Keyword')).toBeInTheDocument() }) test('should render the form with initial data', () => { - render() + render() + expect(screen.getByDisplayValue('Test Keyword')).toBeInTheDocument() expect(screen.getByDisplayValue('This is a test keyword')).toBeInTheDocument() }) @@ -58,6 +69,8 @@ describe('when user types in the form', () => { render() @@ -80,10 +93,19 @@ describe('when user types in the form', () => { describe('when initialData prop changes', () => { test('should update the form', () => { - const { rerender } = render() + const { rerender } = render() expect(screen.getByDisplayValue('Initial Keyword')).toBeInTheDocument() - rerender() + rerender() + expect(screen.getByDisplayValue('Updated Keyword')).toBeInTheDocument() }) }) diff --git a/static/src/js/components/KeywordTree/KeywordTree.jsx b/static/src/js/components/KeywordTree/KeywordTree.jsx index 5a52919d8..48510d9a5 100644 --- a/static/src/js/components/KeywordTree/KeywordTree.jsx +++ b/static/src/js/components/KeywordTree/KeywordTree.jsx @@ -72,7 +72,7 @@ import './KeywordTree.scss' * ); */ export const KeywordTree = ({ - data, onAddNarrower, onNodeClick, onNodeEdit + data, onAddNarrower, onNodeClick, onNodeEdit, searchTerm, selectedNodeId, openAll }) => { const [treeData, setTreeData] = useState(Array.isArray(data) ? data : [data]) const treeRef = useRef(null) @@ -99,13 +99,26 @@ export const KeywordTree = ({ useEffect(() => { if (treeRef.current && treeData.length > 0) { - const tree = treeRef.current - const rootNode = tree.get(treeData[0].id) - if (rootNode) { - rootNode.open() + if (openAll) { + treeRef.current.openAll() + } else if (selectedNodeId) { + treeRef.current.openParents(selectedNodeId) + setTimeout(() => { // Delay to potentially allow tree updates + const node = treeRef.current.get(selectedNodeId) + if (node) { + treeRef.current.select(selectedNodeId) + treeRef.current.scrollTo(selectedNodeId, 'center') + } + }, 0) + } else { + const tree = treeRef.current + const rootNode = tree.get(treeData[0].id) + if (rootNode) { + rootNode.open() + } } } - }, [treeData]) + }, [treeData, openAll]) const closeAllDescendants = (node) => { if (node.isOpen) { @@ -223,9 +236,9 @@ export const KeywordTree = ({ node={node} onAdd={handleAdd} onDelete={handleDelete} + searchTerm={searchTerm} setContextMenu={setContextMenu} onToggle={handleToggle} - onClick={onNodeClick} onEdit={onNodeEdit} onNodeClick={onNodeClick} handleAdd={handleAdd} @@ -273,12 +286,21 @@ const NodeShape = { } NodeShape.children = PropTypes.arrayOf(PropTypes.shape(NodeShape)) +KeywordTree.defaultProps = { + searchTerm: null, + selectedNodeId: null, + openAll: false +} + KeywordTree.propTypes = { + selectedNodeId: PropTypes.string, data: PropTypes.oneOfType([ PropTypes.shape(NodeShape), PropTypes.arrayOf(PropTypes.shape(NodeShape)) ]).isRequired, onAddNarrower: PropTypes.func.isRequired, onNodeClick: PropTypes.func.isRequired, - onNodeEdit: PropTypes.func.isRequired + onNodeEdit: PropTypes.func.isRequired, + searchTerm: PropTypes.string, + openAll: PropTypes.bool } diff --git a/static/src/js/components/KeywordTree/__tests__/KeywordTree.test.jsx b/static/src/js/components/KeywordTree/__tests__/KeywordTree.test.jsx index d926dc29b..7f695dbd4 100644 --- a/static/src/js/components/KeywordTree/__tests__/KeywordTree.test.jsx +++ b/static/src/js/components/KeywordTree/__tests__/KeywordTree.test.jsx @@ -741,4 +741,64 @@ describe('KeywordTree component', () => { }) }) }) + + describe('When opening the tree', () => { + const treeData = [ + { + id: '1', + key: '1', + title: 'Root', + children: [ + { + id: '2', + key: '2', + title: 'Node 2', + children: [ + { + id: '3', + key: '3', + title: 'Node 3', + children: [] + } + ] + } + ] + } + ] + test('should open all nodes when openAll is true', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByText('Node 3')).toBeVisible() + }) + + expect(screen.getByText('Root')).toBeVisible() + expect(screen.getByText('Node 3')).toBeVisible() + }) + + test('should scroll to selected node when selectedNodeId is provided', async () => { + render( + + ) + + await waitFor(() => { + expect(screen.getByText('Node 2')).toBeVisible() + }) + + expect(screen.getByText('Node 2')).toBeVisible() + expect(screen.queryByText('Node 3')).not.toBeInTheDocument() + }) + }) }) diff --git a/static/src/js/components/KeywordTreeContextMenu/KeywordTreeContextMenu.jsx b/static/src/js/components/KeywordTreeContextMenu/KeywordTreeContextMenu.jsx index 9cb3c8154..c4a39b5c1 100644 --- a/static/src/js/components/KeywordTreeContextMenu/KeywordTreeContextMenu.jsx +++ b/static/src/js/components/KeywordTreeContextMenu/KeywordTreeContextMenu.jsx @@ -102,7 +102,6 @@ export const KeywordTreeContextMenu = ({ } onKeyDown={ (e) => { - console.log('KeyDown event triggered', e.key) if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() option.action() diff --git a/static/src/js/components/KeywordTreeCustomNode/KeywordTreeCustomNode.jsx b/static/src/js/components/KeywordTreeCustomNode/KeywordTreeCustomNode.jsx index f8a2c7759..e3e4245cb 100644 --- a/static/src/js/components/KeywordTreeCustomNode/KeywordTreeCustomNode.jsx +++ b/static/src/js/components/KeywordTreeCustomNode/KeywordTreeCustomNode.jsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react' import PropTypes from 'prop-types' +import React, { useState } from 'react' import './KeywordTreeCustomNode.scss' @@ -47,6 +47,7 @@ export const KeywordTreeCustomNode = ({ onEdit, onNodeClick, onToggle, + searchTerm, setContextMenu, style }) => { @@ -82,6 +83,36 @@ export const KeywordTreeCustomNode = ({ setContextMenu(newContextMenu) } + let backgroundColor = 'transparent' + if (node.isSelected) { + backgroundColor = '#99ccff' + } else if (isHovered) { + backgroundColor = '#cce5ff' + } + + const highlightSearchTerm = (text, term) => { + if (!term) { + // Return the original text if there is no search term + return text + } + + // Define a regular expression to match the search term, case-insensitive + const regex = new RegExp(`(${term})`, 'gi') + // Split the text by the search term regex + const parts = text.split(regex) + + // Map over each part, applying tags to the matched term parts + return parts.map((part, index) => { + const key = `${part}-${index}` // This still uses index but further distinguished with text content + + if (regex.test(part)) { + return {part} + } + + return part + }) + } + return (
- {node.data.title} + {highlightSearchTerm(node.data.title, searchTerm)}
@@ -159,18 +190,21 @@ KeywordTreeCustomNode.propTypes = { children: NodeShape.children }).isRequired, isOpen: PropTypes.bool.isRequired, + isSelected: PropTypes.bool.isRequired, toggle: PropTypes.func.isRequired, id: PropTypes.string.isRequired }).isRequired, + style: PropTypes.shape({}), onDelete: PropTypes.func.isRequired, - onEdit: PropTypes.func.isRequired, - onNodeClick: PropTypes.func.isRequired, - onToggle: PropTypes.func.isRequired, + searchTerm: PropTypes.string, setContextMenu: PropTypes.func.isRequired, - style: PropTypes.shape({}) + onToggle: PropTypes.func.isRequired, + onEdit: PropTypes.func.isRequired, + onNodeClick: PropTypes.func.isRequired } KeywordTreeCustomNode.defaultProps = { dragHandle: null, + searchTerm: null, style: {} } diff --git a/static/src/js/components/KeywordTreeCustomNode/__tests__/KeywordTreeCustomNode.test.jsx b/static/src/js/components/KeywordTreeCustomNode/__tests__/KeywordTreeCustomNode.test.jsx index 3759b2c5b..12dfeb7cb 100644 --- a/static/src/js/components/KeywordTreeCustomNode/__tests__/KeywordTreeCustomNode.test.jsx +++ b/static/src/js/components/KeywordTreeCustomNode/__tests__/KeywordTreeCustomNode.test.jsx @@ -215,4 +215,50 @@ describe('KeywordTreeCustomNode component', () => { expect(defaultProps.onDelete).toHaveBeenCalledWith('1') }) }) + + describe('when a search pattern is provided', () => { + describe('when a match occurs', () => { + test('should highlight matched search term in node title', () => { + const propsWithSearchTerm = { + ...defaultProps, + searchTerm: 'Node' + } + render() + + const highlightedText = screen.getByText((content, element) => element.tagName.toLowerCase() === 'strong' && content === 'Node') + + expect(highlightedText).toBeInTheDocument() + }) + }) + + describe('when a match does not occur', () => { + test('renders node title without changes', () => { + const propsWithNoMatchTerm = { + ...defaultProps, + searchTerm: 'NoMatch' + } + render() + + const regularText = screen.getByText('Node 1') + + expect(regularText).toBeInTheDocument() + expect(regularText.tagName.toLowerCase()).not.toBe('strong') + }) + }) + + describe('when search term is empty', () => { + test('should render node title without changes', () => { + const propsWithEmptySearchTerm = { + ...defaultProps, + searchTerm: '' + } + render() + + const regularText = screen.getByText('Node 1') + + expect(regularText).toBeInTheDocument() + expect(regularText.tagName.toLowerCase()).not.toBe('strong') + }) + }) + }) }) diff --git a/static/src/js/components/KmsConceptSchemeSelector/KmsConceptSchemeSelector.jsx b/static/src/js/components/KmsConceptSchemeSelector/KmsConceptSchemeSelector.jsx index 329a335b1..deb03bbe6 100644 --- a/static/src/js/components/KmsConceptSchemeSelector/KmsConceptSchemeSelector.jsx +++ b/static/src/js/components/KmsConceptSchemeSelector/KmsConceptSchemeSelector.jsx @@ -1,8 +1,7 @@ -import React, { useState, useEffect } from 'react' import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' import Select from 'react-select' -import Row from 'react-bootstrap/Row' -import Col from 'react-bootstrap/Col' + import getKmsConceptSchemes from '@/js/utils/getKmsConceptSchemes' /** @@ -15,7 +14,7 @@ import getKmsConceptSchemes from '@/js/utils/getKmsConceptSchemes' * @param {string} props.version - The version of KMS to fetch schemes for * @param {function} props.onSchemeSelect - Callback function triggered when a scheme is selected */ -const KmsConceptSchemeSelector = ({ version, onSchemeSelect }) => { +const KmsConceptSchemeSelector = ({ version, defaultScheme, onSchemeSelect }) => { // State for storing the list of schemes const [schemes, setSchemes] = useState([]) // State for storing the currently selected scheme @@ -55,14 +54,12 @@ const KmsConceptSchemeSelector = ({ version, onSchemeSelect }) => { // Select the first option if (options.length > 0) { - const firstOption = options[0] - setSelectedScheme(firstOption) - onSchemeSelect({ - name: firstOption.value, - longName: firstOption.label, - updateDate: firstOption.updateDate, - csvHeaders: firstOption.csvHeaders - }) + if (defaultScheme) { + const matchingScheme = options.find((option) => option.value === defaultScheme?.name) + if (matchingScheme) { + setSelectedScheme(matchingScheme) + } + } } setLoading(false) @@ -91,23 +88,20 @@ const KmsConceptSchemeSelector = ({ version, onSchemeSelect }) => { } return ( - - -
- ) } KmsConceptSchemeSelector.propTypes = { + defaultScheme: PropTypes.shape({ + name: PropTypes.string + }), version: PropTypes.shape({ version: PropTypes.string, version_type: PropTypes.string @@ -116,6 +110,7 @@ KmsConceptSchemeSelector.propTypes = { } KmsConceptSchemeSelector.defaultProps = { + defaultScheme: null, version: null, onSchemeSelect: () => {} } diff --git a/static/src/js/components/KmsConceptSchemeSelector/__tests__/KmsConceptSchemeSelector.test.jsx b/static/src/js/components/KmsConceptSchemeSelector/__tests__/KmsConceptSchemeSelector.test.jsx index 0a98d5ab2..9679bebba 100644 --- a/static/src/js/components/KmsConceptSchemeSelector/__tests__/KmsConceptSchemeSelector.test.jsx +++ b/static/src/js/components/KmsConceptSchemeSelector/__tests__/KmsConceptSchemeSelector.test.jsx @@ -34,7 +34,7 @@ describe('KmsConceptSchemeSelector', () => { describe('when component is initially rendered', () => { test('should display loading state', () => { render() - expect(screen.getByText('Loading schemes...')).toBeInTheDocument() + expect(screen.getByText('Select scheme...')).toBeInTheDocument() }) }) @@ -74,33 +74,6 @@ describe('KmsConceptSchemeSelector', () => { expect(getKmsConceptSchemes).toHaveBeenCalledWith(mockVersion) }) - - test('should select first scheme by default and call onSchemeSelect', async () => { - const mockSchemes = [ - { - name: 'Scheme 1', - updateDate: '2023-01-01', - csvHeaders: ['header1', 'header2'] - }, - { - name: 'Scheme 2', - updateDate: '2023-01-02', - csvHeaders: ['header3', 'header4'] - } - ] - getKmsConceptSchemes.mockResolvedValue({ schemes: mockSchemes }) - - render() - - await waitFor(() => { - expect(mockOnSchemeSelect).toHaveBeenCalledWith({ - name: 'Scheme 1', - longName: 'Scheme 1', - updateDate: '2023-01-01', - csvHeaders: ['header1', 'header2'] - }) - }) - }) }) describe('when user selects a new scheme', () => { @@ -121,12 +94,29 @@ describe('KmsConceptSchemeSelector', () => { render() + // Open the dropdown + const selectElement = screen.getByRole('combobox') + await userEvent.click(selectElement) + + // Assert options are in the document await waitFor(() => { - expect(screen.getByText('Scheme 1')).toBeInTheDocument() + expect(screen.getByText('Select scheme...')).toBeInTheDocument() }) - await userEvent.click(screen.getByText('Scheme 1')) - await userEvent.click(screen.getByText('Scheme 2')) + // Wait for options to appear + await waitFor(() => { + const option1 = screen.getByRole('option', { name: 'Scheme 1' }) + expect(option1).toBeInTheDocument() + }) + + // Select the first option + const option1 = screen.getByText('Scheme 1') + await userEvent.click(option1) + + // Reopen dropdown and select 'Scheme 2' + await userEvent.click(selectElement) + const option2 = screen.getByRole('option', { name: 'Scheme 2' }) + await userEvent.click(option2) expect(mockOnSchemeSelect).toHaveBeenCalledWith({ name: 'Scheme 2', @@ -148,7 +138,7 @@ describe('KmsConceptSchemeSelector', () => { expect(console.error).toHaveBeenCalledWith('Error fetching schemes:', expect.any(Error)) }) - expect(screen.getByText('Loading schemes...')).toBeInTheDocument() + expect(screen.getByText('Select scheme...')).toBeInTheDocument() }) }) @@ -170,7 +160,7 @@ describe('KmsConceptSchemeSelector', () => { /> ) await waitFor(() => { - expect(screen.getByText('Scheme 1')).toBeInTheDocument() + expect(screen.getByText('Select scheme...')).toBeInTheDocument() }) rerender() @@ -178,7 +168,7 @@ describe('KmsConceptSchemeSelector', () => { await waitFor(() => {}) expect(screen.queryByText('Scheme 1')).toBeNull() - expect(screen.getByText('Loading schemes...')).toBeInTheDocument() + expect(screen.getByText('Select scheme...')).toBeInTheDocument() expect(getKmsConceptSchemes).toHaveBeenCalledTimes(1) }) }) @@ -206,8 +196,12 @@ describe('KmsConceptSchemeSelector', () => { render() + const selectElement = screen.getByRole('combobox') + await userEvent.click(selectElement) + await waitFor(() => { - expect(screen.queryByText('Loading schemes...')).not.toBeInTheDocument() + const options = screen.getAllByRole('option') + expect(options.length).toBe(3) }) expect(screen.getByText('A Scheme')).toBeInTheDocument() @@ -222,13 +216,6 @@ describe('KmsConceptSchemeSelector', () => { expect(dropdownOptions[0]).toHaveTextContent('A Scheme') expect(dropdownOptions[1]).toHaveTextContent('B Scheme') expect(dropdownOptions[2]).toHaveTextContent('C Scheme') - - expect(mockOnSchemeSelect).toHaveBeenCalledWith({ - name: 'A Scheme', - longName: 'A Scheme', - updateDate: '2023-01-02', - csvHeaders: ['header3', 'header4'] - }) }) }) @@ -255,8 +242,12 @@ describe('KmsConceptSchemeSelector', () => { render() + const selectElement = screen.getByRole('combobox') + await userEvent.click(selectElement) + await waitFor(() => { - expect(screen.queryByText('Loading schemes...')).not.toBeInTheDocument() + const options = screen.getAllByRole('option') + expect(options.length).toBe(3) }) expect(screen.getByText('A Scheme')).toBeInTheDocument() @@ -285,35 +276,6 @@ describe('KmsConceptSchemeSelector', () => { expect(mockOnSchemeSelect).not.toHaveBeenCalled() }) - - test('should select first option when schemes are loaded', async () => { - const mockSchemes = [ - { - name: 'Scheme 1', - updateDate: '2023-01-01', - csvHeaders: ['header1'] - }, - { - name: 'Scheme 2', - updateDate: '2023-01-02', - csvHeaders: ['header2'] - } - ] - getKmsConceptSchemes.mockResolvedValue({ schemes: mockSchemes }) - - render() - - await waitFor(() => { - expect(screen.getByText('Scheme 1')).toBeInTheDocument() - }) - - expect(mockOnSchemeSelect).toHaveBeenCalledWith({ - name: 'Scheme 1', - longName: 'Scheme 1', - updateDate: '2023-01-01', - csvHeaders: ['header1'] - }) - }) }) describe('when onSchemeSelect prop is not provided', () => { @@ -341,7 +303,7 @@ describe('KmsConceptSchemeSelector', () => { // Check if the correct option is selected await waitFor(() => { - expect(screen.getByText('Scheme 1')).toBeInTheDocument() + expect(screen.getByText('Select scheme...')).toBeInTheDocument() }) // Attempt to change the selected scheme @@ -383,8 +345,13 @@ describe('KmsConceptSchemeSelector', () => { onSchemeSelect={mockOnSchemeSelect} /> ) + + let selectElement = screen.getByRole('combobox') + await userEvent.click(selectElement) + await waitFor(() => { - expect(screen.getByText('Initial Scheme')).toBeInTheDocument() + const options = screen.getAllByRole('option') + expect(options.length).toBe(1) }) getKmsConceptSchemes.mockResolvedValueOnce({ schemes: updatedMockSchemes }) @@ -400,6 +367,15 @@ describe('KmsConceptSchemeSelector', () => { /> ) + selectElement = screen.getByRole('combobox') + + await userEvent.click(selectElement) + + await waitFor(() => { + const options = screen.getAllByRole('option') + expect(options.length).toBe(1) + }) + await waitFor(() => { expect(screen.getByText('Updated Scheme')).toBeInTheDocument() }) @@ -407,12 +383,36 @@ describe('KmsConceptSchemeSelector', () => { expect(getKmsConceptSchemes).toHaveBeenCalledTimes(2) expect(getKmsConceptSchemes).toHaveBeenNthCalledWith(1, mockVersion) expect(getKmsConceptSchemes).toHaveBeenNthCalledWith(2, updatedVersion) + }) + }) - expect(mockOnSchemeSelect).toHaveBeenCalledWith({ - name: 'Updated Scheme', - longName: 'Updated Scheme', - updateDate: '2023-01-02', - csvHeaders: ['header2'] + describe('when defaultScheme matches an option', () => { + test('should select the default scheme initially', async () => { + const mockSchemes = [ + { + name: 'Scheme 1', + updateDate: '2023-01-01', + csvHeaders: ['header1', 'header2'] + }, + { + name: 'Scheme 2', + updateDate: '2023-01-02', + csvHeaders: ['header3', 'header4'] + } + ] + getKmsConceptSchemes.mockResolvedValue({ schemes: mockSchemes }) + + render( + + ) + + await waitFor(() => { + const selectElement = screen.getByText('Scheme 1') + expect(selectElement).toBeInTheDocument() }) }) }) diff --git a/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx b/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx new file mode 100644 index 000000000..e1c96694f --- /dev/null +++ b/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx @@ -0,0 +1,212 @@ +import PropTypes from 'prop-types' +import React, { + useCallback, + useEffect, + useState +} from 'react' + +import CustomModal from '@/js/components/CustomModal/CustomModal' +import { KeywordTree } from '@/js/components/KeywordTree/KeywordTree' +import KmsConceptSchemeSelector from '@/js/components/KmsConceptSchemeSelector/KmsConceptSchemeSelector' +import getKmsKeywordTree from '@/js/utils/getKmsKeywordTree' + +import './KmsConceptSelectionEditModal.scss' +import { + KeywordTreePlaceHolder +} from '@/js/components/KeywordTreePlaceHolder/KeywordTreePlaceHolder' + +/** + * KmsConceptSelectionEditModal component provides an interface to edit keyword selections + * within a modal. It allows users to select a scheme, search for keywords, and apply their changes. + * + * @param {Object} props - React component props. + * @param {boolean} props.show - Determines if the modal is visible. + * @param {Function} props.toggleModal - Function to toggle the modal visibility. + * @param {string} props.uuid - UUID of the selected keyword. + * @param {Object} props.version - Object containing version details like version and version_type. + * @param {Object} props.scheme - Object containing scheme details. + * @param {Function} props.handleAcceptChanges - Callback function when changes are accepted. + * @returns {JSX.Element} The complete modal component for editing keyword selections. + */ +export const KmsConceptSelectionEditModal = ({ + handleAcceptChanges, + scheme, + show, + toggleModal, + uuid, + version +}) => { + const [treeData, setTreeData] = useState(null) + const [selectedScheme, setSelectedScheme] = useState(scheme) + const [selectedKeyword, setSelectedKeyword] = useState(uuid) + const [isTreeLoading, setIsTreeLoading] = useState(false) + const [treeMessage, setTreeMessage] = useState('') + const [searchPattern, setSearchPattern] = useState('') + const [searchPatternApplied, setSearchPatternApplied] = useState('') + + const fetchTreeData = async () => { + if (version && scheme) { + setIsTreeLoading(true) + + try { + const data = await getKmsKeywordTree(version, selectedScheme, searchPatternApplied) + if (data) { + setTreeData(data) + } else { + setTreeData(null) + setTreeMessage('No results.') + } + } catch (error) { + console.error('Error fetching keyword tree:', error) + setTreeData(null) + setTreeMessage('Failed to load the tree. Please try again.') + } finally { + setIsTreeLoading(false) + } + } else { + setTreeMessage('Select a version and scheme to load the tree') + } + } + + useEffect(() => { + if (version && selectedScheme) { + setTreeMessage('Loading...') + fetchTreeData(version, selectedScheme, searchPatternApplied) + } + }, [show, version, selectedScheme, searchPatternApplied]) + + const onSchemeSelect = useCallback((schemeInfo) => { + setSelectedScheme(schemeInfo) + setTreeData(null) + }, []) + + useEffect(() => { + if (show) { + setTreeMessage('Loading...') + fetchTreeData(version, selectedScheme, searchPatternApplied) + } + }, [show]) + + const onHandleSelectKeyword = (value) => { + setSelectedKeyword(value) + } + + const onHandleAcceptChanges = () => { + handleAcceptChanges(selectedKeyword) + toggleModal(false) + } + + // New function to handle search input change + const onHandleSearchInputChange = (event) => { + setSearchPattern(event.target.value) + + if (event.target.value === '') { + setSearchPatternApplied('') + } + } + + const onHandleApplyFilteredSearch = () => { + setSearchPatternApplied(searchPattern) + } + + const onHandleKeyDown = (event) => { + if (event.key === 'Enter') { + setSearchPatternApplied(searchPattern) + } + } + + const renderTree = () => { + if (isTreeLoading) { + return + } + + const schemeSelectorId = selectedScheme?.name + + return ( +
+
+ + +
+
+ + +
+ { + treeData ? ( + + ) : + } +
+ ) + } + + return ( + { toggleModal(false) } + }, + { + label: 'Accept', + variant: 'primary', + onClick: onHandleAcceptChanges + } + ] + } + /> + ) +} + +KmsConceptSelectionEditModal.propTypes = { + handleAcceptChanges: PropTypes.func.isRequired, + scheme: PropTypes.shape({ + name: PropTypes.string + }).isRequired, + show: PropTypes.bool.isRequired, + toggleModal: PropTypes.func.isRequired, + uuid: PropTypes.string.isRequired, + version: PropTypes.shape({ + version: PropTypes.string, + version_type: PropTypes.string + }).isRequired +} diff --git a/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.scss b/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.scss new file mode 100644 index 000000000..1ec549c95 --- /dev/null +++ b/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.scss @@ -0,0 +1,48 @@ +@use "sass:map"; + +@import '../../../css/vendor/bootstrap/variables'; + +.kms-concept-selection-edit-modal { + &__search-input { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + inline-size: 80%; + + &:focus { + border-color: #007bff; + outline: none; + } + } + + &__scheme-selector { + display: flex; + align-items: center; + padding-block-end: 8px; + } + + &__label { + font-weight: bold; + padding-inline-end: 8px; + } + + &__tree-wrapper { + display: flex; + align-items: center; + margin-block-end: 12px; + } + + &__tree-placeholder { + display: flex; + align-items: center; + justify-content: center; + border: 1px solid $gray-300; + border-radius: 4px; + block-size: 300px; + inline-size: 100%; + } + + &__apply-button { + margin-inline-start: 10px; + } +} \ No newline at end of file diff --git a/static/src/js/components/KmsConceptSelectionEditModal/__tests__/KmsConceptSelectionEditModal.test.jsx b/static/src/js/components/KmsConceptSelectionEditModal/__tests__/KmsConceptSelectionEditModal.test.jsx new file mode 100644 index 000000000..037625618 --- /dev/null +++ b/static/src/js/components/KmsConceptSelectionEditModal/__tests__/KmsConceptSelectionEditModal.test.jsx @@ -0,0 +1,250 @@ +import { + fireEvent, + render, + screen, + waitFor +} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import PropTypes from 'prop-types' +import React from 'react' +import { + beforeEach, + describe, + expect, + vi +} from 'vitest' + +import { + KmsConceptSelectionEditModal +} from '@/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal' +import getKmsKeywordTree from '@/js/utils/getKmsKeywordTree' + +vi.mock('@/js/components/KeywordTree/KeywordTree', () => { + const MockKeywordTree = ({ onNodeClick }) => ( + + ) + + MockKeywordTree.propTypes = { + onNodeClick: PropTypes.func.isRequired + } + + return { KeywordTree: MockKeywordTree } +}) + +vi.mock('@/js/components/KmsConceptSchemeSelector/KmsConceptSchemeSelector', () => { + const MockKmsConceptSchemeSelector = ({ onSchemeSelect }) => ( + + ) + + MockKmsConceptSchemeSelector.propTypes = { + onSchemeSelect: PropTypes.func.isRequired + } + + return { default: MockKmsConceptSchemeSelector } +}) + +vi.mock('@/js/utils/getKmsKeywordTree', () => ({ + default: vi.fn(() => Promise.resolve([{ id: 'mock-tree-data' }])) +})) + +describe('KmsConceptSelectionEditModal', () => { + const defaultProps = { + show: true, + toggleModal: vi.fn(), + uuid: 'test-uuid', + version: { + version: '1.0', + version_type: 'published' + }, + scheme: { name: 'Test Scheme' }, + handleAcceptChanges: vi.fn() + } + + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + describe('when the modal appears', () => { + test('should render the header', async () => { + render() + const modalHeader = await screen.findByText('Select Concept') + expect(modalHeader).toBeInTheDocument() + }) + + test('should render the scheme selector', async () => { + render() + await waitFor(() => { + expect(screen.getByTestId('scheme-selector')).toBeTruthy() + }) + }) + + test('should render the keyword tree', async () => { + render() + await waitFor(() => { + expect(screen.getByTestId('keyword-tree')).toBeTruthy() + }) + }) + }) + + describe('when the modal is hidden', () => { + test('should not show the modal', async () => { + render() + await waitFor(() => { + expect(screen.queryByTestId('custom-modal')).not.toBeInTheDocument() + }) + }) + }) + + describe('when user selects a scheme', () => { + test('shows tree is loading', async () => { + render() + const schemeSelector = await screen.findByTestId('scheme-selector') + fireEvent.change(schemeSelector) + await waitFor(() => { + expect(screen.getByText('Loading...')).toBeTruthy() + }) + }) + + test('should render tree when a scheme is selected', async () => { + render() + const schemeSelector = await screen.findByTestId('scheme-selector') + fireEvent.change(schemeSelector) + await waitFor(() => { + expect(screen.getByTestId('keyword-tree')).toBeTruthy() + }) + }) + }) + + describe('when user accept changes', () => { + test('should responds with the uuid of the selected keyword', async () => { + const handleAcceptChangesMock = vi.fn() + const props = { + ...defaultProps, + handleAcceptChanges: handleAcceptChangesMock + } + + render() + + await waitFor(async () => { + const keywordTree = screen.getByTestId('keyword-tree') + await userEvent.click(keywordTree) + }) + + // Find and click the Accept button + const acceptButton = screen.getByText('Accept') + await userEvent.click(acceptButton) + + // Check if handleAcceptChanges was called with the new keyword value + expect(handleAcceptChangesMock).toHaveBeenCalledWith('mock-uuid') + }) + }) + + describe('when user cancels changes', () => { + test('should close the modal', async () => { + render() + await waitFor(async () => { + const cancelButton = screen.getByText('Cancel') + await userEvent.click(cancelButton) + expect(defaultProps.toggleModal).toHaveBeenCalledWith(false) + }) + }) + }) + + describe('when the user searches', () => { + describe('when apply button is pressed', () => { + test('should fetch the tree and includes search term', async () => { + render() + const searchInput = await screen.findByPlaceholderText('Search by Pattern or UUID') + fireEvent.change(searchInput, { target: { value: 'test search' } }) + const applyButton = screen.getByText('Apply') + fireEvent.click(applyButton) + await waitFor(() => { + expect(getKmsKeywordTree).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'test search') + }) + }) + }) + + describe('when enter is pressed', () => { + test('should fetch the tree and includes search term', async () => { + render() + const searchInput = await screen.findByPlaceholderText('Search by Pattern or UUID') + fireEvent.change(searchInput, { target: { value: 'test search' } }) + fireEvent.keyDown(searchInput, { key: 'Enter' }) + await waitFor(() => { + expect(getKmsKeywordTree).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'test search') + }) + }) + }) + + describe('when fetch returns no results', () => { + test('should show no results', async () => { + vi.mocked(getKmsKeywordTree).mockResolvedValue(null) + render() + const searchInput = await screen.findByPlaceholderText('Search by Pattern or UUID') + await userEvent.type(searchInput, 'test pattern') + const applyButton = screen.getByText('Apply') + await userEvent.click(applyButton) + await waitFor(() => { + expect(screen.getByText('No results.')).toBeInTheDocument() + }) + }) + }) + + test('should handle fetch errors', async () => { + vi.mocked(getKmsKeywordTree).mockRejectedValue(new Error('Fetch error')) + render() + await waitFor(() => { + expect(screen.getByText('Failed to load the tree. Please try again.')).toBeInTheDocument() + }) + }) + + test('should handling clearing the search box', async () => { + render() + const textInput = await screen.findByPlaceholderText('Search by Pattern or UUID') + fireEvent.change(textInput, { target: { value: 'test pattern' } }) + fireEvent.change(textInput, { target: { value: '' } }) + await waitFor(() => { + expect(getKmsKeywordTree).toHaveBeenCalledWith(expect.anything(), expect.anything(), '') + }) + }) + }) + + describe('when no version is provided', () => { + test('displays correct message', async () => { + const propsWithoutVersion = { + ...defaultProps, + version: null + } + render() + await waitFor(() => { + expect(screen.getByText('Select a version and scheme to load the tree')).toBeInTheDocument() + }) + }) + }) + + describe('when no scheme is provided', () => { + test('displays correct message', async () => { + const propsWithoutScheme = { + ...defaultProps, + scheme: null + } + render() + await waitFor(() => { + expect(screen.getByText('Select a version and scheme to load the tree')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.jsx b/static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.jsx new file mode 100644 index 000000000..3eda74e65 --- /dev/null +++ b/static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.jsx @@ -0,0 +1,181 @@ +import { startCase } from 'lodash-es' +import PropTypes from 'prop-types' +import React, { + useEffect, + useRef, + useState +} from 'react' +import { FaPencilAlt } from 'react-icons/fa' + +import Button from '@/js/components/Button/Button' +import { + KmsConceptSelectionEditModal +} from '@/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal' +import { getKmsConceptFullPaths } from '@/js/utils/getKmsConceptFullPaths' + +import CustomWidgetWrapper from '../CustomWidgetWrapper/CustomWidgetWrapper' + +import './KmsConceptSelectionWidget.scss' + +/** + * KmsConceptSelectionWidget component for selecting and editing keyword concepts + * within a user interface. It uses a modal to allow keyword selection and then + * displays the selected keyword and its full path. + * + * @component + * @param {Object} props - Component properties. + * @param {string} props.id - Unique identifier for the widget. + * @param {string} props.label - Label displayed for the widget. + * @param {Function} props.onChange - Callback function to handle changes. + * @param {Object} props.registry - Contains form context information. + * @param {boolean} [props.required=false] - Specifies if the field is required. + * @param {Object} props.schema - Schema containing field constraints. + * @param {Object} props.uiSchema - UI schema allowing customization of display. + * @param {string|number} [props.value=''] - Current value of the widget. + * @returns {JSX.Element} The rendered component. + */ +const KmsConceptSelectionWidget = ({ + id, + label, + onChange, + registry, + required, + schema, + uiSchema, + value +}) => { + const [keywordLabel, setKeywordLabel] = useState('') + const [fullPath, setFullPath] = useState('') + const [showEditModal, setShowEditModal] = useState(false) + const [error, setError] = useState('') + const inputScrollRef = useRef(null) + const focusRef = useRef(null) + + const { formContext } = registry + const { version, scheme } = formContext + + const { maxLength, description } = schema + + let title = startCase(label.split(/-/)[0]) + if (uiSchema['ui:title']) { + title = uiSchema['ui:title'] + } + + useEffect(() => { + const fetchFullPaths = async () => { + try { + let fullPaths = await getKmsConceptFullPaths(value) + const lastField = fullPaths[0].split('|').pop() + fullPaths = fullPaths.map((path) => path.replaceAll('|', ' > ')) + setKeywordLabel(lastField) + setFullPath(fullPaths.join('\n')) + } catch (errorObj) { + console.error(`Error fetching keyword for ${value}`, errorObj) + setError(`Error fetching keyword for ${value}`) + } + } + + if (value !== null && value.trim() !== '') { + fetchFullPaths() + } + }, [value]) + + const toggleEditModal = (nextState) => { + setShowEditModal(nextState) + } + + return ( + +
+ + {keywordLabel || 'No value set'} + +
+ + { + error && ( +
+ {error} +
+ ) + } + {' '} + { + onChange(uuidValue) + } + } + /> +
+ ) +} + +KmsConceptSelectionWidget.defaultProps = { + value: '', + required: false +} + +KmsConceptSelectionWidget.propTypes = { + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + registry: PropTypes.shape({ + formContext: PropTypes.shape({ + focusField: PropTypes.string, + setFocusField: PropTypes.func, + version: PropTypes.shape({ + version: PropTypes.string, + version_type: PropTypes.string + }).isRequired, + scheme: PropTypes.shape({ + name: PropTypes.string + }).isRequired + }).isRequired + }).isRequired, + required: PropTypes.bool, + schema: PropTypes.shape({ + description: PropTypes.string, + maxLength: PropTypes.number, + type: PropTypes.string + }).isRequired, + uiSchema: PropTypes.shape({ + 'ui:title': PropTypes.string + }).isRequired, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]) +} + +export default KmsConceptSelectionWidget diff --git a/static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.scss b/static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.scss new file mode 100644 index 000000000..e086e7f2f --- /dev/null +++ b/static/src/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget.scss @@ -0,0 +1,49 @@ +.kms-concept-selection-widget { + &__container { + display: inline-flex; + align-items: center; + line-height: 1; + } + + &__label { + color: #333; + font-size: 14px; + margin-inline-end: 8px; + } + + &__edit-button { + display: inline-flex; + align-items: center; + border: none; + margin: 0; + background: none; + color: #007bff; + cursor: pointer; + padding-inline-start: 5px; + transition: color 0.2s ease-in-out; + + &:hover, + &:focus { + color: #0056b3; + } + } + + &__input { + padding: 8px; + border: 1px solid #ccc; + border-radius: 4px; + inline-size: 80%; + transition: border-color 0.2s ease; + + &:focus { + border-color: #007bff; + outline: none; + } + } + + &__compact-alert { + padding: 0.5rem 1rem; + font-size: 0.875rem; + margin-block-end: 0.5rem; + } +} \ No newline at end of file diff --git a/static/src/js/components/KmsConceptSelectionWidget/__tests__/KMSConceptSelectionWidget.test.jsx b/static/src/js/components/KmsConceptSelectionWidget/__tests__/KMSConceptSelectionWidget.test.jsx new file mode 100644 index 000000000..3e957a0c1 --- /dev/null +++ b/static/src/js/components/KmsConceptSelectionWidget/__tests__/KMSConceptSelectionWidget.test.jsx @@ -0,0 +1,168 @@ +import { + fireEvent, + render, + screen, + waitFor +} from '@testing-library/react' +import PropTypes from 'prop-types' +import React from 'react' +import { Button } from 'react-bootstrap' +import { + beforeEach, + describe, + vi +} from 'vitest' + +import KmsConceptSelectionWidget from '@/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget' +import { getKmsConceptFullPaths } from '@/js/utils/getKmsConceptFullPaths' + +// Mock the `getKmsConceptFullPaths` and Button components +vi.mock('@/js/utils/getKmsConceptFullPaths') + +vi.mock('react-icons/fa', () => ({ + FaPencilAlt: () => , + FaInfoCircle: () => , + FaTimes: () => +})) + +vi.mock('@/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal', () => { + const KmsConceptSelectionEditModal = ({ show, handleAcceptChanges, toggleModal }) => ( + show ? ( +
+

Handle Change Modal

+ + {/* Add a button to simulate modal close using toggleModal */} + +
+ ) : null + ) + + KmsConceptSelectionEditModal.propTypes = { + show: PropTypes.bool.isRequired, + handleAcceptChanges: PropTypes.func.isRequired, + toggleModal: PropTypes.func.isRequired + } + + return { KmsConceptSelectionEditModal } +}) + +describe('KmsConceptSelectionWidget', () => { + beforeEach(() => { + getKmsConceptFullPaths.mockClear() + getKmsConceptFullPaths.mockResolvedValue(['ScienceKeywords | ATMOSPHERE | TORNADOES']) + }) + + const renderComponent = (props = {}) => render( + + ) + + describe('when a user first loads the page', () => { + test('should render the page', async () => { + renderComponent() + await waitFor(() => { + expect(screen.getByText(/tornadoes/i)).toBeInTheDocument() + }) + }) + + test('should fetch and display the correct keyword', async () => { + renderComponent() + expect(getKmsConceptFullPaths).toHaveBeenCalledWith('test-uuid') + await waitFor(async () => { + expect(await screen.findByText('TORNADOES')).toBeInTheDocument() + }) + }) + + test('should show the full path as a title attribute', async () => { + renderComponent() + expect(getKmsConceptFullPaths).toHaveBeenCalledWith('test-uuid') + await waitFor(() => { + const keywordElement = screen.getByText(/tornadoes/i) + const expectedTitle = /ScienceKeywords\s*>\s*ATMOSPHERE\s*>\s*TORNADOES/ + expect(keywordElement).toHaveAttribute('title', expect.stringMatching(expectedTitle)) + }) + }) + }) + + describe('when a user clicks the edit icon', () => { + test('should open the edit modal', async () => { + renderComponent() + const button = screen.getByRole('button', { name: /select/i }) + fireEvent.click(button) + await waitFor(() => { + expect(screen.getByText('Handle Change Modal')).toBeInTheDocument() + }) + }) + + test('should toggle the edit modal state correctly', async () => { + renderComponent() + + // Open the modal + const editButton = screen.getByRole('button', { name: /select/i }) + fireEvent.click(editButton) + expect(screen.getByText('Handle Change Modal')).toBeInTheDocument() + + // Simulate clicking the custom cancel button in the modal + const cancelButton = screen.getByRole('button', { name: /cancel/i }) + fireEvent.click(cancelButton) + + // Wait for the modal to no longer be in the document + await waitFor(() => { + expect(screen.queryByText('Handle Change Modal')).toBeNull() + }) + }) + }) + + describe('when a user accepts changes', () => { + test('should call onChange when accepting the updated keyword from the modal', async () => { + const onChangeMock = vi.fn() + renderComponent({ onChange: onChangeMock }) + + fireEvent.click(screen.getByRole('button', { name: /select/i })) + fireEvent.click(screen.getByText('Accept Change')) + + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalledWith('new-uuid') + }) + }) + }) + + describe('when the user encounters a network error', () => { + test('should handle errors by adding a notification', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + getKmsConceptFullPaths.mockRejectedValue(new Error('Failed to fetch full paths')) + + renderComponent() + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('Error fetching keyword for test-uuid') + }) + }) + }) +}) diff --git a/static/src/js/components/KmsConceptVersionSelector/KmsConceptVersionSelector.jsx b/static/src/js/components/KmsConceptVersionSelector/KmsConceptVersionSelector.jsx index 2e93bb899..9647b1423 100644 --- a/static/src/js/components/KmsConceptVersionSelector/KmsConceptVersionSelector.jsx +++ b/static/src/js/components/KmsConceptVersionSelector/KmsConceptVersionSelector.jsx @@ -1,8 +1,7 @@ -import React, { useState, useEffect } from 'react' import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' import Select from 'react-select' -import Row from 'react-bootstrap/Row' -import Col from 'react-bootstrap/Col' + import getKmsConceptVersions from '@/js/utils/getKmsConceptVersions' /** @@ -90,19 +89,13 @@ const KmsConceptVersionSelector = ({ onVersionSelect }) => { } return ( - - -
- ) } diff --git a/static/src/js/components/KmsConceptVersionSelector/__tests__/KmsConceptVersionSelector.test.jsx b/static/src/js/components/KmsConceptVersionSelector/__tests__/KmsConceptVersionSelector.test.jsx index 7f68e1375..9898194e5 100644 --- a/static/src/js/components/KmsConceptVersionSelector/__tests__/KmsConceptVersionSelector.test.jsx +++ b/static/src/js/components/KmsConceptVersionSelector/__tests__/KmsConceptVersionSelector.test.jsx @@ -1,13 +1,15 @@ -import React from 'react' import { + fireEvent, render, screen, - fireEvent, waitFor } from '@testing-library/react' -import { vi } from 'vitest' import userEvent from '@testing-library/user-event' +import React from 'react' +import { vi } from 'vitest' + import getKmsConceptVersions from '@/js/utils/getKmsConceptVersions' + import KmsConceptVersionSelector from '../KmsConceptVersionSelector' vi.mock('@/js/utils/getKmsConceptVersions') diff --git a/static/src/js/pages/KeywordManagerPage/KeywordManagerPage.jsx b/static/src/js/pages/KeywordManagerPage/KeywordManagerPage.jsx index 510596ec0..5ddeec3a8 100644 --- a/static/src/js/pages/KeywordManagerPage/KeywordManagerPage.jsx +++ b/static/src/js/pages/KeywordManagerPage/KeywordManagerPage.jsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect } from 'react' +import { Col, Row } from 'react-bootstrap' import { FaPlus } from 'react-icons/fa' import { getApplicationConfig } from 'sharedUtils/getConfig' @@ -80,7 +81,7 @@ const KeywordManagerPage = () => { const newKeywordData = { KeywordUUID: newKeyword.id, PreferredLabel: newKeyword.title, - BroaderKeyword: parentId + BroaderKeywords: [{ BroaderUUID: parentId }] } // Update the selected keyword data with the new keyword @@ -177,6 +178,8 @@ const KeywordManagerPage = () => { return ( ) } @@ -248,7 +251,13 @@ const KeywordManagerPage = () => { > Version: - + + +
+ +
+ +
- + + +
+ +
+ +
diff --git a/static/src/js/pages/KeywordManagerPage/__tests__/KeywordManagerPage.test.jsx b/static/src/js/pages/KeywordManagerPage/__tests__/KeywordManagerPage.test.jsx index e6aebafee..f9a704a0e 100644 --- a/static/src/js/pages/KeywordManagerPage/__tests__/KeywordManagerPage.test.jsx +++ b/static/src/js/pages/KeywordManagerPage/__tests__/KeywordManagerPage.test.jsx @@ -678,10 +678,9 @@ describe('KeywordManagerPage component', () => { expect(keywordFormContent).toBeTruthy() }) - // Now perform the individual assertions expect(keywordFormContent).toContain('"KeywordUUID": "new-id"') expect(keywordFormContent).toContain('"PreferredLabel": "New Keyword"') - expect(keywordFormContent).toContain('"BroaderKeyword": "parent-id"') + expect(keywordFormContent).toContain('"BroaderKeywords": [\n {\n "BroaderUUID": "parent-id"\n }\n ]') }) }) }) diff --git a/static/src/js/schemas/uiSchemas/keywords/editKeyword.js b/static/src/js/schemas/uiSchemas/keywords/editKeyword.js index 6a7a5daa4..72338a1ff 100644 --- a/static/src/js/schemas/uiSchemas/keywords/editKeyword.js +++ b/static/src/js/schemas/uiSchemas/keywords/editKeyword.js @@ -1,6 +1,7 @@ import CustomSelectWidget from '@/js/components/CustomSelectWidget/CustomSelectWidget' import CustomTextareaWidget from '@/js/components/CustomTextareaWidget/CustomTextareaWidget' import CustomTextWidget from '@/js/components/CustomTextWidget/CustomTextWidget' +import KmsConceptSelectionWidget from '@/js/components/KmsConceptSelectionWidget/KmsConceptSelectionWidget' const editKeywordsUiSchema = { 'ui:submitButtonOptions': { @@ -25,7 +26,7 @@ const editKeywordsUiSchema = { { 'ui:col': { md: 12, - children: ['BroaderKeyword'] + children: ['BroaderKeywords'] } }, { @@ -87,8 +88,24 @@ const editKeywordsUiSchema = { 'ui:widget': CustomTextWidget, 'ui:disabled': true }, - BroaderKeyword: { - 'ui:widget': CustomTextWidget + BroaderKeywords: { + items: { + 'ui:field': 'layout', + 'ui:layout_grid': { + 'ui:row': [ + { + 'ui:col': { + md: 12, + children: ['BroaderUUID'] + } + } + ] + }, + BroaderUUID: { + 'ui:widget': KmsConceptSelectionWidget, + 'ui:title': 'Keyword' + } + } }, NarrowerKeywords: { items: { @@ -102,10 +119,11 @@ const editKeywordsUiSchema = { } } ] + }, + NarrowerUUID: { + 'ui:widget': KmsConceptSelectionWidget, + 'ui:title': 'Keyword' } - }, - NarrowerUUID: { - 'ui:widget': CustomTextWidget } }, PreferredLabel: { @@ -194,8 +212,8 @@ const editKeywordsUiSchema = { 'ui:widget': CustomSelectWidget }, UUID: { - 'ui:widget': CustomTextWidget, - 'ui:readonly': true + 'ui:widget': KmsConceptSelectionWidget, + 'ui:title': 'Keyword' } } }, diff --git a/static/src/js/schemas/umm/keywordSchema.js b/static/src/js/schemas/umm/keywordSchema.js index 736d07d58..e97e2c267 100644 --- a/static/src/js/schemas/umm/keywordSchema.js +++ b/static/src/js/schemas/umm/keywordSchema.js @@ -7,8 +7,16 @@ const keywordSchema = { type: 'string', format: 'uuid' }, - BroaderKeyword: { - type: 'string' + BroaderKeywords: { + type: 'array', + items: { + type: 'object', + properties: { + BroaderUUID: { + type: 'string' + } + } + } }, NarrowerKeywords: { type: 'array', diff --git a/static/src/js/utils/__tests__/createFormDataFromRdf.js b/static/src/js/utils/__tests__/createFormDataFromRdf.js index 3b752be4d..0e445d706 100644 --- a/static/src/js/utils/__tests__/createFormDataFromRdf.js +++ b/static/src/js/utils/__tests__/createFormDataFromRdf.js @@ -28,7 +28,9 @@ describe('createFormDataFromRdf', () => { KeywordUUID: 'http://example.com/concept/123', PreferredLabel: 'Test Concept', Definition: 'This is a test concept', - BroaderKeyword: 'http://example.com/concept/parent', + BroaderKeywords: [ + { BroaderUUID: 'http://example.com/concept/parent' } + ], NarrowerKeywords: [ { NarrowerUUID: 'http://example.com/concept/child1' }, { NarrowerUUID: 'http://example.com/concept/child2' } @@ -176,7 +178,7 @@ describe('createFormDataFromRdf', () => { expect(result).toEqual({ KeywordUUID: 'http://example.com/concept/123', PreferredLabel: 'Test Concept', - BroaderKeyword: '', + BroaderKeywords: [], NarrowerKeywords: [], AlternateLabels: [], Definition: '', diff --git a/static/src/js/utils/__tests__/getKmsConceptFullPaths.test.js b/static/src/js/utils/__tests__/getKmsConceptFullPaths.test.js new file mode 100644 index 000000000..0c124f9d9 --- /dev/null +++ b/static/src/js/utils/__tests__/getKmsConceptFullPaths.test.js @@ -0,0 +1,48 @@ +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest' + +import { getKmsConceptFullPaths } from '@/js/utils/getKmsConceptFullPaths' + +// Mocking fetch +global.fetch = vi.fn(() => Promise.resolve({ + ok: true, + text: () => Promise.resolve(` + + Chained Operations + + `) +})) + +describe('getKmsConceptFullPaths', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + + fetch.mockClear() + }) + + it('should fetch and return full paths from KMS', async () => { + const value = 'test-uuid' + const expectedResult = ['Chained Operations'] + + const result = await getKmsConceptFullPaths(value) + + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledWith(expect.stringContaining(`/concept_fullpaths/concept_uuid/${value}`), { method: 'GET' }) + + expect(result).toEqual(expectedResult) + }) + + it('should throw an error if fetch fails', async () => { + fetch.mockImplementationOnce(() => Promise.resolve({ + ok: false, + status: 404 + })) + + await expect(getKmsConceptFullPaths('bad-uuid')).rejects.toThrow('getConceptFullPaths HTTP error! status: 404') + }) +}) diff --git a/static/src/js/utils/__tests__/getKmsConceptSchemes.test.js b/static/src/js/utils/__tests__/getKmsConceptSchemes.test.js index 9d7afb3d1..9a2244376 100644 --- a/static/src/js/utils/__tests__/getKmsConceptSchemes.test.js +++ b/static/src/js/utils/__tests__/getKmsConceptSchemes.test.js @@ -1,12 +1,14 @@ import { + afterEach, + beforeEach, describe, - test, expect, - vi, - beforeEach, - afterEach + test, + vi } from 'vitest' + import { getApplicationConfig } from 'sharedUtils/getConfig' + import getKmsConceptSchemes from '../getKmsConceptSchemes' vi.mock('sharedUtils/getConfig', () => ({ diff --git a/static/src/js/utils/__tests__/getKmsKeywordTree.test.js b/static/src/js/utils/__tests__/getKmsKeywordTree.test.js index 2685c57b4..68a5c4920 100644 --- a/static/src/js/utils/__tests__/getKmsKeywordTree.test.js +++ b/static/src/js/utils/__tests__/getKmsKeywordTree.test.js @@ -344,4 +344,34 @@ describe('getKmsKeywordTree', () => { }) }) }) + + describe('when providing a search pattern', () => { + test('should append search pattern to the endpoint URL', async () => { + const mockResponse = { + tree: { + treeData: [{ children: [{}] }] + } + } + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResponse) + }) + + const searchPattern = 'someSearchPattern' + await getKmsKeywordTree( + { + version: '21.0', + version_type: 'draft' + }, + { name: 'idnnode' }, + searchPattern + ) + + expect(global.fetch).toHaveBeenCalledWith( + 'http://example.com/tree/concept_scheme/idnnode?version=21.0&filter=someSearchPattern', + { method: 'GET' } + ) + }) + }) }) diff --git a/static/src/js/utils/createFormDataFromRdf.js b/static/src/js/utils/createFormDataFromRdf.js index 24f1456c0..21fde5b18 100644 --- a/static/src/js/utils/createFormDataFromRdf.js +++ b/static/src/js/utils/createFormDataFromRdf.js @@ -31,7 +31,10 @@ const createFormDataFromRdf = (rdfData) => { const keywordUUID = conceptElement['@_rdf:about'] const preferredLabel = getTextContent(conceptElement['skos:prefLabel']) const definition = getTextContent(conceptElement['skos:definition']) - const broaderKeyword = conceptElement['skos:broader']?.['@_rdf:resource'] || '' + const broaderKeywords = ensureArray(conceptElement['skos:broader']) + .map((broader) => ({ + BroaderUUID: broader['@_rdf:resource'] + })) const narrowerKeywords = ensureArray(conceptElement['skos:narrower']) .map((narrower) => ({ @@ -73,7 +76,7 @@ const createFormDataFromRdf = (rdfData) => { const transformedData = { KeywordUUID: keywordUUID, - BroaderKeyword: broaderKeyword, + BroaderKeywords: broaderKeywords, NarrowerKeywords: narrowerKeywords, PreferredLabel: preferredLabel, AlternateLabels: alternateLevels, diff --git a/static/src/js/utils/getKmsConceptFullPaths.js b/static/src/js/utils/getKmsConceptFullPaths.js new file mode 100644 index 000000000..631e1d615 --- /dev/null +++ b/static/src/js/utils/getKmsConceptFullPaths.js @@ -0,0 +1,46 @@ +import { XMLParser } from 'fast-xml-parser' +import { castArray } from 'lodash-es' + +import { getApplicationConfig } from 'sharedUtils/getConfig' + +/** + * Fetches the full path(s) for a given KMS concept UUID and returns them as an array of strings. + * + * @async + * @function getKmsConceptFullPaths + * @param {string} value - The UUID of the KMS concept whose full paths are to be fetched. + * @returns {Promise} A promise that resolves to an array of full paths as strings. + * @throws Will throw an error if the fetch operation or XML parsing fails. + */ +const getKmsConceptFullPaths = async (value) => { + const { kmsHost } = getApplicationConfig() + try { + // Fetch data from KMS server + const response = await fetch(`${kmsHost}/concept_fullpaths/concept_uuid/${value}`, { + method: 'GET' + }) + + if (!response.ok) { + throw new Error(`getConceptFullPaths HTTP error! status: ${response.status}`) + } + + const xmlText = await response.text() + + // Parse XML to JavaScript object + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_' + }) + const result = parser.parse(xmlText) + + // Ensure we always have an array of FullPath objects + const fullPaths = castArray(result.FullPaths.FullPath) + + return fullPaths.map((path) => (path['#text'])) + } catch (error) { + console.error('Error fetching KMS concept schemes:', error) + throw error + } +} + +export { getKmsConceptFullPaths } diff --git a/static/src/js/utils/getKmsConceptSchemes.js b/static/src/js/utils/getKmsConceptSchemes.js index 7b1dcbb9d..52766c352 100644 --- a/static/src/js/utils/getKmsConceptSchemes.js +++ b/static/src/js/utils/getKmsConceptSchemes.js @@ -1,4 +1,5 @@ import { XMLParser } from 'fast-xml-parser' + import { getApplicationConfig } from 'sharedUtils/getConfig' /** diff --git a/static/src/js/utils/getKmsKeywordTree.js b/static/src/js/utils/getKmsKeywordTree.js index e002bd543..fe601df10 100644 --- a/static/src/js/utils/getKmsKeywordTree.js +++ b/static/src/js/utils/getKmsKeywordTree.js @@ -1,3 +1,5 @@ +import { castArray } from 'lodash-es' + import { getApplicationConfig } from 'sharedUtils/getConfig' /** @@ -6,6 +8,7 @@ import { getApplicationConfig } from 'sharedUtils/getConfig' * @returns {Object} A new node with added ID. */ const addIdsToNodes = (node) => { + if (!node) return null const newNode = { ...node, id: node.key || node.title @@ -36,7 +39,7 @@ const addIdsToNodes = (node) => { * console.error('Failed to get keyword tree:', error); * } */ -const getKmsKeywordTree = async (version, scheme) => { +const getKmsKeywordTree = async (version, scheme, searchPattern) => { const { kmsHost } = getApplicationConfig() try { // In case of published version, use 'published' instead of the version label @@ -47,8 +50,13 @@ const getKmsKeywordTree = async (version, scheme) => { const schemeParam = encodeURIComponent(scheme.name) + let endpoint = `${kmsHost}/tree/concept_scheme/${schemeParam}?version=${versionParam}` + if (searchPattern && searchPattern.trim() !== '') { + endpoint += `&filter=${searchPattern}` + } + // Fetch data from KMS server - const response = await fetch(`${kmsHost}/tree/concept_scheme/${schemeParam}?version=${versionParam}`, { + const response = await fetch(endpoint, { method: 'GET' }) @@ -59,7 +67,9 @@ const getKmsKeywordTree = async (version, scheme) => { const json = await response.json() // Add ids to all nodes in the tree - const treeWithIds = addIdsToNodes(json.tree.treeData[0].children[0]) + const childrenArray = castArray(json.tree.treeData[0].children) + const firstChild = childrenArray[0] + const treeWithIds = addIdsToNodes(firstChild) return treeWithIds } catch (error) { From 4e23bd133d9b869b4f9fa46c373c94b0ce39645d Mon Sep 17 00:00:00 2001 From: "Christopher D. Gokey" Date: Tue, 6 May 2025 13:45:54 -0400 Subject: [PATCH 6/9] MMT-4017 and MMT-4002: Updated to how searching is handled in the tree. --- .../js/components/KeywordTree/KeywordTree.jsx | 1 + .../KmsConceptSelectionEditModal.jsx | 27 +++++++++---------- .../KmsConceptSelectionWidget.jsx | 15 ++++++----- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/static/src/js/components/KeywordTree/KeywordTree.jsx b/static/src/js/components/KeywordTree/KeywordTree.jsx index 48510d9a5..27da447d2 100644 --- a/static/src/js/components/KeywordTree/KeywordTree.jsx +++ b/static/src/js/components/KeywordTree/KeywordTree.jsx @@ -97,6 +97,7 @@ export const KeywordTree = ({ } }, []) + // Effect to manage tree expansion or node selection useEffect(() => { if (treeRef.current && treeData.length > 0) { if (openAll) { diff --git a/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx b/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx index e1c96694f..84e1cd21f 100644 --- a/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx +++ b/static/src/js/components/KmsConceptSelectionEditModal/KmsConceptSelectionEditModal.jsx @@ -2,7 +2,8 @@ import PropTypes from 'prop-types' import React, { useCallback, useEffect, - useState + useState, + useRef } from 'react' import CustomModal from '@/js/components/CustomModal/CustomModal' @@ -42,14 +43,14 @@ export const KmsConceptSelectionEditModal = ({ const [isTreeLoading, setIsTreeLoading] = useState(false) const [treeMessage, setTreeMessage] = useState('') const [searchPattern, setSearchPattern] = useState('') - const [searchPatternApplied, setSearchPatternApplied] = useState('') + const searchInputRef = useRef(null) const fetchTreeData = async () => { if (version && scheme) { setIsTreeLoading(true) try { - const data = await getKmsKeywordTree(version, selectedScheme, searchPatternApplied) + const data = await getKmsKeywordTree(version, selectedScheme, searchPattern) if (data) { setTreeData(data) } else { @@ -71,9 +72,9 @@ export const KmsConceptSelectionEditModal = ({ useEffect(() => { if (version && selectedScheme) { setTreeMessage('Loading...') - fetchTreeData(version, selectedScheme, searchPatternApplied) + fetchTreeData(version, selectedScheme, searchPattern) } - }, [show, version, selectedScheme, searchPatternApplied]) + }, [show, version, selectedScheme, searchPattern]) const onSchemeSelect = useCallback((schemeInfo) => { setSelectedScheme(schemeInfo) @@ -83,7 +84,7 @@ export const KmsConceptSelectionEditModal = ({ useEffect(() => { if (show) { setTreeMessage('Loading...') - fetchTreeData(version, selectedScheme, searchPatternApplied) + fetchTreeData(version, selectedScheme, searchPattern) } }, [show]) @@ -98,20 +99,18 @@ export const KmsConceptSelectionEditModal = ({ // New function to handle search input change const onHandleSearchInputChange = (event) => { - setSearchPattern(event.target.value) - if (event.target.value === '') { - setSearchPatternApplied('') + setSearchPattern('') } } const onHandleApplyFilteredSearch = () => { - setSearchPatternApplied(searchPattern) + setSearchPattern(searchInputRef.current.value) } const onHandleKeyDown = (event) => { if (event.key === 'Enter') { - setSearchPatternApplied(searchPattern) + setSearchPattern(searchInputRef.current.value) } } @@ -146,7 +145,7 @@ export const KmsConceptSelectionEditModal = ({ onKeyDown={onHandleKeyDown} placeholder="Search by Pattern or UUID" type="text" - value={searchPattern} + ref={searchInputRef} />