Skip to content

Commit

Permalink
Notifies user before discarding unsaved resource. Disables Save butto…
Browse files Browse the repository at this point in the history
…n when no changes.

closes #313
  • Loading branch information
justinlittman committed Jul 29, 2019
1 parent 642fe84 commit 972030b
Show file tree
Hide file tree
Showing 17 changed files with 248 additions and 72 deletions.
11 changes: 7 additions & 4 deletions __tests__/actionCreators/resources.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe('update', () => {
const getState = jest.fn().mockReturnValue(state)
await update(currentUser)(dispatch, getState)
expect(dispatch).toBeCalledWith({ type: 'UPDATE_STARTED' })
expect(dispatch).toBeCalledWith({ type: 'UPDATE_FINISHED' })
expect(dispatch).toBeCalledWith({ type: 'UPDATE_FINISHED', payload: '53ce99f9e4b1132733bae37801cd8000' })
})
})

Expand All @@ -59,9 +59,10 @@ describe('retrieveResource', () => {
const dispatch = jest.fn()

await retrieveResource(currentUser, uri)(dispatch)
expect(dispatch).toHaveBeenCalledTimes(3)
expect(dispatch).toHaveBeenCalledTimes(4)
expect(dispatch).toBeCalledWith({ type: 'RETRIEVE_STARTED' })
expect(dispatch).toBeCalledWith({ type: 'CLEAR_RESOURCE_TEMPLATES' })
expect(dispatch).toBeCalledWith({ type: 'SET_LAST_SAVE_CHECKSUM', payload: '1bb7817b33ce3f39ebdacc70339f6904' })
})
})

Expand Down Expand Up @@ -89,7 +90,8 @@ describe('newResource', () => {
expect(actions[1]).toEqual({ type: 'CLEAR_RESOURCE_URI_MESSAGE' })
expect(actions[2]).toEqual({ type: 'SET_RESOURCE', payload: { [resourceTemplateId]: {} } })
expect(actions[3]).toEqual({ type: 'RETRIEVE_RESOURCE_TEMPLATE_STARTED', payload: resourceTemplateId })
expect(actions[4]).toEqual({ type: 'SET_RESOURCE_TEMPLATE', payload: resourceTemplateResponse.response.body })
expect(actions[4]).toEqual({ type: 'SET_LAST_SAVE_CHECKSUM', payload: undefined })
expect(actions[5]).toEqual({ type: 'SET_RESOURCE_TEMPLATE', payload: resourceTemplateResponse.response.body })
})
})

Expand Down Expand Up @@ -120,7 +122,8 @@ describe('existingResource', () => {
expect(actions[1]).toEqual({ type: 'SET_RESOURCE', payload: { [resourceTemplateId]: {} } })
expect(actions[2]).toEqual({ type: 'SET_BASE_URL', payload: 'http://localhost:8080/repository/stanford/888ea64d-f471-41bf-9d33-c9426ab83b5c' })
expect(actions[3]).toEqual({ type: 'RETRIEVE_RESOURCE_TEMPLATE_STARTED', payload: undefined })
expect(actions[4]).toEqual({ type: 'SET_RESOURCE_TEMPLATE', payload: resourceTemplateResponse.response.body })
expect(actions[4]).toEqual({ type: 'SET_LAST_SAVE_CHECKSUM', payload: undefined })
expect(actions[5]).toEqual({ type: 'SET_RESOURCE_TEMPLATE', payload: resourceTemplateResponse.response.body })
})
})

Expand Down
19 changes: 8 additions & 11 deletions __tests__/components/editor/Editor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import Editor from 'components/editor/Editor'
import ResourceTemplate from 'components/editor/ResourceTemplate'
import Header from 'components/Header'
import AuthenticationMessage from 'components/editor/AuthenticationMessage'
import SaveAndPublishButton from 'components/editor/SaveAndPublishButton'
import { Prompt } from 'react-router'

