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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,31 @@ jobs:
TAG=$( [[ "$VERSION" == *-* ]] && echo "--tag next" || echo "" )
pnpm publish --access public --provenance $TAG --no-git-checks || true

upload-xlsx:
needs: upload-engine
runs-on: ubuntu-latest
name: upload xlsx
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- run: npm install -g npm@11
- name: remove authToken from .npmrc
run: |
NPM_RC=$(npm config get userconfig)
sed -i '/_authToken/d' "$NPM_RC" 2>/dev/null || true
- name: publish xlsx
run: |
pnpm install
pnpm --filter "@gridsheet/xlsx..." build
cd packages/xlsx
VERSION=$(node -p "require('./package.json').version")
TAG=$( [[ "$VERSION" == *-* ]] && echo "--tag next" || echo "" )
pnpm publish --access public --provenance $TAG --no-git-checks || true

upload-react-core:
needs: upload-web
runs-on: ubuntu-latest
Expand Down
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ extension. GitHub org: `walkframe`. Docs site deployed to Cloudflare Pages.

## Packages (dependency order)

`engine → web → {react-core, preact-core, functions}; preact-core → vue-core; react-core → react-dev`
`engine → web → {react-core, preact-core, functions}; engine → xlsx; preact-core → vue-core; react-core → react-dev`

| dir | package | what |
|---|---|---|
Expand All @@ -27,6 +27,7 @@ extension. GitHub org: `walkframe`. Docs site deployed to Cloudflare Pages.
| svelte-core | `@gridsheet/svelte-core` | Svelte implementation. |
| react-dev | `@gridsheet/react-dev` | Dev tools for React (`Debugger`). peerDep on web. |
| functions | `@gridsheet/functions` | Extended formula functions. peerDep on web. |
| xlsx | `@gridsheet/xlsx` | xlsx ⇄ GridSheet converter (values + formulas). Headless. peerDep on engine; deps `fflate`. |
| docs | `@gridsheet/docs` (private) | Astro + Starlight docs site. |
| storybook | `@gridsheet/storybook` (private) | Stories; **the e2e target** (served on :5233). |
| vscode-csv-viewer | `csv-gridsheet` (private) | VSCode extension "CSV Spreadsheet — GridSheet". |
Expand Down Expand Up @@ -55,8 +56,9 @@ pnpm test # jest + e2e
- **Playwright (`e2e/`) is the default** for feature/component/interaction tests. Specs drive the
built Storybook; navigate with `go(page, 'basic-simple--sheet')`, select via `[data-address='A1']`
/ `.gs-cell-rendered`.
- **Jest is only for `packages/functions` and `packages/engine`.** Every formula function needs a
Jest `*.spec.ts` (colocated in `src/`).
- **Jest is only for `packages/functions`, `packages/engine`, and `packages/xlsx`.** Every formula
function needs a Jest `*.spec.ts` (colocated in `src/`); xlsx conversion is likewise headless and
Jest-tested (round-trip + interop specs in `src/`).

## Conventions & gotchas

Expand Down
31 changes: 31 additions & 0 deletions e2e/xlsx.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { test, expect } from '@playwright/test';
import { go } from './utils';

