From a9021f6bd0b29b49288c81f0f533f943a1b30ffe Mon Sep 17 00:00:00 2001 From: Christian Hugo Date: Tue, 26 May 2026 11:43:57 +0200 Subject: [PATCH] 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..e457f24 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 { useFeed } from "@saltcorn/react-lib/hooks"; +import ScView from "@saltcorn/react-lib/components/ScView"; + +export default function App({ state }) { + const { hash, html, page, hasNext, ready } = useFeed("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); + }); +});