Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

"User groups" settings page #9081

Merged
merged 82 commits into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
e7c7916
WIP: "Member roles" settings page
somebody1234 Feb 16, 2024
3adf187
Add backend endpoint; initial implementation of new settings page
somebody1234 Feb 16, 2024
508319e
Minor refactors
somebody1234 Feb 16, 2024
5f826f1
Add entry to settings sidebar; minor styling fixes
somebody1234 Feb 16, 2024
27832f3
Add new backend endpoints; fix existing ones to match the actual API
somebody1234 Mar 5, 2024
2227e8c
Rename `Subject` to `UserId`
somebody1234 Mar 5, 2024
9d5d27c
Refactor `UserPermission` into `AssetPermission`
somebody1234 Mar 5, 2024
4488c00
Merge branch 'develop' into wip/sb/groups-settings-page
somebody1234 Mar 6, 2024
2309572
Merge branch 'develop' into wip/sb/groups-settings-page
somebody1234 Mar 12, 2024
e5b77db
Fix type errors
somebody1234 Mar 12, 2024
9be9346
Merge branch 'develop' into wip/sb/groups-settings-page
somebody1234 Mar 18, 2024
0e0ceaf
Merge branch 'develop' into wip/sb/groups-settings-page
somebody1234 Apr 4, 2024
0b45ac8
Add button to add role
somebody1234 Apr 5, 2024
9cdcdde
Fix type of user group
somebody1234 Apr 5, 2024
a75ab9c
Merge branch 'develop' into wip/sb/groups-settings-page
somebody1234 Apr 9, 2024
c642f55
Address FIXME
somebody1234 Apr 9, 2024
b542cf4
Rename "Member Roles" to "User Groups"; fix lint errors
somebody1234 Apr 9, 2024
e30e674
Refactor members list into new component
somebody1234 Apr 9, 2024
f791a94
Switch table to React Aria omponents
somebody1234 Apr 9, 2024
3d5f619
Drag and drop of users onto user groups
somebody1234 Apr 10, 2024
18a1386
Fix backend API wrapper
somebody1234 Apr 10, 2024
f9f2d9f
Show users below user groups in "user groups" settings page; fix "inv…
somebody1234 Apr 10, 2024
9c62f79
Merge branch 'develop' into wip/sb/groups-settings-page
somebody1234 Apr 10, 2024
2a51827
Fix React error
somebody1234 Apr 10, 2024
571013b
Optimistically add users to user groups list
somebody1234 Apr 10, 2024
c89eb50
Optimistic updates for deleting user groups, and removing users from …
somebody1234 Apr 11, 2024
878b460
Optimistic updates for adding user groups
somebody1234 Apr 11, 2024
32ea354
Fix deleting user group
somebody1234 Apr 11, 2024
bfbf819
Add error messages
somebody1234 Apr 11, 2024
26c5be8
Fix styling of delete buttons
somebody1234 Apr 11, 2024
15f7bb3
Prettier
somebody1234 Apr 11, 2024
087342d
Fix type errors
somebody1234 Apr 11, 2024
d6f6c6d
Rename `backendModule` to `backend` in `RemoteBackend.ts`
somebody1234 Apr 11, 2024
bc89856
Fix users only appearing once even if they are in multiple groups
somebody1234 Apr 12, 2024
087d74f
Fix scroll issues on settings pages
somebody1234 Apr 12, 2024
31bac44
Add "delete" modals for deleting user groups and removing users from …
somebody1234 Apr 12, 2024
02583a0
Finish renaming Member Roles to User Groups
somebody1234 Apr 12, 2024
2b9dffb
Move delete user/delete user group/remove user from group button to r…
somebody1234 Apr 12, 2024
3fa92da
Merge branch 'develop' into wip/sb/groups-settings-page
somebody1234 Apr 29, 2024
b7f6b55
Disable tab if user is not in organization
somebody1234 Apr 30, 2024
c8e4f8c
`cursor: grab` for draggable rows in "members" table
somebody1234 Apr 30, 2024
38834bc
Fix width of user groups columns
somebody1234 Apr 30, 2024
d06c7c8
Avoid flickering when dragging onto "user groups" table
somebody1234 Apr 30, 2024
80c3eee
Controlled validation for "new user group" modal
somebody1234 Apr 30, 2024
aafb2ee
Fix drag and drop
somebody1234 Apr 30, 2024
2acbd47
Minor fixes
somebody1234 Apr 30, 2024
b52d10f
WIP: Adjust "user groups" table formatting in preparation for tooltip…
somebody1234 May 1, 2024
0414d9f
Add context menu to users and groups in "user groups" table
somebody1234 May 1, 2024
117d624
Avoid centered modals in "user groups" settings page
somebody1234 May 1, 2024
336072c
Add tooltips to "user groups" settings page, but only for entries tha…
somebody1234 May 1, 2024
ffbe6e8
Adjust "users" table formatting in preparation for tooltips in long n…
somebody1234 May 1, 2024
aa088ea
Extract `UserRow` into a component
somebody1234 May 1, 2024
e79b22d
Add tooltip for user rows in users table
somebody1234 May 1, 2024
c7b8f20
Add context menu to users table
somebody1234 May 1, 2024
85cbebf
Fix some positioned modals
somebody1234 May 1, 2024
c7d871d
Refactor `needsTooltip` into hook
somebody1234 May 1, 2024
f99febf
Refactor manual context menu event handler into a hook
somebody1234 May 1, 2024
7443a19
Fix styles for tooltips
somebody1234 May 1, 2024
3cbf476
Fix crash
somebody1234 May 1, 2024
db3edb9
Fix alignment of usernames in user groups table
somebody1234 May 1, 2024
02fc7fb
Fix overflow in users table in user groups settings tab
somebody1234 May 1, 2024
8ef7d13
Put settings sidebar in popover on narrow screens
somebody1234 May 3, 2024
3da49ec
Populate users table with self initially, when on users groups settin…
somebody1234 May 3, 2024
df86c2d
Alternative single-column layout for User Groups settings page
somebody1234 May 3, 2024
d44b044
Move sidebar burger menu to title
somebody1234 May 3, 2024
2cfa4bf
Fix broken drag on "users" table in "user groups" settings page
somebody1234 May 3, 2024
0309f00
Prettier
somebody1234 May 3, 2024
56b0299
Scroll only table and not heading and buttons
somebody1234 May 6, 2024
0636cae
Clip body of User Groups table to avoid overlapping header
somebody1234 May 6, 2024
504eacd
Move "delete" button left so that it is visible again
somebody1234 May 6, 2024
e12fd63
Add bottom margin to "user groups" table to indicate it is at the end
somebody1234 May 6, 2024
2199401
Replace bottom margin for "user groups" table with shadow
somebody1234 May 6, 2024
296fc21
Ignore whitespace and capitalization when checking for duplicate User…
somebody1234 May 6, 2024
18af060
Avoid using out of date groups list when updating user groups
somebody1234 May 6, 2024
725b755
Add scroll shadows to "members" table
somebody1234 May 6, 2024
2406e00
Prevent header and button bar from scrolling in "members" settings tab
somebody1234 May 6, 2024
8662ae7
Fix lint error
somebody1234 May 6, 2024
b153de5
ellipsis fix
MrFlashAccount May 7, 2024
ac4ef24
fix tooltip
MrFlashAccount May 7, 2024
95629d5
Fix formatting
somebody1234 May 7, 2024
9efec3b
Prettier
somebody1234 May 8, 2024
383f547
Merge branch 'develop' into wip/sb/groups-settings-page
somebody1234 May 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/ide-desktop/lib/assets/burger_menu.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions app/ide-desktop/lib/assets/cross2.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions app/ide-desktop/lib/dashboard/e2e/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,12 @@ export function locateUpsertSecretModal(page: test.Page) {
return page.getByTestId('upsert-secret-modal')
}

