diff --git a/__mocks__/peaks.js b/__mocks__/peaks.js index 2bfc8851..dcd3e18c 100644 --- a/__mocks__/peaks.js +++ b/__mocks__/peaks.js @@ -62,7 +62,7 @@ export const Peaks = jest.fn((opts) => { return peaks; }); -// segements are built in match with timespans from 'testSmData' +// segments are built in match with timespans from 'testSmData' // in ./testing-helpers.js file const peaksSegments = (opts, peaks) => { let segments = [ @@ -104,6 +104,12 @@ const peaksSegments = (opts, peaks) => { export const Segment = jest.fn((opts) => { let segment = { ...opts }; + // Ensure both id and _id are set + if (opts._id && !opts.id) { + segment.id = opts._id; + } else if (opts.id && !opts._id) { + segment._id = opts.id; + } let checkProp = (newOpts, prop) => { if (newOpts.hasOwnProperty(prop)) { return true; diff --git a/src/App.test.js b/src/App.test.js index 3fcf6522..ae57e0a7 100644 --- a/src/App.test.js +++ b/src/App.test.js @@ -10,6 +10,7 @@ import { manifestWoStructure, manifestWithInvalidStruct, manifestWEmptyCanvas, + manifestWEmptyRanges, } from './services/testing-helpers'; import mockAxios from 'axios'; import Peaks from 'peaks.js'; @@ -289,8 +290,9 @@ describe('App component', () => { await act(() => Promise.resolve()); expect(app.queryByTestId('waveform-container')).toBeInTheDocument(); - expect(app.queryByTestId('alert-container')).toBeInTheDocument(); - expect(app.getByTestId('alert-message').innerHTML).toBe( + // Display 2 alerts for empty media and invalid structure + expect(app.queryAllByTestId('alert-container').length).toEqual(2); + expect(app.getAllByTestId('alert-message')[1].innerHTML).toBe( 'No available media. Editing structure is disabled.' ); }); @@ -432,39 +434,79 @@ describe('App component', () => { }); }); - test('when structure has invalid timespans', async () => { - mockAxios.get.mockImplementationOnce(() => { - return Promise.resolve({ - status: 200, - data: manifestWithInvalidStruct + describe('when structure has', () => { + test('invalid timespans', async () => { + mockAxios.get.mockImplementationOnce(() => { + return Promise.resolve({ + status: 200, + data: manifestWithInvalidStruct + }); }); - }); - mockAxios.head.mockImplementationOnce(() => { - return Promise.resolve({ - status: 200, - request: { - responseURL: 'https://example.com/lunchroom_manners/waveform.json', + mockAxios.head.mockImplementationOnce(() => { + return Promise.resolve({ + status: 200, + request: { + responseURL: 'https://example.com/lunchroom_manners/waveform.json', + }, + }); + }); + const initialState = { + manifest: { + manifestFetched: true, + manifest: manifestWithInvalidStruct, + mediaInfo: { + src: 'http://example.com/volleyball-for-boys/high/volleyball-for-boys.mp4', + duration: 662.037, + }, }, + }; + const app = renderWithRedux(, { initialState }); + + await waitFor(() => { + expect(app.queryAllByTestId('list-item').length).toBeGreaterThan(0); + expect(app.getAllByTestId('heading-label')[0].innerHTML).toEqual('Lunchroom Manners'); + expect(app.getByTestId('alert-container')).toBeInTheDocument(); + expect(app.getByTestId('alert-message').innerHTML) + .toEqual('Please check the marked invalid timespan(s)/heading(s).'); + expect(app.getByTestId('structure-save-button')).not.toBeEnabled(); }); }); - const initialState = { - manifest: { - manifestFetched: true, - manifest: manifestWithInvalidStruct, - mediaInfo: { - src: 'http://example.com/volleyball-for-boys/high/volleyball-for-boys.mp4', - duration: 662.037, + + test('invalid headings (empty)', async () => { + mockAxios.get.mockImplementationOnce(() => { + return Promise.resolve({ + status: 200, + data: manifestWEmptyRanges + }); + }); + mockAxios.head.mockImplementationOnce(() => { + return Promise.resolve({ + status: 200, + request: { + responseURL: 'https://example.com/lunchroom_manners/waveform.json', + }, + }); + }); + const initialState = { + manifest: { + manifestFetched: true, + manifest: manifestWEmptyRanges, + mediaInfo: { + src: 'http://example.com/volleyball-for-boys/high/volleyball-for-boys.mp4', + duration: 662.037, + }, }, - }, - }; - const app = renderWithRedux(, { initialState }); + }; + const app = renderWithRedux(, { initialState }); - await waitFor(() => { - expect(app.queryAllByTestId('list-item').length).toBeGreaterThan(0); - expect(app.getAllByTestId('heading-label')[0].innerHTML).toEqual('Lunchroom Manners'); - expect(app.getByTestId('alert-container')).toBeInTheDocument(); - expect(app.getByTestId('alert-message').innerHTML) - .toEqual('Please check start/end times of the marked invalid timespan(s).'); + await waitFor(() => { + expect(app.queryAllByTestId('list-item').length).toBeGreaterThan(0); + expect(app.getAllByTestId('heading-label')[0].innerHTML).toEqual('Root'); + expect(app.getByTestId('alert-container')).toBeInTheDocument(); + expect(app.getByTestId('alert-message').innerHTML) + .toEqual('Please check the marked invalid timespan(s)/heading(s).'); + expect(app.getByTestId('structure-save-button')).not.toBeEnabled(); + }); }); }); }); diff --git a/src/actions/forms.js b/src/actions/forms.js index 7beed4ea..14839e5c 100644 --- a/src/actions/forms.js +++ b/src/actions/forms.js @@ -6,19 +6,10 @@ import { v4 as uuidv4 } from 'uuid'; * Enable/disable other editing actions when editing a list item * @param {Integer} code - choose from; 1(true) | 0(false) */ -export const handleEditingTimespans = - ( - code, - valid = true // assumes structure data is valid by default - ) => - (dispatch) => { - dispatch({ type: types.IS_EDITING_TIMESPAN, code }); - // Remove dismissible alerts when a CRUD action has been initiated - // given editing is starting (code = 1) and structure is validated. - if (code == 1 && valid) { - dispatch(clearExistingAlerts()); - } - }; +export const handleEditingTimespans = (code) => ({ + type: types.IS_EDITING_TIMESPAN, + code +}); export const setAlert = (alert) => (dispatch) => { const id = uuidv4(); diff --git a/src/actions/sm-data.js b/src/actions/sm-data.js index 1ece0da8..708e6805 100644 --- a/src/actions/sm-data.js +++ b/src/actions/sm-data.js @@ -1,17 +1,5 @@ import * as types from './types'; -import { updateStructureStatus, clearExistingAlerts } from './forms'; - -export function reBuildSMUI(items, duration) { - return (dispatch, getState) => { - dispatch(buildSMUI(items, duration)); - const { structuralMetadata } = getState(); - // Remove invalid structure alert when data is corrected - if (structuralMetadata.smDataIsValid) { - dispatch(clearExistingAlerts()); - } - dispatch(updateStructureStatus(0)); - }; -} +import { updateStructureStatus } from './forms'; export function buildSMUI(json, duration) { return { @@ -21,10 +9,11 @@ export function buildSMUI(json, duration) { }; } -export function deleteItem(id) { +export function updateSMUI(json, isValid) { return { - type: types.DELETE_ITEM, - id, + type: types.UPDATE_SM_UI, + json, + isValid, }; } diff --git a/src/actions/types.js b/src/actions/types.js index cafef0c4..e223def1 100644 --- a/src/actions/types.js +++ b/src/actions/types.js @@ -1,6 +1,5 @@ export const BUILD_SM_UI = 'BUILD_SM_UI'; -export const REBUILD_SM_UI = 'REBUILD_SM_UI'; -export const DELETE_ITEM = 'DELETE_ITEM'; +export const UPDATE_SM_UI = 'UPDATE_SM_UI'; export const ADD_DROP_TARGETS = 'ADD_DROP_TARGETS'; export const REMOVE_DROP_TARGETS = 'REMOVE_DROP_TARGETS'; diff --git a/src/components/ButtonSection.js b/src/components/ButtonSection.js index d87e94d8..f852bce0 100644 --- a/src/components/ButtonSection.js +++ b/src/components/ButtonSection.js @@ -7,7 +7,8 @@ import HeadingFormContainer from '../containers/HeadingFormContainer'; import TimespanFormContainer from '../containers/TimespanFormContainer'; import * as peaksActions from '../actions/peaks-instance'; import { configureAlert } from '../services/alert-status'; -import { handleEditingTimespans, setAlert } from '../actions/forms'; +import { setAlert } from '../actions/forms'; +import { useStructureUpdate } from '../services/sme-hooks'; const styles = { well: { @@ -27,8 +28,11 @@ const ButtonSection = () => { const dispatch = useDispatch(); const createTempSegment = () => dispatch(peaksActions.insertTempSegment()); const removeTempSegment = (id) => dispatch(peaksActions.deleteTempSegment(id)); - const updateEditingTimespans = (value) => dispatch(handleEditingTimespans(value)); const settingAlert = (alert) => dispatch(setAlert(alert)); + const dragSegment = (id, startTimeChanged, flag) => + dispatch(peaksActions.dragSegment(id, startTimeChanged, flag)); + + const { updateEditingTimespans } = useStructureUpdate(); // Get state variables from Redux store const { editingDisabled, structureInfo, streamInfo } = useSelector((state) => state.forms); @@ -90,7 +94,7 @@ const ButtonSection = () => { settingAlert(noSpaceAlert); } else { // Initialize Redux store with temporary segment - dispatch(peaksActions.dragSegment(tempSegment.id, null, 0)); + dragSegment(tempSegment.id, null, 0); setInitSegment(tempSegment); setTimespanOpen(true); setIsInitializing(true); diff --git a/src/components/ListItem.js b/src/components/ListItem.js index b9c61269..defa05e3 100644 --- a/src/components/ListItem.js +++ b/src/components/ListItem.js @@ -10,23 +10,18 @@ import ListItemEditForm from './ListItemEditForm'; import ListItemControls from './ListItemControls'; import { ItemTypes } from '../services/Constants'; import * as actions from '../actions/sm-data'; -import { deleteSegment } from '../actions/peaks-instance'; -import { handleEditingTimespans } from '../actions/forms'; +import { useStructureUpdate } from '../services/sme-hooks'; const ListItem = ({ item, children }) => { // Dispatch actions to Redux store const dispatch = useDispatch(); - const deleteItem = (id) => dispatch(actions.deleteItem(id)); const addDropTargets = (item) => dispatch(actions.addDropTargets(item)); const removeDropTargets = () => dispatch(actions.removeDropTargets()); const removeActiveDragSources = () => dispatch(actions.removeActiveDragSources()); const setActiveDragSource = (id) => dispatch(actions.setActiveDragSource(id)); const handleListItemDrop = (item, dropItem) => dispatch(actions.handleListItemDrop(item, dropItem)); - const removeSegment = (item) => dispatch(deleteSegment(item)); - const updateEditingTimespans = (value, smDataIsValid) => dispatch(handleEditingTimespans(value, smDataIsValid)); - // Get state variables from Redux store - const { smDataIsValid } = useSelector((state) => state.structuralMetadata); + const { deleteStructItem, updateEditingTimespans } = useStructureUpdate(); const [editing, setEditing] = useState(false); @@ -54,21 +49,20 @@ const ListItem = ({ item, children }) => { const handleDelete = () => { try { - deleteItem(item.id); - removeSegment(item); + deleteStructItem(item); } catch (error) { showBoundary(error); } }; const handleEditClick = () => { - updateEditingTimespans(1, smDataIsValid); + updateEditingTimespans(1); setEditing(true); }; const handleEditFormCancel = () => { setEditing(false); - updateEditingTimespans(0, smDataIsValid); + updateEditingTimespans(0); }; const handleShowDropTargetsClick = () => { @@ -143,6 +137,15 @@ const ListItem = ({ item, children }) => { className='structure-title heading' data-testid='heading-label' > + {(!valid && type !== 'root') && ( + <> + + {' '} + + )} {label} )} diff --git a/src/components/ListItemControls.js b/src/components/ListItemControls.js index cba59382..075da484 100644 --- a/src/components/ListItemControls.js +++ b/src/components/ListItemControls.js @@ -7,12 +7,9 @@ import PopoverBody from 'react-bootstrap/PopoverBody'; import PopoverHeader from 'react-bootstrap/PopoverHeader'; import PropTypes from 'prop-types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useDispatch, useSelector } from 'react-redux'; -import { - handleEditingTimespans, - updateStructureStatus, -} from '../actions/forms'; +import { useSelector } from 'react-redux'; import { faPen, faTrash, faDotCircle } from '@fortawesome/free-solid-svg-icons'; +import { useStructureUpdate } from '../services/sme-hooks'; const styles = { buttonToolbar: { @@ -26,10 +23,7 @@ const styles = { }; const ListItemControls = ({ handleDelete, handleEditClick, handleShowDropTargetsClick, item }) => { - // Dispatch actions to Redux store - const dispatch = useDispatch(); - const updateEditingTimespans = (value) => dispatch(handleEditingTimespans(value)); - const updateStructStatus = (value) => dispatch(updateStructureStatus(value)); + const { updateEditingTimespans } = useStructureUpdate(); // Get state variables from Redux store const { editingDisabled } = useSelector((state) => state.forms); @@ -47,8 +41,6 @@ const ListItemControls = ({ handleDelete, handleEditClick, handleShowDropTargets enableEditing(); setDeleteMessage(''); setShowDeleteConfirm(false); - // Change structureIsSaved to false - updateStructStatus(0); }; const handleDeleteClick = (e) => { diff --git a/src/components/ListItemEditForm.js b/src/components/ListItemEditForm.js index f11c9f13..69c45be2 100644 --- a/src/components/ListItemEditForm.js +++ b/src/components/ListItemEditForm.js @@ -1,23 +1,20 @@ import React, { useEffect, useState } from 'react'; import { useErrorBoundary } from 'react-error-boundary'; import PropTypes from 'prop-types'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import TimespanInlineForm from './TimespanInlineForm'; import HeadingInlineForm from './HeadingInlineForm'; -import { reBuildSMUI } from '../actions/sm-data'; import { cloneDeep } from 'lodash'; import StructuralMetadataUtils from '../services/StructuralMetadataUtils'; +import { useStructureUpdate } from '../services/sme-hooks'; const structuralMetadataUtils = new StructuralMetadataUtils(); const ListItemEditForm = ({ item, handleEditFormCancel }) => { - // Dispatch actions to Redux store - const dispatch = useDispatch(); - const updateSMUI = (cloned, duration) => dispatch(reBuildSMUI(cloned, duration)); + const { updateStructure } = useStructureUpdate(); // Get state variables from Redux store const { smData } = useSelector((state) => state.structuralMetadata); - const { duration } = useSelector((state) => state.peaksInstance); const [isTyping, _setIsTyping] = useState(false); const [isInitializing, _setIsInitializing] = useState(true); @@ -78,8 +75,8 @@ const ListItemEditForm = ({ item, handleEditFormCancel }) => { // Update item values item = addUpdatedValues(item, payload); - // Send updated smData back to redux - updateSMUI(clonedItems, duration); + // Send updated smData back to redux via custom hook + updateStructure(clonedItems); // Turn off editing state handleEditFormCancel(); diff --git a/src/components/TimespanForm.js b/src/components/TimespanForm.js index 398eb994..744747df 100644 --- a/src/components/TimespanForm.js +++ b/src/components/TimespanForm.js @@ -43,12 +43,6 @@ const TimespanForm = ({ } }, [initSegment]); - const allSpans = useMemo(() => { - if (smData?.length > 0) { - return structuralMetadataUtils.getItemsOfType(['span'], smData); - } - }, [smData]); - // Find neighboring timespans of the currently editing timespan const { prevSiblingRef, nextSiblingRef, parentTimespanRef } = useFindNeighborSegments({ segment }); @@ -75,45 +69,42 @@ const TimespanForm = ({ }; const isValidTimespan = useMemo(() => { - const { valid } = validTimespans(beginTime, endTime, duration, allSpans); + const { valid } = validTimespans(beginTime, endTime, duration); if (valid) { buildHeadingsOptions(); } else { setValidHeadings([]); } return valid; - }, [beginTime, endTime, duration, allSpans]); - - useEffect(() => { - if (!isInitializing) { - setIsInitializing(false); - } - }, [smData, isInitializing]); + }, [beginTime, endTime, duration]); useEffect(() => { if (!isTyping) { if (initSegment && isInitializing) { // Set isInitializing flag to false setIsInitializing(false); - } - if (!isInitializing) { - const { startTime, endTime } = waveformDataUtils.validateSegment( - segment, startTimeChanged, duration, - { - previousSibling: prevSiblingRef.current, - nextSibling: nextSiblingRef.current, - parentTimespan: parentTimespanRef.current - }, - ); - setBeginTime(structuralMetadataUtils.toHHmmss(startTime)); - setEndTime(structuralMetadataUtils.toHHmmss(endTime)); + validateSegmentInPeaks(); } } if (isDragging) { setIsTyping(0); + validateSegmentInPeaks(); } }, [initSegment, isDragging, isInitializing, peaksInstance]); + const validateSegmentInPeaks = () => { + const { startTime, endTime } = waveformDataUtils.validateSegment( + segment, startTimeChanged, duration, + { + previousSibling: prevSiblingRef.current, + nextSibling: nextSiblingRef.current, + parentTimespan: parentTimespanRef.current + }, + ); + setBeginTime(structuralMetadataUtils.toHHmmss(startTime)); + setEndTime(structuralMetadataUtils.toHHmmss(endTime)); + }; + const clearFormValues = () => { setBeginTime(''); setEndTime(''); diff --git a/src/components/TimespanInlineForm.js b/src/components/TimespanInlineForm.js index 591a20fd..cbe55431 100644 --- a/src/components/TimespanInlineForm.js +++ b/src/components/TimespanInlineForm.js @@ -6,7 +6,7 @@ import Col from 'react-bootstrap/Col'; import { getExistingFormValues, isTitleValid } from '../services/form-helper'; import { useDispatch, useSelector } from 'react-redux'; import StructuralMetadataUtils from '../services/StructuralMetadataUtils'; -import { cloneDeep, isEmpty } from 'lodash'; +import { cloneDeep } from 'lodash'; import ListItemInlineEditControls from './ListItemInlineEditControls'; import * as peaksActions from '../actions/peaks-instance'; import WaveformDataUtils from '../services/WaveformDataUtils'; @@ -70,7 +70,6 @@ function TimespanInlineForm({ cancelFn, item, isInitializing, isTyping, saveFn, setEndTime(formValues.endTime); setTimespanTitle(formValues.timespanTitle); setClonedSegment(formValues.clonedSegment); - activateSegment( item.id, { @@ -99,12 +98,6 @@ function TimespanInlineForm({ cancelFn, item, isInitializing, isTyping, saveFn, }, []); useEffect(() => { - if (!isDragging && isInitializing && !isTyping && !isEmpty(segment)) { - const { startTime, endTime } = segment; - setBeginTime(structuralMetadataUtils.toHHmmss(startTime)); - setEndTime(structuralMetadataUtils.toHHmmss(endTime)); - } - if (isDragging) { // When handles in waveform are dragged clear out isInitializing and isTyping flags if (isInitializing) setIsInitializing(0); @@ -124,7 +117,7 @@ function TimespanInlineForm({ cancelFn, item, isInitializing, isTyping, saveFn, setBeginTime(structuralMetadataUtils.toHHmmss(startTime)); setEndTime(structuralMetadataUtils.toHHmmss(endTime)); } - }, [isDragging, isInitializing, isTyping, segment, peaksInstance]); + }, [isDragging, isInitializing, isTyping, peaksInstance]); /** * When there are invalid timespans in the structure, to edit them diff --git a/src/components/__tests__/ListItemEditForm.test.js b/src/components/__tests__/ListItemEditForm.test.js index b4c1c465..6b6b7e1c 100644 --- a/src/components/__tests__/ListItemEditForm.test.js +++ b/src/components/__tests__/ListItemEditForm.test.js @@ -60,11 +60,9 @@ describe('ListItemEditForm component', () => { test("TimespanInlineForm for item type 'span'", () => { const itemProp = { - type: 'span', - label: 'Segment 1.2', - id: '123a-456b-789c-4d', - begin: '00:00:11.231', - end: '00:08:00.001', + type: 'span', label: 'Segment 1.2', id: '123a-456b-789c-4d', + begin: '00:00:11.231', end: '00:08:00.001', + timeRange: { start: 11.231, end: 480.001 } }; const { getByTestId } = renderWithRedux( @@ -80,11 +78,9 @@ describe('ListItemEditForm component', () => { test('clicking on cancel button closes the form', async () => { const itemProp = { - type: 'span', - label: 'Segment 1.2', - id: '123a-456b-789c-4d', - begin: '00:00:11.231', - end: '00:08:00.001', + type: 'span', label: 'Segment 1.2', id: '123a-456b-789c-4d', + begin: '00:00:11.231', end: '00:08:00.001', + timeRange: { start: 11.231, end: 480.001 } }; const { getByTestId } = renderWithRedux( diff --git a/src/components/__tests__/TimespanForm.test.js b/src/components/__tests__/TimespanForm.test.js index b3c0cad8..22647ca0 100644 --- a/src/components/__tests__/TimespanForm.test.js +++ b/src/components/__tests__/TimespanForm.test.js @@ -429,13 +429,13 @@ describe('Timespan component', () => { test('when begin time is within an existing timespan', () => { // Update the neighbor timespan relationships jest.spyOn(hooks, 'useFindNeighborSegments').mockImplementation(() => ({ - prevSiblingRef: { current: { type: 'span', label: 'Segment 2.1.1', id: '123a-456b-789c-7d' } }, - nextSiblingRef: { current: { type: 'span', label: 'Segment 2.1.2', id: '123a-456b-789c-8d' } }, + prevSiblingRef: { current: { begin: '00:09:10.241', end: '00:10:00.321' } }, + nextSiblingRef: { current: { begin: '00:12:00.231', end: '00:13:00.001' } }, parentTimespanRef: { current: { - type: 'span', label: 'Segment 2.1', id: '123a-456b-789c-6d', + id: '123a-456b-789c-6d', begin: '00:09:00.241', end: '00:15:00.001', items: [{ type: 'span', label: 'Segment 2.1.1', id: '123a-456b-789c-7d' }, - { type: 'span', label: 'Segment 2.1.2', id: '123a-456b-789c-8d' }] + { type: 'span', label: 'Segment 2.1.2', id: '123a-456b-789c-8d' }], } } })); diff --git a/src/components/__tests__/TimespanInlineForm.test.js b/src/components/__tests__/TimespanInlineForm.test.js index 071efebb..186ea595 100644 --- a/src/components/__tests__/TimespanInlineForm.test.js +++ b/src/components/__tests__/TimespanInlineForm.test.js @@ -57,12 +57,10 @@ describe('TimespanInlineForm component', () => { props = { ...props, item: { - type: 'span', - label: 'Segment 2.1', - id: '123a-456b-789c-8d', - begin: '00:09:03.241', - end: '00:15:00.001', + type: 'span', label: 'Segment 2.1', id: '123a-456b-789c-8d', + begin: '00:09:03.241', end: '00:15:00.001', valid: true, + timeRange: { start: 543.241, end: 900.001 } }, }; const timespanInlineForm = renderWithRedux(, { @@ -88,12 +86,10 @@ describe('TimespanInlineForm component', () => { props = { ...props, item: { - type: 'span', - label: 'Segment 2.1', - id: '123a-456b-789c-8d', - begin: '00:09:03.241', - end: '00:15:00.001', + type: 'span', label: 'Segment 2.1', id: '123a-456b-789c-8d', + begin: '00:09:03.241', end: '00:15:00.001', valid: true, + timeRange: { start: 543.241, end: 900.001 } }, }; timespanInlineForm = renderWithRedux(, { @@ -147,12 +143,10 @@ describe('TimespanInlineForm component', () => { props = { ...props, item: { - type: 'span', - label: 'Invalid timespan', - id: '123a-456b-789c-5d', - begin: '00:20:21.000', - end: '00:15:00.001', + type: 'span', label: 'Invalid timespan', id: '123a-456b-789c-5d', + begin: '00:20:21.000', end: '00:15:00.001', valid: false, + timeRange: { start: 261.00, end: 900.001 } }, }; const timespanInlineForm = renderWithRedux(, { initialState }); @@ -274,12 +268,10 @@ describe('TimespanInlineForm component', () => { props = { ...props, item: { - type: 'span', - label: 'Segment 1.2', - id: '123a-456b-789c-4d', - begin: '00:00:11.231', - end: '00:08:00.001', + type: 'span', label: 'Segment 1.2', id: '123a-456b-789c-4d', + begin: '00:00:11.231', end: '00:08:00.001', valid: true, + timeRange: { start: 11.231, end: 480.001 } }, }; timespanInlineForm = renderWithRedux( diff --git a/src/containers/HeadingFormContainer.js b/src/containers/HeadingFormContainer.js index d373ec89..e6b7f258 100644 --- a/src/containers/HeadingFormContainer.js +++ b/src/containers/HeadingFormContainer.js @@ -2,18 +2,16 @@ import React from 'react'; import { useErrorBoundary } from 'react-error-boundary'; import PropTypes from 'prop-types'; import HeadingForm from '../components/HeadingForm'; -import { useDispatch, useSelector } from 'react-redux'; -import * as smActions from '../actions/sm-data'; +import { useSelector } from 'react-redux'; import StructuralMetadataUtils from '../services/StructuralMetadataUtils'; +import { useStructureUpdate } from '../services/sme-hooks'; const structuralMetadataUtils = new StructuralMetadataUtils(); const HeadingFormContainer = ({ cancelClick }) => { const { smData } = useSelector((state) => state.structuralMetadata); - const { duration } = useSelector((state) => state.peaksInstance); - const dispatch = useDispatch(); - const updateSMData = (updatedData, duration) => dispatch(smActions.reBuildSMUI(updatedData, duration)); + const { updateStructure } = useStructureUpdate(); const { showBoundary } = useErrorBoundary(); @@ -28,8 +26,8 @@ const HeadingFormContainer = ({ cancelClick }) => { // Update the data structure with new heading updatedSmData = structuralMetadataUtils.insertNewHeader(submittedItem, smData); - // Update redux store - updateSMData(updatedSmData, duration); + // Update redux store via custom hook + updateStructure(updatedSmData); // Close the form cancelClick(); diff --git a/src/containers/StructureOutputContainer.js b/src/containers/StructureOutputContainer.js index 3ee5fb12..afa7d05e 100644 --- a/src/containers/StructureOutputContainer.js +++ b/src/containers/StructureOutputContainer.js @@ -90,7 +90,7 @@ const StructureOutputContainer = ({ disableSave, structureIsSaved, structureURL variant="primary" onClick={handleSaveItClick} data-testid="structure-save-button" - disabled={editingDisabled} + disabled={editingDisabled || !smDataIsValid} className="float-end" > Save Structure diff --git a/src/containers/TimespanFormContainer.js b/src/containers/TimespanFormContainer.js index 06a82a39..ef001147 100644 --- a/src/containers/TimespanFormContainer.js +++ b/src/containers/TimespanFormContainer.js @@ -4,20 +4,20 @@ import PropTypes from 'prop-types'; import TimespanForm from '../components/TimespanForm'; import { useDispatch, useSelector } from 'react-redux'; import StructuralMetadataUtils from '../services/StructuralMetadataUtils'; -import * as smActions from '../actions/sm-data'; -import * as peaksActions from '../actions/peaks-instance'; +import { insertNewSegment } from '../actions/peaks-instance'; +import { useStructureUpdate } from '../services/sme-hooks'; const structuralMetadataUtils = new StructuralMetadataUtils(); const TimespanFormContainer = ({ cancelClick, ...restProps }) => { // Dispatch actions from Redux store const dispatch = useDispatch(); - const updateSMUI = (data, duration) => dispatch(smActions.reBuildSMUI(data, duration)); - const addNewSegment = (newSpan) => dispatch(peaksActions.insertNewSegment(newSpan)); + const addNewSegment = (newSpan) => dispatch(insertNewSegment(newSpan)); + + const { updateStructure } = useStructureUpdate(); // State variables from Redux store const smData = useSelector((state) => state.structuralMetadata.smData); - const duration = useSelector((state) => state.peaksInstance.duration); const [isTyping, _setIsTyping] = useState(false); @@ -31,8 +31,8 @@ const TimespanFormContainer = ({ cancelClick, ...restProps }) => { // Update the waveform segments with new timespan addNewSegment(newSpan); - // Update redux store - updateSMUI(updatedData, duration); + // Update redux store via custom hook + updateStructure(updatedData); // Close the form cancelClick(); diff --git a/src/containers/__tests__/StructureOutputContainer.test.js b/src/containers/__tests__/StructureOutputContainer.test.js index 0b6e51b2..a10148e5 100644 --- a/src/containers/__tests__/StructureOutputContainer.test.js +++ b/src/containers/__tests__/StructureOutputContainer.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import StructureOutputContainer from '../StructureOutputContainer'; import { renderWithRedux, testSmData } from '../../services/testing-helpers'; import Peaks from 'peaks'; @@ -73,8 +73,8 @@ describe('StructureOutputContainer component', () => { }); describe('displays updated structure tree after', () => { - test('deleting a timespan', () => { - const { getByTestId, queryAllByTestId } = renderWithRedux( + test('deleting a timespan', async () => { + const { getByTestId, queryAllByTestId, queryByText } = renderWithRedux( , { initialState } ); @@ -96,10 +96,14 @@ describe('StructureOutputContainer component', () => { ); // Confirm delete action fireEvent.click(getByTestId('delete-confirmation-confirm-btn')); - expect(timespanToDelete).not.toBeInTheDocument(); + + // Wait for the Redux state updates and verify the timespan was deleted + await waitFor(() => { + expect(timespanToDelete).not.toBeInTheDocument(); + }); }); - test('deleting a heading without children', () => { + test('deleting a heading without children', async () => { const { getByTestId, queryAllByTestId } = renderWithRedux( , { initialState } @@ -107,10 +111,8 @@ describe('StructureOutputContainer component', () => { expect(getByTestId('structure-output-list')).toBeInTheDocument(); let headingToDelete = queryAllByTestId('list-item')[2]; - expect( - headingToDelete.children[0].innerHTML).toEqual( - 'Sub-Segment 1.1' - ); + expect(headingToDelete.children[0].textContent).toEqual(' Sub-Segment 1.1'); + // Get the delete button from the list item controls let deleteButton = headingToDelete.children[1].children[1]; fireEvent.click(deleteButton); @@ -122,21 +124,23 @@ describe('StructureOutputContainer component', () => { ); // Confirm delete action fireEvent.click(getByTestId('delete-confirmation-confirm-btn')); - expect(headingToDelete).not.toBeInTheDocument(); + + // Wait for the Redux state updates and verify the heading was deleted + await waitFor(() => { + expect(headingToDelete).not.toBeInTheDocument(); + }); }); - test('deleting a heading with children', () => { - const { getByTestId, queryAllByTestId } = renderWithRedux( + test('deleting a heading with children', async () => { + const { getByTestId, getByTitle, queryAllByTestId } = renderWithRedux( , { initialState } ); expect(getByTestId('structure-output-list')).toBeInTheDocument(); let headingToDelete = queryAllByTestId('list-item')[5]; - expect( - headingToDelete.children[0].innerHTML).toEqual( - 'Second segment' - ); + expect(headingToDelete.children[0].textContent).toEqual(' Second segment'); + // Get the delete button from the list item controls let deleteButton = headingToDelete.children[1].children[1]; fireEvent.click(deleteButton); @@ -148,7 +152,11 @@ describe('StructureOutputContainer component', () => { ); // Confirm delete action fireEvent.click(getByTestId('delete-confirmation-confirm-btn')); - expect(headingToDelete).not.toBeInTheDocument(); + + // Wait for the Redux state updates and verify the heading was deleted + await waitFor(() => { + expect(headingToDelete).not.toBeInTheDocument(); + }); }); }); }); diff --git a/src/integration.test.js b/src/integration.test.js index 3f162209..d9f8afc5 100644 --- a/src/integration.test.js +++ b/src/integration.test.js @@ -157,8 +157,8 @@ describe('ButtonSection/StructureOutputContainer renders', () => { // Check the new heading was added to the end of the 'First Segment' div expect( - app.queryAllByTestId('heading-label')[3].innerHTML).toEqual( - 'New Heading' + app.queryAllByTestId('heading-label')[3].textContent).toEqual( + ' New Heading' ); }); }); diff --git a/src/reducers/sm-data.js b/src/reducers/sm-data.js index 8ae380b9..fef98729 100644 --- a/src/reducers/sm-data.js +++ b/src/reducers/sm-data.js @@ -14,14 +14,14 @@ let newState = null; const structuralMetadata = (state = initialState, action) => { switch (action.type) { case types.BUILD_SM_UI: - newState = structuralMetadataUtils.buildSMUI( + const { newSmData, newSmDataStatus } = structuralMetadataUtils.buildSMUI( action.json, action.duration ); - return { ...state, smData: newState[0], smDataIsValid: newState[1] }; + return { ...state, smData: newSmData, smDataIsValid: newSmDataStatus }; - case types.REBUILD_SM_UI: - return { ...state, smData: action.items }; + case types.UPDATE_SM_UI: + return { ...state, smData: action.json, smDataIsValid: action.isValid }; case types.SAVE_INIT_SMDATA: return { @@ -29,13 +29,6 @@ const structuralMetadata = (state = initialState, action) => { initSmData: action.payload, }; - case types.DELETE_ITEM: - newState = structuralMetadataUtils.deleteListItem( - action.id, - state.smData - ); - return { ...state, smData: newState }; - case types.ADD_DROP_TARGETS: newState = structuralMetadataUtils.determineDropTargets( action.payload, diff --git a/src/services/StructuralMetadataUtils.js b/src/services/StructuralMetadataUtils.js index ffd10363..de5af298 100644 --- a/src/services/StructuralMetadataUtils.js +++ b/src/services/StructuralMetadataUtils.js @@ -86,6 +86,7 @@ export default class StructuralMetadataUtils { * so that they can be used in the validation logic and Peaks instance * @param {Array} allItems - array of all the items in structured metadata * @param {Float} duration - end time of the media file in seconds + * @return {Object} { newSmData: Array, newSmDataStatus: Boolean } */ buildSMUI(allItems, duration) { let smDataIsValid = true; @@ -123,6 +124,13 @@ export default class StructuralMetadataUtils { item.end = this.toHHmmss(duration); } } + if (item.type === 'div') { + if (item.items.length === 0) { + item.valid = false; + smDataIsValid = false; + } + } + if (item.items) { formatItems(item.items); } @@ -130,7 +138,7 @@ export default class StructuralMetadataUtils { }; formatItems(allItems); - return [allItems, smDataIsValid]; + return { newSmData: allItems, newSmDataStatus: smDataIsValid }; } /** @@ -892,4 +900,47 @@ export default class StructuralMetadataUtils { return clonedItems; } + + + /** + * Get siblings and parent timespans for a given structure item. + * These values are then used across the component to validate timespan + * creation and editing. + * @param {Array} smData structure data + * @param {Object} item 'span' type object matching structure data + * @returns {Object} + */ + calculateAdjacentTimespans(smData, item) { + const allSpans = this.getItemsOfType(['span'], smData); + // const otherSpans = allSpans.filter((span) => span.id != item.id); + + let possibleParent = null; + let closestGapBefore = Infinity; let possiblePrevSibling = null; + let closestGapAfter = Infinity; let possibleNextSibling = null; + + const { start, end } = item.timeRange; + + const parentDiv = this.getParentItem(item, smData); + if (parentDiv && parentDiv.type === 'span') { + possibleParent = parentDiv; + } else { + possibleParent = null; + } + + allSpans.map((span) => { + let gapBefore = start - span.timeRange.end; + if (gapBefore >= 0 && gapBefore < closestGapBefore) { + closestGapBefore = gapBefore; + possiblePrevSibling = span; + } + + let gapAfter = span.timeRange.start - end; + if (gapAfter >= 0 && gapAfter < closestGapAfter) { + closestGapAfter = gapAfter; + possibleNextSibling = span; + } + }); + return { possibleParent, possiblePrevSibling, possibleNextSibling }; + }; + } diff --git a/src/services/WaveformDataUtils.js b/src/services/WaveformDataUtils.js index b93ff27c..5a1eeeab 100644 --- a/src/services/WaveformDataUtils.js +++ b/src/services/WaveformDataUtils.js @@ -285,7 +285,7 @@ export default class WaveformDataUtils { */ activateSegment(id, peaksInstance, duration, neighbors) { const segment = peaksInstance.segments.getSegment(id); - this.validateSegment(segment, false, peaksInstance, duration, neighbors); + this.validateSegment(segment, false, duration, neighbors); // Setting editable: true -> enables handles segment.update({ editable: true, @@ -400,9 +400,11 @@ export default class WaveformDataUtils { }); } else { // Update the start and end times when labelText has not changed - clonedSegment.update({ + const segment = peaksInstance.segments.getSegment(id); + segment.update({ startTime: this.timeToS(beginTime), endTime: this.timeToS(endTime), + color: color }); } return peaksInstance; diff --git a/src/services/__test__/StructuralMetadataUtils.test.js b/src/services/__test__/StructuralMetadataUtils.test.js index 16c391f7..2e074430 100644 --- a/src/services/__test__/StructuralMetadataUtils.test.js +++ b/src/services/__test__/StructuralMetadataUtils.test.js @@ -139,45 +139,46 @@ describe('StructuralMetadataUtils class', () => { describe('for valid items', () => { let structure = []; beforeEach(() => { - structure = smu.buildSMUI(testDataFromServer, 1738.945); + const { newSmData } = smu.buildSMUI(testDataFromServer, 1738.945); + structure = newSmData; }); test('when time is in hh:mm:ss (00:10:42) format', () => { - const timespan = smu.findItem('123a-456b-789c-2d', structure[0]); + const timespan = smu.findItem('123a-456b-789c-2d', structure); expect(timespan.begin).toEqual('00:10:42.000'); expect(timespan.valid).toBeTruthy(); }); test('when time is in hh:mm:ss.ms (00:15:00.23) format', () => { - const timespan = smu.findItem('123a-456b-789c-2d', structure[0]); + const timespan = smu.findItem('123a-456b-789c-2d', structure); expect(timespan.end).toEqual('00:15:00.230'); expect(timespan.valid).toBeTruthy(); }); test('when time is in mm:ss (15:30) format', () => { - const timespan = smu.findItem('123a-456b-789c-3d', structure[0]); + const timespan = smu.findItem('123a-456b-789c-3d', structure); expect(timespan.begin).toEqual('00:15:30.000'); expect(timespan.valid).toBeTruthy(); }); test('when time is in mm:ss.ms (16:00.23) format', () => { - const timespan = smu.findItem('123a-456b-789c-3d', structure[0]); + const timespan = smu.findItem('123a-456b-789c-3d', structure); expect(timespan.end).toEqual('00:16:00.230'); expect(timespan.valid).toBeTruthy(); }); test('when time is in ss (42) format', () => { - const timespan = smu.findItem('123a-456b-789c-1d', structure[0]); + const timespan = smu.findItem('123a-456b-789c-1d', structure); expect(timespan.end).toEqual('00:00:42.000'); expect(timespan.valid).toBeTruthy(); }); test('when time is in ss.ms (41.45) format', () => { - const timespan = smu.findItem('123a-456b-789c-1d', structure[0]); + const timespan = smu.findItem('123a-456b-789c-1d', structure); expect(timespan.begin).toEqual('00:00:41.450'); expect(timespan.valid).toBeTruthy(); }); test('when end time exceeds (00:38:58.000) file duration (00:28:58.945)', () => { - const timespan = smu.findItem('123a-456b-789c-4d', structure[0]); + const timespan = smu.findItem('123a-456b-789c-4d', structure); expect(timespan.end).toEqual('00:28:58.945'); expect(timespan.valid).toBeTruthy(); }); test('when end time is missing', () => { - const timespan = smu.findItem('123a-456b-789c-5d', structure[0]); + const timespan = smu.findItem('123a-456b-789c-5d', structure); expect(timespan.end).toEqual('00:28:58.945'); expect(timespan.valid).toBeTruthy(); }); @@ -185,8 +186,8 @@ describe('StructuralMetadataUtils class', () => { describe('for invalid items', () => { test('when begin > end', () => { - const structure = smu.buildSMUI(testInvalidData, 1738.945); - const timespan = smu.findItem('123a-456b-789c-5d', structure[0]); + const { newSmData } = smu.buildSMUI(testInvalidData, 1738.945); + const timespan = smu.findItem('123a-456b-789c-5d', newSmData); expect(timespan.valid).toBeFalsy(); }); }); diff --git a/src/services/__test__/form-helper.test.js b/src/services/__test__/form-helper.test.js index a157257b..f18f0308 100644 --- a/src/services/__test__/form-helper.test.js +++ b/src/services/__test__/form-helper.test.js @@ -75,77 +75,59 @@ describe('form-helper', () => { }); describe('validTimespans()', () => { - const allSpans = [ - { - type: 'span', - label: 'Segment 1.1', - id: '123a-456b-789c-3d', - begin: '00:00:00.321', - end: '00:00:03.321', - valid: true, - }, - { - type: 'span', - label: 'Segment 1.2', - id: '123a-456b-789c-4d', - begin: '00:00:08.000', - end: '00:00:09.001', - valid: true, - }, - ]; test('returns true for correct input', () => { - const result = formHelper.validTimespans('00:00:01.000', '00:00:02.000', 10, []); + const result = formHelper.validTimespans('00:00:01.000', '00:00:02.000', 10); expect(result.valid).toBe(true); }); test('returns true correct input with a comma decimal seperator', () => { - const result = formHelper.validTimespans('00:00:01,000', '00:00:02,000', 10, []); + const result = formHelper.validTimespans('00:00:01,000', '00:00:02,000', 10); expect(result.valid).toBe(true); }); test('returns false for invalid time format without colons', () => { - const result = formHelper.validTimespans('000001.000', '000003.000', 10, []); + const result = formHelper.validTimespans('000001.000', '000003.000', 10); expect(result.valid).toBe(false); expect(result.message).toMatch('Invalid begin time format'); }); test('returns false for invalid time with correct format with colons', () => { - const result = formHelper.validTimespans('.:,:.', '0:0:9', 10, []); + const result = formHelper.validTimespans('.:,:.', '0:0:9', 10); expect(result.valid).toBe(false); expect(result.message).toMatch('Invalid begin time format'); }); test('returns true for valid time with correct format with colons', () => { - const result = formHelper.validTimespans('0:0:0', '0:0:9', 10, []); + const result = formHelper.validTimespans('0:0:0', '0:0:9', 10); expect(result.valid).toBe(true); }); test('returns false for invalid non-string time format', () => { - const result = formHelper.validTimespans(30, '000003.000', 10, []); + const result = formHelper.validTimespans(30, '000003.000', 10); expect(result.valid).toBe(false); expect(result.message).toMatch('Invalid begin time format'); }); test('returns false for invalid time format with a space character', () => { - const result = formHelper.validTimespans('00:0 :00.000', '00:00:0a.000', 10, []); + const result = formHelper.validTimespans('00:0 :00.000', '00:00:0a.000', 10); expect(result.valid).toBe(false); expect(result.message).toMatch('Invalid begin time format'); }); test('returns false for invalid time format with invalid characters', () => { - const result = formHelper.validTimespans('00:0):00.000', '00:00:0a.000', 10, []); + const result = formHelper.validTimespans('00:0):00.000', '00:00:0a.000', 10); expect(result.valid).toBe(false); expect(result.message).toMatch('Invalid begin time format'); }); test('returns false for invalid end format', () => { - const result = formHelper.validTimespans('00:00:01.000', '00:0s:02.000', 10, []); + const result = formHelper.validTimespans('00:00:01.000', '00:0s:02.000', 10); expect(result.valid).toBe(false); expect(result.message).toMatch('Invalid end time format'); }); test('returns false for begin time overlapping end time', () => { - const result = formHelper.validTimespans('00:00:02.000', '00:00:01.000', 10, []); + const result = formHelper.validTimespans('00:00:02.000', '00:00:01.000', 10); expect(result.valid).toBe(false); expect(result.message).toMatch('Begin time must start before end time'); }); test('returns true for end time overlapping an existing timespan (overlapping now allowed)', () => { - const result = formHelper.validTimespans('00:00:07.000', '00:00:08.300', 10, allSpans); + const result = formHelper.validTimespans('00:00:07.000', '00:00:08.300', 10); expect(result.valid).toBe(true); }); test('returns true for begin time overlapping an existing timespan (overlapping now allowed)', () => { - const result = formHelper.validTimespans('00:00:03.000', '00:00:04.000', 10, allSpans); + const result = formHelper.validTimespans('00:00:03.000', '00:00:04.000', 10); expect(result.valid).toBe(true); }); test('returns false for end time > duration', () => { diff --git a/src/services/__test__/sme-hooks.test.js b/src/services/__test__/sme-hooks.test.js new file mode 100644 index 00000000..2c9afa3f --- /dev/null +++ b/src/services/__test__/sme-hooks.test.js @@ -0,0 +1,527 @@ +import React, { useEffect } from 'react'; +import { nestedTestSmData, renderWithRedux, testSmData } from '../testing-helpers'; +import Peaks from 'peaks'; +import * as hooks from "../sme-hooks"; + +describe('sme-hooks', () => { + // Set up a redux store for the tests + const peaksOptions = { + container: null, + mediaElement: null, + dataUri: null, + dataUriDefaultFormat: 'json', + keyboard: true, + _zoomLevelIndex: 0, + _zoomLevels: [512, 1024, 2048, 4096], + }; + + let peaksInst = null; + let initialState = null; + + beforeAll(() => { + Peaks.init(peaksOptions, (_err, peaks) => { + peaksInst = peaks; + }); + }); + + beforeEach(() => { + // Refresh Redux store for each test + initialState = { + structuralMetadata: { + smData: testSmData, + smDataIsValid: false, + }, + manifest: { + manifestFetched: true + }, + peaksInstance: { + peaks: peaksInst, + readyPeaks: true, + } + }; + }); + + describe('useFindNeighborSegments', () => { + const resultRef = { current: null }; + const renderHook = (props = {}) => { + const UIComponent = () => { + const results = hooks.useFindNeighborSegments({ + ...props + }); + useEffect(() => { + resultRef.current = results; + }, [results]); + return ( +
+ ); + }; + return UIComponent; + }; + + describe('with non-nested structure', () => { + test('returns prevSibling = null for first segment', () => { + const CustomComponent = renderHook({ + segment: { + _startTime: 3.321, _endTime: 10.321, + _id: '123a-456b-789c-3d', labelText: 'Segment 1.1', + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.prevSiblingRef.current).toBeNull(); + expect(resultRef.current.nextSiblingRef.current).not.toBeNull(); + expect(resultRef.current.nextSiblingRef.current.label).toEqual('Segment 1.2'); + }); + + test('returns nextSibling = null for last segment', () => { + const CustomComponent = renderHook({ + segment: { + _startTime: 543.241, _endTime: 900.001, + _id: '123a-456b-789c-8d', labelText: 'Segment 2.1', + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.nextSiblingRef.current).toBeNull(); + expect(resultRef.current.prevSiblingRef.current).not.toBeNull(); + expect(resultRef.current.prevSiblingRef.current.label).toEqual('Segment 1.2'); + }); + + test('returns both siblings for a middle segment', () => { + const CustomComponent = renderHook({ + segment: { + _startTime: 11.231, _endTime: 480.001, + _id: '123a-456b-789c-4d', labelText: 'Segment 1.2', + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.nextSiblingRef.current).not.toBeNull(); + expect(resultRef.current.nextSiblingRef.current.label).toEqual('Segment 2.1'); + expect(resultRef.current.prevSiblingRef.current).not.toBeNull(); + expect(resultRef.current.prevSiblingRef.current.label).toEqual('Segment 1.1'); + }); + + test('returns parentTimespan = null for segment', () => { + const CustomComponent = renderHook({ + segment: { + _startTime: 3.321, + _endTime: 10.321, + _id: '123a-456b-789c-3d', + labelText: 'Segment 1.1', + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.parentTimespanRef.current).toBeNull(); + }); + }); + + describe('with nested structure', () => { + beforeEach(() => { + // Refresh Redux store for each test + initialState = { + structuralMetadata: { + smData: nestedTestSmData, + smDataIsValid: false, + }, + manifest: { + manifestFetched: true + }, + peaksInstance: { + peaks: peaksInst, + readyPeaks: true, + } + }; + }); + + test('returns prevSibling = null for first segment', () => { + const CustomComponent = renderHook({ + segment: { + _startTime: 3.321, _endTime: 10.321, + _id: '123a-456b-789c-2d', labelText: 'Segment 1.1', + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.prevSiblingRef.current).toBeNull(); + expect(resultRef.current.nextSiblingRef.current).not.toBeNull(); + expect(resultRef.current.nextSiblingRef.current.label).toEqual('Segment 1.2'); + }); + + test('returns nextSibling = null for last segment', () => { + const CustomComponent = renderHook({ + segment: { + _startTime: 720.231, _endTime: 790.001, + _id: '123a-456b-789c-8d', labelText: 'Segment 2.1.2' + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.nextSiblingRef.current).toBeNull(); + expect(resultRef.current.prevSiblingRef.current).not.toBeNull(); + expect(resultRef.current.prevSiblingRef.current.label).toEqual('Segment 2.1.1'); + }); + + test('returns both siblings for middle segment', () => { + const CustomComponent = renderHook({ + segment: { + _startTime: 60.231, _endTime: 480.001, + _id: '123a-456b-789c-3d', labelText: 'Segment 1.2' + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.prevSiblingRef.current.label).toEqual('Segment 1.1'); + expect(resultRef.current.prevSiblingRef.current).not.toBeNull(); + expect(resultRef.current.nextSiblingRef.current).not.toBeNull(); + expect(resultRef.current.nextSiblingRef.current.label).toEqual('Segment 2.1'); + }); + + test('returns both siblings and parent for a nested child segment', () => { + // Current segment: first child of 'Segment 2.1' timespan + const CustomComponent = renderHook({ + segment: { + _startTime: 550.241, _endTime: 660.321, + _id: '123a-456b-789c-7d', labelText: 'Segment 2.1.1', + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.nextSiblingRef.current).not.toBeNull(); + expect(resultRef.current.prevSiblingRef.current).not.toBeNull(); + expect(resultRef.current.parentTimespanRef.current).not.toBeNull(); + expect(resultRef.current.parentTimespanRef.current.label).toEqual('Segment 2.1'); + }); + + test('returns parentTimespan = null for non-nested segment', () => { + const CustomComponent = renderHook({ + segment: { + _startTime: 3.321, + _endTime: 10.321, + _id: '123a-456b-789c-2d', + labelText: 'Segment 1.1', + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.parentTimespanRef.current).toBeNull(); + }); + }); + }); + + describe('useFindNeighborTimespans', () => { + const resultRef = { current: null }; + const renderHook = (props = {}) => { + const UIComponent = () => { + const results = hooks.useFindNeighborTimespans({ + ...props + }); + useEffect(() => { + resultRef.current = results; + }, [results]); + return ( +
+ ); + }; + return UIComponent; + }; + + describe('with non-nested structure', () => { + test('returns prevSibling = null for first segment', () => { + const CustomComponent = renderHook({ + item: { + type: 'span', label: 'Segment 1.1', id: '123a-456b-789c-3d', + begin: '00:00:03.321', end: '00:00:10.321', + valid: true, + timeRange: { start: 3.321, end: 10.321 } + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.prevSiblingRef.current).toBeNull(); + expect(resultRef.current.nextSiblingRef.current).not.toBeNull(); + expect(resultRef.current.nextSiblingRef.current.label).toEqual('Segment 1.2'); + }); + + test('returns nextSibling = null for last segment', () => { + const CustomComponent = renderHook({ + item: { + type: 'span', label: 'Segment 2.1', id: '123a-456b-789c-8d', + begin: '00:09:03.241', end: '00:15:00.001', + valid: true, + timeRange: { start: 543.241, end: 900.001 } + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.nextSiblingRef.current).toBeNull(); + expect(resultRef.current.prevSiblingRef.current).not.toBeNull(); + expect(resultRef.current.prevSiblingRef.current.label).toEqual('Segment 1.2'); + }); + + test('returns both siblings for a middle segment', () => { + const CustomComponent = renderHook({ + item: { + type: 'span', label: 'Segment 1.2', id: '123a-456b-789c-4d', + begin: '00:00:11.231', end: '00:08:00.001', + valid: true, + timeRange: { start: 11.231, end: 480.001 } + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.nextSiblingRef.current).not.toBeNull(); + expect(resultRef.current.nextSiblingRef.current.label).toEqual('Segment 2.1'); + expect(resultRef.current.prevSiblingRef.current).not.toBeNull(); + expect(resultRef.current.prevSiblingRef.current.label).toEqual('Segment 1.1'); + }); + + test('returns parentTimespan = null for segment', () => { + const CustomComponent = renderHook({ + item: { + type: 'span', label: 'Segment 1.1', id: '123a-456b-789c-3d', + begin: '00:00:03.321', end: '00:00:10.321', + valid: true, + timeRange: { start: 3.321, end: 10.321 } + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.parentTimespanRef.current).toBeNull(); + }); + }); + + describe('with nested structure', () => { + beforeEach(() => { + // Refresh Redux store for each test + initialState = { + structuralMetadata: { + smData: nestedTestSmData, + smDataIsValid: false, + }, + manifest: { + manifestFetched: true + } + }; + }); + + test('returns prevSibling = null for first segment', () => { + const CustomComponent = renderHook({ + item: { + type: 'span', label: 'Segment 1.1', id: '123a-456b-789c-2d', + begin: '00:00:03.321', end: '00:00:10.321', + valid: true, + timeRange: { start: 3.321, end: 10.321 } + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.prevSiblingRef.current).toBeNull(); + expect(resultRef.current.nextSiblingRef.current).not.toBeNull(); + expect(resultRef.current.nextSiblingRef.current.label).toEqual('Segment 1.2'); + }); + + test('returns nextSibling = null for last segment', () => { + const CustomComponent = renderHook({ + item: { + type: 'span', label: 'Segment 2.1.2', id: '123a-456b-789c-8d', + begin: '00:12:00.231', end: '00:13:00.001', + valid: true, nestedSpan: true, + timeRange: { start: 720.231, end: 790.001 } + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.nextSiblingRef.current).toBeNull(); + expect(resultRef.current.prevSiblingRef.current).not.toBeNull(); + expect(resultRef.current.prevSiblingRef.current.label).toEqual('Segment 2.1.1'); + }); + + test('returns both siblings for middle segment', () => { + const CustomComponent = renderHook({ + item: { + type: 'span', label: 'Segment 1.2', id: '123a-456b-789c-3d', + begin: '00:01:00.231', end: '00:08:00.001', + valid: true, + timeRange: { start: 60.231, end: 480.001 } + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.prevSiblingRef.current.label).toEqual('Segment 1.1'); + expect(resultRef.current.prevSiblingRef.current).not.toBeNull(); + expect(resultRef.current.nextSiblingRef.current).not.toBeNull(); + expect(resultRef.current.nextSiblingRef.current.label).toEqual('Segment 2.1'); + }); + + test('returns both siblings and parent for a nested child segment', () => { + // Current segment: first child of 'Segment 2.1' timespan + const CustomComponent = renderHook({ + item: { + type: 'span', label: 'Segment 2.1.1', id: '123a-456b-789c-7d', + begin: '00:09:10.241', end: '00:10:00.321', + valid: true, nestedSpan: true, + timeRange: { start: 550.241, end: 660.321 } + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.nextSiblingRef.current).not.toBeNull(); + expect(resultRef.current.prevSiblingRef.current).not.toBeNull(); + expect(resultRef.current.parentTimespanRef.current).not.toBeNull(); + expect(resultRef.current.parentTimespanRef.current.label).toEqual('Segment 2.1'); + }); + + test('returns parentTimespan = null for non-nested segment', () => { + const CustomComponent = renderHook({ + item: { + type: 'span', label: 'Segment 1.1', id: '123a-456b-789c-2d', + begin: '00:00:03.321', end: '00:00:10.321', + valid: true, + timeRange: { start: 3.321, end: 10.321 } + } + }); + renderWithRedux(, { initialState }); + expect(resultRef.current.parentTimespanRef.current).toBeNull(); + }); + }); + }); + + describe('useTimespanFormValidation', () => { + const resultRef = { current: null }; + const renderHook = (props = {}) => { + const UIComponent = () => { + const results = hooks.useTimespanFormValidation({ + ...props + }); + useEffect(() => { + resultRef.current = results; + }, [results]); + return ( +
+ ); + }; + return UIComponent; + }; + + test('returns valid when times are valid without neighbors', () => { + const CustomComponent = renderHook({ + beginTime: '00:15:00.000', endTime: '00:18:00.000', + neighbors: { + prevSiblingRef: { current: null }, + nextSiblingRef: { current: null }, + parentTimespanRef: { current: null } + }, + timespanTitle: 'Valid title' + }); + + renderWithRedux(, { initialState }); + expect(resultRef.current.isBeginValid).toBe(true); + expect(resultRef.current.isEndValid).toBe(true); + expect(resultRef.current.formIsValid).toBe(true); + }); + + test('returns valid when times are valid with neighbors', () => { + const CustomComponent = renderHook({ + beginTime: '00:00:11.000', endTime: '00:08:00.000', + neighbors: { + prevSiblingRef: { current: { begin: '00:00:03.321', end: '00:00:10.321' } }, + nextSiblingRef: { current: { begin: '00:09:03.241', end: '00:15:00.001' } }, + parentTimespanRef: { current: null } + }, + timespanTitle: 'Valid title' + }); + + renderWithRedux(, { initialState }); + expect(resultRef.current.isBeginValid).toBe(true); + expect(resultRef.current.isEndValid).toBe(true); + expect(resultRef.current.formIsValid).toBe(true); + }); + + test('returns invalid when begin time overlaps previous sibling', () => { + const CustomComponent = renderHook({ + beginTime: '00:00:00.000', endTime: '00:08:00.000', + neighbors: { + prevSiblingRef: { current: { begin: '00:00:03.321', end: '00:00:10.321' } }, + nextSiblingRef: { current: { begin: '00:09:03.241', end: '00:15:00.001' } }, + parentTimespanRef: { current: null } + }, + timespanTitle: 'Valid title' + }); + + renderWithRedux(, { initialState }); + expect(resultRef.current.isBeginValid).toBe(false); + expect(resultRef.current.isEndValid).toBe(true); + expect(resultRef.current.formIsValid).toBe(false); + }); + + test('returns invalid when end time overlaps next sibling', () => { + const CustomComponent = renderHook({ + beginTime: '00:00:11.000', endTime: '00:10:00.000', + neighbors: { + prevSiblingRef: { current: { begin: '00:00:03.321', end: '00:00:10.321' } }, + nextSiblingRef: { current: { begin: '00:09:03.241', end: '00:15:00.001' } }, + parentTimespanRef: { current: null } + }, + timespanTitle: 'Valid title' + }); + + renderWithRedux(, { initialState }); + expect(resultRef.current.isBeginValid).toBe(true); + expect(resultRef.current.isEndValid).toBe(false); + expect(resultRef.current.formIsValid).toBe(false); + }); + + test('returns invalid when begin time exceeds parent\'s start', () => { + const CustomComponent = renderHook({ + beginTime: '00:09:00.000', endTime: '00:10:00.321', + neighbors: { + prevSiblingRef: { current: { begin: '00:01:00.231', end: '00:08:00.001' } }, + nextSiblingRef: { current: { begin: '00:12:00.231', end: '00:13:00.001' } }, + parentTimespanRef: { current: { begin: '00:09:00.241', end: '00:15:00.001' } } + }, + timespanTitle: 'Valid title' + }); + + renderWithRedux(, { initialState }); + expect(resultRef.current.isBeginValid).toBe(false); + expect(resultRef.current.isEndValid).toBe(true); + expect(resultRef.current.formIsValid).toBe(false); + }); + + test('returns invalid when start time exceeds nested sibling\'s end', () => { + const CustomComponent = renderHook({ + beginTime: '00:07:01.000', endTime: '00:12:00.000', + neighbors: { + prevSiblingRef: { current: { begin: '00:01:00.231', end: '00:08:00.001' } }, + nextSiblingRef: { current: { begin: '00:12:00.231', end: '00:13:00.001' } }, + parentTimespanRef: { current: { begin: '00:09:00.241', end: '00:15:00.001' } } + }, + timespanTitle: 'Valid title' + }); + + renderWithRedux(, { initialState }); + expect(resultRef.current.isBeginValid).toBe(false); + expect(resultRef.current.isEndValid).toBe(true); + expect(resultRef.current.formIsValid).toBe(false); + }); + + test('returns invalid when end time exceeds parent\'s end', () => { + const CustomComponent = renderHook({ + beginTime: '00:12:01.000', endTime: '00:16:00.321', + neighbors: { + prevSiblingRef: { current: { begin: '00:01:00.231', end: '00:08:00.001' } }, + nextSiblingRef: { current: { begin: '00:12:00.231', end: '00:13:00.001' } }, + parentTimespanRef: { current: { begin: '00:09:00.241', end: '00:15:00.001' } } + }, + timespanTitle: 'Valid title' + }); + + renderWithRedux(, { initialState }); + expect(resultRef.current.isBeginValid).toBe(true); + expect(resultRef.current.isEndValid).toBe(false); + expect(resultRef.current.formIsValid).toBe(false); + }); + + test('returns invalid when end time exceeds nested sibling\'s start', () => { + const CustomComponent = renderHook({ + beginTime: '00:11:01.000', endTime: '00:12:00.321', + neighbors: { + prevSiblingRef: { current: { begin: '00:01:00.231', end: '00:08:00.001' } }, + nextSiblingRef: { current: { begin: '00:12:00.231', end: '00:13:00.001' } }, + parentTimespanRef: { current: { begin: '00:09:00.241', end: '00:15:00.001' } } + }, + timespanTitle: 'Valid title' + }); + + renderWithRedux(, { initialState }); + expect(resultRef.current.isBeginValid).toBe(true); + expect(resultRef.current.isEndValid).toBe(false); + expect(resultRef.current.formIsValid).toBe(false); + }); + }); +}); diff --git a/src/services/alert-status.js b/src/services/alert-status.js index 20588e8b..c41273da 100644 --- a/src/services/alert-status.js +++ b/src/services/alert-status.js @@ -14,7 +14,7 @@ export const STREAM_MEDIA_ERROR = export const MISSING_WAVEFORM_ERROR = 'No available waveform data.'; export const INVALID_SEGMENTS_WARNING = - 'Please check start/end times of the marked invalid timespan(s).'; + 'Please check the marked invalid timespan(s)/heading(s).'; export const FETCH_MANIFEST_ERROR = 'Error fetching IIIF manifest.'; export const NO_MEDIA_MESSAGE = 'No available media. Editing structure is disabled.'; diff --git a/src/services/form-helper.js b/src/services/form-helper.js index e60a4a47..8b6bf040 100644 --- a/src/services/form-helper.js +++ b/src/services/form-helper.js @@ -87,10 +87,9 @@ export function isTitleValid(title) { * @param {Number} beginTime * @param {Number} endTime * @param {Number} duration duration saved in central state - * @param {Array} allSpans list of all timespans in peaks * @returns {Object} { valid: , message: } */ -export function validTimespans(beginTime, endTime, duration, allSpans) { +export function validTimespans(beginTime, endTime, duration) { // Valid formats? if (!validTimeFormat(beginTime)) { return { diff --git a/src/services/sme-hooks.js b/src/services/sme-hooks.js index 49a52a76..d60b3bda 100644 --- a/src/services/sme-hooks.js +++ b/src/services/sme-hooks.js @@ -1,11 +1,13 @@ import React, { useEffect, useMemo, useRef } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import StructuralMetadataUtils from './StructuralMetadataUtils'; -import WaveformDataUtils from './WaveformDataUtils'; import { getValidationBeginState, getValidationEndState, isTitleValid } from './form-helper'; +import { updateSMUI } from '../actions/sm-data'; +import { clearExistingAlerts, handleEditingTimespans, updateStructureStatus } from '../actions/forms'; +import { deleteSegment } from '../actions/peaks-instance'; +import { isEmpty } from 'lodash'; const structuralMetadataUtils = new StructuralMetadataUtils(); -const waveformDataUtils = new WaveformDataUtils(); /** * Find sibling and parent timespans of the given Peaks segment. The respective timespans @@ -20,7 +22,7 @@ const waveformDataUtils = new WaveformDataUtils(); * } React refs for siblings and parent timespans */ export const useFindNeighborSegments = ({ segment }) => { - const { peaks, readyPeaks } = useSelector((state) => state.peaksInstance); + const { duration, readyPeaks } = useSelector((state) => state.peaksInstance); const { smData } = useSelector((state) => state.structuralMetadata); // React refs to hold parent timespan, previous and next siblings @@ -33,43 +35,28 @@ export const useFindNeighborSegments = ({ segment }) => { }, [smData]); useEffect(() => { - if (readyPeaks && segment) { - // All segments sorted by start time - const allSegments = waveformDataUtils.sortSegments(peaks, 'startTime'); - const otherSegments = allSegments.filter( - (seg) => seg.id !== segment.id - ); - - const { startTime, endTime } = segment; - - // Find potential parent segments - const potentialParents = otherSegments.filter(seg => - seg.startTime <= startTime && seg.endTime >= endTime - ); - const potentialParentIds = potentialParents?.length > 0 - ? potentialParents.map((p) => p._id) : []; - - // Get the most immediate parent - const parent = potentialParents.reduce((closest, seg) => { - if (!closest) return seg; - const currentRange = seg.endTime - seg.startTime; - const closestRange = closest.endTime - closest.startTime; - return currentRange < closestRange ? seg : closest; - }, null); - - parentTimespanRef.current = parent ? allSpans.find((span) => span.id === parent._id) : null; - // When calculating the previous sibling omit potential parent timespans, as their startTimes are - // less than or equal to the current segment's startTime - const siblingsBefore = otherSegments - .filter(seg => seg.startTime <= startTime && !potentialParentIds?.includes(seg._id)); - if (siblingsBefore?.length > 0) { - prevSiblingRef.current = allSpans.find((span) => span.id === siblingsBefore.at(-1)._id); - }; - - const siblingsAfter = otherSegments.filter((seg) => seg.startTime >= endTime); - if (siblingsAfter?.length > 0) { - nextSiblingRef.current = allSpans.find((span) => span.id === siblingsAfter[0]._id); + if (readyPeaks && !isEmpty(segment)) { + let item; + if (segment._id === 'temp-segment') { + // Construct a span object from segment when handling timespan creation + const { _id, _startTime, _endTime } = segment; + item = { + type: 'span', label: '', id: _id, + begin: structuralMetadataUtils.toHHmmss(_startTime), + end: structuralMetadataUtils.toHHmmss(_endTime), + valid: _startTime < _endTime && _endTime <= duration, + timeRange: { start: _startTime, end: _endTime } + }; + } else { + // Find the existing span object from smData + item = allSpans.filter((span) => span.id === segment._id)[0]; } + + const { possibleParent, possiblePrevSibling, possibleNextSibling } + = structuralMetadataUtils.calculateAdjacentTimespans(smData, item); + parentTimespanRef.current = possibleParent; + prevSiblingRef.current = possiblePrevSibling; + nextSiblingRef.current = possibleNextSibling; } }, [segment, readyPeaks]); @@ -94,36 +81,14 @@ export const useFindNeighborTimespans = ({ item }) => { const prevSiblingRef = useRef(null); const nextSiblingRef = useRef(null); - // Find the parent timespan if it exists - const parentDiv = useMemo(() => { - if (item) { - return structuralMetadataUtils.getParentItem(item, smData); - } - }, [item, smData]); - useEffect(() => { - if (parentDiv && parentDiv.type === 'span') { - parentTimespanRef.current = parentDiv; - } else { - parentTimespanRef.current = null; - } - - // Find previous and next siblings in the hierarchy - if (parentDiv && parentDiv.items) { - const siblings = parentDiv.items.filter(sibling => sibling.type === 'span'); - const currentIndex = siblings.findIndex(sibling => sibling.id === item.id); - - prevSiblingRef.current = currentIndex > 0 ? siblings[currentIndex - 1] : null; - nextSiblingRef.current = currentIndex < siblings.length - 1 ? siblings[currentIndex + 1] : null; - } else if (item) { - const siblings = structuralMetadataUtils.getItemsOfType(['span'], smData); - const currentIndex = siblings.findIndex(sibling => sibling.id === item.id); - - prevSiblingRef.current = currentIndex > 0 ? siblings[currentIndex - 1] : null; - nextSiblingRef.current = currentIndex < siblings.length - 1 ? siblings[currentIndex + 1] : null; - } - }, [parentDiv, item]); + const { possibleParent, possiblePrevSibling, possibleNextSibling } + = structuralMetadataUtils.calculateAdjacentTimespans(smData, item); + parentTimespanRef.current = possibleParent; + prevSiblingRef.current = possiblePrevSibling; + nextSiblingRef.current = possibleNextSibling; + }, [item, smData]); return { prevSiblingRef, nextSiblingRef, parentTimespanRef }; }; @@ -147,25 +112,33 @@ export const useTimespanFormValidation = ({ beginTime, endTime, neighbors, times const { prevSiblingRef, nextSiblingRef, parentTimespanRef } = neighbors; const getBeginTimeConstraint = () => { - // Sibling's end time takes precedence over parent's start time + let prevSiblingEnd, parentBegin; if (prevSiblingRef.current) { - return prevSiblingRef.current.end; + prevSiblingEnd = structuralMetadataUtils.toMs(prevSiblingRef.current.end); } if (parentTimespanRef.current) { - return parentTimespanRef.current.begin; + parentBegin = structuralMetadataUtils.toMs(parentTimespanRef.current.begin); + } + if ((!prevSiblingEnd && parentBegin) || (prevSiblingEnd < parentBegin)) { + return parentBegin; + } else { + return prevSiblingEnd; } - return null; }; const getEndTimeConstraint = () => { - // Sibling's start time takes precedence over parent's end time + let nextSiblingStart, parentEnd; if (nextSiblingRef.current) { - return nextSiblingRef.current.begin; + nextSiblingStart = structuralMetadataUtils.toMs(nextSiblingRef.current.begin); } if (parentTimespanRef.current) { - return parentTimespanRef.current.end; + parentEnd = structuralMetadataUtils.toMs(parentTimespanRef.current.end); + } + if ((!nextSiblingStart && parentEnd) || (nextSiblingStart > parentEnd)) { + return parentEnd; + } else { + return nextSiblingStart; } - return null; }; const isBeginValid = useMemo(() => { @@ -176,7 +149,7 @@ export const useTimespanFormValidation = ({ beginTime, endTime, neighbors, times const constraint = getBeginTimeConstraint(); if (constraint) { // Begin time must be >= constraint time - return structuralMetadataUtils.toMs(beginTime) >= structuralMetadataUtils.toMs(constraint); + return structuralMetadataUtils.toMs(beginTime) >= constraint; } return true; }, [beginTime, endTime]); @@ -189,7 +162,7 @@ export const useTimespanFormValidation = ({ beginTime, endTime, neighbors, times const constraint = getEndTimeConstraint(); if (constraint) { // End time must be <= constraint time - return structuralMetadataUtils.toMs(endTime) <= structuralMetadataUtils.toMs(constraint); + return structuralMetadataUtils.toMs(endTime) <= constraint; } return true; }, [beginTime, endTime]); @@ -201,3 +174,50 @@ export const useTimespanFormValidation = ({ beginTime, endTime, neighbors, times return { formIsValid, isBeginValid, isEndValid }; }; + +/** + * Perform Redux state updates during CRUD operations performed on structure + * @returns { + * deleteStructItem, + * updateEditingTimespans, + * updateStructure, + * } + */ +export const useStructureUpdate = () => { + const dispatch = useDispatch(); + const { smData, smDataIsValid } = useSelector(state => state.structuralMetadata); + const { duration } = useSelector(state => state.peaksInstance); + + const updateStructure = (items = smData) => { + const { newSmData, newSmDataStatus } = structuralMetadataUtils.buildSMUI(items, duration); + + dispatch(updateSMUI(newSmData, newSmDataStatus)); + // Remove invalid structure alert when data is corrected + if (newSmDataStatus) { + dispatch(clearExistingAlerts()); + dispatch(updateStructureStatus(0)); + } + }; + + const deleteStructItem = (item) => { + // Clone smData and remove the item manually + const clonedItems = structuralMetadataUtils.deleteListItem(item.id, smData); + + // Update structure with the item removed + updateStructure(clonedItems); + + // Remove the Peaks segment from the peaks instance + dispatch(deleteSegment(item)); + }; + + const updateEditingTimespans = (code) => { + handleEditingTimespans(code); + // Remove dismissible alerts when a CRUD action has been initiated + // given editing is starting (code = 1) and structure is validated. + if (code == 1 && smDataIsValid) { + dispatch(clearExistingAlerts()); + } + }; + + return { deleteStructItem, updateEditingTimespans, updateStructure }; +}; diff --git a/src/services/testing-helpers.js b/src/services/testing-helpers.js index 785111b9..76a22cec 100644 --- a/src/services/testing-helpers.js +++ b/src/services/testing-helpers.js @@ -244,6 +244,7 @@ export const testInvalidData = [ begin: '00:00:03.321', end: '00:00:10.321', valid: true, + timeRange: { start: 3.321, end: 10.321 } }, { type: 'span', @@ -252,6 +253,7 @@ export const testInvalidData = [ begin: '00:20:21.000', end: '00:15:00.001', valid: false, + timeRange: { start: 261.00, end: 900.001 } }, { type: 'span', @@ -260,6 +262,7 @@ export const testInvalidData = [ begin: '00:00:11.231', end: '00:08:00.001', valid: true, + timeRange: { start: 11.231, end: 480.001 } }, ], },