@rebasepro/app 0.14.0 → 0.14.1

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",
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",
56
+ "@rebasepro/types": "0.14.1",
57
+ "@rebasepro/forms": "0.14.1",
58
+ "@rebasepro/common": "0.14.1",
59
+ "@rebasepro/utils": "0.14.1",
60
+ "@rebasepro/ui": "0.14.1"
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.
@@ -1,4 +1,4 @@
1
- import React, { ReactNode, useEffect, useId, useRef, useState } from "react";
1
+ import React, { ReactNode, useCallback, useEffect, useId, useRef, useState } from "react";
2
2
 
3
3
  /** Google Identity Services SDK — injected by the GIS <script> tag. */
4
4
  declare global {
@@ -145,11 +145,18 @@ export interface LoginViewProps {
145
145
  defaultPassword?: string;
146
146
 
147
147
  /**
148
- * When set, the email/password forms render a "Join the newsletter"
149
- * opt-in checkbox. Called with the address that just authenticated —
150
- * sign-in or registration if the box was ticked. Wire it to whatever
151
- * stores the subscription; failures are the callback's problem, the
152
- * login flow never waits on it.
148
+ * When set, a "Join the newsletter" opt-in checkbox is offered. Called with
149
+ * the address that just authenticated — by any method — if the box was
150
+ * ticked. Wire it to whatever stores the subscription; failures are the
151
+ * callback's problem, the login flow never waits on it.
152
+ *
153
+ * The checkbox sits on the **provider screen**, beside the buttons that
154
+ * choose how to sign in, rather than on the credentials form. It is a
155
+ * decision about the visitor, not about the password: asking on the
156
+ * credentials form put it in front of only the people who got that far by
157
+ * typing an address, so anyone signing in with Google was never offered it
158
+ * at all. Bootstrap mode is the exception — there is no provider screen
159
+ * there, so the form keeps it.
153
160
  */
154
161
  onNewsletterOptIn?: (email: string) => void;
155
162
  }
@@ -225,10 +232,32 @@ export function LoginView({
225
232
  const [mode, setMode] = useState<AuthMode>("buttons");
226
233
  const [fadeIn, setFadeIn] = useState(false);
227
234
  const [viewVisible, setViewVisible] = useState(true);
228
- // Lives here, not in LoginForm: the form unmounts on every login↔register
229
- // switch, and losing the tick on a mode change would silently drop opt-ins.
235
+ // Lives here, not in LoginForm: the box is ticked on the provider screen
236
+ // and read after a sign-in that happens one or two screens later, so it has
237
+ // to outlive both. The form also unmounts on every login↔register switch.
230
238
  const [newsletterOptIn, setNewsletterOptIn] = useState(false);
231
239
 
240
+ // The controller as of the latest render. A provider callback resolves long
241
+ // after the click that started it, and the `authController` captured in that
242
+ // closure is the one from the render that mounted the button — which, for a
243
+ // controller replaced on each render, has no `user` on it yet.
244
+ const authControllerRef = useRef(authController);
245
+ authControllerRef.current = authController;
246
+
247
+ /**
248
+ * Subscribe the address that just authenticated, if the visitor asked for
249
+ * it. Every sign-in path calls this, which is the point: the checkbox sits
250
+ * beside the provider buttons, so it has to mean the same thing whichever
251
+ * one is pressed.
252
+ *
253
+ * Only ever from a resolution path — a ticked box on a *failed* attempt
254
+ * must not subscribe an address nobody proved they control.
255
+ */
256
+ const subscribeIfOptedIn = useCallback((email?: string | null) => {
257
+ if (!newsletterOptIn || !onNewsletterOptIn || !email) return;
258
+ onNewsletterOptIn(email);
259
+ }, [newsletterOptIn, onNewsletterOptIn]);
260
+
232
261
  const switchMode = (newMode: AuthMode) => {
233
262
  setViewVisible(false);
234
263
  setTimeout(() => {
@@ -388,9 +417,10 @@ export function LoginView({
388
417
  bootstrapMode={true}
389
418
  defaultEmail={defaultEmail}
390
419
  defaultPassword={defaultPassword}
391
- onNewsletterOptIn={onNewsletterOptIn}
420
+ onSignedIn={subscribeIfOptedIn}
392
421
  newsletterOptIn={newsletterOptIn}
393
422
  setNewsletterOptIn={setNewsletterOptIn}
423
+ showNewsletterOptIn={Boolean(onNewsletterOptIn)}
394
424
  />
395
425
  )}
396
426
 
@@ -419,6 +449,42 @@ export function LoginView({
419
449
  {topComponent}
420
450
  </div>
421
451
  )}
452
+ {/* Above the buttons, not below them: it is a
453
+ condition on the sign-in you are about to
454
+ make, and it reads as one more line of the
455
+ consent block a host puts in
456
+ `topComponent` — the demo's privacy tick
457
+ sits directly above it.
458
+
459
+ Which is why it pulls back most of the
460
+ column's `gap-3`: at the full gap the two
461
+ ticks sat 24px apart and read as two
462
+ unrelated questions rather than one
463
+ consent block. The offset is against this
464
+ column's own gap, not against whatever
465
+ margins the host's `topComponent` happens
466
+ to carry, so it lands the same way under
467
+ any of them — but only when there is
468
+ something above to hug. With no header
469
+ and no `topComponent` this row is the
470
+ column's first child, and pulling back a
471
+ gap that is not there would crowd it into
472
+ the logo. */}
473
+ {onNewsletterOptIn && (
474
+ <label className={cls(
475
+ "flex items-center gap-2 cursor-pointer",
476
+ (topComponent || title || subtitle) && "-mt-3"
477
+ )}>
478
+ <Checkbox
479
+ checked={newsletterOptIn}
480
+ onCheckedChange={(checked) => setNewsletterOptIn(checked === true)}
481
+ size="small"
482
+ />
483
+ <Typography variant="caption" color="secondary" className="select-none">
484
+ {t("join_newsletter")}
485
+ </Typography>
486
+ </label>
487
+ )}
422
488
  <LoginButton
423
489
  disabled={disabled}
424
490
  text={"Sign in with email"}
@@ -430,6 +496,7 @@ export function LoginView({
430
496
  disabled={disabled}
431
497
  googleClientId={googleClientId}
432
498
  authController={authController}
499
+ onSignedIn={() => subscribeIfOptedIn(authControllerRef.current.user?.email)}
433
500
  />
434
501
  )}
435
502
  {hasGitHubLogin && githubClientId && (
@@ -473,9 +540,7 @@ export function LoginView({
473
540
  switchToRegister={showRegistration ? () => switchMode("register") : undefined}
474
541
  defaultEmail={defaultEmail}
475
542
  defaultPassword={defaultPassword}
476
- onNewsletterOptIn={onNewsletterOptIn}
477
- newsletterOptIn={newsletterOptIn}
478
- setNewsletterOptIn={setNewsletterOptIn}
543
+ onSignedIn={subscribeIfOptedIn}
479
544
  />
480
545
  )}
481
546
 
@@ -491,9 +556,7 @@ export function LoginView({
491
556
  switchToLogin={() => switchMode("login")}
492
557
  defaultEmail={defaultEmail}
493
558
  defaultPassword={defaultPassword}
494
- onNewsletterOptIn={onNewsletterOptIn}
495
- newsletterOptIn={newsletterOptIn}
496
- setNewsletterOptIn={setNewsletterOptIn}
559
+ onSignedIn={subscribeIfOptedIn}
497
560
  />
498
561
  )}
499
562
 
@@ -557,13 +620,21 @@ const GoogleIcon = () => (
557
620
  function GoogleLoginButton({
558
621
  disabled,
559
622
  googleClientId,
560
- authController
623
+ authController,
624
+ onSignedIn
561
625
  }: {
562
626
  disabled?: boolean,
563
627
  googleClientId: string,
564
- authController: AuthControllerExtended
628
+ authController: AuthControllerExtended,
629
+ /** Called once the controller has accepted the provider's code. */
630
+ onSignedIn?: () => void
565
631
  }) {
566
632
  const codeClientRef = useRef<{ requestCode(): void } | null>(null);
633
+ // The code client is built once and its callback closes over this render's
634
+ // props forever. A ref keeps the callback reaching the *current* handler
635
+ // rather than the one that existed when Google's script happened to load.
636
+ const onSignedInRef = useRef(onSignedIn);
637
+ onSignedInRef.current = onSignedIn;
567
638
 
568
639
  useEffect(() => {
569
640
  if (!authController.googleLogin) return;
@@ -587,6 +658,10 @@ function GoogleLoginButton({
587
658
  code: response.code,
588
659
  redirectUri: "postmessage"
589
660
  });
661
+ // After the await, and only on the resolution path: a
662
+ // newsletter tick must not subscribe an address whose
663
+ // sign-in the controller rejected.
664
+ onSignedInRef.current?.();
590
665
  } catch (err: unknown) {
591
666
  console.error("Google login error:", err);
592
667
  }
@@ -692,9 +767,10 @@ function LoginForm({
692
767
  switchToLogin,
693
768
  defaultEmail,
694
769
  defaultPassword,
695
- onNewsletterOptIn,
770
+ onSignedIn,
696
771
  newsletterOptIn = false,
697
- setNewsletterOptIn
772
+ setNewsletterOptIn,
773
+ showNewsletterOptIn = false
698
774
  }: {
699
775
  onClose: () => void,
700
776
  onForgotPassword?: () => void,
@@ -707,9 +783,15 @@ function LoginForm({
707
783
  switchToLogin?: () => void,
708
784
  defaultEmail?: string,
709
785
  defaultPassword?: string,
710
- onNewsletterOptIn?: (email: string) => void,
786
+ /** The address the controller just accepted, reported after every success. */
787
+ onSignedIn?: (email: string) => void,
711
788
  newsletterOptIn?: boolean,
712
- setNewsletterOptIn?: (checked: boolean) => void
789
+ setNewsletterOptIn?: (checked: boolean) => void,
790
+ /**
791
+ * Render the newsletter opt-in here. Only bootstrap mode does: everywhere
792
+ * else it lives on the provider screen, which bootstrap mode never shows.
793
+ */
794
+ showNewsletterOptIn?: boolean
713
795
  }) {
714
796
  const passwordRef = useRef<HTMLInputElement | null>(null);
715
797
  const { t } = useTranslation();
@@ -749,16 +831,14 @@ function LoginForm({
749
831
  // Fires only on the resolution path, i.e. after the controller accepted
750
832
  // the credentials — a ticked box on a failed attempt must not subscribe
751
833
  // an address its owner never proved they control.
752
- function subscribeIfOptedIn(email: string) {
753
- if (newsletterOptIn && onNewsletterOptIn) {
754
- onNewsletterOptIn(email);
755
- }
834
+ function reportSignedIn(email: string) {
835
+ onSignedIn?.(email);
756
836
  }
757
837
 
758
838
  function handleEnterPassword() {
759
839
  if (email && password && authController.emailPasswordLogin) {
760
840
  void Promise.resolve(authController.emailPasswordLogin(email, password))
761
- .then(() => subscribeIfOptedIn(email))
841
+ .then(() => reportSignedIn(email))
762
842
  .catch(() => undefined);
763
843
  }
764
844
  }
@@ -766,7 +846,7 @@ function LoginForm({
766
846
  function handleRegistration() {
767
847
  if (email && password && authController.register) {
768
848
  void Promise.resolve(authController.register(email, password, displayName))
769
- .then(() => subscribeIfOptedIn(email))
849
+ .then(() => reportSignedIn(email))
770
850
  .catch(() => undefined);
771
851
  }
772
852
  }
@@ -891,7 +971,10 @@ function LoginForm({
891
971
  </div>
892
972
  )}
893
973
 
894
- {onNewsletterOptIn && (
974
+ {/* Bootstrap mode only — see `showNewsletterOptIn`. Every other
975
+ route to this form came through the provider screen, which is
976
+ where the box now is. */}
977
+ {showNewsletterOptIn && (
895
978
  <label className="flex items-center gap-2 cursor-pointer mt-1 mb-1">
896
979
  <Checkbox
897
980
  checked={newsletterOptIn}
@@ -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;
@@ -24,8 +24,13 @@ export function useTranslation() {
24
24
  * Also supports i18next interpolation variables, e.g.
25
25
  * t("add_to_field", { fieldName: "Tags" })
26
26
  * t("error_deleting", { message: err.message })
27
+ * t("sort_key_position", { position: 2 })
28
+ *
29
+ * Numbers are accepted as well as strings: i18next interpolates them
30
+ * either way, and a signature that took only strings pushed a `String()`
31
+ * onto every call site with a count in it — `{{count}}`, `{{position}}`.
27
32
  */
28
- const typedT = useCallback((key: string, vars?: Record<string, string>): string =>
33
+ const typedT = useCallback((key: string, vars?: Record<string, string | number>): string =>
29
34
  t(key, vars) as string, [t]);
30
35
 
31
36
  return useMemo(() => ({ t: typedT,
package/src/locales/de.ts CHANGED
@@ -43,6 +43,16 @@ export const de: RebaseTranslations = {
43
43
  clear_sort: "Sortierung löschen",
44
44
  sort: "Sortieren",
45
45
  sort_by: "Sortieren nach",
46
+ sort_then_by: "Dann nach",
47
+ sort_ascending: "Aufsteigend sortieren",
48
+ sort_descending: "Absteigend sortieren",
49
+ sort_remove: "Sortierung entfernen",
50
+ sort_move_up: "Diesen Schlüssel wichtiger machen",
51
+ sort_move_down: "Diesen Schlüssel weniger wichtig machen",
52
+ sort_remove_key: "Diesen Sortierschlüssel entfernen",
53
+ sort_key_position: "Sortierschlüssel {{position}}",
54
+ sort_shift_click_hint: "Umschalt+Klick, um eine Spalte unter der aktuellen Sortierung hinzuzufügen",
55
+ save_entity_before_subcollections: "Du musst den Eintrag speichern, bevor du weitere Sammlungen hinzufügst",
46
56
  clear_all: "Alle löschen",
47
57
  no_items: "Keine Elemente",
48
58
  no_entries_found: "Keine Einträge gefunden",
package/src/locales/en.ts CHANGED
@@ -53,6 +53,16 @@ export const en: RebaseTranslations = {
53
53
  clear_sort: "Clear sort",
54
54
  sort: "Sort",
55
55
  sort_by: "Sort by",
56
+ sort_then_by: "Then by",
57
+ sort_ascending: "Sort ascending",
58
+ sort_descending: "Sort descending",
59
+ sort_remove: "Remove sort",
60
+ sort_move_up: "Make this key more important",
61
+ sort_move_down: "Make this key less important",
62
+ sort_remove_key: "Remove this sort key",
63
+ sort_key_position: "Sort key {{position}}",
64
+ sort_shift_click_hint: "Shift-click to add a column under the current sort",
65
+ save_entity_before_subcollections: "You need to save your entity before adding additional collections",
56
66
  clear_all: "Clear all",
57
67
  no_items: "No items",
58
68
  no_entries_found: "No entries found",
package/src/locales/es.ts CHANGED
@@ -51,6 +51,16 @@ export const es: RebaseTranslations = {
51
51
  clear_sort: "Borrar orden",
52
52
  sort: "Ordenar",
53
53
  sort_by: "Ordenar por",
54
+ sort_then_by: "Luego por",
55
+ sort_ascending: "Ordenar ascendente",
56
+ sort_descending: "Ordenar descendente",
57
+ sort_remove: "Quitar orden",
58
+ sort_move_up: "Dar más importancia a esta clave",
59
+ sort_move_down: "Dar menos importancia a esta clave",
60
+ sort_remove_key: "Quitar esta clave de orden",
61
+ sort_key_position: "Clave de orden {{position}}",
62
+ sort_shift_click_hint: "Mayús+clic para añadir una columna bajo el orden actual",
63
+ save_entity_before_subcollections: "Debes guardar la entidad antes de añadir colecciones adicionales",
54
64
  clear_all: "Borrar todo",
55
65
  no_items: "Sin elementos",
56
66
  no_entries_found: "No se encontraron entradas",
package/src/locales/fr.ts CHANGED
@@ -43,6 +43,16 @@ export const fr: RebaseTranslations = {
43
43
  clear_sort: "Effacer le tri",
44
44
  sort: "Trier",
45
45
  sort_by: "Trier par",
46
+ sort_then_by: "Puis par",
47
+ sort_ascending: "Trier par ordre croissant",
48
+ sort_descending: "Trier par ordre décroissant",
49
+ sort_remove: "Supprimer le tri",
50
+ sort_move_up: "Rendre cette clé plus importante",
51
+ sort_move_down: "Rendre cette clé moins importante",
52
+ sort_remove_key: "Supprimer cette clé de tri",
53
+ sort_key_position: "Clé de tri {{position}}",
54
+ sort_shift_click_hint: "Maj+clic pour ajouter une colonne sous le tri actuel",
55
+ save_entity_before_subcollections: "Vous devez enregistrer l'entité avant d'ajouter des collections supplémentaires",
46
56
  clear_all: "Tout effacer",
47
57
  no_items: "Aucun élément",
48
58
  no_entries_found: "Aucune entrée trouvée",
package/src/locales/hi.ts CHANGED
@@ -43,6 +43,16 @@ export const hi: RebaseTranslations = {
43
43
  clear_sort: "सॉर्ट साफ़ करें",
44
44
  sort: "क्रमबद्ध करें",
45
45
  sort_by: "इसके अनुसार क्रमबद्ध करें",
46
+ sort_then_by: "फिर इसके अनुसार",
47
+ sort_ascending: "आरोही क्रम में लगाएँ",
48
+ sort_descending: "अवरोही क्रम में लगाएँ",
49
+ sort_remove: "क्रम हटाएँ",
50
+ sort_move_up: "इस कुंजी को अधिक महत्व दें",
51
+ sort_move_down: "इस कुंजी को कम महत्व दें",
52
+ sort_remove_key: "यह क्रम कुंजी हटाएँ",
53
+ sort_key_position: "क्रम कुंजी {{position}}",
54
+ sort_shift_click_hint: "वर्तमान क्रम के नीचे कॉलम जोड़ने के लिए Shift+क्लिक करें",
55
+ save_entity_before_subcollections: "अतिरिक्त संग्रह जोड़ने से पहले आपको एंटिटी सहेजनी होगी",
46
56
  clear_all: "सभी साफ़ करें",
47
57
  no_items: "कोई आइटम नहीं",
48
58
  no_entries_found: "कोई प्रविष्टि नहीं मिली",
package/src/locales/it.ts CHANGED
@@ -43,6 +43,16 @@ export const it: RebaseTranslations = {
43
43
  clear_sort: "Cancella ordinamento",
44
44
  sort: "Ordina",
45
45
  sort_by: "Ordina per",
46
+ sort_then_by: "Poi per",
47
+ sort_ascending: "Ordina in modo crescente",
48
+ sort_descending: "Ordina in modo decrescente",
49
+ sort_remove: "Rimuovi ordinamento",
50
+ sort_move_up: "Rendi questa chiave più importante",
51
+ sort_move_down: "Rendi questa chiave meno importante",
52
+ sort_remove_key: "Rimuovi questa chiave di ordinamento",
53
+ sort_key_position: "Chiave di ordinamento {{position}}",
54
+ sort_shift_click_hint: "Maiusc+clic per aggiungere una colonna sotto l'ordinamento attuale",
55
+ save_entity_before_subcollections: "Devi salvare l'entità prima di aggiungere altre raccolte",
46
56
  clear_all: "Cancella tutto",
47
57
  no_items: "Nessun elemento",
48
58
  no_entries_found: "Nessuna voce trovata",