/** Find a "new user group" modal (if any) on the current page. */
export function locateNewUserGroupModal(page: test.Page) {
// This has no identifying features.
return page.getByTestId('new-user-group-modal')
}

/** Find a user menu (if any) on the current page. */
export function locateUserMenu(page: test.Page) {
// This has no identifying features.
Expand Down
4 changes: 2 additions & 2 deletions app/ide-desktop/lib/dashboard/e2e/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ export async function mockApi({ page }: MockParams) {
name: defaultUsername,
organizationId: defaultOrganizationId,
userId: defaultUserId,
profilePicture: null,
isEnabled: true,
rootDirectoryId: defaultDirectoryId,
userGroups: null,
}
let currentUser: backend.User | null = defaultUser
let currentOrganization: backend.OrganizationInfo | null = null
Expand Down Expand Up @@ -571,9 +571,9 @@ export async function mockApi({ page }: MockParams) {
name: body.userName,
organizationId,
userId: backend.UserId(`user-${uniqueString.uniqueString()}`),
profilePicture: null,
isEnabled: false,
rootDirectoryId,
userGroups: null,
}
await route.fulfill({ json: currentUser })
} else if (request.method() === 'GET') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,27 @@ import * as tailwindMerge from 'tailwind-merge'
import * as aria from '#/components/aria'
import * as portal from '#/components/Portal'

