mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 16:41:45 +03:00
Dashboard improvements (#10715)
- Frontend part of https://github.com/enso-org/cloud-v2/issues/1397 - Show organization details to everyone (behavior unchanged) - ⚠️ Allow editing only for admins - ⚠️ Currently there is no backend endpoint to get organization permissions - Stop (incorrectly) submitting *all* settings inputs twice - Frontend part of https://github.com/enso-org/cloud-v2/issues/1396 - Fix "remove invitation" sending wrong request - Stop sending `organizationId` in "create invitation" request - Not adding `email` autocomplete to `/registration` - Currently already exists - but it will need to be revisited after the new sign up flow PR is merged. - Fix https://github.com/enso-org/cloud-v2/issues/1407 - Fix project open request being sent multiple times - Address https://github.com/enso-org/enso/issues/10633#issuecomment-2252540802 - Fix path to local projects (previously gave the path to their containing folder Other fixes: - Various fixes for autocomplete: - Fix autocomplete appearance (dropdown is no longer detached from main input) - Add tooltips for overflowing autocomplete entries - Add tooltips for overflowing usernames in "manage permissions" modal - Animate height of "asset search bar" dropdown and "autocomplete" dropdown - Auto-size names of object keys in Datalink input Other changes: - Avoid gap with missing background on right side of tab bar when resizing window due to the clip path being animated - Add <kbd>Cmd</kbd>+<kbd>W</kbd> and <kbd>Cmd</kbd>+<kbd>Option</kbd>+<kbd>W</kbd> to close tab - Make <kbd>Escape</kbd> only close tab if it is the Settings tab (a temporary tab) # Important Notes None
This commit is contained in:
parent
a5922e0844
commit
d9fc3a0fb6
@ -225,7 +225,7 @@ export function locateNotEnabledStub(page: test.Locator | test.Page) {
|
||||
|
||||
/** Find a "new folder" icon (if any) on the current page. */
|
||||
export function locateNewFolderIcon(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'New Folder' })
|
||||
return page.getByRole('button', { name: 'New Folder', exact: true })
|
||||
}
|
||||
|
||||
/** Find a "new secret" icon (if any) on the current page. */
|
||||
|
@ -226,7 +226,7 @@ export default class DrivePageActions extends PageActions {
|
||||
/** Create a new folder using the icon in the Drive Bar. */
|
||||
createFolder() {
|
||||
return this.step('Create folder', (page) =>
|
||||
page.getByRole('button', { name: 'New Folder' }).click(),
|
||||
page.getByRole('button', { name: 'New Folder', exact: true }).click(),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -79,6 +79,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
rootDirectoryId: defaultDirectoryId,
|
||||
userGroups: null,
|
||||
plan: backend.Plan.enterprise,
|
||||
isOrganizationAdmin: true,
|
||||
}
|
||||
const defaultOrganization: backend.OrganizationInfo = {
|
||||
id: defaultOrganizationId,
|
||||
@ -259,6 +260,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
isEnabled: true,
|
||||
userGroups: null,
|
||||
plan: backend.Plan.enterprise,
|
||||
isOrganizationAdmin: true,
|
||||
...rest,
|
||||
}
|
||||
users.push(user)
|
||||
@ -733,6 +735,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
||||
rootDirectoryId,
|
||||
userGroups: null,
|
||||
plan: backend.Plan.enterprise,
|
||||
isOrganizationAdmin: true,
|
||||
}
|
||||
await route.fulfill({ json: currentUser })
|
||||
})
|
||||
|
7
app/dashboard/src/assets/close_tab.svg
Normal file
7
app/dashboard/src/assets/close_tab.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path opacity="0.3" d="M2 8C2 5.79086 3.79086 4 6 4H10C12.2091 4 14 5.79086 14 8V12H2V8Z" fill="black"/>
|
||||
<rect x="14.4102" y="1" width="2" height="7.65112" rx="1" transform="rotate(45 14.4102 1)" fill="black"/>
|
||||
<rect x="15.8224" y="6.41211" width="2" height="7.64835" rx="1" transform="rotate(135 15.8224 6.41211)" fill="black"/>
|
||||
<path opacity="0.3" fill-rule="evenodd" clip-rule="evenodd" d="M15 10.7655C14.4153 10.1518 14 9.26279 14 8V12H15V10.7655Z" fill="black"/>
|
||||
<path opacity="0.3" fill-rule="evenodd" clip-rule="evenodd" d="M1 10.7655C1.58473 10.1518 2 9.26279 2 8V12H1V10.7655Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 705 B |
@ -20,6 +20,7 @@ export interface TextProps
|
||||
readonly lineClamp?: number
|
||||
readonly tooltip?: React.ReactElement | string | false | null
|
||||
readonly tooltipDisplay?: visualTooltip.VisualTooltipProps['display']
|
||||
readonly tooltipPlacement?: aria.Placement
|
||||
}
|
||||
|
||||
export const TEXT_STYLE = twv.tv({
|
||||
@ -136,6 +137,7 @@ export const Text = React.forwardRef(function Text(
|
||||
elementType: ElementType = 'span',
|
||||
tooltip: tooltipElement = children,
|
||||
tooltipDisplay = 'whenOverflowing',
|
||||
tooltipPlacement,
|
||||
textSelection,
|
||||
disableLineHeightCompensation = false,
|
||||
...ariaProps
|
||||
@ -178,6 +180,7 @@ export const Text = React.forwardRef(function Text(
|
||||
targetRef: textElementRef,
|
||||
display: tooltipDisplay,
|
||||
children: tooltipElement,
|
||||
...(tooltipPlacement ? { overlayPositionProps: { placement: tooltipPlacement } } : {}),
|
||||
})
|
||||
|
||||
return (
|
||||
|
@ -12,7 +12,7 @@ import * as text from '../Text'
|
||||
// =================
|
||||
|
||||
export const TOOLTIP_STYLES = twv.tv({
|
||||
base: 'group flex justify-center items-center text-center text-balance break-words',
|
||||
base: 'group flex justify-center items-center text-center text-balance break-words z-50',
|
||||
variants: {
|
||||
variant: {
|
||||
custom: '',
|
||||
|
@ -1,10 +1,14 @@
|
||||
/** @file A select menu with a dropdown. */
|
||||
import * as React from 'react'
|
||||
|
||||
import CloseIcon from '#/assets/cross.svg'
|
||||
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import Input from '#/components/styled/Input'
|
||||
|
||||
import { Button, Text } from '#/components/AriaComponents'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -39,7 +43,7 @@ interface InternalBaseAutocompleteProps<T> {
|
||||
interface InternalSingleAutocompleteProps<T> extends InternalBaseAutocompleteProps<T> {
|
||||
/** Whether selecting multiple values is allowed. */
|
||||
readonly multiple?: false
|
||||
readonly setValues: (value: [T]) => void
|
||||
readonly setValues: (value: readonly [] | readonly [T]) => void
|
||||
readonly itemsToString?: never
|
||||
}
|
||||
|
||||
@ -76,7 +80,8 @@ export type AutocompleteProps<T> = (
|
||||
/** A select menu with a dropdown. */
|
||||
export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
||||
const { multiple, type = 'text', inputRef: rawInputRef, placeholder, values, setValues } = props
|
||||
const { text, setText, autoFocus, items, itemToKey, itemToString, itemsToString, matches } = props
|
||||
const { text, setText, autoFocus = false, items, itemToKey, itemToString, itemsToString } = props
|
||||
const { matches } = props
|
||||
const [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
|
||||
const valuesSet = React.useMemo(() => new Set(values), [values])
|
||||
@ -181,72 +186,83 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div onKeyDown={onKeyDown} className="grow">
|
||||
<FocusRing within>
|
||||
<div className="flex flex-1 rounded-full">
|
||||
{canEditText ?
|
||||
<Input
|
||||
type={type}
|
||||
ref={inputRef}
|
||||
autoFocus={autoFocus}
|
||||
size={1}
|
||||
value={text ?? ''}
|
||||
autoComplete="off"
|
||||
placeholder={placeholder == null ? placeholder : placeholder}
|
||||
className="text grow rounded-full bg-transparent px-button-x"
|
||||
onFocus={() => {
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setIsDropdownVisible(false)
|
||||
})
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setIsDropdownVisible(true)
|
||||
setText(event.currentTarget.value === '' ? null : event.currentTarget.value)
|
||||
<div className="relative h-6 w-full">
|
||||
<div
|
||||
onKeyDown={onKeyDown}
|
||||
className={twMerge(
|
||||
'absolute w-full grow transition-colors',
|
||||
isDropdownVisible && matchingItems.length !== 0 ?
|
||||
'before:absolute before:inset-0 before:z-1 before:rounded-xl before:border-0.5 before:border-primary/20 before:bg-frame before:shadow-soft before:backdrop-blur-default'
|
||||
: '',
|
||||
)}
|
||||
>
|
||||
<FocusRing within>
|
||||
<div className="relative z-1 flex flex-1 rounded-full">
|
||||
{canEditText ?
|
||||
<Input
|
||||
type={type}
|
||||
ref={inputRef}
|
||||
autoFocus={autoFocus}
|
||||
size={1}
|
||||
value={text ?? ''}
|
||||
autoComplete="off"
|
||||
placeholder={placeholder == null ? placeholder : placeholder}
|
||||
className="text grow rounded-full bg-transparent px-button-x"
|
||||
onFocus={() => {
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setIsDropdownVisible(false)
|
||||
})
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setIsDropdownVisible(true)
|
||||
setText(event.currentTarget.value === '' ? null : event.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
: <div
|
||||
tabIndex={-1}
|
||||
className="text grow cursor-pointer whitespace-nowrap bg-transparent px-button-x"
|
||||
onClick={() => {
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setIsDropdownVisible(false)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)}
|
||||
</div>
|
||||
}
|
||||
<Button
|
||||
size="medium"
|
||||
variant="icon"
|
||||
icon={CloseIcon}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
onPress={() => {
|
||||
setValues([])
|
||||
// setIsDropdownVisible(true)
|
||||
setText?.('')
|
||||
}}
|
||||
/>
|
||||
: <div
|
||||
ref={(element) => element?.focus()}
|
||||
tabIndex={-1}
|
||||
className="text grow cursor-pointer whitespace-nowrap bg-transparent px-button-x"
|
||||
onClick={() => {
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsDropdownVisible(false)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</FocusRing>
|
||||
<div className="h">
|
||||
</div>
|
||||
</FocusRing>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative top-2 z-1 h-max w-full rounded-default shadow-soft before:absolute before:top before:h-full before:w-full before:rounded-default before:bg-frame before:backdrop-blur-default',
|
||||
isDropdownVisible &&
|
||||
matchingItems.length !== 0 &&
|
||||
'before:border before:border-primary/10',
|
||||
'relative z-1 grid h-max w-full rounded-b-xl transition-grid-template-rows duration-200',
|
||||
isDropdownVisible && matchingItems.length !== 0 ? 'grid-rows-1fr' : 'grid-rows-0fr',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative max-h-autocomplete-suggestions w-full overflow-y-auto overflow-x-hidden rounded-default',
|
||||
isDropdownVisible && matchingItems.length !== 0 ? '' : 'h-0',
|
||||
)}
|
||||
>
|
||||
<div className="relative max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-b-xl">
|
||||
{/* FIXME: "Invite" modal does not take into account the height of the autocomplete,
|
||||
* so the suggestions may go offscreen. */}
|
||||
{matchingItems.map((item, index) => (
|
||||
<div
|
||||
key={itemToKey(item)}
|
||||
className={tailwindMerge.twMerge(
|
||||
'text relative cursor-pointer whitespace-nowrap px-input-x first:rounded-t-default last:rounded-b-default hover:bg-hover-bg',
|
||||
'text relative cursor-pointer whitespace-nowrap px-input-x last:rounded-b-xl hover:bg-hover-bg',
|
||||
valuesSet.has(item) && 'bg-hover-bg',
|
||||
index === selectedIndex && 'bg-black/5',
|
||||
)}
|
||||
@ -258,7 +274,9 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
||||
toggleValue(item)
|
||||
}}
|
||||
>
|
||||
{itemToString(item)}
|
||||
<Text truncate="1" className="w-full" tooltipPlacement="left">
|
||||
{itemToString(item)}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
@ -204,7 +204,7 @@ function Dropdown<T>(props: DropdownProps<T>, ref: React.ForwardedRef<HTMLDivEle
|
||||
>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative before:absolute before:top before:w-full before:rounded-input before:border before:border-primary/10 before:backdrop-blur-default before:transition-colors',
|
||||
'relative before:absolute before:top before:w-full before:rounded-input before:border-0.5 before:border-primary/20 before:backdrop-blur-default before:transition-colors',
|
||||
isDropdownVisible ?
|
||||
'before:h-full before:shadow-soft'
|
||||
: 'before:h-text group-hover:before:bg-hover-bg',
|
||||
|
@ -12,6 +12,7 @@ import Checkbox from '#/components/styled/Checkbox'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
|
||||
import { useBackendQuery } from '#/hooks/backendHooks'
|
||||
import * as jsonSchema from '#/utilities/jsonSchema'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
@ -38,13 +39,19 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
const { value, setValue } = props
|
||||
// The functionality for inputting `enso-secret`s SHOULD be injected using a plugin,
|
||||
// but it is more convenient to avoid having plugin infrastructure.
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const { getText } = textProvider.useText()
|
||||
const [autocompleteText, setAutocompleteText] = React.useState(() =>
|
||||
typeof value === 'string' ? value : null,
|
||||
)
|
||||
const [selectedChildIndex, setSelectedChildIndex] = React.useState<number | null>(null)
|
||||
const [autocompleteItems, setAutocompleteItems] = React.useState<string[] | null>(null)
|
||||
const isSecret =
|
||||
'type' in schema &&
|
||||
schema.type === 'string' &&
|
||||
'format' in schema &&
|
||||
schema.format === 'enso-secret'
|
||||
const { data: secrets } = useBackendQuery(remoteBackend, 'listSecrets', [], { enabled: isSecret })
|
||||
const autocompleteItems = isSecret ? secrets?.map((secret) => secret.path) ?? null : null
|
||||
|
||||
// NOTE: `enum` schemas omitted for now as they are not yet used.
|
||||
if ('const' in schema) {
|
||||
@ -57,18 +64,11 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
case 'string': {
|
||||
if ('format' in schema && schema.format === 'enso-secret') {
|
||||
const isValid = typeof value === 'string' && value !== ''
|
||||
if (autocompleteItems == null) {
|
||||
setAutocompleteItems([])
|
||||
void (async () => {
|
||||
const secrets = (await remoteBackend?.listSecrets()) ?? []
|
||||
setAutocompleteItems(secrets.map((secret) => secret.path))
|
||||
})()
|
||||
}
|
||||
children.push(
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'grow rounded-default border',
|
||||
isValid ? 'border-primary/10' : 'border-red-700/60',
|
||||
'w-60 rounded-default border-0.5',
|
||||
isValid ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
>
|
||||
<Autocomplete
|
||||
@ -79,7 +79,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())}
|
||||
values={isValid ? [value] : []}
|
||||
setValues={(values) => {
|
||||
setValue(values[0])
|
||||
setValue(values[0] ?? '')
|
||||
}}
|
||||
text={autocompleteText}
|
||||
setText={setAutocompleteText}
|
||||
@ -97,8 +97,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
size={1}
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60',
|
||||
'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
placeholder={getText('enterText')}
|
||||
onChange={(event) => {
|
||||
@ -125,8 +125,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60',
|
||||
'focus-child text w-60 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
placeholder={getText('enterNumber')}
|
||||
onChange={(event) => {
|
||||
@ -154,8 +154,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
className={tailwindMerge.twMerge(
|
||||
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60',
|
||||
'focus-child min-6- text40 w-80 grow rounded-input border-0.5 bg-transparent px-input-x read-only:read-only',
|
||||
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||
)}
|
||||
placeholder={getText('enterInteger')}
|
||||
onChange={(event) => {
|
||||
@ -195,19 +195,13 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
)
|
||||
if (jsonSchema.constantValue(defs, schema).length !== 1) {
|
||||
children.push(
|
||||
<div className="flex flex-col gap-json-schema rounded-default border border-primary/10 p-json-schema-object-input">
|
||||
<div className="grid items-center gap-json-schema rounded-default border-0.5 border-primary/20 p-json-schema-object-input">
|
||||
{propertyDefinitions.map((definition) => {
|
||||
const { key, schema: childSchema } = definition
|
||||
const isOptional = !requiredProperties.includes(key)
|
||||
return jsonSchema.constantValue(defs, childSchema).length === 1 ?
|
||||
null
|
||||
: <div
|
||||
key={key}
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
{...('description' in childSchema ?
|
||||
{ title: String(childSchema.description) }
|
||||
: {})}
|
||||
>
|
||||
: <>
|
||||
<FocusArea active={isOptional} direction="horizontal">
|
||||
{(innerProps) => {
|
||||
const isPresent = value != null && key in value
|
||||
@ -218,7 +212,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
isDisabled={!isOptional}
|
||||
isActive={!isOptional || isPresent}
|
||||
className={tailwindMerge.twMerge(
|
||||
'text inline-block w-json-schema-object-key whitespace-nowrap rounded-full px-button-x text-left',
|
||||
'text col-start-1 inline-block whitespace-nowrap rounded-full px-button-x text-left',
|
||||
isOptional && 'hover:bg-hover-bg',
|
||||
)}
|
||||
onPress={() => {
|
||||
@ -254,45 +248,47 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
}}
|
||||
</FocusArea>
|
||||
{value != null && key in value && (
|
||||
<JSONSchemaInput
|
||||
readOnly={readOnly}
|
||||
defs={defs}
|
||||
schema={childSchema}
|
||||
path={`${path}/properties/${key}`}
|
||||
getValidator={getValidator}
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
value={(value as Record<string, unknown>)[key] ?? null}
|
||||
setValue={(newValue) => {
|
||||
setValue((oldValue) => {
|
||||
if (typeof newValue === 'function') {
|
||||
const unsafeValue: unknown = newValue(
|
||||
// This is SAFE; but there is no way to tell TypeScript that an object
|
||||
// has an index signature.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(oldValue as Readonly<Record<string, unknown>>)[key] ?? null,
|
||||
)
|
||||
// The value MAY be `null`, but it is better than the value being a
|
||||
// function (which is *never* the intended result).
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
newValue = unsafeValue!
|
||||
}
|
||||
return (
|
||||
typeof oldValue === 'object' &&
|
||||
oldValue != null &&
|
||||
<div className="col-start-2">
|
||||
<JSONSchemaInput
|
||||
readOnly={readOnly}
|
||||
defs={defs}
|
||||
schema={childSchema}
|
||||
path={`${path}/properties/${key}`}
|
||||
getValidator={getValidator}
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
value={(value as Record<string, unknown>)[key] ?? null}
|
||||
setValue={(newValue) => {
|
||||
setValue((oldValue) => {
|
||||
if (typeof newValue === 'function') {
|
||||
const unsafeValue: unknown = newValue(
|
||||
// This is SAFE; but there is no way to tell TypeScript that an object
|
||||
// has an index signature.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(oldValue as Readonly<Record<string, unknown>>)[key] ===
|
||||
newValue
|
||||
) ?
|
||||
oldValue
|
||||
: { ...oldValue, [key]: newValue }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
(oldValue as Readonly<Record<string, unknown>>)[key] ?? null,
|
||||
)
|
||||
// The value MAY be `null`, but it is better than the value being a
|
||||
// function (which is *never* the intended result).
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
newValue = unsafeValue!
|
||||
}
|
||||
return (
|
||||
typeof oldValue === 'object' &&
|
||||
oldValue != null &&
|
||||
// This is SAFE; but there is no way to tell TypeScript that an object
|
||||
// has an index signature.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(oldValue as Readonly<Record<string, unknown>>)[key] ===
|
||||
newValue
|
||||
) ?
|
||||
oldValue
|
||||
: { ...oldValue, [key]: newValue }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
})}
|
||||
</div>,
|
||||
)
|
||||
|
@ -44,6 +44,7 @@ export const ACTION_TO_TEXT_ID: Readonly<
|
||||
>
|
||||
> = {
|
||||
settings: 'settingsShortcut',
|
||||
closeTab: 'closeTabShortcut',
|
||||
open: 'openShortcut',
|
||||
run: 'runShortcut',
|
||||
close: 'closeShortcut',
|
||||
|
@ -1,8 +1,8 @@
|
||||
/** @file A styled submit button. */
|
||||
import * as React from 'react'
|
||||
|
||||
import type * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
import { Button } from '#/components/AriaComponents'
|
||||
import { submitForm } from '#/utilities/event'
|
||||
|
||||
// ====================
|
||||
// === SubmitButton ===
|
||||
@ -14,15 +14,14 @@ export interface SubmitButtonProps {
|
||||
readonly isDisabled?: boolean
|
||||
readonly text: string
|
||||
readonly icon: string
|
||||
readonly onPress: (event: aria.PressEvent) => void
|
||||
}
|
||||
|
||||
/** A styled submit button. */
|
||||
export default function SubmitButton(props: SubmitButtonProps) {
|
||||
const { isDisabled = false, text, icon, onPress, isLoading } = props
|
||||
const { isDisabled = false, text, icon, isLoading } = props
|
||||
|
||||
return (
|
||||
<ariaComponents.Button
|
||||
<Button
|
||||
size="large"
|
||||
fullWidth
|
||||
variant="submit"
|
||||
@ -33,9 +32,9 @@ export default function SubmitButton(props: SubmitButtonProps) {
|
||||
icon={icon}
|
||||
iconPosition="end"
|
||||
rounded="full"
|
||||
onPress={onPress}
|
||||
onPress={submitForm}
|
||||
>
|
||||
{text}
|
||||
</ariaComponents.Button>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
@ -37,6 +37,7 @@ import * as backendModule from '#/services/Backend'
|
||||
import * as localBackend from '#/services/LocalBackend'
|
||||
import * as projectManager from '#/services/ProjectManager'
|
||||
|
||||
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as download from '#/utilities/download'
|
||||
@ -49,6 +50,7 @@ import * as permissions from '#/utilities/permissions'
|
||||
import * as set from '#/utilities/set'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import Visibility from '#/utilities/Visibility'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -154,6 +156,14 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
const openProjectMutate = openProjectMutation.mutateAsync
|
||||
const closeProjectMutate = closeProjectMutation.mutateAsync
|
||||
|
||||
const { data: projectState } = useQuery({
|
||||
// This is SAFE, as `isOpened` is only true for projects.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
...createGetProjectDetailsQuery.createPassiveListener(item.item.id as backendModule.ProjectId),
|
||||
select: (data) => data.state.type,
|
||||
enabled: item.type === backendModule.AssetType.project,
|
||||
})
|
||||
|
||||
const setSelected = useEventCallback((newSelected: boolean) => {
|
||||
const { selectedKeys } = driveStore.getState()
|
||||
setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected))
|
||||
@ -798,7 +808,12 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}
|
||||
}}
|
||||
onDragStart={(event) => {
|
||||
if (rowState.isEditingName) {
|
||||
if (
|
||||
rowState.isEditingName ||
|
||||
(projectState !== backendModule.ProjectState.closed &&
|
||||
projectState !== backendModule.ProjectState.created &&
|
||||
projectState != null)
|
||||
) {
|
||||
event.preventDefault()
|
||||
} else {
|
||||
props.onDragStart?.(event)
|
||||
|
@ -8,13 +8,13 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
import type Backend from '#/services/Backend'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import { Text } from '#/components/AriaComponents'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
// =================
|
||||
@ -85,7 +85,7 @@ export default function Permission(props: PermissionProps) {
|
||||
return (
|
||||
<FocusArea active={!isDisabled} direction="horizontal">
|
||||
{(innerProps) => (
|
||||
<div className="flex items-center gap-user-permission" {...innerProps}>
|
||||
<div className="flex w-full items-center gap-user-permission" {...innerProps}>
|
||||
<PermissionSelector
|
||||
showDelete
|
||||
isDisabled={isDisabled}
|
||||
@ -100,7 +100,7 @@ export default function Permission(props: PermissionProps) {
|
||||
doDelete(backendModule.getAssetPermissionId(permission))
|
||||
}}
|
||||
/>
|
||||
<aria.Text className="text">{backendModule.getAssetPermissionName(permission)}</aria.Text>
|
||||
<Text truncate="1">{backendModule.getAssetPermissionName(permission)}</Text>
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
|
@ -18,7 +18,7 @@ export default function Separator(props: SeparatorProps) {
|
||||
|
||||
return (
|
||||
!hidden && (
|
||||
<aria.Separator className="mx-context-menu-entry-px my-separator-y border-t-[0.5px] border-black/[0.16]" />
|
||||
<aria.Separator className="mx-context-menu-entry-px my-separator-y border-t-0.5 border-black/[0.16]" />
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import * as textProvider from '#/providers/TextProvider'
|
||||
import * as aria from '#/components/aria'
|
||||
import Button from '#/components/styled/Button'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import { twMerge } from '#/utilities/tailwindMerge'
|
||||
|
||||
// =====================
|
||||
// === SettingsInput ===
|
||||
@ -61,8 +62,10 @@ function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLIn
|
||||
{...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()(
|
||||
{
|
||||
ref,
|
||||
className:
|
||||
'w-full rounded-full bg-transparent font-bold placeholder-black/30 transition-colors invalid:border invalid:border-red-700 hover:bg-selected-frame focus:bg-selected-frame',
|
||||
className: twMerge(
|
||||
'w-full rounded-full bg-transparent font-bold placeholder-black/30 transition-colors invalid:border invalid:border-red-700 hover:bg-selected-frame focus:bg-selected-frame px-1 border-0.5 border-transparent',
|
||||
!isDisabled && 'border-primary/20',
|
||||
),
|
||||
...(type == null ? {} : { type: isShowingPassword ? 'text' : type }),
|
||||
disabled: isDisabled,
|
||||
size: 1,
|
||||
|
@ -10,6 +10,7 @@ import ArrowLeftIcon from '#/assets/arrow_left.svg'
|
||||
import ArrowRightIcon from '#/assets/arrow_right.svg'
|
||||
import CameraIcon from '#/assets/camera.svg'
|
||||
import CloseIcon from '#/assets/close.svg'
|
||||
import CloseTabIcon from '#/assets/close_tab.svg'
|
||||
import CloudToIcon from '#/assets/cloud_to.svg'
|
||||
import CopyIcon from '#/assets/copy.svg'
|
||||
import CopyAsPathIcon from '#/assets/copy_as_path.svg'
|
||||
@ -52,6 +53,8 @@ export function createBindings() {
|
||||
|
||||
export const BINDINGS = inputBindings.defineBindings({
|
||||
settings: { name: 'Settings', bindings: ['Mod+,'], icon: SettingsIcon },
|
||||
// An alternative shortcut is required because Mod+W cannot be overridden in browsers.
|
||||
closeTab: { name: 'Close Tab', bindings: ['Mod+W', 'Mod+Alt+W'], icon: CloseTabIcon },
|
||||
open: { name: 'Open', bindings: ['Enter'], icon: OpenIcon },
|
||||
run: { name: 'Execute as Task', bindings: ['Shift+Enter'], icon: Play2Icon },
|
||||
close: { name: 'Close', bindings: [], icon: CloseIcon },
|
||||
|
@ -168,28 +168,20 @@ export function useBackendQuery<Method extends backendQuery.BackendMethods>(
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<
|
||||
reactQuery.UseQueryOptions<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
readonly unknown[]
|
||||
>,
|
||||
'queryFn'
|
||||
>,
|
||||
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
|
||||
'queryFn' | 'queryKey'
|
||||
> &
|
||||
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
): reactQuery.UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
|
||||
export function useBackendQuery<Method extends backendQuery.BackendMethods>(
|
||||
backend: Backend | null,
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<
|
||||
reactQuery.UseQueryOptions<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
readonly unknown[]
|
||||
>,
|
||||
'queryFn'
|
||||
>,
|
||||
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
|
||||
'queryFn' | 'queryKey'
|
||||
> &
|
||||
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
): reactQuery.UseQueryResult<
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
Awaited<ReturnType<Backend[Method]>> | undefined
|
||||
@ -200,21 +192,12 @@ export function useBackendQuery<Method extends backendQuery.BackendMethods>(
|
||||
method: Method,
|
||||
args: Parameters<Backend[Method]>,
|
||||
options?: Omit<
|
||||
reactQuery.UseQueryOptions<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
readonly unknown[]
|
||||
>,
|
||||
'queryFn'
|
||||
>,
|
||||
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
|
||||
'queryFn' | 'queryKey'
|
||||
> &
|
||||
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||
) {
|
||||
return reactQuery.useQuery<
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
Error,
|
||||
Awaited<ReturnType<Backend[Method]>>,
|
||||
readonly unknown[]
|
||||
>({
|
||||
return reactQuery.useQuery<Awaited<ReturnType<Backend[Method]>>>({
|
||||
...options,
|
||||
...backendQuery.backendQueryOptions(backend, method, args, options?.queryKey),
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
|
||||
|
@ -5,6 +5,7 @@ import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
import ContextMenus from '#/components/ContextMenus'
|
||||
import { useSyncRef } from '#/hooks/syncRefHooks'
|
||||
|
||||
// ======================
|
||||
// === contextMenuRef ===
|
||||
@ -16,44 +17,52 @@ export function useContextMenuRef(
|
||||
key: string,
|
||||
label: string,
|
||||
createEntries: (position: Pick<React.MouseEvent, 'pageX' | 'pageY'>) => React.JSX.Element | null,
|
||||
options: { enabled?: boolean } = {},
|
||||
) {
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const createEntriesRef = React.useRef(createEntries)
|
||||
createEntriesRef.current = createEntries
|
||||
const optionsRef = useSyncRef(options)
|
||||
const cleanupRef = React.useRef(() => {})
|
||||
const [contextMenuRef] = React.useState(() => (element: HTMLElement | null) => {
|
||||
cleanupRef.current()
|
||||
if (element == null) {
|
||||
cleanupRef.current = () => {}
|
||||
} else {
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
const position = { pageX: event.pageX, pageY: event.pageY }
|
||||
const children = createEntriesRef.current(position)
|
||||
if (children != null) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setModal(
|
||||
<ContextMenus
|
||||
ref={(contextMenusElement) => {
|
||||
if (contextMenusElement != null) {
|
||||
const rect = contextMenusElement.getBoundingClientRect()
|
||||
position.pageX = rect.left
|
||||
position.pageY = rect.top
|
||||
}
|
||||
}}
|
||||
key={key}
|
||||
event={event}
|
||||
>
|
||||
<ContextMenu aria-label={label}>{children}</ContextMenu>
|
||||
</ContextMenus>,
|
||||
)
|
||||
const contextMenuRef = React.useMemo(
|
||||
() => (element: HTMLElement | null) => {
|
||||
cleanupRef.current()
|
||||
if (element == null) {
|
||||
cleanupRef.current = () => {}
|
||||
} else {
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
const { enabled = true } = optionsRef.current
|
||||
if (enabled) {
|
||||
const position = { pageX: event.pageX, pageY: event.pageY }
|
||||
const children = createEntriesRef.current(position)
|
||||
if (children != null) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setModal(
|
||||
<ContextMenus
|
||||
ref={(contextMenusElement) => {
|
||||
if (contextMenusElement != null) {
|
||||
const rect = contextMenusElement.getBoundingClientRect()
|
||||
position.pageX = rect.left
|
||||
position.pageY = rect.top
|
||||
}
|
||||
}}
|
||||
key={key}
|
||||
event={event}
|
||||
>
|
||||
<ContextMenu aria-label={label}>{children}</ContextMenu>
|
||||
</ContextMenus>,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
element.addEventListener('contextmenu', onContextMenu)
|
||||
cleanupRef.current = () => {
|
||||
element.removeEventListener('contextmenu', onContextMenu)
|
||||
}
|
||||
}
|
||||
element.addEventListener('contextmenu', onContextMenu)
|
||||
cleanupRef.current = () => {
|
||||
element.removeEventListener('contextmenu', onContextMenu)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[key, label, optionsRef, setModal],
|
||||
)
|
||||
return contextMenuRef
|
||||
}
|
||||
|
@ -144,11 +144,11 @@ export function useOpenProjectMutation() {
|
||||
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.openInProgress } })
|
||||
|
||||
void client.cancelQueries({ queryKey })
|
||||
void client.invalidateQueries({ queryKey })
|
||||
},
|
||||
onError: async (_, { id }) => {
|
||||
await client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) })
|
||||
},
|
||||
onSuccess: (_, { id }) =>
|
||||
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
|
||||
onError: (_, { id }) =>
|
||||
client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
|
||||
})
|
||||
}
|
||||
|
||||
@ -177,7 +177,6 @@ export function useCloseProjectMutation() {
|
||||
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.closing } })
|
||||
|
||||
void client.cancelQueries({ queryKey })
|
||||
void client.invalidateQueries({ queryKey })
|
||||
},
|
||||
onSuccess: (_, { id }) =>
|
||||
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
|
||||
|
@ -88,7 +88,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
category !== Category.cloud && category !== Category.local ? null
|
||||
: isCloud ? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}`
|
||||
: asset.type === backendModule.AssetType.project ?
|
||||
localBackend?.getProjectDirectoryPath(asset.id) ?? null
|
||||
localBackend?.getProjectPath(asset.id) ?? null
|
||||
: localBackendModule.extractTypeAndId(asset.id).id
|
||||
const copyMutation = copyHooks.useCopy({ copyText: path ?? '' })
|
||||
|
||||
|
@ -86,7 +86,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
const path =
|
||||
isCloud ? null
|
||||
: item.item.type === backendModule.AssetType.project ?
|
||||
localBackend?.getProjectDirectoryPath(item.item.id) ?? null
|
||||
localBackend?.getProjectPath(item.item.id) ?? null
|
||||
: localBackendModule.extractTypeAndId(item.item.id).id
|
||||
|
||||
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
|
||||
|
@ -310,102 +310,109 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
>
|
||||
<div className="h-[32px]" />
|
||||
|
||||
{areSuggestionsVisible && (
|
||||
<div className="relative mt-3 flex flex-col gap-3">
|
||||
{/* Tags (`name:`, `modified:`, etc.) */}
|
||||
<Tags
|
||||
isCloud={isCloud}
|
||||
querySource={querySource}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
/>
|
||||
{/* Asset labels */}
|
||||
{isCloud && labels.length !== 0 && (
|
||||
<div
|
||||
data-testid="asset-search-labels"
|
||||
className="pointer-events-auto flex gap-2 px-1.5"
|
||||
>
|
||||
{[...labels]
|
||||
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
|
||||
.map((label) => {
|
||||
const negated = query.negativeLabels.some((term) =>
|
||||
array.shallowEqual(term, [label.value]),
|
||||
)
|
||||
return (
|
||||
<Label
|
||||
key={label.id}
|
||||
color={label.color}
|
||||
active={
|
||||
negated ||
|
||||
query.labels.some((term) => array.shallowEqual(term, [label.value]))
|
||||
}
|
||||
negated={negated}
|
||||
onPress={(event) => {
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery((oldQuery) => {
|
||||
const newQuery = oldQuery.withToggled(
|
||||
'labels',
|
||||
'negativeLabels',
|
||||
label.value,
|
||||
event.shiftKey,
|
||||
)
|
||||
baseQuery.current = newQuery
|
||||
return newQuery
|
||||
})
|
||||
}}
|
||||
>
|
||||
{label.value}
|
||||
</Label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Suggestions */}
|
||||
<div className="flex max-h-search-suggestions-list flex-col overflow-y-auto overflow-x-hidden pb-0.5 pl-0.5">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
// This should not be a `<button>`, since `render()` may output a
|
||||
// tree containing a button.
|
||||
<aria.Button
|
||||
data-testid="asset-search-suggestion"
|
||||
key={index}
|
||||
ref={(el) => {
|
||||
if (index === selectedIndex) {
|
||||
el?.focus()
|
||||
}
|
||||
}}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex cursor-pointer rounded-l-default rounded-r-sm px-[7px] py-0.5 text-left transition-[background-color] hover:bg-primary/5',
|
||||
selectedIndices.has(index) && 'bg-primary/10',
|
||||
index === selectedIndex && 'bg-selected-frame',
|
||||
)}
|
||||
onPress={(event) => {
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery(
|
||||
selectedIndices.has(index) ?
|
||||
suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
|
||||
: suggestion.addToQuery(event.shiftKey ? query : baseQuery.current),
|
||||
)
|
||||
if (event.shiftKey) {
|
||||
setSelectedIndices(
|
||||
new Set(
|
||||
selectedIndices.has(index) ?
|
||||
[...selectedIndices].filter((otherIndex) => otherIndex !== index)
|
||||
: [...selectedIndices, index],
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setAreSuggestionsVisible(false)
|
||||
}
|
||||
}}
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'grid transition-grid-template-rows duration-200',
|
||||
areSuggestionsVisible ? 'grid-rows-1fr' : 'grid-rows-0fr',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-y-auto overflow-x-hidden">
|
||||
<div className="relative mt-3 flex flex-col gap-3">
|
||||
{/* Tags (`name:`, `modified:`, etc.) */}
|
||||
<Tags
|
||||
isCloud={isCloud}
|
||||
querySource={querySource}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
/>
|
||||
{/* Asset labels */}
|
||||
{isCloud && labels.length !== 0 && (
|
||||
<div
|
||||
data-testid="asset-search-labels"
|
||||
className="pointer-events-auto flex gap-2 px-1.5"
|
||||
>
|
||||
<ariaComponents.Text variant="body" truncate="1" className="w-full">
|
||||
{suggestion.render()}
|
||||
</ariaComponents.Text>
|
||||
</aria.Button>
|
||||
))}
|
||||
{[...labels]
|
||||
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
|
||||
.map((label) => {
|
||||
const negated = query.negativeLabels.some((term) =>
|
||||
array.shallowEqual(term, [label.value]),
|
||||
)
|
||||
return (
|
||||
<Label
|
||||
key={label.id}
|
||||
color={label.color}
|
||||
active={
|
||||
negated ||
|
||||
query.labels.some((term) => array.shallowEqual(term, [label.value]))
|
||||
}
|
||||
negated={negated}
|
||||
onPress={(event) => {
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery((oldQuery) => {
|
||||
const newQuery = oldQuery.withToggled(
|
||||
'labels',
|
||||
'negativeLabels',
|
||||
label.value,
|
||||
event.shiftKey,
|
||||
)
|
||||
baseQuery.current = newQuery
|
||||
return newQuery
|
||||
})
|
||||
}}
|
||||
>
|
||||
{label.value}
|
||||
</Label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Suggestions */}
|
||||
<div className="flex max-h-search-suggestions-list flex-col overflow-y-auto overflow-x-hidden pb-0.5 pl-0.5">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
// This should not be a `<button>`, since `render()` may output a
|
||||
// tree containing a button.
|
||||
<aria.Button
|
||||
data-testid="asset-search-suggestion"
|
||||
key={index}
|
||||
ref={(el) => {
|
||||
if (index === selectedIndex) {
|
||||
el?.focus()
|
||||
}
|
||||
}}
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex cursor-pointer rounded-l-default rounded-r-sm px-[7px] py-0.5 text-left transition-[background-color] hover:bg-primary/5',
|
||||
selectedIndices.has(index) && 'bg-primary/10',
|
||||
index === selectedIndex && 'bg-selected-frame',
|
||||
)}
|
||||
onPress={(event) => {
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery(
|
||||
selectedIndices.has(index) ?
|
||||
suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
|
||||
: suggestion.addToQuery(event.shiftKey ? query : baseQuery.current),
|
||||
)
|
||||
if (event.shiftKey) {
|
||||
setSelectedIndices(
|
||||
new Set(
|
||||
selectedIndices.has(index) ?
|
||||
[...selectedIndices].filter((otherIndex) => otherIndex !== index)
|
||||
: [...selectedIndices, index],
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setAreSuggestionsVisible(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ariaComponents.Text variant="body" truncate="1" className="w-full">
|
||||
{suggestion.render()}
|
||||
</ariaComponents.Text>
|
||||
</aria.Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SvgMask
|
||||
src={FindIcon}
|
||||
|
@ -78,6 +78,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
||||
<ariaComponents.DialogTrigger>
|
||||
<ariaComponents.TooltipTrigger>
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="icon"
|
||||
aria-label={getText('compareWithLatest')}
|
||||
icon={CompareIcon}
|
||||
@ -94,6 +95,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.TooltipTrigger>
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="icon"
|
||||
aria-label={getText('restoreThisVersion')}
|
||||
icon={RestoreIcon}
|
||||
@ -109,6 +111,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
||||
</ariaComponents.TooltipTrigger>
|
||||
<ariaComponents.TooltipTrigger>
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="icon"
|
||||
aria-label={getText('duplicateThisVersion')}
|
||||
icon={DuplicateIcon}
|
||||
@ -137,6 +140,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
||||
{isProject && (
|
||||
<ariaComponents.TooltipTrigger>
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="icon"
|
||||
aria-label={getText('restoreThisVersion')}
|
||||
icon={RestoreIcon}
|
||||
@ -149,6 +153,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
||||
{isProject && (
|
||||
<ariaComponents.TooltipTrigger>
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="icon"
|
||||
aria-label={getText('duplicateThisVersion')}
|
||||
icon={DuplicateIcon}
|
||||
|
@ -97,60 +97,53 @@ export default function Editor(props: EditorProps) {
|
||||
networkMode: project.type === backendModule.BackendType.remote ? 'online' : 'always',
|
||||
})
|
||||
|
||||
if (isOpeningFailed) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return (
|
||||
const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed
|
||||
const shouldRefetch = !projectQuery.isError && !projectQuery.isLoading
|
||||
|
||||
if (!isOpeningFailed && !isOpening && isProjectClosed && shouldRefetch) {
|
||||
startProject(project)
|
||||
}
|
||||
|
||||
return isOpeningFailed ?
|
||||
<errorBoundary.ErrorDisplay
|
||||
error={openingError}
|
||||
resetErrorBoundary={() => {
|
||||
startProject(project)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed
|
||||
const shouldRefetch = !(projectQuery.isError || projectQuery.isLoading)
|
||||
|
||||
if (!isOpening && isProjectClosed && shouldRefetch) {
|
||||
startProject(project)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge.twJoin('contents', hidden && 'hidden')}
|
||||
data-testvalue={project.id}
|
||||
data-testid="editor"
|
||||
>
|
||||
{(() => {
|
||||
if (projectQuery.isError) {
|
||||
return (
|
||||
<errorBoundary.ErrorDisplay
|
||||
error={projectQuery.error}
|
||||
resetErrorBoundary={() => projectQuery.refetch()}
|
||||
/>
|
||||
)
|
||||
} else if (
|
||||
projectQuery.isLoading ||
|
||||
projectQuery.data?.state.type !== backendModule.ProjectState.opened
|
||||
) {
|
||||
return <suspense.Loader loaderProps={{ minHeight: 'full' }} />
|
||||
} else {
|
||||
return (
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense>
|
||||
<EditorInternal
|
||||
{...props}
|
||||
openedProject={projectQuery.data}
|
||||
backendType={project.type}
|
||||
/>
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
: <div
|
||||
className={twMerge.twJoin('contents', hidden && 'hidden')}
|
||||
data-testvalue={project.id}
|
||||
data-testid="editor"
|
||||
>
|
||||
{(() => {
|
||||
if (projectQuery.isError) {
|
||||
return (
|
||||
<errorBoundary.ErrorDisplay
|
||||
error={projectQuery.error}
|
||||
resetErrorBoundary={() => projectQuery.refetch()}
|
||||
/>
|
||||
)
|
||||
} else if (
|
||||
projectQuery.isLoading ||
|
||||
projectQuery.data?.state.type !== backendModule.ProjectState.opened
|
||||
) {
|
||||
return <suspense.Loader loaderProps={{ minHeight: 'full' }} />
|
||||
} else {
|
||||
return (
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense>
|
||||
<EditorInternal
|
||||
{...props}
|
||||
openedProject={projectQuery.data}
|
||||
backendType={project.type}
|
||||
/>
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
)
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
}
|
||||
|
||||
// ======================
|
||||
|
@ -21,6 +21,8 @@ import NewLabelModal from '#/modals/NewLabelModal'
|
||||
|
||||
import type Backend from '#/services/Backend'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
|
||||
import * as array from '#/utilities/array'
|
||||
import type AssetQuery from '#/utilities/AssetQuery'
|
||||
import * as drag from '#/utilities/drag'
|
||||
@ -44,9 +46,14 @@ export default function Labels(props: LabelsProps) {
|
||||
const currentNegativeLabels = query.negativeLabels
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const dispatchAssetEvent = useDispatchAssetEvent()
|
||||
const labels = backendHooks.useBackendListTags(backend) ?? []
|
||||
|
||||
const deleteTagMutation = backendHooks.useBackendMutation(backend, 'deleteTag')
|
||||
const deleteTagMutation = backendHooks.useBackendMutation(backend, 'deleteTag', {
|
||||
onSuccess: (_data, [, labelName]) => {
|
||||
dispatchAssetEvent({ type: AssetEventType.deleteLabel, labelName })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<FocusArea direction="vertical">
|
||||
|
@ -55,6 +55,7 @@ export default function KeyboardShortcutsSettingsSection() {
|
||||
<>
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="bar"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
|
@ -57,30 +57,37 @@ export default function MembersSettingsSection() {
|
||||
const seatsLeft =
|
||||
isUnderPaywall ? feature.meta.maxSeats - (members.length + invitations.length) : null
|
||||
const seatsTotal = feature.meta.maxSeats
|
||||
const isAdmin = user.isOrganizationAdmin
|
||||
|
||||
return (
|
||||
<>
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.DialogTrigger>
|
||||
<ariaComponents.Button variant="bar" rounded="full" size="medium">
|
||||
{getText('inviteMembers')}
|
||||
</ariaComponents.Button>
|
||||
{isAdmin && (
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.DialogTrigger>
|
||||
<ariaComponents.Button variant="bar" rounded="full" size="medium">
|
||||
{getText('inviteMembers')}
|
||||
</ariaComponents.Button>
|
||||
|
||||
<InviteUsersModal />
|
||||
</ariaComponents.DialogTrigger>
|
||||
<InviteUsersModal />
|
||||
</ariaComponents.DialogTrigger>
|
||||
|
||||
{seatsLeft != null && (
|
||||
<div className="flex items-center gap-1">
|
||||
<ariaComponents.Text>
|
||||
{seatsLeft <= 0 ?
|
||||
getText('noSeatsLeft')
|
||||
: getText('seatsLeft', seatsLeft, seatsTotal)}
|
||||
</ariaComponents.Text>
|
||||
{seatsLeft != null && (
|
||||
<div className="flex items-center gap-1">
|
||||
<ariaComponents.Text>
|
||||
{seatsLeft <= 0 ?
|
||||
getText('noSeatsLeft')
|
||||
: getText('seatsLeft', seatsLeft, seatsTotal)}
|
||||
</ariaComponents.Text>
|
||||
|
||||
<paywall.PaywallDialogButton feature="inviteUserFull" variant="link" showIcon={false} />
|
||||
</div>
|
||||
)}
|
||||
</ariaComponents.ButtonGroup>
|
||||
<paywall.PaywallDialogButton
|
||||
feature="inviteUserFull"
|
||||
variant="link"
|
||||
showIcon={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ariaComponents.ButtonGroup>
|
||||
)}
|
||||
|
||||
<table className="table-fixed self-start rounded-rows">
|
||||
<thead>
|
||||
@ -103,9 +110,11 @@ export default function MembersSettingsSection() {
|
||||
<td className="border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0">
|
||||
<div className="flex flex-col">
|
||||
{getText('active')}
|
||||
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
|
||||
<RemoveMemberButton backend={backend} userId={member.userId} />
|
||||
</ariaComponents.ButtonGroup>
|
||||
{member.email !== user.email && isAdmin && (
|
||||
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
|
||||
<RemoveMemberButton backend={backend} userId={member.userId} />
|
||||
</ariaComponents.ButtonGroup>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -118,20 +127,23 @@ export default function MembersSettingsSection() {
|
||||
<td className="border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0">
|
||||
<div className="flex flex-col">
|
||||
{getText('pendingInvitation')}
|
||||
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
|
||||
<ariaComponents.CopyButton
|
||||
size="custom"
|
||||
copyText={`enso://auth/registration?organization_id=${invitation.organizationId}`}
|
||||
aria-label={getText('copyInviteLink')}
|
||||
copyIcon={false}
|
||||
>
|
||||
{getText('copyInviteLink')}
|
||||
</ariaComponents.CopyButton>
|
||||
{isAdmin && (
|
||||
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
|
||||
<ariaComponents.CopyButton
|
||||
size="custom"
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
copyText={`enso://auth/registration?=${new URLSearchParams({ organization_id: invitation.organizationId }).toString()}`}
|
||||
aria-label={getText('copyInviteLink')}
|
||||
copyIcon={false}
|
||||
>
|
||||
{getText('copyInviteLink')}
|
||||
</ariaComponents.CopyButton>
|
||||
|
||||
<ResendInvitationButton invitation={invitation} backend={backend} />
|
||||
<ResendInvitationButton invitation={invitation} backend={backend} />
|
||||
|
||||
<RemoveInvitationButton backend={backend} email={invitation.userEmail} />
|
||||
</ariaComponents.ButtonGroup>
|
||||
<RemoveInvitationButton backend={backend} email={invitation.userEmail} />
|
||||
</ariaComponents.ButtonGroup>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -157,9 +169,8 @@ function ResendInvitationButton(props: ResendInvitationButtonProps) {
|
||||
const { invitation, backend } = props
|
||||
|
||||
const { getText } = textProvider.useText()
|
||||
const resendMutation = reactQuery.useMutation({
|
||||
mutationKey: ['resendInvitation', invitation.userEmail],
|
||||
mutationFn: (email: backendModule.EmailAddress) => backend.resendInvitation(email),
|
||||
const resendMutation = backendHooks.useBackendMutation(backend, 'resendInvitation', {
|
||||
mutationKey: [invitation.userEmail],
|
||||
})
|
||||
|
||||
return (
|
||||
@ -168,7 +179,7 @@ function ResendInvitationButton(props: ResendInvitationButtonProps) {
|
||||
size="custom"
|
||||
loading={resendMutation.isPending}
|
||||
onPress={() => {
|
||||
resendMutation.mutate(invitation.userEmail)
|
||||
resendMutation.mutate([invitation.userEmail])
|
||||
}}
|
||||
>
|
||||
{getText('resend')}
|
||||
@ -223,7 +234,7 @@ function RemoveInvitationButton(props: RemoveInvitationButtonProps) {
|
||||
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const removeMutation = backendHooks.useBackendMutation(backend, 'resendInvitation', {
|
||||
const removeMutation = backendHooks.useBackendMutation(backend, 'deleteInvitation', {
|
||||
mutationKey: [email],
|
||||
meta: { invalidates: [['listInvitations']], awaitInvalidates: true },
|
||||
})
|
||||
|
@ -27,9 +27,9 @@ import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
export interface MembersTableProps {
|
||||
readonly backend: Backend
|
||||
/** If `true`, initialize the users list with self to avoid needing a loading spinner. */
|
||||
readonly populateWithSelf?: true
|
||||
readonly draggable?: true
|
||||
readonly allowDelete?: true
|
||||
readonly populateWithSelf?: boolean
|
||||
readonly draggable?: boolean
|
||||
readonly allowDelete?: boolean
|
||||
}
|
||||
|
||||
/** A list of members in the organization. */
|
||||
|
@ -33,6 +33,7 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
|
||||
const { nameId, getValue, setValue, validate, getEditable } = data
|
||||
const { getText } = textProvider.useText()
|
||||
const [errorMessage, setErrorMessage] = React.useState('')
|
||||
const isSubmitting = React.useRef(false)
|
||||
const value = getValue(context)
|
||||
const isEditable = getEditable(context)
|
||||
|
||||
@ -42,6 +43,9 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
|
||||
key={value}
|
||||
type="text"
|
||||
onSubmit={(event) => {
|
||||
// Technically causes the form to submit twice when pressing `Enter` due to `Enter`
|
||||
// also triggering the submit button.This is worked around by using a ref
|
||||
// tracking whether the form is currently being submitted.
|
||||
event.currentTarget.form?.requestSubmit()
|
||||
}}
|
||||
/>
|
||||
@ -52,14 +56,18 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
|
||||
validationErrors={{ [FIELD_NAME]: errorMessage }}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
const [[, newValue] = []] = new FormData(event.currentTarget)
|
||||
if (typeof newValue === 'string') {
|
||||
setErrorMessage('')
|
||||
try {
|
||||
await setValue(context, newValue)
|
||||
} catch (error) {
|
||||
setErrorMessage(errorModule.getMessageOrToString(error))
|
||||
if (!isSubmitting.current) {
|
||||
isSubmitting.current = true
|
||||
const [[, newValue] = []] = new FormData(event.currentTarget)
|
||||
if (typeof newValue === 'string') {
|
||||
setErrorMessage('')
|
||||
try {
|
||||
await setValue(context, newValue)
|
||||
} catch (error) {
|
||||
setErrorMessage(errorModule.getMessageOrToString(error))
|
||||
}
|
||||
}
|
||||
isSubmitting.current = false
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -17,6 +17,7 @@ import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
|
||||
// ====================
|
||||
@ -32,8 +33,10 @@ export interface UserGroupRowProps {
|
||||
/** A row representing a user group. */
|
||||
export default function UserGroupRow(props: UserGroupRowProps) {
|
||||
const { userGroup, doDeleteUserGroup } = props
|
||||
const { user } = useFullUserSession()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const isAdmin = user.isOrganizationAdmin
|
||||
const contextMenuRef = contextMenuHooks.useContextMenuRef(
|
||||
userGroup.id,
|
||||
getText('userGroupContextMenuLabel'),
|
||||
@ -53,6 +56,7 @@ export default function UserGroupRow(props: UserGroupRowProps) {
|
||||
}}
|
||||
/>
|
||||
),
|
||||
{ enabled: isAdmin },
|
||||
)
|
||||
|
||||
return (
|
||||
@ -72,23 +76,25 @@ export default function UserGroupRow(props: UserGroupRowProps) {
|
||||
</div>
|
||||
</aria.Cell>
|
||||
<aria.Cell className="relative bg-transparent p-0 opacity-0 group-hover-2:opacity-100">
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
actionText={getText('deleteUserGroupActionText', userGroup.groupName)}
|
||||
doDelete={() => {
|
||||
doDeleteUserGroup(userGroup)
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
}}
|
||||
className="absolute right-full mr-4 size-4 -translate-y-1/2"
|
||||
>
|
||||
<img src={Cross2} className="size-4" />
|
||||
</ariaComponents.Button>
|
||||
{isAdmin && (
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
actionText={getText('deleteUserGroupActionText', userGroup.groupName)}
|
||||
doDelete={() => {
|
||||
doDeleteUserGroup(userGroup)
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
}}
|
||||
className="absolute right-full mr-4 size-4 -translate-y-1/2"
|
||||
>
|
||||
<img src={Cross2} className="size-4" />
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
</aria.Cell>
|
||||
</aria.Row>
|
||||
)
|
||||
|
@ -17,6 +17,7 @@ import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
|
||||
// ========================
|
||||
@ -33,8 +34,10 @@ export interface UserGroupUserRowProps {
|
||||
/** A row of the user groups table representing a user. */
|
||||
export default function UserGroupUserRow(props: UserGroupUserRowProps) {
|
||||
const { user, userGroup, doRemoveUserFromUserGroup } = props
|
||||
const { user: currentUser } = useFullUserSession()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const isAdmin = currentUser.isOrganizationAdmin
|
||||
const contextMenuRef = contextMenuHooks.useContextMenuRef(
|
||||
user.userId,
|
||||
getText('userGroupUserContextMenuLabel'),
|
||||
@ -58,6 +61,7 @@ export default function UserGroupUserRow(props: UserGroupUserRowProps) {
|
||||
}}
|
||||
/>
|
||||
),
|
||||
{ enabled: isAdmin },
|
||||
)
|
||||
|
||||
return (
|
||||
@ -77,28 +81,30 @@ export default function UserGroupUserRow(props: UserGroupUserRowProps) {
|
||||
</div>
|
||||
</aria.Cell>
|
||||
<aria.Cell className="relative bg-transparent p-0 opacity-0 group-hover-2:opacity-100">
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
actionText={getText(
|
||||
'removeUserFromUserGroupActionText',
|
||||
user.name,
|
||||
userGroup.groupName,
|
||||
)}
|
||||
actionButtonLabel={getText('remove')}
|
||||
doDelete={() => {
|
||||
doRemoveUserFromUserGroup(user, userGroup)
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
}}
|
||||
className="absolute right-full mr-4 size-4 -translate-y-1/2"
|
||||
>
|
||||
<img src={Cross2} className="size-4" />
|
||||
</ariaComponents.Button>
|
||||
{isAdmin && (
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
actionText={getText(
|
||||
'removeUserFromUserGroupActionText',
|
||||
user.name,
|
||||
userGroup.groupName,
|
||||
)}
|
||||
actionButtonLabel={getText('remove')}
|
||||
doDelete={() => {
|
||||
doRemoveUserFromUserGroup(user, userGroup)
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
}}
|
||||
className="absolute right-full mr-4 size-4 -translate-y-1/2"
|
||||
>
|
||||
<img src={Cross2} className="size-4" />
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
</aria.Cell>
|
||||
</aria.Row>
|
||||
)
|
||||
|
@ -54,6 +54,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
|
||||
[users],
|
||||
)
|
||||
const isLoading = userGroups == null || users == null
|
||||
const isAdmin = user.isOrganizationAdmin
|
||||
|
||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||
|
||||
@ -65,6 +66,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
|
||||
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, { trackShadowClass: true })
|
||||
|
||||
const { dragAndDropHooks } = aria.useDragAndDrop({
|
||||
isDisabled: !isAdmin,
|
||||
getDropOperation: (target, types, allowedOperations) =>
|
||||
(
|
||||
allowedOperations.includes('copy') &&
|
||||
@ -132,41 +134,43 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
|
||||
|
||||
return (
|
||||
<>
|
||||
<ariaComponents.ButtonGroup verticalAlign="center">
|
||||
{shouldDisplayPaywall && (
|
||||
<paywallComponents.PaywallDialogButton
|
||||
feature="userGroupsFull"
|
||||
variant="bar"
|
||||
size="medium"
|
||||
rounded="full"
|
||||
iconPosition="end"
|
||||
tooltip={getText('userGroupsPaywallMessage')}
|
||||
>
|
||||
{getText('newUserGroup')}
|
||||
</paywallComponents.PaywallDialogButton>
|
||||
)}
|
||||
{!shouldDisplayPaywall && (
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="bar"
|
||||
onPress={(event) => {
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
const position = { pageX: rect.left, pageY: rect.top }
|
||||
setModal(<NewUserGroupModal backend={backend} event={position} />)
|
||||
}}
|
||||
>
|
||||
{getText('newUserGroup')}
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<ariaComponents.ButtonGroup verticalAlign="center">
|
||||
{shouldDisplayPaywall && (
|
||||
<paywallComponents.PaywallDialogButton
|
||||
feature="userGroupsFull"
|
||||
variant="bar"
|
||||
size="medium"
|
||||
rounded="full"
|
||||
iconPosition="end"
|
||||
tooltip={getText('userGroupsPaywallMessage')}
|
||||
>
|
||||
{getText('newUserGroup')}
|
||||
</paywallComponents.PaywallDialogButton>
|
||||
)}
|
||||
{!shouldDisplayPaywall && (
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="bar"
|
||||
onPress={(event) => {
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
const position = { pageX: rect.left, pageY: rect.top }
|
||||
setModal(<NewUserGroupModal backend={backend} event={position} />)
|
||||
}}
|
||||
>
|
||||
{getText('newUserGroup')}
|
||||
</ariaComponents.Button>
|
||||
)}
|
||||
|
||||
{isUnderPaywall && (
|
||||
<span className="text-xs">
|
||||
{userGroupsLeft <= 0 ?
|
||||
getText('userGroupsPaywallMessage')
|
||||
: getText('userGroupsLimitMessage', userGroupsLeft)}
|
||||
</span>
|
||||
)}
|
||||
</ariaComponents.ButtonGroup>
|
||||
{isUnderPaywall && (
|
||||
<span className="text-xs">
|
||||
{userGroupsLeft <= 0 ?
|
||||
getText('userGroupsPaywallMessage')
|
||||
: getText('userGroupsLimitMessage', userGroupsLeft)}
|
||||
</span>
|
||||
)}
|
||||
</ariaComponents.ButtonGroup>
|
||||
)}
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={tailwindMerge.twMerge(
|
||||
|
@ -37,6 +37,7 @@ export default function UserRow(props: UserRowProps) {
|
||||
const { user: self } = authProvider.useNonPartialUserSession()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const isAdmin = self.isOrganizationAdmin
|
||||
const isSelf = user.userId === self.userId
|
||||
const doDeleteUser = isSelf ? null : doDeleteUserRaw
|
||||
|
||||
@ -60,6 +61,7 @@ export default function UserRow(props: UserRowProps) {
|
||||
}}
|
||||
/>
|
||||
),
|
||||
{ enabled: isAdmin },
|
||||
)
|
||||
|
||||
return (
|
||||
|
@ -166,7 +166,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
||||
}
|
||||
},
|
||||
validate: (name) => (/\S/.test(name) ? true : ''),
|
||||
getEditable: () => true,
|
||||
getEditable: (context) => context.user.isOrganizationAdmin,
|
||||
},
|
||||
{
|
||||
type: SettingsEntryType.input,
|
||||
@ -183,7 +183,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
||||
isEmail(email) ? true
|
||||
: email === '' ? ''
|
||||
: context.getText('invalidEmailValidationError'),
|
||||
getEditable: () => true,
|
||||
getEditable: (context) => context.user.isOrganizationAdmin,
|
||||
},
|
||||
{
|
||||
type: SettingsEntryType.input,
|
||||
@ -196,7 +196,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
||||
await context.updateOrganization([{ website: newWebsite }])
|
||||
}
|
||||
},
|
||||
getEditable: () => true,
|
||||
getEditable: (context) => context.user.isOrganizationAdmin,
|
||||
},
|
||||
{
|
||||
type: SettingsEntryType.input,
|
||||
@ -208,7 +208,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
||||
await context.updateOrganization([{ address: newLocation }])
|
||||
}
|
||||
},
|
||||
getEditable: () => true,
|
||||
getEditable: (context) => context.user.isOrganizationAdmin,
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -310,7 +310,11 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
||||
{
|
||||
type: SettingsEntryType.custom,
|
||||
render: (context) => (
|
||||
<MembersTable backend={context.backend} draggable populateWithSelf />
|
||||
<MembersTable
|
||||
backend={context.backend}
|
||||
draggable={context.user.isOrganizationAdmin}
|
||||
populateWithSelf
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
@ -19,6 +19,8 @@ import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
import { useInputBindings } from '#/providers/InputBindingsProvider'
|
||||
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
|
||||
// =================
|
||||
@ -84,8 +86,8 @@ export default function TabBar(props: TabBarProps) {
|
||||
const tabRight = bounds.right - rootBounds.left - TAB_RADIUS_PX
|
||||
const rightSegments = [
|
||||
'M 0 0',
|
||||
`L ${rootBounds.width} 0`,
|
||||
`L ${rootBounds.width} ${rootBounds.height}`,
|
||||
`L ${rootBounds.width + window.outerWidth} 0`,
|
||||
`L ${rootBounds.width + window.outerWidth} ${rootBounds.height}`,
|
||||
`L ${tabRight + TAB_RADIUS_PX} ${rootBounds.height}`,
|
||||
`A ${TAB_RADIUS_PX} ${TAB_RADIUS_PX} 0 0 1 ${tabRight} ${rootBounds.height - TAB_RADIUS_PX}`,
|
||||
]
|
||||
@ -194,10 +196,11 @@ interface InternalTabProps extends Readonly<React.PropsWithChildren> {
|
||||
export function Tab(props: InternalTabProps) {
|
||||
const { id, project, isActive, isHidden = false, icon, labelId, children, onClose } = props
|
||||
const { onLoadEnd } = props
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = useInputBindings()
|
||||
const { setSelectedTab } = useTabBarContext()
|
||||
const ref = React.useRef<HTMLDivElement | null>(null)
|
||||
const isLoadingRef = React.useRef(true)
|
||||
const { getText } = textProvider.useText()
|
||||
const actuallyActive = isActive && !isHidden
|
||||
const [resizeObserver] = React.useState(
|
||||
() =>
|
||||
@ -227,6 +230,16 @@ export function Tab(props: InternalTabProps) {
|
||||
}
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (actuallyActive && onClose) {
|
||||
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
|
||||
closeTab: onClose,
|
||||
})
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}, [inputBindings, actuallyActive, onClose])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (actuallyActive && ref.current) {
|
||||
setSelectedTab(ref.current)
|
||||
|
@ -78,7 +78,7 @@ export default function UserBar(props: UserBarProps) {
|
||||
)}
|
||||
|
||||
{shouldShowUpgradeButton && (
|
||||
<paywall.PaywallDialogButton feature={'inviteUser'} size="medium" variant="tertiary">
|
||||
<paywall.PaywallDialogButton feature="inviteUser" size="medium" variant="tertiary">
|
||||
{getText('invite')}
|
||||
</paywall.PaywallDialogButton>
|
||||
)}
|
||||
|
@ -26,12 +26,11 @@ import * as parserUserEmails from '#/utilities/parseUserEmails'
|
||||
/** Props for an {@link InviteUsersForm}. */
|
||||
export interface InviteUsersFormProps {
|
||||
readonly onSubmitted: (emails: backendModule.EmailAddress[]) => void
|
||||
readonly organizationId: backendModule.OrganizationId
|
||||
}
|
||||
|
||||
/** A modal with inputs for user email and permission level. */
|
||||
export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||
const { onSubmitted, organizationId } = props
|
||||
const { onSubmitted } = props
|
||||
const { getText } = textProvider.useText()
|
||||
const backend = backendProvider.useRemoteBackendStrict()
|
||||
const inputRef = React.useRef<HTMLDivElement>(null)
|
||||
@ -146,9 +145,7 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||
.filter((value): value is backendModule.EmailAddress => isEmail(value))
|
||||
|
||||
await Promise.all(
|
||||
emailsToSubmit.map((userEmail) =>
|
||||
inviteUserMutation.mutateAsync([{ userEmail, organizationId }]),
|
||||
),
|
||||
emailsToSubmit.map((userEmail) => inviteUserMutation.mutateAsync([{ userEmail }])),
|
||||
).then(() => {
|
||||
onSubmitted(emailsToSubmit)
|
||||
})
|
||||
|
@ -67,15 +67,16 @@ function InviteUsersModalContent(props: InviteUsersModalContentProps) {
|
||||
[],
|
||||
)
|
||||
|
||||
const invitationLink = `enso://auth/registration?organization_id=${organizationId}`
|
||||
const invitationParams = new URLSearchParams({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
organization_id: organizationId,
|
||||
}).toString()
|
||||
const invitationLink = `enso://auth/registration?${invitationParams}`
|
||||
|
||||
return (
|
||||
<>
|
||||
{step === 'invite' && (
|
||||
<inviteUsersForm.InviteUsersForm
|
||||
onSubmitted={onInviteUsersFormInviteUsersFormSubmitted}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
<inviteUsersForm.InviteUsersForm onSubmitted={onInviteUsersFormInviteUsersFormSubmitted} />
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
|
@ -178,12 +178,7 @@ export default function ManagePermissionsModal<
|
||||
setUserAndUserGroups([])
|
||||
setEmail('')
|
||||
if (email != null) {
|
||||
await inviteUserMutation.mutateAsync([
|
||||
{
|
||||
organizationId: user.organizationId,
|
||||
userEmail: backendModule.EmailAddress(email),
|
||||
},
|
||||
])
|
||||
await inviteUserMutation.mutateAsync([{ userEmail: backendModule.EmailAddress(email) }])
|
||||
toast.toast.success(getText('inviteSuccess', email))
|
||||
}
|
||||
} catch (error) {
|
||||
@ -322,7 +317,7 @@ export default function ManagePermissionsModal<
|
||||
assetType={item.type}
|
||||
onChange={setAction}
|
||||
/>
|
||||
<div className="-mx-button-px grow">
|
||||
<div className="grow">
|
||||
<Autocomplete
|
||||
multiple
|
||||
autoFocus
|
||||
|
@ -93,10 +93,15 @@ export default function UpsertDatalinkModal(props: UpsertDatalinkModalProps) {
|
||||
<DatalinkInput dropdownTitle="Type" value={value} setValue={setValue} />
|
||||
</div>
|
||||
<ariaComponents.ButtonGroup className="relative">
|
||||
<ariaComponents.Button variant="submit" isDisabled={!isSubmittable} onPress={doSubmit}>
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="submit"
|
||||
isDisabled={!isSubmittable}
|
||||
onPress={doSubmit}
|
||||
>
|
||||
{getText('create')}
|
||||
</ariaComponents.Button>
|
||||
<ariaComponents.Button variant="cancel" onPress={unsetModal}>
|
||||
<ariaComponents.Button size="medium" variant="cancel" onPress={unsetModal}>
|
||||
{getText('cancel')}
|
||||
</ariaComponents.Button>
|
||||
</ariaComponents.ButtonGroup>
|
||||
|
@ -67,7 +67,13 @@ export default function AuthenticationPage(props: AuthenticationPageProps) {
|
||||
{heading}
|
||||
{children}
|
||||
</div>
|
||||
: <form className={containerClasses} onSubmit={onSubmit}>
|
||||
: <form
|
||||
className={containerClasses}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
onSubmit?.(event)
|
||||
}}
|
||||
>
|
||||
{heading}
|
||||
{children}
|
||||
</form>
|
||||
|
@ -18,8 +18,6 @@ import Input from '#/components/Input'
|
||||
import Link from '#/components/Link'
|
||||
import SubmitButton from '#/components/SubmitButton'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
|
||||
// ======================
|
||||
// === ForgotPassword ===
|
||||
// ======================
|
||||
@ -53,11 +51,7 @@ export default function ForgotPassword() {
|
||||
value={email}
|
||||
setValue={setEmail}
|
||||
/>
|
||||
<SubmitButton
|
||||
text={getText('sendLink')}
|
||||
icon={ArrowRightIcon}
|
||||
onPress={eventModule.submitForm}
|
||||
/>
|
||||
<SubmitButton text={getText('sendLink')} icon={ArrowRightIcon} />
|
||||
</AuthenticationPage>
|
||||
)
|
||||
}
|
||||
|
@ -26,8 +26,6 @@ import Link from '#/components/Link'
|
||||
import SubmitButton from '#/components/SubmitButton'
|
||||
import TextLink from '#/components/TextLink'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
|
||||
// =============
|
||||
// === Login ===
|
||||
// =============
|
||||
@ -146,7 +144,6 @@ export default function Login() {
|
||||
isLoading={isSubmitting}
|
||||
text={getText('login')}
|
||||
icon={ArrowRightIcon}
|
||||
onPress={eventModule.submitForm}
|
||||
/>
|
||||
</form>
|
||||
</AuthenticationPage>
|
||||
|
@ -21,7 +21,6 @@ import Input from '#/components/Input'
|
||||
import Link from '#/components/Link'
|
||||
import SubmitButton from '#/components/SubmitButton'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
import * as string from '#/utilities/string'
|
||||
import * as validation from '#/utilities/validation'
|
||||
@ -124,12 +123,7 @@ export default function Registration() {
|
||||
value={confirmPassword}
|
||||
setValue={setConfirmPassword}
|
||||
/>
|
||||
<SubmitButton
|
||||
isDisabled={isSubmitting}
|
||||
text={getText('register')}
|
||||
icon={CreateAccountIcon}
|
||||
onPress={eventModule.submitForm}
|
||||
/>
|
||||
<SubmitButton isDisabled={isSubmitting} text={getText('register')} icon={CreateAccountIcon} />
|
||||
</AuthenticationPage>
|
||||
)
|
||||
}
|
||||
|
@ -23,7 +23,6 @@ import Input from '#/components/Input'
|
||||
import Link from '#/components/Link'
|
||||
import SubmitButton from '#/components/SubmitButton'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as string from '#/utilities/string'
|
||||
import * as validation from '#/utilities/validation'
|
||||
|
||||
@ -123,11 +122,7 @@ export default function ResetPassword() {
|
||||
value={newPasswordConfirm}
|
||||
setValue={setNewPasswordConfirm}
|
||||
/>
|
||||
<SubmitButton
|
||||
text={getText('reset')}
|
||||
icon={ArrowRightIcon}
|
||||
onPress={eventModule.submitForm}
|
||||
/>
|
||||
<SubmitButton text={getText('reset')} icon={ArrowRightIcon} />
|
||||
</AuthenticationPage>
|
||||
)
|
||||
}
|
||||
|
@ -173,7 +173,7 @@ function DashboardInner(props: DashboardProps) {
|
||||
updateModal((oldModal) => {
|
||||
if (oldModal == null) {
|
||||
const currentPage = projectsStore.getState().page
|
||||
if (array.includes(Object.values(TabType), currentPage)) {
|
||||
if (currentPage === TabType.settings) {
|
||||
setPage(TabType.drive)
|
||||
}
|
||||
}
|
||||
|
@ -361,8 +361,10 @@ export default class LocalBackend extends Backend {
|
||||
name: projectManager.ProjectName(body.projectName),
|
||||
})
|
||||
}
|
||||
const parentId = this.projectManager.getProjectDirectoryPath(id)
|
||||
const result = await this.projectManager.listDirectory(parentId)
|
||||
const parentPath = projectManager.getDirectoryAndName(
|
||||
this.projectManager.getProjectPath(id),
|
||||
).directoryPath
|
||||
const result = await this.projectManager.listDirectory(parentPath)
|
||||
const project = result.flatMap((listedProject) =>
|
||||
(
|
||||
listedProject.type === projectManager.FileSystemEntryType.ProjectEntry &&
|
||||
@ -588,7 +590,7 @@ export default class LocalBackend extends Backend {
|
||||
const from =
|
||||
typeAndId.type !== backend.AssetType.project ?
|
||||
typeAndId.id
|
||||
: this.projectManager.getProjectDirectoryPath(typeAndId.id)
|
||||
: this.projectManager.getProjectPath(typeAndId.id)
|
||||
const fileName = fileInfo.fileName(from)
|
||||
const to = projectManager.joinPath(extractTypeAndId(body.parentDirectoryId).id, fileName)
|
||||
await this.projectManager.moveFile(from, to)
|
||||
@ -629,9 +631,9 @@ export default class LocalBackend extends Backend {
|
||||
await this.projectManager.moveFile(from, to)
|
||||
}
|
||||
|
||||
/** Construct a new path using the given parent directory and a file name. */
|
||||
getProjectDirectoryPath(id: backend.ProjectId) {
|
||||
return this.projectManager.getProjectDirectoryPath(extractTypeAndId(id).id)
|
||||
/** Get the path of a project. */
|
||||
getProjectPath(id: backend.ProjectId) {
|
||||
return this.projectManager.getProjectPath(extractTypeAndId(id).id)
|
||||
}
|
||||
|
||||
/** Construct a new path using the given parent directory and a file name. */
|
||||
|
@ -8,6 +8,7 @@ import * as backend from '#/services/Backend'
|
||||
import * as appBaseUrl from '#/utilities/appBaseUrl'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as newtype from '#/utilities/newtype'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -389,6 +390,13 @@ export default class ProjectManager {
|
||||
socket.close()
|
||||
}
|
||||
|
||||
/** Get the path of a project. */
|
||||
getProjectPath(projectId: UUID) {
|
||||
const projectPath = this.internalProjectPaths.get(projectId)
|
||||
invariant(projectPath, `Unknown project path for project '${projectId}'.`)
|
||||
return projectPath
|
||||
}
|
||||
|
||||
/** Get the directory path of a project. */
|
||||
getProjectDirectoryPath(projectId: UUID) {
|
||||
const projectPath = this.internalProjectPaths.get(projectId)
|
||||
|
@ -270,15 +270,7 @@ export default class RemoteBackend extends Backend {
|
||||
|
||||
/** Resend an invitation to a user. */
|
||||
override async resendInvitation(userEmail: backend.EmailAddress): Promise<void> {
|
||||
const response = await this.post(remoteBackendPaths.INVITATION_PATH, {
|
||||
userEmail,
|
||||
resend: true,
|
||||
})
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return await this.throw(response, 'resendInvitationBackendError')
|
||||
} else {
|
||||
return
|
||||
}
|
||||
await this.inviteUser({ userEmail, resend: true })
|
||||
}
|
||||
|
||||
/** Upload a new profile picture for the current user. */
|
||||
|
@ -27,9 +27,7 @@ export const DELETE_ORGANIZATION_PATH = 'organizations/me'
|
||||
export const UPLOAD_ORGANIZATION_PICTURE_PATH = 'organizations/me/picture'
|
||||
/** Relative HTTP path to the "invite user" endpoint of the Cloud backend API. */
|
||||
export const INVITE_USER_PATH = 'users/invite'
|
||||
/**
|
||||
* Relative HTTP path to the "list invitations" endpoint of the Cloud backend API.
|
||||
*/
|
||||
/** Relative HTTP path to the "list invitations" endpoint of the Cloud backend API. */
|
||||
export const INVITATION_PATH = 'invitations'
|
||||
/** Relative HTTP path to the "create permission" endpoint of the Cloud backend API. */
|
||||
export const CREATE_PERMISSION_PATH = 'permissions'
|
||||
|
@ -117,8 +117,6 @@
|
||||
--label-icons-gap: 0.25rem;
|
||||
/* The transition duration of an arrow transitioning between two directions. */
|
||||
--arrow-transition-duration: 300ms;
|
||||
/* The maximum height of the autocomplete suggestions list. */
|
||||
--autocomplete-suggestions-height: 15rem;
|
||||
/* The horizontal gap between the arrow of a dropdown and the label of the current item. */
|
||||
--dropdown-arrow-gap: 0.25rem;
|
||||
--dropdown-items-height: 15rem;
|
||||
|
@ -1,4 +1,9 @@
|
||||
/** @file Utilities for working with permissions. */
|
||||
import {
|
||||
type AssetPermission,
|
||||
compareAssetPermissions,
|
||||
type User,
|
||||
} from 'enso-common/src/services/Backend'
|
||||
import * as permissions from 'enso-common/src/utilities/permissions'
|
||||
|
||||
export * from 'enso-common/src/utilities/permissions'
|
||||
@ -17,3 +22,28 @@ export const PERMISSION_CLASS_NAME: Readonly<Record<permissions.Permission, stri
|
||||
export const DOCS_CLASS_NAME = 'text-tag-text bg-permission-docs'
|
||||
/** CSS classes for the execute permission. */
|
||||
export const EXEC_CLASS_NAME = 'text-tag-text bg-permission-exec'
|
||||
|
||||
/** Try to find a permission belonging to the user. */
|
||||
export function tryFindSelfPermission(
|
||||
self: User,
|
||||
otherPermissions: readonly AssetPermission[] | null,
|
||||
) {
|
||||
let selfPermission: AssetPermission | null = null
|
||||
for (const permission of otherPermissions ?? []) {
|
||||
// `a >= b` means that `a` does not have more permissions than `b`.
|
||||
if (selfPermission && compareAssetPermissions(selfPermission, permission) >= 0) {
|
||||
continue
|
||||
}
|
||||
if ('user' in permission && permission.user.userId !== self.userId) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
'userGroup' in permission &&
|
||||
(self.userGroups ?? []).every((groupId) => groupId !== permission.userGroup.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
selfPermission = permission
|
||||
}
|
||||
return selfPermission
|
||||
}
|
||||
|
@ -181,7 +181,6 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
|
||||
'chat-thread-list': 'var(--chat-thread-list-height)',
|
||||
'payment-form': 'var(--payment-form-height)',
|
||||
'paragraph-input': 'var(--paragraph-input-height)',
|
||||
'autocomplete-suggestions': 'var(--autocomplete-suggestions-height)',
|
||||
'dropdown-items': 'var(--dropdown-items-height)',
|
||||
'manage-permissions-modal-permissions-list':
|
||||
'var(--manage-permissions-modal-permissions-list-height)',
|
||||
|
@ -169,6 +169,7 @@ export interface User extends UserInfo {
|
||||
/** If `false`, this account is awaiting acceptance from an administrator, and endpoints other than
|
||||
* `usersMe` will not work. */
|
||||
readonly isEnabled: boolean
|
||||
readonly isOrganizationAdmin: boolean
|
||||
readonly rootDirectoryId: DirectoryId
|
||||
readonly profilePicture?: HttpsUrl
|
||||
readonly userGroups: readonly UserGroupId[] | null
|
||||
@ -1017,8 +1018,8 @@ export interface UpdateOrganizationRequestBody {
|
||||
|
||||
/** HTTP request body for the "invite user" endpoint. */
|
||||
export interface InviteUserRequestBody {
|
||||
readonly organizationId: OrganizationId
|
||||
readonly userEmail: EmailAddress
|
||||
readonly resend?: boolean
|
||||
}
|
||||
|
||||
/** HTTP response body for the "list invitations" endpoint. */
|
||||
|
@ -578,6 +578,7 @@
|
||||
"docsColumnName": "Docs",
|
||||
|
||||
"settingsShortcut": "Settings",
|
||||
"closeTabShortcut": "Close Tab",
|
||||
"openShortcut": "Open",
|
||||
"runShortcut": "Execute as Task",
|
||||
"closeShortcut": "Close",
|
||||
|
Loading…
Reference in New Issue
Block a user