diff --git a/e2e-tests/site.spec.ts b/e2e-tests/site.spec.ts index 4a96adb..e6d5ad6 100644 --- a/e2e-tests/site.spec.ts +++ b/e2e-tests/site.spec.ts @@ -15,9 +15,7 @@ test("Model page loads", async ({ page }) => { page.getByRole("heading", { name: "Malloy model invoices" }), ).toBeVisible({ timeout: 15 * 1000 }); // Verify data sources section is visible - await expect( - page.getByRole("button", { name: /Data Sources/ }), - ).toBeVisible(); + await expect(page.getByRole("link", { name: /Data Sources/ })).toBeVisible(); }); test("Preview page loads", async ({ page }) => { @@ -28,7 +26,7 @@ test("Preview page loads", async ({ page }) => { }); // Verify preview page loaded with heading and table data await expect(page.getByRole("heading", { name: "invoices" })).toBeVisible(); - await expect(page.getByText("Preview")).toBeVisible(); + await expect(page.getByRole("button", { name: "Preview" })).toBeVisible(); // Verify table has data await expect(page.getByText("invoice_id")).toBeVisible(); }); diff --git a/img/chevron_down.svg b/img/chevron_down.svg index a26bd4c..76fc89b 100644 --- a/img/chevron_down.svg +++ b/img/chevron_down.svg @@ -2,6 +2,6 @@ cahevron_down - + \ No newline at end of file diff --git a/plugins/vite-plugin-copy-downloads.ts b/plugins/vite-plugin-copy-downloads.ts new file mode 100644 index 0000000..aaef059 --- /dev/null +++ b/plugins/vite-plugin-copy-downloads.ts @@ -0,0 +1,185 @@ +/** + * Vite plugin to copy downloadable files (models, notebooks, data) + * to the build output for static serving + */ +import type { Plugin } from "vite"; +import { + copyFileSync, + mkdirSync, + existsSync, + readdirSync, + statSync, + createReadStream, +} from "node:fs"; +import { join, resolve, basename } from "node:path"; + +export default function copyDownloadsPlugin(): Plugin { + let outDir: string; + let modelsDir: string; + + return { + name: "vite-plugin-copy-downloads", + + configResolved(config) { + outDir = resolve(config.root, config.build.outDir); + modelsDir = resolve(config.root, "models"); + }, + + configureServer(server) { + // Respect Vite's base config + const base = server.config.base.endsWith("/") + ? server.config.base + : `${server.config.base}/`; + const downloadsPrefix = `${base}downloads/`; + + server.middlewares.use((req, res, next) => { + const url = req.url || ""; + + if (!url.startsWith(downloadsPrefix)) { + next(); + return; + } + + // Strip the prefix and any query string, then decode + const pathPart = url.slice(downloadsPrefix.length).split("?")[0] || ""; + const [rawCategory, ...rawRestParts] = pathPart + .split("/") + .filter(Boolean); + + if (!rawCategory || rawRestParts.length === 0) { + next(); + return; + } + + // Decode URI components + const category = decodeURIComponent(rawCategory); + const restParts = rawRestParts.map((part) => decodeURIComponent(part)); + + // Reject any path traversal attempts + if (restParts.some((part) => part === ".." || part === ".")) { + res.statusCode = 400; + res.end("Bad Request"); + return; + } + + const rest = restParts.join("/"); + let filePath: string; + + if (category === "models" || category === "notebooks") { + // Models and notebooks both live in the top-level models directory. + filePath = join(modelsDir, rest); + } else if (category === "data") { + filePath = join(modelsDir, "data", rest); + } else { + next(); + return; + } + + // Verify the resolved path is within the allowed directory + const normalizedPath = resolve(filePath); + const allowedDir = + category === "data" ? resolve(modelsDir, "data") : resolve(modelsDir); + if (!normalizedPath.startsWith(allowedDir)) { + res.statusCode = 403; + res.end("Forbidden"); + return; + } + + if (!existsSync(filePath)) { + next(); + return; + } + + const stat = statSync(filePath); + if (!stat.isFile()) { + next(); + return; + } + + res.statusCode = 200; + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${basename(filePath)}"`, + ); + + const stream = createReadStream(filePath); + stream.on("error", () => { + if (!res.headersSent) { + res.statusCode = 500; + res.end("Internal Server Error"); + } else { + res.end(); + } + }); + stream.pipe(res); + }); + }, + + closeBundle() { + // Skip during test runs + if (process.env["VITEST"] || process.env["NODE_ENV"] === "test") { + return; + } + const downloadsDir = join(outDir, "downloads"); + + // Create downloads directory structure + const modelsDest = join(downloadsDir, "models"); + const notebooksDest = join(downloadsDir, "notebooks"); + const dataDest = join(downloadsDir, "data"); + + [downloadsDir, modelsDest, notebooksDest, dataDest].forEach((dir) => { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + }); + + // Copy all .malloy files (models) + const files = readdirSync(modelsDir); + let modelCount = 0; + let notebookCount = 0; + + files.forEach((file) => { + const srcPath = join(modelsDir, file); + const stat = statSync(srcPath); + + if (stat.isFile()) { + if (file.endsWith(".malloy")) { + const destPath = join(modelsDest, file); + copyFileSync(srcPath, destPath); + modelCount++; + console.log(` āœ“ Copied model: ${file}`); + } else if (file.endsWith(".malloynb")) { + const destPath = join(notebooksDest, file); + copyFileSync(srcPath, destPath); + notebookCount++; + console.log(` āœ“ Copied notebook: ${file}`); + } + } + }); + + // Copy all data files from models/data + const dataDir = join(modelsDir, "data"); + if (existsSync(dataDir)) { + const dataFiles = readdirSync(dataDir); + let dataCount = 0; + + dataFiles.forEach((file) => { + const srcPath = join(dataDir, file); + const stat = statSync(srcPath); + + if (stat.isFile() && file !== ".gitkeep") { + const destPath = join(dataDest, file); + copyFileSync(srcPath, destPath); + dataCount++; + console.log(` āœ“ Copied data: ${file}`); + } + }); + + console.log( + `\nšŸ“¦ Download files copied: ${modelCount.toString()} models, ${notebookCount.toString()} notebooks, ${dataCount.toString()} data files`, + ); + } + }, + }; +} diff --git a/src/Breadcrumbs.tsx b/src/Breadcrumbs.tsx index 3be44c4..2c0928c 100644 --- a/src/Breadcrumbs.tsx +++ b/src/Breadcrumbs.tsx @@ -9,6 +9,7 @@ import { useRuntime } from "./contexts"; import MalloyCodeBlock from "./MalloyCodeBlock"; import { type JSX } from "react/jsx-runtime"; import ArrowLeftIcon from "../img/arrow-left.svg?react"; +import FaviconLogo from "../img/favicon-logo.svg?react"; import Menu from "./Menu"; export default Breadcrumbs; @@ -42,6 +43,9 @@ function Breadcrumbs({ models, notebooks }: BreadcrumbsProps): JSX.Element { return ( ); @@ -155,9 +171,11 @@ function BreadcrumbDropdown({ function SourceBreadcrumb({ modelName, sourceName, + pageType, }: { modelName: string; sourceName: string; + pageType: "explorer" | "preview"; }): JSX.Element { const { model } = useRuntime(); const sources = model.exportedExplores; @@ -167,11 +185,44 @@ function SourceBreadcrumb({ / ({ name: explore.name, - to: `/model/${modelName}/explorer/${explore.name}`, + to: `/model/${modelName}/${pageType}/${explore.name}`, active: explore.name === sourceName, }))} + isCurrent={pageType === "explorer"} + /> + + ); +} + +function ViewTypeBreadcrumb({ + modelName, + sourceName, + currentView, +}: { + modelName: string; + sourceName: string; + currentView: "preview" | "explorer"; +}): JSX.Element { + return ( + <> + / + @@ -192,9 +243,9 @@ function QueryBreadcrumb({ <> / ({ - name: query.name, + name: `Query ${query.name}`, to: `/model/${modelName}/query/${query.name}`, active: query.name === queryName, }))} diff --git a/src/Card.tsx b/src/Card.tsx index e8a97cc..7d38a1e 100644 --- a/src/Card.tsx +++ b/src/Card.tsx @@ -1,6 +1,5 @@ import { Link } from "react-router"; import type { JSX } from "react/jsx-runtime"; -import ArrowRightIcon from "../img/arrow-right.svg?react"; export default Card; export type { CardProps }; @@ -9,26 +8,15 @@ type CardProps = { to: string; icon: JSX.Element; title: string; - description?: string; className?: string; }; -function Card({ - to, - icon, - title, - description, - className = "", -}: CardProps): JSX.Element { +function Card({ to, icon, title, className = "" }: CardProps): JSX.Element { return (
{icon}

{title}

- {description &&

{description}

} -
-
-
); diff --git a/src/Home.tsx b/src/Home.tsx index 62c461e..79533c9 100644 --- a/src/Home.tsx +++ b/src/Home.tsx @@ -32,9 +32,10 @@ function Home({ models, notebooks }: HomeProps): JSX.Element { Malloy models and notebooks

+ -
+

Data Models

@@ -59,7 +60,6 @@ function Home({ models, notebooks }: HomeProps): JSX.Element { to={`/model/${encodeURIComponent(modelName)}`} icon={} title={humanizeName(modelName)} - description="Semantic data model" className="model-card" /> ))} @@ -67,8 +67,6 @@ function Home({ models, notebooks }: HomeProps): JSX.Element { )}
- -

