Skip to content

Commit

Permalink
Merge pull request #4525 from gemini-hlsw/sc-4461-allow-read-only-co-…
Browse files Browse the repository at this point in the history
…investigators-to-edit
  • Loading branch information
toddburnside authored Jan 28, 2025
2 parents db06a09 + 9f7aec0 commit 19b7745
Show file tree
Hide file tree
Showing 4 changed files with 92 additions and 49 deletions.
4 changes: 3 additions & 1 deletion explore/src/main/scala/explore/programs/SupportUsers.scala
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ object SupportUsers:
props.title
),
ProgramUsersTable(
None, // user id is not needed as it is used to determine if a readonly CoI can edit their info
props.users,
NonEmptySet.one(props.supportRole.role),
readonly = true,
proposalIsReadonly = true, // the support users are not editable at all
userIsReadonlyCoi = true, // the support users are not editable at all
hiddenColumns = Set(
ProgramUsersTable.Column.Partner,
ProgramUsersTable.Column.EducationalStatus,
Expand Down
52 changes: 31 additions & 21 deletions explore/src/main/scala/explore/proposal/ProposalEditor.scala
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,20 @@ import monocle.Iso
import queries.common.ProposalQueriesGQL

case class ProposalEditor(
programId: Program.Id,
optUserId: Option[User.Id],
undoCtx: UndoContext[ProgramDetails],
proposal: UndoSetter[Proposal],
timeEstimateRange: Pot[Option[ProgramTimeRange]],
users: View[List[ProgramUser]],
attachments: View[AttachmentList],
authToken: Option[NonEmptyString],
cfps: List[CallForProposal],
layout: LayoutsMap,
readonly: Boolean
) extends ReactFnProps(ProposalEditor.Component)
programId: Program.Id,
optUserId: Option[User.Id],
undoCtx: UndoContext[ProgramDetails],
proposal: UndoSetter[Proposal],
timeEstimateRange: Pot[Option[ProgramTimeRange]],
users: View[List[ProgramUser]],
attachments: View[AttachmentList],
authToken: Option[NonEmptyString],
cfps: List[CallForProposal],
layout: LayoutsMap,
proposalIsReadonly: Boolean,
userIsReadonlyCoi: Boolean
) extends ReactFnProps(ProposalEditor.Component):
val proposalOrUserIsReadonly: Boolean = proposalIsReadonly || userIsReadonlyCoi

object ProposalEditor:

Expand Down Expand Up @@ -128,9 +130,9 @@ object ProposalEditor:
proposalAligner,
props.timeEstimateRange,
props.cfps,
props.readonly
props.proposalOrUserIsReadonly
),
(_, s) => ProposalDetailsTitle(props.undoCtx, s, props.readonly)
(_, s) => ProposalDetailsTitle(props.undoCtx, s, props.proposalOrUserIsReadonly)
)

