Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

- name: Build packages
run: bun run build:packages

- name: Run typecheck
run: bun run typecheck

Expand All @@ -53,6 +56,9 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

- name: Build packages
run: bun run build:packages

- name: Build Storybook
run: bun run build

Expand Down
7 changes: 7 additions & 0 deletions .storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const config: StorybookConfig = {
stories: [
"./docs/**/*.mdx",
"../packages/charts/src/**/*.stories.@(ts|tsx)",
"../packages/charts2/src/**/*.stories.@(ts|tsx)",
"../packages/colours/src/**/*.stories.@(ts|tsx)",
"../packages/components/src/**/*.stories.@(ts|tsx)",
],
Expand Down Expand Up @@ -73,6 +74,12 @@ const config: StorybookConfig = {
"d3-transition",
"d3-zoom",
],
// Keep local workspace packages out of Vite's optimized-deps
// cache so Storybook sees newly added exports immediately.
exclude: [
...(config.optimizeDeps?.exclude || []),
"@buildcanada/components",
],
},
esbuild: {
...config.esbuild,
Expand Down
162 changes: 151 additions & 11 deletions bun.lock

Large diffs are not rendered by default.

26 changes: 25 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
},
"scripts": {
"build": "storybook build -o storybook-static --stats-json",
"build:packages": "bun run build:colours && bun run build:components && bun run build:charts",
"build:packages": "bun run build:colours && bun run build:components && bun run build:charts && bun run build:charts2",
"build:colours": "cd packages/colours && bun run build",
"build:components": "cd packages/components && bun run build",
"build:charts": "cd packages/charts && bun run build",
"build:charts2": "cd packages/charts2 && bun run build",
"charts2": "bun packages/charts2/src/cli/index.ts",
"typecheck": "bun run --filter '*' typecheck",
"test": "bun run --filter '*' test",
"storybook": "storybook dev -p 6006",
Expand Down Expand Up @@ -138,6 +140,7 @@
"@storybook/icons": "^1.4.0",
"@storybook/instrumenter": "^8.6.14",
"@storybook/react": "^10.1.11",
"@storybook/react-dom-shim": "^10.4.4",
"@storybook/react-vite": "^10.1.11",
"@storybook/test": "^8.6.15",
"@swc/helpers": "^0.5.18",
Expand Down Expand Up @@ -167,6 +170,8 @@
"@vitejs/plugin-react": "^5.1.1",
"assert": "^2.1.0",
"bail": "^2.0.2",
"call-bind-apply-helpers": "^1.0.2",
"call-bound": "^1.0.4",
"ccount": "^2.0.1",
"character-entities": "^2.0.2",
"chromatic": "^13.3.5",
Expand Down Expand Up @@ -207,19 +212,30 @@
"d3-transition": "^3.0.1",
"d3-zoom": "^3.0.0",
"dayjs": "^1.11.19",
"debug": "^4.4.3",
"decimal.js": "^10.6.0",
"decode-named-character-reference": "^1.2.0",
"delaunator": "^5.0.1",
"dequal": "^2.0.3",
"devlop": "^1.1.0",
"dunder-proto": "^1.0.1",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.2",
"escape-string-regexp": "^5.0.0",
"estree-util-is-identifier-name": "^3.0.0",
"extend": "^3.0.2",
"flip-toolkit": "^7.2.6",
"fparser": "^4.2.0",
"function-bind": "^1.1.2",
"fuzzysort": "^3.1.0",
"get-intrinsic": "^1.3.0",
"get-nonce": "^1.0.1",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"happy-dom": "^20.3.1",
"has-symbols": "^1.1.0",
"hasown": "^2.0.4",
"hast-util-parse-selector": "^4.0.0",
"hast-util-to-jsx-runtime": "^2.3.6",
"hast-util-whitespace": "^3.0.0",
Expand All @@ -238,6 +254,7 @@
"lodash-es": "^4.17.22",
"lodash.deburr": "^4.1.0",
"markdown-table": "^3.0.4",
"math-intrinsics": "^1.1.0",
"mdast-util-find-and-replace": "^3.0.2",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-to-hast": "^13.2.1",
Expand Down Expand Up @@ -269,6 +286,9 @@
"mobx-react": "^7.6.0",
"mobx-react-lite": "^4.1.1",
"mousetrap": "^1.6.5",
"ms": "^2.1.3",
"object-assign": "^4.1.1",
"object-inspect": "^1.13.4",
"papaparse": "^5.5.3",
"point-in-polygon-hao": "^1.2.4",
"prop-types": "^15.8.1",
Expand Down Expand Up @@ -297,6 +317,10 @@
"sass": "^1.77.0",
"semver": "^7.7.3",
"serve": "^14.2.5",
"side-channel": "^1.1.1",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2",
"simple-statistics": "^7.8.8",
"space-separated-tokens": "^2.0.2",
"storybook": "^10.1.11",
Expand Down
6 changes: 3 additions & 3 deletions packages/charts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@buildcanada/charts",
"version": "0.3.9",
"version": "0.4.0",
"description": "A configurable data visualization library for creating interactive charts.",
"type": "module",
"main": "./dist/index.js",
Expand Down Expand Up @@ -53,8 +53,8 @@
"react-dom": "^19.0.0"
},
"dependencies": {
"@buildcanada/colours": "^0.3.3",
"@buildcanada/components": "^0.3.4",
"@buildcanada/colours": "^0.4.0",
"@buildcanada/components": "^0.4.0",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
Expand Down
178 changes: 178 additions & 0 deletions packages/charts/src/grapher/modal/DownloadModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* @vitest-environment jsdom
*
* Reproduces the "empty preview on initial open" bug in DownloadModalVisTab.
*
* Reported symptom: when the Download modal first mounts, the Image (PNG) and
* Vector graphic (SVG) preview thumbnails are empty. Toggling any checkbox
* (e.g. "Optimize SVG for Wikipedia upload") regenerates the preview correctly.
*
* Both the initial mount and the checkbox toggle call the same
* DownloadModalVisTab#export() method, so any difference between them implies
* that state-timing or initial inputs to rasterize differ on the first call.
*/

import { afterEach, expect, it, vi } from "vitest"
import { act, cleanup, render, waitFor } from "@testing-library/react"
import * as React from "react"

import { Bounds } from "../../utils/index.js"
import {
DownloadModalManager,
DownloadModalTabName,
DownloadModalVisTab,
} from "./DownloadModal.js"
import { GrapherRasterizeFn } from "../captionedChart/StaticChartRasterizer.js"
import { LifeExpectancyGrapher } from "../testData/TestData.sample.js"

const PNG_URL =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII="
const SVG_BLOB_CONTENT =
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>'
const SVG_URL =
"data:image/svg+xml;charset=utf-8;base64," + btoa(SVG_BLOB_CONTENT)

const makePngBlob = (): Blob =>
new Blob([new Uint8Array([0])], { type: "image/png" })
const makeSvgBlob = (): Blob =>
new Blob([SVG_BLOB_CONTENT], { type: "image/svg+xml" })

const makeRasterizeStub = (): GrapherRasterizeFn => {
return vi.fn(async () => ({
url: PNG_URL,
blob: makePngBlob(),
svgUrl: SVG_URL,
svgBlob: makeSvgBlob(),
}))
}

const makeStubManager = (
overrides: Partial<DownloadModalManager> = {}
): DownloadModalManager => {
return {
displaySlug: "test-chart",
rasterize: makeRasterizeStub(),
staticBounds: new Bounds(0, 0, 850, 600),
staticBoundsWithDetails: new Bounds(0, 0, 850, 600),
captionedChartBounds: new Bounds(0, 0, 850, 600),
frameBounds: new Bounds(0, 0, 850, 600),
isOnChartOrMapTab: true,
showAdminControls: true,
baseUrl: "https://example.com/grapher/test",
queryStr: "",
activeDownloadModalTab: DownloadModalTabName.Vis,
...overrides,
} as DownloadModalManager
}

afterEach(() => {
cleanup()
})

// --- Sanity / control: with a stub rasterize that always resolves with a
// valid data URL, both the initial mount and a checkbox toggle paint an
// SVG <img>. This proves the modal's lifecycle works in principle.

it("stub rasterize: SVG preview is populated after initial mount", async () => {
const manager = makeStubManager()
const { container } = render(<DownloadModalVisTab manager={manager} />)

await waitFor(
() => {
const previewImgs = container.querySelectorAll(
".download-modal__download-preview-img img"
)
expect(previewImgs.length).toBeGreaterThanOrEqual(2)
},
{ timeout: 2000 }
)

expect(manager.rasterize).toHaveBeenCalledTimes(1)
const srcs = Array.from(
container.querySelectorAll(".download-modal__download-preview-img img")
).map((img) => img.getAttribute("src"))
expect(srcs.every((s) => !!s && s.startsWith("data:"))).toBe(true)
})

it("stub rasterize: SVG preview is regenerated after toggling the Wikipedia checkbox (control)", async () => {
const manager = makeStubManager()
const { container } = render(<DownloadModalVisTab manager={manager} />)

await waitFor(
() => {
expect(
container.querySelector(".download-modal__download-preview-img")
).toBeTruthy()
},
{ timeout: 2000 }
)

const checkboxes = Array.from(
container.querySelectorAll("input[type=checkbox]")
) as HTMLInputElement[]
const wikiCheckbox = checkboxes.find((cb) => {
const label = cb.closest("label")?.textContent ?? ""
return /Wikipedia/i.test(label)
})
expect(wikiCheckbox, "wikipedia checkbox should exist").toBeTruthy()

await act(async () => {
wikiCheckbox!.click()
})

await waitFor(
() => {
const previewImgs = container.querySelectorAll(
".download-modal__download-preview-img img"
) as NodeListOf<HTMLImageElement>
expect(previewImgs.length).toBeGreaterThanOrEqual(2)
const srcs = Array.from(previewImgs).map((img) =>
img.getAttribute("src")
)
expect(srcs.every((s) => !!s && s.startsWith("data:"))).toBe(true)
},
{ timeout: 2000 }
)
})

// --- Failing test: drive the modal against a real GrapherState (the same
// `rasterize` implementation the app uses). This is the closest we can get
// to the real Chrome code path inside JSDOM. The test asserts the same thing
// the user expects: after the modal mounts, the preview images should be
// painted with non-empty data: URLs.

// Marked `.fails`: this documents a currently-unfixed bug, so it is EXPECTED
// to fail. Vitest passes it while the bug reproduces and will flag it once the
// underlying DownloadModal preview bug is fixed (remove `.fails` then).
it.fails(
"real GrapherState: SVG preview is populated on initial mount (FAILS — reproduces bug)",
async () => {
const grapher = LifeExpectancyGrapher()
const manager = grapher as unknown as DownloadModalManager

const { container } = render(<DownloadModalVisTab manager={manager} />)

// After the export pipeline finishes, the loading indicator should be
// replaced by the preview area with both PNG and SVG <img>s.
await waitFor(
() => {
expect(
container.querySelector(
".download-modal__download-preview-img"
)
).toBeTruthy()
},
{ timeout: 5000 }
)

const previewImgs = container.querySelectorAll(
".download-modal__download-preview-img img"
) as NodeListOf<HTMLImageElement>
expect(previewImgs.length).toBe(2)
const srcs = Array.from(previewImgs).map((img) =>
img.getAttribute("src")
)
expect(srcs.every((s) => !!s && s.startsWith("data:"))).toBe(true)
},
10000
)
6 changes: 5 additions & 1 deletion packages/charts/src/grapher/modal/DownloadModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,11 @@ export class DownloadModalVisTab extends React.Component<DownloadModalProps> {
}

override componentDidMount(): void {
queueMicrotask(() => this.export())
if (typeof requestAnimationFrame !== "undefined") {
requestAnimationFrame(() => this.export())
} else {
queueMicrotask(() => this.export())
}

void canWriteToClipboard().then(
(canWriteToClipboard) =>
Expand Down
3 changes: 3 additions & 0 deletions packages/charts2/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.fonts-cache/
dist/
node_modules/
Loading
Loading