Restructure the shopping-list feature end to end:
- New public/plugins/shopping/ plugin with backend.php (proxy
routes registered via PluginManager::onApiRoute), shopping.js
(self-contained; injects the header Shopping button, owns the
picker dialog + sync loop via hyprcrd.hooks), plugin.json and a
placeholder css file. Core no longer knows about shopping —
index.php loses the static button and the proxy block, app.js
loses the initShopping wiring, the old js/modules/shopping.js
is gone.
- Phone view moves out of a standalone /shopping page and into
the relay UI as a regular `view-shopping` behind a nav-burger
entry (relay/ui/layout.php + app.js + style.css). Same session
auth as compose/chat/account — no separate login. Auto-refresh
every 4 s while the view is active; no manual refresh button.
Headings "# h1 … ###### h6" render between items for grouping.
Orphan section headings (no open items under them) are hidden.
- Sync: drop timestamp cursors (broke across SQLite-UTC / PHP
timezones and silently blocked pushes after a pull). Both sides
diff the (uid, title, body) triple instead — timezone-agnostic
and authoritative. Client ticks push-before-pull so fresh local
edits land on the relay before a stale pull can overwrite them.
Relay drops its "server newer" guard and stamps server times
with gmdate so anything left comparing to SQLite is UTC.
- Client also ticks on cards.reloaded, not just sync.tick, so
any edit path (editor save, checkbox toggle, context menu,
undo) pushes the update immediately.
- Tests: updated for the plugin (still uses the same selectors
because the plugin injects the same ids), plus a plugin-
registration check and "(no active list)" option assertion.
- Help section copy updated — references the Shopping header
button and the shopping tab in the relay UI rather than the
gone /shopping URL.
Both #einkauf and #shopping work as trigger tags; phone is
check-only, editing stays in hyprcrd. Version bumped to 1.9.21.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
105 lines
5.0 KiB
JavaScript
105 lines
5.0 KiB
JavaScript
import { describe, it, expect, $, click, sleep, csrf } from './runner.js?v=4';
|
|
|
|
// Shopping-plugin tests. Everything ships through the shopping plugin
|
|
// (backend.php registers /api/shopping*, shopping.js injects the
|
|
// header button and the picker dialog on load). Without a configured
|
|
// relay the backend still returns {ok:false,error:"relay not
|
|
// configured"} so we can assert shape.
|
|
|
|
describe('Shopping: plugin registration', () => {
|
|
it('registers itself in window.hyprcrd.plugins', () => {
|
|
const entries = window.hyprcrd?.plugins || [];
|
|
const entry = entries.find(p => p?.name === 'shopping');
|
|
expect(entry).toExist();
|
|
});
|
|
});
|
|
|
|
describe('Shopping: API proxy', () => {
|
|
it('GET /api/shopping returns an ok field', async () => {
|
|
const r = await fetch('api/shopping').then(x => x.json());
|
|
expect(typeof r.ok).toBe('boolean');
|
|
});
|
|
|
|
it('POST /api/shopping/check returns a JSON object', async () => {
|
|
const r = await fetch('api/shopping/check', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ line: '- [ ] nope', checked: true }),
|
|
}).then(x => x.json());
|
|
expect(r).toExist();
|
|
// The check endpoint is session-auth on the relay (phone-only),
|
|
// so hitting it via the client proxy returns an `error` field
|
|
// from the relay's unauthorized branch. Without a configured
|
|
// relay, it's `ok:false`. Either way it's a JSON object.
|
|
expect(typeof r.ok === 'boolean' || typeof r.error === 'string').toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Shopping: header button', () => {
|
|
it('Shopping button is injected into the header', () => {
|
|
const btn = $('#shopping-btn');
|
|
expect(btn).toExist();
|
|
// Lives inside the main header, not bolted onto the body.
|
|
expect(btn.closest('#main-header')).toExist();
|
|
});
|
|
|
|
it('clicking opens the picker dialog with save/close controls', async () => {
|
|
document.querySelector('.shopping-picker-overlay')?.remove();
|
|
click('#shopping-btn');
|
|
await sleep(60);
|
|
const overlay = document.querySelector('.shopping-picker-overlay');
|
|
expect(overlay).toExist();
|
|
expect(overlay.querySelector('.shopping-picker-save')).toExist();
|
|
expect(overlay.querySelector('.shopping-picker-cancel')).toExist();
|
|
overlay.querySelector('.shopping-picker-cancel').click();
|
|
await sleep(40);
|
|
expect(document.querySelector('.shopping-picker-overlay')).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('Shopping: picker tag matching', () => {
|
|
// Creates one card per trigger tag + one untagged control, then
|
|
// opens the picker and asserts the list. Both #einkauf and
|
|
// #shopping must qualify; untagged card must not.
|
|
it('lists both #einkauf and #shopping cards, excludes others', async () => {
|
|
const token = csrf();
|
|
const h = { 'Content-Type': 'application/json', 'X-CSRF-Token': token };
|
|
const hDel = { 'X-CSRF-Token': token };
|
|
let eink, shop, other;
|
|
try {
|
|
eink = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_einkauf__', body: '- [ ] milk #einkauf' }) }).then(r => r.json());
|
|
shop = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_shopping__', body: '- [ ] bread #shopping' }) }).then(r => r.json());
|
|
other = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_control__', body: 'plain card' }) }).then(r => r.json());
|
|
await window._reloadAll();
|
|
|
|
// Close any leftover overlay, then open the picker fresh.
|
|
document.querySelector('.shopping-picker-overlay')?.remove();
|
|
click('#shopping-btn');
|
|
await sleep(80);
|
|
|
|
const overlay = document.querySelector('.shopping-picker-overlay');
|
|
expect(overlay).toExist();
|
|
const listText = overlay.querySelector('#shopping-picker-list').textContent;
|
|
expect(listText).toContain('__test_shop_einkauf__');
|
|
expect(listText).toContain('__test_shop_shopping__');
|
|
// Non-tagged control must be absent.
|
|
const controlShown = listText.includes('__test_shop_control__');
|
|
expect(controlShown).toBe(false);
|
|
|
|
// Picker always offers a "no active list" option so the
|
|
// user can stop syncing without leaving the dialog.
|
|
const radios = overlay.querySelectorAll('input[name="shopping-active"]');
|
|
const hasEmpty = [...radios].some(r => r.value === '');
|
|
expect(hasEmpty).toBe(true);
|
|
|
|
overlay.querySelector('.shopping-picker-cancel').click();
|
|
await sleep(40);
|
|
} finally {
|
|
for (const c of [eink, shop, other]) {
|
|
if (c?.id) await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: hDel });
|
|
}
|
|
await window._reloadAll();
|
|
}
|
|
});
|
|
});
|