Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,3 +439,12 @@ export default function App({ viewName, query }) {
# 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).

## Setup

To make the **Generate React View** tool available in the Saltcorn Copilot:

1. Make sure **Agents** is installed.
2. Go to **Actions** and create a new Trigger with the **Agent** action.
3. In the configuration, add **Generate React View** to the skill list.
4. Open the **Saltcorn Copilot** trigger configuration, click the add button and select your **Generate React View** trigger.
134 changes: 134 additions & 0 deletions agent-skill.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
const View = require("@saltcorn/data/models/view");
const Table = require("@saltcorn/data/models/table");
const User = require("@saltcorn/data/models/user");
const { a, pre, div, code } = require("@saltcorn/markup/tags");
const {
buildAndUpdateView,
escapeHtml,
reactViewSystemPrompt,
} = require("./common");

class GenerateReactViewSkill {
static skill_name = "Generate React View";

get skill_label() {
return "React View";
}

constructor(cfg) {
Object.assign(this, cfg);
}

static async configFields() {
return [];
}

async systemPrompt() {
const roles = await User.get_roles();
const tables = await Table.find({});

const tableList = tables.map((t) => t.name).join(", ");
const roleList = roles.map((r) => r.role).join(", ");

return (
reactViewSystemPrompt +
`Available tables: ${tableList || "none"}
Available roles (for min_role): ${roleList}
`
);
}

provideTools() {
return [
{
type: "function",
renderToolCall({ view_name, react_code }) {
return (
div({ class: "mb-3" }, view_name) +
pre(code(escapeHtml(react_code)))
);
},
process: async ({
react_code,
view_name,
table,
view_description,
min_role,
}) => {
const roles = await User.get_roles();
const min_role_id = min_role
? roles.find((r) => r.role === min_role)?.id ?? 100
: 100;
const viewCfg = {
name: view_name,
viewtemplate: "React",
min_role: min_role_id,
configuration: { build_mode: "production", user_code: react_code },
};
if (table) {
const tableObj = Table.findOne({ name: table });
if (!tableObj) throw new Error(`Table ${table} not found`);
viewCfg.table_id = tableObj.id;
}
if (view_description) viewCfg.description = view_description;
await View.create(viewCfg);
await buildAndUpdateView(react_code, "production", view_name);
return { view_name };
},
renderToolResponse: async ({ view_name }) => {
return (
"View created. " +
a(
{ target: "_blank", href: `/view/${view_name}`, class: "me-1" },
"Go to view"
) +
" | " +
a(
{
target: "_blank",
href: `/viewedit/config/${view_name}`,
class: "ms-1",
},
"Configure view"
)
);
},
function: {
name: "generate_react_view",
description: "Generate and create a React View in Saltcorn",
parameters: {
type: "object",
required: ["react_code", "view_name"],
properties: {
react_code: {
description: `JavaScript code that constitutes the react component.
You must include the react import at the top, and the code should export default the component.`,
type: "string",
},
view_name: {
description: `The name of the view, this should be a short name which is part of the url.`,
type: "string",
},
table: {
description:
"Which table is this a view on (optional, omit for tableless views)",
type: "string",
},
view_description: {
description: "A description of the purpose of the view.",
type: "string",
},
min_role: {
description:
"The minimum role needed to access the view. Use 'admin' for admin-only, 'public' for publicly accessible.",
type: "string",
},
},
},
},
},
];
}
}

module.exports = GenerateReactViewSkill;
200 changes: 195 additions & 5 deletions common.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const Workflow = require("@saltcorn/data/models/workflow");
const Form = require("@saltcorn/data/models/form");
const Table = require("@saltcorn/data/models/table");
const View = require("@saltcorn/data/models/view");
const { div, script } = require("@saltcorn/markup/tags");
const { getState } = require("@saltcorn/data/db/state");
const {
Expand Down Expand Up @@ -33,7 +34,7 @@ const buildViewBundle = async (buildMode, viewName, timestamp) => {
],
{
cwd: __dirname,
},
}
);
child.stdout.on("data", (data) => {
getState().log(5, data.toString());
Expand All @@ -59,7 +60,7 @@ const handleUserCode = async (
buildMode,
viewName,
oldTimestamp,
newTimestamp,
newTimestamp
) => {
const tenant = db.getTenantSchema() || "public";
const userCodeDir = path.join(__dirname, "user-code", tenant);
Expand All @@ -72,25 +73,214 @@ const handleUserCode = async (
await fs.writeFile(
path.join(userCodeDir, `${safeViewName}.js`),
userCode,
"utf8",
"utf8"
);
if ((await buildViewBundle(buildMode, safeViewName, newTimestamp)) !== 0) {
throw new Error("Build failed please check your server logs");
}
try {
await fs.rm(
path.join(__dirname, "public", tenant, `${safeViewName}_${oldTimestamp}`),
{ recursive: true, force: true },
{ recursive: true, force: true }
);
} catch (err) {
getState().log(
2,
"Error removing old directory: " + err.message || "Unknown error",
"Error removing old directory: " + err.message || "Unknown error"
);
}
};

const reactViewSystemPrompt = `Use the generate_react_view tool to generate a react view. Here is an example of a
valid generated react code:

\`\`\`import React from "react";

export default function App({}) {
return <h1>Hello world</h1>;
}
\`\`\`

The generated code must include the react import at the top, and your generated code should export default the component.

A react view can be tableless or table-based. A tableless react view could for example show the current time, a table based view could show the data of one or multiple persons.

When a react view is tabless, it gets this properties:
\`\`\`import React from "react";
export default function App({viewName, query}) {...}
\`\`\`
When a react view is table based, it gets this properties:
\`\`\`import React from "react";
export default function App({viewName, query, tableName, rows, state}) {...}
\`\`\`
- 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

A react-view has access to bootstrap 5 styles. react-bootstrap is not available please use the normal bootstrap classes.

A react-view can use the function set_state_field(key, value, e) to change the current query of the browser window.
By changing the query you can act as a filter. For example if you show a list of persons, you can filter by name, age, etc.
Key is the name of the field, value is the value you want to set, and e is the event that triggered the change.
A react-filter-view can exist independent of other views, it only has to call set_state_field.

A react-view hast accees to the react-lib npm package. This is a module with hooks and functions to interact with the Saltcorn system. You can import it like this:
\`\`\`
import { useFetchOneRow, useFetchRows } from "@saltcorn/react-lib/hooks";
import { fetchOneRow, fetchRows, insertRow, updateRow, deleteRow } from "@saltcorn/react-lib/api";
\`\`\`
Please note that hooks are in the hooks submodule and functions are in the api submodule.

useFetchOneRow and useFetchRows are hooks to fetch rows from a Saltcorn table. Table-based views receive the initial rows in the rows property,
useFetchOneRow and useFetchRows can load data at runtime. This can be useful when the initial data changes,
or for loading data from another table than the table of the view, or for tableless views.

Parameters of useFetchOneRow:
- tableName: the name of the table
- query: the query to fetch the row as an object
- dependencies: an array of dependencies, when one of the dependencies changes, useFetchOneRow will fetch the row again
Returns an object with:
- isLoading: boolean indicating if the data is loading
- error: string describing the error or null
- row: an object with the data of the row

useFetchRows has the same signature, but returns an array of rows instead of a single row.

Example of useFetchRows:
\`\`\`
import React, { useState } from "react";
import { useFetchRows } from "@saltcorn/react-lib/hooks";
export default function App({query, tableName}) {
const { rows, isLoading, error } = useFetchRows("persons", query || {});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
{rows.map((row) => (
<div key={row.id}>
{row.name} - {row.age}
</div>
))}
</div>
);
}

When you do not want to use the hooks, you can use fetchOneRow and fetchRows. fetchRows example:
\`\`\`
import React, { useState, useEffect } from "react";
import { fetchOneRow, fetchRows } from "@saltcorn/react-lib/api";
export default function App({query, tableName}) {
// load rows once
const [rows, setRows] = useState([]);
useEffect(() => {
fetchRows(tableName, query).then((rows) => setRows(rows));
}, [tableName, query]);
}
\`\`\`
fetchRows returns an empty array if nothing was found and throws an error if something goes wrong.

fetchOneRow example:
\`\`\`
import React, { useState, useEffect } from "react";
import { fetchOneRow } from "@saltcorn/react-lib/api";
export default function App({query, tableName}) {
// load row once
const [row, setRow] = useState(null);
useEffect(() => {
fetchOneRow(tableName, query).then((row) => setRow(row));
}, [tableName, query]);
}
\`\`\`
fetchOneRow returns null if nothing was found and throws an error if something goes wrong.

Under "@saltcorn/react-lib/api" you also find the functions insertRow, updateRow and deleteRow.
They all throw an error if something goes wrong.

Parameters of insertRow:
- tableName: the name of the table
- row: the row to insert as an object
Example:
\`\`\`
import React, { useState } from "react";
import { insertRow } from "@saltcorn/react-lib/api";
export default function App({query, tableName}) {
const [row, setRow] = useState(null);
const handleInsert = () => {
insertRow(tableName, row).then((row) => setRow(row));
}
}
\`\`\`

Parameters of updateRow:
- tableName: the name of the table
- id: the id of the row to update
- row: the row to update as an object
\`\`\`
import React, { useState } from "react";
import { updateRow } from "@saltcorn/react-lib/api";
export default function App({query, tableName}) {
const [row, setRow] = useState(null);
const handleUpdate = () => {
updateRow(tableName, row).then((row) => setRow(row));
}
}
\`\`\`

Parameters of deleteRow:
- tableName: the name of the table
- id: the id of the row to delete
Example:
\`\`\`
import React, { useState } from "react";
import { deleteRow } from "@saltcorn/react-lib/api";
export default function App({query, tableName}) {
const [row, setRow] = useState(null);
const handleDelete = () => {
deleteRow(tableName, row.id).then(() => setRow(null));
}
}
\`\`\`

`;

const escapeHtml = (unsafe) =>
unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");

const buildAndUpdateView = async (
user_code,
build_mode,
viewname,
oldTimestamp
) => {
const newTimestamp = new Date().valueOf();
await handleUserCode(
user_code,
build_mode,
viewname,
oldTimestamp,
newTimestamp
);
const view = View.findOne({ name: viewname });
await View.update(
{
configuration: { ...(view.configuration || {}), timestamp: newTimestamp },
},
view.id
);
await getState().refresh_views();
};

module.exports = {
buildSafeViewName,
handleUserCode,
buildAndUpdateView,
escapeHtml,
reactViewSystemPrompt,
};
Loading
Loading