test('imports a complex styled xlsx and re-evaluates cross-sheet formulas', async ({ page }) => {
await go(page, 'io-xlsx--converter');
await page.getByTestId('load-sample').click();

const sales = page.getByTestId('sheet-Sales');
const summary = page.getByTestId('sheet-Summary');

// Styled + merged Sales sheet: merged title's value survives (top-left), own formula re-evaluates.
const title = sales.locator("[data-address='A1']");
expect(await title.locator('.gs-cell-rendered').textContent()).toContain('Q3 Sales Report');
expect(await sales.locator("[data-address='D3'] .gs-cell-rendered").textContent()).toContain('4.5');

// Imported cell styles render: dark fill on the cell, white bold text inside.
expect(await title.evaluate((el) => getComputedStyle(el as HTMLElement).backgroundColor)).toBe('rgb(32, 56, 100)');
const titleText = title.locator('.gs-cell-rendered');
expect(await titleText.evaluate((el) => getComputedStyle(el as HTMLElement).color)).toBe('rgb(255, 255, 255)');
expect(await titleText.evaluate((el) => getComputedStyle(el as HTMLElement).fontWeight)).toBe('700');

// Imported column widths render: the wide Product column (A) is clearly wider than
// the narrow Qty column (B).
const colA = await sales.locator("[data-address='A3']").boundingBox();
const colB = await sales.locator("[data-address='B3']").boundingBox();
expect(colA!.width).toBeGreaterThan(colB!.width + 40);

// Summary sheet references other sheets; sharing a book, they resolve live:
// B2 = SUM(Sales!D3:D5) → 17.
expect(await summary.locator("[data-address='B2'] .gs-cell-rendered").textContent()).toContain('17');
});
15 changes: 10 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,22 @@
"build:react-dev": "cd packages/react-dev && pnpm build --mode development",
"build:preact-core": "cd packages/preact-core && pnpm build",
"build:functions": "cd packages/functions && pnpm build",
"build:xlsx": "cd packages/xlsx && pnpm build",
"build:web": "cd packages/web && pnpm build",
"build:engine": "cd packages/engine && pnpm build",
"build:vue-core": "cd packages/vue-core && pnpm build",
"build:svelte-core": "cd packages/svelte-core && pnpm build",
"build:all": "pnpm build:engine && pnpm build:web && pnpm build:react-core && pnpm build:react-dev && pnpm build:preact-core && pnpm build:functions && pnpm build:vue-core && pnpm build:svelte-core",
"build:all": "pnpm build:engine && pnpm build:web && pnpm build:react-core && pnpm build:react-dev && pnpm build:preact-core && pnpm build:functions && pnpm build:xlsx && pnpm build:vue-core && pnpm build:svelte-core",
"typecheck:engine": "cd packages/engine && pnpm typecheck",
"typecheck:react-core": "cd packages/react-core && pnpm typecheck",
"typecheck:all": "pnpm typecheck:engine && pnpm typecheck:react-core",
"typecheck:xlsx": "cd packages/xlsx && pnpm typecheck",
"typecheck:all": "pnpm typecheck:engine && pnpm typecheck:react-core && pnpm typecheck:xlsx",
"generate-style": "cd packages/web && pnpm generate-style",
"e2e": "cd e2e && npx playwright test -c playwright.config.ts --workers 4",
"jest:engine": "cd packages/engine && pnpm jest",
"jest:functions": "cd packages/functions && pnpm jest",
"jest": "pnpm jest:engine && pnpm jest:functions",
"jest:xlsx": "cd packages/xlsx && pnpm jest",
"jest": "pnpm jest:engine && pnpm jest:functions && pnpm jest:xlsx",
"test": "pnpm jest && pnpm e2e",
"prettier": "prettier --write '**/*.{ts,tsx}'",
"clean:cache": "rm -rf packages/storybook/node_modules/.cache/storybook",
Expand All @@ -47,25 +50,27 @@
"remove:react-dev": "cd packages/react-dev && rm -rf node_modules",
"remove:preact-core": "cd packages/preact-core && rm -rf node_modules",
"remove:functions": "cd packages/functions && rm -rf node_modules",
"remove:xlsx": "cd packages/xlsx && rm -rf node_modules",
"remove:storybook": "cd packages/storybook && rm -rf node_modules",
"remove:docs": "cd packages/docs && rm -rf node_modules",
"remove:web": "cd packages/web && rm -rf node_modules",
"remove:engine": "cd packages/engine && rm -rf node_modules",
"remove:vue-core": "cd packages/vue-core && rm -rf node_modules",
"remove:svelte-core": "cd packages/svelte-core && rm -rf node_modules",
"remove:all": "pnpm remove:engine && pnpm remove:web && pnpm remove:react-core && pnpm remove:react-dev && pnpm remove:preact-core && pnpm remove:storybook && pnpm remove:docs && pnpm remove:functions && pnpm remove:vue-core && pnpm remove:svelte-core",
"remove:all": "pnpm remove:engine && pnpm remove:web && pnpm remove:react-core && pnpm remove:react-dev && pnpm remove:preact-core && pnpm remove:storybook && pnpm remove:docs && pnpm remove:functions && pnpm remove:xlsx && pnpm remove:vue-core && pnpm remove:svelte-core",
"install:root": "pnpm install",
"install:react-core": "cd packages/react-core && pnpm install",
"install:react-dev": "cd packages/react-dev && pnpm install",
"install:preact-core": "cd packages/preact-core && pnpm install",
"install:functions": "cd packages/functions && pnpm install",
"install:xlsx": "cd packages/xlsx && pnpm install",
"install:storybook": "cd packages/storybook && pnpm install",
"install:docs": "cd packages/docs && pnpm install",
"install:web": "cd packages/web && pnpm install",
"install:engine": "cd packages/engine && pnpm install",
"install:vue-core": "cd packages/vue-core && pnpm install",
"install:svelte-core": "cd packages/svelte-core && pnpm install",
"install:all": "pnpm install:engine && pnpm install:web && pnpm install:react-core && pnpm install:react-dev && pnpm install:preact-core && pnpm install:functions && pnpm install:storybook && pnpm install:docs && pnpm install:vue-core && pnpm install:svelte-core",
"install:all": "pnpm install:engine && pnpm install:web && pnpm install:react-core && pnpm install:react-dev && pnpm install:preact-core && pnpm install:functions && pnpm install:xlsx && pnpm install:storybook && pnpm install:docs && pnpm install:vue-core && pnpm install:svelte-core",
"reset:all": "pnpm remove:all && pnpm install:all && pnpm build:all && pnpm clean:cache",
"set-version": "node scripts/set-version.mjs",
"delete-tags": "node scripts/delete-tags.mjs"
Expand Down
1 change: 1 addition & 0 deletions packages/storybook/.storybook/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ module.exports = {
{ find: '@gridsheet/engine', replacement: path.resolve(__dirname, '../../engine/src/index.ts') },
{ find: '@gridsheet/preact-core', replacement: path.resolve(__dirname, '../../preact-core/dist/index.js') },
{ find: '@gridsheet/functions', replacement: path.resolve(__dirname, '../../functions/src/index.ts') },
{ find: '@gridsheet/xlsx', replacement: path.resolve(__dirname, '../../xlsx/src/index.ts') },
];
return config;
},
Expand Down
1 change: 1 addition & 0 deletions packages/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@gridsheet/preact-core": "workspace:^",
"@gridsheet/react-core": "workspace:^",
"@gridsheet/react-dev": "workspace:^",
"@gridsheet/xlsx": "workspace:^",
"@storybook/addon-docs": "^8.6.7",
"@storybook/addon-essentials": "^8.6.7",
"@storybook/addon-interactions": "^8.6.7",
Expand Down
5 changes: 5 additions & 0 deletions packages/storybook/stories/io/assets.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Vite serves a bundled asset URL for `?url` imports (e.g. a shipped sample.xlsx).
declare module '*.xlsx?url' {
const url: string;
export default url;
}
Binary file added packages/storybook/stories/io/sample.xlsx
Binary file not shown.
151 changes: 151 additions & 0 deletions packages/storybook/stories/io/xlsx.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { buildInitialCells, GridSheet } from '@gridsheet/react-core';
import { useSpellbook } from '@gridsheet/react-core/spellbook';
import { fromXlsx, toXlsx } from '@gridsheet/xlsx';
import type { XlsxSheetData } from '@gridsheet/xlsx';
import sampleUrl from './sample.xlsx?url';

