chore(svelte): Hide cody nav entries and upsell banner when cody isn't enabled (#63463)

Contributes to srch-529

This commit refactors the main navigation and search home page code to
make it more configurable. In particular we now only show navigation
entries for features that are enabled (as determined by
`window.context`) and only show the cody upsell banner when cody is
enabled.

I extended the dev HTML template and .env files to support this.
## Test plan

Manual testing.
This commit is contained in:
Felix Kling 2024-06-28 12:23:04 +02:00 committed by GitHub
parent 40dc6965e8
commit d4548b2be5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 573 additions and 386 deletions

View File

@ -1 +1,5 @@
PUBLIC_DOTCOM=
PUBLIC_CODY_ENABLED_ON_INSTANCE=true
PUBLIC_CODY_ENABLED_FOR_CURRENT_USER=true
PUBLIC_BATCH_CHANGES_ENABLED=true
PUBLIC_CODE_INSIGHTS_ENABLED=true

View File

@ -1 +1,5 @@
PUBLIC_DOTCOM=true
PUBLIC_CODY_ENABLED_ON_INSTANCE=true
PUBLIC_CODY_ENABLED_FOR_CURRENT_USER=true
PUBLIC_BATCH_CHANGES_ENABLED=
PUBLIC_CODE_INSIGHTS_ENABLED=

View File