Data Notebooks

@@ -93,7 +91,6 @@ function Home({ models, notebooks }: HomeProps): JSX.Element { to={`/notebook/${encodeURIComponent(notebookName)}`} icon={} title={humanizeName(notebookName)} - description="Visual data story" className="notebook-card" /> ))} diff --git a/src/Menu.tsx b/src/Menu.tsx index ab4b3c4..ead3b93 100644 --- a/src/Menu.tsx +++ b/src/Menu.tsx @@ -1,5 +1,6 @@ import { Link } from "react-router"; import { useId, type JSX, type ReactNode } from "react"; +import ChevronDownIcon from "../img/chevron_down.svg?react"; export default Menu; @@ -35,6 +36,7 @@ function Menu({ popoverTarget={popoverId} > {trigger} +
+ {modelName && ( + + + Download + + )}
{ const source = explore.name; - return navigate(`explorer/${source}`); + return navigate( + `explorer/${source}?showQueryPanel=true&showSourcePanel=true`, + ); }} />
diff --git a/src/NotebookCellRenderer.tsx b/src/NotebookCellRenderer.tsx index e2e69fd..6f57823 100644 --- a/src/NotebookCellRenderer.tsx +++ b/src/NotebookCellRenderer.tsx @@ -59,7 +59,7 @@ function NotebookCellRenderer({
- +
+ )}
{notebook.sources.length > 0 && ( diff --git a/src/PreviewResult.tsx b/src/PreviewResult.tsx index 698ac05..64d475f 100644 --- a/src/PreviewResult.tsx +++ b/src/PreviewResult.tsx @@ -15,7 +15,7 @@ function PreviewResult(): JSX.Element {

Preview

- +
); diff --git a/src/QueryResult.tsx b/src/QueryResult.tsx index 415325e..629a3f9 100644 --- a/src/QueryResult.tsx +++ b/src/QueryResult.tsx @@ -15,7 +15,7 @@ function QueryResult(): JSX.Element {

Named Query

- +
); diff --git a/src/RenderedResult.tsx b/src/RenderedResult.tsx index 323f468..92b6a7e 100644 --- a/src/RenderedResult.tsx +++ b/src/RenderedResult.tsx @@ -7,10 +7,10 @@ export default RenderedResult; function RenderedResult({ result, - height = "400px", + height, }: { - height?: string; result: malloyInterfaces.Result; + height: string; }): JSX.Element { const vizContainer = useRef(null); const viz = useMemo(() => { diff --git a/src/Schema.tsx b/src/Schema.tsx index fb6058f..dc5a852 100644 --- a/src/Schema.tsx +++ b/src/Schema.tsx @@ -13,6 +13,7 @@ import { } from "@malloydata/malloy"; import MalloyCodeBlock from "./MalloyCodeBlock"; import type { DataSourceInfo } from "./types"; +import { useSearchParams, Link } from "react-router"; import { exploreSubtype, @@ -26,6 +27,7 @@ import ArrayIcon from "../img/data-type-array.svg?react"; import BooleanIcon from "../img/boolean.svg?react"; import ChevronRightIcon from "../img/chevron_right.svg?react"; import ChevronDownIcon from "../img/chevron_down.svg?react"; +import DownloadIcon from "../img/download.svg?react"; import ManyToOneIcon from "../img/many_to_one.svg?react"; import NumberIcon from "../img/number.svg?react"; import NumberAggregateIcon from "../img/number-aggregate.svg?react"; @@ -39,12 +41,13 @@ import TimeIcon from "../img/time.svg?react"; import UnknownIcon from "../img/unknown.svg?react"; import LightningIcon from "../img/lightning.svg?react"; import CodeIcon from "../img/code.svg?react"; -import DownloadIcon from "../img/download.svg?react"; import FileIcon from "../img/file.svg?react"; import EyeIcon from "../img/eye.svg?react"; import CompassIcon from "../img/compass.svg?react"; import DatabaseIcon from "../img/database-icon.svg?react"; import { type JSX } from "react/jsx-runtime"; +import { getDataDownloadUrl } from "./download-utils"; +import Menu from "./Menu"; export { SchemaRenderer }; export type { SchemaRendererProps }; @@ -77,6 +80,7 @@ function SchemaRenderer({ const hidden = !defaultShow; const hasQueries = queries.length > 0; const hasExplores = explores.length > 0; + const [searchParams] = useSearchParams(); // Filter dataSources to only show files referenced in the model const referencedDataSources = React.useMemo(() => { @@ -95,65 +99,73 @@ function SchemaRenderer({ return "sources"; }; - const [activeTab, setActiveTab] = React.useState< - "sources" | "queries" | "code" | "data" - >(getDefaultTab()); + const validTabs = ["sources", "queries", "code", "data"] as const; + const tabParam = searchParams.get("tab"); + const activeTab = + tabParam && validTabs.includes(tabParam as (typeof validTabs)[number]) + ? (tabParam as "sources" | "queries" | "code" | "data") + : getDefaultTab(); + + const createTabUrl = (tab: string) => { + const params = new URLSearchParams(searchParams); + params.set("tab", tab); + return `?${params.toString()}`; + }; return (
{hasExplores && ( - + )} {hasQueries && ( - + )} {modelCode && ( - + )} {hasDataSources && ( - + )}
+ +
{activeTab === "queries" && hasQueries && (
@@ -190,25 +202,28 @@ function SchemaRenderer({ )} {activeTab === "data" && hasDataSources && (
- {referencedDataSources.map((source) => ( - - - {source.name} - - {source.fileType.toUpperCase()} - - - - ))} + {referencedDataSources.map((source) => { + const filename = `${source.name}.${source.fileType}`; + return ( + + + {source.name} + + {source.fileType.toUpperCase()} + + + + ); + })}
)}
@@ -278,10 +293,25 @@ function StructItem({ onExploreClick, startHidden, }: StructItemProps) { - const [hidden, setHidden] = React.useState(startHidden); + const [searchParams, setSearchParams] = useSearchParams(); + const exploreKey = path ? `${path}.${explore.name}` : explore.name; + const expandedExplores = searchParams.get("expanded")?.split(",") || []; + const isToggled = expandedExplores.includes(exploreKey); + // XOR: hidden if (startHidden and not toggled) or (not startHidden and toggled) + const hidden = startHidden ? !isToggled : isToggled; const toggleHidden = () => { - setHidden(!hidden); + const newExpandedExplores = isToggled + ? expandedExplores.filter((key) => key !== exploreKey) + : [...expandedExplores, exploreKey]; + + const params = new URLSearchParams(searchParams); + if (newExpandedExplores.length > 0) { + params.set("expanded", newExpandedExplores.join(",")); + } else { + params.delete("expanded"); + } + setSearchParams(params); }; const onClickingPreview = (event: React.MouseEvent) => { @@ -553,3 +583,94 @@ function buildTitle(field: Field, path: string) { Path: ${path}${path ? "." : ""}${fieldName} Type: ${typeLabel}`; } + +type SchemaMobileMenuProps = { + activeTab: "sources" | "queries" | "code" | "data"; + hasExplores: boolean; + exploresCount: number; + hasQueries: boolean; + queriesCount: number; + hasModelCode: boolean; + hasDataSources: boolean; + dataSourcesCount: number; +}; + +function SchemaMobileMenu({ + activeTab, + hasExplores, + exploresCount, + hasQueries, + queriesCount, + hasModelCode, + hasDataSources, + dataSourcesCount, +}: SchemaMobileMenuProps): JSX.Element { + const [searchParams] = useSearchParams(); + + const getActiveTabLabel = () => { + switch (activeTab) { + case "sources": + return `Data Sources (${exploresCount.toString()})`; + case "queries": + return `Named Queries (${queriesCount.toString()})`; + case "code": + return "Malloy Definition"; + case "data": + return `Raw Data (${dataSourcesCount.toString()})`; + } + }; + + const createTabUrl = (tab: string) => { + const params = new URLSearchParams(searchParams); + params.set("tab", tab); + return `?${params.toString()}`; + }; + + const allItems: Array<{ + value: "sources" | "queries" | "code" | "data"; + label: string; + to: string; + show: boolean; + }> = [ + { + value: "sources" as const, + label: `Data Sources (${exploresCount.toString()})`, + to: createTabUrl("sources"), + show: hasExplores, + }, + { + value: "queries" as const, + label: `Named Queries (${queriesCount.toString()})`, + to: createTabUrl("queries"), + show: hasQueries, + }, + { + value: "code" as const, + label: "Malloy Definition", + to: createTabUrl("code"), + show: hasModelCode, + }, + { + value: "data" as const, + label: `Raw Data (${dataSourcesCount.toString()})`, + to: createTabUrl("data"), + show: hasDataSources, + }, + ]; + + const menuItems = allItems.filter((item) => item.show); + + return ( +
+ ({ + name: item.label, + to: item.to, + active: activeTab === item.value, + }))} + /> +
+ ); +} diff --git a/src/download-utils.ts b/src/download-utils.ts new file mode 100644 index 0000000..d3cb9e4 --- /dev/null +++ b/src/download-utils.ts @@ -0,0 +1,56 @@ +/** + * Utilities for generating static download URLs for models, notebooks, and data files + */ + +/** + * Get the base path for the application + * This respects the BASE_PUBLIC_PATH environment variable used in Vite config + */ +function getBasePath(): string { + const base = import.meta.env.BASE_URL || "/"; + return base.endsWith("/") ? base : `${base}/`; +} + +/** + * Generate a static download URL for a model file + * @param modelName - Name of the model (without extension) + * @returns URL to the static model file + */ +export function getModelDownloadUrl(modelName: string): string { + const base = getBasePath(); + return `${base}downloads/models/${encodeURIComponent(modelName)}.malloy`; +} + +/** + * Generate a static download URL for a notebook file + * @param notebookName - Name of the notebook (without extension) + * @returns URL to the static notebook file + */ +export function getNotebookDownloadUrl(notebookName: string): string { + const base = getBasePath(); + return `${base}downloads/notebooks/${encodeURIComponent(notebookName)}.malloynb`; +} + +/** + * Generate a static download URL for a data file + * @param filename - Full filename with extension (e.g., "data.csv") + * @returns URL to the static data file + */ +export function getDataDownloadUrl(filename: string): string { + const base = getBasePath(); + return `${base}downloads/data/${encodeURIComponent(filename)}`; +} + +/** + * Trigger a browser download for a given URL + * @param url - The URL to download + * @param filename - The filename to use for the download + */ +export function triggerDownload(url: string, filename: string): void { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} diff --git a/src/index.css b/src/index.css index 92cb333..94a6aa6 100644 --- a/src/index.css +++ b/src/index.css @@ -61,6 +61,10 @@ -moz-osx-font-smoothing: grayscale; } +html { + height: 100%; +} + body { margin: 0; height: 100%; @@ -93,7 +97,9 @@ body { } #root { - padding: var(--spacing-md) var(--spacing-lg); + display: flex; + flex-direction: column; + padding: 0 var(--spacing-md); height: 100%; max-width: 1400px; margin: 0 auto; @@ -146,15 +152,75 @@ button { display: flex; align-items: center; gap: var(--spacing-sm); - padding: var(--spacing-sm) 0; - font-size: 0.9rem; + min-height: 48px; + max-height: 48px; + margin-bottom: var(--spacing-md); + font-size: 0.95rem; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scrollbar-gutter: stable; + position: -webkit-sticky; + position: sticky; + top: 0; + z-index: 100; + background-color: var(--color-background); + border-bottom: 1px solid var(--color-border); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + margin-left: calc(var(--spacing-lg) * -1); + margin-right: calc(var(--spacing-lg) * -1); + margin-top: 0; + padding-left: var(--spacing-lg); + padding-right: var(--spacing-lg); + align-self: flex-start; + width: calc(100% + var(--spacing-lg) * 2); + + @media (max-width: 768px) { + font-size: 0.875rem; + gap: var(--spacing-sm); + min-height: 44px; + max-height: 44px; + margin-left: calc(var(--spacing-md) * -1); + margin-right: calc(var(--spacing-md) * -1); + margin-bottom: var(--spacing-sm); + width: calc(100% + var(--spacing-md) * 2); + } + + & .breadcrumb-logo { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex-shrink: 0; + transition: opacity 0.2s; + + & svg { + width: 28px; + height: 28px; + } + + &:hover { + opacity: 0.8; + } + + @media (max-width: 768px) { + width: 28px; + height: 28px; + + & svg { + width: 24px; + height: 24px; + } + } + } & .back-button { display: flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 32px; + height: 32px; padding: 0; background: none; border: 1px solid var(--color-border); @@ -163,20 +229,44 @@ button { cursor: pointer; transition: all 0.2s; + & svg { + width: 16px; + height: 16px; + } + &:hover { background-color: var(--color-background-hover); border-color: var(--color-border-hover); color: var(--color-text-primary); } + + @media (max-width: 768px) { + width: 28px; + height: 28px; + + & svg { + width: 14px; + height: 14px; + } + } } & .breadcrumb-item { color: var(--color-text-secondary); font-weight: 500; text-decoration: none; + white-space: nowrap; + font-size: 0.95rem; + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + transition: all 0.2s; + line-height: 1.5; + display: inline-flex; + align-items: center; &:hover { color: var(--color-accent); + background-color: var(--color-background-hover); } &.current { @@ -186,18 +276,32 @@ button { &.query { display: flex; - gap: var(--spacing-sm); + gap: var(--spacing-xs); align-items: center; + padding: 0; + background-color: transparent; + & code { color: var(--color-text-tertiary); display: block; - font-size: 0.8rem; + font-size: 0.8125rem; max-width: 30ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + line-height: 1.4; + + @media (max-width: 768px) { + max-width: 20ch; + font-size: 0.75rem; + } } } + + @media (max-width: 768px) { + font-size: 0.875rem; + padding: 2px var(--spacing-xs); + } } & button.breadcrumb-item.current { @@ -208,33 +312,52 @@ button { & .breadcrumb-separator { color: var(--color-text-tertiary); font-weight: 300; - margin: 0 var(--spacing-xs); + font-size: 0.95rem; + margin: 0 2px; + + @media (max-width: 768px) { + font-size: 0.875rem; + margin: 0 1px; + } } & .breadcrumb-dropdown { & button.breadcrumb-item { color: var(--color-text-secondary); font-weight: 500; + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); &.current { color: var(--color-text-primary); font-weight: 600; } - &::after { - content: "ā–¾"; - margin-left: var(--spacing-xs); - font-size: 0.7em; - opacity: 0.6; - transition: transform 0.2s; + & .menu-chevron { + width: 16px; + height: 16px; + opacity: 0.7; + transition: + transform 0.2s, + opacity 0.2s; + color: currentColor; + flex-shrink: 0; + + @media (max-width: 768px) { + width: 14px; + height: 14px; + } } } - &:hover button.breadcrumb-item::after { + &:hover button.breadcrumb-item .menu-chevron { opacity: 1; } - & button.breadcrumb-item:popover-open::after { + &:has(.popover-menu-list:popover-open) + button.breadcrumb-item + .menu-chevron { opacity: 1; transform: rotate(180deg); } @@ -259,7 +382,10 @@ button { box-shadow: var(--shadow-lg); list-style: none; min-width: 180px; + max-width: min(280px, calc(100vw - 2rem)); margin: 0; + overflow-x: hidden; + overflow-y: auto; /* CSS Anchor Positioning */ position: fixed; @@ -267,12 +393,23 @@ button { left: anchor(left); margin-top: var(--spacing-xs); - & li a { + & li a, + & li button { display: block; + width: 100%; padding: var(--spacing-sm) var(--spacing-md); color: var(--color-text-secondary); text-decoration: none; font-weight: 400; + background: none; + border: none; + text-align: left; + cursor: pointer; + font-size: inherit; + font-family: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; &:hover { background-color: var(--color-background-hover); @@ -300,28 +437,55 @@ button { .home-container { max-width: 1200px; margin: 0 auto; + padding-top: var(--spacing-md); + + @media (max-width: 768px) { + padding-top: var(--spacing-sm); + } & .home-header { - margin-bottom: var(--spacing-xl); + margin-bottom: var(--spacing-lg); text-align: center; display: flex; flex-direction: column; align-items: center; gap: var(--spacing-sm); - padding: 0 0 var(--spacing-md) 0; position: relative; + + @media (max-width: 900px) { + gap: var(--spacing-md); + margin-bottom: var(--spacing-xl); + } + } + + & .home-logo { + width: 180px; + height: auto; + + @media (max-width: 900px) { + width: 120px; + } } & .home-title { font-size: 2.5rem; margin-bottom: 0; color: var(--color-text-primary); + + @media (max-width: 768px) { + font-size: 1.75rem; + } } & .home-subtitle { font-size: 1.125rem; color: var(--color-text-secondary); margin: 0; + + @media (max-width: 768px) { + font-size: 0.95rem; + text-align: center; + } } & .malloy-link { @@ -336,28 +500,29 @@ button { } } - & .home-content-with-logo { - display: grid; - grid-template-columns: 1fr auto 1fr; + & .home-content { + display: flex; gap: var(--spacing-2xl); align-items: start; + justify-content: space-between; @media (max-width: 900px) { - grid-template-columns: 1fr; + flex-direction: column; + gap: var(--spacing-xl); } } - & .home-logo-center { - width: 225px; - height: auto; - align-self: center; - } - & .home-section { min-width: 0; display: flex; flex-direction: column; max-height: 80vh; + flex: 1; + max-width: 600px; + + @media (max-width: 900px) { + max-width: 100%; + } } & .section-header { @@ -367,9 +532,18 @@ button { margin-bottom: var(--spacing-lg); flex-shrink: 0; + @media (max-width: 768px) { + gap: var(--spacing-sm); + margin-bottom: var(--spacing-md); + } + & h2 { font-size: 1.5rem; font-weight: 600; + + @media (max-width: 768px) { + font-size: 1.25rem; + } } } @@ -390,6 +564,10 @@ button { overflow-y: auto; flex: 1; min-height: 0; + + @media (max-width: 768px) { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + } } .card { @@ -441,14 +619,6 @@ button { overflow-wrap: break-word; line-height: 1.3; } - - & .card-description { - display: none; - } - - & .card-arrow { - display: none; - } } /* === EMPTY STATE === */ @@ -478,11 +648,19 @@ button { /* === MODEL HOME === */ .model-home { - max-width: 1200px; + min-width: 80%; margin: 0 auto; + padding-top: var(--spacing-md); + + @media (max-width: 768px) { + padding-top: var(--spacing-sm); + } & .model-header { background-color: var(--color-background); + display: flex; + align-items: baseline; + justify-content: space-between; border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--spacing-xl); @@ -518,16 +696,20 @@ button { box-shadow: var(--shadow-sm); } + & .model-download { + display: flex; + padding: var(--spacing-xs) 0; + } + & .model-code { border: 1px solid var(--color-border); border-radius: var(--radius-md); - overflow: auto; - max-height: 600px; & pre { margin: 0; - padding: var(--spacing-lg); - background-color: var(--color-background-secondary) !important; + padding: var(--spacing-md); + background-color: var(--color-background-secondary); + word-wrap: break-word; } & code { @@ -536,16 +718,10 @@ button { line-height: 1.6; } - & .shiki { - margin: 0; - padding: var(--spacing-lg); - overflow-x: auto; - - & code { - font-family: "SF Mono", "Fira Code", "Consolas", monospace; - font-size: 0.875rem; - line-height: 1.6; - } + & code { + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + font-size: 0.875rem; + line-height: 1.6; } } @@ -660,6 +836,69 @@ button { gap: var(--spacing-sm); margin-bottom: var(--spacing-lg); border-bottom: 1px solid var(--color-border); + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; + + @media (max-width: 768px) { + display: none; + } +} + +.schema-tabs-mobile { + display: none; + + @media (max-width: 768px) { + display: block; + } + + & .schema-tabs-mobile-trigger { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text-primary); + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + text-align: left; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm); + + & .menu-chevron { + width: 16px; + height: 16px; + opacity: 0.6; + transition: + transform 0.2s, + opacity 0.2s; + color: currentColor; + flex-shrink: 0; + } + + &:hover { + border-color: var(--color-border-hover); + background-color: var(--color-background-hover); + + & .menu-chevron { + opacity: 1; + } + } + + &:focus { + outline: none; + border-color: var(--color-accent); + } + } + + &:has([popover]:popover-open) .schema-tabs-mobile-trigger .menu-chevron { + opacity: 1; + transform: rotate(180deg); + } } .schema-tab { @@ -676,6 +915,15 @@ button { cursor: pointer; transition: all 0.2s; margin-bottom: -1px; + white-space: nowrap; + flex-shrink: 0; + text-decoration: none; + + @media (max-width: 768px) { + padding: var(--spacing-sm) var(--spacing-md); + font-size: 0.875rem; + gap: var(--spacing-xs); + } &:hover { color: var(--color-text-primary); @@ -688,15 +936,25 @@ button { & .count-badge { font-size: 0.8rem; + + @media (max-width: 768px) { + font-size: 0.75rem; + padding: 2px var(--spacing-sm); + } } & svg { flex-shrink: 0; + + @media (max-width: 768px) { + width: 16px; + height: 16px; + } } } .schema-tab-content { - padding-top: var(--spacing-md); + padding-top: var(--spacing-sm); } .explore-header { @@ -711,6 +969,12 @@ button { transition: all 0.2s; margin-bottom: var(--spacing-sm); + @media (max-width: 768px) { + padding: var(--spacing-sm); + flex-wrap: wrap; + gap: var(--spacing-sm); + } + &:hover { background-color: var(--color-background-hover); border-color: var(--color-border-hover); @@ -720,17 +984,31 @@ button { display: flex; align-items: center; gap: var(--spacing-sm); + + @media (max-width: 768px) { + gap: var(--spacing-xs); + } } & .explore_name { line-height: 1.5; font-weight: 600; font-size: 1rem; + + @media (max-width: 768px) { + font-size: 0.875rem; + } } & .explore-actions { display: flex; gap: var(--spacing-sm); + + @media (max-width: 768px) { + gap: var(--spacing-xs); + width: 100%; + justify-content: flex-end; + } } & .chevron { @@ -754,8 +1032,19 @@ button { cursor: pointer; transition: all 0.2s; + @media (max-width: 768px) { + padding: var(--spacing-xs) var(--spacing-sm); + font-size: 0.8125rem; + gap: var(--spacing-xs); + } + & svg { flex-shrink: 0; + + @media (max-width: 768px) { + width: 14px; + height: 14px; + } } &:hover { @@ -825,15 +1114,25 @@ button { /* === MODEL EXPLORER === */ .model-explorer { - height: 85%; + min-height: 85vh; display: flex; flex-direction: column; + padding-top: var(--spacing-md); + + @media (max-width: 768px) { + padding-top: var(--spacing-sm); + } } /* === RESULT PAGE (Query & Preview) === */ .result-page { max-width: 1200px; margin: 0 auto; + padding-top: var(--spacing-md); + + @media (max-width: 768px) { + padding-top: var(--spacing-sm); + } & .result-header { margin-bottom: var(--spacing-xl); @@ -867,6 +1166,11 @@ button { .notebook-container { max-width: 1200px; margin: 0 auto; + padding-top: var(--spacing-md); + + @media (max-width: 768px) { + padding-top: var(--spacing-sm); + } & .notebook-header { display: flex; @@ -1077,6 +1381,29 @@ button { &:hover .code-copy-button { opacity: 1; } + + & pre { + margin: 0; + } + + & .shiki { + margin: 0; + } + + @media (max-width: 768px) { + & pre, + & .shiki { + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + } + + & code { + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + } + } } .code-copy-button { diff --git a/tests/download-utils.test.ts b/tests/download-utils.test.ts new file mode 100644 index 0000000..3525a37 --- /dev/null +++ b/tests/download-utils.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test, vi } from "vitest"; +import { + getModelDownloadUrl, + getNotebookDownloadUrl, + getDataDownloadUrl, + triggerDownload, +} from "../src/download-utils"; + +describe("download-utils", () => { + describe("getModelDownloadUrl", () => { + test("generates correct URL with default base path", () => { + const url = getModelDownloadUrl("my-model"); + expect(url).toContain("downloads/models/my-model.malloy"); + expect(url).toMatch( + /^\/.+\/downloads\/models\/my-model\.malloy$|^\/downloads\/models\/my-model\.malloy$/, + ); + }); + + test("encodes special characters in model name", () => { + const url = getModelDownloadUrl("my model with spaces"); + expect(url).toContain( + "downloads/models/my%20model%20with%20spaces.malloy", + ); + }); + + test("URL structure is correct", () => { + const url = getModelDownloadUrl("test"); + expect(url).toMatch(/downloads\/models\/test\.malloy$/); + }); + }); + + describe("getNotebookDownloadUrl", () => { + test("generates correct URL with default base path", () => { + const url = getNotebookDownloadUrl("my-notebook"); + expect(url).toContain("downloads/notebooks/my-notebook.malloynb"); + expect(url).toMatch(/downloads\/notebooks\/my-notebook\.malloynb$/); + }); + + test("encodes special characters in notebook name", () => { + const url = getNotebookDownloadUrl("my notebook & analysis"); + expect(url).toContain( + "downloads/notebooks/my%20notebook%20%26%20analysis.malloynb", + ); + }); + + test("URL structure is correct", () => { + const url = getNotebookDownloadUrl("notebook"); + expect(url).toMatch(/downloads\/notebooks\/notebook\.malloynb$/); + }); + }); + + describe("getDataDownloadUrl", () => { + test("generates correct URL for data file", () => { + const url = getDataDownloadUrl("data.csv"); + expect(url).toContain("downloads/data/data.csv"); + expect(url).toMatch(/downloads\/data\/data\.csv$/); + }); + + test("preserves file extension", () => { + const url = getDataDownloadUrl("data.parquet"); + expect(url).toContain("downloads/data/data.parquet"); + expect(url).toMatch(/downloads\/data\/data\.parquet$/); + }); + + test("encodes special characters in filename", () => { + const url = getDataDownloadUrl("my data file.csv"); + expect(url).toContain("downloads/data/my%20data%20file.csv"); + }); + + test("URL structure is correct", () => { + const url = getDataDownloadUrl("data.json"); + expect(url).toMatch(/downloads\/data\/data\.json$/); + }); + }); + + describe("triggerDownload", () => { + test("creates anchor element with correct attributes", () => { + // Mock document.createElement + const mockAnchor = { + href: "", + download: "", + click: vi.fn(), + }; + const mockAppendChild = vi.fn(); + const mockRemoveChild = vi.fn(); + const mockCreateElement = vi.fn().mockReturnValue(mockAnchor); + + // Setup global mocks + const originalDocument = global.document; + global.document = { + createElement: mockCreateElement, + body: { + appendChild: mockAppendChild, + removeChild: mockRemoveChild, + }, + } as unknown as Document; + + try { + triggerDownload("/test/url", "test-file.txt"); + + expect(mockCreateElement).toHaveBeenCalledWith("a"); + expect(mockAnchor.href).toBe("/test/url"); + expect(mockAnchor.download).toBe("test-file.txt"); + expect(mockAppendChild).toHaveBeenCalledWith(mockAnchor); + expect(mockAnchor.click).toHaveBeenCalledTimes(1); + expect(mockRemoveChild).toHaveBeenCalledWith(mockAnchor); + } finally { + // Restore + global.document = originalDocument; + } + }); + }); +}); diff --git a/tsconfig.node.json b/tsconfig.node.json index deb7c12..8f8e484 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -32,5 +32,5 @@ "noImplicitThis": true, "noPropertyAccessFromIndexSignature": true }, - "include": ["vite.config.ts", "playwright.config.ts"] + "include": ["vite.config.ts", "playwright.config.ts", "plugins/**/*.ts"] } diff --git a/vite.config.ts b/vite.config.ts index bf25cdd..67534f9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,12 +2,13 @@ import { defineConfig, type UserConfig } from "vite"; import react from "@vitejs/plugin-react"; import svgr from "vite-plugin-svgr"; +import copyDownloadsPlugin from "./plugins/vite-plugin-copy-downloads"; // https://vite.dev/config/ const config: UserConfig = defineConfig({ // NOTE: THIS PATH MUST END WITH A TRAILING SLASH base: process.env["BASE_PUBLIC_PATH"] ?? "/", - plugins: [react(), svgr()], + plugins: [react(), svgr(), copyDownloadsPlugin()], define: { "process.env": {}, },