diff --git a/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx b/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx index 8d06b265f3cf..118ede05e88d 100644 --- a/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx @@ -36,7 +36,12 @@ const MENU_ENTRY_VARIANTS = tailwindVariants.tv({ }, }) -const ACTION_TO_TEXT_ID: Readonly> = { +export const ACTION_TO_TEXT_ID: Readonly< + Record< + inputBindings.DashboardBindingKey, + Extract + > +> = { settings: 'settingsShortcut', open: 'openShortcut', run: 'runShortcut', diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsInput.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/SettingsInput.tsx similarity index 88% rename from app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsInput.tsx rename to app/ide-desktop/lib/dashboard/src/components/styled/SettingsInput.tsx index 055ad5198a4f..8e568a80770e 100644 --- a/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsInput.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/styled/SettingsInput.tsx @@ -18,16 +18,17 @@ import FocusRing from '#/components/styled/FocusRing' /** Props for an {@link SettingsInput}. */ export interface SettingsInputProps { + readonly isDisabled?: boolean readonly type?: string readonly placeholder?: string readonly autoComplete?: React.HTMLInputAutoCompleteAttribute readonly onChange?: React.ChangeEventHandler - readonly onSubmit?: (value: string) => void + readonly onSubmit?: (event: React.SyntheticEvent) => void } /** A styled input specific to settings pages. */ function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef) { - const { type, placeholder, autoComplete, onChange, onSubmit } = props + const { isDisabled = false, type, placeholder, autoComplete, onChange, onSubmit } = props const focusChildProps = focusHooks.useFocusChild() const { getText } = textProvider.useText() // This is SAFE. The value of this context is never a `SlottedContext`. @@ -45,17 +46,6 @@ function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef { if (!cancelled.current) { - onSubmit?.(event.currentTarget.value) + onSubmit?.(event) } }, }, diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsPage.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsPage.tsx deleted file mode 100644 index 82beff8ec6a4..000000000000 --- a/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsPage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -/** @file Styled content of a settings tab. */ -import * as React from 'react' - -// ========================== -// === SettingsTabContent === -// ========================== - -/** Props for a {@link SettingsPage}. */ -export interface SettingsPageProps extends Readonly {} - -/** Styled content of a settings tab. */ -export default function SettingsPage(props: SettingsPageProps) { - const { children } = props - - return ( -
- {children} -
- ) -} diff --git a/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsSection.tsx b/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsSection.tsx deleted file mode 100644 index be32f395cb38..000000000000 --- a/app/ide-desktop/lib/dashboard/src/components/styled/settings/SettingsSection.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/** @file A styled settings section. */ -import * as React from 'react' - -import * as aria from '#/components/aria' -import FocusArea from '#/components/styled/FocusArea' - -import * as tailwindMerge from '#/utilities/tailwindMerge' - -// ======================= -// === SettingsSection === -// ======================= - -/** Props for a {@link SettingsSection}. */ -export interface SettingsSectionProps extends Readonly { - readonly title: React.ReactNode - /** If `true`, the component is not wrapped in an {@link FocusArea}. */ - readonly noFocusArea?: boolean - readonly className?: string -} - -/** A styled settings section. */ -export default function SettingsSection(props: SettingsSectionProps) { - const { title, noFocusArea = false, className, children } = props - const heading = ( - - {title} - - ) - - return noFocusArea ? ( -
- {heading} - {children} -
- ) : ( - - {innerProps => ( -
- {heading} - {children} -
- )} -
- ) -} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/backendHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/backendHooks.ts index 17f2df304412..b10e7f076002 100644 --- a/app/ide-desktop/lib/dashboard/src/hooks/backendHooks.ts +++ b/app/ide-desktop/lib/dashboard/src/hooks/backendHooks.ts @@ -228,7 +228,6 @@ export function useBackendQuery( // === useBackendMutation === // ========================== -/** Wrap a backend method call in a React Query Mutation. */ export function useBackendMutation( backend: Backend, method: Method, @@ -236,8 +235,41 @@ export function useBackendMutation( reactQuery.UseMutationOptions< Awaited unknown>>>, Error, - Parameters unknown>>, - unknown + Parameters unknown>> + >, + 'mutationFn' + > +): reactQuery.UseMutationResult< + Awaited unknown>>>, + Error, + Parameters unknown>> +> +export function useBackendMutation( + backend: Backend | null, + method: Method, + options?: Omit< + reactQuery.UseMutationOptions< + Awaited unknown>>>, + Error, + Parameters unknown>> + >, + 'mutationFn' + > +): reactQuery.UseMutationResult< + // eslint-disable-next-line no-restricted-syntax + Awaited unknown>>> | undefined, + Error, + Parameters unknown>> +> +/** Wrap a backend method call in a React Query Mutation. */ +export function useBackendMutation( + backend: Backend | null, + method: Method, + options?: Omit< + reactQuery.UseMutationOptions< + Awaited unknown>>>, + Error, + Parameters unknown>> >, 'mutationFn' > @@ -245,14 +277,13 @@ export function useBackendMutation( return reactQuery.useMutation< Awaited unknown>>>, Error, - Parameters unknown>>, - unknown + Parameters unknown>> >({ ...options, - mutationKey: [backend.type, method, ...(options?.mutationKey ?? [])], + mutationKey: [backend?.type, method, ...(options?.mutationKey ?? [])], // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return - mutationFn: args => (backend[method] as any)(...args), - networkMode: backend.type === backendModule.BackendType.local ? 'always' : 'online', + mutationFn: args => (backend?.[method] as any)?.(...args), + networkMode: backend?.type === backendModule.BackendType.local ? 'always' : 'online', }) } @@ -290,8 +321,7 @@ export function useBackendMutationWithVariables( reactQuery.UseMutationOptions< Awaited unknown>>>, Error, - Parameters unknown>>, - unknown + Parameters unknown>> >, 'mutationFn' > diff --git a/app/ide-desktop/lib/dashboard/src/hooks/toastAndLogHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/toastAndLogHooks.ts index deaaab9a1dea..bff17836f19f 100644 --- a/app/ide-desktop/lib/dashboard/src/hooks/toastAndLogHooks.ts +++ b/app/ide-desktop/lib/dashboard/src/hooks/toastAndLogHooks.ts @@ -10,6 +10,13 @@ import * as textProvider from '#/providers/TextProvider' import * as errorModule from '#/utilities/error' +// =========================== +// === ToastAndLogCallback === +// =========================== + +/** The type of the `toastAndLog` function returned by {@link useToastAndLog}. */ +export type ToastAndLogCallback = ReturnType + // ====================== // === useToastAndLog === // ====================== diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetPanel.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetPanel.tsx index 3263ade234ee..b87c9a9556d5 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetPanel.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetPanel.tsx @@ -70,7 +70,7 @@ export interface AssetPanelProps extends AssetPanelRequiredProps { /** A panel containing the description and settings for an asset. */ export default function AssetPanel(props: AssetPanelProps) { - const { backend, item, isReadonly = false, setItem, category } = props + const { backend, isReadonly = false, item, setItem, category } = props const { dispatchAssetEvent, dispatchAssetListEvent } = props const { getText } = textProvider.useText() diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetProperties.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetProperties.tsx index 5fd292559709..eb9dbd9c6f8f 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetProperties.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetProperties.tsx @@ -216,7 +216,7 @@ export default function AssetProperties(props: AssetPropertiesProps) { isReadonly={isReadonly} item={item} setItem={setItem} - state={{ backend, category, dispatchAssetEvent, setQuery: null }} + state={{ backend, category, dispatchAssetEvent, setQuery: () => {} }} /> diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetSearchBar.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetSearchBar.tsx index c8bfd13e45f0..c08d6f7b343a 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetSearchBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetSearchBar.tsx @@ -280,7 +280,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) { data-testid="asset-search-bar" {...aria.mergeProps()(innerProps, { className: - 'search-bar group relative flex h-row grow max-w-[60em] items-center gap-asset-search-bar rounded-full px-3 text-primary', + 'z-1 group relative flex h-row grow max-w-[60em] items-center gap-asset-search-bar rounded-full px-3 text-primary', ref: rootRef, onFocus: () => { setAreSuggestionsVisible(true) diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx index de62d492f800..7e47cf576da6 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetsTable.tsx @@ -352,10 +352,12 @@ export interface AssetsTableProps { readonly hidden: boolean readonly query: AssetQuery readonly setQuery: React.Dispatch> + readonly setSuggestions: React.Dispatch< + React.SetStateAction + > readonly setProjectStartupInfo: (projectStartupInfo: backendModule.ProjectStartupInfo) => void readonly setCanDownload: (canDownload: boolean) => void readonly category: Category - readonly setSuggestions: (suggestions: assetSearchBar.Suggestion[]) => void readonly initialProjectName: string | null readonly assetListEvents: assetListEvent.AssetListEvent[] readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Drive.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Drive.tsx index a044ab3b9540..091c91bbf821 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Drive.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Drive.tsx @@ -87,7 +87,7 @@ export default function Drive(props: DriveProps) { const backend = backendProvider.useBackend(category) const { getText } = textProvider.useText() const [query, setQuery] = React.useState(() => AssetQuery.fromString('')) - const [suggestions, setSuggestions] = React.useState([]) + const [suggestions, setSuggestions] = React.useState([]) const [canDownload, setCanDownload] = React.useState(false) const [didLoadingProjectManagerFail, setDidLoadingProjectManagerFail] = React.useState(false) const [assetPanelProps, setAssetPanelProps] = diff --git a/app/ide-desktop/lib/dashboard/src/layouts/DriveBar.tsx b/app/ide-desktop/lib/dashboard/src/layouts/DriveBar.tsx index daf13c87b844..3c60b28f87ef 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/DriveBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/DriveBar.tsx @@ -103,6 +103,7 @@ export default function DriveBar(props: DriveBarProps) { suggestions={suggestions} /> ) + const assetPanelToggle = ( <> {/* Spacing. */} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/InfoMenu.tsx b/app/ide-desktop/lib/dashboard/src/layouts/InfoMenu.tsx index 7884813e02a6..52e11c5dcc4e 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/InfoMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/InfoMenu.tsx @@ -30,6 +30,7 @@ export interface InfoMenuProps { /** A menu containing info about the app. */ export default function InfoMenu(props: InfoMenuProps) { const { hidden = false } = props + const session = authProvider.useUserSession() const { signOut } = authProvider.useAuth() const { setModal } = modalProvider.useSetModal() const { getText } = textProvider.useText() @@ -81,7 +82,7 @@ export default function InfoMenu(props: InfoMenuProps) { setModal() }} /> - + {session && } )} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/SearchBar.tsx b/app/ide-desktop/lib/dashboard/src/layouts/SearchBar.tsx new file mode 100644 index 000000000000..5400558396e4 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/SearchBar.tsx @@ -0,0 +1,66 @@ +/** @file A search bar containing a text input, and a list of suggestions. */ +import * as React from 'react' + +import FindIcon from 'enso-assets/find.svg' + +import * as aria from '#/components/aria' +import FocusArea from '#/components/styled/FocusArea' +import FocusRing from '#/components/styled/FocusRing' + +// ================= +// === SearchBar === +// ================= + +/** Props for a {@link SearchBar}. */ +export interface SearchBarProps { + // eslint-disable-next-line @typescript-eslint/naming-convention + readonly 'data-testid': string + readonly query: string + readonly setQuery: React.Dispatch> + readonly label: string + readonly placeholder: string +} + +/** A search bar containing a text input, and a list of suggestions. */ +export default function SearchBar(props: SearchBarProps) { + const { query, setQuery, label, placeholder } = props + + return ( + + {innerProps => ( + ()(innerProps, { + className: + 'group relative flex grow sm:grow-0 sm:basis-[512px] h-row items-center gap-asset-search-bar rounded-full px-input-x text-primary', + })} + > + +
+
+
+ + { + event.continuePropagation() + }} + > + { + setQuery(event.target.value) + }} + /> + + + + )} + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings.tsx index 2967f8952e24..956a884f6d6b 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings.tsx @@ -1,34 +1,34 @@ /** @file Settings screen. */ import * as React from 'react' +import * as reactQuery from '@tanstack/react-query' + import BurgerMenuIcon from 'enso-assets/burger_menu.svg' import * as backendHooks from '#/hooks/backendHooks' import * as searchParamsState from '#/hooks/searchParamsStateHooks' +import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as authProvider from '#/providers/AuthProvider' +import * as backendProvider from '#/providers/BackendProvider' import * as textProvider from '#/providers/TextProvider' -import AccountSettingsTab from '#/layouts/Settings/AccountSettingsTab' -import ActivityLogSettingsTab from '#/layouts/Settings/ActivityLogSettingsTab' -import KeyboardShortcutsSettingsTab from '#/layouts/Settings/KeyboardShortcutsSettingsTab' -import LocalSettingsTab from '#/layouts/Settings/LocalSettingsTab' -import MembersSettingsTab from '#/layouts/Settings/MembersSettingsTab' -import OrganizationSettingsTab from '#/layouts/Settings/OrganizationSettingsTab' +import SearchBar from '#/layouts/SearchBar' +import * as settingsData from '#/layouts/Settings/settingsData' import SettingsTab from '#/layouts/Settings/SettingsTab' -import UserGroupsSettingsTab from '#/layouts/Settings/UserGroupsSettingsTab' +import SettingsTabType from '#/layouts/Settings/SettingsTabType' import SettingsSidebar from '#/layouts/SettingsSidebar' import * as aria from '#/components/aria' import * as ariaComponents from '#/components/AriaComponents' -import * as errorBoundary from '#/components/ErrorBoundary' import * as portal from '#/components/Portal' import Button from '#/components/styled/Button' -import * as suspense from '#/components/Suspense' import type Backend from '#/services/Backend' +import * as projectManager from '#/services/ProjectManager' import * as array from '#/utilities/array' +import * as string from '#/utilities/string' // ================ // === Settings === @@ -40,78 +40,153 @@ export interface SettingsProps { } /** Settings screen. */ -export default function Settings(props: SettingsProps) { - const { backend } = props - const [settingsTab, setSettingsTab] = searchParamsState.useSearchParamsState( +export default function Settings() { + const backend = backendProvider.useRemoteBackend() + const localBackend = backendProvider.useLocalBackend() + const [tab, setTab] = searchParamsState.useSearchParamsState( 'SettingsTab', - SettingsTab.account, - array.includesPredicate(Object.values(SettingsTab)) + SettingsTabType.account, + array.includesPredicate(Object.values(SettingsTabType)) ) - const { user } = authProvider.useFullUserSession() + const { user, accessToken } = authProvider.useNonPartialUserSession() + const { authQueryKey } = authProvider.useAuth() const { getText } = textProvider.useText() + const toastAndLog = toastAndLogHooks.useToastAndLog() + const [query, setQuery] = React.useState('') const root = portal.useStrictPortalContext() const [isSidebarPopoverOpen, setIsSidebarPopoverOpen] = React.useState(false) const organization = backendHooks.useBackendGetOrganization(backend) - const isUserInOrganization = organization != null + const isQueryBlank = !/\S/.test(query) - let content: React.JSX.Element | null + const updateUserMutation = backendHooks.useBackendMutation(backend, 'updateUser', { + meta: { invalidates: [authQueryKey], awaitInvalidates: true }, + }) + const updateOrganizationMutation = backendHooks.useBackendMutation(backend, 'updateOrganization') + const updateUser = updateUserMutation.mutateAsync + const updateOrganization = updateOrganizationMutation.mutateAsync - switch (settingsTab) { - case SettingsTab.account: { - content = backend == null ? null : - break - } - case SettingsTab.organization: { - content = backend == null ? null : - break - } - case SettingsTab.local: { - content = - break - } - case SettingsTab.members: { - content = - break - } - case SettingsTab.userGroups: { - content = backend == null ? null : - break - } - case SettingsTab.keyboardShortcuts: { - content = - break - } - case SettingsTab.activityLog: { - content = backend == null ? null : - break - } - default: { - // This case should be removed when all settings tabs are implemented. - content = <> - break + const updateLocalRootPathMutation = reactQuery.useMutation({ + mutationKey: [localBackend?.type, 'updateRootPath'], + mutationFn: (value: string) => { + if (localBackend) { + localBackend.rootPath = projectManager.Path(value) + } + return Promise.resolve() + }, + meta: { invalidates: [[localBackend?.type, 'listDirectory']], awaitInvalidates: true }, + }) + const updateLocalRootPath = updateLocalRootPathMutation.mutateAsync + + const context = React.useMemo( + () => ({ + accessToken, + user, + backend, + localBackend, + organization, + updateUser, + updateOrganization, + updateLocalRootPath, + toastAndLog, + getText, + }), + [ + accessToken, + backend, + getText, + localBackend, + organization, + toastAndLog, + updateLocalRootPath, + updateOrganization, + updateUser, + user, + ] + ) + + const isMatch = React.useMemo(() => { + const regex = new RegExp(string.regexEscape(query.trim()).replace(/\s+/g, '.+'), 'i') + return (name: string) => regex.test(name) + }, [query]) + + const doesEntryMatchQuery = React.useCallback( + (entry: settingsData.SettingsEntryData) => { + switch (entry.type) { + case settingsData.SettingsEntryType.input: { + return isMatch(getText(entry.nameId)) + } + case settingsData.SettingsEntryType.custom: { + const doesAliasesIdMatch = + entry.aliasesId == null ? false : getText(entry.aliasesId).split('\n').some(isMatch) + if (doesAliasesIdMatch) { + return true + } else { + return entry.getExtraAliases == null + ? false + : entry.getExtraAliases(context).some(isMatch) + } + } + } + }, + [context, getText, isMatch] + ) + + const tabsToShow = React.useMemo(() => { + if (isQueryBlank) { + return settingsData.ALL_SETTINGS_TABS + } else { + return settingsData.SETTINGS_DATA.flatMap(tabSection => + tabSection.tabs + .filter(tabData => + isMatch(getText(tabData.nameId)) || isMatch(getText(tabSection.nameId)) + ? true + : tabData.sections.some(section => + isMatch(getText(section.nameId)) + ? true + : section.entries.some(doesEntryMatchQuery) + ) + ) + .map(tabData => tabData.settingsTab) + ) } - } - const noContent = content == null + }, [isQueryBlank, doesEntryMatchQuery, getText, isMatch]) + const effectiveTab = tabsToShow.includes(tab) ? tab : tabsToShow[0] ?? SettingsTabType.members - React.useEffect(() => { - if (noContent) { - // Set to the first settings page that does not require a backend. - setSettingsTab(SettingsTab.keyboardShortcuts) + const data = React.useMemo(() => { + const tabData = settingsData.SETTINGS_TAB_DATA[effectiveTab] + if (isQueryBlank) { + return tabData + } else { + if (isMatch(getText(tabData.nameId))) { + return tabData + } else { + const sections = tabData.sections.flatMap(section => { + const matchingEntries = isMatch(getText(section.nameId)) + ? section.entries + : section.entries.filter(doesEntryMatchQuery) + if (matchingEntries.length === 0) { + return [] + } else { + return [{ ...section, entries: matchingEntries }] + } + }) + return { ...tabData, sections } + } } - }, [noContent, setSettingsTab]) + }, [isQueryBlank, doesEntryMatchQuery, getText, isMatch, effectiveTab]) return ( -
+
) diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/AccountSettingsTab.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/AccountSettingsTab.tsx deleted file mode 100644 index 72a480b8062f..000000000000 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/AccountSettingsTab.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/** @file Settings tab for viewing and editing account information. */ -import * as React from 'react' - -import * as authProvider from '#/providers/AuthProvider' - -import ChangePasswordSettingsSection from '#/layouts/Settings/ChangePasswordSettingsSection' -import DeleteUserAccountSettingsSection from '#/layouts/Settings/DeleteUserAccountSettingsSection' -import ProfilePictureSettingsSection from '#/layouts/Settings/ProfilePictureSettingsSection' -import UserAccountSettingsSection from '#/layouts/Settings/UserAccountSettingsSection' - -import type Backend from '#/services/Backend' - -// ========================== -// === AccountSettingsTab === -// ========================== - -/** Props for a {@link AccountSettingsTab}. */ -export interface AccountSettingsTabProps { - readonly backend: Backend -} - -/** Settings tab for viewing and editing account information. */ -export default function AccountSettingsTab(props: AccountSettingsTabProps) { - const { backend } = props - const { accessToken } = authProvider.useNonPartialUserSession() - - // The shape of the JWT payload is statically known. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const username: string | null = - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-non-null-assertion - JSON.parse(atob(accessToken.split('.')[1]!)).username - const canChangePassword = username != null ? !/^Github_|^Google_/.test(username) : false - - return ( -
-
- - {canChangePassword && } - -
- -
- ) -} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/ActivityLogSettingsSection.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/ActivityLogSettingsSection.tsx new file mode 100644 index 000000000000..b817f7215f1a --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/ActivityLogSettingsSection.tsx @@ -0,0 +1,412 @@ +/** @file Settings tab for viewing and editing account information. */ +import * as React from 'react' + +import DataUploadIcon from 'enso-assets/data_upload.svg' +import KeyIcon from 'enso-assets/key.svg' +import Play2Icon from 'enso-assets/play2.svg' +import SortAscendingIcon from 'enso-assets/sort_ascending.svg' +import TrashIcon from 'enso-assets/trash.svg' + +import * as backendHooks from '#/hooks/backendHooks' + +import * as textProvider from '#/providers/TextProvider' + +import * as aria from '#/components/aria' +import * as ariaComponents from '#/components/AriaComponents' +import DateInput from '#/components/DateInput' +import Dropdown from '#/components/Dropdown' +import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner' +import FocusArea from '#/components/styled/FocusArea' +import SvgMask from '#/components/SvgMask' + +import * as backendModule from '#/services/Backend' +import type Backend from '#/services/Backend' + +import * as dateTime from '#/utilities/dateTime' +import * as sorting from '#/utilities/sorting' +import * as tailwindMerge from '#/utilities/tailwindMerge' + +// ================= +// === Constants === +// ================= + +const EVENT_TYPE_ICON: Record = { + [backendModule.EventType.GetSecret]: KeyIcon, + [backendModule.EventType.DeleteAssets]: TrashIcon, + [backendModule.EventType.ListSecrets]: KeyIcon, + [backendModule.EventType.OpenProject]: Play2Icon, + [backendModule.EventType.UploadFile]: DataUploadIcon, +} + +const EVENT_TYPE_NAME: Record = { + [backendModule.EventType.GetSecret]: 'Get Secret', + [backendModule.EventType.DeleteAssets]: 'Delete Assets', + [backendModule.EventType.ListSecrets]: 'List Secrets', + [backendModule.EventType.OpenProject]: 'Open Project', + [backendModule.EventType.UploadFile]: 'Upload File', +} + +// ================================= +// === ActivityLogSortableColumn === +// ================================= + +/** Sortable columns in an activity log table. */ +enum ActivityLogSortableColumn { + type = 'type', + email = 'email', + timestamp = 'timestamp', +} + +// ================================== +// === ActivityLogSettingsSection === +// ================================== + +/** Props for a {@link ActivityLogSettingsSection}. */ +export interface ActivityLogSettingsSectionProps { + readonly backend: Backend +} + +/** Settings tab for viewing and editing organization members. */ +export default function ActivityLogSettingsSection(props: ActivityLogSettingsSectionProps) { + const { backend } = props + const { getText } = textProvider.useText() + const [startDate, setStartDate] = React.useState(null) + const [endDate, setEndDate] = React.useState(null) + const [types, setTypes] = React.useState([]) + const [typeIndices, setTypeIndices] = React.useState(() => []) + const [emails, setEmails] = React.useState([]) + const [emailIndices, setEmailIndices] = React.useState(() => []) + const [sortInfo, setSortInfo] = + React.useState | null>(null) + const users = backendHooks.useBackendListUsers(backend) + const allEmails = React.useMemo(() => (users ?? []).map(user => user.email), [users]) + const logsQuery = backendHooks.useBackendQuery(backend, 'getLogEvents', []) + const logs = logsQuery.data + const filteredLogs = React.useMemo(() => { + const typesSet = new Set(types.length > 0 ? types : backendModule.EVENT_TYPES) + const emailsSet = new Set(emails.length > 0 ? emails : allEmails) + return logs == null + ? null + : logs.filter(log => { + const date = log.timestamp == null ? null : dateTime.toDate(new Date(log.timestamp)) + return ( + typesSet.has(log.metadata.type) && + emailsSet.has(log.userEmail) && + (date == null || + ((startDate == null || date >= startDate) && (endDate == null || date <= endDate))) + ) + }) + }, [logs, types, emails, startDate, endDate, allEmails]) + const sortedLogs = React.useMemo(() => { + if (sortInfo == null || filteredLogs == null) { + return filteredLogs + } else { + let compare: (a: backendModule.Event, b: backendModule.Event) => number + const multiplier = sortInfo.direction === sorting.SortDirection.ascending ? 1 : -1 + switch (sortInfo.field) { + case ActivityLogSortableColumn.type: { + compare = (a, b) => + multiplier * + (a.metadata.type < b.metadata.type ? -1 : a.metadata.type > b.metadata.type ? 1 : 0) + break + } + case ActivityLogSortableColumn.email: { + compare = (a, b) => + multiplier * (a.userEmail < b.userEmail ? -1 : a.userEmail > b.userEmail ? 1 : 0) + break + } + case ActivityLogSortableColumn.timestamp: { + compare = (a, b) => { + const aTime = a.timestamp == null ? 0 : Number(new Date(a.timestamp)) + const bTime = b.timestamp == null ? 0 : Number(new Date(b.timestamp)) + return multiplier * aTime - bTime + } + break + } + } + return [...filteredLogs].sort(compare) + } + }, [filteredLogs, sortInfo]) + const isDescending = sortInfo?.direction === sorting.SortDirection.descending + const isLoading = sortedLogs == null + + return ( +
+ + {innerProps => ( +
+
+ + {getText('startDate')} + + +
+
+ + {getText('endDate')} + + +
+
+ + {getText('types')} + + EVENT_TYPE_NAME[itemProps.item]} + renderMultiple={itemsProps => + itemsProps.items.length === 0 || + itemsProps.items.length === backendModule.EVENT_TYPES.length + ? 'All' + : (itemsProps.items[0] != null ? EVENT_TYPE_NAME[itemsProps.items[0]] : '') + + (itemsProps.items.length <= 1 ? '' : ` (+${itemsProps.items.length - 1})`) + } + onClick={(items, indices) => { + setTypes(items) + setTypeIndices(indices) + }} + /> +
+
+ + {getText('users')} + + itemProps.item} + renderMultiple={itemsProps => + itemsProps.items.length === 0 || itemsProps.items.length === allEmails.length + ? 'All' + : (itemsProps.items[0] ?? '') + + (itemsProps.items.length <= 1 ? '' : `(+${itemsProps.items.length - 1})`) + } + onClick={(items, indices) => { + setEmails(items) + setEmailIndices(indices) + }} + /> +
+
+ )} +
+ + + + + + { + const nextDirection = + sortInfo?.field === ActivityLogSortableColumn.type + ? sorting.nextSortDirection(sortInfo.direction) + : sorting.SortDirection.ascending + if (nextDirection == null) { + setSortInfo(null) + } else { + setSortInfo({ + field: ActivityLogSortableColumn.type, + direction: nextDirection, + }) + } + }} + > + {getText('type')} + { + + + + { + const nextDirection = + sortInfo?.field === ActivityLogSortableColumn.email + ? sorting.nextSortDirection(sortInfo.direction) + : sorting.SortDirection.ascending + if (nextDirection == null) { + setSortInfo(null) + } else { + setSortInfo({ + field: ActivityLogSortableColumn.email, + direction: nextDirection, + }) + } + }} + > + {getText('email')} + { + + + + { + const nextDirection = + sortInfo?.field === ActivityLogSortableColumn.timestamp + ? sorting.nextSortDirection(sortInfo.direction) + : sorting.SortDirection.ascending + if (nextDirection == null) { + setSortInfo(null) + } else { + setSortInfo({ + field: ActivityLogSortableColumn.timestamp, + direction: nextDirection, + }) + } + }} + > + {getText('timestamp')} + { + + + + + + {isLoading ? ( + + + + ) : ( + sortedLogs.map((log, i) => ( + + +
+ +
+
+ {EVENT_TYPE_NAME[log.metadata.type]} + {log.userEmail} + + {log.timestamp ? dateTime.formatDateTime(new Date(log.timestamp)) : ''} + + + )) + )} + +
+
+ +
+
+
+ ) +} + +// ============================= +// === ActivityLogHeaderCell === +// ============================= + +/** Props for a {@link ActivityLogHeaderCell}. */ +export interface ActivityLogHeaderCellProps extends Readonly { + readonly className?: string +} + +/** A styled table cell for an {@link ActivityLogSettingsSection}. */ +function ActivityLogHeaderCell(props: ActivityLogHeaderCellProps) { + const { children, className } = props + + return ( + + {children} + + ) +} + +// ============================ +// === ActivityLogTableCell === +// ============================ + +/** Props for a {@link ActivityLogTableCell}. */ +export interface ActivityLogTableCellProps extends Readonly {} + +/** A styled table cell for an {@link ActivityLogSettingsSection}. */ +function ActivityLogTableCell(props: ActivityLogTableCellProps) { + const { children } = props + + return ( + + {children} + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/ActivityLogSettingsTab.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/ActivityLogSettingsTab.tsx deleted file mode 100644 index 36c3e79d704f..000000000000 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/ActivityLogSettingsTab.tsx +++ /dev/null @@ -1,377 +0,0 @@ -/** @file Settings tab for viewing and editing account information. */ -import * as React from 'react' - -import DataUploadIcon from 'enso-assets/data_upload.svg' -import KeyIcon from 'enso-assets/key.svg' -import Play2Icon from 'enso-assets/play2.svg' -import SortAscendingIcon from 'enso-assets/sort_ascending.svg' -import TrashIcon from 'enso-assets/trash.svg' - -import * as backendHooks from '#/hooks/backendHooks' - -import * as textProvider from '#/providers/TextProvider' - -import * as aria from '#/components/aria' -import * as ariaComponents from '#/components/AriaComponents' -import DateInput from '#/components/DateInput' -import Dropdown from '#/components/Dropdown' -import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner' -import FocusArea from '#/components/styled/FocusArea' -import SettingsPage from '#/components/styled/settings/SettingsPage' -import SettingsSection from '#/components/styled/settings/SettingsSection' -import SvgMask from '#/components/SvgMask' - -import * as backendModule from '#/services/Backend' -import type Backend from '#/services/Backend' - -import * as dateTime from '#/utilities/dateTime' -import * as sorting from '#/utilities/sorting' -import * as tailwindMerge from '#/utilities/tailwindMerge' - -// ================= -// === Constants === -// ================= - -const EMPTY_ARRAY: never[] = [] - -const EVENT_TYPE_ICON: Record = { - [backendModule.EventType.GetSecret]: KeyIcon, - [backendModule.EventType.DeleteAssets]: TrashIcon, - [backendModule.EventType.ListSecrets]: KeyIcon, - [backendModule.EventType.OpenProject]: Play2Icon, - [backendModule.EventType.UploadFile]: DataUploadIcon, -} - -const EVENT_TYPE_NAME: Record = { - [backendModule.EventType.GetSecret]: 'Get Secret', - [backendModule.EventType.DeleteAssets]: 'Delete Assets', - [backendModule.EventType.ListSecrets]: 'List Secrets', - [backendModule.EventType.OpenProject]: 'Open Project', - [backendModule.EventType.UploadFile]: 'Upload File', -} - -// ========================= -// === ActivityLogColumn === -// ========================= - -/** Sortable columns in an activity log table. */ -enum ActivityLogSortableColumn { - type = 'type', - email = 'email', - timestamp = 'timestamp', -} - -// ============================== -// === ActivityLogSettingsTab === -// ============================== - -/** Props for a {@link ActivityLogSettingsTab}. */ -export interface ActivityLogSettingsTabProps { - readonly backend: Backend -} - -/** Settings tab for viewing and editing organization members. */ -export default function ActivityLogSettingsTab(props: ActivityLogSettingsTabProps) { - const { backend } = props - const { getText } = textProvider.useText() - const [startDate, setStartDate] = React.useState(null) - const [endDate, setEndDate] = React.useState(null) - const [types, setTypes] = React.useState([]) - const [typeIndices, setTypeIndices] = React.useState(() => []) - const [emails, setEmails] = React.useState([]) - const [emailIndices, setEmailIndices] = React.useState(() => []) - const [sortInfo, setSortInfo] = - React.useState | null>(null) - const users = backendHooks.useBackendListUsers(backend) ?? EMPTY_ARRAY - const allEmails = React.useMemo(() => users.map(user => user.email), [users]) - const logsQuery = backendHooks.useBackendQuery(backend, 'getLogEvents', []) - const logs = logsQuery.data - const filteredLogs = React.useMemo(() => { - const typesSet = new Set(types.length > 0 ? types : backendModule.EVENT_TYPES) - const emailsSet = new Set(emails.length > 0 ? emails : allEmails) - return logs == null - ? null - : logs.filter(log => { - const date = log.timestamp == null ? null : dateTime.toDate(new Date(log.timestamp)) - return ( - typesSet.has(log.metadata.type) && - emailsSet.has(log.userEmail) && - (date == null || - ((startDate == null || date >= startDate) && (endDate == null || date <= endDate))) - ) - }) - }, [logs, types, emails, startDate, endDate, allEmails]) - const sortedLogs = React.useMemo(() => { - if (sortInfo == null || filteredLogs == null) { - return filteredLogs - } else { - let compare: (a: backendModule.Event, b: backendModule.Event) => number - const multiplier = sortInfo.direction === sorting.SortDirection.ascending ? 1 : -1 - switch (sortInfo.field) { - case ActivityLogSortableColumn.type: { - compare = (a, b) => - multiplier * - (a.metadata.type < b.metadata.type ? -1 : a.metadata.type > b.metadata.type ? 1 : 0) - break - } - case ActivityLogSortableColumn.email: { - compare = (a, b) => - multiplier * (a.userEmail < b.userEmail ? -1 : a.userEmail > b.userEmail ? 1 : 0) - break - } - case ActivityLogSortableColumn.timestamp: { - compare = (a, b) => { - const aTime = a.timestamp == null ? 0 : Number(new Date(a.timestamp)) - const bTime = b.timestamp == null ? 0 : Number(new Date(b.timestamp)) - return multiplier * aTime - bTime - } - break - } - } - return [...filteredLogs].sort(compare) - } - }, [filteredLogs, sortInfo]) - const isDescending = sortInfo?.direction === sorting.SortDirection.descending - const isLoading = sortedLogs == null - - return ( - - - - {innerProps => ( -
-
- {getText('startDate')} - -
-
- {getText('endDate')} - -
-
- {getText('types')} - EVENT_TYPE_NAME[itemProps.item]} - renderMultiple={itemProps => - itemProps.items.length === 0 || - itemProps.items.length === backendModule.EVENT_TYPES.length - ? 'All' - : (itemProps.items[0] != null ? EVENT_TYPE_NAME[itemProps.items[0]] : '') + - (itemProps.items.length <= 1 ? '' : ` (+${itemProps.items.length - 1})`) - } - onClick={(items, indices) => { - setTypes(items) - setTypeIndices(indices) - }} - /> -
-
- {getText('users')} - itemProps.item} - renderMultiple={itemProps => - itemProps.items.length === 0 || itemProps.items.length === allEmails.length - ? 'All' - : (itemProps.items[0] ?? '') + - (itemProps.items.length <= 1 ? '' : `(+${itemProps.items.length - 1})`) - } - onClick={(items, indices) => { - setEmails(items) - setEmailIndices(indices) - }} - /> -
-
- )} -
- - - - - - - - - - {isLoading ? ( - - - - ) : ( - sortedLogs.map((log, i) => ( - - - - - - - )) - )} - -
- - { - const nextDirection = - sortInfo?.field === ActivityLogSortableColumn.type - ? sorting.nextSortDirection(sortInfo.direction) - : sorting.SortDirection.ascending - if (nextDirection == null) { - setSortInfo(null) - } else { - setSortInfo({ - field: ActivityLogSortableColumn.type, - direction: nextDirection, - }) - } - }} - > - {getText('type')} - { - - - { - const nextDirection = - sortInfo?.field === ActivityLogSortableColumn.email - ? sorting.nextSortDirection(sortInfo.direction) - : sorting.SortDirection.ascending - if (nextDirection == null) { - setSortInfo(null) - } else { - setSortInfo({ - field: ActivityLogSortableColumn.email, - direction: nextDirection, - }) - } - }} - > - {getText('email')} - { - - - { - const nextDirection = - sortInfo?.field === ActivityLogSortableColumn.timestamp - ? sorting.nextSortDirection(sortInfo.direction) - : sorting.SortDirection.ascending - if (nextDirection == null) { - setSortInfo(null) - } else { - setSortInfo({ - field: ActivityLogSortableColumn.timestamp, - direction: nextDirection, - }) - } - }} - > - {getText('timestamp')} - { - -
-
- -
-
-
- -
-
- {EVENT_TYPE_NAME[log.metadata.type]} - - {log.userEmail} - - {log.timestamp ? dateTime.formatDateTime(new Date(log.timestamp)) : ''} -
-
-
- ) -} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/ChangePasswordForm.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/ChangePasswordForm.tsx new file mode 100644 index 000000000000..746a805c8d3b --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/ChangePasswordForm.tsx @@ -0,0 +1,120 @@ +/** @file A form for changing the user's password. */ +import * as React from 'react' + +import * as authProvider from '#/providers/AuthProvider' +import * as textProvider from '#/providers/TextProvider' + +import * as aria from '#/components/aria' +import * as ariaComponents from '#/components/AriaComponents' +import ButtonRow from '#/components/styled/ButtonRow' +import SettingsInput from '#/components/styled/SettingsInput' + +import * as eventModule from '#/utilities/event' +import * as uniqueString from '#/utilities/uniqueString' +import * as validation from '#/utilities/validation' + +// ========================== +// === ChangePasswordForm === +// ========================== + +/** A form for changing the user's password. */ +export default function ChangePasswordForm() { + const { user } = authProvider.useNonPartialUserSession() + const { changePassword } = authProvider.useAuth() + const { getText } = textProvider.useText() + const [key, setKey] = React.useState('') + const [currentPassword, setCurrentPassword] = React.useState('') + const [newPassword, setNewPassword] = React.useState('') + const [confirmNewPassword, setConfirmNewPassword] = React.useState('') + + const canSubmitPassword = + currentPassword !== '' && + newPassword !== '' && + confirmNewPassword !== '' && + newPassword === confirmNewPassword && + validation.PASSWORD_REGEX.test(newPassword) + const canCancel = currentPassword !== '' || newPassword !== '' || confirmNewPassword !== '' + + return ( + { + event.preventDefault() + setKey(uniqueString.uniqueString()) + setCurrentPassword('') + setNewPassword('') + setConfirmNewPassword('') + void changePassword(currentPassword, newPassword) + }} + > + + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/ChangePasswordSettingsSection.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/ChangePasswordSettingsSection.tsx deleted file mode 100644 index f8289eb4c278..000000000000 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/ChangePasswordSettingsSection.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/** @file Settings section for changing password. */ -import * as React from 'react' - -import * as authProvider from '#/providers/AuthProvider' -import * as textProvider from '#/providers/TextProvider' - -import * as aria from '#/components/aria' -import * as ariaComponents from '#/components/AriaComponents' -import ButtonRow from '#/components/styled/ButtonRow' -import SettingsInput from '#/components/styled/settings/SettingsInput' -import SettingsSection from '#/components/styled/settings/SettingsSection' - -import * as eventModule from '#/utilities/event' -import * as uniqueString from '#/utilities/uniqueString' -import * as validation from '#/utilities/validation' - -// ===================================== -// === ChangePasswordSettingsSection === -// ===================================== - -/** Settings section for changing password. */ -export default function ChangePasswordSettingsSection() { - const { user } = authProvider.useFullUserSession() - const { changePassword } = authProvider.useAuth() - - const { getText } = textProvider.useText() - - const [key, setKey] = React.useState('') - const [currentPassword, setCurrentPassword] = React.useState('') - const [newPassword, setNewPassword] = React.useState('') - const [confirmNewPassword, setConfirmNewPassword] = React.useState('') - - const canSubmitPassword = - currentPassword !== '' && - newPassword !== '' && - confirmNewPassword !== '' && - newPassword === confirmNewPassword && - validation.PASSWORD_REGEX.test(newPassword) - const canCancel = currentPassword !== '' || newPassword !== '' || confirmNewPassword !== '' - - return ( - - { - event.preventDefault() - setKey(uniqueString.uniqueString()) - setCurrentPassword('') - setNewPassword('') - setConfirmNewPassword('') - void changePassword(currentPassword, newPassword) - }} - > - - - ) -} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/DeleteUserAccountSettingsSection.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/DeleteUserAccountSettingsSection.tsx index 787889ca2180..db73f62506ed 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/DeleteUserAccountSettingsSection.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/DeleteUserAccountSettingsSection.tsx @@ -7,7 +7,7 @@ import * as textProvider from '#/providers/TextProvider' import * as aria from '#/components/aria' import * as ariaComponents from '#/components/AriaComponents' -import SettingsSection from '#/components/styled/settings/SettingsSection' +import FocusArea from '#/components/styled/FocusArea' import ConfirmDeleteUserModal from '#/modals/ConfirmDeleteUserModal' @@ -22,34 +22,36 @@ export default function DeleteUserAccountSettingsSection() { const { getText } = textProvider.useText() return ( - {getText('dangerZone')}} - // This UI element does not appear anywhere else. - // eslint-disable-next-line no-restricted-syntax - className="flex flex-col items-start gap-settings-section-header rounded-2.5xl border-2 border-danger px-[1rem] pb-[0.9375rem] pt-[0.5625rem]" - > -
- { - setModal( - { - await deleteUser() - await signOut() - }} - /> - ) - }} + + {innerProps => ( +
- {getText('deleteUserAccountButtonLabel')} - - - - {getText('deleteUserAccountWarning')} - -
- + + {getText('dangerZone')} + +
+ { + setModal( + { + await deleteUser() + await signOut() + }} + /> + ) + }} + > + {getText('deleteUserAccountButtonLabel')} + + {getText('deleteUserAccountWarning')} +
+
+ )} + ) } diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsSettingsSection.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsSettingsSection.tsx new file mode 100644 index 000000000000..f2b58866394f --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsSettingsSection.tsx @@ -0,0 +1,201 @@ +/** @file Settings tab for viewing and editing keyboard shortcuts. */ +import * as React from 'react' + +import BlankIcon from 'enso-assets/blank.svg' +import CrossIcon from 'enso-assets/cross.svg' +import Plus2Icon from 'enso-assets/plus2.svg' +import ReloadIcon from 'enso-assets/reload.svg' + +import type * as inputBindings from '#/configurations/inputBindings' + +import * as refreshHooks from '#/hooks/refreshHooks' +import * as scrollHooks from '#/hooks/scrollHooks' + +import * as inputBindingsManager from '#/providers/InputBindingsProvider' +import * as modalProvider from '#/providers/ModalProvider' +import * as textProvider from '#/providers/TextProvider' + +import * as aria from '#/components/aria' +import * as ariaComponents from '#/components/AriaComponents' +import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut' +import FocusArea from '#/components/styled/FocusArea' +import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar' +import SvgMask from '#/components/SvgMask' + +import CaptureKeyboardShortcutModal from '#/modals/CaptureKeyboardShortcutModal' +import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal' + +import * as object from '#/utilities/object' + +// ======================================== +// === KeyboardShortcutsSettingsSection === +// ======================================== + +/** Settings tab for viewing and editing keyboard shortcuts. */ +export default function KeyboardShortcutsSettingsSection() { + const [refresh, doRefresh] = refreshHooks.useRefresh() + const inputBindings = inputBindingsManager.useInputBindings() + const { setModal } = modalProvider.useSetModal() + const { getText } = textProvider.useText() + const rootRef = React.useRef(null) + const bodyRef = React.useRef(null) + const allShortcuts = React.useMemo(() => { + // This is REQUIRED, in order to avoid disabling the `react-hooks/exhaustive-deps` lint. + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + refresh + return new Set(Object.values(inputBindings.metadata).flatMap(value => value.bindings)) + }, [inputBindings.metadata, refresh]) + const visibleBindings = React.useMemo( + () => object.unsafeEntries(inputBindings.metadata).filter(kv => kv[1].rebindable !== false), + [inputBindings.metadata] + ) + + const { onScroll } = scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef) + + return ( + <> + + { + setModal( + { + for (const k in inputBindings.metadata) { + // eslint-disable-next-line no-restricted-syntax + inputBindings.reset(k as inputBindings.DashboardBindingKey) + } + doRefresh() + }} + /> + ) + }} + > + {getText('resetAll')} + + + + {innerProps => ( +
()(innerProps, { + ref: rootRef, + // There is a horizontal scrollbar for some reason without `px-px`. + // eslint-disable-next-line no-restricted-syntax + className: 'overflow-auto px-px', + onScroll, + })} + > + + + + + + + + + + + {visibleBindings.map(kv => { + const [action, info] = kv + return ( + + + + + + + ) + })} + +
+ {/* Icon */} + + {getText('name')} + {getText('shortcuts')} + {getText('description')} +
+ + + {info.name} + + + {bindingsProps => ( +
+ {/* I don't know why this padding is needed, + * given that this is a flex container. */} + {/* eslint-disable-next-line no-restricted-syntax */} +
+ {info.bindings.map((binding, j) => ( +
+ + { + inputBindings.delete(action, binding) + doRefresh() + }} + /> +
+ ))} +
+
+ { + setModal( + { + inputBindings.add(action, shortcut) + doRefresh() + }} + /> + ) + }} + /> + { + inputBindings.reset(action) + doRefresh() + }} + /> +
+
+
+ )} + +
+ {info.description} +
+
+ )} +
+ + ) +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsSettingsTab.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsSettingsTab.tsx deleted file mode 100644 index 9149165acc54..000000000000 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsSettingsTab.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/** @file Settings tab for viewing and editing keyboard shortcuts. */ -import * as React from 'react' - -import * as refreshHooks from '#/hooks/refreshHooks' - -import * as textProvider from '#/providers/TextProvider' - -import KeyboardShortcutsSettingsTabBar from '#/layouts/Settings/KeyboardShortcutsSettingsTabBar' -import KeyboardShortcutsTable from '#/layouts/Settings/KeyboardShortcutsTable' - -import SettingsSection from '#/components/styled/settings/SettingsSection' - -// ==================================== -// === KeyboardShortcutsSettingsTab === -// ==================================== - -/** Settings tab for viewing and editing keyboard shortcuts. */ -export default function KeyboardShortcutsSettingsTab() { - const { getText } = textProvider.useText() - const [refresh, doRefresh] = refreshHooks.useRefresh() - - return ( - - - - - ) -} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsSettingsTabBar.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsSettingsTabBar.tsx deleted file mode 100644 index f5c8295ad7d7..000000000000 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsSettingsTabBar.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/** @file Button bar for managing keyboard shortcuts. */ -import * as React from 'react' - -import type * as inputBindingsModule from '#/configurations/inputBindings' - -import * as inputBindingsManager from '#/providers/InputBindingsProvider' -import * as modalProvider from '#/providers/ModalProvider' -import * as textProvider from '#/providers/TextProvider' - -import * as ariaComponents from '#/components/AriaComponents' -import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar' - -import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal' - -// ======================================= -// === KeyboardShortcutsSettingsTabBar === -// ======================================= - -/** Props for a {@link KeyboardShortcutsSettingsTabBar}. */ -export interface KeyboardShortcutsSettingsTabBarProps { - readonly doRefresh: () => void -} - -/** Button bar for managing keyboard shortcuts. */ -export default function KeyboardShortcutsSettingsTabBar( - props: KeyboardShortcutsSettingsTabBarProps -) { - const { doRefresh } = props - const inputBindings = inputBindingsManager.useInputBindings() - const { setModal } = modalProvider.useSetModal() - const { getText } = textProvider.useText() - - return ( - - { - setModal( - { - for (const k in inputBindings.metadata) { - // eslint-disable-next-line no-restricted-syntax - inputBindings.reset(k as inputBindingsModule.DashboardBindingKey) - } - doRefresh() - }} - /> - ) - }} - > - {getText('resetAll')} - - - ) -} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsTable.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsTable.tsx deleted file mode 100644 index a594a535d99a..000000000000 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/KeyboardShortcutsTable.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/** @file Settings tab for viewing and editing keyboard shortcuts. */ -import * as React from 'react' - -import BlankIcon from 'enso-assets/blank.svg' -import CrossIcon from 'enso-assets/cross.svg' -import Plus2Icon from 'enso-assets/plus2.svg' -import ReloadIcon from 'enso-assets/reload.svg' - -import type * as refreshHooks from '#/hooks/refreshHooks' -import * as scrollHooks from '#/hooks/scrollHooks' - -import * as inputBindingsManager from '#/providers/InputBindingsProvider' -import * as modalProvider from '#/providers/ModalProvider' -import * as textProvider from '#/providers/TextProvider' - -import KeyboardShortcutsSettingsTabBar from '#/layouts/Settings/KeyboardShortcutsSettingsTabBar' - -import * as aria from '#/components/aria' -import * as ariaComponents from '#/components/AriaComponents' -import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut' -import FocusArea from '#/components/styled/FocusArea' -import SvgMask from '#/components/SvgMask' - -import CaptureKeyboardShortcutModal from '#/modals/CaptureKeyboardShortcutModal' - -import * as object from '#/utilities/object' - -// ============================== -// === KeyboardShortcutsTable === -// ============================== - -/** Props for a {@link KeyboardShortcutsSettingsTabBar}. */ -export interface KeyboardShortcutsTableProps { - readonly refresh: refreshHooks.RefreshState - readonly doRefresh: () => void -} - -/** Settings tab for viewing and editing keyboard shortcuts. */ -export default function KeyboardShortcutsTable(props: KeyboardShortcutsTableProps) { - const { refresh, doRefresh } = props - const inputBindings = inputBindingsManager.useInputBindings() - const { setModal } = modalProvider.useSetModal() - const { getText } = textProvider.useText() - const rootRef = React.useRef(null) - const bodyRef = React.useRef(null) - const allShortcuts = React.useMemo(() => { - // This is REQUIRED, in order to avoid disabling the `react-hooks/exhaustive-deps` lint. - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - refresh - return new Set(Object.values(inputBindings.metadata).flatMap(value => value.bindings)) - }, [inputBindings.metadata, refresh]) - const visibleBindings = React.useMemo( - () => object.unsafeEntries(inputBindings.metadata).filter(kv => kv[1].rebindable !== false), - [inputBindings.metadata] - ) - - const { onScroll } = scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef) - - return ( - // There is a horizontal scrollbar for some reason without `px-px`. - // eslint-disable-next-line no-restricted-syntax - - {innerProps => ( -
()(innerProps, { - ref: rootRef, - className: 'overflow-auto px-px', - onScroll, - })} - > - - - - - - - - - - - {visibleBindings.map(kv => { - const [action, info] = kv - return ( - - - - - - - ) - })} - -
- {/* Icon */} - - {getText('name')} - {getText('shortcuts')}{getText('description')}
- - - {info.name} - - - {bindingsProps => ( -
- {/* I don't know why this padding is needed, - * given that this is a flex container. */} - {/* eslint-disable-next-line no-restricted-syntax */} -
- {info.bindings.map((binding, j) => ( -
- - { - inputBindings.delete(action, binding) - doRefresh() - }} - /> -
- ))} -
- { - setModal( - { - inputBindings.add(action, shortcut) - doRefresh() - }} - /> - ) - }} - /> - { - inputBindings.reset(action) - doRefresh() - }} - /> -
-
-
- )} -
-
- {info.description} -
-
- )} -
- ) -} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/LocalSettingsTab.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/LocalSettingsTab.tsx deleted file mode 100644 index 9e597202f4cd..000000000000 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/LocalSettingsTab.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/** @file Settings tab for viewing and editing account information. */ -import * as React from 'react' - -import * as reactQuery from '@tanstack/react-query' -import invariant from 'tiny-invariant' - -import * as backendProvider from '#/providers/BackendProvider' -import * as textProvider from '#/providers/TextProvider' - -import * as aria from '#/components/aria' -import SettingsInput from '#/components/styled/settings/SettingsInput' -import SettingsSection from '#/components/styled/settings/SettingsSection' - -import * as projectManager from '#/services/ProjectManager' - -// ======================== -// === LocalSettingsTab === -// ======================== - -/** Settings tab for viewing and editing account information. */ -export default function LocalSettingsTab() { - const localBackend = backendProvider.useLocalBackend() - const { getText } = textProvider.useText() - const rootPathRef = React.useRef(null) - - invariant(localBackend, '`LocalSettingsTab` requires a `localBackend` to function.') - - const doUpdaterootPathMutation = reactQuery.useMutation({ - mutationKey: [localBackend.type, 'updateRootPath'], - mutationFn: (value: string) => { - localBackend.rootPath = projectManager.Path(value) - return Promise.resolve() - }, - meta: { invalidates: [[localBackend.type, 'listDirectory']], awaitInvalidates: true }, - }) - - return ( - - - - {getText('rootDirectory')} - - - - - ) -} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTab.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsSection.tsx similarity index 52% rename from app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTab.tsx rename to app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsSection.tsx index b7e8cd2bf9d2..a38ab9fba986 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTab.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsSection.tsx @@ -10,29 +10,27 @@ import * as authProvider from '#/providers/AuthProvider' import * as backendProvider from '#/providers/BackendProvider' import * as textProvider from '#/providers/TextProvider' -import MembersSettingsTabBar from '#/layouts/Settings/MembersSettingsTabBar' - import * as ariaComponents from '#/components/AriaComponents' -import SettingsPage from '#/components/styled/settings/SettingsPage' -import SettingsSection from '#/components/styled/settings/SettingsSection' +import * as paywall from '#/components/Paywall' +import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar' + +import InviteUsersModal from '#/modals/InviteUsersModal' import type * as backendModule from '#/services/Backend' import type RemoteBackend from '#/services/RemoteBackend' -import * as paywallSettingsLayout from './withPaywall' - // ================= // === Constants === // ================= const LIST_USERS_STALE_TIME_MS = 60_000 -// ========================== -// === MembersSettingsTab === -// ========================== +// ============================== +// === MembersSettingsSection === +// ============================== /** Settings tab for viewing and editing organization members. */ -export function MembersSettingsTab() { +export default function MembersSettingsSection() { const { getText } = textProvider.useText() const backend = backendProvider.useRemoteBackendStrict() const { user } = authProvider.useFullUserSession() @@ -60,76 +58,89 @@ export function MembersSettingsTab() { const seatsLeft = isUnderPaywall ? feature.meta.maxSeats - (members.length + invitations.length) : null + const seatsTotal = feature.meta.maxSeats return ( - - - - - - - - - + <> + + + + {getText('inviteMembers')} + + + + + + {seatsLeft != null && ( +
+ + {seatsLeft <= 0 + ? getText('noSeatsLeft') + : getText('seatsLeft', seatsLeft, seatsTotal)} + + + +
+ )} +
+ +
- {getText('name')} - - {getText('status')} -
+ + + + + + + + {members.map(member => ( + + + - - - - {members.map(member => ( - - - - - ))} - - {invitations.map(invitation => ( - - - - - ))} - -
+ {getText('name')} + + {getText('status')} +
+ {member.email} + {member.name} + +
+ {getText('active')} + + + +
+
- {member.email} - {member.name} - -
- {getText('active')} - - - -
-
- {invitation.userEmail} - -
- {getText('pendingInvitation')} - - - {getText('copyInviteLink')} - - - - - - -
-
-
-
+ ))} + {invitations.map(invitation => ( + + + {invitation.userEmail} + + +
+ {getText('pendingInvitation')} + + + {getText('copyInviteLink')} + + + + + + +
+ + + ))} + + + ) } @@ -230,7 +241,3 @@ function RemoveInvitationButton(props: RemoveInvitationButtonProps) { ) } - -export default paywallSettingsLayout.withPaywall(MembersSettingsTab, { - feature: 'inviteUser', -}) diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTabBar.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTabBar.tsx deleted file mode 100644 index 4671c43e3d18..000000000000 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/MembersSettingsTabBar.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/** @file Button bar for managing organization members. */ -import * as React from 'react' - -import type * as billingHooks from '#/hooks/billing' - -import * as textProvider from '#/providers/TextProvider' - -import * as ariaComponents from '#/components/AriaComponents' -import * as paywallComponents from '#/components/Paywall' -import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar' - -import InviteUsersModal from '#/modals/InviteUsersModal' - -// ============================= -// === MembersSettingsTabBar === -// ============================= - -/** - * - */ -export interface MembersSettingsTabBarProps { - readonly seatsLeft: number | null - readonly seatsTotal: number - readonly feature: billingHooks.PaywallFeatureName -} - -/** Button bar for managing organization members. */ -export default function MembersSettingsTabBar(props: MembersSettingsTabBarProps) { - const { seatsLeft, seatsTotal, feature } = props - const { getText } = textProvider.useText() - - return ( - - - - {getText('inviteMembers')} - - - - - - {seatsLeft != null && ( -
- - {seatsLeft <= 0 ? getText('noSeatsLeft') : getText('seatsLeft', seatsLeft, seatsTotal)} - - - -
- )} -
- ) -} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationProfilePictureSettingsSection.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationProfilePictureInput.tsx similarity index 74% rename from app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationProfilePictureSettingsSection.tsx rename to app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationProfilePictureInput.tsx index 14321a5fd3be..b6dd935564f7 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationProfilePictureSettingsSection.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationProfilePictureInput.tsx @@ -1,4 +1,4 @@ -/** @file Settings tab for viewing and editing account information. */ +/** @file The input for viewing and changing the organization's profile picture. */ import * as React from 'react' import DefaultUserIcon from 'enso-assets/default_user.svg' @@ -10,22 +10,21 @@ import * as textProvider from '#/providers/TextProvider' import * as aria from '#/components/aria' import FocusRing from '#/components/styled/FocusRing' -import SettingsSection from '#/components/styled/settings/SettingsSection' import type Backend from '#/services/Backend' -// ================================================= -// === OrganizationProfilePictureSettingsSection === -// ================================================= +// ======================================= +// === OrganizationProfilePictureInput === +// ======================================= -/** Props for a {@link OrganizationProfilePictureSettingsSection}. */ -export interface OrganizationProfilePictureSettingsSectionProps { +/** Props for a {@link OrganizationProfilePictureInput}. */ +export interface OrganizationProfilePictureInputProps { readonly backend: Backend } -/** Settings tab for viewing and editing organization information. */ -export default function OrganizationProfilePictureSettingsSection( - props: OrganizationProfilePictureSettingsSectionProps +/** The input for viewing and changing the organization's profile picture. */ +export default function OrganizationProfilePictureInput( + props: OrganizationProfilePictureInputProps ) { const { backend } = props const toastAndLog = toastAndLogHooks.useToastAndLog() @@ -54,7 +53,7 @@ export default function OrganizationProfilePictureSettingsSection( } return ( - + <> {getText('organizationProfilePictureWarning')} - + ) } diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsSection.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsSection.tsx deleted file mode 100644 index 7e55adff7621..000000000000 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsSection.tsx +++ /dev/null @@ -1,152 +0,0 @@ -/** @file Settings tab for viewing and editing account information. */ -import * as React from 'react' - -import isEmail from 'validator/lib/isEmail' - -import * as backendHooks from '#/hooks/backendHooks' - -import * as textProvider from '#/providers/TextProvider' - -import * as aria from '#/components/aria' -import SettingsInput from '#/components/styled/settings/SettingsInput' -import SettingsSection from '#/components/styled/settings/SettingsSection' - -import * as backendModule from '#/services/Backend' -import type Backend from '#/services/Backend' - -// =================================== -// === OrganizationSettingsSection === -// =================================== - -/** Props for a {@link OrganizationSettingsSection}. */ -export interface OrganizationSettingsSectionProps { - readonly backend: Backend -} - -/** Settings tab for viewing and editing organization information. */ -export default function OrganizationSettingsSection(props: OrganizationSettingsSectionProps) { - const { backend } = props - const { getText } = textProvider.useText() - const nameRef = React.useRef(null) - const emailRef = React.useRef(null) - const websiteRef = React.useRef(null) - const locationRef = React.useRef(null) - const organization = backendHooks.useBackendGetOrganization(backend) - - const updateOrganizationMutation = backendHooks.useBackendMutation(backend, 'updateOrganization') - - const doUpdateName = () => { - const oldName = organization?.name ?? null - const name = nameRef.current?.value ?? '' - if (oldName !== name) { - updateOrganizationMutation.mutate([{ name }]) - } - } - - const doUpdateEmail = () => { - const oldEmail = organization?.email ?? null - const email = backendModule.EmailAddress(emailRef.current?.value ?? '') - if (oldEmail !== email) { - updateOrganizationMutation.mutate([{ email }]) - } - } - - const doUpdateWebsite = () => { - const oldWebsite = organization?.website ?? null - const website = backendModule.HttpsUrl(websiteRef.current?.value ?? '') - if (oldWebsite !== website) { - updateOrganizationMutation.mutate([{ website }]) - } - } - - const doUpdateLocation = () => { - const oldLocation = organization?.address ?? null - const location = locationRef.current?.value ?? '' - if (oldLocation !== location) { - updateOrganizationMutation.mutate([{ address: location }]) - } - } - - return ( - -
- (/\S/.test(name) ? true : '')} - className="flex h-row gap-settings-entry" - > - - {getText('organizationDisplayName')} - - - - (isEmail(email) ? true : getText('invalidEmailValidationError'))} - className="flex h-row items-start gap-settings-entry" - > - - {getText('email')} - -
- { - if (isEmail(value)) { - doUpdateEmail() - } else { - emailRef.current?.focus() - } - }} - onChange={() => { - emailRef.current?.setCustomValidity( - isEmail(emailRef.current.value) ? '' : 'Invalid email.' - ) - }} - /> - -
-
- - - {getText('website')} - - - - - - {getText('location')} - - - -
-
- ) -} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsTab.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsTab.tsx deleted file mode 100644 index c37d687ad6e8..000000000000 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/OrganizationSettingsTab.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** @file Settings tab for viewing and editing account information. */ -import * as React from 'react' - -import OrganizationProfilePictureSettingsSection from '#/layouts/Settings/OrganizationProfilePictureSettingsSection' -import OrganizationSettingsSection from '#/layouts/Settings/OrganizationSettingsSection' - -import type Backend from '#/services/Backend' - -// =============================== -// === OrganizationSettingsTab === -// =============================== - -/** Props for a {@link OrganizationSettingsTab}. */ -export interface OrganizationSettingsTabProps { - readonly backend: Backend -} - -/** Settings tab for viewing and editing organization information. */ -export default function OrganizationSettingsTab(props: OrganizationSettingsTabProps) { - const { backend } = props - - return ( -
-
- -
- -
- ) -} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/ProfilePictureSettingsSection.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/ProfilePictureInput.tsx similarity index 78% rename from app/ide-desktop/lib/dashboard/src/layouts/Settings/ProfilePictureSettingsSection.tsx rename to app/ide-desktop/lib/dashboard/src/layouts/Settings/ProfilePictureInput.tsx index b6d47f92c39f..cf8094970ec5 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/ProfilePictureSettingsSection.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/ProfilePictureInput.tsx @@ -1,4 +1,4 @@ -/** @file Settings tab for viewing and changing profile picture. */ +/** @file The input for viewing and changing the user's profile picture. */ import * as React from 'react' import DefaultUserIcon from 'enso-assets/default_user.svg' @@ -11,21 +11,20 @@ import * as textProvider from '#/providers/TextProvider' import * as aria from '#/components/aria' import FocusRing from '#/components/styled/FocusRing' -import SettingsSection from '#/components/styled/settings/SettingsSection' import type Backend from '#/services/Backend' -// ===================================== -// === ProfilePictureSettingsSection === -// ===================================== +// =========================== +// === ProfilePictureInput === +// =========================== -/** Props for a {@link ProfilePictureSettingsSection}. */ -export interface ProfilePictureSettingsSectionProps { +/** Props for a {@link ProfilePictureInput}. */ +export interface ProfilePictureInputProps { readonly backend: Backend } -/** Settings tab for viewing and changing profile picture. */ -export default function ProfilePictureSettingsSection(props: ProfilePictureSettingsSectionProps) { +/** The input for viewing and changing the user's profile picture. */ +export default function ProfilePictureInput(props: ProfilePictureInputProps) { const { backend } = props const toastAndLog = toastAndLogHooks.useToastAndLog() const { setUser } = authProvider.useAuth() @@ -55,7 +54,7 @@ export default function ProfilePictureSettingsSection(props: ProfilePictureSetti } return ( - + <> {getText('profilePictureWarning')} - + ) } diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsCustomEntry.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsCustomEntry.tsx new file mode 100644 index 000000000000..ddfc589b918d --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsCustomEntry.tsx @@ -0,0 +1,23 @@ +/** @file Rendering for an {@link settingsData.SettingsCustomEntryData}. */ +import * as React from 'react' + +import type * as settingsData from '#/layouts/Settings/settingsData' + +// ========================== +// === SettingsCustomEntry === +// ========================== + +/** Props for a {@link SettingsCustomEntry}. */ +export interface SettingsCustomEntryProps { + readonly context: settingsData.SettingsContext + readonly data: settingsData.SettingsCustomEntryData +} + +/** Rendering for an {@link settingsData.SettingsCustomEntryData}. */ +export default function SettingsCustomEntry(props: SettingsCustomEntryProps) { + const { context, data } = props + const { render: Render, getVisible } = data + const visible = getVisible?.(context) ?? true + + return !visible ? null : +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsEntry.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsEntry.tsx new file mode 100644 index 000000000000..c6abdea458a6 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsEntry.tsx @@ -0,0 +1,29 @@ +/** @file Rendering for an arbitrary {@link settingsData.SettingsEntryData}. */ +import * as React from 'react' + +import SettingsCustomEntry from '#/layouts/Settings/SettingsCustomEntry' +import * as settingsData from '#/layouts/Settings/settingsData' +import SettingsInputEntry from '#/layouts/Settings/SettingsInputEntry' + +// ===================== +// === SettingsEntry === +// ===================== + +/** Props for a {@link SettingsEntry}. */ +export interface SettingsEntryProps { + readonly context: settingsData.SettingsContext + readonly data: settingsData.SettingsEntryData +} + +/** Rendering for an arbitrary {@link settingsData.SettingsEntryData}. */ +export default function SettingsEntry(props: SettingsEntryProps) { + const { context, data } = props + switch (data.type) { + case settingsData.SettingsEntryType.input: { + return + } + case settingsData.SettingsEntryType.custom: { + return + } + } +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsInputEntry.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsInputEntry.tsx new file mode 100644 index 000000000000..af0fd57b59f4 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsInputEntry.tsx @@ -0,0 +1,88 @@ +/** @file Rendering for an {@link settingsData.SettingsInputEntryData}. */ +import * as React from 'react' + +import * as textProvider from '#/providers/TextProvider' + +import type * as settingsData from '#/layouts/Settings/settingsData' + +import * as aria from '#/components/aria' +import SettingsInput from '#/components/styled/SettingsInput' + +import * as errorModule from '#/utilities/error' + +// ================= +// === Constants === +// ================= + +/** The name of the single field in this form. */ +const FIELD_NAME = 'value' + +// ========================== +// === SettingsInputEntry === +// ========================== + +/** Props for a {@link SettingsInputEntry}. */ +export interface SettingsInputEntryProps { + readonly context: settingsData.SettingsContext + readonly data: settingsData.SettingsInputEntryData +} + +/** Rendering for an {@link settingsData.SettingsInputEntryData}. */ +export default function SettingsInputEntry(props: SettingsInputEntryProps) { + const { context, data } = props + const { nameId, getValue, setValue, validate, getEditable } = data + const { getText } = textProvider.useText() + const [errorMessage, setErrorMessage] = React.useState('') + const value = getValue(context) + const isEditable = getEditable(context) + + const input = ( + { + event.currentTarget.form?.requestSubmit() + }} + /> + ) + + return ( + { + event.preventDefault() + const [[, newValue] = []] = new FormData(event.currentTarget) + if (typeof newValue === 'string') { + setErrorMessage('') + try { + await setValue(context, newValue) + } catch (error) { + setErrorMessage(errorModule.getMessageOrToString(error)) + } + } + }} + > + validate(newValue, context) } : {})} + > + + {getText(nameId)} + + {validate ? ( +
+ {input} + +
+ ) : ( + input + )} + +
+
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsPaywall.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsPaywall.tsx new file mode 100644 index 000000000000..f81c3e5ef457 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsPaywall.tsx @@ -0,0 +1,44 @@ +/** + * @file + * + * This file contains a higher-order component that wraps a component in a paywall. + * The paywall is shown if the user's plan does not include the feature. + * The feature is determined by the `isFeatureUnderPaywall` hook. + */ + +import * as React from 'react' + +import type * as billingHooks from '#/hooks/billing' + +import * as paywallComponents from '#/components/Paywall' + +import * as twv from '#/utilities/tailwindVariants' + +/** + * Props for a {@link SettingsPaywall}. + */ +export interface SettingsPaywallProps { + readonly feature: billingHooks.PaywallFeatureName + readonly className?: string | undefined + readonly onInteracted?: () => void +} + +const PAYWALL_LAYOUT_STYLES = twv.tv({ base: 'mt-1' }) + +/** + * A layout that shows a paywall for a feature. + */ +export default function SettingsPaywall(props: SettingsPaywallProps) { + const { feature, className, onInteracted } = props + + return ( +
+ +
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsSection.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsSection.tsx new file mode 100644 index 000000000000..add9c128ca67 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsSection.tsx @@ -0,0 +1,49 @@ +/** @file Rendering for a settings section. */ +import * as React from 'react' + +import * as textProvider from '#/providers/TextProvider' + +import type * as settingsData from '#/layouts/Settings/settingsData' +import SettingsEntry from '#/layouts/Settings/SettingsEntry' + +import * as aria from '#/components/aria' +import FocusArea from '#/components/styled/FocusArea' + +// ======================= +// === SettingsSection === +// ======================= + +/** Props for a {@link SettingsSection}. */ +export interface SettingsSectionProps { + readonly context: settingsData.SettingsContext + readonly data: settingsData.SettingsSectionData +} + +/** Rendering for a settings section. */ +export default function SettingsSection(props: SettingsSectionProps) { + const { context, data } = props + const { nameId, focusArea = true, heading = true, entries } = data + const { getText } = textProvider.useText() + + return ( + + {innerProps => ( +
+ {!heading ? null : ( + + {getText(nameId)} + + )} +
+ {entries.map((entry, i) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsTab.ts b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsTab.ts deleted file mode 100644 index 54d25584fef9..000000000000 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsTab.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** @file A sub-page of the settings page. */ - -// =================== -// === SettingsTab === -// =================== - -/** A sub-page of the settings page. */ -enum SettingsTab { - account = 'account', - organization = 'organization', - features = 'features', - local = 'local', - notifications = 'notifications', - billingAndPlans = 'billing-and-plans', - members = 'members', - userGroups = 'user-groups', - appearance = 'appearance', - keyboardShortcuts = 'keyboard-shortcuts', - dataCoPilot = 'data-co-pilot', - featurePreview = 'feature-preview', - activityLog = 'activity-log', - compliance = 'compliance', - usageStatistics = 'usage-statistics', - personalAccessToken = 'personal-access-token', -} - -// eslint-disable-next-line no-restricted-syntax -export default SettingsTab diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsTab.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsTab.tsx new file mode 100644 index 000000000000..2d7a05fc25b5 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsTab.tsx @@ -0,0 +1,104 @@ +/** @file Rendering for a settings section. */ +import * as React from 'react' + +import * as tailwindMerge from 'tailwind-merge' + +import * as billing from '#/hooks/billing' + +import * as authProvider from '#/providers/AuthProvider' + +import type * as settingsData from '#/layouts/Settings/settingsData' +import SettingsPaywall from '#/layouts/Settings/SettingsPaywall' +import SettingsSection from '#/layouts/Settings/SettingsSection' + +import * as errorBoundary from '#/components/ErrorBoundary' +import * as loader from '#/components/Loader' + +// =================== +// === SettingsTab === +// =================== + +/** Props for a {@link SettingsTab}. */ +export interface SettingsTabProps { + readonly context: settingsData.SettingsContext + readonly data: settingsData.SettingsTabData + readonly onInteracted: () => void +} + +/** Styled content of a settings tab. */ +export default function SettingsTab(props: SettingsTabProps) { + const { context, data, onInteracted } = props + const { sections } = data + const { user } = authProvider.useFullUserSession() + const { isFeatureUnderPaywall } = billing.usePaywall({ plan: user.plan }) + const paywallFeature = + data.feature != null && isFeatureUnderPaywall(data.feature) ? data.feature : null + const [columns, classes] = React.useMemo< + [readonly (readonly settingsData.SettingsSectionData[])[], readonly string[]] + >(() => { + const resultColumns: settingsData.SettingsSectionData[][] = [] + const resultClasses: string[] = [] + for (const section of sections) { + const columnNumber = section.column ?? 1 + while (resultColumns.length < columnNumber) { + resultColumns.push([]) + } + resultColumns[columnNumber - 1]?.push(section) + while (resultClasses.length < columnNumber) { + resultClasses.push('') + } + if (section.columnClassName != null) { + const oldClasses = resultClasses[columnNumber - 1] + resultClasses[columnNumber - 1] = + oldClasses == null ? section.columnClassName : `${oldClasses} ${section.columnClassName}` + } + } + return [resultColumns, resultClasses] + }, [sections]) + + const contentProps = { + onMouseDown: onInteracted, + onPointerDown: onInteracted, + onFocus: onInteracted, + } + + if (paywallFeature) { + return + } else { + const content = + columns.length === 1 ? ( +
+ {sections.map(section => ( + + ))} +
+ ) : ( +
+ {columns.map((sectionsInColumn, i) => ( +
+ {sectionsInColumn.map(section => ( + + ))} +
+ ))} +
+ ) + + return ( + + }> + {content} + + + ) + } +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsTabType.ts b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsTabType.ts new file mode 100644 index 000000000000..8ef034d97068 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/SettingsTabType.ts @@ -0,0 +1,28 @@ +/** @file A sub-page of the settings page. */ + +// ======================= +// === SettingsTabType === +// ======================= + +/** A sub-page of the settings page. */ +enum SettingsTabType { + account = 'account', + organization = 'organization', + local = 'local', + // features = 'features', + // notifications = 'notifications', + // billingAndPlans = 'billing-and-plans', + members = 'members', + userGroups = 'user-groups', + // appearance = 'appearance', + keyboardShortcuts = 'keyboard-shortcuts', + // dataCoPilot = 'data-co-pilot', + // featurePreview = 'feature-preview', + activityLog = 'activity-log', + // compliance = 'compliance', + // usageStatistics = 'usage-statistics', + // personalAccessToken = 'personal-access-token', +} + +// eslint-disable-next-line no-restricted-syntax +export default SettingsTabType diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserAccountSettingsSection.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserAccountSettingsSection.tsx deleted file mode 100644 index 1af645a4cc17..000000000000 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserAccountSettingsSection.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/** @file Settings section for viewing and editing account information. */ -import * as React from 'react' - -import * as backendHooks from '#/hooks/backendHooks' -import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' - -import * as authProvider from '#/providers/AuthProvider' -import * as textProvider from '#/providers/TextProvider' - -import * as aria from '#/components/aria' -import SettingsInput from '#/components/styled/settings/SettingsInput' -import SettingsSection from '#/components/styled/settings/SettingsSection' - -import type Backend from '#/services/Backend' - -// ================================== -// === UserAccountSettingsSection === -// ================================== - -/** Props for a {@link UserAccountSettingsSection}. */ -export interface UserAccountSettingsSectionProps { - readonly backend: Backend -} - -/** Settings section for viewing and editing account information. */ -export default function UserAccountSettingsSection(props: UserAccountSettingsSectionProps) { - const { backend } = props - const { setUser, authQueryKey } = authProvider.useAuth() - const { user } = authProvider.useFullUserSession() - - const toastAndLog = toastAndLogHooks.useToastAndLog() - - const { getText } = textProvider.useText() - const nameRef = React.useRef(null) - - const updateUserMutation = backendHooks.useBackendMutation(backend, 'updateUser', { - meta: { invalidates: [authQueryKey], awaitInvalidates: true }, - }) - - const doUpdateName = async (newName: string) => { - const oldName = user.name - if (newName === oldName) { - return - } else { - try { - await updateUserMutation.mutateAsync([{ username: newName }]) - setUser({ name: newName }) - } catch (error) { - toastAndLog(null, error) - const ref = nameRef.current - - if (ref) { - ref.value = oldName - } - } - return - } - } - - return ( - -
- - - {getText('name')} - - - -
- - {getText('email')} - - {user.email} -
-
-
- ) -} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupUserRow.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupUserRow.tsx index 000cf61ba453..dbbf5841e533 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupUserRow.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupUserRow.tsx @@ -69,8 +69,8 @@ export default function UserGroupUserRow(props: UserGroupUserRowProps) { )} ref={contextMenuRef} > - -
+ +
{user.name} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupsSettingsTab.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupsSettingsSection.tsx similarity index 51% rename from app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupsSettingsTab.tsx rename to app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupsSettingsSection.tsx index a265535bc60c..2239aa224309 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupsSettingsTab.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/UserGroupsSettingsSection.tsx @@ -12,7 +12,6 @@ import * as authProvider from '#/providers/AuthProvider' import * as modalProvider from '#/providers/ModalProvider' import * as textProvider from '#/providers/TextProvider' -import MembersTable from '#/layouts/Settings/MembersTable' import UserGroupRow from '#/layouts/Settings/UserGroupRow' import UserGroupUserRow from '#/layouts/Settings/UserGroupUserRow' @@ -20,7 +19,7 @@ import * as aria from '#/components/aria' import * as ariaComponents from '#/components/AriaComponents' import * as paywallComponents from '#/components/Paywall' import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner' -import SettingsSection from '#/components/styled/settings/SettingsSection' +import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar' import NewUserGroupModal from '#/modals/NewUserGroupModal' @@ -29,19 +28,17 @@ import type Backend from '#/services/Backend' import * as tailwindMerge from '#/utilities/tailwindMerge' -import * as withPaywall from './withPaywall' +// ================================= +// === UserGroupsSettingsSection === +// ================================= -// ============================= -// === UserGroupsSettingsTab === -// ============================= - -/** Props for a {@link UserGroupsSettingsTab}. */ -export interface UserGroupsSettingsTabProps { +/** Props for a {@link UserGroupsSettingsSection}. */ +export interface UserGroupsSettingsSectionProps { readonly backend: Backend } /** Settings tab for viewing and editing organization members. */ -function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps) { +export default function UserGroupsSettingsSection(props: UserGroupsSettingsSectionProps) { const { backend } = props const { setModal } = modalProvider.useSetModal() const { getText } = textProvider.useText() @@ -133,115 +130,108 @@ function UserGroupsSettingsTab(props: UserGroupsSettingsTabProps) { } return ( -
-
- -
- {shouldDisplayPaywall ? ( - - {getText('newUserGroup')} - - ) : ( - { - const rect = event.target.getBoundingClientRect() - const position = { pageX: rect.left, pageY: rect.top } - setModal() - }} - > - {getText('newUserGroup')} - - )} - - {isUnderPaywall && ( - - {userGroupsLeft <= 0 - ? getText('userGroupsPaywallMessage') - : getText('userGroupsLimitMessage', userGroupsLeft)} - - )} -
-
- + +
+ {shouldDisplayPaywall && ( + - - + )} + {!shouldDisplayPaywall && ( + { + const rect = event.target.getBoundingClientRect() + const position = { pageX: rect.left, pageY: rect.top } + setModal() + }} + > + {getText('newUserGroup')} + + )} + + {isUnderPaywall && ( + + {userGroupsLeft <= 0 + ? getText('userGroupsPaywallMessage') + : getText('userGroupsLimitMessage', userGroupsLeft)} + + )} +
+
+
+ + + + {getText('userGroup')} + + {/* Delete button. */} + + + + {isLoading ? ( + + { + if (element != null) { + element.colSpan = 2 + } + }} > - {getText('userGroup')} - - {/* Delete button. */} - - - - {isLoading ? ( - - { - if (element != null) { - element.colSpan = 2 - } - }} - > -
- -
-
-
- ) : ( - userGroup => ( - <> - - {userGroup.users.map(otherUser => ( - - ))} - - ) - )} -
-
-
- +
+ +
+ + + ) : ( + userGroup => ( + <> + + {userGroup.users.map(otherUser => ( + + ))} + + ) + )} + +
- - - - -
+ ) } - -export default withPaywall.withPaywall(UserGroupsSettingsTab, { feature: 'userGroups' }) diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/settingsData.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/settingsData.tsx new file mode 100644 index 000000000000..5e0dd4081dfd --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/layouts/Settings/settingsData.tsx @@ -0,0 +1,470 @@ +/** @file Metadata for rendering each settings section. */ +import * as React from 'react' + +import isEmail from 'validator/lib/isEmail' + +import KeyboardShortcutsIcon from 'enso-assets/keyboard_shortcuts.svg' +import LogIcon from 'enso-assets/log.svg' +import NotCloudIcon from 'enso-assets/not_cloud.svg' +import PeopleSettingsIcon from 'enso-assets/people_settings.svg' +import PeopleIcon from 'enso-assets/people.svg' +import SettingsIcon from 'enso-assets/settings.svg' + +import type * as text from '#/text' + +import * as inputBindings from '#/configurations/inputBindings' + +import type * as billing from '#/hooks/billing' +import type * as toastAndLogHooks from '#/hooks/toastAndLogHooks' + +import type * as textProvider from '#/providers/TextProvider' + +import ActivityLogSettingsSection from '#/layouts/Settings/ActivityLogSettingsSection' +import ChangePasswordForm from '#/layouts/Settings/ChangePasswordForm' +import DeleteUserAccountSettingsSection from '#/layouts/Settings/DeleteUserAccountSettingsSection' +import KeyboardShortcutsSettingsSection from '#/layouts/Settings/KeyboardShortcutsSettingsSection' +import MembersSettingsSection from '#/layouts/Settings/MembersSettingsSection' +import MembersTable from '#/layouts/Settings/MembersTable' +import OrganizationProfilePictureInput from '#/layouts/Settings/OrganizationProfilePictureInput' +import ProfilePictureInput from '#/layouts/Settings/ProfilePictureInput' +import SettingsTabType from '#/layouts/Settings/SettingsTabType' +import UserGroupsSettingsSection from '#/layouts/Settings/UserGroupsSettingsSection' + +import * as menuEntry from '#/components/MenuEntry' + +import * as backend from '#/services/Backend' +import type Backend from '#/services/Backend' +import type LocalBackend from '#/services/LocalBackend' + +import * as object from '#/utilities/object' + +// ========================= +// === SettingsEntryType === +// ========================= + +/** The tag for the {@link SettingsEntryData} discriminated union. */ +export enum SettingsEntryType { + input = 'input', + custom = 'custom', +} + +// ================= +// === Constants === +// ================= + +export const SETTINGS_TAB_DATA: Readonly> = { + [SettingsTabType.account]: { + nameId: 'accountSettingsTab', + settingsTab: SettingsTabType.account, + icon: SettingsIcon, + sections: [ + { + nameId: 'userAccountSettingsSection', + entries: [ + { + type: SettingsEntryType.input, + nameId: 'userNameSettingsInput', + getValue: context => context.user.name, + setValue: async (context, newName) => { + const oldName = context.user.name + if (newName !== oldName) { + await context.updateUser([{ username: newName }]) + } + }, + validate: name => (/\S/.test(name) ? true : ''), + getEditable: () => true, + }, + { + type: SettingsEntryType.input, + nameId: 'userEmailSettingsInput', + getValue: context => context.user.email, + // A user's email currently cannot be changed. + setValue: async () => {}, + validate: (email, context) => + isEmail(email) + ? true + : email === '' + ? '' + : context.getText('invalidEmailValidationError'), + getEditable: () => false, + }, + ], + }, + { + nameId: 'changePasswordSettingsSection', + entries: [ + { + type: SettingsEntryType.custom, + aliasesId: 'changePasswordSettingsCustomEntryAliases', + render: ChangePasswordForm, + getVisible: context => { + // The shape of the JWT payload is statically known. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const username: string | null = + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-non-null-assertion + JSON.parse(atob(context.accessToken.split('.')[1]!)).username + return username != null ? !/^Github_|^Google_/.test(username) : false + }, + }, + ], + }, + { + nameId: 'deleteUserAccountSettingsSection', + heading: false, + entries: [ + { + type: SettingsEntryType.custom, + aliasesId: 'deleteUserAccountSettingsCustomEntryAliases', + render: () => , + }, + ], + }, + { + nameId: 'profilePictureSettingsSection', + column: 2, + entries: [ + { + type: SettingsEntryType.custom, + aliasesId: 'profilePictureSettingsCustomEntryAliases', + render: context => context.backend && , + }, + ], + }, + ], + }, + [SettingsTabType.organization]: { + nameId: 'organizationSettingsTab', + settingsTab: SettingsTabType.organization, + icon: PeopleSettingsIcon, + organizationOnly: true, + sections: [ + { + nameId: 'organizationSettingsSection', + entries: [ + { + type: SettingsEntryType.input, + nameId: 'organizationNameSettingsInput', + getValue: context => context.organization?.name ?? '', + setValue: async (context, newName) => { + const oldName = context.organization?.name ?? null + if (oldName !== newName) { + await context.updateOrganization([{ name: newName }]) + } + }, + validate: name => (/\S/.test(name) ? true : ''), + getEditable: () => true, + }, + { + type: SettingsEntryType.input, + nameId: 'organizationEmailSettingsInput', + getValue: context => context.organization?.email ?? '', + setValue: async (context, newValue) => { + const newEmail = backend.EmailAddress(newValue) + const oldEmail = context.organization?.email ?? null + if (oldEmail !== newEmail) { + await context.updateOrganization([{ email: newEmail }]) + } + }, + validate: (email, context) => + isEmail(email) + ? true + : email === '' + ? '' + : context.getText('invalidEmailValidationError'), + getEditable: () => true, + }, + { + type: SettingsEntryType.input, + nameId: 'organizationWebsiteSettingsInput', + getValue: context => context.organization?.website ?? '', + setValue: async (context, newValue) => { + const newWebsite = backend.HttpsUrl(newValue) + const oldWebsite = context.organization?.website ?? null + if (oldWebsite !== newWebsite) { + await context.updateOrganization([{ website: newWebsite }]) + } + }, + getEditable: () => true, + }, + { + type: SettingsEntryType.input, + nameId: 'organizationLocationSettingsInput', + getValue: context => context.organization?.address ?? '', + setValue: async (context, newLocation) => { + const oldLocation = context.organization?.address ?? null + if (oldLocation !== newLocation) { + await context.updateOrganization([{ address: newLocation }]) + } + }, + getEditable: () => true, + }, + ], + }, + { + nameId: 'organizationProfilePictureSettingsSection', + column: 2, + entries: [ + { + type: SettingsEntryType.custom, + aliasesId: 'organizationProfilePictureSettingsCustomEntryAliases', + render: context => + context.backend && , + }, + ], + }, + ], + }, + [SettingsTabType.local]: { + nameId: 'localSettingsTab', + settingsTab: SettingsTabType.organization, + icon: NotCloudIcon, + visible: context => context.localBackend != null, + sections: [ + { + nameId: 'localSettingsSection', + entries: [ + { + type: SettingsEntryType.input, + nameId: 'localRootPathSettingsInput', + getValue: context => context.localBackend?.rootPath ?? '', + setValue: (context, value) => context.updateLocalRootPath(value), + getEditable: () => true, + }, + ], + }, + ], + }, + [SettingsTabType.members]: { + nameId: 'membersSettingsTab', + settingsTab: SettingsTabType.members, + icon: PeopleIcon, + organizationOnly: true, + feature: 'inviteUser', + sections: [ + { + nameId: 'membersSettingsSection', + entries: [ + { + type: SettingsEntryType.custom, + render: () => , + }, + ], + }, + ], + }, + [SettingsTabType.userGroups]: { + nameId: 'userGroupsSettingsTab', + settingsTab: SettingsTabType.userGroups, + icon: PeopleSettingsIcon, + organizationOnly: true, + feature: 'userGroups', + sections: [ + { + nameId: 'userGroupsSettingsSection', + columnClassName: 'h-3/5 lg:h-[unset] overflow-auto', + entries: [ + { + type: SettingsEntryType.custom, + render: context => + context.backend && , + }, + ], + }, + { + nameId: 'userGroupsUsersSettingsSection', + column: 2, + columnClassName: 'h-2/5 lg:h-[unset] overflow-auto', + entries: [ + { + type: SettingsEntryType.custom, + render: context => + context.backend && ( + + ), + }, + ], + }, + ], + }, + [SettingsTabType.keyboardShortcuts]: { + nameId: 'keyboardShortcutsSettingsTab', + settingsTab: SettingsTabType.keyboardShortcuts, + icon: KeyboardShortcutsIcon, + sections: [ + { + nameId: 'keyboardShortcutsSettingsSection', + entries: [ + { + type: SettingsEntryType.custom, + aliasesId: 'keyboardShortcutsSettingsCustomEntryAliases', + getExtraAliases: context => { + const rebindableBindings = object + .unsafeEntries(inputBindings.BINDINGS) + .flatMap(kv => { + const [k, v] = kv + if (v.rebindable === false) { + return [] + } else { + return menuEntry.ACTION_TO_TEXT_ID[k] + } + }) + return rebindableBindings.map(binding => context.getText(binding)) + }, + render: KeyboardShortcutsSettingsSection, + }, + ], + }, + ], + }, + [SettingsTabType.activityLog]: { + nameId: 'activityLogSettingsTab', + settingsTab: SettingsTabType.activityLog, + icon: LogIcon, + organizationOnly: true, + sections: [ + { + nameId: 'activityLogSettingsSection', + entries: [ + { + type: SettingsEntryType.custom, + render: context => + context.backend && , + }, + ], + }, + ], + }, +} + +export const SETTINGS_DATA: SettingsData = [ + { + nameId: 'generalSettingsTabSection', + tabs: [ + SETTINGS_TAB_DATA[SettingsTabType.account], + SETTINGS_TAB_DATA[SettingsTabType.organization], + ], + }, + { + nameId: 'accessSettingsTabSection', + tabs: [ + SETTINGS_TAB_DATA[SettingsTabType.members], + SETTINGS_TAB_DATA[SettingsTabType.userGroups], + ], + }, + { + nameId: 'lookAndFeelSettingsTabSection', + tabs: [SETTINGS_TAB_DATA[SettingsTabType.keyboardShortcuts]], + }, + { + nameId: 'securitySettingsTabSection', + tabs: [SETTINGS_TAB_DATA[SettingsTabType.activityLog]], + }, +] + +export const ALL_SETTINGS_TABS = SETTINGS_DATA.flatMap(section => + section.tabs.map(tab => tab.settingsTab) +) + +// ======================= +// === SettingsContext === +// ======================= + +/** Metadata describing inputs passed to every settings entry. */ +export interface SettingsContext { + readonly accessToken: string + readonly user: backend.User + readonly backend: Backend | null + readonly localBackend: LocalBackend | null + readonly organization: backend.OrganizationInfo | null + readonly updateUser: (variables: Parameters) => Promise + readonly updateOrganization: ( + variables: Parameters + ) => Promise + readonly updateLocalRootPath: (rootPath: string) => Promise + readonly toastAndLog: toastAndLogHooks.ToastAndLogCallback + readonly getText: textProvider.GetText +} + +// ============================== +// === SettingsInputEntryData === +// ============================== + +/** Metadata describing a settings entry that is an input. */ +export interface SettingsInputEntryData { + readonly type: SettingsEntryType.input + readonly nameId: text.TextId & `${string}SettingsInput` + readonly getValue: (context: SettingsContext) => string + readonly setValue: (context: SettingsContext, value: string) => Promise + readonly validate?: (value: string, context: SettingsContext) => string | true + readonly getEditable: (context: SettingsContext) => boolean +} + +// =============================== +// === SettingsCustomEntryData === +// =============================== + +/** Metadata describing a settings entry that needs custom rendering. */ +export interface SettingsCustomEntryData { + readonly type: SettingsEntryType.custom + readonly aliasesId?: text.TextId & `${string}SettingsCustomEntryAliases` + readonly getExtraAliases?: (context: SettingsContext) => readonly string[] + readonly render: (context: SettingsContext) => React.ReactNode + readonly getVisible?: (context: SettingsContext) => boolean +} + +// ========================= +// === SettingsEntryData === +// ========================= + +/** A settings entry of an arbitrary type. */ +export type SettingsEntryData = SettingsCustomEntryData | SettingsInputEntryData + +// ======================= +// === SettingsTabData === +// ======================= + +/** Metadata describing a settings section. */ +export interface SettingsSectionData { + readonly nameId: text.TextId & `${string}SettingsSection` + /** The first column is column 1, not column 0. */ + readonly column?: number + readonly heading?: false + readonly focusArea?: false + readonly columnClassName?: string + readonly aliases?: text.TextId[] + readonly entries: readonly SettingsEntryData[] +} + +// ======================= +// === SettingsTabData === +// ======================= + +/** Metadata describing a settings tab. */ +export interface SettingsTabData { + readonly nameId: text.TextId & `${string}SettingsTab` + readonly settingsTab: SettingsTabType + readonly icon: string + readonly visible?: (context: SettingsContext) => boolean + readonly organizationOnly?: true + /** The feature behind which this settings tab is locked. If the user cannot access the feature, + * a paywall is shown instead of the settings tab. */ + readonly feature?: billing.PaywallFeatureName + readonly sections: readonly SettingsSectionData[] +} + +// ============================== +// === SettingsTabSectionData === +// ============================== + +/** Metadata describing a settings tab section. */ +export interface SettingsTabSectionData { + readonly nameId: text.TextId & `${string}SettingsTabSection` + readonly tabs: readonly SettingsTabData[] +} + +// ==================== +// === SettingsData === +// ==================== + +/** Metadata describing all settings. */ +export type SettingsData = readonly SettingsTabSectionData[] diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Settings/withPaywall.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Settings/withPaywall.tsx deleted file mode 100644 index 5c09e03e6fd8..000000000000 --- a/app/ide-desktop/lib/dashboard/src/layouts/Settings/withPaywall.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * @file - * - * This file contains a higher-order component that wraps a component in a paywall. - * The paywall is shown if the user's plan does not include the feature. - * The feature is determined by the `isFeatureUnderPaywall` hook. - */ - -import * as React from 'react' - -import * as billingHooks from '#/hooks/billing' - -import * as authProvider from '#/providers/AuthProvider' - -import * as paywallComponents from '#/components/Paywall' - -import * as twv from '#/utilities/tailwindVariants' - -/** - * Props for the `withPaywall` HOC. - */ -export interface PaywallSettingsLayoutProps { - readonly feature: billingHooks.PaywallFeatureName - readonly className?: string | undefined -} - -const PAYWALL_LAYOUT_STYLES = twv.tv({ base: 'mt-1' }) - -/** - * A layout that shows a paywall for a feature. - */ -export function PaywallSettingsLayout(props: PaywallSettingsLayoutProps) { - const { feature, className } = props - - return ( -
- -
- ) -} - -/** - * Wraps a component in a paywall. - * The paywall is shown if the user's plan does not include the feature. - * The feature is determined by the `isFeatureUnderPaywall` hook. - */ -export function withPaywall

