From 1b817918d87c4963b0d2895a6598dec816cdf241 Mon Sep 17 00:00:00 2001 From: Christian Hugo Date: Tue, 26 May 2026 11:43:57 +0200 Subject: [PATCH 1/2] expose stateHash and totalCount props; add pagination/sort docs and useFeed example - react_view.js: compute stateHash server-side and pass as prop; apply stateFieldsToQuery so pagination/sort URL params affect the row fetch; pass totalCount when a page limit is active - main-code/index.js: forward stateHash and totalCount to the component; remove dead computeStateHash / objectToQueryString (superseded by the server-side data-sc-state-hash attribute) - README: document stateHash / totalCount props, available state keys, and the useFeed + ScView pagination example --- README.md | 211 ++++++++++++++++++++++++++++++++++++++++++--- common.js | 9 +- index.js | 24 +++--- main-code/index.js | 29 +++++-- react_view.js | 8 ++ tests/view.test.js | 58 +++++++++++-- 6 files changed, 297 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index d70852c..4f462dc 100644 --- a/README.md +++ b/README.md @@ -76,13 +76,16 @@ and click Build or Finish. The component also has access to **state**, **query**, **rows** and the current **user**: ```javascript -export default function App({ tableName, viewName, state, query, rows, user }) +export default function App({ tableName, viewName, state, query, rows, user, stateHash, totalCount }) ``` +- `stateHash` — short hash for this view's state context, used to construct pagination/sort URL params +- `totalCount` — total number of matching rows (only set when a pagesize is active), useful for "page X of Y" + For tableless react-views, only the following properties are available: ```javascript -export default function App({ viewName, query, user }) +export default function App({ viewName, query, user, stateHash }) ``` ### Define everything in the view @@ -205,6 +208,7 @@ import { fetchRows, fetchOneRow } from "@saltcorn/react-lib/api"; ``` ### Count rows + ```javascript import React from "react"; import { useCountRows } from "@saltcorn/react-lib/hooks"; @@ -216,16 +220,13 @@ export default function App({ viewName, query }) {

Row count for users:{" "} - {isCounting - ? "Count..." - : error - ? "Error fetching data" - : count} + {isCounting ? "Count..." : error ? "Error fetching data" : count}

); } ``` + Or without hooks: ```javascript @@ -288,13 +289,7 @@ import React from "react"; import { runAction } from "@saltcorn/react-lib/api"; export default function App({}) { - return ( - - ); + return ; } ``` @@ -417,6 +412,128 @@ export default function App({ viewName, query }) { } ``` +## Pagination and Sorting + +React views receive a `stateHash` prop — a short identifier for this view's state context. Use it to set pagination and sort parameters via `set_state_field`, which updates the URL and triggers a re-render with the new page. + +The view also receives `totalCount` when a page size is active, so you can show "page X of Y". + +**Available state keys** (replace `${stateHash}` with the actual value): + +| Key | Effect | +| ------------------------ | ---------------------------------- | +| `_${stateHash}_page` | Current page number (1-based) | +| `_${stateHash}_pagesize` | Rows per page | +| `_${stateHash}_sortby` | Field name to sort by | +| `_${stateHash}_sortdesc` | Set to `"on"` for descending order | + +**Copy-paste example** — paste this into a table-based React view: + +```javascript +import React, { useEffect } from "react"; + +const PAGE_SIZE = 5; + +export default function App({ rows, state, stateHash, totalCount }) { + useEffect(() => { + if (!state[`_${stateHash}_pagesize`]) { + set_state_field(`_${stateHash}_pagesize`, PAGE_SIZE); + } + }, []); + + const currentPage = state[`_${stateHash}_page`] + ? parseInt(state[`_${stateHash}_page`]) + : 1; + const sortBy = state[`_${stateHash}_sortby`] || ""; + const sortDesc = !!state[`_${stateHash}_sortdesc`]; + const totalPages = totalCount ? Math.ceil(totalCount / PAGE_SIZE) : null; + + const setPage = (n) => set_state_field(`_${stateHash}_page`, n); + const setPageSize = (n) => + set_state_fields({ + [`_${stateHash}_pagesize`]: n, + [`_${stateHash}_page`]: 1, + }); + const setSort = (field) => { + const newSortDesc = sortBy === field ? (sortDesc ? "" : "on") : ""; + const kvs = { + [`_${stateHash}_sortby`]: field, + [`_${stateHash}_sortdesc`]: newSortDesc, + }; + const currentPageSize = state[`_${stateHash}_pagesize`]; + if (currentPageSize) kvs[`_${stateHash}_pagesize`] = currentPageSize; + set_state_fields(kvs); + }; + + if (!rows || rows.length === 0) return

