post lti tool config to api when creating lti dev key

closes PLAT-3745

test plan:
* have an example tool config (from `lti-1.3-test-tool`) that includes
a public_jwk
* create a new lti dev key, paste example config into json field,
click "Next"
* an api request should finish without errors
* `state.createLtiKeyState.toolConfiguration` should be the object
representation of the json config
* finish creating the key, it should show up in the list
* in the rails console, confirm that `Lti::ToolConfiguration.last`
has a `developer_key_id` that matches the newly created key

Change-Id: I495ae406c0f06e65ab80bcb14ed6086d9ff4bdce
Reviewed-on: https://gerrit.instructure.com/166664
Tested-by: Jenkins
Reviewed-by: Marc Phillips <mphillips@instructure.com>
Reviewed-by: Weston Dransfield <wdransfield@instructure.com>
QA-Review: Weston Dransfield <wdransfield@instructure.com>
Product-Review: Xander Moffatt <xmoffatt@instructure.com>
This commit is contained in:
Xander Moffatt 2018-10-01 08:59:40 -06:00
parent 3648396895
commit 37470d9b52
11 changed files with 276 additions and 27 deletions

View File

@ -26,6 +26,7 @@ import {ModalFooter} from '@instructure/ui-overlays/lib/components/Modal'
export default class LtiKeyFooter extends React.Component {
onAdvanceToCustomization = () => {
this.props.dispatch(this.props.ltiKeysSetCustomizing(true))
this.props.onAdvanceToCustomization()
}
onSave = e => {
@ -53,7 +54,8 @@ export default class LtiKeyFooter extends React.Component {
render() {
return (
<ModalFooter>
<Button onClick={this.onCancel}>{I18n.t('Cancel')}</Button>&nbsp;
<Button onClick={this.onCancel}>{I18n.t('Cancel')}</Button>
&nbsp;
{this.nextOrSaveButton()}
</ModalFooter>
)
@ -65,5 +67,6 @@ LtiKeyFooter.propTypes = {
ltiKeysSetCustomizing: PropTypes.func.isRequired,
onCancelClick: PropTypes.func.isRequired,
onSaveClick: PropTypes.func.isRequired,
onAdvanceToCustomization: PropTypes.func.isRequired,
customizing: PropTypes.bool.isRequired
}

View File

@ -86,12 +86,31 @@ export default class DeveloperKeyModal extends React.Component {
)
}
saveLtiToolConfiguration = () => {
const formData = new FormData(this.submissionForm)
this.props.store.dispatch(this.props.actions.saveLtiToolConfiguration({
account_id: this.props.ctx.params.contextId,
developer_key: {
name: formData.get("developer_key[name]"),
email: formData.get("developer_key[email]"),
notes: formData.get("developer_key[notes]"),
test_cluster_only: this.testClusterOnly
},
settings: JSON.parse(formData.get("tool_configuration")),
settings_url: formData.get("tool_configuration_url"),
}))
}
get isLtiKey() {
return this.props.createLtiKeyState.isLtiKey
}
modalBody() {
if (this.props.createOrEditDeveloperKeyState.developerKeyCreateOrEditPending) {
const isSavingDeveloperKey = this.props.createOrEditDeveloperKeyState.developerKeyCreateOrEditPending
const isSavingLtiToolConfig = this.props.createLtiKeyState.saveToolConfigurationPending
if (
isSavingDeveloperKey || isSavingLtiToolConfig
) {
return this.spinner()
}
return this.developerKeyForm()
@ -103,6 +122,7 @@ export default class DeveloperKeyModal extends React.Component {
<LtiKeyFooter
onCancelClick={this.closeModal}
onSaveClick={this.submitForm}
onAdvanceToCustomization={this.saveLtiToolConfiguration}
customizing={this.props.createLtiKeyState.customizing}
ltiKeysSetCustomizing={this.props.actions.ltiKeysSetCustomizing}
dispatch={this.props.store.dispatch}
@ -160,8 +180,7 @@ export default class DeveloperKeyModal extends React.Component {
closeModal = () => {
const { actions, store } = this.props
store.dispatch(actions.developerKeysModalClose())
store.dispatch(actions.ltiKeysSetCustomizing(false))
store.dispatch(actions.ltiKeysSetLtiKey(false))
store.dispatch(actions.resetLtiState())
store.dispatch(actions.editDeveloperKey())
}
@ -203,11 +222,16 @@ DeveloperKeyModal.propTypes = {
createOrEditDeveloperKey: PropTypes.func.isRequired,
developerKeysModalClose: PropTypes.func.isRequired,
editDeveloperKey: PropTypes.func.isRequired,
listDeveloperKeyScopesSet: PropTypes.func.isRequired
listDeveloperKeyScopesSet: PropTypes.func.isRequired,
saveLtiToolConfiguration: PropTypes.func.isRequired,
resetLtiState: PropTypes.func.isRequired
}).isRequired,
createLtiKeyState: PropTypes.shape({
isLtiKey: PropTypes.bool.isRequired,
customizing: PropTypes.bool.isRequired
customizing: PropTypes.bool.isRequired,
toolConfiguration: PropTypes.object.isRequired,
toolConfigurationUrl: PropTypes.string.isRequired,
saveToolConfigurationPending: PropTypes.bool.isRequired
}).isRequired,
createOrEditDeveloperKeyState: PropTypes.shape({
developerKeyCreateOrEditSuccessful: PropTypes.bool.isRequired,

View File

@ -27,8 +27,8 @@ export default class ToolConfiguration extends React.Component {
if (!this.props.createLtiKeyState.customizing) {
return (
<ToolConfigurationForm
toolConfiguration={this.props.toolConfiguration}
configurationUrl={this.props.configurationUrl}
toolConfiguration={this.props.createLtiKeyState.toolConfiguration}
toolConfigurationUrl={this.props.createLtiKeyState.toolConfigurationUrl}
/>
)
}
@ -55,20 +55,14 @@ export default class ToolConfiguration extends React.Component {
}
}
ToolConfiguration.defaultProps = {
toolConfiguration: undefined,
configurationUrl: undefined
}
ToolConfiguration.propTypes = {
dispatch: PropTypes.func.isRequired,
setEnabledScopes: PropTypes.func.isRequired,
setDisabledPlacements: PropTypes.func.isRequired,
toolConfiguration: PropTypes.object,
configurationUrl: PropTypes.string,
createLtiKeyState: PropTypes.shape({
customizing: PropTypes.bool.isRequired,
toolConfiguration: PropTypes.object.isRequired,
toolConfigurationUrl: PropTypes.string.isRequired,
enabledScopes: PropTypes.arrayOf(PropTypes.string).isRequired,
disabledPlacements: PropTypes.arrayOf(PropTypes.string).isRequired
}).isRequired

View File

@ -45,13 +45,20 @@ export default class ToolConfigurationForm extends React.Component {
if (this.state.configurationType === 'json') {
return (
<TextArea
name="tool_configuration"
defaultValue={this.toolConfiguration}
label={I18n.t('LTI 1.3 Configuration')}
maxHeight="20rem"
/>
)
}
return <TextInput defaultValue={this.props.configurationUrl} label={I18n.t('JSON URL')} />
return (
<TextInput
name="tool_configuration_url"
defaultValue={this.props.toolConfigurationUrl}
label={I18n.t('JSON URL')}
/>
)
}
render() {
@ -75,12 +82,7 @@ export default class ToolConfigurationForm extends React.Component {
}
}
ToolConfigurationForm.defaultProps = {
toolConfiguration: undefined,
configurationUrl: undefined
}
ToolConfigurationForm.propTypes = {
toolConfiguration: PropTypes.object,
configurationUrl: PropTypes.string
toolConfiguration: PropTypes.object.isRequired,
toolConfigurationUrl: PropTypes.string.isRequired
}

View File

@ -24,6 +24,7 @@ function newProps(customizing = false) {
return {
onCancelClick: jest.fn(),
onSaveClick: jest.fn(),
onAdvanceToCustomization: jest.fn(),
customizing,
dispatch: jest.fn(),
ltiKeysSetCustomizing: jest.fn()
@ -64,6 +65,7 @@ it("Advances to the 'customizing' state when 'next' is clicked", () => {
.at(1)
.simulate('click')
expect(props.ltiKeysSetCustomizing).toHaveBeenCalledWith(true)
expect(props.onAdvanceToCustomization).toHaveBeenCalled()
})
it("Renders the 'Next' button if not customizing", () => {

View File

@ -23,7 +23,7 @@ import ToolConfigurationForm from '../ToolConfigurationForm'
function newProps() {
return {
toolConfiguration: {name: 'Test Tool', url: 'https://www.test.com/launch'},
configurationUrl: 'https://www.test.com/config.json'
toolConfigurationUrl: 'https://www.test.com/config.json'
}
}
@ -60,7 +60,7 @@ describe('when configuration method is by URL', () => {
it('renders the tool configuration URL in a text input', () => {
const textInput = wrapper.find('TextInput')
const expectedString = newProps().configurationUrl
const expectedString = newProps().toolConfigurationUrl
expect(textInput.html()).toEqual(expect.stringContaining(expectedString))
})

View File

@ -0,0 +1,66 @@
/*
* Copyright (C) 2018 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import actions from 'jsx/developer_keys/actions/ltiKeyActions'
import developerKeysActions from 'jsx/developer_keys/actions/developerKeysActions'
import axios from 'axios'
const dispatch = jest.fn()
beforeAll(() => {
axios.post = jest.fn().mockResolvedValue({
data: {tool_configuration: {settings: {test: 'config'}, developer_key_id: '1'}}
})
})
afterAll(() => {
axios.post.mockRestore()
})
describe('saveLtiToolConfiguration', () => {
const save = (includeUrl = false) => {
actions.saveLtiToolConfiguration({
account_id: '1',
developer_key: {name: 'test'},
settings: {test: 'config'},
...(includeUrl ? {settings_url: 'test.url'} : {})
})(dispatch)
}
it('sets the developer key with provided fields', () => {
save()
expect(dispatch).toBeCalledWith(developerKeysActions.setEditingDeveloperKey({name: 'test'}))
})
it('sets the tool configuration url if provided', () => {
save(true)
expect(dispatch).toBeCalledWith(actions.setLtiToolConfigurationUrl('test.url'))
})
describe('on successful response', () => {
it('sets the tool configuration', () => {
expect(dispatch).toBeCalledWith(actions.setLtiToolConfiguration({test: 'config'}))
})
it('prepends the developer key to the list', () => {
expect(dispatch).toBeCalledWith(
developerKeysActions.listDeveloperKeysPrepend({name: 'test', id: '1'})
)
})
})
})

View File

@ -15,6 +15,9 @@
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import axios from 'axios'
import $ from 'jquery'
import developerKeysActions from './developerKeysActions'
const actions = {}
@ -42,4 +45,76 @@ actions.ltiKeysSetDisabledPlacements = payload => ({
payload
})
actions.SET_LTI_TOOL_CONFIGURATION = 'SET_LTI_TOOL_CONFIGURATION'
actions.setLtiToolConfiguration = payload => ({
type: actions.SET_LTI_TOOL_CONFIGURATION,
payload
})
actions.SET_LTI_TOOL_CONFIGURATION_URL = 'SET_LTI_TOOL_CONFIGURATION_URL'
actions.setLtiToolConfigurationUrl = payload => ({
type: actions.SET_LTI_TOOL_CONFIGURATION_URL,
payload
})
actions.RESET_LTI_STATE = 'RESET_LTI_STATE'
actions.resetLtiState = () => ({type: actions.RESET_LTI_STATE})
actions.SAVE_LTI_TOOL_CONFIGURATION_START = 'SAVE_LTI_TOOL_CONFIGURATION_START'
actions.saveLtiToolConfigurationStart = () => ({
type: actions.SAVE_LTI_TOOL_CONFIGURATION_START
})
actions.SAVE_LTI_TOOL_CONFIGURATION_FAILED = 'SAVE_LTI_TOOL_CONFIGURATION_FAILED'
actions.saveLtiToolConfigurationFailed = payload => ({
type: actions.SAVE_LTI_TOOL_CONFIGURATION_FAILED,
payload
})
actions.SAVE_LTI_TOOL_CONFIGURATION_SUCCESSFUL = 'SAVE_LTI_TOOL_CONFIGURATION_SUCCESSFUL'
actions.saveLtiToolConfigurationSuccessful = payload => ({
type: actions.SAVE_LTI_TOOL_CONFIGURATION_SUCCESSFUL,
payload
})
actions.saveLtiToolConfiguration = ({
account_id,
settings,
settings_url,
developer_key
}) => dispatch => {
dispatch(actions.saveLtiToolConfigurationStart())
dispatch(developerKeysActions.setEditingDeveloperKey(developer_key))
if (settings_url) {
dispatch(actions.setLtiToolConfigurationUrl(settings_url))
}
const url = `/api/lti/accounts/${account_id}/developer_keys/tool_configuration`
axios
.post(url, {
tool_configuration: {
settings,
...(settings_url ? {settings_url} : {})
},
developer_key
})
.then(response => {
dispatch(actions.saveLtiToolConfigurationSuccessful())
dispatch(actions.setLtiToolConfiguration(response.data.tool_configuration.settings))
const newDevKey = {
...developer_key,
id: response.data.tool_configuration.developer_key_id
}
dispatch(developerKeysActions.setEditingDeveloperKey(newDevKey))
dispatch(developerKeysActions.listDeveloperKeysPrepend(newDevKey))
})
.catch(error => {
dispatch(actions.saveLtiToolConfigurationFailed(error))
$.flashError(error.message)
})
}
export default actions

View File

@ -23,7 +23,11 @@ function freshState() {
return {
isLtiKey: false,
customizing: false,
saveToolConfigurationPending: false,
saveToolConfigurationSuccessful: false,
saveToolConfigurationError: null,
toolConfiguration: {},
toolConfigurationUrl: '',
enabledScopes: [],
disabledPlacements: []
}
@ -85,3 +89,48 @@ it('handles "LTI_KEYS_SET_DISABLED_PLACEMENTS"', () => {
expect(newState.disabledPlacements).toEqual(['account_navigation'])
expect(newState.enabledScopes).toEqual([])
})
it('handles "SET_LTI_TOOL_CONFIGURATION"', () => {
const state = freshState()
const config = {test: 'config'}
const action = actions.setLtiToolConfiguration(config)
const newState = reducer(state, action)
expect(newState.toolConfiguration).toEqual(config)
})
it('handles "SET_LTI_TOOL_CONFIGURATION_URL"', () => {
const state = freshState()
const configUrl = 'config.url'
const action = actions.setLtiToolConfigurationUrl(configUrl)
const newState = reducer(state, action)
expect(newState.toolConfigurationUrl).toEqual(configUrl)
})
it('handles "SAVE_LTI_TOOL_CONFIGURATION_START"', () => {
const state = freshState()
const action = actions.saveLtiToolConfigurationStart()
const newState = reducer(state, action)
expect(newState.saveToolConfigurationPending).toEqual(true)
})
it('handles "SAVE_LTI_TOOL_CONFIGURATION_ERROR"', () => {
const state = freshState()
const error = new Error('error')
const action = actions.saveLtiToolConfigurationFailed(error)
const newState = reducer(state, action)
expect(newState.saveToolConfigurationPending).toEqual(false)
expect(newState.saveToolConfigurationError).toEqual(error)
})
it('handles "SAVE_LTI_TOOL_CONFIGURATION_SUCCESSFUL"', () => {
const state = freshState()
const action = actions.saveLtiToolConfigurationSuccessful()
const newState = reducer(state, action)
expect(newState.saveToolConfigurationPending).toEqual(false)
expect(newState.saveToolConfigurationError).toBeNull()
expect(newState.saveToolConfigurationSuccessful).toEqual(true)
})

View File

@ -16,12 +16,16 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import ACTION_NAMES from '../actions/developerKeysActions'
import ACTION_NAMES from '../actions/ltiKeyActions'
const initialState = {
isLtiKey: false,
customizing: false,
saveToolConfigurationPending: false,
saveToolConfigurationSuccessful: false,
saveToolConfigurationError: null,
toolConfiguration: {},
toolConfigurationUrl: '',
enabledScopes: [],
disabledPlacements: []
}
@ -42,6 +46,36 @@ const ltiKeysHandlers = {
[ACTION_NAMES.LTI_KEYS_SET_DISABLED_PLACEMENTS]: (state, action) => ({
...state,
disabledPlacements: action.payload
}),
[ACTION_NAMES.SET_LTI_TOOL_CONFIGURATION]: (state, action) => ({
...state,
toolConfiguration: action.payload
}),
[ACTION_NAMES.SET_LTI_TOOL_CONFIGURATION_URL]: (state, action) => ({
...state,
toolConfigurationUrl: action.payload
}),
[ACTION_NAMES.SAVE_LTI_TOOL_CONFIGURATION_START]: state => ({
...state,
saveToolConfigurationPending: true
}),
[ACTION_NAMES.SAVE_LTI_TOOL_CONFIGURATION_FAILED]: (state, action) => ({
...state,
saveToolConfigurationPending: false,
saveToolConfigurationError: action.payload
}),
[ACTION_NAMES.SAVE_LTI_TOOL_CONFIGURATION_SUCCESSFUL]: state => ({
...state,
saveToolConfigurationPending: false,
saveToolConfigurationError: null,
saveToolConfigurationSuccessful: true
}),
[ACTION_NAMES.RESET_LTI_STATE]: state => ({
...state,
isLtiKey: false,
customizing: false,
toolConfiguration: {},
toolConfigurationUrl: ''
})
}

View File

@ -354,7 +354,7 @@ test('clears the lti key state when modal is closed', () => {
const actions = Object.assign(fakeActions, {
developerKeysModalClose: () => {},
ltiKeysSetCustomizing: () => {},
ltiKeysSetLtiKey: ltiStub
resetLtiState: ltiStub
})
const wrapper = mount(