--- name: bricks-widget-development description: "Bricks widget creation patterns: layout, flexbox, events, build.sh, pitfalls" category: software-development --- ## Bricks Widget Development How to create new Bricks widgets for the Sage platform. Patterns extracted from DimensionFilter, SearchBar, and DataViewer development. ### Widget Skeleton Every widget: 1. Source file in `bricks/bricks/.js` 2. Register via `bricks.Factory.register('WidgetName', bricks.WidgetName)` 3. Add to `build.sh` SOURCES list 4. Rebuild: `cd bricks/bricks && ./build.sh` 5. CSS (if needed) in `bricks/bricks/css/bricks.css` ```javascript var bricks = window.bricks || {}; bricks.MyWidget = class extends bricks.VBox { constructor(opts){ opts.width = opts.width || '100%'; opts.height = opts.height || '100%'; super(opts); schedule_once(this.build_all.bind(this), 0.1); } async build_all(){ // Build subwidgets and add them via this.add_widget() // Load async data } }; bricks.Factory.register('MyWidget', bricks.MyWidget); ``` ### 🔴 Custom-widget option names MUST NOT collide with method names `opts_set_style()` (widget.js ~line 202) copies EVERY option key onto the instance: `this[okeys[k]] = this.opts[okeys[k]]`. So an option whose name matches a prototype method silently SHADOWS that method with a string/number, and the next `this.method()` call throws `TypeError: this. is not a function`. **Real failure (ResourceBrowser, 2026-08-12):** the option `tree_width: "280px"` collided with the method `tree_width()`. On build, `opts_set_style` set `this.tree_width = "280px"` (string), so `build_body`'s `this.tree_width()` threw `TypeError: this.tree_width is not a function` → the whole widget rendered empty. Fix: rename the METHOD to `get_tree_width()` (reads `this.opts.tree_width || '280px'`), never collide option names with method names. **Rule when authoring a composite widget:** if you add an option `foo`, do NOT also define a method `foo()`. Prefix getter methods (`get_foo()`) or rename the option. Verify after build: `grep -c "get_tree_width" ../dist/bricks.js` should be non-zero and the served `/bricks/bricks.js` must match (the bundle is served from `dist/bricks.js`; browser may cache it — use a cache-busting query on navigation or confirm the byte-size changed). ### 🔴 Composite widget: `buildBind` pollutes your option descriptors — strip `tools`/`binds` before `clone_descriptor` **Real failure (ResourceBrowser, 2026-08-13):** a composite widget whose options carry a `tools` (or `binds`) array builds those sub-widgets once at startup (`build_tools_row` → `widgetBuild`). `bricks.buildBind` (bricks.js ~line 210) MUTATES each bind descriptor: `desc.event_widget = widget` — a live widget reference. Since `this.browser_options.tools[i]` IS that same descriptor object (not a copy), it now holds a Button whose `dom_element.bricks_widget` points back to itself → **circular reference**. Later the widget calls `clone_descriptor(this.browser_options)` = `JSON.parse(JSON.stringify(desc))` → throws `TypeError: Converting circular structure to JSON` → the browser panel renders empty (`browser_panel.children === 0`, `current_id` already set but no content). **Fix — strip the polluted keys BEFORE cloning:** ```javascript var browser_copy = bricks.extend({}, this.browser_options); // shallow copy delete browser_copy.tools; // drop the polluted array var desc = this.clone_descriptor(browser_copy); // now stringify is safe ``` Apply the same `delete copy.tools` treatment to `tree_options` in `build_body` for symmetry. Rule: any descriptor you will later `JSON.stringify`-clone must never be the same object you handed to `widgetBuild` (which mutates it via `baseURI` and `event_widget`). **Diagnosis signature:** `JSON.stringify` circular-structure stack pointing at `bricks.Button` → `dom_element` → `bricks_widget`; and the target panel stays empty while the sub-widget build itself logged `idset=` successfully. ### 🔴 Declarative confirmation: the `conform` field on any bind (NO inline `confirm()`) Every bind descriptor accepts a `conform` option. When present, `universal_handler` (bricks.js) builds a `bricks.Conform` popup and only runs the action handler AFTER the user clicks "confirm" — no `window.confirm()`, no extra JS. This is the bricks-idiomatic way to satisfy "删除前让客户确认一下". ```json { "wid": "self", "event": "click", "actiontype": "script", "target": "self", "conform": { "title": "删除确认", "message": "确认删除选中的文件?", "conform": {"label": "删除"}, // overrides the confirm button label "discard": {"label": "取消"} // overrides the cancel button label }, "script": "/* runs only after confirm */" } ``` - `conform.title` / `conform.message` → PopupWindow title + `Conform` message (Conform reads `opts.message`). - `conform.conform` / `conform.discard` → override the two `IconTextBar` tool labels (defaults are i18n `'Conform'`/`'Discard'`). - The whole `conform` object is passed verbatim as the `Conform` widget's options (`widgetBuild({widgettype:'Conform', options:desc.conform})`), then `conform_widget.bind('conformed', handler)`. - Works with `script`, `urlwidget`, and any other `actiontype` — the confirmation wraps the handler uniformly. ### 🔴 Drag-drop file upload: `bricks.Droppable` + `filedrop` event `bricks.Droppable` (draggable.js) accepts OS file drops and dispatches a `filedrop` event. This is the ready-made upload zone — no manual `dragover`/`drop` listeners needed. ```json { "widgettype": "Droppable", "options": {"accepts": ["*"], "padding": "10px"}, "subwidgets": [{"widgettype": "Text", "options": {"text": "📥 拖拽文件到此处上传", "halign": "middle"}}], "binds": [{"wid": "self", "event": "filedrop", "actiontype": "script", "target": "self", "script": "var fs=event.params.files; ..."}] } ``` - `event.params.files` = `[{name, size, type, file}]` where `file` is the native `File` object. - `accepts: ['*']` accepts everything (`'*'.replace('*','.*')` → `'.*'` matches any MIME). - In the script, read the file via `FileReader.readAsDataURL(f)` to get a base64 `data:` URI for the backend (see form-encoded note below). ### 🔴 fetch→DSPY POST: use `URLSearchParams` (form-encoded), NOT a JSON body **Verified (2026-08-13):** a DSPY reading `params_kw.get('filename')` does **not** receive fields sent as a JSON body (`Content-Type: application/json`). `params_kw` came back empty (→ "文件名无效"). Sending the same fields as `application/x-www-form-urlencoded` (via `URLSearchParams` or `--data-urlencode`) **did** populate `params_kw`. For raw file bytes use `FormData` (multipart → ahserver auto-saves to `FileStorage`; see `references/agentinput-formdata-multipart.md`). ```javascript // ✅ RIGHT — form-encoded (works with params_kw) var body = new URLSearchParams(); body.append('id', folder_id); body.append('filename', f.name); body.append('filedata', b64); // base64 data: URI from FileReader var resp = await fetch(upload_url, {method:'POST', body: body}); // no Content-Type header var rj = await resp.json(); // ❌ WRONG — JSON body does NOT populate params_kw fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id:..., filename:..., filedata:...})}); ``` Full workspace file-browser (ResourceBrowser + tree + upload/open/delete tools + confirm + selectable file rows): `references/resourcebrowser-file-upload-tools.md`. **"打开/预览文件" dispatch** — text→Wterm+`.xterm` vi-edit, media→VideoPlayer/AudioPlayer/Image, office/pdf→download: DSPY returns `web.FileResponse` (stream + `Content-Disposition: attachment`), `.xterm` returns SSH `cmdargs:['vi', path]`, `websocket_url()` (not `entire_url()`) for the ws_url, config.json `processors` + nginx `/wss/` wiring. Full detail: `references/file-open-vi-media-download.md`. ### Layout: CSS Flexbox **Critical**: Bricks widgets use inline-block layout by default. `flexGrow`, `flex`, and `flexShrink` have NO effect unless the parent has `display: flex`. For proper height fill in VBox: ```javascript // In constructor or build method: this.dom_element.style.display = 'flex'; this.dom_element.style.flexDirection = 'column'; ``` For children to fill remaining space: ```javascript // Child that should expand: child.dom_element.style.flex = '1'; child.dom_element.style.overflow = 'hidden'; // Child that should keep fixed size: child.dom_element.style.flexShrink = '0'; ``` ### DynamicColumn & Card Grid ```javascript // Column layout for card grids "DynamicColumn": { "col_cwidth": 25, // character width per column "col_cgap": 1 // gap between columns } // Each card MUST match the column width: "VBox": { "cwidth": 25, // MUST equal col_cwidth "cheight": 16 // fixed height } ``` **Pitfall**: If `cwidth` != `col_cwidth`, cards overflow or shrink unexpectedly. **Pitfall**: `width: "100%"` does NOT work in DynamicColumn — use `cwidth`. ### SearchBar Events SearchBar dispatches `search` event on Enter or clear: ```javascript // Event handler receives {keyword: "search text"} this.searchbar.bind('search', this.search_handle.bind(this)); // In handler: search_handle(event){ var d = event.params || {}; var keyword = d.keyword || ''; } ``` ### Data Loading with HttpJson ```javascript var jc = new bricks.HttpJson(); var data = await jc.get(this.opts.data_url); // Expects: {rows: [...]} or {data: [...], total: N} ``` **Pitfall**: Don't use `bricks.jc` global — create new `HttpJson()` instance for proper session handling. ### urlwidget for Dynamic Content Areas ```javascript // Build a urlwidget that loads content from a server URL: var desc = { widgettype: 'urlwidget', options: { url: 'https://...', params: {key: value}, // added as query params method: 'GET' } }; var w = await bricks.widgetBuild(desc, this); if (w) { container.add_widget(w); } ``` **Pitfall**: `bricks.widgetBuild` is async — must `await`. Calling without await in a synchronous script bind causes silent failure (widget never renders). **🔴 `widgetBuild(desc, target)` RETURNS the widget — it does NOT insert it into `target`.** The second arg is only the *scope* for id/context resolution. After `widgetBuild`, you MUST `target.add_widget(returned_widget)` (or `clear_widgets()` first for a re-render). Calling `bricks.widgetBuild(d, fp)` alone and expecting `fp` to show content is the classic "panel stays empty" bug — the widget object is built (you'll see `idset=` logs and no JS error) but `fp.dom_element.innerHTML` stays `0` because nothing was attached. Correct re-render pattern: ```javascript fp.clear_widgets(); // or fp.dom_element.innerHTML = ''; var w = await bricks.widgetBuild(d, fp); // build with fp as scope if (w) fp.add_widget(w); // ← attach it — this is the step that renders ``` This is exactly what `ResourceBrowser.render_browser()` does internally. **🔴 PITFALL: `new bricks.urlwidget(...)` does NOT work.** `urlwidget` is NOT a registered Bricks class — `bricks.Factory.get('urlwidget')` returns null. It's handled as a special case inside `widgetBuild` (line 2271 in bricks.js: `while (klassname == 'urlwidget')` fetches and expands). Always use `bricks.widgetBuild({widgettype:'urlwidget', options:{url:'...'}}, parent)` — never `new bricks.urlwidget(...)`. Using `new` causes `TypeError: bricks.urlwidget is not a constructor`, crashing the entire script bind. **Real failure (cockpit project button):** `new bricks.urlwidget({url:'/api/task_detail.dspy?...',method:'GET'})` in an inline script caused the whole button click handler to silently fail. Fix: removed the urlwidget line and built task cards with plain `new bricks.VBox()` / `new bricks.Text()` widgets instead. ### 🔴 TabPanel 动态 tab(菜单项驱动 + 去重 + 立即显示) 原生 `bricks.TabPanel` 的动态 tab 支持是坏的,三处要修/覆盖:`add_tab()` 调了不存在的 `this.add_removeable`(实为 `toolbar.add_removable`,且 `createTool` 内部已处理)→ TypeError; `show_tabcontent()` 里 `cur_tab_name = name`(`name` 未定义);`add_tab` 不把 desc push 进 `opts.items`(导致 `show_tabcontent` 遍历不到动态 tab)。 **去重判断用 `opts.items`(不是 `content_buffer`)**——未登录时 urlwidget 请求 401、content 构建失败,`content_buffer` 存不进,按它判重会误判"不存在"而重复新增。 **🔴 `HttpJson.httpcall` 遇 401 会 `await` 等登录框销毁**(`withLoginInfo` 里 `await new Promise(r => login_window.bind('destroy', r))`)——若 `open_tab` 里 `await widgetBuild` 会卡死,tab 不切换不高亮("还需再点 tab")。修复:open_tab **同步**返回(先切 cur_tab + `toolbar.click` 高亮),content 交 `_load_tab_content` 异步加载。 **🔴 Html widget 内嵌 `` 会提前终止 HTML shell 外层 `