@loadbare/app 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,7 +38,7 @@
38
38
  * landing reaches in there as it reaches everywhere else, so a branch that
39
39
  * returns is already current. Nothing but landing reaches in.
40
40
  */
41
- import { AGGREGATES, ATTR_COLUMN, ATTR_FILTER, ATTR_ORDER, CHOICES_QUERY, LB_RESERVED_PREFIX, ATTR_COLUMN_VALUE, ATTR_COUNT, ATTR_GROUP, ATTR_GROUP_COLUMN, ATTR_GROUP_LEAVING, ATTR_GROUP_LIVE, ATTR_GROUP_VALUE, ATTR_KEY_VALUE, ATTR_MAX, ATTR_MIN, ATTR_QUERY, ATTR_QUERY_ROW_COUNT, ATTR_ROW_CHANGED, ATTR_ROW_CREATED, ATTR_ROW_LEAVING, ATTR_ROW_LIVE, ATTR_ROW_MOVED, ATTR_ROW_REQUESTED, ATTR_SHOW, ATTR_SUM, KIND_ROW, LIVE_GROUP, LIVE_ROW, REQUESTED_CHANGED, REQUESTED_CREATED, REQUESTED_MOVED, SHOW_NOT, ATTR_SELECT, ATTR_ROW_SELECTED, } from "../core/lb-constants.js";
41
+ import { AGGREGATES, ATTR_COLUMN, ATTR_FILTER, ATTR_ORDER, CHOICES_QUERY, LB_RESERVED_PREFIX, ATTR_COLUMN_VALUE, ATTR_COUNT, ATTR_GROUP, ATTR_GROUP_COLUMN, ATTR_GROUP_LEAVING, ATTR_GROUP_LIVE, ATTR_GROUP_VALUE, ATTR_KEY_VALUE, ATTR_MAX, ATTR_MIN, ATTR_QUERY, ATTR_QUERY_ORDER, ATTR_QUERY_ROW_COUNT, ATTR_QUERY_UNSORTED_COUNT, ATTR_ROW_CHANGED, ATTR_ROW_CREATED, ATTR_ROW_LEAVING, ATTR_ROW_LIVE, ATTR_ROW_MOVED, ATTR_ROW_REQUESTED, ATTR_ROW_UNSORTED, ATTR_SHOW, ATTR_SUM, ATTR_URL_PARM, KIND_ROW, LIVE_GROUP, LIVE_ROW, ORDER_DESCENDING, REQUESTED_CHANGED, REQUESTED_CREATED, REQUESTED_MOVED, SHOW_NOT, ATTR_SELECT, ATTR_ROW_SELECTED, } from "../core/lb-constants.js";
42
42
  import { compareRows, compareValues, isPatch, parseFilter, parseOrder, passes, readAs, } from "../core/lb-types.js";
43
43
  import { attachPlaced, detachRemoved, LEAVING, PARKED, placed, upgradeAbsent, } from "./lb-lifecycle.js";
44
44
  /** A DocumentFragment's nodeType. Tier 3 has no `Node` global to read it from. */
