@rebasepro/app 0.14.0 → 0.14.1-canary.g7e666eb

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/app",
3
3
  "type": "module",
4
- "version": "0.14.0",
4
+ "version": "0.14.1-canary.g7e666eb",
5
5
  "description": "Rebase core — framework-agnostic runtime for data-driven admin panels",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -52,12 +52,12 @@
52
52
  "magic-string": "^0.30.0",
53
53
  "notistack": "^3.0.2",
54
54
  "react-i18next": "^17.0.11",
55
- "@rebasepro/admin-types": "0.14.0",
56
- "@rebasepro/common": "0.14.0",
57
- "@rebasepro/forms": "0.14.0",
58
- "@rebasepro/types": "0.14.0",
59
- "@rebasepro/ui": "0.14.0",
60
- "@rebasepro/utils": "0.14.0"
55
+ "@rebasepro/admin-types": "0.14.1-canary.g7e666eb",
56
+ "@rebasepro/forms": "0.14.1-canary.g7e666eb",
57
+ "@rebasepro/common": "0.14.1-canary.g7e666eb",
58
+ "@rebasepro/ui": "0.14.1-canary.g7e666eb",
59
+ "@rebasepro/types": "0.14.1-canary.g7e666eb",
60
+ "@rebasepro/utils": "0.14.1-canary.g7e666eb"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "react": ">=19.2.7",
@@ -138,7 +138,7 @@
138
138
  "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && pnpm exec esbuild src/vitePlugin.ts --platform=node --format=esm --outfile=dist/vitePlugin.js && node ../../scripts/assert-build-output.mjs",
139
139
  "test:lint": "eslint \"src/**\" --quiet",
140
140
  "test": "jest --passWithNoTests",
141
- "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
141
+ "clean": "rm -rf dist && find ./src \\( -name '*.js' -o -name '*.d.ts' \\) -type f | xargs rm -f",
142
142
  "generateIcons": "ts-node --esm src/icons/generateIcons.ts"
143
143
  }
144
144
  }
