From 092d7c084a99efe71b509844b6e45db74d027ca5 Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Fri, 24 Jan 2025 17:36:22 +0300 Subject: [PATCH] Move Help section to the Topbar (#12059) Closes: https://github.com/enso-org/cloud-v2/issues/1699 This PR is stacked on top of: 1. https://github.com/enso-org/enso/pull/12079 2. https://github.com/enso-org/enso/pull/12080 And ***shall*** be reviewed in order. This PR _adds_ new 3 items in the topbar: what's new, community and docs. This should improve discoverability for the users and simplify their day-to-day job. --- ***Note***: This PR ***does not*** remove related items from the `Discovery` dialog. --- app/common/src/text/english.json | 15 +- app/gui/.storybook/preview.tsx | 17 +- .../dashboard/assetsTableFeatures.spec.ts | 27 -- app/gui/src/ReactRoot.tsx | 10 +- .../components/AnimatedBackground.tsx | 1 - .../AriaComponents/Dialog/Dialog.tsx | 179 +++++++----- .../src/dashboard/components/UIProviders.tsx | 42 ++- .../dashboard/configurations/topbarLinks.json | 39 +++ app/gui/src/dashboard/layouts/UserBar.tsx | 275 ++++++++++++------ .../layouts/stories/UserBar.stories.tsx | 88 ++++++ app/gui/src/dashboard/styles.css | 16 +- app/gui/src/dashboard/test/testUtils.tsx | 2 +- app/gui/src/dashboard/utilities/url.ts | 15 + 13 files changed, 499 insertions(+), 227 deletions(-) create mode 100644 app/gui/src/dashboard/configurations/topbarLinks.json create mode 100644 app/gui/src/dashboard/layouts/stories/UserBar.stories.tsx create mode 100644 app/gui/src/dashboard/utilities/url.ts diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index 544a2f4fcc23..b6e9076c27cb 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -280,7 +280,7 @@ "versions": "Versions", "properties": "Properties", "projectSessions": "Sessions", - "docs": "Docs", + "docs": "Documentation", "datalink": "Datalink", "secret": "Secret", "createDatalink": "Create Datalink", @@ -295,6 +295,7 @@ "options": "Options", "googleIcon": "Google icon", "gitHubIcon": "GitHub icon", + "more": "More", "close": "Close", "enterSecretPath": "Enter secret path", @@ -587,8 +588,18 @@ "newPasswordPlaceholder": "Enter your new password", "confirmNewPasswordLabel": "Confirm new password", "confirmNewPasswordPlaceholder": "Confirm your new password", - + "projectName": "Project name", + "help": "Help", + "community": "Community", + "componentExamples": "Component examples", + "enso101": "Enso 101", + "askAQuestion": "Ask a question", + "whatsNew": "What's new", "selectTemplate": "Discover Enso Analytics", + "chooseATemplate": "Choose a template", + "basicTemplates": "Basic templates", + "advancedTemplates": "Advanced templates", + "startWithTemplate": "Start with a template", "welcomeSubtitle": "Explore templates, plugins, and data sources to kickstart your next big idea.", "newsItem3Beta": "Read what’s new in Enso", "newsItem3BetaDescription": "Learn about new features and whats coming soon.", diff --git a/app/gui/.storybook/preview.tsx b/app/gui/.storybook/preview.tsx index 99264ac14c99..809e33a5b242 100644 --- a/app/gui/.storybook/preview.tsx +++ b/app/gui/.storybook/preview.tsx @@ -47,19 +47,24 @@ const reactPreview: ReactPreview = { // Decorators are applied in the reverse order they are defined decorators: [ (Story, context) => { - const [portalRoot, setPortalRoot] = useState(null) + const [roots, setRoots] = useState<{ appRoot: HTMLElement; portalRoot: HTMLElement } | null>( + null, + ) useLayoutEffect(() => { + const appRoot = document.querySelector('#enso-app') + invariant(appRoot instanceof HTMLElement, 'AppRoot element not found') + const portalRoot = document.querySelector('#enso-portal-root') - invariant(portalRoot, 'PortalRoot element not found') + invariant(portalRoot instanceof HTMLElement, 'PortalRoot element not found') - setPortalRoot(portalRoot) + setRoots({ appRoot, portalRoot }) }, []) - if (!portalRoot) return <> + if (!roots) return <> return ( - + ) @@ -67,7 +72,7 @@ const reactPreview: ReactPreview = { (Story, context) => ( <> -
+
diff --git a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts index e2506c04c0e1..ffe279b1db83 100644 --- a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts +++ b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts @@ -31,33 +31,6 @@ function locateRootDirectoryDropzone(page: Page) { const PASS_TIMEOUT = 5_000 -test('extra columns should stick to right side of assets table', ({ page }) => - mockAllAndLogin({ page }) - .withAssetsTable(async (table) => { - await table.evaluate((element) => { - let scrollableParent: HTMLElement | SVGElement | null = element - while ( - scrollableParent != null && - scrollableParent.scrollWidth <= scrollableParent.clientWidth - ) { - scrollableParent = scrollableParent.parentElement - } - scrollableParent?.scrollTo({ left: 999999, behavior: 'instant' }) - }) - }) - .withAssetsTable(async (assetsTable, _, thePage) => { - const extraColumns = locateExtraColumns(thePage) - await expect(async () => { - const extraColumnsRight = await extraColumns.evaluate( - (element) => element.getBoundingClientRect().right, - ) - const assetsTableRight = await assetsTable.evaluate( - (element) => element.getBoundingClientRect().right, - ) - expect(extraColumnsRight).toEqual(assetsTableRight - 8) - }).toPass({ timeout: PASS_TIMEOUT }) - })) - test('extra columns should stick to top of scroll container', ({ page }) => mockAllAndLogin({ page, diff --git a/app/gui/src/ReactRoot.tsx b/app/gui/src/ReactRoot.tsx index aa546b5cfb46..faeb20917532 100644 --- a/app/gui/src/ReactRoot.tsx +++ b/app/gui/src/ReactRoot.tsx @@ -35,21 +35,27 @@ export default function ReactRoot(props: ReactRootProps) { const { config, queryClient, onAuthenticated } = props const httpClient = new HttpClient() + const supportsDeepLinks = !IS_DEV_MODE && !isOnLinux() && isOnElectron() + + const appRoot = document.querySelector('#enso-app') + invariant(appRoot instanceof HTMLElement, 'AppRoot element not found') + const portalRoot = document.querySelector('#enso-portal-root') + invariant(portalRoot instanceof HTMLElement, 'PortalRoot element not found') + const shouldUseAuthentication = config.authentication.enabled const projectManagerUrl = (config.engine.projectManagerUrl || resolveEnvUrl($config.PROJECT_MANAGER_URL)) ?? null const ydocUrl = (config.engine.ydocUrl || resolveEnvUrl($config.YDOC_SERVER_URL)) ?? null const initialProjectName = config.startup.project || null - invariant(portalRoot, 'PortalRoot element not found') const isCloudBuild = $config.CLOUD_BUILD === 'true' return ( - + }> diff --git a/app/gui/src/dashboard/components/AnimatedBackground.tsx b/app/gui/src/dashboard/components/AnimatedBackground.tsx index 4b00d2fa1759..a63709738678 100644 --- a/app/gui/src/dashboard/components/AnimatedBackground.tsx +++ b/app/gui/src/dashboard/components/AnimatedBackground.tsx @@ -34,7 +34,6 @@ const DEFAULT_TRANSITION: Transition = { mass: 0.3, velocity: 8, } - /* eslint-enable @typescript-eslint/no-magic-numbers */ /** `` component visually highlights selected items by sliding a background into view when hovered over or clicked. */ diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx index 8f0a9aaf5574..eec56479bde2 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx @@ -12,12 +12,14 @@ import * as suspense from '#/components/Suspense' import * as mergeRefs from '#/utilities/mergeRefs' -import { DialogDismiss } from '#/components/AriaComponents' +import { DialogDismiss, ResetButtonGroupContext } from '#/components/AriaComponents' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useMeasure } from '#/hooks/measureHooks' import { LayoutGroup, motion, type Spring } from '#/utilities/motion' import type { VariantProps } from '#/utilities/tailwindVariants' import { tv } from '#/utilities/tailwindVariants' +import { unsafeWriteValue } from '#/utilities/write' +import { useRootContext } from '../../UIProviders' import { Close } from './Close' import * as dialogProvider from './DialogProvider' import * as dialogStackProvider from './DialogStackProvider' @@ -25,6 +27,7 @@ import { DialogTrigger } from './DialogTrigger' import type * as types from './types' import * as utlities from './utilities' import { DIALOG_BACKGROUND } from './variants' + // eslint-disable-next-line no-restricted-syntax const MotionDialog = motion(aria.Dialog) @@ -254,6 +257,8 @@ function DialogContent(props: DialogContentProps) { const scrollerRef = React.useRef(null) const dialogId = aria.useId() + const { appRoot } = useRootContext() + const titleId = `${dialogId}-title` const padding = paddingRaw ?? (type === 'modal' ? 'medium' : 'xlarge') const isFullscreen = type === 'fullscreen' @@ -297,6 +302,20 @@ function DialogContent(props: DialogContentProps) { } }, [isFullscreen]) + React.useEffect(() => { + if (isFullscreen && modalState.isOpen) { + unsafeWriteValue(appRoot.style, 'scale', '0.99') + unsafeWriteValue(appRoot.style, 'filter', 'blur(8px)') + unsafeWriteValue(appRoot.style, 'willChange', 'scale, filter') + + return () => { + unsafeWriteValue(appRoot.style, 'scale', '') + unsafeWriteValue(appRoot.style, 'filter', '') + unsafeWriteValue(appRoot.style, 'willChange', '') + } + } + }, [isFullscreen, modalState, appRoot]) + const styles = variants({ className, type, @@ -322,86 +341,88 @@ function DialogContent(props: DialogContentProps) { } return ( - - { - if (scrollerRef.current) { - scrollerRef.current.style.overflowY = 'clip' - } - }} - onLayoutAnimationComplete={() => { - if (scrollerRef.current) { - scrollerRef.current.style.overflowY = '' - } - }} - ref={(ref: HTMLDivElement | null) => { - mergeRefs.mergeRefs(dialogRef, (element) => { - if (element) { - // This is a workaround for the `data-testid` attribute not being - // supported by the 'react-aria-components' library. - // We need to set the `data-testid` attribute on the dialog element - // so that we can use it in our tests. - // This is a temporary solution until we refactor the Dialog component - // to use `useDialog` hook from the 'react-aria-components' library. - // this will allow us to set the `data-testid` attribute on the dialog - element.dataset.testid = testId + + + { + if (scrollerRef.current) { + scrollerRef.current.style.overflowY = 'clip' } - })(ref) - }} - className={styles.base()} - aria-labelledby={titleId} - {...ariaDialogProps} - > - {(opts) => ( - <> - - - - - - { + if (scrollerRef.current) { + scrollerRef.current.style.overflowY = '' + } + }} + ref={(ref: HTMLDivElement | null) => { + mergeRefs.mergeRefs(dialogRef, (element) => { + if (element) { + // This is a workaround for the `data-testid` attribute not being + // supported by the 'react-aria-components' library. + // We need to set the `data-testid` attribute on the dialog element + // so that we can use it in our tests. + // This is a temporary solution until we refactor the Dialog component + // to use `useDialog` hook from the 'react-aria-components' library. + // this will allow us to set the `data-testid` attribute on the dialog + element.dataset.testid = testId + } + })(ref) + }} + className={styles.base()} + aria-labelledby={titleId} + {...ariaDialogProps} + > + {(opts) => ( + <> + + + + + - {children} - - - - )} - - - - + + {children} + + + + )} + + + + + ) } diff --git a/app/gui/src/dashboard/components/UIProviders.tsx b/app/gui/src/dashboard/components/UIProviders.tsx index 04efc3a2ec8a..4dbfdc9402e3 100644 --- a/app/gui/src/dashboard/components/UIProviders.tsx +++ b/app/gui/src/dashboard/components/UIProviders.tsx @@ -17,22 +17,46 @@ const DEFAULT_TRANSITION_OPTIONS: Spring = { velocity: 0, } +/** + * A context containing the root elements for the application. + */ +interface RootContextType { + readonly portalRoot: HTMLElement + readonly appRoot: HTMLElement +} + +const RootContext = React.createContext({ + portalRoot: document.body, + appRoot: document.body, +}) + /** Props for a {@link UIProviders}. */ export interface UIProvidersProps extends Readonly { - readonly portalRoot: Element + readonly portalRoot: HTMLElement + readonly appRoot: HTMLElement readonly locale: string } /** A wrapper containing all UI-related React Provdiers. */ export default function UIProviders(props: UIProvidersProps) { - const { portalRoot, locale, children } = props + const { portalRoot, appRoot, locale, children } = props + return ( - - - - {children} - - - + + + + + {children} + + + + ) } + +/** + * A hook to get the root elements for the application. + */ +export function useRootContext() { + return React.useContext(RootContext) +} diff --git a/app/gui/src/dashboard/configurations/topbarLinks.json b/app/gui/src/dashboard/configurations/topbarLinks.json new file mode 100644 index 000000000000..8fc4d6c4ab58 --- /dev/null +++ b/app/gui/src/dashboard/configurations/topbarLinks.json @@ -0,0 +1,39 @@ +{ + "//": [ + "This is the topbar links configuration file. It is used to configure the topbar links for the dashboard.", + "The items array is used to configure the topbar links for the dashboard.", + "The name property is a valid dictionary key. Please refer to the /app/common/src/text/english.json file for the list of valid keys.", + "The url property is used to configure the url of the topbar link. Can be an absolute url or a relative url.", + "The menu property turns the topbar link into a joined button with \/ button, that opens a dropdown menu.", + "The menu property is an array of objects. Each object is an item.", + "It's possible to omit the url property, in that case the topbar button will be a dropdown menu with the items in the menu array." + ], + "items": [ + { + "name": "whatsNew", + "url": "https://community.ensoanalytics.com/c/what-is-new-in-enso/" + }, + { + "name": "community", + "url": "https://community.ensoanalytics.com/", + "menu": [ + { + "name": "askAQuestion", + "url": "https://community.ensoanalytics.com/c/q_and_a/" + }, + { + "name": "enso101", + "url": "https://community.ensoanalytics.com/c/enso101/" + }, + { + "name": "componentExamples", + "url": "https://community.ensoanalytics.com/c/enso-component-examples/" + } + ] + }, + { + "name": "docs", + "url": "https://help.enso.org/" + } + ] +} diff --git a/app/gui/src/dashboard/layouts/UserBar.tsx b/app/gui/src/dashboard/layouts/UserBar.tsx index 1df0a999ca8c..75e708d82a9d 100644 --- a/app/gui/src/dashboard/layouts/UserBar.tsx +++ b/app/gui/src/dashboard/layouts/UserBar.tsx @@ -2,23 +2,60 @@ import { SUBSCRIBE_PATH } from '#/appUtils' import ChatIcon from '#/assets/chat.svg' import DefaultUserIcon from '#/assets/default_user.svg' +import ArrowDownIcon from '#/assets/expand_arrow_down.svg' import Offline from '#/assets/offline_filled.svg' -import { Button, DialogTrigger, Text } from '#/components/AriaComponents' +import { Button, DialogTrigger, Menu, Popover, Text } from '#/components/AriaComponents' import { PaywallDialogButton } from '#/components/Paywall' -import FocusArea from '#/components/styled/FocusArea' +import SvgMask from '#/components/SvgMask' +import TOPBAR_LINKS from '#/configurations/topbarLinks.json' with { type: 'json' } import { usePaywall } from '#/hooks/billing' +import { useOffline } from '#/hooks/offlineHooks' import UserMenu from '#/layouts/UserMenu' import InviteUsersModal from '#/modals/InviteUsersModal' import { useFullUserSession } from '#/providers/AuthProvider' import { useText } from '#/providers/TextProvider' import { Plan } from '#/services/Backend' +import { isAbsoluteUrl } from '#/utilities/url' +import type { TextId } from 'enso-common/src/text' import { AnimatePresence, motion } from 'framer-motion' -import SvgMask from '../components/SvgMask' -import { useOffline } from '../hooks/offlineHooks' +import { z } from 'zod' /** Whether the chat button should be visible. Temporarily disabled. */ const SHOULD_SHOW_CHAT_BUTTON: boolean = false +export const TOPBAR_LINKS_SCHEMA = z.object({ + items: z.array( + z + .object({ + name: z.custom(), + url: z.string().url(), + menu: z.array( + z.object({ + name: z.custom().and(z.string()), + url: z.string().url(), + }), + ), + }) + .or( + z.object({ + name: z.custom(), + menu: z.array( + z.object({ + name: z.custom().and(z.string()), + url: z.string().url(), + }), + ), + }), + ) + .or( + z.object({ + name: z.custom().and(z.string()), + url: z.string().url(), + }), + ), + ), +}) + /** Props for a {@link UserBar}. */ export interface UserBarProps { /** @@ -33,7 +70,7 @@ export interface UserBarProps { /** A toolbar containing chat and the user menu. */ export default function UserBar(props: UserBarProps) { - const { invisible = false, setIsHelpChatOpen, goToSettingsPage, onSignOut } = props + const { setIsHelpChatOpen, goToSettingsPage, onSignOut } = props const { user } = useFullUserSession() const { getText } = useText() @@ -42,95 +79,161 @@ export default function UserBar(props: UserBarProps) { const shouldShowUpgradeButton = user.isOrganizationAdmin && user.plan !== Plan.enterprise && user.plan !== Plan.team + + const upgradeButtonVariant = user.plan === Plan.free ? 'primary' : 'outline' // eslint-disable-next-line no-restricted-syntax const shouldShowPaywallButton = (false as boolean) && isFeatureUnderPaywall('inviteUser') const shouldShowInviteButton = // eslint-disable-next-line no-restricted-syntax (false as boolean) && !shouldShowPaywallButton + const topbarLinks = TOPBAR_LINKS_SCHEMA.parse(TOPBAR_LINKS) + return ( - - {(innerProps) => ( -
-
- - {isOffline && ( - - - - {getText('youAreOffline')} - - - )} - - - {SHOULD_SHOW_CHAT_BUTTON && ( - +
+
+ + {isOffline && ( + + + + {getText('youAreOffline')} + + + )} + + + + + {SHOULD_SHOW_CHAT_BUTTON && ( + - )} - - -
+ {shouldShowInviteButton && ( + + + + + + )} + + {shouldShowUpgradeButton && ( + + )} + + +
+
+ ) +} + +/** + * Props for a {@link UserBarHelpSection}. + */ +export interface UserBarHelpSectionProps { + readonly items: z.infer['items'] +} + +/** + * A section containing help buttons. + */ +export function UserBarHelpSection(props: UserBarHelpSectionProps) { + const { items } = props + const { getText } = useText() + + const getSafetyProps = (url: string) => + isAbsoluteUrl(url) ? { rel: 'opener', target: '_blank' } : {} + + return ( + + {items.map((item) => { + if ('url' in item) { + if ('menu' in item) { + return ( + + + + + + + + {item.menu.map((menuItem) => ( + + {getText(menuItem.name)} + + ))} + + + ) + } + + return ( + + ) + })} + ) } diff --git a/app/gui/src/dashboard/layouts/stories/UserBar.stories.tsx b/app/gui/src/dashboard/layouts/stories/UserBar.stories.tsx new file mode 100644 index 000000000000..98a231cd4375 --- /dev/null +++ b/app/gui/src/dashboard/layouts/stories/UserBar.stories.tsx @@ -0,0 +1,88 @@ +import TOPBAR_LINKS from '#/configurations/topbarLinks.json' +import type { Meta, StoryObj } from '@storybook/react' +import { TOPBAR_LINKS_SCHEMA, UserBarHelpSection, type UserBarHelpSectionProps } from '../UserBar' + +export default { + title: 'Layouts/UserBar', + component: UserBarHelpSection, + render: (args: UserBarHelpSectionProps) => { + TOPBAR_LINKS_SCHEMA.parse({ items: args.items }) + return + }, + args: { + items: TOPBAR_LINKS.items as UserBarHelpSectionProps['items'], + }, + parameters: { + layout: 'centered', + }, +} satisfies Meta + +export const Default: StoryObj = {} + +export const WithItems: StoryObj = { + args: { + items: [ + { + name: 'signInShortcut', + url: 'https://www.google.com', + }, + { + name: 'submit', + url: 'https://www.google.com', + }, + { + name: 'docs', + url: 'https://www.google.com', + }, + ], + }, +} + +export const WithMenu: StoryObj = { + args: { + items: [ + { + name: 'docs', + url: 'https://www.google.com', + menu: [ + { + name: 'community', + url: 'https://www.google.com', + }, + { + name: 'enso101', + url: 'https://www.google.com', + }, + { + name: 'help', + url: 'https://www.google.com', + }, + ], + }, + ], + }, +} + +export const StandaloneMenu: StoryObj = { + args: { + items: [ + { + name: 'docs', + menu: [ + { + name: 'community', + url: 'https://www.google.com', + }, + { + name: 'enso101', + url: 'https://www.google.com', + }, + { + name: 'help', + url: 'https://www.google.com', + }, + ], + }, + ], + }, +} diff --git a/app/gui/src/dashboard/styles.css b/app/gui/src/dashboard/styles.css index aefdefe729de..0423ba138ef8 100644 --- a/app/gui/src/dashboard/styles.css +++ b/app/gui/src/dashboard/styles.css @@ -5,21 +5,9 @@ } :where(body:not(.vibrancy)) { - &::before { - content: ""; - inset: 0 -16vw -16vh 0; - z-index: -1; - - background-color: #b09778ff; - - @apply pointer-events-none fixed bg-cover; - } - - & > * { - @apply bg-white/80; - } + @apply bg-cover bg-dashboard; } :where(.enso-app) { - @apply absolute inset-0 isolate overflow-hidden; + @apply absolute inset-0 isolate overflow-hidden transition-all duration-200 ease-in-out; } diff --git a/app/gui/src/dashboard/test/testUtils.tsx b/app/gui/src/dashboard/test/testUtils.tsx index f3e3e5e9c1d3..4b61087b3697 100644 --- a/app/gui/src/dashboard/test/testUtils.tsx +++ b/app/gui/src/dashboard/test/testUtils.tsx @@ -39,7 +39,7 @@ function UIProvidersWrapper({ return ( - + {typeof children === 'function' ? children({ queryClient }) : children} diff --git a/app/gui/src/dashboard/utilities/url.ts b/app/gui/src/dashboard/utilities/url.ts new file mode 100644 index 000000000000..1c7aea8ac32b --- /dev/null +++ b/app/gui/src/dashboard/utilities/url.ts @@ -0,0 +1,15 @@ +/** @file Utilities for working with URLs. */ + +/** + * Checks if a URL is absolute. + * @param url - The URL to check. + * @returns True if the URL is absolute, false otherwise. + */ +export function isAbsoluteUrl(url: string) { + try { + new URL(url) + return true + } catch { + return false + } +}