@@ -325,6 +325,8 @@ function note(places, el) {
325
325
  const column = el.getAttribute(ATTR_COLUMN);
326
326
  if (column !== null)
327
327
  addPlace(places.columns, column, el);
328
+ if (el.hasAttribute(ATTR_URL_PARM))
329
+ places.links.push(el);
328
330
  const show = el.getAttribute(ATTR_SHOW);
329
331
  if (show === null)
330
332
  return;
@@ -337,7 +339,20 @@ function note(places, el) {
337
339
  addPlace(places.shows, show, el);
338
340
  }
339
341
  function noPlaces() {
340
- return { columns: new Map(), shows: new Map() };
342
+ return { columns: new Map(), shows: new Map(), links: [] };
343
+ }
344
+ /** An element a row lands on, in any way. */
345
+ const PLACE = `[${ATTR_COLUMN}], [${ATTR_SHOW}], [${ATTR_URL_PARM}]`;
346
+ /**
347
+ * Note a row's root among its places: its own `lb-column` and `lb-url-parm`
348
+ * count, as the row is its own. Its own `lb-show` never does.
349
+ */
350
+ function noteRoot(places, root) {
351
+ if (root.hasAttribute(ATTR_COLUMN)) {
352
+ addPlace(places.columns, root.getAttribute(ATTR_COLUMN), root);
353
+ }
354
+ if (root.hasAttribute(ATTR_URL_PARM))
355
+ places.links.push(root);
341
356
  }
342
357
  /**
343
358
  * The places in root's query, found by searching: root's own `lb-column`
@@ -346,12 +361,10 @@ function noPlaces() {
346
361
  */
347
362
  function search(root, self, stop) {
348
363
  const places = noPlaces();
349
- if (self && root.hasAttribute(ATTR_COLUMN)) {
350
- addPlace(places.columns, root.getAttribute(ATTR_COLUMN), root);
351
- }
352
- for (const el of below(root, `[${ATTR_COLUMN}], [${ATTR_SHOW}]`, stop)) {
364
+ if (self)
365
+ noteRoot(places, root);
366
+ for (const el of below(root, PLACE, stop))
353
367
  note(places, el);
354
- }
355
368
  return places;
356
369
  }
357
370
  function kids(el) {
@@ -372,9 +385,8 @@ function compile(row) {
372
385
  for (let i = 0; i < children.length; i++) {
373
386
  const child = children[i];
374
387
  const at = [...path, i];
375
- if (child.hasAttribute(ATTR_COLUMN) || child.hasAttribute(ATTR_SHOW)) {
388
+ if (child.matches(PLACE))
376
389
  paths.push(at);
377
- }
378
390
  if (child.hasAttribute(ATTR_QUERY))
379
391
  continue;
380
392
  if (child.localName === "template" && !child.hasAttribute(ATTR_SHOW)) {
@@ -393,9 +405,7 @@ function compile(row) {
393
405
  */
394
406
  function placesBy(row, paths) {
395
407
  const places = noPlaces();
396
- if (row.hasAttribute(ATTR_COLUMN)) {
397
- addPlace(places.columns, row.getAttribute(ATTR_COLUMN), row);
398
- }
408
+ noteRoot(places, row);
399
409
  for (const path of paths) {
400
410
  let el = row;
401
411
  for (const i of path)
@@ -446,7 +456,71 @@ function fill(places, row) {
446
456
  * `stop` passes over everything matching it and inside it.
447
457
  */
448
458
  export function applyRow(root, row, self = false, stop) {
449
- fill(search(root, self, stop), row);
459
+ const places = search(root, self, stop);
460
+ fill(places, row);
461
+ link(places, row);
462
+ }
463
+ /** The `href` each link was written with, which every landing starts from. */
464
+ const bases = new WeakMap();
465
+ /** Columns already reported as missing from a row, or as no parm's value. */
466
+ const unlinked = new Set();
467
+ /** The columns an element's `lb-url-parm` names. */
468
+ function parmsOf(el) {
469
+ return (el.getAttribute(ATTR_URL_PARM) ?? "")
470
+ .split(",")
471
+ .map((column) => column.trim())
472
+ .filter((column) => column !== "");
473
+ }
474
+ /**
475
+ * Write each link's `href` from the whole row: the `href` as written, with
476
+ * each column its `lb-url-parm` names as the parm of the same name. A null,
477
+ * empty or missing column leaves its parm off, as an empty value takes a
478
+ * parm out of the URL.
479
+ */
480
+ function link(places, row) {
481
+ if (places.links.length === 0)
482
+ return;
483
+ // A row with no values is a blank, not a row missing its columns.
484
+ const real = Object.values(row).some((v) => v !== null && v !== undefined);
485
+ for (const el of places.links) {
486
+ let base = bases.get(el);
487
+ if (base === undefined) {
488
+ base = el.getAttribute("href") ?? "";
489
+ bases.set(el, base);
490
+ }
491
+ const hashAt = base.indexOf("#");
492
+ const hash = hashAt < 0 ? "" : base.slice(hashAt);
493
+ const rest = hashAt < 0 ? base : base.slice(0, hashAt);
494
+ const queryAt = rest.indexOf("?");
495
+ const path = queryAt < 0 ? rest : rest.slice(0, queryAt);
496
+ const parms = new URLSearchParams(queryAt < 0 ? "" : rest.slice(queryAt));
497
+ for (const column of parmsOf(el)) {
498
+ const value = row[column];
499
+ if (value === undefined && real && !unlinked.has(column)) {
500
+ unlinked.add(column);
501
+ console.warn(`lb-hub: <a ${ATTR_URL_PARM}> names '${column}', which the row ` +
502
+ `around it does not have`);
503
+ }
504
+ const atomic = typeof value === "string" ||
505
+ typeof value === "number" ||
506
+ typeof value === "boolean";
507
+ if (!atomic && value !== null && value !== undefined) {
508
+ if (!unlinked.has(column)) {
509
+ unlinked.add(column);
510
+ console.warn(`lb-hub: '${column}' is no value a query parm can hold, and is ` +
511
+ `left off the link`);
512
+ }
513
+ }
514
+ if (!atomic || value === "")
515
+ parms.delete(column);
516
+ else
517
+ parms.set(column, String(value));
518
+ }
519
+ const query = parms.toString();
520
+ const href = `${path}${query ? `?${query}` : ""}${hash}`;
521
+ if (el.getAttribute("href") !== href)
522
+ el.setAttribute("href", href);
523
+ }
450
524
  }
451
525
  /**
452
526
  * A row naming every column root's query shows, each null: what landing it
@@ -582,7 +656,9 @@ function listFor(el, template) {
582
656
  el: row,
583
657
  places: search(row, true),
584
658
  data: {},
659
+ placed: {},
585
660
  hidden: false,
661
+ unsorted: false,
586
662
  };
587
663
  list.byKey.set(key, entry);
588
664
  list.root.rows.push(entry);
@@ -620,7 +696,9 @@ function create(list, doc, key) {
620
696
  el,
621
697
  places: placesBy(el, list.paths),
622
698
  data: {},
699
+ placed: {},
623
700
  hidden: false,
701
+ unsorted: false,
624
702
  };
625
703
  list.byKey.set(key, entry);
626
704
  return entry;
@@ -722,7 +800,7 @@ function arrange(list, doc, order, terms) {
722
800
  const term = terms[level];
723
801
  const runs = [];
724
802
  for (const entry of entries) {
725
- const value = term ? entry.data[term.column] : null;
803
+ const value = term ? entry.placed[term.column] : null;
726
804
  const id = groupId(term?.column ?? "", value);
727
805
  const last = runs.at(-1);
728
806
  if (last?.id === id)
@@ -936,14 +1014,15 @@ function hide(el, hidden) {
936
1014
  }
937
1015
  /**
938
1016
  * Finish a group and everything under it, from the rows it shows: each
939
- * heading filled from the first, each aggregate set from all of them, and a
940
- * group hidden when a filter hides every row in it. The rows it shows are
941
- * returned.
1017
+ * heading filled from the first, as it was when last placed by the order,
1018
+ * each aggregate set from all of them, and a group hidden when a filter hides
1019
+ * every row in it. The rows it shows are returned.
942
1020
  */
943
1021
  function finish(group, types) {
944
- const rows = group.children.length > 0
1022
+ const shown = group.children.length > 0
945
1023
  ? group.children.flatMap((child) => finish(child, types))
946
- : group.rows.filter((entry) => !entry.hidden).map((entry) => entry.data);
1024
+ : group.rows.filter((entry) => !entry.hidden);
1025
+ const rows = shown.map((entry) => entry.data);
947
1026
  const hidden = rows.length === 0 && rowsUnder(group).length > 0;
948
1027
  if (hidden !== group.hidden) {
949
1028
  group.hidden = hidden;
@@ -953,11 +1032,11 @@ function finish(group, types) {
953
1032
  }
954
1033
  }
955
1034
  }
956
- if (rows[0])
1035
+ if (shown[0])
957
1036
  for (const head of group.heads)
958
- fill(head, rows[0]);
1037
+ fill(head, shown[0].placed);
959
1038
  aggregate(group.aggregates, rows, types);
960
- return rows;
1039
+ return shown;
961
1040
  }
962
1041
  /** The order to place rows by, or none when it cannot be read. */
963
1042
  function termsOf(el, order) {
@@ -969,6 +1048,12 @@ function termsOf(el, order) {
969
1048
  return [];
970
1049
  }
971
1050
  }
1051
+ /** An order's terms written back as its text. */
1052
+ function orderText(terms) {
1053
+ return terms
1054
+ .map((term) => (term.descending ? ORDER_DESCENDING : "") + term.column)
1055
+ .join(",");
1056
+ }
972
1057
  /** The user's choice in a column of `lb-choices`, when one is made. */
973
1058
  function choiceIn(choices, column) {
974
1059
  const value = choices?.[column];
@@ -1044,6 +1129,11 @@ function differs(before, after) {
1044
1129
  * rows go last. A row that stands where it stood is not touched, so landing
1045
1130
  * the same rows again moves nothing. A row a filter the user chose does not
1046
1131
  * pass is `hidden`.
1132
+ *
1133
+ * A landing `held` for the user working in the list moves nothing already
1134
+ * showing, whole or a patch. A row it changed or created that the order
1135
+ * would put elsewhere, or that the filter would hide, stays and is stamped
1136
+ * unsorted until the rows are next placed by the order.
1047
1137
  */
1048
1138
  export function applyRows(el, template, keyColumn, result, options = {}) {
1049
1139
  const list = listFor(el, template);
@@ -1100,6 +1190,10 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1100
1190
  if (fresh || options.refill || changed.has(entry))
1101
1191
  fill(entry.places, data);
1102
1192
  entry.data = { ...entry.data, ...data };
1193
+ // A link reads the whole row, which a patch may send only part of.
1194
+ if (fresh || options.refill || changed.has(entry)) {
1195
+ link(entry.places, entry.data);
1196
+ }
1103
1197
  }
1104
1198
  for (const entry of gone) {
1105
1199
  list.byKey.delete(entry.key);
@@ -1109,9 +1203,6 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1109
1203
  // already showing with what is new after it. A sort keeps rows that
1110
1204
  // compare equal in this order.
1111
1205
  const stayed = shown.filter((entry) => list.byKey.get(entry.key) === entry);
1112
- let order = whole
1113
- ? [...arriving.keys()].map((key) => list.byKey.get(key))
1114
- : [...stayed, ...created];
1115
1206
  const terms = termsOf(el, orderFor(el, options));
1116
1207
  if (list.levels.length > terms.length && !warned.has(el)) {
1117
1208
  warned.add(el);
@@ -1119,16 +1210,48 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1119
1210
  `template(s) and an order of ${terms.length} term(s); a level with ` +
1120
1211
  `no term shows its rows as one group`);
1121
1212
  }
1122
- if (terms.length > 0) {
1123
- order = order
1213
+ const sort = (entries) => terms.length === 0
1214
+ ? entries
1215
+ : entries
1124
1216
  .map((entry, i) => ({ entry, i }))
1125
1217
  .sort((a, b) => compareRows(a.entry.data, b.entry.data, terms, options.types) ||
1126
1218
  a.i - b.i)
1127
1219
  .map(({ entry }) => entry);
1128
- }
1129
1220
  const passing = filterFor(el, options.choices, options.types ?? {});
1130
- for (const entry of order)
1131
- filterRow(entry, !passing(entry.data));
1221
+ let order;
1222
+ if (options.held) {
1223
+ // The user is working in this list: what already shows stays where it
1224
+ // stands, and what is new goes last in the group it belongs to.
1225
+ for (const entry of created)
1226
+ entry.placed = entry.data;
1227
+ order = heldOrder(stayed, created, groupTerms(list, terms));
1228
+ const touched = new Set([...changed, ...created]);
1229
+ for (const entry of order)
1230
+ if (entry.unsorted)
1231
+ touched.add(entry);
1232
+ const out = movedRows(order, sort(order), touched);
1233
+ for (const entry of order) {
1234
+ const fails = !passing(entry.data);
1235
+ // A row the user just changed does not vanish from under them.
1236
+ const kept = touched.has(entry) && !entry.hidden;
1237
+ filterRow(entry, fails && !kept);
1238
+ // A row stands in the group it was placed in until it is placed again.
1239
+ const moves = regrouped(entry, list, terms);
1240
+ if (!moves)
1241
+ entry.placed = entry.data;
1242
+ entry.unsorted = out.has(entry) || (kept && fails) || moves;
1243
+ }
1244
+ }
1245
+ else {
1246
+ order = sort(whole
1247
+ ? [...arriving.keys()].map((key) => list.byKey.get(key))
1248
+ : [...stayed, ...created]);
1249
+ for (const entry of order) {
1250
+ entry.placed = entry.data;
1251
+ entry.unsorted = false;
1252
+ filterRow(entry, !passing(entry.data));
1253
+ }
1254
+ }
1132
1255
  const moved = movedRows(stayed, order, changed);
1133
1256
  const retired = arrange(list, doc, order, terms);
1134
1257
  refocus(list, focused);
@@ -1152,9 +1275,22 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1152
1275
  }
1153
1276
  }
1154
1277
  stamps.done(doc);
1278
+ let unsorted = 0;
1279
+ for (const entry of order) {
1280
+ if (entry.el.hasAttribute(ATTR_ROW_UNSORTED) !== entry.unsorted) {
1281
+ entry.el.toggleAttribute(ATTR_ROW_UNSORTED, entry.unsorted);
1282
+ }
1283
+ if (entry.unsorted)
1284
+ unsorted++;
1285
+ }
1155
1286
  const rows = finish(list.root, options.types ?? {});
1156
1287
  // Counted from what the hub holds, which is what the rows show.
1157
1288
  setStamp(el, ATTR_QUERY_ROW_COUNT, String(rows.length));
1289
+ setStamp(el, ATTR_QUERY_UNSORTED_COUNT, String(unsorted));
1290
+ if (terms.length === 0)
1291
+ el.removeAttribute(ATTR_QUERY_ORDER);
1292
+ else
1293
+ setStamp(el, ATTR_QUERY_ORDER, orderText(terms));
1158
1294
  const departing = gone.map((entry) => entry.el);
1159
1295
  for (const group of retired)
1160
1296
  leaveGroup(group, departing);
@@ -1162,6 +1298,39 @@ export function applyRows(el, template, keyColumn, result, options = {}) {
1162
1298
  depart(departing);
1163
1299
  host.lbRowsLanded?.();
1164
1300
  }
1301
+ /** The order's terms a list's groups break on, outermost first. */
1302
+ function groupTerms(list, terms) {
1303
+ return terms.slice(0, list.levels.length);
1304
+ }
1305
+ /** The group a row's columns put it in, as one string. */
1306
+ function groupOf(row, terms) {
1307
+ return terms.map((term) => groupId(term.column, row[term.column])).join("\u0001");
1308
+ }
1309
+ /** Whether a row's columns now put it in another group than it stands in. */
1310
+ function regrouped(entry, list, terms) {
1311
+ const by = groupTerms(list, terms);
1312
+ return groupOf(entry.data, by) !== groupOf(entry.placed, by);
1313
+ }
1314
+ /**
1315
+ * The rows of a list the user is working in: those showing where they
1316
+ * stand, and each new one after the last row of its group, or last of all
1317
+ * when its group shows no row.
1318
+ */
1319
+ function heldOrder(stayed, created, by) {
1320
+ const order = [...stayed];
1321
+ for (const entry of created) {
1322
+ const group = groupOf(entry.placed, by);
1323
+ let at = order.length;
1324
+ if (by.length > 0) {
1325
+ while (at > 0 && groupOf(order[at - 1].placed, by) !== group)
1326
+ at--;
1327
+ if (at === 0)
1328
+ at = order.length;
1329
+ }
1330
+ order.splice(at, 0, entry);
1331
+ }
1332
+ return order;
1333
+ }
1165
1334
  function focusedRow(list, doc) {
1166
1335
  const active = doc.activeElement;
1167
1336
  const row = active?.closest(LIVE_ROW);
@@ -1352,6 +1521,13 @@ function fillArrivals(root, state) {
1352
1521
  }
1353
1522
  }
1354
1523
  }
1524
+ /**
1525
+ * Whether a request from `origin` was sent from inside `el`'s list: `el` is
1526
+ * the nearest element carrying `lb-query` around it.
1527
+ */
1528
+ function sentFrom(el, origin) {
1529
+ return origin?.parentElement?.closest(`[${ATTR_QUERY}]`) === el;
1530
+ }
1355
1531
  /**
1356
1532
  * Land response items. Every answer arrives through here, and is kept for
1357
1533
  * `root`, so that what arrives after it is filled from it — see forget().
@@ -1375,6 +1551,7 @@ export function applyResponse(root, items, options = {}) {
1375
1551
  landItem(el, item, {
1376
1552
  ...rowsOptions(state, item),
1377
1553
  requested: options.requested,
1554
+ held: sentFrom(el, options.origin),
1378
1555
  });
1379
1556
  }
1380
1557
  if (choices.size > 0)
@@ -1383,4 +1560,18 @@ export function applyResponse(root, items, options = {}) {
1383
1560
  fillArrivals(root, state);
1384
1561
  attachPlaced();
1385
1562
  }
1563
+ /**
1564
+ * Place an element's rows by the order again, from the answer kept for its
1565
+ * query, with no round trip: what the user asks for once an edit has left
1566
+ * rows out of order. False when the element shows no rows the hub holds.
1567
+ */
1568
+ export function resort(root, el) {
1569
+ const state = kept.get(root);
1570
+ const item = state?.answers.get(el.getAttribute(ATTR_QUERY) ?? "");
1571
+ if (!state || !item || item.kind === KIND_ROW || !state.landed.has(el)) {
1572
+ return false;
1573
+ }
1574
+ landItem(el, item, rowsOptions(state, item));
1575
+ return true;
1576
+ }
1386
1577
  //# sourceMappingURL=lb-apply.js.map