portal-mojo 0.2.22 → 0.2.23

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.
@@ -174,6 +174,17 @@ The table retains native row/cell semantics.
174
174
  (`__gte`), `daterange` (the `dr_*` triple). Applied filters render as
175
175
  editable pills; presets are param bundles with DERIVED active state.
176
176
 
177
+ **The selected preset owns its keys.** While a preset matches, its params
178
+ render no pill and don't count on the Filter button, and every Clear (the
179
+ pill row's Clear all, the Filter menu's, the empty state's) keeps them —
180
+ the segment is the one control for them. So a `defaultParams` default that
181
+ matches a preset (`{ status: 'submitted' }` beside a "Submitted" preset)
182
+ shows as the highlighted segment, never as a removable pill. Filters added
183
+ on top still pill. The Filter menu keeps its check on an owned key's
184
+ definition; editing it there changes the value, the preset stops matching,
185
+ and the pill returns. A default with NO matching preset is still a plain
186
+ removable pill; scope that must never be removable is `fixedParams`.
187
+
177
188
  ## Selection + batch
178
189
 
179
190
  `selectable` renders checkboxes independently of `batchActions` (decoupled
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portal-mojo",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
4
4
  "description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -41,7 +41,8 @@ export interface TableParamsApi extends TableParams {
41
41
  removeFilter(f: ActiveFilter): void;
42
42
  applyPreset(params: Record<string, string>): void;
43
43
  presetActive(params: Record<string, string>): boolean;
44
- clearFilters(): void;
44
+ /** Clears search and filters; `keep` survives (the selected preset's own keys). */
45
+ clearFilters(keep?: Readonly<Record<string, string>>): void;
45
46
  setPage(page: number): void;
46
47
  setSize(size: number): void;
47
48
  /**
@@ -295,7 +296,7 @@ export function useTableParams(
295
296
  if (keys.length === 0) return Object.keys(filters).length === 0;
296
297
  return keys.every((k) => filters[k] === params[k]);
297
298
  },
298
- clearFilters: () => write({ ...state, search: '', page: 1 }, {}),
299
+ clearFilters: (keep) => write({ ...state, search: '', page: 1 }, { ...keep }),
299
300
  setPage: (page) => write({ ...state, page }, filters),
300
301
  setSize: (size) => write({ ...state, size, page: 1 }, filters),
301
302
  applySaved: (saved) => {
@@ -2,12 +2,15 @@ import { ComboBox } from './ComboBox';
2
2
  // FilterBar — web-mojo's filter UX: an "Add filter" menu listing every
3
3
  // available filter with a per-type icon and a check on the active ones, each
4
4
  // opening a type-appropriate dialog; applied filters become editable pills.
5
- // Adding a filter to a page is one entry in a `filters` array.
5
+ // Adding a filter to a page is one entry in a `filters` array. Keys owned by
6
+ // the selected preset (`owned`) are the segment's to show: no pill, no count,
7
+ // and Clear all keeps them (see preset-owned.ts).
6
8
  import { useEffect, useRef, useState } from 'react';
7
9
  import { modal } from './modal';
8
10
  import { DateRangePicker } from './date/DateRangePicker';
9
11
  import { formatFilterDisplay } from '../client/lookups';
10
12
  import type { ActiveFilter, TableParamsApi } from '../client/params';
13
+ import { isPresetOwned } from './preset-owned';
11
14
 
12
15
  export type FilterType = 'text' | 'select' | 'multiselect' | 'number' | 'boolean' | 'date' | 'daterange';
13
16
 
@@ -168,7 +171,7 @@ async function openFilterDialog(def: FilterDef, current: ActiveFilter | undefine
168
171
  }
169
172
 
170
173
  // ── the bar ───────────────────────────────────────────────────────────
171
- export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TableParamsApi }) {
174
+ export function FilterBar({ defs, params, owned = {} }: { defs: FilterDef[]; params: TableParamsApi; owned?: Readonly<Record<string, string>> }) {
172
175
  const [open, setOpen] = useState(false);
173
176
  const wrapRef = useRef<HTMLDivElement>(null);
174
177
 
@@ -187,11 +190,14 @@ export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TablePa
187
190
  }, [open]);
188
191
 
189
192
  const active = params.activeFilters;
193
+ // The menu's checks still show owned keys (they ARE filtering, and the
194
+ // dialog edits them); the count and Clear all are the pills' business.
195
+ const clearable = active.filter((f) => !isPresetOwned(f, owned));
190
196
 
191
197
  return (
192
198
  <div className="filter-menu-wrap" ref={wrapRef}>
193
199
  <button
194
- className={`btn btn-compact${active.length ? ' btn-has-filters' : ''}`}
200
+ className={`btn btn-compact${clearable.length ? ' btn-has-filters' : ''}`}
195
201
  onClick={() => setOpen((v) => !v)}
196
202
  aria-expanded={open}
197
203
  title="Filter"
@@ -199,7 +205,7 @@ export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TablePa
199
205
  {/* Label collapses on normal widths (tool-group icon mode);
200
206
  the active-count badge stays visible either way. */}