@@ -602,7 +602,14 @@ selected: true }, { name: "Tags" }].map(c => (
602
602
  {(["h1", "h2", "h3", "h4", "h5", "h6", "subtitle1", "subtitle2", "body1", "body2", "caption", "label", "button"] as const).map(v => (
603
603
  <div key={v} className={cls("flex items-baseline gap-4 border-b pb-3 last:border-0", defaultBorderMixin)}>
604
604
  <span className="w-24 shrink-0 text-xs text-surface-400 font-mono">{v}</span>
605
- <Typography variant={v}>The quick brown fox jumps over the lazy dog</Typography>
605
+ {/* `component="p"` keeps the variant's styling but not its
606
+ element. Rendering the specimen with its native tag put a
607
+ real <h1> — "The quick brown fox jumps over the lazy dog" —
608
+ plus h2-h6 into the document outline of every page that
609
+ embeds this view, including the public rebase.pro/ui gallery,
610
+ where it competed with the page's actual heading. The class
611
+ carries the type, so this renders identically. */}
612
+ <Typography variant={v} component="p">The quick brown fox jumps over the lazy dog</Typography>
606
613
  </div>
607
614
  ))}
608
615
  </div>
@@ -34,7 +34,10 @@ export const UIStyleGuide = () => {
34
34
  {typographyVariants.map(variant => (
35
35
  <div key={variant} className="flex items-center gap-4 border-b border-surface-100 dark:border-surface-950 pb-4 last:border-0">
36
36
  <span className="w-24 text-[10px] uppercase tracking-wider text-text-disabled font-mono">{variant}</span>
37
- <Typography variant={variant}>
37
+ {/* component="p" keeps the variant's styling without emitting a
38
+ real h1-h6 into the host page's document outline. See the
39
+ matching note in UIReferenceView. */}
40
+ <Typography variant={variant} component="p">
38
41
  The quick brown fox jumps over the lazy dog ({variant})
39
42
  </Typography>
40
43
  </div>
@@ -944,7 +944,11 @@ export function CrmDashboardDemo() {
944
944
  <div className="flex flex-col sm:flex-row sm:items-end justify-between mb-6 gap-4">
945
945
  <div>
946
946
  <Typography variant="h4" className="tracking-tight">
947
- {getGreeting()} 👋
947
+ {/* No emoji: this demo renders inside the public rebase.pro/ui
948
+ gallery, and emoji render differently on every platform,
949
+ cannot be recoloured, and read as a placeholder for a design
950
+ decision nobody made. The greeting carries itself. */}
951
+ {getGreeting()}
948
952
  </Typography>
949
953
  <Typography variant="body2" color="secondary" className="mt-0.5">
950
954
  {getFormattedDate()} — here&apos;s your crm overview.
@@ -7,7 +7,8 @@ import { useDataOrder } from "../../hooks/data/useDataOrder";
7
7
  import { populateFetchCache } from "../../hooks/data/useFetch";
8
8
  import { toFindParams } from "../../hooks/data/collectionQuery";
9
9
  import { getRelationIncludeParams } from "../../util/previews";
10
- import { Entity, EntityReference, EntityRelation, FilterValues, User, WhereFilterOp, FindResponse } from "@rebasepro/types";
10
+ import { Entity, EntityReference, EntityRelation, FilterValues, OrderBySpec, OrderByTuple, User, WhereFilterOp, FindResponse } from "@rebasepro/types";
11
+ import { normalizeOrderBy, serializeOrderBy } from "@rebasepro/common";
11
12
  import { EntityTableController, RebaseContext, SelectedCellProps, AdminCollection } from "@rebasepro/admin-types";
12
13
  import { ScrollRestorationController } from "./useScrollRestoration";
13
14
 
@@ -96,17 +97,21 @@ export function useDataTableController<M extends Record<string, any> = any, USER
96
97
  });
97
98
 
98
99
  const checkFilterCombination = useCallback((filterValues: FilterValues<any>,
99
- sortBy?: [string, "asc" | "desc"]) => {
100
+ sortBy?: OrderByTuple[]) => {
100
101
  // PostgREST/SQL can handle arbitrary filter/sort combinations natively.
101
102
  return true;
102
103
  }, []);
103
104
 
105
+ // The collection's default, in the list form the controller works in.
106
+ // `collection.sort` accepts either spelling so a one-key default stays the
107
+ // one-liner it always was.
104
108
  const sortInternal = useMemo(() => {
105
- if (sort && fixedFilter && !checkFilterCombination(fixedFilter, sort)) {
109
+ const keys = normalizeOrderBy(sort as OrderBySpec | undefined);
110
+ if (keys && fixedFilter && !checkFilterCombination(fixedFilter, keys)) {
106
111
  console.warn("Initial sort is not compatible with the force filter. Ignoring initial sort");
107
112
  return undefined;
108
113
  }
109
- return sort;
114
+ return keys;
110
115
  }, [sort, fixedFilter]);
111
116
 
112
117
  const {
@@ -115,7 +120,7 @@ export function useDataTableController<M extends Record<string, any> = any, USER
115
120
  } = parseFilterAndSort(location.search);
116
121
 
117
122
  const [filterValues, setFilterValues] = React.useState<FilterValues<Extract<keyof M, string> | (string & {})> | undefined>(fixedFilter ?? (updateUrl ? filterUrl : undefined) ?? defaultFilter ?? undefined);
118
- const [sortBy, setSortBy] = React.useState<[Extract<keyof M, string> | (string & {}), "asc" | "desc"] | undefined>((updateUrl ? sortUrl : undefined) ?? sortInternal);
123
+ const [sortBy, setSortBy] = React.useState<OrderByTuple<Extract<keyof M, string> | (string & {})>[] | undefined>((updateUrl ? sortUrl : undefined) ?? sortInternal);
119
124
 
120
125
  // Sync filter/sort state from URL on browser navigation (back/forward).
121
126
  // Skip initial mount since useState initializers already handle URL params + collection defaults.
@@ -136,7 +141,7 @@ export function useDataTableController<M extends Record<string, any> = any, USER
136
141
  if (urlSortBy && fixedFilter && !checkFilterCombination(fixedFilter, urlSortBy)) {
137
142
  console.warn("URL sort is not compatible with the force filter.");
138
143
  } else {
139
- setSortBy(urlSortBy as [Extract<keyof M, string> | (string & {}), "asc" | "desc"] | undefined);
144
+ setSortBy(urlSortBy as OrderByTuple<Extract<keyof M, string> | (string & {})>[] | undefined);
140
145
  }
141
146
 
142
147
  // Sync search string from URL
@@ -163,8 +168,10 @@ export function useDataTableController<M extends Record<string, any> = any, USER
163
168
 
164
169
  const [itemCount, setItemCount] = React.useState<number | undefined>(paginationEnabled ? initialItemCount : undefined);
165
170
 
166
- const sortByProperty = sortBy ? sortBy[0] : undefined;
167
- const currentSort = sortBy ? sortBy[1] : undefined;
171
+ // The whole sort, as one string, so the effect below re-runs when any key
172
+ // changes — a `sortBy` array is a new reference on every render and would
173
+ // re-subscribe on each one if used as a dependency directly.
174
+ const sortKey = sortBy ? serializeOrderBy(sortBy) : undefined;
168
175
 
169
176
  const context: RebaseContext<USER> = useRebaseContext();
170
177
 
@@ -253,7 +260,9 @@ export function useDataTableController<M extends Record<string, any> = any, USER
253
260
 
254
261
  // filterValues is already FilterValues — pass directly to the accessor
255
262
  const whereParams = filterValues && Object.keys(filterValues).length > 0 ? filterValues : undefined;
256
- const orderByParams: [string, "asc" | "desc"] | undefined = sortBy ? [String(sortBy[0]), sortBy[1]] : undefined;
263
+ const orderByParams = sortBy && sortBy.length > 0
264
+ ? sortBy.map(([field, direction]) => [String(field), direction] as OrderByTuple)
265
+ : undefined;
257
266
 
258
267
  let unsubscribe: (() => void) | undefined;
259
268
 
@@ -277,7 +286,7 @@ export function useDataTableController<M extends Record<string, any> = any, USER
277
286
  }
278
287
 
279
288
  return unsubscribe;
280
- }, [dataClient, path, itemCount, currentSort, sortByProperty, filterValues, searchString]);
289
+ }, [dataClient, path, itemCount, sortKey, filterValues, searchString]);
281
290
 
282
291
  const orderedData = useDataOrder({
283
292
  data: rawData,
@@ -336,7 +345,7 @@ export function useDataTableController<M extends Record<string, any> = any, USER
336
345
 
337
346
  function useUpdateUrl<M extends Record<string, any> = any>(
338
347
  filterValues: FilterValues<Extract<keyof M, string>> | undefined,
339
- sortBy: [Extract<keyof M, string>, "asc" | "desc"] | undefined,
348
+ sortBy: OrderByTuple<Extract<keyof M, string>>[] | undefined,
340
349
  searchString: string | undefined,
341
350
  updateUrl: boolean | undefined
342
351
  ) {
@@ -371,11 +380,15 @@ function useUpdateUrl<M extends Record<string, any> = any>(
371
380
  }, [filterValues, sortBy, searchString, updateUrl]);
372
381
  }
373
382
 
374
- function encodeFilterAndSort(filterValues?: FilterValues<string>, sortBy?: [string, "asc" | "desc"] | undefined) {
383
+ function encodeFilterAndSort(filterValues?: FilterValues<string>, sortBy?: OrderByTuple[] | undefined) {
375
384
  const entries: Record<string, string> = {};
376
- if (sortBy) {
377
- entries["__sort"] = encodeURIComponent(sortBy[0]);
378
- entries["__sort_order"] = encodeURIComponent(sortBy[1]);
385
+ if (sortBy && sortBy.length > 0) {
386
+ // Comma-separated, positionally paired. A single key encodes exactly as
387
+ // it did before — `__sort=name&__sort_order=asc` — so links already out
388
+ // in the world keep working, and a reload of a two-key sort no longer
389
+ // silently comes back sorted by the first key alone.
390
+ entries["__sort"] = sortBy.map(([field]) => encodeURIComponent(field)).join(",");
391
+ entries["__sort_order"] = sortBy.map(([, direction]) => direction).join(",");
379
392
  }
380
393
  if (filterValues) {
381
394
  Object.entries(filterValues).forEach(([key, value]) => {
@@ -426,14 +439,23 @@ function encodeFilterAndSort(filterValues?: FilterValues<string>, sortBy?: [stri
426
439
 
427
440
  function parseFilterAndSort<M>(search: string): {
428
441
  filterValues: FilterValues<string> | undefined,
429
- sortBy?: [Extract<keyof M, string>, "asc" | "desc"]
442
+ sortBy?: OrderByTuple<Extract<keyof M, string>>[]
430
443
  } {
431
444
  const entries = new URLSearchParams(search);
432
445
  const filterValues: FilterValues<string> = {};
433
- let sortBy: [string, "asc" | "desc"] | undefined = undefined;
446
+ let sortBy: OrderByTuple[] | undefined = undefined;
434
447
  entries.forEach((value, key) => {
435
448
  if (key === "__sort") {
436
- sortBy = [decodeURIComponent(value), entries.get("__sort_order") as "asc" | "desc"];
449
+ const directions = (entries.get("__sort_order") ?? "").split(",");
450
+ const keys = value
451
+ .split(",")
452
+ .map((field) => decodeURIComponent(field).trim())
453
+ .filter(Boolean)
454
+ // A direction missing from the URL — a hand-written link, or the
455
+ // one-key form of an older bookmark — is ascending, the same
456
+ // reading `?orderBy=name` gets everywhere else.
457
+ .map((field, index): OrderByTuple => [field, directions[index] === "desc" ? "desc" : "asc"]);
458
+ sortBy = keys.length > 0 ? keys : undefined;
437
459
  } else if (key.endsWith("_op")) {
438
460
  const field = key.replace("_op", "");
439
461
  const filterOp = decodeURIComponent(value) as WhereFilterOp;
@@ -1,4 +1,4 @@
1
- import type { FindParams, FilterValues, LogicalCondition } from "@rebasepro/types";
1
+ import type { FindParams, FilterValues, LogicalCondition, OrderBySpec } from "@rebasepro/types";
2
2
 
3
3
  /**
4
4
  * The one place a collection read's parameters are assembled.
@@ -24,12 +24,12 @@ export interface CollectionQueryInput<M extends Record<string, unknown>> {
24
24
  logical?: LogicalCondition;
25
25
  /**
26
26
  * Loosened from `FindParams["orderBy"]` on purpose: callers hold the sort
27
- * as a plain `[string, direction]` read off user config or a URL, where the
28
- * column is not statically known. Narrowing it here would push a cast onto
29
- * every call site, which is the kind of friction that sends people back to
30
- * hand-building the object this exists to replace.
27
+ * as plain `[string, direction]` pairs read off user config or a URL, where
28
+ * the columns are not statically known. Narrowing it here would push a cast
29
+ * onto every call site, which is the kind of friction that sends people back
30
+ * to hand-building the object this exists to replace.
31
31
  */
32
- orderBy?: [string, "asc" | "desc"];
32
+ orderBy?: OrderBySpec;
33
33
  limit?: number;
34
34
  offset?: number;
35
35
  page?: number;