Skip to content

Commit

Permalink
Add marker component (#245)
Browse files Browse the repository at this point in the history
* Read annotation service from manifest to enable CRUD operations in markers display

Co-authored-by: Chris Colvard <cjcolvar@iu.edu>
Co-authored-by: Mason Ballengee <masaball@iu.edu>
Co-authored-by: Jon Cameron <joncamer@iu.edu>

* Clean up error handling in API requests

* Work in progress prototype of add marker functionality

Co-authored-by: Mason Ballengee <masaball@iu.edu>

* WIP Successfully create marker, parse response, and update state/context

Co-authored-by: Dananji Withana <dwithana@iu.edu>
Co-authored-by: Mason Ballengee <masaball@iu.edu>

* Add styling to new marker form, code re-organize

* Fixed broken tests, add new tests for new components

* Fix markers state management after edit/delete operations

Co-authored-by: Mason Ballengee <masaball@iu.edu>
Co-authored-by: Chris Colvard <chris.colvard@gmail.com>

* Fix failing tests

---------

Co-authored-by: dananji <dwithana@iu.edu>
Co-authored-by: Mason Ballengee <masaball@iu.edu>
Co-authored-by: Jon Cameron <joncamer@iu.edu>
  • Loading branch information
4 people authored Oct 2, 2023
1 parent ea17212 commit 1f44ea6
Show file tree
Hide file tree
Showing 21 changed files with 1,175 additions and 476 deletions.
9 changes: 5 additions & 4 deletions demo/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const App = ({ manifestURL }) => {
};

return (
<div className='iiif-demo'>
<div className='ramp-demo'>
<h1>Ramp</h1>
<div className='ramp--description'>
<p>An interactive, IIIF powered A/V player built with components
Expand All @@ -50,16 +50,17 @@ const App = ({ manifestURL }) => {
<form onSubmit={handleSubmit}>
<div className='row'>
<div className='col-1'>
<label htmlFor="manifesturl">Manifest URL</label>
<label htmlFor="manifesturl" className="ramp-demo__manifest-input-label">Manifest URL</label>
</div>
<div className='col-2'>
<input type='url'
id='manifesturl'
name='manifesturl'
value={userURL}
onChange={handleUserInput}
placeholder='Manifest URL' />
<input type='submit' value='Set Manifest' />
placeholder='Manifest URL'
className="ramp-demo__manifest-input" />
<input type='submit' value='Set Manifest' className="ramp-demo__manifest-submit" />
</div>
</div>
</form>
Expand Down
14 changes: 7 additions & 7 deletions demo/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ div.iiif-player-demo {
flex: 1;
}

.tab-nav label {
.tab-nav>label {
display: block;
box-sizing: border-box;
/* tab content must clear this */
Expand All @@ -48,7 +48,7 @@ div.iiif-player-demo {
color: black;
}

.tab-nav label:hover {
.tab-nav>label:hover {
background: #d3d3d3;
border-radius: 0.5rem 0.5rem 0 0;
}
Expand Down Expand Up @@ -98,15 +98,15 @@ div.iiif-player-demo {
}
}

.iiif-demo {
.ramp-demo {
margin: auto;
width: 75%;

.ramp--description {
margin-bottom: 15px;
}

input[type=url] {
.ramp-demo__manifest-input {
width: 80%;
padding: 11px;
border: 1px solid #ccc;
Expand All @@ -115,12 +115,12 @@ div.iiif-player-demo {
font-size: medium;
}

label {
.ramp-demo__manifest-input-label {
padding: 12px 12px 12px 0;
display: inline-block;
}

input[type=submit] {
.ramp-demo__manifest-submit {
background-color: #2a5459;
color: white;
padding: 12px 20px;
Expand All @@ -130,7 +130,7 @@ div.iiif-player-demo {
font-size: medium;
}

input[type=submit]:hover {
.ramp-demo__manifest-submit:hover {
background-color: #80a590;
}

Expand Down
13 changes: 11 additions & 2 deletions src/components/IIIFPlayerWrapper.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';
import { useManifestDispatch } from '../context/manifest-context';
import PropTypes from 'prop-types';
import { parseAutoAdvance, getIsPlaylist } from '@Services/iiif-parser';
import { parseAutoAdvance } from '@Services/iiif-parser';
import { getAnnotationService, getIsPlaylist } from '@Services/playlist-parser';

export default function IIIFPlayerWrapper({
manifestUrl,
Expand All @@ -16,7 +17,12 @@ export default function IIIFPlayerWrapper({
if (manifest) {
dispatch({ manifest: manifest, type: 'updateManifest' });
} else {
fetch(manifestUrl)
let requestOptions = {
// NOTE: try thin in Avalon
//credentials: 'include',
headers: { 'Avalon-Api-Key': '' },
};
fetch(manifestUrl, requestOptions)
.then((result) => result.json())
.then((data) => {
setManifest(data);
Expand All @@ -35,6 +41,9 @@ export default function IIIFPlayerWrapper({

const isPlaylist = getIsPlaylist(manifest);
dispatch({ isPlaylist: isPlaylist, type: 'setIsPlaylist' });

const annotationService = getAnnotationService(manifest);
dispatch({ annotationService: annotationService, type: 'setAnnotationService' });
}
}, [manifest]);

Expand Down
159 changes: 159 additions & 0 deletions src/components/MarkersDisplay/MarkerUtils/CreateMarker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import React from 'react';
import PropTypes from 'prop-types';
import { createNewAnnotation, parseMarkerAnnotation } from '@Services/playlist-parser';
import { validateTimeInput, timeToS, timeToHHmmss } from '@Services/utility-helpers';
import { SaveIcon, CancelIcon } from './SVGIcons';

const CreateMarker = ({ newMarkerEndpoint, canvasId, handleCreate, getCurrentTime }) => {
const [isOpen, setIsOpen] = React.useState(false);
const [isValid, setIsValid] = React.useState(false);
const [saveError, setSaveError] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState('');
const [markerTime, setMarkerTime] = React.useState();

const handleAddMarker = () => {
const currentTime = timeToHHmmss(getCurrentTime(), true, true);
validateTime(currentTime);
setIsOpen(true);
};

const handleCreateSubmit = (e) => {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const { label, time } = Object.fromEntries(formData.entries());
const annotation = {
type: "Annotation",
motivation: "highlighting",
body: {
type: "TextualBody",
format: "text/html",
value: label,
},
target: `${canvasId}#t=${timeToS(time)}`
};

const requestOptions = {
method: 'POST',
/** NOTE: In avalon try this option */
headers: {
'Accept': 'application/json',
'Avalon-Api-Key': '',
},
body: JSON.stringify(annotation)
};
fetch(newMarkerEndpoint, requestOptions)
.then((response) => {
if (response.status != 201) {
throw new Error();
} else {
return response.json();
}
}).then((json) => {
const anno = createNewAnnotation(json);
const newMarker = parseMarkerAnnotation(anno);
if (newMarker) {
handleCreate(newMarker);
}
setIsOpen(false);
})
.catch((e) => {
console.error('Failed to create annotation; ', e);
setSaveError(true);
setErrorMessage('Marker creation failed.');
});
};

const handleCreateCancel = () => {
setIsOpen(false);
setIsValid(false);
setErrorMessage('');
setSaveError(false);
};

const validateTime = (value) => {
setMarkerTime(value);
let isValid = validateTimeInput(value);
setIsValid(isValid);
};

return (
<div className="ramp-markers-display__new-marker">
<button
type="submit"
onClick={handleAddMarker}
className="ramp--markers-display__edit-button"
data-testid="create-new-marker-button"
>Add New Marker</button>
{isOpen &&
(<form
className="ramp--markers-display__new-marker-form"
method="post"
onSubmit={handleCreateSubmit}
data-testid="create-new-marker-form"
>
<table className="create-marker-form-table">
<tbody>
<tr>
<td>
<label htmlFor="new-marker-title">Title:</label>
<input
id="new-marker-title"
data-testid="create-marker-title"
type="text"
className="ramp--markers-display__create-marker"
name="label" />
</td>
<td>
<label htmlFor="new-marker-time">Time:</label>
<input
id="new-marker-time"
data-testid="create-marker-timestamp"
type="text"
className={`ramp--markers-display__create-marker ${isValid ? 'time-valid' : 'time-invalid'}`}
name="time"
value={markerTime}
onChange={(e) => validateTime(e.target.value)} />
</td>
<td>
<div className="marker-actions">
{
saveError &&
<p className="ramp--markers-display__error-message">
{errorMessage}
</p>
}
<button
type="submit"
className="ramp--markers-display__edit-button"
data-testid="edit-save-button"
disabled={!isValid}
>
<SaveIcon /> Save
</button>
<button
className="ramp--markers-display__edit-button-danger"
data-testid="edit-cancel-button"
onClick={handleCreateCancel}
>
<CancelIcon /> Cancel
</button>
</div>
</td>
</tr>
</tbody>
</table>
</form>)
}
</div>
);
};

CreateMarker.propTypes = {
newMarkerEndpoint: PropTypes.string.isRequired,
canvasId: PropTypes.string,
handleCreate: PropTypes.func.isRequired,
getCurrentTime: PropTypes.func.isRequired,
};

export default CreateMarker;
91 changes: 91 additions & 0 deletions src/components/MarkersDisplay/MarkerUtils/CreateMarker.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import React from 'react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import CreateMarker from './CreateMarker';

describe('CreateMarker component', () => {
const handleCreateMock = jest.fn();
const getCurrentTimeMock = jest.fn(() => { return 44.3; });
beforeEach(() => {
render(<CreateMarker
newMarkerEndpoint={'http://example.com/marker'}
canvasId={'http://example.com/manifest/canvas/1'}
handleCreate={handleCreateMock}
getCurrentTime={getCurrentTimeMock} />);
});

test('renders successfully', () => {
expect(screen.queryByTestId('create-new-marker-button')).toBeInTheDocument();
expect(screen.queryByTestId('create-new-marker-form')).not.toBeInTheDocument();
});

test('add new marker button click opens form', () => {
fireEvent.click(screen.getByTestId('create-new-marker-button'));
expect(screen.queryByTestId('create-new-marker-form')).toBeInTheDocument();
expect(screen.getByTestId('create-marker-title')).toBeInTheDocument();
expect(screen.getByTestId('create-marker-timestamp')).toBeInTheDocument();
});

test('form opens with empty title and current time of playhead', () => {
fireEvent.click(screen.getByTestId('create-new-marker-button'));
waitFor(() => {
expect(getCurrentTimeMock).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('create-marker-title')).toHaveTextContent('');
expect(screen.getByTestId('create-marker-timestamp')).toHaveTextContent('00:00:44.300');
expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-valid');
});
});

test('validates time input and enable/disable save button', () => {
fireEvent.click(screen.getByTestId('create-new-marker-button'));
fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00' } });
expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-invalid');
expect(screen.getByTestId('edit-save-button')).toBeDisabled();

fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00:00' } });
expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-valid');
expect(screen.getByTestId('edit-save-button')).not.toBeDisabled();

fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00:00:' } });
expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-invalid');
expect(screen.getByTestId('edit-save-button')).toBeDisabled();

fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00:00:32.' } });
expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-invalid');
expect(screen.getByTestId('edit-save-button')).toBeDisabled();

fireEvent.change(screen.getByTestId('create-marker-timestamp'), { target: { value: '00:00:32.543' } });
expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-valid');
expect(screen.getByTestId('edit-save-button')).not.toBeDisabled();
});

test('saves marker on save button click', async () => {
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce({
status: 201,
json: jest.fn(() => {
return {
"@context": "http://www.w3.org/ns/anno.jsonld",
"id": "http://example.com/marker/1",
"type": "Annotation",
"motivation": "highlighting",
"body": {
"type": "TextualBody",
"value": "Test Marker"
},
"target": "http://example.com/manifest/canvas/1#t=44.3"
};
})
});

fireEvent.click(screen.getByTestId('create-new-marker-button'));
fireEvent.change(screen.getByTestId('create-marker-title'), { target: { value: 'Test Marker' } });

expect(screen.getByTestId('create-marker-timestamp')).toHaveClass('time-valid');
expect(screen.getByTestId('edit-save-button')).not.toBeDisabled();

fireEvent.click(screen.getByTestId('edit-save-button'));
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(handleCreateMock).toHaveBeenCalledTimes(1);
});
});
});
Loading

0 comments on commit 1f44ea6

Please sign in to comment.