const props = {
location: { state: { resourceTemplateId: 'resourceTemplate:bf:Note' } },
Expand All @@ -31,6 +33,12 @@ describe('<Editor />', () => {
it('renders <AuthenticationMessage />', () => {
expect(wrapper.exists(AuthenticationMessage)).toBe(true)
})
it('renders <SaveAndPublishButton />', () => {
expect(wrapper.exists(SaveAndPublishButton)).toBe(true)
})
it('includes <Prompt />', () => {
expect(wrapper.exists(Prompt)).toBe(true)
})
})
describe('authenticated user', () => {
props.currentSession = { dummy: 'should be CognitoUserSession instance, but just checked for presence at present' }
Expand All @@ -48,15 +56,4 @@ describe('<Editor />', () => {
expect(wrapper.findWhere(n => n.type() === 'button' && n.contains('Preview RDF')).exists()).toBeTruthy()
})
})

describe('Save & Publish button', () => {
const mockOpenHandler = jest.fn()
const wrapperHandler = () => mockOpenHandler
const wrapper = shallow(<Editor.WrappedComponent {...props} userWantsToSave={wrapperHandler}/>)

it('attempts to save the RDF content when save is clicked', () => {
wrapper.findWhere(n => n.type() === 'button' && n.contains('Save & Publish')).simulate('click')
expect(mockOpenHandler).toHaveBeenCalled()
})
})
})
14 changes: 2 additions & 12 deletions __tests__/components/editor/RDFModal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import React from 'react'
import Modal from 'react-bootstrap/lib/Modal'
import Button from 'react-bootstrap/lib/Button'
import { shallow } from 'enzyme'
import RDFModal from 'components/editor/RDFModal'
import SaveAndPublishButton from 'components/editor/SaveAndPublishButton'

describe('<RDFModal />', () => {
const closeFunc = jest.fn()
Expand Down Expand Up @@ -38,21 +38,11 @@ describe('<RDFModal />', () => {

describe('body', () => {
it('has a save and publish button', () => {
expect(wrapper.find(Modal.Body).find(Button)
.last()
.childAt(0)
.text()).toEqual('Save & Publish')
expect(wrapper.find(SaveAndPublishButton).length).toBe(1)
})

it('has a Modal.Body', () => {
expect(wrapper.find(Modal.Body).length).toBe(1)
})
})

describe('save and close buttons', () => {
it('attempts to save the RDF content when save is clicked', () => {
wrapper.find('.btn-primary', { text: 'Save & Publish' }).simulate('click')
expect(saveFunc).toHaveBeenCalled()
})
})
})
29 changes: 29 additions & 0 deletions __tests__/components/editor/SaveAndPublishButton.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright 2019 Stanford University see LICENSE for license

import React from 'react'
import { shallow } from 'enzyme'
import SaveAndPublishButton from 'components/editor/SaveAndPublishButton'
import Button from 'react-bootstrap/lib/Button'

describe('<SaveAndPublishButton />', () => {
const mockSave = jest.fn()
describe('when disabled', () => {
const wrapper = shallow(<SaveAndPublishButton.WrappedComponent isDisabled={true} save={mockSave}/>)
it('the button is disabled', () => {
expect(wrapper.find(Button).prop('disabled')).toEqual(true)
})
})
describe('when not disabled', () => {
const wrapper = shallow(<SaveAndPublishButton.WrappedComponent isDisabled={false} save={mockSave}/>)
it('the button is not disabled', () => {
expect(wrapper.find(Button).prop('disabled')).toEqual(false)
})
})
describe('clicking the button', () => {
const wrapper = shallow(<SaveAndPublishButton.WrappedComponent isDisabled={false} save={mockSave} isSaved={false} currentUser="Wilford Brimley" />)
it('calls save', () => {
wrapper.find(Button).simulate('click')
expect(mockSave).toHaveBeenCalledWith(false, 'Wilford Brimley')
})
})
})
30 changes: 30 additions & 0 deletions __tests__/integration/leaveEditor.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright 2019 Stanford University see LICENSE for license

import pupExpect from 'expect-puppeteer'
import { testUserLogin } from './loginHelper'

describe('Leaving the editor', () => {
beforeAll(async () => {
await testUserLogin()
})

it('prompts the user before leaving', async () => {
expect.assertions(8)

// Load up a Bibframe Instance
await pupExpect(page).toClick('a', { text: 'BIBFRAME Instance' })
await pupExpect(page).toMatchElement('a', { text: 'Editor' })

const dialog1 = await expect(page).toDisplayDialog(async () => {
await pupExpect(page).toClick('a', { text: 'Load RDF' })
})
await dialog1.dismiss()
await pupExpect(page).not.toMatch('Resource RDF N3')

const dialog2 = await expect(page).toDisplayDialog(async () => {
await pupExpect(page).toClick('a', { text: 'Load RDF' })
})
await dialog2.accept()
await pupExpect(page).toMatch('Resource RDF N3')
})
})
19 changes: 17 additions & 2 deletions __tests__/reducers/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import {
createReducer, setRetrieveError, removeResource, clearRetrieveError, updateFinished,
setLastSaveChecksum,
} from 'reducers/index'
import _ from 'lodash'
import { getFixtureResourceTemplate } from '../fixtureLoaderHelper'
Expand Down Expand Up @@ -175,17 +176,31 @@ describe('removeResource', () => {
})

describe('updateFinished', () => {
const action = { payload: 'abc123' }
it('sets last save differently each time called', () => {
expect(initialState.selectorReducer.editor.lastSave).toBeFalsy()
const newState = updateFinished(initialState.selectorReducer)
const newState = updateFinished(initialState.selectorReducer, action)
expect(newState.editor.lastSave).toBeTruthy()

const now = Date.now()
while (now === Date.now()) {
// Wait
}

const newState2 = updateFinished(_.cloneDeep(newState))
const newState2 = updateFinished(_.cloneDeep(newState), action)
expect(newState.editor.lastSave).not.toEqual(newState2.editor.lastSave)
})
it('sets lastSaveChecksum', () => {
const newState = updateFinished(initialState.selectorReducer, action)
expect(newState.editor.lastSaveChecksum).toEqual('abc123')
})
})

describe('setLastSaveChecksum', () => {
const action = { payload: 'abc123' }

it('sets lastSaveChecksum', () => {
const newState = setLastSaveChecksum(initialState.selectorReducer, action)
expect(newState.editor.lastSaveChecksum).toEqual('abc123')
})
})
48 changes: 48 additions & 0 deletions __tests__/selectors/resourceSelectors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import {
rootResourceId, isExpanded,
getDisplayValidations, getResourceTemplate, getPropertyTemplate,
resourceHasChangesSinceLastSave,
} from 'selectors/resourceSelectors'
import { getFixtureResourceTemplate } from '../fixtureLoaderHelper'

let initialState

Expand All @@ -16,6 +18,7 @@ beforeEach(() => {
},
resource: { // The state we're displaying in the editor
},
editor: { },
},
}
})
Expand Down Expand Up @@ -134,3 +137,48 @@ describe('getPropertyTemplate()', () => {
})
})
})

