From e3df75edffca0eab79feadfe89b294dfd1b2b774 Mon Sep 17 00:00:00 2001 From: Derek Miller <1340081+derek-miller@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:47:57 -0600 Subject: [PATCH 1/5] feat(ui): the kit holds everything the custom tabs repeat: the house look, banners, tabs, the variable browser and the expression editor --- Makefile | 8 +- copier.yml | 8 +- template/CONTRIBUTING.md.jinja | 92 +-- template/src/lib/expressions.lua | 164 +---- template/src/lib/ui/devices.lua | 342 +++++++++++ template/src/lib/ui/expression-tab.lua | 85 +++ template/src/lib/ui/expressions-demo.js | 635 ++++++++++++++++++++ template/src/lib/ui/expressions.css | 68 +++ template/src/lib/ui/expressions.js | 523 ++++++++++++++++ template/src/lib/ui/page-params.lua | 22 +- template/src/lib/ui/page.js | 447 +++++++++++--- template/src/lib/ui/page.lua | 17 + template/src/lib/ui/style.css | 110 +++- template/src/lib/ui/tab-properties.lua | 23 +- template/src/lib/ui/variables.css | 54 ++ template/src/lib/ui/variables.js | 468 +++++++++++++++ template/test/test_expressions.lua | 32 +- template/test/test_ui_devices.lua | 322 ++++++++++ template/test/test_ui_expression_tab.lua | 130 ++++ template/test/test_ui_page_params.lua | 3 + template/test/test_ui_pages.lua | 131 +++- template/test/test_ui_tab_properties.lua | 25 + tests/build_checks.py | 39 +- tests/template-renders/ui-expressions.files | 130 ++++ tests/template-renders/ui-expressions.yml | 2 + tests/template-renders/ui.files | 5 + tests/ui_expressions_demo_js.test.mjs | 238 ++++++++ tests/ui_expressions_js.test.mjs | 469 +++++++++++++++ tests/ui_page_js.test.mjs | 290 +++++++-- tests/ui_variables_js.test.mjs | 504 ++++++++++++++++ 30 files changed, 4993 insertions(+), 393 deletions(-) create mode 100644 template/src/lib/ui/devices.lua create mode 100644 template/src/lib/ui/expression-tab.lua create mode 100644 template/src/lib/ui/expressions-demo.js create mode 100644 template/src/lib/ui/expressions.css create mode 100644 template/src/lib/ui/expressions.js create mode 100644 template/src/lib/ui/page.lua create mode 100644 template/src/lib/ui/variables.css create mode 100644 template/src/lib/ui/variables.js create mode 100644 template/test/test_ui_devices.lua create mode 100644 template/test/test_ui_expression_tab.lua create mode 100644 tests/template-renders/ui-expressions.files create mode 100644 tests/template-renders/ui-expressions.yml create mode 100644 tests/ui_expressions_demo_js.test.mjs create mode 100644 tests/ui_expressions_js.test.mjs create mode 100644 tests/ui_variables_js.test.mjs diff --git a/Makefile b/Makefile index 1bd0df1..d48b591 100644 --- a/Makefile +++ b/Makefile @@ -57,9 +57,13 @@ render-lib-loads: ## Check every src/lib module in the render loads render-crypto: ## Check the render's c4_shim resolves its FFI crypto backend "$(RENDER)/test/run_test.sh" "$(CURDIR)/tests/ffi_crypto_backend.lua" -render-ui-js: ## Check the render's lib/ui/page.js under Node, where the render has one +render-ui-js: ## Check the render's lib/ui scripts under Node, each one the render ships @if [ -f "$(RENDER)/src/lib/ui/page.js" ]; then \ - UI_PAGE_JS="$(RENDER)/src/lib/ui/page.js" node --test tests/ui_page_js.test.mjs; \ + for kit in page variables expressions expressions-demo; do \ + if [ -f "$(RENDER)/src/lib/ui/$$kit.js" ]; then \ + UI_DIR="$(RENDER)/src/lib/ui" node --test "tests/ui_$$(echo $$kit | tr - _)_js.test.mjs" || exit 1; \ + fi; \ + done; \ else echo "This render has no lib/ui."; fi RENDER_LEG := render render-snapshot render-lib-loads render-crypto render-ui-js in-render-test diff --git a/copier.yml b/copier.yml index 532bd08..e997e4e 100644 --- a/copier.yml +++ b/copier.yml @@ -38,6 +38,12 @@ _exclude: - "{% if 'ui' not in vendor_modules.split() %}test/test_ui_tab_properties.lua{% endif %}" - "{% if 'ui' not in vendor_modules.split() %}test/test_ui_page_params.lua{% endif %}" - "{% if 'ui' not in vendor_modules.split() %}test/test_ui_pages.lua{% endif %}" + - "{% if 'ui' not in vendor_modules.split() %}test/test_ui_devices.lua{% endif %}" + - "{% if 'ui' not in vendor_modules.split() or 'expressions' not in vendor_modules.split() %}src/lib/ui/expressions.js{% endif %}" + - "{% if 'ui' not in vendor_modules.split() or 'expressions' not in vendor_modules.split() %}src/lib/ui/expressions.css{% endif %}" + - "{% if 'ui' not in vendor_modules.split() or 'expressions' not in vendor_modules.split() %}src/lib/ui/expressions-demo.js{% endif %}" + - "{% if 'ui' not in vendor_modules.split() or 'expressions' not in vendor_modules.split() %}src/lib/ui/expression-tab.lua{% endif %}" + - "{% if 'ui' not in vendor_modules.split() or 'expressions' not in vendor_modules.split() %}test/test_ui_expression_tab.lua{% endif %}" - "{% if not cloud_diagnostics %}src/lib/diagnostics.lua{% endif %}" - "{% if not cloud_diagnostics %}test/test_diagnostics_reporting.lua{% endif %}" - "{% if not cloud_diagnostics %}test/test_diagnostics_scrub.lua{% endif %}" @@ -85,7 +91,7 @@ icon_style: vendor_modules: type: str - help: "Space-separated optional vendor modules (available: bitn crypto bthome noiseprotocol protobuf oauth expressions ui). Note: the crypto/noiseprotocol/protobuf/bthome bundles are core builds that require bitn on the path; include bitn alongside them (crypto also for noiseprotocol). oauth ships Control4's OAuth authorization-code grant (drivers-common-public module/auth_code_grant.lua) and its short-link helper (global/make_short_link.lua). ui ships the kit for a custom Composer tab in src/lib/ui: lib.ui.tab-properties, lib.ui.page-params, and style.css and page.js for a page to embed." + help: "Space-separated optional vendor modules (available: bitn crypto bthome noiseprotocol protobuf oauth expressions ui). Note: the crypto/noiseprotocol/protobuf/bthome bundles are core builds that require bitn on the path; include bitn alongside them (crypto also for noiseprotocol). oauth ships Control4's OAuth authorization-code grant (drivers-common-public module/auth_code_grant.lua) and its short-link helper (global/make_short_link.lua). ui ships the kit for a custom Composer tab in src/lib/ui: the house style.css and page.js for a page to embed, the variable browser (variables.js and .css, lib.ui.devices), with expressions the expression editor (expressions.js and .css, lib.ui.expression-tab), and the driver side (lib.ui.page, lib.ui.page-params, lib.ui.tab-properties)." default: "" cloud_diagnostics: diff --git a/template/CONTRIBUTING.md.jinja b/template/CONTRIBUTING.md.jinja index 3ea9223..e6cb8ea 100644 --- a/template/CONTRIBUTING.md.jinja +++ b/template/CONTRIBUTING.md.jinja @@ -825,13 +825,26 @@ such as `drivers/tplink_light/www/documentation/index.md` in ### Custom Composer Tabs -`src/lib/ui/` is the kit for a driver's custom Composer tab: the shared look -(`style.css`), the page's link to its driver, the tab bar, demo mode and a card -of the driver's properties (`page.js`), and the driver's side of that card -(`lib.ui.tab-properties` and `lib.ui.page-params`). +`src/lib/ui/` holds everything a driver's custom Composer tab shares with the +others, so a page keeps only its own views and its driver only its own commands: + +- `style.css`: the house look, from the tab bar and cards to form fields, + tables, chips and the segmented switch +- `page.js`: the page's link to its driver, the live-link and demo banners, the + tabs, the properties card and demo mode +- `variables.js` and `variables.css`: the variable browser, with + `lib.ui.devices` on the driver's side +{%- if 'expressions' in vendor_modules.split() %} +- `expressions.js` and `expressions.css`: the expression editor, with its + highlighting, help and live preview, and `lib.ui.expression-tab` on the + driver's side; `expressions-demo.js` adds previews in demo mode +{%- endif %} +- `lib.ui.page`, `lib.ui.page-params` and `lib.ui.tab-properties`: the driver's + side of the link and the properties card -A page embeds the stylesheet first in its `") do + for _, selector in ipairs(selectors(style)) do + -- A page's own tokens go in :root beside the kit's. + if kitSelectors[selector] and selector ~= ":root" then + copies[#copies + 1] = selector + end end end + T.eq(page .. " restyles nothing the kit styles", copies, {}) end end print(checked .. " of " .. #pages .. " pages embed the kit") diff --git a/template/test/test_ui_tab_properties.lua b/template/test/test_ui_tab_properties.lua index 73fee49..c0d73cb 100644 --- a/template/test/test_ui_tab_properties.lua +++ b/template/test/test_ui_tab_properties.lua @@ -118,4 +118,29 @@ OPC.Driver_Version("1") T.eq("so does a read-only one, which only the driver changes", pushed, 3) T.falsy("a password gets no push", OPC.API_Token) +T.section("serve") +local sent = {} +function C4:SendDataToUI(command, params) + sent[#sent + 1] = { command = command, data = JSON:decode(params.json) } +end +--- A command's DATA as lib/ui/page.js sends it. +local function page(params) + return { DATA = C4:Base64Encode(JSON:encode(params)) } +end +tabProperties:serve() +UIR._GET_PROPERTIES() +T.eq("the card's ask is answered with PROPERTIES", sent[1].command, "PROPERTIES") +T.eq("carrying the list, as one JSON parameter", #sent[1].data.properties, 5) +sent = {} +UIR._SET_PROPERTY(page({ property = "Mode", value = "Auto" })) +T.eq("a change it can make is made", Properties.Mode, "Auto") +T.eq("with nothing sent back", #sent, 0) +UIR._SET_PROPERTY(page({ property = "Mode", value = "Slow" })) +T.eq("a change it refuses goes back as PROPERTY_ERROR", sent[1].command, "PROPERTY_ERROR") +T.eq("naming the property", sent[1].data.property, "Mode") +T.eq("and why", sent[1].data.error, "Mode has no choice Slow.") +sent = {} +UIR._SET_PROPERTY({ DATA = "not base64 json" }) +T.eq("a body that does not decode is refused as no property", sent[1].data.error, "The tab cannot set nil.") + T.finish() diff --git a/tests/build_checks.py b/tests/build_checks.py index 3dbb4d9..532b021 100644 --- a/tests/build_checks.py +++ b/tests/build_checks.py @@ -31,24 +31,43 @@ DIAGNOSTICS = Path("src") / "lib" / "diagnostics.lua" UI_KIT = Path("src") / "lib" / "ui" UI_PAGE = EXAMPLE / "www" / "html" / "index.html" +# A probe page that embeds the kit and each component the render ships. UI_PAGE_HTML = """
""" +UI_FILES = ("style.css", "variables.css", "expressions.css", "page.js", "variables.js", "expressions.js", "expressions-demo.js") + + +def kit_as_built(text, build): + """A kit file as a build ships it: its directive lines go, and a DriverCentral build drops what #ifndef + DRIVERCENTRAL holds (the dev login).""" + out, skip = [], False + for line in text.splitlines(keepends=True): + directive = line.strip() + if directive == "// #ifndef DRIVERCENTRAL": + skip = build == "drivercentral" + continue + if directive == "// #endif": + skip = False + continue + if not skip: + out.append(line) + return "".join(out) EXAMPLE_SECRET = "EXAMPLE_SECRET" EXAMPLE_SECRET_VALUE = "ci-secret-value" @@ -341,11 +360,17 @@ def ui_page(render): if not (render / UI_KIT).is_dir(): print("This render has no lib/ui.") return 0 - kit = {name: (render / UI_KIT / name).read_text(encoding="utf-8") for name in ("style.css", "page.js")} + names = [name for name in UI_FILES if (render / UI_KIT / name).exists()] + kit = {name: (render / UI_KIT / name).read_text(encoding="utf-8") for name in names} + embed = "".format + html = UI_PAGE_HTML.format( + styles="\n".join(embed(n) for n in names if n.endswith(".css")), + scripts="\n".join(embed(n) for n in names if n.endswith(".js")), + ) problems = [] with restored(render, UI_PAGE): (render / UI_PAGE).parent.mkdir(parents=True, exist_ok=True) - (render / UI_PAGE).write_text(UI_PAGE_HTML, encoding="utf-8") + (render / UI_PAGE).write_text(html, encoding="utf-8") # The render's own page test finds the probe and resolves its embeds. run(["test/run_test.sh", "test_ui_pages.lua"], render) run(["make", "build-nodocs"], render) @@ -360,9 +385,11 @@ def ui_page(render): if re.search(r"^\s*