mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 21:41:34 +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. */
|
/** Find a "new folder" icon (if any) on the current page. */
|
||||||
export function locateNewFolderIcon(page: test.Locator | test.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. */
|
/** 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. */
|
/** Create a new folder using the icon in the Drive Bar. */
|
||||||
createFolder() {
|
createFolder() {
|
||||||
return this.step('Create folder', (page) =>
|
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,
|
rootDirectoryId: defaultDirectoryId,
|
||||||
userGroups: null,
|
userGroups: null,
|
||||||
plan: backend.Plan.enterprise,
|
plan: backend.Plan.enterprise,
|
||||||
|
isOrganizationAdmin: true,
|
||||||
}
|
}
|
||||||
const defaultOrganization: backend.OrganizationInfo = {
|
const defaultOrganization: backend.OrganizationInfo = {
|
||||||
id: defaultOrganizationId,
|
id: defaultOrganizationId,
|
||||||
@ -259,6 +260,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
|||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
userGroups: null,
|
userGroups: null,
|
||||||
plan: backend.Plan.enterprise,
|
plan: backend.Plan.enterprise,
|
||||||
|
isOrganizationAdmin: true,
|
||||||
...rest,
|
...rest,
|
||||||
}
|
}
|
||||||
users.push(user)
|
users.push(user)
|
||||||
@ -733,6 +735,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
|
|||||||
rootDirectoryId,
|
rootDirectoryId,
|
||||||
userGroups: null,
|
userGroups: null,
|
||||||
plan: backend.Plan.enterprise,
|
plan: backend.Plan.enterprise,
|
||||||
|
isOrganizationAdmin: true,
|
||||||
}
|
}
|
||||||
await route.fulfill({ json: currentUser })
|
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 lineClamp?: number
|
||||||
readonly tooltip?: React.ReactElement | string | false | null
|
readonly tooltip?: React.ReactElement | string | false | null
|
||||||
readonly tooltipDisplay?: visualTooltip.VisualTooltipProps['display']
|
readonly tooltipDisplay?: visualTooltip.VisualTooltipProps['display']
|
||||||
|
readonly tooltipPlacement?: aria.Placement
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TEXT_STYLE = twv.tv({
|
export const TEXT_STYLE = twv.tv({
|
||||||
@ -136,6 +137,7 @@ export const Text = React.forwardRef(function Text(
|
|||||||
elementType: ElementType = 'span',
|
elementType: ElementType = 'span',
|
||||||
tooltip: tooltipElement = children,
|
tooltip: tooltipElement = children,
|
||||||
tooltipDisplay = 'whenOverflowing',
|
tooltipDisplay = 'whenOverflowing',
|
||||||
|
tooltipPlacement,
|
||||||
textSelection,
|
textSelection,
|
||||||
disableLineHeightCompensation = false,
|
disableLineHeightCompensation = false,
|
||||||
...ariaProps
|
...ariaProps
|
||||||
@ -178,6 +180,7 @@ export const Text = React.forwardRef(function Text(
|
|||||||
targetRef: textElementRef,
|
targetRef: textElementRef,
|
||||||
display: tooltipDisplay,
|
display: tooltipDisplay,
|
||||||
children: tooltipElement,
|
children: tooltipElement,
|
||||||
|
...(tooltipPlacement ? { overlayPositionProps: { placement: tooltipPlacement } } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -12,7 +12,7 @@ import * as text from '../Text'
|
|||||||
// =================
|
// =================
|
||||||
|
|
||||||
export const TOOLTIP_STYLES = twv.tv({
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
custom: '',
|
custom: '',
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
/** @file A select menu with a dropdown. */
|
/** @file A select menu with a dropdown. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import CloseIcon from '#/assets/cross.svg'
|
||||||
|
|
||||||
import FocusRing from '#/components/styled/FocusRing'
|
import FocusRing from '#/components/styled/FocusRing'
|
||||||
import Input from '#/components/styled/Input'
|
import Input from '#/components/styled/Input'
|
||||||
|
|
||||||
|
import { Button, Text } from '#/components/AriaComponents'
|
||||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === Constants ===
|
// === Constants ===
|
||||||
@ -39,7 +43,7 @@ interface InternalBaseAutocompleteProps<T> {
|
|||||||
interface InternalSingleAutocompleteProps<T> extends InternalBaseAutocompleteProps<T> {
|
interface InternalSingleAutocompleteProps<T> extends InternalBaseAutocompleteProps<T> {
|
||||||
/** Whether selecting multiple values is allowed. */
|
/** Whether selecting multiple values is allowed. */
|
||||||
readonly multiple?: false
|
readonly multiple?: false
|
||||||
readonly setValues: (value: [T]) => void
|
readonly setValues: (value: readonly [] | readonly [T]) => void
|
||||||
readonly itemsToString?: never
|
readonly itemsToString?: never
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,7 +80,8 @@ export type AutocompleteProps<T> = (
|
|||||||
/** A select menu with a dropdown. */
|
/** A select menu with a dropdown. */
|
||||||
export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
||||||
const { multiple, type = 'text', inputRef: rawInputRef, placeholder, values, setValues } = props
|
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 [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
|
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
|
||||||
const valuesSet = React.useMemo(() => new Set(values), [values])
|
const valuesSet = React.useMemo(() => new Set(values), [values])
|
||||||
@ -181,72 +186,83 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onKeyDown={onKeyDown} className="grow">
|
<div className="relative h-6 w-full">
|
||||||
<FocusRing within>
|
<div
|
||||||
<div className="flex flex-1 rounded-full">
|
onKeyDown={onKeyDown}
|
||||||
{canEditText ?
|
className={twMerge(
|
||||||
<Input
|
'absolute w-full grow transition-colors',
|
||||||
type={type}
|
isDropdownVisible && matchingItems.length !== 0 ?
|
||||||
ref={inputRef}
|
'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'
|
||||||
autoFocus={autoFocus}
|
: '',
|
||||||
size={1}
|
)}
|
||||||
value={text ?? ''}
|
>
|
||||||
autoComplete="off"
|
<FocusRing within>
|
||||||
placeholder={placeholder == null ? placeholder : placeholder}
|
<div className="relative z-1 flex flex-1 rounded-full">
|
||||||
className="text grow rounded-full bg-transparent px-button-x"
|
{canEditText ?
|
||||||
onFocus={() => {
|
<Input
|
||||||
setIsDropdownVisible(true)
|
type={type}
|
||||||
}}
|
ref={inputRef}
|
||||||
onBlur={() => {
|
autoFocus={autoFocus}
|
||||||
window.setTimeout(() => {
|
size={1}
|
||||||
setIsDropdownVisible(false)
|
value={text ?? ''}
|
||||||
})
|
autoComplete="off"
|
||||||
}}
|
placeholder={placeholder == null ? placeholder : placeholder}
|
||||||
onChange={(event) => {
|
className="text grow rounded-full bg-transparent px-button-x"
|
||||||
setIsDropdownVisible(true)
|
onFocus={() => {
|
||||||
setText(event.currentTarget.value === '' ? null : event.currentTarget.value)
|
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
|
</div>
|
||||||
ref={(element) => element?.focus()}
|
</FocusRing>
|
||||||
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
|
<div
|
||||||
className={tailwindMerge.twMerge(
|
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',
|
'relative z-1 grid h-max w-full rounded-b-xl transition-grid-template-rows duration-200',
|
||||||
isDropdownVisible &&
|
isDropdownVisible && matchingItems.length !== 0 ? 'grid-rows-1fr' : 'grid-rows-0fr',
|
||||||
matchingItems.length !== 0 &&
|
|
||||||
'before:border before:border-primary/10',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div className="relative max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-b-xl">
|
||||||
className={tailwindMerge.twMerge(
|
|
||||||
'relative max-h-autocomplete-suggestions w-full overflow-y-auto overflow-x-hidden rounded-default',
|
|
||||||
isDropdownVisible && matchingItems.length !== 0 ? '' : 'h-0',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* FIXME: "Invite" modal does not take into account the height of the autocomplete,
|
{/* FIXME: "Invite" modal does not take into account the height of the autocomplete,
|
||||||
* so the suggestions may go offscreen. */}
|
* so the suggestions may go offscreen. */}
|
||||||
{matchingItems.map((item, index) => (
|
{matchingItems.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={itemToKey(item)}
|
key={itemToKey(item)}
|
||||||
className={tailwindMerge.twMerge(
|
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',
|
valuesSet.has(item) && 'bg-hover-bg',
|
||||||
index === selectedIndex && 'bg-black/5',
|
index === selectedIndex && 'bg-black/5',
|
||||||
)}
|
)}
|
||||||
@ -258,7 +274,9 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
|||||||
toggleValue(item)
|
toggleValue(item)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{itemToString(item)}
|
<Text truncate="1" className="w-full" tooltipPlacement="left">
|
||||||
|
{itemToString(item)}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -204,7 +204,7 @@ function Dropdown<T>(props: DropdownProps<T>, ref: React.ForwardedRef<HTMLDivEle
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={tailwindMerge.twMerge(
|
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 ?
|
isDropdownVisible ?
|
||||||
'before:h-full before:shadow-soft'
|
'before:h-full before:shadow-soft'
|
||||||
: 'before:h-text group-hover:before:bg-hover-bg',
|
: '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 FocusArea from '#/components/styled/FocusArea'
|
||||||
import FocusRing from '#/components/styled/FocusRing'
|
import FocusRing from '#/components/styled/FocusRing'
|
||||||
|
|
||||||
|
import { useBackendQuery } from '#/hooks/backendHooks'
|
||||||
import * as jsonSchema from '#/utilities/jsonSchema'
|
import * as jsonSchema from '#/utilities/jsonSchema'
|
||||||
import * as object from '#/utilities/object'
|
import * as object from '#/utilities/object'
|
||||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||||
@ -38,13 +39,19 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
const { value, setValue } = props
|
const { value, setValue } = props
|
||||||
// The functionality for inputting `enso-secret`s SHOULD be injected using a plugin,
|
// The functionality for inputting `enso-secret`s SHOULD be injected using a plugin,
|
||||||
// but it is more convenient to avoid having plugin infrastructure.
|
// but it is more convenient to avoid having plugin infrastructure.
|
||||||
const remoteBackend = backendProvider.useRemoteBackend()
|
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const [autocompleteText, setAutocompleteText] = React.useState(() =>
|
const [autocompleteText, setAutocompleteText] = React.useState(() =>
|
||||||
typeof value === 'string' ? value : null,
|
typeof value === 'string' ? value : null,
|
||||||
)
|
)
|
||||||
const [selectedChildIndex, setSelectedChildIndex] = React.useState<number | null>(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.
|
// NOTE: `enum` schemas omitted for now as they are not yet used.
|
||||||
if ('const' in schema) {
|
if ('const' in schema) {
|
||||||
@ -57,18 +64,11 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
case 'string': {
|
case 'string': {
|
||||||
if ('format' in schema && schema.format === 'enso-secret') {
|
if ('format' in schema && schema.format === 'enso-secret') {
|
||||||
const isValid = typeof value === 'string' && value !== ''
|
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(
|
children.push(
|
||||||
<div
|
<div
|
||||||
className={tailwindMerge.twMerge(
|
className={tailwindMerge.twMerge(
|
||||||
'grow rounded-default border',
|
'w-60 rounded-default border-0.5',
|
||||||
isValid ? 'border-primary/10' : 'border-red-700/60',
|
isValid ? 'border-primary/20' : 'border-red-700/60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
@ -79,7 +79,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())}
|
matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())}
|
||||||
values={isValid ? [value] : []}
|
values={isValid ? [value] : []}
|
||||||
setValues={(values) => {
|
setValues={(values) => {
|
||||||
setValue(values[0])
|
setValue(values[0] ?? '')
|
||||||
}}
|
}}
|
||||||
text={autocompleteText}
|
text={autocompleteText}
|
||||||
setText={setAutocompleteText}
|
setText={setAutocompleteText}
|
||||||
@ -97,8 +97,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
value={typeof value === 'string' ? value : ''}
|
value={typeof value === 'string' ? value : ''}
|
||||||
size={1}
|
size={1}
|
||||||
className={tailwindMerge.twMerge(
|
className={tailwindMerge.twMerge(
|
||||||
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
|
'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/10' : 'border-red-700/60',
|
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||||
)}
|
)}
|
||||||
placeholder={getText('enterText')}
|
placeholder={getText('enterText')}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@ -125,8 +125,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
value={typeof value === 'number' ? value : ''}
|
value={typeof value === 'number' ? value : ''}
|
||||||
size={1}
|
size={1}
|
||||||
className={tailwindMerge.twMerge(
|
className={tailwindMerge.twMerge(
|
||||||
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
|
'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/10' : 'border-red-700/60',
|
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||||
)}
|
)}
|
||||||
placeholder={getText('enterNumber')}
|
placeholder={getText('enterNumber')}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@ -154,8 +154,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
value={typeof value === 'number' ? value : ''}
|
value={typeof value === 'number' ? value : ''}
|
||||||
size={1}
|
size={1}
|
||||||
className={tailwindMerge.twMerge(
|
className={tailwindMerge.twMerge(
|
||||||
'focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only',
|
'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/10' : 'border-red-700/60',
|
getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60',
|
||||||
)}
|
)}
|
||||||
placeholder={getText('enterInteger')}
|
placeholder={getText('enterInteger')}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
@ -195,19 +195,13 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
)
|
)
|
||||||
if (jsonSchema.constantValue(defs, schema).length !== 1) {
|
if (jsonSchema.constantValue(defs, schema).length !== 1) {
|
||||||
children.push(
|
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) => {
|
{propertyDefinitions.map((definition) => {
|
||||||
const { key, schema: childSchema } = definition
|
const { key, schema: childSchema } = definition
|
||||||
const isOptional = !requiredProperties.includes(key)
|
const isOptional = !requiredProperties.includes(key)
|
||||||
return jsonSchema.constantValue(defs, childSchema).length === 1 ?
|
return jsonSchema.constantValue(defs, childSchema).length === 1 ?
|
||||||
null
|
null
|
||||||
: <div
|
: <>
|
||||||
key={key}
|
|
||||||
className="flex flex-wrap items-center gap-2"
|
|
||||||
{...('description' in childSchema ?
|
|
||||||
{ title: String(childSchema.description) }
|
|
||||||
: {})}
|
|
||||||
>
|
|
||||||
<FocusArea active={isOptional} direction="horizontal">
|
<FocusArea active={isOptional} direction="horizontal">
|
||||||
{(innerProps) => {
|
{(innerProps) => {
|
||||||
const isPresent = value != null && key in value
|
const isPresent = value != null && key in value
|
||||||
@ -218,7 +212,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
isDisabled={!isOptional}
|
isDisabled={!isOptional}
|
||||||
isActive={!isOptional || isPresent}
|
isActive={!isOptional || isPresent}
|
||||||
className={tailwindMerge.twMerge(
|
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',
|
isOptional && 'hover:bg-hover-bg',
|
||||||
)}
|
)}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@ -254,45 +248,47 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
}}
|
}}
|
||||||
</FocusArea>
|
</FocusArea>
|
||||||
{value != null && key in value && (
|
{value != null && key in value && (
|
||||||
<JSONSchemaInput
|
<div className="col-start-2">
|
||||||
readOnly={readOnly}
|
<JSONSchemaInput
|
||||||
defs={defs}
|
readOnly={readOnly}
|
||||||
schema={childSchema}
|
defs={defs}
|
||||||
path={`${path}/properties/${key}`}
|
schema={childSchema}
|
||||||
getValidator={getValidator}
|
path={`${path}/properties/${key}`}
|
||||||
// This is SAFE, as `value` is an untyped object.
|
getValidator={getValidator}
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// This is SAFE, as `value` is an untyped object.
|
||||||
value={(value as Record<string, unknown>)[key] ?? null}
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
setValue={(newValue) => {
|
value={(value as Record<string, unknown>)[key] ?? null}
|
||||||
setValue((oldValue) => {
|
setValue={(newValue) => {
|
||||||
if (typeof newValue === 'function') {
|
setValue((oldValue) => {
|
||||||
const unsafeValue: unknown = newValue(
|
if (typeof newValue === 'function') {
|
||||||
// This is SAFE; but there is no way to tell TypeScript that an object
|
const unsafeValue: unknown = newValue(
|
||||||
// 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 &&
|
|
||||||
// This is SAFE; but there is no way to tell TypeScript that an object
|
// This is SAFE; but there is no way to tell TypeScript that an object
|
||||||
// has an index signature.
|
// has an index signature.
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
(oldValue as Readonly<Record<string, unknown>>)[key] ===
|
(oldValue as Readonly<Record<string, unknown>>)[key] ?? null,
|
||||||
newValue
|
)
|
||||||
) ?
|
// The value MAY be `null`, but it is better than the value being a
|
||||||
oldValue
|
// function (which is *never* the intended result).
|
||||||
: { ...oldValue, [key]: newValue }
|
// 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>,
|
</div>,
|
||||||
)
|
)
|
||||||
|
@ -44,6 +44,7 @@ export const ACTION_TO_TEXT_ID: Readonly<
|
|||||||
>
|
>
|
||||||
> = {
|
> = {
|
||||||
settings: 'settingsShortcut',
|
settings: 'settingsShortcut',
|
||||||
|
closeTab: 'closeTabShortcut',
|
||||||
open: 'openShortcut',
|
open: 'openShortcut',
|
||||||
run: 'runShortcut',
|
run: 'runShortcut',
|
||||||
close: 'closeShortcut',
|
close: 'closeShortcut',
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
/** @file A styled submit button. */
|
/** @file A styled submit button. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import type * as aria from '#/components/aria'
|
import { Button } from '#/components/AriaComponents'
|
||||||
import * as ariaComponents from '#/components/AriaComponents'
|
import { submitForm } from '#/utilities/event'
|
||||||
|
|
||||||
// ====================
|
// ====================
|
||||||
// === SubmitButton ===
|
// === SubmitButton ===
|
||||||
@ -14,15 +14,14 @@ export interface SubmitButtonProps {
|
|||||||
readonly isDisabled?: boolean
|
readonly isDisabled?: boolean
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly icon: string
|
readonly icon: string
|
||||||
readonly onPress: (event: aria.PressEvent) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A styled submit button. */
|
/** A styled submit button. */
|
||||||
export default function SubmitButton(props: SubmitButtonProps) {
|
export default function SubmitButton(props: SubmitButtonProps) {
|
||||||
const { isDisabled = false, text, icon, onPress, isLoading } = props
|
const { isDisabled = false, text, icon, isLoading } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ariaComponents.Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="submit"
|
variant="submit"
|
||||||
@ -33,9 +32,9 @@ export default function SubmitButton(props: SubmitButtonProps) {
|
|||||||
icon={icon}
|
icon={icon}
|
||||||
iconPosition="end"
|
iconPosition="end"
|
||||||
rounded="full"
|
rounded="full"
|
||||||
onPress={onPress}
|
onPress={submitForm}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</ariaComponents.Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -37,6 +37,7 @@ import * as backendModule from '#/services/Backend'
|
|||||||
import * as localBackend from '#/services/LocalBackend'
|
import * as localBackend from '#/services/LocalBackend'
|
||||||
import * as projectManager from '#/services/ProjectManager'
|
import * as projectManager from '#/services/ProjectManager'
|
||||||
|
|
||||||
|
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
|
||||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||||
import * as dateTime from '#/utilities/dateTime'
|
import * as dateTime from '#/utilities/dateTime'
|
||||||
import * as download from '#/utilities/download'
|
import * as download from '#/utilities/download'
|
||||||
@ -49,6 +50,7 @@ import * as permissions from '#/utilities/permissions'
|
|||||||
import * as set from '#/utilities/set'
|
import * as set from '#/utilities/set'
|
||||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||||
import Visibility from '#/utilities/Visibility'
|
import Visibility from '#/utilities/Visibility'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === Constants ===
|
// === Constants ===
|
||||||
@ -154,6 +156,14 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
const openProjectMutate = openProjectMutation.mutateAsync
|
const openProjectMutate = openProjectMutation.mutateAsync
|
||||||
const closeProjectMutate = closeProjectMutation.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 setSelected = useEventCallback((newSelected: boolean) => {
|
||||||
const { selectedKeys } = driveStore.getState()
|
const { selectedKeys } = driveStore.getState()
|
||||||
setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected))
|
setSelectedKeys(set.withPresence(selectedKeys, item.key, newSelected))
|
||||||
@ -798,7 +808,12 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
if (rowState.isEditingName) {
|
if (
|
||||||
|
rowState.isEditingName ||
|
||||||
|
(projectState !== backendModule.ProjectState.closed &&
|
||||||
|
projectState !== backendModule.ProjectState.created &&
|
||||||
|
projectState != null)
|
||||||
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
} else {
|
} else {
|
||||||
props.onDragStart?.(event)
|
props.onDragStart?.(event)
|
||||||
|
@ -8,13 +8,13 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
|||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
import * as aria from '#/components/aria'
|
|
||||||
import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
||||||
import FocusArea from '#/components/styled/FocusArea'
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
|
|
||||||
import type Backend from '#/services/Backend'
|
import type Backend from '#/services/Backend'
|
||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
|
import { Text } from '#/components/AriaComponents'
|
||||||
import * as object from '#/utilities/object'
|
import * as object from '#/utilities/object'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
@ -85,7 +85,7 @@ export default function Permission(props: PermissionProps) {
|
|||||||
return (
|
return (
|
||||||
<FocusArea active={!isDisabled} direction="horizontal">
|
<FocusArea active={!isDisabled} direction="horizontal">
|
||||||
{(innerProps) => (
|
{(innerProps) => (
|
||||||
<div className="flex items-center gap-user-permission" {...innerProps}>
|
<div className="flex w-full items-center gap-user-permission" {...innerProps}>
|
||||||
<PermissionSelector
|
<PermissionSelector
|
||||||
showDelete
|
showDelete
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
@ -100,7 +100,7 @@ export default function Permission(props: PermissionProps) {
|
|||||||
doDelete(backendModule.getAssetPermissionId(permission))
|
doDelete(backendModule.getAssetPermissionId(permission))
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<aria.Text className="text">{backendModule.getAssetPermissionName(permission)}</aria.Text>
|
<Text truncate="1">{backendModule.getAssetPermissionName(permission)}</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</FocusArea>
|
</FocusArea>
|
||||||
|
@ -18,7 +18,7 @@ export default function Separator(props: SeparatorProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
!hidden && (
|
!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 * as aria from '#/components/aria'
|
||||||
import Button from '#/components/styled/Button'
|
import Button from '#/components/styled/Button'
|
||||||
import FocusRing from '#/components/styled/FocusRing'
|
import FocusRing from '#/components/styled/FocusRing'
|
||||||
|
import { twMerge } from '#/utilities/tailwindMerge'
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// === SettingsInput ===
|
// === SettingsInput ===
|
||||||
@ -61,8 +62,10 @@ function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLIn
|
|||||||
{...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()(
|
{...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()(
|
||||||
{
|
{
|
||||||
ref,
|
ref,
|
||||||
className:
|
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',
|
'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 }),
|
...(type == null ? {} : { type: isShowingPassword ? 'text' : type }),
|
||||||
disabled: isDisabled,
|
disabled: isDisabled,
|
||||||
size: 1,
|
size: 1,
|
||||||
|
@ -10,6 +10,7 @@ import ArrowLeftIcon from '#/assets/arrow_left.svg'
|
|||||||
import ArrowRightIcon from '#/assets/arrow_right.svg'
|
import ArrowRightIcon from '#/assets/arrow_right.svg'
|
||||||
import CameraIcon from '#/assets/camera.svg'
|
import CameraIcon from '#/assets/camera.svg'
|
||||||
import CloseIcon from '#/assets/close.svg'
|
import CloseIcon from '#/assets/close.svg'
|
||||||
|
import CloseTabIcon from '#/assets/close_tab.svg'
|
||||||
import CloudToIcon from '#/assets/cloud_to.svg'
|
import CloudToIcon from '#/assets/cloud_to.svg'
|
||||||
import CopyIcon from '#/assets/copy.svg'
|
import CopyIcon from '#/assets/copy.svg'
|
||||||
import CopyAsPathIcon from '#/assets/copy_as_path.svg'
|
import CopyAsPathIcon from '#/assets/copy_as_path.svg'
|
||||||
@ -52,6 +53,8 @@ export function createBindings() {
|
|||||||
|
|
||||||
export const BINDINGS = inputBindings.defineBindings({
|
export const BINDINGS = inputBindings.defineBindings({
|
||||||
settings: { name: 'Settings', bindings: ['Mod+,'], icon: SettingsIcon },
|
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 },
|
open: { name: 'Open', bindings: ['Enter'], icon: OpenIcon },
|
||||||
run: { name: 'Execute as Task', bindings: ['Shift+Enter'], icon: Play2Icon },
|
run: { name: 'Execute as Task', bindings: ['Shift+Enter'], icon: Play2Icon },
|
||||||
close: { name: 'Close', bindings: [], icon: CloseIcon },
|
close: { name: 'Close', bindings: [], icon: CloseIcon },
|
||||||
|
@ -168,28 +168,20 @@ export function useBackendQuery<Method extends backendQuery.BackendMethods>(
|
|||||||
method: Method,
|
method: Method,
|
||||||
args: Parameters<Backend[Method]>,
|
args: Parameters<Backend[Method]>,
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
reactQuery.UseQueryOptions<
|
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
|
||||||
Awaited<ReturnType<Backend[Method]>>,
|
'queryFn' | 'queryKey'
|
||||||
Error,
|
> &
|
||||||
Awaited<ReturnType<Backend[Method]>>,
|
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||||
readonly unknown[]
|
|
||||||
>,
|
|
||||||
'queryFn'
|
|
||||||
>,
|
|
||||||
): reactQuery.UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
|
): reactQuery.UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
|
||||||
export function useBackendQuery<Method extends backendQuery.BackendMethods>(
|
export function useBackendQuery<Method extends backendQuery.BackendMethods>(
|
||||||
backend: Backend | null,
|
backend: Backend | null,
|
||||||
method: Method,
|
method: Method,
|
||||||
args: Parameters<Backend[Method]>,
|
args: Parameters<Backend[Method]>,
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
reactQuery.UseQueryOptions<
|
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
|
||||||
Awaited<ReturnType<Backend[Method]>>,
|
'queryFn' | 'queryKey'
|
||||||
Error,
|
> &
|
||||||
Awaited<ReturnType<Backend[Method]>>,
|
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||||
readonly unknown[]
|
|
||||||
>,
|
|
||||||
'queryFn'
|
|
||||||
>,
|
|
||||||
): reactQuery.UseQueryResult<
|
): reactQuery.UseQueryResult<
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
Awaited<ReturnType<Backend[Method]>> | undefined
|
Awaited<ReturnType<Backend[Method]>> | undefined
|
||||||
@ -200,21 +192,12 @@ export function useBackendQuery<Method extends backendQuery.BackendMethods>(
|
|||||||
method: Method,
|
method: Method,
|
||||||
args: Parameters<Backend[Method]>,
|
args: Parameters<Backend[Method]>,
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
reactQuery.UseQueryOptions<
|
reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>,
|
||||||
Awaited<ReturnType<Backend[Method]>>,
|
'queryFn' | 'queryKey'
|
||||||
Error,
|
> &
|
||||||
Awaited<ReturnType<Backend[Method]>>,
|
Partial<Pick<reactQuery.UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
|
||||||
readonly unknown[]
|
|
||||||
>,
|
|
||||||
'queryFn'
|
|
||||||
>,
|
|
||||||
) {
|
) {
|
||||||
return reactQuery.useQuery<
|
return reactQuery.useQuery<Awaited<ReturnType<Backend[Method]>>>({
|
||||||
Awaited<ReturnType<Backend[Method]>>,
|
|
||||||
Error,
|
|
||||||
Awaited<ReturnType<Backend[Method]>>,
|
|
||||||
readonly unknown[]
|
|
||||||
>({
|
|
||||||
...options,
|
...options,
|
||||||
...backendQuery.backendQueryOptions(backend, method, args, options?.queryKey),
|
...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
|
// 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 ContextMenu from '#/components/ContextMenu'
|
||||||
import ContextMenus from '#/components/ContextMenus'
|
import ContextMenus from '#/components/ContextMenus'
|
||||||
|
import { useSyncRef } from '#/hooks/syncRefHooks'
|
||||||
|
|
||||||
// ======================
|
// ======================
|
||||||
// === contextMenuRef ===
|
// === contextMenuRef ===
|
||||||
@ -16,44 +17,52 @@ export function useContextMenuRef(
|
|||||||
key: string,
|
key: string,
|
||||||
label: string,
|
label: string,
|
||||||
createEntries: (position: Pick<React.MouseEvent, 'pageX' | 'pageY'>) => React.JSX.Element | null,
|
createEntries: (position: Pick<React.MouseEvent, 'pageX' | 'pageY'>) => React.JSX.Element | null,
|
||||||
|
options: { enabled?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const { setModal } = modalProvider.useSetModal()
|
const { setModal } = modalProvider.useSetModal()
|
||||||
const createEntriesRef = React.useRef(createEntries)
|
const createEntriesRef = React.useRef(createEntries)
|
||||||
createEntriesRef.current = createEntries
|
createEntriesRef.current = createEntries
|
||||||
|
const optionsRef = useSyncRef(options)
|
||||||
const cleanupRef = React.useRef(() => {})
|
const cleanupRef = React.useRef(() => {})
|
||||||
const [contextMenuRef] = React.useState(() => (element: HTMLElement | null) => {
|
const contextMenuRef = React.useMemo(
|
||||||
cleanupRef.current()
|
() => (element: HTMLElement | null) => {
|
||||||
if (element == null) {
|
cleanupRef.current()
|
||||||
cleanupRef.current = () => {}
|
if (element == null) {
|
||||||
} else {
|
cleanupRef.current = () => {}
|
||||||
const onContextMenu = (event: MouseEvent) => {
|
} else {
|
||||||
const position = { pageX: event.pageX, pageY: event.pageY }
|
const onContextMenu = (event: MouseEvent) => {
|
||||||
const children = createEntriesRef.current(position)
|
const { enabled = true } = optionsRef.current
|
||||||
if (children != null) {
|
if (enabled) {
|
||||||
event.preventDefault()
|
const position = { pageX: event.pageX, pageY: event.pageY }
|
||||||
event.stopPropagation()
|
const children = createEntriesRef.current(position)
|
||||||
setModal(
|
if (children != null) {
|
||||||
<ContextMenus
|
event.preventDefault()
|
||||||
ref={(contextMenusElement) => {
|
event.stopPropagation()
|
||||||
if (contextMenusElement != null) {
|
setModal(
|
||||||
const rect = contextMenusElement.getBoundingClientRect()
|
<ContextMenus
|
||||||
position.pageX = rect.left
|
ref={(contextMenusElement) => {
|
||||||
position.pageY = rect.top
|
if (contextMenusElement != null) {
|
||||||
}
|
const rect = contextMenusElement.getBoundingClientRect()
|
||||||
}}
|
position.pageX = rect.left
|
||||||
key={key}
|
position.pageY = rect.top
|
||||||
event={event}
|
}
|
||||||
>
|
}}
|
||||||
<ContextMenu aria-label={label}>{children}</ContextMenu>
|
key={key}
|
||||||
</ContextMenus>,
|
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 = () => {
|
[key, label, optionsRef, setModal],
|
||||||
element.removeEventListener('contextmenu', onContextMenu)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return contextMenuRef
|
return contextMenuRef
|
||||||
}
|
}
|
||||||
|
@ -144,11 +144,11 @@ export function useOpenProjectMutation() {
|
|||||||
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.openInProgress } })
|
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.openInProgress } })
|
||||||
|
|
||||||
void client.cancelQueries({ queryKey })
|
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 } })
|
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.closing } })
|
||||||
|
|
||||||
void client.cancelQueries({ queryKey })
|
void client.cancelQueries({ queryKey })
|
||||||
void client.invalidateQueries({ queryKey })
|
|
||||||
},
|
},
|
||||||
onSuccess: (_, { id }) =>
|
onSuccess: (_, { id }) =>
|
||||||
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
|
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
|
||||||
|
@ -88,7 +88,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
category !== Category.cloud && category !== Category.local ? null
|
category !== Category.cloud && category !== Category.local ? null
|
||||||
: isCloud ? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}`
|
: isCloud ? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}`
|
||||||
: asset.type === backendModule.AssetType.project ?
|
: asset.type === backendModule.AssetType.project ?
|
||||||
localBackend?.getProjectDirectoryPath(asset.id) ?? null
|
localBackend?.getProjectPath(asset.id) ?? null
|
||||||
: localBackendModule.extractTypeAndId(asset.id).id
|
: localBackendModule.extractTypeAndId(asset.id).id
|
||||||
const copyMutation = copyHooks.useCopy({ copyText: path ?? '' })
|
const copyMutation = copyHooks.useCopy({ copyText: path ?? '' })
|
||||||
|
|
||||||
|
@ -86,7 +86,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
|||||||
const path =
|
const path =
|
||||||
isCloud ? null
|
isCloud ? null
|
||||||
: item.item.type === backendModule.AssetType.project ?
|
: item.item.type === backendModule.AssetType.project ?
|
||||||
localBackend?.getProjectDirectoryPath(item.item.id) ?? null
|
localBackend?.getProjectPath(item.item.id) ?? null
|
||||||
: localBackendModule.extractTypeAndId(item.item.id).id
|
: localBackendModule.extractTypeAndId(item.item.id).id
|
||||||
|
|
||||||
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
|
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
|
||||||
|
@ -310,102 +310,109 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
>
|
>
|
||||||
<div className="h-[32px]" />
|
<div className="h-[32px]" />
|
||||||
|
|
||||||
{areSuggestionsVisible && (
|
<div
|
||||||
<div className="relative mt-3 flex flex-col gap-3">
|
className={tailwindMerge.twMerge(
|
||||||
{/* Tags (`name:`, `modified:`, etc.) */}
|
'grid transition-grid-template-rows duration-200',
|
||||||
<Tags
|
areSuggestionsVisible ? 'grid-rows-1fr' : 'grid-rows-0fr',
|
||||||
isCloud={isCloud}
|
)}
|
||||||
querySource={querySource}
|
>
|
||||||
query={query}
|
<div className="overflow-y-auto overflow-x-hidden">
|
||||||
setQuery={setQuery}
|
<div className="relative mt-3 flex flex-col gap-3">
|
||||||
/>
|
{/* Tags (`name:`, `modified:`, etc.) */}
|
||||||
{/* Asset labels */}
|
<Tags
|
||||||
{isCloud && labels.length !== 0 && (
|
isCloud={isCloud}
|
||||||
<div
|
querySource={querySource}
|
||||||
data-testid="asset-search-labels"
|
query={query}
|
||||||
className="pointer-events-auto flex gap-2 px-1.5"
|
setQuery={setQuery}
|
||||||
>
|
/>
|
||||||
{[...labels]
|
{/* Asset labels */}
|
||||||
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
|
{isCloud && labels.length !== 0 && (
|
||||||
.map((label) => {
|
<div
|
||||||
const negated = query.negativeLabels.some((term) =>
|
data-testid="asset-search-labels"
|
||||||
array.shallowEqual(term, [label.value]),
|
className="pointer-events-auto flex gap-2 px-1.5"
|
||||||
)
|
|
||||||
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">
|
{[...labels]
|
||||||
{suggestion.render()}
|
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
|
||||||
</ariaComponents.Text>
|
.map((label) => {
|
||||||
</aria.Button>
|
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>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SvgMask
|
<SvgMask
|
||||||
src={FindIcon}
|
src={FindIcon}
|
||||||
|
@ -78,6 +78,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
|||||||
<ariaComponents.DialogTrigger>
|
<ariaComponents.DialogTrigger>
|
||||||
<ariaComponents.TooltipTrigger>
|
<ariaComponents.TooltipTrigger>
|
||||||
<ariaComponents.Button
|
<ariaComponents.Button
|
||||||
|
size="medium"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
aria-label={getText('compareWithLatest')}
|
aria-label={getText('compareWithLatest')}
|
||||||
icon={CompareIcon}
|
icon={CompareIcon}
|
||||||
@ -94,6 +95,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
|||||||
<ariaComponents.ButtonGroup>
|
<ariaComponents.ButtonGroup>
|
||||||
<ariaComponents.TooltipTrigger>
|
<ariaComponents.TooltipTrigger>
|
||||||
<ariaComponents.Button
|
<ariaComponents.Button
|
||||||
|
size="medium"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
aria-label={getText('restoreThisVersion')}
|
aria-label={getText('restoreThisVersion')}
|
||||||
icon={RestoreIcon}
|
icon={RestoreIcon}
|
||||||
@ -109,6 +111,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
|||||||
</ariaComponents.TooltipTrigger>
|
</ariaComponents.TooltipTrigger>
|
||||||
<ariaComponents.TooltipTrigger>
|
<ariaComponents.TooltipTrigger>
|
||||||
<ariaComponents.Button
|
<ariaComponents.Button
|
||||||
|
size="medium"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
aria-label={getText('duplicateThisVersion')}
|
aria-label={getText('duplicateThisVersion')}
|
||||||
icon={DuplicateIcon}
|
icon={DuplicateIcon}
|
||||||
@ -137,6 +140,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
|||||||
{isProject && (
|
{isProject && (
|
||||||
<ariaComponents.TooltipTrigger>
|
<ariaComponents.TooltipTrigger>
|
||||||
<ariaComponents.Button
|
<ariaComponents.Button
|
||||||
|
size="medium"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
aria-label={getText('restoreThisVersion')}
|
aria-label={getText('restoreThisVersion')}
|
||||||
icon={RestoreIcon}
|
icon={RestoreIcon}
|
||||||
@ -149,6 +153,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
|||||||
{isProject && (
|
{isProject && (
|
||||||
<ariaComponents.TooltipTrigger>
|
<ariaComponents.TooltipTrigger>
|
||||||
<ariaComponents.Button
|
<ariaComponents.Button
|
||||||
|
size="medium"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
aria-label={getText('duplicateThisVersion')}
|
aria-label={getText('duplicateThisVersion')}
|
||||||
icon={DuplicateIcon}
|
icon={DuplicateIcon}
|
||||||
|
@ -97,60 +97,53 @@ export default function Editor(props: EditorProps) {
|
|||||||
networkMode: project.type === backendModule.BackendType.remote ? 'online' : 'always',
|
networkMode: project.type === backendModule.BackendType.remote ? 'online' : 'always',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isOpeningFailed) {
|
const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
const shouldRefetch = !projectQuery.isError && !projectQuery.isLoading
|
||||||
return (
|
|
||||||
|
if (!isOpeningFailed && !isOpening && isProjectClosed && shouldRefetch) {
|
||||||
|
startProject(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isOpeningFailed ?
|
||||||
<errorBoundary.ErrorDisplay
|
<errorBoundary.ErrorDisplay
|
||||||
error={openingError}
|
error={openingError}
|
||||||
resetErrorBoundary={() => {
|
resetErrorBoundary={() => {
|
||||||
startProject(project)
|
startProject(project)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
: <div
|
||||||
}
|
className={twMerge.twJoin('contents', hidden && 'hidden')}
|
||||||
|
data-testvalue={project.id}
|
||||||
const isProjectClosed = projectQuery.data?.state.type === backendModule.ProjectState.closed
|
data-testid="editor"
|
||||||
const shouldRefetch = !(projectQuery.isError || projectQuery.isLoading)
|
>
|
||||||
|
{(() => {
|
||||||
if (!isOpening && isProjectClosed && shouldRefetch) {
|
if (projectQuery.isError) {
|
||||||
startProject(project)
|
return (
|
||||||
}
|
<errorBoundary.ErrorDisplay
|
||||||
|
error={projectQuery.error}
|
||||||
return (
|
resetErrorBoundary={() => projectQuery.refetch()}
|
||||||
<div
|
/>
|
||||||
className={twMerge.twJoin('contents', hidden && 'hidden')}
|
)
|
||||||
data-testvalue={project.id}
|
} else if (
|
||||||
data-testid="editor"
|
projectQuery.isLoading ||
|
||||||
>
|
projectQuery.data?.state.type !== backendModule.ProjectState.opened
|
||||||
{(() => {
|
) {
|
||||||
if (projectQuery.isError) {
|
return <suspense.Loader loaderProps={{ minHeight: 'full' }} />
|
||||||
return (
|
} else {
|
||||||
<errorBoundary.ErrorDisplay
|
return (
|
||||||
error={projectQuery.error}
|
<errorBoundary.ErrorBoundary>
|
||||||
resetErrorBoundary={() => projectQuery.refetch()}
|
<suspense.Suspense>
|
||||||
/>
|
<EditorInternal
|
||||||
)
|
{...props}
|
||||||
} else if (
|
openedProject={projectQuery.data}
|
||||||
projectQuery.isLoading ||
|
backendType={project.type}
|
||||||
projectQuery.data?.state.type !== backendModule.ProjectState.opened
|
/>
|
||||||
) {
|
</suspense.Suspense>
|
||||||
return <suspense.Loader loaderProps={{ minHeight: 'full' }} />
|
</errorBoundary.ErrorBoundary>
|
||||||
} else {
|
)
|
||||||
return (
|
}
|
||||||
<errorBoundary.ErrorBoundary>
|
})()}
|
||||||
<suspense.Suspense>
|
</div>
|
||||||
<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 type Backend from '#/services/Backend'
|
||||||
|
|
||||||
|
import AssetEventType from '#/events/AssetEventType'
|
||||||
|
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
|
||||||
import * as array from '#/utilities/array'
|
import * as array from '#/utilities/array'
|
||||||
import type AssetQuery from '#/utilities/AssetQuery'
|
import type AssetQuery from '#/utilities/AssetQuery'
|
||||||
import * as drag from '#/utilities/drag'
|
import * as drag from '#/utilities/drag'
|
||||||
@ -44,9 +46,14 @@ export default function Labels(props: LabelsProps) {
|
|||||||
const currentNegativeLabels = query.negativeLabels
|
const currentNegativeLabels = query.negativeLabels
|
||||||
const { setModal } = modalProvider.useSetModal()
|
const { setModal } = modalProvider.useSetModal()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
const dispatchAssetEvent = useDispatchAssetEvent()
|
||||||
const labels = backendHooks.useBackendListTags(backend) ?? []
|
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 (
|
return (
|
||||||
<FocusArea direction="vertical">
|
<FocusArea direction="vertical">
|
||||||
|
@ -55,6 +55,7 @@ export default function KeyboardShortcutsSettingsSection() {
|
|||||||
<>
|
<>
|
||||||
<ariaComponents.ButtonGroup>
|
<ariaComponents.ButtonGroup>
|
||||||
<ariaComponents.Button
|
<ariaComponents.Button
|
||||||
|
size="medium"
|
||||||
variant="bar"
|
variant="bar"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setModal(
|
setModal(
|
||||||
|
@ -57,30 +57,37 @@ export default function MembersSettingsSection() {
|
|||||||
const seatsLeft =
|
const seatsLeft =
|
||||||
isUnderPaywall ? feature.meta.maxSeats - (members.length + invitations.length) : null
|
isUnderPaywall ? feature.meta.maxSeats - (members.length + invitations.length) : null
|
||||||
const seatsTotal = feature.meta.maxSeats
|
const seatsTotal = feature.meta.maxSeats
|
||||||
|
const isAdmin = user.isOrganizationAdmin
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ariaComponents.ButtonGroup>
|
{isAdmin && (
|
||||||
<ariaComponents.DialogTrigger>
|
<ariaComponents.ButtonGroup>
|
||||||
<ariaComponents.Button variant="bar" rounded="full" size="medium">
|
<ariaComponents.DialogTrigger>
|
||||||
{getText('inviteMembers')}
|
<ariaComponents.Button variant="bar" rounded="full" size="medium">
|
||||||
</ariaComponents.Button>
|
{getText('inviteMembers')}
|
||||||
|
</ariaComponents.Button>
|
||||||
|
|
||||||
<InviteUsersModal />
|
<InviteUsersModal />
|
||||||
</ariaComponents.DialogTrigger>
|
</ariaComponents.DialogTrigger>
|
||||||
|
|
||||||
{seatsLeft != null && (
|
{seatsLeft != null && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ariaComponents.Text>
|
<ariaComponents.Text>
|
||||||
{seatsLeft <= 0 ?
|
{seatsLeft <= 0 ?
|
||||||
getText('noSeatsLeft')
|
getText('noSeatsLeft')
|
||||||
: getText('seatsLeft', seatsLeft, seatsTotal)}
|
: getText('seatsLeft', seatsLeft, seatsTotal)}
|
||||||
</ariaComponents.Text>
|
</ariaComponents.Text>
|
||||||
|
|
||||||
<paywall.PaywallDialogButton feature="inviteUserFull" variant="link" showIcon={false} />
|
<paywall.PaywallDialogButton
|
||||||
</div>
|
feature="inviteUserFull"
|
||||||
)}
|
variant="link"
|
||||||
</ariaComponents.ButtonGroup>
|
showIcon={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ariaComponents.ButtonGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
<table className="table-fixed self-start rounded-rows">
|
<table className="table-fixed self-start rounded-rows">
|
||||||
<thead>
|
<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">
|
<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">
|
<div className="flex flex-col">
|
||||||
{getText('active')}
|
{getText('active')}
|
||||||
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
|
{member.email !== user.email && isAdmin && (
|
||||||
<RemoveMemberButton backend={backend} userId={member.userId} />
|
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
|
||||||
</ariaComponents.ButtonGroup>
|
<RemoveMemberButton backend={backend} userId={member.userId} />
|
||||||
|
</ariaComponents.ButtonGroup>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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">
|
<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">
|
<div className="flex flex-col">
|
||||||
{getText('pendingInvitation')}
|
{getText('pendingInvitation')}
|
||||||
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
|
{isAdmin && (
|
||||||
<ariaComponents.CopyButton
|
<ariaComponents.ButtonGroup gap="small" className="mt-0.5">
|
||||||
size="custom"
|
<ariaComponents.CopyButton
|
||||||
copyText={`enso://auth/registration?organization_id=${invitation.organizationId}`}
|
size="custom"
|
||||||
aria-label={getText('copyInviteLink')}
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
copyIcon={false}
|
copyText={`enso://auth/registration?=${new URLSearchParams({ organization_id: invitation.organizationId }).toString()}`}
|
||||||
>
|
aria-label={getText('copyInviteLink')}
|
||||||
{getText('copyInviteLink')}
|
copyIcon={false}
|
||||||
</ariaComponents.CopyButton>
|
>
|
||||||
|
{getText('copyInviteLink')}
|
||||||
|
</ariaComponents.CopyButton>
|
||||||
|
|
||||||
<ResendInvitationButton invitation={invitation} backend={backend} />
|
<ResendInvitationButton invitation={invitation} backend={backend} />
|
||||||
|
|
||||||
<RemoveInvitationButton backend={backend} email={invitation.userEmail} />
|
<RemoveInvitationButton backend={backend} email={invitation.userEmail} />
|
||||||
</ariaComponents.ButtonGroup>
|
</ariaComponents.ButtonGroup>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -157,9 +169,8 @@ function ResendInvitationButton(props: ResendInvitationButtonProps) {
|
|||||||
const { invitation, backend } = props
|
const { invitation, backend } = props
|
||||||
|
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const resendMutation = reactQuery.useMutation({
|
const resendMutation = backendHooks.useBackendMutation(backend, 'resendInvitation', {
|
||||||
mutationKey: ['resendInvitation', invitation.userEmail],
|
mutationKey: [invitation.userEmail],
|
||||||
mutationFn: (email: backendModule.EmailAddress) => backend.resendInvitation(email),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -168,7 +179,7 @@ function ResendInvitationButton(props: ResendInvitationButtonProps) {
|
|||||||
size="custom"
|
size="custom"
|
||||||
loading={resendMutation.isPending}
|
loading={resendMutation.isPending}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
resendMutation.mutate(invitation.userEmail)
|
resendMutation.mutate([invitation.userEmail])
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getText('resend')}
|
{getText('resend')}
|
||||||
@ -223,7 +234,7 @@ function RemoveInvitationButton(props: RemoveInvitationButtonProps) {
|
|||||||
|
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
|
||||||
const removeMutation = backendHooks.useBackendMutation(backend, 'resendInvitation', {
|
const removeMutation = backendHooks.useBackendMutation(backend, 'deleteInvitation', {
|
||||||
mutationKey: [email],
|
mutationKey: [email],
|
||||||
meta: { invalidates: [['listInvitations']], awaitInvalidates: true },
|
meta: { invalidates: [['listInvitations']], awaitInvalidates: true },
|
||||||
})
|
})
|
||||||
|
@ -27,9 +27,9 @@ import * as tailwindMerge from '#/utilities/tailwindMerge'
|
|||||||
export interface MembersTableProps {
|
export interface MembersTableProps {
|
||||||
readonly backend: Backend
|
readonly backend: Backend
|
||||||
/** If `true`, initialize the users list with self to avoid needing a loading spinner. */
|
/** If `true`, initialize the users list with self to avoid needing a loading spinner. */
|
||||||
readonly populateWithSelf?: true
|
readonly populateWithSelf?: boolean
|
||||||
readonly draggable?: true
|
readonly draggable?: boolean
|
||||||
readonly allowDelete?: true
|
readonly allowDelete?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A list of members in the organization. */
|
/** 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 { nameId, getValue, setValue, validate, getEditable } = data
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const [errorMessage, setErrorMessage] = React.useState('')
|
const [errorMessage, setErrorMessage] = React.useState('')
|
||||||
|
const isSubmitting = React.useRef(false)
|
||||||
const value = getValue(context)
|
const value = getValue(context)
|
||||||
const isEditable = getEditable(context)
|
const isEditable = getEditable(context)
|
||||||
|
|
||||||
@ -42,6 +43,9 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
|
|||||||
key={value}
|
key={value}
|
||||||
type="text"
|
type="text"
|
||||||
onSubmit={(event) => {
|
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()
|
event.currentTarget.form?.requestSubmit()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -52,14 +56,18 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
|
|||||||
validationErrors={{ [FIELD_NAME]: errorMessage }}
|
validationErrors={{ [FIELD_NAME]: errorMessage }}
|
||||||
onSubmit={async (event) => {
|
onSubmit={async (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const [[, newValue] = []] = new FormData(event.currentTarget)
|
if (!isSubmitting.current) {
|
||||||
if (typeof newValue === 'string') {
|
isSubmitting.current = true
|
||||||
setErrorMessage('')
|
const [[, newValue] = []] = new FormData(event.currentTarget)
|
||||||
try {
|
if (typeof newValue === 'string') {
|
||||||
await setValue(context, newValue)
|
setErrorMessage('')
|
||||||
} catch (error) {
|
try {
|
||||||
setErrorMessage(errorModule.getMessageOrToString(error))
|
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 type * as backend from '#/services/Backend'
|
||||||
|
|
||||||
|
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||||
|
|
||||||
// ====================
|
// ====================
|
||||||
@ -32,8 +33,10 @@ export interface UserGroupRowProps {
|
|||||||
/** A row representing a user group. */
|
/** A row representing a user group. */
|
||||||
export default function UserGroupRow(props: UserGroupRowProps) {
|
export default function UserGroupRow(props: UserGroupRowProps) {
|
||||||
const { userGroup, doDeleteUserGroup } = props
|
const { userGroup, doDeleteUserGroup } = props
|
||||||
|
const { user } = useFullUserSession()
|
||||||
const { setModal } = modalProvider.useSetModal()
|
const { setModal } = modalProvider.useSetModal()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
const isAdmin = user.isOrganizationAdmin
|
||||||
const contextMenuRef = contextMenuHooks.useContextMenuRef(
|
const contextMenuRef = contextMenuHooks.useContextMenuRef(
|
||||||
userGroup.id,
|
userGroup.id,
|
||||||
getText('userGroupContextMenuLabel'),
|
getText('userGroupContextMenuLabel'),
|
||||||
@ -53,6 +56,7 @@ export default function UserGroupRow(props: UserGroupRowProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
{ enabled: isAdmin },
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -72,23 +76,25 @@ export default function UserGroupRow(props: UserGroupRowProps) {
|
|||||||
</div>
|
</div>
|
||||||
</aria.Cell>
|
</aria.Cell>
|
||||||
<aria.Cell className="relative bg-transparent p-0 opacity-0 group-hover-2:opacity-100">
|
<aria.Cell className="relative bg-transparent p-0 opacity-0 group-hover-2:opacity-100">
|
||||||
<ariaComponents.Button
|
{isAdmin && (
|
||||||
size="custom"
|
<ariaComponents.Button
|
||||||
variant="custom"
|
size="custom"
|
||||||
onPress={() => {
|
variant="custom"
|
||||||
setModal(
|
onPress={() => {
|
||||||
<ConfirmDeleteModal
|
setModal(
|
||||||
actionText={getText('deleteUserGroupActionText', userGroup.groupName)}
|
<ConfirmDeleteModal
|
||||||
doDelete={() => {
|
actionText={getText('deleteUserGroupActionText', userGroup.groupName)}
|
||||||
doDeleteUserGroup(userGroup)
|
doDelete={() => {
|
||||||
}}
|
doDeleteUserGroup(userGroup)
|
||||||
/>,
|
}}
|
||||||
)
|
/>,
|
||||||
}}
|
)
|
||||||
className="absolute right-full mr-4 size-4 -translate-y-1/2"
|
}}
|
||||||
>
|
className="absolute right-full mr-4 size-4 -translate-y-1/2"
|
||||||
<img src={Cross2} className="size-4" />
|
>
|
||||||
</ariaComponents.Button>
|
<img src={Cross2} className="size-4" />
|
||||||
|
</ariaComponents.Button>
|
||||||
|
)}
|
||||||
</aria.Cell>
|
</aria.Cell>
|
||||||
</aria.Row>
|
</aria.Row>
|
||||||
)
|
)
|
||||||
|
@ -17,6 +17,7 @@ import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
|||||||
|
|
||||||
import type * as backend from '#/services/Backend'
|
import type * as backend from '#/services/Backend'
|
||||||
|
|
||||||
|
import { useFullUserSession } from '#/providers/AuthProvider'
|
||||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
@ -33,8 +34,10 @@ export interface UserGroupUserRowProps {
|
|||||||
/** A row of the user groups table representing a user. */
|
/** A row of the user groups table representing a user. */
|
||||||
export default function UserGroupUserRow(props: UserGroupUserRowProps) {
|
export default function UserGroupUserRow(props: UserGroupUserRowProps) {
|
||||||
const { user, userGroup, doRemoveUserFromUserGroup } = props
|
const { user, userGroup, doRemoveUserFromUserGroup } = props
|
||||||
|
const { user: currentUser } = useFullUserSession()
|
||||||
const { setModal } = modalProvider.useSetModal()
|
const { setModal } = modalProvider.useSetModal()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
const isAdmin = currentUser.isOrganizationAdmin
|
||||||
const contextMenuRef = contextMenuHooks.useContextMenuRef(
|
const contextMenuRef = contextMenuHooks.useContextMenuRef(
|
||||||
user.userId,
|
user.userId,
|
||||||
getText('userGroupUserContextMenuLabel'),
|
getText('userGroupUserContextMenuLabel'),
|
||||||
@ -58,6 +61,7 @@ export default function UserGroupUserRow(props: UserGroupUserRowProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
{ enabled: isAdmin },
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -77,28 +81,30 @@ export default function UserGroupUserRow(props: UserGroupUserRowProps) {
|
|||||||
</div>
|
</div>
|
||||||
</aria.Cell>
|
</aria.Cell>
|
||||||
<aria.Cell className="relative bg-transparent p-0 opacity-0 group-hover-2:opacity-100">
|
<aria.Cell className="relative bg-transparent p-0 opacity-0 group-hover-2:opacity-100">
|
||||||
<ariaComponents.Button
|
{isAdmin && (
|
||||||
size="custom"
|
<ariaComponents.Button
|
||||||
variant="custom"
|
size="custom"
|
||||||
onPress={() => {
|
variant="custom"
|
||||||
setModal(
|
onPress={() => {
|
||||||
<ConfirmDeleteModal
|
setModal(
|
||||||
actionText={getText(
|
<ConfirmDeleteModal
|
||||||
'removeUserFromUserGroupActionText',
|
actionText={getText(
|
||||||
user.name,
|
'removeUserFromUserGroupActionText',
|
||||||
userGroup.groupName,
|
user.name,
|
||||||
)}
|
userGroup.groupName,
|
||||||
actionButtonLabel={getText('remove')}
|
)}
|
||||||
doDelete={() => {
|
actionButtonLabel={getText('remove')}
|
||||||
doRemoveUserFromUserGroup(user, userGroup)
|
doDelete={() => {
|
||||||
}}
|
doRemoveUserFromUserGroup(user, userGroup)
|
||||||
/>,
|
}}
|
||||||
)
|
/>,
|
||||||
}}
|
)
|
||||||
className="absolute right-full mr-4 size-4 -translate-y-1/2"
|
}}
|
||||||
>
|
className="absolute right-full mr-4 size-4 -translate-y-1/2"
|
||||||
<img src={Cross2} className="size-4" />
|
>
|
||||||
</ariaComponents.Button>
|
<img src={Cross2} className="size-4" />
|
||||||
|
</ariaComponents.Button>
|
||||||
|
)}
|
||||||
</aria.Cell>
|
</aria.Cell>
|
||||||
</aria.Row>
|
</aria.Row>
|
||||||
)
|
)
|
||||||
|
@ -54,6 +54,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
|
|||||||
[users],
|
[users],
|
||||||
)
|
)
|
||||||
const isLoading = userGroups == null || users == null
|
const isLoading = userGroups == null || users == null
|
||||||
|
const isAdmin = user.isOrganizationAdmin
|
||||||
|
|
||||||
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
|
||||||
|
|
||||||
@ -65,6 +66,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
|
|||||||
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, { trackShadowClass: true })
|
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, { trackShadowClass: true })
|
||||||
|
|
||||||
const { dragAndDropHooks } = aria.useDragAndDrop({
|
const { dragAndDropHooks } = aria.useDragAndDrop({
|
||||||
|
isDisabled: !isAdmin,
|
||||||
getDropOperation: (target, types, allowedOperations) =>
|
getDropOperation: (target, types, allowedOperations) =>
|
||||||
(
|
(
|
||||||
allowedOperations.includes('copy') &&
|
allowedOperations.includes('copy') &&
|
||||||
@ -132,41 +134,43 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ariaComponents.ButtonGroup verticalAlign="center">
|
{isAdmin && (
|
||||||
{shouldDisplayPaywall && (
|
<ariaComponents.ButtonGroup verticalAlign="center">
|
||||||
<paywallComponents.PaywallDialogButton
|
{shouldDisplayPaywall && (
|
||||||
feature="userGroupsFull"
|
<paywallComponents.PaywallDialogButton
|
||||||
variant="bar"
|
feature="userGroupsFull"
|
||||||
size="medium"
|
variant="bar"
|
||||||
rounded="full"
|
size="medium"
|
||||||
iconPosition="end"
|
rounded="full"
|
||||||
tooltip={getText('userGroupsPaywallMessage')}
|
iconPosition="end"
|
||||||
>
|
tooltip={getText('userGroupsPaywallMessage')}
|
||||||
{getText('newUserGroup')}
|
>
|
||||||
</paywallComponents.PaywallDialogButton>
|
{getText('newUserGroup')}
|
||||||
)}
|
</paywallComponents.PaywallDialogButton>
|
||||||
{!shouldDisplayPaywall && (
|
)}
|
||||||
<ariaComponents.Button
|
{!shouldDisplayPaywall && (
|
||||||
size="medium"
|
<ariaComponents.Button
|
||||||
variant="bar"
|
size="medium"
|
||||||
onPress={(event) => {
|
variant="bar"
|
||||||
const rect = event.target.getBoundingClientRect()
|
onPress={(event) => {
|
||||||
const position = { pageX: rect.left, pageY: rect.top }
|
const rect = event.target.getBoundingClientRect()
|
||||||
setModal(<NewUserGroupModal backend={backend} event={position} />)
|
const position = { pageX: rect.left, pageY: rect.top }
|
||||||
}}
|
setModal(<NewUserGroupModal backend={backend} event={position} />)
|
||||||
>
|
}}
|
||||||
{getText('newUserGroup')}
|
>
|
||||||
</ariaComponents.Button>
|
{getText('newUserGroup')}
|
||||||
)}
|
</ariaComponents.Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isUnderPaywall && (
|
{isUnderPaywall && (
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{userGroupsLeft <= 0 ?
|
{userGroupsLeft <= 0 ?
|
||||||
getText('userGroupsPaywallMessage')
|
getText('userGroupsPaywallMessage')
|
||||||
: getText('userGroupsLimitMessage', userGroupsLeft)}
|
: getText('userGroupsLimitMessage', userGroupsLeft)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</ariaComponents.ButtonGroup>
|
</ariaComponents.ButtonGroup>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className={tailwindMerge.twMerge(
|
className={tailwindMerge.twMerge(
|
||||||
|
@ -37,6 +37,7 @@ export default function UserRow(props: UserRowProps) {
|
|||||||
const { user: self } = authProvider.useNonPartialUserSession()
|
const { user: self } = authProvider.useNonPartialUserSession()
|
||||||
const { setModal } = modalProvider.useSetModal()
|
const { setModal } = modalProvider.useSetModal()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
const isAdmin = self.isOrganizationAdmin
|
||||||
const isSelf = user.userId === self.userId
|
const isSelf = user.userId === self.userId
|
||||||
const doDeleteUser = isSelf ? null : doDeleteUserRaw
|
const doDeleteUser = isSelf ? null : doDeleteUserRaw
|
||||||
|
|
||||||
@ -60,6 +61,7 @@ export default function UserRow(props: UserRowProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
{ enabled: isAdmin },
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -166,7 +166,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
validate: (name) => (/\S/.test(name) ? true : ''),
|
validate: (name) => (/\S/.test(name) ? true : ''),
|
||||||
getEditable: () => true,
|
getEditable: (context) => context.user.isOrganizationAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: SettingsEntryType.input,
|
type: SettingsEntryType.input,
|
||||||
@ -183,7 +183,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
|||||||
isEmail(email) ? true
|
isEmail(email) ? true
|
||||||
: email === '' ? ''
|
: email === '' ? ''
|
||||||
: context.getText('invalidEmailValidationError'),
|
: context.getText('invalidEmailValidationError'),
|
||||||
getEditable: () => true,
|
getEditable: (context) => context.user.isOrganizationAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: SettingsEntryType.input,
|
type: SettingsEntryType.input,
|
||||||
@ -196,7 +196,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
|||||||
await context.updateOrganization([{ website: newWebsite }])
|
await context.updateOrganization([{ website: newWebsite }])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getEditable: () => true,
|
getEditable: (context) => context.user.isOrganizationAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: SettingsEntryType.input,
|
type: SettingsEntryType.input,
|
||||||
@ -208,7 +208,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
|||||||
await context.updateOrganization([{ address: newLocation }])
|
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,
|
type: SettingsEntryType.custom,
|
||||||
render: (context) => (
|
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 * as backend from '#/services/Backend'
|
||||||
|
|
||||||
|
import { useInputBindings } from '#/providers/InputBindingsProvider'
|
||||||
|
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
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 tabRight = bounds.right - rootBounds.left - TAB_RADIUS_PX
|
||||||
const rightSegments = [
|
const rightSegments = [
|
||||||
'M 0 0',
|
'M 0 0',
|
||||||
`L ${rootBounds.width} 0`,
|
`L ${rootBounds.width + window.outerWidth} 0`,
|
||||||
`L ${rootBounds.width} ${rootBounds.height}`,
|
`L ${rootBounds.width + window.outerWidth} ${rootBounds.height}`,
|
||||||
`L ${tabRight + TAB_RADIUS_PX} ${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}`,
|
`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) {
|
export function Tab(props: InternalTabProps) {
|
||||||
const { id, project, isActive, isHidden = false, icon, labelId, children, onClose } = props
|
const { id, project, isActive, isHidden = false, icon, labelId, children, onClose } = props
|
||||||
const { onLoadEnd } = props
|
const { onLoadEnd } = props
|
||||||
|
const { getText } = textProvider.useText()
|
||||||
|
const inputBindings = useInputBindings()
|
||||||
const { setSelectedTab } = useTabBarContext()
|
const { setSelectedTab } = useTabBarContext()
|
||||||
const ref = React.useRef<HTMLDivElement | null>(null)
|
const ref = React.useRef<HTMLDivElement | null>(null)
|
||||||
const isLoadingRef = React.useRef(true)
|
const isLoadingRef = React.useRef(true)
|
||||||
const { getText } = textProvider.useText()
|
|
||||||
const actuallyActive = isActive && !isHidden
|
const actuallyActive = isActive && !isHidden
|
||||||
const [resizeObserver] = React.useState(
|
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(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (actuallyActive && ref.current) {
|
if (actuallyActive && ref.current) {
|
||||||
setSelectedTab(ref.current)
|
setSelectedTab(ref.current)
|
||||||
|
@ -78,7 +78,7 @@ export default function UserBar(props: UserBarProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{shouldShowUpgradeButton && (
|
{shouldShowUpgradeButton && (
|
||||||
<paywall.PaywallDialogButton feature={'inviteUser'} size="medium" variant="tertiary">
|
<paywall.PaywallDialogButton feature="inviteUser" size="medium" variant="tertiary">
|
||||||
{getText('invite')}
|
{getText('invite')}
|
||||||
</paywall.PaywallDialogButton>
|
</paywall.PaywallDialogButton>
|
||||||
)}
|
)}
|
||||||
|
@ -26,12 +26,11 @@ import * as parserUserEmails from '#/utilities/parseUserEmails'
|
|||||||
/** Props for an {@link InviteUsersForm}. */
|
/** Props for an {@link InviteUsersForm}. */
|
||||||
export interface InviteUsersFormProps {
|
export interface InviteUsersFormProps {
|
||||||
readonly onSubmitted: (emails: backendModule.EmailAddress[]) => void
|
readonly onSubmitted: (emails: backendModule.EmailAddress[]) => void
|
||||||
readonly organizationId: backendModule.OrganizationId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A modal with inputs for user email and permission level. */
|
/** A modal with inputs for user email and permission level. */
|
||||||
export function InviteUsersForm(props: InviteUsersFormProps) {
|
export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||||
const { onSubmitted, organizationId } = props
|
const { onSubmitted } = props
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const backend = backendProvider.useRemoteBackendStrict()
|
const backend = backendProvider.useRemoteBackendStrict()
|
||||||
const inputRef = React.useRef<HTMLDivElement>(null)
|
const inputRef = React.useRef<HTMLDivElement>(null)
|
||||||
@ -146,9 +145,7 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
|
|||||||
.filter((value): value is backendModule.EmailAddress => isEmail(value))
|
.filter((value): value is backendModule.EmailAddress => isEmail(value))
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
emailsToSubmit.map((userEmail) =>
|
emailsToSubmit.map((userEmail) => inviteUserMutation.mutateAsync([{ userEmail }])),
|
||||||
inviteUserMutation.mutateAsync([{ userEmail, organizationId }]),
|
|
||||||
),
|
|
||||||
).then(() => {
|
).then(() => {
|
||||||
onSubmitted(emailsToSubmit)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{step === 'invite' && (
|
{step === 'invite' && (
|
||||||
<inviteUsersForm.InviteUsersForm
|
<inviteUsersForm.InviteUsersForm onSubmitted={onInviteUsersFormInviteUsersFormSubmitted} />
|
||||||
onSubmitted={onInviteUsersFormInviteUsersFormSubmitted}
|
|
||||||
organizationId={organizationId}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'success' && (
|
{step === 'success' && (
|
||||||
|
@ -178,12 +178,7 @@ export default function ManagePermissionsModal<
|
|||||||
setUserAndUserGroups([])
|
setUserAndUserGroups([])
|
||||||
setEmail('')
|
setEmail('')
|
||||||
if (email != null) {
|
if (email != null) {
|
||||||
await inviteUserMutation.mutateAsync([
|
await inviteUserMutation.mutateAsync([{ userEmail: backendModule.EmailAddress(email) }])
|
||||||
{
|
|
||||||
organizationId: user.organizationId,
|
|
||||||
userEmail: backendModule.EmailAddress(email),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
toast.toast.success(getText('inviteSuccess', email))
|
toast.toast.success(getText('inviteSuccess', email))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -322,7 +317,7 @@ export default function ManagePermissionsModal<
|
|||||||
assetType={item.type}
|
assetType={item.type}
|
||||||
onChange={setAction}
|
onChange={setAction}
|
||||||
/>
|
/>
|
||||||
<div className="-mx-button-px grow">
|
<div className="grow">
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
multiple
|
multiple
|
||||||
autoFocus
|
autoFocus
|
||||||
|
@ -93,10 +93,15 @@ export default function UpsertDatalinkModal(props: UpsertDatalinkModalProps) {
|
|||||||
<DatalinkInput dropdownTitle="Type" value={value} setValue={setValue} />
|
<DatalinkInput dropdownTitle="Type" value={value} setValue={setValue} />
|
||||||
</div>
|
</div>
|
||||||
<ariaComponents.ButtonGroup className="relative">
|
<ariaComponents.ButtonGroup className="relative">
|
||||||
<ariaComponents.Button variant="submit" isDisabled={!isSubmittable} onPress={doSubmit}>
|
<ariaComponents.Button
|
||||||
|
size="medium"
|
||||||
|
variant="submit"
|
||||||
|
isDisabled={!isSubmittable}
|
||||||
|
onPress={doSubmit}
|
||||||
|
>
|
||||||
{getText('create')}
|
{getText('create')}
|
||||||
</ariaComponents.Button>
|
</ariaComponents.Button>
|
||||||
<ariaComponents.Button variant="cancel" onPress={unsetModal}>
|
<ariaComponents.Button size="medium" variant="cancel" onPress={unsetModal}>
|
||||||
{getText('cancel')}
|
{getText('cancel')}
|
||||||
</ariaComponents.Button>
|
</ariaComponents.Button>
|
||||||
</ariaComponents.ButtonGroup>
|
</ariaComponents.ButtonGroup>
|
||||||
|
@ -67,7 +67,13 @@ export default function AuthenticationPage(props: AuthenticationPageProps) {
|
|||||||
{heading}
|
{heading}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
: <form className={containerClasses} onSubmit={onSubmit}>
|
: <form
|
||||||
|
className={containerClasses}
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onSubmit?.(event)
|
||||||
|
}}
|
||||||
|
>
|
||||||
{heading}
|
{heading}
|
||||||
{children}
|
{children}
|
||||||
</form>
|
</form>
|
||||||
|
@ -18,8 +18,6 @@ import Input from '#/components/Input'
|
|||||||
import Link from '#/components/Link'
|
import Link from '#/components/Link'
|
||||||
import SubmitButton from '#/components/SubmitButton'
|
import SubmitButton from '#/components/SubmitButton'
|
||||||
|
|
||||||
import * as eventModule from '#/utilities/event'
|
|
||||||
|
|
||||||
// ======================
|
// ======================
|
||||||
// === ForgotPassword ===
|
// === ForgotPassword ===
|
||||||
// ======================
|
// ======================
|
||||||
@ -53,11 +51,7 @@ export default function ForgotPassword() {
|
|||||||
value={email}
|
value={email}
|
||||||
setValue={setEmail}
|
setValue={setEmail}
|
||||||
/>
|
/>
|
||||||
<SubmitButton
|
<SubmitButton text={getText('sendLink')} icon={ArrowRightIcon} />
|
||||||
text={getText('sendLink')}
|
|
||||||
icon={ArrowRightIcon}
|
|
||||||
onPress={eventModule.submitForm}
|
|
||||||
/>
|
|
||||||
</AuthenticationPage>
|
</AuthenticationPage>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -26,8 +26,6 @@ import Link from '#/components/Link'
|
|||||||
import SubmitButton from '#/components/SubmitButton'
|
import SubmitButton from '#/components/SubmitButton'
|
||||||
import TextLink from '#/components/TextLink'
|
import TextLink from '#/components/TextLink'
|
||||||
|
|
||||||
import * as eventModule from '#/utilities/event'
|
|
||||||
|
|
||||||
// =============
|
// =============
|
||||||
// === Login ===
|
// === Login ===
|
||||||
// =============
|
// =============
|
||||||
@ -146,7 +144,6 @@ export default function Login() {
|
|||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
text={getText('login')}
|
text={getText('login')}
|
||||||
icon={ArrowRightIcon}
|
icon={ArrowRightIcon}
|
||||||
onPress={eventModule.submitForm}
|
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</AuthenticationPage>
|
</AuthenticationPage>
|
||||||
|
@ -21,7 +21,6 @@ import Input from '#/components/Input'
|
|||||||
import Link from '#/components/Link'
|
import Link from '#/components/Link'
|
||||||
import SubmitButton from '#/components/SubmitButton'
|
import SubmitButton from '#/components/SubmitButton'
|
||||||
|
|
||||||
import * as eventModule from '#/utilities/event'
|
|
||||||
import LocalStorage from '#/utilities/LocalStorage'
|
import LocalStorage from '#/utilities/LocalStorage'
|
||||||
import * as string from '#/utilities/string'
|
import * as string from '#/utilities/string'
|
||||||
import * as validation from '#/utilities/validation'
|
import * as validation from '#/utilities/validation'
|
||||||
@ -124,12 +123,7 @@ export default function Registration() {
|
|||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
setValue={setConfirmPassword}
|
setValue={setConfirmPassword}
|
||||||
/>
|
/>
|
||||||
<SubmitButton
|
<SubmitButton isDisabled={isSubmitting} text={getText('register')} icon={CreateAccountIcon} />
|
||||||
isDisabled={isSubmitting}
|
|
||||||
text={getText('register')}
|
|
||||||
icon={CreateAccountIcon}
|
|
||||||
onPress={eventModule.submitForm}
|
|
||||||
/>
|
|
||||||
</AuthenticationPage>
|
</AuthenticationPage>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,6 @@ import Input from '#/components/Input'
|
|||||||
import Link from '#/components/Link'
|
import Link from '#/components/Link'
|
||||||
import SubmitButton from '#/components/SubmitButton'
|
import SubmitButton from '#/components/SubmitButton'
|
||||||
|
|
||||||
import * as eventModule from '#/utilities/event'
|
|
||||||
import * as string from '#/utilities/string'
|
import * as string from '#/utilities/string'
|
||||||
import * as validation from '#/utilities/validation'
|
import * as validation from '#/utilities/validation'
|
||||||
|
|
||||||
@ -123,11 +122,7 @@ export default function ResetPassword() {
|
|||||||
value={newPasswordConfirm}
|
value={newPasswordConfirm}
|
||||||
setValue={setNewPasswordConfirm}
|
setValue={setNewPasswordConfirm}
|
||||||
/>
|
/>
|
||||||
<SubmitButton
|
<SubmitButton text={getText('reset')} icon={ArrowRightIcon} />
|
||||||
text={getText('reset')}
|
|
||||||
icon={ArrowRightIcon}
|
|
||||||
onPress={eventModule.submitForm}
|
|
||||||
/>
|
|
||||||
</AuthenticationPage>
|
</AuthenticationPage>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -173,7 +173,7 @@ function DashboardInner(props: DashboardProps) {
|
|||||||
updateModal((oldModal) => {
|
updateModal((oldModal) => {
|
||||||
if (oldModal == null) {
|
if (oldModal == null) {
|
||||||
const currentPage = projectsStore.getState().page
|
const currentPage = projectsStore.getState().page
|
||||||
if (array.includes(Object.values(TabType), currentPage)) {
|
if (currentPage === TabType.settings) {
|
||||||
setPage(TabType.drive)
|
setPage(TabType.drive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -361,8 +361,10 @@ export default class LocalBackend extends Backend {
|
|||||||
name: projectManager.ProjectName(body.projectName),
|
name: projectManager.ProjectName(body.projectName),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const parentId = this.projectManager.getProjectDirectoryPath(id)
|
const parentPath = projectManager.getDirectoryAndName(
|
||||||
const result = await this.projectManager.listDirectory(parentId)
|
this.projectManager.getProjectPath(id),
|
||||||
|
).directoryPath
|
||||||
|
const result = await this.projectManager.listDirectory(parentPath)
|
||||||
const project = result.flatMap((listedProject) =>
|
const project = result.flatMap((listedProject) =>
|
||||||
(
|
(
|
||||||
listedProject.type === projectManager.FileSystemEntryType.ProjectEntry &&
|
listedProject.type === projectManager.FileSystemEntryType.ProjectEntry &&
|
||||||
@ -588,7 +590,7 @@ export default class LocalBackend extends Backend {
|
|||||||
const from =
|
const from =
|
||||||
typeAndId.type !== backend.AssetType.project ?
|
typeAndId.type !== backend.AssetType.project ?
|
||||||
typeAndId.id
|
typeAndId.id
|
||||||
: this.projectManager.getProjectDirectoryPath(typeAndId.id)
|
: this.projectManager.getProjectPath(typeAndId.id)
|
||||||
const fileName = fileInfo.fileName(from)
|
const fileName = fileInfo.fileName(from)
|
||||||
const to = projectManager.joinPath(extractTypeAndId(body.parentDirectoryId).id, fileName)
|
const to = projectManager.joinPath(extractTypeAndId(body.parentDirectoryId).id, fileName)
|
||||||
await this.projectManager.moveFile(from, to)
|
await this.projectManager.moveFile(from, to)
|
||||||
@ -629,9 +631,9 @@ export default class LocalBackend extends Backend {
|
|||||||
await this.projectManager.moveFile(from, to)
|
await this.projectManager.moveFile(from, to)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Construct a new path using the given parent directory and a file name. */
|
/** Get the path of a project. */
|
||||||
getProjectDirectoryPath(id: backend.ProjectId) {
|
getProjectPath(id: backend.ProjectId) {
|
||||||
return this.projectManager.getProjectDirectoryPath(extractTypeAndId(id).id)
|
return this.projectManager.getProjectPath(extractTypeAndId(id).id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Construct a new path using the given parent directory and a file name. */
|
/** 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 appBaseUrl from '#/utilities/appBaseUrl'
|
||||||
import * as dateTime from '#/utilities/dateTime'
|
import * as dateTime from '#/utilities/dateTime'
|
||||||
import * as newtype from '#/utilities/newtype'
|
import * as newtype from '#/utilities/newtype'
|
||||||
|
import invariant from 'tiny-invariant'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === Constants ===
|
// === Constants ===
|
||||||
@ -389,6 +390,13 @@ export default class ProjectManager {
|
|||||||
socket.close()
|
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. */
|
/** Get the directory path of a project. */
|
||||||
getProjectDirectoryPath(projectId: UUID) {
|
getProjectDirectoryPath(projectId: UUID) {
|
||||||
const projectPath = this.internalProjectPaths.get(projectId)
|
const projectPath = this.internalProjectPaths.get(projectId)
|
||||||
|
@ -270,15 +270,7 @@ export default class RemoteBackend extends Backend {
|
|||||||
|
|
||||||
/** Resend an invitation to a user. */
|
/** Resend an invitation to a user. */
|
||||||
override async resendInvitation(userEmail: backend.EmailAddress): Promise<void> {
|
override async resendInvitation(userEmail: backend.EmailAddress): Promise<void> {
|
||||||
const response = await this.post(remoteBackendPaths.INVITATION_PATH, {
|
await this.inviteUser({ userEmail, resend: true })
|
||||||
userEmail,
|
|
||||||
resend: true,
|
|
||||||
})
|
|
||||||
if (!responseIsSuccessful(response)) {
|
|
||||||
return await this.throw(response, 'resendInvitationBackendError')
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upload a new profile picture for the current user. */
|
/** 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'
|
export const UPLOAD_ORGANIZATION_PICTURE_PATH = 'organizations/me/picture'
|
||||||
/** Relative HTTP path to the "invite user" endpoint of the Cloud backend API. */
|
/** Relative HTTP path to the "invite user" endpoint of the Cloud backend API. */
|
||||||
export const INVITE_USER_PATH = 'users/invite'
|
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'
|
export const INVITATION_PATH = 'invitations'
|
||||||
/** Relative HTTP path to the "create permission" endpoint of the Cloud backend API. */
|
/** Relative HTTP path to the "create permission" endpoint of the Cloud backend API. */
|
||||||
export const CREATE_PERMISSION_PATH = 'permissions'
|
export const CREATE_PERMISSION_PATH = 'permissions'
|
||||||
|
@ -117,8 +117,6 @@
|
|||||||
--label-icons-gap: 0.25rem;
|
--label-icons-gap: 0.25rem;
|
||||||
/* The transition duration of an arrow transitioning between two directions. */
|
/* The transition duration of an arrow transitioning between two directions. */
|
||||||
--arrow-transition-duration: 300ms;
|
--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. */
|
/* The horizontal gap between the arrow of a dropdown and the label of the current item. */
|
||||||
--dropdown-arrow-gap: 0.25rem;
|
--dropdown-arrow-gap: 0.25rem;
|
||||||
--dropdown-items-height: 15rem;
|
--dropdown-items-height: 15rem;
|
||||||
|
@ -1,4 +1,9 @@
|
|||||||
/** @file Utilities for working with permissions. */
|
/** @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'
|
import * as permissions from 'enso-common/src/utilities/permissions'
|
||||||
|
|
||||||
export * 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'
|
export const DOCS_CLASS_NAME = 'text-tag-text bg-permission-docs'
|
||||||
/** CSS classes for the execute permission. */
|
/** CSS classes for the execute permission. */
|
||||||
export const EXEC_CLASS_NAME = 'text-tag-text bg-permission-exec'
|
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)',
|
'chat-thread-list': 'var(--chat-thread-list-height)',
|
||||||
'payment-form': 'var(--payment-form-height)',
|
'payment-form': 'var(--payment-form-height)',
|
||||||
'paragraph-input': 'var(--paragraph-input-height)',
|
'paragraph-input': 'var(--paragraph-input-height)',
|
||||||
'autocomplete-suggestions': 'var(--autocomplete-suggestions-height)',
|
|
||||||
'dropdown-items': 'var(--dropdown-items-height)',
|
'dropdown-items': 'var(--dropdown-items-height)',
|
||||||
'manage-permissions-modal-permissions-list':
|
'manage-permissions-modal-permissions-list':
|
||||||
'var(--manage-permissions-modal-permissions-list-height)',
|
'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
|
/** If `false`, this account is awaiting acceptance from an administrator, and endpoints other than
|
||||||
* `usersMe` will not work. */
|
* `usersMe` will not work. */
|
||||||
readonly isEnabled: boolean
|
readonly isEnabled: boolean
|
||||||
|
readonly isOrganizationAdmin: boolean
|
||||||
readonly rootDirectoryId: DirectoryId
|
readonly rootDirectoryId: DirectoryId
|
||||||
readonly profilePicture?: HttpsUrl
|
readonly profilePicture?: HttpsUrl
|
||||||
readonly userGroups: readonly UserGroupId[] | null
|
readonly userGroups: readonly UserGroupId[] | null
|
||||||
@ -1017,8 +1018,8 @@ export interface UpdateOrganizationRequestBody {
|
|||||||
|
|
||||||
/** HTTP request body for the "invite user" endpoint. */
|
/** HTTP request body for the "invite user" endpoint. */
|
||||||
export interface InviteUserRequestBody {
|
export interface InviteUserRequestBody {
|
||||||
readonly organizationId: OrganizationId
|
|
||||||
readonly userEmail: EmailAddress
|
readonly userEmail: EmailAddress
|
||||||
|
readonly resend?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HTTP response body for the "list invitations" endpoint. */
|
/** HTTP response body for the "list invitations" endpoint. */
|
||||||
|
@ -578,6 +578,7 @@
|
|||||||
"docsColumnName": "Docs",
|
"docsColumnName": "Docs",
|
||||||
|
|
||||||
"settingsShortcut": "Settings",
|
"settingsShortcut": "Settings",
|
||||||
|
"closeTabShortcut": "Close Tab",
|
||||||
"openShortcut": "Open",
|
"openShortcut": "Open",
|
||||||
"runShortcut": "Execute as Task",
|
"runShortcut": "Execute as Task",
|
||||||
"closeShortcut": "Close",
|
"closeShortcut": "Close",
|
||||||
|
Loading…
Reference in New Issue
Block a user