No rows found.

; + const columns = Object.keys(rows[0]); + + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row, i) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
setSort(col)} + style={{ cursor: "pointer" }} + > + {col} + {sortBy === col ? (sortDesc ? " ▼" : " ▲") : ""} +
{String(row[col] ?? "")}
+ +
+ + + Page {currentPage} + {totalPages ? ` of ${totalPages}` : ""} + + + +
+
+ ); +} +``` + ## Components ### ScView @@ -436,6 +553,72 @@ export default function App({ viewName, query }) { } ``` +## Controlling embedded views via URL state + +A React view can embed and control other Saltcorn views (Feed, List, etc.) by reading and writing URL state. When URL state changes via pjax, Saltcorn re-renders the affected views and passes the updated `state` prop to your React component. + +Embedded views (Feed, List, …) use URL params prefixed with a short hash unique to each view instance: + +| Param | Effect | +|---|---| +| `_${hash}_page` | Current page (1-based) | +| `_${hash}_pagesize` | Rows per page | +| `_${hash}_sortby` | Field name to sort by | +| `_${hash}_sortdesc` | `"on"` for descending order | + +Use `set_state_field(key, value)` to update a single param, or `set_state_fields({...})` to update several at once so they land in a single navigation step. + +**Full example** + +A tableless React controller view that embeds `"persons_feed"` and controls its pagination and sorting. It extracts the server-computed hash from the feed on first load, then keeps the feed in sync with URL state on every pjax navigation. + +```javascript +import React from "react"; +import { useEmbeddedView } from "@saltcorn/react-lib/hooks"; +import ScView from "@saltcorn/react-lib/components/ScView"; + +export default function App({ state }) { + const { hash, html, page, hasNext, ready } = useEmbeddedView("persons_feed", state); + + if (!ready) return null; + + const sortBy = state[`_${hash}_sortby`] || ""; + const sortDesc = !!state[`_${hash}_sortdesc`]; + + const setPage = (n) => set_state_field(`_${hash}_page`, n); + const setSort = (field) => { + const newDesc = sortBy === field ? (sortDesc ? "" : "on") : ""; + set_state_fields({ [`_${hash}_sortby`]: field, [`_${hash}_sortdesc`]: newDesc }); + }; + + return ( +
+
+ Sort: + {["first_name", "last_name"].map((f) => ( + + ))} +
+ + Page {page} + +
+ {/* Hide the feed's built-in paginator — we provide our own above */} + + +
+ ); +} +``` + # Copilot The Saltcorn copilot can generate react-views. Only views where all the code is stored within the view are possible, an action to change the main bundle does not exist yet. When the chat only gives you the code without a button to apply it, try to be more explicit (for example, it could be that the model still needs to know the min_role). diff --git a/common.js b/common.js index 4f8837b..a74aff9 100644 --- a/common.js +++ b/common.js @@ -99,17 +99,22 @@ A react view can be tableless or table-based. A tableless react view could for e When a react view is tabless, it gets this properties: \`\`\`import React from "react"; -export default function App({viewName, query}) {...} +export default function App({viewName, query, stateHash}) {...} \`\`\` When a react view is table based, it gets this properties: \`\`\`import React from "react"; -export default function App({viewName, query, tableName, rows, state}) {...} +export default function App({viewName, query, tableName, rows, state, stateHash}) {...} \`\`\` - viewName: the name of the view - query: the query parameters of the view - tableName: the name of the Saltcorn table - rows: the rows of the table, this is an array of objects, each object is a row of the table - state: the state of the view, this is an object with the state of the view +- stateHash: a short hash string identifying this view's state context. Use it to set pagination and sort parameters + via set_state_field, for example set_state_field(\`_\${stateHash}_page\`, 2) to go to page 2, + set_state_field(\`_\${stateHash}_pagesize\`, 10) to set the page size, + set_state_field(\`_\${stateHash}_sortby\`, "name") to sort by a field, + set_state_field(\`_\${stateHash}_sortdesc\`, true) to sort descending. A react-view has access to bootstrap 5 styles. react-bootstrap is not available please use the normal bootstrap classes. diff --git a/index.js b/index.js index e5ee627..c92ecf2 100644 --- a/index.js +++ b/index.js @@ -29,7 +29,7 @@ const buildMainBundle = async (buildMode, libPath, libMain, timestamp) => { ], { cwd: __dirname, - }, + } ); child.stdout.on("data", (data) => { getState().log(5, data.toString()); @@ -72,12 +72,12 @@ const prepareDirectory = async ({ const libPath = await userLibPath(codeSource, codeLocation); const userLibMain = async () => { const packageJson = JSON.parse( - await fs.readFile(path.join(libPath, "package.json"), "utf8"), + await fs.readFile(path.join(libPath, "package.json"), "utf8") ); if (packageJson.main) return packageJson.main; else { throw new Error( - "No main field in package.json, please specify the main file", + "No main field in package.json, please specify the main file" ); } }; @@ -98,7 +98,7 @@ const prepareDirectory = async ({ buildMode, libPath, libPath ? await userLibMain() : null, - timestamp, + timestamp )) !== 0 ) { throw new Error("Webpack failed, please check your Server logs"); @@ -123,8 +123,8 @@ const configuration_workflow = () => app_code_source === "local" ? app_code_path : app_code_source === "Saltcorn folder" - ? sc_folder - : null, + ? sc_folder + : null, buildMode: build_mode, timestamp, }); @@ -260,7 +260,7 @@ const routes = ({ app_code_source, app_code_path, sc_folder, build_mode }) => { getState().log( 6, `app_code_source: ${app_code_source}, app_code_path: ${app_code_path}, ` + - `sc_folder: ${sc_folder}, build_mode: ${build_mode}, `, + `sc_folder: ${sc_folder}, build_mode: ${build_mode}, ` ); const timestamp = new Date().valueOf(); await prepareDirectory({ @@ -269,8 +269,8 @@ const routes = ({ app_code_source, app_code_path, sc_folder, build_mode }) => { app_code_source === "local" ? app_code_path : app_code_source === "Saltcorn folder" - ? sc_folder - : null, + ? sc_folder + : null, buildMode: build_mode, timestamp, }); @@ -319,7 +319,7 @@ module.exports = { __dirname, "public", tenant, - `main_bundle_${configuration.timestamp}.js`, + `main_bundle_${configuration.timestamp}.js` ); const mainBundleExists = await fs .access(mainBundlePath) @@ -336,8 +336,8 @@ module.exports = { app_code_source === "local" ? app_code_path : app_code_source === "Saltcorn folder" - ? sc_folder - : null, + ? sc_folder + : null, buildMode: build_mode, timestamp, }); diff --git a/main-code/index.js b/main-code/index.js index ef08438..2cef765 100644 --- a/main-code/index.js +++ b/main-code/index.js @@ -5,6 +5,7 @@ import { init, loadRemote } from "@module-federation/runtime"; import * as userLib from "@user-lib"; + const isWeb = typeof parent.saltcorn?.mobileApp === "undefined"; const tenant = window.tenant_name || "public"; @@ -42,9 +43,9 @@ const initMain = async () => { ? `/plugins/public/react/${tenant}/${viewName}_${timestamp}/${viewName}_remote.js` : `/plugins/public/react/${tenant}/${viewName}_remote.js` : // TODO get the version from the plugin, for now hardcoded in any release - timestamp - ? `http://localhost/sc_plugins/public/react@0.1.8/${tenant}/${viewName}_${timestamp}/${viewName}_remote.js` - : `http://localhost/sc_plugins/public/react@0.1.8/${tenant}/${viewName}_remote.js`, + timestamp + ? `http://localhost/sc_plugins/public/react@0.1.8/${tenant}/${viewName}_${timestamp}/${viewName}_remote.js` + : `http://localhost/sc_plugins/public/react@0.1.8/${tenant}/${viewName}_remote.js`, }); } @@ -52,21 +53,33 @@ const initMain = async () => { for (const rootElement of rootElements) { const viewName = rootElement.getAttribute("view-name"); const state = JSON.parse( - decodeURIComponent(rootElement.getAttribute("state")), + decodeURIComponent(rootElement.getAttribute("state")) ); const query = JSON.parse( - decodeURIComponent(rootElement.getAttribute("query")), + decodeURIComponent(rootElement.getAttribute("query")) ); const tableName = rootElement.getAttribute("table-name"); const rows = JSON.parse( - decodeURIComponent(rootElement.getAttribute("rows")), + decodeURIComponent(rootElement.getAttribute("rows")) ); const user = JSON.parse( - decodeURIComponent(rootElement.getAttribute("user")), + decodeURIComponent(rootElement.getAttribute("user")) ); + const stateHash = rootElement.getAttribute("state-hash"); + const totalCountAttr = rootElement.getAttribute("total-count"); + const totalCount = totalCountAttr ? parseInt(totalCountAttr) : undefined; try { const remote = await loadRemote(`${viewName}/${viewName}`); - const props = { tableName, viewName, state, query, rows, user }; + const props = { + tableName, + viewName, + state, + query, + rows, + user, + stateHash, + totalCount, + }; const root = ReactDOMClient.createRoot(rootElement); root.render(React.createElement(remote.default, props)); } catch (e) { diff --git a/react_view.js b/react_view.js index 68dbccd..f07fcd2 100644 --- a/react_view.js +++ b/react_view.js @@ -4,8 +4,10 @@ const Table = require("@saltcorn/data/models/table"); const { div } = require("@saltcorn/markup/tags"); const { stateFieldsToWhere, + stateFieldsToQuery, readState, } = require("@saltcorn/data/plugin-helper"); +const { hashState } = require("@saltcorn/data/utils"); const { buildSafeViewName, buildAndUpdateView, @@ -30,10 +32,12 @@ export default function App({ viewName, query${ const run = async (table_id, viewname, { timestamp }, state, extra) => { const req = extra.req; const query = req.query || {}; + const stateHash = hashState(state, viewname); const props = { "view-name": buildSafeViewName(viewname), query: encodeURIComponent(JSON.stringify(query)), user: encodeURIComponent(JSON.stringify(req.user || {})), + "state-hash": stateHash, }; if (table_id) { // with table @@ -45,14 +49,18 @@ const run = async (table_id, viewname, { timestamp }, state, extra) => { table, prefix: "a.", }); + const q = stateFieldsToQuery({ state, fields, stateHash }); const rows = await table.getRows(where, { + ...q, forUser: req.user, forPublic: !req.user, }); + const totalCount = q.limit ? await table.countRows(where) : undefined; readState(state, fields, req); props["table-name"] = table.name; props.state = encodeURIComponent(JSON.stringify(state)); props.rows = encodeURIComponent(JSON.stringify(rows)); + if (totalCount !== undefined) props["total-count"] = String(totalCount); } return div({ class: "_sc_react-view", diff --git a/tests/view.test.js b/tests/view.test.js index 31cc2ca..b533297 100644 --- a/tests/view.test.js +++ b/tests/view.test.js @@ -1,6 +1,7 @@ const { getState } = require("@saltcorn/data/db/state"); const View = require("@saltcorn/data/models/view"); const { mockReqRes } = require("@saltcorn/data/tests/mocks"); +const { hashState } = require("@saltcorn/data/utils"); const { afterAll, beforeAll, describe, it, expect } = require("@jest/globals"); getState().registerPlugin("base", require("@saltcorn/data/base-plugin")); @@ -11,6 +12,11 @@ beforeAll(async () => { await getState().refresh(true); }); +const extractAttr = (html, attr) => { + const m = html.match(new RegExp(`${attr}="([^"]*)"`)); + return m ? m[1] : null; +}; + describe("react view run tests", () => { it("run tableless view", async () => { const view = View.findOne({ name: "default_react_view" }); @@ -18,9 +24,9 @@ describe("react view run tests", () => { expect(result).toBeDefined(); expect(result).toContain('
{ expect(result).toBeDefined(); expect(result).toContain('
{ + it("tableless view exposes state-hash as a 5-char hex string", async () => { + const view = View.findOne({ name: "default_react_view" }); + const result = await view.run({}, mockReqRes); + expect(result).toContain('state-hash="'); + const hash = extractAttr(result, "state-hash"); + expect(hash).toMatch(/^[0-9a-f]{5}$/); + }); + + it("table-based view exposes state-hash as a 5-char hex string", async () => { + const view = View.findOne({ name: "react_view_with_data" }); + const result = await view.run({}, mockReqRes); + expect(result).toContain('state-hash="'); + const hash = extractAttr(result, "state-hash"); + expect(hash).toMatch(/^[0-9a-f]{5}$/); + }); + + it("state-hash matches hashState utility output", async () => { + const view = View.findOne({ name: "react_view_with_data" }); + const state = {}; + const result = await view.run(state, mockReqRes); + const hash = extractAttr(result, "state-hash"); + expect(hash).toBe(hashState(state, "react_view_with_data")); + }); + + it("state-hash is stable when only pagination params change", async () => { + const view = View.findOne({ name: "react_view_with_data" }); + const baseState = {}; + const baseResult = await view.run(baseState, mockReqRes); + const baseHash = extractAttr(baseResult, "state-hash"); + + const baseHashValue = hashState(baseState, "react_view_with_data"); + const pagedState = { [`_${baseHashValue}_page`]: "2" }; + const pagedResult = await view.run(pagedState, mockReqRes); + const pagedHash = extractAttr(pagedResult, "state-hash"); + + expect(pagedHash).toBe(baseHash); + }); +}); From b76c513eb035afb6583bfc5b24dde8875cc92327 Mon Sep 17 00:00:00 2001 From: Christian Hugo Date: Wed, 3 Jun 2026 19:59:17 +0200 Subject: [PATCH 2/2] set state for tabless views, README update --- README.md | 20 +++++++++++--------- package.json | 2 +- react_view.js | 6 +++--- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 4f462dc..b2e2a02 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ export default function App({ tableName, viewName, state, query, rows, user, sta For tableless react-views, only the following properties are available: ```javascript -export default function App({ viewName, query, user, stateHash }) +export default function App({ viewName, query, state, user, stateHash }) ``` ### Define everything in the view @@ -553,11 +553,13 @@ export default function App({ viewName, query }) { } ``` -## Controlling embedded views via URL state +## Controlling embedded multi-row views via URL state -A React view can embed and control other Saltcorn views (Feed, List, etc.) by reading and writing URL state. When URL state changes via pjax, Saltcorn re-renders the affected views and passes the updated `state` prop to your React component. +A React view can embed and control other Saltcorn views that display multiple rows by reading and writing URL state. When URL state changes via pjax, Saltcorn re-renders the affected views and passes the updated `state` prop to your React component. -Embedded views (Feed, List, …) use URL params prefixed with a short hash unique to each view instance: +> **Note:** Currently only **List** and **Feed** view templates are supported — they expose the pagination metadata that `useManyView` depends on. + +Embedded views use URL params prefixed with a short hash unique to each view instance: | Param | Effect | |---|---| @@ -570,15 +572,15 @@ Use `set_state_field(key, value)` to update a single param, or `set_state_fields **Full example** -A tableless React controller view that embeds `"persons_feed"` and controls its pagination and sorting. It extracts the server-computed hash from the feed on first load, then keeps the feed in sync with URL state on every pjax navigation. +A tableless React controller view that embeds `"persons_feed"` (a **Feed** view — a **List** view works identically) and controls its pagination and sorting. It extracts the server-computed hash from the embedded view on first load, then keeps it in sync with URL state on every pjax navigation. ```javascript import React from "react"; -import { useEmbeddedView } from "@saltcorn/react-lib/hooks"; -import ScView from "@saltcorn/react-lib/components/ScView"; +import { useManyView } from "@saltcorn/react-lib/hooks"; +import { ScView } from "@saltcorn/react-lib/components"; export default function App({ state }) { - const { hash, html, page, hasNext, ready } = useEmbeddedView("persons_feed", state); + const { hash, page, hasNext, ready, viewProps } = useManyView("persons_feed", state); if (!ready) return null; @@ -613,7 +615,7 @@ export default function App({ state }) {
{/* Hide the feed's built-in paginator — we provide our own above */} - +
); } diff --git a/package.json b/package.json index f0d0540..b10e9e6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "dependencies": { "@saltcorn/markup": "^0.2.0", "@saltcorn/data": "^0.2.0", - "@saltcorn/react-lib": "0.0.1-beta.7", + "@saltcorn/react-lib": "0.0.1-beta.8", "react": "^19.0.0", "react-dom": "^19.0.0", "webpack": "5.97.1", diff --git a/react_view.js b/react_view.js index f07fcd2..7ebb0f9 100644 --- a/react_view.js +++ b/react_view.js @@ -20,8 +20,8 @@ const get_state_fields = () => []; const defaultUserCode = (tableId) => { return `import React from "react"; -export default function App({ viewName, query${ - tableId ? ", state, tableName, rows" : " " +export default function App({ viewName, query, state${ + tableId ? ", tableName, rows" : "" } }) { return

Please write your React code here

; }; @@ -38,6 +38,7 @@ const run = async (table_id, viewname, { timestamp }, state, extra) => { query: encodeURIComponent(JSON.stringify(query)), user: encodeURIComponent(JSON.stringify(req.user || {})), "state-hash": stateHash, + state: encodeURIComponent(JSON.stringify(state)), }; if (table_id) { // with table @@ -58,7 +59,6 @@ const run = async (table_id, viewname, { timestamp }, state, extra) => { const totalCount = q.limit ? await table.countRows(where) : undefined; readState(state, fields, req); props["table-name"] = table.name; - props.state = encodeURIComponent(JSON.stringify(state)); props.rows = encodeURIComponent(JSON.stringify(rows)); if (totalCount !== undefined) props["total-count"] = String(totalCount); }