@rebasepro/app 0.13.0 → 0.13.1-canary.g18cfeb7
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/index.es.js +660 -210
- 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/core/Rebase.tsx +20 -2
- package/src/core/RebaseProps.tsx +10 -2
- package/src/hooks/data/useCollection.tsx +20 -3
- 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,
|
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
|
|
@@ -147,6 +147,16 @@ export function useCollection<M extends Record<string, any>, USER extends User>(
|
|
|
147
147
|
}, (res) => onEntitiesUpdate({ data: res.data as Entity<M>[],
|
|
148
148
|
meta: res.meta }), onError);
|
|
149
149
|
} else {
|
|
150
|
+
// The one-shot fallback, taken whenever the client has no socket.
|
|
151
|
+
// `listen` above cancels on cleanup, and every dependency of this
|
|
152
|
+
// effect — the search string, the filters, the sort, the page —
|
|
153
|
+
// changes while a request is in flight. A promise cannot be
|
|
154
|
+
// unsubscribed, so the cleanup has to disown its result instead:
|
|
155
|
+
// without this, whichever response arrives last wins, and responses
|
|
156
|
+
// do not arrive in request order. Typing into search would settle
|
|
157
|
+
// on the results for whichever query the server happened to finish
|
|
158
|
+
// last.
|
|
159
|
+
let cancelled = false;
|
|
150
160
|
accessor.find({
|
|
151
161
|
where: whereParams,
|
|
152
162
|
limit: itemCount,
|
|
@@ -156,10 +166,17 @@ meta: res.meta }), onError);
|
|
|
156
166
|
searchString,
|
|
157
167
|
include: includeParams
|
|
158
168
|
})
|
|
159
|
-
.then((res) =>
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
.then((res) => {
|
|
170
|
+
if (cancelled) return;
|
|
171
|
+
onEntitiesUpdate({ data: res.data as Entity<M>[],
|
|
172
|
+
meta: res.meta });
|
|
173
|
+
})
|
|
174
|
+
.catch((e) => {
|
|
175
|
+
if (cancelled) return;
|
|
176
|
+
onError(e);
|
|
177
|
+
});
|
|
162
178
|
return () => {
|
|
179
|
+
cancelled = true;
|
|
163
180
|
};
|
|
164
181
|
}
|
|
165
182
|
}, [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);
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
|
2
2
|
import { PartialCollectionConfig, UserConfigurationPersistence } from "@rebasepro/admin-types";
|
|
3
3
|
import { stripCollectionPath } from "@rebasepro/common";
|
|
4
|
-
import { mergeDeep } from "@rebasepro/utils";
|
|
4
|
+
import { isArrayValue, isRecordValue, mergeDeep, readStoredJson, writeStoredJson } from "@rebasepro/utils";
|
|
5
5
|
|
|
6
6
|
export function useBuildLocalConfigurationPersistence(): UserConfigurationPersistence {
|
|
7
7
|
|
|
8
8
|
const configCache = useRef<Record<string, PartialCollectionConfig>>({});
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
// Read during render by every collection view, so an unreadable value here
|
|
11
|
+
// used to throw the view away rather than fall back to an unconfigured one.
|
|
12
|
+
const getCollectionFromStorage = useCallback(<M extends Record<string, any>>(storageKey: string): PartialCollectionConfig<M> => {
|
|
13
|
+
return readStoredJson<PartialCollectionConfig<M>>(storageKey, { fallback: {}, accept: isRecordValue });
|
|
13
14
|
}, []);
|
|
14
15
|
|
|
15
16
|
const getCollectionConfig = useCallback(<M extends Record<string, any>>(path: string): PartialCollectionConfig<M> => {
|
|
@@ -22,9 +23,15 @@ export function useBuildLocalConfigurationPersistence(): UserConfigurationPersis
|
|
|
22
23
|
|
|
23
24
|
const onCollectionModified = useCallback(<M extends Record<string, any>>(path: string, data: PartialCollectionConfig<M>) => {
|
|
24
25
|
const storageKey = `collection_config::${stripCollectionPath(path)}`;
|
|
25
|
-
|
|
26
|
+
writeStoredJson(storageKey, data);
|
|
26
27
|
const cachedConfig = configCache.current[storageKey];
|
|
27
|
-
|
|
28
|
+
// `getCollectionFromStorage` takes the storage key, not the path — every
|
|
29
|
+
// other caller passes one. Reading `path` looked up a key this hook
|
|
30
|
+
// never writes, so the fallback could only ever contribute `{}`. It is
|
|
31
|
+
// masked today because the one caller merges the stored config in
|
|
32
|
+
// before calling, but `onCollectionModified` accepts a partial by type
|
|
33
|
+
// and by its public interface, and a real partial would lose the rest.
|
|
34
|
+
const newConfig = mergeDeep(cachedConfig ?? getCollectionFromStorage(storageKey), data);
|
|
28
35
|
configCache.current[storageKey] = mergeDeep(configCache.current[storageKey], newConfig);
|
|
29
36
|
}, [getCollectionFromStorage]);
|
|
30
37
|
|
|
@@ -33,23 +40,24 @@ export function useBuildLocalConfigurationPersistence(): UserConfigurationPersis
|
|
|
33
40
|
const [collapsedGroups, _setCollapsedGroups] = useState<string[]>([]);
|
|
34
41
|
|
|
35
42
|
useEffect(() => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
const readPaths = (key: string) => readStoredJson<string[]>(key, { fallback: [], accept: isArrayValue });
|
|
44
|
+
_setRecentlyVisitedPaths(readPaths("recently_visited_paths"));
|
|
45
|
+
_setFavouritePaths(readPaths("favourite_paths"));
|
|
46
|
+
_setCollapsedGroups(readPaths("collapsed_groups"));
|
|
39
47
|
}, []);
|
|
40
48
|
|
|
41
49
|
const setRecentlyVisitedPaths = useCallback((paths: string[]) => {
|
|
42
|
-
|
|
50
|
+
writeStoredJson("recently_visited_paths", paths);
|
|
43
51
|
_setRecentlyVisitedPaths(paths);
|
|
44
52
|
}, []);
|
|
45
53
|
|
|
46
54
|
const setFavouritePaths = useCallback((paths: string[]) => {
|
|
47
|
-
|
|
55
|
+
writeStoredJson("favourite_paths", paths);
|
|
48
56
|
_setFavouritePaths(paths);
|
|
49
57
|
}, []);
|
|
50
58
|
|
|
51
59
|
const setCollapsedGroups = useCallback((paths: string[]) => {
|
|
52
|
-
|
|
60
|
+
writeStoredJson("collapsed_groups", paths);
|
|
53
61
|
_setCollapsedGroups(paths);
|
|
54
62
|
}, []);
|
|
55
63
|
|
package/src/locales/de.ts
CHANGED
|
@@ -77,6 +77,7 @@ export const de: RebaseTranslations = {
|
|
|
77
77
|
column_cannot_be_edited: "Diese Spalte kann nicht direkt bearbeitet werden",
|
|
78
78
|
close: "Schließen",
|
|
79
79
|
hide_list: "Liste ausblenden",
|
|
80
|
+
show_list: "Liste einblenden",
|
|
80
81
|
unsaved_local_changes: "Ungespeicherte lokale Änderungen",
|
|
81
82
|
unsaved_local_changes_description: "Dieses Dokument wurde lokal bearbeitet und weist ungespeicherte Änderungen auf. Diese lokalen Änderungen gehen verloren, wenn Sie sie nicht anwenden.",
|
|
82
83
|
preview_changes: "Änderungen in der Vorschau anzeigen",
|
package/src/locales/en.ts
CHANGED
|
@@ -87,6 +87,7 @@ export const en: RebaseTranslations = {
|
|
|
87
87
|
column_cannot_be_edited: "This column can't be edited directly",
|
|
88
88
|
close: "Close",
|
|
89
89
|
hide_list: "Hide list",
|
|
90
|
+
show_list: "Show list",
|
|
90
91
|
unsaved_local_changes: "Unsaved Local changes",
|
|
91
92
|
unsaved_local_changes_description: "This document was edited locally and has unsaved changes. These local changes will be lost if you don't apply them.",
|
|
92
93
|
preview_changes: "Preview Changes",
|
|
@@ -993,6 +994,7 @@ export const en: RebaseTranslations = {
|
|
|
993
994
|
inspect_record: "Inspect record",
|
|
994
995
|
inspect: "Inspect",
|
|
995
996
|
close_inspector: "Close inspector",
|
|
997
|
+
open_in_live_site: "Open in the live site",
|
|
996
998
|
|
|
997
999
|
// ─── Revision history ───────────────────────────────────────
|
|
998
1000
|
history_revert: "Revert to this version",
|
package/src/locales/es.ts
CHANGED
|
@@ -85,6 +85,7 @@ export const es: RebaseTranslations = {
|
|
|
85
85
|
column_cannot_be_edited: "Esta columna no se puede editar directamente",
|
|
86
86
|
close: "Cerrar",
|
|
87
87
|
hide_list: "Ocultar lista",
|
|
88
|
+
show_list: "Mostrar lista",
|
|
88
89
|
unsaved_local_changes: "Cambios locales sin guardar",
|
|
89
90
|
unsaved_local_changes_description: "Este documento ha sido editado localmente y tiene cambios sin guardar. Estos cambios locales se perderán si no los aplicas.",
|
|
90
91
|
preview_changes: "Previsualizar cambios",
|
package/src/locales/fr.ts
CHANGED
|
@@ -77,6 +77,7 @@ export const fr: RebaseTranslations = {
|
|
|
77
77
|
column_cannot_be_edited: "Cette colonne ne peut pas être modifiée directement",
|
|
78
78
|
close: "Fermer",
|
|
79
79
|
hide_list: "Masquer la liste",
|
|
80
|
+
show_list: "Afficher la liste",
|
|
80
81
|
unsaved_local_changes: "Modifications locales non enregistrées",
|
|
81
82
|
unsaved_local_changes_description: "Ce document a été modifié localement et contient des modifications non enregistrées. Ces modifications locales seront perdues si vous ne les appliquez pas.",
|
|
82
83
|
preview_changes: "Aperçu des modifications",
|