diff --git a/README.md b/README.md
index 89ddb41d..0a1f9fab 100644
--- a/README.md
+++ b/README.md
@@ -41,3 +41,16 @@ Next start Grist with an URL pointing to a local widget manifest file:
```bash
GRIST_WIDGET_LIST_URL=http://localhost:8585/manifest.json npm start
```
+
+Alternatively you can run the widget repository development server alongside with the Grist docker image preconfigured to use it:
+
+```bash
+yarn run grist:serve
+```
+
+or run it in development mode with automatic reload:
+
+```bash
+yarn install
+yarn run grist:dev
+```
\ No newline at end of file
diff --git a/custom-widget-builder/README.md b/custom-widget-builder/README.md
new file mode 100644
index 00000000..1332e185
--- /dev/null
+++ b/custom-widget-builder/README.md
@@ -0,0 +1,38 @@
+# Grist Custom Widget
+
+This widget enables you to create other custom widgets for Grist documents, right inside Grist that are hosted by Grist itself.
+
+## Getting Started
+
+To begin developing your custom widget, follow these steps:
+
+1. **Open the Widget Editor:** Click on the "Open configuration" button in the creator panel or clear the saved filter settings for the relevant tab.
+2. **Edit Code:** Write your widget's logic in the JavaScript tab and structure its appearance in the HTML tab.
+3. **Preview and Install:** Click the "Preview" button to see your widget in action. This will save the widget's code to the document's metadata.
+4. **Save Configuration:** Press the "Save" button to persist the widget settings to ensure they remain active after refreshing the page.
+
+**Note:** There is no autosave feature, so always remember to save your configuration manually.
+
+## Data Storage
+
+The widget's configuration data is stored in the widget's metadata using the following format:
+
+```javascript
+const options = {
+ _installed: true,
+ _js: `...your JavaScript code...`,
+ _html: `...your HTML code...`,
+};
+grist.setOptions(options);
+```
+
+In the final widget, the _html field is inserted as is into an iframe, and the _js field is embedded within a script tag afterwards.
+
+This widget in itself doesn't require any access to documents metadata, but it can be used to create widgets that do. Storing Javascript and HTML code in the metadata stores it only temporarily. User needs to save it in order to persist the changes (just like for regular filters).
+
+Any contribution is welcome, the big thing missing is dark mode support.
+
+
+## IntelliSense
+
+The widget editor supports `IntelliSense` for the `JavaScript` code. It does it by providing its own types definitions directly to the Monaco editor. The IntelliSense is based on the official Grist Plugin API. See the `genarate.js` script for more details.
\ No newline at end of file
diff --git a/custom-widget-builder/api.js b/custom-widget-builder/api.js
new file mode 100644
index 00000000..71e05f71
--- /dev/null
+++ b/custom-widget-builder/api.js
@@ -0,0 +1,404 @@
+/** Helper for keeping some data and watching for changes */
+function memory(name) {
+ let value = undefined;
+ let listeners = [];
+ const obj = function (arg) {
+ if (arg === undefined) {
+ return value;
+ } else {
+ if (value !== arg) {
+ listeners.forEach(clb => clb(arg));
+ value = arg;
+ }
+ }
+ };
+
+ obj.subscribe = function (clb) {
+ listeners.push(clb);
+ return () => void listeners.splice(listeners.indexOf(clb), 1);
+ };
+
+ return obj;
+}
+
+// Global state, to keep track of the editor state, and file content.
+const currentJs = memory('js');
+const currentHtml = memory('html');
+const state = memory('state'); // null, 'installed', 'editor'
+
+const COLORS = {
+ green: '#16b378',
+}
+
+const DEFAULT_HTML = `
+
+
+
+
+
+
+ Custom widget builder
+
+
+ For instructions on how to use this widget, click the "Open configuration" button on the creator panel and select the "Help" tab.
+
+
+ Remember: there is no autosaving! Always save changes before closing/refreshing the page.
+
+
+
+
+`.trim();
+
+const DEFAULT_JS = `
+grist.ready({ requiredAccess: 'none' });
+grist.onRecords(table => {
+
+});
+grist.onRecord(record => {
+
+});
+`.trim();
+
+let htmlModel;
+let jsModel;
+
+let monacoLoaded = false;
+async function loadMonaco() {
+ // Load all those scripts above.
+
+ if (monacoLoaded) {
+ return;
+ }
+
+ monacoLoaded = true;
+
+ async function loadJs(url) {
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = url;
+ script.onload = resolve;
+ script.onerror = reject;
+ document.head.appendChild(script);
+ });
+ }
+ async function loadCss(url) {
+ return new Promise((resolve, reject) => {
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = url;
+ link.onload = resolve;
+ link.onerror = reject;
+ document.head.appendChild(link);
+ });
+ }
+
+ await loadCss(
+ 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.26.1/min/vs/editor/editor.main.min.css'
+ );
+ await loadJs(
+ 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.26.1/min/vs/loader.min.js'
+ );
+
+ window.require.config({
+ paths: {
+ vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.26.1/min/vs',
+ },
+ });
+
+ await new Promise((resolve, reject) => {
+ window.require(
+ ['vs/editor/editor.main.nls', 'vs/editor/editor.main'],
+ resolve,
+ reject
+ );
+ });
+}
+
+// Builds code editor replacing all elements with a monaco instance.
+function buildEditor() {
+ if (window.editor) {
+ return;
+ }
+ htmlModel = monaco.editor.createModel(currentHtml() ?? DEFAULT_HTML, 'html');
+ jsModel = monaco.editor.createModel(currentJs() ?? DEFAULT_JS, 'javascript');
+
+ jsModel.onDidChangeContent(() => {
+ currentJs(jsModel.getValue());
+ });
+ htmlModel.onDidChangeContent(() => {
+ currentHtml(htmlModel.getValue());
+ });
+ // Replace script tag with a div that will be used as a container for monaco editor.
+ const container = document.getElementById('container');
+ // Create JS monaco model - like a tab in the IDE.
+ // Create IDE. Options here are only for styling and making editor look like a
+ // code snippet.
+ const editor = monaco.editor.create(container, {
+ model: htmlModel,
+ automaticLayout: true,
+ fontSize: '13px',
+ wordWrap: 'on',
+ minimap: {
+ enabled: false,
+ },
+ lineNumbers: 'off',
+ glyphMargin: false,
+ folding: false,
+ });
+ // Set tabSize - this can be done only after editor is created.
+ editor.getModel().updateOptions({ tabSize: 2 });
+ // Disable scrolling past the last line - we will expand editor if necessary.
+ editor.updateOptions({ scrollBeyondLastLine: false });
+ window.editor = editor;
+}
+const page_widget = document.getElementById('page_widget');
+const page_editor = document.getElementById('page_editor');
+const page_help = document.getElementById('page_help');
+const btnTabJs = document.getElementById('tab_js');
+const btnTabHtml = document.getElementById('tab_html');
+const btnTabHelp = document.getElementById('tab_help');
+const tabs = [btnTabJs, btnTabHtml, btnTabHelp];
+function resetTabs() {
+ // Remove .selected class from all tabs.
+ tabs.forEach(e => e.classList.remove('selected'));
+}
+function selectTab(tab) {
+ tab.classList.add('selected');
+}
+const btnReset = document.getElementById('btnReset');
+const btnInstall = document.getElementById('btnInstall');
+const bar = document.getElementById('_bar');
+let wFrame = null;
+
+const bntTabs = [btnTabJs, btnTabHtml, btnTabHelp];
+const pages = [page_editor, page_help, page_widget];
+
+function purge(element) {
+ while (element.firstChild) {
+ element.removeChild(element.firstChild);
+ }
+}
+
+let lastListener;
+function createFrame() {
+ // remove all data from page_widget
+ purge(page_widget);
+ wFrame = document.createElement('iframe');
+ page_widget.appendChild(wFrame);
+ const widgetWindow = wFrame.contentWindow;
+ // Rewire messages between this widget, and the preview
+ if (lastListener) window.removeEventListener('message', lastListener);
+ lastListener = e => {
+ if (e.source === widgetWindow) {
+ // Hijicack configure message to inform Grist that we have custom configuration.
+ // data will have { iface: "CustomSectionAPI", meth: "configure", args: [{}] }
+ if (
+ e.data?.iface === 'CustomSectionAPI' &&
+ e.data?.meth === 'configure'
+ ) {
+ e.data.args ??= [{}];
+ e.data.args[0].hasCustomOptions = true;
+ }
+ window.parent.postMessage(e.data, '*');
+ } else if (e.source === window.parent) {
+ // If user clicks `Open confirguration` button, we will switch to the editor.
+ // The message that we will receive is:
+ // {"mtype":1,"reqId":6,"iface":"editOptions","meth":"invoke","args":[]}
+ if (e.data?.iface === 'editOptions' && e.data?.meth === 'invoke') {
+ if (state() !== 'editor') {
+ showEditor();
+ }
+ } else {
+ widgetWindow.postMessage(e.data, '*');
+ }
+ }
+ };
+ window.addEventListener('message', lastListener);
+}
+
+function init() {
+ if (init.invoked) return;
+ init.invoked = true;
+ // Import definitions from api_deps.js
+ monaco.languages.typescript.javascriptDefaults.addExtraLib(
+ definition,
+ 'plugin.d.ts'
+ );
+ // Declare global grist namespace.
+ monaco.languages.typescript.javascriptDefaults.addExtraLib(
+ `
+ import * as Grist from "grist"
+ declare global {
+ interface Window {
+ var grist: typeof Grist;
+ }
+ }
+ export {}
+ `,
+ 'main.d.ts'
+ );
+}
+
+function cleanUi() {
+ resetTabs();
+ pages.forEach(e => (e.style.display = 'none'));
+}
+
+function changeTab(lang) {
+ if (lang === 'help') {
+ cleanUi();
+ page_help.style.display = 'block';
+ selectTab(btnTabHelp);
+ return;
+ }
+ cleanUi();
+ page_editor.style.display = 'block';
+ page_help.style.display = 'none';
+ editor.setModel(lang === 'js' ? jsModel : htmlModel);
+ selectTab(lang == 'js' ? btnTabJs : btnTabHtml);
+}
+
+function installWidget(code, html) {
+ state('installed');
+ code = code ?? jsModel.getValue();
+ html = html ?? htmlModel.getValue();
+ createFrame();
+ const content = wFrame.contentWindow;
+ content.document.open();
+ content.document.write(html);
+ if (code.trim()) {
+ if (!html.includes('grist-plugin-api.js')) {
+ content.document.write(
+ `'
+ *
+ *
+ * Example usage (let's assume that Grist let's plugin contributes to a Foo API defined as follow ):
+ *
+ * interface Foo {
+ * foo(name: string): Promise;
+ * }
+ *
+ * > main.ts:
+ * class MyFoo {
+ * public foo(name: string): Promise {
+ * return new Promise( async resolve => {
+ * grist.rpc.onMessage( e => {
+ * resolve(e.data + name);
+ * });
+ * grist.ready();
+ * await grist.api.render('view1.html', 'fullscreen');
+ * });
+ * }
+ * }
+ * grist.rpc.registerImpl('grist', new MyFoo()); // can add 3rd arg with type information
+ *
+ * > view1.html includes:
+ * grist.api.render('static/view2.html', 'fullscreen').then( view => {
+ * grist.rpc.onMessage(e => grist.rpc.postMessageForward("main.ts", e.data));
+ * });
+ *
+ * > view2.html includes:
+ * grist.rpc.postMessage('view1.html', 'foo ');
+ *
+ */
+ import { RenderOptions, RenderTarget } from 'grist/RenderOptions';
+ /**
+ * Represents the id of a row in a table. The value of the 'id' column. Might be a number or 'new' value for a new row.
+ */
+ export type UIRowId = number | 'new';
+ /**
+ * Represents the position of an active cursor on a page.
+ */
+ export interface CursorPos {
+ /**
+ * The rowId (value of the 'id' column) of the current cursor position, or 'new' if the cursor is on a new row.
+ */
+ rowId?: UIRowId;
+ /**
+ * The index of the current row in the current view.
+ */
+ rowIndex?: number;
+ /**
+ * The index of the selected field in the current view.
+ */
+ fieldIndex?: number;
+ /**
+ * The id of a section that this cursor is in. Ignored when setting a cursor position for a particular view.
+ */
+ sectionId?: number;
+ /**
+ * When in a linked section, CursorPos may include which rows in the controlling sections are
+ * selected: the rowId in the linking-source section, in _that_ section's linking source, etc.
+ */
+ linkingRowIds?: UIRowId[];
+ }
+ export type ComponentKind = "safeBrowser" | "safePython" | "unsafeNode";
+ export const RPC_GRISTAPI_INTERFACE = "_grist_api";
+ export interface GristAPI {
+ /**
+ * Render the file at 'path' into the 'target' location in Grist. 'path' must be relative to the
+ * root of the plugin's directory and point to an html that is contained within the plugin's
+ * directory. 'target' is a predefined location of the Grist UI, it could be 'fullscreen' or
+ * identifier for an inline target. Grist provides inline target identifiers in certain call
+ * plugins. E.g. ImportSourceAPI.getImportSource is given a target identifier to allow rende UI
+ * inline in the import dialog. Returns the procId which can be used to dispose the view.
+ */
+ render(path: string, target: RenderTarget, options?: RenderOptions): Promise;
+ /**
+ * Dispose the process with id procId. If the process was embedded into the UI, removes the
+ * corresponding element from the view.
+ */
+ dispose(procId: number): Promise;
+ subscribe(tableId: string): Promise;
+ unsubscribe(tableId: string): Promise;
+ }
+ /**
+ * Allows getting information from and interacting with the Grist document to which a plugin or widget is attached.
+ */
+ export interface GristDocAPI {
+ /**
+ * Returns an identifier for the document.
+ */
+ getDocName(): Promise;
+ /**
+ * Returns a sorted list of table IDs.
+ */
+ listTables(): Promise;
+ /**
+ * Returns a complete table of data as {@link GristData.RowRecords | GristData.RowRecords}, including the
+ * 'id' column. Do not modify the returned arrays in-place, especially if used
+ * directly (not over RPC).
+ */
+ fetchTable(tableId: string): Promise;
+ /**
+ * Applies an array of user actions.
+ */
+ applyUserActions(actions: any[][], options?: any): Promise;
+ /**
+ * Get a token for out-of-band access to the document.
+ */
+ getAccessToken(options: AccessTokenOptions): Promise;
+ }
+ /**
+ * Options for functions which fetch data from the selected table or record:
+ *
+ * - {@link onRecords}
+ * - {@link onRecord}
+ * - {@link fetchSelectedRecord}
+ * - {@link fetchSelectedTable}
+ * - {@link GristView.fetchSelectedRecord | GristView.fetchSelectedRecord}
+ * - {@link GristView.fetchSelectedTable | GristView.fetchSelectedTable}
+ *
+ * The different methods have different default values for 'keepEncoded' and 'format'.
+ **/
+ export interface FetchSelectedOptions {
+ /**
+ * - 'true': the returned data will contain raw {@link GristData.CellValue}'s.
+ * - 'false': the values will be decoded, replacing e.g. '['D', timestamp]' with a moment date.
+ */
+ keepEncoded?: boolean;
+ /**
+ * - 'rows', the returned data will be an array of objects, one per row, with column names as keys.
+ * - 'columns', the returned data will be an object with column names as keys, and arrays of values.
+ */
+ format?: 'rows' | 'columns';
+ /**
+ * - 'shown' (default): return only columns that are explicitly shown
+ * in the right panel configuration of the widget. This is the only value that doesn't require full access.
+ * - 'normal': return all 'normal' columns, regardless of whether the user has shown them.
+ * - 'all': also return special invisible columns like 'manualSort' and display helper columns.
+ */
+ includeColumns?: 'shown' | 'normal' | 'all';
+ }
+ /**
+ * Interface for the data backing a single widget.
+ */
+ export interface GristView {
+ /**
+ * Like {@link GristDocAPI.fetchTable | GristDocAPI.fetchTable},
+ * but gets data for the custom section specifically, if there is any.
+ * By default, 'options.keepEncoded' is 'true' and 'format' is 'columns'.
+ */
+ fetchSelectedTable(options?: FetchSelectedOptions): Promise;
+ /**
+ * Fetches selected record by its 'rowId'. By default, 'options.keepEncoded' is 'true'.
+ */
+ fetchSelectedRecord(rowId: number, options?: FetchSelectedOptions): Promise;
+ /**
+ * Deprecated now. It was used for filtering selected table by 'setSelectedRows' method.
+ * Now the preferred way it to use ready message.
+ */
+ allowSelectBy(): Promise;
+ /**
+ * Set the list of selected rows to be used against any linked widget.
+ */
+ setSelectedRows(rowIds: number[] | null): Promise;
+ /**
+ * Sets the cursor position to a specific row and field. 'sectionId' is ignored. Used for widget linking.
+ */
+ setCursorPos(pos: CursorPos): Promise;
+ }
+ /**
+ * Options when creating access tokens.
+ */
+ export interface AccessTokenOptions {
+ /** Restrict use of token to reading only */
+ readOnly?: boolean;
+ }
+ /**
+ * Access token information, including the token string itself, a base URL for
+ * API calls for which the access token can be used, and the time-to-live the
+ * token was created with.
+ */
+ export interface AccessTokenResult {
+ /**
+ * The token string, which can currently be provided in an api call as a
+ * query parameter called "auth"
+ */
+ token: string;
+ /**
+ * The base url of the API for which the token can be used. Currently tokens
+ * are associated with a single document, so the base url will be something
+ * like 'https://..../api/docs/DOCID'
+ *
+ * Access tokens currently only grant access to endpoints dealing with the
+ * internal content of a document (such as tables and cells) and not its
+ * metadata (such as the document name or who it is shared with).
+ */
+ baseUrl: string;
+ /**
+ * Number of milliseconds the access token will remain valid for
+ * after creation. This will be several minutes.
+ */
+ ttlMsecs: number;
+ }
+}
+
+declare module 'grist/GristData' {
+ /**
+ * Letter codes for {@link CellValue} types encoded as [code, args...] tuples.
+ */
+ export enum GristObjCode {
+ List = "L",
+ LookUp = "l",
+ Dict = "O",
+ DateTime = "D",
+ Date = "d",
+ Skip = "S",
+ Censored = "C",
+ Reference = "R",
+ ReferenceList = "r",
+ Exception = "E",
+ Pending = "P",
+ Unmarshallable = "U",
+ Versions = "V"
+ }
+ /**
+ * Possible types of cell content.
+ *
+ * Each 'CellValue' may either be a primitive (e.g. 'true', '123', '"hello"', 'null')
+ * or a tuple (JavaScript Array) representing a Grist object. The first element of the tuple
+ * is a string character representing the object code. For example, '["L", "foo", "bar"]'
+ * is a 'CellValue' of a Choice List column, where '"L"' is the type, and '"foo"' and
+ * '"bar"' are the choices.
+ *
+ * ### Grist Object Types
+ *
+ * | Code | Type |
+ * | ---- | -------------- |
+ * | 'L' | List, e.g. '["L", "foo", "bar"]' or '["L", 1, 2]' |
+ * | 'l' | LookUp, as '["l", value, options]' |
+ * | 'O' | Dict, as '["O", {key: value, ...}]' |
+ * | 'D' | DateTimes, as '["D", timestamp, timezone]', e.g. '["D", 1704945919, "UTC"]' |
+ * | 'd' | Date, as '["d", timestamp]', e.g. '["d", 1704844800]' |
+ * | 'C' | Censored, as '["C"]' |
+ * | 'R' | Reference, as '["R", table_id, row_id]', e.g. '["R", "People", 17]' |
+ * | 'r' | ReferenceList, as '["r", table_id, row_id_list]', e.g. '["r", "People", [1,2]]' |
+ * | 'E' | Exception, as '["E", name, ...]', e.g. '["E", "ValueError"]' |
+ * | 'P' | Pending, as '["P"]' |
+ * | 'U' | Unmarshallable, as '["U", text_representation]' |
+ * | 'V' | Version, as '["V", version_obj]' |
+ */
+ export type CellValue = number | string | boolean | null | [GristObjCode, ...unknown[]];
+ export interface BulkColValues {
+ [colId: string]: CellValue[];
+ }
+ /**
+ * Map of column ids to {@link CellValue}'s.
+ */
+ export interface RowRecord {
+ id: number;
+ [colId: string]: CellValue;
+ }
+ /**
+ * Map of column ids to {@link CellValue} arrays, where array indexes correspond to
+ * rows.
+ */
+ export interface RowRecords {
+ id: number[];
+ [colId: string]: CellValue[];
+ }
+ export type GristType = 'Any' | 'Attachments' | 'Blob' | 'Bool' | 'Choice' | 'ChoiceList' | 'Date' | 'DateTime' | 'Id' | 'Int' | 'ManualSortPos' | 'Numeric' | 'PositionNumber' | 'Ref' | 'RefList' | 'Text';
+}
+
+declare module 'grist/RenderOptions' {
+ /**
+ * Where to append the content that a plugin renders.
+ *
+ * @internal
+ */
+ export type RenderTarget = "fullscreen" | number;
+ /**
+ * Options for the 'grist.render' function.
+ */
+ export interface RenderOptions {
+ height?: string;
+ }
+}
+
+declare module 'grist/TableOperations' {
+ import * as Types from 'grist/DocApiTypes';
+ /**
+ * Offer CRUD-style operations on a table.
+ */
+ export interface TableOperations {
+ /**
+ * Create a record or records.
+ */
+ create(records: Types.NewRecord, options?: OpOptions): Promise;
+ create(records: Types.NewRecord[], options?: OpOptions): Promise;
+ /**
+ * Update a record or records.
+ */
+ update(records: Types.Record | Types.Record[], options?: OpOptions): Promise;
+ /**
+ * Delete a record or records.
+ */
+ destroy(recordIds: Types.RecordId | Types.RecordId[]): Promise;
+ /**
+ * Add or update a record or records.
+ */
+ upsert(records: Types.AddOrUpdateRecord | Types.AddOrUpdateRecord[], options?: UpsertOptions): Promise;
+ /**
+ * Determine the tableId of the table.
+ */
+ getTableId(): Promise;
+ }
+ /**
+ * General options for table operations.
+ */
+ export interface OpOptions {
+ /** Whether to parse strings based on the column type. Defaults to true. */
+ parseStrings?: boolean;
+ }
+ /**
+ * Extra options for upserts.
+ */
+ export interface UpsertOptions extends OpOptions {
+ /** Permit inserting a record. Defaults to true. */
+ add?: boolean;
+ /** Permit updating a record. Defaults to true. */
+ update?: boolean;
+ /** Whether to update none, one, or all matching records. Defaults to "first". */
+ onMany?: 'none' | 'first' | 'all';
+ /** Allow "wildcard" operation. Defaults to false. */
+ allowEmptyRequire?: boolean;
+ }
+}
+
+declare module 'grist/WidgetAPI' {
+ /**
+ * API to manage Custom Widget state.
+ */
+ export interface WidgetAPI {
+ /**
+ * Gets all options stored by the widget. Options are stored as plain JSON object.
+ */
+ getOptions(): Promise