mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
* feat(widgets): add directory-search widget
An interactive, walk-up search view of an existing directory-board. It
references a source board by id (no data copy), so a venue can run the
scrolling board on a main screen and a search view on a tablet, letting
people find an entry instantly.
Server (routes/widgets.js):
- register 'directory-search' + renderDirectorySearch(): resolves the source
board, inlines its categories as one \u003c-guarded JSON blob, renders all
text via textContent (XSS-safe), live case-insensitive filter over
identifier/name/subtitle (debounced), grouped results, available styling,
optional touch on-screen QWERTY keyboard that drives the same filter path.
- missing / non-directory-board source -> friendly full-page fallback, not a 500.
- live-sync while open is out of scope; left a // TODO for a poll hook.
Frontend editor (views/widgets.js): type + magnifier icon, source-board
dropdown (from loaded widgets, filtered to directory-board), title, logo
(reuses the board's picker), placeholder text, theme, on-screen-keyboard toggle;
getConfigFromForm case. i18n: widget.dirsearch.* + type keys in en/es/it/de/pt/fr.
docs: openapi widget_type enum. Tests: server/test/directory-search.test.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(widgets): live-sync for directory-search (poll source board, no reload)
Reflect directory-board edits on an open directory-search page without a reload.
- New public GET /api/widgets/:id/data.json returns { categories } for a
directory-board (404 for missing/wrong-type so the page keeps last-good data
on a transient miss). CORS-open (ACAO:*) + no-store so a null-origin sandboxed
widget iframe can read it; exposes only data already public via /render.
Exempted from CSP + auth in server.js alongside /render.
- directory-search page inlines its source_widget_id and polls the board's
data.json every 30s via a relative URL (works behind a proxy/base path and
from a null-origin iframe). Only rebuilds + rerenders when the data actually
changed, so a mid-search view isn't disturbed; skips while document.hidden;
keeps last-good data on any fetch error. Flatten logic factored into
buildFlat() and reused by the poll.
Tests: data.json feed (categories, CORS header, 404s) + poll wiring in
directory-search.test.js (13/13). Verified live in a browser (page.clock
fast-forward): editing the board updates the search page with no reload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(android): let player WebViews take touch focus for interactive widgets
directory-search is served through the existing generic widget path
(loadUrl <server>/api/widgets/:id/render), so it already renders on Android
with JS + DOM storage + mixed-content enabled, same-origin (so its live-sync
fetch of the source board's data.json works), and no touch blocking.
Add isFocusable/isFocusableInTouchMode to the shared WebView config so the
search field reliably takes a tap/cursor inside the kiosk lock-task WebView.
Harmless for passive widgets (board/YouTube have no focusable inputs); the
widget's own on-screen keyboard still drives the filter when the system IME
is suppressed. Verified with :app:compileDebugKotlin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
117 lines
5.8 KiB
JavaScript
117 lines
5.8 KiB
JavaScript
'use strict';
|
|
|
|
// directory-search widget: references a directory-board by id and renders an
|
|
// interactive walk-up search page. Verifies the source board's entries are
|
|
// safely inlined for client-side filtering, that a missing/wrong source shows a
|
|
// friendly fallback (not a 500), and that entry/category text can't break out
|
|
// of the inlined <script> (it's set via textContent at runtime).
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const Database = require('better-sqlite3');
|
|
|
|
process.env.JWT_SECRET = 'test-secret-dirsearch';
|
|
|
|
const db = new Database(':memory:');
|
|
db.exec(`CREATE TABLE widgets (id TEXT PRIMARY KEY, widget_type TEXT, config TEXT, workspace_id TEXT);`);
|
|
const dbModulePath = require.resolve('../db/database');
|
|
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db } };
|
|
|
|
const express = require('express');
|
|
const widgetsRouter = require('../routes/widgets');
|
|
const app = express();
|
|
app.use('/api/widgets', widgetsRouter);
|
|
const server = app.listen(0);
|
|
let base;
|
|
test.before(async () => { await new Promise(r => server.listening ? r() : server.once('listening', r)); base = `http://127.0.0.1:${server.address().port}`; });
|
|
test.after(() => { server.close(); db.close(); });
|
|
|
|
const seed = (id, type, config) => db.prepare('INSERT INTO widgets (id, widget_type, config, workspace_id) VALUES (?,?,?,?)').run(id, type, JSON.stringify(config), 'ws1');
|
|
const fetchRender = async (id) => { const r = await fetch(`${base}/api/widgets/${id}/render`); return { status: r.status, html: await r.text() }; };
|
|
|
|
const BOARD = {
|
|
title: 'Lincoln Warehouse',
|
|
categories: [
|
|
{ name: 'First Floor', entries: [
|
|
{ identifier: '101', name: 'Acme Co', subtitle: 'Suite A', available: false },
|
|
{ identifier: '102', name: 'Available Unit', subtitle: '', available: true },
|
|
] },
|
|
{ name: 'Second Floor', entries: [
|
|
{ identifier: '201', name: 'Globex', subtitle: 'Logistics', available: false },
|
|
] },
|
|
],
|
|
};
|
|
|
|
test('directory-search renders a search page and inlines the source board entries', async () => {
|
|
seed('board1', 'directory-board', BOARD);
|
|
seed('search1', 'directory-search', { source_widget_id: 'board1', title: 'Find a Tenant', show_onscreen_keyboard: true });
|
|
const { status, html } = await fetchRender('search1');
|
|
assert.equal(status, 200);
|
|
assert.ok(html.includes('id="q"'), 'has a search input');
|
|
assert.ok(html.includes('id="results"'), 'has a results container');
|
|
assert.ok(html.includes('Acme Co') && html.includes('Globex') && html.includes('101'), 'source entries embedded for client-side filtering');
|
|
assert.ok(html.includes('Find a Tenant'), 'search title present');
|
|
});
|
|
|
|
test('show_onscreen_keyboard flag is carried into the page config', async () => {
|
|
seed('search_kb_off', 'directory-search', { source_widget_id: 'board1', show_onscreen_keyboard: false });
|
|
const { html } = await fetchRender('search_kb_off');
|
|
assert.ok(html.includes('"show_onscreen_keyboard":false'), 'keyboard flag inlined (page hides the keyboard when false)');
|
|
});
|
|
|
|
test('missing source -> friendly fallback page, not a 500', async () => {
|
|
seed('search_missing', 'directory-search', { source_widget_id: 'does-not-exist' });
|
|
const { status, html } = await fetchRender('search_missing');
|
|
assert.equal(status, 200);
|
|
assert.ok(html.includes('Directory source not found'), 'friendly message shown instead of an error');
|
|
});
|
|
|
|
test('non-directory-board source -> friendly fallback page', async () => {
|
|
seed('clockX', 'clock', {});
|
|
seed('search_wrongtype', 'directory-search', { source_widget_id: 'clockX' });
|
|
const { status, html } = await fetchRender('search_wrongtype');
|
|
assert.equal(status, 200);
|
|
assert.ok(html.includes('Directory source not found'), 'friendly message for a wrong source type');
|
|
});
|
|
|
|
test('XSS: entry/category text cannot break out of the inlined script', async () => {
|
|
seed('board_xss', 'directory-board', {
|
|
categories: [{
|
|
name: '</script><script>window.__pwned=1</script>',
|
|
entries: [{ identifier: '<img src=x onerror=alert(1)>', name: '"><b>bold</b>', subtitle: 'amp & lt < gt >', available: false }],
|
|
}],
|
|
});
|
|
seed('search_xss', 'directory-search', { source_widget_id: 'board_xss' });
|
|
const { status, html } = await fetchRender('search_xss');
|
|
assert.equal(status, 200);
|
|
assert.ok(!html.includes('</script><script>window.__pwned'), 'raw </script> breakout neutralized');
|
|
assert.ok(html.includes('\\u003c/script>'), 'angle brackets escaped in the inlined JSON blob');
|
|
});
|
|
|
|
// ---- live sync: GET /:id/data.json feed the search page polls ----
|
|
const fetchData = async (id) => fetch(`${base}/api/widgets/${id}/data.json`);
|
|
|
|
test('data.json returns the source board categories, CORS-open for polling', async () => {
|
|
const r = await fetchData('board1'); // seeded in the first test
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.headers.get('access-control-allow-origin'), '*', 'readable from a null-origin sandboxed iframe');
|
|
assert.equal(r.headers.get('cache-control'), 'no-store');
|
|
const body = await r.json();
|
|
assert.ok(Array.isArray(body.categories) && body.categories.length === 2, 'returns the categories array');
|
|
assert.equal(body.categories[0].entries[0].name, 'Acme Co');
|
|
});
|
|
|
|
test('data.json 404s for a missing widget (poll keeps last-good data)', async () => {
|
|
assert.equal((await fetchData('does-not-exist')).status, 404);
|
|
});
|
|
|
|
test('data.json 404s for a non-directory-board widget', async () => {
|
|
assert.equal((await fetchData('clockX')).status, 404); // clockX seeded earlier
|
|
});
|
|
|
|
test('search page wires the live-sync poll to its source board', async () => {
|
|
const { html } = await fetchRender('search1');
|
|
assert.ok(html.includes('"source_widget_id":"board1"'), 'source board id inlined into the page');
|
|
assert.ok(html.includes('/data.json'), 'page polls the data.json feed');
|
|
});
|