const meta: Meta = {
title: 'IO/Xlsx',
};
export default meta;

const DESCRIPTION = [
'## xlsx import / export',
'Convert between `.xlsx` and GridSheet with `@gridsheet/xlsx`.',
'',
'- **Load sample** fetches a shipped `sample.xlsx` — a styled, merged, multi-sheet workbook',
" whose `Summary` sheet has **cross-sheet formulas** (`=SUM(Sales!D3:D5)`, `=Sales!D6*'Meta Data'!B3`).",
' Cell styles (background, text color, bold, alignment) import too; merges are dropped;',
' the sheets share a `book` so cross-sheet references re-evaluate.',
'- **Import file** reads a real `.xlsx` you pick.',
'- **Download** writes every sheet back out with `toXlsx`.',
].join('\n');

// Each sheet is the parsed { matrices, cells } — cells carry imported styles.
type Sheets = Record<string, XlsxSheetData>;

const EMPTY: Sheets = {
Sheet1: { matrices: { A1: [['(click “Load sample” or import a file)']] }, cells: {} },
};

const btnStyle: React.CSSProperties = {
padding: '4px 10px',
marginRight: 8,
cursor: 'pointer',
};

// Renders every sheet of a workbook into one shared book so cross-sheet
// formulas resolve. Keyed by import version so each load gets a fresh book.
const Workbook = ({ sheets, refs }: { sheets: Sheets; refs: React.MutableRefObject<Record<string, any>> }) => {
const book = useSpellbook({});
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24 }}>
{Object.entries(sheets).map(([name, data]) => {
const ref = (refs.current[name] ??= { current: null });
return (
<div key={name} data-testid={`sheet-${name}`}>
<h4 style={{ margin: '0 0 4px' }}>{name}</h4>
<GridSheet
book={book}
sheetName={name}
sheetRef={ref}
options={{ sheetWidth: 480, sheetHeight: 220, showFormulaBar: true }}
initialCells={buildInitialCells({
matrices: data.matrices,
// Imported per-cell styles (background/color/weight/alignment) + a default width.
cells: { defaultCol: { width: 120 }, ...data.cells },
ensured: { numRows: 6, numCols: 4 },
})}
/>
</div>
);
})}
</div>
);
};