@ -324,6 +324,10 @@ svelte_check.svelte_check_test(
args = [
"--tsconfig",
"tsconfig.json",
"--compiler-warnings",
# missing-declaration is raised for our icon components. The Svelte compiler
# does not take into account ambient declarations (will be fixed in Svelte 5).
"missing-declaration:ignore",
],
chdir = package_name(),
data = SRCS + BUILD_DEPS + CONFIGS + [

View File

@ -10,7 +10,7 @@
"build:watch": "vite build --watch",
"preview": "vite preview",
"install:browsers": "playwright install",
"test": "playwright test",
"test": "DISABLE_APP_ASSETS_MOCKING=true playwright test",
"test:dev": "DISABLE_APP_ASSETS_MOCKING=true PORT=5173 playwright test --ui",
"test:svelte": "vitest --run",
"sync": "svelte-kit sync",

View File

@ -5,6 +5,14 @@ const PORT = process.env.PORT ? Number(process.env.PORT) : 4173
const config: PlaywrightTestConfig = {
testMatch: 'src/**/*.spec.ts',
// For local testing
webServer: process.env.DISABLE_APP_ASSETS_MOCKING
? {
command: 'pnpm build:preview && pnpm preview',
port: PORT,
reuseExistingServer: true,
}
: undefined,
reporter: 'list',
// note: if you proxy into a locally running vite preview, you may have to raise this to 60 seconds
timeout: 5_000,

View File

@ -19,7 +19,14 @@
},
// Local standalone dev server for dotcom can be started with
// pnpm dev:dotcom
sourcegraphDotComMode: '%sveltekit.env.PUBLIC_DOTCOM%' ? true : false,
sourcegraphDotComMode: !!'%sveltekit.env.PUBLIC_DOTCOM%',
codyEnabledOnInstance: !!'%sveltekit.env.PUBLIC_CODY_ENABLED_ON_INSTANCE%',
codyEnabledForCurrentUser: !!'%sveltekit.env.PUBLIC_CODY_ENABLED_FOR_CURRENT_USER%',
batchChangesEnabled: !!'%sveltekit.env.PUBLIC_BATCH_CHANGES_ENABLED%',
codeInsightsEnabled: !!'%sveltekit.env.PUBLIC_CODE_INSIGHTS_ENABLED%',
// The following are used to mock context in playwright tests
...(typeof window.context === 'object' ? window.context : {}),
}
window.pageError = undefined
</script>

View File

@ -12,7 +12,7 @@ import {
type FlipOptions,
} from '@floating-ui/dom'
import { tick } from 'svelte'
import type { ActionReturn, Action } from 'svelte/action'
import type { Action } from 'svelte/action'
import * as uuid from 'uuid'
import { highlightNode } from '$lib/common'
@ -96,18 +96,29 @@ export function uniqueID(prefix = ''): string {
* An action that dispatches a custom 'click-outside' event when the user clicks
* outside the attached element.
*/
export function onClickOutside(
node: HTMLElement
): ActionReturn<void, { 'on:click-outside': (event: CustomEvent<HTMLElement>) => void }> {
export const onClickOutside: Action<
HTMLElement,
{ enabled?: boolean } | undefined,
{ 'on:click-outside': (event: CustomEvent<HTMLElement>) => void }
> = (node, { enabled } = { enabled: true }) => {
function handler(event: MouseEvent): void {
if (event.target && !node.contains(event.target as HTMLElement)) {
node.dispatchEvent(new CustomEvent('click-outside', { detail: event.target }))
}
}
window.addEventListener('mousedown', handler)
if (enabled) {
window.addEventListener('mousedown', handler)
}
return {
update({ enabled } = { enabled: true }) {
if (enabled) {
window.addEventListener('mousedown', handler)
} else {
window.removeEventListener('mousedown', handler)
}
},
destroy() {
window.removeEventListener('mousedown', handler)
},

View File

@ -14,57 +14,114 @@
<script lang="ts">
import { browser } from '$app/environment'
import { page } from '$app/stores'
import { onClickOutside } from '$lib/dom'
import Icon from '$lib/Icon.svelte'
import { mark } from '$lib/images'
import GlobalSidebarNavigation from '$lib/navigation/GlobalSidebarNavigation.svelte'
import MainNavigationEntry from '$lib/navigation/MainNavigationEntry.svelte'
import MainNavigationLink from '$lib/navigation/MainNavigationLink.svelte'
import Popover from '$lib/Popover.svelte'
import SourcegraphLogo from '$lib/SourcegraphLogo.svelte'
import { isViewportMediumDown } from '$lib/stores'
import { Badge, Button } from '$lib/wildcard'
import { GlobalNavigation_User } from './GlobalNavigation.gql'
import type { NavigationEntry, NavigationMenu } from './mainNavigation'
import { type NavigationEntry, type NavigationMenu, isNavigationMenu, isCurrent } from './mainNavigation'
import UserMenu from './UserMenu.svelte'
export let authenticatedUser: GlobalNavigation_User | null | undefined
export let handleOptOut: (() => Promise<void>) | undefined
export let entries: (NavigationEntry | NavigationMenu)[]
let isSidebarNavigationOpen: boolean = false
const isDevOrS2 =
(browser && window.location.hostname === 'localhost') ||
window.location.hostname === 'sourcegraph.sourcegraph.com'
let sidebarNavigationOpen: boolean = false
let closeMenuTimer: number = 0
let openedMenu: string = ''
$: withCustomContent = $navigationModeStore === NavigationMode.WithCustomContent
$: sidebarMode = withCustomContent || $isViewportMediumDown
function openMenu(menu: string) {
openedMenu = menu
clearTimeout(closeMenuTimer)
}
function closeMenu() {
// We use a delay to close the menu to make it easier to navigate (back) to it
closeMenuTimer = window.setTimeout(() => {
openedMenu = ''
}, 500)
}
</script>
<header class="root" data-global-header>
{#if isSidebarNavigationOpen}
<GlobalSidebarNavigation onClose={() => (isSidebarNavigationOpen = false)} {entries} />
{/if}
<div class="logo" class:with-custom-content={withCustomContent}>
{#if withCustomContent}
<button class="menu-button" on:click={() => (isSidebarNavigationOpen = true)}>
<Icon icon={ILucideMenu} aria-label="Navigation menu" />
</button>
{/if}
<header class="root" data-global-header class:withCustomContent class:sidebarMode>
<div class="logo">
<button class="menu-button" on:click={() => (sidebarNavigationOpen = true)}>
<Icon icon={ILucideMenu} aria-label="Navigation menu" />
</button>
<a href="/search">
<img src={mark} alt="Sourcegraph" width="25" height="25" />
</a>
</div>
<nav class="plain-navigation" bind:this={$extensionElement}>
{#if !withCustomContent}
<ul class="plain-navigation-list">
<nav aria-label="Main" class:as-sidebar={sidebarMode} class:open={sidebarNavigationOpen}>
<!-- Additional wrapper needed to handle sidebar navigation mode -->
<div
class="content"
use:onClickOutside={{ enabled: sidebarNavigationOpen }}
on:click-outside={() => (sidebarNavigationOpen = false)}
>
<div class="sidebar-navigation-header">
<button class="close-button" on:click={() => (sidebarNavigationOpen = false)}>
<Icon icon={ILucideX} aria-label="Close sidebar navigation" />
</button>
<a href="/search" class="logo-link">
<SourcegraphLogo width="9.1rem" />
</a>
</div>
<ul class="top-navigation">
{#each entries as entry (entry.label)}
<MainNavigationEntry {entry} />
{@const open = openedMenu === entry.label}
<li class:open on:mouseenter={() => openMenu(entry.label)} on:mouseleave={closeMenu}>
{#if isNavigationMenu(entry)}
<span>
<MainNavigationLink {entry} />
<Button
variant="icon"
on:click={() => (openedMenu = open ? '' : entry.label)}
aria-label="{open ? 'Close' : 'Open'} '{entry.label}' submenu"
aria-expanded={open}
>
<Icon icon={ILucideChevronDown} inline aria-hidden />
</Button>
</span>
<ul class="sub-navigation">
{#each entry.children as subEntry (subEntry.label)}
<li>
<MainNavigationLink
entry={subEntry}
aria-current={isCurrent(subEntry, $page) ? 'page' : 'false'}
/>
</li>
{/each}
</ul>
{:else}
<span>
<MainNavigationLink {entry} aria-current={isCurrent(entry, $page) ? 'page' : 'false'} />
</span>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
</nav>
<div class="global-portal" bind:this={$extensionElement} />
<Popover let:registerTrigger showOnHover hoverDelay={100} hoverCloseDelay={50}>
<span class="web-next-badge" use:registerTrigger>
<Badge variant="warning">Experimental</Badge>
@ -121,13 +178,11 @@
margin-left: 0.5rem;
gap: 0.5rem;
&.with-custom-content {
.sidebarMode & {
margin-left: 0;
}
}
.logo img {
&:hover {
img:hover {
@keyframes spin {
50% {
transform: rotate(180deg) scale(1.2);
@ -143,21 +198,216 @@
}
}
.plain-navigation {
.global-portal {
display: none;
flex: 1;
display: flex;
align-self: stretch;
min-width: 0;
&-list {
.withCustomContent & {
display: flex;
}
}
nav {
ul {
list-style: none;
padding: 0;
margin: 0;
}
}
// Horizontal navigation style, only used on search home page
nav:not(.as-sidebar) {
flex: 1;
min-width: 0;
display: flex;
align-self: stretch;
.content {
display: flex;
align-self: stretch;
flex: 1;
min-width: 0;
}
.sidebar-navigation-header {
display: none;
}
.top-navigation {
--icon-color: var(--header-icon-color);
display: flex;
gap: 1rem;
padding: 0;
margin: -0.5rem 0 -0.5rem 0;
list-style: none;
position: relative;
justify-content: center;
background-size: contain;
> li {
position: relative;
white-space: nowrap;
border-color: transparent;
&.open,
&:hover {
border-color: var(--border-color-2);
}
&.open .sub-navigation {
display: block;
}
> span {
display: flex;
align-items: center;
gap: 0.25rem;
height: 100%;
border-bottom: 2px solid;
border-color: inherit;
:global(a) {
align-self: stretch;
}
}
&:has(a[aria-current='page']) {
border-color: var(--brand-secondary);
}
}
}
.sub-navigation {
display: none;
position: absolute;
left: 0;
right: 0;
top: calc(100% + 3px);
min-width: 10rem;
background-clip: padding-box;
background-color: var(--dropdown-bg);
border: 1px solid var(--dropdown-border-color);
border-radius: var(--popover-border-radius);
color: var(--body-color);
box-shadow: var(--dropdown-shadow);
padding: 0.25rem 0;
// This seems necessary to make the dropdown render above other elements
// and keep it open when moving the mouse into it.
z-index: 2;
> li {
cursor: pointer;
display: block;
width: 100%;
padding: var(--dropdown-item-padding);
white-space: nowrap;
color: var(--dropdown-link-hover-color);
&:hover,
&:focus {
background-color: var(--dropdown-link-hover-bg);
color: var(--dropdown-link-hover-color);
text-decoration: none;
}
}
}
}
// Sidebar navigation style
nav.as-sidebar {
display: none;
top: 0;
left: 0;
bottom: 0;
right: 0;
position: fixed;
// Fixed overlay color TODO: find a better design token for it
background-color: rgba(172, 182, 192, 0.2);
// Ensures that the sidebar navigation is all other elements
z-index: 2;
.content {
width: 18.75rem;
background-color: var(--color-bg-1);
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
height: 100%;
}
&.open {
display: block;
}
.sidebar-navigation-header {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.5rem;
// Original menu navigation has 50px - 1px bottom border
// To ensure that there are no jumps between closed/open states
// we set height here to repeat menu and icon buttons positions.
min-height: 49px;
background-color: var(--color-bg-1);
.close-button {
border: none;
padding: 0.35rem 0.35rem;
border-radius: var(--border-radius);
display: flex;
align-items: center;
background-color: transparent;
&:hover {
background-color: var(--secondary-2);
}
--icon-size: 1rem;
}
}
.top-navigation {
overflow-y: auto;
max-width: 100vw;
display: flex;
flex-direction: column;
width: 18.75rem;
border: none;
padding: 0;
margin: 0;
background-color: var(--color-bg-1);
:global(button) {
display: none;
}
:global(a) {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.75rem;
font-size: 1rem;
&:hover {
background-color: var(--secondary-2);
}
}
}
.sub-navigation {
:global(a) {
padding-left: 3.7rem;
}
:global(a[aria-current='page']) {
background-color: var(--secondary-2);
}
}
}
@ -172,6 +422,7 @@
.web-next-badge {
cursor: pointer;
padding: 0.25rem;
margin-left: auto;
}
.web-next-content {
@ -185,13 +436,17 @@
// Custom menu with sidebar navigation controls styles
.menu-button {
display: flex;
display: none;
padding: 0.35rem;
align-items: center;
border: none;
background-color: transparent;
border-radius: var(--border-radius);
.sidebarMode & {
display: flex;
}
&:hover {
background-color: var(--secondary-2);
}

View File

@ -1,149 +0,0 @@
<script context="module" lang="ts">
import type { NavigationEntry, NavigationMenu } from './mainNavigation'
function isNavigationMenu(entry: NavigationEntry | NavigationMenu): entry is NavigationMenu {
return entry && 'children' in entry
}
</script>
<script lang="ts">
import { page } from '$app/stores'
import { onClickOutside, portal } from '$lib/dom'
import Icon from '$lib/Icon.svelte'
import SourcegraphLogo from '$lib/SourcegraphLogo.svelte'
import { isCurrent } from './mainNavigation'
import MainNavigationLink from './MainNavigationLink.svelte'
export let onClose: () => void
export let entries: (NavigationEntry | NavigationMenu)[]
</script>
<div class="root" use:portal>
<div class="content" use:onClickOutside on:click-outside={onClose}>
<header>
<button class="close-button" on:click={onClose}>
<Icon icon={ILucideX} aria-label="Close sidebar navigation" />
</button>
<a href="/search" class="logo-link">
<SourcegraphLogo width="9.1rem" />
</a>
</header>
<nav>
<ul class="list">
{#each entries as entry (entry.label)}
<li>
<MainNavigationLink {entry} />
{#if isNavigationMenu(entry) && entry.children.length > 0}
<ul>
{#each entry.children as subEntry (subEntry.label)}
<li aria-current={isCurrent(subEntry, $page) ? 'page' : 'false'}>
<MainNavigationLink entry={subEntry} />
</li>
{/each}
</ul>
{/if}
</li>
{/each}
</ul>
</nav>
</div>
</div>
<style lang="scss">
.root {
top: 0;
left: 0;
bottom: 0;
right: 0;
position: fixed;
z-index: 1;
// Fixed overlay color TODO: find a better design token for it
background-color: rgba(172, 182, 192, 0.2);
.content {
display: flex;
flex-direction: column;
width: 18.75rem;
height: 100%;
transform: unset;
border: none;
padding: 0;
background-color: var(--color-bg-1);
}
.close-button {
border: none;
padding: 0.35rem 0.35rem;
border-radius: var(--border-radius);
display: flex;
align-items: center;
background-color: transparent;
&:hover {
background-color: var(--secondary-2);
}
--icon-size: 1rem;
}
header {
display: flex;
gap: 0.5rem;
align-items: center;
padding: 0.5rem;
// Original menu navigation has 50px - 1px bottom border
// To ensure that there are no jumps between closed/open states
// we set height here to repeat menu and icon buttons positions.
min-height: 49px;
}
.logo-link {
flex-grow: 1;
display: flex;
align-items: center;
}
nav {
padding-top: 1rem;
padding-bottom: 1rem;
flex-grow: 1;
overflow: auto;
}
ul {
padding: 0;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.25rem;
flex-grow: 1;
li[aria-current='page'] {
background-color: var(--secondary-2);
}
:global(a) {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.75rem;
font-size: 1rem;
&:hover {
background-color: var(--secondary-2);
}
}
& ul {
:global(a) {
padding-left: 3.7rem;
}
}
}
}
</style>

View File

@ -1,117 +0,0 @@
<script lang="ts">
import { page } from '$app/stores'
import { createDropdownMenu } from '@melt-ui/svelte'
import Icon from '$lib/Icon.svelte'
import MainNavigationLink from './MainNavigationLink.svelte'
import { isCurrent, type NavigationEntry, type NavigationMenu } from './mainNavigation'
export let entry: NavigationEntry | NavigationMenu
function isNavigationEntry(entry: NavigationEntry | NavigationMenu): entry is NavigationEntry {
return entry && 'href' in entry
}
const {
elements: { menu, item, trigger },
states: { open },
} = createDropdownMenu({
positioning: {
placement: 'bottom-start',
gutter: 0,
},
})
$: current = isNavigationEntry(entry) ? isCurrent(entry, $page) : entry.isCurrent($page)
</script>
<li class="toplevel-naventry" aria-current={current ? 'page' : 'false'}>
{#if isNavigationEntry(entry)}
<MainNavigationLink {entry} />
{:else}
<button {...$trigger} use:trigger>
{#if typeof entry.icon === 'string'}
<Icon icon={entry.icon} aria-hidden="true" inline />&nbsp;
{:else if entry.icon}
<span class="icon"><svelte:component this={entry.icon} /></span>&nbsp;
{/if}
{entry.label}
<Icon icon={$open ? ILucideChevronUp : ILucideChevronDown} inline aria-hidden />
</button>
<ul {...$menu} use:menu>
{#each entry.children as subEntry (subEntry.label)}
<li {...$item} use:item>
<MainNavigationLink entry={subEntry} />
</li>
{/each}
</ul>
{/if}
</li>
<style lang="scss">
li.toplevel-naventry {
--icon-color: var(--header-icon-color);
position: relative;
display: flex;
align-items: stretch;
white-space: nowrap;
border-color: transparent;
&:hover {
border-color: var(--border-color-2);
}
&[aria-current='page'] {
border-color: var(--brand-secondary);
}
> button,
:global(a) {
border-bottom: 2px solid;
border-color: inherit;
}
}
button {
all: unset;
display: flex;
align-items: center;
cursor: pointer;
// Since this button is part navigation links blocks
// we should override focus ring with inset to avoid
// visual cropping with parent border.
&:focus-visible {
box-shadow: 0 0 0 2px var(--primary-2) inset;
}
}
[role='menu'] {
font-size: 0.875rem;
min-width: 10rem;
background-clip: padding-box;
background-color: var(--dropdown-bg);
border: 1px solid var(--dropdown-border-color);
border-radius: var(--popover-border-radius);
color: var(--body-color);
box-shadow: var(--dropdown-shadow);
padding: 0.25rem 0;
}
[role^='menuitem'] {
cursor: pointer;
display: block;
width: 100%;
padding: var(--dropdown-item-padding);
white-space: nowrap;
color: var(--dropdown-link-hover-color);
&:hover,
&:focus {
background-color: var(--dropdown-link-hover-bg);
color: var(--dropdown-link-hover-color);
text-decoration: none;
}
}
</style>

View File

@ -1,13 +1,18 @@
<script lang="ts">
import Icon from '$lib/Icon.svelte'
import { Badge } from '$lib/wildcard'
import type { HTMLAnchorAttributes } from 'svelte/elements'
import { type NavigationEntry, Status } from './mainNavigation'
type $$Props = {
entry: NavigationEntry
} & HTMLAnchorAttributes
export let entry: NavigationEntry
</script>
<a href={entry.href}>
<a href={entry.href} {...$$restProps}>
{#if entry.icon}
<Icon icon={entry.icon} aria-hidden="true" inline />&nbsp;
{/if}
@ -20,8 +25,7 @@
<style lang="scss">
a {
display: flex;
height: 100%;
display: inline-flex;
align-items: center;
text-decoration: none;
color: var(--body-color);
@ -31,13 +35,4 @@
text-decoration: none;
}
}
.icon {
--icon-color: var(--header-icon-color);
width: var(--icon-inline-size);
height: var(--icon-inline-size);
color: var(--header-icon-color);
display: flex;
align-items: center;
}
</style>

View File

@ -36,7 +36,7 @@ export interface NavigationEntry {
* A navigation menu is a collection of navigation entries.
* Currently, it will be rendered as a dropdown in the navigation bar.
*/
export interface NavigationMenu {
export interface NavigationMenu<T extends NavigationEntry = NavigationEntry> {
/**
* The label of the navigation menu.
*/
@ -44,11 +44,10 @@ export interface NavigationMenu {
/**
* The navigation entries that are part of the menu.
*/
children: NavigationEntry[]
children: T[]
/**
* NavigationMenu item can be rendered as plain link in side navigation mode
* This fallbackURL will be used to set URL to this link
* Target URL to navigate to when the menu is clicked.
*/
href: string
@ -56,11 +55,6 @@ export interface NavigationMenu {
* An optional icon to display next to the label.
*/
icon?: IconComponent
/**
* A function to determine if current page is part of the menu.
* This is used to mark the menu as "current" in the UI.
*/
isCurrent(page: Page): boolean
}
/**
@ -70,3 +64,7 @@ export interface NavigationMenu {
export function isCurrent(entry: NavigationEntry, page: Page): boolean {
return page.url.pathname === entry.href
}
export function isNavigationMenu(entry: NavigationEntry | NavigationMenu): entry is NavigationMenu {
return entry && 'children' in entry && entry.children.length > 0
}

View File

@ -1,6 +1,7 @@
import { getContext, setContext } from 'svelte'
import { readable, writable, type Readable, type Writable } from 'svelte/store'
import { browser } from '$app/environment'
import type { Settings, TemporarySettingsStorage } from '$lib/shared'
import type { AuthenticatedUser, FeatureFlag } from '../routes/layout.gql'
@ -81,3 +82,17 @@ export function createLocalWritable<T>(localStorageKey: string, defaultValue: T)
},
}
}
/**
* Media query store that updates when the media query matches.
*/
export function mediaQuery(query: string): Readable<boolean> {
const mediaQuery = window.matchMedia(query)
return readable(mediaQuery.matches, set => {
const listener = () => set(mediaQuery.matches)
mediaQuery.addEventListener('change', listener)
return () => mediaQuery.removeEventListener('change', listener)
})
}
export const isViewportMediumDown = browser ? mediaQuery('(max-width: 768px)') : readable(false)

View File

@ -14,7 +14,7 @@ import {
EditSettings,
LatestSettingsQuery,
} from './layout.gql'
import { dotcomMainNavigation, mainNavigation } from './navigation'
import { getMainNavigationEntries, Mode } from './navigation'
// Disable server side rendering for the whole app
export const ssr = false
@ -47,11 +47,20 @@ export const load: LayoutLoad = async ({ fetch }) => {
}
return {
navigationEntries: getMainNavigationEntries(
(window.context.sourcegraphDotComMode ? Mode.DOTCOM : Mode.ENTERPRISE) |
(window.context.codyEnabledOnInstance ? Mode.CODY_INSTANCE_ENABLED : 0) |
(window.context.codyEnabledForCurrentUser ? Mode.CODY_USER_ENABLED : 0) |
(window.context.batchChangesEnabled ? Mode.BATCH_CHANGES_ENABLED : 0) |
(window.context.codeInsightsEnabled ? Mode.CODE_INSIGHTS_ENABLED : 0) |
(result.data.currentUser ? Mode.AUTHENTICATED : Mode.UNAUTHENTICATED)
),
// User data
user: result.data.currentUser,
navigationEntries: window.context.sourcegraphDotComMode ? dotcomMainNavigation : mainNavigation,
// Initial user settings
settings,
featureFlags: result.data.evaluatedFeatureFlags,
globalSiteAlerts: globalSiteAlerts.then(result => result.data?.site),
fetchEvaluatedFeatureFlags: async () => {
const result = await client.query(EvaluatedFeatureFlagsQuery, {}, { requestPolicy: 'network-only', fetch })

View File

@ -30,6 +30,82 @@ test('has user menu', async ({ sg, page }) => {
await expect(page.getByRole('heading', { name: 'Signed in as @test' })).toBeVisible()
})
test.describe('cody top level navigation', () => {
const topNavName = 'Cody'
;[
{
name: 'sourcegraph.com, signed out',
context: { sourcegraphDotComMode: true },
signedIn: false,
expectedTopNav: '/cody',
expectedSubNav: false,
},
{
name: 'sourcegraph.com, signed in',
context: { sourcegraphDotComMode: true },
signedIn: true,
expectedTopNav: '/cody/chat',
expectedSubNav: {
'Web Chat': '/cody/chat',
Dashboard: '/cody/manage',
},
},
{
name: 'enterprise, no user cody',
context: { sourcegraphDotComMode: false, codyEnabledOnInstance: true, codyEnabledForCurrentUser: false },
signedIn: true,
expectedTopNav: '/cody/dashboard',
expectedSubNav: false,
},
{
name: 'enterprise, user cody',
context: { sourcegraphDotComMode: false, codyEnabledOnInstance: true, codyEnabledForCurrentUser: true },
signedIn: true,
expectedTopNav: '/cody/chat',
expectedSubNav: {
'Web Chat': '/cody/chat',
Dashboard: '/cody/dashboard',
},
},
{
name: 'enterprise, no cody',
context: { sourcegraphDotComMode: false, codyEnabledOnInstance: false, codyEnabledForCurrentUser: false },
signedIn: true,
expectedTopNav: false,
},
].forEach(({ name, context, signedIn, expectedTopNav, expectedSubNav }) => {
test(name, async ({ sg, page }) => {
const mainNav = page.getByLabel('Main')
const topNavCodyEntry = mainNav.getByRole('link', { name: topNavName })
const menuToggleButton = mainNav.getByLabel("Open 'Cody' submenu")
await sg.setWindowContext(context)
if (signedIn) {
sg.signIn({ username: 'test' })
}
await page.goto('/')
await expect(mainNav).toBeVisible()
if (typeof expectedTopNav === 'string') {
await expect(topNavCodyEntry).toHaveAttribute('href', expectedTopNav)
} else {
await expect(topNavCodyEntry).not.toBeAttached()
}
if (typeof expectedSubNav === 'object') {
await expect(menuToggleButton).toBeVisible()
for (const [name, href] of Object.entries(expectedSubNav)) {
await expect(page.getByRole('link', { name, includeHidden: true })).toHaveAttribute('href', href)
}
} else if (expectedTopNav) {
await expect(menuToggleButton).not.toBeAttached()
}
})
})
})
test('has global notifications', async ({ sg, page }) => {
sg.mockTypes({
Query: () => ({

View File

@ -1,10 +1,68 @@
import { isRepoRoute } from '$lib/navigation'
import { Status, isCurrent, type NavigationEntry, type NavigationMenu } from '$lib/navigation/mainNavigation'
import { Status, type NavigationEntry, type NavigationMenu } from '$lib/navigation/mainNavigation'
/**
* The main navigation of the application.
*/
export const mainNavigation: (NavigationMenu | NavigationEntry)[] = [
export const enum Mode {
ENTERPRISE = 1 << 0,
DOTCOM = 1 << 1,
CODY_INSTANCE_ENABLED = 1 << 2,
CODY_USER_ENABLED = 1 << 3,
BATCH_CHANGES_ENABLED = 1 << 4,
CODE_INSIGHTS_ENABLED = 1 << 5,
AUTHENTICATED = 1 << 6,
UNAUTHENTICATED = 1 << 7,
}
interface NavigationEntryDefinition extends Omit<NavigationEntry, 'href'> {
href: string | [Mode, string][]
mode?: Mode
}
interface NavigationMenuDefinition extends Omit<NavigationMenu, 'children' | 'href'> {
href: string | [Mode, string][]
children: NavigationEntryDefinition[]
mode?: Mode
}
function matchesMode(entry: NavigationEntryDefinition, mode: Mode): boolean {
return entry.mode === undefined || (entry.mode & mode) === entry.mode
}
function toEntry(entry: NavigationEntryDefinition, mode: Mode): NavigationEntry {
return {
...entry,
href: matchHref(entry.href, mode),
}
}
function matchHref(href: NavigationEntryDefinition['href'], mode: Mode): string {
if (typeof href === 'string') {
return href
}
for (const [key, value] of href) {
if ((mode & +key) === +key) {
return value
}
}
return ''
}
export function getMainNavigationEntries(mode: Mode): (NavigationMenu | NavigationEntry)[] {
return navigationEntries
.filter(entry => matchesMode(entry, mode))
.map(definition => {
const entry = toEntry(definition, mode)
return 'children' in definition
? {
...entry,
children: definition.children
.filter(child => matchesMode(child, mode))
.map(child => toEntry(child, mode)),
}
: entry
})
}
const navigationEntries: (NavigationMenuDefinition | NavigationEntryDefinition)[] = [
{
label: 'Code Search',
icon: ILucideSearch,
@ -36,71 +94,72 @@ export const mainNavigation: (NavigationMenu | NavigationEntry)[] = [
status: Status.BETA,
},
],
isCurrent(this: NavigationMenu, page) {
// This is a special case of the code search menu: It is marked as "current" if the
// current page is a repository route.
return isRepoRoute(page.route?.id) || this.children.some(entry => isCurrent(entry, page))
},
mode: Mode.ENTERPRISE,
},
{
label: 'Code Search',
icon: ILucideSearch,
href: '/search',
mode: Mode.DOTCOM,
},
{
label: 'Cody',
icon: ISgCody,
href: '/cody',
isCurrent(this: NavigationMenu, page) {
return this.children.some(entry => isCurrent(entry, page))
},
mode: Mode.DOTCOM | Mode.UNAUTHENTICATED,
},
{
label: 'Cody',
icon: ISgCody,
href: '/cody/chat',
children: [
{
label: 'Dashboard',
href: '/cody',
},
{
label: 'Web Chat',
href: '/cody/chat',
},
{
label: 'Dashboard',
href: '/cody/manage',
},
],
mode: Mode.DOTCOM | Mode.AUTHENTICATED,
},
{
label: 'Cody',
icon: ISgCody,
href: [
[Mode.CODY_USER_ENABLED, '/cody/chat'],
[Mode.CODY_INSTANCE_ENABLED, '/cody/dashboard'],
],
children: [
{
label: 'Web Chat',
href: '/cody/chat',
mode: Mode.CODY_USER_ENABLED,
},
{
label: 'Dashboard',
href: '/cody/dashboard',
mode: Mode.CODY_USER_ENABLED,
},
],
mode: Mode.ENTERPRISE | Mode.CODY_INSTANCE_ENABLED,
},
{
label: 'Batch Changes',
icon: ISgBatchChanges,
href: '/batch-changes',
mode: Mode.BATCH_CHANGES_ENABLED,
},
{
label: 'Insights',
icon: ILucideBarChartBig,
href: '/insights',
},
]
/**
* The main navigation for sourcegraph.com
*/
export const dotcomMainNavigation: (NavigationMenu | NavigationEntry)[] = [
{
label: 'Code Search',
icon: ILucideSearch,
href: '/search',
},
{
label: 'Cody',
icon: ISgCody,
href: '/cody',
children: [
{
label: 'Dashboard',
href: '/cody',
},
{
label: 'Web Chat',
href: '/cody/chat',
},
],
isCurrent(this: NavigationMenu, page) {
return this.children.some(entry => isCurrent(entry, page))
},
mode: Mode.CODE_INSIGHTS_ENABLED,
},
{
label: 'About Sourcegraph',
href: '/',
mode: Mode.DOTCOM,
},
]

View File

@ -41,5 +41,7 @@
selectedFilters={data.queryFilters}
/>
{:else}
<SearchHome {queryState} codyHref={data.codyHref} showDotcomStuff={data.showDotcomFooterLinks} />
<SearchHome {queryState}>
<svelte:component this={data.footer} />
</SearchHome>
{/if}

View File

@ -19,6 +19,7 @@ import {
} from '$lib/shared'
import type { PageLoad } from './$types'
import DotcomFooterLinks from './DotcomFooterLinks.svelte'
type SearchStreamCacheEntry = Observable<AggregateStreamingSearchResults>
@ -142,7 +143,7 @@ export const load: PageLoad = async ({ parent, url, depends }) => {
return {
codyHref,
showDotcomFooterLinks: window.context.sourcegraphDotComMode,
footer: window.context.sourcegraphDotComMode ? DotcomFooterLinks : null,
searchStream,
queryFilters,
queryFromURL: query,
@ -157,7 +158,7 @@ export const load: PageLoad = async ({ parent, url, depends }) => {
}
return {
codyHref,
showDotcomFooterLinks: window.context.sourcegraphDotComMode,
footer: window.context.sourcegraphDotComMode ? DotcomFooterLinks : null,
queryOptions: {
query: '',
},

View File

@ -1,6 +1,4 @@
<script lang="ts">
import { TELEMETRY_RECORDER } from '$lib/telemetry'
const links = [
{
name: 'Docs',
@ -31,7 +29,9 @@
]
function handleLinkClick(telemetryType: number): void {
TELEMETRY_RECORDER.recordEvent('home.footer.CTA', 'click', { metadata: { type: telemetryType } })
import('$lib/telemetry').then(({ TELEMETRY_RECORDER }) => {
TELEMETRY_RECORDER.recordEvent('home.footer.CTA', 'click', { metadata: { type: telemetryType } })
})
}
</script>

View File

@ -9,12 +9,9 @@
import { isLightTheme } from '$lib/stores'
import { TELEMETRY_RECORDER } from '$lib/telemetry'
import DotcomFooterLinks from './DotcomFooterLinks.svelte'
import SearchHomeNotifications from './SearchHomeNotifications.svelte'
export let queryState: QueryStateStore
export let codyHref: string = '/cody'
export let showDotcomStuff: boolean = false
setContext<SearchPageContext>('search-context', {
setQuery(newQuery) {
@ -40,9 +37,7 @@
<SearchInput {queryState} autoFocus onSubmit={handleSubmit} />
<SearchHomeNotifications />
</div>
{#if showDotcomStuff}
<DotcomFooterLinks />
{/if}
<slot />
</div>
</section>

View File

@ -231,6 +231,16 @@ class Sourcegraph {
this.graphqlMock.addFixtures(fixtures)
}
public setWindowContext(context: Partial<Window['context']>): Promise<void> {
return this.page.addInitScript(context => {
if (!window.context) {
// @ts-expect-error - Unclear how to type this correctly
window.context = {}
}
Object.assign(window.context, context)
}, context)
}
public signIn(userMock: UserMock = {}): void {
this.mockTypes({
Query: () => ({