Skip to content

Commit

Permalink
Surveys: allow changing owner from surveys list (only system admins) (#…
Browse files Browse the repository at this point in the history
…3598)

* Added EditableColumn component

* added SurveyOwnerColumn component

* layout adjustments

* layout adjustments

* code cleanup

* surveys table: make selected row editable

---------

Co-authored-by: Stefano Ricci <[email protected]>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Oct 16, 2024
1 parent 026b048 commit f0aa7f7
Show file tree
Hide file tree
Showing 22 changed files with 309 additions and 93 deletions.
1 change: 1 addition & 0 deletions common/activityLog/activityLog.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as ObjectUtils from '@core/objectUtils'
export const type = {
// Survey
surveyCreate: 'surveyCreate',
surveyOwnerUpdate: 'surveyOwnerUpdate',
surveyPropUpdate: 'surveyPropUpdate',
surveyPublish: 'surveyPublish',
surveyUnpublish: 'surveyUnpublish',
Expand Down
1 change: 1 addition & 0 deletions core/auth/authorizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const canViewTemplates = (user) => User.isSystemAdmin(user)
// UPDATE
export const canEditSurvey = _hasSurveyPermission(permissions.surveyEdit)
export const canEditSurveyConfig = (user) => User.isSystemAdmin(user)
export const canEditSurveyOwner = (user) => User.isSystemAdmin(user)
export const canEditTemplates = (user) => User.isSystemAdmin(user)
export const canRefreshAllSurveyRdbs = (user) => User.isSystemAdmin(user)

Expand Down
1 change: 1 addition & 0 deletions core/i18n/resources/en/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,7 @@ Merge cannot be performed.`,

surveysView: {
chains: 'Chains',
confirmUpdateSurveyOwner: `Change the owner of the survey "{{surveyName}}" into "{{ownerName}}"?`,
cycles: 'Cycles',
datePublished: 'Date published',
filter: 'Filter',
Expand Down
1 change: 1 addition & 0 deletions server/modules/auth/authApiMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export const requireSurveyCreatePermission = async (req, _res, next) => {
export const requireSurveyViewPermission = requireSurveyPermission(Authorizer.canViewSurvey)
export const requireSurveyEditPermission = requireSurveyPermission(Authorizer.canEditSurvey)
export const requireSurveyConfigEditPermission = requireSurveyPermission(Authorizer.canEditSurveyConfig)
export const requireSurveyOwnerEditPermission = requireSurveyPermission(Authorizer.canEditSurveyOwner)
export const requireRecordCleansePermission = requireSurveyPermission(Authorizer.canCleanseRecords)
export const requireSurveyRdbRefreshPermission = requirePermission(Authorizer.canRefreshAllSurveyRdbs)
export const requireCanExportSurveysList = requirePermission(Authorizer.canExportSurveysList)
Expand Down
11 changes: 11 additions & 0 deletions server/modules/survey/api/surveyApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,17 @@ export const init = (app) => {
}
})

app.put('/survey/:surveyId/owner', AuthMiddleware.requireSurveyOwnerEditPermission, async (req, res, next) => {
try {
const user = Request.getUser(req)
const { surveyId, ownerUuid } = Request.getParams(req)
await SurveyService.updateSurveyOwner({ user, surveyId, ownerUuid })
Response.sendOk(res)
} catch (error) {
next(error)
}
})

// ==== DELETE

app.delete('/survey/:surveyId', AuthMiddleware.requireSurveyEditPermission, async (req, res, next) => {
Expand Down
14 changes: 14 additions & 0 deletions server/modules/survey/manager/surveyManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,20 @@ export const updateSurveyConfigurationProp = async ({ surveyId, key, value }, cl
return fetchSurveyById({ surveyId, draft: true, validate: true }, client)
}

export const updateSurveyOwner = async ({ user, surveyId, ownerUuid }, client = db) =>
client.tx(async (t) => {
await SurveyRepository.updateSurveyOwner({ surveyId, ownerUuid }, t)
const systemActivity = false
await ActivityLogRepository.insert(
user,
surveyId,
ActivityLog.type.surveyOwnerUpdate,
{ ownerUuid },
systemActivity,
t
)
})

export const { removeSurveyTemporaryFlag, updateSurveyDependencyGraphs } = SurveyRepository

// ====== DELETE
Expand Down
8 changes: 8 additions & 0 deletions server/modules/survey/repository/surveyRepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,14 @@ export const updateSurveyProp = async (surveyId, key, value, client = db) => {
)
}

export const updateSurveyOwner = async ({ surveyId, ownerUuid }, client = db) =>
client.none(
`UPDATE survey
SET owner_uuid = $/ownerUuid/
WHERE id = $/surveyId/`,
{ surveyId, ownerUuid }
)

export const publishSurveyProps = async (surveyId, client = db) =>
client.none(
`
Expand Down
1 change: 1 addition & 0 deletions server/modules/survey/service/surveyService.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export const {
updateSurveyDependencyGraphs,
updateSurveyProps,
updateSurveyConfigurationProp,
updateSurveyOwner,
// DELETE
deleteTemporarySurveys,
// UTILS
Expand Down
4 changes: 2 additions & 2 deletions server/modules/user/api/userApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,9 @@ export const init = (app) => {

app.get('/users', AuthMiddleware.requireUsersAllViewPermission, async (req, res, next) => {
try {
const { offset, limit, sortBy, sortOrder } = Request.getParams(req)
const { offset, onlyAccepted, limit, sortBy, sortOrder } = Request.getParams(req)

const list = await UserService.fetchUsers({ offset, limit, sortBy, sortOrder })
const list = await UserService.fetchUsers({ offset, onlyAccepted, limit, sortBy, sortOrder })

res.json({ list })
} catch (error) {
Expand Down
4 changes: 2 additions & 2 deletions server/modules/user/manager/userManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,9 @@ export const fetchUserByUuid = _userFetcher(UserRepository.fetchUserByUuid)

export const fetchUserByUuidWithPassword = _userFetcher(UserRepository.fetchUserByUuidWithPassword)

export const fetchUsers = async ({ offset, limit, sortBy, sortOrder }, client = db) =>
export const fetchUsers = async ({ offset, onlyAccepted = false, limit, sortBy, sortOrder }, client = db) =>
client.tx(async (t) => {
const users = (await UserRepository.fetchUsers({ offset, limit, sortBy, sortOrder }, t)).map(
const users = (await UserRepository.fetchUsers({ offset, onlyAccepted, limit, sortBy, sortOrder }, t)).map(
User.dissocPrivateProps
)
return _attachAuthGroupsAndInvitationToUsers({ users, t })
Expand Down
30 changes: 18 additions & 12 deletions server/modules/user/repository/userRepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,14 @@ export const countUsersBySurveyId = async (surveyId, countSystemAdmins = false,
`
SELECT count(*)
FROM "user" u
JOIN survey s
ON s.id = $1
JOIN auth_group_user gu
ON gu.user_uuid = u.uuid
JOIN auth_group g
ON g.uuid = gu.group_uuid
AND g.survey_uuid = s.uuid
AND g.name <> '${AuthGroup.groupNames.systemAdmin}'`,
JOIN survey s
ON s.id = $1
JOIN auth_group_user gu
ON gu.user_uuid = u.uuid
JOIN auth_group g
ON g.uuid = gu.group_uuid
AND g.survey_uuid = s.uuid
AND g.name <> '${AuthGroup.groupNames.systemAdmin}'`,
[surveyId, countSystemAdmins],
(row) => Number(row.count)
)
Expand Down Expand Up @@ -121,16 +121,19 @@ const getUsersSelectQueryPrefix = ({ includeSurveys = false }) => `
`

const _usersSelectQuery = ({
onlyAccepted = false,
selectFields,
sortBy = userSortBy.email,
sortOrder = DbOrder.asc,
includeSurveys = false,
whereConditions = [],
}) => {
// check sort by parameters
const orderBy = orderByFieldBySortBy[sortBy] ?? 'email'
const orderByDirection = DbOrder.normalize(sortOrder)

const whereConditions = []
if (onlyAccepted) {
whereConditions.push(`u.status = '${User.userStatus.ACCEPTED}'`)
}
const whereClause = whereConditions?.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : ''

return `${getUsersSelectQueryPrefix({ includeSurveys })}
Expand Down Expand Up @@ -160,9 +163,12 @@ const _usersSelectQuery = ({
ORDER BY ${orderBy} ${orderByDirection} NULLS LAST`
}

export const fetchUsers = async ({ offset = 0, limit = null, sortBy = 'email', sortOrder = 'ASC' }, client = db) =>
export const fetchUsers = async (
{ offset = 0, onlyAccepted = false, limit = null, sortBy = 'email', sortOrder = 'ASC' },
client = db
) =>
client.map(
`${_usersSelectQuery({ selectFields, sortBy, sortOrder })}
`${_usersSelectQuery({ onlyAccepted, selectFields, sortBy, sortOrder })}
LIMIT ${limit || 'ALL'}
OFFSET ${offset}`,
[],
Expand Down
87 changes: 87 additions & 0 deletions webapp/components/DataGrid/EditableColumn.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React, { useCallback, useState } from 'react'
import classNames from 'classnames'
import PropTypes from 'prop-types'

import { ButtonIconEdit } from '@webapp/components'
import { KeyboardKeys } from '@webapp/utils/keyboardKeys'

export const EditableColumn = (props) => {
const { canEdit, className, item, renderItem, renderItemEditing } = props

const [state, setState] = useState({ editing: false, hovering: false })
const { editing, hovering } = state

const setHovering = useCallback(
(hoveringNew) => {
if (hoveringNew !== hovering) {
setState((statePrev) => ({ ...statePrev, hovering: hoveringNew }))
}
},
[hovering]
)

const setEditing = useCallback(
(editingNew) => {
if (editingNew !== editing) {
setState((statePrev) => ({ ...statePrev, editing: editingNew }))
}
},
[editing]
)

const onContainerMouseOver = useCallback(() => setHovering(true), [setHovering])
const onContainerMouseLeave = useCallback(() => setHovering(false), [setHovering])

const onContainerClick = useCallback((e) => {
// prevent table row selection on click
e.stopPropagation()
e.preventDefault()
}, [])

const onContainerFocus = useCallback(() => setHovering(true), [setHovering])

const onContainerKeyDown = useCallback(
(e) => {
if (e.key === KeyboardKeys.Space) {
setEditing(true)
}
},
[setEditing]
)

const onEditClick = useCallback(
(e) => {
e.stopPropagation()
e.preventDefault()
setEditing(true)
},
[setEditing]
)

if (!canEdit) {
return <div className={className}>{renderItem({ item })}</div>
}

return (
<div
className={classNames(className, { editing })}
onClick={onContainerClick}
onFocus={onContainerFocus}
onKeyDown={onContainerKeyDown}
onMouseOver={onContainerMouseOver}
onMouseLeave={onContainerMouseLeave}
>
{editing && renderItemEditing({ item })}
{!editing && renderItem({ item })}
{hovering && !editing && <ButtonIconEdit onClick={onEditClick} />}
</div>
)
}

EditableColumn.propTypes = {
canEdit: PropTypes.bool,
className: PropTypes.string,
item: PropTypes.object.isRequired,
renderItem: PropTypes.func.isRequired,
renderItemEditing: PropTypes.func.isRequired,
}
64 changes: 64 additions & 0 deletions webapp/components/survey/Surveys/SurveyOwnerColumn.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { useCallback } from 'react'
import { useDispatch } from 'react-redux'
import PropTypes from 'prop-types'

import * as Survey from '@core/survey/survey'
import * as User from '@core/user/user'

import * as API from '@webapp/service/api'
import { useAuthCanEditSurveyOwner } from '@webapp/store/user/hooks'
import { DialogConfirmActions } from '@webapp/store/ui'
import { EditableColumn } from '@webapp/components/DataGrid/EditableColumn'

import { SurveyOwnerDropdown } from './SurveyOwnerDropdown'

export const SurveyOwnerColumn = (props) => {
const { item: surveyInfo, onSurveysUpdate } = props

const dispatch = useDispatch()

const surveyId = Survey.getId(surveyInfo)
const ownerUuid = Survey.getOwnerUuid(surveyInfo)
const surveyName = Survey.getName(surveyInfo)

const canEdit = useAuthCanEditSurveyOwner()

const onChangeConfirmed = useCallback(
async ({ selectedOwnerUuid }) => {
await API.updateSurveyOwner({ surveyId, ownerUuid: selectedOwnerUuid })
await onSurveysUpdate?.()
},
[onSurveysUpdate, surveyId]
)

const onChange = useCallback(
async (selectedOwner) => {
const selectedOwnerUuid = User.getUuid(selectedOwner)
if (selectedOwnerUuid !== ownerUuid) {
dispatch(
DialogConfirmActions.showDialogConfirm({
key: 'surveysView.confirmUpdateSurveyOwner',
params: { surveyName, ownerName: User.getName(selectedOwner) },
onOk: async () => onChangeConfirmed({ selectedOwnerUuid }),
})
)
}
},
[dispatch, onChangeConfirmed, ownerUuid, surveyName]
)

return (
<EditableColumn
canEdit={canEdit}
className="owner-col"
item={surveyInfo}
renderItem={({ item }) => Survey.getOwnerName(item)}
renderItemEditing={() => <SurveyOwnerDropdown selectedUuid={ownerUuid} onChange={onChange} />}
/>
)
}

SurveyOwnerColumn.propTypes = {
item: PropTypes.object.isRequired,
onSurveysUpdate: PropTypes.func,
}
55 changes: 55 additions & 0 deletions webapp/components/survey/Surveys/SurveyOwnerDropdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import PropTypes from 'prop-types'

import { Objects } from '@openforis/arena-core'

import * as User from '@core/user/user'

import { Dropdown } from '@webapp/components/form'
import * as API from '@webapp/service/api'
import { useI18n } from '@webapp/store/system'
import { useUserIsSystemAdmin } from '@webapp/store/user'

const ownerLabelFunction = (user) => [User.getName(user), User.getEmail(user)].filter(Objects.isNotEmpty).join(' - ')

export const SurveyOwnerDropdown = (props) => {
const { selectedUuid, onChange } = props

const i18n = useI18n()
const isSystemAdmin = useUserIsSystemAdmin()

const [state, setState] = useState({ loading: true, users: [] })

const { users } = state
const selectedUser = useMemo(() => users.find((user) => User.getUuid(user) === selectedUuid), [selectedUuid, users])

const fetchUsers = useCallback(async () => {
const usersFetched = await API.fetchUsers({
onlyAccepted: true,
includeSystemAdmins: isSystemAdmin,
})
setState({ loading: false, users: usersFetched })
}, [isSystemAdmin])

useEffect(() => {
fetchUsers()
}, [fetchUsers])

return (
<Dropdown
className="width100"
clearable={false}
items={users}
itemValue={User.keys.uuid}
itemLabel={ownerLabelFunction}
onChange={onChange}
placeholder={i18n.t('common.owner')}
selection={selectedUser}
/>
)
}

SurveyOwnerDropdown.propTypes = {
selectedUuid: PropTypes.string,
onChange: PropTypes.func.isRequired,
}
Loading

0 comments on commit f0aa7f7

Please sign in to comment.