@rebasepro/app 0.13.0 → 0.13.1-canary.g249daa1
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/collections/entity-display-cache.d.ts +75 -0
- package/dist/collections/entity-display.d.ts +35 -0
- package/dist/collections/entity_image_preview.d.ts +8 -0
- package/dist/collections/form-layout.d.ts +6 -0
- package/dist/collections/index.d.ts +4 -0
- package/dist/collections/property-path.d.ts +16 -0
- package/dist/collections/property_presentation.d.ts +18 -3
- package/dist/collections/summary-property.d.ts +65 -0
- package/dist/components/common/useColumnsIds.d.ts +22 -0
- package/dist/core/Rebase.d.ts +1 -1
- package/dist/core/RebaseProps.d.ts +10 -2
- package/dist/hooks/data/collectionQuery.d.ts +45 -0
- package/dist/index.es.js +686 -217
- package/dist/index.es.js.map +1 -1
- package/dist/util/entity_cache.d.ts +19 -3
- package/dist/util/previews.d.ts +19 -0
- package/package.json +7 -7
- package/src/auth/useRebaseAuthController.ts +9 -1
- package/src/collections/entity-display-cache.ts +195 -0
- package/src/collections/entity-display.ts +105 -0
- package/src/collections/entity_image_preview.ts +13 -0
- package/src/collections/form-layout.ts +8 -1
- package/src/collections/index.ts +4 -0
- package/src/collections/property-path.ts +30 -0
- package/src/collections/property_presentation.ts +39 -4
- package/src/collections/summary-property.ts +114 -0
- package/src/collections/title-property.ts +13 -16
- package/src/components/common/useColumnsIds.tsx +67 -29
- package/src/components/common/useDataTableController.tsx +10 -14
- package/src/core/Rebase.tsx +20 -2
- package/src/core/RebaseProps.tsx +10 -2
- package/src/hooks/data/collectionQuery.ts +55 -0
- package/src/hooks/data/useCollection.tsx +29 -21
- package/src/hooks/data/useFetch.tsx +17 -2
- package/src/hooks/data/useRelationSelector.tsx +20 -4
- package/src/hooks/useAuthSubscription.ts +18 -2
- package/src/hooks/useBuildLocalConfigurationPersistence.tsx +20 -12
- package/src/locales/de.ts +1 -0
- package/src/locales/en.ts +2 -0
- package/src/locales/es.ts +1 -0
- package/src/locales/fr.ts +1 -0
- package/src/locales/hi.ts +2 -1
- package/src/locales/it.ts +1 -0
- package/src/locales/pt.ts +1 -0
- package/src/util/entity_cache.ts +34 -31
- 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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -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:
|
|
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:
|
|
112
|
-
|
|
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:
|
|
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 ||
|
|
230
|
+
disabled || isDisabled(property) || Boolean(property.admin?.readOnly))
|
|
203
231
|
);
|
|
204
232
|
}
|
|
205
233
|
return [{
|
|
206
234
|
key,
|
|
207
|
-
disabled: disabled ||
|
|
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
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
orderBy: orderByParams,
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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;
|
package/src/core/Rebase.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import type { ComponentOverrideMap } from "@rebasepro/admin-types";
|
|
|
6
6
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
7
7
|
import { CenteredView, Typography } from "@rebasepro/ui";
|
|
8
8
|
import { User, CollectionRegistryController, DataDriver, DataSourceDefinition, RebaseData, DEFAULT_DATA_SOURCE_KEY, StorageSource, StorageSourceDefinition, DEFAULT_STORAGE_SOURCE_KEY } from "@rebasepro/types";
|
|
9
|
-
import { RebaseContext } from "@rebasepro/admin-types";
|
|
9
|
+
import { RebaseContext, UNRENDERED_SLOTS } from "@rebasepro/admin-types";
|
|
10
10
|
import { PluginProviderStack } from "./PluginProviderStack";
|
|
11
11
|
import { PluginLifecycleManager } from "./PluginLifecycleManager";
|
|
12
12
|
import { AuthControllerContext, CollectionResolverRegistrationContext, CollectionResolver } from "../contexts";
|
|
@@ -46,7 +46,7 @@ import { useBuildEffectiveRoleController } from "../hooks/useBuildEffectiveRoleC
|
|
|
46
46
|
*
|
|
47
47
|
* @group Core
|
|
48
48
|
*/
|
|
49
|
-
export function Rebase<USER extends User>(props: RebaseProps<USER>) {
|
|
49
|
+
export function Rebase<USER extends User, DB = unknown>(props: RebaseProps<USER, DB>) {
|
|
50
50
|
|
|
51
51
|
const {
|
|
52
52
|
children,
|
|
@@ -90,6 +90,24 @@ export function Rebase<USER extends User>(props: RebaseProps<USER>) {
|
|
|
90
90
|
...((plugins ?? []).flatMap((p) => p.slots ?? []))
|
|
91
91
|
], [directSlots, plugins]);
|
|
92
92
|
|
|
93
|
+
// Seven of the twenty-nine declared slots are rendered nowhere. They have
|
|
94
|
+
// props interfaces and rows in the public slot reference, so registering
|
|
95
|
+
// for one looks exactly like registering for one that works — and then
|
|
96
|
+
// nothing happens, with no way to tell whether the fault is the plugin's.
|
|
97
|
+
// `UNRENDERED_SLOTS` is kept honest by `slot-render-sites.test.ts`, which
|
|
98
|
+
// derives the same set by scanning for render sites.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const dead = new Set<string>(UNRENDERED_SLOTS);
|
|
101
|
+
const registered = new Set(resolvedSlots.map(s => s.slot).filter(slot => dead.has(slot)));
|
|
102
|
+
for (const slot of registered) {
|
|
103
|
+
console.warn(
|
|
104
|
+
`[Rebase] A component is registered for the "${slot}" slot, which this ` +
|
|
105
|
+
"build declares but renders nowhere — nothing will appear. This is a gap " +
|
|
106
|
+
"in Rebase, not in your plugin."
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}, [resolvedSlots]);
|
|
110
|
+
|
|
93
111
|
|
|
94
112
|
// Auth fallback logic
|
|
95
113
|
const clientAuthController = useAuthSubscription(authControllerProp ? undefined : client?.auth);
|
package/src/core/RebaseProps.tsx
CHANGED
|
@@ -105,7 +105,7 @@ import type { EffectiveRoleController } from "@rebasepro/types";
|
|
|
105
105
|
*
|
|
106
106
|
* @group Models
|
|
107
107
|
*/
|
|
108
|
-
export type RebaseProps<USER extends User> = {
|
|
108
|
+
export type RebaseProps<USER extends User, DB = unknown> = {
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
111
|
* The root components of your application. Use RebaseAdmin, RebaseStudio, and RebaseShell.
|
|
@@ -151,8 +151,16 @@ export type RebaseProps<USER extends User> = {
|
|
|
151
151
|
* entry keyed `"(default)"` carries a driver.
|
|
152
152
|
* `client.auth` is subscribed unless `authController` is provided.
|
|
153
153
|
* `client.storage` is used unless `storageSource` is provided.
|
|
154
|
+
*
|
|
155
|
+
* `DB` is inferred from whatever is passed, and defaults to `unknown` for
|
|
156
|
+
* an untyped client. It has to be a parameter rather than a fixed
|
|
157
|
+
* `RebaseClient`: `RebaseClient<unknown>` is not a supertype of
|
|
158
|
+
* `RebaseClient<Database>` — the dynamic branch of `RebaseSdkData` is an
|
|
159
|
+
* index signature that no concrete instantiation satisfies — so pinning it
|
|
160
|
+
* here rejected the client of every project that generates a `Database`
|
|
161
|
+
* type, which is the whole typed-SDK path.
|
|
154
162
|
*/
|
|
155
|
-
client?: RebaseClient
|
|
163
|
+
client?: RebaseClient<DB>;
|
|
156
164
|
|
|
157
165
|
/**
|
|
158
166
|
* The data sources of the app. Each entry pairs a
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { FindParams, FilterValues, LogicalCondition } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one place a collection read's parameters are assembled.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this exists
|
|
7
|
+
*
|
|
8
|
+
* Four call sites used to build this object by hand — `useCollection` and
|
|
9
|
+
* `useDataTableController`, each in both their `listen` and `find` branches.
|
|
10
|
+
* Every one of them listed the fields it forwarded, so adding an option to
|
|
11
|
+
* `FindParams` meant remembering four places, and forgetting one failed
|
|
12
|
+
* silently: the option simply never reached the server, and the feature it
|
|
13
|
+
* enabled looked broken for reasons nowhere near the code that dropped it.
|
|
14
|
+
*
|
|
15
|
+
* That is not hypothetical. `searchExplain` was added to three of the four and
|
|
16
|
+
* the miss was found by intercepting a WebSocket frame, because nothing else
|
|
17
|
+
* could see it.
|
|
18
|
+
*
|
|
19
|
+
* A field added here reaches every read. `toFindParams` returns `FindParams`
|
|
20
|
+
* itself, so the compiler checks the shape rather than a hand-copied list.
|
|
21
|
+
*/
|
|
22
|
+
export interface CollectionQueryInput<M extends Record<string, unknown>> {
|
|
23
|
+
where?: FilterValues<Extract<keyof M, string>>;
|
|
24
|
+
logical?: LogicalCondition;
|
|
25
|
+
/**
|
|
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.
|
|
31
|
+
*/
|
|
32
|
+
orderBy?: [string, "asc" | "desc"];
|
|
33
|
+
limit?: number;
|
|
34
|
+
offset?: number;
|
|
35
|
+
page?: number;
|
|
36
|
+
searchString?: string;
|
|
37
|
+
include?: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the parameters for one collection read.
|
|
42
|
+
*
|
|
43
|
+
* Derived options live here rather than at the call sites, so they cannot
|
|
44
|
+
* disagree: `searchExplain` rides along exactly when there is a search string
|
|
45
|
+
* to explain, which is also the only time it costs anything.
|
|
46
|
+
*/
|
|
47
|
+
export function toFindParams<M extends Record<string, unknown>>(
|
|
48
|
+
input: CollectionQueryInput<M>
|
|
49
|
+
): FindParams<M> {
|
|
50
|
+
const { searchString, ...rest } = input;
|
|
51
|
+
return {
|
|
52
|
+
...rest,
|
|
53
|
+
...(searchString ? { searchString, searchExplain: true } : {})
|
|
54
|
+
} as FindParams<M>;
|
|
55
|
+
}
|
|
@@ -3,6 +3,7 @@ import { useEffect, useState, useMemo } from "react";
|
|
|
3
3
|
import { Entity, FilterValues, User } from "@rebasepro/types";
|
|
4
4
|
import { useData } from "./useData";
|
|
5
5
|
import { isSchemaDriftError, useSchemaDriftContext } from "../../components/SchemaDriftBanner";
|
|
6
|
+
import { toFindParams } from "./collectionQuery";
|
|
6
7
|
import { getRelationIncludeParams } from "../../util/previews";
|
|
7
8
|
import type { AdminCollection } from "@rebasepro/admin-types";
|
|
8
9
|
/**
|
|
@@ -136,30 +137,37 @@ export function useCollection<M extends Record<string, any>, USER extends User>(
|
|
|
136
137
|
const includeParams = getRelationIncludeParams(collection);
|
|
137
138
|
|
|
138
139
|
if (accessor.listen) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
limit: itemCount,
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
orderBy: orderByParams,
|
|
145
|
-
searchString,
|
|
146
|
-
include: includeParams
|
|
147
|
-
}, (res) => onEntitiesUpdate({ data: res.data as Entity<M>[],
|
|
140
|
+
// Assembled in one place — see `toFindParams`.
|
|
141
|
+
return accessor.listen(
|
|
142
|
+
toFindParams({ where: whereParams, limit: itemCount, offset, page,
|
|
143
|
+
orderBy: orderByParams, searchString, include: includeParams }),
|
|
144
|
+
(res) => onEntitiesUpdate({ data: res.data as Entity<M>[],
|
|
148
145
|
meta: res.meta }), onError);
|
|
149
146
|
} else {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
147
|
+
// The one-shot fallback, taken whenever the client has no socket.
|
|
148
|
+
// `listen` above cancels on cleanup, and every dependency of this
|
|
149
|
+
// effect — the search string, the filters, the sort, the page —
|
|
150
|
+
// changes while a request is in flight. A promise cannot be
|
|
151
|
+
// unsubscribed, so the cleanup has to disown its result instead:
|
|
152
|
+
// without this, whichever response arrives last wins, and responses
|
|
153
|
+
// do not arrive in request order. Typing into search would settle
|
|
154
|
+
// on the results for whichever query the server happened to finish
|
|
155
|
+
// last.
|
|
156
|
+
let cancelled = false;
|
|
157
|
+
accessor.find(
|
|
158
|
+
toFindParams({ where: whereParams, limit: itemCount, offset, page,
|
|
159
|
+
orderBy: orderByParams, searchString, include: includeParams }))
|
|
160
|
+
.then((res) => {
|
|
161
|
+
if (cancelled) return;
|
|
162
|
+
onEntitiesUpdate({ data: res.data as Entity<M>[],
|
|
163
|
+
meta: res.meta });
|
|
164
|
+
})
|
|
165
|
+
.catch((e) => {
|
|
166
|
+
if (cancelled) return;
|
|
167
|
+
onError(e);
|
|
168
|
+
});
|
|
162
169
|
return () => {
|
|
170
|
+
cancelled = true;
|
|
163
171
|
};
|
|
164
172
|
}
|
|
165
173
|
}, [path, itemCount, offset, page, currentSort, sortByProperty, filterValues, searchString, dataClient, collection]);
|
|
@@ -117,10 +117,25 @@ export function useFetch<M extends Record<string, any>, USER extends User = User
|
|
|
117
117
|
if (accessor.listenById) {
|
|
118
118
|
return accessor.listenById(entityId, (entity) => onEntityUpdate(entity as Entity<M> | undefined), onError);
|
|
119
119
|
} else {
|
|
120
|
+
// The one-shot fallback, taken whenever the client has no
|
|
121
|
+
// socket. `listenById` above cancels on cleanup; a promise
|
|
122
|
+
// cannot, so the cleanup disowns its result instead. `entityId`
|
|
123
|
+
// is an effect dependency, and where this hook's caller is not
|
|
124
|
+
// remounted per record — the side panel replaces its contents
|
|
125
|
+
// in place — a slow response for the record just left would
|
|
126
|
+
// otherwise land on top of the one now showing.
|
|
127
|
+
let cancelled = false;
|
|
120
128
|
accessor.findById(entityId)
|
|
121
|
-
.then((entity) =>
|
|
122
|
-
|
|
129
|
+
.then((entity) => {
|
|
130
|
+
if (cancelled) return;
|
|
131
|
+
onEntityUpdate(entity as Entity<M> | undefined);
|
|
132
|
+
})
|
|
133
|
+
.catch((e) => {
|
|
134
|
+
if (cancelled) return;
|
|
135
|
+
onError(e);
|
|
136
|
+
});
|
|
123
137
|
return () => {
|
|
138
|
+
cancelled = true;
|
|
124
139
|
};
|
|
125
140
|
}
|
|
126
141
|
}
|
|
@@ -174,6 +174,14 @@ export function useRelationSelector<M extends Record<string, any> = any>(
|
|
|
174
174
|
}, (res) => onEntitiesUpdate({ data: res.data as Entity<M>[],
|
|
175
175
|
meta: res.meta }), onErrorUpdate);
|
|
176
176
|
} else {
|
|
177
|
+
// The one-shot fallback, taken whenever the client has no socket.
|
|
178
|
+
// `cleanupSubscription` is what keeps this hook's results matching
|
|
179
|
+
// the query that asked for them, and it can only do that if the
|
|
180
|
+
// fallback hands back something that actually cancels. A promise
|
|
181
|
+
// cannot be unsubscribed, so this disowns its result instead —
|
|
182
|
+
// otherwise the debounce is the only thing between a slow response
|
|
183
|
+
// for "ab" and its landing on top of the results for "abc".
|
|
184
|
+
let cancelled = false;
|
|
177
185
|
accessor.find({
|
|
178
186
|
where: whereParams,
|
|
179
187
|
limit: limit,
|
|
@@ -182,10 +190,18 @@ meta: res.meta }), onErrorUpdate);
|
|
|
182
190
|
searchString: currentSearch,
|
|
183
191
|
include: includeParams
|
|
184
192
|
})
|
|
185
|
-
.then((res) =>
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
193
|
+
.then((res) => {
|
|
194
|
+
if (cancelled) return;
|
|
195
|
+
onEntitiesUpdate({ data: res.data as Entity<M>[],
|
|
196
|
+
meta: res.meta });
|
|
197
|
+
})
|
|
198
|
+
.catch((e) => {
|
|
199
|
+
if (cancelled) return;
|
|
200
|
+
onErrorUpdate(e);
|
|
201
|
+
});
|
|
202
|
+
unsubscribe = () => {
|
|
203
|
+
cancelled = true;
|
|
204
|
+
};
|
|
189
205
|
}
|
|
190
206
|
|
|
191
207
|
unsubscribeRef.current = unsubscribe || null;
|
|
@@ -50,9 +50,25 @@ export function useAuthSubscription(authClient?: AuthClient): AuthController {
|
|
|
50
50
|
if (user) {
|
|
51
51
|
setUser(user);
|
|
52
52
|
}
|
|
53
|
+
setAuthError(undefined);
|
|
53
54
|
})
|
|
54
|
-
.catch(e => {
|
|
55
|
-
//
|
|
55
|
+
.catch((e: unknown) => {
|
|
56
|
+
// A 401 or 403 is the ordinary first load of an anonymous
|
|
57
|
+
// visitor: there is no session, which is not an error.
|
|
58
|
+
//
|
|
59
|
+
// Everything else is. This used to swallow the lot with
|
|
60
|
+
// "Ignore, user just isn't logged in", leaving `authError`
|
|
61
|
+
// — a field this controller declares and `Rebase` renders a
|
|
62
|
+
// full-screen "Error loading auth" view for — permanently
|
|
63
|
+
// undefined. So the view was unreachable, and a backend that
|
|
64
|
+
// was down, misconfigured, or blocked by CORS looked exactly
|
|
65
|
+
// like being signed out: the app showed the login form.
|
|
66
|
+
const status = (e as { status?: number } | undefined)?.status;
|
|
67
|
+
if (status === 401 || status === 403) {
|
|
68
|
+
setAuthError(undefined);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
setAuthError(e instanceof Error ? e : new Error(String(e)));
|
|
56
72
|
})
|
|
57
73
|
.finally(() => {
|
|
58
74
|
setInitialLoading(false);
|