diff --git a/docs/web/docs/.vitepress/config.ts b/docs/web/docs/.vitepress/config.ts index bbf9d3d3..84e13772 100644 --- a/docs/web/docs/.vitepress/config.ts +++ b/docs/web/docs/.vitepress/config.ts @@ -75,6 +75,10 @@ export default defineConfig({ text: "Integration", items: [ { text: "Component Libraries", link: "/featured/libraries" }, + { + text: "AG Grid (No Adapter)", + link: "/featured/ag-grid-no-adapter", + }, { text: "Web Components", link: "/featured/web-components" }, { text: "JSX Support", link: "/featured/jsx" }, { text: "Vue Templates", link: "/featured/vue-templates" }, diff --git a/docs/web/docs/featured/ag-grid-no-adapter.md b/docs/web/docs/featured/ag-grid-no-adapter.md new file mode 100644 index 00000000..c26f7629 --- /dev/null +++ b/docs/web/docs/featured/ag-grid-no-adapter.md @@ -0,0 +1,97 @@ +--- +title: AG Grid (No Adapter) +description: Integrate AG Grid directly in Inglorious Web without a dedicated adapter package. +--- + +# AG Grid (No Adapter) + +If your team prefers zero abstraction, you can mount AG Grid directly from an Inglorious Web type. + +## When this is enough + +- You only have one or two grid screens. +- You want full direct access to AG Grid API and options. +- You do not need a reusable adapter layer. + +## Minimal direct integration + +```js +import { html, ref } from "@inglorious/web" +import { + AllCommunityModule, + createGrid, + ModuleRegistry, +} from "ag-grid-community" + +ModuleRegistry.registerModules([AllCommunityModule]) + +const gridInstances = new Map() + +export const gridType = { + create(entity) { + entity.themeClass ??= "ag-theme-quartz" + entity.height ??= 520 + entity.rowData ??= [] + entity.columnDefs ??= [] + }, + + render(entity) { + const height = + typeof entity.height === "number" ? `${entity.height}px` : entity.height + + return html` +
{ + if (!el) return + + const current = gridInstances.get(entity.id) + if (!current) { + const api = createGrid(el, { + rowData: entity.rowData, + columnDefs: entity.columnDefs, + }) + gridInstances.set(entity.id, { api, el }) + return + } + + if (current.el !== el) { + current.api.destroy() + const api = createGrid(el, { + rowData: entity.rowData, + columnDefs: entity.columnDefs, + }) + gridInstances.set(entity.id, { api, el }) + return + } + + current.api.updateGridOptions({ + rowData: entity.rowData, + columnDefs: entity.columnDefs, + }) + })} + >
+ ` + }, + + destroy(entity) { + const current = gridInstances.get(entity.id) + if (!current) return + current.api.destroy() + gridInstances.delete(entity.id) + }, +} +``` + +## CSS imports + +```js +import "ag-grid-community/styles/ag-grid.css" +import "ag-grid-community/styles/ag-theme-quartz.css" +``` + +## Trade-off vs adapter package + +- Direct recipe: less code upfront, maximum control. +- Adapter package: reusable lifecycle patterns and consistent event surface across apps. diff --git a/docs/web/docs/featured/overview.md b/docs/web/docs/featured/overview.md index fc1c09e5..7bfb09c7 100644 --- a/docs/web/docs/featured/overview.md +++ b/docs/web/docs/featured/overview.md @@ -184,8 +184,10 @@ Inglorious Web plays nicely with: - **Web Components** — Shoelace, Material Web Components, etc. - **Design Systems** — Any CSS framework - **Specialized Libraries** — Date pickers, rich editors, etc. +- **Data Grids** — AG Grid can be integrated directly (no adapter required) **[Learn more →](./web-components.md)** +**[AG Grid recipe (no adapter) →](./ag-grid-no-adapter.md)** ## Ecosystem Packages diff --git a/examples/apps/web-ag-grid/eslint.config.js b/examples/apps/web-ag-grid/eslint.config.js new file mode 100644 index 00000000..3f045e7e --- /dev/null +++ b/examples/apps/web-ag-grid/eslint.config.js @@ -0,0 +1 @@ +export { default } from "@inglorious/eslint-config/browser" diff --git a/examples/apps/web-ag-grid/index.html b/examples/apps/web-ag-grid/index.html new file mode 100644 index 00000000..676f50ef --- /dev/null +++ b/examples/apps/web-ag-grid/index.html @@ -0,0 +1,14 @@ + + + + + + + web-ag-grid + + + + +
+ + diff --git a/examples/apps/web-ag-grid/package.json b/examples/apps/web-ag-grid/package.json new file mode 100644 index 00000000..2b5e66bd --- /dev/null +++ b/examples/apps/web-ag-grid/package.json @@ -0,0 +1,27 @@ +{ + "name": "web-ag-grid", + "private": true, + "version": "0.1.0", + "type": "module", + "author": "IceOnFire (https://ingloriouscoderz.it)", + "license": "MIT", + "scripts": { + "format": "prettier --write .", + "lint": "eslint .", + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@inglorious/ag-grid": "workspace:*", + "@inglorious/web": "workspace:*", + "ag-grid-community": "^34.3.1" + }, + "devDependencies": { + "@inglorious/eslint-config": "workspace:*", + "eslint": "^9.39.1", + "prettier": "^3.6.2", + "rollup-plugin-minify-template-literals": "^1.1.7", + "vite": "^7.1.6" + } +} diff --git a/examples/apps/web-ag-grid/public/vite.svg b/examples/apps/web-ag-grid/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/examples/apps/web-ag-grid/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/apps/web-ag-grid/src/app.js b/examples/apps/web-ag-grid/src/app.js new file mode 100644 index 00000000..7fe99421 --- /dev/null +++ b/examples/apps/web-ag-grid/src/app.js @@ -0,0 +1,42 @@ +import { html } from "@inglorious/web" + +export const app = { + render(api) { + const grid = api.getEntity("agGrid") + + return html` +
+
+

AG Grid Demo (Inglorious)

+

+ The app re-renders often, but the grid instance stays alive and only + receives incremental updates. +

+
+ +
+ + + + +
+ +
+
+ Entity: ${grid.id} + Render Tick: ${grid.tickCount} + Grid API ID: ${grid.gridApiId ?? "pending"} + Status: ${grid.gridStatus} +
+ ${api.render("agGrid")} +
+
+ ` + }, +} diff --git a/examples/apps/web-ag-grid/src/main.js b/examples/apps/web-ag-grid/src/main.js new file mode 100644 index 00000000..67dd33e2 --- /dev/null +++ b/examples/apps/web-ag-grid/src/main.js @@ -0,0 +1,22 @@ +import "ag-grid-community/styles/ag-grid.css" +import "ag-grid-community/styles/ag-theme-quartz.css" + +import { configureAgGrid } from "@inglorious/ag-grid" +import { mount } from "@inglorious/web" +import { + AllCommunityModule, + createGrid, + ModuleRegistry, +} from "ag-grid-community" + +import { app } from "./app.js" +import { store } from "./store/index.js" + +configureAgGrid({ + createGrid, + registerModules() { + ModuleRegistry.registerModules([AllCommunityModule]) + }, +}) + +mount(store, app.render, document.getElementById("root")) diff --git a/examples/apps/web-ag-grid/src/store/entities.js b/examples/apps/web-ag-grid/src/store/entities.js new file mode 100644 index 00000000..c552a1bc --- /dev/null +++ b/examples/apps/web-ag-grid/src/store/entities.js @@ -0,0 +1,104 @@ +class MyCellComponent { + eGui + eButton + eventListener + + init(params) { + this.eGui = document.createElement("div") + this.eGui.innerHTML = + ` + + ` + params.value + this.eButton = this.eGui.querySelector("button") + this.eventListener = () => alert(`Value: ${params.value}`) + this.eButton.addEventListener("click", this.eventListener) + } + + getGui() { + return this.eGui + } + + refresh() { + return true + } + + destroy() { + this.eButton.removeEventListener("click", this.eventListener) + } +} + +export const entities = { + agGrid: { + type: "grid", + title: "Sales Data", + tickCount: 0, + rowIdField: "id", + themeClass: "ag-theme-quartz", + height: 520, + columnDefs: [ + { field: "id", maxWidth: 100 }, + { field: "product", cellRenderer: MyCellComponent }, + { field: "category" }, + { field: "country" }, + { + field: "revenue", + valueFormatter: (p) => "€" + p.value.toLocaleString(), + }, + { field: "price", valueFormatter: (p) => "€" + p.value.toLocaleString() }, + { field: "rating" }, + { field: "growth", valueFormatter: ({ value }) => `${value}%` }, + ], + rowData: [ + { + id: 1, + product: "Forge Cloud", + category: "Software", + country: "Italy", + revenue: 120000, + price: 120, + rating: 4.7, + growth: 8.1, + }, + { + id: 2, + product: "Forge Beam", + category: "Hardware", + country: "Brazil", + revenue: 98000, + price: 98, + rating: 4.4, + growth: 5.4, + }, + { + id: 3, + product: "Forge Shield", + category: "Software", + country: "United States", + revenue: 154000, + price: 154, + rating: 4.9, + growth: 10.2, + }, + { + id: 4, + product: "Forge Arc", + category: "Hardware", + country: "Germany", + revenue: 76000, + price: 76, + rating: 4.1, + growth: 3.6, + }, + { + id: 5, + product: "Forge Loop", + category: "Software", + country: "Japan", + revenue: 143000, + price: 143, + rating: 4.8, + growth: 9.4, + }, + ], + }, +} diff --git a/examples/apps/web-ag-grid/src/store/index.js b/examples/apps/web-ag-grid/src/store/index.js new file mode 100644 index 00000000..34cfeaac --- /dev/null +++ b/examples/apps/web-ag-grid/src/store/index.js @@ -0,0 +1,12 @@ +import { agGrid } from "@inglorious/ag-grid" +import { createStore } from "@inglorious/web" + +import { gridDemo } from "../types/grid-demo/index.js" +import { entities } from "./entities.js" +import { middlewares } from "./middlewares.js" + +export const store = createStore({ + types: { grid: [agGrid, gridDemo] }, + entities, + middlewares, +}) diff --git a/examples/apps/web-ag-grid/src/store/middlewares.js b/examples/apps/web-ag-grid/src/store/middlewares.js new file mode 100644 index 00000000..3fd34bab --- /dev/null +++ b/examples/apps/web-ag-grid/src/store/middlewares.js @@ -0,0 +1,7 @@ +import { createDevtools } from "@inglorious/web" + +export const middlewares = [] + +if (import.meta.env.DEV) { + middlewares.push(createDevtools().middleware) +} diff --git a/examples/apps/web-ag-grid/src/style.css b/examples/apps/web-ag-grid/src/style.css new file mode 100644 index 00000000..3609c0e3 --- /dev/null +++ b/examples/apps/web-ag-grid/src/style.css @@ -0,0 +1,69 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: #f2f3f6; + color: #121826; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; +} + +.app-shell { + max-width: 1280px; + margin: 0 auto; + padding: 24px; +} + +.app-header { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 20px; + margin-bottom: 16px; +} + +.app-header h1 { + margin: 0 0 8px; +} + +.app-header p { + margin: 0; + color: #4b5563; +} + +.controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 16px; +} + +.controls button { + border: 1px solid #c7ced8; + background: #fff; + border-radius: 8px; + padding: 8px 12px; + cursor: pointer; +} + +.controls button:hover { + background: #f9fafb; +} + +.demo-main { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 12px; +} + +.iw-ag-grid-meta { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 10px; + font-size: 14px; +} diff --git a/examples/apps/web-ag-grid/src/types/grid-demo/handlers.js b/examples/apps/web-ag-grid/src/types/grid-demo/handlers.js new file mode 100644 index 00000000..000cc63b --- /dev/null +++ b/examples/apps/web-ag-grid/src/types/grid-demo/handlers.js @@ -0,0 +1,98 @@ +/* eslint-disable no-magic-numbers */ +const PRICE_BUMP = 15 + +/** + * @typedef {Record & { + * tickCount?: number + * rowIdField?: string + * rowData: Record[] + * }} GridDemoEntity + */ + +/** + * Initializes demo-specific counters. + * @param {GridDemoEntity} entity + */ +export function create(entity) { + entity.tickCount ??= 0 +} + +/** + * Increments the render tick counter for the demo UI. + * @param {GridDemoEntity} entity + */ +export function tick(entity) { + entity.tickCount += 1 +} + +/** + * Shuffles rows to showcase incremental row updates. + * @param {GridDemoEntity} entity + */ +export function shuffleRows(entity) { + const nextRows = [...entity.rowData] + for (let i = nextRows.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)) + const current = nextRows[i] + nextRows[i] = nextRows[j] + nextRows[j] = current + } + entity.rowData = nextRows +} + +/** + * Applies a simple price/rating mutation used by demo controls. + * @param {GridDemoEntity} entity + */ +export function bumpPrices(entity) { + entity.rowData = entity.rowData.map((row) => ({ + ...row, + price: typeof row.price === "number" ? row.price + PRICE_BUMP : row.price, + rating: + typeof row.rating === "number" + ? Number((row.rating + Math.random()).toFixed(1)) + : row.rating, + })) +} + +/** + * Appends a synthetic row based on the last row shape. + * @param {GridDemoEntity} entity + */ +export function addRow(entity) { + const idField = entity.rowIdField || "id" + const nextId = entity.rowData.length + 1 + const previousRow = entity.rowData.at(-1) || {} + const nextRow = {} + + const entries = Object.entries(previousRow) + if (!entries.length) { + nextRow[idField] = nextId + } + + for (const [key, value] of entries) { + if (key === idField) { + nextRow[key] = nextId + continue + } + + if (typeof value === "number") { + const randomFactor = 0.9 + Math.random() * 0.2 + nextRow[key] = Number((value * randomFactor).toFixed(2)) + continue + } + + if (typeof value === "string") { + nextRow[key] = `${value} ${nextId}` + continue + } + + nextRow[key] = value + } + + if (!(idField in nextRow)) { + nextRow[idField] = nextId + } + + entity.rowData = [...entity.rowData, nextRow] +} diff --git a/examples/apps/web-ag-grid/src/types/grid-demo/index.js b/examples/apps/web-ag-grid/src/types/grid-demo/index.js new file mode 100644 index 00000000..3797ad93 --- /dev/null +++ b/examples/apps/web-ag-grid/src/types/grid-demo/index.js @@ -0,0 +1,6 @@ +import * as handlers from "./handlers.js" + +/** Demo-only behavior layered on top of the base `agGrid` adapter. */ +export const gridDemo = { + ...handlers, +} diff --git a/examples/apps/web-ag-grid/vite.config.js b/examples/apps/web-ag-grid/vite.config.js new file mode 100644 index 00000000..0d05b7dd --- /dev/null +++ b/examples/apps/web-ag-grid/vite.config.js @@ -0,0 +1,22 @@ +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { minifyTemplateLiterals } from "rollup-plugin-minify-template-literals" +import { defineConfig } from "vite" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export default defineConfig({ + build: { + rollupOptions: { + plugins: [minifyTemplateLiterals()], + }, + }, + + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, +}) diff --git a/packages/ag-grid/CHANGELOG.md b/packages/ag-grid/CHANGELOG.md new file mode 100644 index 00000000..92dbcb1b --- /dev/null +++ b/packages/ag-grid/CHANGELOG.md @@ -0,0 +1,5 @@ +# @inglorious/ag-grid + +## 0.1.0 + +- Initial extraction from `@inglorious/web` as a standalone package. diff --git a/packages/ag-grid/README.md b/packages/ag-grid/README.md new file mode 100644 index 00000000..5252bc51 --- /dev/null +++ b/packages/ag-grid/README.md @@ -0,0 +1,86 @@ +# @inglorious/ag-grid + +AG Grid integration for Inglorious Web. + +This package is intentionally thin: it provides a generic `agGrid` type and minimal adapter CSS. +Keep app-specific data mutations and domain handlers in your app types via composition. + +## Install + +```bash +pnpm add @inglorious/ag-grid @inglorious/web ag-grid-community +``` + +## Usage + +```js +import { createStore } from "@inglorious/web" +import { agGrid, configureAgGrid } from "@inglorious/ag-grid" +import { + AllCommunityModule, + createGrid, + ModuleRegistry, +} from "ag-grid-community" + +configureAgGrid({ + createGrid, + registerModules() { + ModuleRegistry.registerModules([AllCommunityModule]) + }, +}) + +const store = createStore({ + types: { + grid: agGrid, + }, + entities: { + agGrid: { + type: "grid", + columnDefs: [], + rowData: [], + }, + }, +}) +``` + +```js +import "ag-grid-community/styles/ag-grid.css" +import "ag-grid-community/styles/ag-theme-quartz.css" +``` + +Set sizing from entity state: + +```js +{ + type: "grid", + themeClass: "ag-theme-quartz", + height: 520, // number (px) or CSS string like "70vh" + gridOptions: { + suppressCellFocus: true, + }, + columnDefs: [], + rowData: [], +} +``` + +For AG Grid Enterprise, import enterprise modules and license setup in your app, then pass the enterprise `createGrid` and module registration via `configureAgGrid(...)`. + +## Event handlers + +The adapter keeps a small event surface: + +- `mounted` updates runtime status when the grid instance is ready. +- `rowDataChange` replaces `entity.rowData`. +- `columnDefsChange` replaces `entity.columnDefs`. +- `gridOptionsChange` replaces `entity.gridOptions`. +- `apiCall` calls a grid API method by name: + +```js +api.notify("#agGrid:apiCall", { method: "sizeColumnsToFit" }) +api.notify("#agGrid:apiCall", { + method: "setFilterModel", + args: [{ id: null }], +}) +``` + +Backward-compatible aliases still exist (`gridMounted`, `setRowData`, `setColumnDefs`, `setGridOptions`). diff --git a/packages/ag-grid/eslint.config.js b/packages/ag-grid/eslint.config.js new file mode 100644 index 00000000..3f045e7e --- /dev/null +++ b/packages/ag-grid/eslint.config.js @@ -0,0 +1 @@ +export { default } from "@inglorious/eslint-config/browser" diff --git a/packages/ag-grid/package.json b/packages/ag-grid/package.json new file mode 100644 index 00000000..9f569282 --- /dev/null +++ b/packages/ag-grid/package.json @@ -0,0 +1,56 @@ +{ + "name": "@inglorious/ag-grid", + "version": "0.1.0", + "description": "AG Grid integration for Inglorious Web.", + "author": "IceOnFire (https://ingloriouscoderz.it)", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/IngloriousCoderz/inglorious-forge.git", + "directory": "packages/ag-grid" + }, + "bugs": { + "url": "https://github.com/IngloriousCoderz/inglorious-forge/issues" + }, + "keywords": [ + "ag-grid", + "data-grid", + "inglorious-web" + ], + "type": "module", + "exports": { + ".": { + "types": "./types/index.d.ts", + "import": "./src/index.js" + } + }, + "files": [ + "src", + "types" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "format": "prettier --write .", + "lint": "eslint .", + "test:watch": "vitest", + "test": "vitest run" + }, + "dependencies": { + "@inglorious/web": "workspace:*" + }, + "peerDependencies": { + "@inglorious/web": "workspace:*", + "ag-grid-community": "^34.3.1" + }, + "devDependencies": { + "@inglorious/eslint-config": "workspace:*", + "prettier": "^3.6.2", + "vitest": "^4.0.15" + }, + "packageManager": "pnpm@10.15.0", + "engines": { + "node": ">= 22" + } +} diff --git a/packages/ag-grid/src/defaults.js b/packages/ag-grid/src/defaults.js new file mode 100644 index 00000000..6a785960 --- /dev/null +++ b/packages/ag-grid/src/defaults.js @@ -0,0 +1,12 @@ +/** Default AG Grid theme class used by the adapter. */ +export const DEFAULT_THEME_CLASS = "ag-theme-quartz" + +/** Default column definition merged into `entity.defaultColDef`. */ +export const DEFAULT_COL_DEF = { + filter: true, + floatingFilter: true, + sortable: true, + resizable: true, + flex: 1, + minWidth: 120, +} diff --git a/packages/ag-grid/src/handlers.js b/packages/ag-grid/src/handlers.js new file mode 100644 index 00000000..cd0ee447 --- /dev/null +++ b/packages/ag-grid/src/handlers.js @@ -0,0 +1,96 @@ +import { DEFAULT_COL_DEF, DEFAULT_THEME_CLASS } from "./defaults.js" +import { callGridApi, destroyGrid } from "./runtime.js" + +/** + * @typedef {Record & { + * id: string + * rowIdField?: string + * themeClass?: string + * height?: number | string + * defaultColDef?: Record + * rowData?: any[] + * columnDefs?: any[] + * gridOptions?: Record + * gridApiId?: string | number | null + * gridStatus?: string + * }} AgGridEntity + */ + +/** + * Initializes an AG Grid entity with adapter defaults. + * @param {AgGridEntity} entity + */ +export function create(entity) { + entity.rowIdField ??= "id" + entity.themeClass ??= DEFAULT_THEME_CLASS + entity.height ??= 520 + entity.defaultColDef = { + ...DEFAULT_COL_DEF, + ...(entity.defaultColDef || {}), + } + entity.gridApiId ??= null + entity.gridStatus ??= "mounting" + entity.rowData ??= [] + entity.columnDefs ??= [] + entity.gridOptions ??= {} +} + +/** + * Marks runtime mount completion and stores a stable API id. + * @param {AgGridEntity} entity + * @param {{ gridApiId?: string | number }} [payload] + */ +export function mounted(entity, payload) { + entity.gridApiId = payload?.gridApiId ?? entity.gridApiId + entity.gridStatus = "mounted" +} + +/** + * Replaces the grid row data. + * @param {AgGridEntity} entity + * @param {any[]} rowData + */ +export function rowDataChange(entity, rowData) { + entity.rowData = rowData +} + +/** + * Replaces the grid column definitions. + * @param {AgGridEntity} entity + * @param {any[]} columnDefs + */ +export function columnDefsChange(entity, columnDefs) { + entity.columnDefs = columnDefs +} + +/** + * Replaces passthrough AG Grid options. + * @param {AgGridEntity} entity + * @param {Record} gridOptions + */ +export function gridOptionsChange(entity, gridOptions) { + entity.gridOptions = gridOptions +} + +/** + * Invokes a grid API method by name. + * @param {AgGridEntity} entity + * @param {string | { method: string, args?: any[] }} payload + */ +export function apiCall(entity, payload) { + callGridApi(entity.id, payload) +} + +// Backward-compatible aliases. +export const gridMounted = mounted +export const setRowData = rowDataChange +export const setColumnDefs = columnDefsChange +export const setGridOptions = gridOptionsChange + +/** + * Disposes runtime resources for this entity. + * @param {AgGridEntity} entity + */ +export function destroy(entity) { + destroyGrid(entity.id) +} diff --git a/packages/ag-grid/src/handlers.test.js b/packages/ag-grid/src/handlers.test.js new file mode 100644 index 00000000..b135d5c6 --- /dev/null +++ b/packages/ag-grid/src/handlers.test.js @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest" + +import { + apiCall, + columnDefsChange, + create, + gridMounted, + gridOptionsChange, + mounted, + rowDataChange, + setColumnDefs, + setGridOptions, + setRowData, +} from "./handlers.js" + +describe("handlers", () => { + it("initializes default entity values", () => { + const entity = { id: "grid-1" } + + create(entity) + + expect(entity.rowIdField).toBe("id") + expect(entity.themeClass).toBe("ag-theme-quartz") + expect(entity.height).toBe(520) + expect(entity.gridStatus).toBe("mounting") + expect(entity.rowData).toEqual([]) + expect(entity.columnDefs).toEqual([]) + expect(entity.gridOptions).toEqual({}) + expect(entity.defaultColDef).toEqual( + expect.objectContaining({ + filter: true, + floatingFilter: true, + sortable: true, + }), + ) + }) + + it("keeps custom defaults when provided", () => { + const entity = { + id: "grid-1", + rowIdField: "customId", + themeClass: "ag-theme-alpine", + height: "70vh", + defaultColDef: { sortable: false, minWidth: 200 }, + rowData: [{ id: 1 }], + columnDefs: [{ field: "id" }], + gridOptions: { suppressCellFocus: true }, + } + + create(entity) + + expect(entity.rowIdField).toBe("customId") + expect(entity.themeClass).toBe("ag-theme-alpine") + expect(entity.height).toBe("70vh") + expect(entity.defaultColDef.sortable).toBe(false) + expect(entity.defaultColDef.minWidth).toBe(200) + expect(entity.rowData).toEqual([{ id: 1 }]) + expect(entity.columnDefs).toEqual([{ field: "id" }]) + expect(entity.gridOptions).toEqual({ suppressCellFocus: true }) + }) + + it("applies mounted and data change handlers", () => { + const entity = { id: "grid-1", gridStatus: "mounting" } + const rowData = [{ id: 1 }] + const columnDefs = [{ field: "id" }] + const gridOptions = { suppressMovableColumns: true } + + mounted(entity, { gridApiId: 7 }) + rowDataChange(entity, rowData) + columnDefsChange(entity, columnDefs) + gridOptionsChange(entity, gridOptions) + + expect(entity.gridApiId).toBe(7) + expect(entity.gridStatus).toBe("mounted") + expect(entity.rowData).toBe(rowData) + expect(entity.columnDefs).toBe(columnDefs) + expect(entity.gridOptions).toBe(gridOptions) + }) + + it("exposes backward-compatible aliases", () => { + expect(gridMounted).toBe(mounted) + expect(setRowData).toBe(rowDataChange) + expect(setColumnDefs).toBe(columnDefsChange) + expect(setGridOptions).toBe(gridOptionsChange) + }) + + it("apiCall does not throw when grid does not exist", () => { + const entity = { id: "missing-grid" } + expect(() => apiCall(entity, { method: "sizeColumnsToFit" })).not.toThrow() + }) +}) diff --git a/packages/ag-grid/src/index.js b/packages/ag-grid/src/index.js new file mode 100644 index 00000000..81cced4b --- /dev/null +++ b/packages/ag-grid/src/index.js @@ -0,0 +1,12 @@ +import * as handlers from "./handlers.js" +export { configureAgGrid } from "./runtime-config.js" +import { render } from "./template.js" + +/** + * Thin AG Grid adapter type for Inglorious Web. + * Compose app-specific behaviors on top of this base type. + */ +export const agGrid = { + ...handlers, + render, +} diff --git a/packages/ag-grid/src/runtime-config.js b/packages/ag-grid/src/runtime-config.js new file mode 100644 index 00000000..50c7d569 --- /dev/null +++ b/packages/ag-grid/src/runtime-config.js @@ -0,0 +1,49 @@ +let runtimeConfig = null + +/** + * @typedef {{ + * createGrid: (element: HTMLElement, options: object) => any + * registerModules?: () => void + * }} AgGridRuntimeConfig + */ + +/** + * Configures the AG Grid runtime used by the adapter. + * + * @param {AgGridRuntimeConfig} config + */ +export function configureAgGrid(config) { + if (!config || typeof config.createGrid !== "function") { + throw new TypeError( + "configureAgGrid(config) requires a createGrid function.", + ) + } + + if ( + config.registerModules !== undefined && + typeof config.registerModules !== "function" + ) { + throw new TypeError( + "configureAgGrid(config) registerModules must be a function when provided.", + ) + } + + runtimeConfig = { + createGrid: config.createGrid, + registerModules: config.registerModules || null, + } +} + +/** + * Returns the configured AG Grid runtime. + * Throws when runtime has not been configured yet. + * + * @returns {{ createGrid: (element: HTMLElement, options: object) => any, registerModules: (() => void) | null }} + */ +export function getAgGridRuntimeConfig() { + if (runtimeConfig) return runtimeConfig + + throw new Error( + "AG Grid runtime is not configured. Call configureAgGrid({ createGrid, registerModules? }) before mounting your app.", + ) +} diff --git a/packages/ag-grid/src/runtime-config.test.js b/packages/ag-grid/src/runtime-config.test.js new file mode 100644 index 00000000..9fd13942 --- /dev/null +++ b/packages/ag-grid/src/runtime-config.test.js @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest" + +import { configureAgGrid, getAgGridRuntimeConfig } from "./runtime-config.js" + +describe("runtime-config", () => { + it("validates required createGrid function", () => { + expect(() => configureAgGrid()).toThrow(/requires a createGrid function/) + expect(() => configureAgGrid({})).toThrow(/requires a createGrid function/) + }) + + it("validates optional registerModules function", () => { + expect(() => + configureAgGrid({ + createGrid: () => ({}), + registerModules: "nope", + }), + ).toThrow(/registerModules must be a function/) + }) + + it("stores runtime configuration", () => { + const createGrid = () => ({}) + const registerModules = () => {} + + configureAgGrid({ + createGrid, + registerModules, + }) + + expect(getAgGridRuntimeConfig()).toEqual({ + createGrid, + registerModules, + }) + }) +}) diff --git a/packages/ag-grid/src/runtime.js b/packages/ag-grid/src/runtime.js new file mode 100644 index 00000000..bd2413cb --- /dev/null +++ b/packages/ag-grid/src/runtime.js @@ -0,0 +1,164 @@ +/* eslint-disable no-magic-numbers */ +import { getAgGridRuntimeConfig } from "./runtime-config.js" + +const gridInstances = new Map() +const apiIds = new WeakMap() +let nextApiId = 1 +let modulesRegistered = false + +/** + * @typedef {Record & { + * id: string + * rowIdField?: string + * defaultColDef?: Record + * rowData?: any[] + * columnDefs?: any[] + * gridOptions?: Record + * }} AgGridEntity + */ + +/** + * @typedef {{ + * api: any + * element: HTMLElement + * lastColumnDefs: any[] | undefined + * lastGridOptions: Record | undefined + * lastRowData: any[] | undefined + * lastDefaultColDef: Record | undefined + * }} GridInstance + */ + +/** + * Mounts a grid instance for an entity, or updates the existing instance. + * @param {AgGridEntity} entity + * @param {HTMLElement} element + * @param {{ notify: (event: string, payload?: any) => void }} api + */ +export function mountOrUpdateGrid(entity, element, api) { + registerModulesOnce() + + const existing = gridInstances.get(entity.id) + + if (!existing || existing.element !== element) { + if (existing) { + existing.api.destroy() + gridInstances.delete(entity.id) + } + + const gridApi = getAgGridRuntimeConfig().createGrid( + element, + buildGridOptions(entity), + ) + + gridInstances.set(entity.id, { + api: gridApi, + element, + lastColumnDefs: entity.columnDefs, + lastGridOptions: entity.gridOptions, + lastRowData: entity.rowData, + lastDefaultColDef: entity.defaultColDef, + }) + + const entityId = entity.id + queueMicrotask(() => { + api.notify(`#${entityId}:mounted`, { + gridApiId: getApiId(gridApi), + }) + }) + return + } + + syncGrid(existing, entity) +} + +/** + * Destroys grid runtime state for the provided entity id. + * @param {string} entityId + */ +export function destroyGrid(entityId) { + const instance = gridInstances.get(entityId) + if (!instance) return + instance.api.destroy() + gridInstances.delete(entityId) +} + +function registerModulesOnce() { + if (modulesRegistered) return + getAgGridRuntimeConfig().registerModules?.() + modulesRegistered = true +} + +/** + * Builds AG Grid options from entity state and passthrough options. + * @param {AgGridEntity} entity + * @returns {Record} + */ +function buildGridOptions(entity) { + const options = entity.gridOptions || {} + + return { + ...options, + defaultColDef: entity.defaultColDef, + animateRows: options.animateRows ?? true, + rowData: entity.rowData, + columnDefs: entity.columnDefs, + getRowId: + options.getRowId || + ((params) => String(params.data?.[entity.rowIdField])), + } +} + +/** + * Applies incremental grid updates when tracked entity fields change. + * @param {GridInstance} instance + * @param {AgGridEntity} entity + */ +function syncGrid(instance, entity) { + const hasChanges = + instance.lastDefaultColDef !== entity.defaultColDef || + instance.lastGridOptions !== entity.gridOptions || + instance.lastRowData !== entity.rowData || + instance.lastColumnDefs !== entity.columnDefs + + if (!hasChanges) return + + instance.api.updateGridOptions(buildGridOptions(entity)) + + instance.lastDefaultColDef = entity.defaultColDef + instance.lastGridOptions = entity.gridOptions + instance.lastRowData = entity.rowData + instance.lastColumnDefs = entity.columnDefs +} + +/** + * Invokes a method on the underlying AG Grid API. + * @param {string} entityId + * @param {string | { method: string, args?: any[] }} payload + */ +export function callGridApi(entityId, payload) { + const instance = gridInstances.get(entityId) + if (!instance || !instance.api) return + + const method = typeof payload === "string" ? payload : payload?.method + if (!method || typeof instance.api[method] !== "function") return + + const args = + typeof payload === "object" && Array.isArray(payload.args) + ? payload.args + : [] + instance.api[method](...args) +} + +/** + * Returns a stable numeric identifier for a grid API instance. + * @param {any} api + * @returns {number} + */ +function getApiId(api) { + if (!apiIds.has(api)) { + apiIds.set(api, nextApiId) + nextApiId += 1 + } + + return apiIds.get(api) +} diff --git a/packages/ag-grid/src/runtime.test.js b/packages/ag-grid/src/runtime.test.js new file mode 100644 index 00000000..29b8057b --- /dev/null +++ b/packages/ag-grid/src/runtime.test.js @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest" + +import { callGridApi, destroyGrid, mountOrUpdateGrid } from "./runtime.js" +import { configureAgGrid } from "./runtime-config.js" + +function createEntity(id) { + return { + id, + rowIdField: "id", + defaultColDef: { sortable: true }, + rowData: [{ id: 1, name: "Alpha" }], + columnDefs: [{ field: "name" }], + gridOptions: { suppressCellFocus: true }, + } +} + +describe("runtime", () => { + it("mounts grid and emits mounted event", async () => { + const createGrid = vi.fn(() => ({ + destroy: vi.fn(), + updateGridOptions: vi.fn(), + })) + const registerModules = vi.fn() + const notify = vi.fn() + const entity = createEntity("grid-runtime-1") + const element = {} + + configureAgGrid({ createGrid, registerModules }) + mountOrUpdateGrid(entity, element, { notify }) + await Promise.resolve() + + expect(registerModules).toHaveBeenCalledTimes(1) + expect(createGrid).toHaveBeenCalledTimes(1) + expect(createGrid).toHaveBeenCalledWith( + element, + expect.objectContaining({ + defaultColDef: entity.defaultColDef, + rowData: entity.rowData, + columnDefs: entity.columnDefs, + suppressCellFocus: true, + }), + ) + expect(notify).toHaveBeenCalledWith("#grid-runtime-1:mounted", { + gridApiId: expect.any(Number), + }) + }) + + it("updates grid options only when tracked values change", async () => { + const updateGridOptions = vi.fn() + const createGrid = vi.fn(() => ({ + destroy: vi.fn(), + updateGridOptions, + })) + const notify = vi.fn() + const entity = createEntity("grid-runtime-2") + const element = {} + + configureAgGrid({ createGrid }) + + mountOrUpdateGrid(entity, element, { notify }) + await Promise.resolve() + mountOrUpdateGrid(entity, element, { notify }) + + expect(updateGridOptions).not.toHaveBeenCalled() + + entity.rowData = [...entity.rowData, { id: 2, name: "Beta" }] + mountOrUpdateGrid(entity, element, { notify }) + + expect(updateGridOptions).toHaveBeenCalledTimes(1) + expect(updateGridOptions).toHaveBeenCalledWith( + expect.objectContaining({ + rowData: entity.rowData, + columnDefs: entity.columnDefs, + }), + ) + }) + + it("calls underlying grid api methods via apiCall payload", async () => { + const sizeColumnsToFit = vi.fn() + const setFilterModel = vi.fn() + const createGrid = vi.fn(() => ({ + destroy: vi.fn(), + updateGridOptions: vi.fn(), + sizeColumnsToFit, + setFilterModel, + })) + const entity = createEntity("grid-runtime-3") + const element = {} + + configureAgGrid({ createGrid }) + mountOrUpdateGrid(entity, element, { notify: vi.fn() }) + await Promise.resolve() + + callGridApi(entity.id, "sizeColumnsToFit") + callGridApi(entity.id, { + method: "setFilterModel", + args: [{ id: null }], + }) + + expect(sizeColumnsToFit).toHaveBeenCalledTimes(1) + expect(setFilterModel).toHaveBeenCalledWith({ id: null }) + }) + + it("destroys mounted grid instances", async () => { + const destroy = vi.fn() + const createGrid = vi.fn(() => ({ + destroy, + updateGridOptions: vi.fn(), + })) + const entity = createEntity("grid-runtime-4") + const element = {} + + configureAgGrid({ createGrid }) + mountOrUpdateGrid(entity, element, { notify: vi.fn() }) + await Promise.resolve() + + destroyGrid(entity.id) + expect(destroy).toHaveBeenCalledTimes(1) + + callGridApi(entity.id, "sizeColumnsToFit") + expect(destroy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/ag-grid/src/template.js b/packages/ag-grid/src/template.js new file mode 100644 index 00000000..157be00d --- /dev/null +++ b/packages/ag-grid/src/template.js @@ -0,0 +1,52 @@ +import { html, ref } from "@inglorious/web" + +import { DEFAULT_THEME_CLASS } from "./defaults.js" +import { mountOrUpdateGrid } from "./runtime.js" +import { warnOnce } from "./warnings.js" + +/** + * @typedef {Record & { + * themeClass?: string + * height?: number | string + * }} AgGridRenderableEntity + */ + +/** + * Renders the AG Grid host element and delegates lifecycle to runtime helpers. + * @param {AgGridRenderableEntity} entity + * @param {{ notify: (event: string, payload?: any) => void }} api + * @returns {import("lit-html").TemplateResult} + */ +export function render(entity, api) { + const height = + typeof entity.height === "number" ? `${entity.height}px` : entity.height + const themeClass = resolveThemeClass(entity.themeClass) + + return html` +
{ + if (!el) return + mountOrUpdateGrid(entity, el, api) + })} + >
+ ` +} + +/** + * Ensures a non-empty AG Grid theme class and warns once in dev when invalid. + * @param {unknown} themeClass + * @returns {string} + */ +function resolveThemeClass(themeClass) { + if (typeof themeClass === "string" && themeClass.trim()) { + return themeClass + } + + warnOnce( + "empty-theme-class", + `Missing or empty "themeClass". Falling back to "${DEFAULT_THEME_CLASS}".`, + ) + return DEFAULT_THEME_CLASS +} diff --git a/packages/ag-grid/src/warnings.js b/packages/ag-grid/src/warnings.js new file mode 100644 index 00000000..aa593595 --- /dev/null +++ b/packages/ag-grid/src/warnings.js @@ -0,0 +1,26 @@ +const emittedWarnings = new Set() + +function isDevEnvironment() { + if ( + typeof import.meta !== "undefined" && + import.meta.env && + typeof import.meta.env.DEV === "boolean" + ) { + return import.meta.env.DEV + } + + return false +} + +/** + * Emits a warning once in development environments. + * @param {string} key + * @param {string} message + */ +export function warnOnce(key, message) { + if (!isDevEnvironment()) return + if (emittedWarnings.has(key)) return + emittedWarnings.add(key) + + console.warn(`[inglorious/ag-grid] ${message}`) +} diff --git a/packages/ag-grid/types/index.d.ts b/packages/ag-grid/types/index.d.ts new file mode 100644 index 00000000..1cacda69 --- /dev/null +++ b/packages/ag-grid/types/index.d.ts @@ -0,0 +1,32 @@ +export type AgGridRuntimeConfig = { + createGrid: (element: HTMLElement, options: Record) => any + registerModules?: () => void +} + +export function configureAgGrid(config: AgGridRuntimeConfig): void + +export type AgGridEntity = Record & { + height?: number | string + gridOptions?: Record +} + +export const agGrid: { + create(entity: AgGridEntity): void + render(entity: AgGridEntity): any + mounted(entity: AgGridEntity, payload?: any): void + rowDataChange(entity: AgGridEntity, rowData: any[]): void + columnDefsChange(entity: AgGridEntity, columnDefs: any[]): void + gridOptionsChange( + entity: AgGridEntity, + gridOptions: Record, + ): void + apiCall( + entity: AgGridEntity, + payload: string | { method: string; args?: any[] }, + ): void + gridMounted(entity: AgGridEntity, payload?: any): void + setRowData(entity: AgGridEntity, rowData: any[]): void + setColumnDefs(entity: AgGridEntity, columnDefs: any[]): void + setGridOptions(entity: AgGridEntity, gridOptions: Record): void + destroy(entity: AgGridEntity): void +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53ed03cb..f4ca1a34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -824,6 +824,28 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/node@24.8.0)(happy-dom@20.0.11)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(sass@1.91.0)(terser@5.44.1) + examples/apps/web-ag-grid: + dependencies: + "@inglorious/web": + specifier: workspace:* + version: link:../../../packages/web + devDependencies: + "@inglorious/eslint-config": + specifier: workspace:* + version: link:../../../packages/eslint-config + eslint: + specifier: ^9.39.1 + version: 9.39.2(jiti@2.5.1) + prettier: + specifier: ^3.6.2 + version: 3.8.1 + rollup-plugin-minify-template-literals: + specifier: ^1.1.7 + version: 1.1.7(rollup@4.50.0) + vite: + specifier: ^7.1.6 + version: 7.3.0(@types/node@24.8.0)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.91.0)(terser@5.44.1) + examples/apps/web-charts: dependencies: "@inglorious/charts": @@ -1743,6 +1765,9 @@ importers: "@lit-labs/ssr-client": specifier: ^1.1.8 version: 1.1.8 + ag-grid-community: + specifier: ^34.3.1 + version: 34.3.1 d3-array: specifier: ^3.2.4 version: 3.2.4 @@ -5542,6 +5567,18 @@ packages: engines: { node: ">=0.4.0" } hasBin: true + ag-charts-types@12.3.1: + resolution: + { + integrity: sha512-5216xYoawnvMXDFI6kTpPku+mH0Csiwu/FE7lsAm8Z22HEN6ciSG/V7g+IrpLWncELqksgENebCTP75PZ3CsHA==, + } + + ag-grid-community@34.3.1: + resolution: + { + integrity: sha512-PwlrPudsFOzGumphi2y9ihWeaUlIwKhOra/MXu2LjeV2U8DgLLcYS8CartE5Hszhn1poJHawwI9HWrxlKliwdw==, + } + agent-base@7.1.4: resolution: { @@ -14156,6 +14193,12 @@ snapshots: acorn@8.15.0: {} + ag-charts-types@12.3.1: {} + + ag-grid-community@34.3.1: + dependencies: + ag-charts-types: 12.3.1 + agent-base@7.1.4: optional: true