( - // eslint-disable-next-line @typescript-eslint/naming-convention - Component: React.ComponentType

, - props: PaywallSettingsLayoutProps -) { - const { feature, className } = props - - return function WithPaywall(componentProps: P & React.JSX.IntrinsicAttributes) { - const { user } = authProvider.useFullUserSession() - - const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan }) - const showPaywall = isFeatureUnderPaywall(feature) - - if (showPaywall) { - return - } else { - return - } - } -} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/SettingsSidebar.tsx b/app/ide-desktop/lib/dashboard/src/layouts/SettingsSidebar.tsx index 364250ce9a36..ead44978297c 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/SettingsSidebar.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/SettingsSidebar.tsx @@ -1,17 +1,10 @@ /** @file A panel to switch between settings tabs. */ import * as React from 'react' -import KeyboardShortcutsIcon from 'enso-assets/keyboard_shortcuts.svg' -import LogIcon from 'enso-assets/log.svg' -import NotCloudIcon from 'enso-assets/not_cloud.svg' -import PeopleSettingsIcon from 'enso-assets/people_settings.svg' -import PeopleIcon from 'enso-assets/people.svg' -import SettingsIcon from 'enso-assets/settings.svg' - -import * as backendProvider from '#/providers/BackendProvider' import * as textProvider from '#/providers/TextProvider' -import SettingsTab from '#/layouts/Settings/SettingsTab' +import * as settingsData from '#/layouts/Settings/settingsData' +import type SettingsTabType from '#/layouts/Settings/SettingsTabType' import * as aria from '#/components/aria' import * as ariaComponents from '#/components/AriaComponents' @@ -20,120 +13,25 @@ import SidebarTabButton from '#/components/styled/SidebarTabButton' import * as tailwindMerge from '#/utilities/tailwindMerge' -// ================= -// === Constants === -// ================= - -const SECTIONS: SettingsSectionData[] = [ - { - name: 'General', - tabs: [ - { - name: 'Account', - settingsTab: SettingsTab.account, - icon: SettingsIcon, - requiresBackend: true, - }, - { - name: 'Organization', - settingsTab: SettingsTab.organization, - icon: PeopleSettingsIcon, - requiresBackend: true, - }, - { - name: 'Local data', - settingsTab: SettingsTab.local, - icon: NotCloudIcon, - requiresBackend: false, - requiresLocalBackend: true, - }, - ], - }, - { - name: 'Access', - tabs: [ - { - name: 'Members', - settingsTab: SettingsTab.members, - icon: PeopleIcon, - requiresBackend: true, - organizationOnly: true, - }, - { - name: 'User Groups', - settingsTab: SettingsTab.userGroups, - icon: PeopleSettingsIcon, - requiresBackend: true, - organizationOnly: true, - }, - ], - }, - { - name: 'Look and feel', - tabs: [ - { - name: 'Keyboard shortcuts', - settingsTab: SettingsTab.keyboardShortcuts, - icon: KeyboardShortcutsIcon, - requiresBackend: false, - }, - ], - }, - { - name: 'Security', - tabs: [ - { - name: 'Activity log', - settingsTab: SettingsTab.activityLog, - icon: LogIcon, - requiresBackend: true, - organizationOnly: true, - }, - ], - }, -] - -// ============= -// === Types === -// ============= - -/** Metadata for rendering a settings tab label. */ -interface SettingsTabLabelData { - readonly name: string - readonly settingsTab: SettingsTab - readonly icon: string - readonly requiresBackend: boolean - readonly requiresLocalBackend?: boolean - readonly organizationOnly?: true -} - -/** Metadata for rendering a settings section. */ -interface SettingsSectionData { - readonly name: string - readonly tabs: SettingsTabLabelData[] -} - // ======================= // === SettingsSidebar === // ======================= /** Props for a {@link SettingsSidebar} */ export interface SettingsSidebarProps { + readonly context: settingsData.SettingsContext + readonly tabsToShow: readonly SettingsTabType[] readonly isMenu?: true - readonly hasBackend: boolean - readonly isUserInOrganization: boolean - readonly settingsTab: SettingsTab - readonly setSettingsTab: React.Dispatch> + readonly tab: SettingsTabType + readonly setTab: React.Dispatch> readonly onClickCapture?: () => void } /** A panel to switch between settings tabs. */ export default function SettingsSidebar(props: SettingsSidebarProps) { - const { isMenu = false, hasBackend, isUserInOrganization, settingsTab, setSettingsTab } = props + const { context, tabsToShow, isMenu = false, tab, setTab } = props const { onClickCapture } = props const { getText } = textProvider.useText() - const localBackend = backendProvider.useLocalBackend() - const hasLocalBackend = localBackend != null return ( @@ -149,32 +47,31 @@ export default function SettingsSidebar(props: SettingsSidebarProps) { onClickCapture={onClickCapture} {...innerProps} > - {SECTIONS.map(section => { - const visibleTabs = section.tabs.filter( - tab => - (!tab.requiresBackend || hasBackend) && - (tab.requiresLocalBackend !== true || hasLocalBackend) + {settingsData.SETTINGS_DATA.map(section => { + const name = getText(section.nameId) + const visibleTabData = section.tabs.filter( + tabData => + tabsToShow.includes(tabData.settingsTab) && + (!tabData.visible || tabData.visible(context)) ) - return visibleTabs.length === 0 ? null : ( -

