@rebasepro/app 0.13.0 → 0.13.1-canary.g06dbe5b

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.
Files changed (50) hide show
  1. package/dist/collections/entity-display-cache.d.ts +75 -0
  2. package/dist/collections/entity-display.d.ts +35 -0
  3. package/dist/collections/entity_image_preview.d.ts +8 -0
  4. package/dist/collections/form-layout.d.ts +6 -0
  5. package/dist/collections/index.d.ts +4 -0
  6. package/dist/collections/property-path.d.ts +16 -0
  7. package/dist/collections/property_presentation.d.ts +18 -3
  8. package/dist/collections/summary-property.d.ts +65 -0
  9. package/dist/components/LoginView/oauth-redirect-flow.d.ts +86 -0
  10. package/dist/components/common/useColumnsIds.d.ts +22 -0
  11. package/dist/core/Rebase.d.ts +1 -1
  12. package/dist/core/RebaseProps.d.ts +10 -2
  13. package/dist/hooks/data/collectionQuery.d.ts +45 -0
  14. package/dist/hooks/data/useRelationSelector.d.ts +12 -1
  15. package/dist/index.es.js +866 -241
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/util/entity_cache.d.ts +19 -3
  18. package/dist/util/previews.d.ts +19 -0
  19. package/package.json +7 -7
  20. package/src/auth/useRebaseAuthController.ts +15 -1
  21. package/src/collections/entity-display-cache.ts +195 -0
  22. package/src/collections/entity-display.ts +105 -0
  23. package/src/collections/entity_image_preview.ts +13 -0
  24. package/src/collections/form-layout.ts +8 -1
  25. package/src/collections/index.ts +4 -0
  26. package/src/collections/property-path.ts +30 -0
  27. package/src/collections/property_presentation.ts +39 -4
  28. package/src/collections/summary-property.ts +114 -0
  29. package/src/collections/title-property.ts +13 -16
  30. package/src/components/LoginView/LoginView.tsx +48 -26
  31. package/src/components/LoginView/oauth-redirect-flow.ts +184 -0
  32. package/src/components/common/useColumnsIds.tsx +67 -29
  33. package/src/components/common/useDataTableController.tsx +10 -14
  34. package/src/core/Rebase.tsx +20 -2
  35. package/src/core/RebaseProps.tsx +10 -2
  36. package/src/hooks/data/collectionQuery.ts +55 -0
  37. package/src/hooks/data/useCollection.tsx +29 -21
  38. package/src/hooks/data/useFetch.tsx +17 -2
  39. package/src/hooks/data/useRelationSelector.tsx +46 -8
  40. package/src/hooks/useAuthSubscription.ts +18 -2
  41. package/src/hooks/useBuildLocalConfigurationPersistence.tsx +20 -12
  42. package/src/locales/de.ts +2 -0
  43. package/src/locales/en.ts +3 -0
  44. package/src/locales/es.ts +2 -0
  45. package/src/locales/fr.ts +2 -0
  46. package/src/locales/hi.ts +3 -1
  47. package/src/locales/it.ts +2 -0
  48. package/src/locales/pt.ts +2 -0
  49. package/src/util/entity_cache.ts +34 -31
  50. package/src/util/previews.ts +49 -29