describe('resourceHasChangesSinceLastSave', () => {
let template
beforeEach(async () => {
const templateResponse = await getFixtureResourceTemplate('resourceTemplate:bf2:Note')
template = templateResponse.response.body
})
const resource = {
'resourceTemplate:bf2:Note': {
'http://www.w3.org/2000/01/rdf-schema#label': {
items: [
{
content: 'foo',
id: 'VBtih30me',
lang: {
id: 'en',
label: 'English',
},
},
],
},
},
}
describe('when not previously saved', () => {
it('returns changed', () => {
expect(resourceHasChangesSinceLastSave(initialState)).toBe(true)
})
})
describe('when resource has changed', () => {
it('returns changed', () => {
initialState.selectorReducer.resource = resource
initialState.selectorReducer.entities.resourceTemplates['resourceTemplate:bf2:Note'] = template
initialState.selectorReducer.editor.lastSaveChecksum = 'abc123'
expect(resourceHasChangesSinceLastSave(initialState)).toBe(true)
})
})
describe('when resource has not changed', () => {
it('returns not changed', () => {
initialState.selectorReducer.resource = resource
initialState.selectorReducer.entities.resourceTemplates['resourceTemplate:bf2:Note'] = template
initialState.selectorReducer.editor.lastSaveChecksum = '08ae75f20a719460c76743bfdeded6a6'
expect(resourceHasChangesSinceLastSave(initialState)).toBe(false)
})
})
})
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"ajv": "^6.10.0",
"amazon-cognito-identity-js": "^3.0.10",
"babel-core": "^7.0.0-bridge.0",
"crypto-js": "^3.1.9-1",
"dotenv": "^8.0.0",
"event-stream": "^4.0.1",
"express": "^4.16.4",
Expand Down
4 changes: 4 additions & 0 deletions src/Utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import N3Parser from 'n3/lib/N3Parser'
import rdf from 'rdf-ext'
import _ from 'lodash'
import shortid from 'shortid'
import CryptoJS from 'crypto-js'