+ return visibleTabData.length === 0 ? null : ( +
- {section.name} + {name} - - {visibleTabs.map(tab => ( + {visibleTabData.map(tabData => ( { - setSettingsTab(tab.settingsTab) + setTab(tabData.settingsTab) }} /> ))} diff --git a/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx b/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx index 0026d9845d82..235868cdac6b 100644 --- a/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx +++ b/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx @@ -400,7 +400,11 @@ export default function Dashboard(props: DashboardProps) { )} - {page === TabType.settings && } + {page === TabType.settings && } {process.env.ENSO_CLOUD_CHAT_URL != null ? ( ({ - modal: null, -}) +const ModalContext = React.createContext({ modal: null }) const ModalStaticContext = React.createContext({ setModal: () => { @@ -58,7 +56,7 @@ export default function ModalProvider(props: ModalProviderProps) { return {setModalProvider} } -/** Props for a {@link ModalProvider}. */ +/** Props for a {@link ModalStaticProvider}. */ interface InternalModalStaticProviderProps extends Readonly { readonly setModal: React.Dispatch> readonly modalRef: React.RefObject @@ -75,12 +73,20 @@ function ModalStaticProvider(props: InternalModalStaticProviderProps) { ) } +// ================ +// === useModal === +// ================ + /** A React context hook exposing the currently active modal, if one is currently visible. */ export function useModal() { const { modal } = React.useContext(ModalContext) return { modal } as const } +// =================== +// === useModalRef === +// =================== + /** A React context hook exposing the currently active modal (if one is currently visible) as a ref. */ export function useModalRef() { @@ -88,6 +94,10 @@ export function useModalRef() { return { modalRef } as const } +// =================== +// === useSetModal === +// =================== + /** A React context hook exposing functions to set and unset the currently active modal. */ export function useSetModal() { const { setModal: setModalRaw } = React.useContext(ModalStaticContext) diff --git a/app/ide-desktop/lib/dashboard/src/tailwind.css b/app/ide-desktop/lib/dashboard/src/tailwind.css index d04142249e28..8a1e7d44d3be 100644 --- a/app/ide-desktop/lib/dashboard/src/tailwind.css +++ b/app/ide-desktop/lib/dashboard/src/tailwind.css @@ -314,17 +314,10 @@ --members-email-column-width: 12rem; --keyboard-shortcuts-icon-column-width: 2rem; --keyboard-shortcuts-name-column-width: 9rem; + --keyboard-shortcuts-description-column-width: 16rem; --icon-column-padding-right: 0.375rem; /* The horizontal gap between each icon for modifying the shortcuts for a particular action. */ --keyboard-shortcuts-button-gap: 0.25rem; - /** The horizontal gap between individual filters in the activity log. */ - --activity-log-filters-gap: 0.75rem; - /** The horizontal gap between a filter and its input in the activity log. */ - --activity-log-filter-gap: 0.5rem; - --activity-log-icon-column-width: 2rem; - --activity-log-type-column-width: 8rem; - --activity-log-email-column-width: 12rem; - --activity-log-timestamp-column-width: 9rem; /***********************\ |* Authentication flow *| diff --git a/app/ide-desktop/lib/dashboard/src/text/english.json b/app/ide-desktop/lib/dashboard/src/text/english.json index 9e32aac44068..405627d3f65b 100644 --- a/app/ide-desktop/lib/dashboard/src/text/english.json +++ b/app/ide-desktop/lib/dashboard/src/text/english.json @@ -220,14 +220,8 @@ "datalink": "Datalink", "createDatalink": "Create Datalink", "resetAll": "Reset All", - "openHelpChat": "Open Help Chat", - "organization": "Organization", - "organizationDisplayName": "Organization display name", - "website": "Website", - "location": "Location", - "rootDirectory": "Root Folder", "enterSecretPath": "Enter secret path", "enterText": "Enter text", @@ -645,9 +639,8 @@ "assetContextMenuLabel": "Asset context menu", "labelContextMenuLabel": "Label context menu", "settingsSidebarLabel": "Settings sidebar", - "userContextMenuLabel": "User context menu", - "userGroupContextMenuLabel": "User Group context menu", - "userGroupUserContextMenuLabel": "User Group User context menu", + "settingsSearchBarLabel": "Settings search bar", + "settingsSearchBarPlaceholder": "Type to search for settings.", "restoreThisVersion": "Restore this version", "duplicateThisVersion": "Duplicate this version", @@ -680,6 +673,46 @@ "setOrgNameTitle": "Set your organization name", + "userContextMenuLabel": "User context menu", + "userGroupContextMenuLabel": "User Group context menu", + "userGroupUserContextMenuLabel": "User Group User context menu", + + "generalSettingsTabSection": "General", + "accountSettingsTab": "Account", + "userAccountSettingsSection": "User Account", + "userNameSettingsInput": "Name", + "userEmailSettingsInput": "Email", + "changePasswordSettingsSection": "Change Password", + "changePasswordSettingsCustomEntryAliases": "current password\nnew password\nconfirm new password", + "deleteUserAccountSettingsSection": "Delete User Account", + "deleteUserAccountSettingsCustomEntryAliases": "danger zone\ndelete this user account", + "profilePictureSettingsSection": "Profile Picture", + "profilePictureSettingsCustomEntryAliases": "user profile picture", + "organizationSettingsTab": "Organization", + "organizationSettingsSection": "Organization", + "organizationNameSettingsInput": "Organization display name", + "organizationEmailSettingsInput": "Email", + "organizationWebsiteSettingsInput": "Website", + "organizationLocationSettingsInput": "Location", + "organizationProfilePictureSettingsSection": "Organization Profile Picture", + "organizationProfilePictureSettingsCustomEntryAliases": "organization profile picture\norganization logo", + "localSettingsTab": "Local", + "localSettingsSection": "Local", + "localRootPathSettingsInput": "Root Folder", + "accessSettingsTabSection": "Access", + "membersSettingsTab": "Members", + "membersSettingsSection": "Members", + "userGroupsSettingsTab": "User groups", + "userGroupsSettingsSection": "User Groups", + "userGroupsUsersSettingsSection": "Members", + "lookAndFeelSettingsTabSection": "Look and feel", + "keyboardShortcutsSettingsTab": "Keyboard shortcuts", + "keyboardShortcutsSettingsSection": "Keyboard Shortcuts", + "keyboardShortcutsSettingsCustomEntryAliases": "keyboard shortcuts", + "securitySettingsTabSection": "Security", + "activityLogSettingsTab": "Activity log", + "activityLogSettingsSection": "Activity Log", + "free": "Free", "freePlan": "Free Plan", "solo": "Solo", diff --git a/app/ide-desktop/lib/dashboard/tailwind.config.js b/app/ide-desktop/lib/dashboard/tailwind.config.js index c1b452ee503f..dcd7ee168d50 100644 --- a/app/ide-desktop/lib/dashboard/tailwind.config.js +++ b/app/ide-desktop/lib/dashboard/tailwind.config.js @@ -157,10 +157,8 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({ 'members-email-column': 'var(--members-email-column-width)', 'keyboard-shortcuts-icon-column': 'var(--keyboard-shortcuts-icon-column-width)', 'keyboard-shortcuts-name-column': 'var(--keyboard-shortcuts-name-column-width)', - 'activity-log-icon-column': 'var(--activity-log-icon-column-width)', - 'activity-log-type-column': 'var(--activity-log-type-column-width)', - 'activity-log-email-column': 'var(--activity-log-email-column-width)', - 'activity-log-timestamp-column': 'var(--activity-log-timestamp-column-width)', + 'keyboard-shortcuts-description-column': + 'var(--keyboard-shortcuts-description-column-width)', 'drive-name-column': 'var(--drive-name-column-width)', 'drive-modified-column': 'var(--drive-modified-column-width)', 'drive-shared-with-column': 'var(--drive-shared-with-column-width)', @@ -246,8 +244,6 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({ 'asset-panel': 'var(--asset-panel-gap)', 'search-suggestions': 'var(--search-suggestions-gap)', 'keyboard-shortcuts-button': 'var(--keyboard-shortcuts-button-gap)', - 'activity-log-filters': 'var(--activity-log-filters-gap)', - 'activity-log-filter': 'var(--activity-log-filter-gap)', 'chat-buttons': 'var(--chat-buttons-gap)', }, padding: {