@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/dist/components/LoginView/LoginView.d.ts +12 -5
- package/dist/hooks/data/collectionQuery.d.ts +6 -6
- package/dist/hooks/useTranslation.d.ts +1 -1
- package/dist/index.es.js +139 -31
- package/dist/index.es.js.map +1 -1
- package/package.json +8 -8
- package/src/components/Debug/UIReferenceView.tsx +8 -1
- package/src/components/Debug/UIStyleGuide.tsx +4 -1
- package/src/components/Debug/crm-dashboard/CrmDashboardDemo.tsx +5 -1
- package/src/components/LoginView/LoginView.tsx +111 -28
- package/src/components/common/useDataTableController.tsx +40 -18
- package/src/hooks/data/collectionQuery.ts +6 -6
- package/src/hooks/useTranslation.ts +6 -1
- package/src/locales/de.ts +10 -0
- package/src/locales/en.ts +10 -0
- package/src/locales/es.ts +10 -0
- package/src/locales/fr.ts +10 -0
- package/src/locales/hi.ts +10 -0
- package/src/locales/it.ts +10 -0
- package/src/locales/pt.ts +10 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/app",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.14.
|
|
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.
|
|
56
|
-
"@rebasepro/
|
|
57
|
-
"@rebasepro/forms": "0.14.
|
|
58
|
-
"@rebasepro/
|
|
59
|
-
"@rebasepro/
|
|
60
|
-
"@rebasepro/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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'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,
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
|
229
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
753
|
-
|
|
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(() =>
|
|
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(() =>
|
|
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
|
-
{
|
|
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?: [
|
|
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
|
-
|
|
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
|
|
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<
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
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,
|
|
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:
|
|
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?: [
|
|
383
|
+
function encodeFilterAndSort(filterValues?: FilterValues<string>, sortBy?: OrderByTuple[] | undefined) {
|
|
375
384
|
const entries: Record<string, string> = {};
|
|
376
|
-
if (sortBy) {
|
|
377
|
-
|
|
378
|
-
|
|
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?:
|
|
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: [
|
|
446
|
+
let sortBy: OrderByTuple[] | undefined = undefined;
|
|
434
447
|
entries.forEach((value, key) => {
|
|
435
448
|
if (key === "__sort") {
|
|
436
|
-
|
|
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
|
|
28
|
-
*
|
|
29
|
-
* every call site, which is the kind of friction that sends people back
|
|
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?:
|
|
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",
|