Switch to useScopeValue for project data in Share modal (#3823)

GitOrigin-RevId: f82170c241c59cf7b66fea7e1471004e46ab3547
This commit is contained in:
Alf Eaton 2021-03-31 11:44:20 +01:00 committed by Copybot
parent 445c850004
commit d0d28524a2
7 changed files with 181 additions and 147 deletions

View file

@ -129,8 +129,6 @@ header.toolbar.toolbar-header.toolbar-with-labels(
handle-hide="handleHide"
show="show"
is-admin="isAdmin"
project="clonedProject"
update-project="updateProject"
)
!= moduleIncludes('publish:button', locals)

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { Trans, useTranslation } from 'react-i18next'
import {
@ -25,6 +25,11 @@ export default function EditMember({ member }) {
setConfirmingOwnershipTransfer
] = useState(false)
// update the local state if the member's privileges change externally
useEffect(() => {
setPrivileges(member.privileges)
}, [member.privileges])
const { updateProject, monitorRequest } = useShareProjectContext()
const project = useProjectContext()

View file

@ -7,6 +7,7 @@ import React, {
} from 'react'
import PropTypes from 'prop-types'
import ShareProjectModalContent from './share-project-modal-content'
import useScopeValue from '../../../shared/context/util/scope-value-hook'
const ShareProjectContext = createContext()
@ -87,12 +88,13 @@ export default function ShareProjectModal({
animation = true,
isAdmin,
eventTracking,
project,
updateProject
ide
}) {
const [inFlight, setInFlight] = useState(false)
const [error, setError] = useState()
const [project, setProject] = useScopeValue('project', ide.$scope, true)
// reset error when the modal is opened
useEffect(() => {
if (show) {
@ -129,6 +131,14 @@ export default function ShareProjectModal({
return promise
}, [])
// merge the new data with the old project data
const updateProject = useCallback(
data => {
setProject(project => Object.assign(project, data))
},
[setProject]
)
if (!project) {
return null
}
@ -162,10 +172,11 @@ ShareProjectModal.propTypes = {
animation: PropTypes.bool,
handleHide: PropTypes.func.isRequired,
isAdmin: PropTypes.bool.isRequired,
project: projectShape,
ide: PropTypes.shape({
$scope: PropTypes.object.isRequired
}).isRequired,
show: PropTypes.bool.isRequired,
eventTracking: PropTypes.shape({
sendMB: PropTypes.func.isRequired
}),
updateProject: PropTypes.func.isRequired
})
}

View file

@ -1,11 +1,13 @@
import App from '../../../base'
import { react2angular } from 'react2angular'
import cloneDeep from 'lodash/cloneDeep'
import ShareProjectModal from '../components/share-project-modal'
import { listProjectInvites, listProjectMembers } from '../utils/api'
App.component('shareProjectModal', react2angular(ShareProjectModal))
App.component(
'shareProjectModal',
react2angular(ShareProjectModal, undefined, ['ide'])
)
export default App.controller('ReactShareProjectModalController', function(
$scope,
@ -15,49 +17,20 @@ export default App.controller('ReactShareProjectModalController', function(
$scope.isAdmin = false
$scope.show = false
let deregisterProjectWatch
// deep watch $scope.project for changes
function registerProjectWatch() {
deregisterProjectWatch = $scope.$watch(
'project',
project => {
$scope.clonedProject = cloneDeep(project)
},
true
)
}
$scope.handleHide = () => {
$scope.$applyAsync(() => {
$scope.show = false
if (deregisterProjectWatch) {
deregisterProjectWatch()
}
})
}
$scope.openShareProjectModal = isAdmin => {
eventTracking.sendMBOnce('ide-open-share-modal-once')
$scope.$applyAsync(() => {
registerProjectWatch()
$scope.isAdmin = isAdmin
$scope.show = true
})
}
// update $scope.project with new data
$scope.updateProject = data => {
if (!$scope.project) {
return
}
$scope.$applyAsync(() => {
Object.assign($scope.project, data)
})
}
/* tokens */
ide.socket.on('project:tokens:changed', data => {

View file

@ -8,6 +8,7 @@ import _ from 'lodash'
*
* @param {string} path - dot '.' path of a property in `sourceScope`.
* @param {object} $scope - Angular $scope containing the value to bind.
* @param {boolean} deep
* @returns {[any, function]} - Binded value and setter function tuple.
*/
export default function useScopeValue(path, $scope, deep = false) {
@ -17,7 +18,7 @@ export default function useScopeValue(path, $scope, deep = false) {
return $scope.$watch(
path,
newValue => {
setValue(newValue)
setValue(deep ? _.cloneDeep(newValue) : newValue)
},
deep
)

View file

@ -73,6 +73,16 @@ const setupFetchMock = () => {
})
}
const ideWithProject = project => {
return {
$scope: {
$watch: () => () => {},
$applyAsync: () => {},
project
}
}
}
export const LinkSharingOff = args => {
setupFetchMock()
@ -81,7 +91,7 @@ export const LinkSharingOff = args => {
publicAccesLevel: 'private'
}
return <ShareProjectModal {...args} project={project} />
return <ShareProjectModal {...args} ide={ideWithProject(project)} />
}
export const LinkSharingOn = args => {
@ -92,7 +102,7 @@ export const LinkSharingOn = args => {
publicAccesLevel: 'tokenBased'
}
return <ShareProjectModal {...args} project={project} />
return <ShareProjectModal {...args} ide={ideWithProject(project)} />
}
export const LinkSharingLoading = args => {
@ -104,7 +114,7 @@ export const LinkSharingLoading = args => {
tokens: undefined
}
return <ShareProjectModal {...args} project={project} />
return <ShareProjectModal {...args} ide={ideWithProject(project)} />
}
export const NonAdminLinkSharingOff = args => {
@ -113,7 +123,13 @@ export const NonAdminLinkSharingOff = args => {
publicAccesLevel: 'private'
}
return <ShareProjectModal {...args} isAdmin={false} project={project} />
return (
<ShareProjectModal
{...args}
isAdmin={false}
ide={ideWithProject(project)}
/>
)
}
export const NonAdminLinkSharingOn = args => {
@ -122,7 +138,13 @@ export const NonAdminLinkSharingOn = args => {
publicAccesLevel: 'tokenBased'
}
return <ShareProjectModal {...args} isAdmin={false} project={project} />
return (
<ShareProjectModal
{...args}
isAdmin={false}
ide={ideWithProject(project)}
/>
)
}
export const RestrictedTokenMember = args => {
@ -139,7 +161,7 @@ export const RestrictedTokenMember = args => {
publicAccesLevel: 'tokenBased'
}
return <ShareProjectModal {...args} project={project} />
return <ShareProjectModal {...args} ide={ideWithProject(project)} />
}
export const LegacyLinkSharingReadAndWrite = args => {
@ -150,7 +172,7 @@ export const LegacyLinkSharingReadAndWrite = args => {
publicAccesLevel: 'readAndWrite'
}
return <ShareProjectModal {...args} project={project} />
return <ShareProjectModal {...args} ide={ideWithProject(project)} />
}
export const LegacyLinkSharingReadOnly = args => {
@ -161,7 +183,7 @@ export const LegacyLinkSharingReadOnly = args => {
publicAccesLevel: 'readOnly'
}
return <ShareProjectModal {...args} project={project} />
return <ShareProjectModal {...args} ide={ideWithProject(project)} />
}
export const LimitedCollaborators = args => {
@ -175,7 +197,7 @@ export const LimitedCollaborators = args => {
}
}
return <ShareProjectModal {...args} project={project} />
return <ShareProjectModal {...args} ide={ideWithProject(project)} />
}
const project = {
@ -232,8 +254,7 @@ export default {
animation: false,
isAdmin: true,
user: {},
project,
updateProject: () => null
project
},
argTypes: {
handleHide: { action: 'hide' }

View file

@ -2,12 +2,12 @@ import { expect } from 'chai'
import sinon from 'sinon'
import React from 'react'
import {
act,
cleanup,
render,
screen,
fireEvent,
waitFor
waitFor,
waitForElementToBeRemoved
} from '@testing-library/react'
import fetchMock from 'fetch-mock'
import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
@ -69,12 +69,21 @@ describe('<ShareProjectModal/>', function() {
}
]
const ideWithProject = project => {
return {
$scope: {
$watch: () => () => {},
$applyAsync: () => {},
project
}
}
}
const modalProps = {
ide: ideWithProject(project),
show: true,
isAdmin: true,
project,
handleHide: sinon.stub(),
updateProject: sinon.stub()
handleHide: sinon.stub()
}
const originalExposedSettings = window.ExposedSettings
@ -122,7 +131,7 @@ describe('<ShareProjectModal/>', function() {
render(
<ShareProjectModal
{...modalProps}
project={{ ...project, publicAccesLevel: 'private' }}
ide={ideWithProject({ ...project, publicAccesLevel: 'private' })}
/>
)
@ -141,7 +150,7 @@ describe('<ShareProjectModal/>', function() {
render(
<ShareProjectModal
{...modalProps}
project={{ ...project, publicAccesLevel: 'tokenBased' }}
ide={ideWithProject({ ...project, publicAccesLevel: 'tokenBased' })}
/>
)
@ -158,7 +167,7 @@ describe('<ShareProjectModal/>', function() {
render(
<ShareProjectModal
{...modalProps}
project={{ ...project, publicAccesLevel: 'readAndWrite' }}
ide={ideWithProject({ ...project, publicAccesLevel: 'readAndWrite' })}
/>
)
@ -172,7 +181,7 @@ describe('<ShareProjectModal/>', function() {
render(
<ShareProjectModal
{...modalProps}
project={{ ...project, publicAccesLevel: 'readOnly' }}
ide={ideWithProject({ ...project, publicAccesLevel: 'readOnly' })}
/>
)
@ -195,7 +204,11 @@ describe('<ShareProjectModal/>', function() {
const { rerender } = render(
<ShareProjectModal
{...modalProps}
project={{ ...project, invites, publicAccesLevel: 'tokenBased' }}
ide={ideWithProject({
...project,
invites,
publicAccesLevel: 'tokenBased'
})}
isAdmin
/>
)
@ -207,7 +220,11 @@ describe('<ShareProjectModal/>', function() {
rerender(
<ShareProjectModal
{...modalProps}
project={{ ...project, invites, publicAccesLevel: 'tokenBased' }}
ide={ideWithProject({
...project,
invites,
publicAccesLevel: 'tokenBased'
})}
isAdmin={false}
/>
)
@ -222,17 +239,21 @@ describe('<ShareProjectModal/>', function() {
.null
expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null
// render as non-admin, link sharing off: actions should be missing and message should be present
// render as non-admin (non-owner), link sharing off: actions should be missing and message should be present
rerender(
<ShareProjectModal
{...modalProps}
project={{ ...project, invites, publicAccesLevel: 'private' }}
ide={ideWithProject({
...project,
invites,
publicAccesLevel: 'private'
})}
isAdmin={false}
/>
)
await screen.findByText(
'To add more collaborators or turn on link sharing, please ask the project owner'
'To change access permissions, please ask the project owner'
)
expect(screen.queryByRole('button', { name: 'Turn off link sharing' })).to
@ -248,7 +269,7 @@ describe('<ShareProjectModal/>', function() {
render(
<ShareProjectModal
{...modalProps}
project={{ ...project, publicAccesLevel: 'tokenBased' }}
ide={ideWithProject({ ...project, publicAccesLevel: 'tokenBased' })}
/>
)
@ -296,12 +317,12 @@ describe('<ShareProjectModal/>', function() {
render(
<ShareProjectModal
{...modalProps}
project={{
ide={ideWithProject({
...project,
members,
invites,
publicAccesLevel: 'tokenBased'
}}
})}
/>
)
@ -340,11 +361,11 @@ describe('<ShareProjectModal/>', function() {
render(
<ShareProjectModal
{...modalProps}
project={{
ide={ideWithProject({
...project,
invites,
publicAccesLevel: 'tokenBased'
}}
})}
/>
)
@ -353,11 +374,9 @@ describe('<ShareProjectModal/>', function() {
})
const resendButton = screen.getByRole('button', { name: 'Resend' })
fireEvent.click(resendButton)
await act(async () => {
await fireEvent.click(resendButton)
expect(closeButton.disabled).to.be.true
})
await waitFor(() => expect(closeButton.disabled).to.be.true)
expect(fetchMock.done()).to.be.true
expect(closeButton.disabled).to.be.false
@ -377,11 +396,11 @@ describe('<ShareProjectModal/>', function() {
render(
<ShareProjectModal
{...modalProps}
project={{
ide={ideWithProject({
...project,
invites,
publicAccesLevel: 'tokenBased'
}}
})}
/>
)
@ -390,11 +409,8 @@ describe('<ShareProjectModal/>', function() {
})
const revokeButton = screen.getByRole('button', { name: 'Revoke' })
await act(async () => {
await fireEvent.click(revokeButton)
expect(closeButton.disabled).to.be.true
})
fireEvent.click(revokeButton)
await waitFor(() => expect(closeButton.disabled).to.be.true)
expect(fetchMock.done()).to.be.true
expect(closeButton.disabled).to.be.false
@ -414,11 +430,11 @@ describe('<ShareProjectModal/>', function() {
render(
<ShareProjectModal
{...modalProps}
project={{
ide={ideWithProject({
...project,
members,
publicAccesLevel: 'tokenBased'
}}
})}
/>
)
@ -433,13 +449,11 @@ describe('<ShareProjectModal/>', function() {
const changeButton = screen.getByRole('button', { name: 'Change' })
await act(async () => {
await fireEvent.click(changeButton)
expect(closeButton.disabled).to.be.true
fireEvent.click(changeButton)
await waitFor(() => expect(closeButton.disabled).to.be.true)
const { body } = fetchMock.lastOptions()
expect(JSON.parse(body)).to.deep.equal({ privilegeLevel: 'readAndWrite' })
})
const { body } = fetchMock.lastOptions()
expect(JSON.parse(body)).to.deep.equal({ privilegeLevel: 'readAndWrite' })
expect(fetchMock.done()).to.be.true
expect(closeButton.disabled).to.be.false
@ -459,11 +473,11 @@ describe('<ShareProjectModal/>', function() {
render(
<ShareProjectModal
{...modalProps}
project={{
ide={ideWithProject({
...project,
members,
publicAccesLevel: 'tokenBased'
}}
})}
/>
)
@ -473,13 +487,16 @@ describe('<ShareProjectModal/>', function() {
name: 'Remove from project'
})
act(() => {
removeButton.click()
})
fireEvent.click(removeButton)
const url = fetchMock.lastUrl()
expect(url).to.equal('/project/test-project/users/member-viewer')
expect(fetchMock.done()).to.be.true
// TODO: once the project data is updated, assert that the member has been removed
await waitForElementToBeRemoved(() =>
screen.queryByText('member-viewer@example.com')
)
})
it('changes member privileges to owner with confirmation', async function() {
@ -496,11 +513,11 @@ describe('<ShareProjectModal/>', function() {
render(
<ShareProjectModal
{...modalProps}
project={{
ide={ideWithProject({
...project,
members,
publicAccesLevel: 'tokenBased'
}}
})}
/>
)
@ -519,20 +536,16 @@ describe('<ShareProjectModal/>', function() {
)
})
const confirmButton = screen.getByRole('button', {
name: 'Change owner',
hidden: true
})
const reloadStub = sinon.stub(locationModule, 'reload')
await act(async () => {
await fireEvent.click(confirmButton)
expect(confirmButton.disabled).to.be.true
const { body } = fetchMock.lastOptions()
expect(JSON.parse(body)).to.deep.equal({ user_id: 'member-viewer' })
const confirmButton = screen.getByRole('button', {
name: 'Change owner'
})
fireEvent.click(confirmButton)
await waitFor(() => expect(confirmButton.disabled).to.be.true)
const { body } = fetchMock.lastOptions()
expect(JSON.parse(body)).to.deep.equal({ user_id: 'member-viewer' })
expect(fetchMock.done()).to.be.true
expect(reloadStub.calledOnce).to.be.true
@ -540,39 +553,13 @@ describe('<ShareProjectModal/>', function() {
})
it('sends invites to input email addresses', async function() {
// TODO: can't use this as the value of useProjectContext doesn't get updated
// let mergedProject = {
// ...project,
// publicAccesLevel: 'tokenBased'
// }
//
// const updateProject = value => {
// mergedProject = { ...mergedProject, ...value }
//
// rerender(
// <ShareProjectModal
// {...modalProps}
// project={mergedProject}
// updateProject={updateProject}
// />
// )
// }
//
// const { rerender } = render(
// <ShareProjectModal
// {...modalProps}
// project={mergedProject}
// updateProject={updateProject}
// />
// )
render(
<ShareProjectModal
{...modalProps}
project={{
ide={ideWithProject({
...project,
publicAccesLevel: 'tokenBased'
}}
})}
/>
)
@ -661,13 +648,13 @@ describe('<ShareProjectModal/>', function() {
render(
<ShareProjectModal
{...modalProps}
project={{
ide={ideWithProject({
...project,
publicAccesLevel: 'tokenBased',
features: {
collaborators: 0
}
}}
})}
/>
)
@ -684,10 +671,10 @@ describe('<ShareProjectModal/>', function() {
render(
<ShareProjectModal
{...modalProps}
project={{
ide={ideWithProject({
...project,
publicAccesLevel: 'tokenBased'
}}
})}
/>
)
@ -703,13 +690,11 @@ describe('<ShareProjectModal/>', function() {
const submitButton = screen.getByRole('button', { name: 'Share' })
const respondWithError = async function(errorReason) {
await act(async () => {
inputElement.focus()
await fireEvent.change(inputElement, {
target: { value: 'invited-author-1@example.com' }
})
inputElement.blur()
inputElement.focus()
fireEvent.change(inputElement, {
target: { value: 'invited-author-1@example.com' }
})
inputElement.blur()
fetchMock.postOnce(
'express:/project/:projectId/invite',
@ -748,5 +733,45 @@ describe('<ShareProjectModal/>', function() {
)
})
// TODO: add test for switching between token-access and private, once project data is in React context
it('handles switching between access levels', async function() {
fetchMock.post('express:/project/:projectId/settings/admin', 204)
render(
<ShareProjectModal
{...modalProps}
ide={ideWithProject({ ...project, publicAccesLevel: 'private' })}
/>
)
await screen.findByText(
'Link sharing is off, only invited users can view this project.'
)
const enableButton = await screen.findByRole('button', {
name: 'Turn on link sharing'
})
fireEvent.click(enableButton)
await waitFor(() => expect(enableButton.disabled).to.be.true)
const { body: tokenBody } = fetchMock.lastOptions()
expect(JSON.parse(tokenBody)).to.deep.equal({
publicAccessLevel: 'tokenBased'
})
await screen.findByText('Link sharing is on')
const disableButton = await screen.findByRole('button', {
name: 'Turn off link sharing'
})
fireEvent.click(disableButton)
await waitFor(() => expect(disableButton.disabled).to.be.true)
const { body: privateBody } = fetchMock.lastOptions()
expect(JSON.parse(privateBody)).to.deep.equal({
publicAccessLevel: 'private'
})
await screen.findByText(
'Link sharing is off, only invited users can view this project.'
)
})
})