@aglyn/tenant-feature-instance 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +3 -0
- package/jest.config.d.ts +36 -0
- package/package.json +45 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/duplicate-resource-dialog.d.ts +77 -0
- package/src/lib/components/duplicate-resource-dialog.js +217 -0
- package/src/lib/components/duplicate-resource-dialog.js.map +1 -0
- package/src/lib/constants/firebase-config.d.ts +68 -0
- package/src/lib/constants/firebase-config.js +155 -0
- package/src/lib/constants/firebase-config.js.map +1 -0
- package/src/lib/constants/firebase-emulator-hosts.d.ts +60 -0
- package/src/lib/constants/firebase-emulator-hosts.js +81 -0
- package/src/lib/constants/firebase-emulator-hosts.js.map +1 -0
- package/src/lib/constants/mock-data.d.ts +24 -0
- package/src/lib/constants/mock-data.js +49 -0
- package/src/lib/constants/mock-data.js.map +1 -0
- package/src/lib/constants/site-paths.d.ts +47 -0
- package/src/lib/constants/site-paths.js +66 -0
- package/src/lib/constants/site-paths.js.map +1 -0
- package/src/lib/hooks/firebase/auth-persistence.d.ts +74 -0
- package/src/lib/hooks/firebase/auth-persistence.js +117 -0
- package/src/lib/hooks/firebase/auth-persistence.js.map +1 -0
- package/src/lib/hooks/firebase/firebase-services.d.ts +172 -0
- package/src/lib/hooks/firebase/firebase-services.js +522 -0
- package/src/lib/hooks/firebase/firebase-services.js.map +1 -0
- package/src/lib/hooks/firebase/firestore-cache.d.ts +125 -0
- package/src/lib/hooks/firebase/firestore-cache.js +152 -0
- package/src/lib/hooks/firebase/firestore-cache.js.map +1 -0
- package/src/lib/hooks/firebase/firestore-shared-client-state.d.ts +169 -0
- package/src/lib/hooks/firebase/firestore-shared-client-state.js +316 -0
- package/src/lib/hooks/firebase/firestore-shared-client-state.js.map +1 -0
- package/src/lib/hooks/firestore-denial-reporter.d.ts +192 -0
- package/src/lib/hooks/firestore-denial-reporter.js +293 -0
- package/src/lib/hooks/firestore-denial-reporter.js.map +1 -0
- package/src/lib/hooks/helpers/besigner-nodes-converter.d.ts +62 -0
- package/src/lib/hooks/helpers/besigner-nodes-converter.js +91 -0
- package/src/lib/hooks/helpers/besigner-nodes-converter.js.map +1 -0
- package/src/lib/hooks/helpers/guarded-nodes-save.d.ts +80 -0
- package/src/lib/hooks/helpers/guarded-nodes-save.js +89 -0
- package/src/lib/hooks/helpers/guarded-nodes-save.js.map +1 -0
- package/src/lib/hooks/helpers/guarded-seed-write.d.ts +125 -0
- package/src/lib/hooks/helpers/guarded-seed-write.js +193 -0
- package/src/lib/hooks/helpers/guarded-seed-write.js.map +1 -0
- package/src/lib/hooks/helpers/listen-options.d.ts +91 -0
- package/src/lib/hooks/helpers/listen-options.js +92 -0
- package/src/lib/hooks/helpers/listen-options.js.map +1 -0
- package/src/lib/hooks/helpers/use-doc.d.ts +38 -0
- package/src/lib/hooks/helpers/use-doc.js +213 -0
- package/src/lib/hooks/helpers/use-doc.js.map +1 -0
- package/src/lib/hooks/helpers/use-modify-doc-callback.d.ts +28 -0
- package/src/lib/hooks/helpers/use-modify-doc-callback.js +110 -0
- package/src/lib/hooks/helpers/use-modify-doc-callback.js.map +1 -0
- package/src/lib/hooks/host-collection-queries.d.ts +125 -0
- package/src/lib/hooks/host-collection-queries.js +126 -0
- package/src/lib/hooks/host-collection-queries.js.map +1 -0
- package/src/lib/hooks/list-filter-constraints.d.ts +64 -0
- package/src/lib/hooks/list-filter-constraints.js +288 -0
- package/src/lib/hooks/list-filter-constraints.js.map +1 -0
- package/src/lib/hooks/sorted-collection-window.d.ts +207 -0
- package/src/lib/hooks/sorted-collection-window.js +150 -0
- package/src/lib/hooks/sorted-collection-window.js.map +1 -0
- package/src/lib/hooks/use-component-version.d.ts +43 -0
- package/src/lib/hooks/use-component-version.js +73 -0
- package/src/lib/hooks/use-component-version.js.map +1 -0
- package/src/lib/hooks/use-component.d.ts +39 -0
- package/src/lib/hooks/use-component.js +55 -0
- package/src/lib/hooks/use-component.js.map +1 -0
- package/src/lib/hooks/use-console-host-route.d.ts +24 -0
- package/src/lib/hooks/use-console-host-route.js +77 -0
- package/src/lib/hooks/use-console-host-route.js.map +1 -0
- package/src/lib/hooks/use-duplicate-resource-api.d.ts +43 -0
- package/src/lib/hooks/use-duplicate-resource-api.js +61 -0
- package/src/lib/hooks/use-duplicate-resource-api.js.map +1 -0
- package/src/lib/hooks/use-firestore-collection.d.ts +62 -0
- package/src/lib/hooks/use-firestore-collection.js +307 -0
- package/src/lib/hooks/use-firestore-collection.js.map +1 -0
- package/src/lib/hooks/use-firestore-doc.d.ts +42 -0
- package/src/lib/hooks/use-firestore-doc.js +192 -0
- package/src/lib/hooks/use-firestore-doc.js.map +1 -0
- package/src/lib/hooks/use-form-version.d.ts +44 -0
- package/src/lib/hooks/use-form-version.js +73 -0
- package/src/lib/hooks/use-form-version.js.map +1 -0
- package/src/lib/hooks/use-host-activity-logger.d.ts +35 -0
- package/src/lib/hooks/use-host-activity-logger.js +96 -0
- package/src/lib/hooks/use-host-activity-logger.js.map +1 -0
- package/src/lib/hooks/use-host-campaigns.d.ts +53 -0
- package/src/lib/hooks/use-host-campaigns.js +93 -0
- package/src/lib/hooks/use-host-campaigns.js.map +1 -0
- package/src/lib/hooks/use-host-org-id.d.ts +96 -0
- package/src/lib/hooks/use-host-org-id.js +154 -0
- package/src/lib/hooks/use-host-org-id.js.map +1 -0
- package/src/lib/hooks/use-host-resource-api.d.ts +43 -0
- package/src/lib/hooks/use-host-resource-api.js +60 -0
- package/src/lib/hooks/use-host-resource-api.js.map +1 -0
- package/src/lib/hooks/use-host-template.d.ts +44 -0
- package/src/lib/hooks/use-host-template.js +82 -0
- package/src/lib/hooks/use-host-template.js.map +1 -0
- package/src/lib/hooks/use-host-version-api.d.ts +59 -0
- package/src/lib/hooks/use-host-version-api.js +69 -0
- package/src/lib/hooks/use-host-version-api.js.map +1 -0
- package/src/lib/hooks/use-host.d.ts +101 -0
- package/src/lib/hooks/use-host.js +45 -0
- package/src/lib/hooks/use-host.js.map +1 -0
- package/src/lib/hooks/use-layout-version.d.ts +33 -0
- package/src/lib/hooks/use-layout-version.js +64 -0
- package/src/lib/hooks/use-layout-version.js.map +1 -0
- package/src/lib/hooks/use-layout.d.ts +41 -0
- package/src/lib/hooks/use-layout.js +49 -0
- package/src/lib/hooks/use-layout.js.map +1 -0
- package/src/lib/hooks/use-live-artifact-count.d.ts +50 -0
- package/src/lib/hooks/use-live-artifact-count.js +102 -0
- package/src/lib/hooks/use-live-artifact-count.js.map +1 -0
- package/src/lib/hooks/use-org-member-options.d.ts +57 -0
- package/src/lib/hooks/use-org-member-options.js +112 -0
- package/src/lib/hooks/use-org-member-options.js.map +1 -0
- package/src/lib/hooks/use-org-plan.d.ts +46 -0
- package/src/lib/hooks/use-org-plan.js +86 -0
- package/src/lib/hooks/use-org-plan.js.map +1 -0
- package/src/lib/hooks/use-paged-collection.d.ts +95 -0
- package/src/lib/hooks/use-paged-collection.js +130 -0
- package/src/lib/hooks/use-paged-collection.js.map +1 -0
- package/src/lib/hooks/use-plugin-config.d.ts +38 -0
- package/src/lib/hooks/use-plugin-config.js +120 -0
- package/src/lib/hooks/use-plugin-config.js.map +1 -0
- package/src/lib/hooks/use-scope-tokens.d.ts +28 -0
- package/src/lib/hooks/use-scope-tokens.js +100 -0
- package/src/lib/hooks/use-scope-tokens.js.map +1 -0
- package/src/lib/hooks/use-screen-version.d.ts +33 -0
- package/src/lib/hooks/use-screen-version.js +65 -0
- package/src/lib/hooks/use-screen-version.js.map +1 -0
- package/src/lib/hooks/use-screen.d.ts +68 -0
- package/src/lib/hooks/use-screen.js +50 -0
- package/src/lib/hooks/use-screen.js.map +1 -0
- package/src/lib/hooks/use-sending-identity-api.d.ts +197 -0
- package/src/lib/hooks/use-sending-identity-api.js +95 -0
- package/src/lib/hooks/use-sending-identity-api.js.map +1 -0
- package/src/lib/hooks/use-sorted-paged-collection.d.ts +48 -0
- package/src/lib/hooks/use-sorted-paged-collection.js +213 -0
- package/src/lib/hooks/use-sorted-paged-collection.js.map +1 -0
- package/src/lib/hooks/use-switcher-collection.d.ts +105 -0
- package/src/lib/hooks/use-switcher-collection.js +275 -0
- package/src/lib/hooks/use-switcher-collection.js.map +1 -0
- package/src/lib/hooks/use-user-name.d.ts +51 -0
- package/src/lib/hooks/use-user-name.js +90 -0
- package/src/lib/hooks/use-user-name.js.map +1 -0
- package/src/lib/hooks/use-user-photo.d.ts +50 -0
- package/src/lib/hooks/use-user-photo.js +87 -0
- package/src/lib/hooks/use-user-photo.js.map +1 -0
- package/src/lib/tenant.d.ts +61 -0
- package/src/lib/tenant.js +62 -0
- package/src/lib/tenant.js.map +1 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2022 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ // A TYPE-only namespace: every `Aglyn.` reference below is a type, and held
|
|
17
|
+
// as a VALUE the namespace pins every module the barrel reaches into the
|
|
18
|
+
// console shell that renders these hooks. `import type` erases instead.
|
|
19
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
20
|
+
import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
|
|
21
|
+
import { Timestamp } from "@aglyn/shared-util-timestamp";
|
|
22
|
+
import { doc } from "firebase/firestore";
|
|
23
|
+
import { useFirestore } from "./firebase/firebase-services.js";
|
|
24
|
+
import useDoc from "./helpers/use-doc.js";
|
|
25
|
+
export const useScreenRef = ({ hostId, screenId })=>{
|
|
26
|
+
const firestore = useFirestore();
|
|
27
|
+
const ref = doc(firestore, 'hosts', hostId, 'screens', screenId);
|
|
28
|
+
return ref.withConverter({
|
|
29
|
+
toFirestore (data) {
|
|
30
|
+
const { $id } = data, rest = _object_without_properties_loose(data, [
|
|
31
|
+
"$id"
|
|
32
|
+
]);
|
|
33
|
+
return _extends({}, rest, {
|
|
34
|
+
updatedAt: Timestamp.now()
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
fromFirestore (snapshot, options) {
|
|
38
|
+
if (!snapshot.exists()) return undefined;
|
|
39
|
+
const data = snapshot.data(options);
|
|
40
|
+
// data.nodes = data.versionRef?.get()
|
|
41
|
+
return data;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
export const useScreen = (data, options)=>{
|
|
46
|
+
return useDoc(useScreenRef(data), options);
|
|
47
|
+
};
|
|
48
|
+
export default useScreen;
|
|
49
|
+
|
|
50
|
+
//# sourceMappingURL=use-screen.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-screen.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2022 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// A TYPE-only namespace: every `Aglyn.` reference below is a type, and held\n// as a VALUE the namespace pins every module the barrel reaches into the\n// console shell that renders these hooks. `import type` erases instead.\nimport type * as Aglyn from '@aglyn/aglyn'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport {\n doc,\n type DocumentSnapshot,\n type SnapshotOptions,\n} from 'firebase/firestore'\nimport { useFirestore, type FirestoreDocOptions } from './firebase/firebase-services'\nimport useDoc from './helpers/use-doc'\n\nexport const useScreenRef = ({ hostId, screenId }: { hostId: Aglyn.HostUid; screenId: Aglyn.ScreenUid }) => {\n const firestore = useFirestore()\n const ref = doc(firestore, 'hosts', hostId, 'screens', screenId)\n return ref.withConverter({\n toFirestore(data: Aglyn.AglynScreen) {\n const { $id, ...rest } = data\n return { ...rest, updatedAt: Timestamp.now() }\n },\n fromFirestore(\n snapshot: DocumentSnapshot<Aglyn.AglynScreen>,\n options: SnapshotOptions,\n ) {\n if (!snapshot.exists()) return undefined\n const data = snapshot.data(options)\n // data.nodes = data.versionRef?.get()\n return data as Aglyn.AglynScreen\n },\n })\n}\n\nexport const useScreen = (\n data: {\n hostId: Aglyn.HostUid\n screenId: Aglyn.ScreenUid\n },\n options?: FirestoreDocOptions<Aglyn.AglynScreen>,\n) => {\n return useDoc(useScreenRef(data), options)\n}\n\nexport default useScreen\n"],"names":["Timestamp","doc","useFirestore","useDoc","useScreenRef","hostId","screenId","firestore","ref","withConverter","toFirestore","data","$id","rest","updatedAt","now","fromFirestore","snapshot","options","exists","undefined","useScreen"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,4EAA4E;AAC5E,yEAAyE;AACzE,wEAAwE;;;AAExE,SAASA,SAAS,QAAQ,+BAA8B;AACxD,SACEC,GAAG,QAGE,qBAAoB;AAC3B,SAASC,YAAY,QAAkC,kCAA8B;AACrF,OAAOC,YAAY,uBAAmB;AAEtC,OAAO,MAAMC,eAAe,CAAC,EAAEC,MAAM,EAAEC,QAAQ,EAAwD;IACrG,MAAMC,YAAYL;IAClB,MAAMM,MAAMP,IAAIM,WAAW,SAASF,QAAQ,WAAWC;IACvD,OAAOE,IAAIC,aAAa,CAAC;QACvBC,aAAYC,IAAuB;YACjC,MAAM,EAAEC,GAAG,EAAW,GAAGD,MAATE,wCAASF;;;YACzB,OAAO,aAAKE;gBAAMC,WAAWd,UAAUe,GAAG;;QAC5C;QACAC,eACEC,QAA6C,EAC7CC,OAAwB;YAExB,IAAI,CAACD,SAASE,MAAM,IAAI,OAAOC;YAC/B,MAAMT,OAAOM,SAASN,IAAI,CAACO;YAC3B,sCAAsC;YACtC,OAAOP;QACT;IACF;AACF,EAAC;AAED,OAAO,MAAMU,YAAY,CACvBV,MAIAO;IAEA,OAAOf,OAAOC,aAAaO,OAAOO;AACpC,EAAC;AAED,eAAeG,UAAS"}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client of the console's sending-identity routes (`/api/email/*`).
|
|
3
|
+
*
|
|
4
|
+
* Which address a site's mail leaves from, and the domains and senders behind
|
|
5
|
+
* it, are the platform's mail rail: the console serves the routes, and every
|
|
6
|
+
* message goes out under that identity whichever plugin wrote it. So the hook
|
|
7
|
+
* that calls them is here, beside the other client hooks a plugin may import,
|
|
8
|
+
* and a one-to-one composer reads the identity without importing the plugin
|
|
9
|
+
* whose settings page edits it.
|
|
10
|
+
*/
|
|
11
|
+
/** What one call answered with. `payload` is `{}` on an unparseable body. */
|
|
12
|
+
export interface SendingApiResult {
|
|
13
|
+
response: Response;
|
|
14
|
+
payload: Record<string, any>;
|
|
15
|
+
}
|
|
16
|
+
/** One DNS record, exactly as the server issued it. */
|
|
17
|
+
export interface SendingDnsRecordView {
|
|
18
|
+
type: 'TXT' | 'MX';
|
|
19
|
+
name: string;
|
|
20
|
+
value: string;
|
|
21
|
+
priority?: number;
|
|
22
|
+
purpose: 'spf' | 'dkim' | 'return-path' | 'dmarc';
|
|
23
|
+
required: boolean;
|
|
24
|
+
note: string;
|
|
25
|
+
}
|
|
26
|
+
/** One org sending domain, as the domains route reports it. */
|
|
27
|
+
export interface SendingDomainView {
|
|
28
|
+
domain: string;
|
|
29
|
+
status: 'requested' | 'records-issued' | 'verified' | 'failed';
|
|
30
|
+
records?: SendingDnsRecordView[];
|
|
31
|
+
lines?: string[];
|
|
32
|
+
lastMissing?: string[] | null;
|
|
33
|
+
lastIssueError?: string | null;
|
|
34
|
+
verifiedAtMs?: number | null;
|
|
35
|
+
lastCheckedAtMs?: number | null;
|
|
36
|
+
dmarc?: {
|
|
37
|
+
policy: 'reject' | 'quarantine' | 'none' | 'absent';
|
|
38
|
+
record: string | null;
|
|
39
|
+
consequence: string;
|
|
40
|
+
} | null;
|
|
41
|
+
dmarcSuggestion?: SendingDnsRecordView | null;
|
|
42
|
+
pendingProvider?: boolean;
|
|
43
|
+
providerDetail?: string | null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* One sender this site holds — `hosts/{hostId}/senders/{senderId}`.
|
|
47
|
+
*
|
|
48
|
+
* `from` is the whole address, assembled by the server for the reason
|
|
49
|
+
* `IdentityOption.from` is: a surface that built `${localPart}@${domain}`
|
|
50
|
+
* itself would be a second derivation of the address, and the two would
|
|
51
|
+
* disagree the first time either moved. It is `null` for a row whose mailbox
|
|
52
|
+
* is not in effect — a site on the pooled Aglyn address has one fixed mailbox,
|
|
53
|
+
* shared with the other sites on it.
|
|
54
|
+
*/
|
|
55
|
+
export interface HostSenderView {
|
|
56
|
+
id: string;
|
|
57
|
+
localPart: string;
|
|
58
|
+
fromName: string | null;
|
|
59
|
+
replyTo: string | null;
|
|
60
|
+
/** Whether an email that names no sender goes out as this one. */
|
|
61
|
+
isDefault: boolean;
|
|
62
|
+
from: string | null;
|
|
63
|
+
}
|
|
64
|
+
/** What this site sends as, and what it is allowed to send as. */
|
|
65
|
+
export interface SendingIdentityView {
|
|
66
|
+
orgId: string | null;
|
|
67
|
+
selected: string;
|
|
68
|
+
/**
|
|
69
|
+
* The site's own provisioned domain inside the platform mail apex, or `''`
|
|
70
|
+
* when it has none and its transactional mail rides the shared pool.
|
|
71
|
+
*/
|
|
72
|
+
platformDomain: string;
|
|
73
|
+
/**
|
|
74
|
+
* The plan that carries sending as a domain the CUSTOMER owns, and the plan
|
|
75
|
+
* that carries a domain the platform provisions for the site — as names
|
|
76
|
+
* ready to print, `null` when no plan carries that gate.
|
|
77
|
+
*
|
|
78
|
+
* Both come from the server because both are derived from the entitlement
|
|
79
|
+
* tables there. A tier name written into this component would be pricing
|
|
80
|
+
* copy that keeps rendering after the gate beneath it moves.
|
|
81
|
+
*/
|
|
82
|
+
customDomainPlan: string | null;
|
|
83
|
+
dedicatedDomainPlan: string | null;
|
|
84
|
+
/** The mailbox this site sends as — the part before the `@`. */
|
|
85
|
+
localPart: string;
|
|
86
|
+
/**
|
|
87
|
+
* Whether that mailbox is the one actually in use.
|
|
88
|
+
*
|
|
89
|
+
* False for a site on the pooled Aglyn address, whose mailbox is fixed. The
|
|
90
|
+
* stored value is real and is kept; it takes effect when the site has a
|
|
91
|
+
* domain of its own, and until then the card says so rather than showing a
|
|
92
|
+
* name that no recipient will see.
|
|
93
|
+
*/
|
|
94
|
+
localPartInUse: boolean;
|
|
95
|
+
/** The site's default sender name, or null. Composers start from it. */
|
|
96
|
+
fromName: string | null;
|
|
97
|
+
/** The site's default reply address, or null. */
|
|
98
|
+
replyTo: string | null;
|
|
99
|
+
/**
|
|
100
|
+
* Every sender this site may send as, the default among them.
|
|
101
|
+
*
|
|
102
|
+
* Never empty: a site with no `senders` subcollection has exactly one
|
|
103
|
+
* sender, and the server synthesizes it from the three fields the host
|
|
104
|
+
* document has always carried rather than reporting an absence.
|
|
105
|
+
*/
|
|
106
|
+
senders: HostSenderView[];
|
|
107
|
+
identity: string;
|
|
108
|
+
/**
|
|
109
|
+
* `'custom'` for a domain this site has verified, `'shared'` for the pooled
|
|
110
|
+
* Aglyn address a site uses until it has one, `null` when the send is
|
|
111
|
+
* refused. `'platform'` is in the union because the resolver can return it,
|
|
112
|
+
* and never reaches this view: it is Aglyn's own `aglyn.com` identity, which
|
|
113
|
+
* a host-scoped resolution cannot produce.
|
|
114
|
+
*/
|
|
115
|
+
identitySource: 'custom' | 'shared' | 'platform' | null;
|
|
116
|
+
refusal: {
|
|
117
|
+
code: string;
|
|
118
|
+
domain: string | null;
|
|
119
|
+
message: string;
|
|
120
|
+
missing: string[];
|
|
121
|
+
} | null;
|
|
122
|
+
options: {
|
|
123
|
+
value: string;
|
|
124
|
+
from: string | null;
|
|
125
|
+
selectable: boolean;
|
|
126
|
+
status: string;
|
|
127
|
+
}[];
|
|
128
|
+
domains: {
|
|
129
|
+
domain: string;
|
|
130
|
+
status: SendingDomainView['status'];
|
|
131
|
+
verifiedAtMs: number | null;
|
|
132
|
+
lastCheckedAtMs: number | null;
|
|
133
|
+
/**
|
|
134
|
+
* Why the last attempt to issue a key produced none, or `null`.
|
|
135
|
+
*
|
|
136
|
+
* Carried in the LIST and not only on the domain's own page, because one
|
|
137
|
+
* of its values is not a fault: a claim held at the mail provider's domain
|
|
138
|
+
* allowance is `requested` with a reason, and a list that could not see
|
|
139
|
+
* the reason would label it "Waiting on a signing key" — a state whose
|
|
140
|
+
* whole instruction is to press a button that will not move it.
|
|
141
|
+
*/
|
|
142
|
+
lastIssueError?: string | null;
|
|
143
|
+
}[];
|
|
144
|
+
canManage: boolean;
|
|
145
|
+
entitled: boolean;
|
|
146
|
+
/**
|
|
147
|
+
* The OFFER of a platform sending domain — never the fact of having one,
|
|
148
|
+
* which `platformDomain` above answers.
|
|
149
|
+
*
|
|
150
|
+
* A dedicated subdomain is no longer issued on upgrade: it costs a provider
|
|
151
|
+
* domain slot and three records in Aglyn's own zone, so it is requested by
|
|
152
|
+
* somebody who wants it rather than handed to every paying site.
|
|
153
|
+
*
|
|
154
|
+
* Optional because a console deployment can be older than the field. A
|
|
155
|
+
* surface reading it must treat its absence as "no offer to show", never as
|
|
156
|
+
* "the offer was refused".
|
|
157
|
+
*/
|
|
158
|
+
dedicated?: {
|
|
159
|
+
/** The plan carries one and this site has none. */
|
|
160
|
+
available: boolean;
|
|
161
|
+
/** The name a request would most likely take. Not a reservation. */
|
|
162
|
+
proposed: string | null;
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* ONE AUTHORIZED CALL TO THE SENDING-IDENTITY AND SENDING-DOMAIN ROUTES.
|
|
167
|
+
*
|
|
168
|
+
* The same shape as `useCampaignSendApi`, including why the user is held in a
|
|
169
|
+
* ref: this callback is a dependency of effects that load on open, and the
|
|
170
|
+
* user object is a new object on most renders, so depending on it directly
|
|
171
|
+
* would re-issue the request on every render of the surface. The token is
|
|
172
|
+
* fetched at call time either way.
|
|
173
|
+
*
|
|
174
|
+
* The two paths are fixed here rather than taken as an argument, for the
|
|
175
|
+
* reason the campaign hook gives: a hook that accepted a URL from a component
|
|
176
|
+
* would be a way to post the console's credentials somewhere the console does
|
|
177
|
+
* not own.
|
|
178
|
+
*
|
|
179
|
+
* The token is obtained under a deadline, for the reason `useCampaignApi`
|
|
180
|
+
* gives: it is awaited in front of the request, so an unbounded refresh is a
|
|
181
|
+
* call that never reaches `fetch` and never reports anything.
|
|
182
|
+
*
|
|
183
|
+
* ## An unobtainable token ANSWERS here rather than throwing
|
|
184
|
+
*
|
|
185
|
+
* Unlike the campaign hook, whose callers must tell a send that never left
|
|
186
|
+
* the browser apart from one the route refused. Everything on this side reads
|
|
187
|
+
* or edits a configuration surface, where both are the same news to the
|
|
188
|
+
* reader — and every caller already renders `payload.error` on a refusal, so
|
|
189
|
+
* answering in that shape is what puts the real reason on screen and leaves
|
|
190
|
+
* the button that started the call usable.
|
|
191
|
+
*/
|
|
192
|
+
export declare function useSendingApi(): (options: {
|
|
193
|
+
path: "sending-identity" | "sending-domains";
|
|
194
|
+
method: "GET" | "POST" | "DELETE";
|
|
195
|
+
query?: Record<string, string>;
|
|
196
|
+
body?: Record<string, unknown>;
|
|
197
|
+
}) => Promise<SendingApiResult>;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
+
import { useUser } from "./firebase/firebase-services.js";
|
|
19
|
+
import { useCallback, useRef } from "react";
|
|
20
|
+
import { describeCallFailure, resolveIdToken } from "@aglyn/shared-util-http/authorized-token";
|
|
21
|
+
/**
|
|
22
|
+
* A refusal the caller reads the same way it reads the route's own.
|
|
23
|
+
*
|
|
24
|
+
* `401` because that is what the request would have been answered with had it
|
|
25
|
+
* gone out, and the reason travels in `payload.error`, which is the field
|
|
26
|
+
* every caller already renders.
|
|
27
|
+
*/ const refusedForCaller = (message)=>({
|
|
28
|
+
response: {
|
|
29
|
+
ok: false,
|
|
30
|
+
status: 401,
|
|
31
|
+
json: async ()=>({
|
|
32
|
+
error: message
|
|
33
|
+
})
|
|
34
|
+
},
|
|
35
|
+
payload: {
|
|
36
|
+
error: message
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* ONE AUTHORIZED CALL TO THE SENDING-IDENTITY AND SENDING-DOMAIN ROUTES.
|
|
41
|
+
*
|
|
42
|
+
* The same shape as `useCampaignSendApi`, including why the user is held in a
|
|
43
|
+
* ref: this callback is a dependency of effects that load on open, and the
|
|
44
|
+
* user object is a new object on most renders, so depending on it directly
|
|
45
|
+
* would re-issue the request on every render of the surface. The token is
|
|
46
|
+
* fetched at call time either way.
|
|
47
|
+
*
|
|
48
|
+
* The two paths are fixed here rather than taken as an argument, for the
|
|
49
|
+
* reason the campaign hook gives: a hook that accepted a URL from a component
|
|
50
|
+
* would be a way to post the console's credentials somewhere the console does
|
|
51
|
+
* not own.
|
|
52
|
+
*
|
|
53
|
+
* The token is obtained under a deadline, for the reason `useCampaignApi`
|
|
54
|
+
* gives: it is awaited in front of the request, so an unbounded refresh is a
|
|
55
|
+
* call that never reaches `fetch` and never reports anything.
|
|
56
|
+
*
|
|
57
|
+
* ## An unobtainable token ANSWERS here rather than throwing
|
|
58
|
+
*
|
|
59
|
+
* Unlike the campaign hook, whose callers must tell a send that never left
|
|
60
|
+
* the browser apart from one the route refused. Everything on this side reads
|
|
61
|
+
* or edits a configuration surface, where both are the same news to the
|
|
62
|
+
* reader — and every caller already renders `payload.error` on a refusal, so
|
|
63
|
+
* answering in that shape is what puts the real reason on screen and leaves
|
|
64
|
+
* the button that started the call usable.
|
|
65
|
+
*/ export function useSendingApi() {
|
|
66
|
+
const { data: user } = useUser();
|
|
67
|
+
const userRef = useRef(user);
|
|
68
|
+
userRef.current = user;
|
|
69
|
+
return useCallback(async (options)=>{
|
|
70
|
+
var _options_query;
|
|
71
|
+
let idToken;
|
|
72
|
+
try {
|
|
73
|
+
idToken = await resolveIdToken(userRef.current);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return refusedForCaller(describeCallFailure(error, 'Your sign-in could not be confirmed'));
|
|
76
|
+
}
|
|
77
|
+
const search = new URLSearchParams((_options_query = options.query) != null ? _options_query : {}).toString();
|
|
78
|
+
const response = await fetch(`/api/email/${options.path}${search ? `?${search}` : ''}`, _extends({
|
|
79
|
+
method: options.method,
|
|
80
|
+
headers: {
|
|
81
|
+
'Content-Type': 'application/json',
|
|
82
|
+
Authorization: `Bearer ${idToken}`
|
|
83
|
+
}
|
|
84
|
+
}, options.body ? {
|
|
85
|
+
body: JSON.stringify(options.body)
|
|
86
|
+
} : {}));
|
|
87
|
+
const json = await response.json().catch(()=>({}));
|
|
88
|
+
return {
|
|
89
|
+
response,
|
|
90
|
+
payload: json
|
|
91
|
+
};
|
|
92
|
+
}, []);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
//# sourceMappingURL=use-sending-identity-api.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-sending-identity-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { useUser } from './firebase/firebase-services'\nimport { useCallback, useRef } from 'react'\nimport {\n describeCallFailure,\n resolveIdToken,\n type TokenSource,\n} from '@aglyn/shared-util-http/authorized-token'\n\n/**\n * The client of the console's sending-identity routes (`/api/email/*`).\n *\n * Which address a site's mail leaves from, and the domains and senders behind\n * it, are the platform's mail rail: the console serves the routes, and every\n * message goes out under that identity whichever plugin wrote it. So the hook\n * that calls them is here, beside the other client hooks a plugin may import,\n * and a one-to-one composer reads the identity without importing the plugin\n * whose settings page edits it.\n */\n\n/** What one call answered with. `payload` is `{}` on an unparseable body. */\nexport interface SendingApiResult {\n response: Response\n payload: Record<string, any>\n}\n\n/**\n * A refusal the caller reads the same way it reads the route's own.\n *\n * `401` because that is what the request would have been answered with had it\n * gone out, and the reason travels in `payload.error`, which is the field\n * every caller already renders.\n */\nconst refusedForCaller = (message: string): SendingApiResult => ({\n response: {\n ok: false,\n status: 401,\n json: async () => ({ error: message }),\n } as unknown as Response,\n payload: { error: message },\n})\n\n/** One DNS record, exactly as the server issued it. */\nexport interface SendingDnsRecordView {\n type: 'TXT' | 'MX'\n name: string\n value: string\n priority?: number\n purpose: 'spf' | 'dkim' | 'return-path' | 'dmarc'\n required: boolean\n note: string\n}\n\n/** One org sending domain, as the domains route reports it. */\nexport interface SendingDomainView {\n domain: string\n status: 'requested' | 'records-issued' | 'verified' | 'failed'\n records?: SendingDnsRecordView[]\n lines?: string[]\n lastMissing?: string[] | null\n lastIssueError?: string | null\n verifiedAtMs?: number | null\n lastCheckedAtMs?: number | null\n dmarc?: {\n policy: 'reject' | 'quarantine' | 'none' | 'absent'\n record: string | null\n consequence: string\n } | null\n dmarcSuggestion?: SendingDnsRecordView | null\n pendingProvider?: boolean\n providerDetail?: string | null\n}\n\n/**\n * One sender this site holds — `hosts/{hostId}/senders/{senderId}`.\n *\n * `from` is the whole address, assembled by the server for the reason\n * `IdentityOption.from` is: a surface that built `${localPart}@${domain}`\n * itself would be a second derivation of the address, and the two would\n * disagree the first time either moved. It is `null` for a row whose mailbox\n * is not in effect — a site on the pooled Aglyn address has one fixed mailbox,\n * shared with the other sites on it.\n */\nexport interface HostSenderView {\n id: string\n localPart: string\n fromName: string | null\n replyTo: string | null\n /** Whether an email that names no sender goes out as this one. */\n isDefault: boolean\n from: string | null\n}\n\n/** What this site sends as, and what it is allowed to send as. */\nexport interface SendingIdentityView {\n orgId: string | null\n selected: string\n /**\n * The site's own provisioned domain inside the platform mail apex, or `''`\n * when it has none and its transactional mail rides the shared pool.\n */\n platformDomain: string\n /**\n * The plan that carries sending as a domain the CUSTOMER owns, and the plan\n * that carries a domain the platform provisions for the site — as names\n * ready to print, `null` when no plan carries that gate.\n *\n * Both come from the server because both are derived from the entitlement\n * tables there. A tier name written into this component would be pricing\n * copy that keeps rendering after the gate beneath it moves.\n */\n customDomainPlan: string | null\n dedicatedDomainPlan: string | null\n /** The mailbox this site sends as — the part before the `@`. */\n localPart: string\n /**\n * Whether that mailbox is the one actually in use.\n *\n * False for a site on the pooled Aglyn address, whose mailbox is fixed. The\n * stored value is real and is kept; it takes effect when the site has a\n * domain of its own, and until then the card says so rather than showing a\n * name that no recipient will see.\n */\n localPartInUse: boolean\n /** The site's default sender name, or null. Composers start from it. */\n fromName: string | null\n /** The site's default reply address, or null. */\n replyTo: string | null\n /**\n * Every sender this site may send as, the default among them.\n *\n * Never empty: a site with no `senders` subcollection has exactly one\n * sender, and the server synthesizes it from the three fields the host\n * document has always carried rather than reporting an absence.\n */\n senders: HostSenderView[]\n identity: string\n /**\n * `'custom'` for a domain this site has verified, `'shared'` for the pooled\n * Aglyn address a site uses until it has one, `null` when the send is\n * refused. `'platform'` is in the union because the resolver can return it,\n * and never reaches this view: it is Aglyn's own `aglyn.com` identity, which\n * a host-scoped resolution cannot produce.\n */\n identitySource: 'custom' | 'shared' | 'platform' | null\n refusal: {\n code: string\n domain: string | null\n message: string\n missing: string[]\n } | null\n options: {\n value: string\n from: string | null\n selectable: boolean\n status: string\n }[]\n domains: {\n domain: string\n status: SendingDomainView['status']\n verifiedAtMs: number | null\n lastCheckedAtMs: number | null\n /**\n * Why the last attempt to issue a key produced none, or `null`.\n *\n * Carried in the LIST and not only on the domain's own page, because one\n * of its values is not a fault: a claim held at the mail provider's domain\n * allowance is `requested` with a reason, and a list that could not see\n * the reason would label it \"Waiting on a signing key\" — a state whose\n * whole instruction is to press a button that will not move it.\n */\n lastIssueError?: string | null\n }[]\n canManage: boolean\n entitled: boolean\n /**\n * The OFFER of a platform sending domain — never the fact of having one,\n * which `platformDomain` above answers.\n *\n * A dedicated subdomain is no longer issued on upgrade: it costs a provider\n * domain slot and three records in Aglyn's own zone, so it is requested by\n * somebody who wants it rather than handed to every paying site.\n *\n * Optional because a console deployment can be older than the field. A\n * surface reading it must treat its absence as \"no offer to show\", never as\n * \"the offer was refused\".\n */\n dedicated?: {\n /** The plan carries one and this site has none. */\n available: boolean\n /** The name a request would most likely take. Not a reservation. */\n proposed: string | null\n }\n}\n\n/**\n * ONE AUTHORIZED CALL TO THE SENDING-IDENTITY AND SENDING-DOMAIN ROUTES.\n *\n * The same shape as `useCampaignSendApi`, including why the user is held in a\n * ref: this callback is a dependency of effects that load on open, and the\n * user object is a new object on most renders, so depending on it directly\n * would re-issue the request on every render of the surface. The token is\n * fetched at call time either way.\n *\n * The two paths are fixed here rather than taken as an argument, for the\n * reason the campaign hook gives: a hook that accepted a URL from a component\n * would be a way to post the console's credentials somewhere the console does\n * not own.\n *\n * The token is obtained under a deadline, for the reason `useCampaignApi`\n * gives: it is awaited in front of the request, so an unbounded refresh is a\n * call that never reaches `fetch` and never reports anything.\n *\n * ## An unobtainable token ANSWERS here rather than throwing\n *\n * Unlike the campaign hook, whose callers must tell a send that never left\n * the browser apart from one the route refused. Everything on this side reads\n * or edits a configuration surface, where both are the same news to the\n * reader — and every caller already renders `payload.error` on a refusal, so\n * answering in that shape is what puts the real reason on screen and leaves\n * the button that started the call usable.\n */\nexport function useSendingApi() {\n const { data: user } = useUser()\n const userRef = useRef(user)\n userRef.current = user\n\n return useCallback(\n async (options: {\n path: 'sending-identity' | 'sending-domains'\n method: 'GET' | 'POST' | 'DELETE'\n query?: Record<string, string>\n body?: Record<string, unknown>\n }): Promise<SendingApiResult> => {\n let idToken: string\n try {\n idToken = await resolveIdToken(\n userRef.current as TokenSource | null | undefined,\n )\n } catch (error) {\n return refusedForCaller(\n describeCallFailure(error, 'Your sign-in could not be confirmed'),\n )\n }\n const search = new URLSearchParams(options.query ?? {}).toString()\n const response = await fetch(\n `/api/email/${options.path}${search ? `?${search}` : ''}`,\n {\n method: options.method,\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${idToken}`,\n },\n ...(options.body ? { body: JSON.stringify(options.body) } : {}),\n },\n )\n const json = await response.json().catch(() => ({}))\n return { response, payload: json as Record<string, any> }\n },\n [],\n )\n}\n"],"names":["useUser","useCallback","useRef","describeCallFailure","resolveIdToken","refusedForCaller","message","response","ok","status","json","error","payload","useSendingApi","data","user","userRef","current","options","idToken","search","URLSearchParams","query","toString","fetch","path","method","headers","Authorization","body","JSON","stringify","catch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,OAAO,QAAQ,kCAA8B;AACtD,SAASC,WAAW,EAAEC,MAAM,QAAQ,QAAO;AAC3C,SACEC,mBAAmB,EACnBC,cAAc,QAET,2CAA0C;AAmBjD;;;;;;CAMC,GACD,MAAMC,mBAAmB,CAACC,UAAuC,CAAA;QAC/DC,UAAU;YACRC,IAAI;YACJC,QAAQ;YACRC,MAAM,UAAa,CAAA;oBAAEC,OAAOL;gBAAQ,CAAA;QACtC;QACAM,SAAS;YAAED,OAAOL;QAAQ;IAC5B,CAAA;AA2JA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,SAASO;IACd,MAAM,EAAEC,MAAMC,IAAI,EAAE,GAAGf;IACvB,MAAMgB,UAAUd,OAAOa;IACvBC,QAAQC,OAAO,GAAGF;IAElB,OAAOd,YACL,OAAOiB;YAgB8BA;QAVnC,IAAIC;QACJ,IAAI;YACFA,UAAU,MAAMf,eACdY,QAAQC,OAAO;QAEnB,EAAE,OAAON,OAAO;YACd,OAAON,iBACLF,oBAAoBQ,OAAO;QAE/B;QACA,MAAMS,SAAS,IAAIC,iBAAgBH,iBAAAA,QAAQI,KAAK,YAAbJ,iBAAiB,CAAC,GAAGK,QAAQ;QAChE,MAAMhB,WAAW,MAAMiB,MACrB,CAAC,WAAW,EAAEN,QAAQO,IAAI,GAAGL,SAAS,CAAC,CAAC,EAAEA,QAAQ,GAAG,IAAI,EACzD;YACEM,QAAQR,QAAQQ,MAAM;YACtBC,SAAS;gBACP,gBAAgB;gBAChBC,eAAe,CAAC,OAAO,EAAET,SAAS;YACpC;WACID,QAAQW,IAAI,GAAG;YAAEA,MAAMC,KAAKC,SAAS,CAACb,QAAQW,IAAI;QAAE,IAAI,CAAC;QAGjE,MAAMnB,OAAO,MAAMH,SAASG,IAAI,GAAGsB,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;QACjD,OAAO;YAAEzB;YAAUK,SAASF;QAA4B;IAC1D,GACA,EAAE;AAEN"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { DocumentData, Query } from 'firebase/firestore';
|
|
2
|
+
import { type DependencyList } from 'react';
|
|
3
|
+
import { type CollectionSort } from './sorted-collection-window';
|
|
4
|
+
import { type UseFirestoreCollectionOptions } from './use-firestore-collection';
|
|
5
|
+
import type { UsePagedCollectionResult } from './use-paged-collection';
|
|
6
|
+
export interface UseSortedPagedCollectionOptions extends UseFirestoreCollectionOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Required: the keyed and unkeyed segments are two listeners, and a
|
|
9
|
+
* document moving between them is recognized by its id.
|
|
10
|
+
*/
|
|
11
|
+
idField: string;
|
|
12
|
+
/** Rows per page. Defaults to the console-wide smallest option. */
|
|
13
|
+
pageSize?: number;
|
|
14
|
+
/**
|
|
15
|
+
* The fields `buildBase` pins by EQUALITY. A sort on one of them needs no
|
|
16
|
+
* unkeyed segment — see `sortFieldIsTotal`.
|
|
17
|
+
*/
|
|
18
|
+
equalityFields?: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A paged window over a live collection SORTED BY A FIELD, which still drops
|
|
22
|
+
* no document that lacks the field (AGL-2853).
|
|
23
|
+
*
|
|
24
|
+
* The same contract as `usePagedCollection` — the window is the query, one
|
|
25
|
+
* page plus a probe row, `hasMore` a fact — over the two-segment walk in
|
|
26
|
+
* `sorted-collection-window.ts`: the documents that carry the sort field in
|
|
27
|
+
* its order, then the ones that do not, in name order. See that module for why
|
|
28
|
+
* both segments are needed and what the second one costs.
|
|
29
|
+
*
|
|
30
|
+
* `buildBase` returns the collection with any EQUALITY predicates and nothing
|
|
31
|
+
* else; the ordering and the limits are this hook's. `deps` identify the
|
|
32
|
+
* subject as they do for `useFirestoreCollection`. A new subject, sort or
|
|
33
|
+
* filter starts again from page one.
|
|
34
|
+
*
|
|
35
|
+
* ```ts
|
|
36
|
+
* useSortedPagedCollection<Entry>(
|
|
37
|
+
* () => query(
|
|
38
|
+
* collection(firestore, 'hosts', hostId, 'collections', collectionId, 'entries'),
|
|
39
|
+
* where('status', '==', status),
|
|
40
|
+
* ),
|
|
41
|
+
* { field: 'publishedAt', direction: 'desc' },
|
|
42
|
+
* [firestore, hostId, collectionId, status],
|
|
43
|
+
* { idField: '$id', equalityFields: ['status'] },
|
|
44
|
+
* )
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export declare function useSortedPagedCollection<T = DocumentData>(buildBase: () => Query<DocumentData> | null | undefined, sort: CollectionSort, deps: DependencyList, options: UseSortedPagedCollectionOptions): UsePagedCollectionResult<T>;
|
|
48
|
+
export default useSortedPagedCollection;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ 'use client';
|
|
17
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
+
import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
|
|
19
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
20
|
+
import { TABLE_PAGE_SIZE_DEFAULT } from "@aglyn/shared-ui-jsx/const/table-pagination";
|
|
21
|
+
import { planKeyedSegment, planSortedWindow, sortedKeyedQuery, sortedScanLimit, sortedUnkeyedQuery, sortFieldIsTotal } from "./sorted-collection-window.js";
|
|
22
|
+
import { useFirestoreCollection } from "./use-firestore-collection.js";
|
|
23
|
+
/** `Object.is` over two dependency lists, the comparison React itself uses. */ const sameDeps = (a, b)=>a.length === b.length && a.every((value, index)=>Object.is(value, b[index]));
|
|
24
|
+
/**
|
|
25
|
+
* The request a read's CURRENT rows were delivered for.
|
|
26
|
+
*
|
|
27
|
+
* `useFirestoreCollection` re-subscribes in an effect, so the render in which
|
|
28
|
+
* its inputs change still returns the previous subscription's rows — rows for
|
|
29
|
+
* another limit, sort or subject. Planning on them would judge a window by a
|
|
30
|
+
* read that was never made for it: page two's window measured against page
|
|
31
|
+
* one's eleven rows looks like a walk that ran out.
|
|
32
|
+
*
|
|
33
|
+
* So rows are labeled with the request that was current when they ARRIVED,
|
|
34
|
+
* which is the render their array first appears in, and a render whose request
|
|
35
|
+
* has since moved on treats them as unread. Held as state and adjusted during
|
|
36
|
+
* render — the pattern React documents for state that follows a prop — so no
|
|
37
|
+
* render ever pairs a request with rows read for another.
|
|
38
|
+
*/ function useCurrentRows(rows, request) {
|
|
39
|
+
const [delivered, setDelivered] = useState(()=>({
|
|
40
|
+
rows,
|
|
41
|
+
request
|
|
42
|
+
}));
|
|
43
|
+
if (delivered.rows !== rows) {
|
|
44
|
+
setDelivered({
|
|
45
|
+
rows,
|
|
46
|
+
request
|
|
47
|
+
});
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return sameDeps(delivered.request, request);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A paged window over a live collection SORTED BY A FIELD, which still drops
|
|
54
|
+
* no document that lacks the field (AGL-2853).
|
|
55
|
+
*
|
|
56
|
+
* The same contract as `usePagedCollection` — the window is the query, one
|
|
57
|
+
* page plus a probe row, `hasMore` a fact — over the two-segment walk in
|
|
58
|
+
* `sorted-collection-window.ts`: the documents that carry the sort field in
|
|
59
|
+
* its order, then the ones that do not, in name order. See that module for why
|
|
60
|
+
* both segments are needed and what the second one costs.
|
|
61
|
+
*
|
|
62
|
+
* `buildBase` returns the collection with any EQUALITY predicates and nothing
|
|
63
|
+
* else; the ordering and the limits are this hook's. `deps` identify the
|
|
64
|
+
* subject as they do for `useFirestoreCollection`. A new subject, sort or
|
|
65
|
+
* filter starts again from page one.
|
|
66
|
+
*
|
|
67
|
+
* ```ts
|
|
68
|
+
* useSortedPagedCollection<Entry>(
|
|
69
|
+
* () => query(
|
|
70
|
+
* collection(firestore, 'hosts', hostId, 'collections', collectionId, 'entries'),
|
|
71
|
+
* where('status', '==', status),
|
|
72
|
+
* ),
|
|
73
|
+
* { field: 'publishedAt', direction: 'desc' },
|
|
74
|
+
* [firestore, hostId, collectionId, status],
|
|
75
|
+
* { idField: '$id', equalityFields: ['status'] },
|
|
76
|
+
* )
|
|
77
|
+
* ```
|
|
78
|
+
*/ export function useSortedPagedCollection(buildBase, sort, deps, options) {
|
|
79
|
+
var _keyedRead_error;
|
|
80
|
+
const { pageSize: initialPageSize = TABLE_PAGE_SIZE_DEFAULT, equalityFields = [] } = options, collectionOptions = _object_without_properties_loose(options, [
|
|
81
|
+
"pageSize",
|
|
82
|
+
"equalityFields"
|
|
83
|
+
]);
|
|
84
|
+
const idField = options.idField;
|
|
85
|
+
const [pageSize, setPageSizeState] = useState(initialPageSize);
|
|
86
|
+
const buildBaseRef = useRef(buildBase);
|
|
87
|
+
buildBaseRef.current = buildBase;
|
|
88
|
+
const equalityKey = equalityFields.join('\n');
|
|
89
|
+
const subject = [
|
|
90
|
+
...deps,
|
|
91
|
+
sort.field,
|
|
92
|
+
sort.direction,
|
|
93
|
+
equalityKey
|
|
94
|
+
];
|
|
95
|
+
/*
|
|
96
|
+
* The reader's position belongs to ONE subject. It resets in the render the
|
|
97
|
+
* subject changes in, not in an effect after it: an effect would let that
|
|
98
|
+
* render subscribe the new sort at the old page's limit, and a sort change
|
|
99
|
+
* on page four would bill forty-one documents to show ten.
|
|
100
|
+
*/ const [cursor, setCursor] = useState(()=>({
|
|
101
|
+
subject,
|
|
102
|
+
page: 0,
|
|
103
|
+
widenedTo: 0
|
|
104
|
+
}));
|
|
105
|
+
const sameSubject = sameDeps(cursor.subject, subject);
|
|
106
|
+
const position = sameSubject ? cursor : {
|
|
107
|
+
subject,
|
|
108
|
+
page: 0,
|
|
109
|
+
widenedTo: 0
|
|
110
|
+
};
|
|
111
|
+
if (!sameSubject) setCursor(position);
|
|
112
|
+
const { page } = position;
|
|
113
|
+
const keyedIsTotal = sortFieldIsTotal(sort, equalityFields);
|
|
114
|
+
const { keyedLimit } = planKeyedSegment({
|
|
115
|
+
page,
|
|
116
|
+
pageSize,
|
|
117
|
+
keyed: undefined,
|
|
118
|
+
keyedSettled: false,
|
|
119
|
+
keyedIsTotal
|
|
120
|
+
});
|
|
121
|
+
const keyedRequest = [
|
|
122
|
+
...subject,
|
|
123
|
+
keyedLimit
|
|
124
|
+
];
|
|
125
|
+
const keyedRead = useFirestoreCollection(()=>{
|
|
126
|
+
const base = buildBaseRef.current();
|
|
127
|
+
return base ? sortedKeyedQuery(base, sort, keyedLimit, equalityFields) : null;
|
|
128
|
+
}, keyedRequest, collectionOptions);
|
|
129
|
+
const keyedCurrent = useCurrentRows(keyedRead.data, keyedRequest);
|
|
130
|
+
const keyed = keyedCurrent && keyedRead.status !== 'loading' ? keyedRead.data : undefined;
|
|
131
|
+
const keyedSettled = keyedCurrent && keyedRead.status === 'success' && !keyedRead.fromCache;
|
|
132
|
+
const keyedPlan = planKeyedSegment({
|
|
133
|
+
page,
|
|
134
|
+
pageSize,
|
|
135
|
+
keyed,
|
|
136
|
+
keyedSettled,
|
|
137
|
+
keyedIsTotal
|
|
138
|
+
});
|
|
139
|
+
const scanLimit = sortedScanLimit(keyedPlan, position.widenedTo);
|
|
140
|
+
const scanRequest = [
|
|
141
|
+
...subject,
|
|
142
|
+
scanLimit
|
|
143
|
+
];
|
|
144
|
+
const scanRead = useFirestoreCollection(()=>{
|
|
145
|
+
if (scanLimit <= 0) return null;
|
|
146
|
+
const base = buildBaseRef.current();
|
|
147
|
+
return base ? sortedUnkeyedQuery(base, scanLimit) : null;
|
|
148
|
+
}, scanRequest, collectionOptions);
|
|
149
|
+
const scanCurrent = useCurrentRows(scanRead.data, scanRequest);
|
|
150
|
+
const scanOpen = scanLimit > 0;
|
|
151
|
+
const sorted = planSortedWindow({
|
|
152
|
+
page,
|
|
153
|
+
pageSize,
|
|
154
|
+
sort,
|
|
155
|
+
keyedIsTotal,
|
|
156
|
+
keyed,
|
|
157
|
+
keyedSettled,
|
|
158
|
+
scan: scanOpen && scanCurrent && scanRead.status !== 'loading' ? scanRead.data : undefined,
|
|
159
|
+
scanSettled: scanOpen && scanCurrent && scanRead.status === 'success' && !scanRead.fromCache,
|
|
160
|
+
scanLimit,
|
|
161
|
+
idOf: (row)=>String(row[idField])
|
|
162
|
+
});
|
|
163
|
+
/*
|
|
164
|
+
* Widening is remembered for the subject, so paging back to where the scan
|
|
165
|
+
* is not needed and forward again re-opens it at the width it reached
|
|
166
|
+
* instead of doubling its way back up.
|
|
167
|
+
*/ const widenTo = sorted.scanLimit;
|
|
168
|
+
useEffect(()=>{
|
|
169
|
+
if (widenTo <= position.widenedTo) return;
|
|
170
|
+
setCursor((previous)=>sameDeps(previous.subject, position.subject) && widenTo > previous.widenedTo ? _extends({}, previous, {
|
|
171
|
+
widenedTo: widenTo
|
|
172
|
+
}) : previous);
|
|
173
|
+
}, [
|
|
174
|
+
widenTo,
|
|
175
|
+
position.widenedTo,
|
|
176
|
+
position.subject
|
|
177
|
+
]);
|
|
178
|
+
const setPage = useCallback((next)=>{
|
|
179
|
+
setCursor((previous)=>_extends({}, previous, {
|
|
180
|
+
page: next
|
|
181
|
+
}));
|
|
182
|
+
}, []);
|
|
183
|
+
const setPageSize = useCallback((next)=>{
|
|
184
|
+
setPageSizeState(next);
|
|
185
|
+
// Page four of a ten-row list does not exist once the reader asks for
|
|
186
|
+
// fifty at a time, and an out-of-range page renders as an empty list.
|
|
187
|
+
setCursor((previous)=>_extends({}, previous, {
|
|
188
|
+
page: 0
|
|
189
|
+
}));
|
|
190
|
+
}, []);
|
|
191
|
+
/*
|
|
192
|
+
* A short window is a claim that the list ENDS here, so it is `loading`
|
|
193
|
+
* until the server has confirmed both segments. A window holding its probe
|
|
194
|
+
* row claims only that more exists, which a cached read can say.
|
|
195
|
+
*/ const errored = keyedRead.status === 'error' || scanOpen && scanRead.status === 'error';
|
|
196
|
+
const status = errored ? 'error' : keyed === undefined ? 'loading' : sorted.hasMore || sorted.settled ? 'success' : 'loading';
|
|
197
|
+
return {
|
|
198
|
+
data: sorted.ordered,
|
|
199
|
+
rows: sorted.rows,
|
|
200
|
+
hasMore: sorted.hasMore,
|
|
201
|
+
page,
|
|
202
|
+
setPage,
|
|
203
|
+
pageSize,
|
|
204
|
+
setPageSize,
|
|
205
|
+
status,
|
|
206
|
+
error: (_keyedRead_error = keyedRead.error) != null ? _keyedRead_error : scanOpen ? scanRead.error : undefined,
|
|
207
|
+
fromCache: keyedRead.fromCache || scanOpen && scanRead.fromCache,
|
|
208
|
+
serverDenied: keyedRead.serverDenied || scanOpen && scanRead.serverDenied
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
export default useSortedPagedCollection;
|
|
212
|
+
|
|
213
|
+
//# sourceMappingURL=use-sorted-paged-collection.js.map
|