201
207
  <i className="bi bi-funnel" /> <span className="btn-label">Filter</span>
202
- {active.length > 0 && <span className="filter-count">{active.length}</span>}
208
+ {clearable.length > 0 && <span className="filter-count">{clearable.length}</span>}
203
209
  </button>
204
210
  {open && (
205
211
  <div className="filter-menu" role="menu">
@@ -221,12 +227,12 @@ export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TablePa
221
227
  </button>
222
228
  );
223
229
  })}
224
- {active.length > 0 && (
230
+ {clearable.length > 0 && (
225
231
  <>
226
232
  <div className="filter-menu-sep" />
227
233
  <button
228
234
  className="filter-menu-item filter-menu-clear"
229
- onClick={() => { setOpen(false); params.clearFilters(); }}
235
+ onClick={() => { setOpen(false); params.clearFilters(owned); }}
230
236
  >
231
237
  <i className="bi bi-x-circle" /> Clear all filters
232
238
  </button>
@@ -238,9 +244,9 @@ export function FilterBar({ defs, params }: { defs: FilterDef[]; params: TablePa
238
244
  );
239
245
  }
240
246
 
241
- /** Active-filter pills — click to edit, × to remove. */
242
- export function FilterPills({ defs, params }: { defs: FilterDef[]; params: TableParamsApi }) {
243
- const active = params.activeFilters;
247
+ /** Active-filter pills — click to edit, × to remove. Preset-owned keys never pill. */
248
+ export function FilterPills({ defs, params, owned = {} }: { defs: FilterDef[]; params: TableParamsApi; owned?: Readonly<Record<string, string>> }) {
249
+ const active = params.activeFilters.filter((f) => !isPresetOwned(f, owned));
244
250
  if (active.length === 0) return null;
245
251
  return (
246
252
  <div className="pill-row">
@@ -261,7 +267,7 @@ export function FilterPills({ defs, params }: { defs: FilterDef[]; params: Table
261
267
  </span>
262
268
  );
263
269
  })}
264
- <button className="pill-clear" onClick={() => params.clearFilters()}>Clear all</button>
270
+ <button className="pill-clear" onClick={() => params.clearFilters(owned)}>Clear all</button>
265
271
  </div>
266
272
  );
267
273
  }
@@ -30,6 +30,7 @@ import { busyWhile } from './loading';
30
30
  import { modal } from './modal';
31
31
  import { RenderGuard } from './safe-node';
32
32
  import { fixedParamKeys, mergeFixedParams } from './fixed-params';
33
+ import { isPresetOwned, presetOwnedFilters } from './preset-owned';
33
34
  import { toast } from './toast';
34
35
  import * as fmt from './format';
35
36
  import { warnOnce } from './warn-once';
@@ -428,6 +429,9 @@ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
428
429
  const firstRow = count === 0 ? 0 : (p.page - 1) * p.size + 1;
429
430
  const lastRow = Math.min(count, (p.page - 1) * p.size + rows.length);
430
431
  const isFiltered = p.activeFilters.length > 0 || (searchable && p.search !== '');
432
+ // The selected segment owns its keys: no pill, and every Clear keeps them.
433
+ const owned = presetOwnedFilters(presets, p.presetActive);
434
+ const clearable = p.activeFilters.some((f) => !isPresetOwned(f, owned)) || (searchable && p.search !== '');
431
435
 
432
436
  // Save the view on every params/visibility change (post-hydration only,
433
437
  // so the restore itself never writes back a half-applied state).
@@ -750,7 +754,7 @@ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
750
754
  toolbar). Icons + tooltips only; text labels appear
751
755
  only on very wide screens via .btn-label. */}
752
756
  <div className="tool-group">
753
- {filters.length > 0 && <FilterBar defs={filters} params={p} />}
757
+ {filters.length > 0 && <FilterBar defs={filters} params={p} owned={owned} />}
754
758
  {columnChooser && (
755
759
  <MenuDropdown button={(open, toggle) => (
756
760
  <button className="btn btn-compact" onClick={toggle} aria-expanded={open} title="Choose columns">
@@ -838,7 +842,7 @@ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
838
842
  </div>
839
843
  )}
840
844
 
841
- <FilterPills defs={filters} params={p} />
845
+ <FilterPills defs={filters} params={p} owned={owned} />
842
846
 
843
847
  {selectable && batchActions.length > 0 && selected.size > 0 && (
844
848
  <div className="batch-bar" role="region" aria-label="Batch actions">
@@ -939,7 +943,7 @@ function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
939
943
  {isFiltered ? (
940
944
  <>
941
945
  <h3>No results match your filters</h3>
942
- <button className="btn" onClick={() => p.clearFilters()}>Clear filters</button>
946
+ {clearable && <button className="btn" onClick={() => p.clearFilters(owned)}>Clear filters</button>}
943
947
  </>
944
948
  ) : (
945
949
  <h3>No {title.toLowerCase()} yet</h3>
@@ -0,0 +1,39 @@
1
+ // presetOwnedFilters — the filter keys the selected segment already shows.
2
+ //
3
+ // A preset is a param bundle with DERIVED active state: its keys live in the
4
+ // params store like any filter. Rendering them as pills as well says the
5
+ // same thing twice, and the pill's × (or Clear all) flips the segment behind
6
+ // the operator's back — a default "Staff" preset cleared to "All users", or
7
+ // a raw lookup like `user__player_profiles__group__not` shown as a filter.
8
+ // So the segment is the one control for the keys it owns: no pill, no count
9
+ // on the Filter button, and Clear all keeps them. A filter the operator adds
10
+ // on top still pills; editing an owned key through the Filter menu changes
11
+ // the value, the preset stops matching, and the pill comes back.
12
+ //
13
+ // This module is intentionally NOT exported from ui/index.ts — it is
14
+ // ModelTable plumbing, kept pure so the verify script can pin it without
15
+ // loading ModelTable's router/query graph.
16
+ import type { ActiveFilter } from '../client/params';
17
+
18
+ interface PresetParams { params: Record<string, string> }
19
+
20
+ /**
21
+ * Union of the params of every active preset. Matching is the params
22
+ * store's own `presetActive`, so a segment highlights exactly when it owns
23
+ * its keys. The empty "All" preset owns nothing.
24
+ */
25
+ export function presetOwnedFilters(
26
+ presets: readonly PresetParams[],
27
+ isActive: (params: Record<string, string>) => boolean,
28
+ ): Record<string, string> {
29
+ const owned: Record<string, string> = {};
30
+ for (const { params } of presets) {
31
+ if (Object.keys(params).length > 0 && isActive(params)) Object.assign(owned, params);
32
+ }
33
+ return owned;
34
+ }
35
+
36
+ /** True when a segment owns this filter. A daterange is owned through its `dr_field`. */
37
+ export function isPresetOwned(f: ActiveFilter, owned: Readonly<Record<string, string>>): boolean {
38
+ return f.kind === 'daterange' ? owned.dr_field === f.id : owned[f.id] !== undefined;
39
+ }