const XlsxConverter = () => {
const refs = React.useRef<Record<string, any>>({});
const [sheets, setSheets] = React.useState<Sheets>(EMPTY);
// Bump to remount: initialCells is initial-only, so a new import means a fresh book + sheets.
const [version, setVersion] = React.useState(0);

const load = (next: Sheets) => {
refs.current = {};
setSheets(next);
setVersion((v) => v + 1);
};

const loadSample = async () => {
const buf = await (await fetch(sampleUrl)).arrayBuffer();
load(fromXlsx(new Uint8Array(buf)));
};

const importFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
return;
}
load(fromXlsx(new Uint8Array(await file.arrayBuffer())));
e.target.value = '';
};

const download = () => {
const out: Record<string, any> = {};
for (const name of Object.keys(sheets)) {
const handle = refs.current[name]?.current;
if (handle) {
out[name] = handle.sheet;
}
}
if (Object.keys(out).length === 0) {
return;
}
const blob = new Blob([toXlsx(out)], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'gridsheet.xlsx';
a.click();
URL.revokeObjectURL(url);
};

return (
<div>
<div style={{ marginBottom: 8 }}>
<button type="button" data-testid="load-sample" onClick={loadSample} style={btnStyle}>
Load sample
</button>
<label style={{ ...btnStyle, display: 'inline-block' }}>
Import file
<input
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
data-testid="import-file"
onChange={importFile}
style={{ display: 'none' }}
/>
</label>
<button type="button" data-testid="download" onClick={download} style={btnStyle}>
Download
</button>
</div>
<Workbook key={version} sheets={sheets} refs={refs} />
</div>
);
};

export const Converter: StoryObj = {
render: () => <XlsxConverter />,
parameters: {
docs: {
description: {
story: DESCRIPTION,
},
},
},
};
Loading
Loading