val usersTile =
Expand All @@ -140,13 +142,15 @@ object ProposalEditor:
)(
_ =>
ProgramUsersTable(
props.optUserId,
props.users,
NonEmptySet.of(ProgramUserRole.Pi, ProgramUserRole.Coi, ProgramUserRole.CoiRO),
props.readonly
props.proposalIsReadonly,
props.userIsReadonlyCoi
),
(_, _) =>
Option
.unless[VdomNode](props.readonly):
.unless[VdomNode](props.proposalOrUserIsReadonly):
<.div(
ExploreStyles.AddProgramUserButton,
AddProgramUserButton(props.programId,
Expand Down Expand Up @@ -183,19 +187,25 @@ object ProposalEditor:
id = "abstract".refined,
value = abstractView.as(OptionNonEmptyStringIso),
onTextChange = t => abstractCounter.setState(t.wordCount).rateLimitMs(1000).void
)(^.disabled := props.readonly,
)(^.disabled := props.proposalOrUserIsReadonly,
^.cls := ExploreStyles.WarningInput.when_(abstractView.get.isEmpty).htmlClass
)
)

val attachmentsTile =
Tile(ProposalTabTileIds.AttachmentsId.id,
"Attachments",
tileClass = ExploreStyles.ProposalAttachmentsTile
Tile(
ProposalTabTileIds.AttachmentsId.id,
"Attachments",
tileClass = ExploreStyles.ProposalAttachmentsTile
)(
_ =>
props.authToken.map(token =>
ProposalAttachmentsTable(props.programId, token, props.attachments, props.readonly)
ProposalAttachmentsTable(
props.programId,
token,
props.attachments,
props.proposalOrUserIsReadonly
)
),
(_, _) =>
<.a(^.href := Constants.P1TemplatesUrl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ import lucuma.ui.LucumaStyles
import lucuma.ui.Resources
import lucuma.ui.components.LoginStyles
import lucuma.ui.primereact.*
import lucuma.ui.reusability.given
import lucuma.ui.sso.UserVault
import org.typelevel.log4cats.Logger
import queries.common.ProposalQueriesGQL.*
Expand Down Expand Up @@ -91,14 +90,14 @@ object ProposalTabContents:

private val component = ScalaFnComponent[Props]: props =>
for {
ctx <- useContext(AppContext.ctx)
readonly <-
useMemo((props.programDetails.get.proposalStatus, props.userIsReadonlyCoi)):
(status, roCoi) =>
status === ProposalStatus.Submitted || status === ProposalStatus.Accepted || roCoi
ctx <- useContext(AppContext.ctx)
} yield
import ctx.given

val proposalStatus = props.programDetails.get.proposalStatus
val proposalIsReadonly =
proposalStatus === ProposalStatus.Submitted || proposalStatus === ProposalStatus.Accepted

val undoCtx: UndoContext[ProgramDetails] =
UndoContext(props.undoStacks, props.programDetails)

Expand Down Expand Up @@ -161,7 +160,8 @@ object ProposalTabContents:
props.userVault.map(_.token),
props.cfps,
props.layout,
readonly
proposalIsReadonly,
props.userIsReadonlyCoi
),
ProposalSubmissionBar(
props.programId,
Expand Down
71 changes: 51 additions & 20 deletions explore/src/main/scala/explore/users/ProgramUsersTable.scala
Original file line number Diff line number Diff line change
Expand Up @@ -59,23 +59,33 @@ import queries.common.InvitationQueriesGQL.RevokeInvitationMutation
import queries.common.ProposalQueriesGQL.DeleteProgramUser

case class ProgramUsersTable(
users: View[List[ProgramUser]],
filterRoles: NonEmptySet[ProgramUserRole],
readonly: Boolean,
hiddenColumns: Set[ProgramUsersTable.Column] = Set.empty
) extends ReactFnProps(ProgramUsersTable.component)
userId: Option[User.Id],
users: View[List[ProgramUser]],
filterRoles: NonEmptySet[ProgramUserRole],
proposalIsReadonly: Boolean,
userIsReadonlyCoi: Boolean,
hiddenColumns: Set[ProgramUsersTable.Column] = Set.empty
) extends ReactFnProps(ProgramUsersTable.component):
val proposalOrUserIsReadonly: Boolean = proposalIsReadonly || userIsReadonlyCoi

object ProgramUsersTable:
private type Props = ProgramUsersTable

private case class TableMeta(
userId: Option[User.Id],
users: View[List[ProgramUser]],
readOnly: Boolean,
proposalIsReadonly: Boolean,
userIsReadonlyCoi: Boolean,
isActive: View[IsActive],
overlayPanelRef: OverlayPanelRef,
createInviteStatus: View[CreateInviteStatus],
currentProgUser: View[Option[View[ProgramUser]]]
)
):
val proposalOrUserIsReadonly: Boolean = proposalIsReadonly || userIsReadonlyCoi
// for the user specific fields - readonly COIs can edit their own.
def currentUserCanEdit(pu: ProgramUser): Boolean =
!proposalIsReadonly &&
(!userIsReadonlyCoi || (userId, pu.user).mapN((uid, p) => uid === p.id).getOrElse(false))

private val ColDef = ColumnDef.WithTableMeta[View[ProgramUser], TableMeta]

Expand Down Expand Up @@ -185,7 +195,7 @@ object ProgramUsersTable:
Button(
severity = Button.Severity.Secondary,
disabled =
tableMeta.readOnly || tableMeta.isActive.get.value || tableMeta.createInviteStatus.get == CreateInviteStatus.Running,
tableMeta.proposalOrUserIsReadonly || tableMeta.isActive.get.value || tableMeta.createInviteStatus.get == CreateInviteStatus.Running,
icon = Icons.PaperPlaneTop,
tooltip = s"Send invitation",
tooltipOptions = TooltipOptions.Left,
Expand Down Expand Up @@ -253,7 +263,7 @@ object ProgramUsersTable:
// In explore, we'll always set the credit name, but a user could have
// updated the fallback profile via the API, and we won't mess with that.
if (
!meta.readOnly && pu.user.isEmpty && pu.fallbackProfile.creditName === pu.fallbackProfile.displayName
!meta.proposalIsReadonly && pu.user.isEmpty && pu.fallbackProfile.creditName === pu.fallbackProfile.displayName
)
val view = c.value
.zoom(ProgramUser.fallbackCreditName)
Expand All @@ -276,9 +286,10 @@ object ProgramUsersTable:
c.table.options.meta.map: meta =>
val pu: ProgramUser = c.value.get
val programUserId: ProgramUser.Id = pu.id
val canEdit = meta.currentUserCanEdit(pu)
// We'll allow editing the email if there is no REAL user
// or the real email address is empty
if (!meta.readOnly && pu.user.flatMap(_.profile).flatMap(_.email).isEmpty)
if (canEdit && pu.user.flatMap(_.profile).flatMap(_.email).isEmpty)
val view = c.value
.zoom(ProgramUser.fallbackEmail)
.withOnMod(oe =>
Expand Down Expand Up @@ -308,8 +319,9 @@ object ProgramUsersTable:
cell.get.partnerLink.flatMap:
case PartnerLink.HasUnspecifiedPartner => None
case p => Some(p)
val canEdit = meta.currentUserCanEdit(cell.get)

partnerSelector(pl, usersView.set, meta.readOnly || meta.isActive.get.value)
partnerSelector(pl, usersView.set, !canEdit || meta.isActive.get.value)
).sortableBy(_.get.toString),
ColDef(
Column.EducationalStatus.id,
Expand All @@ -323,6 +335,7 @@ object ProgramUsersTable:
val view: View[Option[EducationalStatus]] =
c.value.withOnMod: es =>
updateUserES[IO](programUserId, es).runAsync
val canEdit = meta.currentUserCanEdit(cell.get)

EnumDropdownOptionalView(
id = "es".refined,
Expand All @@ -331,7 +344,7 @@ object ProgramUsersTable:
itemTemplate = _.value.shortName,
valueTemplate = _.value.shortName,
emptyMessageTemplate = "No Selection",
disabled = meta.readOnly || meta.isActive.get.value,
disabled = !canEdit || meta.isActive.get.value,
clazz = ExploreStyles.PartnerSelector
)
).sortableBy(_.get.toString),
Expand All @@ -343,12 +356,14 @@ object ProgramUsersTable:
val programUserId: ProgramUser.Id = cell.get.id

c.table.options.meta.map: meta =>
val view = c.value
val view = c.value
.withOnMod(th => updateUserThesis[IO](programUserId, th).runAsync)
val canEdit = meta.currentUserCanEdit(cell.get)

Checkbox(
id = "thesis",
checked = view.get.getOrElse(false),
disabled = meta.readOnly || meta.isActive.get.value,
disabled = !canEdit || meta.isActive.get.value,
onChange = r => view.set(r.some)
)
,
Expand All @@ -362,16 +377,18 @@ object ProgramUsersTable:
val programUserId: ProgramUser.Id = cell.get.id

c.table.options.meta.map: meta =>
val view = c.value
val view = c.value
.withOnMod(th => updateUserGender[IO](programUserId, th).runAsync)
val canEdit = meta.currentUserCanEdit(cell.get)

EnumOptionalDropdown[Gender](
id = "gender".refined,
value = view.get,
showClear = true,
itemTemplate = _.value.shortName,
valueTemplate = _.value.shortName,
emptyMessageTemplate = "No Selection",
disabled = meta.readOnly || meta.isActive.get.value,
disabled = !canEdit || meta.isActive.get.value,
clazz = ExploreStyles.PartnerSelector,
onChange = view.set
)
Expand Down Expand Up @@ -401,13 +418,23 @@ object ProgramUsersTable:
val status = programUserView.get.status

<.span(
deleteUserButton(programUserId, meta.users, meta.isActive, meta.readOnly)
deleteUserButton(
programUserId,
meta.users,
meta.isActive,
meta.proposalOrUserIsReadonly
)
.unless(role === ProgramUserRole.Pi), // don't allow removing the PI
inviteUserButton(programUserView, meta)
.when(status === ProgramUser.Status.NotInvited),
programUserView.get.activeInvitation
.map(invitation =>
revokeInvitationButton(programUserView, invitation, meta.isActive, meta.readOnly)
revokeInvitationButton(
programUserView,
invitation,
meta.isActive,
meta.proposalOrUserIsReadonly
)
.when(status.isInvited)
)
.orEmpty
Expand Down Expand Up @@ -441,8 +468,10 @@ object ProgramUsersTable:
getRowId = (row, _, _) => RowId(row.get.id.toString),
enableSorting = true,
meta = TableMeta(
props.userId,
props.users,
props.readonly,
props.proposalIsReadonly,
props.userIsReadonlyCoi,
isActive,
overlayPanelRef,
createInviteStatus,
Expand All @@ -451,7 +480,9 @@ object ProgramUsersTable:
state = PartialTableState(
columnVisibility = ColumnVisibility(
(props.hiddenColumns.map(_.id -> Visibility.Hidden) +
(Column.Actions.id -> Visibility.fromVisible(!props.readonly))).toMap
(Column.Actions.id -> Visibility.fromVisible(
!props.proposalOrUserIsReadonly
))).toMap
)
)
)
Expand Down

0 comments on commit 19b7745

Please sign in to comment.