@@ -0,0 +1,114 @@
1
+ import type { Property } from "@rebasepro/types";
2
+
3
+ /**
4
+ * Whether a property can stand in for a record in *one line*.
5
+ *
6
+ * Preview surfaces — the reference card, a list row, a board card — have a
7
+ * single line per property and no way to grow. That is a constraint on the
8
+ * *value*, not on the property's importance: a Markdown biography is the most
9
+ * interesting column on an author, and it is still the wrong thing to paste
10
+ * into a 44px card. Ranking it here means the picker can skip it without
11
+ * anyone having to configure their way out of a broken card.
12
+ *
13
+ * Kept beside {@link getTitlePropertyCandidates} because the two answer
14
+ * neighbouring questions — "what is this record called" and "what else can be
15
+ * said about it in a line" — from the same property schema.
16
+ *
17
+ * @group Collections
18
+ */
19
+ export const SUMMARY_RANK = {
20
+ /**
21
+ * No single-line form exists. A map renders as a key/value table and an
22
+ * array of maps as a stack of them; there is no first line to take.
23
+ */
24
+ UNUSABLE: 0,
25
+ /**
26
+ * Readable in one line, but only as an excerpt — the property holds a
27
+ * document, and the line is its opening. Worth showing when nothing better
28
+ * is available, never in preference to a value that fits whole.
29
+ */
30
+ EXCERPT: 1,
31
+ /** Short and self-contained: the line *is* the value. */
32
+ DIRECT: 2
33
+ } as const;
34
+
35
+ /** One of the three summary ranks. */
36
+ export type SummaryRank = typeof SUMMARY_RANK[keyof typeof SUMMARY_RANK];
37
+
38
+ /**
39
+ * File-storage backed content (single image, array of images, generic upload…).
40
+ * Preview surfaces give these their own image slot, so they are never also a
41
+ * text line.
42
+ *
43
+ * @group Collections
44
+ */
45
+ export function isStorageProperty(property: Property | undefined): boolean {
46
+ if (!property) return false;
47
+ if (property.type === "string" && (property.storage || property.admin?.urlPreview === "image")) return true;
48
+ if (property.type === "array" && property.of && !Array.isArray(property.of)) {
49
+ const inner = property.of;
50
+ if (inner.type === "string" && (inner.storage || inner.admin?.urlPreview === "image")) return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ /**
56
+ * True when the property holds free text long enough to be a document rather
57
+ * than a value — Markdown, or an explicitly multi-line string.
58
+ *
59
+ * @group Collections
60
+ */
61
+ export function isLongTextProperty(property: Property | undefined): boolean {
62
+ if (!property || property.type !== "string") return false;
63
+ return Boolean(property.admin?.markdown || property.admin?.multiline);
64
+ }
65
+
66
+ /**
67
+ * How well a property reads as one line of a preview. See {@link SUMMARY_RANK}.
68
+ *
69
+ * Decided from the property *schema* only: no values are consulted, so the
70
+ * answer is stable for a collection and can be memoised per collection rather
71
+ * than per row.
72
+ *
73
+ * @group Collections
74
+ */
75
+ export function rankSummaryProperty(property: Property | undefined): SummaryRank {
76
+ if (!property) return SUMMARY_RANK.UNUSABLE;
77
+
78
+ // Rendered by the image slot, not as text.
79
+ if (isStorageProperty(property)) return SUMMARY_RANK.UNUSABLE;
80
+
81
+ // A map is a table of its own, whether it declares its properties (a row
82
+ // per sub-property) or not (a key/value dump). Neither has a first line.
83
+ if (property.type === "map") return SUMMARY_RANK.UNUSABLE;
84
+
85
+ if (property.type === "array") {
86
+ // A tuple: several properties side by side, so there is nothing single
87
+ // about the line it would produce.
88
+ if (Array.isArray(property.of)) return SUMMARY_RANK.UNUSABLE;
89
+ if (!property.of) return property.oneOf ? SUMMARY_RANK.UNUSABLE : SUMMARY_RANK.DIRECT;
90
+ // Arrays of block-shaped things inherit the block shape.
91
+ const innerRank = rankSummaryProperty(property.of);
92
+ if (innerRank === SUMMARY_RANK.UNUSABLE) return SUMMARY_RANK.UNUSABLE;
93
+ // An array of documents is a stack of excerpts — worse than one
94
+ // excerpt, and never what a card should lead with.
95
+ if (innerRank === SUMMARY_RANK.EXCERPT) return SUMMARY_RANK.UNUSABLE;
96
+ // Short values render as a run of chips, which fits a line.
97
+ return SUMMARY_RANK.DIRECT;
98
+ }
99
+
100
+ if (isLongTextProperty(property)) return SUMMARY_RANK.EXCERPT;
101
+
102
+ // Numbers, dates, booleans, enums, plain strings, and single references or
103
+ // relations (which collapse to one linked line) all fit as they are.
104
+ return SUMMARY_RANK.DIRECT;
105
+ }
106
+
107
+ /**
108
+ * True when the property has any single-line form at all.
109
+ *
110
+ * @group Collections
111
+ */
112
+ export function canSummariseProperty(property: Property | undefined): boolean {
113
+ return rankSummaryProperty(property) !== SUMMARY_RANK.UNUSABLE;
114
+ }
@@ -2,6 +2,9 @@ import { isRelationalCollectionConfig, type Property, type Relation } from "@reb
2
2
  import { generateForeignKeyName } from "@rebasepro/utils";
3
3
  import type { AdminCollection } from "@rebasepro/admin-types";
4
4
  import { getPrimaryKeys, isPropertyBuilder } from "@rebasepro/common";
5
+ import { getPropertyInPath } from "./property-path";
6
+ import { getDisplayPropertyKey, hasDeclaredDisplay } from "./entity-display";
7
+ import { isStorageProperty } from "./summary-property";
5
8
 
6
9
  /**
7
10
  * How good a property is as the human-readable title of an entity.
@@ -56,19 +59,6 @@ function isHidden(property: Property): boolean {
56
59
  return Boolean(property.admin?.hideFromCollection);
57
60
  }
58
61
 
59
- /**
60
- * File-storage backed content (single image, array of images, generic upload…).
61
- * Rendered by the dedicated image slot, so never a title.
62
- */
63
- function isStorageProperty(property: Property): boolean {
64
- if (property.type === "string" && (property.storage || property.admin?.urlPreview === "image")) return true;
65
- if (property.type === "array" && property.of && !Array.isArray(property.of)) {
66
- const inner = property.of;
67
- if (inner.type === "string" && (inner.storage || inner.admin?.urlPreview === "image")) return true;
68
- }
69
- return false;
70
- }
71
-
72
62
  /**
73
63
  * Every column on this collection that stores a foreign key: the `localKey` of
74
64
  * each owning relation (declared inline on a property or in `relations[]`), the
@@ -182,8 +172,13 @@ export function getTitlePropertyCandidates<M extends Record<string, unknown>>(
182
172
  ): string[] {
183
173
  if (!collection.properties) return [];
184
174
 
185
- if (collection.titleProperty && collection.properties[collection.titleProperty as string]) {
186
- return [collection.titleProperty as string];
175
+ // `getPropertyInPath`, not `properties[key]`: a declared title on a nested
176
+ // field — `profile.display_name` — was looked up flat, found nothing, and
177
+ // silently fell through to the derived ranking. The dotted form has been
178
+ // documented as valid the whole time.
179
+ const declared = getDisplayPropertyKey(collection, "title");
180
+ if (declared && getPropertyInPath(collection.properties, declared)) {
181
+ return [declared];
187
182
  }
188
183
 
189
184
  const idKeys = new Set<string>(getPrimaryKeys(collection) as string[]);
@@ -237,7 +232,9 @@ export function getLeadingRelationTitleKey<M extends Record<string, unknown>>(
237
232
  collection: AdminCollection<M>
238
233
  ): string | undefined {
239
234
  if (!collection.properties) return undefined;
240
- if (collection.titleProperty) return undefined;
235
+ // The collection states what it is called, in one form or the other; either
236
+ // way it is not asking for the leading-relation heuristic.
237
+ if (hasDeclaredDisplay(collection, "title")) return undefined;
241
238
 
242
239
  const idKeys = new Set<string>(getPrimaryKeys(collection) as string[]);
243
240
  const foreignKeys = getForeignKeyColumns(collection);
@@ -41,6 +41,7 @@ import { ErrorView } from "../ErrorView";
41
41
  import { RebaseLogo } from "../RebaseLogo";
42
42
  import { LanguageToggle } from "../LanguageToggle";
43
43
  import { useModeController, useTranslation } from "../../hooks";
44
+ import { consumeOAuthCallback, startOAuthRedirect } from "./oauth-redirect-flow";
44
45
 
45
46
  /**
46
47
  * Props for the generic LoginView.
@@ -274,25 +275,38 @@ export function LoginView({
274
275
  return () => clearTimeout(timer);
275
276
  }, []);
276
277
 
277
- // Effect to handle incoming redirect OAuth codes (GitHub, LinkedIn, etc.)
278
+ // Effect to handle incoming redirect OAuth codes (GitHub, LinkedIn, etc.).
279
+ //
280
+ // The `code` is only spent when it belongs to an authorization this browser
281
+ // started: `consumeOAuthCallback` matches the returned `state` against the
282
+ // one generated at button-click time. Without that, an attacker who starts
283
+ // their own authorization, captures the code and gets the victim to load
284
+ // `?code=<theirs>` hands the victim a session on the *attacker's* account.
278
285
  useEffect(() => {
279
- const params = new URLSearchParams(window.location.search);
280
- const code = params.get("code");
281
- const provider = localStorage.getItem("rebase_oauth_provider");
282
- if (code && provider) {
283
- localStorage.removeItem("rebase_oauth_provider");
284
- // Clear URL search params without page reload
285
- const cleanUrl = window.location.origin + window.location.pathname;
286
- window.history.replaceState({}, document.title, cleanUrl);
287
-
288
- if (authController.oauthLogin) {
289
- authController.oauthLogin(provider, {
290
- code,
291
- redirectUri: cleanUrl
292
- }).catch((err) => {
293
- console.error(`${provider} login failed:`, err);
294
- });
295
- }
286
+ const result = consumeOAuthCallback(window.location.search);
287
+ if (result.status === "none") return;
288
+
289
+ // Clear URL search params without page reload
290
+ const cleanUrl = window.location.origin + window.location.pathname;
291
+ window.history.replaceState({}, document.title, cleanUrl);
292
+
293
+ if (result.status === "error") {
294
+ console.error(`OAuth sign-in failed: ${result.error}`);
295
+ return;
296
+ }
297
+ if (result.status === "mismatch") {
298
+ console.error("Ignoring an OAuth code that does not match a sign-in this browser started.");
299
+ return;
300
+ }
301
+
302
+ if (authController.oauthLogin) {
303
+ authController.oauthLogin(result.provider, {
304
+ code: result.code,
305
+ redirectUri: result.redirectUri,
306
+ ...(result.codeVerifier ? { codeVerifier: result.codeVerifier } : {})
307
+ }).catch((err: unknown) => {
308
+ console.error(`${result.provider} login failed:`, err);
309
+ });
296
310
  }
297
311
  }, [authController]);
298
312
 
@@ -612,10 +626,15 @@ function GitHubLoginButton({
612
626
  githubClientId: string
613
627
  }) {
614
628
  const handleClick = () => {
615
- localStorage.setItem("rebase_oauth_provider", "github");
616
- const redirectUri = encodeURIComponent(window.location.origin + window.location.pathname);
617
- const scope = "read:user,user:email";
618
- window.location.href = `https://github.com/login/oauth/authorize?client_id=${githubClientId}&redirect_uri=${redirectUri}&scope=${scope}`;
629
+ // GitHub OAuth Apps do not implement PKCE, so `state` is the whole
630
+ // binding between this click and the code that comes back.
631
+ void startOAuthRedirect({
632
+ provider: "github",
633
+ authorizeUrl: "https://github.com/login/oauth/authorize",
634
+ clientId: githubClientId,
635
+ redirectUri: window.location.origin + window.location.pathname,
636
+ scope: "read:user,user:email"
637
+ });
619
638
  };
620
639
 
621
640
  return (
@@ -642,10 +661,13 @@ function LinkedInLoginButton({
642
661
  linkedinClientId: string
643
662
  }) {
644
663
  const handleClick = () => {
645
- localStorage.setItem("rebase_oauth_provider", "linkedin");
646
- const redirectUri = encodeURIComponent(window.location.origin + window.location.pathname);
647
- const scope = "openid profile email";
648
- window.location.href = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${linkedinClientId}&redirect_uri=${redirectUri}&scope=${scope}`;
664
+ void startOAuthRedirect({
665
+ provider: "linkedin",
666
+ authorizeUrl: "https://www.linkedin.com/oauth/v2/authorization",
667
+ clientId: linkedinClientId,
668
+ redirectUri: window.location.origin + window.location.pathname,
669
+ scope: "openid profile email"
670
+ });
649
671
  };
650
672
 
651
673
  return (
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The browser half of the OAuth authorization-code flow.
3
+ *
4
+ * The server only ever sees a `code` and a `redirectUri` taken from the
5
+ * request body, so `state`, PKCE and the binding between "this browser started
6
+ * a login" and "this code came back" are, by construction, the client's job.
7
+ * In the admin login they had been nobody's job: the authorize URL carried no
8
+ * `state`, and the return leg accepted any `?code=` as long as a localStorage
9
+ * marker naming the *provider* was set — a marker written on button click and
10
+ * removed only on a callback that carried a code, so abandoning the flow left
11
+ * it set indefinitely.
12
+ *
13
+ * Everything here is provider-agnostic on purpose. A button that wants a new
14
+ * provider calls {@link startOAuthRedirect} and gets state, expiry and PKCE
15
+ * without deciding anything, which is what stops the next provider shipping
16
+ * without them.
17
+ */
18
+
19
+ /** sessionStorage, not localStorage: a login attempt belongs to one tab. */
20
+ const STORAGE_KEY = "rebase_oauth_redirect";
21
+
22
+ /** How long a started authorization stays valid. */
23
+ const PENDING_TTL_MS = 10 * 60 * 1000;
24
+
25
+ export interface OAuthRedirectRequest {
26
+ /** Provider id, as mounted at `POST /auth/<id>`. */
27
+ provider: string;
28
+ /** The provider's authorization endpoint. */
29
+ authorizeUrl: string;
30
+ clientId: string;
31
+ scope: string;
32
+ /** Where the provider sends the browser back. Echoed to the token exchange. */
33
+ redirectUri: string;
34
+ /** Set when the provider implements PKCE. */
35
+ pkce?: boolean;
36
+ /** Extra authorize-endpoint parameters (`response_type`, and so on). */
37
+ params?: Record<string, string>;
38
+ }
39
+
40
+ /** What the browser remembers between leaving for the provider and coming back. */
41
+ export interface PendingOAuthRedirect {
42
+ provider: string;
43
+ state: string;
44
+ codeVerifier?: string;
45
+ redirectUri: string;
46
+ startedAt: number;
47
+ }
48
+
49
+ export type OAuthCallbackResult =
50
+ /** No OAuth callback in this URL. */
51
+ | { status: "none" }
52
+ /** The provider reported a failure, or the user declined. */
53
+ | { status: "error"; error: string }
54
+ /**
55
+ * A code arrived that this browser cannot account for: no pending
56
+ * authorization, an expired one, or a `state` that does not match. This is
57
+ * what a login-CSRF / code-injection attempt looks like.
58
+ */
59
+ | { status: "mismatch" }
60
+ | { status: "ok"; provider: string; code: string; codeVerifier?: string; redirectUri: string };
61
+
62
+ type Storage = Pick<globalThis.Storage, "getItem" | "setItem" | "removeItem">;
63
+
64
+ function defaultStorage(): Storage | null {
65
+ try {
66
+ return window.sessionStorage;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function base64Url(bytes: Uint8Array): string {
73
+ let binary = "";
74
+ for (const byte of bytes) binary += String.fromCharCode(byte);
75
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
76
+ }
77
+
78
+ function randomToken(byteLength = 32): string {
79
+ const bytes = new Uint8Array(byteLength);
80
+ crypto.getRandomValues(bytes);
81
+ return base64Url(bytes);
82
+ }
83
+
84
+ async function pkceChallenge(verifier: string): Promise<string> {
85
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
86
+ return base64Url(new Uint8Array(digest));
87
+ }
88
+
89
+ export function readPendingOAuthRedirect(storage: Storage | null = defaultStorage()): PendingOAuthRedirect | null {
90
+ if (!storage) return null;
91
+ const raw = storage.getItem(STORAGE_KEY);
92
+ if (!raw) return null;
93
+ try {
94
+ const parsed = JSON.parse(raw) as PendingOAuthRedirect;
95
+ if (!parsed?.provider || !parsed?.state) return null;
96
+ return parsed;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ export function clearPendingOAuthRedirect(storage: Storage | null = defaultStorage()): void {
103
+ storage?.removeItem(STORAGE_KEY);
104
+ }
105
+
106
+ /**
107
+ * Build the authorize URL and the pending record that will validate its
108
+ * callback. Split out from {@link startOAuthRedirect} so it can be tested
109
+ * without navigating.
110
+ */
111
+ export async function buildOAuthAuthorization(
112
+ request: OAuthRedirectRequest,
113
+ now: number = Date.now()
114
+ ): Promise<{ url: string; pending: PendingOAuthRedirect }> {
115
+ const state = randomToken();
116
+ const url = new URL(request.authorizeUrl);
117
+ url.searchParams.set("response_type", "code");
118
+ url.searchParams.set("client_id", request.clientId);
119
+ url.searchParams.set("redirect_uri", request.redirectUri);
120
+ url.searchParams.set("scope", request.scope);
121
+ url.searchParams.set("state", state);
122
+ for (const [key, value] of Object.entries(request.params ?? {})) {
123
+ url.searchParams.set(key, value);
124
+ }
125
+
126
+ let codeVerifier: string | undefined;
127
+ if (request.pkce) {
128
+ codeVerifier = randomToken(32);
129
+ url.searchParams.set("code_challenge", await pkceChallenge(codeVerifier));
130
+ url.searchParams.set("code_challenge_method", "S256");
131
+ }
132
+
133
+ return {
134
+ url: url.toString(),
135
+ pending: { provider: request.provider, state, codeVerifier, redirectUri: request.redirectUri, startedAt: now }
136
+ };
137
+ }
138
+
139
+ /** Remember the authorization and send the browser to the provider. */
140
+ export async function startOAuthRedirect(
141
+ request: OAuthRedirectRequest,
142
+ storage: Storage | null = defaultStorage()
143
+ ): Promise<void> {
144
+ const { url, pending } = await buildOAuthAuthorization(request);
145
+ storage?.setItem(STORAGE_KEY, JSON.stringify(pending));
146
+ window.location.href = url;
147
+ }
148
+
149
+ /**
150
+ * Interpret a return from the provider.
151
+ *
152
+ * Clears the pending record on **every** path that saw a callback — including
153
+ * `?error=` and a state mismatch — so a declined or abandoned consent screen
154
+ * cannot leave the browser primed to accept somebody else's code later.
155
+ */
156
+ export function consumeOAuthCallback(
157
+ search: string,
158
+ storage: Storage | null = defaultStorage(),
159
+ now: number = Date.now()
160
+ ): OAuthCallbackResult {
161
+ const params = new URLSearchParams(search);
162
+ const code = params.get("code");
163
+ const state = params.get("state");
164
+ const error = params.get("error");
165
+
166
+ if (!code && !error && !state) return { status: "none" };
167
+
168
+ const pending = readPendingOAuthRedirect(storage);
169
+ clearPendingOAuthRedirect(storage);
170
+
171
+ if (error) return { status: "error", error };
172
+ if (!code) return { status: "mismatch" };
173
+ if (!pending) return { status: "mismatch" };
174
+ if (now - pending.startedAt > PENDING_TTL_MS) return { status: "mismatch" };
175
+ if (!state || state !== pending.state) return { status: "mismatch" };
176
+
177
+ return {
178
+ status: "ok",
179
+ provider: pending.provider,
180
+ code,
181
+ codeVerifier: pending.codeVerifier,
182
+ redirectUri: pending.redirectUri
183
+ };
184
+ }
@@ -1,28 +1,10 @@
1
- import type { Property, MapProperty, Properties } from "@rebasepro/types";
1
+ import type { Property, MapProperty, Properties, RelationProperty } from "@rebasepro/types";
2
2
  import { useMemo } from "react";
3
3
  ;
4
- import { getSubcollections } from "@rebasepro/common";
4
+ import { getChildViewDeclaringProperties, getChildViewRelationPropertyKeys, getSubcollections } from "@rebasepro/common";
5
5
  import type { AdminCollection } from "@rebasepro/admin-types";
6
-
7
- /**
8
- * Get a property in a property tree from a dot-path like `address.street`.
9
- * Inlined here to avoid importing property-aware utilities from the admin layer.
10
- */
11
- function getPropertyInPath(properties: Properties, path: string): Property | undefined {
12
- if (typeof properties === "object") {
13
- if (path in properties) {
14
- return (properties as Record<string, Property>)[path];
15
- }
16
- if (path.includes(".")) {
17
- const pathSegments = path.split(".");
18
- const childProperty = (properties as Record<string, Property>)[pathSegments[0]];
19
- if (typeof childProperty === "object" && childProperty.type === "map" && childProperty.properties) {
20
- return getPropertyInPath(childProperty.properties, pathSegments.slice(1).join("."));
21
- }
22
- }
23
- }
24
- return undefined;
25
- }
6
+ import { getPropertyInPath } from "../../collections/property-path";
7
+ import { isDisabled } from "../../collections/property_presentation";
26
8
 
27
9
  export type PropertyColumnConfig = {
28
10
  key: string;
@@ -32,6 +14,43 @@ export type PropertyColumnConfig = {
32
14
  export function getSubcollectionColumnId(collection: AdminCollection<any>) {
33
15
  return `subcollection:${collection.slug}`;
34
16
  }
17
+
18
+ /**
19
+ * The jump-to-tab columns a collection table does not need, because the relation
20
+ * behind them already has a column of its own.
21
+ *
22
+ * Every child view gets a 200px button column that opens its tab. For a relation
23
+ * declared in `relations` that button is the relation's only presence in the
24
+ * table. For one declared as a property it is the second: the property's own
25
+ * column is already there, hydrated by the list fetch's `include: ["*"]`, showing
26
+ * the child rows themselves — and carrying the *same heading*, because the tab
27
+ * takes its name from the declaring property. Two columns called "Applications",
28
+ * one of them a button, and nothing in the header to tell them apart.
29
+ *
30
+ * The property column wins: it shows what the children are, and each chip in it
31
+ * opens one. The tab stays reachable by opening the record, which is what the
32
+ * rest of the table's rows do anyway.
33
+ *
34
+ * Unless the author hid that column — `hideFromCollection` on the property, or a
35
+ * `propertiesOrder` that omits it, is a statement about the column and not about
36
+ * the relation, so the button comes back rather than the relation dropping out
37
+ * of the table altogether.
38
+ */
39
+ export function getRedundantChildViewColumnIds<M extends Record<string, any>>(collection: AdminCollection<M>): Set<string> {
40
+ const redundant = new Set<string>();
41
+ const properties = collection.properties ?? {};
42
+ const order = collection.propertiesOrder as string[] | undefined;
43
+
44
+ for (const [viewKey, propertyKey] of getChildViewDeclaringProperties(collection)) {
45
+ const property = properties[propertyKey] as Property | undefined;
46
+ if (!property || property.admin?.hideFromCollection) continue;
47
+ if (order && !order.includes(propertyKey)) continue;
48
+ redundant.add(`subcollection:${viewKey}`);
49
+ }
50
+
51
+ return redundant;
52
+ }
53
+
35
54
  export function useColumnIds<M extends Record<string, any>>(collection: AdminCollection<M>, includeSubcollections: boolean): PropertyColumnConfig[] {
36
55
  return useMemo(() => {
37
56
  if (collection.propertiesOrder) {
@@ -59,9 +78,14 @@ function hideAndExpandKeys<M extends Record<string, any>>(collection: AdminColle
59
78
  // Track processed keys to avoid duplicates
60
79
  const processedPropertyKeys = new Set<string>();
61
80
 
81
+ // Both column paths funnel through here, including a `propertiesOrder` that
82
+ // was saved back with the button column in it.
83
+ const redundantChildViewColumns = getRedundantChildViewColumnIds(collection);
84
+
62
85
  const result = keys.flatMap((key) => {
63
86
  // Skip if already processed (handles duplicates in propertiesOrder)
64
87
  if (processedPropertyKeys.has(key)) return [null];
88
+ if (redundantChildViewColumns.has(key)) return [null];
65
89
 
66
90
  // Check if it's a top-level property
67
91
  const property = collection.properties[key];
@@ -85,7 +109,7 @@ function hideAndExpandKeys<M extends Record<string, any>>(collection: AdminColle
85
109
  }
86
110
  return [{
87
111
  key,
88
- disabled: Boolean(property.admin?.disabled) || Boolean(property.admin?.readOnly)
112
+ disabled: isDisabled(property) || Boolean(property.admin?.readOnly)
89
113
  }];
90
114
  }
91
115
 
@@ -108,8 +132,8 @@ function hideAndExpandKeys<M extends Record<string, any>>(collection: AdminColle
108
132
 
109
133
  return [{
110
134
  key,
111
- disabled: Boolean(rootProperty.admin?.disabled) || Boolean(rootProperty.admin?.readOnly) ||
112
- Boolean(nestedProperty.admin?.disabled) || Boolean(nestedProperty.admin?.readOnly)
135
+ disabled: isDisabled(rootProperty) || Boolean(rootProperty.admin?.readOnly) ||
136
+ isDisabled(nestedProperty) || Boolean(nestedProperty.admin?.readOnly)
113
137
  }];
114
138
  }
115
139
  }
@@ -163,7 +187,7 @@ function hideAndExpandKeys<M extends Record<string, any>>(collection: AdminColle
163
187
  } else {
164
188
  result.push({
165
189
  key: propKey,
166
- disabled: Boolean(property.admin?.disabled) || Boolean(property.admin?.readOnly)
190
+ disabled: isDisabled(property) || Boolean(property.admin?.readOnly)
167
191
  });
168
192
  processedPropertyKeys.add(propKey);
169
193
  }
@@ -194,22 +218,36 @@ function getDefaultColumnKeys<M extends Record<string, any> = any>(collection: A
194
218
  }
195
219
 
196
220
  export function getColumnKeysForProperty(property: Property, key: string, disabled?: boolean): PropertyColumnConfig[] {
221
+ // A key with no property behind it describes no column. Callers walk whole
222
+ // property maps here to build a table's columns, so reading `.type` off a
223
+ // hole threw away every other column with it.
224
+ if (!property) return [];
197
225
  if (property.type === "map" && property.admin?.spreadChildren && property.properties) {
198
226
  return Object.entries(property.properties)
199
227
  .flatMap(([childKey, childProperty]) => getColumnKeysForProperty(
200
228
  childProperty as Readonly<Property>,
201
229
  `${key}.${childKey}`,
202
- disabled || Boolean(property.admin?.disabled) || Boolean(property.admin?.readOnly))
230
+ disabled || isDisabled(property) || Boolean(property.admin?.readOnly))
203
231
  );
204
232
  }
205
233
  return [{
206
234
  key,
207
- disabled: disabled || Boolean(property.admin?.disabled) || Boolean(property.admin?.readOnly)
235
+ disabled: disabled || isDisabled(property) || Boolean(property.admin?.readOnly)
208
236
  }];
209
237
  }
210
238
 
211
239
  export function getFormFieldKeys(collection: AdminCollection): string[] {
212
- const propertyKeys = collection.properties ? Object.keys(collection.properties) : [];
240
+ const properties = collection.properties ?? {};
241
+ // A many-relation the entity view already lists as a tab is not an input:
242
+ // the tab is the treatment, and rendering the same declaration again as a
243
+ // picker asked the author to select a collection's own children from a
244
+ // dropdown. `renderInForm` is how a project that wants the picker asks for
245
+ // it back — opt in, because the tab is the default.
246
+ const consumedByChildView = getChildViewRelationPropertyKeys(collection);
247
+ const propertyKeys = Object.keys(properties).filter(key => {
248
+ if (!consumedByChildView.has(key)) return true;
249
+ return (properties[key] as RelationProperty).admin?.renderInForm === true;
250
+ });
213
251
  const additionalFields = collection.additionalFields ?? [];
214
252
  const allKeys = [
215
253
  ...propertyKeys,
@@ -5,6 +5,7 @@ import { useLocation } from "react-router";
5
5
  import { useData, useRebaseContext } from "../../hooks";
6
6
  import { useDataOrder } from "../../hooks/data/useDataOrder";
7
7
  import { populateFetchCache } from "../../hooks/data/useFetch";
8
+ import { toFindParams } from "../../hooks/data/collectionQuery";
8
9
  import { getRelationIncludeParams } from "../../util/previews";
9
10
  import { Entity, EntityReference, EntityRelation, FilterValues, User, WhereFilterOp, FindResponse } from "@rebasepro/types";
10
11
  import { EntityTableController, RebaseContext, SelectedCellProps, AdminCollection } from "@rebasepro/admin-types";
@@ -260,21 +261,16 @@ export function useDataTableController<M extends Record<string, any> = any, USER
260
261
  const includeParams = getRelationIncludeParams(collection);
261
262
 
262
263
  if (accessor.listen) {
263
- unsubscribe = accessor.listen({
264
- where: whereParams,
265
- limit: itemCount,
266
- orderBy: orderByParams,
267
- searchString,
268
- include: includeParams
269
- }, (res) => onEntitiesUpdate(res.data as Entity<M>[]), onError);
264
+ // Assembled in one place — see `toFindParams`. Listing the fields
265
+ // here is what let `searchExplain` reach three of four read paths.
266
+ unsubscribe = accessor.listen(
267
+ toFindParams({ where: whereParams, limit: itemCount, orderBy: orderByParams,
268
+ searchString, include: includeParams }),
269
+ (res) => onEntitiesUpdate(res.data as Entity<M>[]), onError);
270
270
  } else {
271
- accessor.find({
272
- where: whereParams,
273
- limit: itemCount,
274
- orderBy: orderByParams,
275
- searchString,
276
- include: includeParams
277
- })
271
+ accessor.find(
272
+ toFindParams({ where: whereParams, limit: itemCount, orderBy: orderByParams,
273
+ searchString, include: includeParams }))
278
274
  .then((res) => onEntitiesUpdate(res.data as Entity<M>[]))
279
275
  .catch(onError);
280
276
  unsubscribe = () => undefined;