diff --git a/app/components/Markdown.tsx b/app/components/Markdown.tsx index 3849e3a..a4f48a3 100644 --- a/app/components/Markdown.tsx +++ b/app/components/Markdown.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { useMemo } from 'react'; import type { ReactElement } from 'react'; import Code from './Code'; -import { kebabCase } from 'change-case'; +import { headingAnchor } from '../lib/docsAnchors'; import { resolveMarkdownLink } from '../lib/markdownLinks'; interface MarkdownProps { @@ -43,7 +43,7 @@ export default function Markdown({ content, sourcePath }: MarkdownProps) { elements.push(
- +

{title}

diff --git a/app/components/QuickStartTabs.tsx b/app/components/QuickStartTabs.tsx new file mode 100644 index 0000000..3d3eb6b --- /dev/null +++ b/app/components/QuickStartTabs.tsx @@ -0,0 +1,313 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import CommandSnippet from "./CommandSnippet"; + +export type QuickStartStep = { + step: string; + description: string; +}; + +type QuickStartTabsProps = { + dockerCommand: string; + dockerSteps: QuickStartStep[]; + vscodeSteps: QuickStartStep[]; + /** Deep link that opens the extension's DocumentDB Local setup wizard. */ + vscodeDeepLinkUrl: string; + /** Marketplace page, linked from the caption so the install is explained, not hidden. */ + vscodeMarketplaceUrl: string; + /** Full Docker guide, linked from the Terminal panel's footer. */ + dockerDocsUrl: string; + /** + * The VS Code guide's setup section, which lists every other way to open the wizard. + * Linked from the VS Code panel's footer. + */ + vscodeDocsUrl: string; +}; + +// "Terminal" rather than "Docker": both paths run the same Docker image, and labelling one of +// them "Docker" implies the other avoids Docker. +const TABS = [ + { id: "terminal", label: "Terminal" }, + { id: "vscode", label: "VS Code" }, +] as const; + +type TabId = (typeof TABS)[number]["id"]; + +function StepList({ steps }: { steps: QuickStartStep[] }) { + return ( +
    + {steps.map((item) => ( +
  1. + + {item.step} + +

    {item.description}

    +
  2. + ))} +
+ ); +} + +/** + * How long after the setup button is used before the retry line appears. Long enough for + * VS Code to open and take focus on a normal machine; short enough that someone still + * looking at the page finds it. It is phrased so it does no harm if setup did work. + */ +const RETRY_HINT_DELAY_MS = 4000; + +/** + * The retry line under the VS Code steps. The deep link can do nothing visible: no VS Code + * installed, or on managed devices whose policy sets a private marketplace, a cold-started + * VS Code fails the install and a second click succeeds. The live region is always mounted + * so it exists before its content arrives, which is what makes the arrival announced. + * Exported so the visible state can be rendered in a test without a DOM library. + */ +export function SetupRetryHint({ + visible, + deepLinkUrl, + marketplaceUrl, +}: { + visible: boolean; + deepLinkUrl: string; + marketplaceUrl: string; +}) { + return ( +
+ {visible && ( +

+ Nothing happened? Try{" "} + + Set up in VS Code + {" "} + again, or install the extension from the{" "} + + Marketplace + {" "} + first. +

+ )} +
+ ); +} + +export default function QuickStartTabs({ + dockerCommand, + dockerSteps, + vscodeSteps, + vscodeDeepLinkUrl, + vscodeMarketplaceUrl, + dockerDocsUrl, + vscodeDocsUrl, +}: QuickStartTabsProps) { + const [activeTab, setActiveTab] = useState("terminal"); + // Deliberately never reset on a tab switch: the advice stays true once the link was used. + const [setupOpened, setSetupOpened] = useState(false); + const [showRetry, setShowRetry] = useState(false); + const tabRefs = useRef>({}); + + useEffect(() => { + if (!setupOpened) { + return; + } + const timer = window.setTimeout(() => setShowRetry(true), RETRY_HINT_DELAY_MS); + return () => window.clearTimeout(timer); + }, [setupOpened]); + + const selectTab = (id: TabId) => { + setActiveTab(id); + tabRefs.current[id]?.focus(); + }; + + // Arrow keys move between tabs, which is what a tablist is expected to do; without it the + // only way through is Tab, and that leaves the panel. Home/End jump to the ends, per the + // ARIA authoring practices for tabs. + const onTabKeyDown = (event: React.KeyboardEvent) => { + const currentIndex = TABS.findIndex((tab) => tab.id === activeTab); + + switch (event.key) { + case "ArrowRight": + case "ArrowLeft": { + event.preventDefault(); + const delta = event.key === "ArrowRight" ? 1 : -1; + selectTab(TABS[(currentIndex + delta + TABS.length) % TABS.length].id); + break; + } + case "Home": + event.preventDefault(); + selectTab(TABS[0].id); + break; + case "End": + event.preventDefault(); + selectTab(TABS[TABS.length - 1].id); + break; + default: + break; + } + }; + + return ( +
+
+ {TABS.map((tab) => { + const isActive = tab.id === activeTab; + + return ( + + ); + })} +
+ + + + +
+ ); +} diff --git a/app/lib/docsAnchors.ts b/app/lib/docsAnchors.ts new file mode 100644 index 0000000..370341f --- /dev/null +++ b/app/lib/docsAnchors.ts @@ -0,0 +1,13 @@ +import { kebabCase } from 'change-case'; + +/** + * Anchor id for a guide H2, exactly as Markdown.tsx emits it. Anything that links into a + * guide section derives the fragment here, so the renderer and the link cannot disagree. + */ +export function headingAnchor(title: string): string { + return kebabCase(title); +} + +/** The guide section the homepage quick start points at when the VS Code deep link does nothing. */ +export const vscodeSetupSectionTitle = 'Set up DocumentDB Local'; +export const vscodeSetupSectionAnchor = headingAnchor(vscodeSetupSectionTitle); diff --git a/app/page.tsx b/app/page.tsx index a8a7098..49dc8ab 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,12 @@ import Image from "next/image"; import Link from "next/link"; -import CommandSnippet from "./components/CommandSnippet"; -import { documentdbKubernetesOperatorQuickStartUrl } from "./services/externalLinks"; +import QuickStartTabs from "./components/QuickStartTabs"; +import { + documentdbKubernetesOperatorQuickStartUrl, + documentdbVsCodeExtensionMarketplaceUrl, + documentdbVsCodeLocalQuickStartDeepLink, +} from "./services/externalLinks"; +import { vscodeSetupSectionAnchor } from "./lib/docsAnchors"; import { getMetadata } from "./services/metadataService"; import { documentdbGitHubForks, @@ -34,23 +39,42 @@ type Capability = { }; const quickRunCommand = `docker run -dt --name documentdb \\ - -p 10260:10260 \\ + -p 127.0.0.1:10260:10260 \\ ghcr.io/documentdb/documentdb/documentdb-local:latest \\ - --username \\ - --password `; + --username '' \\ + --password ''`; -const quickStartSteps = [ +const dockerQuickStartSteps = [ { step: "01", - description: "Run DocumentDB Local with Docker.", + description: + "Replace the placeholders with your own username and password, then run the command. The image is around 300 MB, so the first run takes a few minutes.", }, { step: "02", - description: "Connect on port 10260 with your app, shell, or client.", + description: + "Connect on 127.0.0.1:10260 with mongosh (TLS flags are in the guide), a MongoDB driver, or your app.", }, { step: "03", - description: "Continue with the docs or Linux packages for the setup you need.", + description: + "Run your first query. The Docker guide has connection strings and a flag for sample data.", + }, +]; + +// Two steps against Terminal's three: the guided path should look shorter at a glance. +// Installing the extension is not a step, because VS Code offers to do it when the deep +// link is opened; the caption under the button says so. +const vscodeQuickStartSteps = [ + { + step: "01", + description: + "Once VS Code loads the wizard, select Continue, review the defaults, then select Start DocumentDB Local.", + }, + { + step: "02", + description: + "Select Open Connection to browse your data and run your first query. Sample data is loaded by default.", }, ]; @@ -316,7 +340,7 @@ export default function Home() {
-
+

Open source document database @@ -372,43 +396,22 @@ export default function Home() { Quick start

- Run locally with Docker + Run DocumentDB locally

- Start DocumentDB Local with Docker, then connect on port - 10260. + Run it from your terminal, or let the VS Code extension set it + up for you. Both run the same DocumentDB Local image.

- -
    - {quickStartSteps.map((item) => ( -
  1. - - {item.step} - -

    - {item.description} -

    -
  2. - ))} -
-
- - Docker quick start - - - Download packages - -
+
diff --git a/app/services/articleService.ts b/app/services/articleService.ts index 2be8647..0bcac25 100644 --- a/app/services/articleService.ts +++ b/app/services/articleService.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { vscodeSetupSectionTitle } from '../lib/docsAnchors'; import { load as loadYaml } from 'js-yaml'; import matter from 'gray-matter'; import { Article } from '../types/Article'; @@ -616,15 +617,19 @@ If the target already has PostgreSQL, the PGDG extension dependencies (\`postgre const vscodeQuickStartGuideContent = `# Visual Studio Code Quick Start -Use DocumentDB for VS Code to connect to a local DocumentDB instance, browse sample data, and create your first database without leaving the editor. +Use DocumentDB for VS Code to set up a local DocumentDB instance, browse sample data, and create your first database without leaving the editor. + +The extension can create the instance for you: it pulls the official image, starts the container, waits until the database accepts connections, and saves the connection. It never installs Docker, and it changes nothing else on your machine. ## Prerequisites - [Visual Studio Code](https://code.visualstudio.com/) - The [DocumentDB for VS Code extension](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-documentdb) -- A local DocumentDB instance from [Docker Quick Start](/docs/getting-started/docker) or a host setup with a running DocumentDB gateway +- Docker Desktop or Docker Engine, set to Linux containers, running wherever VS Code is - Optional: [mongosh](https://www.mongodb.com/docs/mongodb-shell/install/) for independent connection checks +Docker must be reachable from the environment VS Code runs in. If you work in WSL, a dev container, an SSH remote, or Codespaces, Docker needs to be available there rather than only on your host machine. Setup runs a readiness check and explains what to fix if it cannot reach Docker. + ## Install the extension Install the extension from the VS Code marketplace, or run: @@ -635,9 +640,26 @@ code --install-extension ms-azuretools.vscode-documentdb If VS Code prompts you to reload after installation, do that before creating a connection. -## Start DocumentDB first +## ${vscodeSetupSectionTitle} -For the fastest local setup, start DocumentDB Local with Docker: +This is the fastest path, and it leaves no Docker commands for you to run. + +1. Open setup using any of these: + - Select the DocumentDB icon in the activity bar, expand **Your own DocumentDB** in the Connections view, and select **Set up DocumentDB Local**. + - Run **DocumentDB: Set up DocumentDB Local** from the Command Palette. + - Open \`vscode://ms-azuretools.vscode-documentdb/local\` from your browser and confirm the prompts. If the extension is not installed, VS Code offers to install it first. This needs extension version 0.10.1 or later. +2. On the **Introduction** step, select **Continue**. Nothing is downloaded or created until the next step. +3. On the **Configure** step, review the defaults and select **Start DocumentDB Local**. The defaults give you an available port (starting at \`10260\`), generated credentials, the \`latest\` official image, and optional sample data. Expand the advanced options to set the port, image tag, or credentials yourself. +4. Wait for setup to finish. The extension creates a container named \`vscode-documentdb-local\` with a persistent volume, then waits until the database accepts connections. +5. Select **Open Connection** to reveal the saved connection, then expand it to browse databases and collections. + +If you keep the sample data option, a \`sampledb\` database is created with \`users\`, \`products\`, \`orders\`, and \`analytics\` collections. + +Right-click the DocumentDB Local entry to **Start**, **Stop**, **Restart**, or **Delete Container**, and to **Copy Connection String**, **Copy Password**, or **View Logs**. Stopping and starting preserves your data; deleting removes the volume and the generated credentials permanently. + +## Alternative: start the container yourself + +Use this if you already run DocumentDB Local outside VS Code, or you want to manage the container yourself. Start it with Docker: \`\`\`bash docker run -dt --name documentdb \\ @@ -649,7 +671,7 @@ docker run -dt --name documentdb \\ If you prefer a host installation instead of Docker, use the [Linux Packages Quick Start](/docs/getting-started/packages) on a distribution in the current release matrix. -## Add a local connection in VS Code +Then add the connection by hand: 1. Open the **DocumentDB** view in the VS Code activity bar. 2. In the local connection area, select **DocumentDB Local** and start the **New Local Connection** flow. @@ -689,8 +711,11 @@ After the connection works, the extension can help you continue without leaving ## Troubleshooting and debugging -If the extension does not connect on the first try: +If setup or the connection does not work on the first try: +- If the browser link does nothing, confirm the extension is installed and up to date, then run **DocumentDB: Set up DocumentDB Local** from the Command Palette instead +- If VS Code reports **No extension gallery service configured**, it could not reach a marketplace to install the extension for you. On managed devices that use a private marketplace, this can happen when the link is also what starts VS Code. Open the link again once VS Code has loaded, or install the extension yourself with \`code --install-extension ms-azuretools.vscode-documentdb\` and then open the link again +- If setup reports that Docker is unreachable, fix what it names (Docker not running, or Docker set to Windows containers rather than Linux) and select **Continue setup**; nothing has been created at that point - Verify the extension is installed and reload VS Code if the DocumentDB view does not appear - Confirm your local DocumentDB instance is actually running before you connect - If you used Docker, check \`docker ps\` and \`docker logs documentdb\` diff --git a/app/services/externalLinks.ts b/app/services/externalLinks.ts index 9445af0..7a3ada9 100644 --- a/app/services/externalLinks.ts +++ b/app/services/externalLinks.ts @@ -13,3 +13,9 @@ export const documentdbKubernetesOperatorQuickStartUrl = export const documentdbKubernetesOperatorGitHubUrl = 'https://github.com/documentdb/documentdb-kubernetes-operator'; + +export const documentdbVsCodeExtensionMarketplaceUrl = + 'https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-documentdb'; + +export const documentdbVsCodeLocalQuickStartDeepLink = + 'vscode://ms-azuretools.vscode-documentdb/local'; diff --git a/tests/quickStart.test.ts b/tests/quickStart.test.ts new file mode 100644 index 0000000..08b517d --- /dev/null +++ b/tests/quickStart.test.ts @@ -0,0 +1,196 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import Home from '../app/page'; +import { SetupRetryHint } from '../app/components/QuickStartTabs'; +import { + headingAnchor, + vscodeSetupSectionAnchor, + vscodeSetupSectionTitle, +} from '../app/lib/docsAnchors'; +import { getArticleByPath } from '../app/services/articleService'; +import { + documentdbVsCodeExtensionMarketplaceUrl, + documentdbVsCodeLocalQuickStartDeepLink, +} from '../app/services/externalLinks'; + +const html = renderToStaticMarkup(createElement(Home)); + +/** The quick start card only, so assertions cannot be satisfied by unrelated page content. */ +const card = html.slice(html.indexOf('id="run-with-docker"')); + +/** One tab panel only: bounded by the next panel, or by the end of the hero section. */ +function panel(id: 'terminal' | 'vscode') { + const start = card.indexOf(`id="quickstart-panel-${id}"`); + expect(start).toBeGreaterThan(-1); + const candidates = [ + card.indexOf('id="quickstart-panel-vscode"', start + 1), + card.indexOf('
', start), + ].filter((index) => index > start); + expect(candidates.length).toBeGreaterThan(0); + return card.slice(start, Math.min(...candidates)); +} + +const vscodeGuideUrl = `/docs/getting-started/vscode-quickstart#${vscodeSetupSectionAnchor}`; + +describe('homepage local quick start', () => { + it('preserves the public anchor and the terminal path as the default tab', () => { + expect(html).toContain('id="run-with-docker"'); + + // Asserted attribute by attribute: matching a fixed attribute sequence would fail on a + // no-op JSX prop reorder while telling us nothing extra. + for (const attr of [ + 'id="quickstart-tab-terminal"', + 'aria-selected="true"', + 'aria-controls="quickstart-panel-terminal"', + 'id="quickstart-panel-terminal"', + 'aria-labelledby="quickstart-tab-terminal"', + 'id="quickstart-tab-vscode"', + 'aria-controls="quickstart-panel-vscode"', + 'id="quickstart-panel-vscode"', + 'aria-labelledby="quickstart-tab-vscode"', + ]) { + expect(card).toContain(attr); + } + + // The VS Code panel is the one hidden on first paint, and its tab is out of the tab order. + expect(card).toMatch(/id="quickstart-panel-vscode"[^>]*hidden=""/); + expect(card).not.toMatch(/id="quickstart-panel-terminal"[^>]*hidden=""/); + }); + + it('ships a command that is safe to publish and actually parses in a shell', () => { + // A bare -p publishes on every interface; the guide this card links to calls that out. + expect(card).toContain('-p 127.0.0.1:10260:10260'); + expect(card).not.toContain('-p 10260:10260 '); + // Unquoted is parsed as a redirection, so the pasted command is a syntax error. + expect(card).toContain("--username '<YOUR_USERNAME>'"); + expect(card).toContain("--password '<YOUR_PASSWORD>'"); + expect(card).toContain( + 'ghcr.io/documentdb/documentdb/documentdb-local:latest', + ); + }); + + it('labels the tabs by interface and says the paths share an image, not a container', () => { + expect(card).toContain('>Terminal'); + expect(card).toContain('>VS Code'); + expect(card).toContain('Both run the same DocumentDB Local image.'); + }); + + it('carries the whole VS Code flow on one button, with the install explained underneath', () => { + expect(documentdbVsCodeLocalQuickStartDeepLink).toBe( + 'vscode://ms-azuretools.vscode-documentdb/local', + ); + const vscode = panel('vscode'); + + // VS Code offers to install a missing extension when the deep link targets it, so a + // separate "Install the extension" action was a step the visitor never has to take. + // Attribute by attribute rather than as one sequence, so a JSX prop reorder cannot fail it. + const button = vscode.slice(0, vscode.indexOf('>Set up in VS Code')); + expect(button).toContain(`href="${documentdbVsCodeLocalQuickStartDeepLink}"`); + expect(button).toContain('aria-describedby="quickstart-vscode-setup-caption"'); + expect(vscode).not.toContain('Install the extension'); + expect(vscode).not.toContain('Open setup in VS Code'); + + // The caption is what the button is described by, and it is where the Marketplace link + // lives now: explained, not hidden, but not a second button. + const captionStart = vscode.indexOf('id="quickstart-vscode-setup-caption"'); + const caption = vscode.slice(captionStart, vscode.indexOf('

', captionStart)); + expect(caption).toContain('Opens VS Code and its setup wizard.'); + expect(caption).toContain(`href="${documentdbVsCodeExtensionMarketplaceUrl}"`); + // VS Code offers; the visitor confirms. "Installs" promised more than the flow does. + expect(caption).toContain('VS Code offers to install it first.'); + + expect(vscode).toContain('Choose this for the smoothest experience.'); + }); + + it('does not put a Docker prerequisite only under the guided path', () => { + // Both paths need Docker and the Terminal tab does not say so; saying it only under VS + // Code made the guided path look like the one with extra requirements. + expect(panel('vscode')).not.toMatch(/docker/i); + expect(panel('vscode')).not.toContain('Linux containers'); + }); + + it('shows the guided path as two steps, the wizard and the payoff', () => { + expect(panel('terminal').match(/ { + // Matched by shape rather than by one literal, so bumping the pin to 0.10.2 is caught + // too. Deliberately not a bare \d+\.\d+\.\d+, which would match the 127.0.0.1 in the + // command and the loopback address in the steps. + expect(card).not.toMatch(/version \d+\.\d+\.\d+/i); + expect(card).not.toMatch(/\d+\.\d+\.\d+ or (later|newer|above)/i); + expect(card).not.toMatch(/\bv\d+\.\d+\.\d+\b/); + }); + + it('links both guides from inside their own panels', () => { + // Conditionally rendering one link left the VS Code guide out of the exported HTML + // entirely, since the server renders with the terminal tab active. + expect(panel('terminal')).toContain('href="/docs/getting-started/docker"'); + expect(panel('terminal')).toContain('Full Docker guide'); + expect(panel('vscode')).toContain(`href="${vscodeGuideUrl}"`); + }); + + it('points the VS Code fallback at a section the guide actually has', () => { + // Markdown.tsx anchors each H2 with kebabCase(title); the link must land on the section + // that lists the other ways to open the wizard, not at the top of the page. + const guide = getArticleByPath('getting-started', ['vscode-quickstart']); + expect(guide?.content).toContain(`## ${vscodeSetupSectionTitle}`); + expect(vscodeSetupSectionAnchor).toBe(headingAnchor('Set up DocumentDB Local')); + expect(vscodeSetupSectionAnchor).toBe('set-up-document-db-local'); + expect(guide?.content).toContain('VS Code offers to install it first'); + // The install-on-link flow fails on machines whose policy points VS Code at a private + // marketplace before the account check completes; the guide names that error verbatim. + expect(guide?.content).toContain('No extension gallery service configured'); + expect(guide?.content).toContain('code --install-extension ms-azuretools.vscode-documentdb'); + }); + + it('offers a labelled route between the two paths', () => { + expect(card).toContain('aria-label="Switch to the VS Code tab"'); + expect(card).toContain('aria-label="Switch to the Terminal tab"'); + }); + + it('keeps troubleshooting out of the happy path', () => { + const vscode = panel('vscode'); + expect(vscode).not.toContain('If nothing happens'); + // The retry line appears only some seconds after the button is used. The live region is + // mounted from the first paint so it is announced when filled, but it starts empty, and + // it sits below the steps so its arrival never shifts what is being read. + expect(vscode).not.toContain('Nothing happened?'); + expect(vscode).toMatch(/
\s*<\/div>/); + expect(vscode.search(/
/)).toBeGreaterThan(vscode.lastIndexOf('')); + expect(vscode).not.toContain('DocumentDB: Set up DocumentDB Local'); + + // A short link after the steps, not a paragraph of doubt in front of someone who has + // not clicked yet. + const fallback = vscode.indexOf('Not working in VS Code?'); + expect(fallback).toBeGreaterThan(vscode.lastIndexOf('')); + expect(vscode.slice(fallback)).toContain(`href="${vscodeGuideUrl}"`); + // Same link text shape as the Terminal panel's "Full Docker guide", so the two panels + // rhyme and a successful visitor still has a neutral route to the guide. + expect(vscode.slice(fallback)).toContain('>full VS Code guide'); + expect(vscode.slice(fallback)).toContain('activity bar or the Command'); + }); + + it('repeats the deep link as the retry control once the hint is visible', () => { + const hint = renderToStaticMarkup( + createElement(SetupRetryHint, { + visible: true, + deepLinkUrl: documentdbVsCodeLocalQuickStartDeepLink, + marketplaceUrl: documentdbVsCodeExtensionMarketplaceUrl, + }), + ); + expect(hint).toContain('Nothing happened?'); + // The second click is the one that works on managed devices, so it is one action away. + expect(hint).toContain(`href="${documentdbVsCodeLocalQuickStartDeepLink}"`); + expect(hint).toContain('>Set up in VS Code'); + expect(hint).toContain(`href="${documentdbVsCodeExtensionMarketplaceUrl}"`); + // Nothing about the error itself: that explanation belongs in the guide. + expect(hint).not.toMatch(/error/i); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..21215b0 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + oxc: { + jsx: { + runtime: 'automatic', + }, + }, +});