Skip to content
Open
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
211 changes: 197 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand All @@ -216,16 +220,13 @@ export default function App({ viewName, query }) {
<div>
<h3>
Row count for users:{" "}
{isCounting
? "Count..."
: error
? "Error fetching data"
: count}
{isCounting ? "Count..." : error ? "Error fetching data" : count}
</h3>
</div>
);
}
```

Or without hooks:

```javascript
Expand Down Expand Up @@ -288,13 +289,7 @@ import React from "react";
import { runAction } from "@saltcorn/react-lib/api";

export default function App({}) {
return (
<button
onClick={() => runAction("my_action")}
>
Run action
</button>
);
return <button onClick={() => runAction("my_action")}>Run action</button>;
}
```

Expand Down Expand Up @@ -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 <p>No rows found.</p>;
const columns = Object.keys(rows[0]);

return (
<div>
<table className="table table-striped">
<thead>
<tr>
{columns.map((col) => (
<th
key={col}
onClick={() => setSort(col)}
style={{ cursor: "pointer" }}
>
{col}
{sortBy === col ? (sortDesc ? " ▼" : " ▲") : ""}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
{columns.map((col) => (
<td key={col}>{String(row[col] ?? "")}</td>
))}
</tr>
))}
</tbody>
</table>

<div className="d-flex align-items-center gap-3 mt-2">
<button
className="btn btn-sm btn-outline-secondary"
disabled={currentPage <= 1}
onClick={() => setPage(currentPage - 1)}
>
‹ Prev
</button>
<span>
Page {currentPage}
{totalPages ? ` of ${totalPages}` : ""}
</span>
<button
className="btn btn-sm btn-outline-secondary"
disabled={
totalPages ? currentPage >= totalPages : rows.length < PAGE_SIZE
}
onClick={() => setPage(currentPage + 1)}
>
Next ›
</button>
<select
className="form-select form-select-sm w-auto"
value={state[`_${stateHash}_pagesize`] || PAGE_SIZE}
onChange={(e) => setPageSize(Number(e.target.value))}
>
{[5, 10, 25, 50].map((n) => (
<option key={n} value={n}>
{n} per page
</option>
))}
</select>
</div>
</div>
);
}
```

## Components

### ScView
Expand All @@ -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 (
<div>
<div className="d-flex flex-wrap align-items-center gap-2 p-2 bg-light rounded">
<span className="text-muted small">Sort:</span>
{["first_name", "last_name"].map((f) => (
<button key={f}
className={`btn btn-sm ${sortBy === f ? "btn-primary" : "btn-outline-secondary"}`}
onClick={() => setSort(f)}>
{f}{sortBy === f ? (sortDesc ? " ▼" : " ▲") : ""}
</button>
))}
<div className="vr" />
<button className="btn btn-sm btn-outline-secondary"
disabled={page <= 1}
onClick={() => setPage(page - 1)}>‹ Prev</button>
<span className="small">Page {page}</span>
<button className="btn btn-sm btn-outline-secondary"
disabled={!hasNext}
onClick={() => setPage(page + 1)}>Next ›</button>
</div>
{/* Hide the feed's built-in paginator — we provide our own above */}
<style>{`.sc-feed-wrapper ul.pagination { display: none !important; }`}</style>
<ScView html={html} className="sc-feed-wrapper" />
</div>
);
}
```

# 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).
Expand Down
9 changes: 7 additions & 2 deletions common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 12 additions & 12 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const buildMainBundle = async (buildMode, libPath, libMain, timestamp) => {
],
{
cwd: __dirname,
},
}
);
child.stdout.on("data", (data) => {
getState().log(5, data.toString());
Expand Down Expand Up @@ -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"
);
}
};
Expand All @@ -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");
Expand All @@ -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,
});
Expand Down Expand Up @@ -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({
Expand All @@ -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,
});
Expand Down Expand Up @@ -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)
Expand All @@ -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,
});
Expand Down
Loading
Loading