export const isResourceWithValueTemplateRef = property => property?.type === 'resource'
&& property?.valueConstraint?.valueTemplateRefs?.length > 0
Expand Down Expand Up @@ -102,3 +104,5 @@ export const rdfDatasetFromN3 = data => new Promise((resolve) => {
}
})
})

export const generateMD5 = message => CryptoJS.MD5(message).toString()
15 changes: 10 additions & 5 deletions src/actionCreators/resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,28 @@ import {
assignBaseURL, updateStarted, updateFinished,
retrieveResourceStarted, setResource, updateProperty,
toggleCollapse, appendResource, clearResourceTemplates,
clearResourceURIMessage,
clearResourceURIMessage, setLastSaveChecksum,
} from 'actions/index'
import { fetchResourceTemplate } from 'actionCreators/resourceTemplates'
import { updateRDFResource, loadRDFResource } from 'sinopiaServer'
import { rootResourceId } from 'selectors/resourceSelectors'
import { findResourceTemplate } from 'selectors/entitySelectors'
import GraphBuilder from 'GraphBuilder'
import { isResourceWithValueTemplateRef, rdfDatasetFromN3, defaultValuesFromPropertyTemplate } from 'Utilities'
import {
isResourceWithValueTemplateRef, rdfDatasetFromN3, defaultValuesFromPropertyTemplate, generateMD5,
} from 'Utilities'
import shortid from 'shortid'
import ResourceStateBuilder from 'ResourceStateBuilder'
import _ from 'lodash'

// A thunk that updates an existing resource in Trellis
// A thunk that updates (saves) an existing resource in Trellis
export const update = currentUser => (dispatch, getState) => {
dispatch(updateStarted())

const uri = rootResourceId(getState())
const rdf = new GraphBuilder(getState().selectorReducer).graph.toString()
const rdf = new GraphBuilder(getState().selectorReducer).graph.toCanonical()
return updateRDFResource(currentUser, uri, rdf)
.then(response => dispatch(updateFinished(response)))
.then(() => dispatch(updateFinished(generateMD5(rdf))))
}

// A thunk that loads an existing resource from Trellis
Expand All @@ -40,6 +42,7 @@ export const retrieveResource = (currentUser, uri) => (dispatch) => {
return rdfDatasetFromN3(data).then((dataset) => {
const builder = new ResourceStateBuilder(dataset, null)
dispatch(existingResource(builder.state, uri))
dispatch(setLastSaveChecksum(generateMD5(dataset.toCanonical())))
})
})
}
Expand All @@ -52,6 +55,7 @@ export const newResource = resourceTemplateId => (dispatch) => {
dispatch(clearResourceURIMessage())
dispatch(setResource(resource))
dispatch(stubResource(true))
dispatch(setLastSaveChecksum(undefined))
}

// A thunk that stubs out an existing new resource
Expand All @@ -60,6 +64,7 @@ export const existingResource = (resource, uri) => (dispatch) => {
dispatch(setResource(resource))
dispatch(assignBaseURL(uri))
dispatch(stubResource(false))
dispatch(setLastSaveChecksum(undefined))
}

// A thunk that expands a nested resource for a property
Expand Down
8 changes: 7 additions & 1 deletion src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ export const updateStarted = () => ({
type: 'UPDATE_STARTED',
})

export const updateFinished = () => ({
export const updateFinished = checksum => ({
type: 'UPDATE_FINISHED',
payload: checksum,
})

export const retrieveResourceStarted = uri => ({
Expand Down Expand Up @@ -151,3 +152,8 @@ export const showSearchResults = searchResults => ({
type: 'SHOW_SEARCH_RESULTS',
payload: searchResults,
})

export const setLastSaveChecksum = checksum => ({
type: 'SET_LAST_SAVE_CHECKSUM',
payload: checksum,
})
Loading

0 comments on commit 972030b

Please sign in to comment.