Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- web/src/customer-grid/useVisibleRows.ts +30 -4
- web/src/filter-kit/FieldsHidePanel.tsx +11 -2
- web/src/filter-kit/fieldClass.ts +11 -13
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v53",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "bf8387b",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v53",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
bf8387b
|
web/src/customer-grid/useVisibleRows.ts
CHANGED
|
@@ -23,8 +23,8 @@ import type {
|
|
| 23 |
VisibleRow,
|
| 24 |
} from "./types";
|
| 25 |
import { FILTER_OPS, cohortIds, isCohortRule, isFilterGroup, isMeasureRule, isRankOp,
|
| 26 |
-
isViewRule, normalizeViewOp, viewRuleId,
|
| 27 |
-
isRuleActive, normalizeCohortOp, rhsColId } from "./types";
|
| 28 |
import { resolveAnchor, resolveWindow } from "./windows";
|
| 29 |
import { computeAggs } from "./aggregations";
|
| 30 |
import { NO_RANKING, hasRankLeaf, resolveRankLeaves, stripRankLeaves } from "./rankOps";
|
|
@@ -688,9 +688,29 @@ export function matchFilterTree(
|
|
| 688 |
* numerics coerced to 0 and outranked real negatives. A blank is only a
|
| 689 |
* tie-breaker skip, so later sort keys still decide between two blanks.
|
| 690 |
*/
|
| 691 |
-
export function makeComparator(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
return (a: Row, b: Row): number => {
|
| 693 |
for (const s of sorts) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
const f = fieldByKey.get(s.colId);
|
| 695 |
const numeric = f ? isNumericType(f.type) : false;
|
| 696 |
const av = a[s.colId];
|
|
@@ -1096,7 +1116,13 @@ export function runPipeline(input: PipelineInput): VisibleRowsResult {
|
|
| 1096 |
const q = search.trim().toLowerCase();
|
| 1097 |
if (q) out = out.filter((r) => matchSearch(q, r, fields));
|
| 1098 |
|
| 1099 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1100 |
|
| 1101 |
if (!groupBy) return asRows(out);
|
| 1102 |
|
|
|
|
| 23 |
VisibleRow,
|
| 24 |
} from "./types";
|
| 25 |
import { FILTER_OPS, cohortIds, isCohortRule, isFilterGroup, isMeasureRule, isRankOp,
|
| 26 |
+
isViewRule, normalizeViewOp, viewRuleId, ROUTE_PROJECTION_KEY, routeStopPosition,
|
| 27 |
+
singleRouteScope, isRuleActive, normalizeCohortOp, rhsColId } from "./types";
|
| 28 |
import { resolveAnchor, resolveWindow } from "./windows";
|
| 29 |
import { computeAggs } from "./aggregations";
|
| 30 |
import { NO_RANKING, hasRankLeaf, resolveRankLeaves, stripRankLeaves } from "./rankOps";
|
|
|
|
| 688 |
* numerics coerced to 0 and outranked real negatives. A blank is only a
|
| 689 |
* tie-breaker skip, so later sort keys still decide between two blanks.
|
| 690 |
*/
|
| 691 |
+
export function makeComparator(
|
| 692 |
+
sorts: SortSpec,
|
| 693 |
+
fieldByKey: Map<string, Field>,
|
| 694 |
+
routeLabel?: string,
|
| 695 |
+
) {
|
| 696 |
return (a: Row, b: Row): number => {
|
| 697 |
for (const s of sorts) {
|
| 698 |
+
// W42 R22: `routes` carries prose such as "North - 10, South - 2". It is not a
|
| 699 |
+
// sortable text value. The pipeline supplies `routeLabel` only after it has proved that
|
| 700 |
+
// the whole view is inside one named route; a stale stored sort otherwise becomes a
|
| 701 |
+
// no-op here instead of a confident lexical wrong answer.
|
| 702 |
+
if (s.colId === ROUTE_PROJECTION_KEY) {
|
| 703 |
+
if (!routeLabel) continue;
|
| 704 |
+
const av = routeStopPosition(a[s.colId], routeLabel);
|
| 705 |
+
const bv = routeStopPosition(b[s.colId], routeLabel);
|
| 706 |
+
const aBlank = av === null;
|
| 707 |
+
const bBlank = bv === null;
|
| 708 |
+
if (aBlank !== bBlank) return aBlank ? 1 : -1;
|
| 709 |
+
if (aBlank && bBlank) continue;
|
| 710 |
+
const c = (av ?? 0) - (bv ?? 0);
|
| 711 |
+
if (c !== 0) return s.dir === "asc" ? c : -c;
|
| 712 |
+
continue;
|
| 713 |
+
}
|
| 714 |
const f = fieldByKey.get(s.colId);
|
| 715 |
const numeric = f ? isNumericType(f.type) : false;
|
| 716 |
const av = a[s.colId];
|
|
|
|
| 1116 |
const q = search.trim().toLowerCase();
|
| 1117 |
if (q) out = out.filter((r) => matchSearch(q, r, fields));
|
| 1118 |
|
| 1119 |
+
// A route stop number is an ordinal only within one route. `singleRouteScope` is also the
|
| 1120 |
+
// builder's refusal decision, so persisted sorts take the same fail-closed path as new ones.
|
| 1121 |
+
// A missing label deliberately leaves the projection rule as a no-op; raw CSV text is never
|
| 1122 |
+
// used as a fallback comparator.
|
| 1123 |
+
const routeScope = singleRouteScope({ nodes: filters, conj: filterConj }, fields);
|
| 1124 |
+
const routeLabel = routeScope.ok ? fieldByKey.get(routeScope.routeKey)?.label : undefined;
|
| 1125 |
+
if (sorts.length) out = [...out].sort(makeComparator(sorts, fieldByKey, routeLabel));
|
| 1126 |
|
| 1127 |
if (!groupBy) return asRows(out);
|
| 1128 |
|
web/src/filter-kit/FieldsHidePanel.tsx
CHANGED
|
@@ -27,7 +27,7 @@ import { useMemo, useState } from "react";
|
|
| 27 |
// miniature, where what a user searches and what they read answer the same question
|
| 28 |
// differently. It lives in `types.ts` so the node harness can run it: this panel is JSX and
|
| 29 |
// its search path only fires once somebody types, which no static render can do.
|
| 30 |
-
import { fieldEditMode, fieldLabel,
|
| 31 |
FIELD_EDIT_BLURBS, FIELD_EDIT_LABELS }
|
| 32 |
from "../customer-grid/types";
|
| 33 |
import { FieldTypeIcon } from "../customer-grid/icons";
|
|
@@ -493,8 +493,17 @@ export function FieldsHidePanel({
|
|
| 493 |
* β PRESENTATION ONLY. Nothing is deleted and nothing is walled: the payload still carries
|
| 494 |
* every withheld field, the grid still renders any that are visible, and a route column's own
|
| 495 |
* doors (its column menu, `patchRouteOrderField`, `deleteRouteOrderField`) are untouched.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
*/
|
| 497 |
-
const fields = useMemo(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
/** W41-T27 - one name for "this mount is the Fields manager", so the title, the width class
|
| 499 |
* and anything a later ticket adds all key off the same answer instead of re-deriving it. */
|
| 500 |
const managerMode = manager;
|
|
|
|
| 27 |
// miniature, where what a user searches and what they read answer the same question
|
| 28 |
// differently. It lives in `types.ts` so the node harness can run it: this panel is JSX and
|
| 29 |
// its search path only fires once somebody types, which no static render can do.
|
| 30 |
+
import { fieldEditMode, fieldLabel, isOfferableField, isPresetField,
|
| 31 |
FIELD_EDIT_BLURBS, FIELD_EDIT_LABELS }
|
| 32 |
from "../customer-grid/types";
|
| 33 |
import { FieldTypeIcon } from "../customer-grid/icons";
|
|
|
|
| 493 |
* β PRESENTATION ONLY. Nothing is deleted and nothing is walled: the payload still carries
|
| 494 |
* every withheld field, the grid still renders any that are visible, and a route column's own
|
| 495 |
* doors (its column menu, `patchRouteOrderField`, `deleteRouteOrderField`) are untouched.
|
| 496 |
+
*
|
| 497 |
+
* R1's narrow exception is a route field whose host has already offered a delete affordance.
|
| 498 |
+
* It stays in THIS manager only so that affordance remains reachable; it remains absent from
|
| 499 |
+
* every picker. `deletableKeys` is a ceiling supplied by the host, so this cannot make a
|
| 500 |
+
* withheld field deletable or offer any field outside the Fields manager.
|
| 501 |
*/
|
| 502 |
+
const fields = useMemo(
|
| 503 |
+
() => allFields.filter((field) =>
|
| 504 |
+
isOfferableField(field) || !!deletableKeys?.has(field.key)),
|
| 505 |
+
[allFields, deletableKeys]
|
| 506 |
+
);
|
| 507 |
/** W41-T27 - one name for "this mount is the Fields manager", so the title, the width class
|
| 508 |
* and anything a later ticket adds all key off the same answer instead of re-deriving it. */
|
| 509 |
const managerMode = manager;
|
web/src/filter-kit/fieldClass.ts
CHANGED
|
@@ -862,30 +862,28 @@ export function fieldAuthorBadge(
|
|
| 862 |
}
|
| 863 |
|
| 864 |
/**
|
| 865 |
-
* ββ R16, AND THE PAYLOAD
|
| 866 |
* definition wins a key collision, the local one is superseded rather than
|
| 867 |
* deleted, and the field manager says so. The first two halves are real
|
| 868 |
* server-side facts (`core.field_permissions.migrate_legacy_fields` re-promotes
|
| 869 |
* a per-user fork of a key on every read, and `routes_tables`' delete door
|
| 870 |
* clears both strata precisely because a fork survives), but NOTHING ON THE
|
| 871 |
-
* WIRE MARKS THE ROW: `routes_customers._merge_shared_fields`
|
| 872 |
-
* definition
|
| 873 |
-
*
|
| 874 |
-
*
|
| 875 |
-
*
|
| 876 |
-
*
|
| 877 |
-
*
|
| 878 |
-
*
|
| 879 |
-
* today, which paints nothing. The missing half is named in the handoff rather
|
| 880 |
-
* than guessed at here, because the only client-side alternative would be to
|
| 881 |
-
* decide a collision happened from a key the server never sent.
|
| 882 |
*/
|
| 883 |
export const SUPERSEDED_NOTE =
|
| 884 |
"Superseded: the shared version of this column is the one in use, and your own copy is kept rather than deleted.";
|
| 885 |
|
| 886 |
export function supersededNoteOf(field: unknown): string | null {
|
| 887 |
if (typeof field !== "object" || field === null) return null;
|
| 888 |
-
|
|
|
|
| 889 |
? SUPERSEDED_NOTE
|
| 890 |
: null;
|
| 891 |
}
|
|
|
|
| 862 |
}
|
| 863 |
|
| 864 |
/**
|
| 865 |
+
* ββ R16, AND THE PAYLOAD EXPRESSES IT UNDER THE SERVER'S OWN KEY. The ruling: a SHARED
|
| 866 |
* definition wins a key collision, the local one is superseded rather than
|
| 867 |
* deleted, and the field manager says so. The first two halves are real
|
| 868 |
* server-side facts (`core.field_permissions.migrate_legacy_fields` re-promotes
|
| 869 |
* a per-user fork of a key on every read, and `routes_tables`' delete door
|
| 870 |
* clears both strata precisely because a fork survives), but NOTHING ON THE
|
| 871 |
+
* WIRE MARKS THE ROW: `routes_customers._merge_shared_fields` projects the shared
|
| 872 |
+
* definition in place and carries its thin `{ superseded: { stratum: "personal" } }`
|
| 873 |
+
* marker. The local definition stays in the workspace member, but nothing needs to
|
| 874 |
+
* copy it back onto the served field to tell the person what happened.
|
| 875 |
+
*
|
| 876 |
+
* This is a C8 reader, not an invented state: it returns the sentence only for
|
| 877 |
+
* that explicit personal-stratum marker. Any absent, malformed, or other-stratum
|
| 878 |
+
* payload stays null rather than turning an unrelated field into a collision.
|
|
|
|
|
|
|
|
|
|
| 879 |
*/
|
| 880 |
export const SUPERSEDED_NOTE =
|
| 881 |
"Superseded: the shared version of this column is the one in use, and your own copy is kept rather than deleted.";
|
| 882 |
|
| 883 |
export function supersededNoteOf(field: unknown): string | null {
|
| 884 |
if (typeof field !== "object" || field === null) return null;
|
| 885 |
+
const marker = (field as { superseded?: { stratum?: unknown } }).superseded;
|
| 886 |
+
return marker?.stratum === "personal"
|
| 887 |
? SUPERSEDED_NOTE
|
| 888 |
: null;
|
| 889 |
}
|