/** Props for a {@link Tooltip}. */
export interface TooltipProps
extends Omit<Readonly<aria.TooltipProps>, 'offset' | 'UNSTABLE_portalContainer'> {}

const DEFAULT_CLASSES = 'z-1 flex bg-neutral-800 text-white p-2 rounded-md shadow-lg text-xs'
// =================
// === Constants ===
// =================

const DEFAULT_CLASSES =
'flex bg-frame backdrop-blur-default text-primary p-2 rounded-default shadow-soft text-xs'
const DEFAULT_CONTAINER_PADDING = 4
const DEFAULT_OFFSET = 4

// ===============
// === Tooltip ===
// ===============

/** Props for a {@link Tooltip}. */
export interface TooltipProps
extends Omit<Readonly<aria.TooltipProps>, 'offset' | 'UNSTABLE_portalContainer'> {}

/** Displays the description of an element on hover or focus. */
export function Tooltip(props: TooltipProps) {
const { className, containerPadding = DEFAULT_CONTAINER_PADDING, ...ariaTooltipProps } = props

const root = portal.useStrictPortalContext()

const classes = tailwindMerge.twJoin(DEFAULT_CLASSES)

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ interface InternalBaseAutocompleteProps<T> {
readonly type?: React.HTMLInputTypeAttribute
readonly inputRef?: React.MutableRefObject<HTMLInputElement | null>
readonly placeholder?: string
readonly values: T[]
readonly values: readonly T[]
readonly autoFocus?: boolean
/** This may change as the user types in the input. */
readonly items: T[]
readonly items: readonly T[]
readonly itemToKey: (item: T) => string
readonly itemToString: (item: T) => string
readonly itemsToString?: (items: T[]) => string
Expand All @@ -48,8 +48,8 @@ interface InternalMultipleAutocompleteProps<T> extends InternalBaseAutocompleteP
/** This is `null` when multiple values are selected, causing the input to switch to a
* {@link HTMLTextAreaElement}. */
readonly inputRef?: React.MutableRefObject<HTMLInputElement | null>
readonly setValues: (value: T[]) => void
readonly itemsToString: (items: T[]) => string
readonly setValues: (value: readonly T[]) => void
readonly itemsToString: (items: readonly T[]) => string
}

/** {@link AutocompleteProps} when the text cannot be edited. */
Expand Down
10 changes: 5 additions & 5 deletions app/ide-desktop/lib/dashboard/src/components/ContextMenus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface ContextMenusProps extends Readonly<React.PropsWithChildren> {
}

/** A context menu that opens at the current mouse position. */
export default function ContextMenus(props: ContextMenusProps) {
function ContextMenus(props: ContextMenusProps, ref: React.ForwardedRef<HTMLDivElement>) {
const { hidden = false, children, event } = props

return hidden ? (
Expand All @@ -31,10 +31,8 @@ export default function ContextMenus(props: ContextMenusProps) {
>
<div
data-testid="context-menus"
style={{
left: event.pageX,
top: event.pageY,
}}
ref={ref}
style={{ left: event.pageX, top: event.pageY }}
className={`pointer-events-none sticky flex w-min items-start gap-context-menus ${
detect.isOnMacOS()
? 'ml-context-menu-macos-half-x -translate-x-context-menu-macos-half-x'
Expand All @@ -49,3 +47,5 @@ export default function ContextMenus(props: ContextMenusProps) {
</Modal>
)
}

export default React.forwardRef(ContextMenus)
33 changes: 33 additions & 0 deletions app/ide-desktop/lib/dashboard/src/components/FocusableText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/** @file An {@link aria.Text} that is focusable to allow it to be a {@link aria.TooltipTrigger}
* target. */
import * as React from 'react'

import * as aria from '#/components/aria'

// =====================
// === FocusableText ===
// =====================

/** Props for a {@link FocusableText}. */
export interface FocusableTextProps extends Readonly<aria.TextProps> {}

/** An {@link aria.Text} that is focusable to allow it to be a {@link aria.TooltipTrigger}
* target. */
function FocusableText(props: FocusableTextProps, ref: React.ForwardedRef<HTMLElement>) {
// @ts-expect-error This error is caused by `exactOptionalPropertyTypes`.
const [props2, ref2] = aria.useContextProps(props, ref, aria.TextContext)
// @ts-expect-error This error is caused by `exactOptionalPropertyTypes`.
const { focusableProps } = aria.useFocusable(props2, ref2)
const { elementType: ElementType = 'span', ...domProps } = props2
return (
<ElementType
className="react-aria-Text"
{...aria.mergeProps<FocusableTextProps>()(domProps, focusableProps)}
// @ts-expect-error This is required because the dynamic element type is too complex for
// TypeScript to typecheck.
ref={ref2}
/>
)
}

export default React.forwardRef(FocusableText)
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,7 @@ export default function AssetRow(props: AssetRowProps) {
element.focus()
}
}}
className={`h-row rounded-full transition-all ease-in-out ${visibility} ${isDraggedOver || selected ? 'selected' : ''}`}
className={`h-row rounded-full transition-all ease-in-out rounded-rows-child ${visibility} ${isDraggedOver || selected ? 'selected' : ''}`}
onClick={event => {
unsetModal()
onClick(innerProps, event)
Expand Down Expand Up @@ -907,7 +907,7 @@ export default function AssetRow(props: AssetRowProps) {
<tr>
<td colSpan={columns.length} className="border-r p rounded-rows-skip-level">
<div
className={`flex h-row w-container justify-center rounded-full ${indent.indentClass(
className={`flex h-row w-container justify-center rounded-full rounded-rows-child ${indent.indentClass(
item.depth
)}`}
>
Expand All @@ -922,7 +922,7 @@ export default function AssetRow(props: AssetRowProps) {
<tr>
<td colSpan={columns.length} className="border-r p rounded-rows-skip-level">
<div
className={`flex h-row items-center rounded-full ${indent.indentClass(item.depth)}`}
className={`flex h-row items-center rounded-full rounded-rows-child ${indent.indentClass(item.depth)}`}
>
<img src={BlankIcon} />
<aria.Text className="px-name-column-x placeholder">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** @file A user and their permissions for a specific asset. */
/** @file Permissions for a specific user or user group on a specific asset. */
import * as React from 'react'

import type * as text from '#/text'
Expand Down Expand Up @@ -30,48 +30,49 @@ const ASSET_TYPE_TO_TEXT_ID: Readonly<Record<backendModule.AssetType, text.TextI
[backendModule.AssetType.specialLoading]: 'specialLoadingAssetType',
} satisfies { [Type in backendModule.AssetType]: `${Type}AssetType` }

// ======================
// === UserPermission ===
// ======================
// ==================
// === Permission ===
// ==================

/** Props for a {@link UserPermission}. */
export interface UserPermissionProps {
/** Props for a {@link Permission}. */
export interface PermissionProps {
readonly asset: backendModule.Asset
readonly self: backendModule.UserPermission
readonly isOnlyOwner: boolean
readonly userPermission: backendModule.UserPermission
readonly setUserPermission: (userPermissions: backendModule.UserPermission) => void
readonly doDelete: (user: backendModule.UserInfo) => void
readonly permission: backendModule.AssetPermission
readonly setPermission: (userPermissions: backendModule.AssetPermission) => void
readonly doDelete: (user: backendModule.UserPermissionIdentifier) => void
}

/** A user and their permissions for a specific asset. */
export default function UserPermission(props: UserPermissionProps) {
/** A user or group, and their permissions for a specific asset. */
export default function Permission(props: PermissionProps) {
const { asset, self, isOnlyOwner, doDelete } = props
const { userPermission: initialUserPermission, setUserPermission: outerSetUserPermission } = props
const { permission: initialPermission, setPermission: outerSetPermission } = props
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [userPermission, setUserPermission] = React.useState(initialUserPermission)
const isDisabled = isOnlyOwner && userPermission.user.userId === self.user.userId
const [permission, setPermission] = React.useState(initialPermission)
const permissionId = backendModule.getAssetPermissionId(permission)
const isDisabled = isOnlyOwner && permissionId === self.user.userId
const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type])

React.useEffect(() => {
setUserPermission(initialUserPermission)
}, [initialUserPermission])
setPermission(initialPermission)
}, [initialPermission])

const doSetUserPermission = async (newUserPermissions: backendModule.UserPermission) => {
const doSetPermission = async (newPermission: backendModule.AssetPermission) => {
try {
setUserPermission(newUserPermissions)
outerSetUserPermission(newUserPermissions)
setPermission(newPermission)
outerSetPermission(newPermission)
await backend.createPermission({
actorsIds: [newUserPermissions.user.userId],
actorsIds: [backendModule.getAssetPermissionId(newPermission)],
resourceId: asset.id,
action: newUserPermissions.permission,
action: newPermission.permission,
})
} catch (error) {
setUserPermission(userPermission)
outerSetUserPermission(userPermission)
toastAndLog('setPermissionsError', error, newUserPermissions.user.email)
setPermission(permission)
outerSetPermission(permission)
toastAndLog('setPermissionsError', error)
}
}

Expand All @@ -84,16 +85,16 @@ export default function UserPermission(props: UserPermissionProps) {
isDisabled={isDisabled}
error={isOnlyOwner ? getText('needsOwnerError', assetTypeName) : null}
selfPermission={self.permission}
action={userPermission.permission}
action={permission.permission}
assetType={asset.type}
onChange={async permissions => {
await doSetUserPermission(object.merge(userPermission, { permission: permissions }))
await doSetPermission(object.merge(permission, { permission: permissions }))
}}
doDelete={() => {
doDelete(userPermission.user)
doDelete(backendModule.getAssetPermissionId(permission))
}}
/>
<aria.Text className="text">{userPermission.user.name}</aria.Text>
<aria.Text className="text">{backendModule.getAssetPermissionName(permission)}</aria.Text>
</div>
)}
</FocusArea>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const asset = item.item
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
const ownPermission =
asset.permissions?.find(permission => permission.user.userId === user?.userId) ?? null
asset.permissions?.find(
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
) ?? null
// This is a workaround for a temporary bad state in the backend causing the `projectState` key
// to be absent.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type * as assetsTable from '#/layouts/AssetsTable'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import DocsColumn from '#/components/dashboard/column/DocsColumn'
import LabelsColumn from '#/components/dashboard/column/LabelsColumn'
import LastModifiedColumn from '#/components/dashboard/column/LastModifiedColumn'
import ModifiedColumn from '#/components/dashboard/column/ModifiedColumn'
import NameColumn from '#/components/dashboard/column/NameColumn'
import PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn'
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
Expand Down Expand Up @@ -55,7 +55,7 @@ export const COLUMN_RENDERER: Readonly<
Record<columnUtils.Column, (props: AssetColumnProps) => React.JSX.Element>
> = {
[columnUtils.Column.name]: NameColumn,
[columnUtils.Column.modified]: LastModifiedColumn,
[columnUtils.Column.modified]: ModifiedColumn,
[columnUtils.Column.sharedWith]: SharedWithColumn,
[columnUtils.Column.labels]: LabelsColumn,
[columnUtils.Column.accessedByProjects]: PlaceholderColumn,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import UnstyledButton from '#/components/UnstyledButton'

import ManageLabelsModal from '#/modals/ManageLabelsModal'

import type * as backendModule from '#/services/Backend'
import * as backendModule from '#/services/Backend'

import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
Expand All @@ -38,14 +38,14 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
const { category, labels, setQuery, deletedLabelNames, doCreateLabel } = state
const { temporarilyAddedLabels, temporarilyRemovedLabels } = rowState
const asset = item.item
const session = authProvider.useNonPartialUserSession()
const { user } = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
const self = asset.permissions?.find(
permission => permission.user.userId === session.user?.userId
backendModule.isUserPermissionAnd(permission => permission.user.userId === user?.userId)
)
const managesThisAsset =
category !== Category.trash &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import type * as column from '#/components/dashboard/column'

import * as dateTime from '#/utilities/dateTime'

// ======================
// === ModifiedColumn ===
// ======================

/** A column displaying the time at which the asset was last modified. */
export default function LastModifiedColumn(props: column.AssetColumnProps) {
export default function ModifiedColumn(props: column.AssetColumnProps) {
return <>{dateTime.formatDateTime(new Date(props.item.item.modifiedAt))}</>
}
Loading
Loading